Repository navigation
Conversation
… writes one Stage R1a's first boundary of the package trust design: the repository's format, the client's whole workflow and the publisher, host-tested, with no consumer on the machine yet. toyos-update gains `repo`: root.<N>.txt, timestamp.txt and targets.<M>.txt as fixed-order `key value` lines under a `toyos-repo <role> <version>` header, each signed by SSHSIG lines in the role's own namespace (toyos-root, toyos-timestamp, toyos-targets), over exactly the bytes before the first `sig`. `refresh` walks the root chain from the newer of the image's and the machine's root (each root signed by its predecessor's threshold and its own, at most 32 steps), then the timestamp, then the targets it names by version, length and SHA-256, each against the final root's threshold of distinct key IDs, its expiry by the wall clock, and the floors the image and the machine hold: no lower version, no equal version with other bytes, no lower item sequence, no equal sequence naming another archive. A held floor counts only while the final root gives its role the keys the root held beside it did, which is TUF's recovery from a fast-forward after a key rotation. `Archive` checks an archive as it streams, within its signed length. Every refusal is a `Refused` variant. `sig::verify` takes the namespace from what is signed (`sig::Signed`), so an image header verifies only as `toyos-image` and a repository document only as its role; no call site in the loader or `update` changes. src/publish.rs reads packages.toml and moves the key's repository forward: root 1 minted where there is none and renewed within 30 days of its expiry, the next targets and timestamp, archives never rewritten, sequences never lowered; every file lands by rename, the timestamp last, after the client has read the whole result back from root 1. `cargo run -- --publish <packages.toml>` writes into target/pkg-repository with this checkout's throwaway key, or ~/.config/toyos/pkg-repository with --owner-key: one key in every role, as the owner ruled for stage 1. src/signing.rs signs the documents and takes the repository's strict base64 in place of its own. The oracle: toyos-update/tests/repo_oracle.rs holds a repository OpenSSH's ssh-keygen signed (the throwaway private key deleted) that the client accepts, and refuses bent; and ssh-keygen -Y verify checked every signature the publisher made, by hand, in the pull request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
…the package track says what the signed repository changes Measured on the host: DataVolume's rename of a two-file directory whose second entry the format refuses (EntryTooLarge) answers ResourceExhausted and leaves one file under each name. That is the stop condition of R1a's commit by rename, so `pkg install <name>` waits on issues/a-directory-rename-on-data-is-not-atomic.md. The package track drops `pkg install <url>` and its SHA256SUMS (owner ruling), names the signed repository as stage 4 with what landed and what is owed, and has stage 7's apps arrive by name. A refusal test never Debug-prints what the client accepted: `Fresh` has no Debug, and the tests' helpers panic without printing it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
clippy::redundant_clone, adopted in src/clippy.rs, reddened --ci host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
|
Mutations, at
diff --git a/toyos-update/src/repo.rs b/toyos-update/src/repo.rs
index 1442ba8dc..898b766e1 100644
--- a/toyos-update/src/repo.rs
+++ b/toyos-update/src/repo.rs
@@ -726,8 +726,7 @@ fn vouched(doc: &Doc<'_>, by: &Root) -> Result<(), Refused> {
Some(public) => check(line, public, &body),
};
match verdict {
- Ok(()) if !counted.contains(id) => counted.push(*id),
- Ok(()) => {}
+ Ok(()) => counted.push(*id),
Err(why) => {
first.get_or_insert(why);
}
diff --git a/toyos-update/src/repo.rs b/toyos-update/src/repo.rs
index 1442ba8dc..788c6e15d 100644
--- a/toyos-update/src/repo.rs
+++ b/toyos-update/src/repo.rs
@@ -570,7 +570,6 @@ pub fn refresh(mirror: &mut dyn Mirror, image: Held<'_>, machine: Option<Held<'_
return Err(Refused::RootVersion { want, got: next.version });
}
vouched(&doc, &root)?;
- vouched(&doc, &next)?;
root = next;
root_bytes = bytes;
}
diff --git a/toyos-update/src/repo.rs b/toyos-update/src/repo.rs
index 1442ba8dc..d08be9765 100644
--- a/toyos-update/src/repo.rs
+++ b/toyos-update/src/repo.rs
@@ -706,7 +706,7 @@ fn not_back(role: Role, version: u64, bytes: &[u8], floor: u64, held: &[u8], hol
if version < floor {
return Err(Refused::Rollback { role, version, floor, holder });
}
- if version == floor && bytes != held {
+ if false && version == floor && bytes != held {
return Err(Refused::Changed { role, version, holder });
}
Ok(())
diff --git a/toyos-update/src/repo.rs b/toyos-update/src/repo.rs
index 1442ba8dc..8bec5875a 100644
--- a/toyos-update/src/repo.rs
+++ b/toyos-update/src/repo.rs
@@ -583,7 +583,7 @@ pub fn refresh(mirror: &mut dyn Mirror, image: Held<'_>, machine: Option<Held<'_
for (holder, held, held_root) in holders {
let (Some(held), Some(held_root)) = (held, held_root) else { continue };
let Some(bytes) = held.of(role) else { continue };
- if held_root.grant(role).keys == root.grant(role).keys {
+ if true || held_root.grant(role).keys == root.grant(role).keys {
out.push((holder, bytes));
}
}
diff --git a/toyos-update/src/repo.rs b/toyos-update/src/repo.rs
index 1442ba8dc..288518ece 100644
--- a/toyos-update/src/repo.rs
+++ b/toyos-update/src/repo.rs
@@ -629,7 +629,7 @@ pub fn refresh(mirror: &mut dyn Mirror, image: Held<'_>, machine: Option<Held<'_
not_back(Role::Targets, targets.version, &targets_bytes, floor.version, held, *holder)?;
for item in &targets.items {
let Some(was) = floor.find(&item.name, &item.target) else { continue };
- if item.sequence < was.sequence {
+ if false && item.sequence < was.sequence {
return Err(Refused::Sequence {
name: item.name.clone(),
target: item.target.clone(),
diff --git a/toyos-update/src/repo.rs b/toyos-update/src/repo.rs
index 1442ba8dc..9c4c3ccae 100644
--- a/toyos-update/src/repo.rs
+++ b/toyos-update/src/repo.rs
@@ -670,7 +670,7 @@ impl Archive {
/// The next bytes, refused where they run past the signed length.
pub fn take(&mut self, chunk: &[u8]) -> Result<(), Refused> {
let seen = self.seen.saturating_add(chunk.len() as u64);
- if seen > self.length {
+ if false && seen > self.length {
return Err(Refused::ArchivePast { length: self.length });
}
self.hash.update(chunk);The control's own failure line (m1): |
|
Oracle, the publisher signs and OpenSSH 10.3p1 verifies. Two runs of |
|
The stop condition, measured: a scratch test appended to
|
|
Gates at
|
Its description and header named only the image, the slots and their record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
|
|
|
Review of #808 at
BLOCKER
NOTE
SEND BACK |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
…ors are the client's, and every archive streams through Archive Review of #808 at 04f8479. - Two rows whose archives share a file name map to one url. The second row's bytes overwrote the first's in this publish's files while the targets signed the first's SHA-256. An archive already on disk or already listed is now never written again, and the read-back streams every item's archive through repo::Archive: the client's length and digest check is the one gate for a url with other bytes, listed twice or published before, which deletes the publisher's own byte compare. - The publisher's own sequence and reissue check is deleted. The read-back passes the directory's newest root, its timestamp and the targets that names as the machine's holding, so repo::refresh's floors decide, with (length, sha256) for a reissue, and Holder::Machine has a production caller. - Targets::item and Refused::NoItem had no caller outside tests; they go until pkg install <name> needs them. - No document's version is u64::MAX, refused by the header, so the root walk's next version cannot overflow: a held root there is Refused::Held, a mirror's is Malformed at line 1. One rule at the parser instead of a checked_add at one of the version sums. - land syncs the directory after each rename, so the timestamp-last order survives a power loss. - Tests: a machine's newer root holds though the mirror withholds it; a machine root at the image's version with other bytes is Changed; a root at the last version is refused, held or walked; two rows naming one archive with other bytes are refused, with the same bytes they share it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
|
Round 2 evidence at Mutation patches (checked with
|
|
Round 2 evidence at An
|
|
Round 2 review of #808 at
Round 1 blockers
The overflow (round 1 NOTE), closed another way. I accept the header bound in place of
The rest of round 1's notes:
BLOCKERNone open. One condition remains: NOTE
LAND AFTER NAMED CHANGES |
`snapshot` bounded the targets version a timestamp names by `>= 1` alone, so a `timestamp.txt` on disk naming targets 2^64 - 1 parsed, and the publisher's `targets_version + 1` panicked on overflow instead of refusing it. The bound is now the header's `1..u64::MAX`; the client only compares this value, and the header already refused `targets.<2^64-1>.txt`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C
|
Round 3 mutations, at mG: the fix reverted ( diff --git b/toyos-update/src/repo.rs a/toyos-update/src/repo.rs
index 29b34322c..cdf781eaf 100644
--- b/toyos-update/src/repo.rs
+++ a/toyos-update/src/repo.rs
@@ -436,7 +436,7 @@ impl Timestamp {
fn snapshot(words: &[&str]) -> Option<Snapshot> {
let [version, length, digest] = words else { return None };
Some(Snapshot {
- version: number(version).filter(|v| (1..u64::MAX).contains(v))?,
+ version: number(version).filter(|&v| v >= 1)?,
length: number(length).filter(|l| (1..=TARGETS_CAP as u64).contains(l))?,
sha256: hex32(digest)?,
})Run log (scrubbed): mH: the fix reverted and the test's parse assertion removed, to show what the publish line alone sees: the publisher's diff --git a/src/publish.rs b/src/publish.rs
index dda438481..30709ea84 100644
--- a/src/publish.rs
+++ b/src/publish.rs
@@ -426,7 +426,6 @@ mod tests {
line: 3,
why: "`targets` is not `<version> <length> <sha256>` within the targets cap",
};
- assert_eq!(Timestamp::parse(&last).err(), Some(why.clone()));
let refused = publish(&m, out.path(), &key(), NOW + DAY);
assert!(refused.as_ref().unwrap_err().ends_with(&format!("timestamp.txt: {why}")), "{refused:?}");
assert_eq!(read(out.path(), "timestamp.txt"), last, "a refused publish wrote nothing");
diff --git a/toyos-update/src/repo.rs b/toyos-update/src/repo.rs
index 29b34322c..cdf781eaf 100644
--- a/toyos-update/src/repo.rs
+++ b/toyos-update/src/repo.rs
@@ -436,7 +436,7 @@ impl Timestamp {
fn snapshot(words: &[&str]) -> Option<Snapshot> {
let [version, length, digest] = words else { return None };
Some(Snapshot {
- version: number(version).filter(|v| (1..u64::MAX).contains(v))?,
+ version: number(version).filter(|&v| v >= 1)?,
length: number(length).filter(|l| (1..=TARGETS_CAP as u64).contains(l))?,
sha256: hex32(digest)?,
})Run log (scrubbed): |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CI at |
Stage R1a of the package trust design, to its first clean boundary: the host verifier, the publisher, and their attack tests. It stops there. A directory rename on DATA is not atomic (measured below), and that is the brief's stop condition for
pkg install <name>, whose install is a commit by rename. The defect is filed as fileserver's inissues/a-directory-rename-on-data-is-not-atomic.md. Nothing on the machine reads a repository yet: there is nopkgchange, no build wiring into ROOT and no guest test.The verifier's production caller is the publisher's read-back. Before
--publishwrites anything, it runsrepo::refreshas a machine would: one that pins root 1 and holds the directory as it was before this publish. It then streams every item's archive throughrepo::Archive. The client's floors are therefore the publisher's floors, and its archive check is the publisher's archive check. Whatever in the verifier has no caller even after that leaves now and comes back withpkg install <name>:Targets::itemandRefused::NoItemare gone.What changed, per decision
toyos-update/src/repo.rs: the format and the client's whole workflow,no_stdwithalloc.root.<N>.txt,timestamp.txtandtargets.<M>.txt. Each is printable ASCII in LF-ended lines ofkey value, with one space between words.toyos-repo <role> <version>header and anexpires <YYYY-MM-DDTHH:MM:SSZ>line.sig <key-id> <sshsig>line is an SSHSIG blob in canonical base64.sigline, in the role's own namespace (toyos-root,toyos-timestamportoyos-targets).ssh-keygen -l's fingerprint.refreshstarts from the newer of two roots: the one the image pins and the one the machine holds. If both are the same version with different bytes, it refuses. It then walksroot.<N+1>.txtfor at most 32 steps; each new root must be signed by its predecessor's threshold and by its own. Next it takes the timestamp, then the targets the timestamp names by version, length and SHA-256.Archivechecks an archive as it streams: it refuses bytes past the signed length, and checks the SHA-256 at the end.Refusedvariant. Where expiry is the reason, itsDisplaynames both times.sig::verifytakes the namespace from what is signed (sig::Signed). An image header verifies only astoyos-image, and a repository document only as its role.updatechanged.the_message_is_sshsigs_signed_data, by the RFC 8032 vectors, and by thessh-keygenimage fixture.src/publish.rsandcargo run -- --publish <packages.toml>: readspackages.tomland writes a signed repository.target/pkg-repository; with--owner-key, into~/.config/toyos/pkg-repository. This mirrorssrc/signing.rs. Every role has one key, as the owner ruled for stage 1.Refused::SequenceorRefused::Reissued. The publisher has no floor check of its own.archives/is never written twice. If its url is already on disk, or already listed by an earlier row of this publish, it is not written again. Two rows whose archives share a file name share one url. The read-back's stream then refuses any row whose bytes differ from the archive at its url, naming that url.src/signing.rssigns the documents (Key::sign_document). It also drops its own lenient base64 decoder for the repository's canonical one, which is a net deletion. A keyssh-keygenminted still loads; this is measured below.issues/a-directory-rename-on-data-is-not-atomic.mdis new.pkg install <url>and itsSHA256SUMSare retired. The owner ruled this in the orchestrator's question tool ("Yes, retire it (Recommended)"), and the track's prose cites his ruling as given.pkg install <file>keeps theSHA256SUMSbeside it.The stop: a directory rename on DATA is not atomic
userland/fileserver/src/data.rsrenames a directory one entry at a time, and its own comment says a kill in the middle leaves two halves. I measured the same split without a kill, using a scratch host test ofDataVolume. It was applied, run and reverted in one script, and the tree was clean afterwards.aand a 240-page filez.ResourceExhausted; forz, the format saidEntryTooLarge { size: 4192, max: 4064 }.<to>/aat 5 bytes andhome/staged/zat 983040 bytes: half the directory under each name.The command was
cargo test --manifest-path userland/fileserver/Cargo.toml scratch_a_directory_rename_refused_half_way -- --nocapture, EXIT=0. The log is in a comment below.Checks (high-risk: a trust boundary)
Negative control (
m1-count-every-signature). This reverts distinct-key counting, so the client counts every validsigline. Exactly one test goes red,repo::tests::a_duplicated_signature_does_not_meet_a_threshold, EXIT=101. The restored tree is green.Mutations. Each was checked with
git apply --check, applied, built and run, then reverted in the same script. Each turned red exactly the tests named here.Round 1, at
4fc6d19ef:m2: a new root vouched for by its predecessor alonea_root_not_signed_by_the_previous_threshold_is_refusedm3: an equal version with other bytes acceptedthe_same_version_with_other_bytes_is_refusedm4: held floors counted through a rotationa_rotated_root_is_walked_and_drops_the_old_keys_floorsm5: no item sequence floora_lower_sequence_or_a_reissued_one_is_refusedm6: an archive allowed past its lengthan_archive_past_its_length_or_not_its_hash_is_refusedRound 2, at
af2874a03, covering the review's findings. The patches and the run log are in a comment below.mA:if false && m.version > image_root.version, the review's named patch, so a machine's newer root is ignoreda_machines_newer_root_holds_though_the_mirror_withholds_it; alsoa_root_at_the_last_version_is_refusedmB: the arm refusing a machine root at the image's version with other bytes is deleteda_machines_root_at_the_images_version_with_other_bytes_is_refusedmC: the header admits version 2^64 − 1 againa_root_at_the_last_version_is_refused, which panics on the overflow atrepo.rs:551mD: the read-back streams no archivetwo_rows_naming_one_archive_with_other_bytes_are_refusedanda_publish_that_would_move_anything_back_is_refused_by_name: both publishes are acceptedmE: an archive already on disk or listed is written againmF: the read-back holds no machine floors (machine.and(None))a_publish_that_would_move_anything_back_is_refused_by_name: the lower sequence is acceptedRound 3, at
8b7c68745, covering the review's NOTE. The patches and run logs are in comment 6085111464.mG: the fix reverted, sosnapshotbounds the timestamp's targets version by>= 1againa_timestamp_naming_the_last_targets_version_is_refused: the timestamp parsesmH:mGwith the test's parse assertion removedattempt to add with overflowatpublish.rs:148After round 2,
cargo test -p toyos-updategave EXIT=0,cargo test --lib -- publish::gave EXIT=0, andgit status --porcelainwas empty.Independent oracle, both directions: OpenSSH 10.3p1.
toyos-update/tests/repo_oracle.rsholds a repository whose bodies I wrote by hand to the format, and whose every signaturessh-keygen -Y sign -n toyos-<role>made.ssh-keygen -t ed25519and deleted afterwards. The key ID was taken fromssh-keygen -l's fingerprint.ssh-keygenat test time.cargo run -- --publish <packages.toml>twice (EXIT=0 each), which wrote root 1, targets 1 and 2, and timestamp 2.ssh-keygen -Y verify -n <role's namespace>then checked everysigline of every file it wrote. Each gaveGood "toyos-<role>" signature, EXIT=0.toyos-image, and over a bent body, EXIT=255.root.1.txtequalsssh-keygen -l's SHA256 fingerprint byte for byte.ssh-keygenminted loads as the owner's, and the owner-key branch publishes. This was ataf2874a03, withHOMEset to a scratch directory:ssh-keygen -t ed25519 -N ''gave EXIT=0, andchmod 600gave EXIT=0.TOYOS_SIGNING_KEY=<scratch>/k cargo run -- --owner-key --publish <toml>gave EXIT=0. It printedPublished into <scratch>/home/.config/toyos/pkg-repository: root 1, targets 1, timestamp 1, signed by SHA256:XKm3I5pC/zP++QnanTqquBEU4MrPARhc+/zNji6eOL4, which is exactlyssh-keygen -l -f <scratch>/k'sSHA256:XKm3I5pC/zP++QnanTqquBEU4MrPARhc+/zNji6eOL4.ssh-keygen -Y verifyaccepted each of the three documents'siglines in its role's namespace, EXIT=0 each.chmod 644, the same command gave EXIT=1: "is mode 0644 … refused unless it is 0600 or narrower".Attack tests, each refused by name (
toyos-update/src/repo.rs):Signature;Rollback), for the timestamp and for the targets the timestamp names;Changed);root.2.txtand serves key 1's timestamp. The result isThreshold { role: Timestamp, root: 2, first: Some(NotTheRolesKey), .. };Changed { role: Root, version: 1, holder: Image };Held { role: Root, holder: Machine }, and a mirror's root there isMalformedat line 1, never an overflow;The publisher's tests (
src/publish.rs) cover:timestamp.txton disk naming targets 2^64 − 1, refused by name asMalformed { role: Timestamp, line: 3, .. }by the parser and by the publish, which writes nothing and does not panic.Gates
The head is
8b7c68745, onmainatd6298c83e.cargo run -- --ci hostat8b7c68745: "Host: 78 step(s), all green", EXIT=0. The whole log, scrubbed, is in nine comments starting at 6085112838; its red lines are the sealed controls' expected reds.cargo run -- --build-onlyat8b7c68745: EXIT=0.cargo test -p toyos-updateandcargo test --lib -- publish::: EXIT=0 each, and both are inside--ci host.cargo test -- virt_user_mode --nocapture, which gavePASS virt_user_mode, EXIT=0. Its serial log hasSlot A: signed header 9e8084a8… verifies under this loader's key, version 1791562104, thenkernel, cmdline and ROOT are the bytes the signed header names. The log is in a comment below.cargo testataf2874a03(round 3 changed only the repository's timestamp parser, which nothing on the machine calls yet, and a publisher test) gave "test result: ok. 41 passed, 41 total (325.7s; workers: 2203s building, 1223s testing)", EXIT=0.uptimeread load averages 53.76 64.32 65.10 before the run and 75.09 66.19 65.14 after it.guest / suiteon this pull request: owed at this head. The orchestrator runs it by marking the pull request ready.No new guest test. The guest test the brief names (
pkg installfrom a local mirror, with a tampered archive and a rolled-back targets refused) needspkg install <name>, which is behind the stop.T14
None is owed, by the review's reasoning: the change does not target hardware.
sig::verifyis pure computation that firmware cannot reach. The message bytes are pinned on the host, and QEMU boots on both architectures run the same verify. None was requested.What
pkg's grant row will need (forwt/toyos-appgrants)pkg = {}is unchanged here. The next stage'spkg install <name>will need:/system/etc/pkg/(the pinned root and floor copies, andmirrors) and to/config/pkg/mirrors;/state/pkg;/apps.R1b adds
netstack.Growth
git diff --shortstat origin/main...8b7c68745: 16 files, +2359 −85.repo.rs: 1083 lines, 75 of them the host-only renderer.publish.rs: 300 lines.sig.rs: +66 −33.signing.rs: +12 −35.main.rs,flags.rsandlib.rs: +29.toyos-update'sCargo.tomlandlib.rs, andCargo.lock: +13 −4.repo.rs535 lines,publish.rs151,repo_oracle.rs99, fixtures 21.toyos-updatetakestoyos-wallclock, which is ours and already in the loader's graph, for the calendar thatexpiresis written in. No new crate.Unsure
~/.config/toyos/pkg-repository, beside his key) is my choice. The next stage's build wiring reads the same function.urlis only a path under the repository in this stage. The format refuses an absolute URL, so R1b widens the parser.refreshdoes not re-verify a held floor's signature. The image's copies are vouched for by the image's signature, and the machine's were accepted by this client. A program that can write/state/pkgcan move the machine's floors. That is the design's "local program with write access to DATA", out of scope until isolation stage 2. A held root at the last version is refused rather than overflowed.checked_addat the walk. I bounded the version in the header instead. This makes one rule at the parser for every role, so every+ 1over a parsed version, the publisher's included, has room. A held root at 2^64 − 1 isRefused::Held, as the review asked.🤖 Generated with Claude Code
https://claude.ai/code/session_017cSFvbD35xJ2kGANVdm23C