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..e626ac525 100644 --- a/crates/engine/src/net/rotation.rs +++ b/crates/engine/src/net/rotation.rs @@ -20,16 +20,16 @@ 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; 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,22 @@ fn publish_verdict(failure: ResolveFailure) -> ScopeRootPublishError { } } +/// 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 + } 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]; @@ -435,11 +451,10 @@ 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 @@ -452,12 +467,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 +586,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 +605,12 @@ where &record.section, self.keys.identity, ) - .map_err(|_| ScopeRootPublishError::NotPublished)?; + .map_err(author_verdict)?; + + let write_scope_seed = current + .write_scope_seed + .as_deref() + .ok_or(ScopeRootPublishError::NotPublished)?; let binding = HeadBinding { node_id: record.scope_id, @@ -1269,23 +1279,30 @@ mod tests { .expect("the interior root's cut lands under its supplied ancestor seed"); } - /// Gate stage 2's encode-side mirror: a commitment that will not verify - /// under the owner identity is refused before the record is signed. + /// 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_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", - ); + fn a_section_the_gate_would_reject_is_a_verdict_not_a_stall() { + 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 ecd1c6298..30b2757e2 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 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), /// 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,34 @@ pub fn reseal_scope_root( }) } +/// 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. +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 +806,148 @@ mod tests { encode_grant_section(§ion).expect("section encodes"); } + /// 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 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, c: &AadContext| { + seal_ascent_link( + seed, + &[0x07; 32], + 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, &ctx), + // Carries a seed that does not derive this node's read key. + 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, &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), + Err(ResealError::AscentLinkMismatch), + ); + } + assert_eq!( + ResealError::AscentLinkMismatch.check(), + "ascent-link-mismatch" + ); + } + + /// 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();