Precompiled.Trust (precompiled v0.2.1)

Copy Markdown View Source

The trust chain's top: a root document shipped with this library names the keys allowed to sign batches; a batch names every manifest by hash. Both travel in an envelope signed over raw bytes. Sequence numbers only go up.

Every entry point here parses attacker-reachable input (a shipped file, a cached file, or whatever a server hands back), so every helper is written to return an error tuple instead of raising, and the public functions that touch the network or disk also carry a function-level rescue as a second line of defense.

A batch read from a bundle (bundle: true) is bounded by its own expiry instead of the 24 h staleness rule. A bundle's offline.json (verify_offline/3) has no expiry at all, and on that path the root's expiry is not checked either (offline: true).

Summary

Functions

The current batch, asking the source only whether it changed: the last verified batch and its ETag are kept in the state dir and the ETag is sent as If-None-Match. A 304 says only that the source still serves those bytes: they go through the same signature, freshness, clock and sequence checks as fresh ones, every run, so a batch older than a day is refused however often the source answers 304. New bytes replace the cache only once they verify. Offline, the cached batch is checked the same way and no request is made.

The root in use: the shipped root, advanced along the adopted chain in roots.json. The shipped root must verify under its own root role; each chain entry must also verify under the previous root's root role and have a higher version, and the first one that fails ends the chain. As in TUF, only the root in use (the last one) must be unexpired: an expired intermediate root does not end the chain, and a release keeps working past its shipped root's expiry once a newer root has been adopted.

Whether the served root should be fetched this run: never stamped, the stamp :root_check_seconds (default an hour) old or older, or a stamp in the future (a clock set back). A batch that fails to verify fetches the root regardless (see Precompiled.Check).

The keys and threshold for a role, or {%{}, nil} when root doesn't name that role with a usable shape. Total: never raises, whatever root is.

Where the batch sequence and adopted roots live for this source: under config.home, named by the source URL and the shipped root's first root key, so two sources (or two trust anchors) never share replay state. Always keyed by the SHIPPED root, so a rotation keeps the same dir. config[:state_key], when set, names it instead of the source URL: the sources that share one key (a device's network source and its bundles) share one sequence floor.

Adopt the source's served root when it chains from root (the root in use), appending it to roots.json and recording its version as the floor load_root/1 will not go back below. Anything else keeps root.

A bundle's offline document (offline.json): manifest maps signed by the root's offline role, with no expiry, for a device that stays offline (PRECOMPILED_API.md, "Offline bundles"). The caller reads it only from a file:// source the user named.

Types

batch()

@type batch() :: %{
  optional(:source_unreachable) => true,
  optional(:offline) => true,
  sequence: integer(),
  manifests: %{required(String.t()) => String.t()},
  manifests_v2: %{required(String.t()) => String.t()} | nil,
  content: [String.t()] | nil
}

conditional_fetch()

@type conditional_fetch() :: (String.t(), String.t() | nil ->
                          {:ok, binary(), String.t() | nil}
                          | :not_modified
                          | {:error, term()})

root()

@type root() :: map()

Functions

fetch_batch_cached(config, root, fetch)

@spec fetch_batch_cached(map(), root(), conditional_fetch()) ::
  {:ok, batch()} | {:error, String.t()}

The current batch, asking the source only whether it changed: the last verified batch and its ETag are kept in the state dir and the ETag is sent as If-None-Match. A 304 says only that the source still serves those bytes: they go through the same signature, freshness, clock and sequence checks as fresh ones, every run, so a batch older than a day is refused however often the source answers 304. New bytes replace the cache only once they verify. Offline, the cached batch is checked the same way and no request is made.

When the source cannot be reached ({:error, {:unreachable, why}} from fetch: a transport error, timeout, 5xx or the run deadline), the cached batch is used under the same rule as offline and one line says so. A response the source did give and that is refused (bad signature, an older sequence, malformed bytes, a 4xx) never falls back to the cache.

load_root(config, opts \\ [])

@spec load_root(map(), keyword()) :: {:ok, root()} | {:error, String.t()}

The root in use: the shipped root, advanced along the adopted chain in roots.json. The shipped root must verify under its own root role; each chain entry must also verify under the previous root's root role and have a higher version, and the first one that fails ends the chain. As in TUF, only the root in use (the last one) must be unexpired: an expired intermediate root does not end the chain, and a release keeps working past its shipped root's expiry once a newer root has been adopted.

With offline: true, for verifying a bundle's offline.json and nothing else, the root in use is returned even when it has expired: an offline document is valid indefinitely, so the clock must not end it. The chain and every signature are checked as always.

The version this ends on can never drop below one update_root/3 already recorded: a stored root_version higher than the folded root's version means roots.json lost the entries that led there (deleted, truncated, or rolled back), and that is reported rather than silently accepted.

root_check_due?(config)

@spec root_check_due?(map()) :: boolean()

Whether the served root should be fetched this run: never stamped, the stamp :root_check_seconds (default an hour) old or older, or a stamp in the future (a clock set back). A batch that fails to verify fetches the root regardless (see Precompiled.Check).

root_keys(root, role)

@spec root_keys(root(), String.t()) ::
  {%{required(String.t()) => binary()}, pos_integer() | nil}

The keys and threshold for a role, or {%{}, nil} when root doesn't name that role with a usable shape. Total: never raises, whatever root is.

state_dir(config, shipped_root)

@spec state_dir(map(), root()) :: Path.t()

Where the batch sequence and adopted roots live for this source: under config.home, named by the source URL and the shipped root's first root key, so two sources (or two trust anchors) never share replay state. Always keyed by the SHIPPED root, so a rotation keeps the same dir. config[:state_key], when set, names it instead of the source URL: the sources that share one key (a device's network source and its bundles) share one sequence floor.

update_root(config, root, fetch, opts \\ [])

@spec update_root(
  map(),
  root(),
  (String.t() -> {:ok, binary()} | {:error, term()}),
  keyword()
) ::
  {:ok, root()}

Adopt the source's served root when it chains from root (the root in use), appending it to roots.json and recording its version as the floor load_root/1 will not go back below. Anything else keeps root.

With offline: true, for a bundle's root.json read to verify its offline.json, a root that chains is adopted even when it has expired (see load_root/2). The online path then chains from it to a newer, unexpired root before it judges expiry (Precompiled.Check).

verify_envelope(envelope, keys, threshold)

@spec verify_envelope(
  map() | binary(),
  %{required(String.t()) => binary()},
  pos_integer()
) ::
  {:ok, binary()} | {:error, :bad_envelope | :below_threshold}

verify_offline(config, root, body)

@spec verify_offline(map(), root(), binary()) :: {:ok, batch()} | {:error, String.t()}

A bundle's offline document (offline.json): manifest maps signed by the root's offline role, with no expiry, for a device that stays offline (PRECOMPILED_API.md, "Offline bundles"). The caller reads it only from a file:// source the user named.

Its keys come from the offline role of root alone, with a threshold above zero and each keyid counted once. A key that any other role of the root also names (the online root and batch key) never counts for it, so a batch signature can never pass as an offline one. No clock is read: not for the document, which has no expiry, and not for the root. The document is held to its own sequence floor, offline_state in the state dir, apart from the batch sequence, and the floor is raised to its sequence.

Returns the batch shape, with content: nil and offline: true.

Refused outright when config.source is not a file:// URL: a bundle's offline.json is honoured only from a source the user named as a bundle, never from a network server that could otherwise use it to dodge the 24 h batch staleness check.