From eac0016e47916f44ea982baf705426a2a3fd7aa7 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 5 Aug 2026 12:42:00 +0200 Subject: [PATCH 1/3] fix: cross-check the minted ascent link and classify rotation authoring refusals Mirror the adoption gate's stage-3 ascent-link cross-check on the produce side of a re-seal, where the ancestor node seed lives: reopen the freshly sealed link exactly as an ancestor reader does and refuse, release-active, a link that does not yield this epoch's override seed. seal_ascent_link and open_ascent_link derive the ascent keypair independently, so nothing else stopped a re-seal signing an interior scope root every ancestor reader rejects whole-record. Route a produce-side trust refusal from author_scope_root_with_section to ScopeRootPublishError::Rejected instead of NotPublished, so the sweep and cascade stop retrying this build's own gate verdict as if it were a stalled endpoint. Drop the duplicate commitment-signature verify from check_publishable now that the authoring call reaches the same Rejected verdict, and take the write-scope seed after authoring so a trust verdict still precedes the capability check. Closes #1057 Closes #1055 --- crates/engine/src/net/author.rs | 3 +- crates/engine/src/net/rotation.rs | 61 ++++++++++++++------ crates/engine/src/rotation/reseal.rs | 86 ++++++++++++++++++++++++++-- 3 files changed, 128 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/net/author.rs b/crates/engine/src/net/author.rs index 50a71ed17..5e441aff7 100644 --- a/crates/engine/src/net/author.rs +++ b/crates/engine/src/net/author.rs @@ -14,7 +14,8 @@ //! envelope's own scope/epoch, returns `Err` off the same decoder and the same //! stage-2/stage-3 predicates the gate runs. The mirror stops where the gate //! needs a reader's secrets: stage 3's ascent-link seed cross-check takes an -//! ancestor node seed [`EnvelopeAuthoring`] does not carry; +//! ancestor node seed [`EnvelopeAuthoring`] does not carry, so it is enforced +//! where that seed lives, in `rotation/reseal.rs`; //! - a kind transplant and a non-canonical child-ref `ipnsName` are //! unrepresentable: [`new_child`] feeds one [`NodeKind`] and one typed //! [`IpnsName`] to both the body and the parent's ref. diff --git a/crates/engine/src/net/rotation.rs b/crates/engine/src/net/rotation.rs index 0f79e35ec..e0d76edf8 100644 --- a/crates/engine/src/net/rotation.rs +++ b/crates/engine/src/net/rotation.rs @@ -20,9 +20,9 @@ use cipherbox_core::kdf; use cipherbox_core::seal::{ AadContext, ChildScopeRef, Envelope, GrantSection, PreservedFields, ReadBody, STRUCT_TAG_OWNER_BLOB, STRUCT_TAG_WRITE_BODY, WriteBody, decode_write_body, open_owner_blob, - unseal, verify_grant_set, + unseal, }; -use cipherbox_core::suite::ecdsa::{EcdsaSignature, EcdsaVerifier}; +use cipherbox_core::suite::ecdsa::EcdsaVerifier; use cipherbox_core::suite::ed25519::Ed25519Signer; use cipherbox_core::suite::secret::SECRET_LEN; use cipherbox_core::suite::x25519::X25519Secret; @@ -435,16 +435,18 @@ where }) } - /// The three encode-side mirrors of gate rejects this build would itself - /// make on the record about to be signed — all release-active, because a - /// signed record cannot be unpublished (security rule 8): + /// The two durable-floor mirrors of gate rejects this build would itself make + /// on the record about to be signed — release-active, because a signed record + /// cannot be unpublished (security rule 8): /// - /// - the commitment must verify under the owner identity (gate stage 2); /// - the read epoch must not sit below the durable revocation floor (stage 5); /// - the write epoch must not sit below the durable write floor, which the /// owner-write-blob's AAD binds — below it the root publishes write-plane /// dead, and floors are monotonic, so it can never be rotated back. /// + /// The stage-2/stage-3 mirrors on the record's own bytes run inside the + /// authoring call itself (`net/author.rs`). + /// /// Runs **after** this pass's gated read of the scope root, whose adoption /// advances the read-epoch floor: measured before it, the floor comparison /// would miss a cut minted below the epoch that read just adopted. @@ -452,12 +454,6 @@ where &self, record: &ResealedScopeRoot, ) -> Result<(), ScopeRootPublishError> { - let section = &record.section; - let signature = EcdsaSignature::from_compact(§ion.commitment_sig) - .ok_or(ScopeRootPublishError::Rejected)?; - verify_grant_set(self.keys.identity, §ion.commitment, &signature) - .map_err(|_| ScopeRootPublishError::Rejected)?; - let floors = self.floors; let scope_id = &record.scope_id; let read_floor = floor::read_epoch_floor(floors, scope_id) @@ -577,10 +573,6 @@ where ), }; self.check_publishable(record).await?; - let write_scope_seed = current - .write_scope_seed - .as_deref() - .ok_or(ScopeRootPublishError::NotPublished)?; let node_seed = kdf::node_seed(&override_seed, &record.scope_id); let read_key = Zeroizing::new(*kdf::read_key(node_seed.as_bytes()).as_bytes()); @@ -600,7 +592,21 @@ where &record.section, self.keys.identity, ) - .map_err(|_| ScopeRootPublishError::NotPublished)?; + // A produce-side trust refusal is this build's own gate verdict on the + // bytes, reached before the PUT: re-authoring the same section reaches it + // again, so it must not be retried like a stalled endpoint (rule 6). + .map_err(|e| { + if e.is_trust_refusal() { + ScopeRootPublishError::Rejected + } else { + ScopeRootPublishError::NotPublished + } + })?; + + let write_scope_seed = current + .write_scope_seed + .as_deref() + .ok_or(ScopeRootPublishError::NotPublished)?; let binding = HeadBinding { node_id: record.scope_id, @@ -1269,6 +1275,27 @@ mod tests { .expect("the interior root's cut lands under its supplied ancestor seed"); } + /// Gate stage 3's encode-side mirror, and the verdict it carries: a section + /// whose structure signatures do not recompute is this build's own gate + /// verdict on bytes it is about to sign, so it is fatal — re-authoring the + /// same section reaches the same refusal, and retrying it forever would + /// launder a trust violation into an availability stall (rule 6). + #[test] + fn a_section_the_gate_would_reject_is_a_verdict_not_a_stall() { + let (harness, root, mut cut) = staged_cut(); + cut.section.owner_blob.signature[0] ^= 0xff; + + let outcome = block_on(harness.net(&[]).publish_scope_root(&cut)); + assert_eq!(outcome, Err(ScopeRootPublishError::Rejected)); + assert!(!outcome.unwrap_err().is_retryable()); + let endpoint = &harness.store.endpoints()[0]; + assert_eq!( + harness.store.record_at(endpoint, root.name.as_str()), + Some(record_for(&SCOPE, &root.head_cid_str, 1)), + "the pre-rotation record still stands — nothing was published", + ); + } + /// Gate stage 2's encode-side mirror: a commitment that will not verify /// under the owner identity is refused before the record is signed. #[test] diff --git a/crates/engine/src/rotation/reseal.rs b/crates/engine/src/rotation/reseal.rs index ecd1c6298..84e3b3439 100644 --- a/crates/engine/src/rotation/reseal.rs +++ b/crates/engine/src/rotation/reseal.rs @@ -31,18 +31,18 @@ use zeroize::{Zeroize, Zeroizing}; use cipherbox_core::kdf; use cipherbox_core::seal::{ - AadContext, GrantBlobPayload, GrantLedgerEntry, GrantSection, GrantSetCommitment, + AadContext, AscentLink, GrantBlobPayload, GrantLedgerEntry, GrantSection, GrantSetCommitment, HistoryLinkPayload, MAX_HISTORY_LINKS, OverrideSeedPayload, OwnerWriteBlobPayload, Permission, PreservedFields, STRUCT_TAG_ASCENT_LINK, STRUCT_TAG_GRANT_BLOB, STRUCT_TAG_HISTORY_LINK, STRUCT_TAG_OWNER_BLOB, STRUCT_TAG_OWNER_WRITE_BLOB, STRUCT_TAG_WRITE_BODY, SignedAscentLink, SignedGrantBlob, SignedOwnerBlob, SignedOwnerWriteBlob, SignedSealed, StructureSigInput, - WriteBody, encode_write_body, open_history_link, seal, seal_ascent_link, seal_grant_blob, - seal_history_link, seal_owner_blob, seal_owner_write_blob, sign_structure, + WriteBody, encode_write_body, open_ascent_link, open_history_link, seal, seal_ascent_link, + seal_grant_blob, seal_history_link, seal_owner_blob, seal_owner_write_blob, sign_structure, }; use cipherbox_core::suite::aead; use cipherbox_core::suite::ecdsa::SIGNATURE_LEN as ECDSA_SIG_LEN; use cipherbox_core::suite::ed25519::Ed25519Signer; -use cipherbox_core::suite::secret::SECRET_LEN; +use cipherbox_core::suite::secret::{SECRET_LEN, ct_eq}; use cipherbox_core::suite::x25519::X25519Public; use crate::entropy::{Entropy, EntropyError}; @@ -144,6 +144,10 @@ pub enum ResealError { /// A grant-ledger entry's recipient encryption key is unusable (malformed or /// low-order X25519). A grant can never be wrapped to an unopenable key. UnusableRecipientKey, + /// The freshly sealed ascent link does not reopen under the parent node seed + /// as this epoch's override seed — bytes every ancestor reader rejects + /// whole-record at the gate's stage 3 (fail-closed symmetry). + AscentLinkMismatch, /// Entropy acquisition failed; no seal proceeds without fresh randomness. Entropy(EntropyError), /// More carried history links than the codec's frozen bound admits — a set @@ -166,6 +170,9 @@ impl core::fmt::Display for ResealError { ResealError::UnusableRecipientKey => { f.write_str("grant-ledger recipient encryption key is unusable") } + ResealError::AscentLinkMismatch => { + f.write_str("sealed ascent link does not reopen as this scope root's override seed") + } ResealError::Entropy(e) => write!(f, "entropy error: {e}"), ResealError::TooManyHistoryLinks => { f.write_str("carried history links exceed the codec's frozen bound") @@ -184,6 +191,7 @@ impl ResealError { ResealError::LedgerDivergesFromCommitment => "ledger-diverges-from-commitment", ResealError::SignerNotCommitted => "signer-not-committed", ResealError::UnusableRecipientKey => "unusable-recipient-key", + ResealError::AscentLinkMismatch => "ascent-link-mismatch", ResealError::Entropy(_) => "entropy-error", ResealError::TooManyHistoryLinks => "too-many-history-links", ResealError::Encode(_) => "structure-encode-failed", @@ -391,6 +399,7 @@ pub fn reseal_scope_root( let link = seal_ascent_link(parent_node_seed, &ephemeral, &ctx, &payload); ephemeral.zeroize(); let link = link.map_err(ResealError::Encode)?; + verify_ascent_link(parent_node_seed, &ctx, seeds.override_seed, &link)?; let signature = sign_over(STRUCT_TAG_ASCENT_LINK, None, &link.ciphertext); Some(SignedAscentLink { ascent_public: link.ascent_public, @@ -479,6 +488,29 @@ pub fn reseal_scope_root( }) } +/// The produce-side half of the gate's ascent-link cross-check +/// (`gate/adoption.rs` stage 3), release-active per AGENTS.md rule 8: reopen the +/// link exactly as an ancestor reader does and confirm it yields this epoch's +/// override seed. `seal_ascent_link` and `open_ascent_link` derive the ascent +/// keypair independently, so nothing else stops a re-seal signing a link every +/// ancestor reader rejects whole-record — an unopenable interior scope root. +/// +/// The gate compares the read key the recovered seed derives; comparing the seed +/// is the same predicate one derivation earlier. +fn verify_ascent_link( + parent_node_seed: &[u8; SECRET_LEN], + ctx: &AadContext, + override_seed: &[u8; SECRET_LEN], + link: &AscentLink, +) -> Result<(), ResealError> { + let payload = open_ascent_link(parent_node_seed, ctx, link) + .map_err(|_| ResealError::AscentLinkMismatch)?; + if payload.epoch != ctx.epoch || !ct_eq(payload.override_seed(), override_seed) { + return Err(ResealError::AscentLinkMismatch); + } + Ok(()) +} + /// The AAD context for a scope-root structure: `id == scope == scope_id` (a /// scope root's node id is its scope id). fn ctx_for(v: u64, scope_id: [u8; 16], epoch: u64, struct_tag: u8) -> AadContext { @@ -769,6 +801,52 @@ mod tests { encode_grant_section(§ion).expect("section encodes"); } + /// The produce-side mirror of the gate's stage-3 ascent-link cross-check, + /// release-active (rule 8): the guard returns `Err`, so a `--release` build + /// refuses exactly the links a debug build does. Every row is a link an + /// ancestor reader rejects whole-record. + #[test] + fn an_ascent_link_the_gate_would_reject_is_never_signed() { + let parent_node_seed = [0x44; 32]; + let override_seed = [0x99; 32]; + let ctx = ctx_for(V, SCOPE, 5, STRUCT_TAG_ASCENT_LINK); + let sealed = |seed: &[u8; 32], carried: [u8; 32], epoch: u64| { + seal_ascent_link( + seed, + &[0x07; 32], + &ctx, + &OverrideSeedPayload::new(carried, epoch), + ) + .expect("seals") + }; + + verify_ascent_link( + &parent_node_seed, + &ctx, + &override_seed, + &sealed(&parent_node_seed, override_seed, ctx.epoch), + ) + .expect("the link a re-seal mints is the one an ancestor reader opens"); + + for link in [ + // Sealed to a keypair no ancestor of this node derives. + sealed(&[0x45; 32], override_seed, ctx.epoch), + // Carries a seed that does not derive this node's read key. + sealed(&parent_node_seed, [0x9a; 32], ctx.epoch), + // Carries an epoch the record does not publish at. + sealed(&parent_node_seed, override_seed, ctx.epoch + 1), + ] { + assert_eq!( + verify_ascent_link(&parent_node_seed, &ctx, &override_seed, &link), + Err(ResealError::AscentLinkMismatch), + ); + } + assert_eq!( + ResealError::AscentLinkMismatch.check(), + "ascent-link-mismatch" + ); + } + #[test] fn vault_root_omits_ascent_link() { let fx = Fixture::new(); From 58640afd7bb7ff4be63c0c0f1476847f41f12510 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 5 Aug 2026 12:51:40 +0200 Subject: [PATCH 2/3] refactor: name the authoring verdict mapper and correct the ascent-guard rationale Fold the /simplify pass: extract the AuthorError routing into a named author_verdict beside publish_verdict, drop the doc paragraph defending the commitment verify that left check_publishable, and merge the two near-identical gate-mirror publish tests into one table. Correct the ascent-link guard's doc: seal_ascent_link and open_ascent_link both derive the keypair through kdf::ascent_keypair, so the guard's value is that it takes its expected seed and epoch from ResealSeeds rather than from the payload under test. Its positive row now runs end-to-end through reseal_scope_root instead of a hand-sealed link. --- crates/engine/src/net/rotation.rs | 89 ++++++++++++---------------- crates/engine/src/rotation/reseal.rs | 69 ++++++++++++++------- 2 files changed, 85 insertions(+), 73 deletions(-) diff --git a/crates/engine/src/net/rotation.rs b/crates/engine/src/net/rotation.rs index e0d76edf8..5f03197a5 100644 --- a/crates/engine/src/net/rotation.rs +++ b/crates/engine/src/net/rotation.rs @@ -29,7 +29,7 @@ use cipherbox_core::suite::x25519::X25519Secret; use zeroize::Zeroizing; use super::adopter::RootAdopter; -use super::author::{ENVELOPE_V, EnvelopeAuthoring, author_scope_root_with_section}; +use super::author::{AuthorError, ENVELOPE_V, EnvelopeAuthoring, author_scope_root_with_section}; use super::publish::PublishOutcome; use super::record_publish::{HeadBinding, RecordPublishRequest, preflight, publish_record}; use crate::api::ApiClient; @@ -314,6 +314,21 @@ fn publish_verdict(failure: ResolveFailure) -> ScopeRootPublishError { } } +/// Carry an authoring refusal into the publish arm on the same rule-6 axis. A +/// trust refusal is this build's own gate verdict on the bytes it was about to +/// sign, reached before the PUT: re-authoring the same section reaches it again. +/// A codec or size refusal is a property of the body this pass built from the +/// record it just resolved, and the next pass resolves that record again — so it +/// stays retryable rather than permanently blocking a revocation over bytes a +/// later read may not carry. +fn author_verdict(refusal: AuthorError) -> ScopeRootPublishError { + if refusal.is_trust_refusal() { + ScopeRootPublishError::Rejected + } else { + ScopeRootPublishError::NotPublished + } +} + /// A fresh per-seal nonce from the injected entropy seam. fn nonce(entropy: &RefCell) -> Result<[u8; 24], ScopeRootPublishError> { let mut nonce = [0u8; 24]; @@ -444,9 +459,6 @@ where /// owner-write-blob's AAD binds — below it the root publishes write-plane /// dead, and floors are monotonic, so it can never be rotated back. /// - /// The stage-2/stage-3 mirrors on the record's own bytes run inside the - /// authoring call itself (`net/author.rs`). - /// /// Runs **after** this pass's gated read of the scope root, whose adoption /// advances the read-epoch floor: measured before it, the floor comparison /// would miss a cut minted below the epoch that read just adopted. @@ -592,16 +604,7 @@ where &record.section, self.keys.identity, ) - // A produce-side trust refusal is this build's own gate verdict on the - // bytes, reached before the PUT: re-authoring the same section reaches it - // again, so it must not be retried like a stalled endpoint (rule 6). - .map_err(|e| { - if e.is_trust_refusal() { - ScopeRootPublishError::Rejected - } else { - ScopeRootPublishError::NotPublished - } - })?; + .map_err(author_verdict)?; let write_scope_seed = current .write_scope_seed @@ -1275,44 +1278,30 @@ mod tests { .expect("the interior root's cut lands under its supplied ancestor seed"); } - /// Gate stage 3's encode-side mirror, and the verdict it carries: a section - /// whose structure signatures do not recompute is this build's own gate - /// verdict on bytes it is about to sign, so it is fatal — re-authoring the - /// same section reaches the same refusal, and retrying it forever would - /// launder a trust violation into an availability stall (rule 6). + /// The encode-side mirrors of gate stages 2 and 3, and the verdict they + /// carry: a section this build's own gate would reject is refused before the + /// record is signed, and fatally — re-authoring the same section reaches the + /// same refusal, so retrying it would launder a trust violation into an + /// availability stall (rule 6). #[test] fn a_section_the_gate_would_reject_is_a_verdict_not_a_stall() { - let (harness, root, mut cut) = staged_cut(); - cut.section.owner_blob.signature[0] ^= 0xff; - - let outcome = block_on(harness.net(&[]).publish_scope_root(&cut)); - assert_eq!(outcome, Err(ScopeRootPublishError::Rejected)); - assert!(!outcome.unwrap_err().is_retryable()); - let endpoint = &harness.store.endpoints()[0]; - assert_eq!( - harness.store.record_at(endpoint, root.name.as_str()), - Some(record_for(&SCOPE, &root.head_cid_str, 1)), - "the pre-rotation record still stands — nothing was published", - ); - } - - /// Gate stage 2's encode-side mirror: a commitment that will not verify - /// under the owner identity is refused before the record is signed. - #[test] - fn a_commitment_that_will_not_verify_is_never_signed() { - let (harness, root, mut cut) = staged_cut(); - cut.section.commitment_sig[0] ^= 0xff; - - assert_eq!( - block_on(harness.net(&[]).publish_scope_root(&cut)), - Err(ScopeRootPublishError::Rejected), - ); - let endpoint = &harness.store.endpoints()[0]; - assert_eq!( - harness.store.record_at(endpoint, root.name.as_str()), - Some(record_for(&SCOPE, &root.head_cid_str, 1)), - "the pre-rotation record still stands — nothing was published", - ); + for corrupt in [ + |cut: &mut ResealedScopeRoot| cut.section.commitment_sig[0] ^= 0xff, + |cut: &mut ResealedScopeRoot| cut.section.owner_blob.signature[0] ^= 0xff, + ] { + let (harness, root, mut cut) = staged_cut(); + corrupt(&mut cut); + + let outcome = block_on(harness.net(&[]).publish_scope_root(&cut)); + assert_eq!(outcome, Err(ScopeRootPublishError::Rejected)); + assert!(!outcome.unwrap_err().is_retryable()); + let endpoint = &harness.store.endpoints()[0]; + assert_eq!( + harness.store.record_at(endpoint, root.name.as_str()), + Some(record_for(&SCOPE, &root.head_cid_str, 1)), + "the pre-rotation record still stands — nothing was published", + ); + } } /// Gate stage 5's encode-side mirror: a plan built from a stale snapshot diff --git a/crates/engine/src/rotation/reseal.rs b/crates/engine/src/rotation/reseal.rs index 84e3b3439..86ed4e263 100644 --- a/crates/engine/src/rotation/reseal.rs +++ b/crates/engine/src/rotation/reseal.rs @@ -144,9 +144,9 @@ pub enum ResealError { /// A grant-ledger entry's recipient encryption key is unusable (malformed or /// low-order X25519). A grant can never be wrapped to an unopenable key. UnusableRecipientKey, - /// The freshly sealed ascent link does not reopen under the parent node seed - /// as this epoch's override seed — bytes every ancestor reader rejects - /// whole-record at the gate's stage 3 (fail-closed symmetry). + /// The freshly sealed ascent link does not reopen as this epoch's override + /// seed — bytes the gate's stage 3 rejects whole-record + /// ([`verify_ascent_link`]). AscentLinkMismatch, /// Entropy acquisition failed; no seal proceeds without fresh randomness. Entropy(EntropyError), @@ -488,12 +488,14 @@ pub fn reseal_scope_root( }) } -/// The produce-side half of the gate's ascent-link cross-check -/// (`gate/adoption.rs` stage 3), release-active per AGENTS.md rule 8: reopen the -/// link exactly as an ancestor reader does and confirm it yields this epoch's -/// override seed. `seal_ascent_link` and `open_ascent_link` derive the ascent -/// keypair independently, so nothing else stops a re-seal signing a link every -/// ancestor reader rejects whole-record — an unopenable interior scope root. +/// The produce-side half of the gate's stage-3 ascent-link cross-check +/// (`gate/adoption.rs`), release-active per AGENTS.md rule 8: reopen the link as +/// an ancestor reader does and confirm it carries the seed and epoch this +/// re-seal publishes at. The expected pair comes from [`ResealSeeds`], never +/// from the payload under test, so a link minted off the wrong local — the prior +/// epoch's seed, a stale epoch — fails here rather than at every ancestor +/// reader. Nothing downstream can catch it: the publisher holds no ancestor +/// seed, so the link stays unopenable until it reaches one. /// /// The gate compares the read key the recovered seed derives; comparing the seed /// is the same predicate one derivation earlier. @@ -801,15 +803,45 @@ mod tests { encode_grant_section(§ion).expect("section encodes"); } - /// The produce-side mirror of the gate's stage-3 ascent-link cross-check, - /// release-active (rule 8): the guard returns `Err`, so a `--release` build - /// refuses exactly the links a debug build does. Every row is a link an - /// ancestor reader rejects whole-record. + /// Release-active (rule 8): the guard returns `Err`, so a `--release` build + /// refuses exactly the links a debug build does. Every reject row is a link + /// an ancestor reader rejects whole-record. #[test] fn an_ascent_link_the_gate_would_reject_is_never_signed() { - let parent_node_seed = [0x44; 32]; + let fx = Fixture::new(); + let owner_pub = fx.owner_enc.public(); + let (commitment, sig, ledger) = fx.committed(); + let parent_node_seed = fx.parent_node_seed; let override_seed = [0x99; 32]; let ctx = ctx_for(V, SCOPE, 5, STRUCT_TAG_ASCENT_LINK); + + // The link a real re-seal mints passes its own guard. + let id = identity(&fx, &owner_pub, b"scope-root-name", Some(&parent_node_seed)); + let s = seeds( + &override_seed, + ctx.epoch, + None, + &fx.write_scope_seed, + &fx.pointer_read_key, + ); + let cs = committed_set(&commitment, &sig, &ledger); + let minted = reseal_scope_root(&mut SeededEntropy::new(13), &id, &s, &cs, &[]) + .expect("reseal") + .ascent_link + .expect("interior root has ascent"); + verify_ascent_link( + &parent_node_seed, + &ctx, + &override_seed, + &AscentLink { + ascent_public: minted.ascent_public, + enc: minted.enc, + ciphertext: minted.ciphertext, + unknown: PreservedFields::new(), + }, + ) + .expect("a minted link is the one an ancestor reader opens"); + let sealed = |seed: &[u8; 32], carried: [u8; 32], epoch: u64| { seal_ascent_link( seed, @@ -819,15 +851,6 @@ mod tests { ) .expect("seals") }; - - verify_ascent_link( - &parent_node_seed, - &ctx, - &override_seed, - &sealed(&parent_node_seed, override_seed, ctx.epoch), - ) - .expect("the link a re-seal mints is the one an ancestor reader opens"); - for link in [ // Sealed to a keypair no ancestor of this node derives. sealed(&[0x45; 32], override_seed, ctx.epoch), From d7acbfd236ea0cc1242324e34c8a063c5cb786ba Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 5 Aug 2026 13:05:58 +0200 Subject: [PATCH 3/3] test: pin the ascent guard's AAD binding and its agreement with the owner blob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the security and crypto-privacy passes. Both flagged that the guard's doc claimed coverage it does not have: seal and open derive the ascent keypair from the one parent_node_seed the re-seal is handed, so a mis-threaded ancestor seed passes. State that limit instead of overclaiming, and file the real check as a follow-up. Add the reject rows that survive a refactor of the guard's placement — three AAD transplants and a substituted ascent public half — and a test that the minted ascent link and owner blob carry one seed, which is the binding the publish arm relies on when it keys the record's read body off the owner blob. Also state honestly why a codec or size refusal stays retryable in author_verdict: a permanent verdict there would let anyone who can grow the record block the owner's revocation for good. --- crates/engine/src/net/rotation.rs | 15 ++-- crates/engine/src/rotation/reseal.rs | 104 +++++++++++++++++++++++---- 2 files changed, 99 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/net/rotation.rs b/crates/engine/src/net/rotation.rs index 5f03197a5..e626ac525 100644 --- a/crates/engine/src/net/rotation.rs +++ b/crates/engine/src/net/rotation.rs @@ -314,13 +314,14 @@ fn publish_verdict(failure: ResolveFailure) -> ScopeRootPublishError { } } -/// Carry an authoring refusal into the publish arm on the same rule-6 axis. A -/// trust refusal is this build's own gate verdict on the bytes it was about to -/// sign, reached before the PUT: re-authoring the same section reaches it again. -/// A codec or size refusal is a property of the body this pass built from the -/// record it just resolved, and the next pass resolves that record again — so it -/// stays retryable rather than permanently blocking a revocation over bytes a -/// later read may not carry. +/// Carry an authoring refusal into the publish arm on the same rule-6 axis as +/// [`publish_verdict`]. A trust refusal is this build's own gate verdict on the +/// bytes it was about to sign, reached before the PUT: re-authoring the same +/// section reaches it again, so retrying it would launder a trust violation into +/// an availability stall. A codec or size refusal is a property of the body this +/// pass built, and stays retryable: it is reached from a record the next pass +/// re-resolves, and a permanent verdict on it would let anyone who can grow that +/// record block the owner's revocation for good. fn author_verdict(refusal: AuthorError) -> ScopeRootPublishError { if refusal.is_trust_refusal() { ScopeRootPublishError::Rejected diff --git a/crates/engine/src/rotation/reseal.rs b/crates/engine/src/rotation/reseal.rs index 86ed4e263..30b2757e2 100644 --- a/crates/engine/src/rotation/reseal.rs +++ b/crates/engine/src/rotation/reseal.rs @@ -488,14 +488,17 @@ pub fn reseal_scope_root( }) } -/// The produce-side half of the gate's stage-3 ascent-link cross-check -/// (`gate/adoption.rs`), release-active per AGENTS.md rule 8: reopen the link as -/// an ancestor reader does and confirm it carries the seed and epoch this -/// re-seal publishes at. The expected pair comes from [`ResealSeeds`], never -/// from the payload under test, so a link minted off the wrong local — the prior -/// epoch's seed, a stale epoch — fails here rather than at every ancestor -/// reader. Nothing downstream can catch it: the publisher holds no ancestor -/// seed, so the link stays unopenable until it reaches one. +/// Reopen the freshly sealed ascent link as an ancestor reader does and refuse +/// unless it carries the seed and epoch this re-seal publishes at — the +/// release-active produce-side half of the gate's stage-3 predicate +/// (`gate/adoption.rs`, AGENTS.md rule 8). The expected pair comes from +/// [`ResealSeeds`], never from the payload under test, so the ascent arm cannot +/// drift from the seed and epoch the rest of the section is minted at. +/// +/// The mirror stops one axis short of the gate's: seal and open derive the +/// ascent keypair from the single `parent_node_seed` this re-seal was handed, so +/// a caller that threads the wrong ancestor seed passes here and is caught only +/// by a reader, which derives its own from cached state. /// /// The gate compares the read key the recovered seed derives; comparing the seed /// is the same predicate one derivation earlier. @@ -842,22 +845,46 @@ mod tests { ) .expect("a minted link is the one an ancestor reader opens"); - let sealed = |seed: &[u8; 32], carried: [u8; 32], epoch: u64| { + let sealed = |seed: &[u8; 32], carried: [u8; 32], epoch: u64, c: &AadContext| { seal_ascent_link( seed, &[0x07; 32], - &ctx, + c, &OverrideSeedPayload::new(carried, epoch), ) .expect("seals") }; + // A valid foreign public half with this link's own `enc`/ciphertext: the + // reader re-derives the public half, never trusts the carried one. + let mut foreign_public = sealed(&parent_node_seed, override_seed, ctx.epoch, &ctx); + foreign_public.ascent_public = X25519Secret::from_scalar([0x31; 32]).public().to_bytes(); for link in [ // Sealed to a keypair no ancestor of this node derives. - sealed(&[0x45; 32], override_seed, ctx.epoch), + sealed(&[0x45; 32], override_seed, ctx.epoch, &ctx), // Carries a seed that does not derive this node's read key. - sealed(&parent_node_seed, [0x9a; 32], ctx.epoch), + sealed(&parent_node_seed, [0x9a; 32], ctx.epoch, &ctx), // Carries an epoch the record does not publish at. - sealed(&parent_node_seed, override_seed, ctx.epoch + 1), + sealed(&parent_node_seed, override_seed, ctx.epoch + 1, &ctx), + // AAD transplants: the context is load-bearing, not decoration. + sealed( + &parent_node_seed, + override_seed, + ctx.epoch, + &ctx_for(V, [0xee; 16], ctx.epoch, STRUCT_TAG_ASCENT_LINK), + ), + sealed( + &parent_node_seed, + override_seed, + ctx.epoch, + &ctx_for(V, SCOPE, ctx.epoch, STRUCT_TAG_OWNER_BLOB), + ), + sealed( + &parent_node_seed, + override_seed, + ctx.epoch, + &ctx_for(V + 1, SCOPE, ctx.epoch, STRUCT_TAG_ASCENT_LINK), + ), + foreign_public, ] { assert_eq!( verify_ascent_link(&parent_node_seed, &ctx, &override_seed, &link), @@ -870,6 +897,57 @@ mod tests { ); } + /// The publish arm keys the record's read body off the seed it recovers from + /// the **owner blob** (`net/rotation.rs`), while an ancestor reader derives + /// its expected read key from the **ascent link**. A section whose two + /// structures disagreed would publish a root its own ancestors reject, so the + /// agreement is asserted on `reseal_scope_root`'s output, not assumed. + #[test] + fn the_ascent_link_and_the_owner_blob_carry_one_seed() { + let fx = Fixture::new(); + let owner_pub = fx.owner_enc.public(); + let (commitment, sig, ledger) = fx.committed(); + let override_seed = [0x99; 32]; + let id = identity( + &fx, + &owner_pub, + b"scope-root-name", + Some(&fx.parent_node_seed), + ); + let s = seeds( + &override_seed, + 5, + None, + &fx.write_scope_seed, + &fx.pointer_read_key, + ); + let cs = committed_set(&commitment, &sig, &ledger); + let section = + reseal_scope_root(&mut SeededEntropy::new(17), &id, &s, &cs, &[]).expect("reseal"); + + let owner = open_owner_blob( + &fx.owner_enc, + §ion.owner_blob.enc, + &ctx_for(V, SCOPE, 5, STRUCT_TAG_OWNER_BLOB), + §ion.owner_blob.ciphertext, + ) + .expect("owner opens its blob"); + let ascent = section.ascent_link.expect("interior root has ascent"); + let recovered = open_ascent_link( + &fx.parent_node_seed, + &ctx_for(V, SCOPE, 5, STRUCT_TAG_ASCENT_LINK), + &AscentLink { + ascent_public: ascent.ascent_public, + enc: ascent.enc, + ciphertext: ascent.ciphertext, + unknown: PreservedFields::new(), + }, + ) + .expect("an ancestor opens the link"); + assert!(ct_eq(recovered.override_seed(), owner.override_seed())); + assert_eq!(recovered.epoch, owner.epoch); + } + #[test] fn vault_root_omits_ascent_link() { let fx = Fixture::new();