From 9998fef3a78c6b47661ccb0fa2d45603c150dcb5 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Wed, 14 Oct 2026 15:51:10 +0000 Subject: [PATCH 01/20] fix(TC-1071): canonicalize CACAO recovery bytes before CID --- CHANGELOG.md | 2 + Cargo.lock | 1 + dependencies/cacao/Cargo.toml | 1 + dependencies/cacao/src/siwe_cacao.rs | 29 ++++++++- tinycloud-auth/src/authorization.rs | 9 ++- tinycloud-core/src/events/mod.rs | 91 ++++++++++++++++++++++++++++ tinycloud-verifier-wasm/src/lib.rs | 4 +- 7 files changed, 129 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e63159..996b39be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- CACAO/SIWE delegation ingestion normalizes ECDSA recovery IDs 0/1 to 27/28 before hashing and persisting the canonical DAG-CBOR bytes (TC-1071). Equivalent low-s signatures now share one delegation CID, including the CID used for revocation lookup. High-s signatures are rejected during decoding, matching the existing EIP-191 verifier and EIP-2 low-s convention; UCAN/JWS bytes remain unchanged. Existing stored delegation IDs are not rewritten. + ## [1.20.6] - 2026-10-14 A security fix (TC-1080, Part A). UCAN issuer resolution no longer dereferences network DID methods by default, so a request can't make the node fetch arbitrary or internal hosts through a crafted issuer DID (#284). diff --git a/Cargo.lock b/Cargo.lock index 60c674f2..f28f6a00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2156,6 +2156,7 @@ dependencies = [ "http 1.4.0", "ipld-core", "iri-string", + "k256", "serde", "serde_bytes", "serde_ipld_dagcbor 0.6.4", diff --git a/dependencies/cacao/Cargo.toml b/dependencies/cacao/Cargo.toml index 18a343d7..3ecbcf23 100755 --- a/dependencies/cacao/Cargo.toml +++ b/dependencies/cacao/Cargo.toml @@ -24,6 +24,7 @@ serde_ipld_dagcbor.workspace = true serde_with.workspace = true time.workspace = true http.workspace = true +k256 = "0.13" hex = { workspace = true, optional = true } serde_bytes = "0.11" serde_repr = "0.1" diff --git a/dependencies/cacao/src/siwe_cacao.rs b/dependencies/cacao/src/siwe_cacao.rs index dbf44815..82432860 100755 --- a/dependencies/cacao/src/siwe_cacao.rs +++ b/dependencies/cacao/src/siwe_cacao.rs @@ -193,10 +193,26 @@ impl AsRef<[u8]> for Signature { } } +impl Signature { + fn decode(mut bytes: [u8; 65]) -> Result { + let signature = k256::ecdsa::Signature::from_slice(&bytes[..64]) + .map_err(|_| SIWESignatureDecodeError::InvalidSignature)?; + if signature.normalize_s().is_some() { + return Err(SIWESignatureDecodeError::NonCanonicalS); + } + bytes[64] = match bytes[64] { + 0 | 1 => bytes[64] + 27, + 27 | 28 => bytes[64], + _ => return Err(SIWESignatureDecodeError::InvalidRecoveryId(bytes[64])), + }; + Ok(Self(bytes)) + } +} + impl TryFrom> for Signature { type Error = SIWESignatureDecodeError; fn try_from(s: Vec) -> Result { - Ok(Self(s.try_into().map_err(SIWESignatureDecodeError::from)?)) + Self::decode(s.try_into().map_err(SIWESignatureDecodeError::from)?) } } @@ -206,6 +222,12 @@ pub enum SIWESignatureDecodeError { InvalidLength(usize), #[error("Invalid Type, expected 'eip191', got {0}")] InvalidType(String), + #[error("Invalid ECDSA signature")] + InvalidSignature, + #[error("Non-canonical high-s ECDSA signature")] + NonCanonicalS, + #[error("Invalid recovery ID: {0}")] + InvalidRecoveryId(u8), } impl From> for SIWESignatureDecodeError { @@ -286,11 +308,12 @@ impl<'de> Deserialize<'de> for Signature { SIWESignatureDecodeError::InvalidLength(l), )); } - Ok(Signature( + Signature::decode( ds.s.try_into() .map_err(|_| SIWESignatureDecodeError::InvalidLength(l)) .map_err(serde::de::Error::custom)?, - )) + ) + .map_err(serde::de::Error::custom) } } diff --git a/tinycloud-auth/src/authorization.rs b/tinycloud-auth/src/authorization.rs index 0baee950..b03969c4 100644 --- a/tinycloud-auth/src/authorization.rs +++ b/tinycloud-auth/src/authorization.rs @@ -48,11 +48,14 @@ impl HeaderEncode for TinyCloudDelegation { s.as_bytes().to_vec(), ) } else { - // Use the imported engine and trait method + // The signature decoder canonicalizes v; serialize the parsed CACAO so + // the bytes hashed, persisted, and looked up by revocation agree. let v = URL_SAFE.decode(s)?; + let cacao: SiweCacao = serde_ipld_dagcbor::from_slice(&v)?; + let canonical = serde_ipld_dagcbor::to_vec(&cacao)?; ( - Self::Cacao(Box::new(serde_ipld_dagcbor::from_slice(&v)?)), - v, + Self::Cacao(Box::new(cacao)), + if canonical == v { v } else { canonical }, ) }) } diff --git a/tinycloud-core/src/events/mod.rs b/tinycloud-core/src/events/mod.rs index 8b3d99f1..dab7a329 100644 --- a/tinycloud-core/src/events/mod.rs +++ b/tinycloud-core/src/events/mod.rs @@ -251,3 +251,94 @@ fn hash_inv(inv_hash: &Hash, sp: &SpaceId, ops: &[Operation]) -> Result(Signature::from(signature)); + let header = URL_SAFE.encode(serde_ipld_dagcbor::to_vec(&cacao).unwrap()); + let event = Delegation::from_header_ser::(&header); + if label.starts_with("high") { + assert!( + event.is_err(), + "{label} must be rejected before CID assignment" + ); + continue; + } + let event = event.unwrap(); + let TinyCloudDelegation::Cacao(decoded) = &event.0.delegation else { + panic!("expected CACAO"); + }; + assert!(decoded.verify().await.is_ok(), "{label}"); + assert_eq!( + decoded.signature().as_ref(), + &low, + "{label} decoded signature" + ); + let cid = event.content_hash().to_cid(0x55); + if let Some(canonical_cid) = canonical_cid { + assert_eq!(cid, canonical_cid, "{label} must use the canonical CID"); + } else { + canonical_cid = Some(cid); + } + } + } +} diff --git a/tinycloud-verifier-wasm/src/lib.rs b/tinycloud-verifier-wasm/src/lib.rs index 789004d5..c907949c 100644 --- a/tinycloud-verifier-wasm/src/lib.rs +++ b/tinycloud-verifier-wasm/src/lib.rs @@ -675,8 +675,8 @@ mod tests { fn mutate_signature(signature: &str) -> String { let mut chars: Vec = signature.chars().collect(); - let last = chars.last_mut().expect("signature length"); - *last = if *last == '0' { '1' } else { '0' }; + let nibble = &mut chars[if signature.starts_with("0x") { 2 } else { 0 }]; + *nibble = if *nibble == '0' { '1' } else { '0' }; chars.into_iter().collect() } From 63b66ceebbe4ed78223b064521c867e0a3910ac0 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Wed, 14 Oct 2026 16:09:45 +0000 Subject: [PATCH 02/20] fix(ci): sync standalone Cargo locks for cacao k256 (TC-1071) --- CHANGELOG.md | 1 + test/load/signer/Cargo.lock | 1 + test/m1-realdata-e2e/Cargo.lock | 1 + test/w5-policy-runtime-node-e2e/Cargo.lock | 3 ++- 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 996b39be..7f9913e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - CACAO/SIWE delegation ingestion normalizes ECDSA recovery IDs 0/1 to 27/28 before hashing and persisting the canonical DAG-CBOR bytes (TC-1071). Equivalent low-s signatures now share one delegation CID, including the CID used for revocation lookup. High-s signatures are rejected during decoding, matching the existing EIP-191 verifier and EIP-2 low-s convention; UCAN/JWS bytes remain unchanged. Existing stored delegation IDs are not rewritten. +- Synchronize the standalone M1, W5 and load-signer Cargo locks with CACAO's `k256` dependency so `--locked` dependency resolution succeeds (TC-1071). ## [1.20.6] - 2026-10-14 diff --git a/test/load/signer/Cargo.lock b/test/load/signer/Cargo.lock index e42c7a6e..88523006 100644 --- a/test/load/signer/Cargo.lock +++ b/test/load/signer/Cargo.lock @@ -580,6 +580,7 @@ dependencies = [ "http 1.3.1", "ipld-core", "iri-string 0.7.8", + "k256 0.13.4", "serde", "serde_bytes", "serde_ipld_dagcbor", diff --git a/test/m1-realdata-e2e/Cargo.lock b/test/m1-realdata-e2e/Cargo.lock index e6a7cf42..c2df9b23 100644 --- a/test/m1-realdata-e2e/Cargo.lock +++ b/test/m1-realdata-e2e/Cargo.lock @@ -1020,6 +1020,7 @@ dependencies = [ "http 1.4.0", "ipld-core", "iri-string", + "k256", "serde", "serde_bytes", "serde_ipld_dagcbor 0.6.4", diff --git a/test/w5-policy-runtime-node-e2e/Cargo.lock b/test/w5-policy-runtime-node-e2e/Cargo.lock index 37974968..e3619958 100644 --- a/test/w5-policy-runtime-node-e2e/Cargo.lock +++ b/test/w5-policy-runtime-node-e2e/Cargo.lock @@ -1249,6 +1249,7 @@ dependencies = [ "http 1.4.0", "ipld-core", "iri-string", + "k256", "serde", "serde_bytes", "serde_ipld_dagcbor 0.6.4", @@ -8231,7 +8232,7 @@ dependencies = [ [[package]] name = "tinycloud-node" -version = "1.20.4" +version = "1.20.6" dependencies = [ "aes-gcm", "anyhow", From 2fad7176b87f6c8a0c2abdc9a607142b63154c71 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Wed, 14 Oct 2026 16:37:06 +0000 Subject: [PATCH 03/20] fix: canonicalize CACAO issuers to verified SIWE identity --- dependencies/cacao/src/siwe_cacao.rs | 40 +- tinycloud-auth/src/authorization.rs | 5 +- .../tests/siwe_issuer_identity.rs | 552 ++++++++++++++++++ 3 files changed, 589 insertions(+), 8 deletions(-) create mode 100644 tinycloud-node-server/tests/siwe_issuer_identity.rs diff --git a/dependencies/cacao/src/siwe_cacao.rs b/dependencies/cacao/src/siwe_cacao.rs index 82432860..e5a4abf7 100755 --- a/dependencies/cacao/src/siwe_cacao.rs +++ b/dependencies/cacao/src/siwe_cacao.rs @@ -25,6 +25,9 @@ pub struct Payload { pub scheme: Option, #[serde_as(as = "DisplayFromStr")] pub domain: Authority, + /// Serialized as the verifier's parsed chain ID and EIP-55 address. + /// This changes only the DID representation, never the signed SIWE text. + #[serde(serialize_with = "serialize_issuer")] pub iss: UriAbsoluteString, pub statement: Option, pub aud: UriString, @@ -119,15 +122,40 @@ pub enum SIWEPayloadConversionError { InvalidDID, } +fn parse_issuer(issuer: &str) -> Result<(u64, [u8; 20]), SIWEPayloadConversionError> { + let mut parts = issuer.split(':'); + match [ + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + ] { + [Some("did"), Some("pkh"), Some("eip155"), Some(chain), Some(address), None] + if address.starts_with("0x") => + { + Ok((chain.parse()?, FromHex::from_hex(&address[2..])?)) + } + _ => Err(SIWEPayloadConversionError::InvalidDID), + } +} + +fn serialize_issuer(issuer: &UriAbsoluteString, serializer: S) -> Result +where + S: serde::Serializer, +{ + let (chain, address) = parse_issuer(issuer.as_str()).map_err(serde::ser::Error::custom)?; + serializer.collect_str(&format_args!( + "did:pkh:eip155:{chain}:0x{}", + encode_eip55(&address) + )) +} + impl TryInto for Payload { type Error = SIWEPayloadConversionError; fn try_into(self) -> Result { - let (chain_id, address) = match &self.iss.as_str().split(':').collect::>()[..] { - &["did", "pkh", "eip155", c, h] if h.get(..2) == Some("0x") => { - (c.parse()?, FromHex::from_hex(&h[2..])?) - } - _ => return Err(Self::Error::InvalidDID), - }; + let (chain_id, address) = parse_issuer(self.iss.as_str())?; Ok(Message { scheme: self.scheme, domain: self.domain, diff --git a/tinycloud-auth/src/authorization.rs b/tinycloud-auth/src/authorization.rs index b03969c4..ab3a419c 100644 --- a/tinycloud-auth/src/authorization.rs +++ b/tinycloud-auth/src/authorization.rs @@ -48,8 +48,9 @@ impl HeaderEncode for TinyCloudDelegation { s.as_bytes().to_vec(), ) } else { - // The signature decoder canonicalizes v; serialize the parsed CACAO so - // the bytes hashed, persisted, and looked up by revocation agree. + // Signature decoding canonicalizes v; payload serialization uses the + // verifier's parsed issuer. Hash, persistence and revocation lookup + // must all use these same canonical DAG-CBOR bytes. let v = URL_SAFE.decode(s)?; let cacao: SiweCacao = serde_ipld_dagcbor::from_slice(&v)?; let canonical = serde_ipld_dagcbor::to_vec(&cacao)?; diff --git a/tinycloud-node-server/tests/siwe_issuer_identity.rs b/tinycloud-node-server/tests/siwe_issuer_identity.rs new file mode 100644 index 00000000..45a19431 --- /dev/null +++ b/tinycloud-node-server/tests/siwe_issuer_identity.rs @@ -0,0 +1,552 @@ +//! TC-1071: changing only the CACAO issuer's address casing must not resurrect +//! a revoked, valid SIWE grant. All requests use throwaway keys and the real app. +use std::collections::HashMap; + +use anyhow::{ensure, Result}; +use k256::ecdsa::SigningKey; +use rocket::{ + figment::providers::Serialized, + http::{ContentType, Header, Status}, + local::asynchronous::{Client, LocalRequest}, +}; +use serde_json::{json, Value}; +use sha3::{Digest, Keccak256}; +use tinycloud_auth::{ + authorization::{ + make_invocation_from_uris, Cid, HeaderEncode, InvocationOptions, TinyCloudDelegation, + TinyCloudRevocation, + }, + cacaos::{ + siwe::{encode_eip55, Message}, + siwe_cacao::Signature, + }, + ipld_core::ipld::Ipld, + resource::{Path, Service, SpaceId}, +}; +use tinycloud_sdk_wasm::{ + host::{generate_host_siwe_message, siwe_to_delegation_headers, HostConfig, SignedMessage}, + session::{complete_session_setup, prepare_session, Session, SessionConfig, SignedSession}, +}; + +struct Wallet { + key: SigningKey, + address: [u8; 20], + space: SpaceId, +} + +impl Wallet { + fn new() -> Result { + let key = SigningKey::from_bytes(&[37; 32].into())?; + let public = key.verifying_key().to_encoded_point(false); + let digest = Keccak256::digest(&public.as_bytes()[1..]); + let address = digest[12..].try_into()?; + let did = format!("did:pkh:eip155:1:0x{}", encode_eip55(&address)); + Ok(Self { + key, + address, + space: SpaceId::new(did.parse()?, "issuer-case".parse()?), + }) + } + + fn sign(&self, message: &Message) -> Result { + let (signature, recovery) = self.key.sign_prehash_recoverable(&message.eip191_hash()?)?; + let mut bytes = [0; 65]; + bytes[..64].copy_from_slice(signature.to_bytes().as_ref()); + bytes[64] = u8::from(recovery) + 27; + Ok(Signature::from(bytes)) + } + + fn session(&self) -> Result { + let now = time::OffsetDateTime::now_utc(); + let prepared = prepare_session(SessionConfig { + abilities: HashMap::from([( + "kv".parse()?, + HashMap::from([("".parse()?, vec!["tinycloud.kv/list".parse()?])]), + )]), + space_abilities: None, + raw_abilities: HashMap::new(), + address: self.address, + chain_id: 1, + domain: "localhost".parse()?, + issued_at: now.into(), + space_id: self.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 = self.sign(&prepared.siwe)?; + Ok(complete_session_setup(SignedSession { + session: prepared, + signature, + })?) + } +} + +fn authorization(headers: &impl serde::Serialize) -> Result { + Ok(serde_json::to_value(headers)?["Authorization"] + .as_str() + .unwrap() + .to_owned()) +} + +fn wire(value: &Ipld) -> Result { + Ok(base64::encode_config( + serde_ipld_dagcbor::to_vec(value)?, + base64::URL_SAFE, + )) +} + +fn wire_value(header: &str) -> Result { + Ok(serde_ipld_dagcbor::from_slice(&base64::decode_config( + header, + base64::URL_SAFE, + )?)?) +} + +fn payload(value: &mut Ipld) -> &mut std::collections::BTreeMap { + let Ipld::Map(cacao) = value else { + panic!("CACAO map") + }; + let Ipld::Map(payload) = cacao.get_mut("p").unwrap() else { + panic!("payload map") + }; + payload +} + +async fn dispatch(request: LocalRequest<'_>) -> (Status, String) { + let response = request.dispatch().await; + ( + response.status(), + response.into_string().await.unwrap_or_default(), + ) +} + +async fn expect_ok(label: &str, request: LocalRequest<'_>) -> Result { + let (status, body) = dispatch(request).await; + ensure!(status == Status::Ok, "{label}: {status} {body}"); + Ok(body) +} + +async fn client() -> Result<(Client, tempfile::TempDir)> { + let dir = tempfile::tempdir()?; + let mut config = tinycloud::config::Config::default(); + config.storage.datadir = dir.path().into(); + config.storage.database = Some("sqlite::memory:".into()); + config.keys = serde_json::from_value( + json!({"type":"static", "secret":base64::encode_config([43; 32], base64::URL_SAFE_NO_PAD)}), + )?; + let figment = rocket::figment::Figment::from(rocket::Config { + address: "127.0.0.1".parse()?, + log_level: rocket::config::LogLevel::Off, + ..rocket::Config::default() + }) + .merge(Serialized::defaults(&config)); + let rocket = tinycloud::app_with_control(&figment, &config, None).await?; + Ok((Client::tracked(rocket).await?, dir)) +} + +async fn bootstrap(client: &Client, wallet: &Wallet) -> Result<()> { + let peer = expect_ok( + "peer", + client.get(format!("/peer/generate/{}", wallet.space)), + ) + .await?; + let message = generate_host_siwe_message(HostConfig { + address: wallet.address, + chain_id: 1, + domain: "localhost".parse()?, + issued_at: time::OffsetDateTime::now_utc().into(), + space_id: wallet.space.clone(), + peer_id: peer, + })?; + let signature = wallet.sign(&message)?; + let header = authorization(&siwe_to_delegation_headers(SignedMessage { + siwe: message, + signature, + }))?; + expect_ok( + "host grant", + client + .post("/delegate") + .header(Header::new("Authorization", header)), + ) + .await?; + Ok(()) +} + +fn list_header(session: &Session, cid: Cid) -> Result { + let mut invocation = session.clone(); + invocation.delegation_cid = cid; + Ok(invocation + .invoke( + [( + "kv".parse::()?, + "".parse::()?, + None, + None, + ["tinycloud.kv/list".parse()?], + )], + None, + )? + .encode()?) +} + +async fn status(client: &Client, session: &Session, cid: &str) -> Result { + let invocation = make_invocation_from_uris( + [( + format!("urn:cid:{cid}").parse()?, + ["tinycloud.delegation/status".parse()?], + )], + &cid.parse()?, + &session.jwk, + &session.verification_method, + (time::OffsetDateTime::now_utc() + time::Duration::minutes(5)).unix_timestamp() as f64, + InvocationOptions { + proof: Some(vec![]), + ..InvocationOptions::default() + }, + )? + .encode()?; + let body = expect_ok( + "status", + client + .post("/delegation/status") + .header(Header::new("Authorization", invocation)) + .header(ContentType::JSON) + .body(json!({"cid":cid}).to_string()), + ) + .await?; + Ok(serde_json::from_str(&body)?) +} + +#[tokio::test] +async fn issuer_case_reimport_cannot_resurrect_revoked_siwe_grant() -> Result<()> { + let (client, _dir) = client().await?; + let wallet = Wallet::new()?; + bootstrap(&client, &wallet).await?; + let session = wallet.session()?; + let canonical = authorization(&session.delegation_header)?; + let mut variant = wire_value(&canonical)?; + let Ipld::String(issuer) = payload(&mut variant).get("iss").unwrap() else { + panic!("issuer") + }; + let issuer = issuer.clone(); + let (prefix, address) = issuer.rsplit_once(':').unwrap(); + let lower = format!("{prefix}:{}", address.to_ascii_lowercase()); + assert_ne!( + issuer, lower, + "fixture must have a mixed-case EIP55 address" + ); + payload(&mut variant).insert("iss".into(), Ipld::String(lower)); + let variant = wire(&variant)?; + + // Same signature, same personal_sign bytes; no resigning the alias. + let (TinyCloudDelegation::Cacao(original), _) = TinyCloudDelegation::decode(&canonical)? else { + panic!("CACAO") + }; + let (TinyCloudDelegation::Cacao(alias), _) = TinyCloudDelegation::decode(&variant)? else { + panic!("CACAO") + }; + let message: Message = original.payload().clone().try_into()?; + let alias_message: Message = alias.payload().clone().try_into()?; + assert_eq!(message.eip191_bytes()?, alias_message.eip191_bytes()?); + assert_eq!(original.signature(), alias.signature()); + original.verify().await?; + alias.verify().await?; + + let grant: Value = serde_json::from_str( + &expect_ok( + "canonical grant", + client + .post("/delegate") + .header(Header::new("Authorization", canonical)), + ) + .await?, + )?; + let cid = grant["cid"].as_str().unwrap(); + assert_eq!(status(&client, &session, cid).await?["status"], "active"); + let before = expect_ok( + "signed kv/list before revoke", + client.post("/invoke").header(Header::new( + "Authorization", + list_header(&session, cid.parse()?)?, + )), + ) + .await?; + println!("canonical={cid} status=active signed kv/list=200 body={before}"); + + let mut revocation_message = message; + revocation_message.uri = format!("ucan:{cid}").parse()?; + revocation_message.statement = None; + revocation_message.resources.clear(); + let signature = wallet.sign(&revocation_message)?; + let revocation = TinyCloudRevocation::Cacao(Box::new( + tinycloud_auth::cacaos::siwe_cacao::Payload::from(revocation_message) + .sign::(signature), + )); + expect_ok( + "signed revoke", + client + .post("/revoke") + .header(Header::new("Authorization", revocation.encode()?)), + ) + .await?; + assert_eq!(status(&client, &session, cid).await?["status"], "revoked"); + let (revoked_status, revoked_body) = dispatch(client.post("/invoke").header(Header::new( + "Authorization", + list_header(&session, cid.parse()?)?, + ))) + .await; + assert_eq!( + revoked_status, + Status::Unauthorized, + "canonical after revoke: {revoked_body}" + ); + assert!( + revoked_body.contains("delegation-revoked"), + "{revoked_body}" + ); + + let imported: Value = serde_json::from_str( + &expect_ok( + "case-only reimport", + client + .post("/delegate") + .header(Header::new("Authorization", variant)), + ) + .await?, + )?; + let alias_cid = imported["cid"].as_str().unwrap(); + let alias_status = status(&client, &session, alias_cid).await?; + let (invoke_status, invoke_body) = dispatch(client.post("/invoke").header(Header::new( + "Authorization", + list_header(&session, alias_cid.parse()?)?, + ))) + .await; + println!( + "reimport={alias_cid} status={} signed kv/list={invoke_status} body={invoke_body}", + alias_status["status"] + ); + assert_eq!( + invoke_status, + Status::Unauthorized, + "case-only revoked grant must not authorize kv/list: {invoke_body}" + ); + assert!(invoke_body.contains("delegation-revoked"), "{invoke_body}"); + assert_eq!(alias_status["status"], "revoked"); + assert_eq!(alias_status["revoked"], true); + assert_eq!( + alias_cid, cid, + "equivalent signed messages must have one CID" + ); + Ok(()) +} + +fn signature_bytes(value: &mut Ipld) -> &mut Vec { + let Ipld::Map(cacao) = value else { + panic!("CACAO map") + }; + let Ipld::Map(signature) = cacao.get_mut("s").unwrap() else { + panic!("signature map") + }; + let Ipld::Bytes(bytes) = signature.get_mut("s").unwrap() else { + panic!("signature bytes") + }; + bytes +} + +#[tokio::test] +async fn issuer_representations_share_canonical_bytes_without_changing_signed_text() -> Result<()> { + let wallet = Wallet::new()?; + let session = wallet.session()?; + let header = authorization(&session.delegation_header)?; + let (TinyCloudDelegation::Cacao(original), canonical) = TinyCloudDelegation::decode(&header)? + else { + panic!("CACAO") + }; + let message: Message = original.payload().clone().try_into()?; + let signed_bytes = message.eip191_bytes()?; + let canonical_cid = tinycloud_core::hash::hash(&canonical).to_cid(0x55); + let checksum = encode_eip55(&wallet.address); + let cases = [ + checksum.to_ascii_lowercase(), + checksum.to_ascii_uppercase(), + checksum + .chars() + .map(|c| { + if c.is_ascii_uppercase() { + c.to_ascii_lowercase() + } else { + c.to_ascii_uppercase() + } + }) + .collect(), + ]; + for chain in ["1", "0001", "+1"] { + for address in &cases { + for recovery_offset in [0, 27] { + let mut variant = wire_value(&header)?; + payload(&mut variant).insert( + "iss".into(), + Ipld::String(format!("did:pkh:eip155:{chain}:0x{address}")), + ); + signature_bytes(&mut variant)[64] -= recovery_offset; + let (TinyCloudDelegation::Cacao(alias), bytes) = + TinyCloudDelegation::decode(&wire(&variant)?)? + else { + panic!("CACAO") + }; + let alias_message: Message = alias.payload().clone().try_into()?; + assert_eq!( + alias_message.eip191_bytes()?, + signed_bytes, + "{chain} {address}" + ); + alias.verify().await?; + assert_eq!(alias.signature(), original.signature(), "v normalization"); + assert_eq!( + bytes, canonical, + "{chain} {address} v-offset={recovery_offset}" + ); + assert_eq!( + tinycloud_core::hash::hash(&bytes).to_cid(0x55), + canonical_cid + ); + assert_eq!( + TinyCloudDelegation::Cacao(alias).encode()?, + header, + "new wire encoding" + ); + } + } + } + + Ok(()) +} + +#[tokio::test] +async fn issuer_canonicalization_does_not_accept_invalid_signatures_or_changed_signed_fields( +) -> Result<()> { + let (client, _dir) = client().await?; + let wallet = Wallet::new()?; + bootstrap(&client, &wallet).await?; + let session = wallet.session()?; + let header = authorization(&session.delegation_header)?; + let (TinyCloudDelegation::Cacao(original), bytes) = TinyCloudDelegation::decode(&header)? + else { + panic!("CACAO") + }; + let message: Message = original.payload().clone().try_into()?; + + let mutations = [ + ("nonce", Ipld::String("ChangedNonce1071".into())), + ( + "iss", + Ipld::String(format!( + "did:pkh:eip155:2:0x{}", + encode_eip55(&wallet.address) + )), + ), + ( + "iss", + Ipld::String("did:pkh:eip155:1:0x1111111111111111111111111111111111111111".into()), + ), + ("domain", Ipld::String("other.example".into())), + ( + "iat", + Ipld::String(message.issued_at.to_string().replace('Z', "+00:00")), + ), + ("aud", Ipld::String("did:key:z6MkiAlternateAudience".into())), + ]; + for (field, value) in mutations { + let mut variant = wire_value(&header)?; + payload(&mut variant).insert(field.into(), value); + let mutated = wire(&variant)?; + let (TinyCloudDelegation::Cacao(cacao), _) = TinyCloudDelegation::decode(&mutated)? else { + panic!("CACAO") + }; + assert!( + cacao.verify().await.is_err(), + "changed {field} must fail signature verification" + ); + let (status, body) = dispatch( + client + .post("/delegate") + .header(Header::new("Authorization", mutated)), + ) + .await; + assert_eq!(status, Status::Unauthorized, "changed {field}: {body}"); + } + + for failure in ["high-s", "zero-r", "zero-s", "invalid-v", "wrong-signer"] { + let mut variant = wire_value(&header)?; + let signature = signature_bytes(&mut variant); + match failure { + "high-s" => { + // secp256k1 n-s, with the matching recovery parity toggle. + let order = hex::decode( + "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", + )?; + let mut borrow = 0i16; + for i in (0..32).rev() { + let difference = i16::from(order[i]) - i16::from(signature[32 + i]) - borrow; + signature[32 + i] = difference as u8; + borrow = i16::from(difference < 0); + } + signature[64] = 27 + ((signature[64] - 27) ^ 1); + } + "zero-r" => signature[..32].fill(0), + "zero-s" => signature[32..64].fill(0), + "invalid-v" => signature[64] = 29, + "wrong-signer" => signature[0] ^= 1, + _ => unreachable!(), + } + let invalid = wire(&variant)?; + let decoded = TinyCloudDelegation::decode(&invalid); + if failure == "wrong-signer" { + let (TinyCloudDelegation::Cacao(cacao), _) = decoded? else { + panic!("CACAO") + }; + assert!(cacao.verify().await.is_err()); + } else { + assert!(decoded.is_err(), "{failure} must be rejected at decode"); + } + let (status, body) = dispatch( + client + .post("/delegate") + .header(Header::new("Authorization", invalid)), + ) + .await; + assert_eq!(status, Status::Unauthorized, "{failure}: {body}"); + } + + // An independently signed message remains a different grant, not an alias. + let mut distinct_message = message; + distinct_message.nonce = "DifferentValidNonce1071".into(); + let signature = wallet.sign(&distinct_message)?; + let distinct = tinycloud_auth::cacaos::siwe_cacao::Payload::from(distinct_message) + .sign::(signature); + distinct.verify().await?; + let distinct = TinyCloudDelegation::Cacao(Box::new(distinct)).encode()?; + let (_, distinct_bytes) = TinyCloudDelegation::decode(&distinct)?; + assert_ne!( + tinycloud_core::hash::hash(&distinct_bytes), + tinycloud_core::hash::hash(&bytes) + ); + expect_ok( + "independently signed distinct grant", + client + .post("/delegate") + .header(Header::new("Authorization", distinct)), + ) + .await?; + println!( + "invalid signatures and changed signed fields rejected; distinct signed grant accepted" + ); + Ok(()) +} From 3464a04378ba40c90fee1a0184ed1b7c56583eb0 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Wed, 14 Oct 2026 16:35:36 +0000 Subject: [PATCH 04/20] fix(sdk): canonicalize wallet signatures before session CID calculation --- .../tests/sdk_session_cid.rs | 298 ++++++++++++++++++ tinycloud-sdk-rs/src/serde_siwe.rs | 11 +- tinycloud-sdk-wasm/src/session.rs | 21 +- 3 files changed, 322 insertions(+), 8 deletions(-) create mode 100644 tinycloud-node-server/tests/sdk_session_cid.rs diff --git a/tinycloud-node-server/tests/sdk_session_cid.rs b/tinycloud-node-server/tests/sdk_session_cid.rs new file mode 100644 index 00000000..000a1a13 --- /dev/null +++ b/tinycloud-node-server/tests/sdk_session_cid.rs @@ -0,0 +1,298 @@ +//! TC-1071: wallet signature import must address the delegation the node stores. +//! Drive the SDK's JSON and Rust entrypoints through the real node, not a hash-only oracle. + +use anyhow::{anyhow, ensure, Context, Result}; +use k256::ecdsa::SigningKey; +use rocket::{ + figment::{providers::Serialized, Figment}, + http::{ContentType, Header, Status}, + local::asynchronous::Client, +}; +use serde_json::{json, Value}; +use sha3::{Digest, Keccak256}; +use tinycloud_auth::{ + cacaos::{ + siwe::{encode_eip55, Message}, + siwe_cacao::Signature, + }, + resource::{Path, Service, SpaceId}, + siwe_recap::Ability, + ssi::jwk::JWK, +}; +use tinycloud_sdk_wasm::{ + host::{generate_host_siwe_message, siwe_to_delegation_headers, SignedMessage}, + session::{complete_session_setup, prepare_session, PreparedSession, Session, SignedSession}, +}; + +async fn node() -> Result<(Client, tempfile::TempDir)> { + let dir = tempfile::tempdir()?; + let mut config = tinycloud::config::Config::default(); + config.storage.datadir = dir.path().into(); + config.storage.database = Some("sqlite::memory:".into()); + config.keys = serde_json::from_value(json!({ + "type": "static", + "secret": base64::encode_config([107; 32], base64::URL_SAFE_NO_PAD), + }))?; + let figment = Figment::from(rocket::Config { + log_level: rocket::config::LogLevel::Off, + ..rocket::Config::default() + }) + .merge(Serialized::defaults(&config)); + Ok((Client::tracked(tinycloud::app(&figment).await?).await?, dir)) +} + +fn wallet() -> Result<(SigningKey, [u8; 20], SpaceId)> { + let key = SigningKey::from_bytes(&[107; 32].into())?; + let public = key.verifying_key().to_encoded_point(false); + let digest = Keccak256::digest(&public.as_bytes()[1..]); + let address = digest[12..].try_into()?; + let space = format!( + "tinycloud:pkh:eip155:1:0x{}:sdk-session-cid", + encode_eip55(&address), + ) + .parse()?; + Ok((key, address, space)) +} + +fn sign(key: &SigningKey, message: &Message) -> Result<[u8; 65]> { + let (signature, recovery) = + key.sign_prehash_recoverable(&message.eip191_hash().map_err(|e| anyhow!("{e}"))?)?; + ensure!(signature.normalize_s().is_none(), "fixture must be low-s"); + let mut bytes = [0; 65]; + bytes[..64].copy_from_slice(&signature.to_bytes()); + bytes[64] = recovery.to_byte(); + Ok(bytes) +} + +fn prepared(address: &[u8; 20], space: &SpaceId) -> Result { + let now = time::OffsetDateTime::now_utc(); + // Throwaway, deterministic session key; no credentials or network DID resolution. + let key = ed25519_dalek::SigningKey::from_bytes(&[108; 32]); + let jwk: JWK = serde_json::from_value(json!({ + "kty": "OKP", "crv": "Ed25519", + "d": base64::encode_config(key.to_bytes(), base64::URL_SAFE_NO_PAD), + "x": base64::encode_config(key.verifying_key().to_bytes(), base64::URL_SAFE_NO_PAD), + }))?; + Ok(prepare_session(serde_json::from_value(json!({ + "abilities": {"kv": {"": ["tinycloud.kv/put", "tinycloud.kv/get"]}}, + "address": format!("0x{}", encode_eip55(address)), + "chainId": 1, + "domain": "localhost", + "issuedAt": (now - time::Duration::minutes(1)).format(&time::format_description::well_known::Rfc3339)?, + "expirationTime": (now + time::Duration::hours(1)).format(&time::format_description::well_known::Rfc3339)?, + "spaceId": space, + "jwk": jwk, + "nonce": "tc1071session", + }))?)?) +} + +fn json_signed(prepared: &PreparedSession, signature: &str) -> Result { + let mut signed = serde_json::to_value(prepared)?; + signed["signature"] = signature.into(); + Ok(signed) +} + +fn authorization(headers: &impl serde::Serialize) -> Result { + serde_json::to_value(headers)?["Authorization"] + .as_str() + .map(str::to_owned) + .context("SDK delegation Authorization header") +} + +async fn register(client: &Client, headers: &impl serde::Serialize) -> Result { + let response = client + .post("/delegate") + .header(Header::new("Authorization", authorization(headers)?)) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + ensure!(status == Status::Ok, "delegate: {status} {body}"); + Ok(serde_json::from_str(&body)?) +} + +async fn invoke( + client: &Client, + session: &Session, + path: &str, + action: &str, + body: &str, +) -> Result<(Status, String)> { + let resource = session.space_id.clone().to_resource( + "kv".parse::()?, + Some(path.parse::()?), + None, + None, + ); + let invocation = session.invoke_any([(resource, [action.parse::()?])], None)?; + let response = client + .post("/invoke") + .header(Header::new("Authorization", invocation.encode()?)) + .header(ContentType::Plain) + .body(body) + .dispatch() + .await; + Ok(( + response.status(), + response.into_string().await.unwrap_or_default(), + )) +} + +#[tokio::test] +async fn wallet_recovery_forms_register_and_invoke() -> Result<()> { + let (client, _dir) = node().await?; + let (wallet, address, space) = wallet()?; + let peer = client + .get(format!("/peer/generate/{space}")) + .dispatch() + .await; + ensure!(peer.status() == Status::Ok, "peer generation"); + let host = generate_host_siwe_message(serde_json::from_value(json!({ + "address": format!("0x{}", encode_eip55(&address)), + "chainId": 1, "domain": "localhost", + "issuedAt": (time::OffsetDateTime::now_utc() - time::Duration::minutes(1)).format(&time::format_description::well_known::Rfc3339)?, + "spaceId": space, + "peerId": peer.into_string().await.context("peer DID")?, + }))?)?; + let host_signature = sign(&wallet, &host)?; + let host: SignedMessage = serde_json::from_value(json!({ + "siwe": host.to_string(), "signature": format!("0x{}", hex::encode(host_signature)), + }))?; + register(&client, &siwe_to_delegation_headers(host)).await?; + + let mut failures = Vec::new(); + for recovery in [0, 1] { + let mut prepared = prepared(&address, &space)?; + let mut signature = None; + // Select a genuine signature for each recovery parity, never alter its parity. + for nonce in 0..256 { + prepared.siwe.nonce = format!("tc1071{recovery}{nonce:03}"); + let candidate = sign(&wallet, &prepared.siwe)?; + if candidate[64] == recovery { + signature = Some(candidate); + break; + } + } + let signature = signature.context("signature with requested recovery parity")?; + for v in [recovery + 27, recovery] { + let mut bytes = signature; + bytes[64] = v; + for import in ["json", "rust"] { + let session = if import == "json" { + let prefix = if v >= 27 { "" } else { "0x" }; + let signed = + json_signed(&prepared, &format!("{prefix}{}", hex::encode(bytes)))?; + complete_session_setup(serde_json::from_value::(signed)?)? + } else { + complete_session_setup(SignedSession { + session: prepared.clone(), + signature: Signature::from(bytes), + })? + }; + let registered = register(&client, &session.delegation_header).await?; + let path = format!("tc1071/{import}/{v}"); + let value = format!("sdk-session-{import}-v{v}"); + let (status, body) = + invoke(&client, &session, &path, "tinycloud.kv/put", &value).await?; + println!( + "{import} v={v}: SDK CID={} node CID={} invoke={status} {body}", + session.delegation_cid, + registered["cid"].as_str().unwrap_or_default() + ); + if status != Status::Ok { + failures.push(format!("{import} v={v}: invoke={status} {body}")); + continue; + } + ensure!( + registered["cid"] == session.delegation_cid.to_string(), + "{import} v={v}: registered CID must match SDK proof" + ); + let (status, body) = + invoke(&client, &session, &path, "tinycloud.kv/get", "").await?; + println!("{import} v={v}: get={status} value={body:?}"); + ensure!( + status == Status::Ok && body == value, + "{import} v={v}: get={status} {body}" + ); + } + } + } + ensure!( + failures.is_empty(), + "SDK invocation failures:\n{}", + failures.join("\n") + ); + Ok(()) +} + +#[test] +fn wallet_import_and_rust_setup_reject_noncanonical_signatures() -> Result<()> { + let (wallet, address, space) = wallet()?; + let prepared = prepared(&address, &space)?; + let bytes = sign(&wallet, &prepared.siwe)?; + let signature = k256::ecdsa::Signature::from_slice(&bytes[..64])?; + let (r, s) = signature.split_scalars(); + let high_s = k256::ecdsa::Signature::from_scalars(r.to_bytes(), (-s).to_bytes())?; + let mut high_s_bytes = bytes; + high_s_bytes[..64].copy_from_slice(&high_s.to_bytes()); + high_s_bytes[64] ^= 1; + let mut cases = vec![("high-s".to_owned(), high_s_bytes.to_vec())]; + for v in [2, 26, 29, 35, 255] { + let mut invalid = bytes; + invalid[64] = v; + cases.push((format!("invalid-v-{v}"), invalid.to_vec())); + } + for range in [0..32, 32..64] { + for value in [0, 255] { + let mut invalid = bytes; + invalid[range.clone()].fill(value); + cases.push(( + format!("invalid-scalar-{}-{value}", range.start), + invalid.to_vec(), + )); + } + } + cases.push(("short".into(), bytes[..64].to_vec())); + cases.push(("long".into(), [bytes.as_slice(), &[0]].concat())); + for (label, bytes) in cases { + let node_error = Signature::try_from(bytes.clone()) + .expect_err("node decoder rejects fixture") + .to_string(); + for prefix in ["", "0x"] { + let signed = json_signed(&prepared, &format!("{prefix}{}", hex::encode(&bytes)))?; + let error = serde_json::from_value::(signed) + .err() + .with_context(|| format!("SDK accepted {label}"))?; + ensure!( + error.to_string().contains(&node_error), + "{label}: SDK={error}; node={node_error}" + ); + let host = json!({"siwe": prepared.siwe.to_string(), "signature": format!("{prefix}{}", hex::encode(&bytes))}); + let error = serde_json::from_value::(host) + .err() + .with_context(|| format!("host SDK accepted {label}"))?; + ensure!( + error.to_string().contains(&node_error), + "host {label}: SDK={error}; node={node_error}" + ); + } + if let Ok(bytes) = <[u8; 65]>::try_from(bytes) { + let error = complete_session_setup(SignedSession { + session: prepared.clone(), + signature: Signature::from(bytes), + }) + .expect_err("Rust setup must validate before hashing"); + ensure!( + error.to_string().contains(&node_error), + "Rust {label}: SDK={error}; node={node_error}" + ); + } + } + for signature in ["0xnothex", "0", "0x"] { + ensure!( + serde_json::from_value::(json_signed(&prepared, signature)?).is_err(), + "accepted malformed hex {signature}" + ); + } + Ok(()) +} diff --git a/tinycloud-sdk-rs/src/serde_siwe.rs b/tinycloud-sdk-rs/src/serde_siwe.rs index 83092a4d..41e7dba5 100644 --- a/tinycloud-sdk-rs/src/serde_siwe.rs +++ b/tinycloud-sdk-rs/src/serde_siwe.rs @@ -39,13 +39,18 @@ pub mod signature { format!("0x{}", addr.deref().encode_hex::()).serialize(s) } + /// Decode a 65-byte hex wallet signature, with an optional `0x` prefix. + /// + /// Uses the node's CACAO decoder: low-s signatures with recovery IDs + /// `0/1` or `27/28` are accepted and stored as `27/28`; high-s signatures, + /// invalid scalars, lengths, and recovery IDs are rejected. pub fn deserialize<'de, D>(d: D) -> Result where D: Deserializer<'de>, { let sig = Cow::<'_, str>::deserialize(d)?; - <[u8; 65]>::from_hex(sig.strip_prefix("0x").unwrap_or(&sig)) - .map(Into::into) - .map_err(|e| D::Error::custom(format!("failed to parse SIWE signature: {e}"))) + let bytes = >::from_hex(sig.strip_prefix("0x").unwrap_or(&sig)) + .map_err(|e| D::Error::custom(format!("failed to parse SIWE signature: {e}")))?; + Signature::try_from(bytes).map_err(D::Error::custom) } } diff --git a/tinycloud-sdk-wasm/src/session.rs b/tinycloud-sdk-wasm/src/session.rs index 608a637b..9b2811f2 100644 --- a/tinycloud-sdk-wasm/src/session.rs +++ b/tinycloud-sdk-wasm/src/session.rs @@ -118,6 +118,8 @@ pub struct PreparedSession { pub struct SignedSession { #[serde(flatten)] pub session: PreparedSession, + /// EIP-191 wallet signature. Hex import validates its encoding and + /// canonicalizes recovery IDs `0/1` to `27/28`. #[serde(with = "tinycloud_sdk_rs::serde_siwe::signature")] pub signature: Signature, } @@ -696,12 +698,18 @@ pub fn prepare_session(config: SessionConfig) -> Result }) } +/// Finish session setup using a validated, canonical wallet signature encoding. +/// +/// Low-s signatures with recovery IDs `0/1` and `27/28` produce the same +/// delegation header and CID registered by the node. Invalid signature scalars, +/// high-s values, and unsupported recovery IDs fail before CID calculation, +/// including when Rust callers construct [`SignedSession`] directly. +/// +/// The node still verifies the signature against the SIWE message and checks +/// the delegation's authority; setup only validates the signature encoding. pub fn complete_session_setup(signed_session: SignedSession) -> Result { - let delegation = SiweCacao::new( - signed_session.session.siwe.into(), - signed_session.signature, - SiweHeader, - ); + let signature = Signature::try_from(signed_session.signature.as_ref().to_vec())?; + let delegation = SiweCacao::new(signed_session.session.siwe.into(), signature, SiweHeader); let serialised = serde_ipld_dagcbor::to_vec(&delegation)?; let hash = Code::Blake3_256.digest(&serialised); // Use raw codec 0x55 to match server behavior @@ -729,6 +737,9 @@ pub enum Error { UnableToGenerateDID(#[from] tinycloud_auth::ssi::dids::GenerateError), #[error("unable to generate the SIWE message to start the session: {0}")] UnableToGenerateSIWEMessage(String), + /// The wallet signature encoding is not accepted by the node's CACAO decoder. + #[error("invalid SIWE signature: {0}")] + InvalidSIWESignature(#[from] tinycloud_auth::cacaos::siwe_cacao::SIWESignatureDecodeError), #[error("unable to generate the CID: {0}")] UnableToGenerateCid(#[from] EncodeError), } From 2f1faec0a13b1b2a5aba54a526fb6cb0f7afd030 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Wed, 14 Oct 2026 19:18:30 +0000 Subject: [PATCH 05/20] fix(TC-1071): preserve historical CACAO revocation identities --- CHANGELOG.md | 2 + docs/delegation-flow.md | 35 ++ tinycloud-core/src/auth_graph.rs | 94 +++- tinycloud-core/src/db.rs | 238 ++++----- tinycloud-core/src/hash.rs | 3 +- .../m20261014_000000_delegation_cid_alias.rs | 90 ++++ tinycloud-core/src/migrations/mod.rs | 5 +- tinycloud-core/src/models/delegation.rs | 82 ++- .../src/models/delegation_cid_alias.rs | 324 ++++++++++++ tinycloud-core/src/models/mod.rs | 1 + tinycloud-core/src/models/revocation.rs | 104 +++- .../fixtures/tc1071/generated/active.sqlite | Bin 0 -> 516096 bytes .../tc1071/generated/encrypted-active.sqlite | Bin 0 -> 516096 bytes .../fixtures/tc1071/generated/manifest.json | 342 +++++++++++++ .../tc1071/generated/revoked-v0.sqlite | Bin 0 -> 528384 bytes .../tc1071/generated/revoked-v27.sqlite | Bin 0 -> 528384 bytes .../tc1071/generated/single-v0.sqlite | Bin 0 -> 487424 bytes .../tests/fixtures/tc1071/generator.rs | 113 ++++ .../tests/fixtures/tc1071/support.rs | 331 ++++++++++++ .../tests/fixtures/tc1071/upgrade_checks.rs | 396 ++++++++++++++ .../tests/historical_cacao_revocation.rs | 484 ++++++++++++++++++ tinycloud-node-server/src/auth_guards.rs | 120 ++++- tinycloud-node-server/src/lib.rs | 4 +- tinycloud-node-server/src/share_v2.rs | 44 +- .../src/webhook_dispatcher.rs | 18 +- 25 files changed, 2625 insertions(+), 205 deletions(-) create mode 100644 tinycloud-core/src/migrations/m20261014_000000_delegation_cid_alias.rs create mode 100644 tinycloud-core/src/models/delegation_cid_alias.rs create mode 100644 tinycloud-core/tests/fixtures/tc1071/generated/active.sqlite create mode 100644 tinycloud-core/tests/fixtures/tc1071/generated/encrypted-active.sqlite create mode 100644 tinycloud-core/tests/fixtures/tc1071/generated/manifest.json create mode 100644 tinycloud-core/tests/fixtures/tc1071/generated/revoked-v0.sqlite create mode 100644 tinycloud-core/tests/fixtures/tc1071/generated/revoked-v27.sqlite create mode 100644 tinycloud-core/tests/fixtures/tc1071/generated/single-v0.sqlite create mode 100644 tinycloud-core/tests/fixtures/tc1071/generator.rs create mode 100755 tinycloud-core/tests/fixtures/tc1071/support.rs create mode 100644 tinycloud-core/tests/fixtures/tc1071/upgrade_checks.rs create mode 100644 tinycloud-core/tests/historical_cacao_revocation.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f9913e1..c970a561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - CACAO/SIWE delegation ingestion normalizes ECDSA recovery IDs 0/1 to 27/28 before hashing and persisting the canonical DAG-CBOR bytes (TC-1071). Equivalent low-s signatures now share one delegation CID, including the CID used for revocation lookup. High-s signatures are rejected during decoding, matching the existing EIP-191 verifier and EIP-2 low-s convention; UCAN/JWS bytes remain unchanged. Existing stored delegation IDs are not rewritten. - Synchronize the standalone M1, W5 and load-signer Cargo locks with CACAO's `k256` dependency so `--locked` dependency resolution succeeds (TC-1071). +- Historical CACAO revocations now apply to every stored encoding of the same delegation, in both directions (TC-1071). A persisted CID-alias index derives identities from current ingestion's canonical bytes, including recovery-ID, issuer-address/chain, and accepted CBOR representation normalization. Startup backfills aliases transactionally after loading the column encryption key and fails closed on undecodable or mismatched history or missing/wrong keys. Historical IDs, signed bytes, revocation targets, and signed child proof references remain unchanged; legacy list/chain responses retain their exact raw-header/CID relationship. This closes reuse of an already owner-signed grant under another accepted encoding; it does not involve forging new rights. + ## [1.20.6] - 2026-10-14 A security fix (TC-1080, Part A). UCAN issuer resolution no longer dereferences network DID methods by default, so a request can't make the node fetch arbitrary or internal hosts through a crafted issuer DID (#284). diff --git a/docs/delegation-flow.md b/docs/delegation-flow.md index a1eaf0db..b477d801 100644 --- a/docs/delegation-flow.md +++ b/docs/delegation-flow.md @@ -393,6 +393,41 @@ export class Authenticator { } ``` +## Historical CACAO CIDs and Revocation + +New CACAO imports hash the canonical bytes returned by +`TinyCloudDelegation::decode`: accepted recovery IDs, issuer address/chain +representations, and DAG-CBOR encodings share the same ingestion identity. +UCAN/JWS bytes remain exact. + +At startup, `m20261014_000000_delegation_cid_alias` creates the persisted alias +index. The database then backfills missing entries in a transaction from each +stored delegation's exact plaintext serialization, using that same decoder. +Encrypted databases must open with `SpaceDatabase::new_with_encryption`; the +column key must be available before backfill. Missing/wrong keys, decoding +failures, and plaintext hash mismatches fail startup rather than silently +leaving an active alias. Completed backfills do not re-decode indexed history; +one indexed encrypted record is checked on each boot to validate the key. + +Revocation status, invocation authorization, and ancestor checks union owner +revocations across equivalent stored CIDs through indexed lookups. Historical +delegation IDs, signed bytes, revocation targets, and child proof edges are +never rewritten. A child signed against an old CID remains valid until the +owner revokes that identity; revocation also denies descendants and new +redelegations citing any equivalent root. + +Canonical chain guards use indexed alias joins in the existing bounded closure +query, so equivalent revocations serialize against in-flight ancestor +authorization without an extra request round trip. New delegations and their +alias commit together; PostgreSQL uses a dependent insert CTE to preserve the +existing registration round-trip budget. + +Legacy capability list/chain responses advertise the stored CID and return +the exact stored raw header, not a re-encoding of the decoded CACAO. Their +parent CID fields retain the signed child references. Existing legacy list +ancestor-visibility behavior is unchanged; authorization always checks the +complete chain. + ## Child Delegation (UCAN) Creation For sharing, child UCANs are created that delegate from the session key to another DID. diff --git a/tinycloud-core/src/auth_graph.rs b/tinycloud-core/src/auth_graph.rs index 0fafaecd..8246c743 100644 --- a/tinycloud-core/src/auth_graph.rs +++ b/tinycloud-core/src/auth_graph.rs @@ -10,7 +10,7 @@ //! the same consistent view. use crate::hash::Hash; -use crate::models::{abilities, delegation, revocation}; +use crate::models::{abilities, delegation, delegation_cid_alias, revocation}; use crate::relationships::parent_delegations; use sea_orm::{ entity::prelude::*, @@ -29,6 +29,23 @@ use crate::models::revocation::MAX_CHAIN_TRAVERSAL_NODES; pub(crate) async fn load_closure_edges( db: &C, roots: &[Hash], +) -> Result<(Vec, HashMap>), ChainTraversalError> { + load_closure(db, roots, false).await +} + +/// Resolve the same bounded closure to canonical lock identities. Signed graph +/// IDs stay untouched; indexed alias joins share the closure's one round trip. +pub(crate) async fn load_chain_lock_ids( + db: &C, + roots: &[Hash], +) -> Result, ChainTraversalError> { + load_closure(db, roots, true).await.map(|(ids, _)| ids) +} + +async fn load_closure( + db: &C, + roots: &[Hash], + canonical_locks: bool, ) -> Result<(Vec, HashMap>), ChainTraversalError> { let mut nodes = roots.to_vec(); nodes.sort_by(|left, right| left.as_ref().cmp(right.as_ref())); @@ -74,6 +91,17 @@ pub(crate) async fn load_closure_edges( ) .to_owned(); let mut closure_query = base; + if canonical_locks { + // Parentless stored roots otherwise produce no edge row. Seed them + // only for locking; roots without a persisted alias retain their ID. + let seed = Query::select() + .expr_as(Expr::col(delegation_cid_alias::Column::Id), child.clone()) + .expr_as(Expr::val(Option::::None), parent.clone()) + .from(delegation_cid_alias::Entity) + .and_where(Expr::col(delegation_cid_alias::Column::Id).is_in(nodes.iter().copied())) + .to_owned(); + closure_query.union(UnionType::Distinct, seed); + } closure_query.union(UnionType::Distinct, recursive); let cte = CommonTableExpression::new() .table_name(closure.clone()) @@ -87,20 +115,65 @@ pub(crate) async fn load_closure_edges( (closure.clone(), child.clone()), (closure.clone(), parent.clone()), ]) - .from(closure) - .limit((edge_cap + 1) as u64); + .from(closure.clone()) + .limit((edge_cap + if canonical_locks { nodes.len() } else { 0 } + 1) as u64); + if canonical_locks { + for (column, alias, output) in [ + ( + child.clone(), + Alias::new("child_alias"), + Alias::new("child_lock"), + ), + ( + parent.clone(), + Alias::new("parent_alias"), + Alias::new("parent_lock"), + ), + ] { + query + .expr_as( + Expr::col((alias.clone(), delegation_cid_alias::Column::Canonical)), + output, + ) + .join_as( + JoinType::LeftJoin, + delegation_cid_alias::Entity, + alias.clone(), + Expr::col((alias, delegation_cid_alias::Column::Id)) + .equals((closure.clone(), column)), + ); + } + } let query = query.with(WithClause::new().recursive(true).cte(cte).to_owned()); let rows = db .query_all(db.get_database_backend().build(&query)) .await?; - if rows.len() > edge_cap { + if rows.len() > edge_cap + if canonical_locks { nodes.len() } else { 0 } { return Err(ChainTraversalError::LimitExceeded); } let mut edges: HashMap> = HashMap::new(); + let mut lock_aliases = HashMap::new(); for row in rows { let edge_child: Hash = row.try_get("", "child")?; - let edge_parent: Hash = row.try_get("", "parent")?; + let edge_parent: Option = row.try_get("", "parent")?; + if canonical_locks { + if let Some(canonical) = row.try_get::>("", "child_lock")? { + if canonical != edge_child { + lock_aliases.insert(edge_child, canonical); + } + } + if let (Some(parent), Some(canonical)) = + (edge_parent, row.try_get::>("", "parent_lock")?) + { + if canonical != parent { + lock_aliases.insert(parent, canonical); + } + } + } + let Some(edge_parent) = edge_parent else { + continue; + }; let parents = edges.entry(edge_child).or_default(); if !parents.contains(&edge_parent) { parents.push(edge_parent); @@ -123,6 +196,11 @@ pub(crate) async fn load_closure_edges( } } } + for id in &mut nodes { + if let Some(canonical) = lock_aliases.get(id) { + *id = *canonical; + } + } Ok((nodes, edges)) } @@ -168,12 +246,10 @@ impl AuthGraphSnapshot { ability_rows.entry(row.delegation).or_default().push(row); } - let revoked = revocation::Entity::find() - .filter(revocation::Column::Revoked.is_in(nodes.iter().copied())) - .all(db) + let revoked = revocation::effective_rows(db, &nodes) .await? .into_iter() - .map(|row| row.revoked) + .map(|(id, _)| id) .collect(); Ok(Self { diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index d04594bb..72565071 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -20,7 +20,7 @@ use crate::types::{ DelegationQueryPage, DelegationQueryStatus, DelegationResource, ListFilters, Metadata, Resource, SpaceIdWrap, }; -use crate::util::{Capability, DelegationInfo, DelegationMode}; +use crate::util::DelegationInfo; use sea_orm::{ entity::prelude::*, error::{DbErr, RuntimeErr, SqlxError}, @@ -343,11 +343,32 @@ impl SpaceDatabase where C: ConnectionTrait + Clone + Into, { + /// Open an unencrypted delegation database and complete historical CID + /// indexing before accepting requests. pub async fn new(conn: C, storage: B, secrets: K) -> Result { + Self::new_with_encryption(conn, storage, secrets, None).await + } + + /// Open a database with its column encryption key available before the + /// historical CID backfill. Missing/wrong keys or undecodable history fail + /// startup; signed records and proof edges are never rewritten. + pub async fn new_with_encryption( + conn: C, + storage: B, + secrets: K, + encryption: Option, + ) -> Result { let pool: RecoveringConnection = conn.clone().into(); // sea-orm-migration's schema manager accepts only the raw sea-orm // connection. Migrations run once at boot, before any request. migrate(&pool).await?; + let tx = begin_write_transaction(&pool, None).await?; + if pool.get_database_backend() == DbBackend::Postgres { + tx.execute_unprepared(crate::db_pool::EXEMPT_STATEMENT_TIMEOUT) + .await?; + } + delegation_cid_alias::backfill(&tx, encryption.as_ref()).await?; + tx.commit().await?; let writer_lock = (pool.get_database_backend() == sea_orm::DatabaseBackend::Sqlite) .then(|| Arc::new(tokio::sync::Mutex::new(()))); let read_audit = ReadAuditPipeline::start(pool, writer_lock.clone()); @@ -355,7 +376,7 @@ where conn, storage, secrets, - encryption: None, + encryption, sql_sizes: SqlSizes::default(), revocation_chain_locks: Arc::new(tokio::sync::Mutex::new(HashMap::new())), kv_object_locks: Arc::new(tokio::sync::Mutex::new(HashMap::new())), @@ -368,11 +389,6 @@ where }) } - pub fn with_encryption(mut self, encryption: Option) -> Self { - self.encryption = encryption; - self - } - pub fn with_sql_sizes(mut self, sql_sizes: SqlSizes) -> Self { self.sql_sizes = sql_sizes; self @@ -592,10 +608,7 @@ where }; let bytes = crate::encryption::maybe_decrypt(self.encryption.as_ref(), &row.serialization) .map_err(|error| error.to_string())?; - let delegation = - TinyCloudDelegation::from_bytes(&bytes).map_err(|error| error.to_string())?; - let info = DelegationInfo::try_from(delegation).map_err(|error| error.to_string())?; - let event = SerializedEvent(info, bytes); + let event = decode_stored_delegation(bytes).map_err(|error| error.to_string())?; if event.content_hash() != row.id || event.content_hash() != Hash::from(cid) { return Err("delegation-signed-bytes-mismatch".to_string()); } @@ -1292,7 +1305,7 @@ where &self, roots: &[Hash], ) -> Result, TxError> { - let keys = revocation::ancestor_chain_ids_for_roots(&self.conn, roots) + let keys = crate::auth_graph::load_chain_lock_ids(&self.conn, roots) .await .map_err(|error| match error { revocation::ChainTraversalError::Db(error) => TxError::Db(error), @@ -1980,9 +1993,8 @@ where ); let authz_start = Instant::now(); let closure_start = Instant::now(); - let lock_keys = crate::auth_graph::load_closure_edges(&self.conn, &guard_roots) + let lock_keys = crate::auth_graph::load_chain_lock_ids(&self.conn, &guard_roots) .await - .map(|(keys, _)| keys) .map_err(|error| match error { revocation::ChainTraversalError::Db(error) => TxError::Db(error), revocation::ChainTraversalError::LimitExceeded => { @@ -2880,9 +2892,9 @@ pub enum InvocationOutcome { KvBatchWrite(Vec), KvBatchRead(Vec), KvRead(Option<(Metadata, Hash, Content)>), - OpenSessions(HashMap), - /// Ordered delegation chain from leaf to root - DelegationChain(Vec), + OpenSessions(HashMap), + /// Ordered delegation chain from leaf to root, retaining exact signed bytes. + DelegationChain(Vec), SqlResult(serde_json::Value), SqlExport(Vec), DuckDbResult(serde_json::Value), @@ -3912,15 +3924,25 @@ fn delegations_touching_space(backend: DbBackend, space_id: &SpaceId) -> SimpleE ) } +/// Reparse response projections from their signed source, preserving complete +/// proof CIDs (including codecs and order) rather than reconstructing them from +/// the graph's hash-only edges. +fn decode_stored_delegation(serialization: Vec) -> Result { + let delegation = TinyCloudDelegation::from_bytes(&serialization) + .map_err(|_| DbErr::Custom("delegation-signed-bytes-invalid".into()))?; + let info = DelegationInfo::try_from(delegation) + .map_err(|_| DbErr::Custom("delegation-signed-info-invalid".into()))?; + Ok(SerializedEvent(info, serialization)) +} + async fn get_valid_delegations( db: &C, space_id: &SpaceId, encryption: Option<&ColumnEncryption>, -) -> Result, TxError> { +) -> Result, TxError> { let (dels, abilities): (Vec, Vec>) = delegation::Entity::find() - .left_join(revocation::Entity) - .filter(revocation::Column::Id.is_null()) + .filter(revocation::unrevoked_condition()) .filter(delegations_touching_space( db.get_database_backend(), space_id, @@ -3930,12 +3952,10 @@ async fn get_valid_delegations( .await? .into_iter() .unzip(); - let parents = dels.load_many(parent_delegations::Entity, db).await?; let now = time::OffsetDateTime::now_utc(); dels.into_iter() .zip(abilities) - .zip(parents) - .filter_map(|((del, ability), parents)| { + .filter_map(|(del, ability)| { // `delegations_touching_space` has already narrowed this to the // delegations whose resources fall in the space's prefix range; // the space check below is the exact filter over that superset. @@ -3948,51 +3968,16 @@ async fn get_valid_delegations( Ok(s) => s, Err(e) => return Some(Err(TxError::Encryption(e))), }; - Some(match TinyCloudDelegation::from_bytes(&serialization) { - Ok(delegation) => Ok(( - del.id, - DelegationInfo { - delegator: del.delegator, - delegate: del.delegatee, - parents: parents.into_iter().map(|p| p.parent.to_cid(0x55)).collect(), - expiry: del.expiry, - not_before: del.not_before, - issued_at: del.issued_at, - delegation_mode: mode_from_facts(&del.facts), - capabilities: ability - .into_iter() - .map(|a| Capability { - resource: a.resource, - ability: a.ability, - caveats: a.caveats, - }) - .collect(), - delegation, - }, - )), - Err(e) => Err(TxError::Encoding(e)), - }) + Some( + decode_stored_delegation(serialization) + .map(|event| (del.id, event)) + .map_err(TxError::Db), + ) } else { None } }) - .collect::, TxError>>() -} - -/// Decode the persisted `xyz.tinycloud.policy/delegationMode` marker from -/// a stored delegation row's facts column. -fn mode_from_facts(facts: &Option) -> DelegationMode { - facts - .as_ref() - .and_then(|f| f.0.get(DelegationMode::FACT_KEY).and_then(|v| v.as_str())) - .map(|s| { - if s == "terminal" { - DelegationMode::Terminal - } else { - DelegationMode::Attenuable - } - }) - .unwrap_or(DelegationMode::Attenuable) + .collect::, TxError>>() } /// Resolve a session key DID (did:key:...) to its root PKH DID (did:pkh:...). @@ -4297,17 +4282,14 @@ async fn load_account_ancestor_state( .filter(delegation::Column::Id.is_in(ids.iter().copied())) .all(db) .await?; - let revocation_rows = revocation::Entity::find() - .filter(revocation::Column::Revoked.is_in(ids)) - .all(db) - .await?; + let revocation_rows = revocation::effective_rows(db, &ids).await?; let delegations = delegation_rows .into_iter() .map(|row| (row.id, row)) .collect(); let mut revocations: HashMap> = HashMap::new(); - for row in revocation_rows { - revocations.entry(row.revoked).or_default().push(row); + for (id, row) in revocation_rows { + revocations.entry(id).or_default().push(row); } for values in revocations.values_mut() { values.sort_by(|left, right| left.id.as_ref().cmp(right.id.as_ref())); @@ -4327,7 +4309,7 @@ async fn get_filtered_delegations, encryption: Option<&ColumnEncryption>, -) -> Result, TxError> { +) -> Result, TxError> { // Resolve session key DID to PKH DID for direction filtering let pkh_did = resolve_pkh_did(db, invoker) .await @@ -4335,8 +4317,7 @@ async fn get_filtered_delegations, Vec>) = delegation::Entity::find() - .left_join(revocation::Entity) - .filter(revocation::Column::Id.is_null()) + .filter(revocation::unrevoked_condition()) .filter(delegations_touching_space( db.get_database_backend(), space_id, @@ -4346,7 +4327,6 @@ async fn get_filtered_delegations now).unwrap_or(true) && del.not_before.map(|n| n <= now).unwrap_or(true)) @@ -4414,32 +4393,13 @@ async fn get_filtered_delegations s, Err(e) => return Some(Err(TxError::Encryption(e))), }; - Some(match TinyCloudDelegation::from_bytes(&serialization) { - Ok(delegation) => Ok(( - del.id, - DelegationInfo { - delegator: del.delegator, - delegate: del.delegatee, - parents: parents.into_iter().map(|p| p.parent.to_cid(0x55)).collect(), - expiry: del.expiry, - not_before: del.not_before, - issued_at: del.issued_at, - delegation_mode: mode_from_facts(&del.facts), - capabilities: ability - .into_iter() - .map(|a| Capability { - resource: a.resource, - ability: a.ability, - caveats: a.caveats, - }) - .collect(), - delegation, - }, - )), - Err(e) => Err(TxError::Encoding(e)), - }) + Some( + decode_stored_delegation(serialization) + .map(|event| (del.id, event)) + .map_err(TxError::Db), + ) }) - .collect::, TxError>>() + .collect::, TxError>>() } /// Get the delegation chain for a specific delegation, ordered from leaf to root. @@ -4449,7 +4409,7 @@ async fn get_delegation_chain( space_id: &SpaceId, delegation_cid: &str, encryption: Option<&ColumnEncryption>, -) -> Result, TxError> { +) -> Result, TxError> { use tinycloud_auth::ipld_core::cid::Cid; // Parse the delegation CID @@ -4466,8 +4426,7 @@ async fn get_delegation_chain( loop { // Find the delegation with this hash let del_with_abilities = delegation::Entity::find_by_id(current_hash) - .left_join(revocation::Entity) - .filter(revocation::Column::Id.is_null()) + .filter(revocation::unrevoked_condition()) .find_with_related(abilities::Entity) .all(db) .await?; @@ -4496,31 +4455,8 @@ async fn get_delegation_chain( .all(db) .await?; - let parent_cids: Vec = parents.iter().map(|p| p.parent.to_cid(0x55)).collect(); - - // Create DelegationInfo let serialization = crate::encryption::maybe_decrypt(encryption, &del.serialization)?; - let delegation = TinyCloudDelegation::from_bytes(&serialization)?; - let info = DelegationInfo { - delegator: del.delegator, - delegate: del.delegatee, - parents: parent_cids.clone(), - expiry: del.expiry, - not_before: del.not_before, - issued_at: del.issued_at, - delegation_mode: mode_from_facts(&del.facts), - capabilities: ability - .into_iter() - .map(|a| Capability { - resource: a.resource, - ability: a.ability, - caveats: a.caveats, - }) - .collect(), - delegation, - }; - - chain.push(info); + chain.push(decode_stored_delegation(serialization)?); // Move to the first parent (if any) to continue the chain // Note: We follow the first parent; for multiple parents, this gives one path @@ -6083,9 +6019,8 @@ mod test { /// Resolve the ancestor closure exactly as `invoke_with_options_mode` /// does, then take the guards shared — the production invocation path. async fn invocation_chain_keys(db: &TestDb, roots: &[Hash]) -> Vec { - crate::auth_graph::load_closure_edges(&db.conn, roots) + crate::auth_graph::load_chain_lock_ids(&db.conn, roots) .await - .map(|(keys, _)| keys) .expect("chain closure query") } @@ -6115,6 +6050,49 @@ mod test { tokio::time::sleep(std::time::Duration::from_millis(150)).await; } + /// Equivalent historical parents and an as-yet unregistered canonical CID + /// must contend on the same guard as an invocation citing the signed child. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn historical_alias_revocation_waits_for_signed_child_invocation() { + use sea_orm::{ActiveModelTrait, ActiveValue::Set}; + + let db = get_db().await.unwrap(); + insert_chain_guard_actors(&db).await; + let (legacy, child) = insert_test_chain(&db, "historical-alias-lock").await; + let (other_legacy, _) = insert_test_chain(&db, "other-historical-alias-lock").await; + let canonical = crate::hash::hash(b"canonical parent not registered as a delegation"); + for id in [legacy, other_legacy] { + delegation_cid_alias::ActiveModel { + id: Set(id), + canonical: Set(canonical), + encrypted: Set(false), + } + .insert(&db.conn) + .await + .unwrap(); + } + + for target in [legacy, other_legacy, canonical] { + let in_flight = acquire_invocation_guards(&db, &[child]).await; + let revoke_db = db.clone(); + let revoke = + tokio::spawn(async move { revoke_db.acquire_chain_guards(&[target]).await }); + settle().await; + assert!( + !revoke.is_finished(), + "equivalent ancestor revocation must wait for the signed child's invocation" + ); + drop(in_flight); + let guards = tokio::time::timeout(std::time::Duration::from_secs(10), revoke) + .await + .expect("alias revocation proceeds after the child releases its guards") + .unwrap() + .ok() + .expect("alias revocation guards"); + drop(guards); + } + } + /// TC-324 requirement 1: invocations sharing one root delegation must /// hold the chain guard at the same time, not merely all succeed. /// diff --git a/tinycloud-core/src/hash.rs b/tinycloud-core/src/hash.rs index 5a7f151e..79ac6b1d 100644 --- a/tinycloud-core/src/hash.rs +++ b/tinycloud-core/src/hash.rs @@ -108,7 +108,8 @@ impl sea_orm::TryGetable for Hash { res: &QueryResult, idx: I, ) -> Result { - let vec: Vec = res.try_get_by(idx).map_err(sea_orm::TryGetError::DbErr)?; + // Preserve TryGetError::Null so nullable closure/alias joins decode as None. + let vec = as sea_orm::TryGetable>::try_get_by(res, idx)?; Hash::try_from(vec).map_err(|e| { sea_orm::TryGetError::DbErr(DbErr::TryIntoErr { from: "Vec", diff --git a/tinycloud-core/src/migrations/m20261014_000000_delegation_cid_alias.rs b/tinycloud-core/src/migrations/m20261014_000000_delegation_cid_alias.rs new file mode 100644 index 00000000..ed6484e9 --- /dev/null +++ b/tinycloud-core/src/migrations/m20261014_000000_delegation_cid_alias.rs @@ -0,0 +1,90 @@ +use sea_orm_migration::prelude::*; + +use crate::models::{delegation, delegation_cid_alias}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(delegation_cid_alias::Entity) + .if_not_exists() + .col( + ColumnDef::new(delegation_cid_alias::Column::Id) + .binary() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(delegation_cid_alias::Column::Canonical) + .binary() + .not_null(), + ) + .col( + ColumnDef::new(delegation_cid_alias::Column::Encrypted) + .boolean() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .from( + delegation_cid_alias::Entity, + delegation_cid_alias::Column::Id, + ) + .to(delegation::Entity, delegation::Column::Id), + ) + // Canonical identities may have no separately persisted + // serialization, so only the exact ID has a foreign key. + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .name("idx_delegation_cid_alias_canonical") + .table(delegation_cid_alias::Entity) + .col(delegation_cid_alias::Column::Canonical) + .col(delegation_cid_alias::Column::Id) + .if_not_exists() + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .name("idx_delegation_cid_alias_encrypted") + .table(delegation_cid_alias::Entity) + .col(delegation_cid_alias::Column::Encrypted) + .col(delegation_cid_alias::Column::Id) + .if_not_exists() + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("idx_delegation_cid_alias_canonical") + .table(delegation_cid_alias::Entity) + .to_owned(), + ) + .await?; + manager + .drop_index( + Index::drop() + .name("idx_delegation_cid_alias_encrypted") + .table(delegation_cid_alias::Entity) + .to_owned(), + ) + .await?; + manager + .drop_table(Table::drop().table(delegation_cid_alias::Entity).to_owned()) + .await + } +} diff --git a/tinycloud-core/src/migrations/mod.rs b/tinycloud-core/src/migrations/mod.rs index b73c241c..97ea9e61 100644 --- a/tinycloud-core/src/migrations/mod.rs +++ b/tinycloud-core/src/migrations/mod.rs @@ -24,6 +24,7 @@ pub mod m20261005_000000_current_kv_sync_order; pub mod m20261005_000000_deactivate_hook_subscriptions; pub mod m20261007_000000_database_alias; pub mod m20261007_010000_database_identity_fence; +pub mod m20261014_000000_delegation_cid_alias; pub struct Migrator; @@ -56,6 +57,7 @@ impl MigratorTrait for Migrator { Box::new(m20261005_000000_deactivate_hook_subscriptions::Migration), Box::new(m20261007_000000_database_alias::Migration), Box::new(m20261007_010000_database_identity_fence::Migration), + Box::new(m20261014_000000_delegation_cid_alias::Migration), ] } } @@ -73,7 +75,7 @@ mod release_line_tests { return; }; let admin = Database::connect(url.clone()).await.unwrap(); - let base_count = (Migrator::migrations().len() - 2) as u32; + let base_count = (Migrator::migrations().len() - 3) as u32; for path in ["fresh", "release"] { let schema = format!( "tc780_{path}_{}_{}", @@ -130,6 +132,7 @@ mod release_line_tests { .collect(); assert!(versions.contains(&"m20261007_000000_database_alias".to_string())); assert!(versions.contains(&"m20261007_010000_database_identity_fence".to_string())); + assert!(versions.contains(&"m20261014_000000_delegation_cid_alias".to_string())); drop(db); admin .execute(Statement::from_string( diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index 931e16f6..98fc2c03 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -6,7 +6,12 @@ use crate::policy_capability::sql_caveat; use crate::types::{Ability, Caveats, Facts, Resource}; use crate::util::DelegationMode; use crate::{events::Delegation, models::*, relationships::*, util}; -use sea_orm::{entity::prelude::*, sea_query::OnConflict, ConnectionTrait}; +use sea_orm::ActiveValue::Set; +use sea_orm::{ + entity::prelude::*, + sea_query::{Alias, CommonTableExpression, Expr, OnConflict, Query, WithClause}, + ConnectionTrait, DbBackend, QueryTrait, +}; use std::collections::BTreeMap; use time::OffsetDateTime; use tinycloud_auth::{authorization::TinyCloudDelegation, identity::did_principal_matches}; @@ -835,10 +840,11 @@ async fn save( // idempotency gate below. save_actors(&[&delegation.delegator, &delegation.delegate], db).await?; - // Idempotency gate: the delegation INSERT ON CONFLICT is the claim. If - // the row already exists (RecordNotInserted) we return Existing immediately - // — no abilities or parent rows are written. - match Entity::insert(ActiveModel::from(Model { + // The signed row remains the idempotency gate. Persist its alias in the + // same transaction; PostgreSQL uses a dependent INSERT CTE to keep the + // existing registration round-trip budget. + let canonical = delegation_cid_alias::canonical_hash(&serialization)?; + let insert = Entity::insert(ActiveModel::from(Model { id: hash, delegator: delegation.delegator, delegatee: delegation.delegate, @@ -848,15 +854,65 @@ async fn save( facts, serialization: stored_serialization, })) - .on_conflict(OnConflict::column(Column::Id).do_nothing().to_owned()) - .exec(db) - .await - { - Err(DbErr::RecordNotInserted) => return Ok(DelegationRegistration::Existing(hash)), - r => { - r?; + .on_conflict(OnConflict::column(Column::Id).do_nothing().to_owned()); + if db.get_database_backend() == DbBackend::Postgres { + let claimed = Alias::new("registered_delegation"); + let mut delegation_insert = insert.into_query(); + delegation_insert.returning(Query::returning().column(Column::Id).to_owned()); + let mut alias_insert = Query::insert(); + alias_insert + .into_table(delegation_cid_alias::Entity) + .columns([ + delegation_cid_alias::Column::Id, + delegation_cid_alias::Column::Canonical, + delegation_cid_alias::Column::Encrypted, + ]) + .select_from( + Query::select() + .column(Column::Id) + .expr(Expr::val(canonical)) + .expr(Expr::val(encryption.is_some())) + .from(claimed.clone()) + .to_owned(), + ) + .map_err(|error| DbErr::Custom(error.to_string()))? + .returning( + Query::returning() + .column(delegation_cid_alias::Column::Id) + .to_owned(), + ) + .with_cte( + WithClause::new() + .cte( + CommonTableExpression::new() + .table_name(claimed) + .query(delegation_insert) + .to_owned(), + ) + .to_owned(), + ); + if db + .query_one(db.get_database_backend().build(&alias_insert)) + .await? + .is_none() + { + return Ok(DelegationRegistration::Existing(hash)); } - }; + } else { + match insert.exec(db).await { + Err(DbErr::RecordNotInserted) => return Ok(DelegationRegistration::Existing(hash)), + result => { + result?; + } + }; + delegation_cid_alias::Entity::insert(delegation_cid_alias::ActiveModel { + id: Set(hash), + canonical: Set(canonical), + encrypted: Set(encryption.is_some()), + }) + .exec(db) + .await?; + } // save abilities — persist UCAN caveats with the delegation row. W1 // contract: caveats MUST NOT be reconstructed from invocation facts; diff --git a/tinycloud-core/src/models/delegation_cid_alias.rs b/tinycloud-core/src/models/delegation_cid_alias.rs new file mode 100644 index 00000000..985d0f6f --- /dev/null +++ b/tinycloud-core/src/models/delegation_cid_alias.rs @@ -0,0 +1,324 @@ +//! Canonical revocation identities without rewriting signed delegation history. + +use std::borrow::Cow; + +use base64::{engine::general_purpose::URL_SAFE, Engine as _}; +use sea_orm::{ + entity::prelude::*, sea_query::Query, ActiveValue::Set, ConnectionTrait, QueryOrder, + QuerySelect, +}; +use tinycloud_auth::authorization::{HeaderEncode, TinyCloudDelegation}; + +use crate::encryption::{maybe_decrypt, ColumnEncryption}; +use crate::hash::{hash, Hash}; +use crate::models::delegation; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "delegation_cid_alias")] +pub struct Model { + /// Hash of the exact plaintext serialization persisted in `delegation`. + #[sea_orm(primary_key, auto_increment = false)] + pub id: Hash, + /// Hash of current ingestion's canonical bytes; it need not be a stored row. + pub canonical: Hash, + /// Whether the original stored serialization requires the column key. + pub encrypted: bool, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "delegation::Entity", + from = "Column::Id", + to = "delegation::Column::Id" + )] + Delegation, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Delegation.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} + +/// Derive the same identity as current delegation ingestion, without changing +/// the stored serialization. UCAN JWT bytes remain exact; CACAO goes through +/// the public header decoder, including all current canonicalization rules. +/// Decoder rejection is fatal: a guessed self-alias could hide a revocation. +pub(crate) fn canonical_hash(serialization: &[u8]) -> Result { + let decoded = match std::str::from_utf8(serialization) { + Ok(token) if token.contains('.') => TinyCloudDelegation::decode(token), + _ => TinyCloudDelegation::decode(&URL_SAFE.encode(serialization)), + }; + let (_, canonical) = decoded.map_err(|_| { + DbErr::Custom("delegation CID alias backfill: delegation decoding failed".into()) + })?; + Ok(hash(&canonical)) +} + +/// Validate one indexed encrypted record on every boot, including after the +/// backfill is complete. A completed index must not permit keyless restarts. +async fn validate_encryption( + db: &C, + encryption: Option<&ColumnEncryption>, +) -> Result<(), DbErr> { + let encrypted_id = Query::select() + .column(Column::Id) + .from(Entity) + .and_where(Expr::col(Column::Encrypted).eq(true)) + .limit(1) + .to_owned(); + let Some((id, stored)) = delegation::Entity::find() + .select_only() + .column(delegation::Column::Id) + .column(delegation::Column::Serialization) + .filter(delegation::Column::Id.in_subquery(encrypted_id)) + .into_tuple::<(Hash, Vec)>() + .one(db) + .await? + else { + return Ok(()); + }; + let encryption = encryption.ok_or_else(|| { + DbErr::Custom( + "delegation CID alias backfill: encrypted delegation requires a column encryption key" + .into(), + ) + })?; + let plaintext = encryption + .decrypt(&stored) + .map_err(|_| DbErr::Custom("delegation CID alias backfill: decryption failed".into()))?; + if hash(&plaintext) != id { + return Err(DbErr::Custom( + "delegation CID alias backfill: plaintext content hash does not match stored ID".into(), + )); + } + Ok(()) +} + +/// Backfill only persisted delegations with no alias, in bounded keyset pages. +/// The caller must use its boot transaction so any failure rolls back all +/// aliases written by this call. Encrypted rows require the configured key; +/// decryption, plaintext content-hash, and decoder failures abort startup. +/// Original IDs, serializations, proof edges, and revocation targets are never +/// updated. Completed restarts validate only one indexed encrypted record. +pub(crate) async fn backfill( + db: &C, + encryption: Option<&ColumnEncryption>, +) -> Result<(), DbErr> { + const PAGE_SIZE: u64 = 128; + let mut after = None; + loop { + let mut query = delegation::Entity::find() + .select_only() + .column(delegation::Column::Id) + .column(delegation::Column::Serialization) + .filter( + Expr::exists( + Query::select() + .expr(Expr::value(1)) + .from(Entity) + .and_where( + Expr::col((Entity, Column::Id)) + .equals((delegation::Entity, delegation::Column::Id)), + ) + .to_owned(), + ) + .not(), + ) + .order_by_asc(delegation::Column::Id) + .limit(PAGE_SIZE); + if let Some(id) = after { + query = query.filter(delegation::Column::Id.gt(id)); + } + let rows = query.into_tuple::<(Hash, Vec)>().all(db).await?; + if rows.is_empty() { + return validate_encryption(db, encryption).await; + } + + let mut aliases = Vec::with_capacity(rows.len()); + for (id, stored) in rows { + // This is ColumnEncryption's version marker. Borrow legacy + // plaintext instead of copying it through the passthrough helper. + let serialization = if stored.first() == Some(&0x01) { + let encryption = encryption.ok_or_else(|| { + DbErr::Custom( + "delegation CID alias backfill: encrypted delegation requires a column encryption key" + .into(), + ) + })?; + Cow::Owned(maybe_decrypt(Some(encryption), &stored).map_err(|_| { + DbErr::Custom("delegation CID alias backfill: decryption failed".into()) + })?) + } else { + Cow::Borrowed(stored.as_slice()) + }; + if hash(serialization.as_ref()) != id { + return Err(DbErr::Custom( + "delegation CID alias backfill: plaintext content hash does not match stored ID" + .into(), + )); + } + aliases.push(ActiveModel { + id: Set(id), + canonical: Set(canonical_hash(serialization.as_ref())?), + encrypted: Set(stored.first() == Some(&0x01)), + }); + after = Some(id); + } + Entity::insert_many(aliases).exec(db).await?; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::migrations::Migrator; + use crate::models::actor; + use crate::test_support::did_resolution::signed_ucan; + use sea_orm::{ActiveModelTrait, Database, DbConn, TransactionTrait}; + use sea_orm_migration::MigratorTrait; + use tinycloud_auth::ssi::jwk::{Algorithm, JWK}; + + const ACTOR: &str = "did:key:cid-alias-fixture"; + + fn ucan_serialization() -> Vec { + signed_ucan(ACTOR, &JWK::generate_ed25519().unwrap(), Algorithm::EdDSA) + .encode() + .unwrap() + .into_bytes() + } + + async fn database() -> DbConn { + let db = Database::connect("sqlite::memory:").await.unwrap(); + Migrator::up(&db, None).await.unwrap(); + actor::ActiveModel { + id: Set(ACTOR.into()), + } + .insert(&db) + .await + .unwrap(); + db + } + + async fn insert_delegation(db: &DbConn, id: Hash, serialization: Vec) { + delegation::ActiveModel { + id: Set(id), + delegator: Set(ACTOR.into()), + delegatee: Set(ACTOR.into()), + expiry: Set(None), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(serialization), + } + .insert(db) + .await + .unwrap(); + } + + #[test] + fn ucan_canonical_identity_preserves_exact_jwt_bytes() { + let serialization = ucan_serialization(); + assert_eq!( + canonical_hash(&serialization).unwrap(), + hash(&serialization) + ); + } + + #[tokio::test] + async fn encrypted_backfill_preserves_storage_and_restart_requires_the_key() { + let db = database().await; + let encryption = ColumnEncryption::new([0x42; 32]); + let serialization = ucan_serialization(); + let id = hash(&serialization); + let stored = encryption.encrypt(&serialization); + insert_delegation(&db, id, stored.clone()).await; + + backfill(&db, Some(&encryption)).await.unwrap(); + assert_eq!( + Entity::find_by_id(id).one(&db).await.unwrap().unwrap(), + Model { + id, + canonical: id, + encrypted: true + } + ); + assert_eq!( + delegation::Entity::find_by_id(id) + .one(&db) + .await + .unwrap() + .unwrap() + .serialization, + stored + ); + assert!(backfill(&db, None).await.is_err()); + assert!(backfill(&db, Some(&ColumnEncryption::new([0x43; 32]))) + .await + .is_err()); + backfill(&db, Some(&encryption)).await.unwrap(); + } + + #[tokio::test] + async fn encrypted_backfill_requires_the_correct_key() { + let db = database().await; + let encryption = ColumnEncryption::new([0x42; 32]); + let serialization = ucan_serialization(); + insert_delegation( + &db, + hash(&serialization), + encryption.encrypt(&serialization), + ) + .await; + + let missing = backfill(&db, None).await.unwrap_err(); + assert!(missing + .to_string() + .contains("requires a column encryption key")); + let wrong = ColumnEncryption::new([0x43; 32]); + let rejected = backfill(&db, Some(&wrong)).await.unwrap_err(); + assert!(rejected.to_string().contains("decryption failed")); + assert_eq!(Entity::find().count(&db).await.unwrap(), 0); + } + + #[tokio::test] + async fn mismatched_plaintext_hash_is_not_aliased() { + let db = database().await; + insert_delegation(&db, hash(b"different content"), ucan_serialization()).await; + let error = backfill(&db, None).await.unwrap_err(); + assert!(error + .to_string() + .contains("plaintext content hash does not match stored ID")); + assert_eq!(Entity::find().count(&db).await.unwrap(), 0); + } + + #[tokio::test] + async fn undecodable_cacao_aborts_without_a_self_alias() { + let db = database().await; + let serialization = vec![0xa0]; + insert_delegation(&db, hash(&serialization), serialization).await; + let transaction = db.begin().await.unwrap(); + let error = backfill(&transaction, None).await.unwrap_err(); + assert!(error.to_string().contains("delegation decoding failed")); + transaction.rollback().await.unwrap(); + assert_eq!(Entity::find().count(&db).await.unwrap(), 0); + } + + #[tokio::test] + async fn keyset_backfill_indexes_more_than_one_page() { + let db = database().await; + for _ in 0..129 { + let serialization = ucan_serialization(); + let id = hash(&serialization); + insert_delegation(&db, id, serialization).await; + } + + backfill(&db, None).await.unwrap(); + assert_eq!(Entity::find().count(&db).await.unwrap(), 129); + backfill(&db, None).await.unwrap(); + assert_eq!(Entity::find().count(&db).await.unwrap(), 129); + } +} diff --git a/tinycloud-core/src/models/mod.rs b/tinycloud-core/src/models/mod.rs index a8a0afca..afefa687 100644 --- a/tinycloud-core/src/models/mod.rs +++ b/tinycloud-core/src/models/mod.rs @@ -6,6 +6,7 @@ pub mod database_artifact; pub mod database_identity_fence; pub mod database_legacy_artifact; pub mod delegation; +pub mod delegation_cid_alias; pub(crate) use crate::did_resolution; pub mod encryption_audit; pub mod encryption_ceremony; diff --git a/tinycloud-core/src/models/revocation.rs b/tinycloud-core/src/models/revocation.rs index 7321ef7c..02137040 100644 --- a/tinycloud-core/src/models/revocation.rs +++ b/tinycloud-core/src/models/revocation.rs @@ -2,8 +2,11 @@ use super::super::{events::Revocation, models::*, relationships::*}; use crate::hash::{hash, Hash}; use crate::models::did_resolution::{verify_ucan_signature, UcanVerifyError}; use crate::types::Resource; +use sea_orm::Condition; use sea_orm::{ - entity::prelude::*, sea_query::OnConflict, ConnectionTrait, QueryOrder, QuerySelect, + entity::prelude::*, + sea_query::{Alias, Expr, JoinType, OnConflict, Query, SimpleExpr, UnionType}, + ConnectionTrait, FromQueryResult, QueryOrder, QuerySelect, }; use std::collections::HashSet; use time::OffsetDateTime; @@ -68,15 +71,108 @@ pub enum ChainTraversalError { LimitExceeded, } +/// Resolve revocation targets through the persisted canonical identity. The +/// exact-target branch also handles graph rows created directly by unit tests. +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())) + .to_owned(); + let equivalents = Query::select() + .column(AliasColumn::Id) + .from(AliasEntity) + .and_where(Expr::col(AliasColumn::Canonical).in_subquery(canonical)) + .to_owned(); + Condition::any() + .add(Expr::col((Entity, Column::Revoked)).eq(id)) + .add(Expr::col((Entity, Column::Revoked)).in_subquery(equivalents)) +} + +/// Correlated, indexed revocation exclusion for legacy delegation listings. +pub(crate) fn unrevoked_condition() -> SimpleExpr { + Expr::exists( + Query::select() + .expr(Expr::value(1)) + .from(Entity) + .cond_where(target_condition( + Expr::col((delegation::Entity, delegation::Column::Id)).into(), + )) + .to_owned(), + ) + .not() +} + +/// Load effective revocations in one indexed batch while keeping the original +/// signed revocation target and bytes intact. The returned key is the queried +/// delegation, not necessarily the CID the owner named in the revocation. +pub(crate) async fn effective_rows( + db: &C, + ids: &[Hash], +) -> Result, DbErr> { + if ids.is_empty() { + return Ok(Vec::new()); + } + use delegation_cid_alias::{Column as AliasColumn, Entity as AliasEntity}; + let source = Alias::new("revocation_source_alias"); + let target = Alias::new("revocation_target_alias"); + let effective_id = Alias::new("effective_id"); + let columns = [ + Column::Id, + Column::Revoker, + Column::Revoked, + Column::Serialization, + Column::RevokedAt, + ]; + let mut direct = Query::select(); + direct + .columns(columns.map(|column| (Entity, column))) + .expr_as(Expr::col((Entity, Column::Revoked)), effective_id.clone()) + .from(Entity) + .and_where(Expr::col((Entity, Column::Revoked)).is_in(ids.iter().copied())); + let mut aliased = Query::select(); + aliased + .columns(columns.map(|column| (Entity, column))) + .expr_as(Expr::col((source.clone(), AliasColumn::Id)), effective_id) + .from_as(AliasEntity, source.clone()) + .join_as( + JoinType::InnerJoin, + AliasEntity, + target.clone(), + Expr::col((source.clone(), AliasColumn::Canonical)) + .equals((target.clone(), AliasColumn::Canonical)), + ) + .join( + JoinType::InnerJoin, + Entity, + Expr::col((target, AliasColumn::Id)).equals((Entity, Column::Revoked)), + ) + .and_where(Expr::col((source, AliasColumn::Id)).is_in(ids.iter().copied())); + // A self-alias appears in both branches. UNION removes that duplicate + // without manufacturing a second revocation or rewriting its target. + direct.union(UnionType::Distinct, aliased); + db.query_all(db.get_database_backend().build(&direct)) + .await? + .into_iter() + .map(|row| { + Ok(( + row.try_get("", "effective_id")?, + Model::from_query_result(&row, "")?, + )) + }) + .collect() +} + pub(crate) async fn is_revoked( db: &C, delegation_id: &Hash, ) -> Result { Ok(Entity::find() - .filter(Column::Revoked.eq(*delegation_id)) - .count(db) + .filter(target_condition(Expr::value(*delegation_id))) + .one(db) .await? - > 0) + .is_some()) } pub(crate) async fn first_revoked_ancestor( diff --git a/tinycloud-core/tests/fixtures/tc1071/generated/active.sqlite b/tinycloud-core/tests/fixtures/tc1071/generated/active.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..cf3d117eb3e4046cf0378d2a5458d7d886ba156d GIT binary patch literal 516096 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
#1>zdL6iO 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≷9%shjE=EN7=7KAjeRtVcl z;yc(@{Pe> zvAJ9k9gjZHFQrLAh%UUQ;wF+nN@WzFnqJbCua55k|MQDM7tG-7tD*qGzq?a_MYB48 zL3SxOgiyZEm3C^2Al*jm`Hb05zB)=YAP#h>{?Ln_egVs)m9IG2&`l{O0{bSZm6fhA+P|KUUnvtN&RZp4IZsR7}{4;2L`yOF%7nX z0tD$v7V}(_)h3R}1{jmQYy9mXXvP0YsQyP@*Z-{h-$KOFyNe1KHMImYz)K0Cy}ZE% zrqmP}Vap0xZywyf1Oy;#S=nU99-vA{Sx2|pLFqJtnI|`8$UQwcMU8M3IMMfpNddmc z=z4!NO7yMF`w!&Fik~%Gof3)PX=$0|Q7?6S8!vT375Au#)G~vzowT{#u#zuAz&J_Ku)fq?&3fYnoNHhT9(!^I9u;IP;~j4Ubk zqemhrzg56O$BMk?sa5-%sGmz2%*3&+%iRE3^&XRg-=UGCgW+HIf$p$#={f+zi&Gqj zsbw((Drp!>hPAOU4h7tV?DF?>4@`jY#@;{*c6TcagpNEeRrO-rWJm?CmJah^tsb66 z?dwr>ndI4lVs%zYq1{BcRZ(xUJMc-8EEb<- zT2oA^qQF}AxU(hPAsCy*#q{Bp3I z@vG05dolzP4P-g~+-G@@z$!kQjg7rn2ZW-i7=cp+3_gz9&p4rW#E0VnW&tV@!pO=s zl~G4}TD%<5`3@ko4897n=oSiFyWPr|ay9JA>&IVF=k#C0QA^Wf&Pv&YKW}SLr-^eR zhRBH_MRN;!pEJz840kq^C8Af?!p(FkkBbiou?#6U?sk|txt28`?X6Cn8`iFe8(}4} zuf9FiNuMv~$AduFG7CzG!#8w2TiCM_$1w&v^Ef<|a}_(ek|_4~7I7>^Ugq-$=zDe4 zz>c@((OWSuiV3@rMEqADq`wjdqAi@@D8qNDBpGQ zP=1`DFjF#PaSyN%I>OH_TX8v*WeB4>L=$|{bL|;MX;D7@Qg=-#!fgr1UrCPFSU)%`G?Z4RqFA!?Vqr_dx)2RDPj?m{}E5D|0z8PI_BX&hKvVBt^Y_yDDN< zCL78a>zZ3tLNwbe_XD=5#Mr0ctHAVyRd>A(D+mtwDzmhobWH@86h%!W5In0`$4fCa LPANIGHh%vNL()7v literal 0 HcmV?d00001 diff --git a/tinycloud-core/tests/fixtures/tc1071/generated/manifest.json b/tinycloud-core/tests/fixtures/tc1071/generated/manifest.json new file mode 100644 index 00000000..67d49fb6 --- /dev/null +++ b/tinycloud-core/tests/fixtures/tc1071/generated/manifest.json @@ -0,0 +1,342 @@ +[ + { + "generated_by": "f9d700bf public SpaceDatabase::new/delegate/revoke/invoke", + "database": "revoked-v0.sqlite", + "encrypted": false, + "issuer": "did:pkh:eip155:1:0x4a62316623ad457F02cDC5D997deD67a383EC569", + "resource": "tinycloud:pkh:eip155:1:0x4a62316623ad457F02cDC5D997deD67a383EC569:revoked-v0/kv", + "session_seed": 11, + "child_seed": 12, + "session_jwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "d": "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws", + "kty": "OKP", + "x": "Zr5-Myx6RTMyvZ0Kf32wVfXF7xoGraZtmLOftoEMRzo" + }, + "child_jwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "d": "DAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw", + "kty": "OKP", + "x": "C1E62bSSQBXKCQLtB5BE06xdvsIwbwaUjBDajrbjny0" + }, + "canonical_header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njllbm9uY2VxdGMxMDcxLXJldm9rZWQtdjBmZG9tYWlubnRjMTA3MS5pbnZhbGlkZnNjaGVtZWVodHRwc2d2ZXJzaW9uAWlyZXNvdXJjZXOBeQFNdXJuOnJlY2FwOmV5SmhkSFFpT25zaWRHbHVlV05zYjNWa09uQnJhRHBsYVhBeE5UVTZNVG93ZURSaE5qSXpNVFkyTWpOaFpEUTFOMFl3TW1ORVF6VkVPVGszWkdWRU5qZGhNemd6UlVNMU5qazZjbVYyYjJ0bFpDMTJNQzlyZGlJNmV5SjBhVzU1WTJ4dmRXUXVhM1l2YkdsemRDSTZXM3Q5WFgwc0luUnBibmxqYkc5MVpEcHdhMmc2Wldsd01UVTFPakU2TUhnMFlUWXlNekUyTmpJellXUTBOVGRHTURKalJFTTFSRGs1TjJSbFJEWTNZVE00TTBWRE5UWTVPbkpsZG05clpXUXRkakF2YzNCaFkyVWlPbnNpZEdsdWVXTnNiM1ZrTG5Od1lXTmxMMmh2YzNRaU9sdDdmVjE5ZlN3aWNISm1JanBiWFgwaXN0YXRlbWVudHkBO0kgZnVydGhlciBhdXRob3JpemUgdGhlIHN0YXRlZCBVUkkgdG8gcGVyZm9ybSB0aGUgZm9sbG93aW5nIGFjdGlvbnMgb24gbXkgYmVoYWxmOiAoMSkgJ3RpbnljbG91ZC5rdic6ICdsaXN0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjAva3YnLiAoMikgJ3RpbnljbG91ZC5zcGFjZSc6ICdob3N0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjAvc3BhY2UnLmFzomFzWEEzStHJIfiJ814D9uou9A7MGqhkAgd2p6gNHtR01DrG1HTn9JldgtspjI5pzMZoeFCGQzfk2BgfJ3ZvebEcFI0LHGF0ZmVpcDE5MQ==", + "rows": [ + { + "encoding": "v27", + "header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njllbm9uY2VxdGMxMDcxLXJldm9rZWQtdjBmZG9tYWlubnRjMTA3MS5pbnZhbGlkZnNjaGVtZWVodHRwc2d2ZXJzaW9uAWlyZXNvdXJjZXOBeQFNdXJuOnJlY2FwOmV5SmhkSFFpT25zaWRHbHVlV05zYjNWa09uQnJhRHBsYVhBeE5UVTZNVG93ZURSaE5qSXpNVFkyTWpOaFpEUTFOMFl3TW1ORVF6VkVPVGszWkdWRU5qZGhNemd6UlVNMU5qazZjbVYyYjJ0bFpDMTJNQzlyZGlJNmV5SjBhVzU1WTJ4dmRXUXVhM1l2YkdsemRDSTZXM3Q5WFgwc0luUnBibmxqYkc5MVpEcHdhMmc2Wldsd01UVTFPakU2TUhnMFlUWXlNekUyTmpJellXUTBOVGRHTURKalJFTTFSRGs1TjJSbFJEWTNZVE00TTBWRE5UWTVPbkpsZG05clpXUXRkakF2YzNCaFkyVWlPbnNpZEdsdWVXTnNiM1ZrTG5Od1lXTmxMMmh2YzNRaU9sdDdmVjE5ZlN3aWNISm1JanBiWFgwaXN0YXRlbWVudHkBO0kgZnVydGhlciBhdXRob3JpemUgdGhlIHN0YXRlZCBVUkkgdG8gcGVyZm9ybSB0aGUgZm9sbG93aW5nIGFjdGlvbnMgb24gbXkgYmVoYWxmOiAoMSkgJ3RpbnljbG91ZC5rdic6ICdsaXN0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjAva3YnLiAoMikgJ3RpbnljbG91ZC5zcGFjZSc6ICdob3N0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjAvc3BhY2UnLmFzomFzWEEzStHJIfiJ814D9uou9A7MGqhkAgd2p6gNHtR01DrG1HTn9JldgtspjI5pzMZoeFCGQzfk2BgfJ3ZvebEcFI0LHGF0ZmVpcDE5MQ==", + "raw_cid": "bafkr4icbta2ccgjle5elh6xrt4e7nyphculci6kfend2h2fxfcqcdt527q", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWNidGEyY2NnamxlNWVsaDZ4cnQ0ZTdueXBoY3VsY2k2a2ZlbmQyaDJmeGZjcWNkdDUyN3EiLCJwcmYiOlsiYmFma3I0aWNidGEyY2NnamxlNWVsaDZ4cnQ0ZTdueXBoY3VsY2k2a2ZlbmQyaDJmeGZjcWNkdDUyN3EiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjAva3YiOnsidGlueWNsb3VkLmt2L2xpc3QiOlt7fV19fX0.oCsIqdGQET9cUPYx-8kltIqBBn8MR4EY9V1bjJE6TGXZchxt4KmlhtTTLD_N4S2OT6uywkLLf9LXbdyVqBTKBw", + "child_cid": "bafkr4ibxd7cfy4kbspzqciitt5deazvrgtedplygztsqogzrztjqb475x4", + "child_proof_cid": "bafkr4icbta2ccgjle5elh6xrt4e7nyphculci6kfend2h2fxfcqcdt527q" + }, + { + "encoding": "v0", + "header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njllbm9uY2VxdGMxMDcxLXJldm9rZWQtdjBmZG9tYWlubnRjMTA3MS5pbnZhbGlkZnNjaGVtZWVodHRwc2d2ZXJzaW9uAWlyZXNvdXJjZXOBeQFNdXJuOnJlY2FwOmV5SmhkSFFpT25zaWRHbHVlV05zYjNWa09uQnJhRHBsYVhBeE5UVTZNVG93ZURSaE5qSXpNVFkyTWpOaFpEUTFOMFl3TW1ORVF6VkVPVGszWkdWRU5qZGhNemd6UlVNMU5qazZjbVYyYjJ0bFpDMTJNQzlyZGlJNmV5SjBhVzU1WTJ4dmRXUXVhM1l2YkdsemRDSTZXM3Q5WFgwc0luUnBibmxqYkc5MVpEcHdhMmc2Wldsd01UVTFPakU2TUhnMFlUWXlNekUyTmpJellXUTBOVGRHTURKalJFTTFSRGs1TjJSbFJEWTNZVE00TTBWRE5UWTVPbkpsZG05clpXUXRkakF2YzNCaFkyVWlPbnNpZEdsdWVXTnNiM1ZrTG5Od1lXTmxMMmh2YzNRaU9sdDdmVjE5ZlN3aWNISm1JanBiWFgwaXN0YXRlbWVudHkBO0kgZnVydGhlciBhdXRob3JpemUgdGhlIHN0YXRlZCBVUkkgdG8gcGVyZm9ybSB0aGUgZm9sbG93aW5nIGFjdGlvbnMgb24gbXkgYmVoYWxmOiAoMSkgJ3RpbnljbG91ZC5rdic6ICdsaXN0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjAva3YnLiAoMikgJ3RpbnljbG91ZC5zcGFjZSc6ICdob3N0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjAvc3BhY2UnLmFzomFzWEEzStHJIfiJ814D9uou9A7MGqhkAgd2p6gNHtR01DrG1HTn9JldgtspjI5pzMZoeFCGQzfk2BgfJ3ZvebEcFI0LAWF0ZmVpcDE5MQ==", + "raw_cid": "bafkr4ich2ybsskzmjef2eexmb5cbqv473wd3hzmkjrtos3ijph4euzoixe", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWNoMnlic3Nrem1qZWYyZWV4bWI1Y2JxdjQ3M3dkM2h6bWtqcnRvczNpanBoNGV1em9peGUiLCJwcmYiOlsiYmFma3I0aWNoMnlic3Nrem1qZWYyZWV4bWI1Y2JxdjQ3M3dkM2h6bWtqcnRvczNpanBoNGV1em9peGUiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjAva3YiOnsidGlueWNsb3VkLmt2L2xpc3QiOlt7fV19fX0.7ILzcnU_3npegeefK-2JQmdDFDnuEiWQuZOVbt4dEv_BzgrhOmnCX86fl1FKrcOEYhpcuzhgTvmlF1DF15DACQ", + "child_cid": "bafkr4ia7cbisvvwuo46zhu6sqzcyivnaat3zkrm3gmlbqngtanruill6ea", + "child_proof_cid": "bafkr4ich2ybsskzmjef2eexmb5cbqv473wd3hzmkjrtos3ijph4euzoixe" + }, + { + "encoding": "map-order", + "header": "o2FzomFzWEEzStHJIfiJ814D9uou9A7MGqhkAgd2p6gNHtR01DrG1HTn9JldgtspjI5pzMZoeFCGQzfk2BgfJ3ZvebEcFI0LHGF0ZmVpcDE5MWFwqmNhdWR4OGRpZDprZXk6ejZNa21OTDZ4a3NkRUpFTkdrOVoxcXFHc1k1bmpLUWRqVTFVaG5VRVV2V3lRVVZYY2V4cHQyMTAwLTAxLTAxVDAwOjAwOjAwWmNpYXR0MjAyMC0wMS0wMVQwMDowMDowMFpjaXNzeDtkaWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OWVub25jZXF0YzEwNzEtcmV2b2tlZC12MGZkb21haW5udGMxMDcxLmludmFsaWRmc2NoZW1lZWh0dHBzZ3ZlcnNpb24BaXJlc291cmNlc4F5AU11cm46cmVjYXA6ZXlKaGRIUWlPbnNpZEdsdWVXTnNiM1ZrT25CcmFEcGxhWEF4TlRVNk1Ub3dlRFJoTmpJek1UWTJNak5oWkRRMU4wWXdNbU5FUXpWRU9UazNaR1ZFTmpkaE16Z3pSVU0xTmprNmNtVjJiMnRsWkMxMk1DOXJkaUk2ZXlKMGFXNTVZMnh2ZFdRdWEzWXZiR2x6ZENJNlczdDlYWDBzSW5ScGJubGpiRzkxWkRwd2EyZzZaV2x3TVRVMU9qRTZNSGcwWVRZeU16RTJOakl6WVdRME5UZEdNREpqUkVNMVJEazVOMlJsUkRZM1lUTTRNMFZETlRZNU9uSmxkbTlyWldRdGRqQXZjM0JoWTJVaU9uc2lkR2x1ZVdOc2IzVmtMbk53WVdObEwyaHZjM1FpT2x0N2ZWMTlmU3dpY0hKbUlqcGJYWDBpc3RhdGVtZW50eQE7SSBmdXJ0aGVyIGF1dGhvcml6ZSB0aGUgc3RhdGVkIFVSSSB0byBwZXJmb3JtIHRoZSBmb2xsb3dpbmcgYWN0aW9ucyBvbiBteSBiZWhhbGY6ICgxKSAndGlueWNsb3VkLmt2JzogJ2xpc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6cmV2b2tlZC12MC9rdicuICgyKSAndGlueWNsb3VkLnNwYWNlJzogJ2hvc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6cmV2b2tlZC12MC9zcGFjZScuYWihYXRnZWlwNDM2MQ==", + "raw_cid": "bafkr4ie7ybmqqynccsui6tymx7y3i3o47y67a77f7ixutqn5xfjxkmhuai", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWU3eWJtcXF5bmNjc3VpNnR5bXg3eTNpM280N3k2N2E3N2Y3aXh1dHFuNXhmanhrbWh1YWkiLCJwcmYiOlsiYmFma3I0aWU3eWJtcXF5bmNjc3VpNnR5bXg3eTNpM280N3k2N2E3N2Y3aXh1dHFuNXhmanhrbWh1YWkiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjAva3YiOnsidGlueWNsb3VkLmt2L2xpc3QiOlt7fV19fX0.mraReiElTBdFY9bPMSfkwNftjOEy5V1KhxqTXROdkj44fdZ_ucP_syT4LlnvUJl7zMKmb4m2lf6pqfaHMXnmDg", + "child_cid": "bafkr4icl3s4ez332k4q5kv7l765ajhwjh2irqd5y6kvtpx53ikru243z2e", + "child_proof_cid": "bafkr4ie7ybmqqynccsui6tymx7y3i3o47y67a77f7ixutqn5xfjxkmhuai" + }, + { + "encoding": "nonminimal", + "header": "uANhaKFhdGdlaXA0MzYxYXCqY2F1ZHg4ZGlkOmtleTp6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlhjZXhwdDIxMDAtMDEtMDFUMDA6MDA6MDBaY2lhdHQyMDIwLTAxLTAxVDAwOjAwOjAwWmNpc3N4O2RpZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5ZW5vbmNlcXRjMTA3MS1yZXZva2VkLXYwZmRvbWFpbm50YzEwNzEuaW52YWxpZGZzY2hlbWVlaHR0cHNndmVyc2lvbgFpcmVzb3VyY2VzgXkBTXVybjpyZWNhcDpleUpoZEhRaU9uc2lkR2x1ZVdOc2IzVmtPbkJyYURwbGFYQXhOVFU2TVRvd2VEUmhOakl6TVRZMk1qTmhaRFExTjBZd01tTkVRelZFT1RrM1pHVkVOamRoTXpnelJVTTFOams2Y21WMmIydGxaQzEyTUM5cmRpSTZleUowYVc1NVkyeHZkV1F1YTNZdmJHbHpkQ0k2VzN0OVhYMHNJblJwYm5samJHOTFaRHB3YTJnNlpXbHdNVFUxT2pFNk1IZzBZVFl5TXpFMk5qSXpZV1EwTlRkR01ESmpSRU0xUkRrNU4yUmxSRFkzWVRNNE0wVkROVFk1T25KbGRtOXJaV1F0ZGpBdmMzQmhZMlVpT25zaWRHbHVlV05zYjNWa0xuTndZV05sTDJodmMzUWlPbHQ3ZlYxOWZTd2ljSEptSWpwYlhYMGlzdGF0ZW1lbnR5ATtJIGZ1cnRoZXIgYXV0aG9yaXplIHRoZSBzdGF0ZWQgVVJJIHRvIHBlcmZvcm0gdGhlIGZvbGxvd2luZyBhY3Rpb25zIG9uIG15IGJlaGFsZjogKDEpICd0aW55Y2xvdWQua3YnOiAnbGlzdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTpyZXZva2VkLXYwL2t2Jy4gKDIpICd0aW55Y2xvdWQuc3BhY2UnOiAnaG9zdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTpyZXZva2VkLXYwL3NwYWNlJy5hc6Jhc1hBM0rRySH4ifNeA_bqLvQOzBqoZAIHdqeoDR7UdNQ6xtR05_SZXYLbKYyOaczGaHhQhkM35NgYHyd2b3mxHBSNCxxhdGZlaXAxOTE=", + "raw_cid": "bafkr4iaualygicsk3wj6q2pedhpli3wap2ii3q7ef7oxbwswmonjj6bo6y", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWF1YWx5Z2ljc2szd2o2cTJwZWRocGxpM3dhcDJpaTNxN2VmN294Yndzd21vbmpqNmJvNnkiLCJwcmYiOlsiYmFma3I0aWF1YWx5Z2ljc2szd2o2cTJwZWRocGxpM3dhcDJpaTNxN2VmN294Yndzd21vbmpqNmJvNnkiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjAva3YiOnsidGlueWNsb3VkLmt2L2xpc3QiOlt7fV19fX0.rRMOYHNkNlauZgcbc8oZujLqMg_rSKn2ZNHCt72rh-3KXskaXfCcmIEY5lFsD_8x8YjeBdi_5uQ2T-mN1G5nBw", + "child_cid": "bafkr4idtyyda3nk6t7fosq3gwa4d3y7upyj6y2izthg6oldl3piekellpq", + "child_proof_cid": "bafkr4iaualygicsk3wj6q2pedhpli3wap2ii3q7ef7oxbwswmonjj6bo6y" + }, + { + "encoding": "null", + "header": "o2FooWF0Z2VpcDQzNjFhcKtjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1NjljbmJm9mVub25jZXF0YzEwNzEtcmV2b2tlZC12MGZkb21haW5udGMxMDcxLmludmFsaWRmc2NoZW1lZWh0dHBzZ3ZlcnNpb24BaXJlc291cmNlc4F5AU11cm46cmVjYXA6ZXlKaGRIUWlPbnNpZEdsdWVXTnNiM1ZrT25CcmFEcGxhWEF4TlRVNk1Ub3dlRFJoTmpJek1UWTJNak5oWkRRMU4wWXdNbU5FUXpWRU9UazNaR1ZFTmpkaE16Z3pSVU0xTmprNmNtVjJiMnRsWkMxMk1DOXJkaUk2ZXlKMGFXNTVZMnh2ZFdRdWEzWXZiR2x6ZENJNlczdDlYWDBzSW5ScGJubGpiRzkxWkRwd2EyZzZaV2x3TVRVMU9qRTZNSGcwWVRZeU16RTJOakl6WVdRME5UZEdNREpqUkVNMVJEazVOMlJsUkRZM1lUTTRNMFZETlRZNU9uSmxkbTlyWldRdGRqQXZjM0JoWTJVaU9uc2lkR2x1ZVdOc2IzVmtMbk53WVdObEwyaHZjM1FpT2x0N2ZWMTlmU3dpY0hKbUlqcGJYWDBpc3RhdGVtZW50eQE7SSBmdXJ0aGVyIGF1dGhvcml6ZSB0aGUgc3RhdGVkIFVSSSB0byBwZXJmb3JtIHRoZSBmb2xsb3dpbmcgYWN0aW9ucyBvbiBteSBiZWhhbGY6ICgxKSAndGlueWNsb3VkLmt2JzogJ2xpc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6cmV2b2tlZC12MC9rdicuICgyKSAndGlueWNsb3VkLnNwYWNlJzogJ2hvc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6cmV2b2tlZC12MC9zcGFjZScuYXOiYXNYQTNK0ckh-InzXgP26i70DswaqGQCB3anqA0e1HTUOsbUdOf0mV2C2ymMjmnMxmh4UIZDN-TYGB8ndm95sRwUjQscYXRmZWlwMTkx", + "raw_cid": "bafkr4idcgrtyubknenl5ue3whw7elkfzmam4r2rfxgdvxrjn5jh632lxwa", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWRjZ3J0eXVia25lbmw1dWUzd2h3N2Vsa2Z6bWFtNHIycmZ4Z2R2eHJqbjVqaDYzMmx4d2EiLCJwcmYiOlsiYmFma3I0aWRjZ3J0eXVia25lbmw1dWUzd2h3N2Vsa2Z6bWFtNHIycmZ4Z2R2eHJqbjVqaDYzMmx4d2EiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjAva3YiOnsidGlueWNsb3VkLmt2L2xpc3QiOlt7fV19fX0.tPb7ZAdKzbQz5k0aWRtnH9xSH09kcGSwjX39sgJH49AWJ23C9tOKziTv5Mz5w-SkA32if7TZmYteQ-R_mSeEAA", + "child_cid": "bafkr4ihxdctnunparnj7bnqqqrh6afvkkhbhjrwxckxuuplqvqbdkqdn3m", + "child_proof_cid": "bafkr4idcgrtyubknenl5ue3whw7elkfzmam4r2rfxgdvxrjn5jh632lxwa" + }, + { + "encoding": "issuer-case", + "header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N2YwMmNkYzVkOTk3ZGVkNjdhMzgzZWM1Njllbm9uY2VxdGMxMDcxLXJldm9rZWQtdjBmZG9tYWlubnRjMTA3MS5pbnZhbGlkZnNjaGVtZWVodHRwc2d2ZXJzaW9uAWlyZXNvdXJjZXOBeQFNdXJuOnJlY2FwOmV5SmhkSFFpT25zaWRHbHVlV05zYjNWa09uQnJhRHBsYVhBeE5UVTZNVG93ZURSaE5qSXpNVFkyTWpOaFpEUTFOMFl3TW1ORVF6VkVPVGszWkdWRU5qZGhNemd6UlVNMU5qazZjbVYyYjJ0bFpDMTJNQzlyZGlJNmV5SjBhVzU1WTJ4dmRXUXVhM1l2YkdsemRDSTZXM3Q5WFgwc0luUnBibmxqYkc5MVpEcHdhMmc2Wldsd01UVTFPakU2TUhnMFlUWXlNekUyTmpJellXUTBOVGRHTURKalJFTTFSRGs1TjJSbFJEWTNZVE00TTBWRE5UWTVPbkpsZG05clpXUXRkakF2YzNCaFkyVWlPbnNpZEdsdWVXTnNiM1ZrTG5Od1lXTmxMMmh2YzNRaU9sdDdmVjE5ZlN3aWNISm1JanBiWFgwaXN0YXRlbWVudHkBO0kgZnVydGhlciBhdXRob3JpemUgdGhlIHN0YXRlZCBVUkkgdG8gcGVyZm9ybSB0aGUgZm9sbG93aW5nIGFjdGlvbnMgb24gbXkgYmVoYWxmOiAoMSkgJ3RpbnljbG91ZC5rdic6ICdsaXN0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjAva3YnLiAoMikgJ3RpbnljbG91ZC5zcGFjZSc6ICdob3N0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjAvc3BhY2UnLmFzomFzWEEzStHJIfiJ814D9uou9A7MGqhkAgd2p6gNHtR01DrG1HTn9JldgtspjI5pzMZoeFCGQzfk2BgfJ3ZvebEcFI0LHGF0ZmVpcDE5MQ==", + "raw_cid": "bafkr4ibkq3meqj6uexlgibl3sgxzy4ynwme6g2yoa3i5wkhc5dhuchalai", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWJrcTNtZXFqNnVleGxnaWJsM3NneHp5NHlud21lNmcyeW9hM2k1d2toYzVkaHVjaGFsYWkiLCJwcmYiOlsiYmFma3I0aWJrcTNtZXFqNnVleGxnaWJsM3NneHp5NHlud21lNmcyeW9hM2k1d2toYzVkaHVjaGFsYWkiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjAva3YiOnsidGlueWNsb3VkLmt2L2xpc3QiOlt7fV19fX0.uSrxp4hfwGsV1TaDOO1ToOxxZxbfB_998-rb32kEaCaRCMemoDzJeECL_yErQVa7bwXO98PmPwycHfC2AoD5AA", + "child_cid": "bafkr4iboshxxvbpxufdea5w7keeruou3ujpvzculh2v5on5dp2anmlnqum", + "child_proof_cid": "bafkr4ibkq3meqj6uexlgibl3sgxzy4ynwme6g2yoa3i5wkhc5dhuchalai" + } + ], + "revocation_header": "o2FooWF0Z2VpcDQzNjFhcKhjYXVkeEB1Y2FuOmJhZmtyNGljaDJ5YnNza3ptamVmMmVleG1iNWNicXY0NzN3ZDNoem1ranJ0b3MzaWpwaDRldXpvaXhlY2V4cHQyMTAwLTAxLTAxVDAwOjAwOjAwWmNpYXR0MjAyMC0wMS0wMVQwMDowMDowMFpjaXNzeDtkaWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OWVub25jZXN0YzEwNzEtb3duZXItcmV2b2tlZmRvbWFpbm50YzEwNzEuaW52YWxpZGZzY2hlbWVlaHR0cHNndmVyc2lvbgFhc6Jhc1hBRrUfvn7t2RXJE5Nq_w2P8CyYgQCdjWBy75eypgm9r3J4_NOY5WvAuVkK2iPwg7kFoFR1fWxiw1KlXM4t7bvloRxhdGZlaXAxOTE=", + "revoked_raw_cid": "bafkr4ich2ybsskzmjef2eexmb5cbqv473wd3hzmkjrtos3ijph4euzoixe" + }, + { + "generated_by": "f9d700bf public SpaceDatabase::new/delegate/revoke/invoke", + "database": "revoked-v27.sqlite", + "encrypted": false, + "issuer": "did:pkh:eip155:1:0x4a62316623ad457F02cDC5D997deD67a383EC569", + "resource": "tinycloud:pkh:eip155:1:0x4a62316623ad457F02cDC5D997deD67a383EC569:revoked-v27/kv", + "session_seed": 11, + "child_seed": 12, + "session_jwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "d": "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws", + "kty": "OKP", + "x": "Zr5-Myx6RTMyvZ0Kf32wVfXF7xoGraZtmLOftoEMRzo" + }, + "child_jwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "d": "DAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw", + "kty": "OKP", + "x": "C1E62bSSQBXKCQLtB5BE06xdvsIwbwaUjBDajrbjny0" + }, + "canonical_header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njllbm9uY2VydGMxMDcxLXJldm9rZWQtdjI3ZmRvbWFpbm50YzEwNzEuaW52YWxpZGZzY2hlbWVlaHR0cHNndmVyc2lvbgFpcmVzb3VyY2VzgXkBUHVybjpyZWNhcDpleUpoZEhRaU9uc2lkR2x1ZVdOc2IzVmtPbkJyYURwbGFYQXhOVFU2TVRvd2VEUmhOakl6TVRZMk1qTmhaRFExTjBZd01tTkVRelZFT1RrM1pHVkVOamRoTXpnelJVTTFOams2Y21WMmIydGxaQzEyTWpjdmEzWWlPbnNpZEdsdWVXTnNiM1ZrTG10MkwyeHBjM1FpT2x0N2ZWMTlMQ0owYVc1NVkyeHZkV1E2Y0d0b09tVnBjREUxTlRveE9qQjROR0UyTWpNeE5qWXlNMkZrTkRVM1JqQXlZMFJETlVRNU9UZGtaVVEyTjJFek9ETkZRelUyT1RweVpYWnZhMlZrTFhZeU55OXpjR0ZqWlNJNmV5SjBhVzU1WTJ4dmRXUXVjM0JoWTJVdmFHOXpkQ0k2VzN0OVhYMTlMQ0p3Y21ZaU9sdGRmUWlzdGF0ZW1lbnR5AT1JIGZ1cnRoZXIgYXV0aG9yaXplIHRoZSBzdGF0ZWQgVVJJIHRvIHBlcmZvcm0gdGhlIGZvbGxvd2luZyBhY3Rpb25zIG9uIG15IGJlaGFsZjogKDEpICd0aW55Y2xvdWQua3YnOiAnbGlzdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTpyZXZva2VkLXYyNy9rdicuICgyKSAndGlueWNsb3VkLnNwYWNlJzogJ2hvc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6cmV2b2tlZC12Mjcvc3BhY2UnLmFzomFzWEFH0ELQOIJzdX2mZ8X1Bo6ubJqZDxCwYqwiA6wjp2Rt2AjMgEXc17dA1KtldOU9dcw1uopF7OdHYx2k-K4OG6DaHGF0ZmVpcDE5MQ==", + "rows": [ + { + "encoding": "v27", + "header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njllbm9uY2VydGMxMDcxLXJldm9rZWQtdjI3ZmRvbWFpbm50YzEwNzEuaW52YWxpZGZzY2hlbWVlaHR0cHNndmVyc2lvbgFpcmVzb3VyY2VzgXkBUHVybjpyZWNhcDpleUpoZEhRaU9uc2lkR2x1ZVdOc2IzVmtPbkJyYURwbGFYQXhOVFU2TVRvd2VEUmhOakl6TVRZMk1qTmhaRFExTjBZd01tTkVRelZFT1RrM1pHVkVOamRoTXpnelJVTTFOams2Y21WMmIydGxaQzEyTWpjdmEzWWlPbnNpZEdsdWVXTnNiM1ZrTG10MkwyeHBjM1FpT2x0N2ZWMTlMQ0owYVc1NVkyeHZkV1E2Y0d0b09tVnBjREUxTlRveE9qQjROR0UyTWpNeE5qWXlNMkZrTkRVM1JqQXlZMFJETlVRNU9UZGtaVVEyTjJFek9ETkZRelUyT1RweVpYWnZhMlZrTFhZeU55OXpjR0ZqWlNJNmV5SjBhVzU1WTJ4dmRXUXVjM0JoWTJVdmFHOXpkQ0k2VzN0OVhYMTlMQ0p3Y21ZaU9sdGRmUWlzdGF0ZW1lbnR5AT1JIGZ1cnRoZXIgYXV0aG9yaXplIHRoZSBzdGF0ZWQgVVJJIHRvIHBlcmZvcm0gdGhlIGZvbGxvd2luZyBhY3Rpb25zIG9uIG15IGJlaGFsZjogKDEpICd0aW55Y2xvdWQua3YnOiAnbGlzdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTpyZXZva2VkLXYyNy9rdicuICgyKSAndGlueWNsb3VkLnNwYWNlJzogJ2hvc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6cmV2b2tlZC12Mjcvc3BhY2UnLmFzomFzWEFH0ELQOIJzdX2mZ8X1Bo6ubJqZDxCwYqwiA6wjp2Rt2AjMgEXc17dA1KtldOU9dcw1uopF7OdHYx2k-K4OG6DaHGF0ZmVpcDE5MQ==", + "raw_cid": "bafkr4iet2gsd5jtlobt27tskjvwvzwjah6wbrdz5uid7ie7auj37khus2q", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWV0MmdzZDVqdGxvYnQyN3Rza2p2d3Z6d2phaDZ3YnJkejV1aWQ3aWU3YXVqMzdraHVzMnEiLCJwcmYiOlsiYmFma3I0aWV0MmdzZDVqdGxvYnQyN3Rza2p2d3Z6d2phaDZ3YnJkejV1aWQ3aWU3YXVqMzdraHVzMnEiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjI3L2t2Ijp7InRpbnljbG91ZC5rdi9saXN0Ijpbe31dfX19.hiM2VKwqnDyfc2NLa07_lVED5-0vimWX3iMkpkiRxvOm7Fr2uGaZ1SOHmhRjjEWXIfaHxvtRnUEUxX5vNRb9AA", + "child_cid": "bafkr4ifeepza3pxa4sa5upwdtb5tyu4s7twpexluwcuxe5bk623436kmcy", + "child_proof_cid": "bafkr4iet2gsd5jtlobt27tskjvwvzwjah6wbrdz5uid7ie7auj37khus2q" + }, + { + "encoding": "v0", + "header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njllbm9uY2VydGMxMDcxLXJldm9rZWQtdjI3ZmRvbWFpbm50YzEwNzEuaW52YWxpZGZzY2hlbWVlaHR0cHNndmVyc2lvbgFpcmVzb3VyY2VzgXkBUHVybjpyZWNhcDpleUpoZEhRaU9uc2lkR2x1ZVdOc2IzVmtPbkJyYURwbGFYQXhOVFU2TVRvd2VEUmhOakl6TVRZMk1qTmhaRFExTjBZd01tTkVRelZFT1RrM1pHVkVOamRoTXpnelJVTTFOams2Y21WMmIydGxaQzEyTWpjdmEzWWlPbnNpZEdsdWVXTnNiM1ZrTG10MkwyeHBjM1FpT2x0N2ZWMTlMQ0owYVc1NVkyeHZkV1E2Y0d0b09tVnBjREUxTlRveE9qQjROR0UyTWpNeE5qWXlNMkZrTkRVM1JqQXlZMFJETlVRNU9UZGtaVVEyTjJFek9ETkZRelUyT1RweVpYWnZhMlZrTFhZeU55OXpjR0ZqWlNJNmV5SjBhVzU1WTJ4dmRXUXVjM0JoWTJVdmFHOXpkQ0k2VzN0OVhYMTlMQ0p3Y21ZaU9sdGRmUWlzdGF0ZW1lbnR5AT1JIGZ1cnRoZXIgYXV0aG9yaXplIHRoZSBzdGF0ZWQgVVJJIHRvIHBlcmZvcm0gdGhlIGZvbGxvd2luZyBhY3Rpb25zIG9uIG15IGJlaGFsZjogKDEpICd0aW55Y2xvdWQua3YnOiAnbGlzdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTpyZXZva2VkLXYyNy9rdicuICgyKSAndGlueWNsb3VkLnNwYWNlJzogJ2hvc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6cmV2b2tlZC12Mjcvc3BhY2UnLmFzomFzWEFH0ELQOIJzdX2mZ8X1Bo6ubJqZDxCwYqwiA6wjp2Rt2AjMgEXc17dA1KtldOU9dcw1uopF7OdHYx2k-K4OG6DaAWF0ZmVpcDE5MQ==", + "raw_cid": "bafkr4icdmivtzmkyu4fts5vglqvabggfuxhmfocrqcjoi24imqvwj22ipa", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWNkbWl2dHpta3l1NGZ0czV2Z2xxdmFiZ2dmdXhobWZvY3JxY2pvaTI0aW1xdndqMjJpcGEiLCJwcmYiOlsiYmFma3I0aWNkbWl2dHpta3l1NGZ0czV2Z2xxdmFiZ2dmdXhobWZvY3JxY2pvaTI0aW1xdndqMjJpcGEiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjI3L2t2Ijp7InRpbnljbG91ZC5rdi9saXN0Ijpbe31dfX19.rdTQdMdQKhtfvDPljQkuFVQ7Tf6D_Ou0T3eDI413ULlctt6uSZh79ZLlkGj0U9yMgxOG8L-bYKf04pB8meHWBQ", + "child_cid": "bafkr4ia6trz6edigj3c6kgzyu2nf3sxwotjrf57j63h36x4qhcr674jf3e", + "child_proof_cid": "bafkr4icdmivtzmkyu4fts5vglqvabggfuxhmfocrqcjoi24imqvwj22ipa" + }, + { + "encoding": "map-order", + "header": "o2FzomFzWEFH0ELQOIJzdX2mZ8X1Bo6ubJqZDxCwYqwiA6wjp2Rt2AjMgEXc17dA1KtldOU9dcw1uopF7OdHYx2k-K4OG6DaHGF0ZmVpcDE5MWFwqmNhdWR4OGRpZDprZXk6ejZNa21OTDZ4a3NkRUpFTkdrOVoxcXFHc1k1bmpLUWRqVTFVaG5VRVV2V3lRVVZYY2V4cHQyMTAwLTAxLTAxVDAwOjAwOjAwWmNpYXR0MjAyMC0wMS0wMVQwMDowMDowMFpjaXNzeDtkaWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OWVub25jZXJ0YzEwNzEtcmV2b2tlZC12MjdmZG9tYWlubnRjMTA3MS5pbnZhbGlkZnNjaGVtZWVodHRwc2d2ZXJzaW9uAWlyZXNvdXJjZXOBeQFQdXJuOnJlY2FwOmV5SmhkSFFpT25zaWRHbHVlV05zYjNWa09uQnJhRHBsYVhBeE5UVTZNVG93ZURSaE5qSXpNVFkyTWpOaFpEUTFOMFl3TW1ORVF6VkVPVGszWkdWRU5qZGhNemd6UlVNMU5qazZjbVYyYjJ0bFpDMTJNamN2YTNZaU9uc2lkR2x1ZVdOc2IzVmtMbXQyTDJ4cGMzUWlPbHQ3ZlYxOUxDSjBhVzU1WTJ4dmRXUTZjR3RvT21WcGNERTFOVG94T2pCNE5HRTJNak14TmpZeU0yRmtORFUzUmpBeVkwUkROVVE1T1Rka1pVUTJOMkV6T0RORlF6VTJPVHB5WlhadmEyVmtMWFl5Tnk5emNHRmpaU0k2ZXlKMGFXNTVZMnh2ZFdRdWMzQmhZMlV2YUc5emRDSTZXM3Q5WFgxOUxDSndjbVlpT2x0ZGZRaXN0YXRlbWVudHkBPUkgZnVydGhlciBhdXRob3JpemUgdGhlIHN0YXRlZCBVUkkgdG8gcGVyZm9ybSB0aGUgZm9sbG93aW5nIGFjdGlvbnMgb24gbXkgYmVoYWxmOiAoMSkgJ3RpbnljbG91ZC5rdic6ICdsaXN0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjI3L2t2Jy4gKDIpICd0aW55Y2xvdWQuc3BhY2UnOiAnaG9zdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTpyZXZva2VkLXYyNy9zcGFjZScuYWihYXRnZWlwNDM2MQ==", + "raw_cid": "bafkr4if2gactlsgkw2g5qbmp6bgrtyof23ib4t5mbj7fob4h2of5imlf2i", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWYyZ2FjdGxzZ2t3Mmc1cWJtcDZiZ3J0eW9mMjNpYjR0NW1iajdmb2I0aDJvZjVpbWxmMmkiLCJwcmYiOlsiYmFma3I0aWYyZ2FjdGxzZ2t3Mmc1cWJtcDZiZ3J0eW9mMjNpYjR0NW1iajdmb2I0aDJvZjVpbWxmMmkiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjI3L2t2Ijp7InRpbnljbG91ZC5rdi9saXN0Ijpbe31dfX19.5CiyHezOol9hPNEecIEvn2M4Y4lhLWav6S-qOY8Iy80JfbKSbFB3FdV_JW5qkTIDszTP0gz0klQPMUhcVkfVBg", + "child_cid": "bafkr4ibvr3kdsxotabwsmyknumju5wnounpjt4rkujiwcokjxbfc6fznqe", + "child_proof_cid": "bafkr4if2gactlsgkw2g5qbmp6bgrtyof23ib4t5mbj7fob4h2of5imlf2i" + }, + { + "encoding": "nonminimal", + "header": "uANhaKFhdGdlaXA0MzYxYXCqY2F1ZHg4ZGlkOmtleTp6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlhjZXhwdDIxMDAtMDEtMDFUMDA6MDA6MDBaY2lhdHQyMDIwLTAxLTAxVDAwOjAwOjAwWmNpc3N4O2RpZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5ZW5vbmNlcnRjMTA3MS1yZXZva2VkLXYyN2Zkb21haW5udGMxMDcxLmludmFsaWRmc2NoZW1lZWh0dHBzZ3ZlcnNpb24BaXJlc291cmNlc4F5AVB1cm46cmVjYXA6ZXlKaGRIUWlPbnNpZEdsdWVXTnNiM1ZrT25CcmFEcGxhWEF4TlRVNk1Ub3dlRFJoTmpJek1UWTJNak5oWkRRMU4wWXdNbU5FUXpWRU9UazNaR1ZFTmpkaE16Z3pSVU0xTmprNmNtVjJiMnRsWkMxMk1qY3ZhM1lpT25zaWRHbHVlV05zYjNWa0xtdDJMMnhwYzNRaU9sdDdmVjE5TENKMGFXNTVZMnh2ZFdRNmNHdG9PbVZwY0RFMU5Ub3hPakI0TkdFMk1qTXhOall5TTJGa05EVTNSakF5WTBSRE5VUTVPVGRrWlVRMk4yRXpPRE5GUXpVMk9UcHlaWFp2YTJWa0xYWXlOeTl6Y0dGalpTSTZleUowYVc1NVkyeHZkV1F1YzNCaFkyVXZhRzl6ZENJNlczdDlYWDE5TENKd2NtWWlPbHRkZlFpc3RhdGVtZW50eQE9SSBmdXJ0aGVyIGF1dGhvcml6ZSB0aGUgc3RhdGVkIFVSSSB0byBwZXJmb3JtIHRoZSBmb2xsb3dpbmcgYWN0aW9ucyBvbiBteSBiZWhhbGY6ICgxKSAndGlueWNsb3VkLmt2JzogJ2xpc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6cmV2b2tlZC12Mjcva3YnLiAoMikgJ3RpbnljbG91ZC5zcGFjZSc6ICdob3N0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjI3L3NwYWNlJy5hc6Jhc1hBR9BC0DiCc3V9pmfF9QaOrmyamQ8QsGKsIgOsI6dkbdgIzIBF3Ne3QNSrZXTlPXXMNbqKReznR2MdpPiuDhug2hxhdGZlaXAxOTE=", + "raw_cid": "bafkr4ibca72m7pqz6hrwennh6pjz3kgr233piiwmo5wbcewd3qavjs4j5q", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWJjYTcybTdwcXo2aHJ3ZW5uaDZwanoza2dyMjMzcGlpd21vNXdiY2V3ZDNxYXZqczRqNXEiLCJwcmYiOlsiYmFma3I0aWJjYTcybTdwcXo2aHJ3ZW5uaDZwanoza2dyMjMzcGlpd21vNXdiY2V3ZDNxYXZqczRqNXEiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjI3L2t2Ijp7InRpbnljbG91ZC5rdi9saXN0Ijpbe31dfX19.S1dAXYm5U00-1IJWDEkld5q853rIs_CuCPMGXsaBIhuHo1_KOLpf8zklSB34MWqJOFQlyeOBWi3gttnxuA0HDw", + "child_cid": "bafkr4iba3krxcssxawbw3cctekj4qm76fxkaafddx4tkpshhcenilpmwyu", + "child_proof_cid": "bafkr4ibca72m7pqz6hrwennh6pjz3kgr233piiwmo5wbcewd3qavjs4j5q" + }, + { + "encoding": "null", + "header": "o2FooWF0Z2VpcDQzNjFhcKtjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1NjljbmJm9mVub25jZXJ0YzEwNzEtcmV2b2tlZC12MjdmZG9tYWlubnRjMTA3MS5pbnZhbGlkZnNjaGVtZWVodHRwc2d2ZXJzaW9uAWlyZXNvdXJjZXOBeQFQdXJuOnJlY2FwOmV5SmhkSFFpT25zaWRHbHVlV05zYjNWa09uQnJhRHBsYVhBeE5UVTZNVG93ZURSaE5qSXpNVFkyTWpOaFpEUTFOMFl3TW1ORVF6VkVPVGszWkdWRU5qZGhNemd6UlVNMU5qazZjbVYyYjJ0bFpDMTJNamN2YTNZaU9uc2lkR2x1ZVdOc2IzVmtMbXQyTDJ4cGMzUWlPbHQ3ZlYxOUxDSjBhVzU1WTJ4dmRXUTZjR3RvT21WcGNERTFOVG94T2pCNE5HRTJNak14TmpZeU0yRmtORFUzUmpBeVkwUkROVVE1T1Rka1pVUTJOMkV6T0RORlF6VTJPVHB5WlhadmEyVmtMWFl5Tnk5emNHRmpaU0k2ZXlKMGFXNTVZMnh2ZFdRdWMzQmhZMlV2YUc5emRDSTZXM3Q5WFgxOUxDSndjbVlpT2x0ZGZRaXN0YXRlbWVudHkBPUkgZnVydGhlciBhdXRob3JpemUgdGhlIHN0YXRlZCBVUkkgdG8gcGVyZm9ybSB0aGUgZm9sbG93aW5nIGFjdGlvbnMgb24gbXkgYmVoYWxmOiAoMSkgJ3RpbnljbG91ZC5rdic6ICdsaXN0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjI3L2t2Jy4gKDIpICd0aW55Y2xvdWQuc3BhY2UnOiAnaG9zdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTpyZXZva2VkLXYyNy9zcGFjZScuYXOiYXNYQUfQQtA4gnN1faZnxfUGjq5smpkPELBirCIDrCOnZG3YCMyARdzXt0DUq2V05T11zDW6ikXs50djHaT4rg4boNocYXRmZWlwMTkx", + "raw_cid": "bafkr4ifwunnetpgkrscldik5y3xqwgkonzz4xfnwjhxvlgw3tnzby7mydu", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWZ3dW5uZXRwZ2tyc2NsZGlrNXkzeHF3Z2tvbnp6NHhmbndqaHh2bGd3M3RuemJ5N215ZHUiLCJwcmYiOlsiYmFma3I0aWZ3dW5uZXRwZ2tyc2NsZGlrNXkzeHF3Z2tvbnp6NHhmbndqaHh2bGd3M3RuemJ5N215ZHUiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjI3L2t2Ijp7InRpbnljbG91ZC5rdi9saXN0Ijpbe31dfX19.3DaWwG151yCaJt1bzPR69aoPAphN-gopdxcifCGLk2dbYHgeOs09DzDO4pXP2TvgQoKOOwQd7LCwD88Vfem7CQ", + "child_cid": "bafkr4igqbe2vgwofso3quiqdwxyzkmqvewhbg5hzftxtipbdrwbm5h4d4u", + "child_proof_cid": "bafkr4ifwunnetpgkrscldik5y3xqwgkonzz4xfnwjhxvlgw3tnzby7mydu" + }, + { + "encoding": "issuer-case", + "header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N2YwMmNkYzVkOTk3ZGVkNjdhMzgzZWM1Njllbm9uY2VydGMxMDcxLXJldm9rZWQtdjI3ZmRvbWFpbm50YzEwNzEuaW52YWxpZGZzY2hlbWVlaHR0cHNndmVyc2lvbgFpcmVzb3VyY2VzgXkBUHVybjpyZWNhcDpleUpoZEhRaU9uc2lkR2x1ZVdOc2IzVmtPbkJyYURwbGFYQXhOVFU2TVRvd2VEUmhOakl6TVRZMk1qTmhaRFExTjBZd01tTkVRelZFT1RrM1pHVkVOamRoTXpnelJVTTFOams2Y21WMmIydGxaQzEyTWpjdmEzWWlPbnNpZEdsdWVXTnNiM1ZrTG10MkwyeHBjM1FpT2x0N2ZWMTlMQ0owYVc1NVkyeHZkV1E2Y0d0b09tVnBjREUxTlRveE9qQjROR0UyTWpNeE5qWXlNMkZrTkRVM1JqQXlZMFJETlVRNU9UZGtaVVEyTjJFek9ETkZRelUyT1RweVpYWnZhMlZrTFhZeU55OXpjR0ZqWlNJNmV5SjBhVzU1WTJ4dmRXUXVjM0JoWTJVdmFHOXpkQ0k2VzN0OVhYMTlMQ0p3Y21ZaU9sdGRmUWlzdGF0ZW1lbnR5AT1JIGZ1cnRoZXIgYXV0aG9yaXplIHRoZSBzdGF0ZWQgVVJJIHRvIHBlcmZvcm0gdGhlIGZvbGxvd2luZyBhY3Rpb25zIG9uIG15IGJlaGFsZjogKDEpICd0aW55Y2xvdWQua3YnOiAnbGlzdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTpyZXZva2VkLXYyNy9rdicuICgyKSAndGlueWNsb3VkLnNwYWNlJzogJ2hvc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6cmV2b2tlZC12Mjcvc3BhY2UnLmFzomFzWEFH0ELQOIJzdX2mZ8X1Bo6ubJqZDxCwYqwiA6wjp2Rt2AjMgEXc17dA1KtldOU9dcw1uopF7OdHYx2k-K4OG6DaHGF0ZmVpcDE5MQ==", + "raw_cid": "bafkr4ifdkwgynitwvzbmpomrsi3ybb4jmmy7szujhnbwy6bgti5wbfrjgi", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWZka3dneW5pdHd2emJtcG9tcnNpM3liYjRqbW15N3N6dWpobmJ3eTZiZ3RpNXdiZnJqZ2kiLCJwcmYiOlsiYmFma3I0aWZka3dneW5pdHd2emJtcG9tcnNpM3liYjRqbW15N3N6dWpobmJ3eTZiZ3RpNXdiZnJqZ2kiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnJldm9rZWQtdjI3L2t2Ijp7InRpbnljbG91ZC5rdi9saXN0Ijpbe31dfX19.k50OT7kgryLb0NbYCr7BPG48fURSsUDQReJx3iC1I0Qr8Vi6e8t-IHVeFMqeNOjvOMLHhcn0-SW_Ry7U7crYBQ", + "child_cid": "bafkr4iagi5i3555ju4fnxd4eee4e42xvbfdeglqziogszsxp3j3s47ynx4", + "child_proof_cid": "bafkr4ifdkwgynitwvzbmpomrsi3ybb4jmmy7szujhnbwy6bgti5wbfrjgi" + } + ], + "revocation_header": "o2FooWF0Z2VpcDQzNjFhcKhjYXVkeEB1Y2FuOmJhZmtyNGlldDJnc2Q1anRsb2J0Mjd0c2tqdnd2endqYWg2d2JyZHo1dWlkN2llN2F1ajM3a2h1czJxY2V4cHQyMTAwLTAxLTAxVDAwOjAwOjAwWmNpYXR0MjAyMC0wMS0wMVQwMDowMDowMFpjaXNzeDtkaWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OWVub25jZXN0YzEwNzEtb3duZXItcmV2b2tlZmRvbWFpbm50YzEwNzEuaW52YWxpZGZzY2hlbWVlaHR0cHNndmVyc2lvbgFhc6Jhc1hBAn8uvVPYmxTvr_R-8VdcAJcqOSBi0X8AwDj7Fybe5i4kTR_ubJyaF-z8-uzvRToiW5_EtikpfiPscGs68sb9pRxhdGZlaXAxOTE=", + "revoked_raw_cid": "bafkr4iet2gsd5jtlobt27tskjvwvzwjah6wbrdz5uid7ie7auj37khus2q" + }, + { + "generated_by": "f9d700bf public SpaceDatabase::new/delegate/revoke/invoke", + "database": "active.sqlite", + "encrypted": false, + "issuer": "did:pkh:eip155:1:0x4a62316623ad457F02cDC5D997deD67a383EC569", + "resource": "tinycloud:pkh:eip155:1:0x4a62316623ad457F02cDC5D997deD67a383EC569:active/kv", + "session_seed": 11, + "child_seed": 12, + "session_jwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "d": "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws", + "kty": "OKP", + "x": "Zr5-Myx6RTMyvZ0Kf32wVfXF7xoGraZtmLOftoEMRzo" + }, + "child_jwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "d": "DAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw", + "kty": "OKP", + "x": "C1E62bSSQBXKCQLtB5BE06xdvsIwbwaUjBDajrbjny0" + }, + "canonical_header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njllbm9uY2VtdGMxMDcxLWFjdGl2ZWZkb21haW5udGMxMDcxLmludmFsaWRmc2NoZW1lZWh0dHBzZ3ZlcnNpb24BaXJlc291cmNlc4F5AUJ1cm46cmVjYXA6ZXlKaGRIUWlPbnNpZEdsdWVXTnNiM1ZrT25CcmFEcGxhWEF4TlRVNk1Ub3dlRFJoTmpJek1UWTJNak5oWkRRMU4wWXdNbU5FUXpWRU9UazNaR1ZFTmpkaE16Z3pSVU0xTmprNllXTjBhWFpsTDJ0MklqcDdJblJwYm5samJHOTFaQzVyZGk5c2FYTjBJanBiZTMxZGZTd2lkR2x1ZVdOc2IzVmtPbkJyYURwbGFYQXhOVFU2TVRvd2VEUmhOakl6TVRZMk1qTmhaRFExTjBZd01tTkVRelZFT1RrM1pHVkVOamRoTXpnelJVTTFOams2WVdOMGFYWmxMM053WVdObElqcDdJblJwYm5samJHOTFaQzV6Y0dGalpTOW9iM04wSWpwYmUzMWRmWDBzSW5CeVppSTZXMTE5aXN0YXRlbWVudHkBM0kgZnVydGhlciBhdXRob3JpemUgdGhlIHN0YXRlZCBVUkkgdG8gcGVyZm9ybSB0aGUgZm9sbG93aW5nIGFjdGlvbnMgb24gbXkgYmVoYWxmOiAoMSkgJ3RpbnljbG91ZC5rdic6ICdsaXN0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmFjdGl2ZS9rdicuICgyKSAndGlueWNsb3VkLnNwYWNlJzogJ2hvc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6YWN0aXZlL3NwYWNlJy5hc6Jhc1hBX40pIGdLrZ9xKtVsdykwjGD0tsiKOyMscFZ_lTZh-SUFAyZYYScTeDPsF1ziyfKnYBY5wlKvnXqlZdWssJDHsRthdGZlaXAxOTE=", + "rows": [ + { + "encoding": "v27", + "header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njllbm9uY2VtdGMxMDcxLWFjdGl2ZWZkb21haW5udGMxMDcxLmludmFsaWRmc2NoZW1lZWh0dHBzZ3ZlcnNpb24BaXJlc291cmNlc4F5AUJ1cm46cmVjYXA6ZXlKaGRIUWlPbnNpZEdsdWVXTnNiM1ZrT25CcmFEcGxhWEF4TlRVNk1Ub3dlRFJoTmpJek1UWTJNak5oWkRRMU4wWXdNbU5FUXpWRU9UazNaR1ZFTmpkaE16Z3pSVU0xTmprNllXTjBhWFpsTDJ0MklqcDdJblJwYm5samJHOTFaQzVyZGk5c2FYTjBJanBiZTMxZGZTd2lkR2x1ZVdOc2IzVmtPbkJyYURwbGFYQXhOVFU2TVRvd2VEUmhOakl6TVRZMk1qTmhaRFExTjBZd01tTkVRelZFT1RrM1pHVkVOamRoTXpnelJVTTFOams2WVdOMGFYWmxMM053WVdObElqcDdJblJwYm5samJHOTFaQzV6Y0dGalpTOW9iM04wSWpwYmUzMWRmWDBzSW5CeVppSTZXMTE5aXN0YXRlbWVudHkBM0kgZnVydGhlciBhdXRob3JpemUgdGhlIHN0YXRlZCBVUkkgdG8gcGVyZm9ybSB0aGUgZm9sbG93aW5nIGFjdGlvbnMgb24gbXkgYmVoYWxmOiAoMSkgJ3RpbnljbG91ZC5rdic6ICdsaXN0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmFjdGl2ZS9rdicuICgyKSAndGlueWNsb3VkLnNwYWNlJzogJ2hvc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6YWN0aXZlL3NwYWNlJy5hc6Jhc1hBX40pIGdLrZ9xKtVsdykwjGD0tsiKOyMscFZ_lTZh-SUFAyZYYScTeDPsF1ziyfKnYBY5wlKvnXqlZdWssJDHsRthdGZlaXAxOTE=", + "raw_cid": "bafkr4igepr6lpyqz3t6zicxoy26ldxhgn5hjnlgwvuqmcllwsydkgevrru", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWdlcHI2bHB5cXozdDZ6aWN4b3kyNmxkeGhnbjVoam5sZ3d2dXFtY2xsd3N5ZGtnZXZycnUiLCJwcmYiOlsiYmFma3I0aWdlcHI2bHB5cXozdDZ6aWN4b3kyNmxkeGhnbjVoam5sZ3d2dXFtY2xsd3N5ZGtnZXZycnUiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmFjdGl2ZS9rdiI6eyJ0aW55Y2xvdWQua3YvbGlzdCI6W3t9XX19fQ.IKuhZKd9LbNL1aan3QuYsf3ZyVj7ZG0YJKJSfRmQf7YxK9EXAUBg6peMh6dj0flOLjmAQZw_E30k1Sf9svx_DA", + "child_cid": "bafkr4igwd6iq7gs2z5fpfqqtlbkfeezdqyfq3y4jrqmlnomurj2ancvfcu", + "child_proof_cid": "bafkr4igepr6lpyqz3t6zicxoy26ldxhgn5hjnlgwvuqmcllwsydkgevrru" + }, + { + "encoding": "v0", + "header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njllbm9uY2VtdGMxMDcxLWFjdGl2ZWZkb21haW5udGMxMDcxLmludmFsaWRmc2NoZW1lZWh0dHBzZ3ZlcnNpb24BaXJlc291cmNlc4F5AUJ1cm46cmVjYXA6ZXlKaGRIUWlPbnNpZEdsdWVXTnNiM1ZrT25CcmFEcGxhWEF4TlRVNk1Ub3dlRFJoTmpJek1UWTJNak5oWkRRMU4wWXdNbU5FUXpWRU9UazNaR1ZFTmpkaE16Z3pSVU0xTmprNllXTjBhWFpsTDJ0MklqcDdJblJwYm5samJHOTFaQzVyZGk5c2FYTjBJanBiZTMxZGZTd2lkR2x1ZVdOc2IzVmtPbkJyYURwbGFYQXhOVFU2TVRvd2VEUmhOakl6TVRZMk1qTmhaRFExTjBZd01tTkVRelZFT1RrM1pHVkVOamRoTXpnelJVTTFOams2WVdOMGFYWmxMM053WVdObElqcDdJblJwYm5samJHOTFaQzV6Y0dGalpTOW9iM04wSWpwYmUzMWRmWDBzSW5CeVppSTZXMTE5aXN0YXRlbWVudHkBM0kgZnVydGhlciBhdXRob3JpemUgdGhlIHN0YXRlZCBVUkkgdG8gcGVyZm9ybSB0aGUgZm9sbG93aW5nIGFjdGlvbnMgb24gbXkgYmVoYWxmOiAoMSkgJ3RpbnljbG91ZC5rdic6ICdsaXN0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmFjdGl2ZS9rdicuICgyKSAndGlueWNsb3VkLnNwYWNlJzogJ2hvc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6YWN0aXZlL3NwYWNlJy5hc6Jhc1hBX40pIGdLrZ9xKtVsdykwjGD0tsiKOyMscFZ_lTZh-SUFAyZYYScTeDPsF1ziyfKnYBY5wlKvnXqlZdWssJDHsQBhdGZlaXAxOTE=", + "raw_cid": "bafkr4igcmrv3kwshf4m737rusvvlyn2smll7rx3oy6trmcyewmc4expofa", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWdjbXJ2M2t3c2hmNG03MzdydXN2dmx5bjJzbWxsN3J4M295NnRybWN5ZXdtYzRleHBvZmEiLCJwcmYiOlsiYmFma3I0aWdjbXJ2M2t3c2hmNG03MzdydXN2dmx5bjJzbWxsN3J4M295NnRybWN5ZXdtYzRleHBvZmEiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmFjdGl2ZS9rdiI6eyJ0aW55Y2xvdWQua3YvbGlzdCI6W3t9XX19fQ.t7emrc0AftuwdX7jA2VV3Hmj40RuTAkjFi7ZxAkXAdpcWYXgUygjEAj3IkukQ2wbVeDwTKHpa54oXDXKxWWYAA", + "child_cid": "bafkr4ifvrwxvjnpeo4w6733xh5ohox4wqnqqkblvy6w4jrlb3axc7z6tka", + "child_proof_cid": "bafkr4igcmrv3kwshf4m737rusvvlyn2smll7rx3oy6trmcyewmc4expofa" + }, + { + "encoding": "map-order", + "header": "o2FzomFzWEFfjSkgZ0utn3Eq1Wx3KTCMYPS2yIo7IyxwVn-VNmH5JQUDJlhhJxN4M-wXXOLJ8qdgFjnCUq-deqVl1aywkMexG2F0ZmVpcDE5MWFwqmNhdWR4OGRpZDprZXk6ejZNa21OTDZ4a3NkRUpFTkdrOVoxcXFHc1k1bmpLUWRqVTFVaG5VRVV2V3lRVVZYY2V4cHQyMTAwLTAxLTAxVDAwOjAwOjAwWmNpYXR0MjAyMC0wMS0wMVQwMDowMDowMFpjaXNzeDtkaWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OWVub25jZW10YzEwNzEtYWN0aXZlZmRvbWFpbm50YzEwNzEuaW52YWxpZGZzY2hlbWVlaHR0cHNndmVyc2lvbgFpcmVzb3VyY2VzgXkBQnVybjpyZWNhcDpleUpoZEhRaU9uc2lkR2x1ZVdOc2IzVmtPbkJyYURwbGFYQXhOVFU2TVRvd2VEUmhOakl6TVRZMk1qTmhaRFExTjBZd01tTkVRelZFT1RrM1pHVkVOamRoTXpnelJVTTFOams2WVdOMGFYWmxMMnQySWpwN0luUnBibmxqYkc5MVpDNXJkaTlzYVhOMElqcGJlMzFkZlN3aWRHbHVlV05zYjNWa09uQnJhRHBsYVhBeE5UVTZNVG93ZURSaE5qSXpNVFkyTWpOaFpEUTFOMFl3TW1ORVF6VkVPVGszWkdWRU5qZGhNemd6UlVNMU5qazZZV04wYVhabEwzTndZV05sSWpwN0luUnBibmxqYkc5MVpDNXpjR0ZqWlM5b2IzTjBJanBiZTMxZGZYMHNJbkJ5WmlJNlcxMTlpc3RhdGVtZW50eQEzSSBmdXJ0aGVyIGF1dGhvcml6ZSB0aGUgc3RhdGVkIFVSSSB0byBwZXJmb3JtIHRoZSBmb2xsb3dpbmcgYWN0aW9ucyBvbiBteSBiZWhhbGY6ICgxKSAndGlueWNsb3VkLmt2JzogJ2xpc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6YWN0aXZlL2t2Jy4gKDIpICd0aW55Y2xvdWQuc3BhY2UnOiAnaG9zdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTphY3RpdmUvc3BhY2UnLmFooWF0Z2VpcDQzNjE=", + "raw_cid": "bafkr4ihvjso7w2uw3ynkjduwfvrtpp55mggvkioofdhqf7acsqvxn5aw2a", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWh2anNvN3cydXczeW5ramR1d2Z2cnRwcDU1bWdndmtpb29mZGhxZjdhY3NxdnhuNWF3MmEiLCJwcmYiOlsiYmFma3I0aWh2anNvN3cydXczeW5ramR1d2Z2cnRwcDU1bWdndmtpb29mZGhxZjdhY3NxdnhuNWF3MmEiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmFjdGl2ZS9rdiI6eyJ0aW55Y2xvdWQua3YvbGlzdCI6W3t9XX19fQ.XhDO_SfhNg9_zVJieeRCXLJmj0dtY8Ls2Mct1ek5c4dAAAySXJ7qBfzr74j9FFY6D6OvNsK50Bj6uvx3pd_0CA", + "child_cid": "bafkr4iafz57rlhgbh7xqis7wl3m7w4mchenvyvfulrqm5hbfd5k4jqiqay", + "child_proof_cid": "bafkr4ihvjso7w2uw3ynkjduwfvrtpp55mggvkioofdhqf7acsqvxn5aw2a" + }, + { + "encoding": "nonminimal", + "header": "uANhaKFhdGdlaXA0MzYxYXCqY2F1ZHg4ZGlkOmtleTp6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlhjZXhwdDIxMDAtMDEtMDFUMDA6MDA6MDBaY2lhdHQyMDIwLTAxLTAxVDAwOjAwOjAwWmNpc3N4O2RpZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5ZW5vbmNlbXRjMTA3MS1hY3RpdmVmZG9tYWlubnRjMTA3MS5pbnZhbGlkZnNjaGVtZWVodHRwc2d2ZXJzaW9uAWlyZXNvdXJjZXOBeQFCdXJuOnJlY2FwOmV5SmhkSFFpT25zaWRHbHVlV05zYjNWa09uQnJhRHBsYVhBeE5UVTZNVG93ZURSaE5qSXpNVFkyTWpOaFpEUTFOMFl3TW1ORVF6VkVPVGszWkdWRU5qZGhNemd6UlVNMU5qazZZV04wYVhabEwydDJJanA3SW5ScGJubGpiRzkxWkM1cmRpOXNhWE4wSWpwYmUzMWRmU3dpZEdsdWVXTnNiM1ZrT25CcmFEcGxhWEF4TlRVNk1Ub3dlRFJoTmpJek1UWTJNak5oWkRRMU4wWXdNbU5FUXpWRU9UazNaR1ZFTmpkaE16Z3pSVU0xTmprNllXTjBhWFpsTDNOd1lXTmxJanA3SW5ScGJubGpiRzkxWkM1emNHRmpaUzlvYjNOMElqcGJlMzFkZlgwc0luQnlaaUk2VzExOWlzdGF0ZW1lbnR5ATNJIGZ1cnRoZXIgYXV0aG9yaXplIHRoZSBzdGF0ZWQgVVJJIHRvIHBlcmZvcm0gdGhlIGZvbGxvd2luZyBhY3Rpb25zIG9uIG15IGJlaGFsZjogKDEpICd0aW55Y2xvdWQua3YnOiAnbGlzdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTphY3RpdmUva3YnLiAoMikgJ3RpbnljbG91ZC5zcGFjZSc6ICdob3N0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmFjdGl2ZS9zcGFjZScuYXOiYXNYQV-NKSBnS62fcSrVbHcpMIxg9LbIijsjLHBWf5U2YfklBQMmWGEnE3gz7Bdc4snyp2AWOcJSr516pWXVrLCQx7EbYXRmZWlwMTkx", + "raw_cid": "bafkr4igpofpucgtiwb2k2i5v7vymdon2a33z2e6ywck5wcfmqt3fth7ymq", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWdwb2ZwdWNndGl3YjJrMmk1djd2eW1kb24yYTMzejJlNnl3Y2s1d2NmbXF0M2Z0aDd5bXEiLCJwcmYiOlsiYmFma3I0aWdwb2ZwdWNndGl3YjJrMmk1djd2eW1kb24yYTMzejJlNnl3Y2s1d2NmbXF0M2Z0aDd5bXEiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmFjdGl2ZS9rdiI6eyJ0aW55Y2xvdWQua3YvbGlzdCI6W3t9XX19fQ.hbVfmbELya_ssVNQRkQ227SdlGYR3KUshsU38NkpzZcmbCub6P-KsGsnUhf9nmhjyrDSne_PPwzgdKg5cTQkCQ", + "child_cid": "bafkr4ihsg3b63bldtjuk5l37wysfkvpg6nteygukdjhkbgdfvpxrxine74", + "child_proof_cid": "bafkr4igpofpucgtiwb2k2i5v7vymdon2a33z2e6ywck5wcfmqt3fth7ymq" + }, + { + "encoding": "null", + "header": "o2FooWF0Z2VpcDQzNjFhcKtjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1NjljbmJm9mVub25jZW10YzEwNzEtYWN0aXZlZmRvbWFpbm50YzEwNzEuaW52YWxpZGZzY2hlbWVlaHR0cHNndmVyc2lvbgFpcmVzb3VyY2VzgXkBQnVybjpyZWNhcDpleUpoZEhRaU9uc2lkR2x1ZVdOc2IzVmtPbkJyYURwbGFYQXhOVFU2TVRvd2VEUmhOakl6TVRZMk1qTmhaRFExTjBZd01tTkVRelZFT1RrM1pHVkVOamRoTXpnelJVTTFOams2WVdOMGFYWmxMMnQySWpwN0luUnBibmxqYkc5MVpDNXJkaTlzYVhOMElqcGJlMzFkZlN3aWRHbHVlV05zYjNWa09uQnJhRHBsYVhBeE5UVTZNVG93ZURSaE5qSXpNVFkyTWpOaFpEUTFOMFl3TW1ORVF6VkVPVGszWkdWRU5qZGhNemd6UlVNMU5qazZZV04wYVhabEwzTndZV05sSWpwN0luUnBibmxqYkc5MVpDNXpjR0ZqWlM5b2IzTjBJanBiZTMxZGZYMHNJbkJ5WmlJNlcxMTlpc3RhdGVtZW50eQEzSSBmdXJ0aGVyIGF1dGhvcml6ZSB0aGUgc3RhdGVkIFVSSSB0byBwZXJmb3JtIHRoZSBmb2xsb3dpbmcgYWN0aW9ucyBvbiBteSBiZWhhbGY6ICgxKSAndGlueWNsb3VkLmt2JzogJ2xpc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6YWN0aXZlL2t2Jy4gKDIpICd0aW55Y2xvdWQuc3BhY2UnOiAnaG9zdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTphY3RpdmUvc3BhY2UnLmFzomFzWEFfjSkgZ0utn3Eq1Wx3KTCMYPS2yIo7IyxwVn-VNmH5JQUDJlhhJxN4M-wXXOLJ8qdgFjnCUq-deqVl1aywkMexG2F0ZmVpcDE5MQ==", + "raw_cid": "bafkr4idpkfjdbywpvmvihbewwukacclgi4m7xmfpckhyzf26im2dhbanoq", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWRwa2ZqZGJ5d3B2bXZpaGJld3d1a2FjY2xnaTRtN3htZnBja2h5emYyNmltMmRoYmFub3EiLCJwcmYiOlsiYmFma3I0aWRwa2ZqZGJ5d3B2bXZpaGJld3d1a2FjY2xnaTRtN3htZnBja2h5emYyNmltMmRoYmFub3EiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmFjdGl2ZS9rdiI6eyJ0aW55Y2xvdWQua3YvbGlzdCI6W3t9XX19fQ.7aVu5sW9ghOVCzAI_uclSBuK3v8BbbDNBoV21dotuLOrn1_4HGbDyD5hVTPyiyfc6CC38p3mbB8ksevDC1jBBA", + "child_cid": "bafkr4iec5otgapfa6tgf6c7a7goctuuxew6kas4pwkjhugstnsxvc6zeuq", + "child_proof_cid": "bafkr4idpkfjdbywpvmvihbewwukacclgi4m7xmfpckhyzf26im2dhbanoq" + }, + { + "encoding": "issuer-case", + "header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N2YwMmNkYzVkOTk3ZGVkNjdhMzgzZWM1Njllbm9uY2VtdGMxMDcxLWFjdGl2ZWZkb21haW5udGMxMDcxLmludmFsaWRmc2NoZW1lZWh0dHBzZ3ZlcnNpb24BaXJlc291cmNlc4F5AUJ1cm46cmVjYXA6ZXlKaGRIUWlPbnNpZEdsdWVXTnNiM1ZrT25CcmFEcGxhWEF4TlRVNk1Ub3dlRFJoTmpJek1UWTJNak5oWkRRMU4wWXdNbU5FUXpWRU9UazNaR1ZFTmpkaE16Z3pSVU0xTmprNllXTjBhWFpsTDJ0MklqcDdJblJwYm5samJHOTFaQzVyZGk5c2FYTjBJanBiZTMxZGZTd2lkR2x1ZVdOc2IzVmtPbkJyYURwbGFYQXhOVFU2TVRvd2VEUmhOakl6TVRZMk1qTmhaRFExTjBZd01tTkVRelZFT1RrM1pHVkVOamRoTXpnelJVTTFOams2WVdOMGFYWmxMM053WVdObElqcDdJblJwYm5samJHOTFaQzV6Y0dGalpTOW9iM04wSWpwYmUzMWRmWDBzSW5CeVppSTZXMTE5aXN0YXRlbWVudHkBM0kgZnVydGhlciBhdXRob3JpemUgdGhlIHN0YXRlZCBVUkkgdG8gcGVyZm9ybSB0aGUgZm9sbG93aW5nIGFjdGlvbnMgb24gbXkgYmVoYWxmOiAoMSkgJ3RpbnljbG91ZC5rdic6ICdsaXN0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmFjdGl2ZS9rdicuICgyKSAndGlueWNsb3VkLnNwYWNlJzogJ2hvc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6YWN0aXZlL3NwYWNlJy5hc6Jhc1hBX40pIGdLrZ9xKtVsdykwjGD0tsiKOyMscFZ_lTZh-SUFAyZYYScTeDPsF1ziyfKnYBY5wlKvnXqlZdWssJDHsRthdGZlaXAxOTE=", + "raw_cid": "bafkr4ifq37jmvwgbqrldmtpw2a44x2xhsnd7msqct65azlgwlth52j3kkq", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWZxMzdqbXZ3Z2JxcmxkbXRwdzJhNDR4Mnhoc25kN21zcWN0NjVhemxnd2x0aDUyajNra3EiLCJwcmYiOlsiYmFma3I0aWZxMzdqbXZ3Z2JxcmxkbXRwdzJhNDR4Mnhoc25kN21zcWN0NjVhemxnd2x0aDUyajNra3EiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmFjdGl2ZS9rdiI6eyJ0aW55Y2xvdWQua3YvbGlzdCI6W3t9XX19fQ.8ObvJ0tQ1RyDZPRavT71gCA_ToiK-mR3Gu36hXgj7VSkMNDYzeWcl--FvmMCaH2iQHEznnBJEqqi3e4IwH9PCg", + "child_cid": "bafkr4iayx6opr2vrxd2vsdg4c4xc2wgmde2to6p46s4ab35yitdntqnp4u", + "child_proof_cid": "bafkr4ifq37jmvwgbqrldmtpw2a44x2xhsnd7msqct65azlgwlth52j3kkq" + } + ], + "revocation_header": null, + "revoked_raw_cid": null + }, + { + "generated_by": "f9d700bf public SpaceDatabase::new/delegate/revoke/invoke", + "database": "encrypted-active.sqlite", + "encrypted": true, + "issuer": "did:pkh:eip155:1:0x4a62316623ad457F02cDC5D997deD67a383EC569", + "resource": "tinycloud:pkh:eip155:1:0x4a62316623ad457F02cDC5D997deD67a383EC569:encrypted-active/kv", + "session_seed": 11, + "child_seed": 12, + "session_jwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "d": "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws", + "kty": "OKP", + "x": "Zr5-Myx6RTMyvZ0Kf32wVfXF7xoGraZtmLOftoEMRzo" + }, + "child_jwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "d": "DAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw", + "kty": "OKP", + "x": "C1E62bSSQBXKCQLtB5BE06xdvsIwbwaUjBDajrbjny0" + }, + "canonical_header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njllbm9uY2V3dGMxMDcxLWVuY3J5cHRlZC1hY3RpdmVmZG9tYWlubnRjMTA3MS5pbnZhbGlkZnNjaGVtZWVodHRwc2d2ZXJzaW9uAWlyZXNvdXJjZXOBeQFddXJuOnJlY2FwOmV5SmhkSFFpT25zaWRHbHVlV05zYjNWa09uQnJhRHBsYVhBeE5UVTZNVG93ZURSaE5qSXpNVFkyTWpOaFpEUTFOMFl3TW1ORVF6VkVPVGszWkdWRU5qZGhNemd6UlVNMU5qazZaVzVqY25sd2RHVmtMV0ZqZEdsMlpTOXJkaUk2ZXlKMGFXNTVZMnh2ZFdRdWEzWXZiR2x6ZENJNlczdDlYWDBzSW5ScGJubGpiRzkxWkRwd2EyZzZaV2x3TVRVMU9qRTZNSGcwWVRZeU16RTJOakl6WVdRME5UZEdNREpqUkVNMVJEazVOMlJsUkRZM1lUTTRNMFZETlRZNU9tVnVZM0o1Y0hSbFpDMWhZM1JwZG1VdmMzQmhZMlVpT25zaWRHbHVlV05zYjNWa0xuTndZV05sTDJodmMzUWlPbHQ3ZlYxOWZTd2ljSEptSWpwYlhYMGlzdGF0ZW1lbnR5AUdJIGZ1cnRoZXIgYXV0aG9yaXplIHRoZSBzdGF0ZWQgVVJJIHRvIHBlcmZvcm0gdGhlIGZvbGxvd2luZyBhY3Rpb25zIG9uIG15IGJlaGFsZjogKDEpICd0aW55Y2xvdWQua3YnOiAnbGlzdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTplbmNyeXB0ZWQtYWN0aXZlL2t2Jy4gKDIpICd0aW55Y2xvdWQuc3BhY2UnOiAnaG9zdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTplbmNyeXB0ZWQtYWN0aXZlL3NwYWNlJy5hc6Jhc1hBxLTM3jDkcqrwghg-W0277b5pFr1_-Qm9LmTS3iGoRnRB7XcK_C9VF1ltecteALKOEEOSD7sCNHtx7VCJUxnaVhxhdGZlaXAxOTE=", + "rows": [ + { + "encoding": "v27", + "header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njllbm9uY2V3dGMxMDcxLWVuY3J5cHRlZC1hY3RpdmVmZG9tYWlubnRjMTA3MS5pbnZhbGlkZnNjaGVtZWVodHRwc2d2ZXJzaW9uAWlyZXNvdXJjZXOBeQFddXJuOnJlY2FwOmV5SmhkSFFpT25zaWRHbHVlV05zYjNWa09uQnJhRHBsYVhBeE5UVTZNVG93ZURSaE5qSXpNVFkyTWpOaFpEUTFOMFl3TW1ORVF6VkVPVGszWkdWRU5qZGhNemd6UlVNMU5qazZaVzVqY25sd2RHVmtMV0ZqZEdsMlpTOXJkaUk2ZXlKMGFXNTVZMnh2ZFdRdWEzWXZiR2x6ZENJNlczdDlYWDBzSW5ScGJubGpiRzkxWkRwd2EyZzZaV2x3TVRVMU9qRTZNSGcwWVRZeU16RTJOakl6WVdRME5UZEdNREpqUkVNMVJEazVOMlJsUkRZM1lUTTRNMFZETlRZNU9tVnVZM0o1Y0hSbFpDMWhZM1JwZG1VdmMzQmhZMlVpT25zaWRHbHVlV05zYjNWa0xuTndZV05sTDJodmMzUWlPbHQ3ZlYxOWZTd2ljSEptSWpwYlhYMGlzdGF0ZW1lbnR5AUdJIGZ1cnRoZXIgYXV0aG9yaXplIHRoZSBzdGF0ZWQgVVJJIHRvIHBlcmZvcm0gdGhlIGZvbGxvd2luZyBhY3Rpb25zIG9uIG15IGJlaGFsZjogKDEpICd0aW55Y2xvdWQua3YnOiAnbGlzdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTplbmNyeXB0ZWQtYWN0aXZlL2t2Jy4gKDIpICd0aW55Y2xvdWQuc3BhY2UnOiAnaG9zdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTplbmNyeXB0ZWQtYWN0aXZlL3NwYWNlJy5hc6Jhc1hBxLTM3jDkcqrwghg-W0277b5pFr1_-Qm9LmTS3iGoRnRB7XcK_C9VF1ltecteALKOEEOSD7sCNHtx7VCJUxnaVhxhdGZlaXAxOTE=", + "raw_cid": "bafkr4ie3nlwvowcjklyyarnxanouzr35ztvnsbrhxdq4r2q36aibjhmtsm", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWUzbmx3dm93Y2prbHl5YXJueGFub3V6cjM1enR2bnNicmh4ZHE0cjJxMzZhaWJqaG10c20iLCJwcmYiOlsiYmFma3I0aWUzbmx3dm93Y2prbHl5YXJueGFub3V6cjM1enR2bnNicmh4ZHE0cjJxMzZhaWJqaG10c20iXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmVuY3J5cHRlZC1hY3RpdmUva3YiOnsidGlueWNsb3VkLmt2L2xpc3QiOlt7fV19fX0.MzaYCn-QGvDFbdJgCxgoJSxAHpds58oux79vmkuijSGFGeFnN66pYgAeHPRo_fmTrgP3d10KtxYarVRSW-Y0Cw", + "child_cid": "bafkr4iczzbo6s3cyge4wxuvzhnfjp74ewirrk3n76lwqoh54l7tsavssaa", + "child_proof_cid": "bafkr4ie3nlwvowcjklyyarnxanouzr35ztvnsbrhxdq4r2q36aibjhmtsm" + }, + { + "encoding": "v0", + "header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njllbm9uY2V3dGMxMDcxLWVuY3J5cHRlZC1hY3RpdmVmZG9tYWlubnRjMTA3MS5pbnZhbGlkZnNjaGVtZWVodHRwc2d2ZXJzaW9uAWlyZXNvdXJjZXOBeQFddXJuOnJlY2FwOmV5SmhkSFFpT25zaWRHbHVlV05zYjNWa09uQnJhRHBsYVhBeE5UVTZNVG93ZURSaE5qSXpNVFkyTWpOaFpEUTFOMFl3TW1ORVF6VkVPVGszWkdWRU5qZGhNemd6UlVNMU5qazZaVzVqY25sd2RHVmtMV0ZqZEdsMlpTOXJkaUk2ZXlKMGFXNTVZMnh2ZFdRdWEzWXZiR2x6ZENJNlczdDlYWDBzSW5ScGJubGpiRzkxWkRwd2EyZzZaV2x3TVRVMU9qRTZNSGcwWVRZeU16RTJOakl6WVdRME5UZEdNREpqUkVNMVJEazVOMlJsUkRZM1lUTTRNMFZETlRZNU9tVnVZM0o1Y0hSbFpDMWhZM1JwZG1VdmMzQmhZMlVpT25zaWRHbHVlV05zYjNWa0xuTndZV05sTDJodmMzUWlPbHQ3ZlYxOWZTd2ljSEptSWpwYlhYMGlzdGF0ZW1lbnR5AUdJIGZ1cnRoZXIgYXV0aG9yaXplIHRoZSBzdGF0ZWQgVVJJIHRvIHBlcmZvcm0gdGhlIGZvbGxvd2luZyBhY3Rpb25zIG9uIG15IGJlaGFsZjogKDEpICd0aW55Y2xvdWQua3YnOiAnbGlzdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTplbmNyeXB0ZWQtYWN0aXZlL2t2Jy4gKDIpICd0aW55Y2xvdWQuc3BhY2UnOiAnaG9zdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTplbmNyeXB0ZWQtYWN0aXZlL3NwYWNlJy5hc6Jhc1hBxLTM3jDkcqrwghg-W0277b5pFr1_-Qm9LmTS3iGoRnRB7XcK_C9VF1ltecteALKOEEOSD7sCNHtx7VCJUxnaVgFhdGZlaXAxOTE=", + "raw_cid": "bafkr4id4lgzbjkz6dk2adth2vznbvmhjgicy636d3hmik4eeq4rgjhsfqe", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWQ0bGd6YmprejZkazJhZHRoMnZ6bmJ2bWhqZ2ljeTYzNmQzaG1pazRlZXE0cmdqaHNmcWUiLCJwcmYiOlsiYmFma3I0aWQ0bGd6YmprejZkazJhZHRoMnZ6bmJ2bWhqZ2ljeTYzNmQzaG1pazRlZXE0cmdqaHNmcWUiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmVuY3J5cHRlZC1hY3RpdmUva3YiOnsidGlueWNsb3VkLmt2L2xpc3QiOlt7fV19fX0.1SCmi3hGgd2zkMk_WsEKM2EsK5xMZKzSpefWQYynPZH0KZ7nxoTuUOhh1u7-bsnhQVOldVUDjZuX4ZgYZreLBw", + "child_cid": "bafkr4igwhrj7anxwo7jugo5esmkxztdvqsujdvwk25oqymqsykqugeqbje", + "child_proof_cid": "bafkr4id4lgzbjkz6dk2adth2vznbvmhjgicy636d3hmik4eeq4rgjhsfqe" + }, + { + "encoding": "map-order", + "header": "o2FzomFzWEHEtMzeMORyqvCCGD5bTbvtvmkWvX_5Cb0uZNLeIahGdEHtdwr8L1UXWW15y14Aso4QQ5IPuwI0e3HtUIlTGdpWHGF0ZmVpcDE5MWFwqmNhdWR4OGRpZDprZXk6ejZNa21OTDZ4a3NkRUpFTkdrOVoxcXFHc1k1bmpLUWRqVTFVaG5VRVV2V3lRVVZYY2V4cHQyMTAwLTAxLTAxVDAwOjAwOjAwWmNpYXR0MjAyMC0wMS0wMVQwMDowMDowMFpjaXNzeDtkaWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OWVub25jZXd0YzEwNzEtZW5jcnlwdGVkLWFjdGl2ZWZkb21haW5udGMxMDcxLmludmFsaWRmc2NoZW1lZWh0dHBzZ3ZlcnNpb24BaXJlc291cmNlc4F5AV11cm46cmVjYXA6ZXlKaGRIUWlPbnNpZEdsdWVXTnNiM1ZrT25CcmFEcGxhWEF4TlRVNk1Ub3dlRFJoTmpJek1UWTJNak5oWkRRMU4wWXdNbU5FUXpWRU9UazNaR1ZFTmpkaE16Z3pSVU0xTmprNlpXNWpjbmx3ZEdWa0xXRmpkR2wyWlM5cmRpSTZleUowYVc1NVkyeHZkV1F1YTNZdmJHbHpkQ0k2VzN0OVhYMHNJblJwYm5samJHOTFaRHB3YTJnNlpXbHdNVFUxT2pFNk1IZzBZVFl5TXpFMk5qSXpZV1EwTlRkR01ESmpSRU0xUkRrNU4yUmxSRFkzWVRNNE0wVkROVFk1T21WdVkzSjVjSFJsWkMxaFkzUnBkbVV2YzNCaFkyVWlPbnNpZEdsdWVXTnNiM1ZrTG5Od1lXTmxMMmh2YzNRaU9sdDdmVjE5ZlN3aWNISm1JanBiWFgwaXN0YXRlbWVudHkBR0kgZnVydGhlciBhdXRob3JpemUgdGhlIHN0YXRlZCBVUkkgdG8gcGVyZm9ybSB0aGUgZm9sbG93aW5nIGFjdGlvbnMgb24gbXkgYmVoYWxmOiAoMSkgJ3RpbnljbG91ZC5rdic6ICdsaXN0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmVuY3J5cHRlZC1hY3RpdmUva3YnLiAoMikgJ3RpbnljbG91ZC5zcGFjZSc6ICdob3N0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmVuY3J5cHRlZC1hY3RpdmUvc3BhY2UnLmFooWF0Z2VpcDQzNjE=", + "raw_cid": "bafkr4ig6ws4iybyh7eoy2u44thknjowql3vcugty4iamup545g7hbgzmga", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWc2d3M0aXlieWg3ZW95MnU0NHRoa25qb3dxbDN2Y3VndHk0aWFtdXA1NDVnN2hiZ3ptZ2EiLCJwcmYiOlsiYmFma3I0aWc2d3M0aXlieWg3ZW95MnU0NHRoa25qb3dxbDN2Y3VndHk0aWFtdXA1NDVnN2hiZ3ptZ2EiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmVuY3J5cHRlZC1hY3RpdmUva3YiOnsidGlueWNsb3VkLmt2L2xpc3QiOlt7fV19fX0.gmnF38Gu9tRjM22TgDo8psVLa_u_7RxOWj1KwPxi3SpRSCY9X0IM9_RHLH4qat3lagF4uutnnGdzOmKy1ERJAA", + "child_cid": "bafkr4icctajikgtxkxsmgmxdoow7vwzemhe63yvzz6mksj66tvwmomsyvu", + "child_proof_cid": "bafkr4ig6ws4iybyh7eoy2u44thknjowql3vcugty4iamup545g7hbgzmga" + }, + { + "encoding": "nonminimal", + "header": "uANhaKFhdGdlaXA0MzYxYXCqY2F1ZHg4ZGlkOmtleTp6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlhjZXhwdDIxMDAtMDEtMDFUMDA6MDA6MDBaY2lhdHQyMDIwLTAxLTAxVDAwOjAwOjAwWmNpc3N4O2RpZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5ZW5vbmNld3RjMTA3MS1lbmNyeXB0ZWQtYWN0aXZlZmRvbWFpbm50YzEwNzEuaW52YWxpZGZzY2hlbWVlaHR0cHNndmVyc2lvbgFpcmVzb3VyY2VzgXkBXXVybjpyZWNhcDpleUpoZEhRaU9uc2lkR2x1ZVdOc2IzVmtPbkJyYURwbGFYQXhOVFU2TVRvd2VEUmhOakl6TVRZMk1qTmhaRFExTjBZd01tTkVRelZFT1RrM1pHVkVOamRoTXpnelJVTTFOams2Wlc1amNubHdkR1ZrTFdGamRHbDJaUzlyZGlJNmV5SjBhVzU1WTJ4dmRXUXVhM1l2YkdsemRDSTZXM3Q5WFgwc0luUnBibmxqYkc5MVpEcHdhMmc2Wldsd01UVTFPakU2TUhnMFlUWXlNekUyTmpJellXUTBOVGRHTURKalJFTTFSRGs1TjJSbFJEWTNZVE00TTBWRE5UWTVPbVZ1WTNKNWNIUmxaQzFoWTNScGRtVXZjM0JoWTJVaU9uc2lkR2x1ZVdOc2IzVmtMbk53WVdObEwyaHZjM1FpT2x0N2ZWMTlmU3dpY0hKbUlqcGJYWDBpc3RhdGVtZW50eQFHSSBmdXJ0aGVyIGF1dGhvcml6ZSB0aGUgc3RhdGVkIFVSSSB0byBwZXJmb3JtIHRoZSBmb2xsb3dpbmcgYWN0aW9ucyBvbiBteSBiZWhhbGY6ICgxKSAndGlueWNsb3VkLmt2JzogJ2xpc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6ZW5jcnlwdGVkLWFjdGl2ZS9rdicuICgyKSAndGlueWNsb3VkLnNwYWNlJzogJ2hvc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6ZW5jcnlwdGVkLWFjdGl2ZS9zcGFjZScuYXOiYXNYQcS0zN4w5HKq8IIYPltNu-2-aRa9f_kJvS5k0t4hqEZ0Qe13CvwvVRdZbXnLXgCyjhBDkg-7AjR7ce1QiVMZ2lYcYXRmZWlwMTkx", + "raw_cid": "bafkr4iblgf2lftgfg2bfh4acvagvgtevm7uzh6frt6qz5wwpujciytexei", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWJsZ2YybGZ0Z2ZnMmJmaDRhY3ZhZ3ZndGV2bTd1emg2ZnJ0NnF6NXd3cHVqY2l5dGV4ZWkiLCJwcmYiOlsiYmFma3I0aWJsZ2YybGZ0Z2ZnMmJmaDRhY3ZhZ3ZndGV2bTd1emg2ZnJ0NnF6NXd3cHVqY2l5dGV4ZWkiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmVuY3J5cHRlZC1hY3RpdmUva3YiOnsidGlueWNsb3VkLmt2L2xpc3QiOlt7fV19fX0.YQc8YYRpqcQtAv2ODx6BbRcZj9SEPIdgKewhjStJ1vE_PYLFW3G3EeZoO3m-SD91g7yPjrRX4bUasnziyucxAg", + "child_cid": "bafkr4iarsm5gz4ltb6hbj5naimbxttrez4udk4rztln2eup4kya3coquhi", + "child_proof_cid": "bafkr4iblgf2lftgfg2bfh4acvagvgtevm7uzh6frt6qz5wwpujciytexei" + }, + { + "encoding": "null", + "header": "o2FooWF0Z2VpcDQzNjFhcKtjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1NjljbmJm9mVub25jZXd0YzEwNzEtZW5jcnlwdGVkLWFjdGl2ZWZkb21haW5udGMxMDcxLmludmFsaWRmc2NoZW1lZWh0dHBzZ3ZlcnNpb24BaXJlc291cmNlc4F5AV11cm46cmVjYXA6ZXlKaGRIUWlPbnNpZEdsdWVXTnNiM1ZrT25CcmFEcGxhWEF4TlRVNk1Ub3dlRFJoTmpJek1UWTJNak5oWkRRMU4wWXdNbU5FUXpWRU9UazNaR1ZFTmpkaE16Z3pSVU0xTmprNlpXNWpjbmx3ZEdWa0xXRmpkR2wyWlM5cmRpSTZleUowYVc1NVkyeHZkV1F1YTNZdmJHbHpkQ0k2VzN0OVhYMHNJblJwYm5samJHOTFaRHB3YTJnNlpXbHdNVFUxT2pFNk1IZzBZVFl5TXpFMk5qSXpZV1EwTlRkR01ESmpSRU0xUkRrNU4yUmxSRFkzWVRNNE0wVkROVFk1T21WdVkzSjVjSFJsWkMxaFkzUnBkbVV2YzNCaFkyVWlPbnNpZEdsdWVXTnNiM1ZrTG5Od1lXTmxMMmh2YzNRaU9sdDdmVjE5ZlN3aWNISm1JanBiWFgwaXN0YXRlbWVudHkBR0kgZnVydGhlciBhdXRob3JpemUgdGhlIHN0YXRlZCBVUkkgdG8gcGVyZm9ybSB0aGUgZm9sbG93aW5nIGFjdGlvbnMgb24gbXkgYmVoYWxmOiAoMSkgJ3RpbnljbG91ZC5rdic6ICdsaXN0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmVuY3J5cHRlZC1hY3RpdmUva3YnLiAoMikgJ3RpbnljbG91ZC5zcGFjZSc6ICdob3N0JyBmb3IgJ3RpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmVuY3J5cHRlZC1hY3RpdmUvc3BhY2UnLmFzomFzWEHEtMzeMORyqvCCGD5bTbvtvmkWvX_5Cb0uZNLeIahGdEHtdwr8L1UXWW15y14Aso4QQ5IPuwI0e3HtUIlTGdpWHGF0ZmVpcDE5MQ==", + "raw_cid": "bafkr4ickblwglcutsmnnoupey5pqetx7e422knpsqz52wuatqdns4n6xsu", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWNrYmx3Z2xjdXRzbW5ub3VwZXk1cHFldHg3ZTQyMmtucHNxejUyd3VhdHFkbnM0bjZ4c3UiLCJwcmYiOlsiYmFma3I0aWNrYmx3Z2xjdXRzbW5ub3VwZXk1cHFldHg3ZTQyMmtucHNxejUyd3VhdHFkbnM0bjZ4c3UiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmVuY3J5cHRlZC1hY3RpdmUva3YiOnsidGlueWNsb3VkLmt2L2xpc3QiOlt7fV19fX0.8dZag0r5qcV1RESnZLDFbZNxNDhmjDcTbyzHamdL-kHM8Gu4Jcj1jc33h2OXUkq49-OUK4i4CGeg5lLTYe_gDw", + "child_cid": "bafkr4iexc7bu7jfwv5q56aebiixshvm7o2kfps7gexbtdfd4vwag6brnja", + "child_proof_cid": "bafkr4ickblwglcutsmnnoupey5pqetx7e422knpsqz52wuatqdns4n6xsu" + }, + { + "encoding": "issuer-case", + "header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N2YwMmNkYzVkOTk3ZGVkNjdhMzgzZWM1Njllbm9uY2V3dGMxMDcxLWVuY3J5cHRlZC1hY3RpdmVmZG9tYWlubnRjMTA3MS5pbnZhbGlkZnNjaGVtZWVodHRwc2d2ZXJzaW9uAWlyZXNvdXJjZXOBeQFddXJuOnJlY2FwOmV5SmhkSFFpT25zaWRHbHVlV05zYjNWa09uQnJhRHBsYVhBeE5UVTZNVG93ZURSaE5qSXpNVFkyTWpOaFpEUTFOMFl3TW1ORVF6VkVPVGszWkdWRU5qZGhNemd6UlVNMU5qazZaVzVqY25sd2RHVmtMV0ZqZEdsMlpTOXJkaUk2ZXlKMGFXNTVZMnh2ZFdRdWEzWXZiR2x6ZENJNlczdDlYWDBzSW5ScGJubGpiRzkxWkRwd2EyZzZaV2x3TVRVMU9qRTZNSGcwWVRZeU16RTJOakl6WVdRME5UZEdNREpqUkVNMVJEazVOMlJsUkRZM1lUTTRNMFZETlRZNU9tVnVZM0o1Y0hSbFpDMWhZM1JwZG1VdmMzQmhZMlVpT25zaWRHbHVlV05zYjNWa0xuTndZV05sTDJodmMzUWlPbHQ3ZlYxOWZTd2ljSEptSWpwYlhYMGlzdGF0ZW1lbnR5AUdJIGZ1cnRoZXIgYXV0aG9yaXplIHRoZSBzdGF0ZWQgVVJJIHRvIHBlcmZvcm0gdGhlIGZvbGxvd2luZyBhY3Rpb25zIG9uIG15IGJlaGFsZjogKDEpICd0aW55Y2xvdWQua3YnOiAnbGlzdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTplbmNyeXB0ZWQtYWN0aXZlL2t2Jy4gKDIpICd0aW55Y2xvdWQuc3BhY2UnOiAnaG9zdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTplbmNyeXB0ZWQtYWN0aXZlL3NwYWNlJy5hc6Jhc1hBxLTM3jDkcqrwghg-W0277b5pFr1_-Qm9LmTS3iGoRnRB7XcK_C9VF1ltecteALKOEEOSD7sCNHtx7VCJUxnaVhxhdGZlaXAxOTE=", + "raw_cid": "bafkr4id74igjlkdt3z53iuf66yub5wfqywqdhle67isvgiws7swifgpg3u", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWQ3NGlnamxrZHQzejUzaXVmNjZ5dWI1d2ZxeXdxZGhsZTY3aXN2Z2l3czdzd2lmZ3BnM3UiLCJwcmYiOlsiYmFma3I0aWQ3NGlnamxrZHQzejUzaXVmNjZ5dWI1d2ZxeXdxZGhsZTY3aXN2Z2l3czdzd2lmZ3BnM3UiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OmVuY3J5cHRlZC1hY3RpdmUva3YiOnsidGlueWNsb3VkLmt2L2xpc3QiOlt7fV19fX0.iH9cmYZ5VrYrMWkAbiaQSwtXjpP55KwsJ1xaZ1Lt73xaBcsn3K3nXyM2voTwmE76CuFYNEb-uhkei1lKbjtdDQ", + "child_cid": "bafkr4ic5cnlbqye3kjphotkxmmtwdrlp6iamwlkbkq3nvzosggk7c2v5wm", + "child_proof_cid": "bafkr4id74igjlkdt3z53iuf66yub5wfqywqdhle67isvgiws7swifgpg3u" + } + ], + "revocation_header": null, + "revoked_raw_cid": null + }, + { + "generated_by": "f9d700bf public SpaceDatabase::new/delegate/revoke/invoke", + "database": "single-v0.sqlite", + "encrypted": false, + "issuer": "did:pkh:eip155:1:0x4a62316623ad457F02cDC5D997deD67a383EC569", + "resource": "tinycloud:pkh:eip155:1:0x4a62316623ad457F02cDC5D997deD67a383EC569:single-v0/kv", + "session_seed": 11, + "child_seed": 12, + "session_jwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "d": "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws", + "kty": "OKP", + "x": "Zr5-Myx6RTMyvZ0Kf32wVfXF7xoGraZtmLOftoEMRzo" + }, + "child_jwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "d": "DAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw", + "kty": "OKP", + "x": "C1E62bSSQBXKCQLtB5BE06xdvsIwbwaUjBDajrbjny0" + }, + "canonical_header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njllbm9uY2VwdGMxMDcxLXNpbmdsZS12MGZkb21haW5udGMxMDcxLmludmFsaWRmc2NoZW1lZWh0dHBzZ3ZlcnNpb24BaXJlc291cmNlc4F5AUp1cm46cmVjYXA6ZXlKaGRIUWlPbnNpZEdsdWVXTnNiM1ZrT25CcmFEcGxhWEF4TlRVNk1Ub3dlRFJoTmpJek1UWTJNak5oWkRRMU4wWXdNbU5FUXpWRU9UazNaR1ZFTmpkaE16Z3pSVU0xTmprNmMybHVaMnhsTFhZd0wydDJJanA3SW5ScGJubGpiRzkxWkM1cmRpOXNhWE4wSWpwYmUzMWRmU3dpZEdsdWVXTnNiM1ZrT25CcmFEcGxhWEF4TlRVNk1Ub3dlRFJoTmpJek1UWTJNak5oWkRRMU4wWXdNbU5FUXpWRU9UazNaR1ZFTmpkaE16Z3pSVU0xTmprNmMybHVaMnhsTFhZd0wzTndZV05sSWpwN0luUnBibmxqYkc5MVpDNXpjR0ZqWlM5b2IzTjBJanBiZTMxZGZYMHNJbkJ5WmlJNlcxMTlpc3RhdGVtZW50eQE5SSBmdXJ0aGVyIGF1dGhvcml6ZSB0aGUgc3RhdGVkIFVSSSB0byBwZXJmb3JtIHRoZSBmb2xsb3dpbmcgYWN0aW9ucyBvbiBteSBiZWhhbGY6ICgxKSAndGlueWNsb3VkLmt2JzogJ2xpc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6c2luZ2xlLXYwL2t2Jy4gKDIpICd0aW55Y2xvdWQuc3BhY2UnOiAnaG9zdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTpzaW5nbGUtdjAvc3BhY2UnLmFzomFzWEEaCF9Lm_P7pVd3m_jo2MnoVbTC4oVB78PDIHUxfgx-aAbIb4eqlhpy8xlo2kTcU_V2G-76hQfa5akWEGRcAXBLG2F0ZmVpcDE5MQ==", + "rows": [ + { + "encoding": "v0", + "header": "o2FooWF0Z2VpcDQzNjFhcKpjYXVkeDhkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWGNleHB0MjEwMC0wMS0wMVQwMDowMDowMFpjaWF0dDIwMjAtMDEtMDFUMDA6MDA6MDBaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njllbm9uY2VwdGMxMDcxLXNpbmdsZS12MGZkb21haW5udGMxMDcxLmludmFsaWRmc2NoZW1lZWh0dHBzZ3ZlcnNpb24BaXJlc291cmNlc4F5AUp1cm46cmVjYXA6ZXlKaGRIUWlPbnNpZEdsdWVXTnNiM1ZrT25CcmFEcGxhWEF4TlRVNk1Ub3dlRFJoTmpJek1UWTJNak5oWkRRMU4wWXdNbU5FUXpWRU9UazNaR1ZFTmpkaE16Z3pSVU0xTmprNmMybHVaMnhsTFhZd0wydDJJanA3SW5ScGJubGpiRzkxWkM1cmRpOXNhWE4wSWpwYmUzMWRmU3dpZEdsdWVXTnNiM1ZrT25CcmFEcGxhWEF4TlRVNk1Ub3dlRFJoTmpJek1UWTJNak5oWkRRMU4wWXdNbU5FUXpWRU9UazNaR1ZFTmpkaE16Z3pSVU0xTmprNmMybHVaMnhsTFhZd0wzTndZV05sSWpwN0luUnBibmxqYkc5MVpDNXpjR0ZqWlM5b2IzTjBJanBiZTMxZGZYMHNJbkJ5WmlJNlcxMTlpc3RhdGVtZW50eQE5SSBmdXJ0aGVyIGF1dGhvcml6ZSB0aGUgc3RhdGVkIFVSSSB0byBwZXJmb3JtIHRoZSBmb2xsb3dpbmcgYWN0aW9ucyBvbiBteSBiZWhhbGY6ICgxKSAndGlueWNsb3VkLmt2JzogJ2xpc3QnIGZvciAndGlueWNsb3VkOnBraDplaXAxNTU6MToweDRhNjIzMTY2MjNhZDQ1N0YwMmNEQzVEOTk3ZGVENjdhMzgzRUM1Njk6c2luZ2xlLXYwL2t2Jy4gKDIpICd0aW55Y2xvdWQuc3BhY2UnOiAnaG9zdCcgZm9yICd0aW55Y2xvdWQ6cGtoOmVpcDE1NToxOjB4NGE2MjMxNjYyM2FkNDU3RjAyY0RDNUQ5OTdkZUQ2N2EzODNFQzU2OTpzaW5nbGUtdjAvc3BhY2UnLmFzomFzWEEaCF9Lm_P7pVd3m_jo2MnoVbTC4oVB78PDIHUxfgx-aAbIb4eqlhpy8xlo2kTcU_V2G-76hQfa5akWEGRcAXBLAGF0ZmVpcDE5MQ==", + "raw_cid": "bafkr4idd3ftxk42o2n3d4tct5nsojvoxu4it3vxc76kfl6lbvkfgvteddq", + "child_header": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoiWnI1LU15eDZSVE15dlowS2YzMndWZlhGN3hvR3JhWnRtTE9mdG9FTVJ6byJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJpc3MiOiJkaWQ6a2V5Ono2TWttTkw2eGtzZEVKRU5HazlaMXFxR3NZNW5qS1FkalUxVWhuVUVVdld5UVVWWCN6Nk1rbU5MNnhrc2RFSkVOR2s5WjFxcUdzWTVuaktRZGpVMVVoblVFVXZXeVFVVlgiLCJhdWQiOiJkaWQ6a2V5Ono2TWtmRFNOUnMyaTlTNkxaNXZkNFJicFo2NzU0SDdSM2J0RHFSVGVIaGFtcXZ1SiIsIm5iZiI6MTU3NzgzNjgwMCwiZXhwIjo0MTAyNDQ0ODAwLCJubmMiOiJ0YzEwNzEtY2hpbGQtYmFma3I0aWRkM2Z0eGs0Mm8ybjNkNHRjdDVuc29qdm94dTRpdDN2eGM3NmtmbDZsYnZrZmd2dGVkZHEiLCJwcmYiOlsiYmFma3I0aWRkM2Z0eGs0Mm8ybjNkNHRjdDVuc29qdm94dTRpdDN2eGM3NmtmbDZsYnZrZmd2dGVkZHEiXSwiYXR0Ijp7InRpbnljbG91ZDpwa2g6ZWlwMTU1OjE6MHg0YTYyMzE2NjIzYWQ0NTdGMDJjREM1RDk5N2RlRDY3YTM4M0VDNTY5OnNpbmdsZS12MC9rdiI6eyJ0aW55Y2xvdWQua3YvbGlzdCI6W3t9XX19fQ.01MWBV0wF7E6wcs6_SiwZ_m1D840FUqr8a5KyXmGXLnsjLGqbnD5HX4mPy5Mt-gPMUEPijxQ2SK997yzV-eiAA", + "child_cid": "bafkr4ibsp3p4zjqg4qjt3hn7oqwwgy26xso4rdf3pejha25hhmj4m32dxy", + "child_proof_cid": "bafkr4idd3ftxk42o2n3d4tct5nsojvoxu4it3vxc76kfl6lbvkfgvteddq" + } + ], + "revocation_header": null, + "revoked_raw_cid": null + } +] \ No newline at end of file diff --git a/tinycloud-core/tests/fixtures/tc1071/generated/revoked-v0.sqlite b/tinycloud-core/tests/fixtures/tc1071/generated/revoked-v0.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..68156e31c35d2983bd71ef13c26aeed2d12a985d GIT binary patch literal 528384 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 Cg)%1q literal 0 HcmV?d00001 diff --git a/tinycloud-core/tests/fixtures/tc1071/generated/revoked-v27.sqlite b/tinycloud-core/tests/fixtures/tc1071/generated/revoked-v27.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..0acc6d5a159f0b51bcb3945a63961ed07a9b4987 GIT binary patch literal 528384 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 z z)sRrWKQUe`q(>8qMkbS{>Zie zY971aqvjGngm*jU@p^qY`Z=h3h`|rB+*;;2Byp|b>F?Ad5Xe#-fSS}ZjcFN6?*y^%Lf7TZ)6@7h)WXjdF9A%lF z&Zo1*Aj%%g2oG^^Q+I$LVmaA8)P4wT-2VTG*@qa**#RqJ$Kn~Y5=y2@xg<{)5{0sC z_e`N+8%#RrXDy|Q1(F;}K+Kf7y)rH&l96h&6BOM}4!tE~CQ_|1cEelKyOId><_yUWS$p`LdKWB310O5WWbJ(H@sF{_)(n>H&2YDGpjB*9zmCAo4nS0-kH zLNJ}Gm(g1gqrE(B2LgJ?>-BV5%#1X8r2vt4YQ2yzX7K{u%}1kx>JK3Q|E2}m{lM>b za`&#k?Hf{^c{p{w`_tY(oHr|-Zv6b&Z@lUaCp=%gOGco_7c1_W$#fl6N;;Nm4e-M;Z!6`nFvPkWwa`L9aXY=oa0sdfTm5fImk^<`2UHCLqwTLXXZ+3FNepHybvX@e2U}DK?9=*3oGa2Od6#ka&mEg zw$hDewZ69<9hByXT9;%Ux79Bv9I6px<)quwC=R4<9>=qxVGi*V7i9OUYu*#rzHr#NmzkTJ;9=w&Dx%Q`npT09SD!T`&hu{Q>5ftGDJ>;)H4-qyt{(n;9 z5Dna%;|q-2Tg`E9Ler=u+M2_-Gi)89G!bIo>?wrcF6r zD9tpuW-@_ZtP?5D1_EqIQ)&UyYh)@>q9fZrf3MKbgwQ*d$fSp})0|^;LL}|XdJurC=Q*k7JtzM2R8bzx}Qo$oTb}KYPj>-tmvW|Hw0yBmYf&bf);O^(Sx}PX5`Eqq2LT_CL!0 zPhn`o5efePLXYLDZMML&3QBN^esNrA_q#i!0!5=&dpBhw8_m_(be3pQTrJv@3(A1Y zqt`~&ovaYES(;^0hIuy17KNgUezAJx<=&ZnsQv#KGj9L?q~zV1jW~n3LOT?!mm71c zQOhy|Dox9#RP7?B3v MC(3iF5FP6Ne_d>6DF6Tf literal 0 HcmV?d00001 diff --git a/tinycloud-core/tests/fixtures/tc1071/generated/single-v0.sqlite b/tinycloud-core/tests/fixtures/tc1071/generated/single-v0.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..13dc768cf568f36bc141ac5ebcab650f7ff4430e GIT binary patch literal 487424 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 literal 0 HcmV?d00001 diff --git a/tinycloud-core/tests/fixtures/tc1071/generator.rs b/tinycloud-core/tests/fixtures/tc1071/generator.rs new file mode 100644 index 00000000..fb932ae0 --- /dev/null +++ b/tinycloud-core/tests/fixtures/tc1071/generator.rs @@ -0,0 +1,113 @@ +// Copy this unchanged into f9d700bf's tinycloud-core/tests/tc1071_generate.rs, +// alongside fixtures/tc1071/support.rs. The output directory must be empty. +#[path = "fixtures/tc1071/support.rs"] +mod support; +use support::*; +use tinycloud_auth::{authorization::{HeaderEncode, TinyCloudDelegation}, resource::SpaceId}; +use tinycloud_core::{encryption::ColumnEncryption, sea_orm::{EntityTrait, PaginatorTrait}, models::delegation}; + +#[tokio::test] +#[ignore = "Run only against unmodified f9d700bf to produce historical SQLite fixtures"] +async fn generate_historical_fixtures() { + let output = std::path::PathBuf::from(std::env::var_os("TC1071_FIXTURE_OUTPUT").expect("set TC1071_FIXTURE_OUTPUT to an empty owned fixture directory")); + std::fs::create_dir_all(&output).unwrap(); + let (_, _, issuer) = owner(); + let session_seed = 11; + let child_seed = 12; + let (jwk, audience, vm) = session(session_seed); + let (child_jwk, _, child_vm) = session(child_seed); + let mut fixtures = Vec::new(); + for (name, revoked_encoding, encrypted) in [ + ("revoked-v0", Some("v0"), false), + ("revoked-v27", Some("v27"), false), + ("active", None, false), + ("encrypted-active", None, true), + ] { + let path = output.join(format!("{name}.sqlite")); + assert!(!path.exists(), "never overwrite historical fixture {}", path.display()); + let db = open(&path).await.with_encryption(encrypted.then(|| ColumnEncryption::new(ENCRYPTION_KEY))); + let resource = SpaceId::new(issuer.parse().unwrap(), name.parse().unwrap()) + .to_resource("kv".parse().unwrap(), None, None, None).to_string(); + let cacao = signed_cacao(&audience, Some(&resource), &format!("tc1071-{name}")); + let canonical_header = TinyCloudDelegation::Cacao(Box::new(cacao.clone())).encode().unwrap(); + let mut rows = Vec::new(); + for (encoding, header) in variants(&cacao) { + let raw_cid = cid(&header); + // This assertion prevents inadvertently generating on fixed code. + let parsed = tinycloud_core::events::Delegation::from_header_ser::(&header).unwrap(); + assert_eq!(parsed.content_hash().to_cid(0x55).to_string(), raw_cid, "generator must run on original f9d700bf decoder"); + register(&db, &header).await; + assert_eq!(list(&db, &resource, &raw_cid, &jwk, &vm).await.unwrap(), Vec::::new()); + let child_header = child_header(&resource, &raw_cid, session_seed, child_seed); + let child_cid = cid(&child_header); + register(&db, &child_header).await; + assert_eq!(list(&db, &resource, &child_cid, &child_jwk, &child_vm).await.unwrap(), Vec::::new()); + println!("TC1071 old {name} {} before revoke: root=kv/list-ok child=kv/list-ok raw={raw_cid}", encoding); + rows.push(SignedRow { encoding, header, raw_cid: raw_cid.clone(), child_header, child_cid, child_proof_cid: raw_cid }); + } + assert_eq!(delegation::Entity::find().count(db.connection()).await.unwrap(), (rows.len() * 2) as u64, "both historical root representations and all real children must be persisted"); + println!("TC1071 old {name}: persisted {} real delegations", rows.len() * 2); + let revoked_raw_cid = revoked_encoding.map(|encoding| rows.iter().find(|row| row.encoding == encoding).unwrap().raw_cid.clone()); + let revocation_header = if let Some(target) = &revoked_raw_cid { + let header = revoke(&db, target).await; + for row in &rows { + let accepted = list(&db, &resource, &row.raw_cid, &jwk, &vm).await; + let child_accepted = list(&db, &resource, &row.child_cid, &child_jwk, &child_vm).await; + println!("TC1071 old {name} {} after revoke {target}: root={} child={}", row.encoding, accepted.is_ok(), child_accepted.is_ok()); + if &row.raw_cid == target { + assert!(accepted.is_err()); + assert!(child_accepted.is_err()); + } else { + assert!(accepted.is_ok(), "pre-fix equivalent {} must demonstrate authorization leak: {accepted:?}", row.encoding); + assert!(child_accepted.is_ok(), "pre-fix child of {} must demonstrate authorization leak: {child_accepted:?}", row.encoding); + } + } + Some(header) + } else { None }; + fixtures.push(Fixture { + generated_by: "f9d700bf public SpaceDatabase::new/delegate/revoke/invoke".into(), + database: format!("{name}.sqlite"), encrypted, issuer: issuer.clone(), resource, + session_seed, child_seed, session_jwk: serde_json::to_value(&jwk).unwrap(), child_jwk: serde_json::to_value(&child_jwk).unwrap(), + canonical_header, rows, revocation_header, revoked_raw_cid, + }); + drop(db); + } + std::fs::write(output.join("manifest.json"), serde_json::to_vec_pretty(&fixtures).unwrap()).unwrap(); +} + +#[tokio::test] +#[ignore = "Append the single-legacy-row control using unmodified f9d700bf only"] +async fn generate_single_historical_fixture() { + let output = std::path::PathBuf::from(std::env::var_os("TC1071_FIXTURE_OUTPUT").unwrap()); + let mut fixtures: Vec = serde_json::from_slice(&std::fs::read(output.join("manifest.json")).unwrap()).unwrap(); + let database = "single-v0.sqlite"; + let path = output.join(database); + assert!(!path.exists(), "never overwrite a historical fixture"); + let db = open(&path).await; + let (_, _, issuer) = owner(); + let (jwk, audience, vm) = session(11); + let (child_jwk, _, child_vm) = session(12); + let resource = SpaceId::new(issuer.parse().unwrap(), "single-v0".parse().unwrap()).to_resource("kv".parse().unwrap(), None, None, None).to_string(); + let cacao = signed_cacao(&audience, Some(&resource), "tc1071-single-v0"); + let canonical_header = TinyCloudDelegation::Cacao(Box::new(cacao.clone())).encode().unwrap(); + let (encoding, header) = variants(&cacao).into_iter().find(|(encoding, _)| encoding == "v0").unwrap(); + let raw_cid = cid(&header); + assert_eq!(tinycloud_core::events::Delegation::from_header_ser::(&header).unwrap().content_hash().to_cid(0x55).to_string(), raw_cid); + register(&db, &header).await; + let child_header = child_header(&resource, &raw_cid, 11, 12); + let child_cid = cid(&child_header); + register(&db, &child_header).await; + assert_eq!(list(&db, &resource, &raw_cid, &jwk, &vm).await.unwrap(), Vec::::new()); + assert_eq!(list(&db, &resource, &child_cid, &child_jwk, &child_vm).await.unwrap(), Vec::::new()); + assert_eq!(delegation::Entity::find().count(db.connection()).await.unwrap(), 2); + println!("TC1071 old single-v0: persisted 2 real delegations; root=kv/list-ok child=kv/list-ok raw={raw_cid}; canonical row absent"); + fixtures.push(Fixture { + generated_by: "f9d700bf public SpaceDatabase::new/delegate/revoke/invoke".into(), + database: database.into(), encrypted: false, issuer, resource, + session_seed: 11, child_seed: 12, session_jwk: serde_json::to_value(&jwk).unwrap(), child_jwk: serde_json::to_value(&child_jwk).unwrap(), + canonical_header, rows: vec![SignedRow { encoding, header, raw_cid: raw_cid.clone(), child_header, child_cid, child_proof_cid: raw_cid }], + revocation_header: None, revoked_raw_cid: None, + }); + drop(db); + std::fs::write(output.join("manifest.json"), serde_json::to_vec_pretty(&fixtures).unwrap()).unwrap(); +} diff --git a/tinycloud-core/tests/fixtures/tc1071/support.rs b/tinycloud-core/tests/fixtures/tc1071/support.rs new file mode 100755 index 00000000..05b7f4c5 --- /dev/null +++ b/tinycloud-core/tests/fixtures/tc1071/support.rs @@ -0,0 +1,331 @@ +#![allow(dead_code)] + +use base64::{engine::general_purpose::URL_SAFE, Engine as _}; +use k256::ecdsa::SigningKey; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha3::{Digest, Keccak256}; +use std::{ + collections::BTreeMap, + sync::atomic::{AtomicU64, Ordering}, +}; +use tinycloud_auth::{ + authorization::{ + make_invocation, Cid, HeaderEncode, InvocationOptions, TinyCloudDelegation, + TinyCloudInvocation, TinyCloudRevocation, + }, + cacaos::{ + siwe::{Message, Version}, + siwe_cacao::{Header, Signature, SiweCacao}, + }, + ipld_core::ipld::Ipld, + resolver::DID_METHODS, + resource::ResourceId, + siwe_recap::{Ability, Capability}, + ssi::{ + claims::jwt::NumericDate, + jwk::{Algorithm, JWK}, + ucan::Payload, + }, + ucan_capabilities_object::Capabilities, +}; +use tinycloud_core::{ + db::{InvocationOutcome, SpaceDatabase}, + events::{Delegation, Invocation, Revocation}, + hash::{hash, Hash}, + keys::{public_key_to_did_key, Keypair, StaticSecret}, + sea_orm::{ConnectOptions, Database, DatabaseConnection}, + storage::memory::{MemoryStaging, MemoryStore}, +}; + +pub type TestDb = SpaceDatabase; +pub const EXPIRATION: f64 = 4_102_444_800.0; // 2100-01-01 +pub const OWNER_SEED: u8 = 7; +pub const ENCRYPTION_KEY: [u8; 32] = [43; 32]; +static NONCE: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SignedRow { + pub encoding: String, + pub header: String, + pub raw_cid: String, + pub child_header: String, + pub child_cid: String, + pub child_proof_cid: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Fixture { + pub generated_by: String, + pub database: String, + pub encrypted: bool, + pub issuer: String, + pub resource: String, + pub session_seed: u8, + pub child_seed: u8, + pub session_jwk: Value, + pub child_jwk: Value, + pub canonical_header: String, + pub rows: Vec, + pub revocation_header: Option, + pub revoked_raw_cid: Option, +} + +pub fn session(seed: u8) -> (JWK, String, String) { + let key = Keypair::ed25519_from_bytes([seed; 32]).unwrap(); + let ed = key.clone().try_into_ed25519().unwrap(); + let did = public_key_to_did_key(key.public()); + let jwk: JWK = serde_json::from_value(serde_json::json!({ + "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", + "x": URL_SAFE.encode(ed.public().to_bytes()).trim_end_matches('='), + "d": URL_SAFE.encode([seed; 32]).trim_end_matches('='), + })) + .unwrap(); + assert_eq!(DID_METHODS.generate(&jwk, "key").unwrap().to_string(), did); + let vm = format!("{did}#{}", did.trim_start_matches("did:key:")); + (jwk, did, vm) +} + +pub fn owner() -> (SigningKey, [u8; 20], String) { + let key = SigningKey::from_bytes(&[OWNER_SEED; 32].into()).unwrap(); + let public = key.verifying_key().to_encoded_point(false); + let address: [u8; 20] = Keccak256::digest(&public.as_bytes()[1..])[12..] + .try_into() + .unwrap(); + let issuer = format!( + "did:pkh:eip155:1:0x{}", + tinycloud_auth::cacaos::siwe::encode_eip55(&address) + ); + (key, address, issuer) +} + +pub fn signed_cacao(audience: &str, resource: Option<&str>, nonce: &str) -> SiweCacao { + let (key, address, _) = owner(); + let mut message = Message { + scheme: Some("https".parse().unwrap()), + domain: "tc1071.invalid".parse().unwrap(), + address, + statement: None, + uri: audience.parse().unwrap(), + version: Version::V1, + chain_id: 1, + nonce: nonce.into(), + issued_at: "2020-01-01T00:00:00Z".parse().unwrap(), + expiration_time: Some("2100-01-01T00:00:00Z".parse().unwrap()), + not_before: None, + request_id: None, + resources: vec![], + }; + if let Some(resource) = resource { + let mut recap = Capability::::new(); + recap.with_action( + resource.parse().unwrap(), + "tinycloud.kv/list".parse::().unwrap(), + [BTreeMap::new()], + ); + let owner_space = resource.replace("/kv", "/space"); + recap.with_action( + owner_space.parse().unwrap(), + "tinycloud.space/host".parse::().unwrap(), + [BTreeMap::new()], + ); + message = recap.build_message(message).unwrap(); + } + 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; + SiweCacao::new(message.into(), Signature::from(bytes), Header) +} + +pub fn cid(header: &str) -> String { + let bytes = if header.contains('.') { + header.as_bytes().to_vec() + } else { + URL_SAFE.decode(header).unwrap() + }; + hash(&bytes).to_cid(0x55).to_string() +} + +pub fn current_cid(header: &str) -> String { + let (_, bytes) = TinyCloudDelegation::decode(header).unwrap(); + hash(&bytes).to_cid(0x55).to_string() +} + +pub fn variants(cacao: &SiweCacao) -> Vec<(String, String)> { + let canonical_header = TinyCloudDelegation::Cacao(Box::new(cacao.clone())) + .encode() + .unwrap(); + let canonical = URL_SAFE.decode(canonical_header).unwrap(); + let value: Ipld = serde_ipld_dagcbor::from_slice(&canonical).unwrap(); + let mut result = vec![("v27".into(), URL_SAFE.encode(&canonical))]; + let mut v0 = value.clone(); + if let Ipld::Map(root) = &mut v0 { + if let Ipld::Map(signature) = root.get_mut("s").unwrap() { + if let Ipld::Bytes(bytes) = signature.get_mut("s").unwrap() { + bytes[64] -= 27; + } else { + panic!("signature must be bytes"); + } + } + } + result.push(( + "v0".into(), + URL_SAFE.encode(serde_ipld_dagcbor::to_vec(&v0).unwrap()), + )); + // Encode the same three root entries in reverse DAG-CBOR order. + if let Ipld::Map(root) = &value { + let mut reordered = vec![0xa3]; + for key in ["s", "p", "h"] { + reordered.extend(serde_ipld_dagcbor::to_vec(&key).unwrap()); + reordered.extend(serde_ipld_dagcbor::to_vec(root.get(key).unwrap()).unwrap()); + } + result.push(("map-order".into(), URL_SAFE.encode(reordered))); + } + assert_eq!(canonical[0], 0xa3); + let mut nonminimal = vec![0xb8, 0x03]; + nonminimal.extend_from_slice(&canonical[1..]); + result.push(("nonminimal".into(), URL_SAFE.encode(nonminimal))); + let mut null = value.clone(); + if let Ipld::Map(root) = &mut null { + if let Ipld::Map(payload) = root.get_mut("p").unwrap() { + payload.insert("nbf".into(), Ipld::Null); + } + } + result.push(( + "null".into(), + URL_SAFE.encode(serde_ipld_dagcbor::to_vec(&null).unwrap()), + )); + let mut lowercase = value; + if let Ipld::Map(root) = &mut lowercase { + if let Ipld::Map(payload) = root.get_mut("p").unwrap() { + if let Ipld::String(issuer) = payload.get_mut("iss").unwrap() { + *issuer = issuer.to_lowercase(); + } + } + } + result.push(( + "issuer-case".into(), + URL_SAFE.encode(serde_ipld_dagcbor::to_vec(&lowercase).unwrap()), + )); + result +} + +pub fn child_header(resource: &str, proof: &str, session_seed: u8, child_seed: u8) -> String { + let (jwk, _, vm) = session(session_seed); + let (_, audience, _) = session(child_seed); + let mut caps = Capabilities::::new(); + caps.with_action( + resource.parse().unwrap(), + "tinycloud.kv/list".parse::().unwrap(), + [BTreeMap::new()], + ); + Payload:: { + issuer: vm.parse().unwrap(), + audience: audience.parse().unwrap(), + not_before: Some(NumericDate::try_from_seconds(1_577_836_800.0).unwrap()), + expiration: NumericDate::try_from_seconds(EXPIRATION).unwrap(), + nonce: Some(format!("tc1071-child-{proof}")), + facts: None, + proof: vec![proof.parse().unwrap()], + attenuation: caps, + } + .sign(Algorithm::EdDSA, &jwk) + .unwrap() + .encode() + .unwrap() +} + +pub async fn connection(path: &std::path::Path) -> DatabaseConnection { + Database::connect( + ConnectOptions::new(format!("sqlite:{}?mode=rwc", path.display())) + .max_connections(1) + .to_owned(), + ) + .await + .unwrap() +} + +pub async fn open(path: &std::path::Path) -> TestDb { + SpaceDatabase::new( + connection(path).await, + MemoryStore::default(), + StaticSecret::new(vec![31; 32]).unwrap(), + ) + .await + .unwrap() +} + +pub async fn register(db: &TestDb, header: &str) { + db.delegate(Delegation::from_header_ser::(header).unwrap()) + .await + .map_err(|error| error.to_string()) + .unwrap(); +} + +pub async fn revoke(db: &TestDb, target: &str) -> String { + let header = TinyCloudRevocation::Cacao(Box::new(signed_cacao( + &format!("ucan:{target}"), + None, + "tc1071-owner-revoke", + ))) + .encode() + .unwrap(); + db.revoke(Revocation::from_header_ser::(&header).unwrap()) + .await + .map_err(|error| error.to_string()) + .unwrap(); + header +} + +pub async fn list( + db: &TestDb, + resource: &str, + proof: &str, + jwk: &JWK, + vm: &str, +) -> Result, String> { + let invocation = make_invocation( + [( + format!("{resource}/").parse::().unwrap(), + ["tinycloud.kv/list".parse::().unwrap()], + )], + &proof.parse::().unwrap(), + jwk, + vm, + EXPIRATION, + InvocationOptions { + nonce: Some(format!( + "tc1071-{}-{}", + std::process::id(), + NONCE.fetch_add(1, Ordering::Relaxed) + )), + ..Default::default() + }, + ) + .unwrap() + .encode() + .unwrap(); + let (_, outcomes) = db + .invoke::( + Invocation::from_header_ser::(&invocation).unwrap(), + Default::default(), + ) + .await + .map_err(|error| error.to_string())?; + assert_eq!(outcomes.len(), 1, "kv/list must actually execute"); + match outcomes.into_iter().next().unwrap() { + InvocationOutcome::KvList(keys, truncated, cursor) => { + assert!(!truncated); + assert!(cursor.is_none()); + Ok(keys.into_iter().map(|key| key.to_string()).collect()) + } + other => panic!("expected actual kv/list result, got {other:?}"), + } +} + +pub fn hash_from_cid(value: &str) -> Hash { + Hash::from(value.parse::().unwrap()) +} diff --git a/tinycloud-core/tests/fixtures/tc1071/upgrade_checks.rs b/tinycloud-core/tests/fixtures/tc1071/upgrade_checks.rs new file mode 100644 index 00000000..70d51fce --- /dev/null +++ b/tinycloud-core/tests/fixtures/tc1071/upgrade_checks.rs @@ -0,0 +1,396 @@ +use super::*; +use tinycloud_core::{ + db::InvocationOutcome, encryption::ColumnEncryption, events::Delegation, hash::hash, +}; + +fn assert_aliases(path: &Path, fixture: &Fixture) { + let connection = rusqlite::Connection::open(path).unwrap(); + let count: i64 = connection + .query_row("SELECT COUNT(*) FROM delegation_cid_alias", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!( + count, + (fixture.rows.len() * 2) as i64, + "every real persisted CACAO and UCAN needs an alias" + ); + for row in &fixture.rows { + for (raw, canonical) in [ + (&row.raw_cid, current_cid(&row.header)), + (&row.child_cid, row.child_cid.clone()), + ] { + let id: Vec = hash_from_cid(raw).into(); + let canonical_bytes: Vec = connection + .query_row( + "SELECT canonical FROM delegation_cid_alias WHERE id = ?1", + [id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + canonical_bytes, + Vec::::from(hash_from_cid(&canonical)), + "alias must use exactly the current ingestion decoder bytes" + ); + } + } +} + +#[tokio::test] +async fn encrypted_boot_decrypts_exact_historical_records_and_preserves_revocation_equivalence() { + let fixture = fixtures() + .into_iter() + .find(|fixture| fixture.encrypted) + .unwrap(); + let (_directory, path) = copy_database(&fixture); + let before = signed_snapshot(&path); + let failed = SpaceDatabase::new_with_encryption( + connection(&path).await, + MemoryStore::default(), + StaticSecret::new(vec![31; 32]).unwrap(), + Some(ColumnEncryption::new([44; 32])), + ) + .await; + assert!( + failed.is_err(), + "wrong encryption key must fail boot, never skip encrypted rows" + ); + assert_eq!(signed_snapshot(&path), before); + let db = SpaceDatabase::new_with_encryption( + connection(&path).await, + MemoryStore::default(), + StaticSecret::new(vec![31; 32]).unwrap(), + Some(ColumnEncryption::new(ENCRYPTION_KEY)), + ) + .await + .unwrap(); + assert_signed_rows_untouched(&db, &fixture).await; + assert_aliases(&path, &fixture); + let (session, vm, child, child_vm) = signers(&fixture); + for row in &fixture.rows { + assert_eq!( + list(&db, &fixture.resource, &row.raw_cid, &session, &vm) + .await + .unwrap(), + Vec::::new() + ); + assert_eq!( + list(&db, &fixture.resource, &row.child_cid, &child, &child_vm) + .await + .unwrap(), + Vec::::new() + ); + } + assert_eq!( + signed_snapshot(&path), + before, + "decrypting at boot must not reencrypt or rewrite signed records" + ); + let target = &fixture + .rows + .iter() + .find(|row| row.encoding == "v0") + .unwrap() + .raw_cid; + revoke(&db, target).await; + assert_denied(&db, &fixture).await; + let after = signed_snapshot(&path); + assert_eq!(before[..2], after[..2]); + drop(db); + let db = SpaceDatabase::new_with_encryption( + connection(&path).await, + MemoryStore::default(), + StaticSecret::new(vec![31; 32]).unwrap(), + Some(ColumnEncryption::new(ENCRYPTION_KEY)), + ) + .await + .unwrap(); + assert_denied(&db, &fixture).await; + assert_aliases(&path, &fixture); +} + +fn assert_raw(event: &Delegation, header: &str, advertised_cid: &str) { + let expected = if header.contains('.') { + header.as_bytes().to_vec() + } else { + URL_SAFE.decode(header).unwrap() + }; + assert_eq!( + event.serialized_bytes(), + expected, + "API must return the exact historically signed encoding" + ); + assert_eq!( + event.content_hash().to_cid(0x55).to_string(), + advertised_cid, + "advertised CID must hash the returned raw bytes, never re-encoded normalized CACAO" + ); + let returned_header = if header.contains('.') { + String::from_utf8(event.serialized_bytes().to_vec()).unwrap() + } else { + URL_SAFE.encode(event.serialized_bytes()) + }; + assert_eq!(returned_header, header); + assert_eq!( + hash(event.serialized_bytes()), + hash_from_cid(advertised_cid) + ); +} + +#[tokio::test] +async fn upgraded_list_and_chain_return_exact_historical_raw_headers_for_advertised_cids() { + let fixture = fixtures() + .into_iter() + .find(|fixture| fixture.database == "active.sqlite") + .unwrap(); + let (_directory, path) = copy_database(&fixture); + let db = open(&path).await; + assert_aliases(&path, &fixture); + let (reader, resource) = reader_grant(&fixture); + let proof = current_cid(&reader); + register(&db, &reader).await; + let before = signed_snapshot(&path); + // Both the legacy parameter-free surface and the explicit list variant + // must retain raw bytes alongside their advertised persisted IDs. + for params in [ + None, + Some(serde_json::json!({"type":"list","filters":{"direction":"all"}})), + ] { + let outcomes = read_capabilities(&db, &fixture, resource.clone(), &proof, params).await; + let InvocationOutcome::OpenSessions(sessions) = outcomes.into_iter().next().unwrap() else { + panic!("expected actual capabilities list outcome") + }; + for row in &fixture.rows { + assert_raw( + sessions + .get(&hash_from_cid(&row.raw_cid)) + .expect("legacy root advertised"), + &row.header, + &row.raw_cid, + ); + let child = sessions + .get(&hash_from_cid(&row.child_cid)) + .expect("legacy child advertised"); + assert_raw(child, &row.child_header, &row.child_cid); + assert_eq!(child.0.parents, vec![row.child_proof_cid.parse().unwrap()]); + } + } + for row in &fixture.rows { + let outcomes = read_capabilities( + &db, + &fixture, + resource.clone(), + &proof, + Some(serde_json::json!({"type":"chain","delegation_cid":row.child_cid})), + ) + .await; + let InvocationOutcome::DelegationChain(chain) = outcomes.into_iter().next().unwrap() else { + panic!("expected actual capabilities chain outcome") + }; + assert_eq!(chain.len(), 2); + assert_raw(&chain[0], &row.child_header, &row.child_cid); + assert_raw(&chain[1], &row.header, &row.raw_cid); + assert_eq!( + chain[0].0.parents, + vec![row.child_proof_cid.parse().unwrap()] + ); + } + assert_eq!( + signed_snapshot(&path), + before, + "list and chain are read-only for immutable signed records and edges" + ); + let target = &fixture + .rows + .iter() + .find(|row| row.encoding == "v0") + .unwrap() + .raw_cid; + revoke(&db, target).await; + assert_denied(&db, &fixture).await; + for params in [ + None, + Some(serde_json::json!({"type":"list","filters":{"direction":"all"}})), + ] { + let outcomes = read_capabilities(&db, &fixture, resource.clone(), &proof, params).await; + let InvocationOutcome::OpenSessions(sessions) = outcomes.into_iter().next().unwrap() else { + panic!("expected actual capabilities list outcome") + }; + assert!( + sessions.contains_key(&hash_from_cid(&proof)), + "independent authorized reader grant remains active" + ); + for row in &fixture.rows { + assert!( + !sessions.contains_key(&hash_from_cid(&row.raw_cid)), + "list must hide equivalent revoked roots" + ); + let outcomes = read_capabilities( + &db, + &fixture, + resource.clone(), + &proof, + Some(serde_json::json!({"type":"chain","delegation_cid":row.child_cid})), + ) + .await; + let InvocationOutcome::DelegationChain(chain) = outcomes.into_iter().next().unwrap() + else { + panic!("expected actual capabilities chain outcome") + }; + for returned in chain { + assert!( + fixture + .rows + .iter() + .all(|root| returned.content_hash() != hash_from_cid(&root.raw_cid)), + "chain must not advertise an equivalent revoked root" + ); + assert_raw(&returned, &row.child_header, &row.child_cid); + } + } + } + println!("TC1071 upgraded active: default+filtered capabilities lists and all six chains preserve exact raw-header/CID correspondence; exclude equivalent revoked roots; independent reader grant remains authorized"); +} + +#[tokio::test] +async fn single_legacy_root_without_canonical_row_survives_import_then_revokes_as_one_identity() { + let fixture = fixtures() + .into_iter() + .find(|fixture| fixture.database == "single-v0.sqlite") + .unwrap(); + let (_directory, path) = copy_database(&fixture); + let before = signed_snapshot(&path); + let db = open(&path).await; + assert_signed_rows_untouched(&db, &fixture).await; + assert_aliases(&path, &fixture); + let canonical = current_cid(&fixture.canonical_header); + assert!( + delegation::Entity::find_by_id(hash_from_cid(&canonical)) + .one(db.connection()) + .await + .unwrap() + .is_none(), + "canonical key must not require an existing delegation row" + ); + let row = &fixture.rows[0]; + let (session, vm, child, child_vm) = signers(&fixture); + assert_eq!( + list(&db, &fixture.resource, &row.raw_cid, &session, &vm) + .await + .unwrap(), + Vec::::new() + ); + assert_eq!( + list(&db, &fixture.resource, &row.child_cid, &child, &child_vm) + .await + .unwrap(), + Vec::::new() + ); + assert_eq!(signed_snapshot(&path), before); + register(&db, &fixture.canonical_header).await; + assert_eq!( + list(&db, &fixture.resource, &canonical, &session, &vm) + .await + .unwrap(), + Vec::::new() + ); + assert_eq!( + list(&db, &fixture.resource, &row.child_cid, &child, &child_vm) + .await + .unwrap(), + Vec::::new() + ); + let header = revoke(&db, &row.raw_cid).await; + let signed = Revocation::from_header_ser::(&header).unwrap(); + assert_eq!(signed.0.revoked.to_string(), row.raw_cid); + assert_denied(&db, &fixture).await; + register(&db, &row.header).await; + register(&db, &fixture.canonical_header).await; + let error = list(&db, &fixture.resource, &canonical, &session, &vm) + .await + .unwrap_err(); + assert!(error.contains("delegation-revoked"), "{error}"); + assert_denied(&db, &fixture).await; + assert_redelegation_denied(&db, &fixture).await; + assert_eq!( + delegation::Entity::find() + .count(db.connection()) + .await + .unwrap(), + 3 + ); + let after = signed_snapshot(&path); + for old_row in &before[0] { + assert!( + after[0].contains(old_row), + "old signed delegation row must remain unchanged" + ); + } + assert_eq!(before[1], after[1]); + println!("TC1071 upgraded single-v0: raw={} canonical={} old signed child={} preserved; canonical import and reimports cannot resurrect owner-revoked identity", row.raw_cid, canonical, row.child_cid); +} + +#[tokio::test] +async fn list_and_chain_preserve_the_exact_signed_ucan_proof_cid() { + use tinycloud_auth::authorization::Cid; + let fixture = fixtures() + .into_iter() + .find(|fixture| fixture.database == "active.sqlite") + .unwrap(); + let (_directory, path) = copy_database(&fixture); + let db = open(&path).await; + let root = &fixture + .rows + .iter() + .find(|row| row.encoding == "v0") + .unwrap() + .raw_cid; + let root: Cid = root.parse().unwrap(); + // The graph stores hashes, but a signed proof is a complete CID. Changing + // its codec during response projection would rewrite the signed reference. + let signed_proof = Cid::new_v1(0x71, *root.hash()); + let header = child_header( + &fixture.resource, + &signed_proof.to_string(), + fixture.session_seed, + fixture.child_seed, + ); + let child = cid(&header); + register(&db, &header).await; + let (reader, resource) = reader_grant(&fixture); + let proof = current_cid(&reader); + register(&db, &reader).await; + let (_, _, child_key, child_vm) = signers(&fixture); + assert_eq!( + list(&db, &fixture.resource, &child, &child_key, &child_vm) + .await + .unwrap(), + Vec::::new() + ); + let outcomes = read_capabilities(&db, &fixture, resource.clone(), &proof, None).await; + let InvocationOutcome::OpenSessions(sessions) = outcomes.into_iter().next().unwrap() else { + panic!("expected capabilities list") + }; + let returned = sessions.get(&hash_from_cid(&child)).unwrap(); + assert_raw(returned, &header, &child); + assert_eq!(returned.0.parents, vec![signed_proof]); + let outcomes = read_capabilities( + &db, + &fixture, + resource, + &proof, + Some(serde_json::json!({"type":"chain","delegation_cid":child})), + ) + .await; + let InvocationOutcome::DelegationChain(chain) = outcomes.into_iter().next().unwrap() else { + panic!("expected capabilities chain") + }; + assert_raw(&chain[0], &header, &child); + assert_eq!(chain[0].0.parents, vec![signed_proof]); + revoke(&db, &root.to_string()).await; + let error = list(&db, &fixture.resource, &child, &child_key, &child_vm) + .await + .unwrap_err(); + assert!(error.contains("delegation-ancestor-revoked"), "{error}"); +} diff --git a/tinycloud-core/tests/historical_cacao_revocation.rs b/tinycloud-core/tests/historical_cacao_revocation.rs new file mode 100644 index 00000000..38143a80 --- /dev/null +++ b/tinycloud-core/tests/historical_cacao_revocation.rs @@ -0,0 +1,484 @@ +#[path = "fixtures/tc1071/support.rs"] +mod support; +#[path = "fixtures/tc1071/upgrade_checks.rs"] +mod upgrade_checks; + +use base64::{engine::general_purpose::URL_SAFE, Engine as _}; +use rusqlite::types::Value as SqlValue; +use std::path::{Path, PathBuf}; +use support::*; +use tinycloud_auth::{ + authorization::{HeaderEncode, TinyCloudDelegation, TinyCloudRevocation}, + ssi::jwk::JWK, +}; +use tinycloud_core::{ + db::SpaceDatabase, + events::Revocation, + keys::StaticSecret, + models::{delegation, revocation}, + relationships::parent_delegations, + sea_orm::{ + ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, PaginatorTrait, QueryFilter, + Statement, + }, + storage::memory::MemoryStore, +}; + +fn fixtures() -> Vec { + let bytes = std::fs::read(fixture_dir().join("manifest.json")).expect("real f9d700bf fixture manifest must be checked in; generate it with fixtures/tc1071/generator.rs"); + let fixtures: Vec = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(fixtures.len(), 5); + for fixture in &fixtures { + assert_eq!( + fixture.generated_by, + "f9d700bf public SpaceDatabase::new/delegate/revoke/invoke" + ); + assert_eq!( + fixture.rows.len(), + if fixture.database == "single-v0.sqlite" { + 1 + } else { + 6 + } + ); + for row in &fixture.rows { + assert_eq!(row.child_proof_cid, row.raw_cid); + } + } + fixtures +} + +fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tc1071/generated") +} + +fn copy_database(fixture: &Fixture) -> (tempfile::TempDir, PathBuf) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(&fixture.database); + std::fs::copy(fixture_dir().join(&fixture.database), &path).unwrap(); + (directory, path) +} + +// Snapshot only immutable signed records and edges; new alias/migration rows +// and ordinary new invocation events are deliberately outside this snapshot. +fn signed_snapshot(path: &Path) -> Vec>> { + let db = rusqlite::Connection::open(path).unwrap(); + [ + "SELECT * FROM delegation ORDER BY id", + "SELECT * FROM parent_delegation WHERE child IN (SELECT id FROM delegation) OR child IN (SELECT id FROM revocation) ORDER BY child, parent", + "SELECT * FROM revocation ORDER BY id", + ].into_iter().map(|sql| { + let mut statement = db.prepare(sql).unwrap(); + let columns = statement.column_count(); + let rows = statement.query_map([], |row| (0..columns).map(|column| row.get::<_, SqlValue>(column)).collect::>>()).unwrap(); + rows.collect::>>().unwrap() + }).collect() +} + +fn signers(fixture: &Fixture) -> (JWK, String, JWK, String) { + let (session, _, session_vm) = session(fixture.session_seed); + let (child, _, child_vm) = support::session(fixture.child_seed); + assert_eq!(serde_json::to_value(&session).unwrap(), fixture.session_jwk); + assert_eq!(serde_json::to_value(&child).unwrap(), fixture.child_jwk); + (session, session_vm, child, child_vm) +} + +async fn assert_signed_rows_untouched(db: &TestDb, fixture: &Fixture) { + assert_eq!( + delegation::Entity::find() + .count(db.connection()) + .await + .unwrap(), + (fixture.rows.len() * 2) as u64 + ); + let encryption = fixture + .encrypted + .then(|| tinycloud_core::encryption::ColumnEncryption::new(ENCRYPTION_KEY)); + for row in &fixture.rows { + let root = delegation::Entity::find_by_id(hash_from_cid(&row.raw_cid)) + .one(db.connection()) + .await + .unwrap() + .unwrap(); + let child = delegation::Entity::find_by_id(hash_from_cid(&row.child_cid)) + .one(db.connection()) + .await + .unwrap() + .unwrap(); + let root_bytes = + tinycloud_core::encryption::maybe_decrypt(encryption.as_ref(), &root.serialization) + .unwrap(); + assert_eq!(root_bytes, URL_SAFE.decode(&row.header).unwrap()); + let child_bytes = + tinycloud_core::encryption::maybe_decrypt(encryption.as_ref(), &child.serialization) + .unwrap(); + assert_eq!(child_bytes, row.child_header.as_bytes()); + let parents = parent_delegations::Entity::find() + .filter(parent_delegations::Column::Child.eq(child.id)) + .all(db.connection()) + .await + .unwrap(); + assert_eq!(parents.len(), 1); + assert_eq!(parents[0].parent, hash_from_cid(&row.child_proof_cid)); + let (decoded, bytes) = TinyCloudDelegation::decode(&row.child_header).unwrap(); + assert_eq!(bytes, row.child_header.as_bytes()); + match decoded { + TinyCloudDelegation::Ucan(ucan) => { + assert_eq!( + ucan.payload().proof, + vec![row.child_proof_cid.parse().unwrap()] + ); + assert_eq!(ucan.encode().unwrap(), row.child_header); + } + _ => panic!("historical child must remain its exact signed UCAN"), + } + } + if let (Some(header), Some(target)) = (&fixture.revocation_header, &fixture.revoked_raw_cid) { + let event = Revocation::from_header_ser::(header).unwrap(); + assert_eq!(event.0.revoked.to_string(), *target); + let stored = revocation::Entity::find_by_id(event.content_hash()) + .one(db.connection()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.revoked, hash_from_cid(target)); + assert_eq!(stored.serialization, URL_SAFE.decode(header).unwrap()); + } +} + +async fn assert_denied(db: &TestDb, fixture: &Fixture) { + let (session, vm, child, child_vm) = signers(fixture); + for row in &fixture.rows { + let error = list(db, &fixture.resource, &row.raw_cid, &session, &vm) + .await + .expect_err(&format!( + "historical equivalent {} still authorized after upgrade", + row.encoding + )); + assert!( + error.contains("delegation-revoked"), + "reject for revocation, not unrelated failure: {error}" + ); + let error = list(db, &fixture.resource, &row.child_cid, &child, &child_vm) + .await + .expect_err(&format!( + "child citing historical {} root still authorized after upgrade", + row.encoding + )); + assert!( + error.contains("delegation-ancestor-revoked"), + "child must reject for root revocation, not unrelated failure: {error}" + ); + for id in [&row.raw_cid, &row.child_cid] { + let status = db + .delegation_status(hash_from_cid(id), &fixture.issuer, &[]) + .await + .map_err(|error| error.to_string()) + .unwrap(); + assert_eq!( + status, + Some(tinycloud_core::db::DelegationStatus::Revoked), + "status must agree with actual invocation rejection for {id}" + ); + } + println!("TC1071 upgraded {} {}: root={} rejected delegation-revoked; child={} rejected delegation-ancestor-revoked; owner status=revoked", fixture.database, row.encoding, row.raw_cid, row.child_cid); + } +} + +#[tokio::test] +async fn upgrade_revoked_historical_equivalents_reject_real_kv_list_both_directions() { + for fixture in fixtures() + .into_iter() + .filter(|fixture| fixture.revoked_raw_cid.is_some()) + { + let (_directory, path) = copy_database(&fixture); + let before = signed_snapshot(&path); + let db = open(&path).await; + assert_signed_rows_untouched(&db, &fixture).await; + assert_denied(&db, &fixture).await; + for row in &fixture.rows { + register(&db, &row.header).await; + } + assert_denied(&db, &fixture).await; + assert_redelegation_denied(&db, &fixture).await; + assert_eq!( + signed_snapshot(&path), + before, + "upgrade and rejection must never rewrite signed historical records" + ); + } +} + +#[tokio::test] +async fn upgrade_active_legacy_children_remain_valid_until_owner_revokes_either_encoding() { + let fixture = fixtures() + .into_iter() + .find(|fixture| fixture.database == "active.sqlite") + .unwrap(); + for encoding in [ + "v0", + "v27", + "map-order", + "nonminimal", + "null", + "issuer-case", + ] { + let (_directory, path) = copy_database(&fixture); + let before = signed_snapshot(&path); + let db = open(&path).await; + assert_signed_rows_untouched(&db, &fixture).await; + let (session, vm, child, child_vm) = signers(&fixture); + for row in &fixture.rows { + assert_eq!( + list(&db, &fixture.resource, &row.raw_cid, &session, &vm) + .await + .unwrap(), + Vec::::new() + ); + assert_eq!( + list(&db, &fixture.resource, &row.child_cid, &child, &child_vm) + .await + .unwrap(), + Vec::::new() + ); + } + assert_eq!( + signed_snapshot(&path), + before, + "valid old signed proof references must survive startup unchanged" + ); + let target = &fixture + .rows + .iter() + .find(|row| row.encoding == encoding) + .unwrap() + .raw_cid; + let header = revoke(&db, target).await; + let signed = Revocation::from_header_ser::(&header).unwrap(); + assert_eq!(signed.0.revoked.to_string(), *target); + assert_denied(&db, &fixture).await; + for row in &fixture.rows { + register(&db, &row.header).await; + } + assert_denied(&db, &fixture).await; + assert_redelegation_denied(&db, &fixture).await; + let after = signed_snapshot(&path); + assert_eq!( + before[..2], + after[..2], + "owner action must not rewrite roots or children" + ); + drop(db); + let db = open(&path).await; + assert_denied(&db, &fixture).await; + } +} + +#[tokio::test] +async fn new_writes_deduplicate_equivalent_cacao_and_revoke_the_single_canonical_row() { + let fixture = fixtures() + .into_iter() + .find(|fixture| fixture.database == "active.sqlite") + .unwrap(); + let (session, vm, _, _) = signers(&fixture); + for encoding in ["v0", "v27"] { + let directory = tempfile::tempdir().unwrap(); + let db = open(&directory.path().join("new.sqlite")).await; + let canonical_cid = current_cid(&fixture.canonical_header); + for row in &fixture.rows { + assert_eq!( + current_cid(&row.header), + canonical_cid, + "same current decode bytes must define equivalence: {}", + row.encoding + ); + let event = tinycloud_core::events::Delegation::from_header_ser::( + &row.header, + ) + .unwrap(); + let receipt = db + .delegate(event) + .await + .map_err(|error| error.to_string()) + .unwrap(); + assert_eq!( + receipt.delegation_cids, + vec![hash_from_cid(&canonical_cid)], + "public ingestion receipt must advertise the retained canonical CID" + ); + } + assert_eq!( + delegation::Entity::find() + .count(db.connection()) + .await + .unwrap(), + 1 + ); + assert_eq!( + list(&db, &fixture.resource, &canonical_cid, &session, &vm) + .await + .unwrap(), + Vec::::new() + ); + let alias_count = db + .connection() + .query_one(Statement::from_string( + DbBackend::Sqlite, + "SELECT COUNT(*) AS n FROM delegation_cid_alias".to_owned(), + )) + .await + .unwrap() + .unwrap() + .try_get::("", "n") + .unwrap(); + assert_eq!(alias_count, 1); + let row = fixture + .rows + .iter() + .find(|row| row.encoding == encoding) + .unwrap(); + // New signatures target the CID the ingestion API actually retained. + assert_eq!(current_cid(&row.header), canonical_cid); + revoke(&db, &canonical_cid).await; + let error = list(&db, &fixture.resource, &canonical_cid, &session, &vm) + .await + .unwrap_err(); + assert!(error.contains("delegation-revoked"), "{error}"); + } +} + +#[tokio::test] +async fn encrypted_historical_rows_require_key_at_startup() { + let fixture = fixtures() + .into_iter() + .find(|fixture| fixture.encrypted) + .unwrap(); + let (_directory, path) = copy_database(&fixture); + let before = signed_snapshot(&path); + let result = SpaceDatabase::new( + connection(&path).await, + MemoryStore::default(), + StaticSecret::new(vec![31; 32]).unwrap(), + ) + .await; + assert!( + result.is_err(), + "encrypted historical rows must fail boot without their decryption key" + ); + assert_eq!( + signed_snapshot(&path), + before, + "failed startup must not modify historical rows" + ); +} + +fn reader_grant(fixture: &Fixture) -> (String, tinycloud_auth::resource::ResourceId) { + use std::collections::BTreeMap; + use tinycloud_auth::{ + cacaos::{ + siwe::Message, + siwe_cacao::{Header, Signature, SiweCacao}, + }, + siwe_recap::{Ability, Capability}, + }; + let (_, audience, _) = session(fixture.session_seed); + let resource = fixture + .resource + .parse::() + .unwrap() + .space() + .clone() + .to_resource( + "capabilities".parse().unwrap(), + Some("all".parse().unwrap()), + None, + None, + ); + let mut recap = Capability::::new(); + recap.with_action( + resource.as_uri(), + "tinycloud.capabilities/read".parse::().unwrap(), + [BTreeMap::new()], + ); + let message: Message = signed_cacao(&audience, None, "tc1071-query") + .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; + let header = TinyCloudDelegation::Cacao(Box::new(SiweCacao::new( + message.into(), + Signature::from(bytes), + Header, + ))) + .encode() + .unwrap(); + (header, resource) +} + +async fn read_capabilities( + db: &TestDb, + fixture: &Fixture, + resource: tinycloud_auth::resource::ResourceId, + proof: &str, + params: Option, +) -> Vec< + tinycloud_core::db::InvocationOutcome< + ::Readable, + >, +> { + use tinycloud_auth::authorization::{make_invocation, InvocationOptions, TinyCloudInvocation}; + use tinycloud_core::{events::Invocation, storage::memory::MemoryStaging}; + let (jwk, _, vm) = session(fixture.session_seed); + let header = make_invocation( + [(resource, ["tinycloud.capabilities/read".parse().unwrap()])], + &proof.parse().unwrap(), + &jwk, + &vm, + EXPIRATION, + InvocationOptions { + facts: params.map(|params| vec![serde_json::json!({"capabilitiesReadParams": params})]), + ..Default::default() + }, + ) + .unwrap() + .encode() + .unwrap(); + let (_, outcomes) = db + .invoke::( + Invocation::from_header_ser::(&header).unwrap(), + Default::default(), + ) + .await + .map_err(|error| error.to_string()) + .unwrap(); + assert_eq!(outcomes.len(), 1); + outcomes +} + +async fn assert_redelegation_denied(db: &TestDb, fixture: &Fixture) { + use tinycloud_core::events::Delegation; + for row in &fixture.rows { + let direct = child_header(&fixture.resource, &row.raw_cid, fixture.session_seed, 13); + let error = db + .delegate(Delegation::from_header_ser::(&direct).unwrap()) + .await + .expect_err("revoked equivalent parent must not allow a new child") + .to_string(); + assert!(error.contains("delegation-parent-revoked"), "{error}"); + let descendant = child_header(&fixture.resource, &row.child_cid, fixture.child_seed, 13); + let error = db + .delegate(Delegation::from_header_ser::(&descendant).unwrap()) + .await + .expect_err("legacy child of revoked root must not allow a descendant") + .to_string(); + assert!(error.contains("delegation-ancestor-revoked"), "{error}"); + } +} diff --git a/tinycloud-node-server/src/auth_guards.rs b/tinycloud-node-server/src/auth_guards.rs index 18fe2980..755ccc1f 100644 --- a/tinycloud-node-server/src/auth_guards.rs +++ b/tinycloud-node-server/src/auth_guards.rs @@ -11,16 +11,9 @@ use rocket::{ use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; use std::time::Instant; -use tinycloud_auth::{ - authorization::{EncodingError, HeaderEncode}, - ipld_core::cid::Cid, - resource::SpaceId, -}; +use tinycloud_auth::{authorization::TinyCloudDelegation, ipld_core::cid::Cid, resource::SpaceId}; use tinycloud_core::{ - hash::Hash, - types::Metadata, - util::{Capability, DelegationInfo}, - InvocationOutcome, + events::Delegation, hash::Hash, types::Metadata, util::Capability, InvocationOutcome, }; use tokio_util::compat::FuturesAsyncReadCompatExt; use tracing::{info_span, Instrument}; @@ -250,7 +243,7 @@ where InvocationOutcome::DelegationChain(chain) => Json( chain .into_iter() - .map(|del| Ok(CapJsonRep::from_delegation(del)?)) + .map(CapJsonRep::from_delegation) .collect::>>() .map_err(|_| Status::InternalServerError)?, ) @@ -282,6 +275,93 @@ mod tests { use rocket::{get, http::Header, local::asynchronous::Client, routes}; use tinycloud_core::{hash::hash, KvBatchReadItem, KvBatchReadValue}; + type HistoricalDb = tinycloud_core::db::SpaceDatabase< + tinycloud_core::sea_orm::DatabaseConnection, + tinycloud_core::storage::memory::MemoryStore, + tinycloud_core::keys::StaticSecret, + >; + + #[get("/historical/?")] + async fn historical_delegation_response( + db: &rocket::State, + cid: &str, + chain: bool, + ) -> Result>>, Status> { + let cid: Cid = cid.parse().map_err(|_| Status::BadRequest)?; + let (_, event) = db + .load_signed_delegation(cid) + .await + .map_err(|_| Status::InternalServerError)? + .ok_or(Status::NotFound)?; + Ok(InvOut(if chain { + InvocationOutcome::DelegationChain(vec![event]) + } else { + InvocationOutcome::OpenSessions(HashMap::from([(Hash::from(cid), event)])) + })) + } + + #[tokio::test] + async fn historical_list_and_chain_json_preserve_raw_header_cids() -> Result<()> { + use tinycloud_core::{keys::StaticSecret, sea_orm::Database, storage::memory::MemoryStore}; + let fixture_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../tinycloud-core/tests/fixtures/tc1071/generated"); + let manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(fixture_dir.join("manifest.json"))?)?; + let fixture = manifest + .as_array() + .unwrap() + .iter() + .find(|fixture| fixture["database"] == "active.sqlite") + .unwrap(); + let directory = tempfile::tempdir()?; + let path = directory.path().join("caps.sqlite"); + std::fs::copy(fixture_dir.join("active.sqlite"), &path)?; + let db = HistoricalDb::new( + Database::connect(format!("sqlite:{}?mode=rwc", path.display())).await?, + MemoryStore::default(), + StaticSecret::new(vec![31; 32]).unwrap(), + ) + .await?; + let client = Client::tracked( + rocket::build() + .manage(db) + .mount("/", routes![historical_delegation_response]), + ) + .await?; + for row in fixture["rows"].as_array().unwrap() { + for (id, header) in [("raw_cid", "header"), ("child_cid", "child_header")] { + let cid = row[id].as_str().unwrap(); + let header = row[header].as_str().unwrap(); + for chain in [false, true] { + let response = client + .get(format!("/historical/{cid}?chain={chain}")) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let json: serde_json::Value = + serde_json::from_str(&response.into_string().await.unwrap())?; + let record = if chain { &json[0] } else { &json[cid] }; + let raw = record["raw"].as_str().unwrap(); + assert_eq!(raw, header); + let bytes = if raw.contains('.') { + raw.as_bytes().to_vec() + } else { + base64::decode_config(raw, base64::URL_SAFE)? + }; + assert_eq!(hash(&bytes).to_cid(0x55).to_string(), cid); + if id == "child_cid" { + let parents: Vec = serde_json::from_value(record["parents"].clone())?; + assert_eq!( + parents, + vec![row["raw_cid"].as_str().unwrap().parse::()?] + ); + } + } + } + } + Ok(()) + } + #[get("/")] fn conditional_kv_response() -> KVResponse>> { let content = b"hello".to_vec(); @@ -520,13 +600,21 @@ pub struct CapJsonRep { } impl CapJsonRep { - pub fn from_delegation(d: DelegationInfo) -> Result { + /// Return the exact persisted header, whose bytes hash to the advertised + /// CID. Re-encoding a decoded historical CACAO would change that identity. + pub fn from_delegation(d: Delegation) -> Result { + let raw = match &d.0.delegation { + TinyCloudDelegation::Ucan(_) => std::str::from_utf8(d.serialized_bytes())?.to_owned(), + TinyCloudDelegation::Cacao(_) => { + base64::encode_config(d.serialized_bytes(), base64::URL_SAFE) + } + }; Ok(Self { - capabilities: d.capabilities, - delegator: d.delegator, - delegate: d.delegate, - parents: d.parents, - raw: d.delegation.encode()?, + capabilities: d.0.capabilities, + delegator: d.0.delegator, + delegate: d.0.delegate, + parents: d.0.parents, + raw, }) } } diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index 27d86000..c576932a 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -613,13 +613,13 @@ pub async fn app_with_control( encryption_backend, ); - let tinycloud = TinyCloud::new( + let tinycloud = TinyCloud::new_with_encryption( database_connection, tinycloud_config.storage.blocks.open().await?, key_setup.setup(()).await?, + Some(webhook_encryption.clone()), ) .await? - .with_encryption(Some(webhook_encryption.clone())) .with_sql_sizes(sql_sizes.clone()); let encryption_service = encryption_service.with_sqlite_writer_lock(tinycloud.sqlite_writer_lock()); diff --git a/tinycloud-node-server/src/share_v2.rs b/tinycloud-node-server/src/share_v2.rs index f3673fd6..c301958c 100644 --- a/tinycloud-node-server/src/share_v2.rs +++ b/tinycloud-node-server/src/share_v2.rs @@ -4532,26 +4532,30 @@ mod tests { let runtime = compose_test_runtime(&key_setup, Some(tee_context.clone()), true).await; let conn = runtime.conn.clone(); - actor::ActiveModel { - id: Set("did:key:tc349-restart".to_owned()), - } - .insert(&conn) - .await - .expect("actor row"); - let delegation_id = hash(b"tc349-restart-delegation"); - delegation::ActiveModel { - id: Set(delegation_id), - delegator: Set("did:key:tc349-restart".to_owned()), - delegatee: Set("did:key:tc349-restart".to_owned()), - expiry: Set(None), - issued_at: Set(None), - not_before: Set(None), - facts: Set(None), - serialization: Set(delegation_id.as_ref().to_vec()), - } - .insert(&conn) - .await - .expect("delegation row"); + use tinycloud_auth::{ + resolver::DID_METHODS, + ssi::jwk::{Algorithm, JWK}, + }; + let jwk = JWK::generate_ed25519().expect("local test signer"); + let did = DID_METHODS.generate(&jwk, "key").unwrap().to_string(); + let issuer = format!("{did}#{}", did.trim_start_matches("did:key:")); + let signed = crate::did_resolution_test_support::signed_ucan_with_proof( + &issuer, + &jwk, + Algorithm::EdDSA, + false, + ); + let delegation = + tinycloud_core::events::Delegation::from_header_ser::( + &signed.encode().unwrap(), + ) + .unwrap(); + runtime + .tinycloud + .delegate(delegation) + .await + .map_err(|error| error.to_string()) + .expect("valid signed delegation persisted before restart"); // Re-compose over the same, now-populated connection: the restart shape. let directory = tempfile::tempdir().expect("tempdir for local block store"); diff --git a/tinycloud-node-server/src/webhook_dispatcher.rs b/tinycloud-node-server/src/webhook_dispatcher.rs index fb70859b..02379bff 100644 --- a/tinycloud-node-server/src/webhook_dispatcher.rs +++ b/tinycloud-node-server/src/webhook_dispatcher.rs @@ -313,13 +313,13 @@ mod tests { let storage = NodeFileSystemConfig::new(tempdir.path()).open().await?; let _persisted = tempdir.keep(); let encryption = ColumnEncryption::new([9u8; 32]); - let tinycloud = TinyCloud::new( + let tinycloud = TinyCloud::new_with_encryption( db.clone().into(), Either::B(storage), StaticSecret::new(vec![0u8; 32]).unwrap(), + Some(encryption.clone()), ) - .await? - .with_encryption(Some(encryption.clone())); + .await?; Ok((tinycloud, db, encryption)) } @@ -540,13 +540,13 @@ mod tests { let tempdir = TempDir::new()?; let storage = NodeFileSystemConfig::new(tempdir.path()).open().await?; let _persisted = tempdir.keep(); - let tinycloud = TinyCloud::new( + let tinycloud = TinyCloud::new_with_encryption( db.clone().into(), Either::B(storage), StaticSecret::new(vec![0u8; 32]).unwrap(), + Some(encryption.clone()), ) - .await? - .with_encryption(Some(encryption.clone())); + .await?; let dispatcher = WebhookDispatcher::new(tinycloud, HooksConfig::default(), encryption)?; dispatcher.dispatch_due_once().await?; @@ -762,14 +762,14 @@ mod tests { .unwrap(); let _persisted = tempdir.keep(); let encryption = ColumnEncryption::new([9u8; 32]); - let tinycloud = TinyCloud::new( + let tinycloud = TinyCloud::new_with_encryption( conn, Either::B(storage), StaticSecret::new(vec![0u8; 32]).unwrap(), + Some(encryption.clone()), ) .await - .unwrap() - .with_encryption(Some(encryption.clone())); + .unwrap(); let mut setup_options = ConnectOptions::new(url.to_owned()); setup_options From 5c1c2487056080624505d6318cfd46db879cdab7 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Wed, 14 Oct 2026 20:56:14 +0000 Subject: [PATCH 06/20] fix(sdk): validate signature arrays without temporary allocations --- dependencies/cacao/src/siwe_cacao.rs | 6 +++- .../tests/sdk_session_cid.rs | 32 +++++++------------ tinycloud-sdk-rs/src/serde_siwe.rs | 4 +-- tinycloud-sdk-wasm/src/session.rs | 2 +- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/dependencies/cacao/src/siwe_cacao.rs b/dependencies/cacao/src/siwe_cacao.rs index e5a4abf7..03ccf645 100755 --- a/dependencies/cacao/src/siwe_cacao.rs +++ b/dependencies/cacao/src/siwe_cacao.rs @@ -222,7 +222,11 @@ impl AsRef<[u8]> for Signature { } impl Signature { - fn decode(mut bytes: [u8; 65]) -> Result { + /// Validate a fixed-size EIP-191 signature and canonicalize its recovery ID. + /// + /// Low-s signatures accept `0/1` and `27/28`; invalid scalars, high-s values + /// and other recovery IDs are rejected without allocating a byte buffer. + pub fn decode(mut bytes: [u8; 65]) -> Result { let signature = k256::ecdsa::Signature::from_slice(&bytes[..64]) .map_err(|_| SIWESignatureDecodeError::InvalidSignature)?; if signature.normalize_s().is_some() { diff --git a/tinycloud-node-server/tests/sdk_session_cid.rs b/tinycloud-node-server/tests/sdk_session_cid.rs index 000a1a13..66c0fc8c 100644 --- a/tinycloud-node-server/tests/sdk_session_cid.rs +++ b/tinycloud-node-server/tests/sdk_session_cid.rs @@ -255,36 +255,28 @@ fn wallet_import_and_rust_setup_reject_noncanonical_signatures() -> Result<()> { cases.push(("short".into(), bytes[..64].to_vec())); cases.push(("long".into(), [bytes.as_slice(), &[0]].concat())); for (label, bytes) in cases { - let node_error = Signature::try_from(bytes.clone()) - .expect_err("node decoder rejects fixture") - .to_string(); for prefix in ["", "0x"] { let signed = json_signed(&prepared, &format!("{prefix}{}", hex::encode(&bytes)))?; - let error = serde_json::from_value::(signed) - .err() - .with_context(|| format!("SDK accepted {label}"))?; ensure!( - error.to_string().contains(&node_error), - "{label}: SDK={error}; node={node_error}" + serde_json::from_value::(signed).is_err(), + "SDK accepted {label}" ); let host = json!({"siwe": prepared.siwe.to_string(), "signature": format!("{prefix}{}", hex::encode(&bytes))}); - let error = serde_json::from_value::(host) - .err() - .with_context(|| format!("host SDK accepted {label}"))?; ensure!( - error.to_string().contains(&node_error), - "host {label}: SDK={error}; node={node_error}" + serde_json::from_value::(host).is_err(), + "host SDK accepted {label}" ); } if let Ok(bytes) = <[u8; 65]>::try_from(bytes) { - let error = complete_session_setup(SignedSession { - session: prepared.clone(), - signature: Signature::from(bytes), - }) - .expect_err("Rust setup must validate before hashing"); ensure!( - error.to_string().contains(&node_error), - "Rust {label}: SDK={error}; node={node_error}" + matches!( + complete_session_setup(SignedSession { + session: prepared.clone(), + signature: Signature::from(bytes), + }), + Err(tinycloud_sdk_wasm::session::Error::InvalidSIWESignature(_)) + ), + "Rust setup accepted {label}" ); } } diff --git a/tinycloud-sdk-rs/src/serde_siwe.rs b/tinycloud-sdk-rs/src/serde_siwe.rs index 41e7dba5..5bfb79d9 100644 --- a/tinycloud-sdk-rs/src/serde_siwe.rs +++ b/tinycloud-sdk-rs/src/serde_siwe.rs @@ -49,8 +49,8 @@ pub mod signature { D: Deserializer<'de>, { let sig = Cow::<'_, str>::deserialize(d)?; - let bytes = >::from_hex(sig.strip_prefix("0x").unwrap_or(&sig)) + let bytes = <[u8; 65]>::from_hex(sig.strip_prefix("0x").unwrap_or(&sig)) .map_err(|e| D::Error::custom(format!("failed to parse SIWE signature: {e}")))?; - Signature::try_from(bytes).map_err(D::Error::custom) + Signature::decode(bytes).map_err(D::Error::custom) } } diff --git a/tinycloud-sdk-wasm/src/session.rs b/tinycloud-sdk-wasm/src/session.rs index 9b2811f2..3437b188 100644 --- a/tinycloud-sdk-wasm/src/session.rs +++ b/tinycloud-sdk-wasm/src/session.rs @@ -708,7 +708,7 @@ pub fn prepare_session(config: SessionConfig) -> Result /// The node still verifies the signature against the SIWE message and checks /// the delegation's authority; setup only validates the signature encoding. pub fn complete_session_setup(signed_session: SignedSession) -> Result { - let signature = Signature::try_from(signed_session.signature.as_ref().to_vec())?; + let signature = Signature::decode(*signed_session.signature)?; let delegation = SiweCacao::new(signed_session.session.siwe.into(), signature, SiweHeader); let serialised = serde_ipld_dagcbor::to_vec(&delegation)?; let hash = Code::Blake3_256.digest(&serialised); From 3af478809e97ae542aab06b10eafd94434b767cf Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Wed, 14 Oct 2026 20:56:28 +0000 Subject: [PATCH 07/20] fix(core): serialize historical CID bootstrap across database boots --- CHANGELOG.md | 3 + docs/delegation-flow.md | 30 +++ .../m20261014_000000_delegation_cid_alias.rs | 24 ++- .../src/models/delegation_cid_alias.rs | 203 ++++++++++++++++-- 4 files changed, 229 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c970a561..d27d60a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ - Synchronize the standalone M1, W5 and load-signer Cargo locks with CACAO's `k256` dependency so `--locked` dependency resolution succeeds (TC-1071). - Historical CACAO revocations now apply to every stored encoding of the same delegation, in both directions (TC-1071). A persisted CID-alias index derives identities from current ingestion's canonical bytes, including recovery-ID, issuer-address/chain, and accepted CBOR representation normalization. Startup backfills aliases transactionally after loading the column encryption key and fails closed on undecodable or mismatched history or missing/wrong keys. Historical IDs, signed bytes, revocation targets, and signed child proof references remain unchanged; legacy list/chain responses retain their exact raw-header/CID relationship. This closes reuse of an already owner-signed grant under another accepted encoding; it does not involve forging new rights. +- CACAO issuer address casing and numeric chain spelling now serialize to the verifier's same EIP-55/u64 identity without changing the signed SIWE message. SDK session setup checks and normalizes wallet signatures before deriving its proof CID, including direct Rust sessions; low-v wallet signatures no longer produce `MissingParents` (TC-1071). +- Serialize concurrent PostgreSQL/MySQL historical backfills before their first history read; failed startup still rolls back aliases and preserves signed records. MySQL alias columns retain full variable-length multihashes, and encrypted-key validation avoids its unsupported limited `IN` subquery. Native alias checks do not waive the separately reproduced older MySQL 8.0.46 full-schema error 1074 (TC-1071). +- Rust API cutover: pass encryption through `SpaceDatabase::new_with_encryption`; list/chain outcomes carry serialized delegation wrappers. SDK exhaustive error matches must handle the new `session::Error::InvalidSIWESignature` variant (TC-1071). ## [1.20.6] - 2026-10-14 diff --git a/docs/delegation-flow.md b/docs/delegation-flow.md index b477d801..ddc62f98 100644 --- a/docs/delegation-flow.md +++ b/docs/delegation-flow.md @@ -409,6 +409,18 @@ failures, and plaintext hash mismatches fail startup rather than silently leaving an active alias. Completed backfills do not re-decode indexed history; one indexed encrypted record is checked on each boot to validate the key. +Concurrent PostgreSQL and MySQL boot transactions lock this migration's history +row before their first history read. This also prevents a waiting MySQL boot +from establishing a stale repeatable-read snapshot. SQLite keeps its existing +`BEGIN IMMEDIATE` writer serialization. Failures roll back the boot transaction; +alias conflicts are not ignored. MySQL stores complete, unpadded multihashes in +indexed `VARBINARY(255)` columns, and the one-row encrypted-key check uses a join +with an outer limit rather than MySQL's unsupported limited `IN` subquery. + +Stop old-version writers before upgrading the shared database. Older binaries +do not maintain the alias index; mixed-version writes are not a supported +security cutover. Do not accept requests until the new constructors complete. + Revocation status, invocation authorization, and ancestor checks union owner revocations across equivalent stored CIDs through indexed lookups. Historical delegation IDs, signed bytes, revocation targets, and child proof edges are @@ -428,6 +440,24 @@ parent CID fields retain the signed child references. Existing legacy list ancestor-visibility behavior is unchanged; authorization always checks the complete chain. +Rust callers must supply column encryption at construction through +`SpaceDatabase::new_with_encryption`, not a post-open setter. +`InvocationOutcome::OpenSessions` and `DelegationChain` retain serialized +delegation wrappers so callers can preserve the original headers rather than +re-encode them. SDK session setup validates and normalizes wallet signatures +before deriving its delegation CID, including direct Rust `SignedSession` +values that bypass JSON deserialization. Recovery IDs 0/1 and 27/28 therefore +produce the node's same proof CID; invalid encodings and high-s signatures are +rejected. Rust exhaustive matches must account for +`session::Error::InvalidSIWESignature`. + +MySQL coverage remains bounded: native MySQL 8.0.46 alias-migration, backfill, +concurrent-boot, and key-validation checks do not establish full-constructor +support. On the same `utf8mb4`/default-strict configuration, unmodified +`f9d700bf` fails the older initial schema with error 1074 on `id`, before the +alias migration exists. That separate full-schema prerequisite must be resolved +and exercised before claiming end-to-end support for that configuration. + ## Child Delegation (UCAN) Creation For sharing, child UCANs are created that delegate from the session key to another DID. diff --git a/tinycloud-core/src/migrations/m20261014_000000_delegation_cid_alias.rs b/tinycloud-core/src/migrations/m20261014_000000_delegation_cid_alias.rs index ed6484e9..349e3dd0 100644 --- a/tinycloud-core/src/migrations/m20261014_000000_delegation_cid_alias.rs +++ b/tinycloud-core/src/migrations/m20261014_000000_delegation_cid_alias.rs @@ -8,22 +8,24 @@ pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let mut id = ColumnDef::new(delegation_cid_alias::Column::Id); + let mut canonical = ColumnDef::new(delegation_cid_alias::Column::Canonical); + if manager.get_database_backend() == sea_orm::DbBackend::MySql { + // Encoded multihashes have variable length; MySQL's bare BINARY + // defaults to one byte, and fixed-width BINARY would pad the ID. + id.var_binary(255); + canonical.var_binary(255); + } else { + id.binary(); + canonical.binary(); + } manager .create_table( Table::create() .table(delegation_cid_alias::Entity) .if_not_exists() - .col( - ColumnDef::new(delegation_cid_alias::Column::Id) - .binary() - .not_null() - .primary_key(), - ) - .col( - ColumnDef::new(delegation_cid_alias::Column::Canonical) - .binary() - .not_null(), - ) + .col(id.not_null().primary_key()) + .col(canonical.not_null()) .col( ColumnDef::new(delegation_cid_alias::Column::Encrypted) .boolean() diff --git a/tinycloud-core/src/models/delegation_cid_alias.rs b/tinycloud-core/src/models/delegation_cid_alias.rs index 985d0f6f..9f320a24 100644 --- a/tinycloud-core/src/models/delegation_cid_alias.rs +++ b/tinycloud-core/src/models/delegation_cid_alias.rs @@ -4,13 +4,15 @@ use std::borrow::Cow; use base64::{engine::general_purpose::URL_SAFE, Engine as _}; use sea_orm::{ - entity::prelude::*, sea_query::Query, ActiveValue::Set, ConnectionTrait, QueryOrder, + entity::prelude::*, sea_query::Query, ActiveValue::Set, ConnectionTrait, JoinType, QueryOrder, QuerySelect, }; +use sea_orm_migration::{seaql_migrations, MigrationName}; use tinycloud_auth::authorization::{HeaderEncode, TinyCloudDelegation}; use crate::encryption::{maybe_decrypt, ColumnEncryption}; use crate::hash::{hash, Hash}; +use crate::migrations::m20261014_000000_delegation_cid_alias::Migration; use crate::models::delegation; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] @@ -64,17 +66,12 @@ async fn validate_encryption( db: &C, encryption: Option<&ColumnEncryption>, ) -> Result<(), DbErr> { - let encrypted_id = Query::select() - .column(Column::Id) - .from(Entity) - .and_where(Expr::col(Column::Encrypted).eq(true)) - .limit(1) - .to_owned(); let Some((id, stored)) = delegation::Entity::find() .select_only() .column(delegation::Column::Id) .column(delegation::Column::Serialization) - .filter(delegation::Column::Id.in_subquery(encrypted_id)) + .join_rev(JoinType::InnerJoin, Relation::Delegation.def()) + .filter(Column::Encrypted.eq(true)) .into_tuple::<(Hash, Vec)>() .one(db) .await? @@ -109,6 +106,24 @@ pub(crate) async fn backfill( encryption: Option<&ColumnEncryption>, ) -> Result<(), DbErr> { const PAGE_SIZE: u64 = 128; + if db.get_database_backend() != sea_orm::DbBackend::Sqlite { + // Serialize boot transactions before the first history read. In + // particular, MySQL must not establish a stale repeatable-read snapshot + // before waiting for another boot's aliases. SQLite's caller already + // holds its BEGIN IMMEDIATE writer lock. + let migration = seaql_migrations::Entity::find_by_id(Migration.name()) + .select_only() + .column(seaql_migrations::Column::AppliedAt) + .lock_exclusive() + .into_tuple::() + .one(db) + .await?; + if migration.is_none() { + return Err(DbErr::Custom( + "delegation CID alias backfill: migration record is missing".into(), + )); + } + } let mut after = None; loop { let mut query = delegation::Entity::find() @@ -274,13 +289,9 @@ mod tests { ) .await; - let missing = backfill(&db, None).await.unwrap_err(); - assert!(missing - .to_string() - .contains("requires a column encryption key")); + assert!(backfill(&db, None).await.is_err()); let wrong = ColumnEncryption::new([0x43; 32]); - let rejected = backfill(&db, Some(&wrong)).await.unwrap_err(); - assert!(rejected.to_string().contains("decryption failed")); + assert!(backfill(&db, Some(&wrong)).await.is_err()); assert_eq!(Entity::find().count(&db).await.unwrap(), 0); } @@ -288,10 +299,7 @@ mod tests { async fn mismatched_plaintext_hash_is_not_aliased() { let db = database().await; insert_delegation(&db, hash(b"different content"), ucan_serialization()).await; - let error = backfill(&db, None).await.unwrap_err(); - assert!(error - .to_string() - .contains("plaintext content hash does not match stored ID")); + assert!(backfill(&db, None).await.is_err()); assert_eq!(Entity::find().count(&db).await.unwrap(), 0); } @@ -301,8 +309,7 @@ mod tests { let serialization = vec![0xa0]; insert_delegation(&db, hash(&serialization), serialization).await; let transaction = db.begin().await.unwrap(); - let error = backfill(&transaction, None).await.unwrap_err(); - assert!(error.to_string().contains("delegation decoding failed")); + assert!(backfill(&transaction, None).await.is_err()); transaction.rollback().await.unwrap(); assert_eq!(Entity::find().count(&db).await.unwrap(), 0); } @@ -321,4 +328,160 @@ mod tests { backfill(&db, None).await.unwrap(); assert_eq!(Entity::find().count(&db).await.unwrap(), 129); } + + #[tokio::test] + async fn postgres_concurrent_historical_backfills_produce_ready_databases() { + use std::time::Duration; + + use crate::{ + db::{DelegationStatus, SpaceDatabase}, + keys::StaticSecret, + models::{abilities, revocation, space}, + relationships::parent_delegations, + storage::memory::MemoryStore, + test_support::PostgresTestSchema, + }; + use sea_orm::{IntoActiveModel, Statement}; + use tinycloud_auth::authorization::Cid; + + let Some(fixture) = PostgresTestSchema::new( + "postgres_concurrent_historical_backfills_produce_ready_databases", + ) + .await + else { + return; + }; + let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/tc1071/generated"); + let manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(source.join("manifest.json")).unwrap()).unwrap(); + let historical = manifest + .as_array() + .unwrap() + .iter() + .find(|row| row["database"] == "revoked-v0.sqlite") + .unwrap(); + let old = Database::connect(format!( + "sqlite:{}?mode=ro", + source.join("revoked-v0.sqlite").display() + )) + .await + .unwrap(); + macro_rules! copy_records { + ($entity:path) => { + let records = <$entity>::find().all(&old).await.unwrap(); + if !records.is_empty() { + <$entity>::insert_many(records.into_iter().map(|row| row.into_active_model())) + .exec(&fixture.conn) + .await + .unwrap(); + } + }; + } + copy_records!(actor::Entity); + copy_records!(space::Entity); + copy_records!(delegation::Entity); + copy_records!(abilities::Entity); + copy_records!(parent_delegations::Entity); + copy_records!(revocation::Entity); + old.close().await.unwrap(); + let original = delegation::Entity::find() + .order_by_asc(delegation::Column::Id) + .all(&fixture.conn) + .await + .unwrap(); + + // Force both constructors into overlapping database work. Following + // the blocker graph permits either a serialized backfill or a + // conflict-safe insert, without pinning the implementation's lock. + let blocker = fixture.conn.begin().await.unwrap(); + let blocker_pid: i32 = blocker + .query_one(Statement::from_string( + sea_orm::DbBackend::Postgres, + "SELECT pg_backend_pid() AS pid", + )) + .await + .unwrap() + .unwrap() + .try_get("", "pid") + .unwrap(); + blocker + .execute_unprepared("LOCK TABLE delegation_cid_alias IN EXCLUSIVE MODE") + .await + .unwrap(); + let workers = std::array::from_fn::<_, 2, _>(|_| { + let connection = fixture.conn.clone(); + tokio::spawn(async move { + SpaceDatabase::new( + connection, + MemoryStore::default(), + StaticSecret::new(vec![31; 32]).unwrap(), + ) + .await + }) + }); + let waiting = tokio::time::timeout(Duration::from_secs(30), async { + loop { + let blocked: i64 = fixture + .conn + .query_one(Statement::from_sql_and_values( + sea_orm::DbBackend::Postgres, + "WITH RECURSIVE blocked(pid) AS (SELECT $1::int UNION SELECT activity.pid FROM pg_stat_activity AS activity INNER JOIN blocked ON blocked.pid = ANY(pg_blocking_pids(activity.pid))) SELECT COUNT(*) AS blocked FROM blocked WHERE pid <> $1", + [blocker_pid.into()], + )) + .await? + .ok_or_else(|| DbErr::Custom("missing PostgreSQL wait observation".into()))? + .try_get("", "blocked")?; + if blocked == 2 { + return Ok::<(), DbErr>(()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + blocker.commit().await.unwrap(); + let [first, second] = workers; + let results = [first.await.unwrap(), second.await.unwrap()]; + let roots = historical["rows"].as_array().unwrap(); + let mut statuses = Vec::with_capacity(roots.len() * results.len()); + let mut errors = Vec::with_capacity(results.len()); + for result in results { + match result { + Ok(db) => { + for row in roots { + let cid: Cid = row["raw_cid"].as_str().unwrap().parse().unwrap(); + statuses.push( + db.delegation_status( + Hash::from(cid), + historical["issuer"].as_str().unwrap(), + &[], + ) + .await, + ); + } + } + Err(error) => errors.push(error), + } + } + let current = delegation::Entity::find() + .order_by_asc(delegation::Column::Id) + .all(&fixture.conn) + .await; + fixture.conn.clone().close().await.unwrap(); + waiting + .expect("both constructors must overlap under the database blocker") + .unwrap(); + assert!(errors.is_empty(), "concurrent startup failed: {errors:?}"); + for status in statuses { + assert!( + matches!(status, Ok(Some(DelegationStatus::Revoked))), + "each historical equivalent must remain revoked after concurrent startup", + ); + } + assert_eq!( + current.unwrap(), + original, + "concurrent startup must preserve every historical signed row", + ); + } } From 34caaaaa408c25a0af81496f9b3185a14d9500a5 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Wed, 14 Oct 2026 22:40:53 +0000 Subject: [PATCH 08/20] fix: enforce canonical revocations in policy admission --- CHANGELOG.md | 1 + docs/delegation-flow.md | 10 + tinycloud-core/src/db.rs | 19 + tinycloud-core/src/models/revocation.rs | 9 +- tinycloud-node-server/src/policy_v3.rs | 27 +- .../src/policy_v3/account_alias_tests.rs | 542 ++++++++++++++++++ tinycloud-node-server/src/share_v2.rs | 18 +- .../share_v2/tests/alias_revocation_tests.rs | 196 +++++++ 8 files changed, 793 insertions(+), 29 deletions(-) create mode 100644 tinycloud-node-server/src/policy_v3/account_alias_tests.rs create mode 100644 tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d27d60a8..97f93f0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Historical CACAO revocations now apply to every stored encoding of the same delegation, in both directions (TC-1071). A persisted CID-alias index derives identities from current ingestion's canonical bytes, including recovery-ID, issuer-address/chain, and accepted CBOR representation normalization. Startup backfills aliases transactionally after loading the column encryption key and fails closed on undecodable or mismatched history or missing/wrong keys. Historical IDs, signed bytes, revocation targets, and signed child proof references remain unchanged; legacy list/chain responses retain their exact raw-header/CID relationship. This closes reuse of an already owner-signed grant under another accepted encoding; it does not involve forging new rights. - CACAO issuer address casing and numeric chain spelling now serialize to the verifier's same EIP-55/u64 identity without changing the signed SIWE message. SDK session setup checks and normalizes wallet signatures before deriving its proof CID, including direct Rust sessions; low-v wallet signatures no longer produce `MissingParents` (TC-1071). - Serialize concurrent PostgreSQL/MySQL historical backfills before their first history read; failed startup still rolls back aliases and preserves signed records. MySQL alias columns retain full variable-length multihashes, and encrypted-key validation avoids its unsupported limited `IN` subquery. Native alias checks do not waive the separately reproduced older MySQL 8.0.46 full-schema error 1074 (TC-1071). +- Policy v3 account-owner admission, its transactional recheck, and legacy Share v2 revocation consumers now use the same effective historical-CID revocation lookup. Account-owner proofs retain shared canonical chain guards through commit or rollback, so a same-registry sibling revocation cannot pass an in-flight admission; a separately committed sibling revocation fails the transaction recheck (TC-1071). - Rust API cutover: pass encryption through `SpaceDatabase::new_with_encryption`; list/chain outcomes carry serialized delegation wrappers. SDK exhaustive error matches must handle the new `session::Error::InvalidSIWESignature` variant (TC-1071). ## [1.20.6] - 2026-10-14 diff --git a/docs/delegation-flow.md b/docs/delegation-flow.md index ddc62f98..c7c6288b 100644 --- a/docs/delegation-flow.md +++ b/docs/delegation-flow.md @@ -434,6 +434,16 @@ authorization without an extra request round trip. New delegations and their alias commit together; PostgreSQL uses a dependent insert CTE to preserve the existing registration round-trip budget. +Policy v3 account-owner credential admission, its locked transactional recheck, +and legacy Share v2 ancestry/root checks use the same effective revocation +predicate, not an exact stored-ID comparison. Account-owner authentication takes +shared canonical ancestor guards with `TinyCloud::guard_delegation_chains` +before per-space guards or the SQL transaction and retains them in the proof +through commit or rollback. These guards coordinate the database instance and +its clones through their shared graph-lock registry; they are not distributed +locks. The transaction still rechecks effective revocation, including a sibling +revocation committed by a separately constructed database instance. + Legacy capability list/chain responses advertise the stored CID and return the exact stored raw header, not a re-encoding of the decoded CACAO. Their parent CID fields retain the signed child references. Existing legacy list diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 72565071..0aa7f314 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -1316,6 +1316,25 @@ where Ok(self.acquire_exclusive_chain_guards_for_keys(keys).await) } + /// Hold shared guards for the canonical ancestor identities of `roots`. + /// + /// These coordinate admission with delegation/revocation writers using + /// this database and its clones. Acquire before space-sequence guards or + /// SQL transactions, and retain through commit or rollback. Callers must + /// still recheck effective revocation inside their admission transaction. + pub async fn guard_delegation_chains( + &self, + roots: &[Hash], + ) -> Result, DbErr> { + let keys = crate::auth_graph::load_chain_lock_ids(&self.conn, roots) + .await + .map_err(|error| match error { + revocation::ChainTraversalError::Db(error) => error, + error => DbErr::Custom(error.to_string()), + })?; + Ok(self.acquire_shared_chain_guards_for_keys(keys).await) + } + /// Resolve `keys` to their per-delegation locks in a stable global order. /// /// Sorting and deduplicating here is the deadlock discipline: every diff --git a/tinycloud-core/src/models/revocation.rs b/tinycloud-core/src/models/revocation.rs index 02137040..a37d5034 100644 --- a/tinycloud-core/src/models/revocation.rs +++ b/tinycloud-core/src/models/revocation.rs @@ -164,10 +164,11 @@ pub(crate) async fn effective_rows( .collect() } -pub(crate) async fn is_revoked( - db: &C, - delegation_id: &Hash, -) -> Result { +/// Whether this delegation or any persisted equivalent CID has been revoked. +/// +/// Use the caller's transaction for an admission-time recheck; signed targets +/// and stored delegation IDs are never rewritten. +pub async fn is_revoked(db: &C, delegation_id: &Hash) -> Result { Ok(Entity::find() .filter(target_condition(Expr::value(*delegation_id))) .one(db) diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index d307a625..24313081 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -2912,11 +2912,8 @@ async fn is_root_generically_revoked( ) -> Result { let cid = tinycloud_auth::ipld_core::cid::Cid::try_from(root_cid) .map_err(|_| "policy-root-invalid")?; - revocation::Entity::find() - .filter(revocation::Column::Revoked.eq(tinycloud_core::hash::Hash::from(cid))) - .one(db) + revocation::is_revoked(db, &tinycloud_core::hash::Hash::from(cid)) .await - .map(|record| record.is_some()) .map_err(|_| "root-revocation-unavailable") } @@ -5345,6 +5342,9 @@ struct AccountOwnerProof { owner_did: String, space: SpaceId, expires_at: Option, + // Canonical identities, not the chosen legacy row, remain guarded until + // admission commits or rolls back. Revoke uses these same guards exclusive. + _chain_guards: Vec>, } /// Authenticate the policy/v2 credential owner from an already-admitted @@ -5457,12 +5457,13 @@ async fn authenticate_account_owner( "account-authorization-capability-mismatch".into(), )); } - if revocation::Entity::find() - .filter(revocation::Column::Revoked.eq(row.id)) - .one(&runtime.conn) + let chain_guards = tinycloud + .guard_delegation_chains(&[row.id]) + .await + .map_err(db_error)?; + if revocation::is_revoked(&runtime.conn, &row.id) .await .map_err(db_error)? - .is_some() || space::Entity::find_by_id(SpaceIdWrap(credentials_space.clone())) .one(&runtime.conn) .await @@ -5477,6 +5478,7 @@ async fn authenticate_account_owner( owner_did, space: credentials_space, expires_at: event.0.expiry, + _chain_guards: chain_guards, }) } @@ -5495,12 +5497,9 @@ async fn validate_locked_account_owner( || proof .expires_at .is_some_and(|expires_at| expires_at <= OffsetDateTime::now_utc()) - || revocation::Entity::find() - .filter(revocation::Column::Revoked.eq(locked.id)) - .one(transaction) + || revocation::is_revoked(transaction, &locked.id) .await .map_err(db_error)? - .is_some() || space::Entity::find_by_id(SpaceIdWrap(proof.space.clone())) .one(transaction) .await @@ -10181,3 +10180,7 @@ mod tests { .is_err()); } } + +#[cfg(test)] +#[path = "policy_v3/account_alias_tests.rs"] +mod account_alias_tests; diff --git a/tinycloud-node-server/src/policy_v3/account_alias_tests.rs b/tinycloud-node-server/src/policy_v3/account_alias_tests.rs new file mode 100644 index 00000000..6a26666a --- /dev/null +++ b/tinycloud-node-server/src/policy_v3/account_alias_tests.rs @@ -0,0 +1,542 @@ +//! Actual account-owner admission boundaries over signed historical CACAO siblings. +//! +//! The generic historical-wire decoder preserves valid pre-normalization bytes. +//! This constructs disposable fixtures; it is not an f9-generated credentials +//! database, the current HTTP ingestion path, or an end-to-end mint HTTP test. + +use super::*; +use k256::ecdsa::SigningKey; +use sha3::Keccak256; +use std::time::Duration as WaitDuration; +use tinycloud_auth::{ + authorization::{EncodingError, HeaderEncode, TinyCloudRevocation}, + cacaos::{ + siwe::{encode_eip55, Message, Version}, + siwe_cacao::{Eip191, Payload, Signature}, + }, + resolver::DID_METHODS, + siwe_recap::Capability, +}; +use tinycloud_core::{ + db::{begin_write_transaction, DelegationStatus}, + events::Revocation, + hash::Hash, + sea_orm::Database, + storage::StorageConfig as _, +}; +use tokio::{sync::OwnedRwLockReadGuard, time::timeout}; + +const WAIT: WaitDuration = WaitDuration::from_secs(10); + +/// Preserve the signed wire identity using the public generic event decoder, +/// instead of inserting inconsistent hashes or changing persisted graph rows. +struct HistoricalWire(TinyCloudDelegation); + +impl HeaderEncode for HistoricalWire { + fn encode(&self) -> Result { + self.0.encode() + } + + fn decode(header: &str) -> Result<(Self, Vec), EncodingError> { + let bytes = + decode_config(header, base64::URL_SAFE).expect("valid historical fixture base64"); + Ok((Self(TinyCloudDelegation::from_bytes(&bytes)?), bytes)) + } +} + +impl TryFrom for DelegationInfo { + type Error = >::Error; + + fn try_from(value: HistoricalWire) -> Result { + Self::try_from(value.0) + } +} + +struct SignedGrant { + id: Hash, + bytes: Vec, +} + +impl SignedGrant { + fn from_signature(message: Message, signature: [u8; 65]) -> anyhow::Result { + let cacao = Payload::from(message).sign::(Signature::from(signature)); + let header = TinyCloudDelegation::Cacao(Box::new(cacao)).encode()?; + let bytes = decode_config(header, base64::URL_SAFE)?; + Ok(Self { + id: hash(&bytes), + bytes, + }) + } + + fn event(&self) -> anyhow::Result { + Ok(Delegation::from_header_ser::( + &encode_config(&self.bytes, base64::URL_SAFE), + )?) + } +} + +struct AccountAliasFixture { + node: crate::TinyCloud, + runtime: PolicyV3Runtime, + key: SigningKey, + address: [u8; 20], + holder: String, + owner: String, + credentials_space: SpaceId, + // Exactly the same payload/r/s, with raw v=0 and conventional v=27. + aliases: [SignedGrant; 2], + unrelated: SignedGrant, + database_url: String, + directory: tempfile::TempDir, +} + +fn wallet_message(address: [u8; 20], audience: &str, nonce: &str) -> anyhow::Result { + let now = OffsetDateTime::now_utc(); + Ok(Message { + scheme: Some("https".parse()?), + domain: "account-alias.tc1071.test".parse()?, + address, + statement: None, + uri: audience.parse()?, + version: Version::V1, + chain_id: 1, + nonce: nonce.into(), + issued_at: (now - Duration::minutes(1)).into(), + expiration_time: Some((now + Duration::hours(1)).into()), + not_before: None, + request_id: None, + resources: vec![], + }) +} + +fn account_message( + address: [u8; 20], + holder: &str, + space: &SpaceId, + nonce: &str, +) -> anyhow::Result { + let mut capability = Capability::<()>::default(); + capability.with_actions_convert(format!("{space}/space"), [("tinycloud.space/host", [])])?; + Ok(capability.build_message(wallet_message(address, holder, nonce)?)?) +} + +fn sign_conventional(key: &SigningKey, message: Message) -> anyhow::Result { + let (signature, recovery) = key.sign_prehash_recoverable(&message.eip191_hash()?)?; + let mut bytes = [0_u8; 65]; + bytes[..64].copy_from_slice(signature.to_bytes().as_ref()); + bytes[64] = recovery.to_byte() + 27; + SignedGrant::from_signature(message, bytes) +} + +fn authentication_error(error: (Status, String)) -> anyhow::Error { + anyhow::anyhow!("{}: {}", error.0, error.1) +} + +impl AccountAliasFixture { + async fn new() -> anyhow::Result { + let directory = tempfile::tempdir()?; + let database_url = format!( + "sqlite:{}?mode=rwc", + directory.path().join("caps.db").display() + ); + let conn = Database::connect(&database_url).await?; + let storage = crate::BlockConfig::B(crate::storage::file_system::FileSystemConfig::new( + directory.path(), + )) + .open() + .await?; + let secret = StaticSecret::new(vec![31; 32]) + .map_err(|_| anyhow::anyhow!("invalid disposable node secret"))?; + let node = crate::TinyCloud::new(conn.clone().into(), storage, secret.clone()).await?; + let runtime = PolicyV3Runtime::new(conn, secret.node_did(), secret) + .with_sqlite_writer_lock(node.sqlite_writer_lock()); + let key = SigningKey::from_bytes(&[7_u8; 32].into())?; + let point = key.verifying_key().to_encoded_point(false); + let address: [u8; 20] = Keccak256::digest(&point.as_bytes()[1..])[12..].try_into()?; + let holder_key = JWK::generate_ed25519()?; + let holder = DID_METHODS.generate(&holder_key, "key")?.to_string(); + let owner = format!("did:pkh:eip155:1:0x{}", encode_eip55(&address)); + let credentials_space: SpaceId = format!( + "tinycloud:pkh:eip155:1:0x{}:credentials", + encode_eip55(&address) + ) + .parse()?; + + // Select a genuine recoverable signature whose raw recovery byte is + // exactly zero, rather than forcing a recovery byte onto another key. + let mut nonce = 0_u64; + let aliases = loop { + let message = account_message( + address, + &holder, + &credentials_space, + &format!("tc1071account{nonce}"), + )?; + let (signature, recovery) = key.sign_prehash_recoverable(&message.eip191_hash()?)?; + if recovery.to_byte() == 0 { + let mut bytes = [0_u8; 65]; + bytes[..64].copy_from_slice(signature.to_bytes().as_ref()); + let historical = SignedGrant::from_signature(message.clone(), bytes)?; + bytes[64] = 27; + break [historical, SignedGrant::from_signature(message, bytes)?]; + } + nonce = nonce + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("nonce overflow"))?; + }; + assert_ne!(aliases[0].id, aliases[1].id); + let unrelated = sign_conventional( + &key, + account_message(address, &holder, &credentials_space, "tc1071unrelated")?, + )?; + let fixture = Self { + node, + runtime, + key, + address, + holder, + owner, + credentials_space, + aliases, + unrelated, + database_url, + directory, + }; + for grant in fixture.aliases.iter().chain([&fixture.unrelated]) { + fixture + .node + .delegate(grant.event()?) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + fixture.assert_signed_row(grant).await?; + fixture + .assert_status(grant.id, DelegationStatus::Active) + .await?; + } + // The real signed space/host grant, not a manually inserted space row, + // is what establishes the hosted credentials-space authority. + assert!( + space::Entity::find_by_id(SpaceIdWrap(fixture.credentials_space.clone())) + .one(&fixture.runtime.conn) + .await? + .is_some() + ); + Ok(fixture) + } + + fn grant(&self, nonce: &str) -> anyhow::Result { + sign_conventional( + &self.key, + account_message(self.address, &self.holder, &self.credentials_space, nonce)?, + ) + } + + fn revocation(&self, target: Hash) -> anyhow::Result { + let signed = sign_conventional( + &self.key, + wallet_message( + self.address, + &format!("ucan:{}", target.to_cid(0x55)), + &format!("tc1071revoke{}", target.to_cid(0x55)), + )?, + )?; + Ok(Revocation::from_header_ser::( + &encode_config(signed.bytes, base64::URL_SAFE), + )?) + } + + async fn authenticate(&self, id: Hash) -> Result { + authenticate_account_owner( + Some(&id.to_cid(0x55).to_string()), + Some(&self.credentials_space.to_string()), + &self.holder, + &self.runtime, + &self.node, + ) + .await + } + + async fn assert_signed_row(&self, grant: &SignedGrant) -> anyhow::Result<()> { + let (row, event) = self + .node + .load_signed_delegation(grant.id.to_cid(0x55)) + .await + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("missing signed account row"))?; + assert_eq!(row.id, hash(&grant.bytes)); + assert_eq!(row.serialization, grant.bytes); + assert_eq!(event.content_hash(), grant.id); + let TinyCloudDelegation::Cacao(cacao) = event.0.delegation else { + anyhow::bail!("account fixture is not CACAO"); + }; + cacao.verify().await?; + Ok(()) + } + + async fn assert_status(&self, id: Hash, expected: DelegationStatus) -> anyhow::Result<()> { + assert_eq!( + self.node + .delegation_status(id, &self.owner, &[]) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?, + Some(expected), + "core status for {}", + id.to_cid(0x55) + ); + Ok(()) + } + + async fn assert_active_admission(&self, id: Hash) -> anyhow::Result<()> { + self.assert_status(id, DelegationStatus::Active).await?; + let proof = self.authenticate(id).await.map_err(authentication_error)?; + assert_eq!(proof.row.id, id); + assert_eq!(proof.cid, id.to_cid(0x55)); + assert_eq!(proof.owner_did, self.owner); + assert_eq!(proof.space, self.credentials_space); + let transaction = begin_write_transaction(&self.runtime.conn, None).await?; + validate_locked_account_owner(&transaction, &proof) + .await + .map_err(authentication_error)?; + transaction.commit().await?; + drop(proof); + Ok(()) + } + + async fn assert_aliases_denied(&self) -> anyhow::Result<()> { + for grant in &self.aliases { + self.assert_status(grant.id, DelegationStatus::Revoked) + .await?; + let error = self + .authenticate(grant.id) + .await + .err() + .expect("effectively revoked account owner must not authenticate"); + assert_eq!(error.0, Status::Forbidden); + self.assert_signed_row(grant).await?; + } + Ok(()) + } + + async fn independent_node(&self) -> anyhow::Result { + let conn = Database::connect(&self.database_url).await?; + let storage = crate::BlockConfig::B(crate::storage::file_system::FileSystemConfig::new( + self.directory.path(), + )) + .open() + .await?; + // Same disposable DB, but a new SpaceDatabase and lock registry. This + // models a writer outside this node's in-process admission guard. + Ok(crate::TinyCloud::new(conn.into(), storage, self.runtime.signer.clone()).await?) + } +} + +#[tokio::test] +async fn account_owner_rejects_revoked_cacao_aliases_in_both_directions() -> anyhow::Result<()> { + for target_index in 0..2 { + let fixture = timeout(WAIT, AccountAliasFixture::new()).await??; + for grant in fixture.aliases.iter().chain([&fixture.unrelated]) { + fixture.assert_active_admission(grant.id).await?; + } + let target = fixture.aliases[target_index].id; + timeout(WAIT, fixture.node.revoke(fixture.revocation(target)?)) + .await? + .map_err(|error| anyhow::anyhow!("{error}"))?; + fixture.assert_aliases_denied().await?; + // Equivalent grants are revoked, not every valid grant by this wallet. + fixture + .assert_active_admission(fixture.unrelated.id) + .await?; + let rows = revocation::Entity::find() + .all(&fixture.runtime.conn) + .await?; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].revoked, target); + + // Positive revoke control, after the unrelated admission proof drops. + timeout( + WAIT, + fixture + .node + .revoke(fixture.revocation(fixture.unrelated.id)?), + ) + .await? + .map_err(|error| anyhow::anyhow!("{error}"))?; + fixture + .assert_status(fixture.unrelated.id, DelegationStatus::Revoked) + .await?; + assert_eq!( + fixture + .authenticate(fixture.unrelated.id) + .await + .err() + .expect("revoked control") + .0, + Status::Forbidden + ); + } + Ok(()) +} + +#[tokio::test] +async fn locked_account_owner_recheck_rejects_committed_sibling_revocations_in_both_directions( +) -> anyhow::Result<()> { + for target_index in 0..2 { + let fixture = timeout(WAIT, AccountAliasFixture::new()).await??; + let target = fixture.aliases[target_index].id; + let proof = fixture + .authenticate(fixture.aliases[1 - target_index].id) + .await + .map_err(authentication_error)?; + let active_transaction = begin_write_transaction(&fixture.runtime.conn, None).await?; + validate_locked_account_owner(&active_transaction, &proof) + .await + .map_err(authentication_error)?; + active_transaction.commit().await?; + + let other_node = timeout(WAIT, fixture.independent_node()).await??; + timeout(WAIT, other_node.revoke(fixture.revocation(target)?)) + .await? + .map_err(|error| anyhow::anyhow!("{error}"))?; + // Confirm a durable core transition while the original proof is still + // alive; a same-registry writer would intentionally wait for it. + for grant in &fixture.aliases { + assert_eq!( + other_node + .delegation_status(grant.id, &fixture.owner, &[]) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?, + Some(DelegationStatus::Revoked) + ); + } + // No changed projection, expired proof, or removed hosted space can + // account for the Conflict: only effective sibling revocation changed. + assert_eq!( + delegation_model::Entity::find_by_id(proof.row.id) + .one(&fixture.runtime.conn) + .await?, + Some(proof.row.clone()) + ); + assert!(proof + .expires_at + .is_some_and(|expiry| expiry > OffsetDateTime::now_utc())); + assert!(space::Entity::find_by_id(SpaceIdWrap(proof.space.clone())) + .one(&fixture.runtime.conn) + .await? + .is_some()); + let transaction = begin_write_transaction(&fixture.runtime.conn, None).await?; + let error = validate_locked_account_owner(&transaction, &proof) + .await + .expect_err("transactional recheck must reject a committed sibling revocation"); + assert_eq!(error.0, Status::Conflict); + transaction.rollback().await?; + drop(proof); + fixture.assert_aliases_denied().await?; + fixture + .assert_active_admission(fixture.unrelated.id) + .await?; + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn account_owner_proofs_hold_canonical_guards_through_commit_in_both_directions( +) -> anyhow::Result<()> { + for target_index in 0..2 { + let fixture = timeout(WAIT, AccountAliasFixture::new()).await??; + let target = fixture.aliases[target_index].id; + let proof = fixture + .authenticate(fixture.aliases[1 - target_index].id) + .await + .map_err(authentication_error)?; + // Both actual return values coexist: equivalent account proofs take + // shared, not exclusive, canonical guards. + let second_proof = timeout(WAIT, fixture.authenticate(target)) + .await? + .map_err(authentication_error)?; + let locks: Vec<_> = proof + ._chain_guards + .iter() + .map(|guard| OwnedRwLockReadGuard::rwlock(guard).clone()) + .collect(); + let revoke_node = fixture.node.clone(); + let revocation = fixture.revocation(target)?; + let mut revoke = tokio::spawn(async move { + revoke_node + .revoke(revocation) + .await + .map_err(|error| anyhow::anyhow!("{error}")) + }); + + // Tokio's write-preferring RwLock refuses a new reader once the real + // revoke writer queues. Observe that state on the authentic proof's + // lock: unlike a started-task signal or sleep, this proves contention. + // No admission SQL transaction or writer/sequence guard exists yet. + timeout(WAIT, async { + loop { + if locks.iter().any(|lock| lock.try_read().is_err()) { + break; + } + tokio::select! { + result = &mut revoke => panic!("revocation escaped a live account proof: {result:?}"), + _ = tokio::task::yield_now() => {} + } + } + }) + .await?; + for grant in &fixture.aliases { + assert!(!revocation::is_revoked(&fixture.runtime.conn, &grant.id).await?); + } + + // A real unrelated signed graph write commits while that writer is + // queued. SQLite or a global writer gate cannot explain its wait. + let write_control = fixture.grant("tc1071sqlwritecontrol")?; + timeout(WAIT, fixture.node.delegate(write_control.event()?)) + .await? + .map_err(|error| anyhow::anyhow!("{error}"))?; + fixture + .assert_status(write_control.id, DelegationStatus::Active) + .await?; + + let admitted = fixture.grant("tc1071guardedadmission")?; + let sequence_guards = fixture + .node + .acquire_space_sequence_guards(std::slice::from_ref(&fixture.credentials_space)) + .await; + let writer = match &fixture.runtime.sqlite_writer_lock { + Some(lock) => Some(lock.lock().await), + None => None, + }; + let transaction = begin_write_transaction(&fixture.runtime.conn, None).await?; + validate_locked_account_owner(&transaction, &proof) + .await + .map_err(authentication_error)?; + validate_locked_account_owner(&transaction, &second_proof) + .await + .map_err(authentication_error)?; + fixture + .node + .delegate_batch_in_transaction(&transaction, vec![admitted.event()?]) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + transaction.commit().await?; + drop(writer); + drop(sequence_guards); + fixture.assert_signed_row(&admitted).await?; + assert!(!revoke.is_finished()); + assert!(!revocation::is_revoked(&fixture.runtime.conn, &target).await?); + + // Commit alone cannot release the proof's lifetime protection, nor + // can dropping just one of the two concurrently held shared proofs. + drop(proof); + assert!(locks.iter().any(|lock| lock.try_read().is_err())); + assert!(!revocation::is_revoked(&fixture.runtime.conn, &target).await?); + drop(second_proof); + timeout(WAIT, revoke).await???; + fixture.assert_aliases_denied().await?; + fixture + .assert_active_admission(fixture.unrelated.id) + .await?; + fixture.assert_active_admission(admitted.id).await?; + } + Ok(()) +} diff --git a/tinycloud-node-server/src/share_v2.rs b/tinycloud-node-server/src/share_v2.rs index c301958c..710e2fd3 100644 --- a/tinycloud-node-server/src/share_v2.rs +++ b/tinycloud-node-server/src/share_v2.rs @@ -1402,13 +1402,7 @@ async fn revocation_in_ancestry( if visited.len() > MAX_GRAPH_NODES { return Err(()); } - if revocation::Entity::find() - .filter(revocation::Column::Revoked.eq(current)) - .count(db) - .await - .map_err(|_| ())? - > 0 - { + if revocation::is_revoked(db, ¤t).await.map_err(|_| ())? { return Ok(true); } let parents = parent_delegations::Entity::find() @@ -2023,12 +2017,7 @@ async fn cid_revoked(db: &C, cid: &str) -> Result .parse::() .map_err(|_| ())? .into(); - Ok(revocation::Entity::find() - .filter(revocation::Column::Revoked.eq(id)) - .count(db) - .await - .map_err(|_| ())? - > 0) + revocation::is_revoked(db, &id).await.map_err(|_| ()) } fn typed_scope( @@ -3420,6 +3409,9 @@ mod tests { use rocket::local::asynchronous::Client; use time::Duration; + #[path = "alias_revocation_tests.rs"] + mod alias_revocation_tests; + #[test] fn policy_domain_matches_the_sdk_wire_contract() { assert_eq!(POLICY_DOMAIN, "xyz.tinycloud.share/policy/v2\0"); diff --git a/tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs b/tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs new file mode 100644 index 00000000..66703c46 --- /dev/null +++ b/tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs @@ -0,0 +1,196 @@ +use super::*; +use k256::ecdsa::SigningKey; +use sha3::Keccak256; +use std::str::FromStr; +use tinycloud_auth::{ + authorization::{EncodingError, HeaderEncode, TinyCloudRevocation}, + cacaos::{ + siwe::{encode_eip55, Message}, + siwe_cacao::{Eip191, Payload, Signature}, + }, + siwe_recap::Capability, +}; +use tinycloud_core::{ + events::{Delegation, Revocation}, + util::DelegationInfo, +}; + +// Public historical-wire ingestion preserves the signed 0/1 recovery byte. +// This is not evidence of a retained credentials database or a full HTTP flow. +struct HistoricalWire(TinyCloudDelegation); + +impl HeaderEncode for HistoricalWire { + fn encode(&self) -> Result { + self.0.encode() + } + + fn decode(value: &str) -> Result<(Self, Vec), EncodingError> { + let bytes = decode_config(value, base64::URL_SAFE).expect("fixture header encoding"); + Ok((Self(TinyCloudDelegation::from_bytes(&bytes)?), bytes)) + } +} + +impl TryFrom for DelegationInfo { + type Error = >::Error; + + fn try_from(value: HistoricalWire) -> Result { + Self::try_from(value.0) + } +} + +fn message(address: &[u8; 20], audience: &str, nonce: &str, expiry: OffsetDateTime) -> Message { + Message::from_str(&format!( + "localhost wants you to sign in with your Ethereum account:\n0x{}\n\n\nURI: {}\nVersion: 1\nChain ID: 1\nNonce: {}\nIssued At: {}\nExpiration Time: {}", + encode_eip55(address), audience, nonce, + timestamp(OffsetDateTime::now_utc() - Duration::minutes(1)), timestamp(expiry) + )).expect("valid SIWE fixture") +} + +fn signed(key: &SigningKey, message: Message, historical: bool) -> Vec { + let (signature, recovery) = key + .sign_prehash_recoverable(&message.eip191_hash().unwrap()) + .unwrap(); + let mut bytes = [0u8; 65]; + bytes[..64].copy_from_slice(&signature.to_bytes()); + bytes[64] = recovery.to_byte() + if historical { 0 } else { 27 }; + let cacao = Payload::from(message).sign::(Signature::from(bytes)); + decode_config( + TinyCloudDelegation::Cacao(Box::new(cacao)) + .encode() + .unwrap(), + base64::URL_SAFE, + ) + .unwrap() +} + +async fn assert_consumers( + runtime: &ShareV2Runtime, + policy: &PolicyDocument, + bytes: &[u8], + revoked: bool, +) { + let cid = raw_blake3_cid(bytes); + let input = OwnerDelegationInput { + cid: cid.clone(), + dag_cbor: encode_config(bytes, URL_SAFE_NO_PAD), + }; + assert_eq!( + verify_owner_delegation(runtime, policy, &input) + .await + .is_err(), + revoked, + "actual owner verifier must agree with effective alias revocation" + ); + assert_eq!(cid_revoked(&runtime.conn, &cid).await.unwrap(), revoked); + assert_eq!( + revocation_in_ancestry(&runtime.conn, hash(bytes)) + .await + .unwrap(), + revoked + ); +} + +async fn signed_alias_consumers(reverse: bool) { + let secret = StaticSecret::new(vec![0x42; 32]).unwrap(); + let runtime = + compose_test_runtime(&secret, Some(TeeContext::derive_local(&secret)), true).await; + let key = SigningKey::from_bytes(&[7u8; 32].into()).unwrap(); + let point = key.verifying_key().to_encoded_point(false); + let address: [u8; 20] = Keccak256::digest(&point.as_bytes()[1..])[12..] + .try_into() + .unwrap(); + let (_, share_did) = tamper_keypair_and_did(); + let owner = format!("did:pkh:eip155:1:0x{}", encode_eip55(&address)); + let space = format!( + "tinycloud:pkh:eip155:1:0x{}:default", + encode_eip55(&address) + ); + let expiry = OffsetDateTime::now_utc() + Duration::hours(1); + let mut caps = Capability::<()>::default(); + caps.with_actions_convert( + format!("{space}/kv/shares/alias/document.md"), + [("tinycloud.kv/get", [])], + ) + .unwrap(); + let grant = caps + .build_message(message(&address, &share_did, "sharealias1071", expiry)) + .unwrap(); + let historical = signed(&key, grant.clone(), true); + let canonical = signed(&key, grant, false); + let unrelated = signed( + &key, + caps.build_message(message(&address, &share_did, "unrelated1071", expiry)) + .unwrap(), + false, + ); + assert_ne!(hash(&historical), hash(&canonical)); + for bytes in [&historical, &canonical, &unrelated] { + let parsed = TinyCloudDelegation::from_bytes(bytes).unwrap(); + verify_delegation_signature(&parsed).await.unwrap(); + let event = + Delegation::from_header_ser::(&encode_config(bytes, base64::URL_SAFE)) + .unwrap(); + runtime + .tinycloud + .delegate(event) + .await + .map_err(|error| error.to_string()) + .unwrap(); + let row = delegation::Entity::find_by_id(hash(bytes)) + .one(&runtime.conn) + .await + .unwrap() + .unwrap(); + assert_eq!( + maybe_decrypt(Some(&runtime.policy_encryption), &row.serialization).unwrap(), + *bytes, + "stored content and its hash must retain the actual signed wire bytes" + ); + } + let (mut envelope, _, _) = sample_policy(); + envelope.policy.owner_did = owner; + envelope.policy.share_key_did = share_did; + envelope.policy.target.space_id = space; + envelope.policy.resource.path = "shares/alias/document.md".into(); + envelope.policy.actions = vec!["tinycloud.kv/get".into()]; + envelope.policy.expires_at = timestamp(expiry - Duration::minutes(1)); + for bytes in [&historical, &canonical, &unrelated] { + assert_consumers(&runtime, &envelope.policy, bytes, false).await; + } + let target = if reverse { &canonical } else { &historical }; + let revocation_bytes = signed( + &key, + message( + &address, + &format!("ucan:{}", raw_blake3_cid(target)), + "revokeshare1071", + expiry, + ), + false, + ); + let event = Revocation::from_header_ser::(&encode_config( + revocation_bytes, + base64::URL_SAFE, + )) + .unwrap(); + runtime + .tinycloud + .revoke(event) + .await + .map_err(|error| error.to_string()) + .unwrap(); + for bytes in [&historical, &canonical] { + assert_consumers(&runtime, &envelope.policy, bytes, true).await; + } + assert_consumers(&runtime, &envelope.policy, &unrelated, false).await; +} + +#[tokio::test] +async fn signed_legacy_revocation_denies_canonical_share_owner_consumers() { + signed_alias_consumers(false).await; +} + +#[tokio::test] +async fn signed_canonical_revocation_denies_legacy_share_owner_consumers() { + signed_alias_consumers(true).await; +} From bed0cf9ca53d748846d6d411fbfa280516b36b7f Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Wed, 14 Oct 2026 23:47:07 +0000 Subject: [PATCH 09/20] test: register valid signed grants before W5 restart --- .../tests/sql_isolation.rs | 78 ++++++++++++------- 1 file changed, 49 insertions(+), 29 deletions(-) diff --git a/test/w5-policy-runtime-node-e2e/tests/sql_isolation.rs b/test/w5-policy-runtime-node-e2e/tests/sql_isolation.rs index 46e53ad1..336ee63d 100644 --- a/test/w5-policy-runtime-node-e2e/tests/sql_isolation.rs +++ b/test/w5-policy-runtime-node-e2e/tests/sql_isolation.rs @@ -178,6 +178,7 @@ async fn legacy_web_artifacts_load_via_invoke_after_alias_cutover_and_restart() let duck_resource = db_resource(&space_id, "duckdb", "appA/")?; let connectors_a = db_resource(&space_id, "sql", "xyz.tinycloud.tinychat/connectors")?; let connectors_b = db_resource(&space_id, "sql", "another.app/connectors")?; + let mut grant_cids = Vec::with_capacity(5); for (index, resource) in [ &sql_resource, &canvas_resource, @@ -193,15 +194,41 @@ async fn legacy_web_artifacts_load_via_invoke_after_alias_cutover_and_restart() } else { "tinycloud.duckdb/read" }; - persist_grant( - &conn, - tinycloud_core::hash::hash(format!("n3-grant-{index}").as_bytes()), - &owner_did, - &holder_did, - resource, - ability, - ) - .await?; + // Restart validates stored signed bytes and their content IDs. + // Register real grants rather than metadata-only synthetic rows. + let mut caps = Capabilities::new(); + caps.with_action( + resource.as_uri(), + ability.parse::()?, + [BTreeMap::::new()], + ); + let grant = Payload { + issuer: owner_vm.parse::()?, + audience: holder_did.parse::()?, + not_before: None, + expiration: NumericDate::try_from_seconds(FAR_FUTURE_SECONDS)?, + nonce: Some(format!("tc780-restart-grant-{index}")), + facts: Some(Vec::::new()), + proof: vec![], + attenuation: caps, + } + .sign(owner_jwk.get_algorithm().unwrap_or_default(), &owner_jwk)? + .encode()?; + let response = client + .post("/delegate") + .header(Header::new("Authorization", grant)) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Ok, "register signed grant: {body}"); + let registered: serde_json::Value = serde_json::from_str(&body)?; + grant_cids.push( + registered["cid"] + .as_str() + .context("registered delegation CID")? + .parse::()?, + ); } let items = database_migration::inventory(&conn, &datadir).await?; @@ -266,24 +293,18 @@ async fn legacy_web_artifacts_load_via_invoke_after_alias_cutover_and_restart() "13", ), ] { - let grant = tinycloud_core::hash::hash( - format!( - "n3-grant-{}", - if suffix == "11" { - 0 - } else if suffix == "12" { - 1 - } else { - 2 - } - ) - .as_bytes(), - ); + let grant = grant_cids[if suffix == "11" { + 0 + } else if suffix == "12" { + 1 + } else { + 2 + }]; let header = invocation( &holder_jwk, &holder_did, &holder_vm, - vec![grant.to_cid(0x55)], + vec![grant], resource, ability, &format!("urn:uuid:00000000-0000-4000-8000-0000000000{suffix}"), @@ -426,7 +447,7 @@ async fn legacy_web_artifacts_load_via_invoke_after_alias_cutover_and_restart() &holder_jwk, &holder_did, &holder_vm, - vec![tinycloud_core::hash::hash(b"n3-grant-3").to_cid(0x55)], + vec![grant_cids[3]], &connectors_a, "tinycloud.sql/read", "urn:uuid:00000000-0000-4000-8000-000000000019", @@ -443,12 +464,11 @@ async fn legacy_web_artifacts_load_via_invoke_after_alias_cutover_and_restart() ) .await?; database_migration::set_fence(&conn, false).await?; - assert_eq!( + assert!( database_migration::resolve(&conn, "sql", &space_id.to_string(), Some("connectors")) .await - .unwrap_err() - .to_string(), - "quarantined legacy artifact is unreachable without an explicit alias" + .is_err(), + "clearing the alias must not expose the quarantined legacy artifact" ); drop(client); let restarted = restart_node(&tempdir, false).await?; @@ -456,7 +476,7 @@ async fn legacy_web_artifacts_load_via_invoke_after_alias_cutover_and_restart() &holder_jwk, &holder_did, &holder_vm, - vec![tinycloud_core::hash::hash(b"n3-grant-0").to_cid(0x55)], + vec![grant_cids[0]], &sql_resource, "tinycloud.sql/read", "urn:uuid:00000000-0000-4000-8000-000000000017", From 8e1b3ff4db4e59792cc458b8641ae06238e1d378 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Thu, 15 Oct 2026 15:48:48 +0000 Subject: [PATCH 10/20] Align short-lived grant fixture timing Locked M1 tests, strict lint and formatting passed. TC-1071. --- test/m1-realdata-e2e/README.md | 7 ++++++- test/m1-realdata-e2e/tests/cross_layer_contract.rs | 12 +++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/test/m1-realdata-e2e/README.md b/test/m1-realdata-e2e/README.md index 524cbd78..599c9193 100644 --- a/test/m1-realdata-e2e/README.md +++ b/test/m1-realdata-e2e/README.md @@ -19,6 +19,11 @@ named-statement caveats, and the minimum/maximum TTL bounds. Producer and audit cases remain explicitly assigned to m1-g-07 in `vendor/grant-output/ SKIP_MANIFEST.json`. +The one-second live grant is signed for the next whole Unix second and +submitted once its validity window opens, matching the emitter's integer +`nbf`/`exp` timestamps. Successful real-node use and denial after expiry are +both required; frozen vectors and production clocks are unchanged. + The pinned node has no production route for initial space creation. Following the W5 recipe, this test directly provisions one space row and its storage directory solely as a node-storage precondition, then proves delegation and @@ -34,7 +39,7 @@ correspondence with every frozen case. Run it explicitly with the pinned private git dependency: ```sh -CARGO_NET_GIT_FETCH_WITH_CLI=true cargo test --manifest-path test/m1-realdata-e2e/Cargo.toml +CARGO_NET_GIT_FETCH_WITH_CLI=true cargo test --locked --manifest-path test/m1-realdata-e2e/Cargo.toml ``` ## Live M1 gate runner and independent verifier diff --git a/test/m1-realdata-e2e/tests/cross_layer_contract.rs b/test/m1-realdata-e2e/tests/cross_layer_contract.rs index 0f2758fa..c220dfb7 100644 --- a/test/m1-realdata-e2e/tests/cross_layer_contract.rs +++ b/test/m1-realdata-e2e/tests/cross_layer_contract.rs @@ -1366,7 +1366,14 @@ async fn observe_grant_output_on_real_node( ]; for (case_name, capabilities, ttl_seconds) in interop_cases { let issued_capabilities = capabilities.clone(); - let issued_at = Utc::now(); + // The issuer encodes nbf/exp as whole Unix seconds. Prepare the + // one-second grant before its window, not at an arbitrary fraction. + let issued_at = if ttl_seconds == 1 { + DateTime::from_timestamp(Utc::now().timestamp() + 1, 0) + .context("minimum-TTL whole-second issuance")? + } else { + Utc::now() + }; let mut engine_policy = policy(&space_id, &grant_issuer_did); engine_policy.policy_id = format!("pol_m1_g08_{case_name}"); engine_policy.resource.permissions_ceiling = capabilities.clone(); @@ -1383,6 +1390,9 @@ async fn observe_grant_output_on_real_node( evidence_provenance: Vec::new(), policy: engine_policy, })?; + if let Ok(until_valid) = (issued_at - Utc::now()).to_std() { + tokio::time::sleep(until_valid).await; + } let emitted_response = client .post("/delegate") .header(Header::new("Authorization", emitted.encoded.clone())) From 60e6f935589a99c3daa83ecda666203804a6f55f Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Thu, 15 Oct 2026 14:12:36 +0000 Subject: [PATCH 11/20] test: cover revocation cutoff across policy sessions --- tinycloud-core/src/db.rs | 63 +++++ tinycloud-node-server/src/policy_v3.rs | 305 +++++++++++++++++++++++-- 2 files changed, 345 insertions(+), 23 deletions(-) diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 383d0c53..9e04f89d 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -6697,6 +6697,69 @@ mod test { drop(after); } + /// A second node process has a different lock registry but the same durable + /// authorization graph. Its revoke ACK must not overtake a read admitted + /// on the first connection, and a new admission after ACK must see revoke. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn independent_connections_fence_revocation_ack_and_later_admission() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("revoke-cutoff.db"); + let make_db = |conn| async move { + SpaceDatabase::new( + conn, + MemoryStore::default(), + StaticSecret::new([0u8; 32].to_vec()).unwrap(), + ).await.unwrap() + }; + let admitting = make_db(sqlite_write_pool( + &path, std::time::Duration::from_secs(5), 2 + ).await).await; + let revoking = make_db(sqlite_write_pool( + &path, std::time::Duration::from_secs(5), 2 + ).await).await; + insert_chain_guard_actors(&admitting).await; + let (parent, child) = insert_test_chain(&admitting, "cross-connection-cutoff").await; + let in_flight = admitting.guard_delegation_chains(&[child]).await.unwrap(); + assert!(admitting.delegation_chains_unrevoked(&[child.to_cid(0x55)]).await.unwrap()); + + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let revoke = tokio::spawn(async move { + started_tx.send(()).unwrap(); + let guards = revoking.acquire_chain_guards(&[parent]).await + .ok().expect("exclusive revoke chain guard"); + insert_test_revocation(&revoking, "cross-connection-revoke", parent).await; + drop(guards); + }); + started_rx.await.unwrap(); + settle().await; + assert!( + !revoke.is_finished(), + "revoke ACK on another connection must wait for in-flight authorization" + ); + assert!( + admitting.delegation_chains_unrevoked(&[child.to_cid(0x55)]).await.unwrap(), + "the admitted read must finish before revoke commits" + ); + drop(in_flight); + tokio::time::timeout(std::time::Duration::from_secs(10), revoke) + .await.expect("revoke resumes after admission").unwrap(); + let after_ack = admitting.guard_delegation_chains(&[child]).await.unwrap(); + assert!( + !admitting.delegation_chains_unrevoked(&[child.to_cid(0x55)]).await.unwrap(), + "a new admission after ACK cannot reuse the signed child" + ); + drop(after_ack); + // Dropping every in-memory guard/cache in a new process must not make + // the old child usable again. + let restarted = make_db(sqlite_write_pool( + &path, std::time::Duration::from_secs(5), 2 + ).await).await; + assert!( + !restarted.delegation_chains_unrevoked(&[child.to_cid(0x55)]).await.unwrap(), + "a cold connection must honor the committed revoke" + ); + } + /// TC-324 requirement 3: delegation registration still mutually excludes /// against invocations on the same chain, in both orders. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 24313081..a75696b6 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -7948,37 +7948,62 @@ mod tests { )); } - #[tokio::test] - async fn policy_v2_admits_v3_account_and_v4_accountless_receivers() -> anyhow::Result<()> { - run_policy_v2_flow(PolicyOwner::SpaceOwner).await + #[test] + fn policy_v2_admits_v3_account_and_v4_accountless_receivers() -> anyhow::Result<()> { + run_policy_v2_flow_on_large_stack(PolicyOwner::SpaceOwner) + } + + #[test] + fn revoking_policy_authority_root_denies_admitted_sessions() -> anyhow::Result<()> { + run_policy_v2_flow_on_large_stack(PolicyOwner::SpaceOwnerPolicyRoot) } /// The real Share and CLI shape: the policy owner is a session key the /// space owner delegated to. It registers, mints and reads; once the /// space owner revokes that delegation, no new session can be minted. - #[tokio::test] - async fn policy_owner_may_be_a_session_the_space_owner_delegated_to() -> anyhow::Result<()> { - run_policy_v2_flow(PolicyOwner::DelegatedSession).await + #[test] + fn policy_owner_may_be_a_session_the_space_owner_delegated_to() -> anyhow::Result<()> { + run_policy_v2_flow_on_large_stack(PolicyOwner::DelegatedSession) } /// The policy owner signs a valid, self-consistent policy over content in /// a space it holds no authority in (the victim hosts and seeds it). /// Registration must refuse it, so no session can ever read the bytes. - #[tokio::test] - async fn policy_registration_requires_the_owner_to_hold_what_it_grants() -> anyhow::Result<()> { - run_policy_v2_flow(PolicyOwner::Unauthorized).await + #[test] + fn policy_registration_requires_the_owner_to_hold_what_it_grants() -> anyhow::Result<()> { + run_policy_v2_flow_on_large_stack(PolicyOwner::Unauthorized) } #[derive(Clone, Copy, PartialEq, Eq)] enum PolicyOwner { /// The policy owner is the content space's owner. SpaceOwner, + /// A second owner-owned policy revoking the policy-authority root. + SpaceOwnerPolicyRoot, /// The policy owner is a session key the space owner delegated to. DelegatedSession, /// The policy owner holds nothing in the content space. Unauthorized, } + // This existing end-to-end fixture now exercises get and put across + // several policy descendants; its async frame exceeds the default test + // thread stack. Keep the test self-contained on CI rather than requiring + // RUST_MIN_STACK in the runner environment. + fn run_policy_v2_flow_on_large_stack(policy_owner: PolicyOwner) -> anyhow::Result<()> { + std::thread::Builder::new() + .stack_size(16 << 20) + .spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("policy fixture runtime") + .block_on(run_policy_v2_flow(policy_owner)) + })? + .join() + .expect("policy fixture thread panicked") + } + async fn run_policy_v2_flow(policy_owner: PolicyOwner) -> anyhow::Result<()> { use k256::ecdsa::SigningKey; use sha3::Keccak256; @@ -8016,7 +8041,10 @@ mod tests { SpaceId::new(account_owner_did.parse::()?, "credentials".parse()?); let victim_key = tinycloud_core::libp2p::identity::ed25519::Keypair::generate(); let victim_did = did(&victim_key); - let content_owner_did = if policy_owner != PolicyOwner::SpaceOwner { + let space_owner = matches!( + policy_owner, PolicyOwner::SpaceOwner | PolicyOwner::SpaceOwnerPolicyRoot + ); + let content_owner_did = if !space_owner { &victim_did } else { &owner_did @@ -8052,19 +8080,33 @@ mod tests { let now = OffsetDateTime::now_utc().replace_nanosecond(0).unwrap(); let issued = now - Duration::seconds(5); let expires = now + Duration::seconds(600); + let requested_actions = if policy_owner != PolicyOwner::Unauthorized { + vec!["tinycloud.kv/get", "tinycloud.kv/put"] + } else { + vec!["tinycloud.kv/get"] + }; + let ceiling_actions = if policy_owner != PolicyOwner::Unauthorized { + vec!["tinycloud.kv/get", "tinycloud.kv/metadata", "tinycloud.kv/put"] + } else { + vec!["tinycloud.kv/get", "tinycloud.kv/metadata"] + }; let requested = vec![json!({ "kind": "kv", "resource": content_resource.to_string(), "selector": "exact", - "actions": ["tinycloud.kv/get"] + "actions": requested_actions })]; + let mut read_capabilities = requested.clone(); + read_capabilities[0]["actions"] = json!(["tinycloud.kv/get"]); + let mut write_capabilities = requested.clone(); + write_capabilities[0]["actions"] = json!(["tinycloud.kv/put"]); let encryption_resource = format!("urn:tinycloud:encryption:{content_owner_did}:mainnet"); let ceiling = vec![ json!({ "kind": "kv", "resource": content_resource.to_string(), "selector": "exact", - "actions": ["tinycloud.kv/get", "tinycloud.kv/metadata"] + "actions": ceiling_actions }), json!({ "kind": "encryption", @@ -8470,7 +8512,7 @@ mod tests { // never carries authority for this resource. // The content space's owner hosts and seeds it through the ordinary // graph: the policy owner itself, or a victim when the content is foreign. - let content_owner_key = if policy_owner != PolicyOwner::SpaceOwner { + let content_owner_key = if !space_owner { &victim_key } else { &owner_key @@ -8539,7 +8581,7 @@ mod tests { // that delegation covers, as the Share and CLI owners do. let owner_delegation_cid = if policy_owner == PolicyOwner::DelegatedSession { let mut owner_capabilities = Capabilities::::new(); - for ability in ["tinycloud.kv/get", "tinycloud.kv/metadata"] { + for ability in ["tinycloud.kv/get", "tinycloud.kv/metadata", "tinycloud.kv/put"] { owner_capabilities.with_action( content_resource.as_uri(), ability.parse::()?, @@ -9152,7 +9194,7 @@ mod tests { facts: Some(Vec::::new()), proof: vec![child_cid], attenuation: serde_json::from_value::>( - attenuation_for_policy_capabilities(&requested) + attenuation_for_policy_capabilities(&read_capabilities) .map_err(|(_, error)| anyhow::anyhow!(error))?, )?, } @@ -9389,7 +9431,7 @@ mod tests { facts: Some(Vec::::new()), proof: vec![v4_s0.content_hash().to_cid(0x55)], attenuation: serde_json::from_value::>( - attenuation_for_policy_capabilities(&requested) + attenuation_for_policy_capabilities(&read_capabilities) .map_err(|(_, error)| anyhow::anyhow!(error))?, )?, } @@ -9456,7 +9498,8 @@ mod tests { let reader_b_did = tinycloud_auth::resolver::DID_METHODS .generate(&reader_b_jwk, "key")? .to_string(); - let mut chain_tip = v4_s0; + let mut chain_tip = + decode_delegation(v4_authorization).map_err(|(_, error)| anyhow::anyhow!(error))?; for (issuer_jwk, issuer_did, audience_did, nonce) in [ (&holder_jwk, &holder_did, &reader_a_did, "tc529-child"), ( @@ -9499,7 +9542,7 @@ mod tests { facts: Some(Vec::::new()), proof: vec![grandchild_cid], attenuation: serde_json::from_value::>( - attenuation_for_policy_capabilities(&requested) + attenuation_for_policy_capabilities(&read_capabilities) .map_err(|(_, error)| anyhow::anyhow!(error))?, )?, } @@ -9590,6 +9633,64 @@ mod tests { .await; assert_eq!(replay.status(), Status::Unauthorized); + let session_invocation = + |key: &JWK, + principal: &str, + parent: tinycloud_auth::ipld_core::cid::Cid, + allowed: &[Value], + nonce: &str| + -> anyhow::Result { + let now = OffsetDateTime::now_utc(); + let vm = format!("{principal}#{}", principal.trim_start_matches("did:key:")); + Ok(Payload { + issuer: vm.parse()?, + audience: principal.parse()?, + not_before: Some(NumericDate::try_from_seconds(now.unix_timestamp() as f64)?), + expiration: NumericDate::try_from_seconds( + (now + Duration::seconds(30)).unix_timestamp() as f64, + )?, + nonce: Some(nonce.into()), + facts: Some(Vec::::new()), + proof: vec![parent], + attenuation: serde_json::from_value::>( + attenuation_for_policy_capabilities(allowed) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + } + .sign(Algorithm::EdDSA, key)? + .encode()?) + }; + let s0_cid = v4_s0.content_hash().to_cid(0x55); + for (key, principal, parent, nonce, content) in [ + (&holder_jwk, holder_did.as_str(), s0_cid, "tc1149-s0-write-before", "s0-before"), + (&reader_b_jwk, reader_b_did.as_str(), grandchild_cid, "tc1149-descendant-write-before", "descendant-before"), + ] { + let write = client.post("/invoke") + .header(rocket::http::Header::new( + "Authorization", + session_invocation(key, principal, parent, &write_capabilities, nonce)?, + )) + .header(ContentType::Plain) + .body(content) + .dispatch().await; + let status = write.status(); + let body = write.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Ok, "pre-revoke {nonce}: {body}"); + } + let owner_read = |nonce: &str| -> anyhow::Result { + Ok(make_invocation( + [(content_resource.clone(), ["tinycloud.kv/get".parse::()?])], + &sender_cid, &holder_jwk, &holder_vm, + (OffsetDateTime::now_utc() + Duration::seconds(30)).unix_timestamp() as f64, + InvocationOptions { nonce: Some(nonce.into()), ..InvocationOptions::default() }, + )?.encode()?) + }; + let before = client.post("/invoke") + .header(rocket::http::Header::new("Authorization", owner_read("tc1149-owner-read-before")?)) + .dispatch().await; + assert_eq!(before.status(), Status::Ok); + assert_eq!(before.into_bytes().await.unwrap_or_default(), b"descendant-before"); + if let Some(owner_delegation_cid) = owner_delegation_cid.as_deref() { // Revoking the delegation the policy owner held stops new // sessions, although the policy and its roots are untouched. @@ -9630,6 +9731,55 @@ mod tests { "owner session revoke: {revoked_body}" ); + // The revoke ACK is the cutoff. Old signatures and registered + // descendants must no longer authorize reads, writes, or children. + for (key, principal, parent, label) in [ + (&holder_jwk, holder_did.as_str(), s0_cid, "s0"), + (&reader_b_jwk, reader_b_did.as_str(), grandchild_cid, "descendant"), + ] { + for (action, allowed) in [ + ("get", &read_capabilities), + ("put", &write_capabilities), + ] { + let authorization = session_invocation( + key, principal, parent, allowed, + &format!("tc1149-after-owner-revoke-{label}-{action}"), + )?; + let response = client.post("/invoke") + .header(rocket::http::Header::new("Authorization", authorization)) + .header(ContentType::Plain) + .body("must-not-overwrite") + .dispatch().await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Forbidden, "{label} {action}: {body}"); + } + } + let after = client.post("/invoke") + .header(rocket::http::Header::new("Authorization", owner_read("tc1149-owner-read-after")?)) + .dispatch().await; + assert_eq!(after.status(), Status::Ok); + assert_eq!(after.into_bytes().await.unwrap_or_default(), b"descendant-before", + "denied policy writes must leave the owner-visible bytes unchanged"); + let next_reader = JWK::generate_ed25519()?; + let next_did = tinycloud_auth::resolver::DID_METHODS + .generate(&next_reader, "key")?.to_string(); + for (parent, issuer, principal, label) in [ + (&v4_s0, &holder_jwk, holder_did.as_str(), "s0"), + (&chain_tip, &reader_b_jwk, reader_b_did.as_str(), "descendant"), + ] { + let attempted = hop( + parent, issuer, principal, &next_did, + &format!("tc1149-child-after-owner-revoke-{label}"), + )?; + let response = client.post("/delegate") + .header(rocket::http::Header::new("Authorization", attempted)) + .dispatch().await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Forbidden, "{label} child: {body}"); + } + let challenge_response = client .post("/policy/v3/challenges") .header(ContentType::JSON) @@ -9689,21 +9839,68 @@ mod tests { Status::Forbidden, "mint after owner revoke: {refused_body}" ); - assert_eq!(refused_body, "policy-owner-not-authorized"); } + // Prepare a fresh, unused admission while both roots are live. It + // cannot be created after root revoke because challenge itself closes. + let fresh_root_mint = if space_owner { + let response = client.post("/policy/v3/challenges") + .header(ContentType::JSON) + .body(json!({ + "policyCid": policy_cid, + "recipientDid": holder_did, + "requestedCapabilities": requested, + }).to_string()) + .dispatch().await; + assert_eq!(response.status(), Status::Ok); + let challenge: Value = response.into_json().await.unwrap(); + let mut signed = presentation.clone(); + signed["schema"] = json!(CREDENTIAL_PRESENTATION_V4_SCHEMA); + signed["jti"] = json!("presentation-tc1149-after-root-revoke"); + signed["challengeId"] = challenge["challengeId"].clone(); + signed["nonce"] = challenge["nonce"].clone(); + signed.as_object_mut().unwrap().remove("credentialSpaceOwnerDid"); + signed["issuedAt"] = json!(format_time(OffsetDateTime::now_utc())); + signed["expiresAt"] = json!(format_time( + OffsetDateTime::now_utc() + Duration::seconds(45) + )); + let mut unsigned = signed.clone(); + unsigned.as_object_mut().unwrap().remove("signature"); + let mut preimage = CREDENTIAL_PRESENTATION_V4_DOMAIN.to_vec(); + preimage.extend_from_slice(&canonical_json_value(&unsigned)); + signed["signature"]["value"] = json!(encode_config( + holder_key.sign(&Sha256::digest(preimage)), + URL_SAFE_NO_PAD + )); + Some(json!({ + "policyCid": policy_cid, + "challengeId": challenge["challengeId"], + "nonce": challenge["nonce"], + "requirement": requirement, + "credential": credential, + "presentation": signed, + }).to_string()) + } else { + None + }; + // SDK revocation writes the generic graph. Policy/v3 joins that // graph with its signed root status, so this one root revocation must // close the already-minted session plus new control-plane admission // and delivery. It intentionally does not fabricate a root-status // checkpoint. - let policy_root_cid = registered["policyRootCid"] + let root_field = if policy_owner == PolicyOwner::SpaceOwner { + "enforcementRootCid" + } else { + "policyRootCid" + }; + let revoked_root_cid = registered[root_field] .as_str() - .ok_or_else(|| anyhow::anyhow!("missing policy root cid"))? + .ok_or_else(|| anyhow::anyhow!("missing {root_field}"))? .parse::()?; let mut revoke_capabilities = Capabilities::::new(); revoke_capabilities.with_action( - format!("urn:cid:{policy_root_cid}").parse()?, + format!("urn:cid:{revoked_root_cid}").parse()?, "tinycloud.delegation/revoke".parse::()?, [std::collections::BTreeMap::::new()], ); @@ -9734,6 +9931,68 @@ mod tests { let revoke_body = revoke_response.into_string().await.unwrap_or_default(); assert_eq!(revoke_status, Status::Ok, "SDK root revoke: {revoke_body}"); + if space_owner { + for (key, principal, parent, label) in [ + (&holder_jwk, holder_did.as_str(), s0_cid, "s0"), + (&reader_b_jwk, reader_b_did.as_str(), grandchild_cid, "descendant"), + ] { + for (action, allowed) in [ + ("get", &read_capabilities), + ("put", &write_capabilities), + ] { + let authorization = session_invocation( + key, principal, parent, allowed, + &format!("tc1149-after-{root_field}-revoke-{label}-{action}"), + )?; + let response = client.post("/invoke") + .header(rocket::http::Header::new("Authorization", authorization)) + .header(ContentType::Plain) + .body("root-denied-write") + .dispatch().await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Forbidden, "{root_field} {label} {action}: {body}"); + } + } + let unchanged = client.post("/invoke") + .header(rocket::http::Header::new( + "Authorization", + owner_read(&format!("tc1149-owner-read-after-{root_field}"))?, + )) + .dispatch().await; + assert_eq!(unchanged.status(), Status::Ok); + assert_eq!( + unchanged.into_bytes().await.unwrap_or_default(), + b"descendant-before", + "revoking {root_field} must not commit denied writes" + ); + let next_reader = JWK::generate_ed25519()?; + let next_did = tinycloud_auth::resolver::DID_METHODS + .generate(&next_reader, "key")?.to_string(); + for (parent, issuer, principal, label) in [ + (&v4_s0, &holder_jwk, holder_did.as_str(), "s0"), + (&chain_tip, &reader_b_jwk, reader_b_did.as_str(), "descendant"), + ] { + let attempted = hop( + parent, issuer, principal, &next_did, + &format!("tc1149-{root_field}-child-after-revoke-{label}"), + )?; + let response = client.post("/delegate") + .header(rocket::http::Header::new("Authorization", attempted)) + .dispatch().await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Forbidden, "{root_field} {label} child: {body}"); + } + let refused = client.post("/policy/v3/delegations") + .header(ContentType::JSON) + .body(fresh_root_mint.expect("pre-revoke challenge and presentation")) + .dispatch().await; + let status = refused.status(); + let body = refused.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Forbidden, "{root_field} fresh mint: {body}"); + } + let post_revoke_now = OffsetDateTime::now_utc(); let post_revoke_read = Payload { issuer: reader_vm.parse()?, @@ -9748,7 +10007,7 @@ mod tests { facts: Some(Vec::::new()), proof: vec![child_cid], attenuation: serde_json::from_value::>( - attenuation_for_policy_capabilities(&requested) + attenuation_for_policy_capabilities(&read_capabilities) .map_err(|(_, error)| anyhow::anyhow!(error))?, )?, } From 95dc940a61ef84622391abf0c9d277eea24c63bf Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Thu, 15 Oct 2026 18:53:05 +0000 Subject: [PATCH 12/20] feat: add scoped key revocation and authority witnesses --- CHANGELOG.md | 1 + docs/delegation-flow.md | 30 + tinycloud-core/src/auth_graph.rs | 112 +- tinycloud-core/src/db.rs | 1156 +++++++++++++++-- tinycloud-core/src/key_authority.rs | 190 +++ tinycloud-core/src/lib.rs | 3 + .../m20261016_000000_policy_witness.rs | 40 + .../m20261016_000001_key_revocation.rs | 112 ++ tinycloud-core/src/migrations/mod.rs | 4 + tinycloud-core/src/models/delegation.rs | 66 + tinycloud-core/src/models/key_revocation.rs | 117 ++ tinycloud-core/src/models/mod.rs | 1 + .../src/policy_authority/database.rs | 23 +- tinycloud-core/src/policy_witness.rs | 943 ++++++++++++++ tinycloud-core/src/recovery_authority.rs | 381 ++++++ tinycloud-core/src/share_email/bridge.rs | 252 ++++ tinycloud-core/src/util.rs | 43 +- tinycloud-core/src/write_hooks.rs | 45 +- tinycloud-core/tests/policy_witness_legacy.rs | 450 +++++++ .../src/bin/policy-witness-dry-run.rs | 166 +++ tinycloud-node-server/src/key_revoke.rs | 227 ++++ tinycloud-node-server/src/lib.rs | 5 +- tinycloud-node-server/src/policy_v3.rs | 747 ++++++++--- tinycloud-node-server/src/routes/hooks.rs | 306 ++++- tinycloud-node-server/src/routes/mod.rs | 779 ++++++++++- tinycloud-node-server/src/share_v2.rs | 37 + .../tests/share_v2_legacy_key_tests.rs | 733 +++++++++++ tinycloud-node-server/src/signed_urls.rs | 89 +- .../src/webhook_dispatcher.rs | 360 ++++- .../tests/postgres_round_trip_budget.rs | 16 +- 30 files changed, 7029 insertions(+), 405 deletions(-) create mode 100644 tinycloud-core/src/key_authority.rs create mode 100644 tinycloud-core/src/migrations/m20261016_000000_policy_witness.rs create mode 100644 tinycloud-core/src/migrations/m20261016_000001_key_revocation.rs create mode 100644 tinycloud-core/src/models/key_revocation.rs create mode 100644 tinycloud-core/src/policy_witness.rs create mode 100644 tinycloud-core/src/recovery_authority.rs create mode 100644 tinycloud-core/tests/policy_witness_legacy.rs create mode 100644 tinycloud-node-server/src/bin/policy-witness-dry-run.rs create mode 100644 tinycloud-node-server/src/key_revoke.rs create mode 100644 tinycloud-node-server/src/share_v2/tests/share_v2_legacy_key_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 61bb2263..b9f02750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Add signed, scoped and global key-principal revocation with durable receipts, policy authority witnesses, and ordered admission/invocation checks (TC-1149). Existing natural session-expiry behavior is unchanged; legacy witness backfill requires a count-only snapshot dry-run before release. - CACAO/SIWE delegation ingestion normalizes ECDSA recovery IDs 0/1 to 27/28 before hashing and persisting the canonical DAG-CBOR bytes (TC-1071). Equivalent low-s signatures now share one delegation CID, including the CID used for revocation lookup. High-s signatures are rejected during decoding, matching the existing EIP-191 verifier and EIP-2 low-s convention; UCAN/JWS bytes remain unchanged. Existing stored delegation IDs are not rewritten. - Synchronize the standalone M1, W5 and load-signer Cargo locks with CACAO's `k256` dependency so `--locked` dependency resolution succeeds (TC-1071). diff --git a/docs/delegation-flow.md b/docs/delegation-flow.md index 1a9a4cb3..5a0a1bbe 100644 --- a/docs/delegation-flow.md +++ b/docs/delegation-flow.md @@ -506,6 +506,36 @@ support. On the same `utf8mb4`/default-strict configuration, unmodified alias migration exists. That separate full-schema prerequisite must be resolved and exercised before claiming end-to-end support for that configuration. +## Explicit Key Revocation and Policy Witness + +`POST /keys/revoke` accepts JSON with `targetDid`, `scope` (`{"kind":"global"}` +or `{"kind":"space","spaceId":"..."}`), and `authorization`, a signed UCAN or +SIWE/CACAO addressed to this node. The signed capability names the same +target and scope. Global revocation requires the target key or its explicitly +enrolled global recovery delegate. A space owner or its scoped recovery +delegate may revoke a key only in that owner's space. Generic `/revoke` +continues to revoke a single delegation CID, not a whole key. + +The committed response includes the target, scope, and original cutoff. +Repeating the exact signed request returns the same receipt even after the +signer is revoked; another request using its nonce cannot alter that cutoff. +New requests still need live authorization. A successful response follows the +durable cutoff, so later policy mint, delegation admission, and invocations +cannot use authority derived from the revoked principal. Natural proof expiry +is not an explicit revoke. + +Policy registration persists a versioned, signed-byte-bound witness for its +authority dependencies. Preexisting registrations receive a conservative +transactional backfill at startup; inconsistent or incomplete provenance +prevents serving rather than silently narrowing dependencies. Before release, +an operator runs `policy-witness-dry-run` with the node's configuration on a +production snapshot or read replica. It opens read-only and reports only +aggregate `ok`, `missing`, `ambiguous`, and `overflow` counts. Any unresolved +rows require a release decision; the startup backfill is not a substitute for +the dry-run gate. Owner-self classification remains `unknown/legacy` in this +phase; natural sign-in expiry retains existing behavior. Signed self-session +classification is tracked separately in TC-1213. + ## Child Delegation (UCAN) Creation For sharing, child UCANs are created that delegate from the session key to another DID. diff --git a/tinycloud-core/src/auth_graph.rs b/tinycloud-core/src/auth_graph.rs index 8246c743..2a9af614 100644 --- a/tinycloud-core/src/auth_graph.rs +++ b/tinycloud-core/src/auth_graph.rs @@ -15,9 +15,10 @@ use crate::relationships::parent_delegations; use sea_orm::{ entity::prelude::*, sea_query::{Alias, CommonTableExpression, Expr, JoinType, Query, UnionType, WithClause}, - ConnectionTrait, + ConnectionTrait, DbErr, }; use std::collections::{HashMap, HashSet}; +use tinycloud_auth::identity::principal_did; pub(crate) use crate::models::revocation::ChainTraversalError; use crate::models::revocation::MAX_CHAIN_TRAVERSAL_NODES; @@ -42,6 +43,115 @@ pub(crate) async fn load_chain_lock_ids( load_closure(db, roots, true).await.map(|(ids, _)| ids) } +/// The caller already loaded and bounded the signed proof closure. Resolve +/// its immutable signer/audience principals and canonical alias lock IDs in +/// one query; live revocation is still checked after acquiring those guards. +pub(crate) async fn invocation_authority_for_closure( + db: &C, + mut ids: Vec, +) -> Result<(Vec, Vec), DbErr> { + if ids.is_empty() { + return Ok((ids, Vec::new())); + } + let query = Query::select() + .columns([ + (delegation::Entity, delegation::Column::Id), + (delegation::Entity, delegation::Column::Delegator), + (delegation::Entity, delegation::Column::Delegatee), + ]) + .expr_as( + Expr::col(( + delegation_cid_alias::Entity, + delegation_cid_alias::Column::Canonical, + )), + Alias::new("canonical_lock"), + ) + .from(delegation::Entity) + .join( + JoinType::LeftJoin, + delegation_cid_alias::Entity, + Expr::col((delegation::Entity, delegation::Column::Id)).equals(( + delegation_cid_alias::Entity, + delegation_cid_alias::Column::Id, + )), + ) + .and_where( + Expr::col((delegation::Entity, delegation::Column::Id)).is_in(ids.iter().copied()), + ) + .to_owned(); + let rows = db + .query_all(db.get_database_backend().build(&query)) + .await?; + if rows.len() != ids.len() { + return Err(DbErr::Custom("authorization-chain-missing".into())); + } + let mut principals = Vec::with_capacity(rows.len() * 2); + let mut aliases = HashMap::with_capacity(rows.len()); + for row in rows { + let id: Hash = row.try_get("", "id")?; + if let Some(canonical) = row.try_get::>("", "canonical_lock")? { + aliases.insert(id, canonical); + } + for column in ["delegator", "delegatee"] { + let did: String = row.try_get("", column)?; + principals.push( + principal_did(&did) + .map_err(|_| DbErr::Custom("authorization-principal-invalid".into()))?, + ); + } + } + for id in &mut ids { + if let Some(canonical) = aliases.get(id) { + *id = *canonical; + } + } + principals.sort_unstable(); + principals.dedup(); + Ok((ids, principals)) +} + +/// Every signer and audience in a bounded, persisted proof closure. Missing +/// rows and unparseable principals are authorization failures, not an empty +/// dependency set. CID identity and DID-principal identity remain separate. +pub(crate) async fn load_chain_principals( + db: &C, + roots: &[Hash], +) -> Result, ChainTraversalError> { + let (ids, _) = load_closure_edges(db, roots).await?; + principals_for_ids(db, &ids).await +} + +/// Resolve the signers of an already bounded proof closure without re-running +/// its recursive query. The invocation request also needs these same IDs to +/// discover its policy session, so it can share this request-local snapshot. +pub(crate) async fn principals_for_ids( + db: &C, + ids: &[Hash], +) -> Result, ChainTraversalError> { + if ids.is_empty() { + return Ok(Vec::new()); + } + let rows = delegation::Entity::find() + .filter(delegation::Column::Id.is_in(ids.iter().copied())) + .all(db) + .await?; + if rows.len() != ids.len() { + return Err(DbErr::Custom("authorization-chain-missing".into()).into()); + } + let mut principals = Vec::with_capacity(rows.len() * 2); + for row in rows { + for did in [&row.delegator, &row.delegatee] { + principals.push( + principal_did(did) + .map_err(|_| DbErr::Custom("authorization-principal-invalid".into()))?, + ); + } + } + principals.sort_unstable(); + principals.dedup(); + Ok(principals) +} + async fn load_closure( db: &C, roots: &[Hash], diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 9e04f89d..26f35fab 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -258,6 +258,29 @@ pub enum TxError { Encryption(#[from] crate::encryption::EncryptionError), #[error("delegation-chain-traversal-limit-exceeded")] ChainTraversalLimitExceeded, + #[error("authorization-dependency-denied")] + AuthorizationDenied, + #[error("authorization-revoke-wait-timeout")] + AuthorizationWaitTimeout, +} + +fn authority_error(error: DbErr) -> TxError { + if matches!(&error, DbErr::Custom(message) if message == "authorization-revoke-wait-timeout") { + TxError::AuthorizationWaitTimeout + } else if crate::key_authority::is_revoked_error(&error) { + TxError::AuthorizationDenied + } else { + TxError::Db(error) + } +} + +fn witness_error( + error: crate::policy_witness::WitnessError, +) -> TxError { + match error { + crate::policy_witness::WitnessError::Database(error) => authority_error(error), + _ => TxError::AuthorizationDenied, + } } #[non_exhaustive] @@ -368,6 +391,13 @@ where .await?; } delegation_cid_alias::backfill(&tx, encryption.as_ref()).await?; + // Release gate: Amos must run the read-only count inventory on a + // production snapshot before deploying this migration. Once enabled, + // legacy admissions are reconstructed atomically or startup refuses + // to serve them; neither CID indexing nor witness rows partially land. + crate::policy_witness::backfill_in_transaction(&tx, encryption.as_ref()) + .await + .map_err(|_| DbErr::Custom("policy-witness-backfill-unavailable".into()))?; tx.commit().await?; let writer_lock = (pool.get_database_backend() == sea_orm::DatabaseBackend::Sqlite) .then(|| Arc::new(tokio::sync::Mutex::new(()))); @@ -447,6 +477,11 @@ impl SpaceDatabase { &self.conn } + /// Encryption key for verifying signed authority from persisted rows. + pub fn column_encryption(&self) -> Option<&ColumnEncryption> { + self.encryption.as_ref() + } + /// Share the SQL/DuckDB cutover fence cache with all node data paths. pub fn database_fence_cache(&self) -> Arc { Arc::clone(&self.database_fence_cache) @@ -559,7 +594,19 @@ where invocation: &crate::util::InvocationInfo, now: OffsetDateTime, ) -> Result<(), crate::models::invocation::Error> { - invocation::verify_and_authorize(&self.conn, invocation, now).await + invocation::verify_and_authorize(&self.conn, invocation, now).await?; + let spaces = invocation + .spaces() + .map(ToString::to_string) + .collect::>(); + self.ticket_authority_unrevoked(&invocation.parents, &invocation.invoker, &spaces) + .await + .map_err(|_| { + crate::models::invocation::InvocationError::UnauthorizedInvoker( + "authorization-dependency-denied".into(), + ) + })?; + Ok(()) } /// [`Self::authorize_invocation`] for an invocation whose signature and @@ -571,7 +618,20 @@ where invocation: &crate::AdmittedInvocation, now: OffsetDateTime, ) -> Result<(), crate::models::invocation::Error> { - invocation::authorize_admitted(&self.conn, &invocation.invocation().0, now, None).await + let admitted = &invocation.invocation().0; + invocation::authorize_admitted(&self.conn, admitted, now, None).await?; + let spaces = admitted + .spaces() + .map(ToString::to_string) + .collect::>(); + self.ticket_authority_unrevoked(&admitted.parents, &admitted.invoker, &spaces) + .await + .map_err(|_| { + crate::models::invocation::InvocationError::UnauthorizedInvoker( + "authorization-dependency-denied".into(), + ) + })?; + Ok(()) } /// Check the persisted revocation state for cited delegations and every @@ -584,14 +644,53 @@ where let chain_ids = revocation::ancestor_chain_ids_for_roots(&self.conn, &roots) .await .map_err(|error| DbErr::Custom(error.to_string()))?; - for id in chain_ids { - if revocation::is_revoked(&self.conn, &id).await? { + for chunk in chain_ids.chunks(200) { + if !revocation::effective_rows(&self.conn, chunk) + .await? + .is_empty() + { return Ok(false); } } Ok(true) } + /// Recheck a minted capability ticket against its signed subject, proof + /// closure and registration witness. A proofless root still depends on its + /// subject; a policy session also depends on its historical registration. + pub async fn ticket_authority_unrevoked( + &self, + roots: &[Cid], + subject: &str, + spaces: &[String], + ) -> Result<(), DbErr> { + let ids = roots.iter().copied().map(Hash::from).collect::>(); + let mut principals = crate::auth_graph::load_chain_principals(&self.conn, &ids) + .await + .map_err(|error| DbErr::Custom(error.to_string()))?; + principals.push(subject.to_owned()); + let policy_cid = policy_cid_in_chain(&self.conn, &ids).await?; + if let Some(policy_cid) = policy_cid.as_deref() { + let witness = crate::policy_witness::revocation_dependencies(&self.conn, policy_cid) + .await + .map_err(|error| DbErr::Custom(error.to_string()))?; + principals.extend(witness.principals.iter().cloned()); + let checked_spaces = if spaces.is_empty() { + &witness.spaces + } else { + spaces + }; + crate::policy_witness::check_dependencies(&self.conn, &witness, checked_spaces) + .await + .map_err(|error| DbErr::Custom(error.to_string()))?; + } + crate::key_authority::check_principals(&self.conn, &principals, spaces).await?; + if !self.delegation_chains_unrevoked(roots).await? { + return Err(DbErr::Custom("hook ticket authority revoked".into())); + } + Ok(()) + } + /// Load and reparse a delegation from its exact signed Authorization /// bytes. The relational row is returned only so callers can compare all /// projections against the signed source of truth. @@ -640,6 +739,13 @@ where invocation::verify_and_authorize(&self.conn, invocation, now) .await .map_err(|_| AccountDelegationQueryError::Unauthorized)?; + let spaces = invocation + .spaces() + .map(ToString::to_string) + .collect::>(); + self.ticket_authority_unrevoked(&invocation.parents, &invocation.invoker, &spaces) + .await + .map_err(|_| AccountDelegationQueryError::Unauthorized)?; let principal = account_query_principal(&self.conn, invocation) .await? .ok_or(AccountDelegationQueryError::Unauthorized)?; @@ -1316,6 +1422,28 @@ where Ok(self.acquire_exclusive_chain_guards_for_keys(keys).await) } + /// One sorted registry acquisition for CID aliases and DID principals. + /// Separate acquisitions can deadlock when a root CID overlaps another + /// request's target principal guard. + async fn acquire_authority_chain_guards( + &self, + roots: &[Hash], + principals: &[String], + ) -> Result, TxError> { + let mut keys = crate::auth_graph::load_chain_lock_ids(&self.conn, roots) + .await + .map_err(|error| match error { + revocation::ChainTraversalError::Db(error) => TxError::Db(error), + revocation::ChainTraversalError::LimitExceeded => { + TxError::ChainTraversalLimitExceeded + } + })?; + for principal in principals { + keys.push(crate::key_authority::principal_lock_id(principal).map_err(TxError::Db)?); + } + Ok(self.acquire_exclusive_chain_guards_for_keys(keys).await) + } + /// Hold shared guards for the canonical ancestor identities of `roots`. /// /// These coordinate admission with delegation/revocation writers using @@ -1332,7 +1460,7 @@ where revocation::ChainTraversalError::Db(error) => error, error => DbErr::Custom(error.to_string()), })?; - Ok(self.acquire_shared_chain_guards_for_keys(keys).await) + self.acquire_shared_chain_guards_bounded(keys).await } /// Resolve `keys` to their per-delegation locks in a stable global order. @@ -1430,6 +1558,20 @@ where guards } + /// Never park a read indefinitely behind a pending revoke. Dropping the + /// timed-out future also releases any earlier guards in its sorted set. + async fn acquire_shared_chain_guards_bounded( + &self, + keys: Vec, + ) -> Result, DbErr> { + tokio::time::timeout( + std::time::Duration::from_secs(2), + self.acquire_shared_chain_guards_for_keys(keys), + ) + .await + .map_err(|_| DbErr::Custom("authorization-revoke-wait-timeout".into())) + } + async fn acquire_kv_object_guards( &self, keys: &[(SpaceId, Path)], @@ -1474,7 +1616,152 @@ where None => None, }; let tx = self.begin_graph_transaction().await?; - // `transact` takes the database-side sequence lock itself, first. + crate::kv_sync::lock_space_sequences(&tx, &spaces).await?; + let mut admission_principals = Vec::new(); + let mut admission_scopes = Vec::new(); + let mut admission_cids = Vec::new(); + for event in &events { + match event { + Event::Delegation(delegation) => { + let event_scopes = delegation + .0 + .capabilities + .iter() + .filter_map(|capability| { + capability.resource.space().map(ToString::to_string) + }) + .collect::>(); + let parents = delegation + .0 + .parents + .iter() + .copied() + .map(Hash::from) + .collect::>(); + if !parents.is_empty() { + let (ids, _) = crate::auth_graph::load_closure_edges(&tx, &parents) + .await + .map_err(|error| match error { + revocation::ChainTraversalError::Db(error) => TxError::Db(error), + revocation::ChainTraversalError::LimitExceeded => { + TxError::ChainTraversalLimitExceeded + } + })?; + if let Some(policy_cid) = + policy_cid_from_ids(&tx, &ids) + .await + .map_err(|error| match &error { + DbErr::Custom(message) + if message.starts_with("policy-session-proof-") => + { + TxError::AuthorizationDenied + } + _ => TxError::Db(error), + })? + { + let dependencies = + crate::policy_witness::revocation_dependencies(&tx, &policy_cid) + .await + .map_err(|error| match error { + crate::policy_witness::WitnessError::Database(source) => { + TxError::Db(source) + } + _ => TxError::AuthorizationDenied, + })?; + admission_cids.extend(dependencies.signed_cids); + admission_principals.extend(dependencies.principals); + if event_scopes.is_empty() + || delegation + .0 + .capabilities + .iter() + .any(|cap| cap.resource.space().is_none()) + { + admission_scopes.extend(dependencies.spaces); + } + } + admission_cids.extend(ids); + } + admission_principals.extend( + crate::auth_graph::load_chain_principals(&tx, &parents) + .await + .map_err(|error| TxError::Db(DbErr::Custom(error.to_string())))?, + ); + admission_principals.push(delegation.0.delegator.clone()); + admission_principals.push(delegation.0.delegate.clone()); + admission_scopes.extend(event_scopes); + } + Event::Revocation(revocation) => { + lock_revocation_alias_rows(&tx, Hash::from(revocation.0.revoked)).await?; + let parents = revocation + .0 + .parents + .iter() + .copied() + .map(Hash::from) + .collect::>(); + let target_scopes = abilities::Entity::find() + .filter(abilities::Column::Delegation.eq(Hash::from(revocation.0.revoked))) + .all(&tx) + .await? + .into_iter() + .filter_map(|row| row.resource.space().map(ToString::to_string)) + .collect::>(); + if !parents.is_empty() { + let policy_cid = policy_cid_in_chain(&tx, &parents).await.map_err( + |error| match &error { + DbErr::Custom(message) + if message.starts_with("policy-session-proof-") => + { + TxError::AuthorizationDenied + } + _ => TxError::Db(error), + }, + )?; + if let Some(policy_cid) = policy_cid { + let dependencies = + crate::policy_witness::revocation_dependencies(&tx, &policy_cid) + .await + .map_err(|error| match error { + crate::policy_witness::WitnessError::Database(source) => { + TxError::Db(source) + } + _ => TxError::AuthorizationDenied, + })?; + admission_cids.extend(dependencies.signed_cids); + admission_principals.extend(dependencies.principals); + if target_scopes.is_empty() { + admission_scopes.extend(dependencies.spaces); + } + } + } + admission_principals.extend( + crate::auth_graph::load_chain_principals(&tx, &parents) + .await + .map_err(|error| TxError::Db(DbErr::Custom(error.to_string())))?, + ); + admission_principals.push(revocation.0.revoker.clone()); + // The CID action is scoped to the capabilities it revokes; + // a key cutoff in an unrelated space must not block it. + admission_scopes.extend(target_scopes); + } + _ => {} + } + } + crate::key_authority::lock_signed_dependencies(&tx, &admission_cids).await?; + if !admission_cids.is_empty() + && !revocation::effective_rows(&tx, &admission_cids) + .await? + .is_empty() + { + return Err(TxError::AuthorizationDenied); + } + if !admission_principals.is_empty() { + crate::key_authority::lock_targets(&tx, &admission_principals, false).await?; + crate::key_authority::check_principals(&tx, &admission_principals, &admission_scopes) + .await + .map_err(authority_error::)?; + } let result = transact( &tx, @@ -1483,7 +1770,7 @@ where events, self.encryption.as_ref(), None, - &[], + &spaces, ) .await?; @@ -1690,8 +1977,15 @@ where let expected_ability_count = delegation.0.capabilities.len() as u64; let expected_parent_count = parent_hashes.len() as u64; let roots = delegation_guard_roots(retained_hash, &parent_hashes); + let mut principals = crate::auth_graph::load_chain_principals(&self.conn, &parent_hashes) + .await + .map_err(|error| TxError::Db(DbErr::Custom(error.to_string())))?; + principals.push(delegation.0.delegator.clone()); + principals.push(delegation.0.delegate.clone()); let authz_start = Instant::now(); - let chain_guards = self.acquire_chain_guards(&roots).await; + let chain_guards = self + .acquire_authority_chain_guards(&roots, &principals) + .await; crate::telemetry::observe_stage( crate::telemetry::InvocationStage::AuthorizationGraphLoad, crate::telemetry::StageOutcome::from(chain_guards.is_ok()), @@ -1776,6 +2070,9 @@ where let mut roots = vec![Hash::from(revocation.0.revoked)]; roots.extend(revocation.0.parents.iter().copied().map(Hash::from)); let revocation_start = Instant::now(); + // A CID revoke writes CID state, not a principal tombstone. Acquiring + // an exclusive principal lock here would queue unrelated grants from + // the same signer behind an account proof held on the target CID. let chain_guards = self.acquire_chain_guards(&roots).await; crate::telemetry::observe_stage( crate::telemetry::InvocationStage::RevocationWork, @@ -1787,6 +2084,211 @@ where .await } + /// Atomically publish a permanent DID-principal cutoff and its retryable + /// receipt. The signed request is verified by the Node route; authority + /// and nonce precedence are checked again on the transaction snapshot. + pub async fn revoke_key( + &self, + request: key_revocation::VerifiedKeyRevoke, + ) -> Result { + use key_revocation::{KeyRevokeError, KeyRevokeScope}; + let db_error = |source, write_may_have_applied| KeyRevokeError::Db { + source, + write_may_have_applied, + }; + let nonce_key = (request.issuer_did.clone(), request.nonce.clone()); + // This read never discloses a target. The original signed request + // proves possession of the same issuer key even after its revocation. + let existing = key_revocation::nonce::Entity::find_by_id(nonce_key.clone()) + .one(&self.conn) + .await + .map_err(|error| db_error(error, false))?; + if let Some(row) = &existing { + if row.authorization_hash == request.authorization_hash + && row.target_did == request.target_did + { + let receipt: key_revocation::KeyRevokeReceipt = + serde_json::from_slice(&row.receipt_bytes).map_err(|_| { + db_error(DbErr::Custom("key-receipt-invalid".into()), false) + })?; + if receipt.scope == request.scope && receipt.target_did == request.target_did { + return Ok(receipt); + } + return Err(KeyRevokeError::Forbidden); + } + // A nonce already bound to this exact target/scope remains a + // conflict even if its signer has since revoked itself. A changed + // target or scope must pass fresh authority before diagnostics. + if row.target_did == request.target_did { + let receipt: key_revocation::KeyRevokeReceipt = + serde_json::from_slice(&row.receipt_bytes).map_err(|_| { + db_error(DbErr::Custom("key-receipt-invalid".into()), false) + })?; + if receipt.scope == request.scope { + return Err(KeyRevokeError::Conflict); + } + } + } + + let target_key = crate::key_authority::principal_lock_id(&request.target_did) + .map_err(|error| db_error(error, false))?; + let _target_guard = self + .acquire_exclusive_chain_guards_for_keys(vec![target_key]) + .await; + let _writer = match &self.writer_lock { + Some(lock) => Some(lock.lock().await), + None => None, + }; + let tx = self + .begin_graph_transaction() + .await + .map_err(|error| db_error(error, false))?; + let now = OffsetDateTime::now_utc(); + let direct = if tinycloud_auth::identity::did_principal_matches( + &request.issuer_did, + &request.target_did, + ) { + true + } else if let KeyRevokeScope::Space { space_id } = &request.scope { + space_id + .parse::() + .ok() + .filter(|space| space.to_string() == *space_id) + .is_some_and(|space| { + tinycloud_auth::identity::did_principal_matches( + space.did().as_str(), + &request.issuer_did, + ) + }) + } else { + false + }; + let authorized = if direct { + let spaces = match &request.scope { + KeyRevokeScope::Global => Vec::new(), + KeyRevokeScope::Space { space_id } => vec![space_id.clone()], + }; + match crate::key_authority::check_principals( + &tx, + std::slice::from_ref(&request.issuer_did), + &spaces, + ) + .await + { + Ok(()) => true, + Err(error) if crate::key_authority::is_revoked_error(&error) => false, + Err(error) => return Err(db_error(error, false)), + } + } else { + crate::recovery_authority::is_authorized(&tx, &request, now, self.encryption.as_ref()) + .await + .map_err(|error| db_error(error, false))? + }; + if !authorized { + return Err(KeyRevokeError::Forbidden); + } + if existing.is_some() { + return Err(KeyRevokeError::Conflict); + } + let issued_at = request.not_before; + let now_seconds = now.unix_timestamp(); + if issued_at > now_seconds.saturating_add(30) + || request.expires_at <= now_seconds + || request.expires_at.saturating_sub(issued_at) > 60 + || request.expires_at <= issued_at + || request + .valid_after + .is_some_and(|not_before| now_seconds < not_before) + { + return Err(KeyRevokeError::Forbidden); + } + // Both the target and nonce issuer use the same sorted row-lock + // namespace; global and scoped revokes cannot pass each other. + crate::key_authority::lock_targets( + &tx, + &[request.target_did.clone(), request.issuer_did.clone()], + true, + ) + .await + .map_err(|error| db_error(error, false))?; + let spaces = match &request.scope { + KeyRevokeScope::Global => Vec::new(), + KeyRevokeScope::Space { space_id } => vec![space_id.clone()], + }; + match crate::key_authority::check_principals( + &tx, + std::slice::from_ref(&request.issuer_did), + &spaces, + ) + .await + { + Ok(()) => {} + Err(error) if crate::key_authority::is_revoked_error(&error) => { + return Err(KeyRevokeError::Forbidden); + } + Err(error) => return Err(db_error(error, false)), + } + if let Some(row) = key_revocation::nonce::Entity::find_by_id(nonce_key.clone()) + .one(&tx) + .await + .map_err(|error| db_error(error, false))? + { + if row.authorization_hash == request.authorization_hash + && row.target_did == request.target_did + { + let receipt: key_revocation::KeyRevokeReceipt = + serde_json::from_slice(&row.receipt_bytes).map_err(|_| { + db_error(DbErr::Custom("key-receipt-invalid".into()), false) + })?; + if receipt.scope == request.scope && receipt.target_did == request.target_did { + return Ok(receipt); + } + } + return Err(KeyRevokeError::Conflict); + } + let scope_key = request.scope.storage_key(); + let existing_cutoff = + key_revocation::Entity::find_by_id((request.target_did.clone(), scope_key.clone())) + .one(&tx) + .await + .map_err(|error| db_error(error, false))?; + let revoked_at = match existing_cutoff { + Some(row) => row.revoked_at, + None => { + let cutoff = now_seconds; + key_revocation::ActiveModel { + target_did: Set(request.target_did.clone()), + scope_key: Set(scope_key), + revoked_at: Set(cutoff), + } + .insert(&tx) + .await + .map_err(|error| db_error(error, false))?; + cutoff + } + }; + let receipt = key_revocation::KeyRevokeReceipt { + revoked: true, + target_did: request.target_did.clone(), + scope: request.scope, + revoked_at, + }; + let receipt_bytes = serde_json::to_vec(&receipt) + .map_err(|_| db_error(DbErr::Custom("key-receipt-encode-invalid".into()), false))?; + key_revocation::nonce::ActiveModel { + issuer_did: Set(nonce_key.0), + nonce: Set(nonce_key.1), + authorization_hash: Set(request.authorization_hash.to_vec()), + target_did: Set(request.target_did), + receipt_bytes: Set(receipt_bytes), + } + .insert(&tx) + .await + .map_err(|error| db_error(error, false))?; + tx.commit().await.map_err(|error| db_error(error, true))?; + Ok(receipt) + } + pub async fn delegation_status( &self, target: Hash, @@ -1809,12 +2311,14 @@ where let mut roots = vec![target]; roots.extend(proofs.iter().copied().map(Hash::from)); - let _chain_guards = match self.acquire_chain_guards(&roots).await { + let _chain_guards = match self.guard_delegation_chains(&roots).await { Ok(guards) => guards, - Err(TxError::ChainTraversalLimitExceeded) => { + Err(DbErr::Custom(message)) + if message == "delegation-chain-traversal-limit-exceeded" => + { return Ok(Some(DelegationStatus::Unavailable)); } - Err(error) => return Err(error), + Err(error) => return Err(authority_error(error)), }; let principal = match revocation::control_proof_decision( @@ -1843,6 +2347,34 @@ where return Ok(None); } + // Check every key dependency before reporting even a generically + // revoked target. A revoked requester or historical policy signer + // cannot recover delegation metadata through the status route. + let mut principals = crate::auth_graph::load_chain_principals(&self.conn, &roots) + .await + .map_err(|_| TxError::::AuthorizationDenied)?; + principals.push(invoker.to_owned()); + let witness = match policy_cid_in_chain(&self.conn, &roots) + .await + .map_err(|_| TxError::::AuthorizationDenied)? + { + Some(policy_cid) => { + let dependencies = + crate::policy_witness::revocation_dependencies(&self.conn, &policy_cid) + .await + .map_err(|_| TxError::::AuthorizationDenied)?; + principals.extend(dependencies.principals.iter().cloned()); + Some(dependencies) + } + None => None, + }; + let spaces = abilities + .iter() + .filter_map(|ability| ability.resource.space().map(ToString::to_string)) + .collect::>(); + crate::key_authority::check_principals(&self.conn, &principals, &spaces) + .await + .map_err(|_| TxError::::AuthorizationDenied)?; if revocation::is_revoked(&self.conn, &target).await? { return Ok(Some(DelegationStatus::Revoked)); } @@ -1854,6 +2386,11 @@ where } Err(revocation::ChainTraversalError::Db(error)) => return Err(error.into()), } + if let Some(dependencies) = witness.as_ref() { + crate::policy_witness::check_signed_cids(&self.conn, dependencies) + .await + .map_err(|_| TxError::::AuthorizationDenied)?; + } let now = OffsetDateTime::now_utc(); if delegation @@ -2010,23 +2547,57 @@ where .as_ref() .and_then(|sync| sync.retention_grant), ); - let authz_start = Instant::now(); - let closure_start = Instant::now(); - let lock_keys = crate::auth_graph::load_chain_lock_ids(&self.conn, &guard_roots) + let (guard_chain_ids, _) = crate::auth_graph::load_closure_edges(&self.conn, &guard_roots) .await - .map_err(|error| match error { - revocation::ChainTraversalError::Db(error) => TxError::Db(error), - revocation::ChainTraversalError::LimitExceeded => { - TxError::ChainTraversalLimitExceeded + .map_err(|error| { + TxStoreError::Tx(match error { + revocation::ChainTraversalError::Db(error) => TxError::Db(error), + revocation::ChainTraversalError::LimitExceeded => { + TxError::ChainTraversalLimitExceeded + } + }) + })?; + let policy_cid = if guard_roots == roots { + policy_cid_from_ids(&self.conn, &guard_chain_ids).await + } else { + policy_cid_in_chain(&self.conn, &roots).await + } + .map_err(|error| { + TxStoreError::Tx(match &error { + DbErr::Custom(message) if message.starts_with("policy-session-proof-") => { + TxError::AuthorizationDenied } - }); + _ => TxError::Db(error), + }) + })?; + let witness_dependencies = match policy_cid.as_deref() { + Some(policy_cid) => Some( + crate::policy_witness::revocation_dependencies(&self.conn, policy_cid) + .await + .map_err(|error| { + TxStoreError::Tx(match error { + crate::policy_witness::WitnessError::Database(source) => { + TxError::Db(source) + } + _ => TxError::AuthorizationDenied, + }) + })?, + ), + None => None, + }; + let authz_start = Instant::now(); + let closure_start = Instant::now(); + let authority = + crate::auth_graph::invocation_authority_for_closure(&self.conn, guard_chain_ids) + .await + .map_err(TxError::Db); crate::telemetry::observe_stage( crate::telemetry::InvocationStage::ChainClosureQuery, - crate::telemetry::StageOutcome::from(lock_keys.is_ok()), + crate::telemetry::StageOutcome::from(authority.is_ok()), closure_start.elapsed(), ); - let lock_keys = match lock_keys { - Ok(keys) => keys, + let (mut lock_keys, mut principals) = match authority { + Ok(authority) => authority, Err(error) => { crate::telemetry::observe_stage( crate::telemetry::InvocationStage::AuthorizationGraphLoad, @@ -2036,13 +2607,48 @@ where return Err(TxStoreError::Tx(error)); } }; - // TC-324: invocations take the chain guards SHARED. The full ancestor - // closure is still guarded and the guards are still held through - // commit, so a revocation (which takes them exclusive) remains - // serialized against this authorization decision. What is dropped is - // invocation-vs-invocation exclusion, which the revocation-ordering - // invariant never depended on. - let _chain_guards = self.acquire_shared_chain_guards_for_keys(lock_keys).await; + principals.push(invocation.0.invoker.clone()); + if let Some(dependencies) = &witness_dependencies { + principals.extend(dependencies.principals.iter().cloned()); + } + principals.sort_unstable(); + principals.dedup(); + let mut affected_spaces = invocation + .0 + .spaces() + .map(ToString::to_string) + .collect::>(); + if affected_spaces.is_empty() + || invocation + .0 + .capabilities + .iter() + .any(|cap| cap.resource.space().is_none()) + { + if let Some(dependencies) = &witness_dependencies { + affected_spaces.extend(dependencies.spaces.iter().cloned()); + } + } + affected_spaces.sort_unstable(); + affected_spaces.dedup(); + if let Some(dependencies) = &witness_dependencies { + lock_keys.extend( + crate::auth_graph::load_chain_lock_ids(&self.conn, &dependencies.signed_cids) + .await + .map_err(|error| match error { + revocation::ChainTraversalError::Db(error) => TxError::Db(error), + revocation::ChainTraversalError::LimitExceeded => { + TxError::ChainTraversalLimitExceeded + } + })?, + ); + } + for principal in &principals { + lock_keys.push( + crate::key_authority::principal_lock_id(principal) + .map_err(|error| TxStoreError::Tx(TxError::Db(error)))?, + ); + } let mutation_keys = invocation .0 .capabilities @@ -2064,9 +2670,35 @@ where count: mutation_keys.len(), }); } + // DID resolution may perform network I/O. Verify a public read's + // envelope before acquiring its shared revocation guards; the guarded + // authorization below rechecks the signed time and live dependencies. + if mutation_keys.is_empty() && mode != InvokeMode::Admitted { + invocation::verify_invocation(&invocation.0.invocation) + .await + .map_err(TxError::::from)?; + } + // TC-324: invocations take the chain guards SHARED. The full ancestor + // closure is still guarded and the guards are still held through + // commit, so a revocation (which takes them exclusive) remains + // serialized against this authorization decision. What is dropped is + // invocation-vs-invocation exclusion, which the revocation-ordering + // invariant never depended on. + let chain_guards = self + .acquire_shared_chain_guards_bounded(lock_keys) + .await + .map_err(|error| TxStoreError::Tx(authority_error::(error)))?; if mutation_keys.is_empty() { return self - .invoke_read_only::(invocation, options, mode, sync_target) + .invoke_read_only::( + invocation, + options, + sync_target, + chain_guards, + principals, + affected_spaces, + witness_dependencies, + ) .await; } let _kv_object_guards = self.acquire_kv_object_guards(&mutation_keys).await; @@ -2137,10 +2769,37 @@ where begin_start.elapsed(), ); let tx = tx_result?; - // TC-732: order this transaction's event sequence under the - // per-space lock. On PostgreSQL this is an advisory lock, so the - // meeting legacy guard lock below is still the first row lock. + // TC-732: sequence first, then sorted signed proof and principal + // rows, then any meeting legacy row. Mutations use this order. crate::kv_sync::lock_space_sequences(&tx, &sequence_spaces).await?; + let (mut signed_ids, _) = crate::auth_graph::load_closure_edges(&tx, &guard_roots) + .await + .map_err(|error| match error { + revocation::ChainTraversalError::Db(error) => TxStoreError::Tx(TxError::Db(error)), + revocation::ChainTraversalError::LimitExceeded => { + TxStoreError::Tx(TxError::ChainTraversalLimitExceeded) + } + })?; + if let Some(dependencies) = &witness_dependencies { + signed_ids.extend(dependencies.signed_cids.iter().copied()); + } + crate::key_authority::lock_signed_dependencies(&tx, &signed_ids).await?; + if !signed_ids.is_empty() + && !revocation::effective_rows(&tx, &signed_ids) + .await? + .is_empty() + { + return Err(TxStoreError::Tx(TxError::AuthorizationDenied)); + } + crate::key_authority::lock_targets(&tx, &principals, false).await?; + crate::key_authority::check_principals(&tx, &principals, &affected_spaces) + .await + .map_err(|error| TxStoreError::Tx(authority_error::(error)))?; + if let Some(dependencies) = &witness_dependencies { + crate::policy_witness::check_signed_cids(&tx, dependencies) + .await + .map_err(|error| TxStoreError::Tx(witness_error::(error)))?; + } // On SQLite `begin_graph_transaction` already holds the write lock, // so no read-to-write upgrade race remains. Distinct spaces are // locked in a stable order to prevent cross-space deadlocks on PG. @@ -2355,16 +3014,18 @@ where } /// Execute a successful non-mutating invocation without entering the - /// single-writer transaction. Authorization and data access remain under - /// the caller's shared chain guards, which are held across this call and - /// released only after it returns; the response waits for the grouped - /// audit commit. + /// single-writer transaction. Shared guards cover authorization only; + /// data access and the read-audit acknowledgment never retain a guard. + #[allow(clippy::too_many_arguments)] async fn invoke_read_only( &self, invocation: Invocation, options: KvInvokeOptions, - mode: InvokeMode, sync_target: Option, + chain_guards: Vec, + principals: Vec, + affected_spaces: Vec, + witness_dependencies: Option, ) -> Result<(TransactResult, Vec>), TxStoreError> where B: ImmutableWriteStore + ImmutableReadStore, @@ -2405,27 +3066,20 @@ where } None => None, }; - // TC-409: an admitted invocation already had its signature verified - // once at the admission boundary; only re-check authorization and - // signed-time validity here rather than verifying the signature a - // second time. - match mode { - InvokeMode::Admitted => { - invocation::authorize_admitted(&self.conn, &invocation.0, now, sync_graph.as_ref()) - .await - .map_err(TxError::::from)? - } - InvokeMode::Public | InvokeMode::Internal | InvokeMode::MeetingPublication => { - invocation::verify_and_authorize_with_graph( - &self.conn, - &invocation.0, - now, - sync_graph.as_ref(), - ) + // Admitted envelopes were verified at their admission boundary; + // public read envelopes were verified before acquiring these guards. + // Recheck authorization and signed time at the guarded ordering point. + invocation::authorize_admitted(&self.conn, &invocation.0, now, sync_graph.as_ref()) + .await + .map_err(TxError::::from)?; + crate::key_authority::check_principals(&self.conn, &principals, &affected_spaces) + .await + .map_err(|error| TxStoreError::Tx(authority_error::(error)))?; + if let Some(dependencies) = &witness_dependencies { + crate::policy_witness::check_signed_cids(&self.conn, dependencies) .await - .map_err(TxError::::from)? - } - }; + .map_err(|error| TxStoreError::Tx(witness_error::(error)))?; + } let requested_spaces = invocation.0.spaces().cloned().collect::>(); if !requested_spaces.is_empty() { @@ -2437,6 +3091,9 @@ where return Err(TxError::SpaceNotFound.into()); } } + // The authorization decision is the read's ordering point. A revoke + // committed later may proceed while storage or the audit ACK waits. + drop(chain_guards); let sync_outcome = match (sync_target, sync_graph.as_ref()) { (Some((space, prefix, resource)), Some(graph)) => { @@ -2620,6 +3277,170 @@ where } } +/// Locate the admitted S0 inside an invocation's bounded signed proof +/// closure. Ordinary delegations have no policy session index. Two different +/// policies in one closure are ambiguous rather than interchangeable grants. +async fn policy_cid_in_chain( + db: &C, + roots: &[Hash], +) -> Result, DbErr> { + let (ids, _) = crate::auth_graph::load_closure_edges(db, roots) + .await + .map_err(|error| DbErr::Custom(error.to_string()))?; + policy_cid_from_ids(db, &ids).await +} + +async fn policy_cid_from_ids( + db: &C, + ids: &[Hash], +) -> Result, DbErr> { + if ids.is_empty() { + return Ok(None); + } + let session_ids = ids + .iter() + .map(|id| id.to_cid(0x55).to_string()) + .collect::>(); + let sessions = policy_v3_session::Entity::find() + .filter(policy_v3_session::Column::SessionCid.is_in(session_ids.iter().map(String::as_str))) + .all(db) + .await?; + match sessions.as_slice() { + [] => { + // A signed policy root in the closure without its admitted S0 + // index is missing provenance, not an ordinary delegation. + let has_policy_root = policy_v3_root::Entity::find() + .filter(policy_v3_root::Column::RootCid.is_in(session_ids)) + .one(db) + .await? + .is_some(); + if has_policy_root { + Err(DbErr::Custom("policy-session-proof-missing".into())) + } else { + Ok(None) + } + } + [session] if session.state == "admitted" => { + if policy_session_matches_signed_binding(db, session).await? { + Ok(Some(session.policy_cid.clone())) + } else { + Err(DbErr::Custom("policy-session-proof-ambiguous".into())) + } + } + _ => Err(DbErr::Custom("policy-session-proof-ambiguous".into())), + } +} + +/// The mutable S0 index cannot reassign a signed session to another policy, +/// recipient, or proof pair. The registration witness checks those roots +/// separately; this binds the exact S0 that selected that witness. +async fn policy_session_matches_signed_binding( + db: &C, + session: &policy_v3_session::Model, +) -> Result { + let Ok(cid) = Cid::try_from(session.session_cid.as_str()) else { + return Ok(false); + }; + let id = Hash::from(cid); + if id.to_cid(0x55).to_string() != session.session_cid + || crate::hash::hash(&session.authorization_bytes) != id + { + return Ok(false); + } + let Ok(TinyCloudDelegation::Ucan(ucan)) = + TinyCloudDelegation::from_bytes(&session.authorization_bytes) + else { + return Ok(false); + }; + let payload = ucan.payload(); + let Some(facts) = payload.facts.as_ref().filter(|facts| facts.len() == 1) else { + return Ok(false); + }; + let Some(fact) = facts[0].as_object() else { + return Ok(false); + }; + let Some(registration) = policy_v3_registration::Entity::find_by_id(session.policy_cid.clone()) + .one(db) + .await? + else { + return Ok(false); + }; + if fact.get("profile").and_then(serde_json::Value::as_str) != Some("policy-session-ucan/v1") + || fact.get("policyCid").and_then(serde_json::Value::as_str) + != Some(session.policy_cid.as_str()) + || fact.get("recipientDid").and_then(serde_json::Value::as_str) + != Some(session.recipient_did.as_str()) + || fact + .get("policyDelegationCid") + .and_then(serde_json::Value::as_str) + != Some(registration.policy_root_cid.as_str()) + || fact + .get("enforcementDelegationCid") + .and_then(serde_json::Value::as_str) + != Some(registration.enforcement_root_cid.as_str()) + || fact.get("nodeAudience").and_then(serde_json::Value::as_str) + != Some(payload.issuer.as_str()) + || payload.audience.as_str() != session.recipient_did + || payload.proof.len() != 2 + || payload.proof[0].to_string() != registration.policy_root_cid + || payload.proof[1].to_string() != registration.enforcement_root_cid + { + return Ok(false); + } + let Some(graph) = delegation::Entity::find_by_id(id).one(db).await? else { + return Ok(false); + }; + if graph.delegator.as_str() != payload.issuer.as_str() + || graph.delegatee != session.recipient_did + || graph + .facts + .as_ref() + .and_then(|facts| facts.0.get("xyz.tinycloud.policy/session-fact")) + != Some(&facts[0]) + { + return Ok(false); + } + let persisted = parent_delegations::Entity::find() + .filter(parent_delegations::Column::Child.eq(id)) + .all(db) + .await?; + let signed = [Hash::from(payload.proof[0]), Hash::from(payload.proof[1])]; + Ok(signed[0] != signed[1] + && persisted.len() == 2 + && persisted.iter().any(|edge| edge.parent == signed[0]) + && persisted.iter().any(|edge| edge.parent == signed[1])) +} + +/// A generic CID revoke must conflict with recovery proofs that read any +/// equivalent signed statement in another process. Lock all stored aliases +/// before validating/persisting the revocation, not only the submitted CID. +async fn lock_revocation_alias_rows(tx: &DatabaseTransaction, target: Hash) -> Result<(), DbErr> { + if tx.get_database_backend() == DbBackend::Sqlite { + return Ok(()); + } + let canonical = delegation_cid_alias::Entity::find_by_id(target) + .one(tx) + .await? + .map_or(target, |alias| alias.canonical); + let mut ids = delegation_cid_alias::Entity::find() + .filter(delegation_cid_alias::Column::Canonical.eq(canonical)) + .all(tx) + .await? + .into_iter() + .map(|alias| alias.id) + .collect::>(); + ids.push(target); + ids.sort_unstable(); + ids.dedup(); + for id in ids { + delegation::Entity::find_by_id(id) + .lock_exclusive() + .one(tx) + .await?; + } + Ok(()) +} + /// The `(space, prefix, resource)` a `tinycloud.kv/sync` invocation reads. type KvSyncTarget = (SpaceId, Path, Resource); @@ -6511,9 +7332,13 @@ mod test { /// Resolve the ancestor closure exactly as `invoke_with_options_mode` /// does, then take the guards shared — the production invocation path. async fn invocation_chain_keys(db: &TestDb, roots: &[Hash]) -> Vec { - crate::auth_graph::load_chain_lock_ids(&db.conn, roots) + let (signed, _) = crate::auth_graph::load_closure_edges(&db.conn, roots) .await - .expect("chain closure query") + .expect("signed chain closure"); + crate::auth_graph::invocation_authority_for_closure(&db.conn, signed) + .await + .expect("canonical alias locks") + .0 } async fn acquire_invocation_guards(db: &TestDb, roots: &[Hash]) -> Vec { @@ -6697,11 +7522,11 @@ mod test { drop(after); } - /// A second node process has a different lock registry but the same durable - /// authorization graph. Its revoke ACK must not overtake a read admitted - /// on the first connection, and a new admission after ACK must see revoke. + /// The in-process guard registry is not shared between database handles. + /// A committed CID revoke must still be visible to another connection and + /// after a cold restart; simultaneous cross-process reads are TC-1216. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn independent_connections_fence_revocation_ack_and_later_admission() { + async fn independent_connections_observe_committed_cid_revoke_after_ack() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("revoke-cutoff.db"); let make_db = |conn| async move { @@ -6709,55 +7534,186 @@ mod test { conn, MemoryStore::default(), StaticSecret::new([0u8; 32].to_vec()).unwrap(), - ).await.unwrap() + ) + .await + .unwrap() }; - let admitting = make_db(sqlite_write_pool( - &path, std::time::Duration::from_secs(5), 2 - ).await).await; - let revoking = make_db(sqlite_write_pool( - &path, std::time::Duration::from_secs(5), 2 - ).await).await; + let admitting = + make_db(sqlite_write_pool(&path, std::time::Duration::from_secs(5), 2).await).await; + let revoking = + make_db(sqlite_write_pool(&path, std::time::Duration::from_secs(5), 2).await).await; insert_chain_guard_actors(&admitting).await; let (parent, child) = insert_test_chain(&admitting, "cross-connection-cutoff").await; - let in_flight = admitting.guard_delegation_chains(&[child]).await.unwrap(); - assert!(admitting.delegation_chains_unrevoked(&[child.to_cid(0x55)]).await.unwrap()); + let before = admitting.guard_delegation_chains(&[child]).await.unwrap(); + assert!(admitting + .delegation_chains_unrevoked(&[child.to_cid(0x55)]) + .await + .unwrap()); + drop(before); + let guards = revoking + .acquire_chain_guards(&[parent]) + .await + .ok() + .expect("exclusive revoke chain guard"); + insert_test_revocation(&revoking, "cross-connection-revoke", parent).await; + drop(guards); + let after_ack = admitting.guard_delegation_chains(&[child]).await.unwrap(); + assert!( + !admitting + .delegation_chains_unrevoked(&[child.to_cid(0x55)]) + .await + .unwrap(), + "a new admission after ACK cannot reuse the signed child" + ); + drop(after_ack); + // A fresh database connection has no in-memory guard/cache from the + // admission and must still see the persisted ancestor cutoff. + let reopened = sqlite_write_pool(&path, std::time::Duration::from_secs(5), 2).await; + assert_eq!( + revocation::first_revoked_ancestor(&reopened, &child) + .await + .unwrap(), + Some(parent.to_cid(0x55).to_string()), + "a cold connection must honor the committed revoke" + ); + } + + /// The alias's canonical CID is also the key-principal registry key. + /// No request may retain its shared guard while waiting to revoke that + /// key, and later reads must fail within the bounded guard deadline. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn key_revoke_alias_overlap_bounds_waiting_reads_and_commits_cutoff() { + use sea_orm::{ActiveModelTrait, ActiveValue::Set}; + let db = get_db().await.unwrap(); + insert_chain_guard_actors(&db).await; + let target = "did:key:chain-guard-owner".to_owned(); + let key = crate::key_authority::principal_lock_id(&target).unwrap(); + let alias = insert_test_delegation(&db, "key-revoke-alias-overlap").await; + delegation_cid_alias::ActiveModel { + id: Set(alias), + canonical: Set(key), + encrypted: Set(false), + } + .insert(&db.conn) + .await + .unwrap(); + let keys = invocation_chain_keys(&db, &[alias]).await; + assert!(keys.contains(&key)); + let in_flight = db.acquire_shared_chain_guards_for_keys(keys).await; + let now = OffsetDateTime::now_utc().unix_timestamp(); + let revoking = db.clone(); + let request = key_revocation::VerifiedKeyRevoke { + target_did: target.clone(), + issuer_did: target.clone(), + scope: key_revocation::KeyRevokeScope::Global, + nonce: "key-revoke-alias-overlap".into(), + authorization_hash: [7; 32], + proof_cids: Vec::new(), + not_before: now, + valid_after: None, + expires_at: now + 40, + }; + let revoke = tokio::spawn(async move { revoking.revoke_key(request).await }); + settle().await; + assert!( + !revoke.is_finished(), + "revoke must wait for the in-flight read decision" + ); + let timed_out = db + .acquire_shared_chain_guards_bounded(vec![key]) + .await + .unwrap_err(); + assert!(matches!(&timed_out, DbErr::Custom(message) + if message == "authorization-revoke-wait-timeout")); + assert!(matches!( + authority_error::(timed_out), + TxError::AuthorizationWaitTimeout + )); + drop(in_flight); + let receipt = tokio::time::timeout(std::time::Duration::from_secs(5), revoke) + .await + .expect("same-registry alias overlap cannot deadlock") + .unwrap() + .unwrap(); + assert!(receipt.revoked); + assert!(crate::key_authority::is_revoked_error( + &crate::key_authority::check_principals(&db.conn, &[target], &[]) + .await + .unwrap_err() + )); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn key_cutoff_serializes_across_connections_and_survives_restart() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("key-cutoff.db"); + let make_db = |conn| async move { + SpaceDatabase::new( + conn, + MemoryStore::default(), + StaticSecret::new([0u8; 32].to_vec()).unwrap(), + ) + .await + .unwrap() + }; + let admitting = + make_db(sqlite_write_pool(&path, std::time::Duration::from_secs(5), 2).await).await; + let revoking = + make_db(sqlite_write_pool(&path, std::time::Duration::from_secs(5), 2).await).await; + let target = "did:key:cross-connection-key-cutoff".to_owned(); + let tx = admitting.begin_graph_transaction().await.unwrap(); + crate::key_authority::lock_targets(&tx, std::slice::from_ref(&target), false) + .await + .unwrap(); + crate::key_authority::check_principals(&tx, std::slice::from_ref(&target), &[]) + .await + .unwrap(); + let now = OffsetDateTime::now_utc().unix_timestamp(); let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let revoked_target = target.clone(); let revoke = tokio::spawn(async move { started_tx.send(()).unwrap(); - let guards = revoking.acquire_chain_guards(&[parent]).await - .ok().expect("exclusive revoke chain guard"); - insert_test_revocation(&revoking, "cross-connection-revoke", parent).await; - drop(guards); + revoking + .revoke_key(key_revocation::VerifiedKeyRevoke { + target_did: revoked_target.clone(), + issuer_did: revoked_target, + scope: key_revocation::KeyRevokeScope::Global, + nonce: "cross-connection-key-cutoff".into(), + authorization_hash: [9; 32], + proof_cids: Vec::new(), + not_before: now, + valid_after: None, + expires_at: now + 40, + }) + .await }); started_rx.await.unwrap(); settle().await; assert!( !revoke.is_finished(), - "revoke ACK on another connection must wait for in-flight authorization" - ); - assert!( - admitting.delegation_chains_unrevoked(&[child.to_cid(0x55)]).await.unwrap(), - "the admitted read must finish before revoke commits" - ); - drop(in_flight); - tokio::time::timeout(std::time::Duration::from_secs(10), revoke) - .await.expect("revoke resumes after admission").unwrap(); - let after_ack = admitting.guard_delegation_chains(&[child]).await.unwrap(); - assert!( - !admitting.delegation_chains_unrevoked(&[child.to_cid(0x55)]).await.unwrap(), - "a new admission after ACK cannot reuse the signed child" - ); - drop(after_ack); - // Dropping every in-memory guard/cache in a new process must not make - // the old child usable again. - let restarted = make_db(sqlite_write_pool( - &path, std::time::Duration::from_secs(5), 2 - ).await).await; - assert!( - !restarted.delegation_chains_unrevoked(&[child.to_cid(0x55)]).await.unwrap(), - "a cold connection must honor the committed revoke" + "the cutoff cannot ACK before admission completes" ); + tx.commit().await.unwrap(); + let receipt = tokio::time::timeout(std::time::Duration::from_secs(5), revoke) + .await + .expect("cross-connection cutoff completes") + .unwrap() + .unwrap(); + assert!(receipt.revoked); + for db in [ + admitting, + make_db(sqlite_write_pool(&path, std::time::Duration::from_secs(5), 2).await).await, + ] { + let error = crate::key_authority::check_principals( + &db.conn, + std::slice::from_ref(&target), + &[], + ) + .await + .unwrap_err(); + assert!(crate::key_authority::is_revoked_error(&error)); + } } /// TC-324 requirement 3: delegation registration still mutually excludes diff --git a/tinycloud-core/src/key_authority.rs b/tinycloud-core/src/key_authority.rs new file mode 100644 index 00000000..67330e6e --- /dev/null +++ b/tinycloud-core/src/key_authority.rs @@ -0,0 +1,190 @@ +//! Durable principal revocation and the database-side admission cutoff. +//! +//! Delegation CIDs identify signed statements; key tombstones identify DID +//! principals. Never use a CID alias as a substitute for a principal. + +use crate::hash::{hash, Hash}; +use crate::models::{ + delegation, + key_revocation::{self, target}, +}; +use sea_orm::{ + sea_query::OnConflict, ActiveValue::Set, ColumnTrait, Condition, ConnectionTrait, + DatabaseTransaction, DbBackend, DbErr, EntityTrait, QueryFilter, QueryOrder, QuerySelect, +}; +use tinycloud_auth::{identity::principal_did, resource::SpaceId}; + +pub const REVOKED: &str = "authorization-key-revoked"; + +pub fn is_revoked_error(error: &DbErr) -> bool { + matches!(error, DbErr::Custom(message) if message == REVOKED) +} + +/// Domain-separated registry key for a DID principal. Every scoped operation +/// also participates in this global-principal guard. +pub fn principal_lock_id(principal: &str) -> Result { + let did = principal_did(principal) + .map_err(|_| DbErr::Custom("authorization-principal-invalid".into()))?; + Ok(hash( + format!("tinycloud/key-principal-guard/v1\0{did}").as_bytes(), + )) +} + +/// Fail closed if any signer or audience in the authorization dependency set +/// was explicitly revoked globally or in one of the affected spaces. An empty +/// scope list still checks the global tombstone. +pub async fn check_principals( + db: &C, + principals: &[String], + spaces: &[String], +) -> Result<(), DbErr> { + if principals.is_empty() { + return Err(DbErr::Custom("authorization-principal-missing".into())); + } + let principals = canonical_principals(principals)?; + let scope = spaces.iter().try_fold( + Condition::any().add(key_revocation::Column::ScopeKey.eq("global")), + |scope, raw| { + let space = raw + .parse::() + .map_err(|_| DbErr::Custom("authorization-scope-invalid".into()))?; + Ok::( + scope.add(key_revocation::Column::ScopeKey.eq(format!("space:{space}"))), + ) + }, + )?; + if key_revocation::Entity::find() + .filter(key_revocation::Column::TargetDid.is_in(principals)) + .filter(scope) + .one(db) + .await? + .is_some() + { + return Err(DbErr::Custom(REVOKED.into())); + } + Ok(()) +} +/// Legacy email shares bind an owner DID, not a namespaced SpaceId. No scoped +/// tombstone can be proven unrelated to that historical authority, so deny on +/// any tombstone for its verified signers and audiences. +pub async fn check_principals_any_scope( + db: &C, + principals: &[String], +) -> Result<(), DbErr> { + if principals.is_empty() { + return Err(DbErr::Custom("authorization-principal-missing".into())); + } + if key_revocation::Entity::find() + .filter(key_revocation::Column::TargetDid.is_in(canonical_principals(principals)?)) + .one(db) + .await? + .is_some() + { + return Err(DbErr::Custom(REVOKED.into())); + } + Ok(()) +} + +/// Check every signer and audience of a persisted proof closure, including +/// the requesting signer. The bounded graph load denies missing ancestry +/// rather than silently shrinking the key-revocation dependency set. +pub async fn check_invoker_chain( + db: &C, + roots: &[Hash], + invoker: &str, + spaces: &[String], +) -> Result<(), DbErr> { + let mut principals = crate::auth_graph::load_chain_principals(db, roots) + .await + .map_err(|error| DbErr::Custom(error.to_string()))?; + principals.push(invoker.to_owned()); + check_principals(db, &principals, spaces).await +} + +/// Establish a per-principal row before checking authority in a mutating +/// transaction. Shared row locks permit independent admissions; an exclusive +/// key revoke of that DID conflicts with both global and scoped effects in +/// other node processes. SQLite's write transaction serializes these writes; +/// it does not understand SELECT FOR SHARE. Call before the revocation check +/// and keep `tx` alive through the authorized effect/commit. +pub async fn lock_targets( + tx: &DatabaseTransaction, + principals: &[String], + exclusive: bool, +) -> Result<(), DbErr> { + let principals = canonical_principals(principals)?; + if principals.is_empty() { + return Ok(()); + } + // Insert and lock in canonical order across all node processes. A single + // batched insert plus one ordered shared/exclusive row-lock query avoids + // serial DB round trips for every signer in a proof closure. + match target::Entity::insert_many(principals.iter().cloned().map(|target_did| { + target::ActiveModel { + target_did: Set(target_did), + } + })) + .on_conflict( + OnConflict::column(target::Column::TargetDid) + .do_nothing() + .to_owned(), + ) + .exec(tx) + .await + { + Ok(_) | Err(DbErr::RecordNotInserted) => (), + Err(error) => return Err(error), + } + if tx.get_database_backend() != DbBackend::Sqlite { + let query = target::Entity::find() + .filter(target::Column::TargetDid.is_in(principals.iter().cloned())) + .order_by_asc(target::Column::TargetDid); + let rows = if exclusive { + query.lock_exclusive().all(tx).await? + } else { + query.lock_shared().all(tx).await? + }; + if rows.len() != principals.len() { + return Err(DbErr::Custom("authorization-key-lock-missing".into())); + } + } + Ok(()) +} + +fn canonical_principals(principals: &[String]) -> Result, DbErr> { + let mut canonical = principals + .iter() + .map(|did| { + principal_did(did).map_err(|_| DbErr::Custom("authorization-principal-invalid".into())) + }) + .collect::, _>>()?; + canonical.sort_unstable(); + canonical.dedup(); + Ok(canonical) +} + +/// Keep signed proof rows shared through a mutating admission or effect. +/// Generic CID revoke locks every canonical alias row exclusively, so this +/// conflicts across processes even if the submitted CID uses another alias. +pub async fn lock_signed_dependencies( + tx: &DatabaseTransaction, + cids: &[Hash], +) -> Result<(), DbErr> { + if tx.get_database_backend() == DbBackend::Sqlite { + return Ok(()); + } + let mut ordered = cids.to_vec(); + ordered.sort_unstable(); + ordered.dedup(); + for id in ordered { + if delegation::Entity::find_by_id(id) + .lock_shared() + .one(tx) + .await? + .is_none() + { + return Err(DbErr::Custom("authorization-proof-missing".into())); + } + } + Ok(()) +} diff --git a/tinycloud-core/src/lib.rs b/tinycloud-core/src/lib.rs index 3cacd735..8bab5900 100644 --- a/tinycloud-core/src/lib.rs +++ b/tinycloud-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod encryption; pub mod encryption_network; pub mod events; pub mod hash; +pub mod key_authority; pub mod keys; pub mod kv_sync; pub mod manifest; @@ -20,7 +21,9 @@ pub mod migrations; pub mod models; pub mod policy_authority; pub mod policy_capability; +pub mod policy_witness; mod read_audit; +pub mod recovery_authority; pub mod relationships; pub mod share_email; pub mod sql; diff --git a/tinycloud-core/src/migrations/m20261016_000000_policy_witness.rs b/tinycloud-core/src/migrations/m20261016_000000_policy_witness.rs new file mode 100644 index 00000000..328705a6 --- /dev/null +++ b/tinycloud-core/src/migrations/m20261016_000000_policy_witness.rs @@ -0,0 +1,40 @@ +//! Schema only. Startup backfills legacy policies transactionally; the +//! read-only count gate must run before this migration is released. +use crate::policy_witness::{Column, Entity}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Entity) + .if_not_exists() + .col(ColumnDef::new(Column::PolicyCid).string().not_null()) + .col(ColumnDef::new(Column::RootCid).string().not_null()) + .col(ColumnDef::new(Column::CapabilityIndex).integer().not_null()) + .col(ColumnDef::new(Column::Version).integer().not_null()) + .col(ColumnDef::new(Column::RegisteredAt).string().not_null()) + .col(ColumnDef::new(Column::WitnessBytes).blob().not_null()) + .col(ColumnDef::new(Column::WitnessDigestHex).string().not_null()) + .primary_key( + Index::create() + .col(Column::PolicyCid) + .col(Column::RootCid) + .col(Column::CapabilityIndex), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Entity).to_owned()) + .await + } +} diff --git a/tinycloud-core/src/migrations/m20261016_000001_key_revocation.rs b/tinycloud-core/src/migrations/m20261016_000001_key_revocation.rs new file mode 100644 index 00000000..629c15aa --- /dev/null +++ b/tinycloud-core/src/migrations/m20261016_000001_key_revocation.rs @@ -0,0 +1,112 @@ +use sea_orm_migration::prelude::*; + +use crate::models::key_revocation; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(key_revocation::target::Entity) + .if_not_exists() + .col( + ColumnDef::new(key_revocation::target::Column::TargetDid) + .string() + .not_null() + .primary_key(), + ) + .to_owned(), + ) + .await?; + manager + .create_table( + Table::create() + .table(key_revocation::Entity) + .if_not_exists() + .col( + ColumnDef::new(key_revocation::Column::TargetDid) + .string() + .not_null(), + ) + .col( + ColumnDef::new(key_revocation::Column::ScopeKey) + .string() + .not_null(), + ) + .col( + ColumnDef::new(key_revocation::Column::RevokedAt) + .big_integer() + .not_null(), + ) + .primary_key( + Index::create() + .col(key_revocation::Column::TargetDid) + .col(key_revocation::Column::ScopeKey), + ) + .to_owned(), + ) + .await?; + manager + .create_table( + Table::create() + .table(key_revocation::nonce::Entity) + .if_not_exists() + .col( + ColumnDef::new(key_revocation::nonce::Column::IssuerDid) + .string() + .not_null(), + ) + .col( + ColumnDef::new(key_revocation::nonce::Column::Nonce) + .string() + .not_null(), + ) + .col( + ColumnDef::new(key_revocation::nonce::Column::AuthorizationHash) + .binary() + .not_null(), + ) + .col( + ColumnDef::new(key_revocation::nonce::Column::TargetDid) + .string() + .not_null(), + ) + .col( + ColumnDef::new(key_revocation::nonce::Column::ReceiptBytes) + .binary() + .not_null(), + ) + .primary_key( + Index::create() + .col(key_revocation::nonce::Column::IssuerDid) + .col(key_revocation::nonce::Column::Nonce), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(key_revocation::nonce::Entity) + .to_owned(), + ) + .await?; + manager + .drop_table(Table::drop().table(key_revocation::Entity).to_owned()) + .await?; + manager + .drop_table( + Table::drop() + .table(key_revocation::target::Entity) + .to_owned(), + ) + .await + } +} diff --git a/tinycloud-core/src/migrations/mod.rs b/tinycloud-core/src/migrations/mod.rs index e8f7c992..1477a8d9 100644 --- a/tinycloud-core/src/migrations/mod.rs +++ b/tinycloud-core/src/migrations/mod.rs @@ -26,6 +26,8 @@ pub mod m20261007_000000_database_alias; pub mod m20261007_010000_database_identity_fence; pub mod m20261014_000000_delegation_cid_alias; pub mod m20261015_000000_delegation_cursor_index; +pub mod m20261016_000000_policy_witness; +pub mod m20261016_000001_key_revocation; pub struct Migrator; @@ -60,6 +62,8 @@ impl MigratorTrait for Migrator { Box::new(m20261007_010000_database_identity_fence::Migration), Box::new(m20261014_000000_delegation_cid_alias::Migration), Box::new(m20261015_000000_delegation_cursor_index::Migration), + Box::new(m20261016_000000_policy_witness::Migration), + Box::new(m20261016_000001_key_revocation::Migration), ] } } diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index 98fc2c03..8a163fbe 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -242,6 +242,15 @@ async fn validate( !is_root_authority(c, &delegation.delegator) }) .collect(); + // A recovery enrollment is a deliberately narrow target- or + // space-owner-signed root, not an arbitrary parentless key capability. + if delegation.parents.is_empty() + && delegation.capabilities.iter().any(is_key_revoke_capability) + && (delegation.capabilities.len() != 1 + || !is_key_recovery_root(&delegation.capabilities[0], &delegation.delegator)) + { + return Err(DelegationError::MissingParents.into()); + } match (dependant_caps.is_empty(), delegation.parents.is_empty()) { // no dependant caps, no parents needed, must be valid @@ -786,7 +795,64 @@ async fn ensure_parent_active(db: &C, parent_id: &Hash) -> R Ok(()) } +fn is_key_revoke_capability(cap: &util::Capability) -> bool { + cap.ability.as_ref().as_ref() == "tinycloud.key/revoke" +} + +fn is_key_recovery_root(cap: &util::Capability, delegator: &str) -> bool { + use crate::models::key_revocation::KeyRevokeScope; + use tinycloud_auth::resource::SpaceId; + + if !is_key_revoke_capability(cap) || cap.caveats.0.len() != 1 { + return false; + } + let Resource::Other(uri) = &cap.resource else { + return false; + }; + let Some(target) = uri.as_str().strip_prefix("urn:tinycloud:key:") else { + return false; + }; + if target.contains(['#', '?', '/']) + || tinycloud_auth::identity::canonicalize_did(target) + .ok() + .as_deref() + != Some(target) + { + return false; + } + let Some(caveat) = cap.caveats.0.get("0") else { + return false; + }; + let Some(scope) = caveat + .get("scope") + .cloned() + .and_then(|scope| serde_json::from_value::(scope).ok()) + else { + return false; + }; + if *caveat + != serde_json::json!({ + "version": 1, + "targetDid": target, + "scope": scope, + }) + { + return false; + } + match scope { + KeyRevokeScope::Global => did_principal_matches(target, delegator), + KeyRevokeScope::Space { space_id } => space_id + .parse::() + .ok() + .filter(|space| space.to_string() == space_id) + .is_some_and(|space| did_principal_matches(space.did().as_str(), delegator)), + } +} + fn is_root_authority(cap: &util::Capability, delegator: &str) -> bool { + if is_key_recovery_root(cap, delegator) { + return true; + } if cap .resource .space() diff --git a/tinycloud-core/src/models/key_revocation.rs b/tinycloud-core/src/models/key_revocation.rs new file mode 100644 index 00000000..d9a6a23a --- /dev/null +++ b/tinycloud-core/src/models/key_revocation.rs @@ -0,0 +1,117 @@ +use sea_orm::entity::prelude::*; +use sea_orm::DbErr; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, thiserror::Error)] +pub enum KeyRevokeError { + #[error("key revocation forbidden")] + Forbidden, + #[error("key revocation nonce conflict")] + Conflict, + #[error("key revocation database unavailable")] + Db { + #[source] + source: DbErr, + write_may_have_applied: bool, + }, +} + +/// The signed scope; a space revocation never confers global authority. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)] +pub enum KeyRevokeScope { + Global, + Space { + #[serde(rename = "spaceId")] + space_id: String, + }, +} + +impl KeyRevokeScope { + pub fn storage_key(&self) -> String { + match self { + Self::Global => "global".into(), + Self::Space { space_id } => format!("space:{space_id}"), + } + } +} + +/// Only the node's signature-verification boundary may construct this input. +/// Database authorization of the authenticated actor and proof chain follows +/// inside the transaction, before any nonce/tombstone diagnostics are exposed. +#[derive(Clone, Debug)] +pub struct VerifiedKeyRevoke { + pub target_did: String, + pub issuer_did: String, + pub scope: KeyRevokeScope, + pub nonce: String, + pub authorization_hash: [u8; 32], + pub proof_cids: Vec, + pub not_before: i64, + /// SIWE/CACAO's optional signed `nbf`; UCAN's `nbf` is `not_before` itself. + pub valid_after: Option, + pub expires_at: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyRevokeReceipt { + pub revoked: bool, + pub target_did: String, + pub scope: KeyRevokeScope, + pub revoked_at: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "key_revocation")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub target_did: String, + #[sea_orm(primary_key, auto_increment = false)] + pub scope_key: String, + pub revoked_at: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +/// A row per principal ensures that global and scoped writes targeting the +/// same DID serialize in all supported database backends. +pub mod target { + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "key_revocation_target")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub target_did: String, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} +} + +pub mod nonce { + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "key_revocation_nonce")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub issuer_did: String, + #[sea_orm(primary_key, auto_increment = false)] + pub nonce: String, + pub authorization_hash: Vec, + pub target_did: String, + pub receipt_bytes: Vec, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} +} diff --git a/tinycloud-core/src/models/mod.rs b/tinycloud-core/src/models/mod.rs index afefa687..8d5a6e15 100644 --- a/tinycloud-core/src/models/mod.rs +++ b/tinycloud-core/src/models/mod.rs @@ -18,6 +18,7 @@ pub mod hook_delivery; pub mod hook_subscription; pub mod invocation; pub mod invocation_replay; +pub mod key_revocation; pub mod kv_delete; pub mod kv_write; pub mod meeting_legacy_write_guard; diff --git a/tinycloud-core/src/policy_authority/database.rs b/tinycloud-core/src/policy_authority/database.rs index 37c769de..2fa6f952 100644 --- a/tinycloud-core/src/policy_authority/database.rs +++ b/tinycloud-core/src/policy_authority/database.rs @@ -2,7 +2,7 @@ use super::*; use crate::models::{policy_challenge, policy_delegation, policy_edge, policy_issuance_audit}; use sea_orm::{ sea_query::Expr, ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseTransaction, - DbBackend, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait, + DbBackend, DbErr, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait, }; #[derive(Clone)] @@ -13,6 +13,18 @@ pub struct DatabaseAuthorityStore { race_barrier: Option>, } +async fn check_signed_principals( + db: &C, + principals: &[String], +) -> Result<(), AuthorityError> { + crate::key_authority::check_principals_any_scope(db, principals) + .await + .map_err(|error| match error { + DbErr::Custom(_) => AuthorityError::DelegationAncestorRevoked, + _ => AuthorityError::AuthorityStateUnavailable, + }) +} + impl DatabaseAuthorityStore { pub fn new(db: impl Into) -> Self { Self { @@ -415,11 +427,14 @@ impl DatabaseAuthorityStore { ) -> Result<(), AuthorityError> { let mut stack = vec![(cid.to_string(), false, 0usize)]; let mut visited = HashSet::new(); + let mut principals = Vec::new(); while let Some((current, ancestor, depth)) = stack.pop() { if depth > MAX_ANCESTRY_DEPTH || !visited.insert(current.clone()) { return Err(AuthorityError::AncestryTooDeep); } let artifact = self.ensure_live(¤t, now, ancestor).await?; + principals.push(artifact.issuer_did.clone()); + principals.push(artifact.audience_did.clone()); let edges = self.edges(¤t).await?; validate_persisted_edges(&artifact, &edges)?; stack.extend( @@ -429,7 +444,7 @@ impl DatabaseAuthorityStore { .map(|edge| (edge.parent_cid, true, depth + 1)), ); } - Ok(()) + check_signed_principals(&self.db, &principals).await } /// Revalidates the complete ancestry/revocation chain for `cid` with @@ -446,12 +461,15 @@ impl DatabaseAuthorityStore { ) -> Result<(), AuthorityError> { let mut stack = vec![(cid.to_owned(), false, 0usize)]; let mut ancestry = HashMap::new(); + let mut principals = Vec::new(); while let Some((current, ancestor, depth)) = stack.pop() { if depth > MAX_ANCESTRY_DEPTH || ancestry.contains_key(¤t) { return Err(AuthorityError::AncestryTooDeep); } let row = delegation_row(transaction, ¤t, true).await?; let verified = ensure_live_model(&row, now, ancestor)?; + principals.push(verified.issuer_did.clone()); + principals.push(verified.audience_did.clone()); let edges = edges_on(transaction, ¤t).await?; validate_persisted_edges(&verified, &edges)?; ancestry.insert(current.clone(), verified); @@ -462,6 +480,7 @@ impl DatabaseAuthorityStore { .map(|edge| (edge.parent_cid, true, depth + 1)), ); } + check_signed_principals(transaction, &principals).await?; let mut locked_artifacts = HashMap::new(); locked_artifacts.extend(ancestry); validate_locked_ancestry(transaction, cid, &locked_artifacts).await diff --git a/tinycloud-core/src/policy_witness.rs b/tinycloud-core/src/policy_witness.rs new file mode 100644 index 00000000..f3de7dfe --- /dev/null +++ b/tinycloud-core/src/policy_witness.rs @@ -0,0 +1,943 @@ +//! Immutable, versioned evidence of every owner authority that could have +//! supported a registered policy at its original admission time. Expiry is +//! deliberately not a revocation signal: TC-1213 owns owner-self expiry. +use crate::{ + encryption::ColumnEncryption, + events::SerializedEvent, + hash::{hash, Hash}, + models::{ + abilities, delegation, delegation_cid_alias, policy_v3_registration, policy_v3_root, + revocation, + }, + policy_capability::{self, sql_caveat}, + relationships::parent_delegations, + types::{Caveats, Resource}, + util::{Capability, DelegationInfo}, +}; +use base64::{engine::general_purpose::URL_SAFE, Engine as _}; +use sea_orm::{ + entity::prelude::*, ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseTransaction, + EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use time::OffsetDateTime; +use tinycloud_auth::{ + authorization::TinyCloudDelegation, + identity::{did_principal_matches, principal_did}, +}; + +const VERSION: i32 = 1; +const MAX_NODES: usize = revocation::MAX_CHAIN_TRAVERSAL_NODES; +const MAX_CANDIDATES: usize = 64; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "policy_witness")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub policy_cid: String, + #[sea_orm(primary_key, auto_increment = false)] + pub root_cid: String, + #[sea_orm(primary_key, auto_increment = false)] + pub capability_index: i32, + pub version: i32, + pub registered_at: String, + pub witness_bytes: Vec, + pub witness_digest_hex: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Debug, thiserror::Error)] +pub enum WitnessError { + #[error("policy-witness-unavailable")] + Database(#[from] DbErr), + #[error("policy-witness-missing")] + Missing, + #[error("policy-witness-ambiguous")] + Ambiguous, + #[error("policy-witness-traversal-overflow")] + Overflow, + #[error("policy-witness-revoked")] + Revoked, + #[error("policy-witness-invalid")] + Invalid, +} + +/// Counter-only surface: neither serialization nor Debug includes identifiers. +#[derive(Clone, Copy, Default, Debug, Serialize, PartialEq, Eq)] +pub struct BackfillCounts { + pub policies: u64, + pub witnesses: u64, + pub missing: u64, + pub ambiguous: u64, + pub overflow: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Contributor { + pub cid: String, + pub canonical_cid: String, + pub signer: String, + pub audience: String, + pub proof_cids: Vec, + /// Exact verified capability/caveat projections from the signed grant. + pub signed_capabilities: Vec, + /// UCAN facts as signed (CACAO/SIWE has no UCAN facts). + pub signed_facts: Vec, + /// Exact signed bytes, kept independent of the mutable delegation graph. + pub signed_bytes: Vec, + pub not_before: Option, + pub expiry: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CandidateClosure { + pub tip_cid: String, + pub contributors: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PolicyWitness { + pub version: i32, + pub policy_cid: String, + pub root_cid: String, + pub capability_index: i32, + pub owner_principal: String, + pub capability: Capability, + pub registered_at: String, + /// Unknown/legacy by design. An owner-self natural expiry is NOT a + /// revocation and must not change policy liveness in this phase. + pub owner_self_classification: String, + pub root: Contributor, + pub candidates: Vec, +} + +fn principal(value: &str) -> Result { + principal_did(value).map_err(|_| WitnessError::Invalid) +} + +fn signed_event(bytes: &[u8]) -> Result { + let token = match std::str::from_utf8(bytes) { + Ok(text) + if text.split('.').count() == 3 + && text.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'=') + }) => + { + text.to_owned() + } + _ => URL_SAFE.encode(bytes), + }; + SerializedEvent::::from_header_ser::(&token) + .map_err(|_| WitnessError::Invalid) +} + +async fn signed_contributor( + bytes: &[u8], + cid: &str, + event: &crate::events::Delegation, +) -> Result { + if event.content_hash().to_cid(0x55).to_string() != cid || event.serialized_bytes() != bytes { + return Err(WitnessError::Invalid); + } + match &event.0.delegation { + TinyCloudDelegation::Ucan(ucan) => { + crate::did_resolution::verify_ucan_signature(ucan) + .await + .map_err(|_| WitnessError::Invalid)?; + } + TinyCloudDelegation::Cacao(cacao) => { + cacao.verify().await.map_err(|_| WitnessError::Invalid)?; + } + } + Ok(Contributor { + cid: cid.to_owned(), + canonical_cid: cid.to_owned(), + signer: principal(&event.0.delegator)?, + audience: principal(&event.0.delegate)?, + proof_cids: event.0.parents.iter().map(ToString::to_string).collect(), + signed_capabilities: event.0.capabilities.clone(), + signed_facts: match &event.0.delegation { + TinyCloudDelegation::Ucan(ucan) => ucan.payload().facts.clone().unwrap_or_default(), + TinyCloudDelegation::Cacao(_) => Vec::new(), + }, + signed_bytes: bytes.to_vec(), + not_before: event.0.not_before.map(format_time).transpose()?, + expiry: event.0.expiry.map(format_time).transpose()?, + }) +} + +fn format_time(at: OffsetDateTime) -> Result { + at.format(&time::format_description::well_known::Rfc3339) + .map_err(|_| WitnessError::Invalid) +} + +fn active_at(row: &Contributor, at: OffsetDateTime) -> Result { + let parse = |value: &str| { + OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) + .map_err(|_| WitnessError::Invalid) + }; + Ok(row + .not_before + .as_deref() + .map(parse) + .transpose()? + .is_none_or(|start| at >= start) + && row + .expiry + .as_deref() + .map(parse) + .transpose()? + .is_none_or(|end| at < end)) +} + +fn signed_policy_artifact(delegation: &TinyCloudDelegation) -> bool { + match delegation { + TinyCloudDelegation::Ucan(ucan) => ucan.payload().facts.as_ref().is_some_and(|facts| { + facts.iter().any(|fact| { + fact.as_object().is_some_and(|object| { + object.contains_key("xyz.tinycloud.policy/session-fact") + || object.contains_key("xyz.tinycloud.policy/root-profile") + }) + }) + }), + TinyCloudDelegation::Cacao(_) => false, + } +} + +fn sql_caveat(caveats: &Caveats) -> Option { + caveats.0.values().find_map(|value| { + sql_caveat::parse(value).ok().or_else(|| { + value + .as_object() + .and_then(|object| object.get("constrained-statements")) + .and_then(|inner| sql_caveat::parse(inner).ok()) + }) + }) +} + +fn covers(held: &Capability, requested: &Capability) -> bool { + if !requested.resource.extends(&held.resource) + || !policy_capability::ability_matches( + held.ability.as_ref().as_ref(), + requested.ability.as_ref().as_ref(), + ) + { + return false; + } + // A signed ReCap `[{}]` (or no caveat) is unconstrained. A valid + // policy selector that stays inside the requested native resource only + // narrows it; do not treat the absent parent selector as a conflict. + let unconstrained = held.caveats.0.is_empty() + || (held.caveats.0.len() == 1 + && held + .caveats + .0 + .values() + .all(|value| value.as_object().is_some_and(serde_json::Map::is_empty))); + if unconstrained + && policy_capability::selector_caveats_contain(&requested.caveats.0, &requested.caveats.0) + == Some(true) + { + return requested + .caveats + .0 + .values() + .next() + .and_then(|value| value.get("value")) + .and_then(serde_json::Value::as_str) + .and_then(|value| value.parse::().ok()) + .is_some_and(|selector| selector.extends(&requested.resource)); + } + if let Some(contained) = + policy_capability::selector_caveats_contain(&held.caveats.0, &requested.caveats.0) + { + return contained; + } + match (sql_caveat(&held.caveats), sql_caveat(&requested.caveats)) { + (Some(parent), Some(child)) => sql_caveat::contains(&parent, &child).is_ok(), + (Some(_), None) => false, + (None, _) => held.caveats.0.is_empty() || held.caveats == requested.caveats, + } +} + +fn root_authority(capability: &Capability, owner: &str) -> bool { + capability + .resource + .space() + .is_some_and(|space| did_principal_matches(space.did().as_str(), owner)) + || matches!(&capability.resource, Resource::Other(uri) + if uri.as_str().parse::() + .is_ok_and(|network| did_principal_matches(network.owner_did(), owner))) +} + +async fn closure( + db: &C, + tip: Hash, + owner: &str, + capability: &Capability, + at: OffsetDateTime, + encryption: Option<&ColumnEncryption>, +) -> Result, WitnessError> { + let mut pending = vec![tip]; + let mut visited = BTreeSet::new(); + let mut nodes = BTreeMap::new(); + let mut covering = HashMap::::new(); + while let Some(id) = pending.pop() { + if !visited.insert(id) { + continue; + } + if visited.len() > MAX_NODES { + return Err(WitnessError::Overflow); + } + let row = delegation::Entity::find_by_id(id) + .one(db) + .await? + .ok_or(WitnessError::Missing)?; + let alias = delegation_cid_alias::Entity::find_by_id(id) + .one(db) + .await? + .ok_or(WitnessError::Missing)?; + let bytes = if alias.encrypted { + encryption + .ok_or(WitnessError::Missing)? + .decrypt(&row.serialization) + .map_err(|_| WitnessError::Invalid)? + } else { + row.serialization.clone() + }; + if hash(&bytes) != id + || delegation_cid_alias::canonical_hash(&bytes).map_err(|_| WitnessError::Invalid)? + != alias.canonical + { + return Err(WitnessError::Ambiguous); + } + let event = signed_event(&bytes)?; + let mut contributor = + signed_contributor(&bytes, &id.to_cid(0x55).to_string(), &event).await?; + contributor.canonical_cid = alias.canonical.to_cid(0x55).to_string(); + if row.delegator != event.0.delegator || row.delegatee != event.0.delegate { + return Err(WitnessError::Ambiguous); + } + let links = parent_delegations::Entity::find() + .filter(parent_delegations::Column::Child.eq(id)) + .order_by_asc(parent_delegations::Column::Parent) + .all(db) + .await?; + let signed_parents: BTreeSet<_> = contributor + .proof_cids + .iter() + .map(|cid| { + tinycloud_auth::ipld_core::cid::Cid::try_from(cid.as_str()) + .map(Hash::from) + .map_err(|_| WitnessError::Invalid) + }) + .collect::>()?; + let graph_parents: BTreeSet<_> = links.iter().map(|link| link.parent).collect(); + if signed_parents.len() != contributor.proof_cids.len() || signed_parents != graph_parents { + return Err(WitnessError::Ambiguous); + } + // Do not classify expiry of a signed owner sign-in as loss of policy + // authority. TC-1213 supplies the missing signed self-session fact; + // this witness retains proof dependencies without changing expiry. + // A grant revoked before admission was not an authority candidate. + // A later revocation must not erase its historical dependency. + if revocation::effective_rows(db, &[id]) + .await? + .iter() + .any(|(_, revoked)| revoked.revoked_at.is_some_and(|time| time <= at)) + { + return Ok(None); + } + let abilities = abilities::Entity::find() + .filter(abilities::Column::Delegation.eq(id)) + .all(db) + .await?; + let signed_caps = &event.0.capabilities; + if abilities.len() != signed_caps.len() + || abilities.iter().any(|ability| { + !signed_caps.iter().any(|cap| { + ability.resource == cap.resource + && ability.ability == cap.ability + && ability.caveats == cap.caveats + }) + }) + { + return Err(WitnessError::Ambiguous); + } + if signed_policy_artifact(&event.0.delegation) { + return Ok(None); + } + let has_capability = signed_caps.iter().any(|held| covers(held, capability)); + covering.insert(id, has_capability); + for parent in graph_parents { + pending.push(parent); + } + nodes.insert(id, contributor); + } + let tip_row = nodes.get(&tip).ok_or(WitnessError::Missing)?; + if tip_row.audience != owner || !covering.get(&tip).copied().unwrap_or(false) { + return Ok(None); + } + // The recorded closure always contains *all* signed proof dependencies. + // A proof path must reach the true space owner and cover the capability + // throughout; unrelated signed branches remain captured for revocation. + fn reaches_root( + id: Hash, + nodes: &BTreeMap, + covering: &HashMap, + capability: &Capability, + visiting: &mut BTreeSet, + ) -> Result { + if !visiting.insert(id) { + return Err(WitnessError::Ambiguous); + } + let row = nodes.get(&id).ok_or(WitnessError::Missing)?; + let valid = if !covering.get(&id).copied().unwrap_or(false) { + false + } else if root_authority(capability, &row.signer) { + // An owner-signed grant has direct authority even when it cites + // an earlier sign-in as a dependency. Capture that signed proof + // for revocation without treating its audience as the signer. + true + } else if row.proof_cids.is_empty() { + false + } else { + let mut found = false; + for cid in &row.proof_cids { + let hash = Hash::from( + tinycloud_auth::ipld_core::cid::Cid::try_from(cid.as_str()) + .map_err(|_| WitnessError::Invalid)?, + ); + let parent = nodes.get(&hash).ok_or(WitnessError::Missing)?; + if parent.audience != row.signer { + return Err(WitnessError::Ambiguous); + } + found |= reaches_root(hash, nodes, covering, capability, visiting)?; + } + found + }; + visiting.remove(&id); + Ok(valid) + } + if !reaches_root(tip, &nodes, &covering, capability, &mut BTreeSet::new())? { + return Ok(None); + } + Ok(Some(CandidateClosure { + tip_cid: tip_row.cid.clone(), + contributors: nodes.into_values().collect(), + })) +} + +/// Call after both signed roots and graph rows have been inserted on the same +/// transaction. `at` is the original registered_at for historical backfill. +/// Revoked *now* but valid *at admission* candidates remain in the witness. +pub async fn capture_in_transaction( + db: &C, + policy_cid: &str, + owner_did: &str, + root_cids: &[String], + at: OffsetDateTime, + encryption: Option<&ColumnEncryption>, +) -> Result, WitnessError> { + if root_cids.len() != 2 || root_cids[0] == root_cids[1] { + return Err(WitnessError::Ambiguous); + } + let owner = principal(owner_did)?; + let mut result = Vec::new(); + let mut roots = Vec::new(); + for (index, root_cid) in root_cids.iter().enumerate() { + let root = policy_v3_root::Entity::find_by_id(root_cid.clone()) + .one(db) + .await? + .ok_or(WitnessError::Missing)?; + if root.policy_cid != policy_cid + || root.role + != if index == 0 { + "policy-authority" + } else { + "policy-enforcement" + } + { + return Err(WitnessError::Ambiguous); + } + let event = signed_event(&root.authorization_bytes)?; + let mut signed = signed_contributor(&root.authorization_bytes, root_cid, &event).await?; + let cid = tinycloud_auth::ipld_core::cid::Cid::try_from(root_cid.as_str()) + .map_err(|_| WitnessError::Invalid)?; + let alias = delegation_cid_alias::Entity::find_by_id(Hash::from(cid)) + .one(db) + .await? + .ok_or(WitnessError::Missing)?; + signed.canonical_cid = alias.canonical.to_cid(0x55).to_string(); + if signed.signer != owner || !signed.proof_cids.is_empty() || !active_at(&signed, at)? { + return Err(WitnessError::Ambiguous); + } + roots.push((root, signed, event)); + } + let candidates = delegation::Entity::find() + .filter( + sea_orm::Condition::any() + .add(delegation::Column::Delegatee.eq(owner_did)) + .add(delegation::Column::Delegatee.like(format!("{owner_did}#%"))), + ) + .limit((MAX_CANDIDATES * 8 + 1) as u64) + .all(db) + .await?; + if candidates.len() > MAX_CANDIDATES * 8 { + return Err(WitnessError::Overflow); + } + for (root, signed, event) in roots { + for (index, capability) in event.0.capabilities.into_iter().enumerate() { + if index > i32::MAX as usize { + return Err(WitnessError::Overflow); + } + let mut closures = Vec::new(); + for candidate in &candidates { + if !did_principal_matches(&candidate.delegatee, &owner) { + continue; + } + if let Some(found) = + closure(db, candidate.id, &owner, &capability, at, encryption).await? + { + closures.push(found); + if closures.len() > MAX_CANDIDATES { + return Err(WitnessError::Overflow); + } + } + } + closures.sort_by(|a, b| a.tip_cid.cmp(&b.tip_cid)); + if closures.is_empty() && !root_authority(&capability, &owner) { + return Err(WitnessError::Missing); + } + result.push(PolicyWitness { + version: VERSION, + policy_cid: policy_cid.to_owned(), + root_cid: root.root_cid.clone(), + capability_index: index as i32, + owner_principal: owner.clone(), + capability, + registered_at: format_time(at)?, + owner_self_classification: "unknown/legacy".to_owned(), + root: signed.clone(), + candidates: closures, + }); + } + } + if result.is_empty() { + return Err(WitnessError::Missing); + } + Ok(result) +} + +/// Idempotent only for byte-identical witness rows. Caller owns commit. +pub async fn persist_in_transaction( + db: &C, + witnesses: &[PolicyWitness], +) -> Result<(), WitnessError> { + if witnesses.is_empty() { + return Err(WitnessError::Missing); + } + for witness in witnesses { + let bytes = serde_json::to_vec(witness).map_err(|_| WitnessError::Invalid)?; + let digest = hex::encode(hash(&bytes).as_ref()); + if let Some(existing) = Entity::find_by_id(( + witness.policy_cid.clone(), + witness.root_cid.clone(), + witness.capability_index, + )) + .one(db) + .await? + { + if existing.version != VERSION + || existing.witness_bytes != bytes + || existing.witness_digest_hex != digest + { + return Err(WitnessError::Ambiguous); + } + continue; + } + ActiveModel { + policy_cid: Set(witness.policy_cid.clone()), + root_cid: Set(witness.root_cid.clone()), + capability_index: Set(witness.capability_index), + version: Set(VERSION), + registered_at: Set(witness.registered_at.clone()), + witness_bytes: Set(bytes), + witness_digest_hex: Set(digest), + } + .insert(db) + .await?; + } + Ok(()) +} +/// Read bounded indexed witness dependencies without traversing signed graphs +/// or re-resolving historical candidates. Callers may lock `principals` on +/// their transaction before checking and committing an authorized effect. +#[derive(Clone, Debug)] +pub struct WitnessDependencies { + pub principals: Vec, + pub spaces: Vec, + pub signed_cids: Vec, +} + +pub async fn revocation_dependencies( + db: &C, + policy_cid: &str, +) -> Result { + let registration = policy_v3_registration::Entity::find_by_id(policy_cid) + .one(db) + .await? + .ok_or(WitnessError::Missing)?; + let owner = principal(®istration.owner_did)?; + let rows = Entity::find() + .filter(Column::PolicyCid.eq(policy_cid)) + .all(db) + .await?; + if rows.is_empty() || rows.len() > MAX_NODES * 2 { + return Err(WitnessError::Missing); + } + let mut principals = BTreeSet::from([owner.clone()]); + let mut spaces = BTreeSet::new(); + let mut signed_cids = BTreeSet::new(); + let mut expected_aliases = BTreeMap::new(); + let by_root: BTreeMap<_, _> = rows + .iter() + .map(|row| ((row.root_cid.as_str(), row.capability_index), row)) + .collect(); + if by_root.len() != rows.len() { + return Err(WitnessError::Ambiguous); + } + let mut indexed = BTreeSet::new(); + for (root_cid, role) in [ + (®istration.policy_root_cid, "policy-authority"), + (®istration.enforcement_root_cid, "policy-enforcement"), + ] { + let root = policy_v3_root::Entity::find_by_id(root_cid.clone()) + .one(db) + .await? + .ok_or(WitnessError::Missing)?; + if root.policy_cid != policy_cid || root.role != role { + return Err(WitnessError::Ambiguous); + } + let event = signed_event(&root.authorization_bytes)?; + if event.content_hash().to_cid(0x55).to_string() != *root_cid { + return Err(WitnessError::Ambiguous); + } + let root_audience = principal(&event.0.delegate)?; + if principal(&event.0.delegator)? != owner { + return Err(WitnessError::Ambiguous); + } + let root_facts = match &event.0.delegation { + TinyCloudDelegation::Ucan(ucan) => ucan.payload().facts.clone().unwrap_or_default(), + TinyCloudDelegation::Cacao(_) => Vec::new(), + }; + for (index, capability) in event.0.capabilities.iter().enumerate() { + let key = (root_cid.as_str(), index as i32); + let row = by_root.get(&key).ok_or(WitnessError::Missing)?; + if !indexed.insert(key) + || row.version != VERSION + || row.registered_at != registration.registered_at + || row.witness_digest_hex != hex::encode(hash(&row.witness_bytes).as_ref()) + { + return Err(WitnessError::Ambiguous); + } + let witness: PolicyWitness = + serde_json::from_slice(&row.witness_bytes).map_err(|_| WitnessError::Invalid)?; + if witness.version != VERSION + || witness.policy_cid != policy_cid + || witness.root_cid != *root_cid + || witness.capability_index != index as i32 + || witness.registered_at != row.registered_at + || witness.owner_principal != owner + || witness.owner_self_classification != "unknown/legacy" + || witness.capability != *capability + || witness.root.cid != *root_cid + || witness.root.signed_bytes != root.authorization_bytes + || witness.root.signer != owner + || witness.root.audience != root_audience + || witness.root.signed_capabilities != event.0.capabilities + || witness.root.signed_facts != root_facts + || !witness.root.proof_cids.is_empty() + { + return Err(WitnessError::Ambiguous); + } + if let Some(space) = capability.resource.space() { + spaces.insert(space.to_string()); + } + let mut tips = BTreeSet::new(); + for closure in &witness.candidates { + if !tips.insert(&closure.tip_cid) + || closure.contributors.is_empty() + || !closure + .contributors + .iter() + .any(|node| node.cid == closure.tip_cid) + { + return Err(WitnessError::Ambiguous); + } + } + for contributor in std::iter::once(&witness.root).chain( + witness + .candidates + .iter() + .flat_map(|closure| closure.contributors.iter()), + ) { + if principal(&contributor.signer)? != contributor.signer + || principal(&contributor.audience)? != contributor.audience + { + return Err(WitnessError::Invalid); + } + principals.insert(contributor.signer.clone()); + principals.insert(contributor.audience.clone()); + let cid = tinycloud_auth::ipld_core::cid::Cid::try_from(contributor.cid.as_str()) + .map_err(|_| WitnessError::Invalid)?; + let canonical = tinycloud_auth::ipld_core::cid::Cid::try_from( + contributor.canonical_cid.as_str(), + ) + .map_err(|_| WitnessError::Invalid)?; + let id = Hash::from(cid); + if id.to_cid(0x55).to_string() != contributor.cid + || Hash::from(canonical).to_cid(0x55).to_string() != contributor.canonical_cid + { + return Err(WitnessError::Ambiguous); + } + if expected_aliases + .insert(id, Hash::from(canonical)) + .is_some_and(|old| old != Hash::from(canonical)) + { + return Err(WitnessError::Ambiguous); + } + signed_cids.insert(id); + if signed_cids.len() > MAX_NODES * MAX_CANDIDATES * 2 { + return Err(WitnessError::Overflow); + } + } + } + } + if rows.len() != indexed.len() { + return Err(WitnessError::Ambiguous); + } + let ids: Vec<_> = signed_cids.iter().copied().collect(); + for chunk in ids.chunks(200) { + let aliases = delegation_cid_alias::Entity::find() + .filter(delegation_cid_alias::Column::Id.is_in(chunk.iter().copied())) + .all(db) + .await?; + if aliases.len() != chunk.len() { + return Err(WitnessError::Missing); + } + for alias in aliases { + if expected_aliases.get(&alias.id) != Some(&alias.canonical) { + return Err(WitnessError::Ambiguous); + } + } + } + Ok(WitnessDependencies { + principals: principals.into_iter().collect(), + spaces: spaces.into_iter().collect(), + signed_cids: ids, + }) +} + +/// Never treat natural expiry or unknown/legacy owner-self classification as +/// explicit revocation. Run on the invocation/mint transaction after acquiring +/// shared `key_authority::lock_targets` for `revocation_dependencies.principals`. +pub async fn check_revocations( + db: &C, + policy_cid: &str, + _encryption: Option<&ColumnEncryption>, +) -> Result<(), WitnessError> { + let dependencies = revocation_dependencies(db, policy_cid).await?; + check_dependencies(db, &dependencies, &dependencies.spaces).await +} + +pub async fn check_revocations_for_spaces( + db: &C, + policy_cid: &str, + spaces: &[String], +) -> Result<(), WitnessError> { + let dependencies = revocation_dependencies(db, policy_cid).await?; + let spaces = if spaces.is_empty() { + &dependencies.spaces + } else { + spaces + }; + check_dependencies(db, &dependencies, spaces).await +} + +/// The caller must supply every space touched by this authorization. Signed +/// CID dependencies are checked regardless of scope; only principal +/// tombstones can be limited to the requested spaces. +pub async fn check_dependencies( + db: &C, + dependencies: &WitnessDependencies, + spaces: &[String], +) -> Result<(), WitnessError> { + crate::key_authority::check_principals(db, &dependencies.principals, spaces) + .await + .map_err(|_| WitnessError::Revoked)?; + check_signed_cids(db, dependencies).await +} + +/// Call after checking all dependency principals (including witness signers) +/// in the same guarded authorization window or write transaction. CID +/// revocation has no space scope and must always be checked. +pub async fn check_signed_cids( + db: &C, + dependencies: &WitnessDependencies, +) -> Result<(), WitnessError> { + for chunk in dependencies.signed_cids.chunks(200) { + if !revocation::effective_rows(db, chunk).await?.is_empty() { + return Err(WitnessError::Revoked); + } + } + Ok(()) +} + +async fn plan_backfill( + db: &C, + encryption: Option<&ColumnEncryption>, + collect: bool, +) -> Result<(BackfillCounts, Vec), WitnessError> { + let mut counts = BackfillCounts::default(); + let mut planned = Vec::new(); + let registrations = policy_v3_registration::Entity::find().all(db).await?; + for registration in registrations { + counts.policies += 1; + // Existing immutable witnesses are never reconstructed from a graph + // that may have gained other grants since the original admission. + // Dry-run must also work on a pre-migration read replica without + // reading the not-yet-created witness table. + if collect { + let existing = Entity::find() + .filter(Column::PolicyCid.eq(registration.policy_cid.clone())) + .all(db) + .await?; + if !existing.is_empty() { + match revocation_dependencies(db, ®istration.policy_cid).await { + Ok(_) => counts.witnesses += existing.len() as u64, + Err(WitnessError::Missing) => counts.missing += 1, + Err( + WitnessError::Ambiguous | WitnessError::Invalid | WitnessError::Revoked, + ) => counts.ambiguous += 1, + Err(WitnessError::Overflow) => counts.overflow += 1, + Err(error) => return Err(error), + } + continue; + } + } + let at = match OffsetDateTime::parse( + ®istration.registered_at, + &time::format_description::well_known::Rfc3339, + ) { + Ok(at) => at, + Err(_) => { + counts.ambiguous += 1; + continue; + } + }; + match capture_in_transaction( + db, + ®istration.policy_cid, + ®istration.owner_did, + &[ + registration.policy_root_cid, + registration.enforcement_root_cid, + ], + at, + encryption, + ) + .await + { + Ok(witnesses) => { + counts.witnesses += witnesses.len() as u64; + if collect { + planned.extend(witnesses); + } + } + Err(WitnessError::Missing) => counts.missing += 1, + Err(WitnessError::Ambiguous | WitnessError::Invalid) => counts.ambiguous += 1, + Err(WitnessError::Overflow) => counts.overflow += 1, + Err(error) => return Err(error), + } + } + Ok((counts, planned)) +} + +/// Read only: callers must print/serialize counts, never witness payloads. +pub async fn dry_run_backfill( + db: &C, + encryption: Option<&ColumnEncryption>, +) -> Result { + plan_backfill(db, encryption, false) + .await + .map(|(counts, _)| counts) +} + +/// Must be called only after the operator's Amos production snapshot gate. +/// Supply the caller's transaction; any unresolved policy aborts the whole run. +pub async fn backfill_in_transaction( + db: &DatabaseTransaction, + encryption: Option<&ColumnEncryption>, +) -> Result { + let (counts, witnesses) = plan_backfill(db, encryption, true).await?; + if counts.missing != 0 { + return Err(WitnessError::Missing); + } + if counts.ambiguous != 0 { + return Err(WitnessError::Ambiguous); + } + if counts.overflow != 0 { + return Err(WitnessError::Overflow); + } + if !witnesses.is_empty() { + persist_in_transaction(db, &witnesses).await?; + } + Ok(counts) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dry_run_counts_never_serialize_identifiers() { + let value = BackfillCounts { + policies: 2, + witnesses: 4, + missing: 1, + ambiguous: 0, + overflow: 0, + }; + let text = serde_json::to_string(&value).unwrap(); + assert_eq!( + text, + r#"{"policies":2,"witnesses":4,"missing":1,"ambiguous":0,"overflow":0}"# + ); + } + + #[test] + fn expiry_is_not_a_revocation_signal() { + let expired = Contributor { + cid: String::new(), + canonical_cid: String::new(), + signer: String::new(), + audience: String::new(), + proof_cids: vec![], + signed_capabilities: vec![], + signed_facts: vec![], + signed_bytes: vec![], + not_before: None, + expiry: Some(format_time(OffsetDateTime::UNIX_EPOCH).unwrap()), + }; + assert!(!active_at(&expired, OffsetDateTime::now_utc()).unwrap()); + // Revocation checks deliberately use signed IDs, not active_at. + } +} diff --git a/tinycloud-core/src/recovery_authority.rs b/tinycloud-core/src/recovery_authority.rs new file mode 100644 index 00000000..161ecfb5 --- /dev/null +++ b/tinycloud-core/src/recovery_authority.rs @@ -0,0 +1,381 @@ +//! Signed, live recovery enrollment checks for explicit key revocation. + +use std::collections::{HashMap, HashSet}; + +use sea_orm::{ + ColumnTrait, ConnectionTrait, DatabaseTransaction, DbBackend, DbErr, EntityTrait, QueryFilter, + QuerySelect, +}; +use time::OffsetDateTime; +use tinycloud_auth::{ + authorization::TinyCloudDelegation, + identity::{canonicalize_did, principal_did}, + ipld_core::cid::Cid, + resource::SpaceId, + ssi::jwk::Algorithm, +}; + +use crate::{ + auth_graph, + encryption::{maybe_decrypt, ColumnEncryption}, + hash::{hash, Hash}, + key_authority, + models::{ + abilities, delegation, delegation_cid_alias, invocation, key_revocation::VerifiedKeyRevoke, + revocation, + }, + types::Resource, + util::DelegationInfo, +}; + +/// A failed or ambiguous proof is a denial, not information about the target. +/// The caller must use the same transaction for this check and the tombstone. +/// Signatures are checked before shared SQL locks, so DID resolution cannot +/// hold a row lock while awaiting external work. +pub async fn is_authorized( + db: &DatabaseTransaction, + request: &VerifiedKeyRevoke, + now: OffsetDateTime, + encryption: Option<&ColumnEncryption>, +) -> Result { + if request.proof_cids.is_empty() + || request.proof_cids.len() > revocation::MAX_CHAIN_TRAVERSAL_NODES + { + return Ok(false); + } + let resource = format!("urn:tinycloud:key:{}", request.target_did); + let caveat = serde_json::json!({ + "version": 1, + "targetDid": &request.target_did, + "scope": &request.scope, + }); + let root_issuer = match &request.scope { + crate::models::key_revocation::KeyRevokeScope::Global => { + std::borrow::Cow::Borrowed(request.target_did.as_str()) + } + crate::models::key_revocation::KeyRevokeScope::Space { space_id } => { + let Ok(space) = space_id.parse::() else { + return Ok(false); + }; + if space.to_string() != *space_id { + return Ok(false); + } + std::borrow::Cow::Owned(space.did().to_string()) + } + }; + if canonicalize_did(&root_issuer).ok().as_deref() != Some(root_issuer.as_ref()) { + return Ok(false); + } + let mut roots = Vec::with_capacity(request.proof_cids.len()); + for proof in &request.proof_cids { + let Ok(cid) = proof.parse::() else { + return Ok(false); + }; + if cid.codec() != 0x55 || cid.to_string() != *proof { + return Ok(false); + } + let Some(id) = resolve_proof(db, cid).await? else { + return Ok(false); + }; + if roots.contains(&id) { + return Ok(false); + } + roots.push(id); + } + let (nodes, edges) = match auth_graph::load_closure_edges(db, &roots).await { + Ok(closure) => closure, + Err(auth_graph::ChainTraversalError::LimitExceeded) => return Ok(false), + Err(auth_graph::ChainTraversalError::Db(error)) => return Err(error), + }; + if nodes.is_empty() { + return Ok(false); + } + let rows = delegation::Entity::find() + .filter(delegation::Column::Id.is_in(nodes.iter().copied())) + .all(db) + .await?; + if rows.len() != nodes.len() { + return Ok(false); + } + let ability_rows = abilities::Entity::find() + .filter(abilities::Column::Delegation.is_in(nodes.iter().copied())) + .all(db) + .await?; + let mut stored_abilities: HashMap> = HashMap::new(); + for ability in ability_rows { + stored_abilities + .entry(ability.delegation) + .or_default() + .push(ability); + } + let mut signed = HashMap::with_capacity(rows.len()); + for row in &rows { + let Ok(serialization) = maybe_decrypt(encryption, &row.serialization) else { + return Ok(false); + }; + if hash(&serialization) != row.id { + return Ok(false); + } + let Ok(canonical) = delegation_cid_alias::canonical_hash(&serialization) else { + return Ok(false); + }; + let alias = delegation_cid_alias::Entity::find_by_id(row.id) + .one(db) + .await?; + if alias.as_ref().is_some_and(|alias| { + alias.canonical != canonical + || alias.encrypted != (row.serialization.first() == Some(&0x01)) + }) || (alias.is_none() && canonical != row.id) + { + return Ok(false); + } + let Ok(token) = TinyCloudDelegation::from_bytes(&serialization) else { + return Ok(false); + }; + // A recovery chain has only offline cryptographic methods. Never + // follow a did:web (or other network) issuer under a SQL row lock. + let valid_signature = match &token { + TinyCloudDelegation::Ucan(ucan) => { + ucan.header().algorithm == Algorithm::EdDSA + && ucan.payload().issuer.as_str().starts_with("did:key:") + && crate::did_resolution::verify_ucan_signature(ucan) + .await + .is_ok() + } + TinyCloudDelegation::Cacao(cacao) => { + cacao + .payload() + .iss + .to_string() + .starts_with("did:pkh:eip155:") + && cacao.verify().await.is_ok() + } + }; + if !valid_signature { + return Ok(false); + } + let Ok(info) = DelegationInfo::try_from(token) else { + return Ok(false); + }; + let expected_terminal = + matches!(info.delegation_mode, crate::util::DelegationMode::Terminal); + let facts_match = if expected_terminal { + row.facts.as_ref().is_some_and(|facts| { + facts.0.len() == 1 + && facts.0.get(crate::util::DelegationMode::FACT_KEY) + == Some(&serde_json::json!("terminal")) + }) + } else { + row.facts.is_none() + }; + if row.delegator != info.delegator + || row.delegatee != info.delegate + || row.expiry != info.expiry + || row.not_before != info.not_before + || row.issued_at != info.issued_at + || !facts_match + || row.issued_at.is_some_and(|issued| now < issued) + || row.expiry.is_some_and(|expiry| now >= expiry) + || row.not_before.is_some_and(|start| now < start) + || info.capabilities.len() != 1 + || !exact_capability(&info.capabilities[0], &resource, &caveat) + { + return Ok(false); + } + let Some(ability) = stored_abilities.get(&row.id) else { + return Ok(false); + }; + if ability.len() != 1 + || ability[0].resource != info.capabilities[0].resource + || ability[0].ability != info.capabilities[0].ability + || ability[0].caveats != info.capabilities[0].caveats + { + return Ok(false); + } + let mut signed_parents = Vec::with_capacity(info.parents.len()); + for parent in &info.parents { + let Some(id) = resolve_proof(db, *parent).await? else { + return Ok(false); + }; + signed_parents.push(id); + } + let mut persisted_parents = edges.get(&row.id).cloned().unwrap_or_default(); + signed_parents.sort_unstable(); + persisted_parents.sort_unstable(); + if signed_parents.windows(2).any(|pair| pair[0] == pair[1]) + || signed_parents != persisted_parents + { + return Ok(false); + } + signed.insert(row.id, info); + } + // The writer's /revoke transaction takes an exclusive lock on its target + // delegation row. Lock every dependency in deterministic order, then read + // revocation state under these locks before declaring authority live. + if db.get_database_backend() != DbBackend::Sqlite { + let mut ordered = nodes; + ordered.sort_unstable(); + for id in ordered { + let Some(locked) = delegation::Entity::find_by_id(id) + .lock_shared() + .one(db) + .await? + else { + return Ok(false); + }; + if rows.iter().find(|row| row.id == id) != Some(&locked) { + return Ok(false); + } + } + } + let mut principals = Vec::with_capacity(rows.len() * 2); + for row in &rows { + let (Ok(signer), Ok(audience)) = + (principal_did(&row.delegator), principal_did(&row.delegatee)) + else { + return Ok(false); + }; + principals.push(signer); + principals.push(audience); + } + // The issuer and target receive exclusive locks at the cutoff. Other + // recovery dependencies remain shared through that commit. + let shared = principals + .iter() + .filter(|did| *did != &request.target_did && *did != &request.issuer_did) + .cloned() + .collect::>(); + key_authority::lock_targets(db, &shared, false).await?; + let spaces = match &request.scope { + crate::models::key_revocation::KeyRevokeScope::Global => Vec::new(), + crate::models::key_revocation::KeyRevokeScope::Space { space_id } => vec![space_id.clone()], + }; + match key_authority::check_principals(db, &principals, &spaces).await { + Ok(()) => {} + Err(error) if key_authority::is_revoked_error(&error) => return Ok(false), + Err(error) => return Err(error), + } + let row_lookup = rows + .iter() + .map(|row| (row.id, row)) + .collect::>(); + for (child_id, parents) in &edges { + let Some(child) = row_lookup.get(child_id) else { + return Ok(false); + }; + for parent_id in parents { + let Some(parent) = row_lookup.get(parent_id) else { + return Ok(false); + }; + let linked = matches!( + (principal_did(&parent.delegatee), principal_did(&child.delegator)), + (Ok(parent_did), Ok(child_did)) if parent_did == child_did + ); + if !linked + || matches!( + signed[parent_id].delegation_mode, + crate::util::DelegationMode::Terminal + ) + { + return Ok(false); + } + } + } + let mut memo = HashMap::new(); + for id in row_lookup.keys() { + if !is_rooted(*id, &edges, &mut memo, &mut HashSet::new()) { + return Ok(false); + } + } + for row in &rows { + if revocation::is_revoked(db, &row.id).await? { + return Ok(false); + } + let info = &signed[&row.id]; + if info.parents.is_empty() { + if principal_did(&row.delegator).ok().as_deref() != Some(root_issuer.as_ref()) { + return Ok(false); + } + } else if edges.get(&row.id).is_none_or(Vec::is_empty) { + return Ok(false); + } + } + for root in &roots { + if principal_did(&signed[root].delegate).ok().as_deref() + != Some(request.issuer_did.as_str()) + { + return Ok(false); + } + } + // The existing holder verifier checks live chain windows, direct signer + // links and caveat containment; root authority is checked separately above + // because generic invocations do not treat key URNs as space roots. + let proofs = roots.iter().map(|id| id.to_cid(0x55)).collect::>(); + let caps = &signed[&roots[0]].capabilities; + match invocation::authorize_holder(db, &request.issuer_did, caps, &proofs, now).await { + Ok(()) => Ok(true), + Err(invocation::Error::Db(error)) => Err(error), + Err(_) => Ok(false), + } +} + +fn is_rooted( + id: Hash, + edges: &HashMap>, + memo: &mut HashMap, + path: &mut HashSet, +) -> bool { + if let Some(&rooted) = memo.get(&id) { + return rooted; + } + if !path.insert(id) { + return false; + } + let rooted = edges.get(&id).is_none_or(|parents| { + parents.is_empty() + || parents + .iter() + .all(|parent| is_rooted(*parent, edges, memo, path)) + }); + path.remove(&id); + memo.insert(id, rooted); + rooted +} + +fn exact_capability( + cap: &crate::util::Capability, + resource: &str, + caveat: &serde_json::Value, +) -> bool { + matches!(&cap.resource, Resource::Other(uri) if uri.as_str() == resource) + && cap.ability.to_string() == "tinycloud.key/revoke" + && cap.caveats.0.len() == 1 + && cap.caveats.0.get("0") == Some(caveat) +} + +/// Resolve a CID only as a CID: its alias is never a DID principal. Multiple +/// historical encodings for one canonical hash are deliberately ambiguous. +async fn resolve_proof(db: &C, cid: Cid) -> Result, DbErr> { + if cid.codec() != 0x55 { + return Ok(None); + } + let id = Hash::from(cid); + let aliases = delegation_cid_alias::Entity::find() + .filter(delegation_cid_alias::Column::Canonical.eq(id)) + .all(db) + .await?; + if aliases.len() > 1 { + return Ok(None); + } + let direct = delegation::Entity::find_by_id(id).one(db).await?.is_some(); + if let Some(alias) = aliases.first() { + if direct && alias.id != id { + return Ok(None); + } + Ok(Some(alias.id)) + } else if direct { + Ok(Some(id)) + } else { + Ok(None) + } +} diff --git a/tinycloud-core/src/share_email/bridge.rs b/tinycloud-core/src/share_email/bridge.rs index 10cc8c8b..ff0a5247 100644 --- a/tinycloud-core/src/share_email/bridge.rs +++ b/tinycloud-core/src/share_email/bridge.rs @@ -1193,6 +1193,22 @@ impl DatabaseAuthorityBridge117 { let statement = authorized_statement(scope, &policy, &delegation).inspect_err(|&failure| { tracing::error!(failure = ?failure, stage = "authorized_statement", "share scope validation failed"); })?; + let source_owner = match &scope.content_source { + super::types::ContentSource::Kv { space, .. } + | super::types::ContentSource::Sql { space, .. } => space.as_str(), + }; + let principals = vec![ + policy.issuer_did.clone(), + policy.audience_did.clone(), + delegation.issuer_did.clone(), + delegation.audience_did.clone(), + source_owner.to_owned(), + enforcer.as_str().to_owned(), + scope.node_audience.as_str().to_owned(), + ]; + crate::key_authority::check_principals_any_scope(tx, &principals) + .await + .map_err(|_| PortError::Denied)?; Ok((expiry, matcher, statement, bundle.internal_delegation_cid)) } } @@ -1936,4 +1952,240 @@ mod tests { .await; assert!(matches!(result, Err(PortError::Unavailable))); } + // The checked-in vector's ceiling hash uses the generator's single-capability + // preimage, whereas the #117 kernel hashes the canonical capability ARRAY. + // Re-sign the corrected parents and their dependent evidence with the + // deterministic test-only keys declared by the vector's build.mjs. + fn sign_fixture_parent(mut parent: PolicyDelegation) -> PolicyDelegation { + use k256::ecdsa::SigningKey; + use sha3::Keccak256; + + let mut unsigned = serde_json::to_value(&parent).unwrap(); + let object = unsigned.as_object_mut().unwrap(); + object.remove("delegationCid"); + object.remove("signature"); + let unsigned = jcs::canonicalize(&unsigned); + let digest = Sha256::digest( + [ + b"xyz.tinycloud.policy/enforcement-delegation/v1\0".as_slice(), + &unsigned, + ] + .concat(), + ); + let mut preimage = b"\x19Ethereum Signed Message:\n32".to_vec(); + preimage.extend_from_slice(&digest); + let signing = SigningKey::from_bytes((&[0x55; 32]).into()).unwrap(); + let (signature, recovery) = signing + .sign_prehash_recoverable(&Keccak256::digest(preimage)) + .unwrap(); + let mut signature_bytes = signature.to_bytes().to_vec(); + signature_bytes.push(recovery.to_byte() + 27); + parent.signature.value = URL_SAFE_NO_PAD.encode(signature_bytes); + + let mut cid_value = serde_json::to_value(&parent).unwrap(); + cid_value.as_object_mut().unwrap().remove("delegationCid"); + parent.delegation_cid = Cid::new_v1( + 0x55, + Code::Blake3_256.digest(&jcs::canonicalize(&cid_value)), + ) + .to_string(); + AuthorityArtifactVerifier + .verify(&jcs::canonicalize(&serde_json::to_value(&parent).unwrap())) + .expect("re-signed owner parent verifies"); + parent + } + + fn sign_fixture_status( + status: &mut Value, + parent_cid: &str, + node: &libp2p::identity::ed25519::Keypair, + ) { + status["parentCid"] = json!(parent_cid); + let mut unsigned = status.clone(); + unsigned.as_object_mut().unwrap().remove("signature"); + let mut signed = b"xyz.tinycloud.share/authority-status/v1\0".to_vec(); + signed.extend_from_slice(&jcs::canonicalize(&unsigned)); + status["signature"]["value"] = json!(URL_SAFE_NO_PAD.encode(node.sign(&signed))); + } + + fn sign_fixture_wrapper( + wrapper: &mut Value, + material: Value, + sender: &libp2p::identity::ed25519::Keypair, + ) { + let domain = b"xyz.tinycloud.share/authority-material-bundle/v1\0"; + assert_eq!(wrapper["domain"].as_str().unwrap().as_bytes(), domain); + let canonical = jcs::canonicalize(&material); + let mut signed = domain.to_vec(); + signed.extend_from_slice(&canonical); + let signature = sender.sign(&signed); + wrapper["message"] = material; + wrapper["jcs"] = json!(std::str::from_utf8(&canonical).unwrap()); + wrapper["messageDigest"] = json!(URL_SAFE_NO_PAD.encode(Sha256::digest(&canonical))); + wrapper["signedBytesDigest"] = json!(URL_SAFE_NO_PAD.encode(Sha256::digest(&signed))); + wrapper["signatureDigest"] = json!(URL_SAFE_NO_PAD.encode(Sha256::digest(&signature))); + wrapper["signature"]["value"] = json!(URL_SAFE_NO_PAD.encode(signature)); + } + + #[tokio::test] + async fn signed_share_authority_denies_owner_and_enforcer_after_key_revoke() { + use super::super::authority::AuthenticatedAuthorityMaterialProvider; + use crate::models::key_revocation; + + let fixture: Value = serde_json::from_str(include_str!( + "../../../tinycloud-node-server/tests/fixtures/email-claim-v1/positive.json" + )) + .unwrap(); + let scenario = &fixture["scenarios"][0]; + let artifact = |name| { + scenario["artifacts"] + .as_array() + .unwrap() + .iter() + .find(|artifact| artifact["name"] == name) + .unwrap() + .clone() + }; + let mut material = scenario["authorityMaterial"].clone(); + let mut parent_bytes = Vec::new(); + for key in ["policyAuthorityBytes", "policyEnforcementBytes"] { + let bytes = URL_SAFE_NO_PAD + .decode(material[key].as_str().unwrap()) + .unwrap(); + parent_bytes.push(serde_json::from_slice::(&bytes).unwrap()); + } + let ceiling = crate::policy_capability::requested_capabilities_hash_hex( + &parent_bytes[0] + .capabilities + .iter() + .map(parse_capability) + .collect::, _>>() + .unwrap(), + ); + let node = libp2p::identity::ed25519::Keypair::from( + libp2p::identity::ed25519::SecretKey::try_from_bytes([0x42; 32]).unwrap(), + ); + let sender = libp2p::identity::ed25519::Keypair::from( + libp2p::identity::ed25519::SecretKey::try_from_bytes([0x44; 32]).unwrap(), + ); + assert_eq!( + crate::keys::public_key_to_did_key(sender.public().into()), + material["senderDid"].as_str().unwrap() + ); + for (index, (bytes_key, cid_key)) in [ + ("policyAuthorityBytes", "policyAuthorityCid"), + ("policyEnforcementBytes", "policyEnforcementCid"), + ] + .into_iter() + .enumerate() + { + let mut parent = parent_bytes[index].clone(); + parent.facts.insert( + "xyz.tinycloud.policy/capabilityCeilingHashHex".to_owned(), + ceiling.clone(), + ); + let parent = sign_fixture_parent(parent); + let cid = parent.delegation_cid.clone(); + material[bytes_key] = + json!(URL_SAFE_NO_PAD + .encode(jcs::canonicalize(&serde_json::to_value(&parent).unwrap()))); + material[cid_key] = json!(cid.as_str()); + material["mapping"][cid_key] = json!(cid.as_str()); + sign_fixture_status(&mut material["statusObservations"][index], &cid, &node); + } + let material_digest = + Sha256Digest::from_bytes(Sha256::digest(jcs::canonicalize(&material)).into()); + let mut wrapper = artifact("authorityMaterial"); + sign_fixture_wrapper(&mut wrapper, material, &sender); + let provider = AuthenticatedAuthorityMaterialProvider::from_json( + &serde_json::to_vec(&json!({ + "authorityMaterial": wrapper, + "policy": artifact("policy"), + })) + .unwrap(), + ) + .expect("authenticated re-signed authority, status, and attestation"); + let now = + OffsetDateTime::parse(scenario["evaluationTime"].as_str().unwrap(), &Rfc3339).unwrap(); + let mut requested = scope(scenario["policyCid"].as_str().unwrap()); + requested.share_cid = ShareCid::parse(scenario["shareCid"].as_str().unwrap()).unwrap(); + requested.share_id = ShareId::parse(scenario["shareId"].as_str().unwrap()).unwrap(); + requested.delegation_cid = + Some(ShareDelegationCid::parse(scenario["delegationCid"].as_str().unwrap()).unwrap()); + requested.authority_material_handle = + AuthorityMaterialHandle::parse(scenario["authorityMaterialHandle"].as_str().unwrap()) + .unwrap(); + requested.authority_material_digest = material_digest; + requested.content_source_digest = + Sha256Digest::parse(scenario["sourceDigest"].as_str().unwrap()).unwrap(); + requested.content_source = serde_json::from_value(scenario["source"].clone()).unwrap(); + requested.action = serde_json::from_value(scenario["policy"]["action"].clone()).unwrap(); + requested.allowed_actions = vec![requested.action]; + requested.resource = ExactResource::Kv { + path: Path::parse(scenario["policy"]["resource"].as_str().unwrap()).unwrap(), + }; + let bundle = provider + .resolve_exact( + &requested.policy_cid, + requested.delegation_cid.as_ref().unwrap(), + &requested.authority_material_handle, + &requested.authority_material_digest, + ) + .await + .expect("exact signed authority material resolves"); + validate_share_policy_state(&bundle.policy_state, &requested) + .expect("signed policy grants the requested share scope"); + + for target in [ + scenario["authorityMaterial"]["policyOwnerDid"] + .as_str() + .unwrap(), + scenario["authorityMaterial"]["attestation"]["enforcerDid"] + .as_str() + .unwrap(), + ] { + let db = Database::connect("sqlite::memory:").await.unwrap(); + crate::migrations::Migrator::up(&db, None).await.unwrap(); + let authority = DatabaseAuthorityStore::new(db.clone()); + let bridge = DatabaseAuthorityBridge117::new(db.clone(), authority) + .with_authority_providers( + Arc::new(provider.clone()), + Arc::new(provider.status_provider()), + Arc::new(provider.attestation_provider()), + ); + + bridge + .validate_scope(&requested, now) + .await + .expect("signed authority is live before key revoke"); + key_revocation::ActiveModel { + target_did: Set( + "did:pkh:eip155:1:0x2222222222222222222222222222222222222222".to_owned(), + ), + scope_key: Set("global".to_owned()), + revoked_at: Set(now.unix_timestamp()), + } + .insert(&db) + .await + .unwrap(); + bridge + .validate_scope(&requested, now) + .await + .expect("unrelated principal tombstone does not revoke this authority"); + + key_revocation::ActiveModel { + target_did: Set(tinycloud_auth::identity::principal_did(target).unwrap()), + scope_key: Set("global".to_owned()), + revoked_at: Set(now.unix_timestamp()), + } + .insert(&db) + .await + .unwrap(); + assert_eq!( + bridge.validate_scope(&requested, now).await, + Err(PortError::Denied), + "signed authority must be denied after {target} is revoked" + ); + } + } } diff --git a/tinycloud-core/src/util.rs b/tinycloud-core/src/util.rs index 6bcea110..c98f7d07 100644 --- a/tinycloud-core/src/util.rs +++ b/tinycloud-core/src/util.rs @@ -74,18 +74,40 @@ fn extract_ucan_caps(caps: &UcanCapabilities) -> Vec) -> (Vec, Vec) { +fn extract_siwe_cap(c: SiweCap) -> (Vec, Vec) { let (c, p) = c.into_inner(); ( c.into_inner() .into_iter() - .flat_map(|(r, acs)| { - // r is UriString, acs is BTreeMap> - acs.into_keys() // Iterate over Ability keys - .map(|ability| Capability { - resource: Resource::from(r.clone()), - ability: ability.into(), - caveats: Caveats::default(), + .flat_map(|(resource, actions)| { + actions + .into_iter() + .map(|(ability, caveats)| { + // Existing SIWE capabilities retain their historical + // caveat behavior. Key-recovery authority is the only + // new action that requires its typed signed scope. + let caveats = if ability.to_string() == "tinycloud.key/revoke" { + Caveats( + caveats + .into_inner() + .into_iter() + .enumerate() + .map(|(index, value)| { + ( + index.to_string(), + serde_json::Value::Object(value.into_iter().collect()), + ) + }) + .collect(), + ) + } else { + Caveats::default() + }; + Capability { + resource: Resource::from(resource.clone()), + ability: ability.into(), + caveats, + } }) .collect::>() }) @@ -197,8 +219,9 @@ impl TryFrom for DelegationInfo { }, TinyCloudDelegation::Cacao(ref c) => { let m: Message = c.payload().clone().try_into()?; - // Use the public extract_and_verify, which returns Result>, VerificationError> - let maybe_siwe_cap = SiweCap::extract_and_verify(&m)?; + // Keep the signed typed caveat for key recovery while + // retaining legacy caveat semantics for other SIWE actions. + let maybe_siwe_cap = SiweCap::::extract_and_verify(&m)?; let (capabilities, parents) = match maybe_siwe_cap { Some(siwe_cap) => { diff --git a/tinycloud-core/src/write_hooks.rs b/tinycloud-core/src/write_hooks.rs index 7695382d..1315726a 100644 --- a/tinycloud-core/src/write_hooks.rs +++ b/tinycloud-core/src/write_hooks.rs @@ -148,19 +148,30 @@ pub async fn hook_register_authority_holds( .expect("valid hooks/register ability"), caveats, }; + let holder_principal = match tinycloud_auth::identity::principal_did(holder) { + Ok(principal) => principal, + Err(_) => return Ok(false), + }; + let scope = [space.to_string()]; match invocation::authorize_holder(db, holder, &[cap(Caveats(Default::default()))], &[], now) .await { - Ok(()) => return Ok(true), + Ok(()) => { + let principals = [holder_principal.clone(), space.did().to_string()]; + return match crate::key_authority::check_principals(db, &principals, &scope).await { + Ok(()) => Ok(true), + Err(error) if crate::key_authority::is_revoked_error(&error) => Ok(false), + Err(error) => Err(error), + }; + } Err(invocation::Error::Db(error)) => return Err(error), Err(invocation::Error::InvalidInvocation(_)) => {} } - let principal = - tinycloud_auth::identity::principal_did(holder).unwrap_or_else(|_| holder.to_string()); + let principal = holder_principal.as_str(); let candidates = delegation::Entity::find() .filter( Condition::any() - .add(delegation::Column::Delegatee.eq(&principal)) + .add(delegation::Column::Delegatee.eq(principal)) .add(delegation::Column::Delegatee.like(format!("{principal}#%"))) .add(delegation::Column::Delegatee.eq(holder)), ) @@ -219,7 +230,31 @@ pub async fn hook_register_authority_holds( ) .await { - Ok(()) => return Ok(true), + Ok(()) => { + let mut principals = + match crate::auth_graph::load_chain_principals(db, &[candidate.id]).await { + Ok(principals) => principals, + Err(crate::auth_graph::ChainTraversalError::LimitExceeded) => continue, + Err(crate::auth_graph::ChainTraversalError::Db(DbErr::Custom( + reason, + ))) if matches!( + reason.as_str(), + "authorization-chain-missing" | "authorization-principal-invalid" + ) => + { + continue; + } + Err(crate::auth_graph::ChainTraversalError::Db(error)) => { + return Err(error) + } + }; + principals.push(holder_principal.clone()); + match crate::key_authority::check_principals(db, &principals, &scope).await { + Ok(()) => return Ok(true), + Err(error) if crate::key_authority::is_revoked_error(&error) => continue, + Err(error) => return Err(error), + } + } Err(invocation::Error::Db(error)) => return Err(error), Err(invocation::Error::InvalidInvocation(_)) => {} } diff --git a/tinycloud-core/tests/policy_witness_legacy.rs b/tinycloud-core/tests/policy_witness_legacy.rs new file mode 100644 index 00000000..befefcf1 --- /dev/null +++ b/tinycloud-core/tests/policy_witness_legacy.rs @@ -0,0 +1,450 @@ +//! Synthetic pre-TC-1149 policies: signed historical bytes, not mocks. +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde_json::json; +use std::collections::BTreeMap; +use time::OffsetDateTime; +use tinycloud_auth::{ + authorization::{Cid, HeaderEncode}, + ssi::{ + claims::jwt::NumericDate, + jwk::{Algorithm, JWK}, + ucan::Payload, + }, + ucan_capabilities_object::Capabilities, +}; +use tinycloud_core::{ + hash::{hash, Hash}, + keys::{public_key_to_did_key, Keypair}, + migrations::Migrator, + models::{ + abilities, actor, delegation, delegation_cid_alias, policy_v3_registration, policy_v3_root, + revocation, + }, + policy_witness::{ + backfill_in_transaction, capture_in_transaction, check_revocations, dry_run_backfill, + persist_in_transaction, WitnessError, + }, + relationships::parent_delegations, + sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ConnectOptions, Database, DatabaseConnection, + EntityTrait, TransactionTrait, + }, + sea_orm_migration::MigratorTrait, + types::{Ability, Caveats, Resource}, +}; + +const ADMISSION: &str = "2026-01-01T00:00:10Z"; +const SHORT_EXPIRY: &str = "2026-01-01T00:01:00Z"; +const LONG_EXPIRY: &str = "2100-01-01T00:00:00Z"; + +fn date(value: &str) -> OffsetDateTime { + OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339).unwrap() +} + +fn signer(seed: u8) -> (JWK, String, String) { + let key = Keypair::ed25519_from_bytes([seed; 32]).unwrap(); + let public = key.clone().try_into_ed25519().unwrap().public().to_bytes(); + let did = public_key_to_did_key(key.public()); + let jwk = serde_json::from_value(json!({ + "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", + "x": URL_SAFE_NO_PAD.encode(public), + "d": URL_SAFE_NO_PAD.encode([seed; 32]), + })) + .unwrap(); + let fragment = format!("{did}#{}", did.trim_start_matches("did:key:")); + (jwk, did, fragment) +} + +fn token( + seed: u8, + audience: &str, + resource: &str, + proof: &[String], + expires: &str, + nonce: &str, + role: Option<&str>, +) -> String { + let (jwk, _, issuer) = signer(seed); + let mut caps = Capabilities::::new(); + caps.with_action( + resource.parse().unwrap(), + "tinycloud.kv/list".parse().unwrap(), + [BTreeMap::new()], + ); + Payload:: { + issuer: issuer.parse().unwrap(), + audience: audience.parse().unwrap(), + not_before: Some( + NumericDate::try_from_seconds((date(ADMISSION).unix_timestamp() - 60) as f64).unwrap(), + ), + expiration: NumericDate::try_from_seconds(date(expires).unix_timestamp() as f64).unwrap(), + nonce: Some(nonce.to_owned()), + facts: role.map(|role| vec![json!({"xyz.tinycloud.policy/root-profile": {"role": role}})]), + proof: proof + .iter() + .map(|cid| cid.parse::().unwrap()) + .collect(), + attenuation: caps, + } + .sign(Algorithm::EdDSA, &jwk) + .unwrap() + .encode() + .unwrap() +} + +fn cid(token: &str) -> String { + hash(token.as_bytes()).to_cid(0x55).to_string() +} + +fn canonical_id(token: &str) -> Hash { + let (_, bytes) = tinycloud_auth::authorization::TinyCloudDelegation::decode(token).unwrap(); + hash(&bytes) +} + +fn id(cid: &str) -> Hash { + Hash::from(cid.parse::().unwrap()) +} + +async fn database() -> DatabaseConnection { + let opts = ConnectOptions::new("sqlite::memory:") + .max_connections(1) + .to_owned(); + let db = Database::connect(opts).await.unwrap(); + Migrator::up(&db, None).await.unwrap(); + db +} + +async fn ensure_actor(db: &DatabaseConnection, did: &str) { + if actor::Entity::find_by_id(did.to_owned()) + .one(db) + .await + .unwrap() + .is_none() + { + actor::ActiveModel { + id: Set(did.to_owned()), + } + .insert(db) + .await + .unwrap(); + } +} + +async fn grant( + db: &DatabaseConnection, + seed: u8, + audience: &str, + resource: &str, + proof: &[String], + nonce: &str, +) -> String { + let serialized = token(seed, audience, resource, proof, SHORT_EXPIRY, nonce, None); + let (_, delegator, _) = signer(seed); + let name = cid(&serialized); + ensure_actor(db, &delegator).await; + ensure_actor(db, audience).await; + delegation::ActiveModel { + id: Set(id(&name)), + delegator: Set(delegator), + delegatee: Set(audience.to_owned()), + expiry: Set(Some(date(SHORT_EXPIRY))), + issued_at: Set(None), + not_before: Set(Some(date(ADMISSION) - time::Duration::seconds(60))), + facts: Set(None), + serialization: Set(serialized.as_bytes().to_vec()), + } + .insert(db) + .await + .unwrap(); + abilities::ActiveModel { + resource: Set(resource.parse::().unwrap()), + ability: Set(Ability::try_from("tinycloud.kv/list".to_owned()).unwrap()), + delegation: Set(id(&name)), + caveats: Set(Caveats::default()), + } + .insert(db) + .await + .unwrap(); + delegation_cid_alias::ActiveModel { + id: Set(id(&name)), + canonical: Set(canonical_id(&serialized)), + encrypted: Set(false), + } + .insert(db) + .await + .unwrap(); + for parent in proof { + parent_delegations::ActiveModel { + parent: Set(id(parent)), + child: Set(id(&name)), + } + .insert(db) + .await + .unwrap(); + } + name +} + +async fn root(db: &DatabaseConnection, owner_seed: u8, resource: &str, role: &str) -> String { + let serialized = token( + owner_seed, + "did:tinycloud:policy:legacy", + resource, + &[], + LONG_EXPIRY, + role, + Some(role), + ); + let name = cid(&serialized); + let owner = signer(owner_seed).1; + ensure_actor(db, &owner).await; + ensure_actor(db, "did:tinycloud:policy:legacy").await; + // A policy root is parentless by profile, even when its owner is an + // authorised grantee rather than the owner of this space. + delegation::ActiveModel { + id: Set(id(&name)), + delegator: Set(owner), + delegatee: Set("did:tinycloud:policy:legacy".to_owned()), + expiry: Set(Some(date(LONG_EXPIRY))), + issued_at: Set(None), + not_before: Set(Some(date(ADMISSION) - time::Duration::seconds(60))), + facts: Set(None), + serialization: Set(serialized.as_bytes().to_vec()), + } + .insert(db) + .await + .unwrap(); + delegation_cid_alias::ActiveModel { + id: Set(id(&name)), + canonical: Set(canonical_id(&serialized)), + encrypted: Set(false), + } + .insert(db) + .await + .unwrap(); + policy_v3_root::ActiveModel { + root_cid: Set(name.clone()), + policy_cid: Set("legacy-policy".into()), + role: Set(role.to_owned()), + authorization_bytes: Set(serialized.into_bytes()), + status_checkpoint_bytes: Set(None), + previous_checkpoint_digest_hex: Set(None), + status_sequence: Set(0), + admission_epoch: Set(0), + status_checked_at: Set(None), + status_fresh_until: Set(None), + revoked_at: Set(None), + revocation_bytes: Set(None), + } + .insert(db) + .await + .unwrap(); + name +} + +async fn legacy_policy(db: &DatabaseConnection) -> (String, Vec, String, String) { + let space = signer(1).1; + let owner = signer(7).1; + let intermediary = signer(2).1; + let resource = format!( + "tinycloud:key:{}:legacy/kv", + space.trim_start_matches("did:key:") + ); + let direct = grant(db, 1, &owner, &resource, &[], "direct").await; + let ancestor = grant(db, 1, &intermediary, &resource, &[], "ancestor").await; + let chain_tip = grant(db, 2, &owner, &resource, &[ancestor], "chain-tip").await; + let policy = root(db, 7, &resource, "policy-authority").await; + let enforcement = root(db, 7, &resource, "policy-enforcement").await; + policy_v3_registration::ActiveModel { + policy_cid: Set("legacy-policy".into()), + policy_bytes: Set(b"{}".to_vec()), + policy_digest_hex: Set("fixture".into()), + owner_did: Set(owner.clone()), + policy_root_cid: Set(policy.clone()), + enforcement_root_cid: Set(enforcement.clone()), + content_source_digest_hex: Set("fixture".into()), + native_projection_hash_hex: Set("fixture".into()), + attested_enforcer_binding_bytes: Set(b"{}".to_vec()), + registered_at: Set(ADMISSION.into()), + expires_at: Set(LONG_EXPIRY.into()), + } + .insert(db) + .await + .unwrap(); + (owner, vec![policy, enforcement], direct, chain_tip) +} + +#[tokio::test] +async fn legacy_short_signin_expiry_does_not_revoke_all_historical_candidates() { + let db = database().await; + let (owner, roots, direct, chained) = legacy_policy(&db).await; + let witnesses = + capture_in_transaction(&db, "legacy-policy", &owner, &roots, date(ADMISSION), None) + .await + .unwrap(); + assert_eq!(witnesses.len(), 2); + for witness in &witnesses { + assert_eq!(witness.owner_self_classification, "unknown/legacy"); + assert_eq!(witness.candidates.len(), 2); + assert!(witness + .candidates + .iter() + .any(|closure| closure.tip_cid == direct)); + assert!(witness + .candidates + .iter() + .any(|closure| closure.tip_cid == chained && closure.contributors.len() == 2)); + } + persist_in_transaction(&db, &witnesses).await.unwrap(); + assert!(date(SHORT_EXPIRY) < OffsetDateTime::now_utc()); + check_revocations(&db, "legacy-policy", None).await.unwrap(); + let dry = dry_run_backfill(&db, None).await.unwrap(); + assert_eq!( + ( + dry.policies, + dry.witnesses, + dry.missing, + dry.ambiguous, + dry.overflow + ), + (1, 2, 0, 0, 0) + ); + let tx = db.begin().await.unwrap(); + backfill_in_transaction(&tx, None).await.unwrap(); + tx.rollback().await.unwrap(); +} + +#[tokio::test] +async fn expired_signed_ancestor_remains_a_revocation_dependency_without_expiry_denial() { + let db = database().await; + let (owner, roots, direct, chained) = legacy_policy(&db).await; + let after_signin_expired = date(SHORT_EXPIRY) + time::Duration::seconds(1); + let witnesses = capture_in_transaction( + &db, + "legacy-policy", + &owner, + &roots, + after_signin_expired, + None, + ) + .await + .unwrap(); + for witness in &witnesses { + assert!(witness + .candidates + .iter() + .any(|candidate| candidate.tip_cid == direct)); + assert!(witness + .candidates + .iter() + .any(|candidate| candidate.tip_cid == chained && candidate.contributors.len() == 2)); + } +} + +#[tokio::test] +async fn revoking_one_of_multiple_historical_candidates_denies_the_policy() { + let db = database().await; + let (owner, roots, direct, chained) = legacy_policy(&db).await; + let witnesses = + capture_in_transaction(&db, "legacy-policy", &owner, &roots, date(ADMISSION), None) + .await + .unwrap(); + assert!(witnesses.iter().all(|witness| witness + .candidates + .iter() + .any(|candidate| candidate.tip_cid == chained))); + persist_in_transaction(&db, &witnesses).await.unwrap(); + revocation::ActiveModel { + id: Set(hash(b"historical-direct-cutoff")), + revoker: Set(signer(1).1), + revoked: Set(id(&direct)), + serialization: Set(Vec::new()), + revoked_at: Set(Some(OffsetDateTime::now_utc())), + } + .insert(&db) + .await + .unwrap(); + assert!(matches!( + check_revocations(&db, "legacy-policy", None).await, + Err(WitnessError::Revoked) + )); +} + +#[tokio::test] +async fn a_policy_cannot_borrow_another_owners_signed_candidates() { + let db = database().await; + let (_owner, roots, _, _) = legacy_policy(&db).await; + let unrelated = signer(9).1; + assert!(matches!( + capture_in_transaction( + &db, + "legacy-policy", + &unrelated, + &roots, + date(ADMISSION), + None + ) + .await, + Err(WitnessError::Ambiguous) + )); +} + +#[tokio::test] +async fn legacy_missing_signed_dependency_fails_closed() { + let db = database().await; + let (owner, roots, direct, _) = legacy_policy(&db).await; + delegation_cid_alias::Entity::delete_by_id(id(&direct)) + .exec(&db) + .await + .unwrap(); + assert!(matches!( + capture_in_transaction(&db, "legacy-policy", &owner, &roots, date(ADMISSION), None).await, + Err(WitnessError::Missing) + )); + let counts = dry_run_backfill(&db, None).await.unwrap(); + assert_eq!(counts.missing, 1); +} + +#[tokio::test] +async fn legacy_spoofed_proof_edge_is_ambiguous() { + let db = database().await; + let (owner, roots, direct, _) = legacy_policy(&db).await; + parent_delegations::ActiveModel { + parent: Set(id(&roots[0])), + child: Set(id(&direct)), + } + .insert(&db) + .await + .unwrap(); + assert!(matches!( + capture_in_transaction(&db, "legacy-policy", &owner, &roots, date(ADMISSION), None).await, + Err(WitnessError::Ambiguous) + )); + assert_eq!(dry_run_backfill(&db, None).await.unwrap().ambiguous, 1); +} + +#[tokio::test] +async fn legacy_candidate_overflow_is_never_silently_narrowed() { + let db = database().await; + let (owner, roots, _, _) = legacy_policy(&db).await; + let resource = format!( + "tinycloud:key:{}:legacy/kv", + signer(1).1.trim_start_matches("did:key:") + ); + for index in 0..64 { + grant( + &db, + 1, + &owner, + &resource, + &[], + &format!("candidate-{index}"), + ) + .await; + } + assert!(matches!( + capture_in_transaction(&db, "legacy-policy", &owner, &roots, date(ADMISSION), None).await, + Err(WitnessError::Overflow) + )); + assert_eq!(dry_run_backfill(&db, None).await.unwrap().overflow, 1); +} diff --git a/tinycloud-node-server/src/bin/policy-witness-dry-run.rs b/tinycloud-node-server/src/bin/policy-witness-dry-run.rs new file mode 100644 index 00000000..8e590715 --- /dev/null +++ b/tinycloud-node-server/src/bin/policy-witness-dry-run.rs @@ -0,0 +1,166 @@ +//! Read-only policy witness inventory. No migrations, alias backfills, or +//! signed artifacts are written; success output contains only four counts. +//! Run with the same TINYCLOUD_CONFIG_FILE / TINYCLOUD_* settings as the node. +use rocket::figment::{ + providers::{Env, Format, Serialized, Toml}, + Figment, +}; +use tinycloud::{config, resolve_keys}; +use tinycloud_core::{ + encryption::ColumnEncryption, + policy_witness::{dry_run_backfill, BackfillCounts}, + sea_orm::{ConnectOptions, ConnectionTrait, Database, DbBackend, Statement, TransactionTrait}, +}; + +fn config() -> Result { + let config_file = + std::env::var("TINYCLOUD_CONFIG_FILE").unwrap_or_else(|_| "tinycloud.toml".to_owned()); + let mut config = Figment::from(rocket::Config::default()) + .merge(Serialized::defaults(config::Config::default())) + .merge(Toml::file(config_file).nested()) + .merge(Env::prefixed("TINYCLOUD_").split("_").global()) + .merge(Env::prefixed("TINYCLOUD_").split("__").global()) + .merge(Env::prefixed("ROCKET_").global()) + .extract::() + .map_err(|_| ())?; + config.storage.resolve(); + Ok(config) +} + +fn readonly_url(url: &str) -> Result { + if url.starts_with("sqlite:") { + // A SQLite read-only open must not create a database (or be silently + // overridden by caller-supplied URI mode parameters). + if url.contains('?') || url.contains('#') || url.contains(":memory:") { + return Err(()); + } + Ok(format!("{url}?mode=ro")) + } else if url.starts_with("postgres://") + || url.starts_with("postgresql://") + || url.starts_with("mysql://") + { + Ok(url.to_owned()) + } else { + Err(()) + } +} + +async fn dry_run() -> Result { + let config = config()?; + tinycloud_core::did_resolution::configure( + tinycloud_core::did_resolution::DidResolutionPolicy { + network_methods: config.did_resolution.network_methods, + }, + ); + let key = resolve_keys(&config.keys).await.map_err(|_| ())?; + // Exactly the node's delegation-column context; no key material is logged. + let encryption = ColumnEncryption::new(key.derive_key(b"tinycloud/hooks/webhook-secrets")); + let database = readonly_url(config.storage.database())?; + let mut opts = ConnectOptions::new(database); + opts.max_connections(1) + .min_connections(1) + .sqlx_logging(false); + let conn = Database::connect(opts).await.map_err(|_| ())?; + // The SQLite connection was opened with mode=ro above. MySQL's session + // setting applies to BEGIN; verify the acquired transaction inherited it + // before issuing any inventory query, including after a pool reconnect. + let backend = conn.get_database_backend(); + match backend { + DbBackend::MySql => { + conn.execute_unprepared("SET SESSION TRANSACTION READ ONLY") + .await + .map_err(|_| ())?; + } + DbBackend::Sqlite => return scan_sqlite(&conn, &encryption).await, + DbBackend::Postgres => (), + } + let tx = conn.begin().await.map_err(|_| ())?; + match backend { + DbBackend::Postgres => { + tx.execute_unprepared("SET TRANSACTION READ ONLY") + .await + .map_err(|_| ())?; + } + DbBackend::MySql => { + let row = tx + .query_one(Statement::from_string( + backend, + "SELECT CAST(@@session.transaction_read_only AS SIGNED) AS read_only", + )) + .await + .map_err(|_| ())? + .ok_or(())?; + if row.try_get::("", "read_only").map_err(|_| ())? != 1 { + return Err(()); + } + } + DbBackend::Sqlite => return Err(()), + } + let counts = dry_run_backfill(&tx, Some(&encryption)) + .await + .map_err(|_| ())?; + tx.rollback().await.map_err(|_| ())?; + Ok(counts) +} + +async fn scan_sqlite( + conn: &tinycloud_core::sea_orm::DatabaseConnection, + encryption: &ColumnEncryption, +) -> Result { + dry_run_backfill(conn, Some(encryption)) + .await + .map_err(|_| ()) +} + +fn counts_json(counts: &BackfillCounts) -> String { + let unresolved = counts + .missing + .saturating_add(counts.ambiguous) + .saturating_add(counts.overflow); + let ok = counts.policies.saturating_sub(unresolved); + format!( + "{{\"ok\":{ok},\"missing\":{},\"ambiguous\":{},\"overflow\":{}}}", + counts.missing, counts.ambiguous, counts.overflow + ) +} + +#[tokio::main] +async fn main() { + match dry_run().await { + Ok(counts) => println!("{}", counts_json(&counts)), + Err(()) => { + eprintln!("policy witness dry-run unavailable"); + std::process::exit(1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sqlite_dry_run_forces_read_only_and_rejects_preexisting_modes() { + assert_eq!( + readonly_url("sqlite:./data/caps.db"), + Ok("sqlite:./data/caps.db?mode=ro".into()) + ); + assert!(readonly_url("sqlite:./data/caps.db?mode=rwc").is_err()); + assert!(readonly_url("sqlite::memory:").is_err()); + } + + #[test] + fn dry_run_output_contains_counts_only() { + let counts = BackfillCounts { + policies: 5, + witnesses: 10, + missing: 1, + ambiguous: 1, + overflow: 1, + }; + assert_eq!( + counts_json(&counts), + r#"{"ok":2,"missing":1,"ambiguous":1,"overflow":1}"# + ); + } +} diff --git a/tinycloud-node-server/src/key_revoke.rs b/tinycloud-node-server/src/key_revoke.rs new file mode 100644 index 00000000..e7f7fb22 --- /dev/null +++ b/tinycloud-node-server/src/key_revoke.rs @@ -0,0 +1,227 @@ +use rocket::{http::Status, serde::json::Json, State}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tinycloud_auth::{ + authorization::{HeaderEncode, TinyCloudDelegation}, + identity::{canonicalize_did, principal_did}, + resource::SpaceId, + siwe_recap::Capability as SiweRecap, +}; +use tinycloud_core::{ + encryption_network::EncryptionService, + models::key_revocation::{KeyRevokeError, KeyRevokeReceipt, KeyRevokeScope, VerifiedKeyRevoke}, +}; + +use crate::TinyCloud; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct KeyRevokeRequest { + target_did: String, + scope: KeyRevokeScope, + authorization: String, +} + +const MALFORMED: (Status, &str) = (Status::BadRequest, "key_revocation_invalid_request"); +const UNAUTHENTICATED: (Status, &str) = (Status::Unauthorized, "key_revocation_invalid_signature"); + +/// Parse the signed grant without allowing the unsigned request body to +/// broaden its scope or replace the principal named by its capability. +async fn verify_request( + request: KeyRevokeRequest, + node_did: &str, +) -> Result { + let target_did = request.target_did; + if target_did.len() > 255 + || target_did + .bytes() + .any(|byte| matches!(byte, b'#' | b'?' | b'/')) + || canonicalize_did(&target_did).ok().as_deref() != Some(&target_did) + { + return Err(MALFORMED); + } + if let KeyRevokeScope::Space { space_id } = &request.scope { + if space_id.len() > 249 + || space_id + .parse::() + .map_or(true, |space| space.to_string() != *space_id) + { + return Err(MALFORMED); + } + } + if request.authorization.len() > 16_384 { + return Err(MALFORMED); + } + let (authorization, bytes) = + TinyCloudDelegation::decode(&request.authorization).map_err(|_| UNAUTHENTICATED)?; + let expected_resource = format!("urn:tinycloud:key:{target_did}"); + let expected_caveat = + json!({ "version": 1, "targetDid": &target_did, "scope": &request.scope }); + + let (issuer, audience, nonce, not_before, valid_after, expires_at, proof_cids) = + match authorization { + TinyCloudDelegation::Ucan(ucan) => { + if ucan.header().algorithm != tinycloud_auth::ssi::jwk::Algorithm::EdDSA + || !ucan.payload().issuer.as_str().starts_with("did:key:") + { + return Err(UNAUTHENTICATED); + } + tinycloud_core::did_resolution::verify_ucan_signature(&ucan) + .await + .map_err(|_| UNAUTHENTICATED)?; + let payload = ucan.payload(); + let attenuation = + serde_json::to_value(&payload.attenuation).map_err(|_| MALFORMED)?; + if attenuation + != json!({(expected_resource): {"tinycloud.key/revoke": [expected_caveat]}}) + { + return Err(MALFORMED); + } + let nbf = payload.not_before.ok_or(MALFORMED)?.as_seconds(); + let exp = payload.expiration.as_seconds(); + ( + payload.issuer.to_string(), + payload.audience.to_string(), + payload.nonce.clone().ok_or(MALFORMED)?, + signed_seconds(nbf)?, + None, + signed_seconds(exp)?, + payload + .proof + .iter() + .map(ToString::to_string) + .collect::>(), + ) + } + TinyCloudDelegation::Cacao(cacao) => { + cacao.verify().await.map_err(|_| UNAUTHENTICATED)?; + let payload = cacao.payload(); + if !payload.iss.to_string().starts_with("did:pkh:eip155:") { + return Err(UNAUTHENTICATED); + } + let message: tinycloud_auth::cacaos::siwe::Message = + payload.clone().try_into().map_err(|_| MALFORMED)?; + let recap = SiweRecap::::extract_and_verify(&message) + .map_err(|_| MALFORMED)? + .ok_or(MALFORMED)?; + let (capabilities, proofs) = recap.into_inner(); + let mut entries = capabilities.into_inner().into_iter(); + let (resource, abilities) = entries.next().ok_or(MALFORMED)?; + if entries.next().is_some() || resource.as_str() != expected_resource { + return Err(MALFORMED); + } + let mut actions = abilities.into_iter(); + let (action, caveats) = actions.next().ok_or(MALFORMED)?; + if actions.next().is_some() || action.to_string() != "tinycloud.key/revoke" { + return Err(MALFORMED); + } + let caveats = caveats.into_inner(); + if caveats.len() != 1 + || serde_json::to_value(&caveats[0]).map_err(|_| MALFORMED)? != expected_caveat + { + return Err(MALFORMED); + } + let issued = payload.iat.as_ref(); + let expiry = payload.exp.as_ref().ok_or(MALFORMED)?.as_ref(); + if expiry <= issued || *expiry - *issued > time::Duration::seconds(60) { + return Err(MALFORMED); + } + ( + payload.iss.to_string(), + payload.aud.to_string(), + message.nonce, + ceil_second(issued)?, + payload + .nbf + .as_ref() + .map(|value| ceil_second(value.as_ref())) + .transpose()?, + expiry.unix_timestamp(), + proofs.iter().map(ToString::to_string).collect::>(), + ) + } + }; + let issuer_did = principal_did(&issuer).map_err(|_| UNAUTHENTICATED)?; + if audience != node_did + || !(issuer_did.starts_with("did:key:") || issuer_did.starts_with("did:pkh:eip155:")) + { + return Err(UNAUTHENTICATED); + } + if nonce.is_empty() + || nonce.len() > 128 + || expires_at + .checked_sub(not_before) + .is_none_or(|seconds| !(1..=60).contains(&seconds)) + || proof_cids.len() > 64 + { + return Err(MALFORMED); + } + Ok(VerifiedKeyRevoke { + target_did, + issuer_did, + scope: request.scope, + nonce, + authorization_hash: Sha256::digest(bytes).into(), + proof_cids, + not_before, + valid_after, + expires_at, + }) +} + +fn signed_seconds(seconds: f64) -> Result { + if seconds.is_finite() + && seconds.fract() == 0.0 + && seconds >= i64::MIN as f64 + && seconds < i64::MAX as f64 + { + Ok(seconds as i64) + } else { + Err(MALFORMED) + } +} + +fn ceil_second(timestamp: &time::OffsetDateTime) -> Result { + let seconds = timestamp.unix_timestamp(); + if timestamp.nanosecond() == 0 { + Ok(seconds) + } else { + seconds.checked_add(1).ok_or(MALFORMED) + } +} + +#[post("/keys/revoke", format = "json", data = "")] +pub async fn revoke_key( + request: Json, + tinycloud: &State, + encryption: &State, +) -> Result, (Status, String)> { + let request: KeyRevokeRequest = serde_json::from_value(request.into_inner()) + .map_err(|_| (Status::BadRequest, MALFORMED.1.to_string()))?; + let verified = verify_request(request, encryption.node_did()) + .await + .map_err(|(status, message)| (status, message.to_string()))?; + let receipt = tinycloud + .revoke_key(verified) + .await + .map_err(|error| match error { + KeyRevokeError::Forbidden => (Status::Forbidden, "key_revocation_forbidden".into()), + KeyRevokeError::Conflict => (Status::Conflict, "key_revocation_conflict".into()), + KeyRevokeError::Db { + write_may_have_applied: false, + .. + } => ( + Status::ServiceUnavailable, + "unavailable_retryable: key_revocation_unavailable".into(), + ), + KeyRevokeError::Db { + write_may_have_applied: true, + .. + } => ( + Status::InternalServerError, + "outcome_unknown: key_revocation_unavailable".into(), + ), + })?; + Ok(Json(receipt)) +} diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index 886fbdff..96a7761b 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -32,6 +32,7 @@ pub mod dstack; pub mod egress; pub mod hooks; pub mod invocation_replay; +pub mod key_revoke; pub mod link; pub mod node_control; pub mod policy_v3; @@ -129,6 +130,7 @@ use routes::{ }, hooks::{create_hook_ticket, create_webhook, delete_webhook, hook_events, list_webhooks}, info, invoke, + key_revoke::revoke_key, node_keys::{node_keys, NodePublicKeys}, open_host_key, public::{public_kv_get, public_kv_head, public_kv_list, public_kv_options, RateLimiter}, @@ -452,6 +454,7 @@ fn production_routes() -> Vec { delegation_query, delegation_status, revoke, + revoke_key, create_signed_kv_url, signed_kv_get, create_hook_ticket, @@ -1151,7 +1154,7 @@ async fn run_retention_sweep( } } -pub(crate) async fn resolve_keys(keys: &Keys) -> Result { +pub async fn resolve_keys(keys: &Keys) -> Result { match keys { Keys::Static(s) => Ok(s.clone().try_into()?), #[cfg(feature = "dstack")] diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index a75696b6..bbea70aa 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -37,6 +37,7 @@ use tinycloud_core::{ policy_v3_challenge, policy_v3_registration, policy_v3_root, policy_v3_session, revocation, share_invitation_authorization_jti, space, }, + policy_witness, relationships::parent_delegations, sea_orm::{ sea_query::Expr, ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, @@ -213,6 +214,14 @@ impl PolicyV3Runtime { { return Err("policy-session-proof-order-invalid"); } + let spaces = event + .0 + .spaces() + .map(ToString::to_string) + .collect::>(); + policy_witness::check_revocations_for_spaces(&self.conn, ®istration.policy_cid, &spaces) + .await + .map_err(|_| "policy-authority-revoked")?; let Ok(enforcer_did) = self .authenticated_registration_enforcer(®istration, OffsetDateTime::now_utc()) .await @@ -311,13 +320,22 @@ impl PolicyV3Runtime { // Validate the whole chain up to the admitted S0, then compare the // child with its *immediate* parent (S0 for a first hop, an // earlier descendant for deeper hops). - if self - .validate_policy_chain(tinycloud, event.0.parents[0]) + let requested_spaces = event + .0 + .spaces() + .map(ToString::to_string) + .collect::>(); + let (session, _) = self + .validate_policy_chain(tinycloud, event.0.parents[0], &requested_spaces) .await - .is_err() - { - return Err("policy-session-parent-invalid"); - } + .map_err(|_| "policy-session-parent-invalid")?; + policy_witness::check_revocations_for_spaces( + &self.conn, + &session.policy_cid, + &requested_spaces, + ) + .await + .map_err(|_| "policy-authority-revoked")?; let Ok(Some((_, parent))) = tinycloud.load_signed_delegation(event.0.parents[0]).await else { return Err("policy-session-parent-invalid"); @@ -404,8 +422,12 @@ impl PolicyV3Runtime { if !capabilities_are_contained(&invocation.capabilities, &immediate.0.capabilities) { return Err("policy-invocation-capability-widening"); } + let requested_spaces = invocation + .spaces() + .map(ToString::to_string) + .collect::>(); let (row, session) = self - .validate_policy_chain(tinycloud, invocation.parents[0]) + .validate_policy_chain(tinycloud, invocation.parents[0], &requested_spaces) .await?; let payload = invocation.invocation.payload(); let invocation_expiry = @@ -445,6 +467,13 @@ impl PolicyV3Runtime { { return Err("policy-session-proof-index-mismatch"); } + policy_witness::check_revocations_for_spaces( + &self.conn, + ®istration.policy_cid, + &requested_spaces, + ) + .await + .map_err(|_| "policy-authority-revoked")?; let policy_root = policy_v3_root::Entity::find_by_id(registration.policy_root_cid.clone()) .one(&self.conn) .await @@ -554,6 +583,7 @@ impl PolicyV3Runtime { &self, tinycloud: &crate::TinyCloud, start: tinycloud_auth::ipld_core::cid::Cid, + requested_spaces: &[String], ) -> Result<(policy_v3_session::Model, Delegation), &'static str> { let mut current_cid = start; let mut child: Option = None; @@ -631,7 +661,7 @@ impl PolicyV3Runtime { { return Err("policy-session-proof-index-mismatch"); } - self.authorize_roots(®istration, OffsetDateTime::now_utc()) + self.authorize_roots(®istration, OffsetDateTime::now_utc(), requested_spaces) .await?; for root_cid in event.0.parents.iter().copied() { let Some((_, root)) = tinycloud @@ -1344,6 +1374,7 @@ pub async fn authorize_delivery( request: Json, runtime: &State, invocation: AuthHeaderGetter, + tinycloud: &State, ) -> Result, (Status, String)> { let delivery = runtime .delivery @@ -1438,6 +1469,18 @@ pub async fn authorize_delivery( .and_then(|value| value.get("kvResource")) .and_then(Value::as_str) .ok_or((Status::Forbidden, "delivery-authorization-invalid".into()))?; + let space = resource + .parse::() + .ok() + .and_then(|resource| resource.space().map(ToString::to_string)) + .ok_or((Status::Forbidden, "delivery-authorization-invalid".into()))?; + policy_witness::check_revocations(&runtime.conn, policy_cid, None) + .await + .map_err(|_| (Status::Forbidden, "delivery-authorization-invalid".into()))?; + tinycloud + .ticket_authority_unrevoked(&invocation.0 .0.parents, sender_key_did, &[space]) + .await + .map_err(|_| (Status::Forbidden, "delivery-authorization-invalid".into()))?; let policy = envelope .get("policy") .and_then(Value::as_object) @@ -1964,6 +2007,31 @@ pub async fn register_policy( .await .map_err(|e| (Status::Conflict, e.to_string()))?; } + let witnesses = policy_witness::capture_in_transaction( + &txn, + &request.policy_cid, + owner_did, + &[policy_root_cid.clone(), enforcement_root_cid.clone()], + now, + tinycloud.column_encryption(), + ) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-witness-invalid".into()))?; + policy_witness::persist_in_transaction(&txn, &witnesses) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-witness-invalid".into()))?; + let dependencies = policy_witness::revocation_dependencies(&txn, &request.policy_cid) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-witness-invalid".into()))?; + tinycloud_core::key_authority::lock_signed_dependencies(&txn, &dependencies.signed_cids) + .await + .map_err(db_error)?; + tinycloud_core::key_authority::lock_targets(&txn, &dependencies.principals, false) + .await + .map_err(db_error)?; + policy_witness::check_revocations(&txn, &request.policy_cid, tinycloud.column_encryption()) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-revoked".into()))?; txn.commit().await.map_err(db_error)?; Ok(Json(RegisterResponse { policy_cid: request.policy_cid, @@ -1984,10 +2052,6 @@ pub async fn challenge( .map_err(db_error)? .ok_or((Status::NotFound, "policy-registration-missing".into()))?; let policy = validate_registration_projection(®istration)?; - runtime - .authorize_roots(®istration, OffsetDateTime::now_utc()) - .await - .map_err(|error| (Status::Forbidden, error.into()))?; request .recipient_did .parse::() @@ -1999,6 +2063,14 @@ pub async fn challenge( .and_then(Value::as_array) .ok_or((Status::Forbidden, "policy-registration-corrupt".into()))?, )?; + runtime + .authorize_roots( + ®istration, + OffsetDateTime::now_utc(), + &requested_capability_spaces(&requested_capabilities), + ) + .await + .map_err(|error| (Status::Forbidden, error.into()))?; let mut nonce_bytes = [0u8; 32]; rand::rngs::OsRng.fill_bytes(&mut nonce_bytes); let nonce = base64::encode_config(nonce_bytes, URL_SAFE_NO_PAD); @@ -2109,8 +2181,13 @@ pub async fn mint( )?; let enforcer_did = fact(&enforcement_root.0.delegation, "enforcerDid") .ok_or((Status::Forbidden, "sibling-root-mismatch".into()))?; + let requested_spaces = challenge + .requested_capabilities + .as_array() + .map(|capabilities| requested_capability_spaces(capabilities)) + .unwrap_or_default(); runtime - .authorize_roots(®istration, now) + .authorize_roots(®istration, now, &requested_spaces) .await .map_err(|e| (Status::Forbidden, e.into()))?; // Policies registered before owners had to hold what they grant are @@ -2455,6 +2532,28 @@ pub async fn mint( tinycloud_core::kv_sync::lock_space_sequences(&txn, &sequence_spaces) .await .map_err(db_error)?; + let dependencies = policy_witness::revocation_dependencies(&txn, ®istration.policy_cid) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-witness-invalid".into()))?; + tinycloud_core::key_authority::lock_signed_dependencies(&txn, &dependencies.signed_cids) + .await + .map_err(db_error)?; + let mut mint_principals = dependencies.principals.clone(); + mint_principals.push(session.delegate.clone()); + tinycloud_core::key_authority::lock_targets(&txn, &mint_principals, false) + .await + .map_err(db_error)?; + let checked_spaces = if requested_spaces.is_empty() { + &dependencies.spaces + } else { + &requested_spaces + }; + tinycloud_core::key_authority::check_principals(&txn, &mint_principals, checked_spaces) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-revoked".into()))?; + policy_witness::check_signed_cids(&txn, &dependencies) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-revoked".into()))?; if let Some(proof) = account_owner_proof.as_ref() { validate_locked_account_owner(&txn, proof).await?; } @@ -2698,6 +2797,7 @@ impl PolicyV3Runtime { &self, registration: &policy_v3_registration::Model, now: OffsetDateTime, + requested_spaces: &[String], ) -> Result<(), &'static str> { let policy = validate_registration_projection(registration) .map_err(|_| "policy-registration-projection-mismatch")?; @@ -2751,6 +2851,13 @@ impl PolicyV3Runtime { now, ) .map_err(|_| "enforcer-binding-invalid")?; + policy_witness::check_revocations_for_spaces( + &self.conn, + ®istration.policy_cid, + requested_spaces, + ) + .await + .map_err(|_| "policy-authority-revoked")?; Ok(()) } } @@ -3388,6 +3495,9 @@ pub async fn status( { return Err((Status::Conflict, "root-revoked".into())); } + policy_witness::check_revocations(&runtime.conn, &root.policy_cid, None) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-revoked".into()))?; validate_persisted_root( &root, &request.root_cid, @@ -3491,6 +3601,11 @@ pub async fn get_status( let generic_revocation = is_root_generically_revoked(&runtime.conn, root_cid) .await .map_err(|error| (Status::ServiceUnavailable, error.into()))?; + if root.revoked_at.is_none() && !generic_revocation { + policy_witness::check_revocations(&runtime.conn, &root.policy_cid, None) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-revoked".into()))?; + } if root.revoked_at.is_none() { validate_stored_root_status( &root, @@ -4717,6 +4832,30 @@ fn validate_requested_policy_capabilities( Ok(approved) } +/// Legacy logical KV identifiers and encryption capabilities do not establish +/// a canonical SpaceId. An empty result deliberately checks every witness +/// scope rather than treating an unrecognized scope as unrelated. +fn requested_capability_spaces(capabilities: &[Value]) -> Vec { + let mut spaces = Vec::with_capacity(capabilities.len()); + for capability in capabilities { + let Some(resource) = capability.get("resource").and_then(Value::as_str) else { + return Vec::new(); + }; + if capability.get("kind").and_then(Value::as_str) != Some("kv") + || resource.starts_with("tinycloud://") + { + return Vec::new(); + } + let Ok(resource) = resource.parse::() else { + return Vec::new(); + }; + spaces.push(resource.space().to_string()); + } + spaces.sort_unstable(); + spaces.dedup(); + spaces +} + fn attenuation_for_policy_capabilities(capabilities: &[Value]) -> Result { let mut attenuation = serde_json::Map::new(); for capability in capabilities { @@ -7966,6 +8105,16 @@ mod tests { run_policy_v2_flow_on_large_stack(PolicyOwner::DelegatedSession) } + #[test] + fn registrar_key_tombstone_closes_registered_policy_side_routes() -> anyhow::Result<()> { + run_policy_v2_flow_on_large_stack(PolicyOwner::RegistrarKeyCutoff) + } + + #[test] + fn historical_owner_grant_signer_tombstone_closes_policy_side_routes() -> anyhow::Result<()> { + run_policy_v2_flow_on_large_stack(PolicyOwner::AncestorKeyCutoff) + } + /// The policy owner signs a valid, self-consistent policy over content in /// a space it holds no authority in (the victim hosts and seeds it). /// Registration must refuse it, so no session can ever read the bytes. @@ -7982,6 +8131,10 @@ mod tests { SpaceOwnerPolicyRoot, /// The policy owner is a session key the space owner delegated to. DelegatedSession, + /// The registered policy owner self-revokes its key, not either root CID. + RegistrarKeyCutoff, + /// The signer of the owner's historical grant is distinct from the root signer. + AncestorKeyCutoff, /// The policy owner holds nothing in the content space. Unauthorized, } @@ -8042,7 +8195,8 @@ mod tests { let victim_key = tinycloud_core::libp2p::identity::ed25519::Keypair::generate(); let victim_did = did(&victim_key); let space_owner = matches!( - policy_owner, PolicyOwner::SpaceOwner | PolicyOwner::SpaceOwnerPolicyRoot + policy_owner, + PolicyOwner::SpaceOwner | PolicyOwner::SpaceOwnerPolicyRoot ); let content_owner_did = if !space_owner { &victim_did @@ -8086,7 +8240,11 @@ mod tests { vec!["tinycloud.kv/get"] }; let ceiling_actions = if policy_owner != PolicyOwner::Unauthorized { - vec!["tinycloud.kv/get", "tinycloud.kv/metadata", "tinycloud.kv/put"] + vec![ + "tinycloud.kv/get", + "tinycloud.kv/metadata", + "tinycloud.kv/put", + ] } else { vec!["tinycloud.kv/get", "tinycloud.kv/metadata"] }; @@ -8481,6 +8639,9 @@ mod tests { crate::routes::invoke, crate::routes::revoke, crate::routes::create_signed_kv_url, + crate::key_revoke::revoke_key, + status, + get_status, crate::routes::signed_kv_get, crate::routes::hooks::create_hook_ticket ], @@ -8579,9 +8740,18 @@ mod tests { // A session the space owner delegated to may own a policy over what // that delegation covers, as the Share and CLI owners do. - let owner_delegation_cid = if policy_owner == PolicyOwner::DelegatedSession { + let owner_delegation_cid = if matches!( + policy_owner, + PolicyOwner::DelegatedSession + | PolicyOwner::RegistrarKeyCutoff + | PolicyOwner::AncestorKeyCutoff + ) { let mut owner_capabilities = Capabilities::::new(); - for ability in ["tinycloud.kv/get", "tinycloud.kv/metadata", "tinycloud.kv/put"] { + for ability in [ + "tinycloud.kv/get", + "tinycloud.kv/metadata", + "tinycloud.kv/put", + ] { owner_capabilities.with_action( content_resource.as_uri(), ability.parse::()?, @@ -8891,6 +9061,218 @@ mod tests { delivery_receipt["admission"]["audience"], "https://witness.credentials.org" ); + if matches!( + policy_owner, + PolicyOwner::RegistrarKeyCutoff | PolicyOwner::AncestorKeyCutoff + ) { + // These are real registered sibling roots. Neither root CID is + // directly revoked: the tombstone targets their signer or the + // separate signer of the owner's historical authority grant. + let policy_root_cid = registered["policyRootCid"].as_str().unwrap(); + let status_path = format!("/policy/v3/status/{policy_root_cid}"); + let before_status = client.get(&status_path).dispatch().await; + assert_eq!(before_status.status(), Status::Ok); + let before_status: Value = before_status.into_json().await.unwrap(); + assert_eq!(before_status["state"], "active"); + let previous = + checkpoint_predecessor_digest(&canonical_json_value(&before_status["checkpoint"])) + .expect("signed initial checkpoint"); + let sign_renewal = |sequence: i64, previous: &str, nonce: &str| { + let mut renewal = json!({ + "schema": "xyz.tinycloud.policy/root-status-renewal/v1", + "targetCid": policy_root_cid, + "ownerDid": owner_did, + "nodeAudience": node_did, + "issuedAt": format_time(OffsetDateTime::now_utc() - Duration::seconds(1)), + "nonce": nonce, + "sequence": sequence, + "previousCheckpointDigestHex": previous, + }); + let mut preimage = b"xyz.tinycloud.policy/RootStatusRenewal/v1\0".to_vec(); + preimage.extend_from_slice(&canonical_json_value(&renewal)); + renewal["signature"] = json!({ + "suite": "Ed25519", + "signerDid": owner_did, + "value": encode_config(owner_key.sign(&Sha256::digest(preimage)), URL_SAFE_NO_PAD), + }); + renewal + }; + let renewal = sign_renewal(2, &previous, "tc1149-status-before-key-cutoff"); + let renewed = client + .post("/policy/v3/status") + .header(ContentType::JSON) + .body(json!({"rootCid": policy_root_cid, "renewal": renewal}).to_string()) + .dispatch() + .await; + let renewal_status = renewed.status(); + let renewal_body = renewed.into_string().await.unwrap_or_default(); + assert_eq!(renewal_status, Status::Ok, "live renewal: {renewal_body}"); + let latest_status = client.get(&status_path).dispatch().await; + assert_eq!(latest_status.status(), Status::Ok); + let latest_status: Value = latest_status.into_json().await.unwrap(); + assert_eq!(latest_status["state"], "active"); + let latest_previous = + checkpoint_predecessor_digest(&canonical_json_value(&latest_status["checkpoint"])) + .expect("signed renewed checkpoint"); + let after_renewal = sign_renewal(3, &latest_previous, "tc1149-status-after-key-cutoff"); + let challenge_body = json!({ + "policyCid": policy_cid, + "recipientDid": holder_did, + "requestedCapabilities": requested, + }) + .to_string(); + let live_challenge = client + .post("/policy/v3/challenges") + .header(ContentType::JSON) + .body(challenge_body.clone()) + .dispatch() + .await; + assert_eq!(live_challenge.status(), Status::Ok); + assert!(live_challenge.into_json::().await.unwrap()["challengeId"].is_string()); + + let (target_did, target_vm, target_jwk) = + if policy_owner == PolicyOwner::RegistrarKeyCutoff { + (&owner_did, &owner_vm, &owner_jwk) + } else { + (content_owner_did, &content_owner_vm, &content_owner_jwk) + }; + assert_ne!( + owner_did.as_str(), + content_owner_did.as_str(), + "historical grant has a distinct signer" + ); + let scope = json!({"kind": "global"}); + let mut revoke_caps = Capabilities::::new(); + revoke_caps.with_action( + format!("urn:tinycloud:key:{target_did}").parse()?, + "tinycloud.key/revoke".parse::()?, + [serde_json::from_value(json!({ + "version": 1, + "targetDid": target_did, + "scope": scope, + }))?], + ); + let key_revoke = TinyCloudDelegation::Ucan(Box::new( + Payload { + issuer: target_vm.parse()?, + audience: node_did.parse()?, + not_before: Some(NumericDate::try_from_seconds( + (OffsetDateTime::now_utc() - Duration::seconds(1)).unix_timestamp() as f64, + )?), + expiration: NumericDate::try_from_seconds( + (OffsetDateTime::now_utc() + Duration::seconds(45)).unix_timestamp() as f64, + )?, + nonce: Some(format!("tc1149-policy-{target_did}-key-cutoff")), + facts: Some(Vec::new()), + proof: Vec::new(), + attenuation: revoke_caps, + } + .sign(Algorithm::EdDSA, target_jwk)?, + )) + .encode()?; + let revoked = client + .post("/keys/revoke") + .header(ContentType::JSON) + .body( + json!({ + "targetDid": target_did, + "scope": scope, + "authorization": key_revoke, + }) + .to_string(), + ) + .dispatch() + .await; + let revoked_status = revoked.status(); + let revoked_body = revoked.into_string().await.unwrap_or_default(); + assert_eq!( + revoked_status, + Status::Ok, + "signed key tombstone: {revoked_body}" + ); + assert_eq!( + serde_json::from_str::(&revoked_body)?["revoked"], + true + ); + for root_field in ["policyRootCid", "enforcementRootCid"] { + let cid = registered[root_field] + .as_str() + .unwrap() + .parse::()?; + assert!( + !revocation::is_revoked(&runtime.conn, &tinycloud_core::hash::Hash::from(cid)) + .await? + ); + } + let denied_challenge = client + .post("/policy/v3/challenges") + .header(ContentType::JSON) + .body(challenge_body) + .dispatch() + .await; + assert_eq!( + denied_challenge.status(), + Status::Forbidden, + "fresh challenge after key tombstone" + ); + let denied_status = client.get(&status_path).dispatch().await; + assert_eq!( + denied_status.status(), + Status::Forbidden, + "status GET after key tombstone" + ); + let denied_renewal = client + .post("/policy/v3/status") + .header(ContentType::JSON) + .body(json!({"rootCid": policy_root_cid, "renewal": after_renewal}).to_string()) + .dispatch() + .await; + assert_eq!( + denied_renewal.status(), + Status::Forbidden, + "renewal after key tombstone" + ); + + if policy_owner == PolicyOwner::RegistrarKeyCutoff { + // New request/JTI and a still-live ordinary sender grant: + // denial must come from policy owner/root revocation, not + // invocation replay or an unrelated revoked content owner. + let mut fresh_delivery = delivery_request.clone(); + fresh_delivery.jti = encode_config([9_u8; 16], URL_SAFE_NO_PAD); + fresh_delivery.request_body_digest = delivery_request_digest(&fresh_delivery) + .map_err(|_| anyhow::anyhow!("fresh delivery digest"))?; + let fresh_invocation = make_invocation( + [( + content_resource.clone(), + ["tinycloud.kv/get".parse::()?], + )], + &sender_cid, + &delivery_jwk, + &format!("{holder_did}#{}", holder_did.trim_start_matches("did:key:")), + (OffsetDateTime::now_utc() + Duration::seconds(45)).unix_timestamp() as f64, + InvocationOptions { + nonce: Some("tc1149-delivery-after-owner-key-cutoff".into()), + ..InvocationOptions::default() + }, + )?; + let denied_delivery = client + .post("/policy/v3/deliveries/authorize") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + fresh_invocation.encode()?, + )) + .body(serde_json::to_string(&fresh_delivery)?) + .dispatch() + .await; + assert_eq!( + denied_delivery.status(), + Status::Forbidden, + "delivery after owner key tombstone" + ); + } + return Ok(()); + } // Model the cross-service partial failure where Node authorized the // delivery but the OpenCredentials response was lost. The SDK retries @@ -9270,10 +9652,9 @@ mod tests { InvocationOptions::default(), )?; let owner_response = hooks_ticket(owner_hooks.encode()?).await; - assert_eq!(owner_response.status(), Status::Forbidden); - assert_eq!( - owner_response.into_string().await.unwrap_or_default(), - "policy-root-cannot-authorize-invocation" + assert!( + [Status::Unauthorized, Status::Forbidden].contains(&owner_response.status()), + "a policy root cannot authorize a hook ticket" ); // The accountless v4 branch reuses the issuer credential and the @@ -9633,63 +10014,93 @@ mod tests { .await; assert_eq!(replay.status(), Status::Unauthorized); - let session_invocation = - |key: &JWK, - principal: &str, - parent: tinycloud_auth::ipld_core::cid::Cid, - allowed: &[Value], - nonce: &str| - -> anyhow::Result { - let now = OffsetDateTime::now_utc(); - let vm = format!("{principal}#{}", principal.trim_start_matches("did:key:")); - Ok(Payload { - issuer: vm.parse()?, - audience: principal.parse()?, - not_before: Some(NumericDate::try_from_seconds(now.unix_timestamp() as f64)?), - expiration: NumericDate::try_from_seconds( - (now + Duration::seconds(30)).unix_timestamp() as f64, - )?, - nonce: Some(nonce.into()), - facts: Some(Vec::::new()), - proof: vec![parent], - attenuation: serde_json::from_value::>( - attenuation_for_policy_capabilities(allowed) - .map_err(|(_, error)| anyhow::anyhow!(error))?, - )?, - } - .sign(Algorithm::EdDSA, key)? - .encode()?) - }; + let session_invocation = |key: &JWK, + principal: &str, + parent: tinycloud_auth::ipld_core::cid::Cid, + allowed: &[Value], + nonce: &str| + -> anyhow::Result { + let now = OffsetDateTime::now_utc(); + let vm = format!("{principal}#{}", principal.trim_start_matches("did:key:")); + Ok(Payload { + issuer: vm.parse()?, + audience: principal.parse()?, + not_before: Some(NumericDate::try_from_seconds(now.unix_timestamp() as f64)?), + expiration: NumericDate::try_from_seconds( + (now + Duration::seconds(30)).unix_timestamp() as f64, + )?, + nonce: Some(nonce.into()), + facts: Some(Vec::::new()), + proof: vec![parent], + attenuation: serde_json::from_value::>( + attenuation_for_policy_capabilities(allowed) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + } + .sign(Algorithm::EdDSA, key)? + .encode()?) + }; let s0_cid = v4_s0.content_hash().to_cid(0x55); for (key, principal, parent, nonce, content) in [ - (&holder_jwk, holder_did.as_str(), s0_cid, "tc1149-s0-write-before", "s0-before"), - (&reader_b_jwk, reader_b_did.as_str(), grandchild_cid, "tc1149-descendant-write-before", "descendant-before"), + ( + &holder_jwk, + holder_did.as_str(), + s0_cid, + "tc1149-s0-write-before", + "s0-before", + ), + ( + &reader_b_jwk, + reader_b_did.as_str(), + grandchild_cid, + "tc1149-descendant-write-before", + "descendant-before", + ), ] { - let write = client.post("/invoke") + let write = client + .post("/invoke") .header(rocket::http::Header::new( "Authorization", session_invocation(key, principal, parent, &write_capabilities, nonce)?, )) .header(ContentType::Plain) .body(content) - .dispatch().await; + .dispatch() + .await; let status = write.status(); let body = write.into_string().await.unwrap_or_default(); assert_eq!(status, Status::Ok, "pre-revoke {nonce}: {body}"); } let owner_read = |nonce: &str| -> anyhow::Result { Ok(make_invocation( - [(content_resource.clone(), ["tinycloud.kv/get".parse::()?])], - &sender_cid, &holder_jwk, &holder_vm, + [( + content_resource.clone(), + ["tinycloud.kv/get".parse::()?], + )], + &sender_cid, + &holder_jwk, + &holder_vm, (OffsetDateTime::now_utc() + Duration::seconds(30)).unix_timestamp() as f64, - InvocationOptions { nonce: Some(nonce.into()), ..InvocationOptions::default() }, - )?.encode()?) + InvocationOptions { + nonce: Some(nonce.into()), + ..InvocationOptions::default() + }, + )? + .encode()?) }; - let before = client.post("/invoke") - .header(rocket::http::Header::new("Authorization", owner_read("tc1149-owner-read-before")?)) - .dispatch().await; + let before = client + .post("/invoke") + .header(rocket::http::Header::new( + "Authorization", + owner_read("tc1149-owner-read-before")?, + )) + .dispatch() + .await; assert_eq!(before.status(), Status::Ok); - assert_eq!(before.into_bytes().await.unwrap_or_default(), b"descendant-before"); + assert_eq!( + before.into_bytes().await.unwrap_or_default(), + b"descendant-before" + ); if let Some(owner_delegation_cid) = owner_delegation_cid.as_deref() { // Revoking the delegation the policy owner held stops new @@ -9735,46 +10146,73 @@ mod tests { // descendants must no longer authorize reads, writes, or children. for (key, principal, parent, label) in [ (&holder_jwk, holder_did.as_str(), s0_cid, "s0"), - (&reader_b_jwk, reader_b_did.as_str(), grandchild_cid, "descendant"), + ( + &reader_b_jwk, + reader_b_did.as_str(), + grandchild_cid, + "descendant", + ), ] { - for (action, allowed) in [ - ("get", &read_capabilities), - ("put", &write_capabilities), - ] { + for (action, allowed) in [("get", &read_capabilities), ("put", &write_capabilities)] + { let authorization = session_invocation( - key, principal, parent, allowed, + key, + principal, + parent, + allowed, &format!("tc1149-after-owner-revoke-{label}-{action}"), )?; - let response = client.post("/invoke") + let response = client + .post("/invoke") .header(rocket::http::Header::new("Authorization", authorization)) .header(ContentType::Plain) .body("must-not-overwrite") - .dispatch().await; + .dispatch() + .await; let status = response.status(); let body = response.into_string().await.unwrap_or_default(); assert_eq!(status, Status::Forbidden, "{label} {action}: {body}"); } } - let after = client.post("/invoke") - .header(rocket::http::Header::new("Authorization", owner_read("tc1149-owner-read-after")?)) - .dispatch().await; + let after = client + .post("/invoke") + .header(rocket::http::Header::new( + "Authorization", + owner_read("tc1149-owner-read-after")?, + )) + .dispatch() + .await; assert_eq!(after.status(), Status::Ok); - assert_eq!(after.into_bytes().await.unwrap_or_default(), b"descendant-before", - "denied policy writes must leave the owner-visible bytes unchanged"); + assert_eq!( + after.into_bytes().await.unwrap_or_default(), + b"descendant-before", + "denied policy writes must leave the owner-visible bytes unchanged" + ); let next_reader = JWK::generate_ed25519()?; let next_did = tinycloud_auth::resolver::DID_METHODS - .generate(&next_reader, "key")?.to_string(); + .generate(&next_reader, "key")? + .to_string(); for (parent, issuer, principal, label) in [ (&v4_s0, &holder_jwk, holder_did.as_str(), "s0"), - (&chain_tip, &reader_b_jwk, reader_b_did.as_str(), "descendant"), + ( + &chain_tip, + &reader_b_jwk, + reader_b_did.as_str(), + "descendant", + ), ] { let attempted = hop( - parent, issuer, principal, &next_did, + parent, + issuer, + principal, + &next_did, &format!("tc1149-child-after-owner-revoke-{label}"), )?; - let response = client.post("/delegate") + let response = client + .post("/delegate") .header(rocket::http::Header::new("Authorization", attempted)) - .dispatch().await; + .dispatch() + .await; let status = response.status(); let body = response.into_string().await.unwrap_or_default(); assert_eq!(status, Status::Forbidden, "{label} child: {body}"); @@ -9793,65 +10231,25 @@ mod tests { ) .dispatch() .await; - assert_eq!(challenge_response.status(), Status::Ok); - let fresh_challenge: Value = challenge_response.into_json().await.unwrap(); - let mut fresh = presentation.clone(); - fresh["schema"] = json!(CREDENTIAL_PRESENTATION_V4_SCHEMA); - fresh["jti"] = json!("presentation-tc-597-after-owner-revoke"); - fresh["challengeId"] = fresh_challenge["challengeId"].clone(); - fresh["nonce"] = fresh_challenge["nonce"].clone(); - fresh - .as_object_mut() - .unwrap() - .remove("credentialSpaceOwnerDid"); - fresh["issuedAt"] = json!(format_time(OffsetDateTime::now_utc())); - fresh["expiresAt"] = json!(format_time( - OffsetDateTime::now_utc() + Duration::seconds(45) - )); - let mut unsigned_fresh = fresh.clone(); - unsigned_fresh.as_object_mut().unwrap().remove("signature"); - let mut fresh_preimage = CREDENTIAL_PRESENTATION_V4_DOMAIN.to_vec(); - fresh_preimage.extend_from_slice(&canonical_json_value(&unsigned_fresh)); - fresh["signature"]["value"] = json!(encode_config( - holder_key.sign(&Sha256::digest(fresh_preimage)), - URL_SAFE_NO_PAD - )); - let refused = client - .post("/policy/v3/delegations") + assert_eq!(challenge_response.status(), Status::Forbidden); + } + + // Prepare a fresh, unused admission while both roots are live. It + // cannot be created after root revoke because challenge itself closes. + let fresh_root_mint = if space_owner { + let response = client + .post("/policy/v3/challenges") .header(ContentType::JSON) .body( json!({ "policyCid": policy_cid, - "challengeId": fresh_challenge["challengeId"], - "nonce": fresh_challenge["nonce"], - "requirement": requirement, - "credential": credential, - "presentation": fresh, + "recipientDid": holder_did, + "requestedCapabilities": requested, }) .to_string(), ) .dispatch() .await; - let refused_status = refused.status(); - let refused_body = refused.into_string().await.unwrap_or_default(); - assert_eq!( - refused_status, - Status::Forbidden, - "mint after owner revoke: {refused_body}" - ); - } - - // Prepare a fresh, unused admission while both roots are live. It - // cannot be created after root revoke because challenge itself closes. - let fresh_root_mint = if space_owner { - let response = client.post("/policy/v3/challenges") - .header(ContentType::JSON) - .body(json!({ - "policyCid": policy_cid, - "recipientDid": holder_did, - "requestedCapabilities": requested, - }).to_string()) - .dispatch().await; assert_eq!(response.status(), Status::Ok); let challenge: Value = response.into_json().await.unwrap(); let mut signed = presentation.clone(); @@ -9859,7 +10257,10 @@ mod tests { signed["jti"] = json!("presentation-tc1149-after-root-revoke"); signed["challengeId"] = challenge["challengeId"].clone(); signed["nonce"] = challenge["nonce"].clone(); - signed.as_object_mut().unwrap().remove("credentialSpaceOwnerDid"); + signed + .as_object_mut() + .unwrap() + .remove("credentialSpaceOwnerDid"); signed["issuedAt"] = json!(format_time(OffsetDateTime::now_utc())); signed["expiresAt"] = json!(format_time( OffsetDateTime::now_utc() + Duration::seconds(45) @@ -9872,14 +10273,17 @@ mod tests { holder_key.sign(&Sha256::digest(preimage)), URL_SAFE_NO_PAD )); - Some(json!({ - "policyCid": policy_cid, - "challengeId": challenge["challengeId"], - "nonce": challenge["nonce"], - "requirement": requirement, - "credential": credential, - "presentation": signed, - }).to_string()) + Some( + json!({ + "policyCid": policy_cid, + "challengeId": challenge["challengeId"], + "nonce": challenge["nonce"], + "requirement": requirement, + "credential": credential, + "presentation": signed, + }) + .to_string(), + ) } else { None }; @@ -9934,32 +10338,46 @@ mod tests { if space_owner { for (key, principal, parent, label) in [ (&holder_jwk, holder_did.as_str(), s0_cid, "s0"), - (&reader_b_jwk, reader_b_did.as_str(), grandchild_cid, "descendant"), + ( + &reader_b_jwk, + reader_b_did.as_str(), + grandchild_cid, + "descendant", + ), ] { - for (action, allowed) in [ - ("get", &read_capabilities), - ("put", &write_capabilities), - ] { + for (action, allowed) in [("get", &read_capabilities), ("put", &write_capabilities)] + { let authorization = session_invocation( - key, principal, parent, allowed, + key, + principal, + parent, + allowed, &format!("tc1149-after-{root_field}-revoke-{label}-{action}"), )?; - let response = client.post("/invoke") + let response = client + .post("/invoke") .header(rocket::http::Header::new("Authorization", authorization)) .header(ContentType::Plain) .body("root-denied-write") - .dispatch().await; + .dispatch() + .await; let status = response.status(); let body = response.into_string().await.unwrap_or_default(); - assert_eq!(status, Status::Forbidden, "{root_field} {label} {action}: {body}"); + assert_eq!( + status, + Status::Forbidden, + "{root_field} {label} {action}: {body}" + ); } } - let unchanged = client.post("/invoke") + let unchanged = client + .post("/invoke") .header(rocket::http::Header::new( "Authorization", owner_read(&format!("tc1149-owner-read-after-{root_field}"))?, )) - .dispatch().await; + .dispatch() + .await; assert_eq!(unchanged.status(), Status::Ok); assert_eq!( unchanged.into_bytes().await.unwrap_or_default(), @@ -9968,26 +10386,43 @@ mod tests { ); let next_reader = JWK::generate_ed25519()?; let next_did = tinycloud_auth::resolver::DID_METHODS - .generate(&next_reader, "key")?.to_string(); + .generate(&next_reader, "key")? + .to_string(); for (parent, issuer, principal, label) in [ (&v4_s0, &holder_jwk, holder_did.as_str(), "s0"), - (&chain_tip, &reader_b_jwk, reader_b_did.as_str(), "descendant"), + ( + &chain_tip, + &reader_b_jwk, + reader_b_did.as_str(), + "descendant", + ), ] { let attempted = hop( - parent, issuer, principal, &next_did, + parent, + issuer, + principal, + &next_did, &format!("tc1149-{root_field}-child-after-revoke-{label}"), )?; - let response = client.post("/delegate") + let response = client + .post("/delegate") .header(rocket::http::Header::new("Authorization", attempted)) - .dispatch().await; + .dispatch() + .await; let status = response.status(); let body = response.into_string().await.unwrap_or_default(); - assert_eq!(status, Status::Forbidden, "{root_field} {label} child: {body}"); + assert_eq!( + status, + Status::Forbidden, + "{root_field} {label} child: {body}" + ); } - let refused = client.post("/policy/v3/delegations") + let refused = client + .post("/policy/v3/delegations") .header(ContentType::JSON) .body(fresh_root_mint.expect("pre-revoke challenge and presentation")) - .dispatch().await; + .dispatch() + .await; let status = refused.status(); let body = refused.into_string().await.unwrap_or_default(); assert_eq!(status, Status::Forbidden, "{root_field} fresh mint: {body}"); diff --git a/tinycloud-node-server/src/routes/hooks.rs b/tinycloud-node-server/src/routes/hooks.rs index 3175984b..648e9041 100644 --- a/tinycloud-node-server/src/routes/hooks.rs +++ b/tinycloud-node-server/src/routes/hooks.rs @@ -313,6 +313,34 @@ impl HookEventStream { } } +async fn check_hook_ticket_authority( + tinycloud: &TinyCloud, + claims: &HookTicketClaims, + spaces: &[String], +) -> Result<(), (Status, String)> { + let result = rocket::tokio::time::timeout( + Duration::from_secs(2), + tinycloud.ticket_authority_unrevoked(&claims.authorizing_delegations, &claims.sub, spaces), + ) + .await + .map_err(|_| { + ( + Status::Unauthorized, + "authorization-check-timeout".to_string(), + ) + })?; + result.map_err(|error| { + let message = if tinycloud_core::key_authority::is_revoked_error(&error) { + tinycloud_core::key_authority::REVOKED + } else if matches!(&error, tinycloud_core::sea_orm::DbErr::Custom(message) if message == "hook ticket authority revoked") { + "hook ticket authority revoked" + } else { + "hook ticket authority invalid" + }; + (Status::Unauthorized, message.to_string()) + }) +} + #[get("/hooks/events?")] pub async fn hook_events<'r>( ticket: &'r str, @@ -322,23 +350,32 @@ pub async fn hook_events<'r>( let claims = hooks .verify_ticket(ticket) .map_err(|e| (Status::Unauthorized, e))?; + let spaces = claims + .scopes + .iter() + .map(|scope| { + let space = scope + .space + .parse::() + .map_err(|_| { + ( + Status::Unauthorized, + "hook ticket scope invalid".to_string(), + ) + })?; + if space.to_string() != scope.space { + return Err(( + Status::Unauthorized, + "hook ticket scope invalid".to_string(), + )); + } + Ok(scope.space.clone()) + }) + .collect::, _>>()?; let stream_deadline = hook_stream_deadline( &claims, hooks.config().max_ticket_ttl_seconds, - || async { - if tinycloud - .delegation_chains_unrevoked(&claims.authorizing_delegations) - .await - .map_err(|error| (Status::InternalServerError, error.to_string()))? - { - Ok(()) - } else { - Err(( - Status::Unauthorized, - "hook ticket authority revoked".to_string(), - )) - } - }, + || check_hook_ticket_authority(tinycloud, &claims, &spaces), OffsetDateTime::now_utc, ) .await?; @@ -363,6 +400,12 @@ pub async fn hook_events<'r>( match message { Ok(event) => { if claims.scopes.iter().any(|scope| matches_scope(&event, scope)) { + if let Err((_, message)) = + check_hook_ticket_authority(tinycloud, &claims, &spaces).await + { + yield Event::data(message).event("error"); + break; + } yield Event::json(&event) .id(event.id.clone()) .event("write"); @@ -1013,14 +1056,16 @@ mod tests { .manage(test_tinycloud().await?), ) .await?; - rocket::tokio::time::pause(); let now = OffsetDateTime::now_utc().unix_timestamp(); + let (_, subject) = did_key()?; + let subject = did_of(&subject).to_owned(); + let space = space_of(&subject)?.to_string(); let ticket = hooks .sign_ticket(&HookTicketClaims { v: 2, - sub: "did:key:subscriber".to_string(), + sub: subject, scopes: vec![HookSubscription { - space: "tinycloud:space".to_string(), + space: space.clone(), service: "kv".to_string(), path_prefix: None, abilities: Vec::new(), @@ -1044,7 +1089,7 @@ mod tests { hooks.bus().publish(crate::hooks::WriteEvent { event_type: "write".to_string(), id: "epoch:1".to_string(), - space: "tinycloud:space".to_string(), + space, service: "kv".to_string(), ability: "tinycloud.kv/put".to_string(), path: Some("documents/1".to_string()), @@ -1056,6 +1101,9 @@ mod tests { timestamp: "2026-01-01T00:00:00Z".to_string(), }); + // Keep database-backed authority checks on a live clock: paused Tokio + // time can auto-advance their two-second timeout while SQLite awaits. + rocket::tokio::time::pause(); rocket::tokio::time::advance(Duration::from_secs(11)).await; for response in responses { assert!( @@ -2852,6 +2900,228 @@ mod tests { Ok(()) } + async fn revoke_hook_test_key(fixture: &HookRouteFixture, did: &str) -> Result<()> { + use tinycloud_core::{ + models::key_revocation, + sea_orm::{ActiveModelTrait, ActiveValue::Set}, + }; + + key_revocation::ActiveModel { + target_did: Set(did.to_string()), + scope_key: Set("global".to_string()), + revoked_at: Set(OffsetDateTime::now_utc().unix_timestamp()), + } + .insert(&fixture.db) + .await?; + Ok(()) + } + + #[tokio::test] + async fn hook_stream_rejects_existing_delegated_ticket_after_subject_self_global_revoke( + ) -> Result<()> { + let fixture = + hook_route_fixture(&[("hooks", "kv/documents", "tinycloud.hooks/subscribe")]).await?; + let claim = [(fixture.hooks("kv/documents")?, SUBSCRIBE)]; + let (status, body) = fixture + .ticket( + fixture.session_header(&claim)?, + &fixture.space, + "kv", + "documents", + ) + .await; + assert_eq!(status, Status::Ok, "{body}"); + let delegated: HookTicketResponse = serde_json::from_str(&body)?; + let live = fixture + .client + .get(format!("/hooks/events?ticket={}", delegated.ticket)) + .dispatch() + .await; + assert_eq!(live.status(), Status::Ok); + drop(live); + + let owner_header = signed_header( + &fixture.owner.0, + &fixture.owner.1, + vec![], + &[(fixture.hooks("kv/documents")?, SUBSCRIBE)], + )?; + let (status, body) = fixture + .ticket(owner_header, &fixture.space, "kv", "documents") + .await; + assert_eq!(status, Status::Ok, "{body}"); + let independent: HookTicketResponse = serde_json::from_str(&body)?; + + // Model the committed self-global key tombstone without revoking the + // owner-to-session delegation CID. + revoke_hook_test_key(&fixture, did_of(&fixture.session.1)).await?; + let denied = fixture + .client + .get(format!("/hooks/events?ticket={}", delegated.ticket)) + .dispatch() + .await; + assert_eq!(denied.status(), Status::Unauthorized); + assert_eq!( + denied.into_string().await.as_deref(), + Some("authorization-key-revoked") + ); + let control = fixture + .client + .get(format!("/hooks/events?ticket={}", independent.ticket)) + .dispatch() + .await; + assert_eq!(control.status(), Status::Ok); + Ok(()) + } + + #[tokio::test] + async fn hook_stream_rejects_existing_proofless_root_ticket_after_owner_key_revoke( + ) -> Result<()> { + let fixture = hook_route_fixture(&[]).await?; + let independent = hook_route_fixture(&[]).await?; + let owner_header = signed_header( + &fixture.owner.0, + &fixture.owner.1, + vec![], + &[(fixture.hooks("kv/documents")?, SUBSCRIBE)], + )?; + let (status, body) = fixture + .ticket(owner_header, &fixture.space, "kv", "documents") + .await; + assert_eq!(status, Status::Ok, "{body}"); + let root: HookTicketResponse = serde_json::from_str(&body)?; + let claims = test_hook_runtime() + .verify_ticket(&root.ticket) + .map_err(anyhow::Error::msg)?; + assert!(claims.authorizing_delegations.is_empty()); + assert_eq!(claims.sub, did_of(&fixture.owner.1)); + let live = fixture + .client + .get(format!("/hooks/events?ticket={}", root.ticket)) + .dispatch() + .await; + assert_eq!(live.status(), Status::Ok); + drop(live); + + let control_header = signed_header( + &independent.owner.0, + &independent.owner.1, + vec![], + &[(independent.hooks("kv/documents")?, SUBSCRIBE)], + )?; + let (status, body) = independent + .ticket(control_header, &independent.space, "kv", "documents") + .await; + assert_eq!(status, Status::Ok, "{body}"); + let control_ticket: HookTicketResponse = serde_json::from_str(&body)?; + + revoke_hook_test_key(&fixture, did_of(&fixture.owner.1)).await?; + let denied = fixture + .client + .get(format!("/hooks/events?ticket={}", root.ticket)) + .dispatch() + .await; + assert_eq!(denied.status(), Status::Unauthorized); + assert_eq!( + denied.into_string().await.as_deref(), + Some("authorization-key-revoked") + ); + let control = independent + .client + .get(format!("/hooks/events?ticket={}", control_ticket.ticket)) + .dispatch() + .await; + assert_eq!(control.status(), Status::Ok); + Ok(()) + } + + async fn next_hook_sse_event( + response: &mut rocket::local::asynchronous::LocalResponse<'_>, + ) -> Result { + use rocket::tokio::io::AsyncReadExt; + + rocket::tokio::time::timeout(Duration::from_secs(2), async { + let mut frame = Vec::new(); + loop { + let byte = response.read_u8().await?; + frame.push(byte); + if frame == b":\n" { + frame.clear(); + continue; + } + if frame.ends_with(b"\n\n") { + return Ok::<_, anyhow::Error>(String::from_utf8(frame)?); + } + anyhow::ensure!(frame.len() < 8192, "oversized hook SSE frame"); + } + }) + .await? + } + + #[tokio::test] + async fn open_hook_stream_sends_terminal_error_instead_of_first_event_after_key_revoke( + ) -> Result<()> { + use rocket::tokio::io::AsyncReadExt; + + let fixture = + hook_route_fixture(&[("hooks", "kv/documents", "tinycloud.hooks/subscribe")]).await?; + let (status, body) = fixture + .ticket( + fixture.session_header(&[(fixture.hooks("kv/documents")?, SUBSCRIBE)])?, + &fixture.space, + "kv", + "documents", + ) + .await; + assert_eq!(status, Status::Ok, "{body}"); + let ticket: HookTicketResponse = serde_json::from_str(&body)?; + let hooks = fixture + .client + .rocket() + .state::() + .expect("managed hooks runtime"); + let mut stream = fixture + .client + .get(format!("/hooks/events?ticket={}", ticket.ticket)) + .dispatch() + .await; + assert_eq!(stream.status(), Status::Ok); + + let event = |id: &str| crate::hooks::WriteEvent { + event_type: "write".to_string(), + id: id.to_string(), + space: fixture.space.to_string(), + service: "kv".to_string(), + ability: "tinycloud.kv/put".to_string(), + path: Some("documents/1".to_string()), + database_path: None, + table_name: None, + actor: did_of(&fixture.owner.1).to_string(), + epoch: "hook-cutoff".to_string(), + event_index: 1, + timestamp: "2026-10-15T00:00:00Z".to_string(), + }; + hooks.bus().publish(event("hook-cutoff:control")); + let first = next_hook_sse_event(&mut stream).await?; + assert!(first.contains("event:write\n"), "{first}"); + assert!(first.contains("id:hook-cutoff:control\n"), "{first}"); + + // Reading this frame ACKs the control delivery before revocation. + revoke_hook_test_key(&fixture, did_of(&fixture.session.1)).await?; + hooks.bus().publish(event("hook-cutoff:revoked")); + let terminal = next_hook_sse_event(&mut stream).await?; + assert!(terminal.contains("event:error\n"), "{terminal}"); + assert!(terminal.contains("authorization-key-revoked"), "{terminal}"); + assert!(!terminal.contains("hook-cutoff:revoked"), "{terminal}"); + let mut eof = [0u8; 1]; + assert_eq!( + rocket::tokio::time::timeout(Duration::from_secs(2), stream.read(&mut eof)).await??, + 0, + "stream must close after its terminal error" + ); + Ok(()) + } + #[tokio::test] async fn hook_stream_disables_proxy_buffering() -> Result<()> { let fixture = diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index 94cf9f1d..0d493b71 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -71,6 +71,7 @@ pub mod hooks; pub(crate) mod kv_sync; mod meeting_publication; pub mod node_keys; +pub use crate::key_revoke; pub mod public; #[cfg(feature = "tc-bench-v1")] pub mod tc_bench; @@ -345,9 +346,8 @@ pub async fn create_signed_kv_url( ) -> Result, (Status, String)> { let invocation_info = invocation.0 .0.clone(); verify_auth("server.signed_kv.auth", invocation.0, tinycloud).await?; - // Same policy gate as `/invoke`. A policy-session invocation lives at most - // 60 s, and the ticket never outlives it, so a revoked policy stops new - // signed URLs here and existing ones within a minute. + // Mint and every redemption check the policy's current revocation + // dependencies; the ticket lifetime is not a revocation grace period. policy_v3 .authorize_invocation(tinycloud, &invocation_info, OffsetDateTime::now_utc()) .await @@ -526,8 +526,16 @@ pub async fn delegation_status( match tinycloud .delegation_status(target, &auth.invoker, &auth.parents) .await - .map_err(|_| (Status::ServiceUnavailable, "Status unavailable".to_string()))? - { + .map_err(|error| match error { + tinycloud_core::db::TxError::AuthorizationDenied => { + (Status::Forbidden, "Status authority revoked".to_string()) + } + tinycloud_core::db::TxError::AuthorizationWaitTimeout => ( + Status::Forbidden, + "Status authority wait timed out".to_string(), + ), + _ => (Status::ServiceUnavailable, "Status unavailable".to_string()), + })? { Some(status) => Ok(Custom( Status::Ok, Json(DelegationStatusResponse::from_status(request.cid, status)), @@ -602,6 +610,9 @@ pub async fn delegate( database_failure(error, write_may_have_applied, e.to_string()) } TxError::SpaceNotFound => (Status::NotFound, e.to_string()), + TxError::AuthorizationDenied | TxError::AuthorizationWaitTimeout => { + (Status::Forbidden, e.to_string()) + } _ => (Status::Unauthorized, e.to_string()), }) .and_then(|result: TransactResult| { @@ -2210,6 +2221,8 @@ async fn invoke_impl( TxStoreError::Tx(TxError::SpaceNotFound) => Status::NotFound, TxStoreError::KvSyncInvalidRequest(_) => Status::BadRequest, TxStoreError::KvPreconditionFailed => Status::PreconditionFailed, + TxStoreError::Tx(TxError::AuthorizationDenied | TxError::AuthorizationWaitTimeout) => + Status::Forbidden, TxStoreError::MeetingSnapshotProtected => Status::Forbidden, TxStoreError::LegacyMeetingFrozen => Status::Conflict, TxStoreError::KvResponseTooLarge { .. } => Status::PayloadTooLarge, @@ -5389,9 +5402,17 @@ mod tests { #[tokio::test] async fn delegation_status_and_query_enforce_control_proofs_without_oracles() -> Result<()> { + use k256::ecdsa::SigningKey; use rocket::http::{ContentType, Header, Status}; use rocket::local::asynchronous::Client; + use sha3::Keccak256; use tinycloud_auth::authorization::Cid as AuthCid; + use tinycloud_auth::authorization::{HeaderEncode, TinyCloudDelegation}; + use tinycloud_auth::cacaos::{ + siwe::{encode_eip55, Message, Version}, + siwe_cacao::{Header as SiweHeader, Signature as SiweSignature, SiweCacao}, + }; + use tinycloud_auth::siwe_recap::Capability as RecapCapability; use tinycloud_auth::ssi::{ claims::jwt::NumericDate, dids::{DIDBuf, DIDURLBuf}, @@ -5400,11 +5421,91 @@ mod tests { }; use tinycloud_auth::ucan_capabilities_object::Capabilities; use tinycloud_core::models::{ - abilities, actor, delegation as deleg_model, revocation as revo_model, + abilities, actor, delegation as deleg_model, policy_v3_root, revocation as revo_model, }; use tinycloud_core::relationships::parent_delegations; use tinycloud_core::sea_orm::{ActiveModelTrait, ActiveValue::Set}; + fn signed_ucan_grant( + jwk: &JWK, + vm: &str, + audience: &str, + nonce: &str, + resource: &Resource, + action: &str, + expiry: f64, + ) -> Result> { + let mut capabilities = Capabilities::new(); + capabilities.with_action( + resource.to_string().parse()?, + action.parse::()?, + [std::collections::BTreeMap::::new()], + ); + Ok(Payload { + issuer: vm.parse::()?, + audience: audience.parse::()?, + not_before: None, + expiration: NumericDate::try_from_seconds(expiry)?, + nonce: Some(nonce.to_string()), + facts: Some(Vec::::new()), + proof: Vec::new(), + attenuation: capabilities, + } + .sign(jwk.get_algorithm().unwrap_or_default(), jwk)? + .encode()? + .into_bytes()) + } + + fn signed_wallet_session( + key: &SigningKey, + address: [u8; 20], + audience: &str, + resource: &Resource, + nonce: &str, + ) -> Result<(Vec, OffsetDateTime, OffsetDateTime)> { + let mut recap = RecapCapability::::new(); + for action in [ + "tinycloud.delegation/list", + "tinycloud.delegation/status", + "tinycloud.delegation/revoke", + ] { + recap.with_action( + resource.to_string().parse()?, + action.parse::()?, + [std::collections::BTreeMap::::new()], + ); + } + let issued = OffsetDateTime::now_utc() - time::Duration::minutes(1); + let expires = issued + time::Duration::hours(1); + let message = recap.build_message(Message { + scheme: Some("https".parse()?), + domain: "tc1149.test".parse()?, + address, + statement: None, + uri: audience.parse()?, + version: Version::V1, + chain_id: 1, + nonce: nonce.to_string(), + issued_at: issued.into(), + expiration_time: Some(expires.into()), + not_before: None, + request_id: None, + resources: vec![], + })?; + let (signature, recovery_id) = key.sign_prehash_recoverable(&message.eip191_hash()?)?; + let mut signature_bytes = [0u8; 65]; + signature_bytes[..64].copy_from_slice(signature.to_bytes().as_ref()); + signature_bytes[64] = u8::from(recovery_id) + 27; + let authorization = TinyCloudDelegation::Cacao(Box::new(SiweCacao::new( + message.into(), + SiweSignature::from(signature_bytes), + SiweHeader, + ))) + .encode()?; + let (_, serialization) = TinyCloudDelegation::decode(&authorization)?; + Ok((serialization, issued, expires)) + } + fn verification_method(jwk: &JWK) -> Result<(String, String)> { let did = DID_METHODS.generate(jwk, "key")?.to_string(); let fragment = did @@ -5512,8 +5613,14 @@ mod tests { let mut holder_jwk = JWK::generate_ed25519()?; holder_jwk.algorithm = Some(Algorithm::EdDSA); let (holder_did, holder_vm) = verification_method(&holder_jwk)?; - let owner_did = "did:key:zStatusOwner".to_string(); - let wallet_pkh = "did:pkh:eip155:1:0x1111111111111111111111111111111111111111".to_string(); + let mut owner_jwk = JWK::generate_ed25519()?; + owner_jwk.algorithm = Some(Algorithm::EdDSA); + let (owner_did, owner_vm) = verification_method(&owner_jwk)?; + let wallet_key = SigningKey::from_bytes(&[0x47; 32].into())?; + let wallet_public = wallet_key.verifying_key().to_encoded_point(false); + let wallet_digest = Keccak256::digest(&wallet_public.as_bytes()[1..]); + let wallet_address: [u8; 20] = wallet_digest[12..].try_into()?; + let wallet_pkh = format!("did:pkh:eip155:1:0x{}", encode_eip55(&wallet_address)); let unrelated_pkh = "did:pkh:eip155:1:0x2222222222222222222222222222222222222222".to_string(); for did in [&owner_did, &holder_did, &wallet_pkh, &unrelated_pkh] { @@ -5523,10 +5630,26 @@ mod tests { .insert(&conn) .await?; } - let parent_id = tinycloud_core::hash::hash(b"status-parent"); + let owner_control_space = SpaceId::new(owner_did.parse::()?, "control".parse()?); + let owner_control_resource = Resource::TinyCloud(owner_control_space.to_resource( + "delegation".parse()?, + None, + None, + None, + )); + let parent_serialization = signed_ucan_grant( + &owner_jwk, + &owner_vm, + &holder_did, + "status-parent", + &owner_control_resource, + "tinycloud.delegation/status", + 4_102_444_800.0, + )?; + let parent_id = tinycloud_core::hash::hash(&parent_serialization); let mut child_capabilities = Capabilities::new(); child_capabilities.with_action( - format!("urn:cid:{}", parent_id.to_cid(0x55)).parse()?, + owner_control_resource.to_string().parse()?, "tinycloud.delegation/status".parse::()?, [std::collections::BTreeMap::::new()], ); @@ -5544,30 +5667,39 @@ mod tests { .encode()? .into_bytes(); let child_id = tinycloud_core::hash::hash(&child_serialization); - let direct_id = tinycloud_core::hash::hash(b"status-direct"); - for (id, delegator, delegatee, bytes) in [ - ( - parent_id, - &owner_did, - &holder_did, - b"status-parent".as_slice(), - ), - ( - direct_id, - &owner_did, - &holder_did, - b"status-direct".as_slice(), - ), + let direct_serialization = signed_ucan_grant( + &owner_jwk, + &owner_vm, + &holder_did, + "status-direct", + &owner_control_resource, + "tinycloud.delegation/status", + 4_102_444_800.0, + )?; + let direct_id = tinycloud_core::hash::hash(&direct_serialization); + for (id, bytes) in [ + (parent_id, parent_serialization), + (direct_id, direct_serialization), ] { deleg_model::ActiveModel { id: Set(id), - delegator: Set(delegator.clone()), - delegatee: Set(delegatee.clone()), - expiry: Set(None), + delegator: Set(owner_did.clone()), + delegatee: Set(holder_did.clone()), + expiry: Set(Some(OffsetDateTime::from_unix_timestamp(4_102_444_800)?)), issued_at: Set(None), not_before: Set(None), facts: Set(None), - serialization: Set(bytes.to_vec()), + serialization: Set(bytes), + } + .insert(&conn) + .await?; + abilities::ActiveModel { + delegation: Set(id), + resource: Set(owner_control_resource.clone()), + ability: Set(Ability::try_from( + "tinycloud.delegation/status".to_string(), + )?), + caveats: Set(Default::default()), } .insert(&conn) .await?; @@ -5576,7 +5708,7 @@ mod tests { id: Set(child_id), delegator: Set(holder_did.clone()), delegatee: Set(holder_did.clone()), - expiry: Set(None), + expiry: Set(Some(OffsetDateTime::from_unix_timestamp(4_102_444_800)?)), issued_at: Set(None), not_before: Set(None), facts: Set(None), @@ -5584,7 +5716,26 @@ mod tests { } .insert(&conn) .await?; - let expired_id = tinycloud_core::hash::hash(b"status-expired"); + abilities::ActiveModel { + delegation: Set(child_id), + resource: Set(owner_control_resource.clone()), + ability: Set(Ability::try_from( + "tinycloud.delegation/status".to_string(), + )?), + caveats: Set(Default::default()), + } + .insert(&conn) + .await?; + let expired_serialization = signed_ucan_grant( + &owner_jwk, + &owner_vm, + &holder_did, + "status-expired", + &owner_control_resource, + "tinycloud.delegation/status", + 1.0, + )?; + let expired_id = tinycloud_core::hash::hash(&expired_serialization); deleg_model::ActiveModel { id: Set(expired_id), delegator: Set(owner_did.clone()), @@ -5593,37 +5744,94 @@ mod tests { issued_at: Set(None), not_before: Set(None), facts: Set(None), - serialization: Set(b"status-expired".to_vec()), + serialization: Set(expired_serialization), + } + .insert(&conn) + .await?; + abilities::ActiveModel { + delegation: Set(expired_id), + resource: Set(owner_control_resource.clone()), + ability: Set(Ability::try_from( + "tinycloud.delegation/status".to_string(), + )?), + caveats: Set(Default::default()), + } + .insert(&conn) + .await?; + let wallet_control_space = SpaceId::new(wallet_pkh.parse::()?, "control".parse()?); + let control_resource = Resource::TinyCloud(wallet_control_space.clone().to_resource( + "delegation".parse()?, + None, + None, + None, + )); + let query_control_resource = control_resource.clone(); + let (wallet_session_serialization, wallet_session_issued, wallet_session_expires) = + signed_wallet_session( + &wallet_key, + wallet_address, + &holder_did, + &control_resource, + "tc1149-wallet-session", + )?; + let wallet_session_id = tinycloud_core::hash::hash(&wallet_session_serialization); + deleg_model::ActiveModel { + id: Set(wallet_session_id), + delegator: Set(wallet_pkh.clone()), + delegatee: Set(holder_did.clone()), + expiry: Set(Some(wallet_session_expires)), + issued_at: Set(Some(wallet_session_issued)), + not_before: Set(None), + facts: Set(None), + serialization: Set(wallet_session_serialization), } .insert(&conn) .await?; - let wallet_session_id = tinycloud_core::hash::hash(b"wallet-session-proof"); let unrelated_session_id = tinycloud_core::hash::hash(b"unrelated-session-proof"); let wrong_target_session_id = tinycloud_core::hash::hash(b"wrong-target-session-proof"); let foreign_scope_session_id = tinycloud_core::hash::hash(b"foreign-scope-session-proof"); let narrow_scope_session_id = tinycloud_core::hash::hash(b"narrow-scope-session-proof"); let query_scope_session_id = tinycloud_core::hash::hash(b"query-scope-session-proof"); let fragment_scope_session_id = tinycloud_core::hash::hash(b"fragment-scope-session-proof"); - let wallet_child_id = tinycloud_core::hash::hash(b"wallet-pkh-child"); + let wallet_child_serialization = signed_ucan_grant( + &owner_jwk, + &owner_vm, + &wallet_pkh, + "wallet-pkh-child", + &owner_control_resource, + "tinycloud.delegation/status", + 4_102_444_800.0, + )?; + let wallet_child_id = tinycloud_core::hash::hash(&wallet_child_serialization); + deleg_model::ActiveModel { + id: Set(wallet_child_id), + delegator: Set(owner_did.clone()), + delegatee: Set(wallet_pkh.clone()), + expiry: Set(Some(OffsetDateTime::from_unix_timestamp(4_102_444_800)?)), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(wallet_child_serialization), + } + .insert(&conn) + .await?; + abilities::ActiveModel { + delegation: Set(wallet_child_id), + resource: Set(owner_control_resource.clone()), + ability: Set(Ability::try_from( + "tinycloud.delegation/status".to_string(), + )?), + caveats: Set(Default::default()), + } + .insert(&conn) + .await?; for (id, delegator, delegatee, bytes) in [ - ( - wallet_session_id, - &wallet_pkh, - &holder_did, - b"wallet-session-proof".as_slice(), - ), ( unrelated_session_id, &wallet_pkh, &holder_did, b"unrelated-session-proof".as_slice(), ), - ( - wallet_child_id, - &owner_did, - &wallet_pkh, - b"wallet-pkh-child".as_slice(), - ), ( wrong_target_session_id, &wallet_pkh, @@ -5668,17 +5876,6 @@ mod tests { .insert(&conn) .await?; } - let wallet_control_space = SpaceId::new( - wallet_pkh.parse::()?, - "control".parse().expect("valid space name"), - ); - let control_resource = Resource::TinyCloud(wallet_control_space.clone().to_resource( - "delegation".parse()?, - None, - None, - None, - )); - let query_control_resource = control_resource.clone(); for action in [ "tinycloud.delegation/status", "tinycloud.delegation/revoke", @@ -6113,7 +6310,7 @@ mod tests { .await?; revo_model::ActiveModel { id: Set(tinycloud_core::hash::hash(b"status-parent-revocation")), - revoker: Set(owner_did), + revoker: Set(owner_did.clone()), revoked: Set(parent_id), serialization: Set(b"status-parent-revocation".to_vec()), revoked_at: Set(None), @@ -6211,6 +6408,474 @@ mod tests { assert_eq!(body["status"], "not_found"); assert_eq!(body["exists"], false); + // A valid control session remains independently usable after the + // original holder's signing key is globally revoked. + use tinycloud_core::models::key_revocation; + + let direct_live_serialization = signed_ucan_grant( + &owner_jwk, + &owner_vm, + &holder_did, + "tc1149-direct-live", + &owner_control_resource, + "tinycloud.delegation/status", + 4_102_444_800.0, + )?; + let direct_live_id = tinycloud_core::hash::hash(&direct_live_serialization); + let conn = client + .rocket() + .state::() + .expect("tinycloud state") + .readable() + .await?; + deleg_model::ActiveModel { + id: Set(direct_live_id), + delegator: Set(owner_did.clone()), + delegatee: Set(holder_did.clone()), + expiry: Set(Some(OffsetDateTime::from_unix_timestamp(4_102_444_800)?)), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(direct_live_serialization), + } + .insert(&conn) + .await?; + abilities::ActiveModel { + delegation: Set(direct_live_id), + resource: Set(owner_control_resource.clone()), + ability: Set(Ability::try_from( + "tinycloud.delegation/status".to_string(), + )?), + caveats: Set(Default::default()), + } + .insert(&conn) + .await?; + + let mut control_jwk = JWK::generate_ed25519()?; + control_jwk.algorithm = Some(Algorithm::EdDSA); + let (control_did, control_vm) = verification_method(&control_jwk)?; + actor::ActiveModel { + id: Set(control_did.clone()), + } + .insert(&conn) + .await?; + let owner_control_serialization = signed_ucan_grant( + &owner_jwk, + &owner_vm, + &control_did, + "tc1149-owner-control", + &owner_control_resource, + "tinycloud.delegation/status", + 4_102_444_800.0, + )?; + let owner_control_id = tinycloud_core::hash::hash(&owner_control_serialization); + deleg_model::ActiveModel { + id: Set(owner_control_id), + delegator: Set(owner_did.clone()), + delegatee: Set(control_did.clone()), + expiry: Set(Some(OffsetDateTime::from_unix_timestamp(4_102_444_800)?)), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(owner_control_serialization), + } + .insert(&conn) + .await?; + abilities::ActiveModel { + delegation: Set(owner_control_id), + resource: Set(owner_control_resource.clone()), + ability: Set(Ability::try_from( + "tinycloud.delegation/status".to_string(), + )?), + caveats: Set(Default::default()), + } + .insert(&conn) + .await?; + let (control_serialization, control_issued, control_expires) = signed_wallet_session( + &wallet_key, + wallet_address, + &control_did, + &query_control_resource, + "tc1149-independent-session", + )?; + let control_session_id = tinycloud_core::hash::hash(&control_serialization); + deleg_model::ActiveModel { + id: Set(control_session_id), + delegator: Set(wallet_pkh.clone()), + delegatee: Set(control_did.clone()), + expiry: Set(Some(control_expires)), + issued_at: Set(Some(control_issued)), + not_before: Set(None), + facts: Set(None), + serialization: Set(control_serialization), + } + .insert(&conn) + .await?; + for action in [ + "tinycloud.delegation/list", + "tinycloud.delegation/status", + "tinycloud.delegation/revoke", + ] { + abilities::ActiveModel { + delegation: Set(control_session_id), + resource: Set(query_control_resource.clone()), + ability: Set(Ability::try_from(action.to_string())?), + caveats: Set(Default::default()), + } + .insert(&conn) + .await?; + } + conn.commit().await?; + + let direct_live_cid = direct_live_id.to_cid(0x55).to_string(); + let owner_control_cid = owner_control_id.to_cid(0x55).to_string(); + let session_cid = wallet_session_id.to_cid(0x55).to_string(); + let control_session_cid = control_session_id.to_cid(0x55).to_string(); + let holder_query = || -> Result { + query_header_with_proofs( + &holder_jwk, + &holder_vm, + &holder_did, + &query_control_resource, + "tc1149-holder-query", + vec![wallet_session_id.to_cid(0x55)], + ) + }; + let control_query = || -> Result { + query_header_with_proofs( + &control_jwk, + &control_vm, + &control_did, + &query_control_resource, + "tc1149-control-query", + vec![control_session_id.to_cid(0x55)], + ) + }; + for (label, header) in [ + ("target", holder_query()?), + ("independent session", control_query()?), + ] { + let response = query_request(header).dispatch().await; + assert_eq!( + response.status(), + Status::Ok, + "{label} query before key revoke" + ); + let body = response.into_string().await.unwrap_or_default(); + assert!(body.contains(&session_cid), "{label} query: {body}"); + } + let response = request( + &direct_live_cid, + status_header( + &holder_jwk, + &holder_vm, + &holder_did, + &direct_live_cid, + "tc1149-direct-before", + )?, + ) + .dispatch() + .await; + assert_eq!( + response.status(), + Status::Ok, + "proofless direct-root status" + ); + let body: serde_json::Value = serde_json::from_str(&response.into_string().await.unwrap())?; + assert_eq!(body["status"], "active"); + // A status read shares the registry with another reader. It must not + // queue behind an exclusive worker while a live read holds this CID. + let existing_reader = client + .rocket() + .state::() + .expect("tinycloud state") + .guard_delegation_chains(&[direct_live_id]) + .await?; + let simultaneous_status = tokio::time::timeout( + std::time::Duration::from_secs(1), + request( + &direct_live_cid, + status_header( + &holder_jwk, + &holder_vm, + &holder_did, + &direct_live_cid, + "tc1149-concurrent-reader", + )?, + ) + .dispatch(), + ) + .await + .expect("status read queued behind another shared reader"); + drop(existing_reader); + assert_eq!(simultaneous_status.status(), Status::Ok); + for (label, jwk, vm, did, proof) in [ + ( + "target", + &holder_jwk, + &holder_vm, + &holder_did, + wallet_session_id, + ), + ( + "independent session", + &control_jwk, + &control_vm, + &control_did, + control_session_id, + ), + ] { + let response = request( + &session_cid, + status_header_with_proofs( + jwk, + vm, + did, + &session_cid, + label, + vec![proof.to_cid(0x55)], + )?, + ) + .dispatch() + .await; + assert_eq!( + response.status(), + Status::Ok, + "{label} delegated status before key revoke" + ); + let body: serde_json::Value = + serde_json::from_str(&response.into_string().await.unwrap())?; + assert_eq!(body["status"], "active", "{label}"); + } + + // Model the durable self-global key tombstone. Neither the signed + // proof nor the queried delegation CID is revoked. + let conn = client + .rocket() + .state::() + .expect("tinycloud state") + .readable() + .await?; + key_revocation::ActiveModel { + target_did: Set(holder_did.clone()), + scope_key: Set("global".to_string()), + revoked_at: Set(OffsetDateTime::now_utc().unix_timestamp()), + } + .insert(&conn) + .await?; + conn.commit().await?; + + let response = query_request(holder_query()?).dispatch().await; + assert!( + response.status() == Status::Unauthorized || response.status() == Status::Forbidden, + "revoked signer queried delegation metadata: {}", + response.status() + ); + for (label, cid, header) in [ + ( + "proofless direct-root", + &direct_live_cid, + status_header( + &holder_jwk, + &holder_vm, + &holder_did, + &direct_live_cid, + "tc1149-direct-after", + )?, + ), + ( + "delegated proof", + &session_cid, + status_header_with_proofs( + &holder_jwk, + &holder_vm, + &holder_did, + &session_cid, + "tc1149-session-after", + vec![wallet_session_id.to_cid(0x55)], + )?, + ), + ] { + let response = request(cid, header).dispatch().await; + assert!( + response.status() == Status::Unauthorized || response.status() == Status::Forbidden, + "{label} status accepted revoked signer: {}", + response.status() + ); + } + let response = query_request(control_query()?).dispatch().await; + assert_eq!( + response.status(), + Status::Ok, + "independent session query after key revoke" + ); + let body = response.into_string().await.unwrap_or_default(); + assert!(body.contains(&session_cid), "{body}"); + let revoked_target = request( + &session_cid, + status_header_with_proofs( + &control_jwk, + &control_vm, + &control_did, + &session_cid, + "tc1149-control-revoked-target", + vec![control_session_id.to_cid(0x55)], + )?, + ) + .dispatch() + .await; + assert_eq!( + revoked_target.status(), + Status::Forbidden, + "an independent signer cannot activate the revoked target" + ); + let response = request( + &control_session_cid, + status_header_with_proofs( + &control_jwk, + &control_vm, + &control_did, + &control_session_cid, + "tc1149-control-after", + vec![control_session_id.to_cid(0x55)], + )?, + ) + .dispatch() + .await; + assert_eq!( + response.status(), + Status::Ok, + "independent session status after key revoke" + ); + let body: serde_json::Value = serde_json::from_str(&response.into_string().await.unwrap())?; + assert_eq!(body["status"], "active"); + let response = request( + &owner_control_cid, + status_header( + &owner_jwk, + &owner_vm, + &owner_did, + &owner_control_cid, + "tc1149-owner-after", + )?, + ) + .dispatch() + .await; + assert_eq!( + response.status(), + Status::Ok, + "independent owner status after key revoke" + ); + let body: serde_json::Value = serde_json::from_str(&response.into_string().await.unwrap())?; + assert_eq!(body["status"], "active"); + // A target CID may also have a generic revoke. Reporting that CID's + // status must not reauthorize the already-tombstoned requester key. + let conn = client + .rocket() + .state::() + .expect("tinycloud state") + .readable() + .await?; + revo_model::ActiveModel { + id: Set(tinycloud_core::hash::hash( + b"tc1149-direct-live-cid-revocation", + )), + revoker: Set(owner_did.clone()), + revoked: Set(direct_live_id), + serialization: Set(b"tc1149-direct-live-cid-revocation".to_vec()), + revoked_at: Set(None), + } + .insert(&conn) + .await?; + conn.commit().await?; + let denied_status = request( + &direct_live_cid, + status_header( + &holder_jwk, + &holder_vm, + &holder_did, + &direct_live_cid, + "tc1149-revoked-cid-and-caller-key", + )?, + ) + .dispatch() + .await; + assert_eq!(denied_status.status(), Status::Forbidden); + let owner_status = request( + &direct_live_cid, + status_header( + &owner_jwk, + &owner_vm, + &owner_did, + &direct_live_cid, + "tc1149-revoked-cid-live-owner", + )?, + ) + .dispatch() + .await; + assert_eq!( + owner_status.status(), + Status::Forbidden, + "a revoked target key cannot be reported through an independent signer" + ); + + // A control proof can be valid as an ordinary grant, but cannot + // silently stay valid when its ancestry is recorded as an admitted + // policy root without the corresponding signed session/witness. + let independent_control = client + .post("/revoke") + .header(Header::new( + "Authorization", + revocation_header( + &control_jwk, + &control_vm, + &control_did, + &unrelated_session_id.to_cid(0x55).to_string(), + control_session_id.to_cid(0x55), + )?, + )) + .dispatch() + .await; + assert_eq!(independent_control.status(), Status::Ok); + let conn = client + .rocket() + .state::() + .expect("tinycloud state") + .readable() + .await?; + policy_v3_root::ActiveModel { + root_cid: Set(control_session_cid.clone()), + policy_cid: Set("tc1149-missing-control-witness".to_owned()), + role: Set("policy-authority".to_owned()), + authorization_bytes: Set(b"spoofed-policy-root".to_vec()), + status_checkpoint_bytes: Set(None), + previous_checkpoint_digest_hex: Set(None), + status_sequence: Set(0), + admission_epoch: Set(0), + status_checked_at: Set(None), + status_fresh_until: Set(None), + revoked_at: Set(None), + revocation_bytes: Set(None), + } + .insert(&conn) + .await?; + conn.commit().await?; + let missing_witness = client + .post("/revoke") + .header(Header::new( + "Authorization", + revocation_header( + &control_jwk, + &control_vm, + &control_did, + &session_cid, + control_session_id.to_cid(0x55), + )?, + )) + .dispatch() + .await; + assert_eq!(missing_witness.status(), Status::Forbidden); + Ok(()) } diff --git a/tinycloud-node-server/src/share_v2.rs b/tinycloud-node-server/src/share_v2.rs index 710e2fd3..2b2b8ec5 100644 --- a/tinycloud-node-server/src/share_v2.rs +++ b/tinycloud-node-server/src/share_v2.rs @@ -2009,7 +2009,19 @@ async fn verify_registered_ancestors( ®istered.envelope, ®istered.row.policy_cid, &enforcement, + )?; + tinycloud_core::key_authority::check_principals( + &runtime.conn, + &[ + registered.row.owner_did.clone(), + registered.row.share_key_did.clone(), + registered.row.enforcer_did.clone(), + ], + std::slice::from_ref(®istered.row.space_id), ) + .await + .map_err(|_| ())?; + Ok(()) } async fn cid_revoked(db: &C, cid: &str) -> Result { @@ -2928,6 +2940,13 @@ pub async fn invoke_v2( verify_registered_ancestors(runtime, ®istered, None) .await .map_err(|_| share_error("invoke_denied"))?; + tinycloud_core::key_authority::check_principals( + &runtime.conn, + std::slice::from_ref(&challenge.holder_did), + std::slice::from_ref(®istered.row.space_id), + ) + .await + .map_err(|_| share_error("invoke_denied"))?; let credential_digest = session .binding_json .get("credentialDigest") @@ -3294,6 +3313,22 @@ pub async fn authorize_delivery_v2( verify_registered_ancestors(runtime, ®istered, None) .await .map_err(|_| share_error("delivery_authorization_invalid"))?; + let proof_ids = invocation + .0 + .0 + .parents + .iter() + .copied() + .map(tinycloud_core::hash::Hash::from) + .collect::>(); + tinycloud_core::key_authority::check_invoker_chain( + &runtime.conn, + &proof_ids, + &invocation.0 .0.invoker, + std::slice::from_ref(®istered.row.space_id), + ) + .await + .map_err(|_| share_error("delivery_authorization_invalid"))?; let expires = OffsetDateTime::parse(&request.expires_at, &Rfc3339) .map_err(|_| share_error("delivery_authorization_invalid"))?; let now = OffsetDateTime::now_utc(); @@ -3411,6 +3446,8 @@ mod tests { #[path = "alias_revocation_tests.rs"] mod alias_revocation_tests; + #[path = "share_v2_legacy_key_tests.rs"] + mod share_v2_legacy_key_tests; #[test] fn policy_domain_matches_the_sdk_wire_contract() { diff --git a/tinycloud-node-server/src/share_v2/tests/share_v2_legacy_key_tests.rs b/tinycloud-node-server/src/share_v2/tests/share_v2_legacy_key_tests.rs new file mode 100644 index 00000000..91c8d99a --- /dev/null +++ b/tinycloud-node-server/src/share_v2/tests/share_v2_legacy_key_tests.rs @@ -0,0 +1,733 @@ +use super::*; +use tinycloud_auth::{ + authorization::{make_invocation, InvocationOptions}, + resolver::DID_METHODS, + siwe_recap::Ability as UcanAbility, + ssi::{ + claims::jwt::NumericDate, + dids::{DIDBuf, DIDURLBuf}, + jwk::{Algorithm, JWK}, + ucan::Payload, + }, + ucan_capabilities_object::Capabilities, +}; +use tinycloud_core::{ + models::{actor, key_revocation, space}, + types::{Ability, Caveats, SpaceIdWrap}, +}; + +// Registration and challenge/session creation closed September 30. The +// signed synthetic policy and delegation are inserted as a reconstruction of +// a historically registered policy, not sent to today's closed creation +// route. Reads remain open until January 5; new challenge/mint requests +// continue returning 410. A real pre-cutoff five-minute session cannot still +// be live today, so its short-lived signed binding is reconstructed separately. +struct HistoricalShare { + challenge: Value, + session_id: String, + sender: JWK, + sender_did: String, + sender_vm: String, + sender_delegation_cid: tinycloud_auth::ipld_core::cid::Cid, + holder: Ed25519InvitationSigner, + registered: RegisteredPolicy, +} + +fn owner_identity() -> (JWK, String, String) { + let owner = JWK::generate_ed25519().expect("owner Ed25519 key"); + let did = DID_METHODS + .generate(&owner, "key") + .expect("owner DID") + .to_string(); + let vm = canonical_did_key_kid(&did); + (owner, did, vm) +} + +async fn historical_share( + runtime: &ShareV2Runtime, + owner: &JWK, + owner_did: &str, + owner_vm: &str, + share_id: &str, +) -> HistoricalShare { + let (share_key, share_did) = tamper_keypair_and_did(); + let (holder, holder_did) = tamper_keypair_and_did(); + let (mut sender, sender_did, sender_vm) = owner_identity(); + sender.algorithm = Some(Algorithm::EdDSA); + let path = format!("shares/{share_id}"); + let space_id = tinycloud_auth::resource::SpaceId::new( + owner_did.parse::().expect("owner DID"), + "applications".parse().expect("space name"), + ); + let space_id_string = space_id.to_string(); + let expires_at = "2026-12-28T00:00:00.000Z".to_owned(); + let actions = vec![ + "tinycloud.kv/get".to_owned(), + "tinycloud.kv/list".to_owned(), + ]; + let service = "kv".parse().expect("kv service"); + let resource_path = path.parse().expect("resource path"); + let resource = space_id.to_resource(service, Some(resource_path), None, None); + let mut capabilities = Capabilities::new(); + for action in &actions { + capabilities.with_action( + resource.as_uri(), + action.parse::().expect("UCAN action"), + [std::collections::BTreeMap::::new()], + ); + } + let owner_ucan = Payload { + issuer: owner_vm + .parse::() + .expect("owner verification method"), + audience: share_did.parse::().expect("share DID"), + not_before: None, + expiration: NumericDate::try_from_seconds(1_798_416_000.0).expect("December 2026"), + nonce: Some(format!("historical-{share_id}")), + facts: Some(Vec::::new()), + proof: vec![], + attenuation: capabilities, + } + .sign(Algorithm::EdDSA, owner) + .expect("signed owner delegation") + .encode() + .expect("encoded owner delegation") + .into_bytes(); + let owner_cid = raw_blake3_cid(&owner_ucan); + let owner_id: tinycloud_core::hash::Hash = owner_cid + .parse::() + .expect("owner CID") + .into(); + actor::ActiveModel { + id: Set(share_did.clone()), + } + .insert(&runtime.conn) + .await + .expect("share-key actor"); + delegation::ActiveModel { + id: Set(owner_id), + delegator: Set(owner_did.to_owned()), + delegatee: Set(share_did.clone()), + expiry: Set(Some(OffsetDateTime::parse(&expires_at, &Rfc3339).unwrap())), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(runtime.policy_encryption.encrypt(&owner_ucan)), + } + .insert(&runtime.conn) + .await + .expect("historical signed owner delegation row"); + for action in &actions { + abilities::ActiveModel { + resource: Set(Resource::TinyCloud(resource.clone())), + ability: Set(Ability::try_from(action.clone()).expect("stored ability")), + delegation: Set(owner_id), + caveats: Set(Caveats(Default::default())), + } + .insert(&runtime.conn) + .await + .expect("owner ability row"); + } + // A separate owner-signed grant gives the sender exactly one ordinary + // capability for the delivery route's authenticated invocation. + let mut sender_capabilities = Capabilities::new(); + sender_capabilities.with_action( + resource.as_uri(), + "tinycloud.kv/get".parse::().unwrap(), + [std::collections::BTreeMap::::new()], + ); + let sender_ucan = Payload { + issuer: owner_vm.parse::().unwrap(), + audience: sender_did.parse::().unwrap(), + not_before: None, + expiration: NumericDate::try_from_seconds(1_798_416_000.0).unwrap(), + nonce: Some(format!("historical-sender-{share_id}")), + facts: Some(Vec::::new()), + proof: vec![], + attenuation: sender_capabilities, + } + .sign(Algorithm::EdDSA, owner) + .expect("signed sender grant") + .encode() + .expect("encoded sender grant") + .into_bytes(); + let sender_delegation_cid: tinycloud_auth::ipld_core::cid::Cid = + raw_blake3_cid(&sender_ucan).parse().expect("sender CID"); + actor::ActiveModel { + id: Set(sender_did.clone()), + } + .insert(&runtime.conn) + .await + .expect("sender actor"); + delegation::ActiveModel { + id: Set(sender_delegation_cid.into()), + delegator: Set(owner_did.to_owned()), + delegatee: Set(sender_did.clone()), + expiry: Set(Some(OffsetDateTime::parse(&expires_at, &Rfc3339).unwrap())), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(runtime.policy_encryption.encrypt(&sender_ucan)), + } + .insert(&runtime.conn) + .await + .expect("historical signed sender grant row"); + abilities::ActiveModel { + resource: Set(Resource::TinyCloud(resource.clone())), + ability: Set(Ability::try_from("tinycloud.kv/get".to_owned()).unwrap()), + delegation: Set(sender_delegation_cid.into()), + caveats: Set(Caveats(Default::default())), + } + .insert(&runtime.conn) + .await + .expect("sender ability row"); + let source = serde_json::json!({ + "kind": "kv", "space": space_id_string, "path": path, "action": "tinycloud.kv/get" + }); + let source_digest = b64_digest(&jcs::canonicalize(&source)); + let policy = serde_json::json!({ + "domain": POLICY_DOMAIN, + "policy": { + "type": "TinyCloudSharePolicy", "version": 2, + "shareId": share_id, "ownerDid": owner_did, "shareKeyDid": share_did, + "recipientMatcher": {"kind": "exactEmail", "value": "alice@example.com"}, + "target": {"origin": runtime.config.target_origin, "nodeAudience": runtime.config.node_audience, + "enforcerDid": runtime.enforcer_did, "spaceId": space_id_string}, + "resource": {"kind": "prefix", "path": path}, + "actions": actions, "contentSource": source, + "contentSourceDigest": source_digest, "ownerDelegationCid": owner_cid, + "expiresAt": expires_at + } + }); + let policy_bytes = jcs::canonicalize(&policy); + let policy_cid = raw_sha256_cid(&policy_bytes); + let policy_proof = encode_config( + share_key.sign(&policy_bytes).expect("policy signed"), + URL_SAFE_NO_PAD, + ); + let facts: EnforcementFacts = serde_json::from_value(serde_json::json!({ + "ownerDelegationCid": owner_cid, "policyCid": policy_cid, "shareId": share_id, + "shareKeyDid": share_did, "enforcerDid": runtime.enforcer_did, + "nodeAudience": runtime.config.node_audience, "spaceId": space_id_string, + "path": path, "actions": actions, "contentSourceDigest": source_digest, + "expiresAt": expires_at + })) + .expect("enforcement facts"); + let enforcement_wire = serde_json::json!({ + "domain": ENFORCEMENT_DOMAIN, + "unsigned": {"type": "TinyCloudSharePolicyEnforcement", "version": 2, + "issuerDid": share_did, "audienceDid": runtime.enforcer_did, "facts": facts} + }); + let enforcement_bytes = + serde_ipld_dagcbor::to_vec(&enforcement_wire).expect("canonical DAG-CBOR"); + let enforcement = EnforcementDelegationInput { + cid: raw_sha256_cid(&enforcement_bytes), + dag_cbor: encode_config(&enforcement_bytes, URL_SAFE_NO_PAD), + issuer_did: share_did.clone(), + audience_did: runtime.enforcer_did.clone(), + facts, + signature: encode_config( + share_key + .sign(&enforcement_bytes) + .expect("enforcement signed"), + URL_SAFE_NO_PAD, + ), + }; + let matcher = &policy["policy"]["recipientMatcher"]; + let registration = Registration { + registration_cid: String::new(), + policy_cid: policy_cid.clone(), + owner_delegation_cid: owner_cid.clone(), + enforcement_delegation_cid: enforcement.cid.clone(), + owner_did: owner_did.to_owned(), + share_key_did: share_did.clone(), + enforcer_did: runtime.enforcer_did.clone(), + share_id: share_id.to_owned(), + recipient_matcher: serde_json::from_value(matcher.clone()).unwrap(), + target: RegistrationTarget { + origin: runtime.config.target_origin.clone(), + node_audience: runtime.config.node_audience.clone(), + enforcer_did: runtime.enforcer_did.clone(), + space_id: space_id_string.clone(), + }, + resource: ExactResource { + kind: "prefix".into(), + path: path.clone(), + }, + actions: actions.clone(), + decryption: None, + content_source: serde_json::from_value(source.clone()).unwrap(), + content_source_digest: source_digest.clone(), + registered_at: "2026-09-29T00:00:00.000Z".into(), + expires_at: expires_at.clone(), + }; + let mut core = serde_json::to_value(®istration).unwrap(); + core.as_object_mut().unwrap().remove("registrationCid"); + let registration_cid = raw_sha256_cid(&jcs::canonicalize(&core)); + let row = owner_share_policy::ActiveModel { + policy_cid: Set(policy_cid.clone()), + registration_cid: Set(registration_cid.clone()), + owner_delegation_cid: Set(owner_cid.clone()), + enforcement_delegation_cid: Set(enforcement.cid.clone()), + policy_bytes: Set(runtime.policy_encryption.encrypt(&policy_bytes)), + enforcement_delegation_bytes: Set(runtime + .policy_encryption + .encrypt(&serde_json::to_vec(&enforcement).unwrap())), + policy_proof: Set(policy_proof), + policy_digest: Set(b64_digest(&policy_bytes)), + matcher_digest: Set(b64_digest(&jcs::canonicalize(matcher))), + content_source_digest: Set(source_digest.clone()), + owner_did: Set(owner_did.to_owned()), + share_key_did: Set(share_did.clone()), + enforcer_did: Set(runtime.enforcer_did.clone()), + node_audience: Set(runtime.config.node_audience.clone()), + target_origin: Set(runtime.config.target_origin.clone()), + space_id: Set(space_id_string.clone()), + resource_kind: Set("prefix".to_owned()), + resource_path: Set(path.clone()), + actions: Set(serde_json::to_value(&actions).unwrap()), + registered_at: Set(registration.registered_at), + expires_at: Set(expires_at.clone()), + revoked_at: Set(None), + }; + row.insert(&runtime.conn) + .await + .expect("historical policy registration row"); + let registered = registered_policy(runtime, ®istration_cid, &policy_cid) + .await + .expect("signed historical registration passes real DB and signature checks"); + verify_registered_ancestors(runtime, ®istered, Some(&enforcement)) + .await + .expect("signed owner and enforcement ancestors pass real verification"); + + let target = policy["policy"]["target"].clone(); + let envelope_identity = serde_json::json!({ + "schema": "xyz.tinycloud.share/envelope/v2", "version": 2, + "shareId": share_id, "delegationCid": owner_cid, "policyCid": policy_cid, + "target": target, "resource": {"kind": "prefix", "path": path}, + "actions": actions, "contentSource": source, "contentSourceDigest": source_digest, + "expiresAt": expires_at + }); + let envelope_cid = raw_sha256_cid(&jcs::canonicalize(&envelope_identity)); + let share_cid = raw_sha256_cid(&jcs::canonicalize(&serde_json::json!({ + "version": 2, "shareId": share_id, "policyCid": policy_cid, "envelopeCid": envelope_cid + }))); + let mut outer = envelope_identity; + let obj = outer.as_object_mut().unwrap(); + obj.insert("envelopeCid".into(), Value::String(envelope_cid.clone())); + obj.insert("shareCid".into(), Value::String(share_cid.clone())); + let mut signed_outer = b"xyz.tinycloud.share/envelope/v2\0".to_vec(); + signed_outer.extend(jcs::canonicalize(&outer)); + outer.as_object_mut().unwrap().insert("signature".into(), serde_json::json!({ + "signerDid": share_did, "algorithm": "Ed25519", + "value": encode_config(share_key.sign(&signed_outer).expect("signed outer envelope"), URL_SAFE_NO_PAD) + })); + let mut challenge = serde_json::json!({ + "envelopeCid": envelope_cid, "shareCid": share_cid, "shareId": share_id, + "registrationCid": registration_cid, "delegationCid": owner_cid, + "policyCid": policy_cid, "enforcementDelegationCid": enforcement.cid, + "enforcementDelegation": enforcement, "outerEnvelope": outer, + "contentSource": source, "contentSourceDigest": source_digest, + "holderDid": holder_did, "targetOrigin": runtime.config.target_origin, + "nodeAudience": runtime.config.node_audience, "action": "tinycloud.kv/list", + "actions": actions, "resource": path + }); + let digest = b64_digest(&jcs::canonicalize(&challenge)); + challenge + .as_object_mut() + .unwrap() + .insert("requestBodyDigest".into(), Value::String(digest)); + let challenge_request: V2ChallengeRequest = serde_json::from_value(challenge.clone()).unwrap(); + verify_outer_envelope( + &challenge_request.outer_envelope, + &challenge_request, + &share_did, + None, + ) + .expect("historically signed outer envelope verifies"); + // No post-cutoff mint route exists. Reconstruct only its durable binding + // from the real node signer and a genuine holder-signed presentation. + let session_id = format!("historical-session-{share_id}"); + let session_issued = timestamp(OffsetDateTime::now_utc()); + let session_expires = timestamp(OffsetDateTime::now_utc() + Duration::minutes(4)); + let credential_digest = b64_digest(b"synthetic credential bound to test-only session"); + let presentation = serde_json::json!({ + "type": "TinyCloudSharePolicyPresentation", "version": 2, + "challengeId": format!("synthetic-{share_id}"), "nonce": format!("nonce-{share_id}"), + "shareCid": share_cid, "shareId": share_id, "delegationCid": owner_cid, + "policyCid": policy_cid, "contentSource": source, "contentSourceDigest": source_digest, + "holderDid": holder_did, "targetOrigin": runtime.config.target_origin, + "nodeAudience": runtime.config.node_audience, "enforcerDid": runtime.enforcer_did, + "credentialDigest": credential_digest, "action": "tinycloud.kv/list", + "actions": actions, "resource": path, + "requestBodyDigest": challenge_request.request_body_digest, + "issuedAt": session_issued, "expiresAt": session_expires, "jti": format!("vp-{share_id}") + }); + let presentation_proof = signed_value(&holder, V2_SESSION_DOMAIN, &presentation) + .expect("holder-signed session presentation"); + verify_v2_proof( + &holder_did, + &presentation_proof, + V2_SESSION_DOMAIN, + &presentation, + ) + .expect("holder session proof verifies"); + let vp_digest = b64_digest(&jcs::canonicalize(&presentation)); + let runtime_delegation = create_runtime_delegation( + runtime, + ®istered, + &challenge_request, + &holder_did, + &credential_digest, + &vp_digest, + &session_expires, + ) + .await + .expect("node-signed reconstructed session delegation"); + share_session_handle::ActiveModel { + session_handle: Set(session_id.clone()), + authority_session_cid: Set(registration_cid), + binding_json: Set(serde_json::json!({ + "challenge": challenge, "holderDid": holder_did, + "credentialDigest": credential_digest, "vpDigest": vp_digest, + "runtimeDelegation": runtime_delegation + })), + holder_digest: Set(b64_digest(holder_did.as_bytes())), + issued_at: Set(session_issued), + expires_at: Set(session_expires), + revoked_at: Set(None), + } + .insert(&runtime.conn) + .await + .expect("reconstructed short-lived holder session row"); + HistoricalShare { + challenge: serde_json::to_value(challenge_request).unwrap(), + session_id, + holder, + registered, + sender, + sender_did, + sender_vm, + sender_delegation_cid, + } +} + +async fn invoke(client: &Client, share: &HistoricalShare, jti: &str) -> (Status, Value) { + let challenge = &share.challenge; + let now = OffsetDateTime::now_utc(); + let mut invocation = serde_json::json!({ + "type": "TinyCloudShareReadInvocation", "version": 2, + "sessionId": share.session_id, "envelopeCid": challenge["envelopeCid"], + "shareCid": challenge["shareCid"], "shareId": challenge["shareId"], + "registrationCid": challenge["registrationCid"], "delegationCid": challenge["delegationCid"], + "policyCid": challenge["policyCid"], + "enforcementDelegationCid": challenge["enforcementDelegationCid"], + "contentSource": challenge["contentSource"], "contentSourceDigest": challenge["contentSourceDigest"], + "holderDid": challenge["holderDid"], "nodeAudience": challenge["nodeAudience"], + "action": "tinycloud.kv/list", "actions": challenge["actions"], + "resource": challenge["resource"], "issuedAt": timestamp(now), + "expiresAt": timestamp(now + Duration::minutes(1)), "jti": jti, + "bodyDigest": null, "ifMatch": null, "contentType": null + }); + let digest = b64_digest(&jcs::canonicalize(&invocation)); + invocation + .as_object_mut() + .unwrap() + .insert("requestBodyDigest".into(), Value::String(digest.clone())); + let mut request = serde_json::json!({ + "sessionId": share.session_id, "envelopeCid": challenge["envelopeCid"], + "shareCid": challenge["shareCid"], "shareId": challenge["shareId"], + "registrationCid": challenge["registrationCid"], "delegationCid": challenge["delegationCid"], + "policyCid": challenge["policyCid"], + "enforcementDelegationCid": challenge["enforcementDelegationCid"], + "contentSource": challenge["contentSource"], "contentSourceDigest": challenge["contentSourceDigest"], + "holderDid": challenge["holderDid"], "nodeAudience": challenge["nodeAudience"], + "action": "tinycloud.kv/list", "actions": challenge["actions"], + "resource": challenge["resource"], "requestBodyDigest": digest, + "invocation": invocation + }); + let proof = signed_value(&share.holder, V2_INVOCATION_DOMAIN, &invocation) + .expect("holder signs real invocation"); + request + .as_object_mut() + .unwrap() + .insert("proof".into(), serde_json::to_value(proof).unwrap()); + let response = client + .post("/share/v2/invoke") + .header(rocket::http::ContentType::JSON) + .body(serde_json::to_vec(&serde_json::json!({"request": request})).unwrap()) + .dispatch() + .await; + let status = response.status(); + let body: Value = serde_json::from_slice(&response.into_bytes().await.unwrap()).unwrap(); + (status, body) +} + +async fn authorize_delivery(client: &Client, share: &HistoricalShare, jti: u8) -> (Status, Value) { + let challenge = &share.challenge; + let mut request = serde_json::json!({ + "envelopeCid": challenge["envelopeCid"], "shareCid": challenge["shareCid"], + "shareId": challenge["shareId"], "registrationCid": challenge["registrationCid"], + "policyCid": challenge["policyCid"], "delegationCid": challenge["delegationCid"], + "enforcementDelegationCid": challenge["enforcementDelegationCid"], + "recipientEmail": "alice@example.com", + "shareUrl": format!("https://share.tinycloud.xyz/s/{}", challenge["shareCid"].as_str().unwrap()), + "documentName": "historical document", + "jti": encode_config([jti; 16], URL_SAFE_NO_PAD), + "expiresAt": timestamp(OffsetDateTime::now_utc() + Duration::minutes(4)) + }); + let digest = request_digest_without(&request, "requestBodyDigest").expect("delivery digest"); + request + .as_object_mut() + .unwrap() + .insert("requestBodyDigest".into(), Value::String(digest)); + let space_id = share + .registered + .row + .space_id + .parse::() + .expect("registered space"); + let path = share + .registered + .row + .resource_path + .parse() + .expect("registered path"); + let resource = space_id.to_resource("kv".parse().unwrap(), Some(path), None, None); + let invocation = make_invocation( + [( + resource, + ["tinycloud.kv/get".parse::().unwrap()], + )], + &share.sender_delegation_cid, + &share.sender, + &share.sender_vm, + (OffsetDateTime::now_utc() + Duration::seconds(45)).unix_timestamp() as f64, + InvocationOptions::default(), + ) + .expect("real sender-signed ordinary invocation"); + let response = client + .post("/share/v2/deliveries/authorize") + .header(rocket::http::ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + invocation.encode().unwrap(), + )) + .body(serde_json::to_vec(&request).unwrap()) + .dispatch() + .await; + let status = response.status(); + let body: Value = serde_json::from_slice(&response.into_bytes().await.unwrap()).unwrap(); + (status, body) +} + +async fn creation_closed(client: &Client, route: &str, body: &Value) { + let response = client + .post(route) + .header(rocket::http::ContentType::JSON) + .body(serde_json::to_vec(body).unwrap()) + .dispatch() + .await; + assert_eq!( + response.status(), + Status::Gone, + "{route} must remain closed after September 30" + ); + let body: Value = serde_json::from_slice(&response.into_bytes().await.unwrap()).unwrap(); + assert_eq!(body["error"]["code"], "legacy_v2_creation_closed"); +} + +#[tokio::test] +async fn historical_signed_share_invoke_and_delivery_stop_after_share_key_tombstone_without_affecting_other_shares( +) { + let key_setup = StaticSecret::new(vec![0x59u8; 32]).expect("node test key"); + let runtime = + compose_test_runtime(&key_setup, Some(TeeContext::derive_local(&key_setup)), true).await; + assert!( + runtime.live().await, + "composed node must accept read requests" + ); + let (owner, owner_did, owner_vm) = owner_identity(); + actor::ActiveModel { + id: Set(owner_did.clone()), + } + .insert(&runtime.conn) + .await + .unwrap(); + let space_id = tinycloud_auth::resource::SpaceId::new( + owner_did.parse().unwrap(), + "applications".parse().unwrap(), + ); + space::ActiveModel { + id: Set(SpaceIdWrap(space_id)), + } + .insert(&runtime.conn) + .await + .unwrap(); + let revoked = historical_share(&runtime, &owner, &owner_did, &owner_vm, "legacy-revoked").await; + let unaffected = + historical_share(&runtime, &owner, &owner_did, &owner_vm, "legacy-control").await; + let share_did = revoked.registered.row.share_key_did.clone(); + let client = Client::tracked( + rocket::build() + .manage(Some(runtime.clone())) + .manage(crate::BlockStage::from( + crate::config::StagingStorage::Memory, + )) + .mount( + "/", + rocket::routes![ + policy_challenge_v2, + policy_session_v2, + invoke_v2, + authorize_delivery_v2 + ], + ), + ) + .await + .expect("real Rocket client"); + + // September 30 closed *creation*, not historically registered read access. + creation_closed(&client, "/share/v2/policy/challenges", &revoked.challenge).await; + creation_closed(&client, "/share/v2/policy/session", &serde_json::json!({})).await; + let (status, body) = invoke(&client, &revoked, "pre-revoke").await; + assert_eq!( + status, + Status::Ok, + "signed historical read must work: {body}" + ); + assert_eq!(body["action"], "tinycloud.kv/list"); + assert_eq!( + invoke(&client, &unaffected, "control-before").await.0, + Status::Ok + ); + let (status, body) = authorize_delivery(&client, &revoked, 1).await; + assert_eq!( + status, + Status::Ok, + "signed sender delivery must work: {body}" + ); + assert_eq!(body["authorization"]["senderDid"], revoked.sender_did); + assert_eq!(body["authorization"]["senderTrust"], "verified"); + assert_eq!( + authorize_delivery(&client, &unaffected, 2).await.0, + Status::Ok + ); + + // Persist the same canonical global DID tombstone written by explicit key + // revoke. No CID revocation or policy/session flag is used as a substitute. + key_revocation::ActiveModel { + target_did: Set(share_did), + scope_key: Set("global".to_owned()), + revoked_at: Set(OffsetDateTime::now_utc().unix_timestamp()), + } + .insert(&runtime.conn) + .await + .expect("durable key tombstone"); + creation_closed(&client, "/share/v2/policy/challenges", &revoked.challenge).await; + creation_closed(&client, "/share/v2/policy/session", &serde_json::json!({})).await; + let (status, body) = invoke(&client, &revoked, "post-revoke-new-jti").await; + assert_eq!( + status, + Status::Forbidden, + "revoked share key must deny invoke: {body}" + ); + assert_eq!(body["error"]["code"], "invoke_denied"); + let (status, body) = authorize_delivery(&client, &revoked, 3).await; + assert_eq!( + status, + Status::Forbidden, + "revoked share key must deny delivery: {body}" + ); + assert_eq!(body["error"]["code"], "delivery_authorization_invalid"); + let (status, body) = invoke(&client, &unaffected, "control-after").await; + assert_eq!( + status, + Status::Ok, + "unrelated share must still work: {body}" + ); + let (status, body) = authorize_delivery(&client, &unaffected, 4).await; + assert_eq!( + status, + Status::Ok, + "unrelated share delivery must still work: {body}" + ); +} + +#[tokio::test] +async fn historical_signed_delivery_denies_revoked_owner_enforcer_or_sender() { + for (index, target) in ["owner", "enforcer", "sender"].iter().enumerate() { + let key_setup = StaticSecret::new(vec![0x59u8; 32]).expect("node test key"); + let runtime = + compose_test_runtime(&key_setup, Some(TeeContext::derive_local(&key_setup)), true) + .await; + assert!( + runtime.live().await, + "composed node must accept read requests" + ); + let (owner, owner_did, owner_vm) = owner_identity(); + actor::ActiveModel { + id: Set(owner_did.clone()), + } + .insert(&runtime.conn) + .await + .unwrap(); + let space_id = tinycloud_auth::resource::SpaceId::new( + owner_did.parse().unwrap(), + "applications".parse().unwrap(), + ); + space::ActiveModel { + id: Set(SpaceIdWrap(space_id)), + } + .insert(&runtime.conn) + .await + .unwrap(); + let share = historical_share( + &runtime, + &owner, + &owner_did, + &owner_vm, + &format!("legacy-delivery-{target}"), + ) + .await; + let revoked_did = match *target { + "owner" => owner_did, + "enforcer" => runtime.enforcer_did.clone(), + "sender" => share.sender_did.clone(), + _ => unreachable!(), + }; + let client = Client::tracked(rocket::build().manage(Some(runtime.clone())).mount( + "/", + rocket::routes![ + policy_challenge_v2, + policy_session_v2, + authorize_delivery_v2 + ], + )) + .await + .expect("real Rocket client"); + creation_closed(&client, "/share/v2/policy/challenges", &share.challenge).await; + creation_closed(&client, "/share/v2/policy/session", &serde_json::json!({})).await; + let (status, body) = authorize_delivery(&client, &share, (index * 2 + 10) as u8).await; + assert_eq!(status, Status::Ok, "{target} pre-revoke delivery: {body}"); + assert_eq!(body["authorization"]["senderDid"], share.sender_did); + assert_eq!(body["authorization"]["senderTrust"], "verified"); + + key_revocation::ActiveModel { + target_did: Set(revoked_did), + scope_key: Set("global".to_owned()), + revoked_at: Set(OffsetDateTime::now_utc().unix_timestamp()), + } + .insert(&runtime.conn) + .await + .expect("durable key tombstone"); + creation_closed(&client, "/share/v2/policy/challenges", &share.challenge).await; + creation_closed(&client, "/share/v2/policy/session", &serde_json::json!({})).await; + let (status, body) = authorize_delivery(&client, &share, (index * 2 + 11) as u8).await; + assert!( + status == Status::Unauthorized || status == Status::Forbidden, + "{target} tombstone must deny delivery, got {status}: {body}" + ); + assert_eq!(body["error"]["code"], "delivery_authorization_invalid"); + } +} diff --git a/tinycloud-node-server/src/signed_urls.rs b/tinycloud-node-server/src/signed_urls.rs index f75bf213..b6b04496 100644 --- a/tinycloud-node-server/src/signed_urls.rs +++ b/tinycloud-node-server/src/signed_urls.rs @@ -413,7 +413,89 @@ pub async fn load_signed_kv_ticket( "signed URL ticket not found".to_string(), ) })?; - validate_signed_kv_ticket(&ticket)?; + let (space, _) = validate_signed_kv_ticket(&ticket)?; + let issuer = tinycloud_auth::identity::principal_did(&ticket.issuer_did).map_err(|_| { + ( + Status::Forbidden, + "signed URL authority invalid".to_string(), + ) + })?; + let subject = tinycloud_auth::identity::principal_did(&ticket.subject_did).map_err(|_| { + ( + Status::Forbidden, + "signed URL authority invalid".to_string(), + ) + })?; + if issuer != subject || space.to_string() != ticket.space_id { + return Err(( + Status::Forbidden, + "signed URL authority invalid".to_string(), + )); + } + let parents = match ticket.parent_cids_json.as_deref() { + Some(serialized) => { + let cids: Vec = serde_json::from_str(serialized).map_err(|_| { + ( + Status::Forbidden, + "signed URL authority invalid".to_string(), + ) + })?; + if cids.is_empty() { + return Err(( + Status::Forbidden, + "signed URL authority invalid".to_string(), + )); + } + let mut parents = Vec::with_capacity(cids.len()); + for value in cids { + let cid = value + .parse::() + .map_err(|_| { + ( + Status::Forbidden, + "signed URL authority invalid".to_string(), + ) + })?; + if cid.to_string() != value || parents.contains(&cid) { + return Err(( + Status::Forbidden, + "signed URL authority invalid".to_string(), + )); + } + parents.push(cid); + } + parents + } + None => { + if issuer + != tinycloud_auth::identity::principal_did(space.did().as_str()).map_err(|_| { + ( + Status::Forbidden, + "signed URL authority invalid".to_string(), + ) + })? + { + return Err(( + Status::Forbidden, + "signed URL authority invalid".to_string(), + )); + } + Vec::new() + } + }; + tinycloud + .ticket_authority_unrevoked( + &parents, + &ticket.subject_did, + std::slice::from_ref(&ticket.space_id), + ) + .await + .map_err(|_| { + ( + Status::Forbidden, + "signed URL authority revoked".to_string(), + ) + })?; Ok(ticket) } @@ -1066,8 +1148,9 @@ mod tests { assert!(!response.url.contains("?token=")); assert!(!response.url.contains(&space.to_string())); - let ticket = load_signed_kv_ticket(&tinycloud, &response.ticket_id) - .await + let ticket = tinycloud + .find_signed_kv_ticket(&response.ticket_id) + .await? .expect("minted ticket should be persisted"); let (ticket_space, ticket_path) = validate_signed_kv_ticket(&ticket).expect("persisted ticket should validate"); diff --git a/tinycloud-node-server/src/webhook_dispatcher.rs b/tinycloud-node-server/src/webhook_dispatcher.rs index 831d08d2..99898a3a 100644 --- a/tinycloud-node-server/src/webhook_dispatcher.rs +++ b/tinycloud-node-server/src/webhook_dispatcher.rs @@ -392,12 +392,31 @@ mod tests { callback_url: &str, subscription_id: &str, subscriber_did: &str, + ) -> Result<()> { + insert_subscription_in_space( + db, + encryption, + callback_url, + subscription_id, + subscriber_did, + SPACE_ID, + ) + .await + } + + async fn insert_subscription_in_space( + db: &DatabaseConnection, + encryption: &ColumnEncryption, + callback_url: &str, + subscription_id: &str, + subscriber_did: &str, + space_id: &str, ) -> Result<()> { hook_subscription::Entity::insert(hook_subscription::ActiveModel::from( hook_subscription::Model { id: subscription_id.to_string(), subscriber_did: subscriber_did.to_string(), - space_id: SPACE_ID.to_string(), + space_id: space_id.to_string(), target_service: "kv".to_string(), path_prefix: Some("documents".to_string()), abilities_json: Some( @@ -420,12 +439,22 @@ mod tests { subscription_id: &str, delivery_id: &str, attempts: i64, + ) -> Result<()> { + insert_delivery_in_space(db, subscription_id, delivery_id, attempts, SPACE_ID).await + } + + async fn insert_delivery_in_space( + db: &DatabaseConnection, + subscription_id: &str, + delivery_id: &str, + attempts: i64, + space_id: &str, ) -> Result<()> { hook_delivery::Entity::insert(hook_delivery::ActiveModel::from(hook_delivery::Model { id: delivery_id.to_string(), subscription_id: subscription_id.to_string(), event_id: delivery_id.replace("delivery", "event"), - payload_json: serde_json::json!({"id": "event_01", "space": SPACE_ID}).to_string(), + payload_json: serde_json::json!({"id": "event_01", "space": space_id}).to_string(), status: tinycloud_core::db::HOOK_DELIVERY_STATUS_PENDING.to_string(), attempts, next_attempt_at: None, @@ -650,6 +679,7 @@ mod tests { /// subscriber's DID, which only the authority check does. mod postgres_connection_loss { use super::*; + use std::collections::BTreeMap; use std::{ collections::BTreeSet, net::SocketAddr, @@ -658,12 +688,23 @@ mod tests { Arc, }, }; + use tinycloud_auth::{ + authorization::{HeaderEncode, TinyCloudDelegation}, + resolver::DID_METHODS, + resource::SpaceId, + ssi::{ + claims::jwt::NumericDate, + dids::{DIDBuf, DIDURLBuf}, + jwk::JWK, + ucan::Payload, + }, + ucan_capabilities_object::{Ability, Capabilities}, + }; + use tinycloud_core::events::Delegation; use tinycloud_core::{ db_pool::PoolRecoveryStats, - hash::hash, - models::{abilities, actor, delegation}, + models::key_revocation, sea_orm::{ConnectionTrait, DbBackend, Statement}, - types::{Ability, Caveats, Resource}, RecoveringConnection, }; use tokio::{ @@ -777,6 +818,9 @@ mod tests { encryption: ColumnEncryption, proxy: Proxy, holder: String, + owner_key: JWK, + owner_did: String, + space_id: String, callback_url: String, callbacks: tokio::sync::mpsc::UnboundedReceiver<(String, String, String)>, } @@ -798,9 +842,18 @@ mod tests { .await .unwrap(); - // A holder of `tinycloud.hooks/register` on `kv/documents` - // through the space owner's delegation, as in TC-991's tests. - let holder = format!("did:key:z6MkTc991Dispatch{unique}"); + // Register a real owner-signed UCAN grant on this fixture's space. + let owner_key = JWK::generate_ed25519().unwrap(); + let owner_did = DID_METHODS.generate(&owner_key, "key").unwrap().to_string(); + let space_id = SpaceId::new( + owner_did.parse::().unwrap(), + "files".parse().unwrap(), + ) + .to_string(); + let holder = DID_METHODS + .generate(&JWK::generate_ed25519().unwrap(), "key") + .unwrap() + .to_string(); let upstream = Url::parse(url).unwrap(); let upstream_addr: SocketAddr = format!( "{}:{}", @@ -851,7 +904,7 @@ mod tests { .sqlx_logging(false) .set_schema_search_path(schema.clone()); let setup = Database::connect(setup_options).await.unwrap(); - grant_hook_register(&setup, &holder).await; + grant_hook_register(&tinycloud, &owner_key, &owner_did, &holder, &space_id).await; let (callback_url, callbacks) = spawn_callback_server(StatusCode::OK).await.unwrap(); @@ -864,6 +917,9 @@ mod tests { encryption, proxy, holder, + owner_key, + owner_did, + space_id, callback_url, callbacks, } @@ -897,16 +953,17 @@ mod tests { self.holder, self.holder.strip_prefix("did:key:").unwrap() ); - insert_subscription( + insert_subscription_in_space( &self.setup, &self.encryption, &self.callback_url, &subscription, &subscriber, + &self.space_id, ) .await .unwrap(); - insert_delivery(&self.setup, &subscription, &delivery, 0) + insert_delivery_in_space(&self.setup, &subscription, &delivery, 0, &self.space_id) .await .unwrap(); (subscription, delivery) @@ -1038,50 +1095,263 @@ mod tests { } } - async fn grant_hook_register(db: &DatabaseConnection, holder: &str) { - let space = SPACE_ID - .parse::() + async fn grant_hook_register( + tinycloud: &TinyCloud, + owner_key: &JWK, + owner_did: &str, + holder: &str, + space_id: &str, + ) { + let space = space_id.parse::().unwrap(); + let resource = space.to_resource( + "hooks".parse().unwrap(), + Some("kv/documents".parse().unwrap()), + None, + None, + ); + let mut capabilities = Capabilities::new(); + capabilities.with_action( + resource.as_uri(), + "tinycloud.hooks/register".parse::().unwrap(), + [BTreeMap::::new()], + ); + let fragment = owner_did.rsplit_once(':').unwrap().1; + let payload = Payload { + issuer: format!("{owner_did}#{fragment}") + .parse::() + .unwrap(), + audience: holder.parse::().unwrap(), + not_before: None, + expiration: NumericDate::try_from_seconds( + OffsetDateTime::now_utc().unix_timestamp() as f64 + 3600.0, + ) + .unwrap(), + nonce: Some(format!("tc1149-{holder}-{space_id}")), + facts: Some(Vec::::new()), + proof: vec![], + attenuation: capabilities, + }; + let ucan = payload + .sign(owner_key.get_algorithm().unwrap_or_default(), owner_key) .unwrap(); - let owner = space.did().to_string(); - let now = OffsetDateTime::now_utc(); - for id in [&owner, &holder.to_string()] { - actor::ActiveModel { - id: ActiveValue::Set(id.to_string()), - } - .insert(db) + let header = TinyCloudDelegation::Ucan(Box::new(ucan)).encode().unwrap(); + let delegation = Delegation::from_header_ser::(&header).unwrap(); + tinycloud + .delegate(delegation) + .await + .unwrap_or_else(|_| panic!("signed hook grant must persist")); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn tc1149_revoked_holder_stops_queued_webhook_but_owner_stays_live() { + let Some(url) = + postgres_url("tc1149_revoked_holder_stops_queued_webhook_but_owner_stays_live") + else { + return; + }; + let mut fixture = Fixture::new(&url).await; + let dispatcher = fixture.dispatcher(); + let (subscription, before) = fixture.subscribe("holder_before").await; + dispatcher.dispatch_due_once().await.unwrap(); + fixture.assert_delivered(&before).await; + + let after = "delivery_holder_after"; + insert_delivery_in_space(&fixture.setup, &subscription, after, 0, &fixture.space_id) .await .unwrap(); + key_revocation::ActiveModel { + target_did: ActiveValue::Set(fixture.holder.clone()), + scope_key: ActiveValue::Set("global".to_string()), + revoked_at: ActiveValue::Set(OffsetDateTime::now_utc().unix_timestamp()), } - let id = hash(format!("tc991-dispatch-{holder}").as_bytes()); - delegation::ActiveModel { - id: ActiveValue::Set(id), - delegator: ActiveValue::Set(owner), - delegatee: ActiveValue::Set(holder.to_string()), - expiry: ActiveValue::Set(Some(now + TimeDuration::hours(1))), - issued_at: ActiveValue::Set(Some(now)), - not_before: ActiveValue::Set(None), - facts: ActiveValue::Set(None), - serialization: ActiveValue::Set(vec![]), + .insert(&fixture.setup) + .await + .unwrap(); + insert_subscription_in_space( + &fixture.setup, + &fixture.encryption, + &fixture.callback_url, + "sub_owner_control", + &fixture.owner_did, + &fixture.space_id, + ) + .await + .unwrap(); + insert_delivery_in_space( + &fixture.setup, + "sub_owner_control", + "delivery_owner_control", + 0, + &fixture.space_id, + ) + .await + .unwrap(); + + dispatcher.dispatch_due_once().await.unwrap(); + fixture.assert_delivered("delivery_owner_control").await; + assert!( + fixture.callbacks.try_recv().is_err(), + "revoked holder emitted a callback" + ); + let denied = fixture.delivery(after).await; + assert_eq!( + denied.status, + tinycloud_core::db::HOOK_DELIVERY_STATUS_DEAD_LETTER + ); + assert!(!fixture.subscription_active(&subscription).await); + fixture.drop_schema().await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn tc1149_revoked_owner_root_stops_queued_webhook() { + let Some(url) = postgres_url("tc1149_revoked_owner_root_stops_queued_webhook") else { + return; + }; + let mut fixture = Fixture::new(&url).await; + insert_subscription_in_space( + &fixture.setup, + &fixture.encryption, + &fixture.callback_url, + "sub_root", + &fixture.owner_did, + &fixture.space_id, + ) + .await + .unwrap(); + insert_delivery_in_space( + &fixture.setup, + "sub_root", + "delivery_root_before", + 0, + &fixture.space_id, + ) + .await + .unwrap(); + let dispatcher = fixture.dispatcher(); + dispatcher.dispatch_due_once().await.unwrap(); + fixture.assert_delivered("delivery_root_before").await; + + insert_delivery_in_space( + &fixture.setup, + "sub_root", + "delivery_root_after", + 0, + &fixture.space_id, + ) + .await + .unwrap(); + key_revocation::ActiveModel { + target_did: ActiveValue::Set(fixture.owner_did.clone()), + scope_key: ActiveValue::Set("global".to_string()), + revoked_at: ActiveValue::Set(OffsetDateTime::now_utc().unix_timestamp()), } - .insert(db) + .insert(&fixture.setup) .await .unwrap(); - abilities::ActiveModel { - delegation: ActiveValue::Set(id), - resource: ActiveValue::Set(Resource::TinyCloud(space.to_resource( - "hooks".parse().unwrap(), - Some("kv/documents".parse().unwrap()), - None, - None, - ))), - ability: ActiveValue::Set( - Ability::try_from("tinycloud.hooks/register".to_string()).unwrap(), - ), - caveats: ActiveValue::Set(Caveats::default()), + dispatcher.dispatch_due_once().await.unwrap(); + assert!( + fixture.callbacks.try_recv().is_err(), + "revoked root emitted a callback" + ); + let denied = fixture.delivery("delivery_root_after").await; + assert_eq!( + denied.status, + tinycloud_core::db::HOOK_DELIVERY_STATUS_DEAD_LETTER + ); + assert!(!fixture.subscription_active("sub_root").await); + fixture.drop_schema().await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn tc1149_space_revocation_stops_only_matching_webhook_space() { + let Some(url) = + postgres_url("tc1149_space_revocation_stops_only_matching_webhook_space") + else { + return; + }; + let mut fixture = Fixture::new(&url).await; + let other_space = SpaceId::new( + fixture.owner_did.parse::().unwrap(), + "other".parse().unwrap(), + ) + .to_string(); + grant_hook_register( + &fixture.tinycloud, + &fixture.owner_key, + &fixture.owner_did, + &fixture.holder, + &other_space, + ) + .await; + let (primary, before) = fixture.subscribe("scoped_before").await; + let subscriber = format!( + "{}#{}", + fixture.holder, + fixture.holder.strip_prefix("did:key:").unwrap() + ); + insert_subscription_in_space( + &fixture.setup, + &fixture.encryption, + &fixture.callback_url, + "sub_other_space", + &subscriber, + &other_space, + ) + .await + .unwrap(); + let dispatcher = fixture.dispatcher(); + dispatcher.dispatch_due_once().await.unwrap(); + fixture.assert_delivered(&before).await; + insert_delivery_in_space( + &fixture.setup, + "sub_other_space", + "delivery_other_before", + 0, + &other_space, + ) + .await + .unwrap(); + dispatcher.dispatch_due_once().await.unwrap(); + fixture.assert_delivered("delivery_other_before").await; + + insert_delivery_in_space( + &fixture.setup, + &primary, + "delivery_scoped_after", + 0, + &fixture.space_id, + ) + .await + .unwrap(); + insert_delivery_in_space( + &fixture.setup, + "sub_other_space", + "delivery_other_after", + 0, + &other_space, + ) + .await + .unwrap(); + key_revocation::ActiveModel { + target_did: ActiveValue::Set(fixture.holder.clone()), + scope_key: ActiveValue::Set(format!("space:{}", fixture.space_id)), + revoked_at: ActiveValue::Set(OffsetDateTime::now_utc().unix_timestamp()), } - .insert(db) + .insert(&fixture.setup) .await .unwrap(); + dispatcher.dispatch_due_once().await.unwrap(); + fixture.assert_delivered("delivery_other_after").await; + assert!( + fixture.callbacks.try_recv().is_err(), + "scoped-revoked holder emitted a callback" + ); + assert_eq!( + fixture.delivery("delivery_scoped_after").await.status, + tinycloud_core::db::HOOK_DELIVERY_STATUS_DEAD_LETTER + ); + assert!(!fixture.subscription_active(&primary).await); + assert!(fixture.subscription_active("sub_other_space").await); + fixture.drop_schema().await; } /// (a) Every idle pooled connection is terminated, then one pass diff --git a/tinycloud-node-server/tests/postgres_round_trip_budget.rs b/tinycloud-node-server/tests/postgres_round_trip_budget.rs index 95e33b00..4a6d191f 100644 --- a/tinycloud-node-server/tests/postgres_round_trip_budget.rs +++ b/tinycloud-node-server/tests/postgres_round_trip_budget.rs @@ -114,14 +114,16 @@ use tokio::{ }; /// Round trips allowed per flow; see the module docs. Measured with -/// `test_before_acquire(false)`, database identity resolve as one statement, -/// the read audit as one statement and no second sequence lock (TC-951): +/// `test_before_acquire(false)`, database identity resolve as one statement +/// and the read audit as one statement (TC-951). TC-1149 adds canonical +/// principal/ancestor cutoff checks and transactional target row locks; the +/// immutable proof signers and CID alias identities share one request query: /// -/// - sign-in 75 = peer key 0 + host `/delegate` 17 + session `/delegate` 18 -/// + account-space SQL write 40; -/// - kv put 37; -/// - kv get 22, including the read-audit commit. -const BUDGETS: [(&str, u64); 3] = [("sign-in", 75), ("kv put", 37), ("kv get", 22)]; +/// - sign-in 89 = peer key 0 + host `/delegate` 20 + session `/delegate` 21 +/// + account-space SQL write 48; +/// - kv put 49, including cross-process principal lock rows and recheck; +/// - kv get 30, including the live key tombstone and read-audit commit. +const BUDGETS: [(&str, u64); 3] = [("sign-in", 89), ("kv put", 49), ("kv get", 30)]; /// Full sign-in plus put plus get rounds run before the measured one. const WARM_UP_ROUNDS: u8 = 3; From 255ac896a49d09809f45586f6a9918a3234b93f0 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Thu, 15 Oct 2026 19:15:38 +0000 Subject: [PATCH 13/20] fix: validate key revocation target DIDs --- docs/delegation-flow.md | 3 +++ tinycloud-node-server/src/key_revoke.rs | 2 ++ 2 files changed, 5 insertions(+) diff --git a/docs/delegation-flow.md b/docs/delegation-flow.md index 5a0a1bbe..dbeb3268 100644 --- a/docs/delegation-flow.md +++ b/docs/delegation-flow.md @@ -516,6 +516,9 @@ enrolled global recovery delegate. A space owner or its scoped recovery delegate may revoke a key only in that owner's space. Generic `/revoke` continues to revoke a single delegation CID, not a whole key. +`targetDid` must be a canonical DID with valid DID syntax, not a DID URL +or a delegation CID; an empty method-specific ID is rejected. + The committed response includes the target, scope, and original cutoff. Repeating the exact signed request returns the same receipt even after the signer is revoked; another request using its nonce cannot alter that cutoff. diff --git a/tinycloud-node-server/src/key_revoke.rs b/tinycloud-node-server/src/key_revoke.rs index e7f7fb22..9738cdcc 100644 --- a/tinycloud-node-server/src/key_revoke.rs +++ b/tinycloud-node-server/src/key_revoke.rs @@ -7,6 +7,7 @@ use tinycloud_auth::{ identity::{canonicalize_did, principal_did}, resource::SpaceId, siwe_recap::Capability as SiweRecap, + ssi::dids::DID, }; use tinycloud_core::{ encryption_network::EncryptionService, @@ -37,6 +38,7 @@ async fn verify_request( || target_did .bytes() .any(|byte| matches!(byte, b'#' | b'?' | b'/')) + || DID::new(&target_did).is_err() || canonicalize_did(&target_did).ok().as_deref() != Some(&target_did) { return Err(MALFORMED); From 827e907c661c594e84935d2a61c590623433a97d Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Thu, 15 Oct 2026 19:31:28 +0000 Subject: [PATCH 14/20] fix: align policy root status with authority witness --- docs/delegation-flow.md | 4 + tinycloud-node-server/src/policy_v3.rs | 175 ++++++++++++++++++++----- 2 files changed, 143 insertions(+), 36 deletions(-) diff --git a/docs/delegation-flow.md b/docs/delegation-flow.md index dbeb3268..bd6053c7 100644 --- a/docs/delegation-flow.md +++ b/docs/delegation-flow.md @@ -516,6 +516,10 @@ enrolled global recovery delegate. A space owner or its scoped recovery delegate may revoke a key only in that owner's space. Generic `/revoke` continues to revoke a single delegation CID, not a whole key. +The signed policy-v3 JSON `/revoke` records one root's status, not a +key-wide cutoff. Fresh root events check the historical policy witness; +repeating the exact committed event remains idempotent. + `targetDid` must be a canonical DID with valid DID syntax, not a DID URL or a delegation CID; an empty method-specific ID is rejected. diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index bbea70aa..92e7285a 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -3700,6 +3700,9 @@ pub async fn revoke_root( &runtime.node_did, OffsetDateTime::now_utc(), )?; + policy_witness::check_revocations(&runtime.conn, &root.policy_cid, None) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-revoked".into()))?; let previous = root .status_checkpoint_bytes .as_deref() @@ -8097,6 +8100,11 @@ mod tests { run_policy_v2_flow_on_large_stack(PolicyOwner::SpaceOwnerPolicyRoot) } + #[test] + fn signed_policy_root_revoke_closes_sessions_and_replays_idempotently() -> anyhow::Result<()> { + run_policy_v2_flow_with_root_route(PolicyOwner::SpaceOwnerPolicyRoot, true) + } + /// The real Share and CLI shape: the policy owner is a session key the /// space owner delegated to. It registers, mints and reads; once the /// space owner revokes that delegation, no new session can be minted. @@ -8144,6 +8152,13 @@ mod tests { // thread stack. Keep the test self-contained on CI rather than requiring // RUST_MIN_STACK in the runner environment. fn run_policy_v2_flow_on_large_stack(policy_owner: PolicyOwner) -> anyhow::Result<()> { + run_policy_v2_flow_with_root_route(policy_owner, false) + } + + fn run_policy_v2_flow_with_root_route( + policy_owner: PolicyOwner, + signed_root_revoke: bool, + ) -> anyhow::Result<()> { std::thread::Builder::new() .stack_size(16 << 20) .spawn(move || { @@ -8151,13 +8166,16 @@ mod tests { .enable_all() .build() .expect("policy fixture runtime") - .block_on(run_policy_v2_flow(policy_owner)) + .block_on(run_policy_v2_flow(policy_owner, signed_root_revoke)) })? .join() .expect("policy fixture thread panicked") } - async fn run_policy_v2_flow(policy_owner: PolicyOwner) -> anyhow::Result<()> { + async fn run_policy_v2_flow( + policy_owner: PolicyOwner, + signed_root_revoke: bool, + ) -> anyhow::Result<()> { use k256::ecdsa::SigningKey; use sha3::Keccak256; use tinycloud_auth::cacaos::siwe::encode_eip55; @@ -8641,6 +8659,7 @@ mod tests { crate::routes::create_signed_kv_url, crate::key_revoke::revoke_key, status, + revoke_root, get_status, crate::routes::signed_kv_get, crate::routes::hooks::create_hook_ticket @@ -9233,6 +9252,50 @@ mod tests { "renewal after key tombstone" ); + if policy_owner == PolicyOwner::RegistrarKeyCutoff { + let mut root_revocation = json!({ + "schema": ROOT_REVOCATION_V1_SCHEMA, + "targetCid": policy_root_cid, + "targetRole": "policy-authority", + "ownerDid": owner_did, + "nodeAudience": node_did, + "revokedAt": format_time(OffsetDateTime::now_utc()), + "reason": "owner-requested", + "issuerDid": owner_did, + }); + let mut preimage = b"xyz.tinycloud.policy/RootRevocation/v1\0".to_vec(); + preimage.extend_from_slice(&canonical_json_value(&root_revocation)); + root_revocation["signature"] = json!({ + "suite": "Ed25519", + "signerDid": owner_did, + "value": encode_config(owner_key.sign(&Sha256::digest(preimage)), URL_SAFE_NO_PAD), + }); + validate_root_revocation( + &root_revocation, + policy_root_cid, + "policy-authority", + &owner_did, + Some(&enforcer_did), + &node_did, + OffsetDateTime::now_utc(), + ) + .map_err(|(_, error)| anyhow::anyhow!("valid signed root revocation: {error}"))?; + let fresh_revoke = client + .post("/revoke") + .header(ContentType::JSON) + .body(json!({"revocation": root_revocation}).to_string()) + .dispatch() + .await; + let revoke_status = fresh_revoke.status(); + let revoke_body = fresh_revoke.into_string().await.unwrap_or_default(); + assert_eq!( + revoke_status, + Status::Forbidden, + "fresh signed root revoke after owner key cutoff: {revoke_body}" + ); + assert_eq!(revoke_body, "policy-authority-revoked"); + } + if policy_owner == PolicyOwner::RegistrarKeyCutoff { // New request/JTI and a still-live ordinary sender grant: // denial must come from policy owner/root revocation, not @@ -10288,11 +10351,8 @@ mod tests { None }; - // SDK revocation writes the generic graph. Policy/v3 joins that - // graph with its signed root status, so this one root revocation must - // close the already-minted session plus new control-plane admission - // and delivery. It intentionally does not fabricate a root-status - // checkpoint. + // Exercise both distinct root-revocation routes: the generic CID + // tombstone and the policy root's signed status event. let root_field = if policy_owner == PolicyOwner::SpaceOwner { "enforcementRootCid" } else { @@ -10302,35 +10362,78 @@ mod tests { .as_str() .ok_or_else(|| anyhow::anyhow!("missing {root_field}"))? .parse::()?; - let mut revoke_capabilities = Capabilities::::new(); - revoke_capabilities.with_action( - format!("urn:cid:{revoked_root_cid}").parse()?, - "tinycloud.delegation/revoke".parse::()?, - [std::collections::BTreeMap::::new()], - ); - let generic_revoke = tinycloud_auth::authorization::TinyCloudRevocation::Ucan(Box::new( - Payload { - issuer: owner_vm.parse()?, - audience: owner_did.parse()?, - not_before: None, - expiration: NumericDate::try_from_seconds( - (OffsetDateTime::now_utc() + Duration::seconds(60)).unix_timestamp() as f64, - )?, - nonce: Some("tc500-policy-root-generic-revoke".into()), - facts: Some(Vec::new()), - proof: Vec::new(), - attenuation: revoke_capabilities, - } - .sign(Algorithm::EdDSA, &owner_jwk)?, - )); - let revoke_response = client - .post("/revoke") - .header(rocket::http::Header::new( - "Authorization", - generic_revoke.encode()?, - )) - .dispatch() - .await; + let revoke_response = if signed_root_revoke { + assert_eq!(root_field, "policyRootCid"); + let mut revocation = json!({ + "schema": ROOT_REVOCATION_V1_SCHEMA, + "targetCid": revoked_root_cid.to_string(), + "targetRole": "policy-authority", + "ownerDid": owner_did, + "nodeAudience": node_did, + "revokedAt": format_time(OffsetDateTime::now_utc()), + "reason": "owner-requested", + "issuerDid": owner_did, + }); + let mut preimage = b"xyz.tinycloud.policy/RootRevocation/v1\0".to_vec(); + preimage.extend_from_slice(&canonical_json_value(&revocation)); + revocation["signature"] = json!({ + "suite": "Ed25519", + "signerDid": owner_did, + "value": encode_config(owner_key.sign(&Sha256::digest(preimage)), URL_SAFE_NO_PAD), + }); + let body = json!({"revocation": revocation}).to_string(); + let result = client + .post("/revoke") + .header(ContentType::JSON) + .body(body.clone()) + .dispatch() + .await; + assert_eq!(result.status(), Status::Ok, "live signed root revoke"); + let retry = client + .post("/revoke") + .header(ContentType::JSON) + .body(body) + .dispatch() + .await; + assert_eq!( + retry.status(), + Status::Ok, + "exact signed root revoke replay" + ); + result + } else { + let mut revoke_capabilities = Capabilities::::new(); + revoke_capabilities.with_action( + format!("urn:cid:{revoked_root_cid}").parse()?, + "tinycloud.delegation/revoke".parse::()?, + [std::collections::BTreeMap::::new()], + ); + let generic_revoke = + tinycloud_auth::authorization::TinyCloudRevocation::Ucan(Box::new( + Payload { + issuer: owner_vm.parse()?, + audience: owner_did.parse()?, + not_before: None, + expiration: NumericDate::try_from_seconds( + (OffsetDateTime::now_utc() + Duration::seconds(60)).unix_timestamp() + as f64, + )?, + nonce: Some("tc500-policy-root-generic-revoke".into()), + facts: Some(Vec::new()), + proof: Vec::new(), + attenuation: revoke_capabilities, + } + .sign(Algorithm::EdDSA, &owner_jwk)?, + )); + client + .post("/revoke") + .header(rocket::http::Header::new( + "Authorization", + generic_revoke.encode()?, + )) + .dispatch() + .await + }; let revoke_status = revoke_response.status(); let revoke_body = revoke_response.into_string().await.unwrap_or_default(); assert_eq!(revoke_status, Status::Ok, "SDK root revoke: {revoke_body}"); From 077595f5c40b074bf0926ec9aea736bd0027f610 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Thu, 15 Oct 2026 19:44:06 +0000 Subject: [PATCH 15/20] test: cover signed root cutoff at acknowledgement --- tinycloud-node-server/src/policy_v3.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 92e7285a..0b89d402 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -10370,7 +10370,7 @@ mod tests { "targetRole": "policy-authority", "ownerDid": owner_did, "nodeAudience": node_did, - "revokedAt": format_time(OffsetDateTime::now_utc()), + "revokedAt": format_time(OffsetDateTime::now_utc() + Duration::seconds(30)), "reason": "owner-requested", "issuerDid": owner_did, }); From d5e696944fd09b37de6798db0291eeda516e52ec Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Thu, 15 Oct 2026 20:31:46 +0000 Subject: [PATCH 16/20] Harden key revoke receipts and witness selection --- docs/delegation-flow.md | 16 ++- tinycloud-core/src/db.rs | 113 +++++++++++++----- tinycloud-core/src/policy_witness.rs | 57 +++++---- tinycloud-core/src/recovery_authority.rs | 19 ++- tinycloud-core/tests/policy_witness_legacy.rs | 37 ++++++ 5 files changed, 183 insertions(+), 59 deletions(-) diff --git a/docs/delegation-flow.md b/docs/delegation-flow.md index bd6053c7..3b674229 100644 --- a/docs/delegation-flow.md +++ b/docs/delegation-flow.md @@ -525,14 +525,18 @@ or a delegation CID; an empty method-specific ID is rejected. The committed response includes the target, scope, and original cutoff. Repeating the exact signed request returns the same receipt even after the -signer is revoked; another request using its nonce cannot alter that cutoff. -New requests still need live authorization. A successful response follows the -durable cutoff, so later policy mint, delegation admission, and invocations -cannot use authority derived from the revoked principal. Natural proof expiry -is not an explicit revoke. +signer is revoked or its recovery proof expires. Changed signed bytes with +the same nonce return `409` only for a still-authorized signer; a signer +without current target/scope authority receives `403`. First-use request +and recovery proof validity are checked after blocking authorization locks. +The cutoff remains immutable; successful responses follow its durable commit. +Later policy mints, delegation admission, and invocations cannot use authority +derived from the revoked principal. Natural proof expiry is not an explicit +revoke. Policy registration persists a versioned, signed-byte-bound witness for its -authority dependencies. Preexisting registrations receive a conservative +authority dependencies. The signed candidate limit counts relevant closures, +not unrelated incoming grants. Preexisting registrations receive a conservative transactional backfill at startup; inconsistent or incomplete provenance prevents serving rather than silently narrowing dependencies. Before release, an operator runs `policy-witness-dry-run` with the node's configuration on a diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 26f35fab..06a32bb0 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -2116,18 +2116,6 @@ where } return Err(KeyRevokeError::Forbidden); } - // A nonce already bound to this exact target/scope remains a - // conflict even if its signer has since revoked itself. A changed - // target or scope must pass fresh authority before diagnostics. - if row.target_did == request.target_did { - let receipt: key_revocation::KeyRevokeReceipt = - serde_json::from_slice(&row.receipt_bytes).map_err(|_| { - db_error(DbErr::Custom("key-receipt-invalid".into()), false) - })?; - if receipt.scope == request.scope { - return Err(KeyRevokeError::Conflict); - } - } } let target_key = crate::key_authority::principal_lock_id(&request.target_did) @@ -2143,6 +2131,26 @@ where .begin_graph_transaction() .await .map_err(|error| db_error(error, false))?; + // A concurrent request may have committed the identical self-revoke + // while this one waited for its target lock. Its receipt remains + // retryable even though fresh issuer authority is now gone. + if let Some(row) = key_revocation::nonce::Entity::find_by_id(nonce_key.clone()) + .one(&tx) + .await + .map_err(|error| db_error(error, false))? + { + if row.authorization_hash == request.authorization_hash + && row.target_did == request.target_did + { + let receipt: key_revocation::KeyRevokeReceipt = + serde_json::from_slice(&row.receipt_bytes).map_err(|_| { + db_error(DbErr::Custom("key-receipt-invalid".into()), false) + })?; + if receipt.scope == request.scope && receipt.target_did == request.target_did { + return Ok(receipt); + } + } + } let now = OffsetDateTime::now_utc(); let direct = if tinycloud_auth::identity::did_principal_matches( &request.issuer_did, @@ -2163,6 +2171,7 @@ where } else { false }; + let mut recovery_expires_at = None; let authorized = if direct { let spaces = match &request.scope { KeyRevokeScope::Global => Vec::new(), @@ -2180,28 +2189,19 @@ where Err(error) => return Err(db_error(error, false)), } } else { - crate::recovery_authority::is_authorized(&tx, &request, now, self.encryption.as_ref()) - .await - .map_err(|error| db_error(error, false))? + crate::recovery_authority::is_authorized( + &tx, + &request, + now, + self.encryption.as_ref(), + &mut recovery_expires_at, + ) + .await + .map_err(|error| db_error(error, false))? }; if !authorized { return Err(KeyRevokeError::Forbidden); } - if existing.is_some() { - return Err(KeyRevokeError::Conflict); - } - let issued_at = request.not_before; - let now_seconds = now.unix_timestamp(); - if issued_at > now_seconds.saturating_add(30) - || request.expires_at <= now_seconds - || request.expires_at.saturating_sub(issued_at) > 60 - || request.expires_at <= issued_at - || request - .valid_after - .is_some_and(|not_before| now_seconds < not_before) - { - return Err(KeyRevokeError::Forbidden); - } // Both the target and nonce issuer use the same sorted row-lock // namespace; global and scoped revokes cannot pass each other. crate::key_authority::lock_targets( @@ -2228,6 +2228,20 @@ where } Err(error) => return Err(db_error(error, false)), } + let cutoff_now = OffsetDateTime::now_utc(); + let now_seconds = cutoff_now.unix_timestamp(); + let issued_at = request.not_before; + if recovery_expires_at.is_some_and(|expiry| cutoff_now >= expiry) + || issued_at > now_seconds.saturating_add(30) + || request.expires_at <= now_seconds + || request.expires_at.saturating_sub(issued_at) > 60 + || request.expires_at <= issued_at + || request + .valid_after + .is_some_and(|not_before| now_seconds < not_before) + { + return Err(KeyRevokeError::Forbidden); + } if let Some(row) = key_revocation::nonce::Entity::find_by_id(nonce_key.clone()) .one(&tx) .await @@ -7644,6 +7658,45 @@ mod test { )); } + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn overlapping_exact_self_revokes_return_the_same_receipt() { + let db = get_db().await.unwrap(); + let target = "did:key:overlapping-receipt-owner".to_owned(); + let key = crate::key_authority::principal_lock_id(&target).unwrap(); + let in_flight = db.acquire_shared_chain_guards_for_keys(vec![key]).await; + let now = OffsetDateTime::now_utc().unix_timestamp(); + let request = key_revocation::VerifiedKeyRevoke { + target_did: target.clone(), + issuer_did: target, + scope: key_revocation::KeyRevokeScope::Global, + nonce: "overlapping-exact-receipt".into(), + authorization_hash: [8; 32], + proof_cids: Vec::new(), + not_before: now, + valid_after: None, + expires_at: now + 40, + }; + let first_db = db.clone(); + let first_request = request.clone(); + let first = tokio::spawn(async move { first_db.revoke_key(first_request).await }); + let second_db = db.clone(); + let second = tokio::spawn(async move { second_db.revoke_key(request).await }); + settle().await; + assert!(!first.is_finished() && !second.is_finished()); + drop(in_flight); + let first_receipt = tokio::time::timeout(std::time::Duration::from_secs(5), first) + .await + .expect("first cutoff finishes") + .unwrap() + .unwrap(); + let second_receipt = tokio::time::timeout(std::time::Duration::from_secs(5), second) + .await + .expect("concurrent exact retry finishes") + .unwrap() + .unwrap(); + assert_eq!(first_receipt, second_receipt); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn key_cutoff_serializes_across_connections_and_survives_restart() { let directory = tempfile::tempdir().unwrap(); diff --git a/tinycloud-core/src/policy_witness.rs b/tinycloud-core/src/policy_witness.rs index f3de7dfe..9737d608 100644 --- a/tinycloud-core/src/policy_witness.rs +++ b/tinycloud-core/src/policy_witness.rs @@ -30,6 +30,7 @@ use tinycloud_auth::{ const VERSION: i32 = 1; const MAX_NODES: usize = revocation::MAX_CHAIN_TRAVERSAL_NODES; const MAX_CANDIDATES: usize = 64; +const CANDIDATE_PAGE_SIZE: u64 = 128; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "policy_witness")] @@ -375,6 +376,9 @@ async fn closure( return Ok(None); } let has_capability = signed_caps.iter().any(|held| covers(held, capability)); + if id == tip && !has_capability { + return Ok(None); + } covering.insert(id, has_capability); for parent in graph_parents { pending.push(parent); @@ -481,35 +485,46 @@ pub async fn capture_in_transaction( } roots.push((root, signed, event)); } - let candidates = delegation::Entity::find() - .filter( - sea_orm::Condition::any() - .add(delegation::Column::Delegatee.eq(owner_did)) - .add(delegation::Column::Delegatee.like(format!("{owner_did}#%"))), - ) - .limit((MAX_CANDIDATES * 8 + 1) as u64) - .all(db) - .await?; - if candidates.len() > MAX_CANDIDATES * 8 { - return Err(WitnessError::Overflow); - } + let delegatee_prefix = format!("{owner_did}#%"); for (root, signed, event) in roots { for (index, capability) in event.0.capabilities.into_iter().enumerate() { if index > i32::MAX as usize { return Err(WitnessError::Overflow); } let mut closures = Vec::new(); - for candidate in &candidates { - if !did_principal_matches(&candidate.delegatee, &owner) { - continue; + let mut cursor = None; + loop { + let mut query = delegation::Entity::find() + .filter( + sea_orm::Condition::any() + .add(delegation::Column::Delegatee.eq(owner_did)) + .add(delegation::Column::Delegatee.like(delegatee_prefix.as_str())), + ) + .order_by_asc(delegation::Column::Id) + .limit(CANDIDATE_PAGE_SIZE); + if let Some(last) = cursor { + query = query.filter(delegation::Column::Id.gt(last)); } - if let Some(found) = - closure(db, candidate.id, &owner, &capability, at, encryption).await? - { - closures.push(found); - if closures.len() > MAX_CANDIDATES { - return Err(WitnessError::Overflow); + let candidates = query.all(db).await?; + if candidates.is_empty() { + break; + } + cursor = candidates.last().map(|row| row.id); + for candidate in &candidates { + if !did_principal_matches(&candidate.delegatee, &owner) { + continue; } + if let Some(found) = + closure(db, candidate.id, &owner, &capability, at, encryption).await? + { + closures.push(found); + if closures.len() > MAX_CANDIDATES { + return Err(WitnessError::Overflow); + } + } + } + if candidates.len() < CANDIDATE_PAGE_SIZE as usize { + break; } } closures.sort_by(|a, b| a.tip_cid.cmp(&b.tip_cid)); diff --git a/tinycloud-core/src/recovery_authority.rs b/tinycloud-core/src/recovery_authority.rs index 161ecfb5..a45a8477 100644 --- a/tinycloud-core/src/recovery_authority.rs +++ b/tinycloud-core/src/recovery_authority.rs @@ -37,6 +37,7 @@ pub async fn is_authorized( request: &VerifiedKeyRevoke, now: OffsetDateTime, encryption: Option<&ColumnEncryption>, + proof_expires_at: &mut Option, ) -> Result { if request.proof_cids.is_empty() || request.proof_cids.len() > revocation::MAX_CHAIN_TRAVERSAL_NODES @@ -246,6 +247,17 @@ pub async fn is_authorized( .cloned() .collect::>(); key_authority::lock_targets(db, &shared, false).await?; + // The graph and principal locks may have waited past a signed proof's + // window. Recheck every bound under those locks rather than trusting the + // entry timestamp passed by the caller. + let locked_now = OffsetDateTime::now_utc(); + if rows.iter().any(|row| { + row.issued_at.is_some_and(|issued| locked_now < issued) + || row.not_before.is_some_and(|start| locked_now < start) + || row.expiry.is_some_and(|expiry| locked_now >= expiry) + }) { + return Ok(false); + } let spaces = match &request.scope { crate::models::key_revocation::KeyRevokeScope::Global => Vec::new(), crate::models::key_revocation::KeyRevokeScope::Space { space_id } => vec![space_id.clone()], @@ -312,8 +324,11 @@ pub async fn is_authorized( // because generic invocations do not treat key URNs as space roots. let proofs = roots.iter().map(|id| id.to_cid(0x55)).collect::>(); let caps = &signed[&roots[0]].capabilities; - match invocation::authorize_holder(db, &request.issuer_did, caps, &proofs, now).await { - Ok(()) => Ok(true), + match invocation::authorize_holder(db, &request.issuer_did, caps, &proofs, locked_now).await { + Ok(()) => { + *proof_expires_at = rows.iter().filter_map(|row| row.expiry).min(); + Ok(true) + } Err(invocation::Error::Db(error)) => Err(error), Err(_) => Ok(false), } diff --git a/tinycloud-core/tests/policy_witness_legacy.rs b/tinycloud-core/tests/policy_witness_legacy.rs index befefcf1..783df29d 100644 --- a/tinycloud-core/tests/policy_witness_legacy.rs +++ b/tinycloud-core/tests/policy_witness_legacy.rs @@ -448,3 +448,40 @@ async fn legacy_candidate_overflow_is_never_silently_narrowed() { )); assert_eq!(dry_run_backfill(&db, None).await.unwrap().overflow, 1); } + +#[tokio::test] +async fn unrelated_incoming_grants_do_not_exhaust_a_policy_witness_limit() { + let db = database().await; + let (owner, roots, direct, chained) = legacy_policy(&db).await; + let unrelated = format!( + "tinycloud:key:{}:unrelated/kv", + signer(3).1.trim_start_matches("did:key:") + ); + for index in 0..513 { + grant( + &db, + 3, + &owner, + &unrelated, + &[], + &format!("unrelated-{index}"), + ) + .await; + } + let witnesses = + capture_in_transaction(&db, "legacy-policy", &owner, &roots, date(ADMISSION), None) + .await + .unwrap(); + for witness in &witnesses { + assert_eq!(witness.candidates.len(), 2); + assert!(witness + .candidates + .iter() + .any(|closure| closure.tip_cid == direct)); + assert!(witness + .candidates + .iter() + .any(|closure| closure.tip_cid == chained)); + } + assert_eq!(dry_run_backfill(&db, None).await.unwrap().overflow, 0); +} From 36f34afb687912d98e164d341c3676cd16e219eb Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Thu, 15 Oct 2026 21:02:56 +0000 Subject: [PATCH 17/20] test: cover key revoke expiry after lock wait --- tinycloud-core/src/db.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 06a32bb0..014250a3 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -7697,6 +7697,46 @@ mod test { assert_eq!(first_receipt, second_receipt); } + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn first_use_expiry_while_waiting_for_target_guard_does_not_commit_a_cutoff() { + let db = get_db().await.unwrap(); + let target = "did:key:waited-out-key-revoke".to_owned(); + let key = crate::key_authority::principal_lock_id(&target).unwrap(); + let in_flight = db.acquire_shared_chain_guards_for_keys(vec![key]).await; + let now = OffsetDateTime::now_utc().unix_timestamp(); + let request = key_revocation::VerifiedKeyRevoke { + target_did: target.clone(), + issuer_did: target.clone(), + scope: key_revocation::KeyRevokeScope::Global, + nonce: "waited-out-key-revoke".into(), + authorization_hash: [9; 32], + proof_cids: Vec::new(), + not_before: now, + valid_after: None, + expires_at: now + 1, + }; + let revoking = db.clone(); + let pending = tokio::spawn(async move { revoking.revoke_key(request).await }); + settle().await; + assert!( + !pending.is_finished(), + "the target guard must block the revoke" + ); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + drop(in_flight); + let result = tokio::time::timeout(std::time::Duration::from_secs(5), pending) + .await + .expect("expired revoke finishes") + .unwrap(); + assert!(matches!( + result, + Err(key_revocation::KeyRevokeError::Forbidden) + )); + crate::key_authority::check_principals(&db.conn, &[target], &[]) + .await + .expect("no cutoff was committed after first-use expiry"); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn key_cutoff_serializes_across_connections_and_survives_restart() { let directory = tempfile::tempdir().unwrap(); From 9c2a9d3cc825209ec0249e9c418e55d890883399 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Thu, 15 Oct 2026 22:25:32 +0000 Subject: [PATCH 18/20] TC-1149: complete revocation checks --- .github/workflows/rust.yml | 12 + docs/delegation-flow.md | 11 + tinycloud-core/Cargo.toml | 3 + tinycloud-core/src/db.rs | 112 +++-- tinycloud-core/src/key_authority.rs | 75 +++- .../m20261016_000001_key_revocation.rs | 114 ++++- tinycloud-core/src/models/invocation.rs | 24 +- tinycloud-core/src/policy_witness.rs | 7 + tinycloud-core/src/recovery_authority.rs | 13 +- .../tests/key_revoke_cross_process_receipt.rs | 393 ++++++++++++++++ .../tests/key_revoke_recovery_order.rs | 414 +++++++++++++++++ tinycloud-node-server/Cargo.toml | 2 +- tinycloud-node-server/src/policy_v3.rs | 419 ++++++++++++++++-- tinycloud-node-server/src/routes/hooks.rs | 216 ++++++++- tinycloud-node-server/src/routes/mod.rs | 14 + tinycloud-node-server/src/share_v2.rs | 154 +++++-- .../share_v2/tests/alias_revocation_tests.rs | 2 +- .../tests/share_v2_legacy_key_tests.rs | 108 +++-- 18 files changed, 1903 insertions(+), 190 deletions(-) create mode 100644 tinycloud-core/tests/key_revoke_cross_process_receipt.rs create mode 100644 tinycloud-core/tests/key_revoke_recovery_order.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 91069892..07438e13 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -70,8 +70,20 @@ jobs: --health-interval 5s --health-timeout 5s --health-retries 5 + mysql: + image: mysql:8.0 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1 -u root" + --health-interval 5s + --health-timeout 5s + --health-retries 10 env: TINYCLOUD_TEST_POSTGRES_URL: postgres://postgres:postgres@localhost:5432/postgres + TINYCLOUD_TEST_MYSQL_URL: mysql://root@127.0.0.1:3306/mysql # TC-1054: tests/postgres_pool_timeouts.rs black-holes chosen loopback # connections with exact-4-tuple iptables DROP rules (the runner is a # throwaway VM with passwordless sudo). Unset, they fail under CI. diff --git a/docs/delegation-flow.md b/docs/delegation-flow.md index 3b674229..55b236aa 100644 --- a/docs/delegation-flow.md +++ b/docs/delegation-flow.md @@ -519,6 +519,9 @@ continues to revoke a single delegation CID, not a whole key. The signed policy-v3 JSON `/revoke` records one root's status, not a key-wide cutoff. Fresh root events check the historical policy witness; repeating the exact committed event remains idempotent. +Every witness-backed use checks the loaded roots' signed revocation status, +including an already-issued signed KV download ticket. This cutoff uses the +same root rows as provenance verification, without another query or lock. `targetDid` must be a canonical DID with valid DID syntax, not a DID URL or a delegation CID; an empty method-specific ID is rejected. @@ -529,6 +532,14 @@ signer is revoked or its recovery proof expires. Changed signed bytes with the same nonce return `409` only for a still-authorized signer; a signer without current target/scope authority receives `403`. First-use request and recovery proof validity are checked after blocking authorization locks. + +On a fresh recovery request, the node verifies the signed chain before +locking its persisted proof rows. It then locks every dependency principal +in canonical DID order, taking the target and request signer exclusively +and other chain principals shared on their first visit. No principal lock +is upgraded within the transaction; the locks cover the tombstone and +receipt commit. + The cutoff remains immutable; successful responses follow its durable commit. Later policy mints, delegation admission, and invocations cannot use authority derived from the revoked principal. Natural proof expiry is not an explicit diff --git a/tinycloud-core/Cargo.toml b/tinycloud-core/Cargo.toml index d166e69a..c9cc8f82 100644 --- a/tinycloud-core/Cargo.toml +++ b/tinycloud-core/Cargo.toml @@ -16,6 +16,9 @@ duckdb = ["dep:arrow", "dep:duckdb"] # build. Downstream crates enable it only on their `tinycloud-core` # dev-dependency so their own tests can assert exactly-once verification. verification-count-test-hook = [] +# Reconstructed pre-sunset signed Share-v2 fixtures only. Off by default so +# shipped authorization never accepts a caller-selected signature time. +historical-invocation-test-clock = [] tokio = [ "sea-orm/runtime-tokio-rustls", diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 014250a3..2cc9e735 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -2097,6 +2097,20 @@ where write_may_have_applied, }; let nonce_key = (request.issuer_did.clone(), request.nonce.clone()); + let exact_receipt = |row: &key_revocation::nonce::Model| { + if row.authorization_hash != request.authorization_hash + || row.target_did != request.target_did + { + return Ok(None); + } + let receipt: key_revocation::KeyRevokeReceipt = + serde_json::from_slice(&row.receipt_bytes) + .map_err(|_| db_error(DbErr::Custom("key-receipt-invalid".into()), false))?; + if receipt.scope != request.scope || receipt.target_did != request.target_did { + return Err(KeyRevokeError::Forbidden); + } + Ok(Some(receipt)) + }; // This read never discloses a target. The original signed request // proves possession of the same issuer key even after its revocation. let existing = key_revocation::nonce::Entity::find_by_id(nonce_key.clone()) @@ -2104,17 +2118,8 @@ where .await .map_err(|error| db_error(error, false))?; if let Some(row) = &existing { - if row.authorization_hash == request.authorization_hash - && row.target_did == request.target_did - { - let receipt: key_revocation::KeyRevokeReceipt = - serde_json::from_slice(&row.receipt_bytes).map_err(|_| { - db_error(DbErr::Custom("key-receipt-invalid".into()), false) - })?; - if receipt.scope == request.scope && receipt.target_did == request.target_did { - return Ok(receipt); - } - return Err(KeyRevokeError::Forbidden); + if let Some(receipt) = exact_receipt(row)? { + return Ok(receipt); } } @@ -2133,22 +2138,13 @@ where .map_err(|error| db_error(error, false))?; // A concurrent request may have committed the identical self-revoke // while this one waited for its target lock. Its receipt remains - // retryable even though fresh issuer authority is now gone. if let Some(row) = key_revocation::nonce::Entity::find_by_id(nonce_key.clone()) .one(&tx) .await .map_err(|error| db_error(error, false))? { - if row.authorization_hash == request.authorization_hash - && row.target_did == request.target_did - { - let receipt: key_revocation::KeyRevokeReceipt = - serde_json::from_slice(&row.receipt_bytes).map_err(|_| { - db_error(DbErr::Custom("key-receipt-invalid".into()), false) - })?; - if receipt.scope == request.scope && receipt.target_did == request.target_did { - return Ok(receipt); - } + if let Some(receipt) = exact_receipt(&row)? { + return Ok(receipt); } } let now = OffsetDateTime::now_utc(); @@ -2200,17 +2196,45 @@ where .map_err(|error| db_error(error, false))? }; if !authorized { + // A second process may have committed the same signed request + // before this authority read. Its receipt is still retryable even + // when that commit has revoked the issuer itself. + if let Some(row) = key_revocation::nonce::Entity::find_by_id(nonce_key.clone()) + .one(&tx) + .await + .map_err(|error| db_error(error, false))? + { + if let Some(receipt) = exact_receipt(&row)? { + return Ok(receipt); + } + } return Err(KeyRevokeError::Forbidden); } - // Both the target and nonce issuer use the same sorted row-lock - // namespace; global and scoped revokes cannot pass each other. - crate::key_authority::lock_targets( - &tx, - &[request.target_did.clone(), request.issuer_did.clone()], - true, - ) - .await - .map_err(|error| db_error(error, false))?; + // Recovery already holds the target and issuer UPDATE locks along + // with shared proof principals in one canonical order. Direct + // self/space-owner revokes have no proof principal locks to upgrade. + if direct { + crate::key_authority::lock_targets( + &tx, + &[request.target_did.clone(), request.issuer_did.clone()], + true, + ) + .await + .map_err(|error| db_error(error, false))?; + } + // The issuer/target database row locks are the cross-process + // serialization point. Re-read the durable nonce before fresh + // liveness or time checks: a simultaneous self-revoke cannot turn an + // identical committed request into a 403. + let post_lock_nonce = key_revocation::nonce::Entity::find_by_id(nonce_key.clone()) + .one(&tx) + .await + .map_err(|error| db_error(error, false))?; + if let Some(row) = &post_lock_nonce { + if let Some(receipt) = exact_receipt(row)? { + return Ok(receipt); + } + } let spaces = match &request.scope { KeyRevokeScope::Global => Vec::new(), KeyRevokeScope::Space { space_id } => vec![space_id.clone()], @@ -2242,22 +2266,9 @@ where { return Err(KeyRevokeError::Forbidden); } - if let Some(row) = key_revocation::nonce::Entity::find_by_id(nonce_key.clone()) - .one(&tx) - .await - .map_err(|error| db_error(error, false))? - { - if row.authorization_hash == request.authorization_hash - && row.target_did == request.target_did - { - let receipt: key_revocation::KeyRevokeReceipt = - serde_json::from_slice(&row.receipt_bytes).map_err(|_| { - db_error(DbErr::Custom("key-receipt-invalid".into()), false) - })?; - if receipt.scope == request.scope && receipt.target_did == request.target_did { - return Ok(receipt); - } - } + // A changed nonce is disclosed only after the fresh signer and + // signed-time checks, never to a revoked or unrelated signer. + if post_lock_nonce.is_some() { return Err(KeyRevokeError::Conflict); } let scope_key = request.scope.storage_key(); @@ -6621,6 +6632,13 @@ mod test { .await .unwrap(); + assert!( + !crate::models::revocation::is_revoked(&db.conn, &parent_id) + .await + .unwrap(), + "the issue and existing-use checks must see a live parent before revoke" + ); + let revoke_guard = db .acquire_chain_guards(&[parent_id]) .await diff --git a/tinycloud-core/src/key_authority.rs b/tinycloud-core/src/key_authority.rs index 67330e6e..502a2d3c 100644 --- a/tinycloud-core/src/key_authority.rs +++ b/tinycloud-core/src/key_authority.rs @@ -119,22 +119,7 @@ pub async fn lock_targets( // Insert and lock in canonical order across all node processes. A single // batched insert plus one ordered shared/exclusive row-lock query avoids // serial DB round trips for every signer in a proof closure. - match target::Entity::insert_many(principals.iter().cloned().map(|target_did| { - target::ActiveModel { - target_did: Set(target_did), - } - })) - .on_conflict( - OnConflict::column(target::Column::TargetDid) - .do_nothing() - .to_owned(), - ) - .exec(tx) - .await - { - Ok(_) | Err(DbErr::RecordNotInserted) => (), - Err(error) => return Err(error), - } + ensure_target_rows(tx, &principals).await?; if tx.get_database_backend() != DbBackend::Sqlite { let query = target::Entity::find() .filter(target::Column::TargetDid.is_in(principals.iter().cloned())) @@ -151,6 +136,64 @@ pub async fn lock_targets( Ok(()) } +/// Recovery authorization must lock the target and signer exclusively while +/// keeping every other live proof principal shared. Acquire the strongest +/// mode each principal needs on its first visit, across one canonical order: +/// taking intermediate SHARE locks before target UPDATE locks can deadlock +/// when two otherwise valid recovery chains cross. +pub(crate) async fn lock_recovery_targets( + tx: &DatabaseTransaction, + principals: &[String], + target_did: &str, + issuer_did: &str, +) -> Result<(), DbErr> { + let invalid = || DbErr::Custom("authorization-principal-invalid".into()); + let target_did = principal_did(target_did).map_err(|_| invalid())?; + let issuer_did = principal_did(issuer_did).map_err(|_| invalid())?; + let mut ordered = Vec::with_capacity(principals.len() + 2); + for principal in principals { + ordered.push(principal_did(principal).map_err(|_| invalid())?); + } + ordered.push(target_did.clone()); + ordered.push(issuer_did.clone()); + ordered.sort_unstable(); + ordered.dedup(); + ensure_target_rows(tx, &ordered).await?; + if tx.get_database_backend() != DbBackend::Sqlite { + for principal in ordered { + let query = target::Entity::find_by_id(principal.clone()); + let locked = if principal == target_did || principal == issuer_did { + query.lock_exclusive().one(tx).await? + } else { + query.lock_shared().one(tx).await? + }; + if locked.is_none() { + return Err(DbErr::Custom("authorization-key-lock-missing".into())); + } + } + } + Ok(()) +} + +async fn ensure_target_rows(tx: &DatabaseTransaction, principals: &[String]) -> Result<(), DbErr> { + match target::Entity::insert_many(principals.iter().cloned().map(|target_did| { + target::ActiveModel { + target_did: Set(target_did), + } + })) + .on_conflict( + OnConflict::column(target::Column::TargetDid) + .do_nothing() + .to_owned(), + ) + .exec(tx) + .await + { + Ok(_) | Err(DbErr::RecordNotInserted) => Ok(()), + Err(error) => Err(error), + } +} + fn canonical_principals(principals: &[String]) -> Result, DbErr> { let mut canonical = principals .iter() diff --git a/tinycloud-core/src/migrations/m20261016_000001_key_revocation.rs b/tinycloud-core/src/migrations/m20261016_000001_key_revocation.rs index 629c15aa..dce2e38b 100644 --- a/tinycloud-core/src/migrations/m20261016_000001_key_revocation.rs +++ b/tinycloud-core/src/migrations/m20261016_000001_key_revocation.rs @@ -67,7 +67,7 @@ impl MigrationTrait for Migration { ) .col( ColumnDef::new(key_revocation::nonce::Column::AuthorizationHash) - .binary() + .binary_len(32) .not_null(), ) .col( @@ -77,7 +77,7 @@ impl MigrationTrait for Migration { ) .col( ColumnDef::new(key_revocation::nonce::Column::ReceiptBytes) - .binary() + .blob() .not_null(), ) .primary_key( @@ -110,3 +110,113 @@ impl MigrationTrait for Migration { .await } } + +#[cfg(test)] +mod tests { + use super::*; + use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ConnectOptions, ConnectionTrait, Database, EntityTrait, + }; + + struct MySqlDatabase { + url: String, + name: String, + } + + impl Drop for MySqlDatabase { + fn drop(&mut self) { + let (url, name) = (self.url.clone(), self.name.clone()); + std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async move { + let admin = Database::connect(ConnectOptions::new(url)).await.unwrap(); + admin + .execute_unprepared(&format!("DROP DATABASE IF EXISTS {name}")) + .await + .unwrap(); + }) + }) + .join() + .unwrap(); + } + } + + #[tokio::test] + async fn mysql_nonce_migration_persists_real_digest_and_replay_receipt() { + let url = match std::env::var("TINYCLOUD_TEST_MYSQL_URL") { + Ok(url) if !url.trim().is_empty() => url, + _ => { + assert!( + std::env::var_os("CI").is_none(), + "MySQL nonce migration requires TINYCLOUD_TEST_MYSQL_URL in CI" + ); + eprintln!("skipping MySQL nonce migration: TINYCLOUD_TEST_MYSQL_URL is unset"); + return; + } + }; + let name = format!( + "tc1149_nonce_{}_{}", + std::process::id(), + time::OffsetDateTime::now_utc().unix_timestamp_nanos() + ); + let database = MySqlDatabase { + url: url.clone(), + name: name.clone(), + }; + let admin = Database::connect(ConnectOptions::new(url.clone())) + .await + .expect("connect to disposable MySQL"); + admin + .execute_unprepared(&format!("CREATE DATABASE {name}")) + .await + .expect("create isolated MySQL database"); + admin.close().await.unwrap(); + let database_url = format!("{}/{name}", url.rsplit_once('/').unwrap().0); + let conn = Database::connect(ConnectOptions::new(database_url)) + .await + .expect("connect to isolated MySQL database"); + Migration.up(&SchemaManager::new(&conn)).await.unwrap(); + + let target = "did:key:tc1149-mysql-test-principal".to_owned(); + let receipt = key_revocation::KeyRevokeReceipt { + revoked: true, + target_did: target.clone(), + scope: key_revocation::KeyRevokeScope::Global, + revoked_at: 1_000, + }; + let receipt_bytes = serde_json::to_vec(&receipt).unwrap(); + assert!( + receipt_bytes.len() > 1, + "real signed receipt exceeds BINARY(1)" + ); + let digest = vec![0x42; 32]; + let nonce = "mysql-nonce-roundtrip".to_owned(); + key_revocation::nonce::ActiveModel { + issuer_did: Set(target.clone()), + nonce: Set(nonce.clone()), + authorization_hash: Set(digest.clone()), + target_did: Set(target.clone()), + receipt_bytes: Set(receipt_bytes.clone()), + } + .insert(&conn) + .await + .expect("persist full-width signed digest and durable receipt on MySQL"); + let stored = key_revocation::nonce::Entity::find_by_id((target, nonce)) + .one(&conn) + .await + .unwrap() + .expect("committed replay receipt"); + assert_eq!(stored.authorization_hash, digest); + assert_eq!(stored.receipt_bytes, receipt_bytes); + assert_eq!( + serde_json::from_slice::(&stored.receipt_bytes) + .unwrap(), + receipt, + ); + conn.close().await.unwrap(); + drop(database); + } +} diff --git a/tinycloud-core/src/models/invocation.rs b/tinycloud-core/src/models/invocation.rs index 4602bd80..27784f7c 100644 --- a/tinycloud-core/src/models/invocation.rs +++ b/tinycloud-core/src/models/invocation.rs @@ -196,6 +196,13 @@ pub(crate) async fn authorize_admitted( } pub async fn verify_invocation(invocation: &TinyCloudInvocation) -> Result<(), Error> { + verify_invocation_at(invocation, None).await +} + +async fn verify_invocation_at( + invocation: &TinyCloudInvocation, + signed_time: Option, +) -> Result<(), Error> { verify_ucan_signature(invocation) .await .map_err(|error| match error { @@ -209,7 +216,7 @@ pub async fn verify_invocation(invocation: &TinyCloudInvocation) -> Result<(), E })?; invocation .payload() - .validate_time(None) + .validate_time(signed_time) .map_err(|_| InvocationError::InvalidTime)?; // TC-409: this is the single cryptographic-verification primitive @@ -237,6 +244,21 @@ pub async fn verify_and_authorize( verify_and_authorize_with_graph(db, invocation, now, None).await } +/// Evaluate a reconstructed signed invocation at its historical proof time. +/// Live routes use [`verify_and_authorize`], which always checks the signature +/// against the actual clock. The supplied time applies to both the invocation +/// and its cited delegation chain; neither check is bypassed. +#[cfg(feature = "historical-invocation-test-clock")] +pub async fn verify_and_authorize_with_signed_time( + db: &C, + invocation: &util::InvocationInfo, + now: OffsetDateTime, + signed_time: f64, +) -> Result<(), Error> { + verify_invocation_at(&invocation.invocation, Some(signed_time)).await?; + validate(db, invocation, Some(now), None).await +} + /// [`verify_and_authorize`] against a caller-loaded authorization snapshot, /// so the caller can derive more facts (TC-732's authority window) from the /// exact graph the decision was made on. diff --git a/tinycloud-core/src/policy_witness.rs b/tinycloud-core/src/policy_witness.rs index 9737d608..67710a06 100644 --- a/tinycloud-core/src/policy_witness.rs +++ b/tinycloud-core/src/policy_witness.rs @@ -638,6 +638,13 @@ pub async fn revocation_dependencies( .one(db) .await? .ok_or(WitnessError::Missing)?; + // A signed policy-root status revocation is an explicit CID cutoff, + // even when no generic `/revoke` tombstone was written. This row is + // already loaded for every witness-backed consumer (including issued + // download tickets), so the check adds no read round trip. + if root.revoked_at.is_some() || root.revocation_bytes.is_some() { + return Err(WitnessError::Revoked); + } if root.policy_cid != policy_cid || root.role != role { return Err(WitnessError::Ambiguous); } diff --git a/tinycloud-core/src/recovery_authority.rs b/tinycloud-core/src/recovery_authority.rs index a45a8477..fdfc3b08 100644 --- a/tinycloud-core/src/recovery_authority.rs +++ b/tinycloud-core/src/recovery_authority.rs @@ -239,14 +239,11 @@ pub async fn is_authorized( principals.push(signer); principals.push(audience); } - // The issuer and target receive exclusive locks at the cutoff. Other - // recovery dependencies remain shared through that commit. - let shared = principals - .iter() - .filter(|did| *did != &request.target_did && *did != &request.issuer_did) - .cloned() - .collect::>(); - key_authority::lock_targets(db, &shared, false).await?; + // The target and issuer need UPDATE, while other proof principals need + // SHARE. Lock the complete set once in canonical order; never upgrade an + // already-held intermediate SHARE lock to UPDATE in this transaction. + key_authority::lock_recovery_targets(db, &principals, &request.target_did, &request.issuer_did) + .await?; // The graph and principal locks may have waited past a signed proof's // window. Recheck every bound under those locks rather than trusting the // entry timestamp passed by the caller. diff --git a/tinycloud-core/tests/key_revoke_cross_process_receipt.rs b/tinycloud-core/tests/key_revoke_cross_process_receipt.rs new file mode 100644 index 00000000..5a449665 --- /dev/null +++ b/tinycloud-core/tests/key_revoke_cross_process_receipt.rs @@ -0,0 +1,393 @@ +//! Two independent PostgreSQL clients must agree on the first self-revoke receipt. +use std::{ + process::{Child, Command, Stdio}, + time::Duration, +}; + +use tinycloud_core::{ + db::SpaceDatabase, + keys::{public_key_to_did_key, Keypair, StaticSecret}, + migrations::Migrator, + models::key_revocation::{self, KeyRevokeReceipt, KeyRevokeScope, VerifiedKeyRevoke}, + sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ConnectOptions, ConnectionTrait, Database, + DatabaseConnection, DbBackend, EntityTrait, Statement, TransactionTrait, + }, + sea_orm_migration::MigratorTrait, + storage::memory::MemoryStore, +}; +use tokio::time::{sleep, timeout, Instant}; + +const TEST: &str = "postgres_cross_process_self_revoke_returns_one_immutable_receipt"; +const MARKER: &str = "TC1149_CROSS_PROCESS_WORKER"; +const RECEIPT: &str = "TC1149_RECEIPT:"; +const ERROR: &str = "TC1149-REVOKE-RESULT:"; +const NONCE: &str = "tc1149-cross-process-identical-self-revoke"; +const AUTHORIZATION_HASH: [u8; 32] = [19; 32]; + +fn target_did() -> String { + public_key_to_did_key(Keypair::ed25519_from_bytes([37; 32]).unwrap().public()) +} + +fn request(now: i64) -> VerifiedKeyRevoke { + let target_did = target_did(); + VerifiedKeyRevoke { + issuer_did: target_did.clone(), + target_did, + scope: KeyRevokeScope::Global, + nonce: NONCE.into(), + authorization_hash: AUTHORIZATION_HASH, + proof_cids: Vec::new(), + not_before: now, + valid_after: None, + expires_at: now + 60, + } +} + +fn postgres_url() -> Option { + match std::env::var("TINYCLOUD_TEST_POSTGRES_URL") { + Ok(url) if !url.trim().is_empty() => Some(url), + _ => { + assert!( + std::env::var_os("CI").is_none(), + "{TEST} requires TINYCLOUD_TEST_POSTGRES_URL in CI" + ); + eprintln!("skipping {TEST}: TINYCLOUD_TEST_POSTGRES_URL is unset"); + None + } + } +} + +fn connection_options(url: &str, schema: &str) -> ConnectOptions { + let mut options = ConnectOptions::new(url); + options + .max_connections(1) + .sqlx_logging(false) + .set_schema_search_path(schema); + options +} + +// Drop is synchronous so cleanup also runs while an assertion is unwinding. +struct Schema { + url: String, + name: String, + conn: DatabaseConnection, +} + +impl Schema { + async fn new(url: String) -> Self { + let name = format!( + "tc1149_receipt_{}_{}", + std::process::id(), + time::OffsetDateTime::now_utc().unix_timestamp_nanos() + ); + let admin = Database::connect(ConnectOptions::new(&url)) + .await + .expect("connect to PG"); + admin + .execute_unprepared(&format!("CREATE SCHEMA {name}")) + .await + .expect("create isolated schema"); + admin.close().await.expect("close schema admin"); + let conn = Database::connect(connection_options(&url, &name)) + .await + .expect("connect to isolated schema"); + let fixture = Self { url, name, conn }; + Migrator::up(&fixture.conn, None) + .await + .expect("migrate isolated schema"); + fixture + } +} + +impl Drop for Schema { + fn drop(&mut self) { + let url = self.url.clone(); + let name = self.name.clone(); + let result = std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("schema cleanup runtime") + .block_on(async move { + timeout(Duration::from_secs(10), async { + let admin = Database::connect(ConnectOptions::new(url)).await?; + // A failed assertion may leave a worker waiting on our row lock. + // Only sessions bearing this schema's two unique application names are killed. + let terminate = Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity \ + WHERE application_name IN ($1, $2) AND pid <> pg_backend_pid()", + vec![format!("{name}_one").into(), format!("{name}_two").into()], + ); + admin.query_all(terminate).await?; + admin + .execute_unprepared(&format!("DROP SCHEMA IF EXISTS {name} CASCADE")) + .await?; + admin.close().await + }) + .await + }) + }) + .join(); + if !matches!(result, Ok(Ok(Ok(())))) { + eprintln!("isolated PG receipt schema cleanup failed"); + } + } +} + +// Kill and reap both OS processes even if a parent assertion panics. +struct Workers(Vec>); + +impl Drop for Workers { + fn drop(&mut self) { + for child in self.0.iter_mut().flatten() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +async fn worker(url: String, schema: String, label: &str, now: i64) { + let conn = Database::connect(connection_options(&url, &schema)) + .await + .expect("worker connects to its own PG pool"); + let db = SpaceDatabase::new( + conn, + MemoryStore::default(), + StaticSecret::new(vec![0; 32]).unwrap(), + ) + .await + .expect("worker opens database"); + db.connection() + .execute_unprepared(&format!("SET application_name = '{schema}_{label}'")) + .await + .expect("name worker PostgreSQL session"); + match db.revoke_key(request(now)).await { + Ok(receipt) => println!("{RECEIPT}{}", serde_json::to_string(&receipt).unwrap()), + Err(key_revocation::KeyRevokeError::Forbidden) => println!("{ERROR}403"), + Err(key_revocation::KeyRevokeError::Conflict) => println!("{ERROR}409"), + Err(key_revocation::KeyRevokeError::Db { .. }) => println!("{ERROR}503"), + } +} + +fn receipt_from(output: std::process::Output) -> KeyRevokeReceipt { + assert!( + output.status.success(), + "cross-process revoke worker failed" + ); + let text = std::str::from_utf8(&output.stdout).expect("worker stdout is UTF-8"); + let error = text.lines().find_map(|line| line.strip_prefix(ERROR)); + assert!( + error.is_none(), + "concurrent worker returned {error:?} instead of committed receipt" + ); + let receipts: Vec<_> = text + .lines() + .filter_map(|line| line.strip_prefix(RECEIPT)) + .collect(); + assert_eq!( + receipts.len(), + 1, + "worker must produce exactly one live receipt" + ); + serde_json::from_str(receipts[0]).expect("worker receipt is valid JSON") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn postgres_cross_process_self_revoke_returns_one_immutable_receipt() { + if let Some(label) = std::env::var_os(MARKER) { + let label = label.to_str().expect("worker label UTF-8"); + assert!(label == "one" || label == "two", "recognized worker label"); + let url = postgres_url().expect("worker requires PostgreSQL URL"); + let schema = std::env::var("TC1149_RECEIPT_SCHEMA").expect("parent supplies schema"); + let now = std::env::var("TC1149_RECEIPT_NOW") + .expect("parent supplies signed time") + .parse() + .expect("valid signed time"); + worker(url, schema, label, now).await; + return; + } + let Some(url) = postgres_url() else { return }; + let schema = Schema::new(url.clone()).await; + let target = target_did(); + key_revocation::target::ActiveModel { + target_did: Set(target.clone()), + } + .insert(&schema.conn) + .await + .expect("pre-create target lock row"); + let guard = schema + .conn + .begin() + .await + .expect("open shared-lock transaction"); + let locked = guard + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT target_did FROM key_revocation_target \ + WHERE target_did = $1 FOR SHARE", + vec![target.clone().into()], + )) + .await + .expect("hold PG shared row guard") + .expect("target row exists"); + assert_eq!(locked.try_get::("", "target_did").unwrap(), target); + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let exe = std::env::current_exe().expect("current test executable"); + let mut workers = Workers(Vec::new()); + for label in ["one", "two"] { + let mut command = Command::new(&exe); + command + .arg("--exact") + .arg(TEST) + .arg("--nocapture") + .env(MARKER, label) + .env("TC1149_RECEIPT_SCHEMA", &schema.name) + .env("TC1149_RECEIPT_NOW", now.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + workers.0.push(Some( + command.spawn().expect("spawn independent revoke worker"), + )); + } + let observer = Database::connect(ConnectOptions::new(&url)) + .await + .expect("independent PG activity observer"); + let one = format!("{}_one", schema.name); + let two = format!("{}_two", schema.name); + let deadline = Instant::now() + Duration::from_secs(30); + loop { + let blocked = observer + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT application_name, pid FROM pg_stat_activity \ + WHERE application_name IN ($1, $2) AND state = 'active' \ + AND wait_event_type = 'Lock' \ + AND query LIKE '%key_revocation_target%' AND query ILIKE '%FOR UPDATE%'", + vec![one.clone().into(), two.clone().into()], + )) + .await + .expect("inspect blocked worker backends"); + if blocked.len() == 2 { + let mut names = Vec::new(); + let mut pids = Vec::new(); + for row in blocked { + names.push(row.try_get::("", "application_name").unwrap()); + pids.push(row.try_get::("", "pid").unwrap()); + } + names.sort(); + assert_eq!( + names, + [one.clone(), two.clone()], + "both named processes reach target SQL" + ); + assert_ne!( + pids[0], pids[1], + "workers use different PostgreSQL backends" + ); + break; + } + if Instant::now() >= deadline { + let sessions = observer + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT application_name, state, wait_event_type, query FROM pg_stat_activity \ + WHERE application_name IN ($1, $2)", + vec![one.clone().into(), two.clone().into()], + )) + .await + .expect("inspect named worker sessions"); + let waiting: Vec<_> = sessions + .iter() + .map(|row| { + let name: String = row.try_get("", "application_name").unwrap(); + let state: String = row.try_get("", "state").unwrap(); + let event: Option = row.try_get("", "wait_event_type").unwrap(); + let query: String = row.try_get("", "query").unwrap(); + ( + name, + state, + event, + query.contains("key_revocation_target"), + query.to_ascii_uppercase().contains("FOR UPDATE"), + ) + }) + .collect(); + let exits: Vec<_> = workers + .0 + .iter_mut() + .map(|child| { + child + .as_mut() + .unwrap() + .try_wait() + .unwrap() + .map(|status| status.code()) + }) + .collect(); + panic!("workers did not reach target row guard: sessions {waiting:?}, exit codes {exits:?}"); + } + sleep(Duration::from_millis(20)).await; + } + guard.commit().await.expect("release FOR SHARE row guard"); + let mut receipts = Vec::new(); + let completion_deadline = Instant::now() + Duration::from_secs(20); + for worker in &mut workers.0 { + loop { + if worker + .as_mut() + .unwrap() + .try_wait() + .expect("inspect worker status") + .is_some() + { + break; + } + assert!( + Instant::now() < completion_deadline, + "blocked worker finishes after release" + ); + sleep(Duration::from_millis(20)).await; + } + let child = worker.take().unwrap(); + receipts.push(receipt_from( + child.wait_with_output().expect("read worker receipt"), + )); + } + assert_eq!( + receipts[0], receipts[1], + "concurrent requests share the immutable first receipt" + ); + assert!(receipts[0].revoked); + assert_eq!(receipts[0].target_did, target); + assert_eq!(receipts[0].scope, KeyRevokeScope::Global); + let persisted = key_revocation::nonce::Entity::find_by_id((target.clone(), NONCE.into())) + .one(&schema.conn) + .await + .expect("read durable nonce") + .expect("receipt persisted"); + assert_eq!(persisted.authorization_hash, AUTHORIZATION_HASH.to_vec()); + assert_eq!( + persisted.receipt_bytes, + serde_json::to_vec(&receipts[0]).expect("serialize immutable receipt") + ); + let fresh = SpaceDatabase::new( + Database::connect(connection_options(&url, &schema.name)) + .await + .expect("cold connection"), + MemoryStore::default(), + StaticSecret::new(vec![0; 32]).unwrap(), + ) + .await + .expect("cold database opens"); + assert_eq!( + fresh + .revoke_key(request(now)) + .await + .expect("committed receipt replays"), + receipts[0], + "replay on fresh connection preserves first receipt" + ); +} diff --git a/tinycloud-core/tests/key_revoke_recovery_order.rs b/tinycloud-core/tests/key_revoke_recovery_order.rs new file mode 100644 index 00000000..d0c7f089 --- /dev/null +++ b/tinycloud-core/tests/key_revoke_recovery_order.rs @@ -0,0 +1,414 @@ +//! Two live recovery chains can cross at principal locks during key revocation. +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde_json::json; +use std::{collections::BTreeMap, time::Duration}; +use time::OffsetDateTime; +use tinycloud_auth::{ + authorization::{Cid, HeaderEncode, TinyCloudDelegation}, + ssi::{ + claims::jwt::NumericDate, + jwk::{Algorithm, JWK}, + ucan::Payload, + }, + ucan_capabilities_object::Capabilities, +}; +use tinycloud_core::{ + hash::{hash, Hash}, + keys::{public_key_to_did_key, Keypair, StaticSecret}, + migrations::Migrator, + models::{ + abilities, actor, delegation, delegation_cid_alias, + key_revocation::{self, KeyRevokeError, KeyRevokeScope, VerifiedKeyRevoke}, + }, + recovery_authority, + relationships::parent_delegations, + sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ConnectOptions, ConnectionTrait, Database, + DatabaseConnection, DatabaseTransaction, DbBackend, EntityTrait, Statement, + TransactionTrait, + }, + sea_orm_migration::MigratorTrait, + storage::MemoryStore, + util::DelegationInfo, + SpaceDatabase, +}; + +fn signer(seed: u8) -> (JWK, String, String) { + let key = Keypair::ed25519_from_bytes([seed; 32]).unwrap(); + let public = key.clone().try_into_ed25519().unwrap().public().to_bytes(); + let did = public_key_to_did_key(key.public()); + let jwk = serde_json::from_value(json!({ + "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", + "x": URL_SAFE_NO_PAD.encode(public), + "d": URL_SAFE_NO_PAD.encode([seed; 32]), + })) + .unwrap(); + let fragment = format!("{did}#{}", did.trim_start_matches("did:key:")); + (jwk, did, fragment) +} + +fn signed_grant( + seed: u8, + audience: &str, + target: &str, + parent: Option<&str>, + nonce: &str, + now: i64, +) -> String { + let (jwk, _, issuer) = signer(seed); + let mut caps = Capabilities::::new(); + let caveat: BTreeMap = serde_json::from_value(json!({ + "version": 1, "targetDid": target, "scope": {"kind": "global"} + })) + .unwrap(); + caps.with_action( + format!("urn:tinycloud:key:{target}").parse().unwrap(), + "tinycloud.key/revoke".parse().unwrap(), + [caveat], + ); + Payload:: { + issuer: issuer.parse().unwrap(), + audience: audience.parse().unwrap(), + not_before: Some(NumericDate::try_from_seconds((now - 60) as f64).unwrap()), + expiration: NumericDate::try_from_seconds((now + 600) as f64).unwrap(), + nonce: Some(nonce.to_owned()), + facts: None, + proof: parent + .into_iter() + .map(|cid| cid.parse::().unwrap()) + .collect(), + attenuation: caps, + } + .sign(Algorithm::EdDSA, &jwk) + .unwrap() + .encode() + .unwrap() +} + +async fn persist_grant(db: &DatabaseConnection, serialized: &str) -> String { + let (_, bytes) = TinyCloudDelegation::decode(serialized).unwrap(); + let info = DelegationInfo::try_from(TinyCloudDelegation::from_bytes(&bytes).unwrap()).unwrap(); + assert_eq!(info.capabilities.len(), 1); + let id = hash(&bytes); + let cid = id.to_cid(0x55).to_string(); + assert_eq!(bytes, serialized.as_bytes()); + for did in [&info.delegator, &info.delegate] { + if actor::Entity::find_by_id(did.to_owned()) + .one(db) + .await + .unwrap() + .is_none() + { + actor::ActiveModel { + id: Set(did.to_owned()), + } + .insert(db) + .await + .unwrap(); + } + } + delegation::ActiveModel { + id: Set(id), + delegator: Set(info.delegator), + delegatee: Set(info.delegate), + expiry: Set(info.expiry), + issued_at: Set(info.issued_at), + not_before: Set(info.not_before), + facts: Set(None), + serialization: Set(bytes.clone()), + } + .insert(db) + .await + .unwrap(); + let cap = &info.capabilities[0]; + abilities::ActiveModel { + resource: Set(cap.resource.clone()), + ability: Set(cap.ability.clone()), + delegation: Set(id), + caveats: Set(cap.caveats.clone()), + } + .insert(db) + .await + .unwrap(); + delegation_cid_alias::ActiveModel { + id: Set(id), + canonical: Set(hash(&bytes)), + encrypted: Set(false), + } + .insert(db) + .await + .unwrap(); + for parent in &info.parents { + parent_delegations::ActiveModel { + parent: Set(Hash::from(*parent)), + child: Set(id), + } + .insert(db) + .await + .unwrap(); + } + cid +} + +fn request(target: &str, issuer: &str, proof: String, nonce: &str, now: i64) -> VerifiedKeyRevoke { + let mut authorization_hash = [0; 32]; + authorization_hash.copy_from_slice(hash(nonce.as_bytes()).as_ref()); + VerifiedKeyRevoke { + target_did: target.to_owned(), + issuer_did: issuer.to_owned(), + scope: KeyRevokeScope::Global, + nonce: nonce.to_owned(), + authorization_hash, + proof_cids: vec![proof], + not_before: now, + valid_after: None, + expires_at: now + 60, + } +} + +// The names consist only of our numeric PID and timestamp, never URL/user input. +struct Schema { + url: String, + name: String, +} + +impl Drop for Schema { + fn drop(&mut self) { + let (url, name) = (self.url.clone(), self.name.clone()); + let result = std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async move { + let conn = Database::connect(ConnectOptions::new(url)).await.unwrap(); + conn.execute_unprepared(&format!("DROP SCHEMA IF EXISTS {name} CASCADE")) + .await + .unwrap(); + }) + }) + .join(); + assert!(result.is_ok(), "drop isolated PostgreSQL schema"); + } +} + +async fn wait_for_both_at_update( + admin: &DatabaseConnection, + names: [&str; 2], + holder: i32, +) -> bool { + let deadline = tokio::time::Instant::now() + Duration::from_secs(12); + loop { + // pg_blocking_pids pins each wait to OUR held R/S rows rather than a + // migration or a competing test. Only the ordered exclusive row query + // qualifies; an earlier proof/share wait is not a valid schedule. + let rows = admin + .query_all(Statement::from_string( + DbBackend::Postgres, + format!( + "SELECT application_name FROM pg_stat_activity \ + WHERE application_name IN ('{}', '{}') AND state = 'active' \ + AND wait_event_type = 'Lock' AND {} = ANY(pg_blocking_pids(pid)) \ + AND query LIKE '%key_revocation_target%' AND query LIKE '%FOR UPDATE%'", + names[0], names[1], holder, + ), + )) + .await + .expect("inspect isolated PostgreSQL wait events"); + if rows.len() == 2 { + let seen: Vec = rows + .iter() + .map(|row| row.try_get("", "application_name").unwrap()) + .collect(); + if names + .iter() + .all(|name| seen.iter().any(|seen| seen == name)) + { + return true; + } + } + if tokio::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn postgres_crossed_recovery_revokes_do_not_deadlock() { + let url = match std::env::var("TINYCLOUD_TEST_POSTGRES_URL") { + Ok(url) if !url.trim().is_empty() => url, + _ => { + assert!(std::env::var_os("CI").is_none(), + "postgres_crossed_recovery_revokes_do_not_deadlock requires TINYCLOUD_TEST_POSTGRES_URL in CI"); + eprintln!("skipping PostgreSQL-only recovery order test: TINYCLOUD_TEST_POSTGRES_URL is unset"); + return; + } + }; + let schema = Schema { + url: url.clone(), + name: format!( + "tc1149_{}_{}", + std::process::id(), + OffsetDateTime::now_utc().unix_timestamp_nanos() + ), + }; + let mut admin_options = ConnectOptions::new(url.clone()); + admin_options.max_connections(2).sqlx_logging(false); + let admin = Database::connect(admin_options) + .await + .expect("connect PostgreSQL observer"); + admin + .execute_unprepared(&format!("CREATE SCHEMA {}", schema.name)) + .await + .unwrap(); + let connect = |label: &str| { + let app = format!("{}_{}", schema.name, label); + let mut options = ConnectOptions::new(format!( + "{}{}application_name={app}", + url, + if url.contains('?') { "&" } else { "?" } + )); + options + .max_connections(1) + .sqlx_logging(false) + .set_schema_search_path(schema.name.clone()); + (app, options) + }; + let (first_name, first_options) = connect("first"); + let (second_name, second_options) = connect("second"); + let first_conn = Database::connect(first_options).await.unwrap(); + Migrator::up(&first_conn, None).await.unwrap(); + let second_conn = Database::connect(second_options).await.unwrap(); + let first = SpaceDatabase::new( + first_conn.clone(), + MemoryStore::default(), + StaticSecret::new(vec![0; 32]).unwrap(), + ) + .await + .unwrap(); + let second = SpaceDatabase::new( + second_conn, + MemoryStore::default(), + StaticSecret::new(vec![0; 32]).unwrap(), + ) + .await + .unwrap(); + + // R and S sort before either target. Both transactions reach the held + // issuer rows: the unfixed path already holds SHARE on the opposite + // intermediate, whereas the fixed path blocks before taking that SHARE. + let mut identities: Vec<(u8, String)> = (1..=8).map(|seed| (seed, signer(seed).1)).collect(); + identities.sort_by(|left, right| left.1.cmp(&right.1)); + let (_, r) = &identities[0]; + let (_, s) = &identities[1]; + let (a_seed, a) = &identities[2]; + let (b_seed, b) = &identities[3]; + assert!(r < a && s < b && a != b); + let now = OffsetDateTime::now_utc().unix_timestamp(); + let a_root = persist_grant( + &first_conn, + &signed_grant(*a_seed, b, a, None, "a-root", now), + ) + .await; + let a_tip = persist_grant( + &first_conn, + &signed_grant(*b_seed, r, a, Some(&a_root), "a-tip", now), + ) + .await; + let b_root = persist_grant( + &first_conn, + &signed_grant(*b_seed, a, b, None, "b-root", now), + ) + .await; + let b_tip = persist_grant( + &first_conn, + &signed_grant(*a_seed, s, b, Some(&b_root), "b-tip", now), + ) + .await; + let a_request = request(a, r, a_tip, "revoke-a", now); + let b_request = request(b, s, b_tip, "revoke-b", now); + + // Demonstrate both independently valid, signed recovery paths before + // arranging any waits. A Forbidden caused by fixture drift is not a red. + for req in [&a_request, &b_request] { + let tx = first_conn.begin().await.unwrap(); + let mut expiry = None; + assert!( + recovery_authority::is_authorized( + &tx, + req, + OffsetDateTime::now_utc(), + None, + &mut expiry + ) + .await + .unwrap(), + "signed recovery grant must be valid before the lock schedule" + ); + assert!(expiry.is_some()); + tx.rollback().await.unwrap(); + } + for did in [a, b, r, s] { + key_revocation::target::ActiveModel { + target_did: Set(did.clone()), + } + .insert(&first_conn) + .await + .unwrap(); + } + let holder: DatabaseTransaction = admin.begin().await.unwrap(); + let held = holder + .query_one(Statement::from_string( + DbBackend::Postgres, + format!( + "SELECT count(*) AS n FROM \ + (SELECT target_did FROM {}.key_revocation_target \ + WHERE target_did IN ('{r}', '{s}') ORDER BY target_did FOR UPDATE) AS held", + schema.name, + ), + )) + .await + .unwrap() + .unwrap(); + let held_count: i64 = held.try_get("", "n").unwrap(); + assert_eq!(held_count, 2, "test transaction must lock both issuers"); + let pid = holder + .query_one(Statement::from_string( + DbBackend::Postgres, + "SELECT pg_backend_pid() AS pid".to_owned(), + )) + .await + .unwrap() + .unwrap(); + let holder_pid: i32 = pid.try_get("", "pid").unwrap(); + let first_job = tokio::spawn(async move { first.revoke_key(a_request).await }); + let second_job = tokio::spawn(async move { second.revoke_key(b_request).await }); + let reached = wait_for_both_at_update(&admin, [&first_name, &second_name], holder_pid).await; + holder.rollback().await.expect("release issuer row locks"); + if !reached { + first_job.abort(); + second_job.abort(); + panic!("both recovery revokes must reach their ordered exclusive target/issuer row locks"); + } + let mut committed = 0; + for job in [first_job, second_job] { + let result = tokio::time::timeout(Duration::from_secs(12), job) + .await + .expect("recovery revoke must finish without a PostgreSQL lock cycle") + .expect("revoke task must not panic"); + assert!( + matches!(&result, Ok(_) | Err(KeyRevokeError::Forbidden)), + "concurrent recovery revoke returned a database/deadlock error: {result:?}" + ); + if result.is_ok() { + committed += 1; + } + } + assert!( + committed >= 1, + "at least one live recovery revoke must commit" + ); + first_conn.close().await.unwrap(); + admin.close().await.unwrap(); +} diff --git a/tinycloud-node-server/Cargo.toml b/tinycloud-node-server/Cargo.toml index 67c23011..928e7ec2 100644 --- a/tinycloud-node-server/Cargo.toml +++ b/tinycloud-node-server/Cargo.toml @@ -110,7 +110,7 @@ path = "../tinycloud-auth/" [dev-dependencies.tinycloud-core] path = "../tinycloud-core/" default-features = false -features = ["sqlite", "postgres", "mysql", "tokio", "verification-count-test-hook"] +features = ["sqlite", "postgres", "mysql", "tokio", "verification-count-test-hook", "historical-invocation-test-clock"] [dev-dependencies] # Parse production Rust items in the DID resolver source guard (tests only). diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 0b89d402..97f0229f 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -8457,7 +8457,15 @@ mod tests { issued + Duration::seconds(301), ) .is_err()); - let db = Database::connect("sqlite::memory:").await.unwrap(); + let mut db = Database::connect("sqlite::memory:").await.unwrap(); + // The measured-head fixture executes 68 SQL statements for a policy + // descendant KV read and 23 for a signed GET. A signed-root cutoff + // must reuse its already-loaded root rows, not add read-path I/O. + let query_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = std::sync::Arc::clone(&query_count); + db.set_metric_callback(move |_| { + counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + }); let delivery_config = ShareEmailConfig { enabled: true, target_origin: "https://node.example".into(), @@ -9149,6 +9157,87 @@ mod tests { assert_eq!(live_challenge.status(), Status::Ok); assert!(live_challenge.into_json::().await.unwrap()["challengeId"].is_string()); + // The owner grant is already registered and the content is seeded: + // exercise signed URLs before either its signer or its holder key + // is cut off, independently of the later policy-session mint. + let owner_grant_cid: tinycloud_auth::ipld_core::cid::Cid = owner_delegation_cid + .as_deref() + .expect("key-cutoff owner has a registered content grant") + .parse()?; + let signed_request = json!({ + "space": content_space.to_string(), + "path": "shares/tc-470/document.txt", + }) + .to_string(); + let owner_signed_invocation = |nonce: &str| -> anyhow::Result { + Ok(make_invocation( + [( + content_resource.clone(), + ["tinycloud.kv/get".parse::()?], + )], + &owner_grant_cid, + &owner_jwk, + &owner_vm, + (OffsetDateTime::now_utc() + Duration::seconds(30)).unix_timestamp() as f64, + InvocationOptions { + nonce: Some(nonce.into()), + ..InvocationOptions::default() + }, + )? + .encode()?) + }; + let live_signed = client + .post("/signed/kv") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + owner_signed_invocation("tc1149-key-cutoff-signed-before")?, + )) + .body(signed_request.clone()) + .dispatch() + .await; + let live_status = live_signed.status(); + let live_body = live_signed.into_string().await.unwrap_or_default(); + assert_eq!( + live_status, + Status::Ok, + "live signed URL before key revoke: {live_body}" + ); + let signed_url = serde_json::from_str::(&live_body)?["url"] + .as_str() + .expect("signed URL") + .to_owned(); + let live_read = client.get(&signed_url).dispatch().await; + assert_eq!(live_read.status(), Status::Ok); + let etag = live_read + .headers() + .get_one("ETag") + .expect("live signed GET ETag") + .to_owned(); + assert_eq!( + live_read.into_bytes().await.unwrap_or_default(), + b"tc-470-real-content" + ); + for (label, header, expected) in [ + ( + "range", + rocket::http::Header::new("Range", "bytes=0-1"), + Status::PartialContent, + ), + ( + "304", + rocket::http::Header::new("If-None-Match", etag.clone()), + Status::NotModified, + ), + ] { + let response = client.get(&signed_url).header(header).dispatch().await; + assert_eq!( + response.status(), + expected, + "live {label} signed GET before key revoke" + ); + } + let (target_did, target_vm, target_jwk) = if policy_owner == PolicyOwner::RegistrarKeyCutoff { (&owner_did, &owner_vm, &owner_jwk) @@ -9213,6 +9302,40 @@ mod tests { serde_json::from_str::(&revoked_body)?["revoked"], true ); + let old_ticket = client.get(&signed_url).dispatch().await; + assert_eq!( + old_ticket.status(), + Status::Forbidden, + "pre-revoke signed URL after owner or ancestor key cutoff" + ); + for (label, header) in [ + ("range", rocket::http::Header::new("Range", "bytes=0-1")), + ("304", rocket::http::Header::new("If-None-Match", etag)), + ] { + let response = client.get(&signed_url).header(header).dispatch().await; + assert_eq!( + response.status(), + Status::Forbidden, + "{label} signed GET after key revoke" + ); + } + let fresh_signed = client + .post("/signed/kv") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + owner_signed_invocation("tc1149-key-cutoff-fresh-signed-after")?, + )) + .body(signed_request) + .dispatch() + .await; + let status = fresh_signed.status(); + let body = fresh_signed.into_string().await.unwrap_or_default(); + assert_eq!( + status, + Status::Unauthorized, + "fresh signed URL after key revoke: {body}" + ); for root_field in ["policyRootCid", "enforcementRootCid"] { let cid = registered[root_field] .as_str() @@ -9993,6 +10116,7 @@ mod tests { .sign(Algorithm::EdDSA, &reader_b_jwk)? .encode()?) }; + let kv_queries_before = query_count.load(std::sync::atomic::Ordering::Relaxed); let grandchild_read = client .post("/invoke") .header(rocket::http::Header::new( @@ -10006,6 +10130,11 @@ mod tests { grandchild_read.into_bytes().await.unwrap_or_default(), b"tc-470-real-content" ); + assert_eq!( + query_count.load(std::sync::atomic::Ordering::Relaxed) - kv_queries_before, + 68, + "ordinary KV read gained SQL round trips against the measured head" + ); // /signed/kv runs the same policy gate as /invoke. A policy // invocation within the 60-second cap mints a working URL; a longer @@ -10032,12 +10161,18 @@ mod tests { .as_str() .unwrap() .to_owned(); - let signed_read = client.get(signed_url).dispatch().await; + let signed_queries_before = query_count.load(std::sync::atomic::Ordering::Relaxed); + let signed_read = client.get(signed_url.clone()).dispatch().await; assert_eq!(signed_read.status(), Status::Ok); assert_eq!( signed_read.into_bytes().await.unwrap_or_default(), b"tc-470-real-content" ); + assert_eq!( + query_count.load(std::sync::atomic::Ordering::Relaxed) - signed_queries_before, + 23, + "signed GET gained SQL round trips against the measured head" + ); let long_signed = client .post("/signed/kv") .header(ContentType::JSON) @@ -10045,7 +10180,7 @@ mod tests { "Authorization", reader_b_invocation(90, "tc529-grandchild-long-signed-url")?, )) - .body(signed_request) + .body(signed_request.clone()) .dispatch() .await; assert_eq!(long_signed.status(), Status::Forbidden); @@ -10165,6 +10300,37 @@ mod tests { b"descendant-before" ); + let live_ticket_before_owner_grant_revoke = if owner_delegation_cid.is_some() { + let minted = client + .post("/signed/kv") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + reader_b_invocation(30, "tc1149-live-signed-url-before-owner-grant-revoke")?, + )) + .body(signed_request.clone()) + .dispatch() + .await; + let status = minted.status(); + let body = minted.into_string().await.unwrap_or_default(); + assert_eq!( + status, + Status::Ok, + "current signed URL before owner grant revoke: {body}" + ); + let url = serde_json::from_str::(&body)?["url"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing current signed URL"))? + .to_owned(); + assert_eq!( + client.get(&url).dispatch().await.status(), + Status::Ok, + "current ticket must redeem before owner grant revoke" + ); + Some(url) + } else { + None + }; if let Some(owner_delegation_cid) = owner_delegation_cid.as_deref() { // Revoking the delegation the policy owner held stops new // sessions, although the policy and its roots are untouched. @@ -10205,6 +10371,32 @@ mod tests { "owner session revoke: {revoked_body}" ); + let old_ticket = client + .get(live_ticket_before_owner_grant_revoke.expect("live owner-grant ticket")) + .dispatch() + .await; + assert_eq!( + old_ticket.status(), + Status::Forbidden, + "pre-revoke signed URL must not redeem after owner proof revoke" + ); + let fresh_signed = client + .post("/signed/kv") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + reader_b_invocation(30, "tc1149-fresh-signed-url-after-owner-proof-revoke")?, + )) + .body(signed_request.clone()) + .dispatch() + .await; + let status = fresh_signed.status(); + let body = fresh_signed.into_string().await.unwrap_or_default(); + assert_eq!( + status, + Status::Unauthorized, + "fresh signed URL after proof revoke: {body}" + ); // The revoke ACK is the cutoff. Old signatures and registered // descendants must no longer authorize reads, writes, or children. for (key, principal, parent, label) in [ @@ -10300,44 +10492,49 @@ mod tests { // Prepare a fresh, unused admission while both roots are live. It // cannot be created after root revoke because challenge itself closes. let fresh_root_mint = if space_owner { - let response = client - .post("/policy/v3/challenges") - .header(ContentType::JSON) - .body( - json!({ - "policyCid": policy_cid, - "recipientDid": holder_did, - "requestedCapabilities": requested, - }) - .to_string(), - ) - .dispatch() - .await; - assert_eq!(response.status(), Status::Ok); - let challenge: Value = response.into_json().await.unwrap(); - let mut signed = presentation.clone(); - signed["schema"] = json!(CREDENTIAL_PRESENTATION_V4_SCHEMA); - signed["jti"] = json!("presentation-tc1149-after-root-revoke"); - signed["challengeId"] = challenge["challengeId"].clone(); - signed["nonce"] = challenge["nonce"].clone(); - signed - .as_object_mut() - .unwrap() - .remove("credentialSpaceOwnerDid"); - signed["issuedAt"] = json!(format_time(OffsetDateTime::now_utc())); - signed["expiresAt"] = json!(format_time( - OffsetDateTime::now_utc() + Duration::seconds(45) - )); - let mut unsigned = signed.clone(); - unsigned.as_object_mut().unwrap().remove("signature"); - let mut preimage = CREDENTIAL_PRESENTATION_V4_DOMAIN.to_vec(); - preimage.extend_from_slice(&canonical_json_value(&unsigned)); - signed["signature"]["value"] = json!(encode_config( - holder_key.sign(&Sha256::digest(preimage)), - URL_SAFE_NO_PAD - )); - Some( - json!({ + let mut pending = None; + for (label, mint_before_revoke) in [("live", true), ("after", false)] { + let response = client + .post("/policy/v3/challenges") + .header(ContentType::JSON) + .body( + json!({ + "policyCid": policy_cid, + "recipientDid": holder_did, + "requestedCapabilities": requested, + }) + .to_string(), + ) + .dispatch() + .await; + assert_eq!( + response.status(), + Status::Ok, + "fresh root challenge: {label}" + ); + let challenge: Value = response.into_json().await.unwrap(); + let mut signed = presentation.clone(); + signed["schema"] = json!(CREDENTIAL_PRESENTATION_V4_SCHEMA); + signed["jti"] = json!(format!("presentation-tc1149-{label}-root-revoke")); + signed["challengeId"] = challenge["challengeId"].clone(); + signed["nonce"] = challenge["nonce"].clone(); + signed + .as_object_mut() + .unwrap() + .remove("credentialSpaceOwnerDid"); + signed["issuedAt"] = json!(format_time(OffsetDateTime::now_utc())); + signed["expiresAt"] = json!(format_time( + OffsetDateTime::now_utc() + Duration::seconds(45) + )); + let mut unsigned = signed.clone(); + unsigned.as_object_mut().unwrap().remove("signature"); + let mut preimage = CREDENTIAL_PRESENTATION_V4_DOMAIN.to_vec(); + preimage.extend_from_slice(&canonical_json_value(&unsigned)); + signed["signature"]["value"] = json!(encode_config( + holder_key.sign(&Sha256::digest(preimage)), + URL_SAFE_NO_PAD + )); + let body = json!({ "policyCid": policy_cid, "challengeId": challenge["challengeId"], "nonce": challenge["nonce"], @@ -10345,8 +10542,26 @@ mod tests { "credential": credential, "presentation": signed, }) - .to_string(), - ) + .to_string(); + if mint_before_revoke { + let minted = client + .post("/policy/v3/delegations") + .header(ContentType::JSON) + .body(body) + .dispatch() + .await; + let status = minted.status(); + let response_body = minted.into_string().await.unwrap_or_default(); + assert_eq!( + status, + Status::Ok, + "fresh mint before root revoke: {response_body}" + ); + } else { + pending = Some(body); + } + } + pending } else { None }; @@ -10358,6 +10573,61 @@ mod tests { } else { "policyRootCid" }; + // Earlier test writes may have changed the KV object's content after + // the first ticket was minted. Issue and redeem a CURRENT ticket so a + // post-ACK 403 proves revocation rather than an unrelated stale-hash + // precondition failure. + let live_ticket_before_root = if space_owner { + let minted = client + .post("/signed/kv") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + reader_b_invocation( + 30, + &format!("tc1149-live-signed-url-before-{root_field}-revoke"), + )?, + )) + .body(signed_request.clone()) + .dispatch() + .await; + let status = minted.status(); + let body = minted.into_string().await.unwrap_or_default(); + assert_eq!( + status, + Status::Ok, + "current signed URL before {root_field} revoke: {body}" + ); + let url = serde_json::from_str::(&body)?["url"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing current signed URL"))? + .to_owned(); + let still_live_status = client.get(&url).dispatch().await.status(); + assert_eq!( + still_live_status, + Status::Ok, + "current ticket must redeem before revoke" + ); + Some(url) + } else { + None + }; + let status_path = format!( + "/policy/v3/status/{}", + registered[root_field].as_str().unwrap() + ); + if space_owner { + let active_status = client.get(&status_path).dispatch().await; + assert_eq!( + active_status.status(), + Status::Ok, + "root status before revoke" + ); + assert_eq!( + active_status.into_json::().await.unwrap()["state"], + "active" + ); + } let revoked_root_cid = registered[root_field] .as_str() .ok_or_else(|| anyhow::anyhow!("missing {root_field}"))? @@ -10437,8 +10707,71 @@ mod tests { let revoke_status = revoke_response.status(); let revoke_body = revoke_response.into_string().await.unwrap_or_default(); assert_eq!(revoke_status, Status::Ok, "SDK root revoke: {revoke_body}"); + if space_owner { + let public_status = client.get(&status_path).dispatch().await; + assert_eq!( + public_status.status(), + Status::Ok, + "root checkpoint after revoke" + ); + let public_status: Value = public_status.into_json().await.unwrap(); + assert_eq!(public_status["state"], "revoked"); + if signed_root_revoke { + assert!( + public_status["revocation"].is_object(), + "signed root revocation checkpoint" + ); + } + } + + if let Some(signed_url) = live_ticket_before_root { + let old_ticket = client.get(signed_url).dispatch().await; + assert_eq!( + old_ticket.status(), + Status::Forbidden, + "pre-revoke signed URL must not redeem after {root_field} revoke" + ); + let fresh_signed = client + .post("/signed/kv") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + reader_b_invocation( + 30, + &format!("tc1149-fresh-signed-url-after-{root_field}-revoke"), + )?, + )) + .body(signed_request) + .dispatch() + .await; + let status = fresh_signed.status(); + let body = fresh_signed.into_string().await.unwrap_or_default(); + assert_eq!( + status, + Status::Unauthorized, + "fresh signed URL after {root_field} revoke: {body}" + ); + } if space_owner { + let challenge = client + .post("/policy/v3/challenges") + .header(ContentType::JSON) + .body( + json!({ + "policyCid": policy_cid, + "recipientDid": holder_did, + "requestedCapabilities": requested, + }) + .to_string(), + ) + .dispatch() + .await; + assert_eq!( + challenge.status(), + Status::Forbidden, + "{root_field} fresh challenge after revoke" + ); for (key, principal, parent, label) in [ (&holder_jwk, holder_did.as_str(), s0_cid, "s0"), ( diff --git a/tinycloud-node-server/src/routes/hooks.rs b/tinycloud-node-server/src/routes/hooks.rs index 648e9041..f695c7f1 100644 --- a/tinycloud-node-server/src/routes/hooks.rs +++ b/tinycloud-node-server/src/routes/hooks.rs @@ -988,15 +988,22 @@ mod tests { use rocket::http::Status; use tempfile::TempDir; use tinycloud_auth::{ - authorization::{make_invocation, InvocationOptions}, + authorization::{make_invocation, HeaderEncode, InvocationOptions, TinyCloudDelegation}, ipld_core::cid::Cid, multihash_codetable::{Code, MultihashDigest}, resolver::DID_METHODS, resource::{Path, ResourceId, Service, SpaceId}, siwe_recap::Ability, - ssi::{dids::DIDBuf, jwk::JWK}, + ssi::{ + claims::jwt::NumericDate, + dids::DIDBuf, + jwk::{Algorithm, JWK}, + ucan::Payload, + }, + ucan_capabilities_object::Capabilities, }; use tinycloud_core::{ + encryption_network::{EncryptionService, LocalOneOfOneBackend}, keys::StaticSecret, sea_orm::{ConnectOptions, Database}, storage::either::Either, @@ -2141,6 +2148,13 @@ mod tests { } let node = StaticSecret::new(vec![5u8; 32]).unwrap(); + let encryption = EncryptionService::new_with_node_keypair( + db.clone(), + node.node_keypair(), + std::sync::Arc::new(LocalOneOfOneBackend::new(ColumnEncryption::new( + node.derive_key(b"tinycloud/encryption/network-seal"), + ))), + ); let rocket = rocket::build() .mount( "/", @@ -2149,7 +2163,8 @@ mod tests { hook_events, create_webhook, list_webhooks, - delete_webhook + delete_webhook, + crate::key_revoke::revoke_key ], ) .manage(tinycloud.clone()) @@ -2158,6 +2173,7 @@ mod tests { .manage(PolicyV3Runtime::new(db.clone(), node.node_did(), node)) .manage(InvocationReplayCache::new(db.clone())) .manage(policy) + .manage(encryption) .manage(Config::default()); Ok(HookRouteFixture { @@ -2456,6 +2472,173 @@ mod tests { Ok(()) } + #[tokio::test] + async fn webhook_crud_refuses_revoked_session_key_after_revoke_ack() -> Result<()> { + let fixture = hook_route_fixture(&[ + ("hooks", "kv/documents", "tinycloud.hooks/register"), + ("hooks", "kv/documents", "tinycloud.hooks/list"), + ("hooks", "kv/documents", "tinycloud.hooks/unregister"), + ]) + .await?; + let scope = fixture.hooks("kv/documents")?; + let (status, body) = fixture + .register( + fixture.session_header(&[(scope.clone(), &["tinycloud.hooks/register"])])?, + &fixture.space, + ) + .await; + assert_eq!(status, Status::Ok, "live webhook POST: {body}"); + let id = serde_json::from_str::(&body)?["id"] + .as_str() + .expect("created webhook id") + .to_string(); + let (status, body) = fixture + .list(fixture.session_header(&[(scope.clone(), &["tinycloud.hooks/list"])])?) + .await; + assert_eq!(status, Status::Ok, "live webhook GET: {body}"); + assert_eq!( + serde_json::from_str::(&body)?[0]["id"], + id + ); + // DELETE also needs a live, authorized control; keep the original + // subscription active for the post-ACK denial. + let (status, body) = fixture + .register( + fixture.session_header(&[(scope.clone(), &["tinycloud.hooks/register"])])?, + &fixture.space, + ) + .await; + assert_eq!(status, Status::Ok, "second live webhook POST: {body}"); + let disposable_id = serde_json::from_str::(&body)?["id"] + .as_str() + .expect("second webhook id") + .to_string(); + assert_ne!(disposable_id, id); + let (status, body) = fixture + .unregister( + fixture.session_header(&[(scope.clone(), &["tinycloud.hooks/unregister"])])?, + &disposable_id, + ) + .await; + assert_eq!(status, Status::NoContent, "live webhook DELETE: {body}"); + + let target_did = did_of(&fixture.session.1); + let scope_json = serde_json::json!({"kind": "global"}); + let mut capabilities = Capabilities::::new(); + capabilities.with_action( + format!("urn:tinycloud:key:{target_did}").parse()?, + "tinycloud.key/revoke".parse()?, + [serde_json::from_value(serde_json::json!({ + "version": 1, "targetDid": target_did, "scope": scope_json + }))?], + ); + let now = OffsetDateTime::now_utc(); + let node_did = fixture + .client + .rocket() + .state::() + .expect("mounted node encryption service") + .node_did(); + let authorization = TinyCloudDelegation::Ucan(Box::new( + Payload { + issuer: fixture.session.1.parse()?, + audience: node_did.parse()?, + not_before: Some(NumericDate::try_from_seconds( + (now - time::Duration::seconds(1)).unix_timestamp() as f64, + )?), + expiration: NumericDate::try_from_seconds( + (now + time::Duration::seconds(45)).unix_timestamp() as f64, + )?, + nonce: Some("webhook-crud-self-revoke".into()), + facts: Some(Vec::new()), + proof: Vec::new(), + attenuation: capabilities, + } + .sign(Algorithm::EdDSA, &fixture.session.0)?, + )) + .encode()?; + let revoked = fixture + .client + .post("/keys/revoke") + .header(rocket::http::ContentType::JSON) + .body( + serde_json::json!({ + "targetDid": target_did, "scope": scope_json, "authorization": authorization + }) + .to_string(), + ) + .dispatch() + .await; + let revoked_status = revoked.status(); + let revoked_body = revoked.into_string().await.unwrap_or_default(); + assert_eq!(revoked_status, Status::Ok, "key revoke ACK: {revoked_body}"); + assert_eq!( + serde_json::from_str::(&revoked_body)?["revoked"], + true + ); + + let (status, body) = fixture + .register( + fixture.session_header(&[(scope.clone(), &["tinycloud.hooks/register"])])?, + &fixture.space, + ) + .await; + assert_eq!(status, Status::Unauthorized, "revoked webhook POST: {body}"); + let (status, body) = fixture + .list(fixture.session_header(&[(scope.clone(), &["tinycloud.hooks/list"])])?) + .await; + assert_eq!(status, Status::Unauthorized, "revoked webhook GET: {body}"); + let (status, body) = fixture + .unregister( + fixture.session_header(&[(scope.clone(), &["tinycloud.hooks/unregister"])])?, + &id, + ) + .await; + assert_eq!( + status, + Status::Unauthorized, + "revoked webhook DELETE: {body}" + ); + assert!( + fixture + .tinycloud + .find_hook_subscription(&id) + .await? + .unwrap() + .active, + "denied DELETE must leave the subscription active" + ); + + let owner_header = |ability| { + signed_header( + &fixture.owner.0, + &fixture.owner.1, + vec![], + &[(scope.clone(), &[ability])], + ) + }; + let (status, body) = fixture + .register(owner_header("tinycloud.hooks/register")?, &fixture.space) + .await; + assert_eq!(status, Status::Ok, "unrevoked signer POST: {body}"); + let control_id = serde_json::from_str::(&body)?["id"] + .as_str() + .expect("control webhook id") + .to_string(); + let (status, body) = fixture.list(owner_header("tinycloud.hooks/list")?).await; + assert_eq!(status, Status::Ok, "unrevoked signer GET: {body}"); + assert!(serde_json::from_str::(&body)? + .as_array() + .unwrap() + .iter() + .any(|row| row["id"] == control_id)); + let (status, body) = fixture + .unregister(owner_header("tinycloud.hooks/unregister")?, &control_id) + .await; + assert_eq!(status, Status::NoContent, "unrevoked signer DELETE: {body}"); + Ok(()) + } + #[tokio::test] async fn refuses_hook_tickets_without_a_verified_delegation() -> Result<()> { let fixture = @@ -2965,6 +3148,19 @@ mod tests { denied.into_string().await.as_deref(), Some("authorization-key-revoked") ); + let (status, body) = fixture + .ticket( + fixture.session_header(&claim)?, + &fixture.space, + "kv", + "documents", + ) + .await; + assert_eq!( + status, + Status::Unauthorized, + "new ticket after key revoke: {body}" + ); let control = fixture .client .get(format!("/hooks/events?ticket={}", independent.ticket)) @@ -3026,6 +3222,20 @@ mod tests { denied.into_string().await.as_deref(), Some("authorization-key-revoked") ); + let owner_header = signed_header( + &fixture.owner.0, + &fixture.owner.1, + vec![], + &[(fixture.hooks("kv/documents")?, SUBSCRIBE)], + )?; + let (status, body) = fixture + .ticket(owner_header, &fixture.space, "kv", "documents") + .await; + assert_eq!( + status, + Status::Unauthorized, + "new root ticket after key revoke: {body}" + ); let control = independent .client .get(format!("/hooks/events?ticket={}", control_ticket.ticket)) diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index 0d493b71..bda28d6b 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -4995,6 +4995,20 @@ mod tests { Status::Conflict, "exact invocation replay must be rejected" ); + let live = client + .post("/invoke") + .header(Header::new( + "Authorization", + make_auth_header("urn:uuid:00000000-0000-4000-8000-000000000003")?, + )) + .header(ContentType::JSON) + .body(serde_json::to_string(&SqlRequest::ExecuteStatement { + name: "get_val".to_string(), + params: vec![], + })?) + .dispatch() + .await; + assert_eq!(live.status(), Status::Ok, "fresh SQL read before revoke"); let revocation_hash = tinycloud_core::hash::hash(b"w1-rocket-http-revocation"); revo_model::ActiveModel { diff --git a/tinycloud-node-server/src/share_v2.rs b/tinycloud-node-server/src/share_v2.rs index 2b2b8ec5..54345c22 100644 --- a/tinycloud-node-server/src/share_v2.rs +++ b/tinycloud-node-server/src/share_v2.rs @@ -83,10 +83,9 @@ fn legacy_v2_creation_open() -> bool { .expect("valid v2 creation cutoff") } -fn legacy_v2_reads_open() -> bool { - OffsetDateTime::now_utc() - <= OffsetDateTime::parse(crate::policy_v3::LAST_V2_READ_AT, &Rfc3339) - .expect("valid v2 read cutoff") +fn legacy_v2_reads_open(now: OffsetDateTime) -> bool { + now <= OffsetDateTime::parse(crate::policy_v3::LAST_V2_READ_AT, &Rfc3339) + .expect("valid v2 read cutoff") } fn legacy_expiry_allowed(expiry: OffsetDateTime) -> bool { @@ -173,6 +172,8 @@ pub struct ShareV2Runtime { migration_ready: bool, graph_ready: bool, verifier: Option, + #[cfg(test)] + evaluation_time: Option, } /// Probes that the `owner_share_policy` table is queryable. @@ -234,6 +235,24 @@ fn report_probe(check: &str, result: Result<(), DbErr>) -> bool { } impl ShareV2Runtime { + fn evaluation_time(&self) -> OffsetDateTime { + #[cfg(test)] + if let Some(now) = self.evaluation_time { + return now; + } + OffsetDateTime::now_utc() + } + #[cfg(test)] + fn ucan_validation_time(&self) -> Option { + self.evaluation_time + .map(|now| now.unix_timestamp_nanos() as f64 / 1_000_000_000.0) + } + + #[cfg(not(test))] + fn ucan_validation_time(&self) -> Option { + None + } + fn static_ready(&self) -> bool { self.config.enabled && TargetOrigin::parse(self.config.target_origin.clone()).is_ok() @@ -481,6 +500,8 @@ pub async fn compose( migration_ready, graph_ready, verifier, + #[cfg(test)] + evaluation_time: None, }; let probe = runtime.clone(); tokio::spawn(async move { @@ -830,8 +851,14 @@ pub async fn register_policy( ) .map_err(|_| error(Status::BadRequest, "policy_registration_invalid"))?, }; - validate_policy(&policy, &request, &runtime.config, &runtime.enforcer_did) - .map_err(|_| error(Status::Forbidden, "policy_registration_invalid"))?; + validate_policy( + &policy, + &request, + &runtime.config, + &runtime.enforcer_did, + runtime.evaluation_time(), + ) + .map_err(|_| error(Status::Forbidden, "policy_registration_invalid"))?; if request.policy.cid == request.owner_delegation.cid || request.policy.cid == request.enforcement_delegation.cid || request.owner_delegation.cid == request.enforcement_delegation.cid @@ -1115,6 +1142,7 @@ fn validate_policy( request: &RegisterRequest, config: &ShareEmailConfig, enforcer_did: &str, + now: OffsetDateTime, ) -> Result<(), ()> { if envelope.domain != POLICY_DOMAIN { return Err(()); @@ -1138,7 +1166,7 @@ fn validate_policy( } let policy_expiry = OffsetDateTime::parse(&policy.expires_at, &Rfc3339).map_err(|_| ())?; if timestamp(policy_expiry) != policy.expires_at - || policy_expiry <= OffsetDateTime::now_utc() + || policy_expiry <= now || !legacy_expiry_allowed(policy_expiry) { return Err(()); @@ -1216,10 +1244,7 @@ fn validate_policy( return Err(()); } let expiry = OffsetDateTime::parse(&policy.expires_at, &Rfc3339).map_err(|_| ())?; - if timestamp(expiry) != policy.expires_at - || expiry <= OffsetDateTime::now_utc() - || !legacy_expiry_allowed(expiry) - { + if timestamp(expiry) != policy.expires_at || expiry <= now || !legacy_expiry_allowed(expiry) { return Err(()); } Ok(()) @@ -1275,17 +1300,17 @@ async fn verify_owner_delegation( return Err(()); } let delegation = TinyCloudDelegation::from_bytes(&bytes).map_err(|_| ())?; - verify_delegation_signature(&delegation).await?; + verify_delegation_signature(&delegation, runtime.ucan_validation_time()).await?; let info = DelegationInfo::try_from(delegation).map_err(|_| ())?; if info.delegation_mode == DelegationMode::Terminal || !did_principal_matches(&info.delegator, &policy.owner_did) || !did_principal_matches(&info.delegate, &policy.share_key_did) || row .expiry - .is_none_or(|expiry| expiry <= OffsetDateTime::now_utc()) + .is_none_or(|expiry| expiry <= runtime.evaluation_time()) || row .not_before - .is_some_and(|not_before| not_before > OffsetDateTime::now_utc()) + .is_some_and(|not_before| not_before > runtime.evaluation_time()) { return Err(()); } @@ -1367,13 +1392,18 @@ fn owner_delegation_abilities_match( true } -async fn verify_delegation_signature(delegation: &TinyCloudDelegation) -> Result<(), ()> { +async fn verify_delegation_signature( + delegation: &TinyCloudDelegation, + evaluation_time: Option, +) -> Result<(), ()> { match delegation { TinyCloudDelegation::Ucan(ucan) => { tinycloud_core::did_resolution::verify_ucan_signature(ucan) .await .map_err(|_| ())?; - ucan.payload().validate_time(None).map_err(|_| ())?; + ucan.payload() + .validate_time(evaluation_time) + .map_err(|_| ())?; } TinyCloudDelegation::Cacao(cacao) => { cacao.verify().await.map_err(|_| ())?; @@ -1933,10 +1963,11 @@ async fn registered_policy( }, &runtime.config, &runtime.enforcer_did, + runtime.evaluation_time(), ) .map_err(|_| ())?; let expiry = OffsetDateTime::parse(&row.expires_at, &Rfc3339).map_err(|_| ())?; - if expiry <= OffsetDateTime::now_utc() { + if expiry <= runtime.evaluation_time() { return Err(()); } let matcher = match policy.policy.recipient_matcher.kind.as_str() { @@ -2320,7 +2351,7 @@ fn verify_runtime_delegation( ) .map_err(|_| ())?; let session_expiry = OffsetDateTime::parse(session_expires_at, &Rfc3339).map_err(|_| ())?; - if expiry <= OffsetDateTime::now_utc() || expiry > session_expiry { + if expiry <= runtime.evaluation_time() || expiry > session_expiry { return Err(()); } Ok(()) @@ -2883,7 +2914,7 @@ pub async fn invoke_v2( if !runtime.live().await { return Err(error(Status::ServiceUnavailable, "capability_unavailable")); } - if !legacy_v2_reads_open() { + if !legacy_v2_reads_open(runtime.evaluation_time()) { return Err(error(Status::Gone, "legacy_v2_reads_closed")); } let raw = read_body(data).await?; @@ -2894,7 +2925,7 @@ pub async fn invoke_v2( .await .map_err(|_| error(Status::ServiceUnavailable, "capability_unavailable"))? .ok_or_else(|| share_error("invoke_denied"))?; - let now = OffsetDateTime::now_utc(); + let now = runtime.evaluation_time(); if session.revoked_at.is_some() || OffsetDateTime::parse(&session.expires_at, &Rfc3339) .map_err(|_| share_error("invoke_denied"))? @@ -3286,13 +3317,24 @@ pub async fn authorize_delivery_v2( if !runtime.live().await { return Err(error(Status::ServiceUnavailable, "capability_unavailable")); } - invocation_model::verify_and_authorize( - &runtime.conn, - &invocation.0 .0, - OffsetDateTime::now_utc(), - ) - .await - .map_err(|_| error(Status::Unauthorized, "delivery_authorization_invalid"))?; + let now = runtime.evaluation_time(); + #[cfg(test)] + let authorization = match runtime.ucan_validation_time() { + Some(signed_time) => { + invocation_model::verify_and_authorize_with_signed_time( + &runtime.conn, + &invocation.0 .0, + now, + signed_time, + ) + .await + } + None => invocation_model::verify_and_authorize(&runtime.conn, &invocation.0 .0, now).await, + }; + #[cfg(not(test))] + let authorization = + invocation_model::verify_and_authorize(&runtime.conn, &invocation.0 .0, now).await; + authorization.map_err(|_| error(Status::Unauthorized, "delivery_authorization_invalid"))?; let raw = read_body(data).await?; let request: V2DeliveryRequest = serde_json::from_slice(&raw) .map_err(|_| error(Status::BadRequest, "delivery_authorization_invalid"))?; @@ -3331,7 +3373,7 @@ pub async fn authorize_delivery_v2( .map_err(|_| share_error("delivery_authorization_invalid"))?; let expires = OffsetDateTime::parse(&request.expires_at, &Rfc3339) .map_err(|_| share_error("delivery_authorization_invalid"))?; - let now = OffsetDateTime::now_utc(); + let now = runtime.evaluation_time(); if expires <= now || expires > now + time::Duration::minutes(5) || decode_canonical_b64(&request.jti, 16).map_or(true, |value| value.len() != 16) @@ -3433,11 +3475,12 @@ mod tests { listener.local_addr().unwrap().port() ); let ucan = signed_ucan(&issuer, &JWK::generate_ed25519().unwrap(), Algorithm::EdDSA); - assert!( - super::verify_delegation_signature(&TinyCloudDelegation::Ucan(Box::new(ucan))) - .await - .is_err() - ); + assert!(super::verify_delegation_signature( + &TinyCloudDelegation::Ucan(Box::new(ucan)), + None + ) + .await + .is_err()); assert_no_connections(&[&listener]).await; } use super::*; @@ -3545,18 +3588,39 @@ mod tests { fn policy_validation_accepts_arbitrary_exact_email_matchers() { let (mut envelope, request, config) = sample_policy(); envelope.policy.recipient_matcher.value = "person+tag@any.example".to_owned(); - assert!(validate_policy(&envelope, &request, &config, "did:key:z6MkEnforcer").is_ok()); + assert!(validate_policy( + &envelope, + &request, + &config, + "did:key:z6MkEnforcer", + OffsetDateTime::now_utc() + ) + .is_ok()); } #[test] fn policy_validation_rejects_path_and_action_widening() { let (mut envelope, request, config) = sample_policy(); envelope.policy.resource.path = "shares/share-1/../other.md".to_owned(); - assert!(validate_policy(&envelope, &request, &config, "did:key:z6MkEnforcer").is_err()); + assert!(validate_policy( + &envelope, + &request, + &config, + "did:key:z6MkEnforcer", + OffsetDateTime::now_utc() + ) + .is_err()); let (mut envelope, request, config) = sample_policy(); envelope.policy.actions.push("tinycloud.kv/list".to_owned()); - assert!(validate_policy(&envelope, &request, &config, "did:key:z6MkEnforcer").is_err()); + assert!(validate_policy( + &envelope, + &request, + &config, + "did:key:z6MkEnforcer", + OffsetDateTime::now_utc() + ) + .is_err()); } #[test] @@ -3564,12 +3628,26 @@ mod tests { let (mut envelope, request, config) = sample_policy(); envelope.policy.resource.path = "shares/other/document.md".to_owned(); envelope.policy.content_source.path = "shares/other/document.md".to_owned(); - assert!(validate_policy(&envelope, &request, &config, "did:key:z6MkEnforcer").is_err()); + assert!(validate_policy( + &envelope, + &request, + &config, + "did:key:z6MkEnforcer", + OffsetDateTime::now_utc() + ) + .is_err()); let (mut envelope, request, config) = sample_policy(); envelope.policy.content_source_digest = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned(); - assert!(validate_policy(&envelope, &request, &config, "did:key:z6MkEnforcer").is_err()); + assert!(validate_policy( + &envelope, + &request, + &config, + "did:key:z6MkEnforcer", + OffsetDateTime::now_utc() + ) + .is_err()); } #[test] diff --git a/tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs b/tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs index 66703c46..09ffe5f9 100644 --- a/tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs +++ b/tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs @@ -126,7 +126,7 @@ async fn signed_alias_consumers(reverse: bool) { assert_ne!(hash(&historical), hash(&canonical)); for bytes in [&historical, &canonical, &unrelated] { let parsed = TinyCloudDelegation::from_bytes(bytes).unwrap(); - verify_delegation_signature(&parsed).await.unwrap(); + verify_delegation_signature(&parsed, None).await.unwrap(); let event = Delegation::from_header_ser::(&encode_config(bytes, base64::URL_SAFE)) .unwrap(); diff --git a/tinycloud-node-server/src/share_v2/tests/share_v2_legacy_key_tests.rs b/tinycloud-node-server/src/share_v2/tests/share_v2_legacy_key_tests.rs index 91c8d99a..151e1d20 100644 --- a/tinycloud-node-server/src/share_v2/tests/share_v2_legacy_key_tests.rs +++ b/tinycloud-node-server/src/share_v2/tests/share_v2_legacy_key_tests.rs @@ -16,12 +16,17 @@ use tinycloud_core::{ types::{Ability, Caveats, SpaceIdWrap}, }; -// Registration and challenge/session creation closed September 30. The -// signed synthetic policy and delegation are inserted as a reconstruction of -// a historically registered policy, not sent to today's closed creation -// route. Reads remain open until January 5; new challenge/mint requests -// continue returning 410. A real pre-cutoff five-minute session cannot still -// be live today, so its short-lived signed binding is reconstructed separately. +// Registration and challenge/session creation closed September 30. This +// signed fixture reconstructs a historical registration and short-lived +// session. A test-only clock should pin evaluation to October 13, 2026 +// 12:00 UTC. Its October 14 expiry is already past under today's real clock; +// reads remain open until January 5, while challenge/mint still return 410. +const HISTORICAL_EVALUATION_AT: &str = "2026-10-13T12:00:00.000Z"; + +fn historical_evaluation_time() -> OffsetDateTime { + OffsetDateTime::parse(HISTORICAL_EVALUATION_AT, &Rfc3339).unwrap() +} + struct HistoricalShare { challenge: Value, session_id: String, @@ -60,7 +65,7 @@ async fn historical_share( "applications".parse().expect("space name"), ); let space_id_string = space_id.to_string(); - let expires_at = "2026-12-28T00:00:00.000Z".to_owned(); + let expires_at = "2026-10-14T00:00:00.000Z".to_owned(); let actions = vec![ "tinycloud.kv/get".to_owned(), "tinycloud.kv/list".to_owned(), @@ -82,7 +87,7 @@ async fn historical_share( .expect("owner verification method"), audience: share_did.parse::().expect("share DID"), not_before: None, - expiration: NumericDate::try_from_seconds(1_798_416_000.0).expect("December 2026"), + expiration: NumericDate::try_from_seconds(1_791_936_000.0).expect("October 14, 2026"), nonce: Some(format!("historical-{share_id}")), facts: Some(Vec::::new()), proof: vec![], @@ -140,7 +145,7 @@ async fn historical_share( issuer: owner_vm.parse::().unwrap(), audience: sender_did.parse::().unwrap(), not_before: None, - expiration: NumericDate::try_from_seconds(1_798_416_000.0).unwrap(), + expiration: NumericDate::try_from_seconds(1_791_936_000.0).unwrap(), nonce: Some(format!("historical-sender-{share_id}")), facts: Some(Vec::::new()), proof: vec![], @@ -273,7 +278,7 @@ async fn historical_share( enforcement_delegation_bytes: Set(runtime .policy_encryption .encrypt(&serde_json::to_vec(&enforcement).unwrap())), - policy_proof: Set(policy_proof), + policy_proof: Set(policy_proof.clone()), policy_digest: Set(b64_digest(&policy_bytes)), matcher_digest: Set(b64_digest(&jcs::canonicalize(matcher))), content_source_digest: Set(source_digest.clone()), @@ -290,15 +295,24 @@ async fn historical_share( expires_at: Set(expires_at.clone()), revoked_at: Set(None), }; - row.insert(&runtime.conn) + let row = row + .insert(&runtime.conn) .await .expect("historical policy registration row"); - let registered = registered_policy(runtime, ®istration_cid, &policy_cid) - .await - .expect("signed historical registration passes real DB and signature checks"); - verify_registered_ancestors(runtime, ®istered, Some(&enforcement)) - .await - .expect("signed owner and enforcement ancestors pass real verification"); + // The live-clock registration/ancestor helpers reject this expired fixture + // before the route assertions. The routes verify its signed ancestors at + // the historical evaluation time once the test clock is injected. + verify_policy_proof(&share_did, &policy_bytes, &policy_proof) + .expect("historically signed policy verifies"); + let envelope: PolicyEnvelope = serde_json::from_value(policy.clone()).unwrap(); + verify_enforcement_delegation(runtime, &envelope, &policy_cid, &enforcement) + .expect("historically signed enforcement verifies"); + let registered = RegisteredPolicy { + row, + envelope, + matcher: TypedRecipientMatcher::ExactEmail("alice@example.com".to_owned()), + expiry: OffsetDateTime::parse(&expires_at, &Rfc3339).unwrap(), + }; let target = policy["policy"]["target"].clone(); let envelope_identity = serde_json::json!({ @@ -338,18 +352,20 @@ async fn historical_share( .unwrap() .insert("requestBodyDigest".into(), Value::String(digest)); let challenge_request: V2ChallengeRequest = serde_json::from_value(challenge.clone()).unwrap(); - verify_outer_envelope( - &challenge_request.outer_envelope, - &challenge_request, - &share_did, - None, + let outer_signature = decode_config( + challenge_request.outer_envelope["signature"]["value"] + .as_str() + .expect("signed outer envelope"), + URL_SAFE_NO_PAD, ) - .expect("historically signed outer envelope verifies"); + .expect("canonical signed outer envelope"); + verify_detached_ed25519(&share_did, &signed_outer, &outer_signature) + .expect("historically signed outer envelope verifies"); // No post-cutoff mint route exists. Reconstruct only its durable binding // from the real node signer and a genuine holder-signed presentation. let session_id = format!("historical-session-{share_id}"); - let session_issued = timestamp(OffsetDateTime::now_utc()); - let session_expires = timestamp(OffsetDateTime::now_utc() + Duration::minutes(4)); + let session_issued = timestamp(historical_evaluation_time()); + let session_expires = timestamp(historical_evaluation_time() + Duration::minutes(4)); let credential_digest = b64_digest(b"synthetic credential bound to test-only session"); let presentation = serde_json::json!({ "type": "TinyCloudSharePolicyPresentation", "version": 2, @@ -414,7 +430,7 @@ async fn historical_share( async fn invoke(client: &Client, share: &HistoricalShare, jti: &str) -> (Status, Value) { let challenge = &share.challenge; - let now = OffsetDateTime::now_utc(); + let now = historical_evaluation_time(); let mut invocation = serde_json::json!({ "type": "TinyCloudShareReadInvocation", "version": 2, "sessionId": share.session_id, "envelopeCid": challenge["envelopeCid"], @@ -474,7 +490,7 @@ async fn authorize_delivery(client: &Client, share: &HistoricalShare, jti: u8) - "shareUrl": format!("https://share.tinycloud.xyz/s/{}", challenge["shareCid"].as_str().unwrap()), "documentName": "historical document", "jti": encode_config([jti; 16], URL_SAFE_NO_PAD), - "expiresAt": timestamp(OffsetDateTime::now_utc() + Duration::minutes(4)) + "expiresAt": timestamp(historical_evaluation_time() + Duration::minutes(4)) }); let digest = request_digest_without(&request, "requestBodyDigest").expect("delivery digest"); request @@ -502,7 +518,7 @@ async fn authorize_delivery(client: &Client, share: &HistoricalShare, jti: u8) - &share.sender_delegation_cid, &share.sender, &share.sender_vm, - (OffsetDateTime::now_utc() + Duration::seconds(45)).unix_timestamp() as f64, + (historical_evaluation_time() + Duration::seconds(45)).unix_timestamp() as f64, InvocationOptions::default(), ) .expect("real sender-signed ordinary invocation"); @@ -541,8 +557,9 @@ async fn creation_closed(client: &Client, route: &str, body: &Value) { async fn historical_signed_share_invoke_and_delivery_stop_after_share_key_tombstone_without_affecting_other_shares( ) { let key_setup = StaticSecret::new(vec![0x59u8; 32]).expect("node test key"); - let runtime = + let mut runtime = compose_test_runtime(&key_setup, Some(TeeContext::derive_local(&key_setup)), true).await; + runtime.evaluation_time = Some(historical_evaluation_time()); assert!( runtime.live().await, "composed node must accept read requests" @@ -568,6 +585,16 @@ async fn historical_signed_share_invoke_and_delivery_stop_after_share_key_tombst let unaffected = historical_share(&runtime, &owner, &owner_did, &owner_vm, "legacy-control").await; let share_did = revoked.registered.row.share_key_did.clone(); + let historically_registered = registered_policy( + &runtime, + &revoked.registered.row.registration_cid, + &revoked.registered.row.policy_cid, + ) + .await + .expect("unrevoked historical policy must validate at the pinned evaluation time"); + verify_registered_ancestors(&runtime, &historically_registered, None) + .await + .expect("unrevoked historical signed ancestors must validate"); let client = Client::tracked( rocket::build() .manage(Some(runtime.clone())) @@ -601,6 +628,26 @@ async fn historical_signed_share_invoke_and_delivery_stop_after_share_key_tombst invoke(&client, &unaffected, "control-before").await.0, Status::Ok ); + // This same signed fixture cannot reopen the read route after its + // production January 5, 2027 sunset; the cutoff precedes session lookup. + let mut after_sunset = runtime.clone(); + after_sunset.evaluation_time = Some( + OffsetDateTime::parse("2027-01-06T00:00:00.000Z", &Rfc3339) + .expect("post-sunset evaluation time"), + ); + let sunset_client = Client::tracked( + rocket::build() + .manage(Some(after_sunset)) + .manage(crate::BlockStage::from( + crate::config::StagingStorage::Memory, + )) + .mount("/", rocket::routes![invoke_v2]), + ) + .await + .expect("post-sunset Rocket route"); + let (status, body) = invoke(&sunset_client, &revoked, "after-read-sunset").await; + assert_eq!(status, Status::Gone, "legacy read sunset: {body}"); + assert_eq!(body["error"]["code"], "legacy_v2_reads_closed"); let (status, body) = authorize_delivery(&client, &revoked, 1).await; assert_eq!( status, @@ -658,9 +705,10 @@ async fn historical_signed_share_invoke_and_delivery_stop_after_share_key_tombst async fn historical_signed_delivery_denies_revoked_owner_enforcer_or_sender() { for (index, target) in ["owner", "enforcer", "sender"].iter().enumerate() { let key_setup = StaticSecret::new(vec![0x59u8; 32]).expect("node test key"); - let runtime = + let mut runtime = compose_test_runtime(&key_setup, Some(TeeContext::derive_local(&key_setup)), true) .await; + runtime.evaluation_time = Some(historical_evaluation_time()); assert!( runtime.live().await, "composed node must accept read requests" From 9932929aa09984acea495a03e5d333f1e4df19e9 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Thu, 15 Oct 2026 23:35:38 +0000 Subject: [PATCH 19/20] TC-1149: preserve witness startup and canonical lock ordering Separate full historical witness validation from live policy cutoff; align principal locks and maintain typed admission errors. Remove a brittle source-inspection test while retaining runtime DID route coverage. Verified Rust workspace (1129), M1 (13), SDK localhost (21), formatting and strict Clippy. --- tinycloud-core/src/db.rs | 70 +- tinycloud-core/src/key_authority.rs | 17 +- tinycloud-core/src/models/delegation.rs | 2 +- tinycloud-core/src/models/revocation.rs | 30 +- tinycloud-core/src/policy_witness.rs | 22 +- .../tests/historical_cacao_revocation.rs | 28 +- .../tests/key_revoke_recovery_order.rs | 248 +++++- tinycloud-node-server/src/policy_v3.rs | 70 +- .../tests/did_resolution_single_entry.rs | 736 ------------------ 9 files changed, 431 insertions(+), 792 deletions(-) delete mode 100644 tinycloud-node-server/tests/did_resolution_single_entry.rs diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 2cc9e735..07f7d407 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -1735,11 +1735,27 @@ where } } } - admission_principals.extend( - crate::auth_graph::load_chain_principals(&tx, &parents) - .await - .map_err(|error| TxError::Db(DbErr::Custom(error.to_string())))?, - ); + let principals = + match crate::auth_graph::load_chain_principals(&tx, &parents).await { + Ok(principals) => principals, + Err(revocation::ChainTraversalError::Db(DbErr::Custom(reason))) + if reason == "authorization-chain-missing" => + { + // Preserve signed issuer-policy refusal when a + // claimed revocation parent was never admitted. + revocation::verify(&revocation.0.revocation).await?; + return Err(TxError::InvalidRevocation( + revocation::RevocationError::MissingParents, + )); + } + Err(revocation::ChainTraversalError::Db(error)) => { + return Err(TxError::Db(error)); + } + Err(revocation::ChainTraversalError::LimitExceeded) => { + return Err(TxError::ChainTraversalLimitExceeded); + } + }; + admission_principals.extend(principals); admission_principals.push(revocation.0.revoker.clone()); // The CID action is scoped to the capabilities it revokes; // a key cutoff in an unrelated space must not block it. @@ -1965,6 +1981,25 @@ where } pub async fn delegate(&self, delegation: Delegation) -> Result> { + // Fresh expired/not-yet-valid statements cannot be admitted regardless + // of their parents. An exact already-registered statement retains its + // existing idempotent behavior; the normal authority guards still run + // before that existing-row fast path returns. + let retained_hash = delegation.content_hash(); + let invalid_time = match &delegation.0.delegation { + TinyCloudDelegation::Ucan(ucan) => ucan.payload().validate_time(None).is_err(), + TinyCloudDelegation::Cacao(cacao) => !cacao.payload().valid_now(), + }; + if invalid_time + && delegation::Entity::find_by_id(retained_hash) + .one(&self.conn) + .await? + .is_none() + { + return Err(TxError::InvalidDelegation( + delegation::DelegationError::InvalidTime, + )); + } let parent_hashes: Vec = delegation .0 .parents @@ -1972,14 +2007,29 @@ where .copied() .map(Hash::from) .collect(); - let retained_hash = delegation.content_hash(); // Capture expected row counts before consuming the delegation. let expected_ability_count = delegation.0.capabilities.len() as u64; let expected_parent_count = parent_hashes.len() as u64; let roots = delegation_guard_roots(retained_hash, &parent_hashes); - let mut principals = crate::auth_graph::load_chain_principals(&self.conn, &parent_hashes) - .await - .map_err(|error| TxError::Db(DbErr::Custom(error.to_string())))?; + let mut principals = + match crate::auth_graph::load_chain_principals(&self.conn, &parent_hashes).await { + Ok(principals) => principals, + Err(revocation::ChainTraversalError::Db(DbErr::Custom(reason))) + if reason == "authorization-chain-missing" => + { + // The signature and DID method were historically checked + // before a missing parent. Do that only on this failing + // path; successful imports still verify once in process(). + delegation::verify(&delegation.0.delegation).await?; + return Err(TxError::InvalidDelegation( + delegation::DelegationError::MissingParents, + )); + } + Err(revocation::ChainTraversalError::Db(error)) => return Err(TxError::Db(error)), + Err(revocation::ChainTraversalError::LimitExceeded) => { + return Err(TxError::ChainTraversalLimitExceeded); + } + }; principals.push(delegation.0.delegator.clone()); principals.push(delegation.0.delegate.clone()); let authz_start = Instant::now(); @@ -4755,7 +4805,7 @@ fn byte_ordered_resource(backend: DbBackend) -> SimpleExpr { } /// `column`, compared in byte order on `backend` (see `byte_ordered_resource`). -fn byte_ordered(backend: DbBackend, column: impl Into) -> SimpleExpr { +pub(crate) fn byte_ordered(backend: DbBackend, column: impl Into) -> SimpleExpr { // `$1` / `?` is sea-query's per-backend placeholder for the embedded // column expression, not for a bound value. match backend { diff --git a/tinycloud-core/src/key_authority.rs b/tinycloud-core/src/key_authority.rs index 502a2d3c..3c08bb11 100644 --- a/tinycloud-core/src/key_authority.rs +++ b/tinycloud-core/src/key_authority.rs @@ -9,8 +9,10 @@ use crate::models::{ key_revocation::{self, target}, }; use sea_orm::{ - sea_query::OnConflict, ActiveValue::Set, ColumnTrait, Condition, ConnectionTrait, - DatabaseTransaction, DbBackend, DbErr, EntityTrait, QueryFilter, QueryOrder, QuerySelect, + sea_query::{Expr, OnConflict}, + ActiveValue::Set, + ColumnTrait, Condition, ConnectionTrait, DatabaseTransaction, DbBackend, DbErr, EntityTrait, + QueryFilter, QueryOrder, QuerySelect, }; use tinycloud_auth::{identity::principal_did, resource::SpaceId}; @@ -116,14 +118,17 @@ pub async fn lock_targets( if principals.is_empty() { return Ok(()); } - // Insert and lock in canonical order across all node processes. A single - // batched insert plus one ordered shared/exclusive row-lock query avoids - // serial DB round trips for every signer in a proof closure. + // Retain the batched row-lock query, but pin the same byte order used by + // recovery's Rust-sorted per-principal locks. PostgreSQL's default text + // collation (and MySQL's) need not order mixed-case DIDs bytewise. ensure_target_rows(tx, &principals).await?; if tx.get_database_backend() != DbBackend::Sqlite { let query = target::Entity::find() .filter(target::Column::TargetDid.is_in(principals.iter().cloned())) - .order_by_asc(target::Column::TargetDid); + .order_by_asc(crate::db::byte_ordered( + tx.get_database_backend(), + Expr::col((target::Entity, target::Column::TargetDid)), + )); let rows = if exclusive { query.lock_exclusive().all(tx).await? } else { diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index 8a163fbe..efc03d9a 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -197,7 +197,7 @@ pub(crate) async fn process( } // verify signatures and time -async fn verify(delegation: &TinyCloudDelegation) -> Result<(), Error> { +pub(crate) async fn verify(delegation: &TinyCloudDelegation) -> Result<(), Error> { match delegation { TinyCloudDelegation::Ucan(ref ucan) => { verify_ucan_signature(ucan) diff --git a/tinycloud-core/src/models/revocation.rs b/tinycloud-core/src/models/revocation.rs index a37d5034..bf0eb848 100644 --- a/tinycloud-core/src/models/revocation.rs +++ b/tinycloud-core/src/models/revocation.rs @@ -350,26 +350,18 @@ pub enum RevocationError { MissingParents, } -pub(crate) async fn process( - db: &C, - revocation: Revocation, -) -> Result { - let (r, serialization) = (revocation.0, revocation.1); - +pub(crate) async fn verify(revocation: &TinyCloudRevocation) -> Result<(), Error> { let t = OffsetDateTime::now_utc(); - - // W1 (audit P0 finding 5): verify both CACAO and did:key/UCAN format - // revocations. The route accepts either suite so the Policy Engine - // active_cutoff loop can rely on a single endpoint regardless of - // whether the Grant Issuer signs with SIWE or did:key. - match &r.revocation { + // Verify both CACAO and did:key/UCAN revocations before inspecting the + // referenced graph, including when an ancestor is missing. + match revocation { TinyCloudRevocation::Cacao(c) => { c.verify() .await .map_err(|_| RevocationError::InvalidSignature)?; if !c.payload().valid_at(&t) { return Err(RevocationError::InvalidTime.into()); - }; + } } TinyCloudRevocation::Ucan(u) => { verify_ucan_signature(u) @@ -387,7 +379,17 @@ pub(crate) async fn process( .validate_time(None) .map_err(|_| RevocationError::InvalidTime)?; } - }; + } + Ok(()) +} + +pub(crate) async fn process( + db: &C, + revocation: Revocation, +) -> Result { + let (r, serialization) = (revocation.0, revocation.1); + + verify(&r.revocation).await?; let hash: Hash = hash(&serialization); let delegation = delegation::Entity::find_by_id(Hash::from(r.revoked)) diff --git a/tinycloud-core/src/policy_witness.rs b/tinycloud-core/src/policy_witness.rs index 67710a06..fa3df479 100644 --- a/tinycloud-core/src/policy_witness.rs +++ b/tinycloud-core/src/policy_witness.rs @@ -605,6 +605,17 @@ pub struct WitnessDependencies { pub async fn revocation_dependencies( db: &C, policy_cid: &str, +) -> Result { + load_dependencies(db, policy_cid, true).await +} + +// Startup validates the entire immutable witness even when a root was later +// revoked. Live consumers still deny that already-loaded root immediately, +// without reading the other root or aliases on the denied request path. +async fn load_dependencies( + db: &C, + policy_cid: &str, + require_live_root: bool, ) -> Result { let registration = policy_v3_registration::Entity::find_by_id(policy_cid) .one(db) @@ -638,11 +649,10 @@ pub async fn revocation_dependencies( .one(db) .await? .ok_or(WitnessError::Missing)?; - // A signed policy-root status revocation is an explicit CID cutoff, - // even when no generic `/revoke` tombstone was written. This row is - // already loaded for every witness-backed consumer (including issued - // download tickets), so the check adds no read round trip. - if root.revoked_at.is_some() || root.revocation_bytes.is_some() { + // Signed root status is an explicit CID cutoff. Startup continues + // validating both historical roots and all witness dependencies; + // a live request fails closed here with no additional SQL. + if require_live_root && (root.revoked_at.is_some() || root.revocation_bytes.is_some()) { return Err(WitnessError::Revoked); } if root.policy_cid != policy_cid || root.role != role { @@ -843,7 +853,7 @@ async fn plan_backfill( .all(db) .await?; if !existing.is_empty() { - match revocation_dependencies(db, ®istration.policy_cid).await { + match load_dependencies(db, ®istration.policy_cid, false).await { Ok(_) => counts.witnesses += existing.len() as u64, Err(WitnessError::Missing) => counts.missing += 1, Err( diff --git a/tinycloud-core/tests/historical_cacao_revocation.rs b/tinycloud-core/tests/historical_cacao_revocation.rs index 38143a80..d0dc433c 100644 --- a/tinycloud-core/tests/historical_cacao_revocation.rs +++ b/tinycloud-core/tests/historical_cacao_revocation.rs @@ -470,15 +470,31 @@ async fn assert_redelegation_denied(db: &TestDb, fixture: &Fixture) { let error = db .delegate(Delegation::from_header_ser::(&direct).unwrap()) .await - .expect_err("revoked equivalent parent must not allow a new child") - .to_string(); - assert!(error.contains("delegation-parent-revoked"), "{error}"); + .expect_err("revoked equivalent parent must not allow a new child"); + assert!( + matches!( + error, + tinycloud_core::db::TxError::AuthorizationDenied + | tinycloud_core::db::TxError::InvalidDelegation( + tinycloud_core::models::delegation::DelegationError::ParentRevoked(_) + ) + ), + "a revoked parent must deny delegation: {error}" + ); let descendant = child_header(&fixture.resource, &row.child_cid, fixture.child_seed, 13); let error = db .delegate(Delegation::from_header_ser::(&descendant).unwrap()) .await - .expect_err("legacy child of revoked root must not allow a descendant") - .to_string(); - assert!(error.contains("delegation-ancestor-revoked"), "{error}"); + .expect_err("legacy child of revoked root must not allow a descendant"); + assert!( + matches!( + error, + tinycloud_core::db::TxError::AuthorizationDenied + | tinycloud_core::db::TxError::InvalidDelegation( + tinycloud_core::models::delegation::DelegationError::AncestorRevoked { .. } + ) + ), + "a revoked ancestor must deny delegation: {error}" + ); } } diff --git a/tinycloud-core/tests/key_revoke_recovery_order.rs b/tinycloud-core/tests/key_revoke_recovery_order.rs index d0c7f089..6da60938 100644 --- a/tinycloud-core/tests/key_revoke_recovery_order.rs +++ b/tinycloud-core/tests/key_revoke_recovery_order.rs @@ -14,6 +14,7 @@ use tinycloud_auth::{ }; use tinycloud_core::{ hash::{hash, Hash}, + key_authority, keys::{public_key_to_did_key, Keypair, StaticSecret}, migrations::Migrator, models::{ @@ -192,30 +193,33 @@ impl Drop for Schema { } } -async fn wait_for_both_at_update( +async fn wait_for_target_updates( admin: &DatabaseConnection, - names: [&str; 2], - holder: i32, + names: &[&str], + holder: Option, ) -> bool { let deadline = tokio::time::Instant::now() + Duration::from_secs(12); + let blocker_clause = holder.map_or_else( + || "cardinality(pg_blocking_pids(pid)) > 0".to_owned(), + |pid| format!("{pid} = ANY(pg_blocking_pids(pid))"), + ); loop { - // pg_blocking_pids pins each wait to OUR held R/S rows rather than a - // migration or a competing test. Only the ordered exclusive row query - // qualifies; an earlier proof/share wait is not a valid schedule. + // A first waiter is pinned to our holder. A later waiter can be + // queued behind that earlier waiter rather than behind the holder. let rows = admin .query_all(Statement::from_string( DbBackend::Postgres, format!( "SELECT application_name FROM pg_stat_activity \ - WHERE application_name IN ('{}', '{}') AND state = 'active' \ - AND wait_event_type = 'Lock' AND {} = ANY(pg_blocking_pids(pid)) \ + WHERE application_name IN ('{}') AND state = 'active' \ + AND wait_event_type = 'Lock' AND {blocker_clause} \ AND query LIKE '%key_revocation_target%' AND query LIKE '%FOR UPDATE%'", - names[0], names[1], holder, + names.join("','"), ), )) .await .expect("inspect isolated PostgreSQL wait events"); - if rows.len() == 2 { + if rows.len() == names.len() { let seen: Vec = rows .iter() .map(|row| row.try_get("", "application_name").unwrap()) @@ -384,7 +388,8 @@ async fn postgres_crossed_recovery_revokes_do_not_deadlock() { let holder_pid: i32 = pid.try_get("", "pid").unwrap(); let first_job = tokio::spawn(async move { first.revoke_key(a_request).await }); let second_job = tokio::spawn(async move { second.revoke_key(b_request).await }); - let reached = wait_for_both_at_update(&admin, [&first_name, &second_name], holder_pid).await; + let reached = + wait_for_target_updates(&admin, &[&first_name, &second_name], Some(holder_pid)).await; holder.rollback().await.expect("release issuer row locks"); if !reached { first_job.abort(); @@ -412,3 +417,224 @@ async fn postgres_crossed_recovery_revokes_do_not_deadlock() { first_conn.close().await.unwrap(); admin.close().await.unwrap(); } + +/// Recovery's Rust byte order must agree with the direct/admission transaction +/// even when PostgreSQL's configured text collation orders those DIDs differently. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn postgres_mixed_direct_and_recovery_locks_share_byte_order() { + let url = match std::env::var("TINYCLOUD_TEST_POSTGRES_URL") { + Ok(url) if !url.trim().is_empty() => url, + _ => { + assert!( + std::env::var_os("CI").is_none(), + "mixed lock-order regression requires TINYCLOUD_TEST_POSTGRES_URL in CI" + ); + eprintln!("skipping PostgreSQL-only mixed lock-order test: URL is unset"); + return; + } + }; + let schema = Schema { + url: url.clone(), + name: format!( + "tc1149_mixed_{}_{}", + std::process::id(), + OffsetDateTime::now_utc().unix_timestamp_nanos() + ), + }; + let mut admin_options = ConnectOptions::new(url.clone()); + admin_options.max_connections(2).sqlx_logging(false); + let admin = Database::connect(admin_options).await.unwrap(); + admin + .execute_unprepared(&format!("CREATE SCHEMA {}", schema.name)) + .await + .unwrap(); + let connect = |label: &str| { + let app = format!("{}_{}", schema.name, label); + let mut options = ConnectOptions::new(format!( + "{}{}application_name={app}", + url, + if url.contains('?') { "&" } else { "?" } + )); + options + .max_connections(1) + .sqlx_logging(false) + .set_schema_search_path(schema.name.clone()); + (app, options) + }; + let (recovery_name, recovery_options) = connect("recovery"); + let (direct_name, direct_options) = connect("direct"); + let recovery_conn = Database::connect(recovery_options).await.unwrap(); + Migrator::up(&recovery_conn, None).await.unwrap(); + recovery_conn + .execute_unprepared( + "ALTER TABLE key_revocation_target ALTER COLUMN target_did TYPE varchar(255) COLLATE \"en-US-x-icu\"", + ) + .await + .expect("PostgreSQL must support the test's non-bytewise ICU collation"); + let direct_conn = Database::connect(direct_options).await.unwrap(); + let recovery = SpaceDatabase::new( + recovery_conn.clone(), + MemoryStore::default(), + StaticSecret::new(vec![0; 32]).unwrap(), + ) + .await + .unwrap(); + + let mut identities: Vec<(u8, String)> = (1..=64).map(|seed| (seed, signer(seed).1)).collect(); + identities.sort_by(|left, right| left.1.cmp(&right.1)); + for (_, did) in &identities { + key_revocation::target::ActiveModel { + target_did: Set(did.clone()), + } + .insert(&recovery_conn) + .await + .unwrap(); + } + let db_order = recovery_conn + .query_all(Statement::from_string( + DbBackend::Postgres, + "SELECT target_did FROM key_revocation_target ORDER BY target_did".to_owned(), + )) + .await + .unwrap() + .into_iter() + .map(|row| row.try_get::("", "target_did").unwrap()) + .collect::>(); + let mut chosen = None; + for first in 1..identities.len() { + for second in first + 1..identities.len() { + let a = &identities[first].1; + let b = &identities[second].1; + if db_order.iter().position(|did| did == b).unwrap() + < db_order.iter().position(|did| did == a).unwrap() + { + chosen = Some((identities[first].clone(), identities[second].clone())); + break; + } + } + if chosen.is_some() { + break; + } + } + let ((a_seed, a), (b_seed, b)) = + chosen.expect("fixture must assert an actual database-versus-Rust DID ordering inversion"); + let r = identities[0].1.clone(); + assert!(r < a && a < b); + assert!( + db_order.iter().position(|did| did == &b).unwrap() + < db_order.iter().position(|did| did == &a).unwrap(), + "PostgreSQL sorts B before A while canonical Rust bytes sort A before B" + ); + let now = OffsetDateTime::now_utc().unix_timestamp(); + let root = persist_grant( + &recovery_conn, + &signed_grant(a_seed, &b, &a, None, "mixed-root", now), + ) + .await; + let tip = persist_grant( + &recovery_conn, + &signed_grant(b_seed, &r, &a, Some(&root), "mixed-tip", now), + ) + .await; + let recovery_request = request(&a, &r, tip, "mixed-recovery", now); + let proof_tx = recovery_conn.begin().await.unwrap(); + let mut expiry = None; + assert!( + recovery_authority::is_authorized( + &proof_tx, + &recovery_request, + OffsetDateTime::now_utc(), + None, + &mut expiry + ) + .await + .unwrap(), + "signed recovery chain is valid before arranging either lock wait" + ); + proof_tx.rollback().await.unwrap(); + + let holder = admin.begin().await.unwrap(); + holder + .query_one(Statement::from_string( + DbBackend::Postgres, + format!( + "SELECT target_did FROM {}.key_revocation_target WHERE target_did = '{}' FOR UPDATE", + schema.name, a + ), + )) + .await + .unwrap() + .expect("holder locks A"); + let holder_pid: i32 = holder + .query_one(Statement::from_string( + DbBackend::Postgres, + "SELECT pg_backend_pid() AS pid".to_owned(), + )) + .await + .unwrap() + .unwrap() + .try_get("", "pid") + .unwrap(); + let recovered = tokio::spawn(async move { recovery.revoke_key(recovery_request).await }); + assert!( + wait_for_target_updates(&admin, &[&recovery_name], Some(holder_pid)).await, + "the recovery path must be blocked at A before direct locks are requested" + ); + let direct = tokio::spawn(async move { + let tx = direct_conn.begin().await.unwrap(); + let result = key_authority::lock_targets(&tx, &[a, b], true).await; + tx.rollback().await.unwrap(); + result + }); + let reached = wait_for_target_updates(&admin, &[&recovery_name, &direct_name], None).await; + let diagnostic = if reached { + Vec::new() + } else { + admin + .query_all(Statement::from_string( + DbBackend::Postgres, + format!( + "SELECT application_name, state, COALESCE(wait_event_type, '') AS wait, \ + cardinality(pg_blocking_pids(pid)) AS blockers, \ + query LIKE '%key_revocation_target%' AS target_sql, \ + query LIKE '%FOR UPDATE%' AS update_sql \ + FROM pg_stat_activity WHERE application_name IN ('{recovery_name}', '{direct_name}')" + ), + )) + .await + .unwrap() + .into_iter() + .map(|row| { + let app: String = row.try_get("", "application_name").unwrap(); + ( + if app == recovery_name { "recovery" } else { "direct" }, + row.try_get::("", "state").unwrap(), + row.try_get::("", "wait").unwrap(), + row.try_get::("", "blockers").unwrap(), + row.try_get::("", "target_sql").unwrap(), + row.try_get::("", "update_sql").unwrap(), + ) + }) + .collect::>() + }; + holder + .rollback() + .await + .expect("release A after both paths are waiting"); + assert!( + reached, + "both real lock paths must block at held A; count-only connection states: {diagnostic:?}" + ); + let recovered = tokio::time::timeout(Duration::from_secs(12), recovered) + .await + .expect("recovery path must not deadlock") + .expect("recovery task must not panic"); + let direct = tokio::time::timeout(Duration::from_secs(12), direct) + .await + .expect("direct path must not deadlock") + .expect("direct task must not panic"); + assert!(recovered.is_ok(), "valid recovery failed: {recovered:?}"); + assert!(direct.is_ok(), "direct/admission lock failed: {direct:?}"); + recovery_conn.close().await.unwrap(); + admin.close().await.unwrap(); +} diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 97f0229f..bbf176ac 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -8105,6 +8105,11 @@ mod tests { run_policy_v2_flow_with_root_route(PolicyOwner::SpaceOwnerPolicyRoot, true) } + #[test] + fn signed_enforcement_root_revoke_preserves_witness_on_cold_open() -> anyhow::Result<()> { + run_policy_v2_flow_with_root_route(PolicyOwner::SpaceOwner, true) + } + /// The real Share and CLI shape: the policy owner is a session key the /// space owner delegated to. It registers, mints and reads; once the /// space owner revokes that delegation, no new session can be minted. @@ -10628,16 +10633,32 @@ mod tests { "active" ); } + if signed_root_revoke { + let storage = crate::storage::file_system::FileSystemConfig::new(&_storage_dir) + .open() + .await?; + crate::TinyCloud::new( + db.clone().into(), + Either::B(storage), + StaticSecret::new(vec![0; 32]).unwrap(), + ) + .await + .expect("cold-open succeeds before signed root revoke"); + } let revoked_root_cid = registered[root_field] .as_str() .ok_or_else(|| anyhow::anyhow!("missing {root_field}"))? .parse::()?; let revoke_response = if signed_root_revoke { - assert_eq!(root_field, "policyRootCid"); + let root_role = if root_field == "enforcementRootCid" { + "policy-enforcement" + } else { + "policy-authority" + }; let mut revocation = json!({ "schema": ROOT_REVOCATION_V1_SCHEMA, "targetCid": revoked_root_cid.to_string(), - "targetRole": "policy-authority", + "targetRole": root_role, "ownerDid": owner_did, "nodeAudience": node_did, "revokedAt": format_time(OffsetDateTime::now_utc() + Duration::seconds(30)), @@ -10933,6 +10954,51 @@ mod tests { .dispatch() .await; assert_eq!(post_revoke_delivery_response.status(), Status::Forbidden); + if signed_root_revoke { + let key = StaticSecret::new(vec![0; 32]).unwrap(); + let encryption = + ColumnEncryption::new(key.derive_key(b"tinycloud/hooks/webhook-secrets")); + let counts = + tinycloud_core::policy_witness::dry_run_backfill(&db, Some(&encryption)).await?; + assert_eq!( + ( + counts.policies, + counts.missing, + counts.ambiguous, + counts.overflow + ), + (1, 0, 0, 0), + "revoked signed roots remain structurally complete witnesses" + ); + let storage = crate::storage::file_system::FileSystemConfig::new(&_storage_dir) + .open() + .await?; + crate::TinyCloud::new(db.clone().into(), Either::B(storage), key) + .await + .expect("cold-open succeeds after signed root revoke"); + if root_field == "policyRootCid" { + // Revocation does not excuse the other historical root from + // full structural validation during startup. Roll back the + // deliberately corrupt row so the signed fixture stays valid. + let transaction = db.begin().await?; + let other_cid = registered["enforcementRootCid"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing enforcement root"))?; + let other = policy_v3_root::Entity::find_by_id(other_cid.to_owned()) + .one(&transaction) + .await? + .ok_or_else(|| anyhow::anyhow!("missing enforcement root row"))?; + let mut damaged: policy_v3_root::ActiveModel = other.into(); + damaged.role = Set("invalid-structural-role".to_owned()); + damaged.update(&transaction).await?; + let error = + policy_witness::backfill_in_transaction(&transaction, Some(&encryption)) + .await + .expect_err("a revoked root must not mask a corrupt sibling root"); + assert!(matches!(error, policy_witness::WitnessError::Ambiguous)); + transaction.rollback().await?; + } + } Ok(()) } diff --git a/tinycloud-node-server/tests/did_resolution_single_entry.rs b/tinycloud-node-server/tests/did_resolution_single_entry.rs deleted file mode 100644 index 9eba02ac..00000000 --- a/tinycloud-node-server/tests/did_resolution_single_entry.rs +++ /dev/null @@ -1,736 +0,0 @@ -//! Scan all production items, including those following test-only items. -use proc_macro2::{TokenStream, TokenTree}; -use quote::ToTokens; -use std::path::{Path, PathBuf}; -use syn::{ - ext::IdentExt, - visit::{self, Visit}, - Attribute, Item, -}; - -fn ident_name(ident: &syn::Ident) -> String { - ident.unraw().to_string() -} - -fn normalized_tokens(tokens: TokenStream) -> TokenStream { - tokens - .into_iter() - .map(|token| match token { - TokenTree::Ident(ident) => TokenTree::Ident(ident.unraw()), - TokenTree::Group(group) => { - let mut normalized = - proc_macro2::Group::new(group.delimiter(), normalized_tokens(group.stream())); - normalized.set_span(group.span()); - TokenTree::Group(normalized) - } - other => other, - }) - .collect() -} - -fn canonical_tokens(value: &impl ToTokens) -> String { - normalized_tokens(value.to_token_stream()).to_string() -} - -fn source_files(dir: &Path, files: &mut Vec) { - for entry in std::fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - source_files(&path, files); - } else if path.extension().is_some_and(|ext| ext == "rs") { - files.push(path); - } - } -} - -fn test_only(attrs: &[Attribute]) -> bool { - attrs.iter().any(|attr| { - canonical_tokens(attr.path()) == "cfg" - && matches!(&attr.meta, syn::Meta::List(list) if canonical_tokens(&list.tokens) == "test") - }) -} - -fn item_attrs(item: &Item) -> &[Attribute] { - match item { - Item::Const(x) => &x.attrs, - Item::Enum(x) => &x.attrs, - Item::ExternCrate(x) => &x.attrs, - Item::Fn(x) => &x.attrs, - Item::ForeignMod(x) => &x.attrs, - Item::Impl(x) => &x.attrs, - Item::Macro(x) => &x.attrs, - Item::Mod(x) => &x.attrs, - Item::Static(x) => &x.attrs, - Item::Struct(x) => &x.attrs, - Item::Trait(x) => &x.attrs, - Item::TraitAlias(x) => &x.attrs, - Item::Type(x) => &x.attrs, - Item::Union(x) => &x.attrs, - Item::Use(x) => &x.attrs, - _ => &[], - } -} - -fn resolver_entry(name: &str) -> bool { - matches!( - name, - "verify_signature" - | "resolve" - | "resolve_with" - | "resolve_representation" - | "resolve_method_representation" - | "resolve_into_any_verification_method" - ) || name == "dereference" - || name.starts_with("dereference_") - || name.starts_with("resolve_verification_method") - || name.starts_with("into_vm_resolver") -} - -fn raw_resolver_ident(name: &str) -> bool { - matches!( - name, - "AnyDidMethod" - | "DID_METHODS" - | "DIDWeb" - | "DIDTz" - | "DIDION" - | "DIDEthr" - | "VerificationMethodDIDResolver" - | "VerificationParameters" - | "did_web" - | "did_tz" - | "did_ion" - | "did_ethr" - ) -} - -fn network_did_module(name: &str) -> bool { - matches!(name, "web" | "tz" | "ion" | "ethr") -} - -#[derive(Default)] -struct Guard { - file: String, - function: String, - violations: Vec, - helper_calls: usize, - test_modules: Vec, - module_dir: PathBuf, -} - -impl Guard { - fn new(file: &str) -> Self { - let path = Path::new(file); - let mut module_dir = path.parent().unwrap().to_path_buf(); - let stem = path.file_stem().unwrap().to_str().unwrap(); - if !matches!(stem, "lib" | "main" | "mod") { - module_dir.push(stem); - } - Self { - file: file.into(), - module_dir, - ..Self::default() - } - } - - fn allowed(&self, call: &syn::ExprMethodCall) -> bool { - let expression = canonical_tokens(call); - // Exact pre-existing calls only: storage config, byte ranges, pinned - // webhook DNS, share authority, and DID reference absolutization. - // Manifest::resolve is the existing non-UCAN, caller-supplied resolver - // API. Its single call is narrowly exempted; new calls still fail. - matches!((self.file.as_str(), self.function.as_str(), expression.as_str()), - ("tinycloud-core/src/manifest.rs", "resolve", "resolver . resolve (id . did ())") - | ("tinycloud-core/src/manifest.rs", "id_from_vm", "r . resolve (did)") - | ("tinycloud-core/src/share_email/ports.rs", "resolve_exact", "self . resolve (policy , delegation)") - | ("tinycloud-core/src/storage/memory.rs", "read_range", "range . resolve (total_size)") - | ("tinycloud-node-server/src/storage/file_system.rs", "read_range", "range . resolve (total_size)") - | ("tinycloud-node-server/src/storage/s3.rs", "parse_content_range", "ByteRangeSpec :: Inclusive { start , end } . resolve (total_size)") - | ("tinycloud-node-server/src/lib.rs", "resolve_runtime_config", "resolved . storage . resolve ()") - | ("tinycloud-node-server/src/runtime.rs", "serve_config_figment", "resolved . storage . resolve ()") - | ("tinycloud-node-server/src/runtime.rs", "launch_with_figment", "tinycloud_config . storage . resolve ()") - | ("tinycloud-node-server/src/node_control/service.rs", "effective_config", "config . storage . resolve ()") - | ("tinycloud-node-server/src/node_control/control.rs", "spawn_control_plane", "effective_config . storage . resolve ()") - | ("tinycloud-node-server/src/node_control/control.rs", "effective_public_config", "config . storage . resolve ()") - | ("tinycloud-node-server/src/webhook_dispatcher.rs", "pinned_client", "Client :: builder () . timeout (self . timeout) . redirect (Policy :: none ()) . resolve (host , address)") - ) - } - - fn auth_resolver_import(&self, item: &syn::ItemUse) -> bool { - self.file == "tinycloud-auth/src/resolver.rs" - && canonical_tokens(item) == "use ssi :: dids :: AnyDidMethod ;" - } - - fn auth_resolver_definition(&self, mac: &syn::Macro) -> bool { - // Exempt only the existing offline-generation registry declaration, - // never the rest of resolver.rs or another lazy_static body. - self.file == "tinycloud-auth/src/resolver.rs" - && canonical_tokens(&mac.path) == "lazy_static :: lazy_static" - && canonical_tokens(&mac.tokens) - == "pub static ref DID_METHODS : AnyDidMethod = AnyDidMethod :: default () ;" - } - - fn offline_cacao_verification(&self, call: &syn::ExprMethodCall) -> bool { - // CACAO's argument-free SIWE verification is offline. Pin these - // existing expressions; never exempt a file, function, or receiver. - matches!( - ( - self.file.as_str(), - self.function.as_str(), - canonical_tokens(call).as_str() - ), - ( - "tinycloud-core/src/models/delegation.rs", - "verify", - "cacao . verify ()" - ) | ( - "tinycloud-core/src/models/revocation.rs", - "process", - "c . verify ()" - ) | ( - "tinycloud-node-server/src/policy_v3.rs", - "authenticate_account_owner", - "cacao . verify ()" - ) | ( - "tinycloud-node-server/src/share_v2.rs", - "verify_delegation_signature", - "cacao . verify ()" - ) - ) - } - - fn scan_macro(&mut self, tokens: TokenStream) { - // syn leaves macro bodies opaque. Inspect their tokens too, so a raw - // resolver call inside a production macro cannot evade the guard. - let tokens: Vec<_> = tokens.into_iter().collect(); - for window in tokens.windows(4) { - if matches!(&window[0], TokenTree::Ident(ident) if ident_name(ident) == "dids") - && matches!(&window[1], TokenTree::Punct(punct) if punct.as_char() == ':') - && matches!(&window[2], TokenTree::Punct(punct) if punct.as_char() == ':') - { - match &window[3] { - TokenTree::Ident(ident) if network_did_module(&ident_name(ident)) => { - self.violations - .push(format!("network DID module dids::{ident} in macro")); - } - TokenTree::Group(_) => { - // Preserve grouped import paths such as - // dids::{web as resolver, key::{self, DIDKey}}. - if let Ok(tree) = syn::parse2(window.iter().cloned().collect()) { - self.scan_use_tree(&tree, false); - } - } - _ => {} - } - } - } - for token in tokens { - match token { - TokenTree::Group(group) => self.scan_macro(group.stream()), - TokenTree::Ident(ident) => { - let name = ident_name(&ident); - if raw_resolver_ident(&name) || resolver_entry(&name) { - self.violations - .push(format!("resolver token {name} in macro")); - } - } - _ => {} - } - } - } - - fn scan_use_tree(&mut self, tree: &syn::UseTree, parent_is_dids: bool) { - match tree { - syn::UseTree::Path(path) => { - self.check_module(&path.ident, parent_is_dids); - self.scan_use_tree(&path.tree, ident_name(&path.ident) == "dids"); - } - syn::UseTree::Name(name) => self.check_module(&name.ident, parent_is_dids), - syn::UseTree::Rename(rename) => self.check_module(&rename.ident, parent_is_dids), - syn::UseTree::Group(group) => { - for tree in &group.items { - self.scan_use_tree(tree, parent_is_dids); - } - } - syn::UseTree::Glob(_) => {} - } - } - - fn check_module(&mut self, ident: &syn::Ident, parent_is_dids: bool) { - if parent_is_dids && network_did_module(&ident_name(ident)) { - self.violations - .push(format!("network DID module dids::{ident}")); - } - } -} - -impl<'ast> Visit<'ast> for Guard { - fn visit_item(&mut self, item: &'ast Item) { - if test_only(item_attrs(item)) { - if let Item::Mod(module) = item { - if module.content.is_none() { - let dir = self.module_dir.join(ident_name(&module.ident)); - self.test_modules.push(dir.with_extension("rs")); - self.test_modules.push(dir.join("mod.rs")); - } - } - return; - } - visit::visit_item(self, item); - } - - fn visit_item_mod(&mut self, module: &'ast syn::ItemMod) { - let previous = self.module_dir.clone(); - self.module_dir.push(ident_name(&module.ident)); - visit::visit_item_mod(self, module); - self.module_dir = previous; - } - - fn visit_impl_item(&mut self, item: &'ast syn::ImplItem) { - let attrs = match item { - syn::ImplItem::Const(x) => &x.attrs, - syn::ImplItem::Fn(x) => &x.attrs, - syn::ImplItem::Type(x) => &x.attrs, - syn::ImplItem::Macro(x) => &x.attrs, - _ => return, - }; - if !test_only(attrs) { - visit::visit_impl_item(self, item); - } - } - - fn visit_trait_item(&mut self, item: &'ast syn::TraitItem) { - let attrs = match item { - syn::TraitItem::Const(x) => &x.attrs, - syn::TraitItem::Fn(x) => &x.attrs, - syn::TraitItem::Type(x) => &x.attrs, - syn::TraitItem::Macro(x) => &x.attrs, - _ => return, - }; - if !test_only(attrs) { - visit::visit_trait_item(self, item); - } - } - - fn visit_signature(&mut self, signature: &'ast syn::Signature) { - self.function = ident_name(&signature.ident); - visit::visit_signature(self, signature); - } - - fn visit_ident(&mut self, ident: &'ast syn::Ident) { - if raw_resolver_ident(&ident_name(ident)) { - self.violations.push(format!("raw resolver {ident}")); - } - } - - fn visit_path(&mut self, path: &'ast syn::Path) { - for (parent, child) in path.segments.iter().zip(path.segments.iter().skip(1)) { - self.check_module(&child.ident, ident_name(&parent.ident) == "dids"); - } - visit::visit_path(self, path); - } - - fn visit_item_use(&mut self, item: &'ast syn::ItemUse) { - if self.auth_resolver_import(item) { - return; - } - self.scan_use_tree(&item.tree, false); - visit::visit_item_use(self, item); - } - - fn visit_expr_method_call(&mut self, call: &'ast syn::ExprMethodCall) { - // DID generation is offline and does not resolve an issuer. Visit its - // arguments, but exempt only the DID_METHODS.generate receiver. - if ident_name(&call.method) == "generate" - && matches!(&*call.receiver, - syn::Expr::Path(path) if path.path.segments.last().is_some_and(|s| ident_name(&s.ident) == "DID_METHODS")) - { - for argument in &call.args { - self.visit_expr(argument); - } - return; - } - if !self.offline_cacao_verification(call) - && resolver_entry(&ident_name(&call.method)) - && !self.allowed(call) - { - self.violations.push(call.to_token_stream().to_string()); - } - // Ordinary verify() also serves fixed-key cryptography; any resolver - // types, factories, or calls in its receiver/arguments are still visited. - visit::visit_expr_method_call(self, call); - } - - fn visit_expr_call(&mut self, call: &'ast syn::ExprCall) { - if let syn::Expr::Path(path) = &*call.func { - let name = ident_name(&path.path.segments.last().unwrap().ident); - // Explicit non-DID function paths; aliases and unqualified raw - // resolve calls still fail. These operate on profile paths or - // stored SQL/DuckDB publication names, never on issuer DIDs. - let function_path = canonical_tokens(path); - let non_did = matches!( - (self.file.as_str(), function_path.as_str()), - ( - "tinycloud-node-server/src/node_control/paths.rs", - "ProfilePaths :: resolve" - ) | ( - "tinycloud-node-server/src/routes/mod.rs", - "database_migration :: resolve" - ) | ( - "tinycloud-node-server/src/share_email.rs", - "tinycloud_core :: database_migration :: resolve" - ) - ); - if resolver_entry(&name) && !non_did { - self.violations.push(call.to_token_stream().to_string()); - } - if matches!( - name.as_str(), - "verify_ucan_signature" | "verify_ucan_revocation_signature" - ) { - self.helper_calls += 1; - } - } - visit::visit_expr_call(self, call); - } - - fn visit_macro(&mut self, mac: &'ast syn::Macro) { - if self.auth_resolver_definition(mac) { - return; - } - self.scan_macro(mac.tokens.clone()); - } -} - -#[test] -fn ucan_resolution_has_one_production_entry_point() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); - let mut files = Vec::new(); - for dir in [ - "tinycloud-core/src", - "tinycloud-node-server/src", - "tinycloud-auth/src", - ] { - source_files(&root.join(dir), &mut files); - } - let mut scans = Vec::new(); - for path in files { - let relative = path.strip_prefix(root).unwrap().to_str().unwrap(); - if relative == "tinycloud-core/src/did_resolution.rs" { - continue; - } - let source = std::fs::read_to_string(&path).unwrap(); - let ast = - syn::parse_file(&source).unwrap_or_else(|error| panic!("{}: {error}", path.display())); - let mut guard = Guard::new(relative); - guard.visit_file(&ast); - scans.push(guard); - } - let test_modules: Vec<_> = scans.iter().flat_map(|scan| &scan.test_modules).collect(); - let mut calls = 0; - for scan in &scans { - if test_modules - .iter() - .any(|path| Path::new(&scan.file).starts_with(path)) - { - continue; - } - assert!( - scan.violations.is_empty(), - "UCAN resolver bypass in {}: {:?}", - scan.file, - scan.violations - ); - calls += scan.helper_calls; - } - assert!( - calls >= 5, - "expected at least five production helper calls, found {calls}" - ); -} - -#[test] -fn only_test_items_are_excluded_and_all_raw_entry_points_are_checked() { - let source = r#" - #[cfg(test)] extern crate tokio; - #[cfg(test)] mod tests { fn offline() { AnyDidMethod::default().resolve(did); } } - struct Example; - impl Example { - #[cfg(test)] fn fixture() { DID_METHODS.resolve(did); } - fn production() { - let resolver = AnyDidMethod::default(); - resolver.resolve(did); - resolver.resolve_representation(did, options); - resolver.resolve_with(did, options); - resolver.resolve_into_any_verification_method(did); - resolver.dereference(url); - resolver.dereference_with(url, options); - DIDResolver::resolve(&resolver, did); - DID_METHODS.resolve(did); - wrapper!(resolver.dereference(url)); - verify_ucan_signature(ucan); - } - } - #[cfg(test)] fn other_fixture() { DID_METHODS.resolve(did); } - fn later_production() { resolver.dereference(url); } - "#; - let mut guard = Guard::new("fixture.rs"); - guard.visit_file(&syn::parse_file(source).unwrap()); - assert_eq!(guard.helper_calls, 1); - assert_eq!(guard.violations.len(), 12, "{:?}", guard.violations); - - let mut guard = Guard::new("tinycloud-core/src/storage/memory.rs"); - guard.visit_file(&syn::parse_file("fn read_range() { range.resolve(total_size); }").unwrap()); - assert!(guard.violations.is_empty()); - guard.visit_file(&syn::parse_file("fn other() { range.resolve(total_size); }").unwrap()); - assert_eq!(guard.violations.len(), 1); -} - -fn scan_fixture(code: &str) -> Guard { - let mut guard = Guard::new("fixture.rs"); - guard.visit_file(&syn::parse_file(code).unwrap()); - guard -} - -#[test] -fn raw_identifiers_cannot_hide_resolver_names() { - for source in [ - "async fn production() { u.r#verify_signature(&dids::r#AnyDidMethod::default()).await; }", - "async fn production() { dids::r#DIDWeb.r#into_vm_resolver::().r#resolve_verification_method_with(id, None, options).await; }", - "async fn production() { adapter.r#resolve(did).await; adapter.r#dereference_with(url, options).await; }", - "async fn production() { Adapter::r#into_vm_resolver_with(adapter, options); }", - "wrapper!(dids::r#DIDTz.r#into_vm_resolver::());", - "use provider::r#dids::r#web as Renamed;", - "use provider::r#dids::{r#tz as Renamed, key::DIDKey};", - "type Adapter = provider::r#dids::r#ion::Resolver;", - "wrapper!(provider::r#dids::r#ethr::Resolver);", - "wrapper!(use provider::r#dids::{r#web as Renamed});", - ] { - let raw = scan_fixture(source); - let ordinary = scan_fixture(&source.replace("r#", "")); - assert!(!raw.violations.is_empty(), "raw identifier bypass: {source}"); - assert_eq!(raw.violations.len(), ordinary.violations.len(), "{source}"); - } - for ident in [ - "AnyDidMethod", - "DID_METHODS", - "DIDWeb", - "DIDTz", - "DIDION", - "DIDEthr", - "VerificationMethodDIDResolver", - "VerificationParameters", - "did_web", - "did_tz", - "did_ion", - "did_ethr", - ] { - for source in [ - format!("use provider::r#{ident} as Renamed;"), - format!("type Adapter = provider::r#{ident};"), - format!("wrapper!(provider::r#{ident});"), - ] { - assert!(!scan_fixture(&source).violations.is_empty(), "{source}"); - } - } -} - -#[test] -fn raw_identifiers_preserve_exact_offline_and_test_exceptions() { - let source = r##" - #[r#cfg(r#test)] mod r#tests { fn fixture() { r#AnyDidMethod::default(); } } - fn production() { - r#DID_METHODS.r#generate(&key, pattern); - r#verify_ucan_signature(ucan); - wrapper!("r#DIDWeb r#into_vm_resolver r#dids::r#web"); - } - "##; - let guard = scan_fixture(source); - assert!(guard.violations.is_empty(), "{:?}", guard.violations); - assert_eq!(guard.helper_calls, 1); - let mut guard = Guard::new("tinycloud-core/src/storage/memory.rs"); - guard.visit_file( - &syn::parse_file("fn r#read_range() { r#range.r#resolve(r#total_size); }").unwrap(), - ); - assert!(guard.violations.is_empty(), "{:?}", guard.violations); - guard.visit_file(&syn::parse_file("fn other() { r#range.r#resolve(r#total_size); }").unwrap()); - assert_eq!(guard.violations.len(), 1); - - let guard = scan_fixture("fn production() { r#DID_METHODS.r#generate(r#DIDWeb, pattern); }"); - assert!( - !guard.violations.is_empty(), - "generation arguments were exempted" - ); -} - -#[test] -fn auth_registry_exception_cannot_hide_another_resolver_call() { - let registry = include_str!("../../tinycloud-auth/src/resolver.rs"); - for source in [ - registry.to_string(), - registry - .replace("AnyDidMethod", "r#AnyDidMethod") - .replace("DID_METHODS", "r#DID_METHODS"), - ] { - let mut guard = Guard::new("tinycloud-auth/src/resolver.rs"); - guard.visit_file(&syn::parse_file(&source).unwrap()); - assert!(guard.violations.is_empty(), "{:?}", guard.violations); - let mut guard = Guard::new("tinycloud-auth/src/other.rs"); - guard.visit_file(&syn::parse_file(&source).unwrap()); - assert!( - !guard.violations.is_empty(), - "registry exception spread to another file" - ); - } - for source in [ - format!("{registry}\nasync fn verify_with_any(u: &Ucan) {{ u.verify_signature(&*DID_METHODS).await; }}"), - registry.replace("AnyDidMethod::default()", "AnyDidMethod::default().resolve(did)"), - registry.replace("pub static ref", "static ref"), - format!("{registry}\nwrapper!(DID_METHODS.resolve(did));"), - ] { - let mut guard = Guard::new("tinycloud-auth/src/resolver.rs"); - guard.visit_file(&syn::parse_file(&source).unwrap()); - assert!(!guard.violations.is_empty(), "auth bypass: {source}"); - } -} - -#[test] -fn offline_cacao_exceptions_are_exact_and_do_not_hide_claims_resolvers() { - for (file, function, receiver) in [ - ("tinycloud-core/src/models/delegation.rs", "verify", "cacao"), - ("tinycloud-core/src/models/revocation.rs", "process", "c"), - ( - "tinycloud-node-server/src/policy_v3.rs", - "authenticate_account_owner", - "cacao", - ), - ( - "tinycloud-node-server/src/share_v2.rs", - "verify_delegation_signature", - "cacao", - ), - ] { - let mut guard = Guard::new(file); - guard.function = function.into(); - let call: syn::ExprMethodCall = syn::parse_str(&format!("{receiver}.verify()")).unwrap(); - assert!(guard.offline_cacao_verification(&call)); - guard.visit_expr_method_call(&call); - assert!(guard.violations.is_empty()); - let call: syn::ExprMethodCall = syn::parse_str(&format!( - "{receiver}.verify(VerificationParameters::from_resolver(DIDWeb.into_vm_resolver::()))" - )).unwrap(); - assert!(!guard.offline_cacao_verification(&call)); - guard.visit_expr_method_call(&call); - assert!( - !guard.violations.is_empty(), - "CACAO exception hid a claims resolver" - ); - guard.function = "another_function".into(); - let call = syn::parse_str(&format!("{receiver}.verify()")).unwrap(); - assert!(!guard.offline_cacao_verification(&call)); - } -} - -#[test] -fn vm_and_claims_bypasses_and_existing_raw_spellings_are_refused() { - // Review 5513828343's four surviving spellings and two already killed - // spellings. Parsing these fixtures never invokes a resolver or claims verifier. - let cases = [ - ("web VM", "DIDWeb.into_vm_resolver::().resolve_verification_method_with(id, None, options).await;"), - ("tz VM", "DIDTz::default().into_vm_resolver::().resolve_verification_method_with(id, None, options).await;"), - ("web claims", "jws.verify(VerificationParameters::from_resolver(DIDWeb.into_vm_resolver::())).await;"), - ("tz claims", "jws.verify(VerificationParameters::from_resolver(DIDTz::default().into_vm_resolver::())).await;"), - ("raw signature", "u.verify_signature(&AnyDidMethod::default()).await;"), - ("raw resolve", "DID_METHODS.resolve(d).await;"), - ]; - for (name, expression) in cases { - let production = format!("async fn production() {{ {expression} }}"); - let guard = scan_fixture(&production); - assert!(!guard.violations.is_empty(), "{name} bypass survived"); - let test_only = format!("#[cfg(test)] mod fixture {{ {production} }}"); - assert!( - scan_fixture(&test_only).violations.is_empty(), - "{name} test fixture was scanned" - ); - } -} - -#[test] -fn vm_method_families_are_refused_without_concrete_type_names() { - for expression in [ - "adapter.into_vm_resolver::();", - "adapter.into_vm_resolver_with::(options);", - "adapter.resolve_verification_method(id).await;", - "adapter.resolve_verification_method_with(id, None, options).await;", - "DIDResolver::into_vm_resolver::(adapter);", - "DIDResolver::into_vm_resolver_with::(adapter, options);", - "VerificationMethodResolver::resolve_verification_method_with(&adapter, id, None, options).await;", - "wrapper!(adapter.into_vm_resolver_with::(options));", - "wrapper!(adapter.resolve_verification_method_with(id, None, options));", - ] { - let guard = scan_fixture(&format!("async fn production() {{ {expression} }}")); - assert!(!guard.violations.is_empty(), "method bypass survived: {expression}"); - } -} - -#[test] -fn network_resolver_imports_paths_and_claims_parameters_are_refused() { - for ident in [ - "DIDWeb", - "DIDTz", - "DIDION", - "DIDEthr", - "VerificationMethodDIDResolver", - "VerificationParameters", - "did_web", - "did_tz", - "did_ion", - "did_ethr", - ] { - for source in [ - format!("use provider::{ident} as Renamed;"), - format!("type Adapter = provider::{ident};"), - format!("wrapper!(provider::{ident});"), - ] { - assert!( - !scan_fixture(&source).violations.is_empty(), - "identifier bypass survived: {source}" - ); - } - } - for module in ["web", "tz", "ion", "ethr"] { - for source in [ - format!("use provider::dids::{module} as Renamed;"), - format!("use provider::dids::{{{module} as Renamed, key::DIDKey}};"), - format!("use provider::dids::{{{module}::*, key::DIDKey}};"), - format!("type Adapter = provider::dids::{module}::Resolver;"), - format!("wrapper!(provider::dids::{module}::Resolver);"), - format!("wrapper!(use provider::dids::{{{module} as Renamed, key::DIDKey}});"), - ] { - assert!( - !scan_fixture(&source).violations.is_empty(), - "module bypass survived: {source}" - ); - } - } -} - -#[test] -fn non_did_module_names_comments_and_strings_remain_allowed() { - let source = r#" - // DIDWeb.into_vm_resolver::() is documentation, not a call. - use provider::{web, tz, ion, ethr}; - use provider::dids::{key::web as key_web, pkh}; - fn production() { - let _description = "VerificationParameters and provider::dids::web"; - web::request(); - tz::offset(); - ion::charge(); - ethr::lookup(); - wrapper!(provider::dids::key::web); - wrapper!(use provider::dids::{key::web as key_web}); - } - "#; - let guard = scan_fixture(source); - assert!(guard.violations.is_empty(), "{:?}", guard.violations); -} From 92cccb001d880198c768c5bc6f0e724c373dd93c Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Fri, 16 Oct 2026 00:03:49 +0000 Subject: [PATCH 20/20] TC-1149: Stabilize PostgreSQL budget fixture --- .../tests/postgres_round_trip_budget.rs | 244 +++++++++++++++++- 1 file changed, 232 insertions(+), 12 deletions(-) diff --git a/tinycloud-node-server/tests/postgres_round_trip_budget.rs b/tinycloud-node-server/tests/postgres_round_trip_budget.rs index 4a6d191f..059c1b5f 100644 --- a/tinycloud-node-server/tests/postgres_round_trip_budget.rs +++ b/tinycloud-node-server/tests/postgres_round_trip_budget.rs @@ -53,12 +53,13 @@ //! cache has expired, so whether a given SQL call pays it depends on wall //! time. //! -//! The counts are deterministic without sleeping for them: every request is -//! measured between two barriers that wait until every pooled connection is -//! idle again, so the release ping of the last statement is inside the window -//! and no request overlaps another's traffic. The flows run three times -//! first, so the pool has grown to the connections it needs and the -//! statements are prepared. +//! The counts are deterministic: every request is measured between two +//! barriers that wait until every pooled connection is idle again, so the +//! release ping of the last statement is inside the window and no request +//! overlaps another's traffic. The flows run three times first, so the pool +//! has grown to the connections they need and the statements are prepared. +//! A dispatcher poll executed after warm-up is settled before measurement; +//! its connection setup is attributed separately from request traffic. //! //! # Budgets //! @@ -196,7 +197,7 @@ struct RoundTrip { #[derive(Default)] struct Log { - connections: u64, + started_connections: Vec, round_trips: Vec, } @@ -205,7 +206,7 @@ struct Wire(Arc>); #[derive(Clone, Copy)] struct Mark { - connections: u64, + connections: usize, round_trips: usize, } @@ -217,17 +218,80 @@ impl Wire { fn mark(&self) -> Mark { let log = self.log(); Mark { - connections: log.connections, + connections: log.started_connections.len(), round_trips: log.round_trips.len(), } } fn since(&self, mark: Mark) -> Window { let log = self.log(); - Window { - connections: log.connections - mark.connections, + let new_connections = &log.started_connections[mark.connections..]; + let mut window = Window { + connections: new_connections.len() as u64, round_trips: log.round_trips[mark.round_trips..].to_vec(), + }; + // A new connection belongs to the dispatcher only if its first + // executed non-setup SQL is an actual dispatcher SELECT. At most one + // exact startup timeout SET and bare pings can precede it. A second + // SET, a SQL batch, another statement, or a Parse without execution + // never hides request work or a request-path connection. + for &connection in new_connections { + let mut saw_setup_set = false; + let mut saw_poll_parse = false; + let mut poll_execution = None; + for (index, round_trip) in window + .round_trips + .iter() + .enumerate() + .filter(|(_, round_trip)| round_trip.connection == connection) + { + if !saw_poll_parse + && round_trip.kind == Kind::Counted + && round_trip.executed.is_empty() + && round_trip.parsed.is_empty() + { + continue; + } + if !saw_poll_parse + && !saw_setup_set + && round_trip.kind == Kind::Counted + && round_trip.parsed.is_empty() + && round_trip.executed.as_slice() == ["SET statement_timeout = 30000"] + { + saw_setup_set = true; + continue; + } + if round_trip.kind == Kind::Excluded("webhook dispatcher poll") + && round_trip + .parsed + .iter() + .all(|sql| background(sql) == Some("webhook dispatcher poll")) + { + if round_trip.executed.is_empty() && !round_trip.parsed.is_empty() { + saw_poll_parse = true; + continue; + } + if !round_trip.executed.is_empty() + && round_trip + .executed + .iter() + .all(|sql| background(sql) == Some("webhook dispatcher poll")) + { + poll_execution = Some(index); + } + } + break; + } + if let Some(poll_execution) = poll_execution { + window.connections -= 1; + for round_trip in &mut window.round_trips[..poll_execution] { + if round_trip.connection == connection && round_trip.kind == Kind::Counted { + round_trip.kind = Kind::Excluded("webhook dispatcher setup"); + } + } + } } + window } } @@ -310,7 +374,7 @@ impl FrontendParser { } if be_i32(&self.buf[4..8]) == PROTOCOL_V3 { self.started = true; - self.wire.log().connections += 1; + self.wire.log().started_connections.push(self.connection); } self.buf.drain(..len); } else { @@ -582,6 +646,127 @@ impl Window { } } +const DISPATCH_POLL_SQL: &str = r#"SELECT "hook_delivery"."id" AS "A_id""#; + +#[test] +fn dispatcher_connection_setup_is_excluded_without_hiding_request_connections() { + let wire = Wire::default(); + let mark = wire.mark(); + let trip = |connection, kind, sql: &str| RoundTrip { + connection, + kind, + executed: vec![sql.to_owned()], + parsed: Vec::new(), + }; + { + let mut log = wire.log(); + log.started_connections.extend([11, 12]); + log.round_trips.extend([ + trip(11, Kind::Counted, "SET statement_timeout = 30000"), + trip(12, Kind::Counted, "SET statement_timeout = 30000"), + RoundTrip { + connection: 11, + kind: Kind::Counted, + executed: Vec::new(), + parsed: Vec::new(), + }, + trip( + 11, + Kind::Excluded("webhook dispatcher poll"), + DISPATCH_POLL_SQL, + ), + trip(11, Kind::Counted, "SELECT current_kv"), + trip(12, Kind::Counted, "SELECT current_kv"), + trip( + 12, + Kind::Excluded("webhook dispatcher poll"), + DISPATCH_POLL_SQL, + ), + ]); + } + let window = wire.since(mark); + let counts = window.counts(); + assert_eq!(counts.connections, 1, "request connection setup is counted"); + assert_eq!( + counts.round_trips, 3, + "request and reused-connection SQL is counted" + ); + assert_eq!( + counts.excluded, 4, + "only dispatcher setup, ping and polls are excluded" + ); + assert_eq!( + window.round_trips[0].kind, + Kind::Excluded("webhook dispatcher setup") + ); + assert_eq!(window.round_trips[1].kind, Kind::Counted); + assert_eq!( + window.round_trips[2].kind, + Kind::Excluded("webhook dispatcher setup") + ); + assert_eq!(window.round_trips[4].kind, Kind::Counted); +} + +#[test] +fn dispatcher_attribution_never_hides_duplicate_setup_parse_or_prior_request_sql() { + let wire = Wire::default(); + let mark = wire.mark(); + let trip = |connection, kind, sql: &str| RoundTrip { + connection, + kind, + executed: vec![sql.to_owned()], + parsed: Vec::new(), + }; + { + let mut log = wire.log(); + log.started_connections.extend([13, 14, 15, 16]); + log.round_trips.extend([ + trip(13, Kind::Counted, "SET statement_timeout = 30000"), + trip(13, Kind::Counted, "SET statement_timeout = 30000"), + trip( + 13, + Kind::Excluded("webhook dispatcher poll"), + DISPATCH_POLL_SQL, + ), + trip(14, Kind::Counted, "SET statement_timeout = 30000"), + RoundTrip { + connection: 14, + kind: Kind::Excluded("webhook dispatcher poll"), + executed: Vec::new(), + parsed: vec![DISPATCH_POLL_SQL.to_owned()], + }, + trip(15, Kind::Counted, "SET statement_timeout = 30000"), + trip(15, Kind::Counted, "SELECT current_kv"), + trip( + 15, + Kind::Excluded("webhook dispatcher poll"), + DISPATCH_POLL_SQL, + ), + trip( + 16, + Kind::Counted, + "SET statement_timeout = 30000; SELECT current_kv", + ), + trip( + 16, + Kind::Excluded("webhook dispatcher poll"), + DISPATCH_POLL_SQL, + ), + ]); + } + let window = wire.since(mark); + let counts = window.counts(); + assert_eq!(counts.connections, 4, "all four are request-side setups"); + assert_eq!(counts.round_trips, 6, "no SET or request SQL is hidden"); + assert_eq!( + counts.excluded, 4, + "only dispatcher parse/SELECTs are excluded" + ); + for index in [0, 1, 3, 5, 6, 8] { + assert_eq!(window.round_trips[index].kind, Kind::Counted); + } +} + // --------------------------------------------------------------------------- // The node under test // --------------------------------------------------------------------------- @@ -953,6 +1138,36 @@ async fn run(url: &str, schema: &str) -> Result> { .await .with_context(|| format!("warm-up round {seed}"))?; } + // The dispatcher may open a connection after request warm-up; a + // background SET precedes its poll SQL. Wait for an actual execution + // after this mark (not an older poll or a statement-cache Parse), then + // settle the pool before starting the measured requests. + let after_warm_up = node.wire.mark(); + let poll_deadline = Instant::now() + Duration::from_secs(5); + loop { + let polled = { + let log = node.wire.log(); + log.round_trips[after_warm_up.round_trips..] + .iter() + .any(|round_trip| { + round_trip.kind == Kind::Excluded("webhook dispatcher poll") + && round_trip + .executed + .iter() + .any(|sql| background(sql) == Some("webhook dispatcher poll")) + }) + }; + if polled { + break; + } + ensure!( + Instant::now() < poll_deadline, + "no dispatcher poll executed after warm-up within five seconds" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + node.settle().await?; + println!("post-warm-up dispatcher execution settled before measured requests"); let mut measured = Vec::new(); round(&node, &Account::new(WARM_UP_ROUNDS + 1)?, &mut measured) .await @@ -1005,6 +1220,11 @@ async fn postgres_round_trip_budget() { let mut detail = String::new(); for request in measured.iter().filter(|m| m.flow == flow) { let counts = request.window.counts(); + assert_eq!( + counts.connections, 0, + "{flow} {}: a request opened an unwarmed PostgreSQL connection", + request.request + ); total += counts; let _ = writeln!( detail,