diff --git a/crates/engine/src/facade.rs b/crates/engine/src/facade.rs index 9a02e6d8b..cb2a10afd 100644 --- a/crates/engine/src/facade.rs +++ b/crates/engine/src/facade.rs @@ -24,7 +24,7 @@ use std::rc::Rc; use cipherbox_core::content::encode_content_cid_str; use cipherbox_core::error::CodecError; use cipherbox_core::ipns::IpnsName; -use cipherbox_core::seal::{ReadBody, Version, seal_content_key}; +use cipherbox_core::seal::{ChildScopeRef, ReadBody, Version, seal_content_key}; use cipherbox_core::suite::ecdsa::EcdsaVerifier; use cipherbox_core::suite::x25519::X25519Secret; use futures_channel::mpsc; @@ -40,19 +40,24 @@ use crate::content::{ }; use crate::entropy::{Entropy, SharedEntropy}; use crate::gate::{GateError, floor}; -use crate::grants::{Contact, ContactStore, ContactStoreError, StagingContactStore}; +use crate::grants::{ + Contact, ContactStore, ContactStoreError, InviteError, InviteMintError, InviteMintPlan, + InviteStoreError, MintedInviteLink, OwnerAuthority, StagingContactStore, StagingInviteStore, + mint_invite_link, +}; use crate::net::record_publish::RecordPublishError; use crate::net::retire::{OrphanHeads, retire}; +use crate::net::rotation::{GatedRoots, RotationAncestry, SweptScopeState}; use crate::net::{ Adopter, ChildAdopter, ChildResolveError, EolRenewResult, FolderRefresh, HeldMaterial, - HeldRecord, HeldRecords, LivenessControl, PublishError, PublishOutcome, RE_PUT_INTERVAL, - RecordPointerFetch, ResolveOutcome, RootAdopter, VaultProvisionNet, eol_renew_pass, - fanout_get_verify, keyless_re_put, refresh_base_from_outcome, resolve_and_hold, resolve_child, - run_liveness_loop, + HeldRecord, HeldRecords, LivenessControl, OwnerRotationKeys, OwnerRotationNet, PublishError, + PublishOutcome, RE_PUT_INTERVAL, RecordPointerFetch, ResolveOutcome, RootAdopter, + VaultProvisionNet, eol_renew_pass, fanout_get_verify, keyless_re_put, + refresh_base_from_outcome, resolve_and_hold, resolve_child, run_liveness_loop, }; use crate::owner_keys::OwnerSessionKeys; use crate::profile::SyncTimingProfile; -use crate::rotation::derive_write_name; +use crate::rotation::{ResealError, ResolveFailure, derive_write_name}; use crate::seams::{ FloorStore, OpId, RecordTransport, Scheduler, SeamError, SeamResult, SeamSet, SeamTypes, StagingStore, UnixMillis, @@ -339,6 +344,15 @@ pub enum Permission { Write, } +impl From for cipherbox_core::seal::Permission { + fn from(permission: Permission) -> Self { + match permission { + Permission::Read => Self::Read, + Permission::Write => Self::Write, + } + } +} + /// The staleness ladder (#33 D4): fresh → reconciling → stale → offline. /// Availability staleness keeps cached views usable indefinitely; trust /// violations are never staleness. @@ -543,8 +557,19 @@ pub enum Command { recipient_identity_public_key: Vec, }, /// Mint an invite link for a node (#25 D6). The returned URL fragment - /// carries the ephemeral secret; response payloads land with the grants - /// slice. + /// carries the ephemeral secret. `node` must be a scope root — this build + /// invites at the vault root only, since inviting to a folder below it + /// mints that folder's scope first. + /// + /// What a host must tell a user before it shows the link: a read grant + /// mints a fresh scope at epoch 1, but an invite adds a row to the scope + /// root's existing set, so the bearer gets that scope's current seed **and + /// the retained history links that walk back from it** — every epoch the + /// owner has cut, including cuts made to revoke someone. It carries no + /// deadline, and only a write rotation ends a write link + /// ([`LinkCapability::BearerWrite`](crate::grants::LinkCapability)). + /// + /// [`LinkCapability`]: crate::grants::LinkCapability CreateInviteLink { /// Node to invite to. node: NodeId, @@ -637,6 +662,11 @@ pub enum CommandOutcome { /// [`Command::ImportContact`] verified a contact code. Holding the /// [`Contact`] is itself the proof its binding signature verified. ContactImported(Contact), + /// [`Command::CreateInviteLink`] minted a link: recorded, published, and + /// only then handed out. The payload is the bearer capability itself + /// ([`MintedInviteLink`]) — a host puts it in a URL fragment and nowhere + /// durable. + InviteLinkMinted(MintedInviteLink), } impl fmt::Debug for CommandOutcome { @@ -645,6 +675,7 @@ impl fmt::Debug for CommandOutcome { CommandOutcome::Done => f.write_str("CommandOutcome(done)"), CommandOutcome::Queued { op_id } => write!(f, "CommandOutcome(queued {})", op_id.0), CommandOutcome::ContactImported(_) => f.write_str("CommandOutcome(contactImported)"), + CommandOutcome::InviteLinkMinted(_) => f.write_str("CommandOutcome(inviteLinkMinted)"), } } } @@ -936,6 +967,15 @@ pub enum EngineError { /// Diagnostic message; never carries key material. message: String, }, + /// A command named a node this build cannot act on. Neither a refusal of + /// the bytes that named it ([`MalformedInput`](EngineError::MalformedInput)) + /// nor of the whole command ([`Unimplemented`](EngineError::Unimplemented)): + /// the command is wired and the node is well-formed, but the rule named + /// here rules that node out as its target. + UnsupportedTarget { + /// The rule that refused; never key material. + check: &'static str, + }, } impl EngineError { @@ -958,6 +998,60 @@ impl EngineError { } } + /// Map an invite-mint failure on the classes a host acts on: availability + /// it may retry, an input or a bound it can change, and a fail-closed + /// refusal it must never retry (rule 6). + fn from_invite_mint(err: InviteMintError) -> Self { + let refused = |check: &'static str| EngineError::MalformedInput { check }; + match err { + InviteMintError::Mint(InviteError::Entropy(e)) + | InviteMintError::Store(InviteStoreError::Entropy(e)) + | InviteMintError::Reseal(ResealError::Entropy(e)) => EngineError::from_entropy(e), + // The vault root's own commitment is not signed by this session's + // identity key, or the ledger it carries diverges from it: verdicts + // on the record, never on the request. + e @ InviteMintError::Mint(InviteError::NotOwner | InviteError::Authority(_)) => { + EngineError::TrustViolation { + message: e.to_string(), + } + } + InviteMintError::Mint(e) => refused(e.check()), + InviteMintError::Sign(e) => refused(e.check()), + // Only a mint's own link can overflow the set it offers, and the + // host acts on it by revoking a live one. + InviteMintError::Store(InviteStoreError::Full { .. }) => refused("invite-records-full"), + InviteMintError::Store(InviteStoreError::Encode(_)) => { + refused("invite-records-unstorable") + } + InviteMintError::Store(InviteStoreError::Seal(e)) => refused(e.check()), + InviteMintError::Store(InviteStoreError::Seam(e)) => EngineError::Seam { + message: e.message().to_owned(), + }, + InviteMintError::Publish(e) if e.is_retryable() => EngineError::Seam { + message: e.to_string(), + }, + // A stored set that will not open, a re-seal this build refuses to + // sign, a root that is not the vault root, and a rejected publish + // are all fail-closed verdicts on the owner's own state. + other => EngineError::TrustViolation { + message: other.to_string(), + }, + } + } + + /// Map the gated read a mint runs first: a rejection is a fail-closed trust + /// verdict, and every other verdict is availability. + fn from_resolve_failure(err: ResolveFailure) -> Self { + match err { + ResolveFailure::Rejected => EngineError::TrustViolation { + message: err.to_string(), + }, + _ => EngineError::Seam { + message: err.to_string(), + }, + } + } + /// Map a child-pipeline gate error: a rejection is a fail-closed trust /// verdict; a seam failure is availability. fn from_gate(err: GateError) -> Self { @@ -1115,6 +1209,9 @@ impl fmt::Display for EngineError { write!(f, "content key seal failed: [{check}]") } EngineError::RefreshFailed { message } => write!(f, "refresh failed: {message}"), + EngineError::UnsupportedTarget { check } => { + write!(f, "unsupported target: {check}") + } EngineError::Seam { message } => write!(f, "seam error: {message}"), EngineError::Entropy { message } => write!(f, "entropy error: {message}"), EngineError::Auth { message } => write!(f, "auth error: {message}"), @@ -2759,6 +2856,10 @@ where { }, }) } + Command::CreateInviteLink { node, permission } => self + .create_invite_link(node, permission) + .await + .map(CommandOutcome::InviteLinkMinted), Command::ManualRefresh => self.manual_refresh().await.map(|()| CommandOutcome::Done), Command::SaveVaultSettings { settings } => { self.save_vault_settings(&settings).await?; @@ -2777,6 +2878,87 @@ where { } } + /// Mint an invite link over the vault root's scope: record it, publish the + /// row into the owner-signed commitment, and only then hand the bearer + /// capability back ([`mint_invite_link`]). + /// + /// The engine holds no node-to-scope mapping, so a node below the root + /// names no scope root this can invite to — a grant there mints the scope + /// first, which is the grant-creation arm's job. + async fn create_invite_link( + &self, + node: NodeId, + permission: Permission, + ) -> Result { + let session = self.session.as_ref().ok_or(EngineError::NotStarted)?; + let api = self.api.as_ref().ok_or(EngineError::NotStarted)?; + let scope_id = self.snapshot.borrow().root.0; + if node.0 != scope_id { + return Err(EngineError::UnsupportedTarget { + check: "invite-target-is-not-a-scope-root", + }); + } + let write_scope_seed = cached_seed(&self.scope_write_seeds, &scope_id).ok_or( + EngineError::ContentUnavailable { + message: "no write scope seed is held for the vault root".to_owned(), + }, + )?; + let scope = ChildScopeRef::new( + scope_id, + derive_write_name(&write_scope_seed, &scope_id) + .as_str() + .as_bytes() + .to_vec(), + ); + let owner_identity = session.owner_identity(); + let scope_keys = OwnerSessionKeys::new(session); + let net = OwnerRotationNet { + transport: &self.seams.record_transport, + api: api.as_ref(), + gateway: &self.gateway, + http: &self.seams.http, + floors: &self.seams.floor_store, + scheduler: &self.seams.scheduler, + profile: &self.profile, + entropy: &self.entropy, + keys: OwnerRotationKeys { + enc_secret: session.enc_subkey(), + identity: &owner_identity, + scope_keys: &scope_keys, + }, + ancestry: RotationAncestry::default(), + owner_pointer_seed: None, + payload_version: POINTER_PAYLOAD_VERSION, + gated: GatedRoots::default(), + swept: SweptScopeState::default(), + }; + let current = net + .resolve_vault_root(&scope) + .await + .map_err(EngineError::from_resolve_failure)?; + mint_invite_link( + &OwnerAuthority { + identity_signer: session.identity(), + enc_secret: session.enc_subkey(), + }, + &net, + &StagingInviteStore::new( + &self.seams.staging_store, + session.enc_subkey(), + &self.entropy, + ), + &self.entropy, + &InviteMintPlan { + scope: &scope, + current: ¤t, + permission: permission.into(), + expires_at: None, + }, + ) + .await + .map_err(EngineError::from_invite_mint) + } + /// Seal and publish the vault settings record, then adopt what it /// published: the renewal enrolment [`publish_settings`] states the need /// for, and the placement this session writes under. diff --git a/crates/engine/src/grants/create.rs b/crates/engine/src/grants/create.rs index e7301eda7..e3055aaf6 100644 --- a/crates/engine/src/grants/create.rs +++ b/crates/engine/src/grants/create.rs @@ -883,6 +883,8 @@ mod tests { write_history_link: Vec::new(), direct_child_scope_index: Vec::new(), carried_history_links: Vec::new(), + // Every scope this resolver reaches is a descendant. + carried_ascent_link: true, }) } } diff --git a/crates/engine/src/grants/invite.rs b/crates/engine/src/grants/invite.rs index 1ff972a09..6462ea286 100644 --- a/crates/engine/src/grants/invite.rs +++ b/crates/engine/src/grants/invite.rs @@ -496,7 +496,7 @@ impl OwnerAuthority<'_> { /// Fail closed unless this caller's identity key signed the committed set it /// is about to change. Every commitment change is owner-only, and /// `mint_invite_grant` gets that from its arguments where these two do not. - fn authorise(&self, scope: &CommittedScope<'_>) -> Result<(), InviteError> { + pub(super) fn authorise(&self, scope: &CommittedScope<'_>) -> Result<(), InviteError> { verify_grant_set( &self.identity_signer.verifying_key(), scope.commitment, @@ -784,7 +784,7 @@ pub fn revoke_invite_link( /// rejects the first two at decode and before signing; the third is the adoption /// gate's owner-authority check). Release-active, so no build can emit a set its /// own readers refuse. -fn check_publishable( +pub(super) fn check_publishable( commitment: &GrantSetCommitment, ledger: &[GrantLedgerEntry], ) -> Result<(), InviteError> { diff --git a/crates/engine/src/grants/invite_mint.rs b/crates/engine/src/grants/invite_mint.rs new file mode 100644 index 000000000..4f514b55b --- /dev/null +++ b/crates/engine/src/grants/invite_mint.rs @@ -0,0 +1,628 @@ +//! The owner-side mint of an invite link, end to end (blueprint/engine.md +//! "Grants and ledger: Invites"). +//! +//! [`mint_invite_grant`] produces a row and a record; neither is worth anything +//! alone, so this composes the three effects one mint needs and hands the +//! bearer capability back only after all three land. +//! +//! Recording before publishing is the ack-after-durable rule the accept flow +//! already follows ([`ConvertedClaim::record`](super::ConvertedClaim::record)): +//! a committed entry no record names is authority no +//! [`revoke_invite_link`](super::revoke_invite_link) call can cut +//! (`invite_store.rs` header), while a record whose row never published is +//! inert — conversion refuses it as uncommitted. + +use core::cell::RefCell; +use core::fmt; + +use cipherbox_core::error::CodecError; +use cipherbox_core::seal::{ + ChildScopeRef, GrantLedgerEntry, GrantSetCommitment, Permission, sign_grant_set, +}; +use cipherbox_core::suite::contact::ContactCode; +use cipherbox_core::suite::ecdsa::EcdsaSignature; +use cipherbox_core::suite::secret::SecretBytes; + +use crate::entropy::Entropy; +use crate::rotation::{ + CascadeTarget, CommittedSet, ResealError, ResealSeeds, ResealedScopeRoot, ScopeRootIdentity, + ScopeRootPublishError, ScopeRootPublisher, WriteHistory, reseal_scope_root, +}; +use crate::seams::UnixMillis; + +use super::invite::{ + CommittedScope, EphemeralInvitee, InviteError, LinkCapability, check_publishable, + mint_invite_grant, +}; +use super::invite_store::{InviteStore, InviteStoreError}; +use super::{GrantRow, OwnerAuthority}; + +/// What one mint needs beyond the owner's own key material: which scope root +/// the link grants on, that root's current gate-passing state, and the terms. +pub struct InviteMintPlan<'a> { + /// The scope root's id and opaque `ipnsName`. + pub scope: &'a ChildScopeRef, + /// The scope root's current re-seal material, from a gated read that has + /// parked its republish base with the publisher. A root carrying an ascent + /// link is refused ([`InviteMintError::NotAVaultRoot`]). + pub current: &'a CascadeTarget, + /// Read or write. A write link hands out an extractable subtree signing key + /// ([`LinkCapability::BearerWrite`]). + pub permission: Permission, + /// The link's deadline, or `None` for a link that never expires. The + /// recorded copy is the authority for it + /// ([`RecordedInvite::expires_at`](super::RecordedInvite::expires_at)). + pub expires_at: Option, +} + +/// A minted link as the host must present it: the bearer capability, the owner +/// bundle a claimant seals its claim to, and what the link hands out. +/// +/// The URL fragment carries the invite secret and the owner's contact bundle +/// (blueprint/engine.md "Invites"); assembling the URL is the host's, since the +/// engine knows no origin. +#[derive(Clone, PartialEq, Eq)] +pub struct MintedInviteLink { + /// The invite secret the fragment carries — **the whole capability**. + pub invite_secret: SecretBytes, + /// The owner's contact code, which a claimant seals its claim to. + pub owner_contact_code: Vec, + /// The scope root's opaque `ipnsName`, which a claim names. + pub scope_root_name: Vec, + /// What the link hands out. + pub capability: LinkCapability, +} + +impl fmt::Debug for MintedInviteLink { + /// Hand-written like [`Command`](crate::facade::Command)'s: the secret is + /// the capability, and a derived `{:?}` would put it in host logs. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("MintedInviteLink(..)") + } +} + +/// A fail-closed mint failure. On every variant the host is handed no +/// capability. +#[derive(Debug)] +pub enum InviteMintError { + /// The scope root carries an ascent link, so it is a descendant of some + /// parent scope. Re-sealing it here would drop that link and orphan the + /// subtree from every later gated descent, so it is refused rather than + /// re-sealed without one. + NotAVaultRoot, + /// The committed set names a different scope root than the one this mint + /// publishes at. The blinded tag binds the publish name and the commitment + /// binds its own, so re-signing across the two would commit rows no reader + /// re-derives at the name it resolved. The gate pins them equal on a read; + /// this refuses release-active rather than trusting that it ran + /// (AGENTS.md rule 8). + ScopeNameMismatch, + /// Minting the row failed, or the extended set is not one this build may + /// publish (grant-set ceiling, duplicate tag, divergent ledger). + Mint(InviteError), + /// The extended commitment could not be encoded for signing. + Sign(CodecError), + /// Re-sealing the scope root failed — nothing was recorded or published. + Reseal(ResealError), + /// The link could not be recorded durably. The row is unpublished, so the + /// link exists nowhere. + Store(InviteStoreError), + /// The re-sealed scope root did not land. The link is recorded and inert — + /// no commitment carries its tag, so no claim converts against it — and + /// stays recorded: dropping it would risk forgetting a row that landed + /// after all, which is the one state nothing can revoke. Until a prune + /// path exists, each failed publish spends a slot toward + /// [`MAX_INVITE_RECORDS`](super::MAX_INVITE_RECORDS). + Publish(ScopeRootPublishError), +} + +impl fmt::Display for InviteMintError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + InviteMintError::NotAVaultRoot => f.write_str("the scope root carries an ascent link"), + InviteMintError::ScopeNameMismatch => { + f.write_str("the committed set names another scope root") + } + InviteMintError::Sign(e) => write!(f, "commitment encode failed: {}", e.check()), + InviteMintError::Reseal(e) => write!(f, "scope root re-seal failed: {e}"), + InviteMintError::Mint(e) => write!(f, "{e}"), + InviteMintError::Store(e) => write!(f, "{e}"), + InviteMintError::Publish(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for InviteMintError {} + +/// Mint one invite link on the vault root: record it, publish its row, and hand +/// back the bearer capability. +/// +/// Owner-only, on the same rule as +/// [`revoke_invite_link`](super::revoke_invite_link): the caller's identity key +/// must have signed the set it is about to extend, or nothing is minted. +pub async fn mint_invite_link( + owner: &OwnerAuthority<'_>, + publisher: &P, + store: &S, + entropy: &RefCell, + plan: &InviteMintPlan<'_>, +) -> Result { + let current = plan.current; + if current.carried_ascent_link { + return Err(InviteMintError::NotAVaultRoot); + } + if current.commitment.ipns_name != plan.scope.ipns_name { + return Err(InviteMintError::ScopeNameMismatch); + } + let commitment_sig = EcdsaSignature::from_compact(¤t.commitment_sig) + .ok_or(InviteMintError::Mint(InviteError::NotOwner))?; + owner + .authorise(&CommittedScope { + scope_id: &plan.scope.scope_id, + commitment: ¤t.commitment, + commitment_sig: &commitment_sig, + ledger: ¤t.grant_ledger, + }) + .map_err(InviteMintError::Mint)?; + + let invitee = + EphemeralInvitee::mint(&mut *entropy.borrow_mut()).map_err(InviteMintError::Mint)?; + let minted = mint_invite_grant( + owner.enc_secret, + &invitee, + &plan.scope.scope_id, + ¤t.write_scope_seed, + plan.permission, + plan.expires_at, + ) + .map_err(InviteMintError::Mint)?; + + let (commitment, ledger) = extend(current, &minted.row)?; + let extended_sig = + sign_grant_set(owner.identity_signer, &commitment).map_err(InviteMintError::Sign)?; + let section = reseal_scope_root( + &mut *entropy.borrow_mut(), + &ScopeRootIdentity { + v: current.v, + scope_id: plan.scope.scope_id, + ipns_name: &plan.scope.ipns_name, + owner_enc_pub: ¤t.owner_enc_pub, + owner_enc_secret: Some(owner.enc_secret), + parent_node_seed: None, + owes_ascent_link: current.carried_ascent_link, + pseudonym_signer: ¤t.pseudonym_signer, + }, + &ResealSeeds { + override_seed: ¤t.override_seed, + read_epoch: current.current_read_epoch, + prev: None, + write_scope_seed: ¤t.write_scope_seed, + write_epoch: current.write_epoch, + write_history: WriteHistory::Carried(¤t.write_history_link), + pointer_read_key: ¤t.pointer_read_key, + }, + &CommittedSet { + commitment: &commitment, + commitment_sig: &extended_sig.to_compact(), + grant_ledger: &ledger, + direct_child_scope_index: ¤t.direct_child_scope_index, + }, + ¤t.carried_history_links, + ) + .map_err(InviteMintError::Reseal)?; + + let resealed = ResealedScopeRoot { + scope_id: plan.scope.scope_id, + ipns_name: plan.scope.ipns_name.clone(), + read_epoch: current.current_read_epoch, + write_epoch: current.write_epoch, + section, + }; + + // Whole-set replacement, so the load is what keeps the links already + // recorded. + let mut records = store.load().await.map_err(InviteMintError::Store)?; + records.links.push(minted.link); + store + .persist(&records) + .await + .map_err(InviteMintError::Store)?; + + publisher + .publish_scope_root(&resealed) + .await + .map_err(InviteMintError::Publish)?; + + Ok(MintedInviteLink { + invite_secret: invitee.secret().clone(), + owner_contact_code: ContactCode::create(owner.identity_signer, owner.enc_secret.public()) + .encode(), + scope_root_name: resealed.ipns_name, + capability: minted.capability, + }) +} + +/// The committed set with the link's row in it, refused release-active on every +/// invariant a resolver hard-rejects (AGENTS.md rule 8). +fn extend( + current: &CascadeTarget, + row: &GrantRow, +) -> Result<(GrantSetCommitment, Vec), InviteMintError> { + let mut commitment = current.commitment.clone(); + commitment.entries.push(row.commitment_entry.clone()); + let mut ledger = current.grant_ledger.clone(); + ledger.push(row.ledger_entry.clone()); + check_publishable(&commitment, &ledger).map_err(InviteMintError::Mint)?; + Ok((commitment, ledger)) +} + +#[cfg(test)] +mod tests { + use super::*; + use cipherbox_core::kdf; + use cipherbox_core::seal::PreservedFields; + use cipherbox_core::suite::ecdsa::EcdsaSigner; + use cipherbox_core::suite::secret::SECRET_LEN; + use cipherbox_core::suite::x25519::{X25519Public, X25519Secret}; + use zeroize::Zeroizing; + + use crate::grants::{ + RecordedInvite, StagingInviteStore, mint_grant_row, recipient_blinded_tag, + }; + use crate::rotation::derive_write_name; + use crate::testkit::fakes::InMemoryStagingStore; + use crate::testkit::{SeededEntropy, block_on}; + + const OWNER_SECRET: [u8; SECRET_LEN] = [0x21; SECRET_LEN]; + const IMPOSTOR_SECRET: [u8; SECRET_LEN] = [0x31; SECRET_LEN]; + const GRANTEE_SECRET: [u8; SECRET_LEN] = [0x41; SECRET_LEN]; + const SCOPE_ID: [u8; 16] = [0x33; 16]; + const WRITE_SCOPE_SEED: [u8; SECRET_LEN] = [0x44; SECRET_LEN]; + const OVERRIDE_SEED: [u8; SECRET_LEN] = [0x55; SECRET_LEN]; + const POINTER_READ_KEY: [u8; SECRET_LEN] = [0x66; SECRET_LEN]; + const PSEUDONYM_SEED: [u8; SECRET_LEN] = [0x77; SECRET_LEN]; + const SEED: u64 = 9; + + fn signer(secret: &[u8; SECRET_LEN]) -> EcdsaSigner { + EcdsaSigner::from_scalar(secret).expect("valid scalar") + } + + #[derive(Default)] + struct FakePublisher { + published: RefCell>, + refuse: bool, + } + + impl ScopeRootPublisher for FakePublisher { + async fn publish_scope_root( + &self, + record: &ResealedScopeRoot, + ) -> Result<(), ScopeRootPublishError> { + if self.refuse { + return Err(ScopeRootPublishError::NotPublished); + } + self.published.borrow_mut().push(record.clone()); + Ok(()) + } + } + + /// One owner, one vault root as a gated read would hand it over, and the + /// durable backing its records land in. + struct Fixture { + owner: EcdsaSigner, + enc: X25519Secret, + scope: ChildScopeRef, + current: CascadeTarget, + staging: InMemoryStagingStore, + entropy: RefCell, + publisher: FakePublisher, + } + + impl Fixture { + /// A vault root with one grantee already committed — the shape that + /// makes the re-seal's per-row checks non-vacuous. + fn with_a_grantee() -> (Self, [u8; 32]) { + let mut f = Self::new(); + let grantee = signer(&GRANTEE_SECRET); + let row = mint_grant_row( + &f.enc, + grantee.verifying_key().to_sec1(), + &kdf::enc_subkey(&GRANTEE_SECRET).public(), + &SCOPE_ID, + &f.scope.ipns_name, + Permission::Read, + ) + .expect("usable grantee key"); + let tag = row.tag; + f.current.commitment.entries.push(row.commitment_entry); + f.current.grant_ledger.push(row.ledger_entry); + f.current.commitment_sig = sign_grant_set(&f.owner, &f.current.commitment) + .expect("signs") + .to_compact(); + (f, tag) + } + + fn new() -> Self { + let owner = signer(&OWNER_SECRET); + let scope = ChildScopeRef::new( + SCOPE_ID, + derive_write_name(&WRITE_SCOPE_SEED, &SCOPE_ID) + .as_str() + .as_bytes() + .to_vec(), + ); + let pseudonym_signer = kdf::pseudonym_sign(&PSEUDONYM_SEED, &SCOPE_ID); + let enc = kdf::enc_subkey(&OWNER_SECRET); + let commitment = GrantSetCommitment { + ipns_name: scope.ipns_name.clone(), + owner_pseudonym_pk: pseudonym_signer.verifying_key().to_bytes(), + entries: Vec::new(), + unknown: PreservedFields::default(), + }; + let commitment_sig = sign_grant_set(&owner, &commitment).expect("signs"); + Self { + enc: enc.clone(), + current: CascadeTarget { + v: 1, + current_read_epoch: 1, + owner_enc_pub: enc.public(), + pseudonym_signer, + override_seed: Zeroizing::new(OVERRIDE_SEED), + write_scope_seed: Zeroizing::new(WRITE_SCOPE_SEED), + pointer_read_key: Zeroizing::new(POINTER_READ_KEY), + write_epoch: 1, + commitment, + commitment_sig: commitment_sig.to_compact(), + grant_ledger: Vec::new(), + write_history_link: Vec::new(), + direct_child_scope_index: Vec::new(), + carried_history_links: Vec::new(), + carried_ascent_link: false, + }, + owner, + scope, + staging: InMemoryStagingStore::default(), + entropy: RefCell::new(SeededEntropy::new(SEED)), + publisher: FakePublisher::default(), + } + } + + fn store(&self) -> StagingInviteStore<'_, InMemoryStagingStore, SeededEntropy> { + StagingInviteStore::new(&self.staging, &self.enc, &self.entropy) + } + + fn authority(&self) -> OwnerAuthority<'_> { + OwnerAuthority { + identity_signer: &self.owner, + enc_secret: &self.enc, + } + } + + fn plan(&self, permission: Permission) -> InviteMintPlan<'_> { + InviteMintPlan { + scope: &self.scope, + current: &self.current, + permission, + expires_at: None, + } + } + + fn mint(&self, permission: Permission) -> Result { + self.mint_as(&self.authority(), permission) + } + + fn mint_as( + &self, + owner: &OwnerAuthority<'_>, + permission: Permission, + ) -> Result { + block_on(mint_invite_link( + owner, + &self.publisher, + &self.store(), + &self.entropy, + &self.plan(permission), + )) + } + + /// The links a later session recovers: a fresh handle over the same + /// durable backing. + fn recovered(&self) -> Vec { + block_on(self.store().load()) + .expect("the records load") + .links + } + } + + /// The whole point of the slice: what the mint hands out is claimable, and + /// what a later session recovers is the record the mint made. + #[test] + fn a_minted_link_is_recorded_and_its_row_published() { + let f = Fixture::new(); + + let link = f.mint(Permission::Read).expect("the mint lands"); + + let [record] = f.recovered()[..] else { + panic!("one link was minted"); + }; + let invitee = + EphemeralInvitee::from_secret(link.invite_secret.as_bytes()).expect("valid secret"); + assert_eq!( + record.ephemeral_identity_pk, + invitee.identity_pk().to_sec1(), + "the recovered record answers to the fragment holder's identity", + ); + assert_eq!(record.expires_at, None); + assert_eq!(link.capability, LinkCapability::Read); + assert_eq!(link.scope_root_name, f.scope.ipns_name); + + let published = f.publisher.published.borrow(); + let [root] = &published[..] else { + panic!("one scope root was published"); + }; + // The tag conversion re-derives from the record it recovered + // (`convert_invite_claim`), so this is the entry a claim reads its + // permission out of. + assert_eq!( + recipient_blinded_tag( + &f.enc, + &X25519Public::from_bytes(record.ephemeral_enc_pk).expect("valid key"), + &f.scope.ipns_name, + ), + Some(record.tag), + ); + let [entry] = &root.section.commitment.entries[..] else { + panic!("one grant was committed"); + }; + assert_eq!(entry.tag, record.tag); + assert_eq!(entry.permission, Permission::Read); + assert_eq!( + root.read_epoch, f.current.current_read_epoch, + "a mint cuts no read plane", + ); + } + + /// A write link hands out an extractable subtree signing key, and a host + /// must be able to say so. + #[test] + fn a_write_link_reports_itself_bearer_write() { + let f = Fixture::new(); + + let link = f.mint(Permission::Write).expect("the mint lands"); + + assert!(link.capability.is_bearer_write()); + } + + /// An unclaimable link is worse than a refused mint: a record that did not + /// land refuses the whole mint, and nothing is published. + #[test] + fn a_mint_whose_record_does_not_land_publishes_nothing() { + let f = Fixture::new(); + f.staging + .interrupt_staged_write_after(f.store().staging_key(), 0); + + let refused = f + .mint(Permission::Read) + .expect_err("an unrecorded link is refused"); + + assert!(matches!( + refused, + InviteMintError::Store(InviteStoreError::Seam(_)) + )); + assert!( + f.publisher.published.borrow().is_empty(), + "nothing is published for a link the owner cannot revoke", + ); + } + + /// The record lands first, so a publish that fails leaves an inert record + /// rather than a committed entry no `revoke_invite_link` call can name. + #[test] + fn a_publish_that_fails_hands_out_no_capability() { + let mut f = Fixture::new(); + f.publisher.refuse = true; + + let refused = f + .mint(Permission::Read) + .expect_err("an unpublished link is refused"); + + assert!(matches!(refused, InviteMintError::Publish(_))); + assert_eq!( + f.recovered().len(), + 1, + "the record landed before the publish", + ); + } + + /// Owner-only: a caller whose identity key did not sign the set it is + /// extending mints nothing, on the same rule `revoke_invite_link` enforces. + #[test] + fn a_caller_who_did_not_sign_the_set_mints_nothing() { + let f = Fixture::new(); + let impostor = signer(&IMPOSTOR_SECRET); + + let refused = f + .mint_as( + &OwnerAuthority { + identity_signer: &impostor, + enc_secret: &f.enc, + }, + Permission::Read, + ) + .expect_err("a non-owner is refused"); + + assert!(matches!( + refused, + InviteMintError::Mint(InviteError::NotOwner) + )); + assert!(f.publisher.published.borrow().is_empty()); + assert!(f.recovered().is_empty(), "a refused mint records nothing"); + } + + /// Revocation completeness cuts both ways: a re-seal wraps a blob for + /// exactly the committed set, so a mint must leave every grantee already in + /// it able to open the scope. + #[test] + fn a_mint_leaves_an_existing_grantees_grant_intact() { + let (f, grantee_tag) = Fixture::with_a_grantee(); + + let link = f.mint(Permission::Read).expect("the mint lands"); + + let published = f.publisher.published.borrow(); + let [root] = &published[..] else { + panic!("one scope root was published"); + }; + let tags: Vec<[u8; 32]> = root + .section + .commitment + .entries + .iter() + .map(|entry| entry.tag) + .collect(); + assert!(tags.contains(&grantee_tag), "the grantee stays committed"); + assert_eq!(tags.len(), 2, "the mint adds exactly the link's own row"); + assert_eq!( + root.section.grant_blobs.len(), + 2, + "one blob per committed row, so the grantee can still open the scope", + ); + assert_eq!(link.capability, LinkCapability::Read); + } + + /// The publish name is derived, the commitment carries its own copy, and + /// every blinded tag binds one of them — so a set naming another root is + /// refused before anything is signed, recorded or published. + #[test] + fn a_committed_set_naming_another_scope_root_is_refused() { + let (mut f, _) = Fixture::with_a_grantee(); + f.current.commitment.ipns_name = b"k51qzi5uqu5dianothername".to_vec(); + f.current.commitment_sig = sign_grant_set(&f.owner, &f.current.commitment) + .expect("signs") + .to_compact(); + + let refused = f + .mint(Permission::Read) + .expect_err("a set naming another root is refused"); + + assert!(matches!(refused, InviteMintError::ScopeNameMismatch)); + assert!(f.publisher.published.borrow().is_empty()); + assert!(f.recovered().is_empty(), "nothing is recorded either"); + } + + /// A root carrying an ascent link is a descendant of some parent scope, and + /// this re-seal has no parent node seed to author one from — so it refuses + /// rather than publish a root orphaned from every later gated descent. + #[test] + fn a_root_carrying_an_ascent_link_is_refused() { + let mut f = Fixture::new(); + f.current.carried_ascent_link = true; + + let refused = f + .mint(Permission::Read) + .expect_err("a descendant scope root is refused"); + + assert!(matches!(refused, InviteMintError::NotAVaultRoot)); + assert!(f.publisher.published.borrow().is_empty()); + assert!(f.recovered().is_empty()); + } +} diff --git a/crates/engine/src/grants/mod.rs b/crates/engine/src/grants/mod.rs index edee727cf..0c806f52d 100644 --- a/crates/engine/src/grants/mod.rs +++ b/crates/engine/src/grants/mod.rs @@ -16,6 +16,7 @@ pub mod contact; pub mod contact_store; pub mod create; pub mod invite; +pub mod invite_mint; pub mod invite_store; pub mod ledger; pub mod owner_entry; @@ -46,6 +47,7 @@ pub use invite::{ OwnerAuthority, RecordedInvite, convert_invite_claim, mint_invite_grant, post_invite_claim, revoke_invite_link, }; +pub use invite_mint::{InviteMintError, InviteMintPlan, MintedInviteLink, mint_invite_link}; pub use invite_store::{ INVITE_RECORDS_PREFIX, InviteRecords, InviteRecordsCodecError, InviteStore, InviteStoreError, MAX_CONVERTED_CLAIMS, MAX_INVITE_RECORDS, StagingInviteStore, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8f1462be7..7335c0875 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -69,11 +69,11 @@ pub use gate::{ SeedBlob, adopt, }; pub use grants::{ - AbuseEvent, AcceptError, AcceptOutcome, AuthorityViolation, Contact, OwnerEntry, - OwnerSeedCache, PublishedGrantBlob, ReceivedShare, ReceivedShareStore, ReceivedShareStoreError, - ReceivedSharesCodecError, ReceivedSharesList, ResolutionClass, ResolutionFacts, SentIndex, - SentShare, SharePointer, StagingReceivedShareStore, accept_share, cross_check, - enforce_committed_ledger, import_contact, recipient_blinded_tag, self_locate, + AbuseEvent, AcceptError, AcceptOutcome, AuthorityViolation, Contact, MintedInviteLink, + OwnerEntry, OwnerSeedCache, PublishedGrantBlob, ReceivedShare, ReceivedShareStore, + ReceivedShareStoreError, ReceivedSharesCodecError, ReceivedSharesList, ResolutionClass, + ResolutionFacts, SentIndex, SentShare, SharePointer, StagingReceivedShareStore, accept_share, + cross_check, enforce_committed_ledger, import_contact, recipient_blinded_tag, self_locate, }; pub use mailbox::{VerifiedMailboxItem, poll_verified, post_sealed}; pub use net::{ diff --git a/crates/engine/src/net/rotation.rs b/crates/engine/src/net/rotation.rs index 0d6aec980..0749e3905 100644 --- a/crates/engine/src/net/rotation.rs +++ b/crates/engine/src/net/rotation.rs @@ -119,8 +119,11 @@ pub struct OwnerRotationNet<'a, T, H: Http, C: CredentialStore, F, Sch, E> { /// The ancestor seeds every interior scope root's gated read needs. pub ancestry: RotationAncestry, /// Derives the scope pointer's name for the sweep's consult (owner-only - /// material, never published). - pub owner_pointer_seed: &'a [u8; SECRET_LEN], + /// material, never published). `None` on a pass that runs no sweep, so an + /// arm that needs neither the pointer nor its signing key is never handed + /// the seed that derives both (store the narrowest derived capability); a + /// consult without it refuses rather than skipping. + pub owner_pointer_seed: Option<&'a [u8; SECRET_LEN]>, /// The pointer-payload envelope version a consulted re-point is read under. pub payload_version: u64, /// The record a re-key is about to replace, handed from the resolve that @@ -284,6 +287,19 @@ impl RotationAncestry { } } +/// Which root binding a gated read must prove. +/// +/// A descendant's record is bound to its parent by an ascent link the gate +/// verifies ([`gated_child_root`]) — a `directChildScopeIndex` entry, or one +/// reparented into a grant's subtree. A vault root carries no ascent link, so +/// requiring one there would refuse every honest record. +enum RootAnchor { + /// A claimed descendant scope root — proven a child, not merely a root. + Descendant, + /// The vault root. + VaultRoot, +} + /// One scope root as the adoption gate authenticated it, plus the seeds the /// owner recovered from its own blobs. Terminal owner of those seeds: they /// zeroize when the value is dropped. @@ -577,23 +593,24 @@ where /// cascade re-keys top-down, so a descendant's record still carries the /// ascent link its parent's pre-cascade seed sealed. /// - /// Caller contract: `scope` is a claimed **descendant** scope root — a - /// `directChildScopeIndex` entry, or one reparented into a grant's subtree — - /// so the record is proven a child ([`gated_child_root`]), not merely a - /// scope root. A scope's *own* root goes through [`Self::gated_root`] - /// instead, since a vault root carries no ascent link to require. + /// Which binding the gated read must prove is [`RootAnchor`]'s. async fn gated_write_plane( &self, scope: &ChildScopeRef, + anchor: RootAnchor, ) -> Result { let name = scope_name(&scope.ipns_name)?; let adopter = self.root_adopter(scope.scope_id); let Some((_, record_bytes)) = fanout_get_verify(self.transport, &name).await else { return Err(ResolveFailure::Unavailable); }; - let root = gated_child_root(&adopter, &name, &record_bytes, scope.scope_id) - .await - .map_err(ResolveFailure::from)?; + let root = match anchor { + RootAnchor::Descendant => { + gated_child_root(&adopter, &name, &record_bytes, scope.scope_id).await + } + RootAnchor::VaultRoot => gated_scope_root(&adopter, &name, &record_bytes).await, + } + .map_err(ResolveFailure::from)?; let (write_body, write_epoch) = self.write_body(&root, scope.scope_id).await?; self.ancestry.record( scope.scope_id, @@ -639,36 +656,27 @@ where } Ok(()) } -} -impl ChildIndexResolver - for OwnerRotationNet<'_, T, H, C, F, Sch, E> -where - T: RecordTransport, - F: FloorStore, -{ - async fn direct_child_index( + /// [`CascadeResealResolver::resolve`] at [`RootAnchor::VaultRoot`]. Same + /// gated read, same parked republish base; only the binding differs. + pub async fn resolve_vault_root( &self, - child: &ChildScopeRef, - ) -> Result, ResolveFailure> { - let gated = self.gated_write_plane(child).await?; - Ok(gated.write_body.direct_child_scope_index) + scope: &ChildScopeRef, + ) -> Result { + self.resolve_at(scope, RootAnchor::VaultRoot).await } -} -impl CascadeResealResolver - for OwnerRotationNet<'_, T, H, C, F, Sch, E> -where - T: RecordTransport, - F: FloorStore, -{ - async fn resolve(&self, scope: &ChildScopeRef) -> Result { + async fn resolve_at( + &self, + scope: &ChildScopeRef, + anchor: RootAnchor, + ) -> Result { let GatedWritePlane { name, root, write_body, write_epoch, - } = self.gated_write_plane(scope).await?; + } = self.gated_write_plane(scope, anchor).await?; let GatedScopeRoot { envelope, section, @@ -679,7 +687,12 @@ where // This build authors exactly `ENVELOPE_V`, so re-sealing a newer // client's root under its own `v` would mint structures whose AAD this // build can never reproduce (`sync/drain.rs` guards the same downgrade). - if envelope.v != ENVELOPE_V { + // + // The root gate binds `envelope.scope` but not `envelope.id`, and every + // AAD a re-seal of this target authors binds the id — so a root whose + // record claims another node would be re-sealed under a key no reader + // re-derives (the write wave imposes the same binding). + if envelope.v != ENVELOPE_V || envelope.id != scope.scope_id { return Err(ResolveFailure::Rejected); } let Some(write_scope_seed) = write_scope_seed else { @@ -701,6 +714,7 @@ where write_history_link: write_body.write_history_link, direct_child_scope_index: write_body.direct_child_scope_index, carried_history_links: section.history_links, + carried_ascent_link: section.ascent_link.is_some(), }; self.gated.park( name, @@ -715,6 +729,34 @@ where } } +impl ChildIndexResolver + for OwnerRotationNet<'_, T, H, C, F, Sch, E> +where + T: RecordTransport, + F: FloorStore, +{ + async fn direct_child_index( + &self, + child: &ChildScopeRef, + ) -> Result, ResolveFailure> { + let gated = self + .gated_write_plane(child, RootAnchor::Descendant) + .await?; + Ok(gated.write_body.direct_child_scope_index) + } +} + +impl CascadeResealResolver + for OwnerRotationNet<'_, T, H, C, F, Sch, E> +where + T: RecordTransport, + F: FloorStore, +{ + async fn resolve(&self, scope: &ChildScopeRef) -> Result { + self.resolve_at(scope, RootAnchor::Descendant).await + } +} + impl ScopeRootPublisher for OwnerRotationNet<'_, T, H, C, F, Sch, E> where @@ -1079,7 +1121,10 @@ where &self, scope_id: &[u8; 16], ) -> Result>, SweepResolveFailure> { - let pointer = scope_pointer_name(self.owner_pointer_seed, scope_id); + let seed = self + .owner_pointer_seed + .ok_or(SweepResolveFailure::Unavailable)?; + let pointer = scope_pointer_name(seed, scope_id); let block = match RecordPointerFetch::new(self.transport) .fetch(&pointer) .await @@ -2659,7 +2704,7 @@ mod tests { scope_keys: &OwnerSeeds, }, ancestry, - owner_pointer_seed: &OWNER_POINTER_SEED, + owner_pointer_seed: Some(&OWNER_POINTER_SEED), payload_version: PAYLOAD_VERSION, gated: GatedRoots::default(), swept: SweptScopeState::default(), @@ -3409,6 +3454,66 @@ mod tests { (root, child, child_ref) } + /// The vault root carries no ascent link, so the descendant edge refuses + /// it — nothing proves it a child of anything. Its own edge reads it, which + /// is what a mint or a rotation anchored at the root needs. + #[test] + fn the_vault_root_resolves_only_through_its_own_edge() { + let root = vault_root(SCOPE, Vec::new()); + let root_ref = child_ref(SCOPE, &root); + let harness = Harness::plain(); + harness.stage(SCOPE, &root, Some(OWNER_ROOT_EPOCH)); + + assert_eq!( + block_on(harness.net(&[]).resolve(&root_ref)).err(), + Some(ResolveFailure::Rejected), + "an ascent link the vault root never carries is not evidence it lacks", + ); + + let target = block_on(harness.net(&[]).resolve_vault_root(&root_ref)) + .expect("the vault root's own re-seal material"); + assert_eq!(target.current_read_epoch, OWNER_ROOT_EPOCH); + assert!( + ct_eq(&target.override_seed, &OWNER_ROOT_SCOPE_SEED), + "the seed comes from the gated record's own owner blob", + ); + assert!(ct_eq( + &target.write_scope_seed, + &OWNER_ROOT_WRITE_SCOPE_SEED + )); + } + + /// The root gate binds the scope but not the node id, and every AAD a + /// re-seal authors binds the id — so a record claiming another node yields + /// no re-seal material rather than one re-sealed under a key no reader + /// re-derives. Fail-closed on whichever ladder rung catches it first. + #[test] + fn a_vault_root_record_claiming_another_node_yields_no_reseal_material() { + let planted = owner_root_fixture(OwnerRootSpec { + owner_identity: &owner_identity(), + owner_enc: &owner_enc().public(), + scope_id: SCOPE, + root_id: CHILD_SCOPE, + children: Vec::new(), + child_scope_index: Vec::new(), + parent_node_seed: None, + owner_write_blob_epoch: Some(OWNER_ROOT_EPOCH), + write_history_link: Vec::new(), + grants: Vec::new(), + }); + let harness = Harness::plain(); + harness.stage(SCOPE, &planted, Some(OWNER_ROOT_EPOCH)); + + assert!( + block_on( + harness + .net(&[]) + .resolve_vault_root(&child_ref(SCOPE, &planted)) + ) + .is_err(), + ); + } + #[test] fn a_descendants_reseal_material_comes_from_its_own_gated_record() { let (_, child, child_ref) = owner_tree(); diff --git a/crates/engine/src/rotation/cascade.rs b/crates/engine/src/rotation/cascade.rs index 59f820fd4..57c914929 100644 --- a/crates/engine/src/rotation/cascade.rs +++ b/crates/engine/src/rotation/cascade.rs @@ -125,6 +125,14 @@ pub struct CascadeTarget { /// retained window by the re-seal — see /// [`reseal_scope_root`](super::reseal::reseal_scope_root). pub carried_history_links: Vec, + /// Whether the record this replaces carried an ascent link, and so whether + /// the re-seal owes one + /// ([`ScopeRootIdentity::owes_ascent_link`](super::ScopeRootIdentity::owes_ascent_link)). + /// Read off the record rather than inferred from the walk, on the rule the + /// sweep's [`SweptScope`](super::sweep::SweptScope) already follows: a root + /// re-sealed without the link its record carried is orphaned from every + /// later gated descent. + pub carried_ascent_link: bool, } /// The impure edge that resolves a descendant scope root's current re-seal @@ -857,6 +865,8 @@ mod tests { write_history_link: Vec::new(), direct_child_scope_index: s.children.clone(), carried_history_links: Vec::new(), + // Every scope this resolver reaches is a descendant. + carried_ascent_link: true, }) } } diff --git a/crates/engine/tests/facade.rs b/crates/engine/tests/facade.rs index 4c551d1b3..4eb15fa9c 100644 --- a/crates/engine/tests/facade.rs +++ b/crates/engine/tests/facade.rs @@ -65,13 +65,6 @@ fn unimplemented_commands() -> Vec<(Command, &'static str)> { }, "downgrade", ), - ( - Command::CreateInviteLink { - node, - permission: Permission::Write, - }, - "createInviteLink", - ), ( Command::AcceptShare { sealed_share_pointer: b"sealed-pointer".to_vec(), @@ -188,6 +181,46 @@ fn unimplemented_commands_return_their_typed_error() { } } +/// The invite mint is wired, so it refuses with its own verdict rather than +/// falling through the catch-all — and it refuses a node that names no scope +/// root before it reaches any key material. +#[test] +fn minting_an_invite_link_refuses_a_node_that_names_no_scope_root() { + let world = FakeWorld::new(); + let device = world.device(b"alice-pk"); + let (mut engine, _events) = new_engine(&device); + block_on(engine.start(secret())).unwrap(); + + assert_eq!( + block_on(engine.command(Command::CreateInviteLink { + node: NodeId([1; 16]), + permission: Permission::Read, + })), + Err(EngineError::UnsupportedTarget { + check: "invite-target-is-not-a-scope-root" + }), + ); +} + +/// The vault root passes the target check, so an offline engine stops at the +/// scope material it has not resolved — availability, never the catch-all. +#[test] +fn minting_an_invite_link_on_an_unresolved_vault_root_reports_availability() { + let world = FakeWorld::new(); + let device = world.device(b"alice-pk"); + let (mut engine, _events) = new_engine(&device); + block_on(engine.start(secret())).unwrap(); + let root = block_on(engine.view()).expect("view").root(); + + assert!(matches!( + block_on(engine.command(Command::CreateInviteLink { + node: root, + permission: Permission::Read, + })), + Err(EngineError::ContentUnavailable { .. }), + )); +} + /// A contact code the peer signed itself: the bundle a real import receives /// out of band. fn contact_code(scalar: [u8; 32]) -> Vec { diff --git a/crates/fuse/src/error.rs b/crates/fuse/src/error.rs index bcc893f6d..31d970545 100644 --- a/crates/fuse/src/error.rs +++ b/crates/fuse/src/error.rs @@ -73,6 +73,11 @@ impl From for VfsError { } EngineError::OverBudget { cause, .. } => VfsError::OverBudget(cause), EngineError::ScopeExitRefused { message } => VfsError::Refused { message }, + // A node this build cannot act on is a refusal of the target, which + // is what `Refused` names — never an unavailability a mount retries. + error @ EngineError::UnsupportedTarget { .. } => VfsError::Refused { + message: error.to_string(), + }, EngineError::ContentUnavailable { message } | EngineError::RefreshFailed { message } => VfsError::Unavailable { message }, // Retryable once the vault settings resolve or are saved again, and diff --git a/crates/wasm/src/host.rs b/crates/wasm/src/host.rs index a4c1303e3..1bcbe1a5f 100644 --- a/crates/wasm/src/host.rs +++ b/crates/wasm/src/host.rs @@ -487,6 +487,7 @@ fn engine_error(error: EngineError) -> JsValue { EngineError::Auth { .. } => "auth", EngineError::ColdStart { .. } => "coldStart", EngineError::ScopeExitRefused { .. } => "scopeExitRefused", + EngineError::UnsupportedTarget { .. } => "unsupportedTarget", }; let js = js_sys::Error::new(&error.to_string()); // Setting a plain property on a fresh `Error` cannot fail. diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 3c916352f..a583c2c2f 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -11,9 +11,15 @@ //! The wasm-bindgen-generated `.d.ts` is the single boundary contract that //! `packages/client` re-exports — there is no hand-maintained TS mirror of //! engine structures. Boundary hygiene is structural: `u64`s cross as `bigint`, -//! binary payloads as `Uint8Array`, and no secret key material crosses at all — -//! the command surface exposes only intent, the event and read surfaces only -//! key-free view state and decrypted user content. +//! binary payloads as `Uint8Array`, and the command surface exposes only +//! intent while the event and read surfaces carry key-free view state and +//! decrypted user content. +//! +//! One secret crosses, and only because handing it over *is* the feature: an +//! invite link's bearer capability ([`CommandOutcome::invite_secret`]), which +//! the host puts in a URL fragment. Residual: wasm-bindgen copies the returned +//! buffer into the JS heap and frees it unwiped, so those bytes stay readable +//! in linear memory until the allocator reuses the block. // wasm-bindgen's macro-generated glue is unsafe by nature and exempt; this // forbids only unsafe we would hand-write (there is none). @@ -22,7 +28,7 @@ use cipherbox_engine::content::{ByoIpfsConfig as EngineByo, ByoKind as EngineByoKind}; use cipherbox_engine::facade; -use cipherbox_engine::{Contact, PinMode as EnginePinMode, RetentionPolicy}; +use cipherbox_engine::{Contact, MintedInviteLink, PinMode as EnginePinMode, RetentionPolicy}; use core::num::NonZeroU64; use wasm_bindgen::prelude::*; use zeroize::Zeroizing; @@ -394,6 +400,7 @@ impl CommandOutcome { facade::CommandOutcome::Done => "done", facade::CommandOutcome::Queued { .. } => "queued", facade::CommandOutcome::ContactImported(_) => "contactImported", + facade::CommandOutcome::InviteLinkMinted(_) => "inviteLinkMinted", } .to_owned() } @@ -421,6 +428,37 @@ impl CommandOutcome { self.contact() .map(|contact| contact.enc_subkey().to_bytes().to_vec()) } + + /// `inviteLinkMinted`: the invite secret the link's URL fragment carries; + /// otherwise `undefined`. + #[wasm_bindgen(getter, js_name = inviteSecret)] + pub fn invite_secret(&self) -> Option> { + self.link() + .map(|link| link.invite_secret.as_bytes().to_vec()) + } + + /// `inviteLinkMinted`: the owner's contact code, which the fragment carries + /// beside the secret so a claimant can seal its claim to the owner; + /// otherwise `undefined`. + #[wasm_bindgen(getter, js_name = ownerContactCode)] + pub fn owner_contact_code(&self) -> Option> { + self.link().map(|link| link.owner_contact_code.clone()) + } + + /// `inviteLinkMinted`: the scope root's opaque `ipnsName`, which a claim + /// names; otherwise `undefined`. + #[wasm_bindgen(getter, js_name = scopeRootName)] + pub fn scope_root_name(&self) -> Option> { + self.link().map(|link| link.scope_root_name.clone()) + } + + /// `inviteLinkMinted`: whether the link hands out an extractable subtree + /// signing key, which only a write rotation revokes — the bearer-write flag + /// a host UI must show; otherwise `undefined`. + #[wasm_bindgen(getter, js_name = isBearerWrite)] + pub fn is_bearer_write(&self) -> Option { + self.link().map(|link| link.capability.is_bearer_write()) + } } impl CommandOutcome { @@ -429,6 +467,13 @@ impl CommandOutcome { Self { inner } } + fn link(&self) -> Option<&MintedInviteLink> { + match &self.inner { + facade::CommandOutcome::InviteLinkMinted(link) => Some(link), + _ => None, + } + } + fn contact(&self) -> Option<&Contact> { match &self.inner { facade::CommandOutcome::ContactImported(contact) => Some(contact),