Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/engine/src/net/author.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
85 changes: 51 additions & 34 deletions crates/engine/src/net/rotation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<E: Entropy>(entropy: &RefCell<E>) -> Result<[u8; 24], ScopeRootPublishError> {
let mut nonce = [0u8; 24];
Expand Down Expand Up @@ -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
Expand All @@ -452,12 +467,6 @@ where
&self,
record: &ResealedScopeRoot,
) -> Result<(), ScopeRootPublishError> {
let section = &record.section;
let signature = EcdsaSignature::from_compact(&section.commitment_sig)
.ok_or(ScopeRootPublishError::Rejected)?;
verify_grant_set(self.keys.identity, &section.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)
Expand Down Expand Up @@ -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());
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
187 changes: 183 additions & 4 deletions crates/engine/src/rotation/reseal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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",
Expand Down Expand Up @@ -391,6 +399,7 @@ pub fn reseal_scope_root<E: Entropy>(
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,
Expand Down Expand Up @@ -479,6 +488,34 @@ pub fn reseal_scope_root<E: Entropy>(
})
}

/// 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(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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 {
Expand Down Expand Up @@ -769,6 +806,148 @@ mod tests {
encode_grant_section(&section).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,
&section.owner_blob.enc,
&ctx_for(V, SCOPE, 5, STRUCT_TAG_OWNER_BLOB),
&section.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();
Expand Down