diff --git a/CHANGELOG.md b/CHANGELOG.md index 339f6f6f..da049ca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Changes +- A delegator may revoke a delegation it issued when its session proof grants no revoke. Details: TC-2132 + ## [1.25.8] - 2026-10-26 ### Changes diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index c41dd934..e95d95f4 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -2227,7 +2227,8 @@ where revocation::ControlProofDecision::Scoped(control) => { control.authorizes_target(&target, &delegation, &abilities) } - revocation::ControlProofDecision::Denied => false, + revocation::ControlProofDecision::Ungranted + | revocation::ControlProofDecision::Denied => false, }; if !authorized { return Ok(None); diff --git a/tinycloud-core/src/models/revocation.rs b/tinycloud-core/src/models/revocation.rs index 44392011..21d4b279 100644 --- a/tinycloud-core/src/models/revocation.rs +++ b/tinycloud-core/src/models/revocation.rs @@ -285,6 +285,9 @@ impl ControlAction { pub(crate) enum ControlProofDecision { DirectSigner(String), Scoped(ScopedControl), + /// A valid proof that carries no grant for this action. It adds no + /// authority; the signer keeps only its own party authority. + Ungranted, Denied, } @@ -474,7 +477,7 @@ pub(crate) async fn control_proof_decision( } } if scopes.is_empty() { - return Ok(ControlProofDecision::Denied); + return Ok(ControlProofDecision::Ungranted); } Ok(ControlProofDecision::Scoped(ScopedControl { action, @@ -704,7 +707,15 @@ async fn revoker_is_authorized( .filter(abilities::Column::Delegation.eq(delegation.id)) .all(db) .await?; - return Ok(control.authorizes_target(requested, delegation, &target_abilities)); + if control.authorizes_target(requested, delegation, &target_abilities) { + return Ok(true); + } + // A proof that does not grant revoke leaves the signer's own + // party authority in force: the delegator of this delegation. + return Ok(ControlAction::Revoke.party_may_act(delegation, revoker)); + } + ControlProofDecision::Ungranted => { + return Ok(ControlAction::Revoke.party_may_act(delegation, revoker)) } ControlProofDecision::Denied => return Ok(false), }; @@ -990,4 +1001,215 @@ mod tests { assert_eq!(first_revoked_ancestor(&db, &first).await.unwrap(), None); } + + async fn insert_actor(db: &C, id: &str) { + actor::ActiveModel { + id: Set(id.to_string()), + } + .insert(db) + .await + .unwrap(); + } + + async fn insert_delegation_between( + db: &C, + id: Hash, + delegator: &str, + delegatee: &str, + ) { + delegation::ActiveModel { + id: Set(id), + delegator: Set(delegator.to_string()), + delegatee: Set(delegatee.to_string()), + expiry: Set(None), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(id.as_ref().to_vec()), + } + .insert(db) + .await + .unwrap(); + } + + async fn insert_revocation_of(db: &C, id: Hash, revoked: Hash) { + ActiveModel { + id: Set(id), + revoker: Set("did:key:owner".to_string()), + revoked: Set(revoked), + serialization: Set(id.as_ref().to_vec()), + revoked_at: Set(None), + } + .insert(db) + .await + .unwrap(); + } + + async fn revoker_may_revoke( + db: &sea_orm::DbConn, + target: Hash, + revoker: &str, + proof: Hash, + ) -> bool { + let target_row = delegation::Entity::find_by_id(target) + .one(db) + .await + .unwrap() + .unwrap(); + super::revoker_is_authorized(db, &target_row, &target, revoker, &[proof.to_cid(0x55)]) + .await + .unwrap() + } + + // Owner O grants session S a delegation P with no revoke ability. S + // sub-delegates target D to recipient X. S is the delegator of D, so S + // must be able to revoke D while carrying P as its proof. + #[tokio::test] + async fn delegator_revokes_sub_delegation_with_proof_that_grants_no_revoke() { + let db = database().await; + for did in ["did:key:owner", "did:key:session", "did:key:recipient"] { + insert_actor(&db, did).await; + } + let proof = hash(b"session-proof"); + let target = hash(b"sub-delegation"); + insert_delegation_between(&db, proof, "did:key:owner", "did:key:session").await; + insert_delegation_between(&db, target, "did:key:session", "did:key:recipient").await; + + assert!(revoker_may_revoke(&db, target, "did:key:session", proof).await); + } + + // A signer that is not the delegator of D gains nothing from carrying a + // proof that grants no revoke ability: its own party authority is absent. + #[tokio::test] + async fn non_delegator_cannot_revoke_with_proof_that_grants_no_revoke() { + let db = database().await; + for did in [ + "did:key:owner", + "did:key:session", + "did:key:recipient", + "did:key:other", + ] { + insert_actor(&db, did).await; + } + let proof = hash(b"other-proof"); + let target = hash(b"sub-delegation"); + insert_delegation_between(&db, proof, "did:key:owner", "did:key:other").await; + insert_delegation_between(&db, target, "did:key:session", "did:key:recipient").await; + + assert!(!revoker_may_revoke(&db, target, "did:key:other", proof).await); + } + + // A proof whose ancestor is revoked is invalid, so it is Denied rather + // than Ungranted and must not authorize even the delegator. + #[tokio::test] + async fn revoked_proof_chain_is_refused_even_for_delegator() { + let db = database().await; + for did in ["did:key:owner", "did:key:session", "did:key:recipient"] { + insert_actor(&db, did).await; + } + let proof = hash(b"session-proof"); + let ancestor = hash(b"revoked-ancestor"); + let target = hash(b"sub-delegation"); + insert_delegation_between(&db, ancestor, "did:key:owner", "did:key:owner").await; + insert_delegation_between(&db, proof, "did:key:owner", "did:key:session").await; + insert_delegation_between(&db, target, "did:key:session", "did:key:recipient").await; + insert_link(&db, proof, ancestor).await; + insert_revocation_of(&db, hash(b"revocation-of-ancestor"), ancestor).await; + + assert!(!revoker_may_revoke(&db, target, "did:key:session", proof).await); + } + + async fn insert_ability( + db: &C, + delegation: Hash, + resource: &str, + ability: &str, + ) { + abilities::ActiveModel { + delegation: Set(delegation), + resource: Set(resource.parse().unwrap()), + ability: Set(ability.to_string().try_into().unwrap()), + caveats: Set(crate::types::Caveats(Default::default())), + } + .insert(db) + .await + .unwrap(); + } + + // Valid did:key values are required: space resources parse only when the + // DID is real. + const OWNER_DID: &str = "did:key:z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK"; + const SESSION_DID: &str = "did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw"; + const RECIPIENT_DID: &str = "did:key:z6Mkg49NtQR2LyYRDCQFK4w1VVHqhypZSSRo7HsyuN7SV7v5"; + const OTHER_DID: &str = "did:key:z6MkjHi79SMX4doJyo2w7jDTM6Fa7iX6EVZWXNbJ6RzBm3Wi"; + + // Space resources are `tinycloud:key::/`: the + // parser re-adds the `did:` prefix to the segment before the space name. + fn space_resource(did: &str, rest: &str) -> String { + format!( + "tinycloud:{}:default/{rest}", + did.trim_start_matches("did:") + ) + } + + // Owner O grants session S revoke authority over O's space only. S + // sub-delegates D whose ability lives in another space, so the proof + // does not cover D. The Scoped fallthrough keeps S's party authority. + #[tokio::test] + async fn delegator_revokes_when_scoped_proof_covers_another_space() { + let db = database().await; + for did in [OWNER_DID, SESSION_DID, RECIPIENT_DID] { + insert_actor(&db, did).await; + } + let proof = hash(b"session-revoke-proof"); + let target = hash(b"sub-delegation"); + insert_delegation_between(&db, proof, OWNER_DID, SESSION_DID).await; + insert_ability( + &db, + proof, + &space_resource(OWNER_DID, "delegation"), + "tinycloud.delegation/revoke", + ) + .await; + insert_delegation_between(&db, target, SESSION_DID, RECIPIENT_DID).await; + insert_ability( + &db, + target, + &space_resource(RECIPIENT_DID, "kv/a"), + "tinycloud.kv/get", + ) + .await; + + assert!(revoker_may_revoke(&db, target, SESSION_DID, proof).await); + } + + // Same uncovered scope, but the signer is not D's delegator: the proof + // must not confer party authority on a non-party. + #[tokio::test] + async fn non_delegator_is_refused_when_scoped_proof_covers_another_space() { + let db = database().await; + for did in [OWNER_DID, SESSION_DID, RECIPIENT_DID, OTHER_DID] { + insert_actor(&db, did).await; + } + let proof = hash(b"other-revoke-proof"); + let target = hash(b"sub-delegation"); + insert_delegation_between(&db, proof, OWNER_DID, OTHER_DID).await; + insert_ability( + &db, + proof, + &space_resource(OWNER_DID, "delegation"), + "tinycloud.delegation/revoke", + ) + .await; + insert_delegation_between(&db, target, SESSION_DID, RECIPIENT_DID).await; + insert_ability( + &db, + target, + &space_resource(RECIPIENT_DID, "kv/a"), + "tinycloud.kv/get", + ) + .await; + + assert!(!revoker_may_revoke(&db, target, OTHER_DID, proof).await); + } } diff --git a/tinycloud-node-server/src/routes/tests/revocation_scope.rs b/tinycloud-node-server/src/routes/tests/revocation_scope.rs index 3eb9748a..5b57cf92 100644 --- a/tinycloud-node-server/src/routes/tests/revocation_scope.rs +++ b/tinycloud-node-server/src/routes/tests/revocation_scope.rs @@ -1065,6 +1065,242 @@ async fn issuing_delegator_and_owner_revoke_a_descendant_its_recipient_cannot() Ok(()) } +/// The share-link shape: the owner grants a session key read on `notes/`, +/// the session delegates that read to another did, and revokes that +/// sub-delegation with its own session delegation as a proof that grants no +/// revoke ability. The recipient reads before the revoke and is refused after +/// it, while the owner and device keep reading. +#[tokio::test] +async fn session_delegator_revokes_its_sub_delegation_with_grantless_proof() -> Result<()> { + let fixture = scoped_fixture().await?; + fixture.owner_write("notes/a", Some(b"a1")).await?; + let uri = fixture.kv_resource("notes/a")?.as_uri().to_string(); + let device_read = fixture + .device_grant("device-read", &["tinycloud.kv/get"]) + .await?; + let (session, session_vm, session_did) = registered_key(&fixture).await?; + let (recipient, recipient_vm, recipient_did) = registered_key(&fixture).await?; + let owner_did = fixture.space.did().to_string(); + let session_grant = fixture + .grant( + "session-read-no-revoke", + &owner_did, + &session_did, + &[("notes/", "tinycloud.kv/get")], + None, + Some(whole_seconds_from_now(3600)), + &[], + ) + .await?; + let sub_delegation = fixture + .grant( + "session-sub-delegation", + &session_did, + &recipient_did, + &[("notes/", "tinycloud.kv/get")], + None, + Some(whole_seconds_from_now(1800)), + &[session_grant], + ) + .await?; + // Each call signs a fresh nonce, so a repeated read is not a replay. + let read_header = || { + fixture.signed_caps_token( + &recipient, + &recipient_vm, + &recipient_did, + &[(uri.as_str(), "tinycloud.kv/get")], + whole_seconds_from_now(120), + &[sub_delegation.to_cid(0x55)], + ) + }; + let before = fixture.dispatch(read_header()?, &[]).await; + assert_eq!( + before.status, + Status::Ok, + "recipient read before revoke: {}", + before.body + ); + assert_eq!(before.body, "a1"); + + let revoke = fixture.signed_scope_token( + &session, + &session_vm, + &session_did, + &format!("urn:cid:{}", sub_delegation.to_cid(0x55)), + "tinycloud.delegation/revoke", + whole_seconds_from_now(3600), + &[session_grant.to_cid(0x55)], + )?; + assert_revoked_ok(&fixture, sub_delegation, revoke).await?; + + let after = fixture.dispatch(read_header()?, &[]).await; + assert_eq!( + after.status, + Status::Unauthorized, + "recipient read after revoke: {}", + after.body + ); + + let owner = fixture + .dispatch( + fixture.owner_invocation(&[(uri.clone(), "tinycloud.kv/get")])?, + &[], + ) + .await; + assert_eq!(owner.status, Status::Ok, "owner read: {}", owner.body); + assert_eq!(owner.body, "a1"); + let device = fixture + .dispatch( + fixture.device_invocation(&[("notes/a", "tinycloud.kv/get")], device_read)?, + &[], + ) + .await; + assert_eq!(device.status, Status::Ok, "device read: {}", device.body); + assert_eq!(device.body, "a1"); + Ok(()) +} + +/// Owner -> session -> recipient: `(session_grant, sub_delegation)`, the +/// shared shape of the refusal tests below. +async fn session_sub_delegation( + fixture: &KvSyncFixture, + session_did: &str, + recipient_did: &str, + session_grant_expiry: OffsetDateTime, +) -> Result<(Hash, Hash)> { + let owner_did = fixture.space.did().to_string(); + let session_grant = fixture + .grant( + "refusal-session-grant", + &owner_did, + session_did, + &[("notes/", "tinycloud.kv/get")], + None, + Some(session_grant_expiry), + &[], + ) + .await?; + let sub_delegation = fixture + .grant( + "refusal-sub-delegation", + session_did, + recipient_did, + &[("notes/", "tinycloud.kv/get")], + None, + Some(whole_seconds_from_now(1800)), + &[session_grant], + ) + .await?; + Ok((session_grant, sub_delegation)) +} + +/// The recipient of D presents D itself as its proof. D grants no revoke, so +/// the proof is Ungranted and only the recipient's own party authority is +/// left, which excludes revoking what it received. +#[tokio::test] +async fn recipient_presenting_the_target_as_its_proof_cannot_revoke_it() -> Result<()> { + let fixture = scoped_fixture().await?; + let (_session, _session_vm, session_did) = registered_key(&fixture).await?; + let (recipient, recipient_vm, recipient_did) = registered_key(&fixture).await?; + let (_, sub_delegation) = session_sub_delegation( + &fixture, + &session_did, + &recipient_did, + whole_seconds_from_now(3600), + ) + .await?; + let header = fixture.signed_scope_token( + &recipient, + &recipient_vm, + &recipient_did, + &format!("urn:cid:{}", sub_delegation.to_cid(0x55)), + "tinycloud.delegation/revoke", + whole_seconds_from_now(3600), + &[sub_delegation.to_cid(0x55)], + )?; + fixture.assert_scope_denied(sub_delegation, header).await +} + +/// The delegator's own session proof has expired, so the proof is Denied, not +/// Ungranted: the with-proof form is refused. The proofless form, which needs +/// no proof, still works for the same signer. +#[tokio::test] +async fn delegator_presenting_an_expired_proof_cannot_revoke_with_it() -> Result<()> { + let fixture = scoped_fixture().await?; + let (session, session_vm, session_did) = registered_key(&fixture).await?; + let (_recipient, _recipient_vm, recipient_did) = registered_key(&fixture).await?; + let (session_grant, sub_delegation) = session_sub_delegation( + &fixture, + &session_did, + &recipient_did, + whole_seconds_from_now(-60), + ) + .await?; + let with_proof = fixture.signed_scope_token( + &session, + &session_vm, + &session_did, + &format!("urn:cid:{}", sub_delegation.to_cid(0x55)), + "tinycloud.delegation/revoke", + whole_seconds_from_now(3600), + &[session_grant.to_cid(0x55)], + )?; + fixture + .assert_scope_denied(sub_delegation, with_proof) + .await?; + + let proofless = fixture.signed_scope_token( + &session, + &session_vm, + &session_did, + &format!("urn:cid:{}", sub_delegation.to_cid(0x55)), + "tinycloud.delegation/revoke", + whole_seconds_from_now(3600), + &[], + )?; + assert_revoked_ok(&fixture, sub_delegation, proofless).await +} + +/// The space owner presents a valid proof that grants no revoke for a +/// delegation it did not issue. Ungranted carries only party authority, and +/// the owner is not the delegator, so the revocation is refused. +#[tokio::test] +async fn owner_with_a_grantless_proof_cannot_revoke_a_delegation_it_did_not_issue() -> Result<()> { + let fixture = scoped_fixture().await?; + let (_session, _session_vm, session_did) = registered_key(&fixture).await?; + let (_recipient, _recipient_vm, recipient_did) = registered_key(&fixture).await?; + let (_, sub_delegation) = session_sub_delegation( + &fixture, + &session_did, + &recipient_did, + whole_seconds_from_now(3600), + ) + .await?; + let owner_did = fixture.space.did().to_string(); + let owner_proof = fixture + .grant( + "refusal-owner-grantless-proof", + &session_did, + &owner_did, + &[("notes/", "tinycloud.kv/get")], + None, + Some(whole_seconds_from_now(3600)), + &[], + ) + .await?; + let header = fixture.signed_scope_token( + &fixture.owner, + &fixture.owner_vm, + &owner_did, + &format!("urn:cid:{}", sub_delegation.to_cid(0x55)), + "tinycloud.delegation/revoke", + whole_seconds_from_now(3600), + &[owner_proof.to_cid(0x55)], + )?; + fixture.assert_scope_denied(sub_delegation, header).await +} + /// POST `/delegate` and return the status, body and receipt CID. async fn activate(fixture: &KvSyncFixture, header: String) -> (Status, String, Option) { let response = fixture @@ -1810,3 +2046,46 @@ async fn network_status_control_follows_the_revoke_scope() -> Result<()> { ); Ok(()) } + +/// A non-party carrying a valid proof that grants no status ability gets no +/// status: the proof is Ungranted for Status, and Ungranted is refused. +#[tokio::test] +async fn non_party_with_grantless_proof_gets_no_delegation_status() -> Result<()> { + use tinycloud_core::db::DelegationStatus; + let fixture = scoped_fixture().await?; + let device_did = fixture.device_did(); + let (_, _, other_did) = registered_key(&fixture).await?; + let (_, _, recipient_did) = registered_key(&fixture).await?; + let target = fixture + .grant( + "status-target", + &device_did, + &recipient_did, + &[("notes/", "tinycloud.kv/get")], + None, + Some(whole_seconds_from_now(1800)), + &[], + ) + .await?; + let proof = fixture + .grant( + "status-proof-read-only", + &device_did, + &other_did, + &[("notes/", "tinycloud.kv/get")], + None, + Some(whole_seconds_from_now(3600)), + &[], + ) + .await?; + assert_eq!( + fixture.status_with(target, &device_did, &[]).await?, + Some(DelegationStatus::Active), + "delegator status control" + ); + assert_eq!( + fixture.status_with(target, &other_did, &[proof]).await?, + None + ); + Ok(()) +}