From fdbe6878c46fa59459f4779fd2a7e43dd5caf116 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 14:44:05 +0000 Subject: [PATCH 1/4] test(TC-821): cover owner exact-CID revoke grants and canonical CID targets --- .../tests/historical_cacao_revocation.rs | 205 +++++++++++++++++- .../src/routes/tests/revocation_scope.rs | 134 ++++++++++++ 2 files changed, 338 insertions(+), 1 deletion(-) 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 757101de..296c5d11 100644 --- a/tinycloud-node-server/src/routes/tests/revocation_scope.rs +++ b/tinycloud-node-server/src/routes/tests/revocation_scope.rs @@ -1021,3 +1021,137 @@ async fn issuing_delegator_and_owner_revoke_a_descendant_its_recipient_cannot() assert!(!stored_revoked(&fixture.conn, &parent).await?); 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(()) +} From 248f419458017d3837d7f98344ed2151ce3c7a2a Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 15:02:42 +0000 Subject: [PATCH 2/4] feat(TC-821): owner exact-CID revoke grants and canonical CID targets --- CHANGELOG.md | 2 + tinycloud-core/src/db.rs | 14 +- tinycloud-core/src/models/delegation.rs | 14 +- tinycloud-core/src/models/revocation.rs | 168 ++++++++++++++---- .../tests/siwe_issuer_identity.rs | 17 +- 5 files changed, 154 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5624c151..613b9f5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Changes - `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). +- 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.0] - 2026-10-18 diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index b91cd49c..42c45e09 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -1881,18 +1881,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, @@ -1933,7 +1935,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) => { 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 c5b771c9..10405536 100644 --- a/tinycloud-core/src/models/revocation.rs +++ b/tinycloud-core/src/models/revocation.rs @@ -72,13 +72,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) @@ -388,25 +393,40 @@ pub(crate) async fn control_proof_decision( } } - let mut control_abilities = abilities::Entity::find() + let control_abilities = abilities::Entity::find() .filter(abilities::Column::Delegation.eq(proof_id)) .filter(abilities::Column::Ability.eq(action.ability())) .all(db) .await?; - let target_resource = format!("urn:cid:{}", target.to_cid(0x55)); - control_abilities.retain(|ability| match &ability.resource { - Resource::Other(resource) => resource.as_str() == target_resource, - Resource::TinyCloud(resource) => { - resource.service().as_str() == "delegation" - && resource - .path() - .map(|path| path.as_str().is_empty()) - .unwrap_or(true) - && resource.query().is_none() - && resource.fragment().is_none() - && did_principal_matches(resource.space().did().as_str(), &parent.delegator) + let target_canonical = canonical_identity(db, *target).await?; + let mut matching = Vec::with_capacity(control_abilities.len()); + for ability in control_abilities { + let matches = match &ability.resource { + // An exact-CID grant covers every stored encoding of its target. + 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(resource) => { + resource.service().as_str() == "delegation" + && resource + .path() + .map(|path| path.as_str().is_empty()) + .unwrap_or(true) + && resource.query().is_none() + && resource.fragment().is_none() + && did_principal_matches(resource.space().did().as_str(), &parent.delegator) + } + }; + if matches { + matching.push(ability); } - }); + } + let control_abilities = matching; if control_abilities.is_empty() { return Ok(ControlProofDecision::Denied); } @@ -418,6 +438,74 @@ 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. +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)) + .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)] @@ -482,8 +570,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)?; @@ -496,7 +584,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()); }; @@ -545,30 +633,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-node-server/tests/siwe_issuer_identity.rs b/tinycloud-node-server/tests/siwe_issuer_identity.rs index e4ec3818..b27bb405 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( From 5c8175cc95b23b92f93293aea69b5dd692802886 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 15:35:31 +0000 Subject: [PATCH 3/4] test(TC-821): canonical-CID revocations enter the target space's event history --- .../tests/siwe_issuer_identity.rs | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) diff --git a/tinycloud-node-server/tests/siwe_issuer_identity.rs b/tinycloud-node-server/tests/siwe_issuer_identity.rs index b27bb405..d2f0de09 100644 --- a/tinycloud-node-server/tests/siwe_issuer_identity.rs +++ b/tinycloud-node-server/tests/siwe_issuer_identity.rs @@ -966,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(()) +} From 794d101db0ae2c4c961bee55a70802a605c12372 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 15:43:28 +0000 Subject: [PATCH 4/4] fix(TC-821): sequence canonical-CID revocations in the target's spaces sequence_spaces and event_spaces resolve each revocation's signed target through revocation::target_for_cid before the event_order lookup, so a revocation naming an unstored canonical CID takes the space sequence lock and appends to event_order like one naming the stored row. target_for_cid picks the lowest stored alias ID deterministically. --- tinycloud-core/src/db.rs | 40 ++++++++++++++++++------- tinycloud-core/src/models/revocation.rs | 5 +++- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 42c45e09..ec75e86f 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -3073,8 +3073,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? { @@ -3084,19 +3088,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 @@ -3105,7 +3119,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? }; @@ -3144,9 +3158,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/revocation.rs b/tinycloud-core/src/models/revocation.rs index 10405536..1f2e4901 100644 --- a/tinycloud-core/src/models/revocation.rs +++ b/tinycloud-core/src/models/revocation.rs @@ -451,7 +451,9 @@ async fn canonical_identity(db: &C, id: Hash) -> Result( db: &C, target: Hash, @@ -461,6 +463,7 @@ pub(crate) async fn target_for_cid( } 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 {