Skip to content

Update core: embedded identity, durable trust promotion, check and observed state - #5

Closed
immakermatty wants to merge 14 commits into
mainfrom
claude/DEV-6594-update-core
Closed

immakermatty wants to merge 14 commits into
mainfrom
claude/DEV-6594-update-core

Conversation

@immakermatty

@immakermatty immakermatty commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

docs/update.md (PR #4) is the accepted contract for how an installed Lazurio learns about, obtains and activates a new version. This is its first implementation slice: the part that must be dependable before anything else can be — who am I, what do I trust, and is there something newer. It replaces nothing yet; it lands beside the pilot installer so the contract's "prove it, then delete the journal" order can be followed.

Nothing here is deployed to clients, so there is no compatibility burden.

What changes

New module directory src/update/, the future home of the mechanism.

  • Identity in the binary. version, commit, target are embedded with Bun --define; src/update/identity.ts is the only reader and falls back to a development identity (0.0.0-development) that sorts below every release. lazurio --version [--json]. scripts/build-candidate.ts passes the defines from the same values that go into identity.json and, for a native build, asks the produced executable to confirm they are one fact; scripts/smoke-candidate.ts asserts the same.
  • Durable trust promotion, exactly as "Check" describes. The pinned tuf-js refreshes in update/scratch-<id>/ seeded from trust/. When the refresh ends — success or failure — every newly verified role is promoted by temp file + fsync + rename + directory fsync: numbered root chain, root.json, timestamp, snapshot, targets, then the channel floor. trust/ always holds a prefix of that order. Scratch is always deleted.
  • When tuf-js persists vs. verifies was read in the pinned 6.0.0 source and is recorded with line references in src/update/trust.ts. Timestamp, snapshot and targets are persisted only after complete verification including expiry (updater.js:213→225, :249→251, :285→287; the expiry throws at store.js:91, :136, :172 precede the persist). The root is persisted (updater.js:178) after complete authenticity verification (store.js:42-48) but before the final-root expiry check, which only happens when a timestamp is loaded (store.js:57-59). It is promoted regardless, deliberately — see "Deviations".
    Promotion does not rely on that reading alone: it re-verifies the root chain link by link with @tufjs/models, and it withholds the role that was delivered last in a refresh that then failed (the client fetches strictly sequentially, so a later request is the proof that the previous phase returned).
  • Channel floors (sequence, document sha256 per channel) live in trust/channel-floors.json as schema-versioned security state, advanced only after the signed document was verified and only forward. Channels are stable and preview.
  • checkForUpdate returns up-to-date | available{version, artifactSha256, length, sequence} | error{code, context} and never throws for expected failures. Error codes, exit statuses and retryability are one table in src/update/errors.ts. No product mutation, no artifact download.
  • update/observed.json is rewritten atomically after every check. readObserved() returns a rebuilt minimal observation for a missing, corrupt, oversized or unknown-schema file; nothing reads it as authority.
  • CLI: lazurio update --check [--json], lazurio update status [--json], with explicit --metadata-url, --target-url, --channel, --bootstrap-root, --base, --loopback-fixture. Exit 0 up to date, 10 available, a distinct status per error code, and the code is printed. Plain lazurio update returns the typed not-implemented (31) and touches nothing.
  • Lock: update/lock, one regular file under flock, polled to a timeout (busy). O_CREAT is atomic and the content is never read, so there is no initialization to interrupt.

What deliberately does not change

  • src/distribution/* (journal, replay, recovery cycles, product commands) is untouched and still passes its tests. It is deleted only after the contract's interruption/expiry/rotation evidence exists.
  • src/folder/lock.ts is not modified (findings below).
  • No download, no activation, no rollback, no compiled-in root, no default origins, no update/config.json, no Launchpad poller or pill, no publisher.

Verification

bun run check on the pushed HEAD: lint (biome --error-on-warnings), typecheck, 451 pass / 3 skip / 0 fail across 79 files (3408 expect calls), public guard, proof build, proof smoke — all green. Baseline on the design branch was 416 pass / 3 skip, so this adds 35 tests in 6 files.

Tests run against a signed loopback repository built for this slice (scripts/update-fixture.ts: consistent snapshots, one key per role, numbered immutable objects retained). Covered:

  • first check with a bootstrap root → trust promoted, observation written, available / up-to-date;
  • later checks need no root; a root beside established trust → trust-conflict; neither → trust-missing;
  • root rotation during a refresh that then fails at the targets, the channel document, or the timestamp → root 2 and 2.root.json are durable, and the next check only probes 3.root.json;
  • a kill before every single one of the 7 durable writes → the surviving state is asserted to be an exact prefix of the promotion order, and a plain next check converges to the same trust as an uninterrupted one;
  • expired timestamp (first contact and established), snapshot, targets, and an expired rotated root → metadata-expired, trust untouched where nothing was verified, and recovery once the publisher republishes;
  • a tampered snapshot is never promoted while the timestamp verified before it is;
  • repository advance, lower sequence, reused sequence with other bytes → channel-rollback, floors unchanged; per-channel floors;
  • corrupt / missing / unwritable observed.json never blocks; unknown entries in the base, trust/ and update/ are ignored and left alone;
  • three concurrent checks serialize without corrupting trust; a held lock → busy with no network and no observation write; a SIGKILLed holder in another process releases immediately;
  • a compiled executable reports exactly the identity it was built with.

Mutation spot-checks confirmed the tests fail when promotion is skipped on failure, when roles are promoted before the root, when the floor check is removed, and when the unsettled guard is removed. I also built a real candidate with scripts/build-candidate.ts on darwin-arm64 and ran scripts/smoke-candidate.ts against it (not part of bun run check).

Not verified: Linux (CI will be the first run there), power loss as opposed to process death, and any filesystem other than local APFS.

Deviations from docs/update.md, and why

  1. The root is promoted before its expiry was checked. The task brief asked not to promote a role persisted before its final verification. For the root that would contradict "Trust never rewinds": expiry is a freshness property of a refresh, not of who may sign the next root, and TUF itself persists at 5.3.8 before the freeze check at 5.3.10. Forgetting a correctly signed root that revoked a key would let the holder of that key fork the chain on the next refresh. An expired root authenticates nothing: every refresh re-checks it before accepting a timestamp. Covered by a test.
  2. The bootstrap root becomes durable only with the first role it verified. The contract does not say when first trust is written. Writing it unconditionally would turn a mistyped root into a permanent wedge and make the identical retry command fail with trust-conflict.
  3. The pilot channel parser is not imported. It hard-codes channel: "pilot" and an exact four-field set, and src/distribution/ was to stay untouched. src/update/channel.ts keeps its invariants and adds version and minimumVersion — a check must not download the artifact or its identity to learn the version.
  4. minimumVersion, not minimum_version. Every other field of the document is camelCase. Easy to flip before a publisher exists.
  5. running is always null; checkedBy was added. Only the Launchpad can say what the Launchpad runs. A CLI check records the identity of the executable that compared versions instead of claiming a fact it does not have.
  6. Status checking is never written. A CLI check is short and a stale checking is exactly what the contract says must not stick.
  7. The clock input stamps the observation only. tuf-js takes new Date() internally (store.js:11) and offers no clock; tests publish already-expired metadata rather than moving a clock the client cannot see.
  8. docs/update.md gained an "Implemented so far" section recording the concrete choices above.

Open points

  • trust/root.json or channel-floors.json that is unreadable yields trust-invalid with no automatic way out. Atomic writes make that a disk-failure case, but it is the one place this slice can refuse until a person acts. The compiled-in root gives the root a natural fallback; the floors need a decision (refuse vs. rebuild from TUF's own targets rollback protection).
  • The shared FFI helper cannot read errno, so a filesystem without flock surfaces as busy after the timeout instead of its own code.
  • Snapshot/targets expiry is recognised from the message text of tuf-js's wrapped RuntimeError (updater.js:254, :290), which drops the cause. A wrong guess only swaps two retryable codes; it is qualified by a test against the pinned version.
  • A transport body that exceeds its length limit is reported as network-unavailable, because DistributionTransport raises one generic error for "refused, interrupted or too large".
  • src/folder/lock.ts cannot serve the contract's lock and should be fixed in its own PR: it refuses every filesystem except APFS and ext (:14-19); it wedges permanently if a process dies between mkdir (:23) and the protocol marker (:57-67), refusing at :29-32 by design (:58), and a healthy second process arriving in that window gets the same hard refusal; any stray entry or half-written marker refuses too (:70-74, :84-92); and it never waits, because flock is called with LOCK_NB only (native-lock.ts:16-18).
  • While writing the lock test I found that a lock whose FileHandle becomes unreachable is silently released by garbage collection. src/update/lock.ts roots held handles; src/folder/lock.ts relies on its caller keeping the returned object alive.

Note

Add update check, trust promotion, observed state, and embedded identity to update system

  • Adds a complete update-check workflow that refreshes signed TUF metadata into scratch storage, promotes only the verified prefix to durable trust, parses authenticated channel documents, and reports up-to-date, available, or typed error results without downloading or activating anything
  • Adds durable file writing with temporary files, atomic rename, and directory sync, plus a kernel-released update lock with inode verification and polling-based acquisition
  • Adds embedded product identity (version, commit, target) with build-define generation, a development fallback for source runs, and validation in the candidate build and smoke scripts
  • Adds CLI version, check, and status commands with stable error codes, distinct exit statuses, and human-readable or JSON output
  • Adds a persisted observation model with schema validation, size limits, and automatic rebuild to an idle state on corruption or write failure
  • Risk: parseObserved and readChannelFloors throw on newer-schema or malformed files, so a manual edit to trust state with an unsupported schema version will produce trust-invalid; checkForUpdate ignores durable observation-write failures as derived-state, so a full disk silently leaves a stale observation file
📊 Macroscope summarized b2af76f. 17 files reviewed, 6 issues evaluated, 0 issues filtered, 6 comments posted

🗂️ Filtered Issues

Follow-up on this branch: revised contract merged, authenticated failure-state floors (head 278c449)

  • Merged claude/DEV-6594-update-design @ 5eb2e56 (authority-boundary table, superseded banners, foundations docs from main, the new floor rule of the Check step). No conflicts here.
  • Reviewer finding confirmed in the pinned tuf-js 6.0.0: updateTimestamp installs the new timestamp in memory (store.js:89) and only then throws on expiry (:91 → :196-197); updateSnapshot does the same (:133, :136 → :209-215); Updater never reaches persistMetadata (updater.js:225, :251). The authenticated newer-but-expired role died with the process, and a later replay of a lower, still valid generation passed.
  • How the client loads an expired LOCAL role (the claim to verify): it holds. Timestamp: updater.js:195-206 calls updateTimestamp(local) inside try/catch { continue }; store.js:89 installs before :91 throws, so the expired local timestamp stays in memory and a lower remote version (:72) or snapshot version (:83) is refused. Snapshot: updater.js:230-234 calls updateSnapshot(local, true); store.js:133 installs before :136 throws, and :122-131 then refuses a remote snapshot with a lower targets version. Targets differ: store.js:172-175 checks expiry BEFORE installing, so an expired local targets file is discarded without a trace; its version floor is the snapshot's meta. A targets role delivered last is therefore deliberately never captured. The final expiry checks (:102, :145, :172) still run before any target lookup: expired metadata authorizes nothing.
  • Implemented: the rule "withhold the role delivered last in a failed refresh" is replaced. The fetcher retains the raw bytes of the last-delivered role; after a failed refresh capturedFloor (src/update/trust.ts) re-verifies it with @tufjs/models only, ignoring expiry — timestamp: signature threshold under the promoted root, version above and snapshot version not below the trusted one; snapshot: length/hashes recorded by the newest authenticated timestamp, signature threshold, exactly the version that timestamp names, no targets version below the trusted snapshot's — and promotes it as the ordinary role file.
  • Tests: expired newer timestamp and snapshot are in trust/ after metadata-expired; replay of the last valid LOWER generation is refused (metadata-invalid) — for the snapshot also with the timestamp file lost, so the snapshot floor is proven alone; a fresh higher generation is accepted (no wedge); a damaged signature, a tampered snapshot and an authentic lower version are not promoted; a write that dies before or after the capture converges at the next check. Mutation check: with the capture switched off, five tests fail. bun run check: 453 pass / 3 skip / 0 fail.
  • Behaviour changes to know: the floor is also captured on first contact and under an expired rotated root (the root authenticated a signature; a floor only raises the bar; a floor that no longer verifies under a later root is not loaded, so a revoked key cannot wedge a Machine). On THIS branch alone, repeating a first check with --bootstrap-root after such a capture answers trust-conflict; Update: download, staging self-check, activation worker, rollback #7 replaces that rule (a supplied root is never a conflict). The fetcher keeps only the last-delivered role's bytes, not every role's — nothing else reads them.

Follow-up 2: the floor vector (head 80770bc; contract d8877ed merged)

trust/floors.json (src/update/floors.ts) keeps exactly the facts of the contract's table and decides every promotion and every target lookup. src/distribution/* is untouched.

  • Signed-content digest = SHA-256 of canonicalize(metadata.signed.toJSON()) from @tufjs/canonical-json — byte for byte what @tufjs/models verifies a signature over (dist/key.js:39-41 → dist/utils/verify.js:10). @tufjs/canonical-json@2.0.0 (the version @tufjs/models pins, already in the lockfile) is now a declared dependency.
  • Who is compared: after a failed refresh the roles that changed plus the authenticated last-delivered role; after a successful refresh every role the client trusts, changed or not.
  • Write order and why it is safe: (1) floors.json = element-wise maximum, (2) numbered root chain, (3) root.json, (4) timestamp, snapshot, targets; each write atomic, the first failure stops the rest. A fact reaches a file only after it reached the vector, so the vector is never lower than what the files prove. Interrupted after (1) the vector is ahead, which cannot wedge: it refuses only what is LOWER or DIFFERENT, the same repository state is a no-op and any later valid state exceeds it. Interrupted inside (2)–(4) trust/ holds a prefix the client recovers from by itself. Tested by failing every durable write in turn.
  • Violation: metadata-rollback (exit 47, context role, rule, floor, offered); only the valid root chain and its part of the vector are kept; it outranks whatever else failed in that refresh; an authentic LOWER role the client itself refused is reported under the same code. retryable: true in the sense of the error table (it ends when metadata at or above the floors is served again, no repair on the Machine) — observers should watch the code.
  • Targets (channel document) are looked up only after a refresh that completed without error and satisfied the vector (check.ts: refresh → capture → lookup).
  • Rebuild: missing, damaged or newer-schema floors.json → rebuilt from the retained numbered roots (walked as a verified chain) and the role files, each under the newest retained root that verifies it; written back; never blocks.

Where the pinned client forced more than the contract states

  1. It loads a local snapshot WITHOUT holding it against the timestamp's hashes (updater.js:231-232, store.js:107): a new timestamp naming the same snapshot version with other hashes is accepted and the old snapshot keeps being used. So a reference — the timestamp's snapshot reference, a snapshot.meta entry — that names the same version with other recorded length/hashes is refused too.
  2. A replayed state that EQUALS the files is "no change" for the client, which then authorizes targets — also when the vector is ahead of the files. Hence the comparison of unchanged roles after a successful refresh.
  3. A snapshot.meta entry may not fall below a role of that name that was actually verified, and a snapshot not below the newest recorded reference, whichever file is lost.
    Proven by switching the comparison off: after a rotation of every role key, and after an interruption between vector and files, the client accepts the lower state and reports an update.

Old proof → new proof (tests/historical-roles.test.ts → new path)

Retained test Equivalent
higher snapshot reference cannot mask rollback of the cached snapshot itself (86-119) update-floors: "rule 3: a timestamp may not name a snapshot below ANY snapshot version…"
validly signed timestamp / snapshot / targets cannot extend expiry at the same version (120-169) update-floors: "rule 2: the same version with different signed content…" (all three roles) + "after a rotation…" case same version, other signed content
content bindings ignore JSON formatting but retain unknown signed fields (171-201) update-floors: rule 2, second half (key order, whitespace, signatures ignored; unknown signed member counts)
publication comparison refuses lost counters, omitted roles and root substitution (203-274) update-floors: rule 1 (each role's version), rule 4 (entry removed, entry lowered); root substitution: violationOf root rule + update-trust: "a root in scratch reaches trust/ only through a verified chain"
partial subsequent cycles cannot erase earlier authenticated floors (276-300) update-floors: "the vector is an element-wise maximum…" + "interrupted after the vector and before the files…"
explicit previously trusted mixed-stage cache retains all historical versions (302-329) update-floors: "a missing or damaged floors.json is rebuilt … across a rotation" (timestamp under root 2, snapshot/targets under root 1)
trusted input conversion preserves snapshot's own higher version… (331-361) update-floors: rule 3 (max(reference, snapshot's own version)), malformed input: parseFloors strictness
a later snapshot retains missing historical role floors… (363-404) update-floors: rule 4 (refused snapshot never erases an entry; accepted one only adds)
continuation rejects serialized floors and foreign signatures… (406-418) deliberately different: the vector IS a persisted record, but it is facts that only refuse, never authority; strict parse + rebuild in update-floors; foreign signatures: update-trust: "after a failed refresh the role delivered last…"
expired distinct-role chain authenticates floors, not a checkpoint or target (420-439) update-check: "an expired timestamp…", "an expired snapshot…" (floor kept, nothing authorized, no wedge)
signatures and both metadata links are required even for historical floors (441-464) update-trust: foreign/damaged signature, snapshot not the one the timestamp names, no timestamp
gaps, repeated roles, wrong filename versions and duplicate JSON (466-480) update-trust: chain gap / never-received link; duplicate members: parseUniqueJson in factsOf/parseFloors (update-floors)
root rotation requires consecutive version and old plus new authorization (482+) update-trust: "a root in scratch reaches trust/ only through a verified chain from the seed"; update-check: rotation tests
— (new) all rules ACROSS a rotation that revoked every role key update-floors: "after a rotation that revoked every role key the client itself is blind…" (5 attacks, no target request, only the root chain kept, then no wedge)
— (new) expiry carrying a newer floor; repository advance update-check expiry tests; update-floors interruption test, last part

bun run check at be7f88d: 461 pass / 3 skip / 0 fail (one unrelated timing flake in guarded-process.test.ts on the first run, green on re-run); 80770bc only makes signature damage in tests deterministic.

🤖 Generated with Claude Code

immakermatty and others added 6 commits September 19, 2026 01:21
…lity, no-wedge core)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…known-good binary, atomic publishing

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…l document, durable writes, crash-safe lock

The future home of the update mechanism is src/update/; src/distribution/
stays untouched. One table defines every error code with its exit status and
retryability. The identity module is the only reader of the build-time defines
and falls back to a development identity that sorts below every release. The
channel parser keeps the pilot parser's invariants for the stable and preview
channels and adds the version a check compares. The lock is one flock-ed
regular file polled to a timeout: nothing to initialize, nothing to wedge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the observed state

The pinned tuf-js refreshes in a scratch copy seeded from trust/. When the
refresh ends - success or failure - every newly verified role is promoted by
temporary file, sync, rename and directory sync: numbered root chain, root,
timestamp, snapshot, targets, then the channel floor. trust.ts records exactly
when tuf-js 6.0.0 persists relative to verification, with line references;
promotion re-verifies the root chain itself and withholds the role delivered
last in a failed refresh. checkForUpdate returns a typed result with stable
error codes and never throws for expected failures; update/observed.json is
derived state that a missing or corrupt file can never turn into a wedge.

Tests run against a signed consistent-snapshot loopback repository: rotation
retained across a failed refresh, a kill before every single promotion,
expired timestamp/snapshot/targets/root, repository advance and channel
rollback, unknown files, concurrent checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…embed their identity

build-candidate passes the identity as --define values and, for a native
build, asks the produced executable to confirm it equals identity.json; the
candidate smoke asserts the same. A unit test compiles the real CLI entrypoint
with the defines and compares --version. Plain 'lazurio update' reports the
typed not-implemented error: this build can only check. docs/update.md records
what the first slice implements and the concrete choices it fixed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… promotion; exact lock.ts references

Floors are read after promotion, so a floors file of a newer schema still lets
a verified root rotation become durable. The TODO in src/update/lock.ts now
names the exact lines of src/folder/lock.ts that prevent sharing it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread src/update/cli.ts
}
const clock = environment.clock ?? (() => new Date());
const env = environment.env ?? process.env;
const base =

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium update/cli.ts:133

Plain lazurio update returns invalid-request (exit 2) instead of the promised not-implemented (exit 31) when HOME is unset or the platform has no default base, such as win32. The command resolves and validates the default install base before checking values.check, even though this no-op path does not use the base; dispatch the no---check path before default-base resolution, while retaining validation for an explicitly supplied base if required.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/cli.ts around line 133:

Plain `lazurio update` returns `invalid-request` (exit 2) instead of the promised `not-implemented` (exit 31) when `HOME` is unset or the platform has no default base, such as `win32`. The command resolves and validates the default install base before checking `values.check`, even though this no-op path does not use the base; dispatch the no-`--check` path before default-base resolution, while retaining validation for an explicitly supplied `base` if required.

Comment thread src/update/cli.ts
Comment on lines +133 to +141
const base =
values.base ??
resolveInstallBase({
platform: environment.platform ?? process.platform,
env,
homedir: env.HOME,
});
if (!base || !isAbsolute(base) || resolve(base) !== base)
return render(failure("invalid-request", { option: "base" }), json);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High update/cli.ts:133

Valid absolute directory paths such as /tmp/lazurio/ are rejected with invalid-request, so both update status and update --check fail for documented inputs. The resolve(base) !== base check compares raw spelling instead of path meaning; normalize base after validating that it is absolute, then pass the normalized value onward.

-    const base =
+    let base =
       values.base ??
       resolveInstallBase({
         platform: environment.platform ?? process.platform,
         env,
         homedir: env.HOME,
       });
-    if (!base || !isAbsolute(base) || resolve(base) !== base)
+    if (!base || !isAbsolute(base))
       return render(failure("invalid-request", { option: "base" }), json);
+    base = resolve(base);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/cli.ts around lines 133-141:

Valid absolute directory paths such as `/tmp/lazurio/` are rejected with `invalid-request`, so both `update status` and `update --check` fail for documented inputs. The `resolve(base) !== base` check compares raw spelling instead of path meaning; normalize `base` after validating that it is absolute, then pass the normalized value onward.

Comment thread src/update/version.ts
Comment on lines +14 to +21
.map(Number);
const pre = dash === -1 ? [] : value.slice(dash + 1).split(".");
return { core, pre };
};
const a = split(left);
const b = split(right);
for (let index = 0; index < 3; index++) {
const difference = (a.core[index] as number) - (b.core[index] as number);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium update/version.ts:14

compareVersions can return 0 for distinct valid releases, and can return 1 when comparing identical sufficiently large core components. split converts arbitrarily long components with Number, causing rounding or Infinity - Infinity (NaN), which the ordering branch treats as 1; compare normalized decimal strings instead.

-      .map(Number);
+      .map((part) => part.replace(/^0+(?=\d)/, ""));
     const pre = dash === -1 ? [] : value.slice(dash + 1).split(".");
     return { core, pre };
   };
   const a = split(left);
   const b = split(right);
   for (let index = 0; index < 3; index++) {
-    const difference = (a.core[index] as number) - (b.core[index] as number);
-    if (difference !== 0) return difference < 0 ? -1 : 1;
+    const x = a.core[index] as string;
+    const y = b.core[index] as string;
+    if (x.length !== y.length) return x.length < y.length ? -1 : 1;
+    if (x !== y) return x < y ? -1 : 1;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/version.ts around lines 14-21:

`compareVersions` can return `0` for distinct valid releases, and can return `1` when comparing identical sufficiently large core components. `split` converts arbitrarily long components with `Number`, causing rounding or `Infinity - Infinity` (`NaN`), which the ordering branch treats as `1`; compare normalized decimal strings instead.

Comment thread src/update/trust.ts
/** Owned security state must not be replaceable by another account. */
export async function ensureOwnedDirectory(path: string): Promise<void> {
await mkdir(path, { recursive: true, mode: 0o700 });
const stat = await lstat(path);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High update/trust.ts:101

An attacker who can modify the parent of trustDirectory can replace trust/ after ensureOwnedDirectory returns, causing readSeed to read an attacker-controlled root.json and accept it if it is self-signed. Because subsequent reads and promotions reopen paths by name, this replaces the TUF trust anchor and can authenticate forged metadata; validate and hold a trusted parent or use descriptor-relative no-follow operations for the entire refresh.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/trust.ts around line 101:

An attacker who can modify the parent of `trustDirectory` can replace `trust/` after `ensureOwnedDirectory` returns, causing `readSeed` to read an attacker-controlled `root.json` and accept it if it is self-signed. Because subsequent reads and promotions reopen paths by name, this replaces the TUF trust anchor and can authenticate forged metadata; validate and hold a trusted parent or use descriptor-relative no-follow operations for the entire refresh.

Comment thread src/update/observed.ts
Comment on lines +276 to +278
const path = observedPath(base);
if ((await stat(path)).size > 64 * 1024) throw new Error("Too large");
return parseObserved(JSON.parse(await readFile(path, "utf8")));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High update/observed.ts:276

readObserved hangs indefinitely when update/observed.json is a FIFO or other special file, so update status can hold update/lock and cause subsequent checks to fail as busy. stat() only checks the reported size; require stat(path).isFile() before calling readFile().

     const path = observedPath(base);
-    if ((await stat(path)).size > 64 * 1024) throw new Error("Too large");
+    const file = await stat(path);
+    if (!file.isFile() || file.size > 64 * 1024) throw new Error("Too large");
     return parseObserved(JSON.parse(await readFile(path, "utf8")));
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/observed.ts around lines 276-278:

`readObserved` hangs indefinitely when `update/observed.json` is a FIFO or other special file, so `update status` can hold `update/lock` and cause subsequent checks to fail as `busy`. `stat()` only checks the reported size; require `stat(path).isFile()` before calling `readFile()`.

Comment thread src/update/check.ts
Comment on lines +179 to +185
if (
!isAbsolute(input.base) ||
resolve(input.base) !== input.base ||
!isUpdateChannel(input.channel) ||
!validUrl(input.metadataBaseUrl) ||
!validUrl(input.targetBaseUrl)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High update/check.ts:179

Passing operationId: "x/../../trust" makes checkForUpdate recursively delete <base>/trust in its finally block, destroying durable trust state; the same traversal also directs scratch operations outside update/. Validate caller-provided IDs as a single safe path component before constructing scratch (or derive the directory name from a generated safe identifier).

 if (
-    !isAbsolute(input.base) ||
+    !isAbsolute(input.base) ||
     resolve(input.base) !== input.base ||
     !isUpdateChannel(input.channel) ||
     !validUrl(input.metadataBaseUrl) ||
-    !validUrl(input.targetBaseUrl)
+    !validUrl(input.targetBaseUrl) ||
+    (input.operationId !== undefined &&
+      !/^(?!\.{1,2}$)[^/\\]+$/.test(input.operationId))
   )
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/check.ts around lines 179-185:

Passing `operationId: "x/../../trust"` makes `checkForUpdate` recursively delete `<base>/trust` in its `finally` block, destroying durable trust state; the same traversal also directs scratch operations outside `update/`. Validate caller-provided IDs as a single safe path component before constructing `scratch` (or derive the directory name from a generated safe identifier).

immakermatty and others added 4 commits September 19, 2026 03:36
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… durable capture of authenticated failure-state floors

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ing the last-delivered role

The pinned tuf-js authenticates a newer timestamp or snapshot, installs it in
memory and only then throws on expiry (store.js:89-91, :133-136); the Updater
never persists it, so the rollback floor died with the process and a later
replay of a lower, still valid version passed.

The fetcher now retains the raw bytes of the role delivered last; after a
failed refresh promotion re-verifies it without the client and without expiry
(signature threshold under the promoted root, version rules of the store,
and for a snapshot the length, hashes and version recorded by the newest
authenticated timestamp) and writes it as the ordinary role file. An expired
local timestamp or snapshot is only a version floor for the client
(updater.js:195-206, :230-234); expired targets carry no floor (store.js:172-175)
and are never captured. Expired metadata still authorizes nothing.

Tests: expired newer timestamp and snapshot are kept, a replay of the last
valid lower generation is refused (for the snapshot even with the timestamp
file lost), a fresh higher generation is accepted, unauthentic or not-newer
roles are discarded, and an interrupted capture converges.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
immakermatty and others added 3 commits September 19, 2026 04:27
…e-version binding, rotation)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nd every target lookup

Per-role version floors in the role files are not enough: they die with a lost
file, with an interruption, and with a key rotation (a retained role signed by
a revoked key no longer loads in the pinned client). trust/floors.json keeps
the facts themselves: root versions and digests, the timestamp and its
snapshot reference, the snapshot and EVERY snapshot.meta entry, and version +
signed-content digest of each verified role. Signed-content digest = SHA-256 of
canonicalize(signed.toJSON()), exactly what @tufjs/models verifies a signature
over; @tufjs/canonical-json becomes a declared dependency.

Every candidate — after a failed refresh the changed roles and the
authenticated last-delivered one, after a successful refresh every role the
client trusts — is compared with the whole vector: lower version, same version
with other content, lowered snapshot reference, removed/lowered/changed
snapshot.meta entry, contradicting root. A violation is `metadata-rollback`
(exit 47) and keeps only the valid root chain. floors.json is written FIRST,
then the files, so the vector is never lower than what the files prove and a
vector that is ahead cannot wedge. Targets are looked up only after a refresh
that completed without error and satisfied the vector. A missing or damaged
vector is rebuilt from the retained files under the retained roots.

tests/update-floors.test.ts ports the proofs of tests/historical-roles.test.ts
to the new path, including all rules after a rotation of every role key and
after an interruption — two cases in which, with the comparison switched off,
the pinned client accepts the lower state. src/distribution is untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread src/update/lock.ts
const opened = await handle.stat();
const current = await lstat(path).catch(() => undefined);
// Otherwise an inode that is no longer the lock was locked: retry.
if (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High update/lock.ts:84

The inode check does not prevent concurrent updates: if path is replaced immediately after the comparisons at lines 84–87, this caller keeps the flock on the old inode while another caller locks the new pathname, so both enter the update critical section and can concurrently promote trust or clean scratch. Hold a stable directory descriptor and coordinate replacement, or revalidate at the mutation boundary with a replacement-safe scheme.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/lock.ts around line 84:

The inode check does not prevent concurrent updates: if `path` is replaced immediately after the comparisons at lines 84–87, this caller keeps the `flock` on the old inode while another caller locks the new pathname, so both enter the update critical section and can concurrently promote trust or clean scratch. Hold a stable directory descriptor and coordinate replacement, or revalidate at the mutation boundary with a replacement-safe scheme.

Comment thread src/update/observed.ts
Comment on lines +143 to +144
Number.isSafeInteger(value.length) &&
Number.isSafeInteger(value.sequence) &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium update/observed.ts:143

parseObserved accepts available.length <= 0 and available.sequence <= 0, so a corrupt observed.json such as { "length": -1, "sequence": 0 } is retained and displayed as an available update instead of triggering the documented rebuilt-observation fallback. Require both fields to be positive in isAvailable, matching the values produced by successful checks and valid channel documents.

-    Number.isSafeInteger(value.length) &&
-    Number.isSafeInteger(value.sequence) &&
+    Number.isSafeInteger(value.length) && value.length > 0 &&
+    Number.isSafeInteger(value.sequence) && value.sequence > 0 &&
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/observed.ts around lines 143-144:

`parseObserved` accepts `available.length <= 0` and `available.sequence <= 0`, so a corrupt `observed.json` such as `{ "length": -1, "sequence": 0 }` is retained and displayed as an available update instead of triggering the documented rebuilt-observation fallback. Require both fields to be positive in `isAvailable`, matching the values produced by successful checks and valid channel documents.

Comment on lines +51 to +53
if (!url.startsWith(this.metadataBaseUrl)) return undefined;
// Consistent snapshots prefix the version: `7.snapshot.json`.
const name = url.slice(this.metadataBaseUrl.length);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High update/trust-fetcher.ts:51

metadataFile rejects every request when the configured base contains an explicit default port, so root bytes are not recorded and timestamp/snapshot bytes are not retained as rollback floors. The updater's URL serialization removes :443 (or :80) before calling this method, making the raw startsWith(this.metadataBaseUrl) check fail; compare the parsed URL origin and pathname instead.

-    if (!url.startsWith(this.metadataBaseUrl)) return undefined;
+    const request = new URL(url);
+    const base = new URL(this.metadataBaseUrl);
+    const basePath = base.pathname.endsWith("/")
+      ? base.pathname
+      : `${base.pathname}/`;
+    if (
+      request.origin !== base.origin ||
+      !request.pathname.startsWith(basePath)
+    )
+      return undefined;
     // Consistent snapshots prefix the version: `7.snapshot.json`.
-    const name = url.slice(this.metadataBaseUrl.length);
+    const name = request.pathname.slice(basePath.length);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/trust-fetcher.ts around lines 51-53:

`metadataFile` rejects every request when the configured base contains an explicit default port, so root bytes are not recorded and timestamp/snapshot bytes are not retained as rollback floors. The updater's `URL` serialization removes `:443` (or `:80`) before calling this method, making the raw `startsWith(this.metadataBaseUrl)` check fail; compare the parsed URL origin and pathname instead.

Comment thread docs/update.md
Comment on lines +350 to +355
anchor the TUF client is seeded with; `<N>.root.json` is the retained verified
chain; `timestamp.json`, `snapshot.json`, `targets.json`;
`channel-floors.json` (`schemaVersion`, per-channel `sequence` and
`documentSha256`). Only these names are read; anything else is ignored.
Promotion order is the numbered chain, `root.json`, timestamp, snapshot,
targets, then the floor, and `trust/` always holds a prefix of that order.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low docs/update.md:350

This section documents an incorrect trust layout and write order: it omits trust/floors.json even though the implementation reads it, and places the unspecified floor after the roles even though floors.json is written first. This makes the documented security-state ordering contradict src/update/floors.ts and src/update/trust.ts; list both floor files and distinguish their ordering.

-  `root.json` is the current trusted root and the only
-  anchor the TUF client is seeded with; `<N>.root.json` is the retained verified
-  chain; `timestamp.json`, `snapshot.json`, `targets.json`;
-  `channel-floors.json` (`schemaVersion`, per-channel `sequence` and
-  `documentSha256`). Only these names are read; anything else is ignored.
-  Promotion order is the numbered chain, `root.json`, timestamp, snapshot,
-  targets, then the floor, and `trust/` always holds a prefix of that order.
+  `root.json` is the current trusted root and the only
+  anchor the TUF client is seeded with; `<N>.root.json` is the retained verified
+  chain; `timestamp.json`, `snapshot.json`, `targets.json`;
+  `channel-floors.json` (`schemaVersion`, per-channel `sequence` and
+  `documentSha256`); `floors.json` (the rollback floor vector). Only these
+  names are read; anything else is ignored. Write order is `floors.json`, the
+  numbered chain, `root.json`, timestamp, snapshot, targets, then
+  `channel-floors.json`; `trust/` always holds a prefix of that order.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @docs/update.md around lines 350-355:

This section documents an incorrect trust layout and write order: it omits `trust/floors.json` even though the implementation reads it, and places the unspecified floor after the roles even though `floors.json` is written first. This makes the documented security-state ordering contradict `src/update/floors.ts` and `src/update/trust.ts`; list both floor files and distinguish their ordering.

…o-op once in 256 runs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@immakermatty

Copy link
Copy Markdown
Contributor Author

Superseded by #11, which implements the simplified contract merged in #10 (decision F13: GitHub Releases + Sigstore attestation, one activation path). The TUF-based stack in this PR is not continued; nothing from it should be ported except what #11 already carries.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant