diff --git a/CHANGELOG.md b/CHANGELOG.md index b69ddcb4..74d4a7ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - `POST /revoke` authorizes the delegation's issuer, the owner of a space its abilities target, or the holder of a revoke control grant issued by one of them. The delegatee alone is no longer an authorized revoker, directly or through a control grant it issued; an intermediate delegator still revokes the delegations it issued. Delegation status visibility is unchanged (TC-1436). - Revoke and status control grants may name an encryption network (`urn:tinycloud:encryption::`) owned by the control's grantor. Only the network's owner can issue such a control without a parent, because the owner is already the network's root authority (`/delegate` accepted this before); naming the network in a control grants no decrypt or other network ability, so this is not an escalation. Each resource of a control is typed as an exact `urn:cid:`, a `/delegation` root, or a network; any other resource is ignored. A control covers a target only when every one of the target's resources is covered: a space resource by a named space, a network resource by the same parsed network id (owner DID including chain id, and name), so lowercase and EIP-55 checksummed spellings of an address name the same network, and a resource that does not parse as a network is never covered. A control that names a network never counts as an exact-CID control, and a network named by a control issued by someone other than its owner is ignored. A control such as `/delegation` + `/delegation` + `:share` therefore revokes or reads the status of share roots that carry `:share` decrypt, but not grants on `:default` or any other network (TC-1423). +- An owner can activate a parentless `tinycloud.delegation/revoke` grant on exactly `urn:cid:` (for example the CLI's raw-CID revoke permission) when the stored target's abilities name a space the owner owns. Every non-root capability of such a grant must be one of these. Unknown targets, targets without abilities, and signers who are only the target's delegator or delegatee are refused with `Cannot find parent delegation` (TC-821). +- Revocation and delegation status accept a canonical CID that names a stored historical encoding. An exact-CID revoke or status grant covers every stored encoding of its target. The signed CID is kept as signed, and authorization uses the stored row (TC-821). ## [1.21.1] - 2026-10-19 diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 8c8e57c3..8ab1d718 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -1896,18 +1896,20 @@ where if proofs.len() > 1 { return Ok(None); } - let Some(delegation) = delegation::Entity::find_by_id(target) - .one(&self.conn) - .await? - else { + // A canonical CID may name a historical stored row; the requested CID + // keeps its own authority scope, the stored row supplies the grant. + let Some(delegation) = revocation::target_for_cid(&self.conn, target).await? else { return Ok(None); }; let abilities = abilities::Entity::find() - .filter(abilities::Column::Delegation.eq(target)) + .filter(abilities::Column::Delegation.eq(delegation.id)) .all(&self.conn) .await?; let mut roots = vec![target]; + if delegation.id != target { + roots.push(delegation.id); + } roots.extend(proofs.iter().copied().map(Hash::from)); let _chain_guards = match self.acquire_chain_guards(&roots).await { Ok(guards) => guards, @@ -1948,7 +1950,7 @@ where if revocation::is_revoked(&self.conn, &target).await? { return Ok(Some(DelegationStatus::Revoked)); } - match revocation::first_revoked_ancestor(&self.conn, &target).await { + match revocation::first_revoked_ancestor(&self.conn, &delegation.id).await { Ok(Some(_)) => return Ok(Some(DelegationStatus::Revoked)), Ok(None) => {} Err(revocation::ChainTraversalError::LimitExceeded) => { @@ -3092,8 +3094,12 @@ async fn sequence_spaces( } } if !revoked.is_empty() { + let mut stored = Vec::with_capacity(revoked.len()); + for target in revoked { + stored.push(stored_revocation_target(db, target).await?); + } for row in event_order::Entity::find() - .filter(event_order::Column::Event.is_in(revoked)) + .filter(event_order::Column::Event.is_in(stored)) .all(db) .await? { @@ -3103,19 +3109,29 @@ async fn sequence_spaces( Ok(spaces.into_iter().collect()) } +/// The stored delegation a revocation's signed target names: the target itself, +/// or the stored historical encoding a canonical CID resolves to. `event_order` +/// records the stored row, so the revocation is sequenced in its spaces. +async fn stored_revocation_target(db: &C, target: Hash) -> Result { + Ok(revocation::target_for_cid(db, target) + .await? + .map(|delegation| delegation.id) + .unwrap_or(target)) +} + async fn event_spaces<'a, C: ConnectionTrait>( db: &C, ev: &'a [(Hash, Event)], ) -> Result>, DbErr> { // get orderings of events listed as revoked by events in the ev list let mut spaces = HashMap::>::new(); - let revoked = ev - .iter() - .filter_map(|(_, e)| match e { - Event::Revocation(r) => Some(Hash::from(r.0.revoked)), - _ => None, - }) - .collect::>(); + let mut revoked = Vec::new(); + for (_, e) in ev { + if let Event::Revocation(r) = e { + let signed = Hash::from(r.0.revoked); + revoked.push((signed, stored_revocation_target(db, signed).await?)); + } + } // TC-732: `transact` now calls this before any write. Issue no read when // there is nothing to look up: on SQLite a read that opens the // transaction's snapshot ahead of its first write turns a concurrent @@ -3124,7 +3140,7 @@ async fn event_spaces<'a, C: ConnectionTrait>( Vec::new() } else { event_order::Entity::find() - .filter(event_order::Column::Event.is_in(revoked)) + .filter(event_order::Column::Event.is_in(revoked.iter().map(|(_, stored)| *stored))) .all(db) .await? }; @@ -3163,9 +3179,13 @@ async fn event_spaces<'a, C: ConnectionTrait>( } } Event::Revocation(r) => { - let r_hash = Hash::from(r.0.revoked); + let signed = Hash::from(r.0.revoked); + let Some(&(_, stored)) = revoked.iter().find(|(target, _)| *target == signed) + else { + continue; + }; for revoked in &revoked_events { - if r_hash == revoked.event { + if stored == revoked.event { let entry = spaces.entry(revoked.space.0.clone()).or_default(); if !entry.iter().any(|(h, _)| h == &e.0) { entry.push(e); diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index d266eaa8..72cacda8 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -256,8 +256,18 @@ async fn validate( // ordered sibling pair. Keeping the graph row here is what lets the // ordinary transaction validate an S0's proof CIDs. (false, true) if is_policy_root(&delegation.delegation) => Ok(()), - // dependant caps, no parents, invalid - (false, true) => Err(DelegationError::MissingParents.into()), + // An owner may grant revocation of one exact stored delegation that + // targets their space, without holding the target's signed bytes: + // every non-root capability must be such an exact-CID revoke grant. + // The target's delegatee or delegator alone cannot issue one. + (false, true) => { + for cap in &dependant_caps { + if !revocation::owner_controls_target(db, &delegation.delegator, cap).await? { + return Err(DelegationError::MissingParents.into()); + } + } + Ok(()) + } // dependant caps, parents, check parents (false, false) => { // Policy-session UCANs intentionally carry two sibling roots. The diff --git a/tinycloud-core/src/models/revocation.rs b/tinycloud-core/src/models/revocation.rs index ab45c0f7..44392011 100644 --- a/tinycloud-core/src/models/revocation.rs +++ b/tinycloud-core/src/models/revocation.rs @@ -74,13 +74,18 @@ pub enum ChainTraversalError { } /// Resolve revocation targets through the persisted canonical identity. The -/// exact-target branch also handles graph rows created directly by unit tests. +/// exact-target branch also handles graph rows created directly by unit tests; +/// matching the canonical column covers a canonical CID with no stored row. fn target_condition(id: SimpleExpr) -> Condition { use delegation_cid_alias::{Column as AliasColumn, Entity as AliasEntity}; let canonical = Query::select() .column(AliasColumn::Canonical) .from(AliasEntity) - .and_where(Expr::col(AliasColumn::Id).eq(id.clone())) + .cond_where( + Condition::any() + .add(Expr::col(AliasColumn::Id).eq(id.clone())) + .add(Expr::col(AliasColumn::Canonical).eq(id.clone())), + ) .to_owned(); let equivalents = Query::select() .column(AliasColumn::Id) @@ -299,15 +304,16 @@ enum ControlScope { } impl ControlScope { - /// `target_resource` is `urn:cid:`. + /// `names_target` is whether `resource` is an exact `urn:cid:` naming a + /// stored encoding of `target`. fn parse( resource: &Resource, target: &Hash, - target_resource: &str, + names_target: bool, grantor: &str, ) -> Option { match resource { - Resource::Other(uri) if uri.as_str() == target_resource => Some(Self::Cid(*target)), + Resource::Other(_) if names_target => Some(Self::Cid(*target)), Resource::Other(uri) => uri .as_str() .parse::() @@ -441,22 +447,32 @@ pub(crate) async fn control_proof_decision( } } - let target_resource = format!("urn:cid:{}", target.to_cid(0x55)); - let scopes: Vec = abilities::Entity::find() + let target_canonical = canonical_identity(db, *target).await?; + let mut scopes: Vec = Vec::new(); + for ability in abilities::Entity::find() .filter(abilities::Column::Delegation.eq(proof_id)) .filter(abilities::Column::Ability.eq(action.ability())) .all(db) .await? - .iter() - .filter_map(|ability| { - ControlScope::parse( - &ability.resource, - target, - &target_resource, - &parent.delegator, - ) - }) - .collect(); + { + // An exact-CID grant covers every stored encoding of its target. + let names_target = match &ability.resource { + Resource::Other(resource) => match resource + .as_str() + .strip_prefix("urn:cid:") + .and_then(|cid| cid.parse::().ok()) + { + Some(cid) => canonical_identity(db, Hash::from(cid)).await? == target_canonical, + None => false, + }, + Resource::TinyCloud(_) => false, + }; + if let Some(scope) = + ControlScope::parse(&ability.resource, target, names_target, &parent.delegator) + { + scopes.push(scope); + } + } if scopes.is_empty() { return Ok(ControlProofDecision::Denied); } @@ -468,6 +484,77 @@ pub(crate) async fn control_proof_decision( })) } +/// The persisted canonical identity of a delegation CID: its alias row's +/// canonical hash, or the CID itself when it has no alias row (a canonical CID, +/// or a graph row created directly by unit tests). +async fn canonical_identity(db: &C, id: Hash) -> Result { + Ok(delegation_cid_alias::Entity::find_by_id(id) + .one(db) + .await? + .map(|alias| alias.canonical) + .unwrap_or(id)) +} + +/// The stored delegation a CID names: the row with that ID, or the stored +/// historical encoding whose canonical identity it is. Signed CIDs stay as +/// signed; authorization uses the stored row. When several stored encodings +/// share the canonical identity, the lowest stored ID is chosen so the result +/// is the same on every call and every database. +pub(crate) async fn target_for_cid( + db: &C, + target: Hash, +) -> Result, DbErr> { + if let Some(row) = delegation::Entity::find_by_id(target).one(db).await? { + return Ok(Some(row)); + } + let Some(alias) = delegation_cid_alias::Entity::find() + .filter(delegation_cid_alias::Column::Canonical.eq(target)) + .order_by_asc(delegation_cid_alias::Column::Id) + .one(db) + .await? + else { + return Ok(None); + }; + delegation::Entity::find_by_id(alias.id).one(db).await +} + +/// Whether `owner` may issue a parentless `tinycloud.delegation/revoke` grant +/// on exactly `urn:cid:`: the target is stored and one of its +/// persisted abilities names a space `owner` owns. Being the target's +/// delegator or delegatee is not enough. +pub(crate) async fn owner_controls_target( + db: &C, + owner: &str, + cap: &crate::util::Capability, +) -> Result { + if cap.ability.as_ref().as_ref() != ControlAction::Revoke.ability() { + return Ok(false); + } + let Resource::Other(resource) = &cap.resource else { + return Ok(false); + }; + let Some(target) = resource + .as_str() + .strip_prefix("urn:cid:") + .and_then(|cid| cid.parse::().ok()) + else { + return Ok(false); + }; + let Some(target) = target_for_cid(db, Hash::from(target)).await? else { + return Ok(false); + }; + Ok(abilities::Entity::find() + .filter(abilities::Column::Delegation.eq(target.id)) + .all(db) + .await? + .iter() + .any(|row| { + row.resource + .space() + .is_some_and(|space| did_principal_matches(space.did().as_str(), owner)) + })) +} + #[derive(Debug, thiserror::Error)] pub enum Error { #[error(transparent)] @@ -532,8 +619,8 @@ pub(crate) async fn process( }; let hash: Hash = hash(&serialization); - let delegation = delegation::Entity::find_by_id(Hash::from(r.revoked)) - .one(db) + let requested = Hash::from(r.revoked); + let delegation = target_for_cid(db, requested) .await? .ok_or(RevocationError::MissingParents)?; @@ -546,7 +633,7 @@ pub(crate) async fn process( // the above. // The delegatee alone is never authorized: it may hold a key that // others share, and revoking would cut them off as well. - if !revoker_is_authorized(db, &delegation, &r.revoker, &r.parents).await? { + if !revoker_is_authorized(db, &delegation, &requested, &r.revoker, &r.parents).await? { return Err(RevocationError::UnauthorizedRevoker(r.revoker).into()); }; @@ -595,30 +682,32 @@ pub(crate) async fn process( async fn revoker_is_authorized( db: &C, delegation: &delegation::Model, + requested: &Hash, revoker: &str, proofs: &[Cid], ) -> Result { - let principal = - match control_proof_decision(db, revoker, proofs, ControlAction::Revoke, &delegation.id) - .await? - { - ControlProofDecision::DirectSigner(principal) => principal, - ControlProofDecision::Scoped(control) => { - if control.directly_authorizes_cid(&delegation.id, delegation) { - return Ok(true); - } - let target_abilities = abilities::Entity::find() - .filter(abilities::Column::Delegation.eq(delegation.id)) - .all(db) - .await?; - return Ok(control.authorizes_target( - &delegation.id, - delegation, - &target_abilities, - )); + let principal = match control_proof_decision( + db, + revoker, + proofs, + ControlAction::Revoke, + requested, + ) + .await? + { + ControlProofDecision::DirectSigner(principal) => principal, + ControlProofDecision::Scoped(control) => { + if control.directly_authorizes_cid(requested, delegation) { + return Ok(true); } - ControlProofDecision::Denied => return Ok(false), - }; + let target_abilities = abilities::Entity::find() + .filter(abilities::Column::Delegation.eq(delegation.id)) + .all(db) + .await?; + return Ok(control.authorizes_target(requested, delegation, &target_abilities)); + } + ControlProofDecision::Denied => return Ok(false), + }; if ControlAction::Revoke.party_may_act(delegation, &principal) { return Ok(true); } diff --git a/tinycloud-core/tests/historical_cacao_revocation.rs b/tinycloud-core/tests/historical_cacao_revocation.rs index 38143a80..255a82ea 100644 --- a/tinycloud-core/tests/historical_cacao_revocation.rs +++ b/tinycloud-core/tests/historical_cacao_revocation.rs @@ -13,7 +13,7 @@ use tinycloud_auth::{ }; use tinycloud_core::{ db::SpaceDatabase, - events::Revocation, + events::{Delegation, Revocation}, keys::StaticSecret, models::{delegation, revocation}, relationships::parent_delegations, @@ -274,6 +274,209 @@ async fn upgrade_active_legacy_children_remain_valid_until_owner_revokes_either_ } } +/// An owner-signed SIWE grant of `tinycloud.delegation/revoke` on exactly +/// `urn:cid:`, with no proofs (the CLI's raw revoke permission). +fn owner_cid_control(target: &str, audience: &str) -> String { + use tinycloud_auth::{ + cacaos::{ + siwe::Message, + siwe_cacao::{Header, Signature, SiweCacao}, + }, + siwe_recap::{Ability, Capability}, + }; + let mut recap = Capability::::new(); + recap.with_action( + format!("urn:cid:{target}").parse().unwrap(), + "tinycloud.delegation/revoke".parse::().unwrap(), + [std::collections::BTreeMap::new()], + ); + let message: Message = signed_cacao(audience, None, "tc821-cid-control") + .payload() + .clone() + .try_into() + .unwrap(); + let message = recap.build_message(message).unwrap(); + let (key, _, _) = owner(); + let (signature, recovery) = key + .sign_prehash_recoverable(&message.eip191_hash().unwrap()) + .unwrap(); + let mut bytes = [0; 65]; + bytes[..64].copy_from_slice(signature.to_bytes().as_ref()); + bytes[64] = u8::from(recovery) + 27; + TinyCloudDelegation::Cacao(Box::new(SiweCacao::new( + message.into(), + Signature::from(bytes), + Header, + ))) + .encode() + .unwrap() +} + +fn session_cid_revocation(target: &str, control: &str, seed: u8) -> Revocation { + use tinycloud_auth::{ + ssi::{claims::jwt::NumericDate, jwk::Algorithm, ucan::Payload}, + ucan_capabilities_object::Capabilities, + }; + let (key, did, vm) = session(seed); + let mut caps = Capabilities::new(); + caps.with_action( + format!("urn:cid:{target}").parse().unwrap(), + "tinycloud.delegation/revoke".parse().unwrap(), + [std::collections::BTreeMap::::new()], + ); + let header = TinyCloudRevocation::Ucan(Box::new( + Payload { + issuer: vm.parse().unwrap(), + audience: did.parse().unwrap(), + not_before: None, + expiration: NumericDate::try_from_seconds(EXPIRATION).unwrap(), + nonce: Some("tc821-session-revoke".into()), + facts: None, + proof: vec![control.parse().unwrap()], + attenuation: caps, + } + .sign(Algorithm::EdDSA, &key) + .unwrap(), + )) + .encode() + .unwrap(); + Revocation::from_header_ser::(&header).unwrap() +} + +#[tokio::test] +async fn owner_cid_control_revokes_historical_equivalents_without_target_artifact() { + for database in ["active.sqlite", "single-v0.sqlite"] { + let fixture = fixtures() + .into_iter() + .find(|fixture| fixture.database == database) + .unwrap(); + for target in &fixture.rows { + let (_directory, path) = copy_database(&fixture); + let before = signed_snapshot(&path); + let db = open(&path).await; + let (_, audience, _) = session(fixture.session_seed); + let header = owner_cid_control(&target.raw_cid, &audience); + db.delegate(Delegation::from_header_ser::(&header).unwrap()) + .await + .map_err(|error| error.to_string()) + .unwrap(); + let signed = + session_cid_revocation(&target.raw_cid, &cid(&header), fixture.session_seed); + db.revoke(signed) + .await + .map_err(|error| error.to_string()) + .unwrap(); + for equivalent in &fixture.rows { + assert_eq!( + db.delegation_status(hash_from_cid(&equivalent.raw_cid), &fixture.issuer, &[]) + .await + .map_err(|error| error.to_string()) + .unwrap(), + Some(tinycloud_core::db::DelegationStatus::Revoked), + "{database} {} via {}", + equivalent.encoding, + target.encoding, + ); + } + let after = signed_snapshot(&path); + assert!(before[0].iter().all(|row| after[0].contains(row))); + assert!(before[1].iter().all(|row| after[1].contains(row))); + } + } +} + +#[tokio::test] +async fn unstored_canonical_status_resolves_historical_grant() { + let fixture = fixtures() + .into_iter() + .find(|fixture| fixture.database == "single-v0.sqlite") + .unwrap(); + let canonical = current_cid(&fixture.canonical_header); + assert_ne!(canonical, fixture.rows[0].raw_cid); + let (_directory, path) = copy_database(&fixture); + let db = open(&path).await; + assert_eq!( + db.delegation_status(hash_from_cid(&canonical), &fixture.issuer, &[]) + .await + .map_err(|error| error.to_string()) + .unwrap(), + Some(tinycloud_core::db::DelegationStatus::Active), + ); +} + +#[tokio::test] +async fn owner_revokes_unstored_canonical_target_directly() { + let fixture = fixtures() + .into_iter() + .find(|fixture| fixture.database == "single-v0.sqlite") + .unwrap(); + let canonical = current_cid(&fixture.canonical_header); + let raw = &fixture.rows[0].raw_cid; + assert_ne!(canonical, *raw); + let (_directory, path) = copy_database(&fixture); + let db = open(&path).await; + let header = revoke(&db, &canonical).await; + let signed = Revocation::from_header_ser::(&header).unwrap(); + assert_eq!(signed.0.revoked.to_string(), canonical); + for target in [raw, &canonical] { + assert_eq!( + db.delegation_status(hash_from_cid(target), &fixture.issuer, &[]) + .await + .map_err(|error| error.to_string()) + .unwrap(), + Some(tinycloud_core::db::DelegationStatus::Revoked), + ); + } + assert_denied(&db, &fixture).await; +} + +#[tokio::test] +async fn owner_cid_control_resolves_unstored_canonical_target() { + let fixture = fixtures() + .into_iter() + .find(|fixture| fixture.database == "single-v0.sqlite") + .unwrap(); + let canonical = current_cid(&fixture.canonical_header); + let raw = &fixture.rows[0].raw_cid; + assert_ne!(canonical, *raw); + for (control_target, signed_target) in [(&canonical, raw), (raw, &canonical)] { + let (_directory, path) = copy_database(&fixture); + let db = open(&path).await; + let (_, audience, _) = session(fixture.session_seed); + let header = owner_cid_control(control_target, &audience); + db.delegate(Delegation::from_header_ser::(&header).unwrap()) + .await + .map_err(|error| error.to_string()) + .unwrap(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + db.revoke(session_cid_revocation( + signed_target, + &cid(&header), + fixture.session_seed, + )), + ) + .await + .expect("same-registry root/alias revoke must not deadlock") + .map_err(|error| error.to_string()) + .unwrap(); + assert_eq!( + db.delegation_status(hash_from_cid(raw), &fixture.issuer, &[]) + .await + .map_err(|error| error.to_string()) + .unwrap(), + Some(tinycloud_core::db::DelegationStatus::Revoked), + ); + assert_eq!( + db.delegation_status(hash_from_cid(&canonical), &fixture.issuer, &[]) + .await + .map_err(|error| error.to_string()) + .unwrap(), + Some(tinycloud_core::db::DelegationStatus::Revoked), + ); + } +} + #[tokio::test] async fn new_writes_deduplicate_equivalent_cacao_and_revoke_the_single_canonical_row() { let fixture = fixtures() diff --git a/tinycloud-node-server/src/routes/tests/revocation_scope.rs b/tinycloud-node-server/src/routes/tests/revocation_scope.rs index ed862212..3eb9748a 100644 --- a/tinycloud-node-server/src/routes/tests/revocation_scope.rs +++ b/tinycloud-node-server/src/routes/tests/revocation_scope.rs @@ -1065,6 +1065,140 @@ async fn issuing_delegator_and_owner_revoke_a_descendant_its_recipient_cannot() Ok(()) } +/// POST `/delegate` and return the status, body and receipt CID. +async fn activate(fixture: &KvSyncFixture, header: String) -> (Status, String, Option) { + let response = fixture + .client + .post("/delegate") + .header(Header::new("Authorization", header)) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + let cid = serde_json::from_str::(&body) + .ok() + .and_then(|receipt| { + receipt["cid"] + .as_str() + .and_then(|cid| cid.parse::().ok()) + }) + .map(Hash::from); + (status, body, cid) +} + +#[tokio::test] +async fn owner_activates_a_parentless_exact_cid_revoke_grant() -> Result<()> { + let fixture = scoped_fixture().await?; + let target = fixture + .device_grant("owner-cid-control-target", &["tinycloud.kv/get"]) + .await?; + let (helper, helper_vm, helper_did) = registered_key(&fixture).await?; + let resource = format!("urn:cid:{}", target.to_cid(0x55)); + let control = fixture.signed_scope_token( + &fixture.owner, + &fixture.owner_vm, + &helper_did, + &resource, + "tinycloud.delegation/revoke", + whole_seconds_from_now(3600), + &[], + )?; + let (status, body, control) = activate(&fixture, control).await; + assert_eq!(status, Status::Ok, "owner exact-CID control: {body}"); + let control = control.expect("control receipt CID"); + let header = fixture.signed_scope_token( + &helper, + &helper_vm, + &helper_did, + &resource, + "tinycloud.delegation/revoke", + whole_seconds_from_now(3600), + &[control.to_cid(0x55)], + )?; + assert_revoked_ok(&fixture, target, header).await +} + +#[tokio::test] +async fn only_an_owner_of_the_target_space_activates_a_parentless_exact_cid_revoke_grant( +) -> Result<()> { + let fixture = scoped_fixture().await?; + let owner_did = fixture.space.did().to_string(); + let device_did = fixture.device_did(); + let target = fixture + .device_grant("owner-only-cid-target", &["tinycloud.kv/get"]) + .await?; + let (helper, helper_vm, helper_did) = registered_key(&fixture).await?; + let reshare = fixture + .grant( + "owner-only-cid-reshare", + &device_did, + &helper_did, + &[("notes/", "tinycloud.kv/get")], + None, + Some(whole_seconds_from_now(1800)), + &[target], + ) + .await?; + let empty = fixture + .grant( + "owner-only-cid-no-abilities", + &owner_did, + &device_did, + &[], + None, + Some(whole_seconds_from_now(3600)), + &[], + ) + .await?; + let unknown = tinycloud_core::hash::hash(b"owner-only-cid-unknown"); + let foreign = JWK::generate_ed25519()?; + let foreign_vm = tc341_verification_method(&foreign)?; + for (label, key, vm, cid) in [ + ("unrelated signer", &foreign, foreign_vm.as_str(), target), + ( + "target delegatee", + &fixture.device, + fixture.device_vm.as_str(), + target, + ), + ("re-share delegatee", &helper, helper_vm.as_str(), reshare), + ( + "intermediate delegator, not an owner", + &fixture.device, + fixture.device_vm.as_str(), + reshare, + ), + ( + "owner, unknown target", + &fixture.owner, + fixture.owner_vm.as_str(), + unknown, + ), + ( + "owner, target without abilities", + &fixture.owner, + fixture.owner_vm.as_str(), + empty, + ), + ] { + let header = fixture.signed_scope_token( + key, + vm, + &helper_did, + &format!("urn:cid:{}", cid.to_cid(0x55)), + "tinycloud.delegation/revoke", + whole_seconds_from_now(3600), + &[], + )?; + let (status, body, _) = activate(&fixture, header).await; + assert_eq!(status, Status::Unauthorized, "{label}: {body}"); + } + for cid in [target, reshare, empty] { + assert!(!stored_revoked(&fixture.conn, &cid).await?); + } + Ok(()) +} + // N4': revoke and status controls that name an encryption network (TC-1423). const REVOKE: &str = "tinycloud.delegation/revoke"; diff --git a/tinycloud-node-server/tests/siwe_issuer_identity.rs b/tinycloud-node-server/tests/siwe_issuer_identity.rs index e4ec3818..d2f0de09 100644 --- a/tinycloud-node-server/tests/siwe_issuer_identity.rs +++ b/tinycloud-node-server/tests/siwe_issuer_identity.rs @@ -888,15 +888,8 @@ async fn status_signed_invocation_cannot_revoke_but_revoke_signed_invocation_can .body(json!({"cid": target.to_string()}).to_string()), ) .await; - assert_eq!( - status_response, - if target == raw { - Status::Ok - } else { - Status::NotFound - }, - "{target}: {body}" - ); + // A canonical CID resolves to its stored historical encoding (TC-821). + assert_eq!(status_response, Status::Ok, "{target}: {body}"); let before_revocations = revocation::Entity::find().count(state.connection()).await?; let before_events = event_order::Entity::find() .count(state.connection()) @@ -925,11 +918,7 @@ async fn status_signed_invocation_cannot_revoke_but_revoke_signed_invocation_can .delegation_status(Hash::from(target), &owner, &[]) .await .map_err(|error| anyhow::anyhow!(error.to_string()))?, - if target == raw { - Some(DelegationStatus::Active) - } else { - None - } + Some(DelegationStatus::Active) ); } let revoke = make_invocation_from_uris( @@ -977,3 +966,181 @@ async fn status_signed_invocation_cannot_revoke_but_revoke_signed_invocation_can ); Ok(()) } + +/// TC-821: a revocation naming the unstored canonical CID enters the target +/// space's event history exactly as one naming the stored raw CID does, by the +/// owner directly and through an owner exact-CID revoke grant. +#[tokio::test] +async fn canonical_cid_revocations_are_sequenced_like_raw_ones() -> Result<()> { + use tinycloud_core::{ + hash::{hash, Hash}, + models::revocation, + relationships::event_order, + sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder}, + }; + + let fixture_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../tinycloud-core/tests/fixtures/tc1071/generated"); + let fixtures: Vec = + serde_json::from_slice(&std::fs::read(fixture_dir.join("manifest.json"))?)?; + let fixture = fixtures + .iter() + .find(|row| row["database"] == "single-v0.sqlite") + .ok_or_else(|| anyhow::anyhow!("missing genuine single-v0 fixture"))?; + let raw: Cid = fixture["rows"][0]["raw_cid"].as_str().unwrap().parse()?; + let canonical_header = fixture["canonical_header"].as_str().unwrap(); + let (_, canonical_bytes) = TinyCloudDelegation::decode(canonical_header)?; + let canonical = hash(&canonical_bytes).to_cid(0x55); + assert_ne!(raw, canonical); + let key = SigningKey::from_bytes(&[7; 32].into())?; + let public = key.verifying_key().to_encoded_point(false); + let address: [u8; 20] = Keccak256::digest(&public.as_bytes()[1..])[12..].try_into()?; + let owner = format!("did:pkh:eip155:1:0x{}", encode_eip55(&address)); + let wallet = Wallet { + key, + address, + space: SpaceId::new(owner.parse()?, "single-v0".parse()?), + }; + + for grant_path in [false, true] { + let mut placements = Vec::new(); + for target in [raw, canonical] { + let label = format!("{} {target}", if grant_path { "grant" } else { "direct" }); + let directory = tempfile::tempdir()?; + let path = directory.path().join("historical.sqlite"); + std::fs::copy(fixture_dir.join("single-v0.sqlite"), &path)?; + let (client, _data) = + client_with_database(&format!("sqlite:{}?mode=rwc", path.display()), [31; 32]) + .await?; + let state = client + .rocket() + .state::() + .expect("managed database"); + let target_row = event_order::Entity::find() + .filter(event_order::Column::Event.eq(Hash::from(raw))) + .one(state.connection()) + .await? + .ok_or_else(|| anyhow::anyhow!("raw grant has an event_order row"))?; + + let revocation_header = if grant_path { + let now = time::OffsetDateTime::now_utc(); + let prepared = prepare_session(SessionConfig { + abilities: HashMap::new(), + space_abilities: None, + raw_abilities: HashMap::from([( + format!("urn:cid:{target}"), + vec!["tinycloud.delegation/revoke".parse()?], + )]), + address: wallet.address, + chain_id: 1, + domain: "localhost".parse()?, + issued_at: now.into(), + space_id: wallet.space.clone(), + additional_spaces: None, + not_before: None, + expiration_time: (now + time::Duration::hours(1)).into(), + parents: None, + jwk: None, + delegate_uri: None, + nonce: None, + invocation_ttl_secs: None, + })?; + let signature = wallet.sign(&prepared.siwe)?; + let control = complete_session_setup(SignedSession { + session: prepared, + signature, + })?; + let activated: Value = serde_json::from_str( + &expect_ok( + &format!("{label}: owner exact-CID control"), + client.post("/delegate").header(Header::new( + "Authorization", + authorization(&control.delegation_header)?, + )), + ) + .await?, + )?; + let control_cid: Cid = activated["cid"].as_str().unwrap().parse()?; + make_invocation_from_uris( + [( + format!("urn:cid:{target}").parse()?, + ["tinycloud.delegation/revoke".parse()?], + )], + &control_cid, + &control.jwk, + &control.verification_method, + (time::OffsetDateTime::now_utc() + time::Duration::minutes(5)).unix_timestamp() + as f64, + InvocationOptions::default(), + )? + .encode()? + } else { + let (TinyCloudDelegation::Cacao(cacao), _) = + TinyCloudDelegation::decode(canonical_header)? + else { + panic!("historical CACAO"); + }; + let mut message: Message = cacao.payload().clone().try_into()?; + message.uri = format!("ucan:{target}").parse()?; + message.statement = None; + message.resources.clear(); + let signature = wallet.sign(&message)?; + TinyCloudRevocation::Cacao(Box::new( + tinycloud_auth::cacaos::siwe_cacao::Payload::from(message) + .sign::(signature), + )) + .encode()? + }; + + let before_revocations = revocation::Entity::find().count(state.connection()).await?; + let before_events = event_order::Entity::find() + .count(state.connection()) + .await?; + let space_head = event_order::Entity::find() + .filter(event_order::Column::Space.eq(target_row.space.clone())) + .order_by_desc(event_order::Column::Seq) + .one(state.connection()) + .await? + .expect("target space has events") + .seq; + let response: Value = serde_json::from_str( + &expect_ok( + &format!("{label}: revoke"), + client + .post("/revoke") + .header(Header::new("Authorization", revocation_header)), + ) + .await?, + )?; + assert_eq!(response["cid"], target.to_string(), "{label}"); + assert_eq!( + revocation::Entity::find().count(state.connection()).await?, + before_revocations + 1, + "{label}" + ); + assert_eq!( + event_order::Entity::find() + .count(state.connection()) + .await?, + before_events + 1, + "{label}: the revocation enters the event history" + ); + let appended = event_order::Entity::find() + .filter(event_order::Column::Seq.gt(space_head)) + .all(state.connection()) + .await?; + assert_eq!(appended.len(), 1, "{label}"); + assert_eq!(appended[0].space, target_row.space, "{label}: target space"); + assert!( + revocation::Entity::find_by_id(appended[0].event) + .one(state.connection()) + .await? + .is_some(), + "{label}: the appended event is the revocation" + ); + placements.push((appended[0].space.clone(), appended[0].seq - space_head)); + } + assert_eq!(placements[0], placements[1], "canonical ordered like raw"); + } + Ok(()) +}