diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index ebc0cad8..1ddbc72d 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/CHANGELOG.md b/CHANGELOG.md index de734dab..b272f6ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [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). Keep the load signer compatible with the SDK's default invocation lifetime. + +- 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). - Harden optional DuckDB statement and settings policy (TC-1304). - Tighten revocation scope checks (TC-1302). - Account delegation session discovery keyset-pages delegation rows independently of ability fan-out, using a composite delegator/id index so large grant sets are not truncated (TC-1195). diff --git a/Cargo.lock b/Cargo.lock index cdb833c8..2f23023d 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..03ccf645 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, @@ -193,10 +221,30 @@ impl AsRef<[u8]> for Signature { } } +impl Signature { + /// 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() { + 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 +254,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 +340,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/docs/delegation-flow.md b/docs/delegation-flow.md index e8ff79ad..55b236aa 100644 --- a/docs/delegation-flow.md +++ b/docs/delegation-flow.md @@ -431,6 +431,133 @@ 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. + +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 +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. + +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 +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. + +## 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 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. + +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 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 +revoke. + +Policy registration persists a versioned, signed-byte-bound witness for its +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 +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/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/load/signer/src/main.rs b/test/load/signer/src/main.rs index 8d8c98b6..111af1e3 100644 --- a/test/load/signer/src/main.rs +++ b/test/load/signer/src/main.rs @@ -80,6 +80,7 @@ async fn new_user(wallet: LocalWallet, jwk: JWK) -> User { jwk: Some(jwk), delegate_uri: None, nonce: None, + invocation_ttl_secs: None, issued_at: TimeStamp::from_str("1985-04-12T23:20:50.52Z").unwrap(), expiration_time: TimeStamp::from_str("2985-04-12T23:20:50.52Z").unwrap(), }; diff --git a/test/m1-realdata-e2e/Cargo.lock b/test/m1-realdata-e2e/Cargo.lock index d978db99..5941dace 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/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())) diff --git a/test/w5-policy-runtime-node-e2e/Cargo.lock b/test/w5-policy-runtime-node-e2e/Cargo.lock index ed9cdf47..2eac9317 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", 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", diff --git a/tinycloud-auth/src/authorization.rs b/tinycloud-auth/src/authorization.rs index 0baee950..ab3a419c 100644 --- a/tinycloud-auth/src/authorization.rs +++ b/tinycloud-auth/src/authorization.rs @@ -48,11 +48,15 @@ impl HeaderEncode for TinyCloudDelegation { s.as_bytes().to_vec(), ) } else { - // Use the imported engine and trait method + // 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)?; ( - 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/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/auth_graph.rs b/tinycloud-core/src/auth_graph.rs index 0fafaecd..2a9af614 100644 --- a/tinycloud-core/src/auth_graph.rs +++ b/tinycloud-core/src/auth_graph.rs @@ -10,14 +10,15 @@ //! 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::*, 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; @@ -29,6 +30,132 @@ 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) +} + +/// 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], + 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 +201,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 +225,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 +306,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 +356,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 527fcbff..cef92e30 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}, @@ -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] @@ -343,11 +366,39 @@ 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?; + // 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(()))); let read_audit = ReadAuditPipeline::start(pool, writer_lock.clone()); @@ -355,7 +406,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 +419,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 @@ -431,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) @@ -543,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 @@ -555,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 @@ -568,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. @@ -592,10 +707,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()); } @@ -627,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)?; @@ -1292,7 +1411,26 @@ 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), + revocation::ChainTraversalError::LimitExceeded => { + TxError::ChainTraversalLimitExceeded + } + })?; + 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), @@ -1300,9 +1438,31 @@ where 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 + /// 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()), + })?; + self.acquire_shared_chain_guards_bounded(keys).await + } + /// Resolve `keys` to their per-delegation locks in a stable global order. /// /// Sorting and deduplicating here is the deadlock discipline: every @@ -1398,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)], @@ -1442,7 +1616,168 @@ 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); + } + } + } + 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. + 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, @@ -1451,7 +1786,7 @@ where events, self.encryption.as_ref(), None, - &[], + &spaces, ) .await?; @@ -1646,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 @@ -1653,13 +2007,35 @@ 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 = + 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(); - 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()), @@ -1744,6 +2120,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, @@ -1755,6 +2134,236 @@ 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()); + 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()) + .one(&self.conn) + .await + .map_err(|error| db_error(error, false))?; + if let Some(row) = &existing { + if let Some(receipt) = exact_receipt(row)? { + return Ok(receipt); + } + } + + 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))?; + // A concurrent request may have committed the identical self-revoke + // while this one waited for its target lock. Its receipt remains + 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); + } + } + 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 mut recovery_expires_at = None; + 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(), + &mut recovery_expires_at, + ) + .await + .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); + } + // 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()], + }; + 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)), + } + 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); + } + // 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(); + 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, @@ -1777,12 +2386,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 authorized = match revocation::control_proof_decision( @@ -1814,6 +2425,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)); } @@ -1825,6 +2464,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 @@ -1981,24 +2625,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_closure_edges(&self.conn, &guard_roots) + let (guard_chain_ids, _) = crate::auth_graph::load_closure_edges(&self.conn, &guard_roots) .await - .map(|(keys, _)| keys) - .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, @@ -2008,13 +2685,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 @@ -2036,9 +2748,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; @@ -2109,10 +2847,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. @@ -2327,16 +3092,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, @@ -2377,27 +3144,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() { @@ -2409,6 +3169,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)) => { @@ -2592,6 +3355,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); @@ -2883,9 +3810,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), @@ -3881,7 +4808,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 { @@ -3915,15 +4842,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, @@ -3933,12 +4870,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. @@ -3951,51 +4886,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:...). @@ -4311,17 +5211,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())); @@ -4341,7 +5238,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 @@ -4349,8 +5246,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, @@ -4360,7 +5256,6 @@ async fn get_filtered_delegations now).unwrap_or(true) && del.not_before.map(|n| n <= now).unwrap_or(true)) @@ -4428,32 +5322,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. @@ -4463,7 +5338,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 @@ -4480,8 +5355,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?; @@ -4510,31 +5384,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 @@ -5834,6 +6685,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 @@ -6559,10 +7417,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_closure_edges(&db.conn, roots) + let (signed, _) = crate::auth_graph::load_closure_edges(&db.conn, roots) + .await + .expect("signed chain closure"); + crate::auth_graph::invocation_authority_for_closure(&db.conn, signed) .await - .map(|(keys, _)| keys) - .expect("chain closure query") + .expect("canonical alias locks") + .0 } async fn acquire_invocation_guards(db: &TestDb, roots: &[Hash]) -> Vec { @@ -6591,6 +7452,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. /// @@ -6703,6 +7607,279 @@ mod test { drop(after); } + /// 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_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 { + 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 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 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 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(); + 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(); + 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(), + "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 /// against invocations on the same chain, in both orders. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 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-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/key_authority.rs b/tinycloud-core/src/key_authority.rs new file mode 100644 index 00000000..3c08bb11 --- /dev/null +++ b/tinycloud-core/src/key_authority.rs @@ -0,0 +1,238 @@ +//! 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::{Expr, 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(()); + } + // 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(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 { + query.lock_shared().all(tx).await? + }; + if rows.len() != principals.len() { + return Err(DbErr::Custom("authorization-key-lock-missing".into())); + } + } + 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() + .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/m20261014_000000_delegation_cid_alias.rs b/tinycloud-core/src/migrations/m20261014_000000_delegation_cid_alias.rs new file mode 100644 index 00000000..349e3dd0 --- /dev/null +++ b/tinycloud-core/src/migrations/m20261014_000000_delegation_cid_alias.rs @@ -0,0 +1,92 @@ +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> { + 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(id.not_null().primary_key()) + .col(canonical.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/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..dce2e38b --- /dev/null +++ b/tinycloud-core/src/migrations/m20261016_000001_key_revocation.rs @@ -0,0 +1,222 @@ +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_len(32) + .not_null(), + ) + .col( + ColumnDef::new(key_revocation::nonce::Column::TargetDid) + .string() + .not_null(), + ) + .col( + ColumnDef::new(key_revocation::nonce::Column::ReceiptBytes) + .blob() + .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 + } +} + +#[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/migrations/mod.rs b/tinycloud-core/src/migrations/mod.rs index ee7f0d2f..1477a8d9 100644 --- a/tinycloud-core/src/migrations/mod.rs +++ b/tinycloud-core/src/migrations/mod.rs @@ -24,7 +24,10 @@ 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 mod m20261015_000000_delegation_cursor_index; +pub mod m20261016_000000_policy_witness; +pub mod m20261016_000001_key_revocation; pub struct Migrator; @@ -57,7 +60,10 @@ 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), Box::new(m20261015_000000_delegation_cursor_index::Migration), + Box::new(m20261016_000000_policy_witness::Migration), + Box::new(m20261016_000001_key_revocation::Migration), ] } } @@ -75,7 +81,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}_{}_{}", @@ -132,6 +138,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..efc03d9a 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}; @@ -192,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) @@ -237,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 @@ -781,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() @@ -835,10 +906,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 +920,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..9f320a24 --- /dev/null +++ b/tinycloud-core/src/models/delegation_cid_alias.rs @@ -0,0 +1,487 @@ +//! 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, 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)] +#[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 Some((id, stored)) = delegation::Entity::find() + .select_only() + .column(delegation::Column::Id) + .column(delegation::Column::Serialization) + .join_rev(JoinType::InnerJoin, Relation::Delegation.def()) + .filter(Column::Encrypted.eq(true)) + .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; + 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() + .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; + + assert!(backfill(&db, None).await.is_err()); + let wrong = ColumnEncryption::new([0x43; 32]); + assert!(backfill(&db, Some(&wrong)).await.is_err()); + 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; + assert!(backfill(&db, None).await.is_err()); + 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(); + assert!(backfill(&transaction, None).await.is_err()); + 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); + } + + #[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", + ); + } +} 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/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 a8a0afca..8d5a6e15 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; @@ -17,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/models/revocation.rs b/tinycloud-core/src/models/revocation.rs index bf79bafc..537e1f01 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,109 @@ pub enum ChainTraversalError { LimitExceeded, } -pub(crate) async fn is_revoked( +/// 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, - delegation_id: &Hash, -) -> Result { + 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() +} + +/// 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(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( @@ -318,26 +415,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) @@ -355,7 +444,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_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..fa3df479 --- /dev/null +++ b/tinycloud-core/src/policy_witness.rs @@ -0,0 +1,975 @@ +//! 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; +const CANDIDATE_PAGE_SIZE: u64 = 128; + +#[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)); + if id == tip && !has_capability { + return Ok(None); + } + 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 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(); + 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)); + } + 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)); + 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 { + 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) + .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)?; + // 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 { + 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 load_dependencies(db, ®istration.policy_cid, false).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..fdfc3b08 --- /dev/null +++ b/tinycloud-core/src/recovery_authority.rs @@ -0,0 +1,393 @@ +//! 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>, + proof_expires_at: &mut Option, +) -> 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 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. + 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()], + }; + 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, 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), + } +} + +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/fixtures/tc1071/generated/active.sqlite b/tinycloud-core/tests/fixtures/tc1071/generated/active.sqlite new file mode 100644 index 00000000..cf3d117e Binary files /dev/null and b/tinycloud-core/tests/fixtures/tc1071/generated/active.sqlite differ diff --git a/tinycloud-core/tests/fixtures/tc1071/generated/encrypted-active.sqlite b/tinycloud-core/tests/fixtures/tc1071/generated/encrypted-active.sqlite new file mode 100644 index 00000000..432dcfa0 Binary files /dev/null and b/tinycloud-core/tests/fixtures/tc1071/generated/encrypted-active.sqlite differ 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 00000000..68156e31 Binary files /dev/null and b/tinycloud-core/tests/fixtures/tc1071/generated/revoked-v0.sqlite differ 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 00000000..0acc6d5a Binary files /dev/null and b/tinycloud-core/tests/fixtures/tc1071/generated/revoked-v27.sqlite differ 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 00000000..13dc768c Binary files /dev/null and b/tinycloud-core/tests/fixtures/tc1071/generated/single-v0.sqlite differ 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..b2e3e90e --- /dev/null +++ b/tinycloud-core/tests/fixtures/tc1071/upgrade_checks.rs @@ -0,0 +1,449 @@ +use super::*; +use sea_orm_migration::{MigrationTrait, MigratorTrait}; +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" + ); + } + } +} + +struct BeforeAliasMigrator; + +#[async_trait::async_trait] +impl MigratorTrait for BeforeAliasMigrator { + fn migrations() -> Vec> { + tinycloud_core::migrations::Migrator::migrations() + .into_iter() + .filter(|migration| migration.name() != "m20261014_000000_delegation_cid_alias") + .collect() + } +} + +#[tokio::test] +async fn later_schema_upgrade_preserves_revoked_historical_grants() { + for fixture in fixtures().into_iter().filter(|fixture| { + matches!( + fixture.database.as_str(), + "revoked-v0.sqlite" | "revoked-v27.sqlite" + ) + }) { + let (_directory, path) = copy_database(&fixture); + let before = signed_snapshot(&path); + let connection = connection(&path).await; + BeforeAliasMigrator::up(&connection, None).await.unwrap(); + assert!( + connection + .query_one(Statement::from_string( + DbBackend::Sqlite, + "SELECT version FROM seaql_migrations WHERE version = 'm20261015_000000_delegation_cursor_index'".to_string(), + )) + .await + .unwrap() + .is_some(), + "exercise an already-applied later migration before alias backfill" + ); + drop(connection); + + let db = open(&path).await; + assert_eq!(signed_snapshot(&path), before); + assert_signed_rows_untouched(&db, &fixture).await; + assert_aliases(&path, &fixture); + assert_denied(&db, &fixture).await; + for row in &fixture.rows { + register(&db, &row.header).await; + } + assert_denied(&db, &fixture).await; + drop(db); + let db = open(&path).await; + assert_denied(&db, &fixture).await; + } +} + +#[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..d0dc433c --- /dev/null +++ b/tinycloud-core/tests/historical_cacao_revocation.rs @@ -0,0 +1,500 @@ +#[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"); + 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"); + 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_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..6da60938 --- /dev/null +++ b/tinycloud-core/tests/key_revoke_recovery_order.rs @@ -0,0 +1,640 @@ +//! 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}, + key_authority, + 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_target_updates( + admin: &DatabaseConnection, + 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 { + // 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 {blocker_clause} \ + AND query LIKE '%key_revocation_target%' AND query LIKE '%FOR UPDATE%'", + names.join("','"), + ), + )) + .await + .expect("inspect isolated PostgreSQL wait events"); + if rows.len() == names.len() { + 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_target_updates(&admin, &[&first_name, &second_name], Some(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(); +} + +/// 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-core/tests/policy_witness_legacy.rs b/tinycloud-core/tests/policy_witness_legacy.rs new file mode 100644 index 00000000..783df29d --- /dev/null +++ b/tinycloud-core/tests/policy_witness_legacy.rs @@ -0,0 +1,487 @@ +//! 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); +} + +#[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); +} 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/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/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..9738cdcc --- /dev/null +++ b/tinycloud-node-server/src/key_revoke.rs @@ -0,0 +1,229 @@ +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, + ssi::dids::DID, +}; +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'/')) + || DID::new(&target_did).is_err() + || 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 f2c49408..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, @@ -622,13 +625,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()); @@ -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 d307a625..bbf176ac 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(()) } } @@ -2912,11 +3019,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") } @@ -3391,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, @@ -3494,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, @@ -3588,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() @@ -4720,6 +4835,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 { @@ -5345,6 +5484,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 +5599,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 +5620,7 @@ async fn authenticate_account_owner( owner_did, space: credentials_space, expires_at: event.0.expiry, + _chain_guards: chain_guards, }) } @@ -5495,12 +5639,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 @@ -7949,38 +8090,97 @@ 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) + } + + #[test] + fn signed_policy_root_revoke_closes_sessions_and_replays_idempotently() -> anyhow::Result<()> { + 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. - #[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) + } + + #[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. - #[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 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, } - async fn run_policy_v2_flow(policy_owner: PolicyOwner) -> anyhow::Result<()> { + // 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<()> { + 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 || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("policy fixture runtime") + .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, + signed_root_revoke: bool, + ) -> anyhow::Result<()> { use k256::ecdsa::SigningKey; use sha3::Keccak256; use tinycloud_auth::cacaos::siwe::encode_eip55; @@ -8017,7 +8217,11 @@ 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 @@ -8053,19 +8257,37 @@ 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", @@ -8240,7 +8462,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(), @@ -8440,6 +8670,10 @@ mod tests { crate::routes::invoke, crate::routes::revoke, 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 ], @@ -8471,7 +8705,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 @@ -8538,9 +8772,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"] { + for ability in [ + "tinycloud.kv/get", + "tinycloud.kv/metadata", + "tinycloud.kv/put", + ] { owner_capabilities.with_action( content_resource.as_uri(), ability.parse::()?, @@ -8850,6 +9093,377 @@ 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()); + + // 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) + } 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 + ); + 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() + .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 { + 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 + // 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 @@ -9153,7 +9767,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))?, )?, } @@ -9229,10 +9843,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 @@ -9390,7 +10003,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))?, )?, } @@ -9457,7 +10070,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"), ( @@ -9500,13 +10114,14 @@ 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))?, )?, } .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( @@ -9520,6 +10135,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 @@ -9546,12 +10166,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) @@ -9559,7 +10185,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); @@ -9591,6 +10217,125 @@ 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" + ); + + 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. @@ -9631,6 +10376,108 @@ 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 [ + (&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) @@ -9644,96 +10491,399 @@ 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 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"], + "requirement": requirement, + "credential": credential, + "presentation": signed, + }) + .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 + }; + + // 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 { + "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" + ); + } + 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 { + 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": root_role, + "ownerDid": owner_did, + "nodeAudience": node_did, + "revokedAt": format_time(OffsetDateTime::now_utc() + Duration::seconds(30)), + "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}"); + 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, - "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, + challenge.status(), Status::Forbidden, - "mint after owner revoke: {refused_body}" + "{root_field} fresh challenge after revoke" ); - assert_eq!(refused_body, "policy-owner-not-authorized"); - } - - // 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"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("missing policy root cid"))? - .parse::()?; - let mut revoke_capabilities = Capabilities::::new(); - revoke_capabilities.with_action( - format!("urn:cid:{policy_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, + 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}" + ); + } } - .sign(Algorithm::EdDSA, &owner_jwk)?, - )); - let revoke_response = 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}"); + 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 { @@ -9749,7 +10899,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))?, )?, } @@ -9804,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(()) } @@ -10181,3 +11376,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/routes/hooks.rs b/tinycloud-node-server/src/routes/hooks.rs index 3175984b..f695c7f1 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"); @@ -945,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, @@ -1013,14 +1063,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 +1096,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 +1108,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!( @@ -2093,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( "/", @@ -2101,7 +2163,8 @@ mod tests { hook_events, create_webhook, list_webhooks, - delete_webhook + delete_webhook, + crate::key_revoke::revoke_key ], ) .manage(tinycloud.clone()) @@ -2110,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 { @@ -2408,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 = @@ -2852,6 +3083,255 @@ 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 (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)) + .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 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)) + .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 43031cb5..870330f2 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, @@ -4984,6 +4997,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 { @@ -5391,9 +5418,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}, @@ -5402,11 +5437,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 @@ -5514,8 +5629,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] { @@ -5525,10 +5646,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()], ); @@ -5546,30 +5683,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?; @@ -5578,7 +5724,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), @@ -5586,7 +5732,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()), @@ -5595,37 +5760,93 @@ 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 (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, @@ -5696,6 +5917,45 @@ mod tests { None, )); let query_control_resource = control_resource.clone(); + // The wallet-space session cannot control a target that also carries + // the owner's separate control space. An exact-CID grant from the + // target's delegatee keeps the positive status/revoke path scoped. + let exact_control_resource = + Resource::Other(format!("urn:cid:{}", wallet_child_id.to_cid(0x55)).parse()?); + let (exact_serialization, exact_issued, exact_expires) = signed_wallet_session( + &wallet_key, + wallet_address, + &holder_did, + &exact_control_resource, + "tc1149-wallet-exact-session", + )?; + let wallet_exact_session_id = tinycloud_core::hash::hash(&exact_serialization); + deleg_model::ActiveModel { + id: Set(wallet_exact_session_id), + delegator: Set(wallet_pkh.clone()), + delegatee: Set(holder_did.clone()), + expiry: Set(Some(exact_expires)), + issued_at: Set(Some(exact_issued)), + not_before: Set(None), + facts: Set(None), + serialization: Set(exact_serialization), + } + .insert(&conn) + .await?; + for action in [ + "tinycloud.delegation/list", + "tinycloud.delegation/status", + "tinycloud.delegation/revoke", + ] { + abilities::ActiveModel { + delegation: Set(wallet_exact_session_id), + resource: Set(exact_control_resource.clone()), + ability: Set(Ability::try_from(action.to_string())?), + caveats: Set(Default::default()), + } + .insert(&conn) + .await?; + } for action in [ "tinycloud.delegation/status", "tinycloud.delegation/revoke", @@ -5988,12 +6248,26 @@ mod tests { &holder_vm, &holder_did, &wallet_child_cid, - "wallet-session-status", + "wallet-space-not-full-target", vec![wallet_session_id.to_cid(0x55)], )?, ) .dispatch() .await; + assert_eq!(response.status(), Status::NotFound); + let response = request( + &wallet_child_cid, + status_header_with_proofs( + &holder_jwk, + &holder_vm, + &holder_did, + &wallet_child_cid, + "wallet-exact-session-status", + vec![wallet_exact_session_id.to_cid(0x55)], + )?, + ) + .dispatch() + .await; assert_eq!(response.status(), Status::Ok); let body: serde_json::Value = serde_json::from_str(&response.into_string().await.unwrap())?; assert_eq!(body["status"], "active"); @@ -6065,6 +6339,7 @@ mod tests { } for (label, proof_id) in [ + ("wallet-space-not-full-target-revoke", wallet_session_id), ("unrelated-capability-revoke", unrelated_session_id), ("wrong-target-resource-revoke", wrong_target_session_id), ("foreign-root-scope-revoke", foreign_scope_session_id), @@ -6098,7 +6373,7 @@ mod tests { &holder_vm, &holder_did, &wallet_child_cid, - wallet_session_id.to_cid(0x55), + wallet_exact_session_id.to_cid(0x55), )?, )) .dispatch() @@ -6112,8 +6387,8 @@ mod tests { &holder_vm, &holder_did, &wallet_child_cid, - "wallet-session-post-revoke", - vec![wallet_session_id.to_cid(0x55)], + "wallet-exact-session-post-revoke", + vec![wallet_exact_session_id.to_cid(0x55)], )?, ) .dispatch() @@ -6130,7 +6405,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), @@ -6228,6 +6503,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 f3673fd6..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(|_| ())?; @@ -1402,13 +1432,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() @@ -1939,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() { @@ -2015,7 +2040,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 { @@ -2023,12 +2060,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( @@ -2319,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(()) @@ -2882,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?; @@ -2893,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"))? @@ -2939,6 +2971,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") @@ -3278,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"))?; @@ -3305,9 +3355,25 @@ 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(); + 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) @@ -3409,17 +3475,23 @@ 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::*; use rocket::local::asynchronous::Client; use time::Duration; + #[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() { assert_eq!(POLICY_DOMAIN, "xyz.tinycloud.share/policy/v2\0"); @@ -3516,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] @@ -3535,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] @@ -4532,26 +4639,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/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..09ffe5f9 --- /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, None).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; +} 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..151e1d20 --- /dev/null +++ b/tinycloud-node-server/src/share_v2/tests/share_v2_legacy_key_tests.rs @@ -0,0 +1,781 @@ +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. 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, + 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-10-14T00: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_791_936_000.0).expect("October 14, 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_791_936_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.clone()), + 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), + }; + let row = row + .insert(&runtime.conn) + .await + .expect("historical policy registration row"); + // 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!({ + "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(); + let outer_signature = decode_config( + challenge_request.outer_envelope["signature"]["value"] + .as_str() + .expect("signed outer envelope"), + URL_SAFE_NO_PAD, + ) + .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(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, + "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 = historical_evaluation_time(); + 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(historical_evaluation_time() + 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, + (historical_evaluation_time() + 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 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" + ); + 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 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())) + .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 + ); + // 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, + 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 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" + ); + 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 b1d52b59..99898a3a 100644 --- a/tinycloud-node-server/src/webhook_dispatcher.rs +++ b/tinycloud-node-server/src/webhook_dispatcher.rs @@ -314,13 +314,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)) } @@ -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, @@ -609,13 +638,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(), @@ -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!( "{}:{}", @@ -836,14 +889,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 @@ -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/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); -} diff --git a/tinycloud-node-server/tests/postgres_round_trip_budget.rs b/tinycloud-node-server/tests/postgres_round_trip_budget.rs index 95e33b00..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 //! @@ -114,14 +115,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; @@ -194,7 +197,7 @@ struct RoundTrip { #[derive(Default)] struct Log { - connections: u64, + started_connections: Vec, round_trips: Vec, } @@ -203,7 +206,7 @@ struct Wire(Arc>); #[derive(Clone, Copy)] struct Mark { - connections: u64, + connections: usize, round_trips: usize, } @@ -215,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 } } @@ -308,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 { @@ -580,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 // --------------------------------------------------------------------------- @@ -951,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 @@ -1003,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, 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..66c0fc8c --- /dev/null +++ b/tinycloud-node-server/tests/sdk_session_cid.rs @@ -0,0 +1,290 @@ +//! 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 { + for prefix in ["", "0x"] { + let signed = json_signed(&prepared, &format!("{prefix}{}", hex::encode(&bytes)))?; + ensure!( + serde_json::from_value::(signed).is_err(), + "SDK accepted {label}" + ); + let host = json!({"siwe": prepared.siwe.to_string(), "signature": format!("{prefix}{}", hex::encode(&bytes))}); + ensure!( + serde_json::from_value::(host).is_err(), + "host SDK accepted {label}" + ); + } + if let Ok(bytes) = <[u8; 65]>::try_from(bytes) { + ensure!( + matches!( + complete_session_setup(SignedSession { + session: prepared.clone(), + signature: Signature::from(bytes), + }), + Err(tinycloud_sdk_wasm::session::Error::InvalidSIWESignature(_)) + ), + "Rust setup accepted {label}" + ); + } + } + 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-node-server/tests/siwe_issuer_identity.rs b/tinycloud-node-server/tests/siwe_issuer_identity.rs new file mode 100644 index 00000000..3e48d68f --- /dev/null +++ b/tinycloud-node-server/tests/siwe_issuer_identity.rs @@ -0,0 +1,775 @@ +//! 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 { + self.session_for("kv", "tinycloud.kv/list") + } + + fn session_for(&self, service: &str, ability: &str) -> Result { + let now = time::OffsetDateTime::now_utc(); + let prepared = prepare_session(SessionConfig { + abilities: HashMap::from([( + service.parse()?, + HashMap::from([("".parse()?, vec![ability.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)> { + client_with_database("sqlite::memory:", [43; 32]).await +} + +async fn client_with_database( + database: &str, + secret: [u8; 32], +) -> 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(database.into()); + config.keys = serde_json::from_value( + json!({"type":"static", "secret":base64::encode_config(secret, 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(()) +} + +#[tokio::test] +async fn historical_alias_targets_cannot_widen_a_space_scoped_revoke_control() -> Result<()> { + use tinycloud_auth::{ + resolver::DID_METHODS, resource::iri_string::types::UriString, ssi::jwk::JWK, + }; + use tinycloud_core::{ + db::DelegationStatus, + hash::Hash, + models::{delegation, revocation}, + sea_orm::{EntityTrait, PaginatorTrait}, + }; + + let fixture_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../tinycloud-core/tests/fixtures/tc1071/generated"); + let fixtures: Vec = + serde_json::from_slice(&std::fs::read(fixture_dir.join("manifest.json"))?)?; + let fixture = fixtures + .iter() + .find(|fixture| fixture["database"] == "active.sqlite") + .ok_or_else(|| anyhow::anyhow!("missing genuine active historical fixture"))?; + let legacy = fixture["rows"] + .as_array() + .unwrap() + .iter() + .find(|row| row["encoding"] == "v0") + .ok_or_else(|| anyhow::anyhow!("missing historical low-v row"))?; + let resource = fixture["resource"].as_str().unwrap(); + let legacy_cid: Cid = legacy["raw_cid"].as_str().unwrap().parse()?; + let child_cid: Cid = legacy["child_cid"].as_str().unwrap().parse()?; + let directory = tempfile::tempdir()?; + let path = directory.path().join("historical.sqlite"); + std::fs::copy(fixture_dir.join("active.sqlite"), &path)?; + let (client, _data) = + client_with_database(&format!("sqlite:{}?mode=rwc", path.display()), [31; 32]).await?; + + let canonical_header = fixture["canonical_header"].as_str().unwrap(); + let imported: Value = serde_json::from_str( + &expect_ok( + "historical canonical reimport", + client + .post("/delegate") + .header(Header::new("Authorization", canonical_header.to_owned())), + ) + .await?, + )?; + let canonical_cid: Cid = imported["cid"].as_str().unwrap().parse()?; + assert_ne!( + legacy_cid, canonical_cid, + "historical and canonical paths must differ" + ); + + let key = SigningKey::from_bytes(&[7; 32].into())?; + let public = key.verifying_key().to_encoded_point(false); + let address = Keccak256::digest(&public.as_bytes()[1..])[12..].try_into()?; + let did = format!("did:pkh:eip155:1:0x{}", encode_eip55(&address)); + let wallet = Wallet { + key, + address, + space: SpaceId::new(did.parse()?, "scope-control".parse()?), + }; + bootstrap(&client, &wallet).await?; + let control = wallet.session_for("delegation", "tinycloud.delegation/revoke")?; + let activated: Value = serde_json::from_str( + &expect_ok( + "space-scoped revoke control", + client.post("/delegate").header(Header::new( + "Authorization", + authorization(&control.delegation_header)?, + )), + ) + .await?, + )?; + let control_cid: Cid = activated["cid"].as_str().unwrap().parse()?; + let session_jwk: JWK = serde_json::from_value(fixture["session_jwk"].clone())?; + let child_jwk: JWK = serde_json::from_value(fixture["child_jwk"].clone())?; + let session_did = DID_METHODS + .generate(&session_jwk, "key") + .map_err(|error| anyhow::anyhow!(error.to_string()))? + .to_string(); + let child_did = DID_METHODS + .generate(&child_jwk, "key") + .map_err(|error| anyhow::anyhow!(error.to_string()))? + .to_string(); + let session_vm = format!( + "{session_did}#{}", + session_did.trim_start_matches("did:key:") + ); + let child_vm = format!("{child_did}#{}", child_did.trim_start_matches("did:key:")); + let kv_resource: UriString = format!("{resource}/").parse()?; + let list = |cid: Cid, key: &JWK, method: &str| -> Result { + Ok(make_invocation_from_uris( + [(kv_resource.clone(), ["tinycloud.kv/list".parse()?])], + &cid, + key, + method, + (time::OffsetDateTime::now_utc() + time::Duration::minutes(5)).unix_timestamp() as f64, + InvocationOptions::default(), + )? + .encode()?) + }; + let state = client + .rocket() + .state::() + .expect("managed database"); + let original = delegation::Entity::find_by_id(Hash::from(legacy_cid)) + .one(state.connection()) + .await? + .expect("original historical row"); + assert_eq!( + original.serialization, + base64::decode_config(legacy["header"].as_str().unwrap(), base64::URL_SAFE)? + ); + let before = revocation::Entity::find().count(state.connection()).await?; + for target in [legacy_cid, canonical_cid] { + let invocation = make_invocation_from_uris( + [( + format!("urn:cid:{target}").parse()?, + ["tinycloud.delegation/revoke".parse()?], + )], + &control_cid, + &control.jwk, + &control.verification_method, + (time::OffsetDateTime::now_utc() + time::Duration::minutes(5)).unix_timestamp() as f64, + InvocationOptions::default(), + )? + .encode()?; + let (denied, body) = dispatch( + client + .post("/revoke") + .header(Header::new("Authorization", invocation)), + ) + .await; + assert_eq!(denied, Status::Forbidden, "target {target}: {body}"); + assert_eq!( + revocation::Entity::find().count(state.connection()).await?, + before + ); + assert_eq!( + state + .delegation_status(Hash::from(target), &session_did, &[]) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?, + Some(DelegationStatus::Active) + ); + expect_ok( + "historical/canonical signed list after scope denial", + client.post("/invoke").header(Header::new( + "Authorization", + list(target, &session_jwk, &session_vm)?, + )), + ) + .await?; + } + expect_ok( + "signed child list after scope denial", + client.post("/invoke").header(Header::new( + "Authorization", + list(child_cid, &child_jwk, &child_vm)?, + )), + ) + .await?; + + let (TinyCloudDelegation::Cacao(cacao), _) = TinyCloudDelegation::decode(canonical_header)? + else { + panic!("historical CACAO"); + }; + let mut message: Message = cacao.payload().clone().try_into()?; + message.uri = format!("ucan:{legacy_cid}").parse()?; + message.statement = None; + message.resources.clear(); + let signature = wallet.sign(&message)?; + let owner_revocation = TinyCloudRevocation::Cacao(Box::new( + tinycloud_auth::cacaos::siwe_cacao::Payload::from(message) + .sign::(signature), + )); + expect_ok( + "owner signed historical target revocation", + client + .post("/revoke") + .header(Header::new("Authorization", owner_revocation.encode()?)), + ) + .await?; + for target in [legacy_cid, canonical_cid] { + assert_eq!( + state + .delegation_status(Hash::from(target), &session_did, &[]) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?, + Some(DelegationStatus::Revoked) + ); + } + for (target, key, method) in [ + (legacy_cid, &session_jwk, session_vm.as_str()), + (canonical_cid, &session_jwk, session_vm.as_str()), + (child_cid, &child_jwk, child_vm.as_str()), + ] { + let (denied, body) = dispatch( + client + .post("/invoke") + .header(Header::new("Authorization", list(target, key, method)?)), + ) + .await; + assert_eq!(denied, Status::Unauthorized, "revoked {target}: {body}"); + } + let preserved = delegation::Entity::find_by_id(original.id) + .one(state.connection()) + .await? + .expect("preserved historical row"); + assert_eq!(preserved.serialization, original.serialization); + Ok(()) +} diff --git a/tinycloud-sdk-rs/src/serde_siwe.rs b/tinycloud-sdk-rs/src/serde_siwe.rs index 83092a4d..5bfb79d9 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 = <[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::decode(bytes).map_err(D::Error::custom) } } diff --git a/tinycloud-sdk-wasm/src/session.rs b/tinycloud-sdk-wasm/src/session.rs index 608a637b..3437b188 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::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); // 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), } 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() }