diff --git a/CHANGELOG.md b/CHANGELOG.md index de734dab..0a066218 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,16 @@ # Changelog ## [Unreleased] +- Owners can activate a single-target `urn:cid:` revocation grant without the target's signed artifact when its persisted abilities name their space. Direct revocation accepts the stored target or its historical canonical CID, and alias-equivalent proof targets use the same effective identity. Status resolves an unstored canonical CID to its persisted row while preserving the requested CID's authority scope; unrelated owners and unknown targets remain denied (TC-721, TC-821). +- 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..1a9a4cb3 100644 --- a/docs/delegation-flow.md +++ b/docs/delegation-flow.md @@ -431,6 +431,81 @@ 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. + ## 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/src/auth_graph.rs b/tinycloud-core/src/auth_graph.rs index 0fafaecd..8246c743 100644 --- a/tinycloud-core/src/auth_graph.rs +++ b/tinycloud-core/src/auth_graph.rs @@ -10,7 +10,7 @@ //! the same consistent view. use crate::hash::Hash; -use crate::models::{abilities, delegation, revocation}; +use crate::models::{abilities, delegation, delegation_cid_alias, revocation}; use crate::relationships::parent_delegations; use sea_orm::{ entity::prelude::*, @@ -29,6 +29,23 @@ use crate::models::revocation::MAX_CHAIN_TRAVERSAL_NODES; pub(crate) async fn load_closure_edges( db: &C, roots: &[Hash], +) -> Result<(Vec, HashMap>), ChainTraversalError> { + load_closure(db, roots, false).await +} + +/// Resolve the same bounded closure to canonical lock identities. Signed graph +/// IDs stay untouched; indexed alias joins share the closure's one round trip. +pub(crate) async fn load_chain_lock_ids( + db: &C, + roots: &[Hash], +) -> Result, ChainTraversalError> { + load_closure(db, roots, true).await.map(|(ids, _)| ids) +} + +async fn load_closure( + db: &C, + roots: &[Hash], + canonical_locks: bool, ) -> Result<(Vec, HashMap>), ChainTraversalError> { let mut nodes = roots.to_vec(); nodes.sort_by(|left, right| left.as_ref().cmp(right.as_ref())); @@ -74,6 +91,17 @@ pub(crate) async fn load_closure_edges( ) .to_owned(); let mut closure_query = base; + if canonical_locks { + // Parentless stored roots otherwise produce no edge row. Seed them + // only for locking; roots without a persisted alias retain their ID. + let seed = Query::select() + .expr_as(Expr::col(delegation_cid_alias::Column::Id), child.clone()) + .expr_as(Expr::val(Option::::None), parent.clone()) + .from(delegation_cid_alias::Entity) + .and_where(Expr::col(delegation_cid_alias::Column::Id).is_in(nodes.iter().copied())) + .to_owned(); + closure_query.union(UnionType::Distinct, seed); + } closure_query.union(UnionType::Distinct, recursive); let cte = CommonTableExpression::new() .table_name(closure.clone()) @@ -87,20 +115,65 @@ pub(crate) async fn load_closure_edges( (closure.clone(), child.clone()), (closure.clone(), parent.clone()), ]) - .from(closure) - .limit((edge_cap + 1) as u64); + .from(closure.clone()) + .limit((edge_cap + if canonical_locks { nodes.len() } else { 0 } + 1) as u64); + if canonical_locks { + for (column, alias, output) in [ + ( + child.clone(), + Alias::new("child_alias"), + Alias::new("child_lock"), + ), + ( + parent.clone(), + Alias::new("parent_alias"), + Alias::new("parent_lock"), + ), + ] { + query + .expr_as( + Expr::col((alias.clone(), delegation_cid_alias::Column::Canonical)), + output, + ) + .join_as( + JoinType::LeftJoin, + delegation_cid_alias::Entity, + alias.clone(), + Expr::col((alias, delegation_cid_alias::Column::Id)) + .equals((closure.clone(), column)), + ); + } + } let query = query.with(WithClause::new().recursive(true).cte(cte).to_owned()); let rows = db .query_all(db.get_database_backend().build(&query)) .await?; - if rows.len() > edge_cap { + if rows.len() > edge_cap + if canonical_locks { nodes.len() } else { 0 } { return Err(ChainTraversalError::LimitExceeded); } let mut edges: HashMap> = HashMap::new(); + let mut lock_aliases = HashMap::new(); for row in rows { let edge_child: Hash = row.try_get("", "child")?; - let edge_parent: Hash = row.try_get("", "parent")?; + let edge_parent: Option = row.try_get("", "parent")?; + if canonical_locks { + if let Some(canonical) = row.try_get::>("", "child_lock")? { + if canonical != edge_child { + lock_aliases.insert(edge_child, canonical); + } + } + if let (Some(parent), Some(canonical)) = + (edge_parent, row.try_get::>("", "parent_lock")?) + { + if canonical != parent { + lock_aliases.insert(parent, canonical); + } + } + } + let Some(edge_parent) = edge_parent else { + continue; + }; let parents = edges.entry(edge_child).or_default(); if !parents.contains(&edge_parent) { parents.push(edge_parent); @@ -123,6 +196,11 @@ pub(crate) async fn load_closure_edges( } } } + for id in &mut nodes { + if let Some(canonical) = lock_aliases.get(id) { + *id = *canonical; + } + } Ok((nodes, edges)) } @@ -168,12 +246,10 @@ impl AuthGraphSnapshot { ability_rows.entry(row.delegation).or_default().push(row); } - let revoked = revocation::Entity::find() - .filter(revocation::Column::Revoked.is_in(nodes.iter().copied())) - .all(db) + let revoked = revocation::effective_rows(db, &nodes) .await? .into_iter() - .map(|row| row.revoked) + .map(|(id, _)| id) .collect(); Ok(Self { diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 527fcbff..2f5035a5 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -20,7 +20,7 @@ use crate::types::{ DelegationQueryPage, DelegationQueryStatus, DelegationResource, ListFilters, Metadata, Resource, SpaceIdWrap, }; -use crate::util::{Capability, DelegationInfo, DelegationMode}; +use crate::util::DelegationInfo; use sea_orm::{ entity::prelude::*, error::{DbErr, RuntimeErr, SqlxError}, @@ -343,11 +343,32 @@ impl SpaceDatabase where C: ConnectionTrait + Clone + Into, { + /// Open an unencrypted delegation database and complete historical CID + /// indexing before accepting requests. pub async fn new(conn: C, storage: B, secrets: K) -> Result { + Self::new_with_encryption(conn, storage, secrets, None).await + } + + /// Open a database with its column encryption key available before the + /// historical CID backfill. Missing/wrong keys or undecodable history fail + /// startup; signed records and proof edges are never rewritten. + pub async fn new_with_encryption( + conn: C, + storage: B, + secrets: K, + encryption: Option, + ) -> Result { let pool: RecoveringConnection = conn.clone().into(); // sea-orm-migration's schema manager accepts only the raw sea-orm // connection. Migrations run once at boot, before any request. migrate(&pool).await?; + let tx = begin_write_transaction(&pool, None).await?; + if pool.get_database_backend() == DbBackend::Postgres { + tx.execute_unprepared(crate::db_pool::EXEMPT_STATEMENT_TIMEOUT) + .await?; + } + delegation_cid_alias::backfill(&tx, encryption.as_ref()).await?; + tx.commit().await?; let writer_lock = (pool.get_database_backend() == sea_orm::DatabaseBackend::Sqlite) .then(|| Arc::new(tokio::sync::Mutex::new(()))); let read_audit = ReadAuditPipeline::start(pool, writer_lock.clone()); @@ -355,7 +376,7 @@ where conn, storage, secrets, - encryption: None, + encryption, sql_sizes: SqlSizes::default(), revocation_chain_locks: Arc::new(tokio::sync::Mutex::new(HashMap::new())), kv_object_locks: Arc::new(tokio::sync::Mutex::new(HashMap::new())), @@ -368,11 +389,6 @@ where }) } - pub fn with_encryption(mut self, encryption: Option) -> Self { - self.encryption = encryption; - self - } - pub fn with_sql_sizes(mut self, sql_sizes: SqlSizes) -> Self { self.sql_sizes = sql_sizes; self @@ -592,10 +608,7 @@ where }; let bytes = crate::encryption::maybe_decrypt(self.encryption.as_ref(), &row.serialization) .map_err(|error| error.to_string())?; - let delegation = - TinyCloudDelegation::from_bytes(&bytes).map_err(|error| error.to_string())?; - let info = DelegationInfo::try_from(delegation).map_err(|error| error.to_string())?; - let event = SerializedEvent(info, bytes); + let event = decode_stored_delegation(bytes).map_err(|error| error.to_string())?; if event.content_hash() != row.id || event.content_hash() != Hash::from(cid) { return Err("delegation-signed-bytes-mismatch".to_string()); } @@ -1292,7 +1305,7 @@ where &self, roots: &[Hash], ) -> Result, TxError> { - let keys = revocation::ancestor_chain_ids_for_roots(&self.conn, roots) + let keys = crate::auth_graph::load_chain_lock_ids(&self.conn, roots) .await .map_err(|error| match error { revocation::ChainTraversalError::Db(error) => TxError::Db(error), @@ -1303,6 +1316,25 @@ where Ok(self.acquire_exclusive_chain_guards_for_keys(keys).await) } + /// Hold shared guards for the canonical ancestor identities of `roots`. + /// + /// These coordinate admission with delegation/revocation writers using + /// this database and its clones. Acquire before space-sequence guards or + /// SQL transactions, and retain through commit or rollback. Callers must + /// still recheck effective revocation inside their admission transaction. + pub async fn guard_delegation_chains( + &self, + roots: &[Hash], + ) -> Result, DbErr> { + let keys = crate::auth_graph::load_chain_lock_ids(&self.conn, roots) + .await + .map_err(|error| match error { + revocation::ChainTraversalError::Db(error) => error, + error => DbErr::Custom(error.to_string()), + })?; + Ok(self.acquire_shared_chain_guards_for_keys(keys).await) + } + /// Resolve `keys` to their per-delegation locks in a stable global order. /// /// Sorting and deduplicating here is the deadlock discipline: every @@ -1764,18 +1796,18 @@ where if proofs.len() > 1 { return Ok(None); } - let Some(delegation) = delegation::Entity::find_by_id(target) - .one(&self.conn) - .await? - else { + let Some(delegation) = revocation::target_for_cid(&self.conn, target).await? else { return Ok(None); }; let abilities = abilities::Entity::find() - .filter(abilities::Column::Delegation.eq(target)) + .filter(abilities::Column::Delegation.eq(delegation.id)) .all(&self.conn) .await?; let mut roots = vec![target]; + if delegation.id != target { + roots.push(delegation.id); + } roots.extend(proofs.iter().copied().map(Hash::from)); let _chain_guards = match self.acquire_chain_guards(&roots).await { Ok(guards) => guards, @@ -1817,7 +1849,7 @@ where if revocation::is_revoked(&self.conn, &target).await? { return Ok(Some(DelegationStatus::Revoked)); } - match revocation::first_revoked_ancestor(&self.conn, &target).await { + match revocation::first_revoked_ancestor(&self.conn, &delegation.id).await { Ok(Some(_)) => return Ok(Some(DelegationStatus::Revoked)), Ok(None) => {} Err(revocation::ChainTraversalError::LimitExceeded) => { @@ -1983,9 +2015,8 @@ where ); let authz_start = Instant::now(); let closure_start = Instant::now(); - let lock_keys = crate::auth_graph::load_closure_edges(&self.conn, &guard_roots) + let lock_keys = crate::auth_graph::load_chain_lock_ids(&self.conn, &guard_roots) .await - .map(|(keys, _)| keys) .map_err(|error| match error { revocation::ChainTraversalError::Db(error) => TxError::Db(error), revocation::ChainTraversalError::LimitExceeded => { @@ -2883,9 +2914,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), @@ -3915,15 +3946,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 +3974,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 +3990,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 +4315,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 +4342,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 +4350,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 +4360,6 @@ async fn get_filtered_delegations now).unwrap_or(true) && del.not_before.map(|n| n <= now).unwrap_or(true)) @@ -4428,32 +4426,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 +4442,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 +4459,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 +4488,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 @@ -6559,9 +6514,8 @@ mod test { /// Resolve the ancestor closure exactly as `invoke_with_options_mode` /// does, then take the guards shared — the production invocation path. async fn invocation_chain_keys(db: &TestDb, roots: &[Hash]) -> Vec { - crate::auth_graph::load_closure_edges(&db.conn, roots) + crate::auth_graph::load_chain_lock_ids(&db.conn, roots) .await - .map(|(keys, _)| keys) .expect("chain closure query") } @@ -6591,6 +6545,49 @@ mod test { tokio::time::sleep(std::time::Duration::from_millis(150)).await; } + /// Equivalent historical parents and an as-yet unregistered canonical CID + /// must contend on the same guard as an invocation citing the signed child. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn historical_alias_revocation_waits_for_signed_child_invocation() { + use sea_orm::{ActiveModelTrait, ActiveValue::Set}; + + let db = get_db().await.unwrap(); + insert_chain_guard_actors(&db).await; + let (legacy, child) = insert_test_chain(&db, "historical-alias-lock").await; + let (other_legacy, _) = insert_test_chain(&db, "other-historical-alias-lock").await; + let canonical = crate::hash::hash(b"canonical parent not registered as a delegation"); + for id in [legacy, other_legacy] { + delegation_cid_alias::ActiveModel { + id: Set(id), + canonical: Set(canonical), + encrypted: Set(false), + } + .insert(&db.conn) + .await + .unwrap(); + } + + for target in [legacy, other_legacy, canonical] { + let in_flight = acquire_invocation_guards(&db, &[child]).await; + let revoke_db = db.clone(); + let revoke = + tokio::spawn(async move { revoke_db.acquire_chain_guards(&[target]).await }); + settle().await; + assert!( + !revoke.is_finished(), + "equivalent ancestor revocation must wait for the signed child's invocation" + ); + drop(in_flight); + let guards = tokio::time::timeout(std::time::Duration::from_secs(10), revoke) + .await + .expect("alias revocation proceeds after the child releases its guards") + .unwrap() + .ok() + .expect("alias revocation guards"); + drop(guards); + } + } + /// TC-324 requirement 1: invocations sharing one root delegation must /// hold the chain guard at the same time, not merely all succeed. /// diff --git a/tinycloud-core/src/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/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/mod.rs b/tinycloud-core/src/migrations/mod.rs index ee7f0d2f..e8f7c992 100644 --- a/tinycloud-core/src/migrations/mod.rs +++ b/tinycloud-core/src/migrations/mod.rs @@ -24,6 +24,7 @@ pub mod m20261005_000000_current_kv_sync_order; pub mod m20261005_000000_deactivate_hook_subscriptions; pub mod m20261007_000000_database_alias; pub mod m20261007_010000_database_identity_fence; +pub mod m20261014_000000_delegation_cid_alias; pub mod m20261015_000000_delegation_cursor_index; pub struct Migrator; @@ -57,6 +58,7 @@ impl MigratorTrait for Migrator { Box::new(m20261005_000000_deactivate_hook_subscriptions::Migration), Box::new(m20261007_000000_database_alias::Migration), Box::new(m20261007_010000_database_identity_fence::Migration), + Box::new(m20261014_000000_delegation_cid_alias::Migration), Box::new(m20261015_000000_delegation_cursor_index::Migration), ] } @@ -75,7 +77,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 +134,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..4c265f0f 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}; @@ -248,6 +253,19 @@ async fn validate( // ordered sibling pair. Keeping the graph row here is what lets the // ordinary transaction validate an S0's proof CIDs. (false, true) if is_policy_root(&delegation.delegation) => Ok(()), + // A space owner may mint a narrowly scoped control grant for a + // persisted target without possessing the target's signed bytes. + (false, true) + if delegation.capabilities.len() == 1 + && revocation::owner_controls_target( + db, + &delegation.delegator, + dependant_caps[0], + ) + .await? => + { + Ok(()) + } // dependant caps, no parents, invalid (false, true) => Err(DelegationError::MissingParents.into()), // dependant caps, parents, check parents @@ -835,10 +853,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 +867,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/mod.rs b/tinycloud-core/src/models/mod.rs index a8a0afca..afefa687 100644 --- a/tinycloud-core/src/models/mod.rs +++ b/tinycloud-core/src/models/mod.rs @@ -6,6 +6,7 @@ pub mod database_artifact; pub mod database_identity_fence; pub mod database_legacy_artifact; pub mod delegation; +pub mod delegation_cid_alias; pub(crate) use crate::did_resolution; pub mod encryption_audit; pub mod encryption_ceremony; diff --git a/tinycloud-core/src/models/revocation.rs b/tinycloud-core/src/models/revocation.rs index bf79bafc..bb443353 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,113 @@ 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) + .cond_where( + Condition::any() + .add(Expr::col(AliasColumn::Id).eq(id.clone())) + .add(Expr::col(AliasColumn::Canonical).eq(id.clone())), + ) + .to_owned(); + let equivalents = Query::select() + .column(AliasColumn::Id) + .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( @@ -270,20 +371,60 @@ pub(crate) async fn control_proof_decision( .filter(abilities::Column::Ability.eq(requested_action)) .all(db) .await?; - let target_resource = format!("urn:cid:{}", target.to_cid(0x55)); - control_abilities.retain(|ability| match &ability.resource { - Resource::Other(resource) => resource.as_str() == target_resource, - Resource::TinyCloud(resource) => { - resource.service().as_str() == "delegation" - && resource - .path() - .map(|path| path.as_str().is_empty()) - .unwrap_or(true) - && resource.query().is_none() - && resource.fragment().is_none() - && did_principal_matches(resource.space().did().as_str(), &parent.delegator) + let mut canonical_target = None; + let mut index = 0; + while index < control_abilities.len() { + let matches = match &control_abilities[index].resource { + Resource::Other(resource) => { + let control_target = resource + .as_str() + .strip_prefix("urn:cid:") + .and_then(|cid| cid.parse::().ok()) + .map(Hash::from); + if let Some(control_target) = control_target { + if control_target == *target { + true + } else { + let target_canonical = if let Some(canonical) = canonical_target { + canonical + } else { + let canonical = delegation_cid_alias::Entity::find_by_id(*target) + .one(db) + .await? + .map(|alias| alias.canonical) + .unwrap_or(*target); + canonical_target = Some(canonical); + canonical + }; + let control_canonical = + delegation_cid_alias::Entity::find_by_id(control_target) + .one(db) + .await? + .map(|alias| alias.canonical) + .unwrap_or(control_target); + control_canonical == target_canonical + } + } else { + false + } + } + Resource::TinyCloud(resource) => { + resource.service().as_str() == "delegation" + && resource + .path() + .map(|path| path.as_str().is_empty()) + .unwrap_or(true) + && resource.query().is_none() + && resource.fragment().is_none() + && did_principal_matches(resource.space().did().as_str(), &parent.delegator) + } + }; + if matches { + index += 1; + } else { + control_abilities.swap_remove(index); } - }); + } if control_abilities.is_empty() { return Ok(ControlProofDecision::Denied); } @@ -294,6 +435,59 @@ pub(crate) async fn control_proof_decision( })) } +/// A canonical CID can name a historical stored row without being its primary +/// key. Keep the signed CID on the wire; use the stored row for authorization. +pub(crate) async fn target_for_cid( + db: &C, + target: Hash, +) -> Result, DbErr> { + if let Some(row) = delegation::Entity::find_by_id(target).one(db).await? { + return Ok(Some(row)); + } + let Some(alias) = delegation_cid_alias::Entity::find() + .filter(delegation_cid_alias::Column::Canonical.eq(target)) + .one(db) + .await? + else { + return Ok(None); + }; + delegation::Entity::find_by_id(alias.id).one(db).await +} + +/// Root CID control is available only to an owner of a space in the target's +/// persisted abilities. A delegator or delegatee alone is not an owner. +pub(crate) async fn owner_controls_target( + db: &C, + owner: &str, + cap: &crate::util::Capability, +) -> Result { + if cap.ability.as_ref().as_ref() != "tinycloud.delegation/revoke" { + return Ok(false); + } + let Resource::Other(resource) = &cap.resource else { + return Ok(false); + }; + let Some(target) = resource + .as_str() + .strip_prefix("urn:cid:") + .and_then(|cid| cid.parse::().ok()) + else { + return Ok(false); + }; + let Some(target) = target_for_cid(db, Hash::from(target)).await? else { + return Ok(false); + }; + let abilities = abilities::Entity::find() + .filter(abilities::Column::Delegation.eq(target.id)) + .all(db) + .await?; + Ok(abilities.iter().any(|row| { + row.resource + .space() + .is_some_and(|space| did_principal_matches(space.did().as_str(), owner)) + })) +} + #[derive(Debug, thiserror::Error)] pub enum Error { #[error(transparent)] @@ -358,8 +552,7 @@ pub(crate) async fn process( }; let hash: Hash = hash(&serialization); - let delegation = delegation::Entity::find_by_id(Hash::from(r.revoked)) - .one(db) + let delegation = target_for_cid(db, Hash::from(r.revoked)) .await? .ok_or(RevocationError::MissingParents)?; @@ -372,7 +565,15 @@ pub(crate) async fn process( // Any one of these is sufficient. The Cacao revoker == delegator // path stays as the cheapest check; owner-authorized falls back to // walking the ability rows. - if !revoker_is_authorized(db, &delegation, &r.revoker, &r.parents).await? { + if !revoker_is_authorized( + db, + &delegation, + &r.revoker, + &r.parents, + &Hash::from(r.revoked), + ) + .await? + { return Err(RevocationError::UnauthorizedRevoker(r.revoker).into()); }; @@ -421,29 +622,25 @@ async fn revoker_is_authorized( delegation: &delegation::Model, revoker: &str, proofs: &[Cid], + target: &Hash, ) -> Result { - let principal = match control_proof_decision( - db, - revoker, - proofs, - "tinycloud.delegation/revoke", - &delegation.id, - ) - .await? - { - ControlProofDecision::DirectSigner(principal) => principal, - ControlProofDecision::Scoped(control) => { - if control.directly_authorizes_cid(&delegation.id, delegation) { - return Ok(true); + let principal = + match control_proof_decision(db, revoker, proofs, "tinycloud.delegation/revoke", target) + .await? + { + ControlProofDecision::DirectSigner(principal) => principal, + ControlProofDecision::Scoped(control) => { + if control.directly_authorizes_cid(target, delegation) { + return Ok(true); + } + let target_abilities = abilities::Entity::find() + .filter(abilities::Column::Delegation.eq(delegation.id)) + .all(db) + .await?; + return Ok(control.authorizes_target(target, delegation, &target_abilities)); } - let target_abilities = abilities::Entity::find() - .filter(abilities::Column::Delegation.eq(delegation.id)) - .all(db) - .await?; - return Ok(control.authorizes_target(&delegation.id, delegation, &target_abilities)); - } - ControlProofDecision::Denied => return Ok(false), - }; + ControlProofDecision::Denied => return Ok(false), + }; if did_principal_matches(&delegation.delegator, &principal) { return Ok(true); } 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..7592e3e0 --- /dev/null +++ b/tinycloud-core/tests/historical_cacao_revocation.rs @@ -0,0 +1,659 @@ +#[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::{Delegation, 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; + } +} + +fn owner_cid_control(target: &str, audience: &str) -> String { + use tinycloud_auth::{ + cacaos::{ + siwe::Message, + siwe_cacao::{Header, Signature, SiweCacao}, + }, + siwe_recap::{Ability, Capability}, + }; + let mut recap = Capability::::new(); + recap.with_action( + format!("urn:cid:{target}").parse().unwrap(), + "tinycloud.delegation/revoke".parse::().unwrap(), + [std::collections::BTreeMap::new()], + ); + let message: Message = signed_cacao(audience, None, "tc821-cid-control") + .payload() + .clone() + .try_into() + .unwrap(); + let message = recap.build_message(message).unwrap(); + let (key, _, _) = owner(); + let (signature, recovery) = key + .sign_prehash_recoverable(&message.eip191_hash().unwrap()) + .unwrap(); + let mut bytes = [0; 65]; + bytes[..64].copy_from_slice(signature.to_bytes().as_ref()); + bytes[64] = u8::from(recovery) + 27; + TinyCloudDelegation::Cacao(Box::new(SiweCacao::new( + message.into(), + Signature::from(bytes), + Header, + ))) + .encode() + .unwrap() +} + +fn session_cid_revocation(target: &str, control: &str, seed: u8) -> Revocation { + use tinycloud_auth::{ + ssi::{claims::jwt::NumericDate, jwk::Algorithm, ucan::Payload}, + ucan_capabilities_object::Capabilities, + }; + let (key, did, vm) = session(seed); + let mut caps = Capabilities::new(); + caps.with_action( + format!("urn:cid:{target}").parse().unwrap(), + "tinycloud.delegation/revoke".parse().unwrap(), + [std::collections::BTreeMap::::new()], + ); + let header = TinyCloudRevocation::Ucan(Box::new( + Payload { + issuer: vm.parse().unwrap(), + audience: did.parse().unwrap(), + not_before: None, + expiration: NumericDate::try_from_seconds(EXPIRATION).unwrap(), + nonce: Some("tc821-session-revoke".into()), + facts: None, + proof: vec![control.parse().unwrap()], + attenuation: caps, + } + .sign(Algorithm::EdDSA, &key) + .unwrap(), + )) + .encode() + .unwrap(); + Revocation::from_header_ser::(&header).unwrap() +} + +#[tokio::test] +async fn owner_cid_control_revokes_historical_equivalents_without_target_artifact() { + for database in ["active.sqlite", "single-v0.sqlite"] { + let fixture = fixtures() + .into_iter() + .find(|fixture| fixture.database == database) + .unwrap(); + for target in &fixture.rows { + let (_directory, path) = copy_database(&fixture); + let before = signed_snapshot(&path); + let db = open(&path).await; + let (_, audience, _) = session(fixture.session_seed); + let header = owner_cid_control(&target.raw_cid, &audience); + db.delegate(Delegation::from_header_ser::(&header).unwrap()) + .await + .map_err(|error| error.to_string()) + .unwrap(); + let signed = + session_cid_revocation(&target.raw_cid, &cid(&header), fixture.session_seed); + db.revoke(signed) + .await + .map_err(|error| error.to_string()) + .unwrap(); + for equivalent in &fixture.rows { + assert_eq!( + db.delegation_status(hash_from_cid(&equivalent.raw_cid), &fixture.issuer, &[]) + .await + .map_err(|error| error.to_string()) + .unwrap(), + Some(tinycloud_core::db::DelegationStatus::Revoked), + "{database} {} via {}", + equivalent.encoding, + target.encoding, + ); + } + let after = signed_snapshot(&path); + assert!(before[0].iter().all(|row| after[0].contains(row))); + assert!(before[1].iter().all(|row| after[1].contains(row))); + } + } +} + +#[tokio::test] +async fn unstored_canonical_status_resolves_historical_grant() { + let fixture = fixtures() + .into_iter() + .find(|fixture| fixture.database == "single-v0.sqlite") + .unwrap(); + let canonical = current_cid(&fixture.canonical_header); + assert_ne!(canonical, fixture.rows[0].raw_cid); + let (_directory, path) = copy_database(&fixture); + let db = open(&path).await; + assert_eq!( + db.delegation_status(hash_from_cid(&canonical), &fixture.issuer, &[]) + .await + .map_err(|error| error.to_string()) + .unwrap(), + Some(tinycloud_core::db::DelegationStatus::Active), + ); +} + +#[tokio::test] +async fn owner_cid_control_resolves_unstored_canonical_target() { + let fixture = fixtures() + .into_iter() + .find(|fixture| fixture.database == "single-v0.sqlite") + .unwrap(); + let canonical = current_cid(&fixture.canonical_header); + let raw = &fixture.rows[0].raw_cid; + assert_ne!(canonical, *raw); + for (control_target, signed_target) in [(&canonical, raw), (raw, &canonical)] { + let (_directory, path) = copy_database(&fixture); + let db = open(&path).await; + let (_, audience, _) = session(fixture.session_seed); + let header = owner_cid_control(control_target, &audience); + db.delegate(Delegation::from_header_ser::(&header).unwrap()) + .await + .map_err(|error| error.to_string()) + .unwrap(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + db.revoke(session_cid_revocation( + signed_target, + &cid(&header), + fixture.session_seed, + )), + ) + .await + .expect("same-registry root/alias revoke must not deadlock") + .map_err(|error| error.to_string()) + .unwrap(); + assert_eq!( + db.delegation_status(hash_from_cid(raw), &fixture.issuer, &[]) + .await + .map_err(|error| error.to_string()) + .unwrap(), + Some(tinycloud_core::db::DelegationStatus::Revoked), + ); + assert_eq!( + db.delegation_status(hash_from_cid(&canonical), &fixture.issuer, &[]) + .await + .map_err(|error| error.to_string()) + .unwrap(), + Some(tinycloud_core::db::DelegationStatus::Revoked), + ); + } +} + +#[tokio::test] +async fn new_writes_deduplicate_equivalent_cacao_and_revoke_the_single_canonical_row() { + let fixture = fixtures() + .into_iter() + .find(|fixture| fixture.database == "active.sqlite") + .unwrap(); + let (session, vm, _, _) = signers(&fixture); + for encoding in ["v0", "v27"] { + let directory = tempfile::tempdir().unwrap(); + let db = open(&directory.path().join("new.sqlite")).await; + let canonical_cid = current_cid(&fixture.canonical_header); + for row in &fixture.rows { + assert_eq!( + current_cid(&row.header), + canonical_cid, + "same current decode bytes must define equivalence: {}", + row.encoding + ); + let event = tinycloud_core::events::Delegation::from_header_ser::( + &row.header, + ) + .unwrap(); + let receipt = db + .delegate(event) + .await + .map_err(|error| error.to_string()) + .unwrap(); + assert_eq!( + receipt.delegation_cids, + vec![hash_from_cid(&canonical_cid)], + "public ingestion receipt must advertise the retained canonical CID" + ); + } + assert_eq!( + delegation::Entity::find() + .count(db.connection()) + .await + .unwrap(), + 1 + ); + assert_eq!( + list(&db, &fixture.resource, &canonical_cid, &session, &vm) + .await + .unwrap(), + Vec::::new() + ); + let alias_count = db + .connection() + .query_one(Statement::from_string( + DbBackend::Sqlite, + "SELECT COUNT(*) AS n FROM delegation_cid_alias".to_owned(), + )) + .await + .unwrap() + .unwrap() + .try_get::("", "n") + .unwrap(); + assert_eq!(alias_count, 1); + let row = fixture + .rows + .iter() + .find(|row| row.encoding == encoding) + .unwrap(); + // New signatures target the CID the ingestion API actually retained. + assert_eq!(current_cid(&row.header), canonical_cid); + revoke(&db, &canonical_cid).await; + let error = list(&db, &fixture.resource, &canonical_cid, &session, &vm) + .await + .unwrap_err(); + assert!(error.contains("delegation-revoked"), "{error}"); + } +} + +#[tokio::test] +async fn encrypted_historical_rows_require_key_at_startup() { + let fixture = fixtures() + .into_iter() + .find(|fixture| fixture.encrypted) + .unwrap(); + let (_directory, path) = copy_database(&fixture); + let before = signed_snapshot(&path); + let result = SpaceDatabase::new( + connection(&path).await, + MemoryStore::default(), + StaticSecret::new(vec![31; 32]).unwrap(), + ) + .await; + assert!( + result.is_err(), + "encrypted historical rows must fail boot without their decryption key" + ); + assert_eq!( + signed_snapshot(&path), + before, + "failed startup must not modify historical rows" + ); +} + +fn reader_grant(fixture: &Fixture) -> (String, tinycloud_auth::resource::ResourceId) { + use std::collections::BTreeMap; + use tinycloud_auth::{ + cacaos::{ + siwe::Message, + siwe_cacao::{Header, Signature, SiweCacao}, + }, + siwe_recap::{Ability, Capability}, + }; + let (_, audience, _) = session(fixture.session_seed); + let resource = fixture + .resource + .parse::() + .unwrap() + .space() + .clone() + .to_resource( + "capabilities".parse().unwrap(), + Some("all".parse().unwrap()), + None, + None, + ); + let mut recap = Capability::::new(); + recap.with_action( + resource.as_uri(), + "tinycloud.capabilities/read".parse::().unwrap(), + [BTreeMap::new()], + ); + let message: Message = signed_cacao(&audience, None, "tc1071-query") + .payload() + .clone() + .try_into() + .unwrap(); + let message = recap.build_message(message).unwrap(); + let (key, _, _) = owner(); + let (signature, recovery) = key + .sign_prehash_recoverable(&message.eip191_hash().unwrap()) + .unwrap(); + let mut bytes = [0; 65]; + bytes[..64].copy_from_slice(signature.to_bytes().as_ref()); + bytes[64] = u8::from(recovery) + 27; + let header = TinyCloudDelegation::Cacao(Box::new(SiweCacao::new( + message.into(), + Signature::from(bytes), + Header, + ))) + .encode() + .unwrap(); + (header, resource) +} + +async fn read_capabilities( + db: &TestDb, + fixture: &Fixture, + resource: tinycloud_auth::resource::ResourceId, + proof: &str, + params: Option, +) -> Vec< + tinycloud_core::db::InvocationOutcome< + ::Readable, + >, +> { + use tinycloud_auth::authorization::{make_invocation, InvocationOptions, TinyCloudInvocation}; + use tinycloud_core::{events::Invocation, storage::memory::MemoryStaging}; + let (jwk, _, vm) = session(fixture.session_seed); + let header = make_invocation( + [(resource, ["tinycloud.capabilities/read".parse().unwrap()])], + &proof.parse().unwrap(), + &jwk, + &vm, + EXPIRATION, + InvocationOptions { + facts: params.map(|params| vec![serde_json::json!({"capabilitiesReadParams": params})]), + ..Default::default() + }, + ) + .unwrap() + .encode() + .unwrap(); + let (_, outcomes) = db + .invoke::( + Invocation::from_header_ser::(&header).unwrap(), + Default::default(), + ) + .await + .map_err(|error| error.to_string()) + .unwrap(); + assert_eq!(outcomes.len(), 1); + outcomes +} + +async fn assert_redelegation_denied(db: &TestDb, fixture: &Fixture) { + use tinycloud_core::events::Delegation; + for row in &fixture.rows { + let direct = child_header(&fixture.resource, &row.raw_cid, fixture.session_seed, 13); + let error = db + .delegate(Delegation::from_header_ser::(&direct).unwrap()) + .await + .expect_err("revoked equivalent parent must not allow a new child") + .to_string(); + assert!(error.contains("delegation-parent-revoked"), "{error}"); + let descendant = child_header(&fixture.resource, &row.child_cid, fixture.child_seed, 13); + let error = db + .delegate(Delegation::from_header_ser::(&descendant).unwrap()) + .await + .expect_err("legacy child of revoked root must not allow a descendant") + .to_string(); + assert!(error.contains("delegation-ancestor-revoked"), "{error}"); + } +} diff --git a/tinycloud-node-server/src/auth_guards.rs b/tinycloud-node-server/src/auth_guards.rs index 18fe2980..755ccc1f 100644 --- a/tinycloud-node-server/src/auth_guards.rs +++ b/tinycloud-node-server/src/auth_guards.rs @@ -11,16 +11,9 @@ use rocket::{ use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; use std::time::Instant; -use tinycloud_auth::{ - authorization::{EncodingError, HeaderEncode}, - ipld_core::cid::Cid, - resource::SpaceId, -}; +use tinycloud_auth::{authorization::TinyCloudDelegation, ipld_core::cid::Cid, resource::SpaceId}; use tinycloud_core::{ - hash::Hash, - types::Metadata, - util::{Capability, DelegationInfo}, - InvocationOutcome, + events::Delegation, hash::Hash, types::Metadata, util::Capability, InvocationOutcome, }; use tokio_util::compat::FuturesAsyncReadCompatExt; use tracing::{info_span, Instrument}; @@ -250,7 +243,7 @@ where InvocationOutcome::DelegationChain(chain) => Json( chain .into_iter() - .map(|del| Ok(CapJsonRep::from_delegation(del)?)) + .map(CapJsonRep::from_delegation) .collect::>>() .map_err(|_| Status::InternalServerError)?, ) @@ -282,6 +275,93 @@ mod tests { use rocket::{get, http::Header, local::asynchronous::Client, routes}; use tinycloud_core::{hash::hash, KvBatchReadItem, KvBatchReadValue}; + type HistoricalDb = tinycloud_core::db::SpaceDatabase< + tinycloud_core::sea_orm::DatabaseConnection, + tinycloud_core::storage::memory::MemoryStore, + tinycloud_core::keys::StaticSecret, + >; + + #[get("/historical/?")] + async fn historical_delegation_response( + db: &rocket::State, + cid: &str, + chain: bool, + ) -> Result>>, Status> { + let cid: Cid = cid.parse().map_err(|_| Status::BadRequest)?; + let (_, event) = db + .load_signed_delegation(cid) + .await + .map_err(|_| Status::InternalServerError)? + .ok_or(Status::NotFound)?; + Ok(InvOut(if chain { + InvocationOutcome::DelegationChain(vec![event]) + } else { + InvocationOutcome::OpenSessions(HashMap::from([(Hash::from(cid), event)])) + })) + } + + #[tokio::test] + async fn historical_list_and_chain_json_preserve_raw_header_cids() -> Result<()> { + use tinycloud_core::{keys::StaticSecret, sea_orm::Database, storage::memory::MemoryStore}; + let fixture_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../tinycloud-core/tests/fixtures/tc1071/generated"); + let manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(fixture_dir.join("manifest.json"))?)?; + let fixture = manifest + .as_array() + .unwrap() + .iter() + .find(|fixture| fixture["database"] == "active.sqlite") + .unwrap(); + let directory = tempfile::tempdir()?; + let path = directory.path().join("caps.sqlite"); + std::fs::copy(fixture_dir.join("active.sqlite"), &path)?; + let db = HistoricalDb::new( + Database::connect(format!("sqlite:{}?mode=rwc", path.display())).await?, + MemoryStore::default(), + StaticSecret::new(vec![31; 32]).unwrap(), + ) + .await?; + let client = Client::tracked( + rocket::build() + .manage(db) + .mount("/", routes![historical_delegation_response]), + ) + .await?; + for row in fixture["rows"].as_array().unwrap() { + for (id, header) in [("raw_cid", "header"), ("child_cid", "child_header")] { + let cid = row[id].as_str().unwrap(); + let header = row[header].as_str().unwrap(); + for chain in [false, true] { + let response = client + .get(format!("/historical/{cid}?chain={chain}")) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let json: serde_json::Value = + serde_json::from_str(&response.into_string().await.unwrap())?; + let record = if chain { &json[0] } else { &json[cid] }; + let raw = record["raw"].as_str().unwrap(); + assert_eq!(raw, header); + let bytes = if raw.contains('.') { + raw.as_bytes().to_vec() + } else { + base64::decode_config(raw, base64::URL_SAFE)? + }; + assert_eq!(hash(&bytes).to_cid(0x55).to_string(), cid); + if id == "child_cid" { + let parents: Vec = serde_json::from_value(record["parents"].clone())?; + assert_eq!( + parents, + vec![row["raw_cid"].as_str().unwrap().parse::()?] + ); + } + } + } + } + Ok(()) + } + #[get("/")] fn conditional_kv_response() -> KVResponse>> { let content = b"hello".to_vec(); @@ -520,13 +600,21 @@ pub struct CapJsonRep { } impl CapJsonRep { - pub fn from_delegation(d: DelegationInfo) -> Result { + /// Return the exact persisted header, whose bytes hash to the advertised + /// CID. Re-encoding a decoded historical CACAO would change that identity. + pub fn from_delegation(d: Delegation) -> Result { + let raw = match &d.0.delegation { + TinyCloudDelegation::Ucan(_) => std::str::from_utf8(d.serialized_bytes())?.to_owned(), + TinyCloudDelegation::Cacao(_) => { + base64::encode_config(d.serialized_bytes(), base64::URL_SAFE) + } + }; Ok(Self { - capabilities: d.capabilities, - delegator: d.delegator, - delegate: d.delegate, - parents: d.parents, - raw: d.delegation.encode()?, + capabilities: d.0.capabilities, + delegator: d.0.delegator, + delegate: d.0.delegate, + parents: d.0.parents, + raw, }) } } diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index f2c49408..886fbdff 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -622,13 +622,13 @@ pub async fn app_with_control( encryption_backend, ); - let tinycloud = TinyCloud::new( + let tinycloud = TinyCloud::new_with_encryption( database_connection, tinycloud_config.storage.blocks.open().await?, key_setup.setup(()).await?, + Some(webhook_encryption.clone()), ) .await? - .with_encryption(Some(webhook_encryption.clone())) .with_sql_sizes(sql_sizes.clone()); let encryption_service = encryption_service.with_sqlite_writer_lock(tinycloud.sqlite_writer_lock()); diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index d307a625..24313081 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -2912,11 +2912,8 @@ async fn is_root_generically_revoked( ) -> Result { let cid = tinycloud_auth::ipld_core::cid::Cid::try_from(root_cid) .map_err(|_| "policy-root-invalid")?; - revocation::Entity::find() - .filter(revocation::Column::Revoked.eq(tinycloud_core::hash::Hash::from(cid))) - .one(db) + revocation::is_revoked(db, &tinycloud_core::hash::Hash::from(cid)) .await - .map(|record| record.is_some()) .map_err(|_| "root-revocation-unavailable") } @@ -5345,6 +5342,9 @@ struct AccountOwnerProof { owner_did: String, space: SpaceId, expires_at: Option, + // Canonical identities, not the chosen legacy row, remain guarded until + // admission commits or rolls back. Revoke uses these same guards exclusive. + _chain_guards: Vec>, } /// Authenticate the policy/v2 credential owner from an already-admitted @@ -5457,12 +5457,13 @@ async fn authenticate_account_owner( "account-authorization-capability-mismatch".into(), )); } - if revocation::Entity::find() - .filter(revocation::Column::Revoked.eq(row.id)) - .one(&runtime.conn) + let chain_guards = tinycloud + .guard_delegation_chains(&[row.id]) + .await + .map_err(db_error)?; + if revocation::is_revoked(&runtime.conn, &row.id) .await .map_err(db_error)? - .is_some() || space::Entity::find_by_id(SpaceIdWrap(credentials_space.clone())) .one(&runtime.conn) .await @@ -5477,6 +5478,7 @@ async fn authenticate_account_owner( owner_did, space: credentials_space, expires_at: event.0.expiry, + _chain_guards: chain_guards, }) } @@ -5495,12 +5497,9 @@ async fn validate_locked_account_owner( || proof .expires_at .is_some_and(|expires_at| expires_at <= OffsetDateTime::now_utc()) - || revocation::Entity::find() - .filter(revocation::Column::Revoked.eq(locked.id)) - .one(transaction) + || revocation::is_revoked(transaction, &locked.id) .await .map_err(db_error)? - .is_some() || space::Entity::find_by_id(SpaceIdWrap(proof.space.clone())) .one(transaction) .await @@ -10181,3 +10180,7 @@ mod tests { .is_err()); } } + +#[cfg(test)] +#[path = "policy_v3/account_alias_tests.rs"] +mod account_alias_tests; diff --git a/tinycloud-node-server/src/policy_v3/account_alias_tests.rs b/tinycloud-node-server/src/policy_v3/account_alias_tests.rs new file mode 100644 index 00000000..6a26666a --- /dev/null +++ b/tinycloud-node-server/src/policy_v3/account_alias_tests.rs @@ -0,0 +1,542 @@ +//! Actual account-owner admission boundaries over signed historical CACAO siblings. +//! +//! The generic historical-wire decoder preserves valid pre-normalization bytes. +//! This constructs disposable fixtures; it is not an f9-generated credentials +//! database, the current HTTP ingestion path, or an end-to-end mint HTTP test. + +use super::*; +use k256::ecdsa::SigningKey; +use sha3::Keccak256; +use std::time::Duration as WaitDuration; +use tinycloud_auth::{ + authorization::{EncodingError, HeaderEncode, TinyCloudRevocation}, + cacaos::{ + siwe::{encode_eip55, Message, Version}, + siwe_cacao::{Eip191, Payload, Signature}, + }, + resolver::DID_METHODS, + siwe_recap::Capability, +}; +use tinycloud_core::{ + db::{begin_write_transaction, DelegationStatus}, + events::Revocation, + hash::Hash, + sea_orm::Database, + storage::StorageConfig as _, +}; +use tokio::{sync::OwnedRwLockReadGuard, time::timeout}; + +const WAIT: WaitDuration = WaitDuration::from_secs(10); + +/// Preserve the signed wire identity using the public generic event decoder, +/// instead of inserting inconsistent hashes or changing persisted graph rows. +struct HistoricalWire(TinyCloudDelegation); + +impl HeaderEncode for HistoricalWire { + fn encode(&self) -> Result { + self.0.encode() + } + + fn decode(header: &str) -> Result<(Self, Vec), EncodingError> { + let bytes = + decode_config(header, base64::URL_SAFE).expect("valid historical fixture base64"); + Ok((Self(TinyCloudDelegation::from_bytes(&bytes)?), bytes)) + } +} + +impl TryFrom for DelegationInfo { + type Error = >::Error; + + fn try_from(value: HistoricalWire) -> Result { + Self::try_from(value.0) + } +} + +struct SignedGrant { + id: Hash, + bytes: Vec, +} + +impl SignedGrant { + fn from_signature(message: Message, signature: [u8; 65]) -> anyhow::Result { + let cacao = Payload::from(message).sign::(Signature::from(signature)); + let header = TinyCloudDelegation::Cacao(Box::new(cacao)).encode()?; + let bytes = decode_config(header, base64::URL_SAFE)?; + Ok(Self { + id: hash(&bytes), + bytes, + }) + } + + fn event(&self) -> anyhow::Result { + Ok(Delegation::from_header_ser::( + &encode_config(&self.bytes, base64::URL_SAFE), + )?) + } +} + +struct AccountAliasFixture { + node: crate::TinyCloud, + runtime: PolicyV3Runtime, + key: SigningKey, + address: [u8; 20], + holder: String, + owner: String, + credentials_space: SpaceId, + // Exactly the same payload/r/s, with raw v=0 and conventional v=27. + aliases: [SignedGrant; 2], + unrelated: SignedGrant, + database_url: String, + directory: tempfile::TempDir, +} + +fn wallet_message(address: [u8; 20], audience: &str, nonce: &str) -> anyhow::Result { + let now = OffsetDateTime::now_utc(); + Ok(Message { + scheme: Some("https".parse()?), + domain: "account-alias.tc1071.test".parse()?, + address, + statement: None, + uri: audience.parse()?, + version: Version::V1, + chain_id: 1, + nonce: nonce.into(), + issued_at: (now - Duration::minutes(1)).into(), + expiration_time: Some((now + Duration::hours(1)).into()), + not_before: None, + request_id: None, + resources: vec![], + }) +} + +fn account_message( + address: [u8; 20], + holder: &str, + space: &SpaceId, + nonce: &str, +) -> anyhow::Result { + let mut capability = Capability::<()>::default(); + capability.with_actions_convert(format!("{space}/space"), [("tinycloud.space/host", [])])?; + Ok(capability.build_message(wallet_message(address, holder, nonce)?)?) +} + +fn sign_conventional(key: &SigningKey, message: Message) -> anyhow::Result { + let (signature, recovery) = key.sign_prehash_recoverable(&message.eip191_hash()?)?; + let mut bytes = [0_u8; 65]; + bytes[..64].copy_from_slice(signature.to_bytes().as_ref()); + bytes[64] = recovery.to_byte() + 27; + SignedGrant::from_signature(message, bytes) +} + +fn authentication_error(error: (Status, String)) -> anyhow::Error { + anyhow::anyhow!("{}: {}", error.0, error.1) +} + +impl AccountAliasFixture { + async fn new() -> anyhow::Result { + let directory = tempfile::tempdir()?; + let database_url = format!( + "sqlite:{}?mode=rwc", + directory.path().join("caps.db").display() + ); + let conn = Database::connect(&database_url).await?; + let storage = crate::BlockConfig::B(crate::storage::file_system::FileSystemConfig::new( + directory.path(), + )) + .open() + .await?; + let secret = StaticSecret::new(vec![31; 32]) + .map_err(|_| anyhow::anyhow!("invalid disposable node secret"))?; + let node = crate::TinyCloud::new(conn.clone().into(), storage, secret.clone()).await?; + let runtime = PolicyV3Runtime::new(conn, secret.node_did(), secret) + .with_sqlite_writer_lock(node.sqlite_writer_lock()); + let key = SigningKey::from_bytes(&[7_u8; 32].into())?; + let point = key.verifying_key().to_encoded_point(false); + let address: [u8; 20] = Keccak256::digest(&point.as_bytes()[1..])[12..].try_into()?; + let holder_key = JWK::generate_ed25519()?; + let holder = DID_METHODS.generate(&holder_key, "key")?.to_string(); + let owner = format!("did:pkh:eip155:1:0x{}", encode_eip55(&address)); + let credentials_space: SpaceId = format!( + "tinycloud:pkh:eip155:1:0x{}:credentials", + encode_eip55(&address) + ) + .parse()?; + + // Select a genuine recoverable signature whose raw recovery byte is + // exactly zero, rather than forcing a recovery byte onto another key. + let mut nonce = 0_u64; + let aliases = loop { + let message = account_message( + address, + &holder, + &credentials_space, + &format!("tc1071account{nonce}"), + )?; + let (signature, recovery) = key.sign_prehash_recoverable(&message.eip191_hash()?)?; + if recovery.to_byte() == 0 { + let mut bytes = [0_u8; 65]; + bytes[..64].copy_from_slice(signature.to_bytes().as_ref()); + let historical = SignedGrant::from_signature(message.clone(), bytes)?; + bytes[64] = 27; + break [historical, SignedGrant::from_signature(message, bytes)?]; + } + nonce = nonce + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("nonce overflow"))?; + }; + assert_ne!(aliases[0].id, aliases[1].id); + let unrelated = sign_conventional( + &key, + account_message(address, &holder, &credentials_space, "tc1071unrelated")?, + )?; + let fixture = Self { + node, + runtime, + key, + address, + holder, + owner, + credentials_space, + aliases, + unrelated, + database_url, + directory, + }; + for grant in fixture.aliases.iter().chain([&fixture.unrelated]) { + fixture + .node + .delegate(grant.event()?) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + fixture.assert_signed_row(grant).await?; + fixture + .assert_status(grant.id, DelegationStatus::Active) + .await?; + } + // The real signed space/host grant, not a manually inserted space row, + // is what establishes the hosted credentials-space authority. + assert!( + space::Entity::find_by_id(SpaceIdWrap(fixture.credentials_space.clone())) + .one(&fixture.runtime.conn) + .await? + .is_some() + ); + Ok(fixture) + } + + fn grant(&self, nonce: &str) -> anyhow::Result { + sign_conventional( + &self.key, + account_message(self.address, &self.holder, &self.credentials_space, nonce)?, + ) + } + + fn revocation(&self, target: Hash) -> anyhow::Result { + let signed = sign_conventional( + &self.key, + wallet_message( + self.address, + &format!("ucan:{}", target.to_cid(0x55)), + &format!("tc1071revoke{}", target.to_cid(0x55)), + )?, + )?; + Ok(Revocation::from_header_ser::( + &encode_config(signed.bytes, base64::URL_SAFE), + )?) + } + + async fn authenticate(&self, id: Hash) -> Result { + authenticate_account_owner( + Some(&id.to_cid(0x55).to_string()), + Some(&self.credentials_space.to_string()), + &self.holder, + &self.runtime, + &self.node, + ) + .await + } + + async fn assert_signed_row(&self, grant: &SignedGrant) -> anyhow::Result<()> { + let (row, event) = self + .node + .load_signed_delegation(grant.id.to_cid(0x55)) + .await + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("missing signed account row"))?; + assert_eq!(row.id, hash(&grant.bytes)); + assert_eq!(row.serialization, grant.bytes); + assert_eq!(event.content_hash(), grant.id); + let TinyCloudDelegation::Cacao(cacao) = event.0.delegation else { + anyhow::bail!("account fixture is not CACAO"); + }; + cacao.verify().await?; + Ok(()) + } + + async fn assert_status(&self, id: Hash, expected: DelegationStatus) -> anyhow::Result<()> { + assert_eq!( + self.node + .delegation_status(id, &self.owner, &[]) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?, + Some(expected), + "core status for {}", + id.to_cid(0x55) + ); + Ok(()) + } + + async fn assert_active_admission(&self, id: Hash) -> anyhow::Result<()> { + self.assert_status(id, DelegationStatus::Active).await?; + let proof = self.authenticate(id).await.map_err(authentication_error)?; + assert_eq!(proof.row.id, id); + assert_eq!(proof.cid, id.to_cid(0x55)); + assert_eq!(proof.owner_did, self.owner); + assert_eq!(proof.space, self.credentials_space); + let transaction = begin_write_transaction(&self.runtime.conn, None).await?; + validate_locked_account_owner(&transaction, &proof) + .await + .map_err(authentication_error)?; + transaction.commit().await?; + drop(proof); + Ok(()) + } + + async fn assert_aliases_denied(&self) -> anyhow::Result<()> { + for grant in &self.aliases { + self.assert_status(grant.id, DelegationStatus::Revoked) + .await?; + let error = self + .authenticate(grant.id) + .await + .err() + .expect("effectively revoked account owner must not authenticate"); + assert_eq!(error.0, Status::Forbidden); + self.assert_signed_row(grant).await?; + } + Ok(()) + } + + async fn independent_node(&self) -> anyhow::Result { + let conn = Database::connect(&self.database_url).await?; + let storage = crate::BlockConfig::B(crate::storage::file_system::FileSystemConfig::new( + self.directory.path(), + )) + .open() + .await?; + // Same disposable DB, but a new SpaceDatabase and lock registry. This + // models a writer outside this node's in-process admission guard. + Ok(crate::TinyCloud::new(conn.into(), storage, self.runtime.signer.clone()).await?) + } +} + +#[tokio::test] +async fn account_owner_rejects_revoked_cacao_aliases_in_both_directions() -> anyhow::Result<()> { + for target_index in 0..2 { + let fixture = timeout(WAIT, AccountAliasFixture::new()).await??; + for grant in fixture.aliases.iter().chain([&fixture.unrelated]) { + fixture.assert_active_admission(grant.id).await?; + } + let target = fixture.aliases[target_index].id; + timeout(WAIT, fixture.node.revoke(fixture.revocation(target)?)) + .await? + .map_err(|error| anyhow::anyhow!("{error}"))?; + fixture.assert_aliases_denied().await?; + // Equivalent grants are revoked, not every valid grant by this wallet. + fixture + .assert_active_admission(fixture.unrelated.id) + .await?; + let rows = revocation::Entity::find() + .all(&fixture.runtime.conn) + .await?; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].revoked, target); + + // Positive revoke control, after the unrelated admission proof drops. + timeout( + WAIT, + fixture + .node + .revoke(fixture.revocation(fixture.unrelated.id)?), + ) + .await? + .map_err(|error| anyhow::anyhow!("{error}"))?; + fixture + .assert_status(fixture.unrelated.id, DelegationStatus::Revoked) + .await?; + assert_eq!( + fixture + .authenticate(fixture.unrelated.id) + .await + .err() + .expect("revoked control") + .0, + Status::Forbidden + ); + } + Ok(()) +} + +#[tokio::test] +async fn locked_account_owner_recheck_rejects_committed_sibling_revocations_in_both_directions( +) -> anyhow::Result<()> { + for target_index in 0..2 { + let fixture = timeout(WAIT, AccountAliasFixture::new()).await??; + let target = fixture.aliases[target_index].id; + let proof = fixture + .authenticate(fixture.aliases[1 - target_index].id) + .await + .map_err(authentication_error)?; + let active_transaction = begin_write_transaction(&fixture.runtime.conn, None).await?; + validate_locked_account_owner(&active_transaction, &proof) + .await + .map_err(authentication_error)?; + active_transaction.commit().await?; + + let other_node = timeout(WAIT, fixture.independent_node()).await??; + timeout(WAIT, other_node.revoke(fixture.revocation(target)?)) + .await? + .map_err(|error| anyhow::anyhow!("{error}"))?; + // Confirm a durable core transition while the original proof is still + // alive; a same-registry writer would intentionally wait for it. + for grant in &fixture.aliases { + assert_eq!( + other_node + .delegation_status(grant.id, &fixture.owner, &[]) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?, + Some(DelegationStatus::Revoked) + ); + } + // No changed projection, expired proof, or removed hosted space can + // account for the Conflict: only effective sibling revocation changed. + assert_eq!( + delegation_model::Entity::find_by_id(proof.row.id) + .one(&fixture.runtime.conn) + .await?, + Some(proof.row.clone()) + ); + assert!(proof + .expires_at + .is_some_and(|expiry| expiry > OffsetDateTime::now_utc())); + assert!(space::Entity::find_by_id(SpaceIdWrap(proof.space.clone())) + .one(&fixture.runtime.conn) + .await? + .is_some()); + let transaction = begin_write_transaction(&fixture.runtime.conn, None).await?; + let error = validate_locked_account_owner(&transaction, &proof) + .await + .expect_err("transactional recheck must reject a committed sibling revocation"); + assert_eq!(error.0, Status::Conflict); + transaction.rollback().await?; + drop(proof); + fixture.assert_aliases_denied().await?; + fixture + .assert_active_admission(fixture.unrelated.id) + .await?; + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn account_owner_proofs_hold_canonical_guards_through_commit_in_both_directions( +) -> anyhow::Result<()> { + for target_index in 0..2 { + let fixture = timeout(WAIT, AccountAliasFixture::new()).await??; + let target = fixture.aliases[target_index].id; + let proof = fixture + .authenticate(fixture.aliases[1 - target_index].id) + .await + .map_err(authentication_error)?; + // Both actual return values coexist: equivalent account proofs take + // shared, not exclusive, canonical guards. + let second_proof = timeout(WAIT, fixture.authenticate(target)) + .await? + .map_err(authentication_error)?; + let locks: Vec<_> = proof + ._chain_guards + .iter() + .map(|guard| OwnedRwLockReadGuard::rwlock(guard).clone()) + .collect(); + let revoke_node = fixture.node.clone(); + let revocation = fixture.revocation(target)?; + let mut revoke = tokio::spawn(async move { + revoke_node + .revoke(revocation) + .await + .map_err(|error| anyhow::anyhow!("{error}")) + }); + + // Tokio's write-preferring RwLock refuses a new reader once the real + // revoke writer queues. Observe that state on the authentic proof's + // lock: unlike a started-task signal or sleep, this proves contention. + // No admission SQL transaction or writer/sequence guard exists yet. + timeout(WAIT, async { + loop { + if locks.iter().any(|lock| lock.try_read().is_err()) { + break; + } + tokio::select! { + result = &mut revoke => panic!("revocation escaped a live account proof: {result:?}"), + _ = tokio::task::yield_now() => {} + } + } + }) + .await?; + for grant in &fixture.aliases { + assert!(!revocation::is_revoked(&fixture.runtime.conn, &grant.id).await?); + } + + // A real unrelated signed graph write commits while that writer is + // queued. SQLite or a global writer gate cannot explain its wait. + let write_control = fixture.grant("tc1071sqlwritecontrol")?; + timeout(WAIT, fixture.node.delegate(write_control.event()?)) + .await? + .map_err(|error| anyhow::anyhow!("{error}"))?; + fixture + .assert_status(write_control.id, DelegationStatus::Active) + .await?; + + let admitted = fixture.grant("tc1071guardedadmission")?; + let sequence_guards = fixture + .node + .acquire_space_sequence_guards(std::slice::from_ref(&fixture.credentials_space)) + .await; + let writer = match &fixture.runtime.sqlite_writer_lock { + Some(lock) => Some(lock.lock().await), + None => None, + }; + let transaction = begin_write_transaction(&fixture.runtime.conn, None).await?; + validate_locked_account_owner(&transaction, &proof) + .await + .map_err(authentication_error)?; + validate_locked_account_owner(&transaction, &second_proof) + .await + .map_err(authentication_error)?; + fixture + .node + .delegate_batch_in_transaction(&transaction, vec![admitted.event()?]) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + transaction.commit().await?; + drop(writer); + drop(sequence_guards); + fixture.assert_signed_row(&admitted).await?; + assert!(!revoke.is_finished()); + assert!(!revocation::is_revoked(&fixture.runtime.conn, &target).await?); + + // Commit alone cannot release the proof's lifetime protection, nor + // can dropping just one of the two concurrently held shared proofs. + drop(proof); + assert!(locks.iter().any(|lock| lock.try_read().is_err())); + assert!(!revocation::is_revoked(&fixture.runtime.conn, &target).await?); + drop(second_proof); + timeout(WAIT, revoke).await???; + fixture.assert_aliases_denied().await?; + fixture + .assert_active_admission(fixture.unrelated.id) + .await?; + fixture.assert_active_admission(admitted.id).await?; + } + Ok(()) +} diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index 43031cb5..119365ef 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -8283,7 +8283,7 @@ mod tests { DuckDbService::new(path, u64::MAX, 300, "256MB".into(), repo) }; let rocket = rocket::build() - .mount("/", rocket::routes![invoke, info, delegate]) + .mount("/", rocket::routes![invoke, info, delegate, revoke]) .attach(crate::tracing::TracingFairing::new( &Config::default().log.tracing, )) @@ -9740,6 +9740,169 @@ mod tests { Ok(()) } + #[tokio::test] + async fn owner_can_activate_cid_root_revoke_without_target_artifact() -> Result<()> { + use rocket::http::{Header, Status}; + use tinycloud_auth::ssi::{ + dids::{DIDBuf, DIDURLBuf}, + ucan::Payload, + }; + use tinycloud_auth::ucan_capabilities_object::Capabilities; + + let fixture = KvSyncFixture::new().await?; + let target = fixture + .signed_root_grant( + "owner-cid-target", + "tinycloud.kv/sync", + whole_seconds_from_now(3600), + false, + ) + .await?; + let target_cid = target.to_cid(0x55); + assert_eq!( + fixture.sync("notes/", target, &[]).await?.status, + Status::Ok + ); + + let mut capabilities = Capabilities::new(); + capabilities.with_action( + format!("urn:cid:{target_cid}").parse()?, + "tinycloud.delegation/revoke".parse::()?, + [std::collections::BTreeMap::::new()], + ); + let root = Payload { + issuer: fixture.owner_vm.parse::()?, + audience: fixture.device_did().parse::()?, + not_before: None, + expiration: test_invocation_expiration(), + nonce: Some(fixture.next_nonce()), + facts: Some(Vec::::new()), + proof: vec![], + attenuation: capabilities.clone(), + } + .sign( + fixture.owner.get_algorithm().unwrap_or_default(), + &fixture.owner, + )? + .encode()?; + let activated = fixture + .client + .post("/delegate") + .header(Header::new("Authorization", root)) + .dispatch() + .await; + let status = activated.status(); + let body = activated.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Ok, "{body}"); + let control_cid = serde_json::from_str::(&body)?["cid"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing control CID"))? + .parse::()?; + + let revoke = Payload { + issuer: fixture.device_vm.parse::()?, + audience: fixture.device_did().parse::()?, + not_before: None, + expiration: test_invocation_expiration(), + nonce: Some(fixture.next_nonce()), + facts: Some(Vec::::new()), + proof: vec![control_cid], + attenuation: capabilities, + } + .sign( + fixture.device.get_algorithm().unwrap_or_default(), + &fixture.device, + )? + .encode()?; + let revoked = fixture + .client + .post("/revoke") + .header(Header::new("Authorization", revoke)) + .dispatch() + .await; + let status = revoked.status(); + let body = revoked.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Ok, "{body}"); + assert_eq!( + fixture.sync("notes/", target, &[]).await?.status, + Status::Unauthorized + ); + Ok(()) + } + + #[tokio::test] + async fn cid_root_revoke_rejects_foreign_signer_and_unknown_target() -> Result<()> { + use rocket::http::{Header, Status}; + use tinycloud_auth::ssi::{ + dids::{DIDBuf, DIDURLBuf}, + ucan::Payload, + }; + use tinycloud_auth::ucan_capabilities_object::Capabilities; + + let fixture = KvSyncFixture::new().await?; + let target = fixture + .signed_root_grant( + "owner-only-revoke", + "tinycloud.kv/sync", + whole_seconds_from_now(3600), + false, + ) + .await?; + let unknown = tinycloud_core::hash::hash(b"unknown-revoke-target"); + let foreign = JWK::generate_ed25519()?; + let foreign_vm = tc341_verification_method(&foreign)?; + let owner_did = fixture.space.did().to_string(); + let device_did = fixture.device_did(); + let empty = fixture + .grant( + "owner-revoke-no-abilities", + &owner_did, + &device_did, + &[], + None, + Some(whole_seconds_from_now(3600)), + &[], + ) + .await?; + for (key, issuer, cid) in [ + (&foreign, foreign_vm.as_str(), target), + (&fixture.device, fixture.device_vm.as_str(), target), + (&fixture.owner, fixture.owner_vm.as_str(), unknown), + (&fixture.owner, fixture.owner_vm.as_str(), empty), + ] { + let mut capabilities = Capabilities::new(); + capabilities.with_action( + format!("urn:cid:{}", cid.to_cid(0x55)).parse()?, + "tinycloud.delegation/revoke".parse::()?, + [std::collections::BTreeMap::::new()], + ); + let header = Payload { + issuer: issuer.parse::()?, + audience: fixture.device_did().parse::()?, + not_before: None, + expiration: test_invocation_expiration(), + nonce: Some(fixture.next_nonce()), + facts: Some(Vec::::new()), + proof: vec![], + attenuation: capabilities, + } + .sign(key.get_algorithm().unwrap_or_default(), key)? + .encode()?; + let response = fixture + .client + .post("/delegate") + .header(Header::new("Authorization", header)) + .dispatch() + .await; + assert_eq!(response.status(), Status::Unauthorized, "target {cid:?}"); + } + assert_eq!( + fixture.sync("notes/", target, &[]).await?.status, + Status::Ok + ); + Ok(()) + } + #[tokio::test] async fn kv_sync_revoked_grant_is_401_delegation_revoked() -> Result<()> { use rocket::http::Status; diff --git a/tinycloud-node-server/src/routes/tests/revocation_scope.rs b/tinycloud-node-server/src/routes/tests/revocation_scope.rs index 90cac741..638b9281 100644 --- a/tinycloud-node-server/src/routes/tests/revocation_scope.rs +++ b/tinycloud-node-server/src/routes/tests/revocation_scope.rs @@ -23,7 +23,7 @@ async fn stored_revoked( } async fn scoped_fixture() -> Result { - KvSyncFixture::new_with(|rocket| rocket.mount("/", routes![revoke])).await + KvSyncFixture::new().await } #[tokio::test] @@ -373,6 +373,89 @@ async fn space_control_cannot_revoke_a_grant_with_an_uncovered_space() -> Result .await } +#[tokio::test] +async fn space_control_covering_both_owner_spaces_revokes_a_mixed_space_grant() -> Result<()> { + use tinycloud_core::models::space as space_model; + use tinycloud_core::types::SpaceIdWrap; + + let fixture = scoped_fixture().await?; + let other = SpaceId::new(fixture.space.did().to_string().parse()?, "other".parse()?); + space_model::ActiveModel { + id: Set(SpaceIdWrap(other.clone())), + } + .insert(&fixture.conn) + .await?; + let target = fixture + .grant_in( + "covered-mixed-space-target", + &fixture.space.did().to_string(), + &fixture.device_did(), + &[ + (&fixture.space, "notes/", "tinycloud.kv/get"), + (&other, "notes/", "tinycloud.kv/get"), + ], + None, + None, + &[], + ) + .await?; + + let mut capabilities = Capabilities::new(); + for space in [&fixture.space, &other] { + capabilities.with_action( + fixture.resource_in(space, "delegation", "")?.as_uri(), + "tinycloud.delegation/revoke".parse::()?, + [std::collections::BTreeMap::::new()], + ); + } + let header = Payload { + issuer: fixture.owner_vm.parse::()?, + audience: fixture.device_did().parse()?, + not_before: None, + expiration: NumericDate::try_from_seconds( + whole_seconds_from_now(3600).unix_timestamp() as f64 + )?, + nonce: Some(fixture.next_nonce()), + facts: Some(Vec::::new()), + proof: vec![], + attenuation: capabilities, + } + .sign( + fixture.owner.get_algorithm().unwrap_or_default(), + &fixture.owner, + )? + .encode()?; + let response = fixture + .client + .post("/delegate") + .header(Header::new("Authorization", header)) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Ok, "control activation: {body}"); + let control = Hash::from( + serde_json::from_str::(&body)?["cid"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing control CID"))? + .parse::()?, + ); + let response = fixture + .client + .post("/revoke") + .header(Header::new( + "Authorization", + fixture.scoped_revoke_header(target, Some(control))?, + )) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Ok, "covered mixed-space revoke: {body}"); + assert!(stored_revoked(&fixture.conn, &target).await?); + Ok(()) +} + #[tokio::test] async fn narrowed_control_path_or_action_cannot_revoke_a_target() -> Result<()> { let fixture = scoped_fixture().await?; diff --git a/tinycloud-node-server/src/share_v2.rs b/tinycloud-node-server/src/share_v2.rs index f3673fd6..710e2fd3 100644 --- a/tinycloud-node-server/src/share_v2.rs +++ b/tinycloud-node-server/src/share_v2.rs @@ -1402,13 +1402,7 @@ async fn revocation_in_ancestry( if visited.len() > MAX_GRAPH_NODES { return Err(()); } - if revocation::Entity::find() - .filter(revocation::Column::Revoked.eq(current)) - .count(db) - .await - .map_err(|_| ())? - > 0 - { + if revocation::is_revoked(db, ¤t).await.map_err(|_| ())? { return Ok(true); } let parents = parent_delegations::Entity::find() @@ -2023,12 +2017,7 @@ async fn cid_revoked(db: &C, cid: &str) -> Result .parse::() .map_err(|_| ())? .into(); - Ok(revocation::Entity::find() - .filter(revocation::Column::Revoked.eq(id)) - .count(db) - .await - .map_err(|_| ())? - > 0) + revocation::is_revoked(db, &id).await.map_err(|_| ()) } fn typed_scope( @@ -3420,6 +3409,9 @@ mod tests { use rocket::local::asynchronous::Client; use time::Duration; + #[path = "alias_revocation_tests.rs"] + mod alias_revocation_tests; + #[test] fn policy_domain_matches_the_sdk_wire_contract() { assert_eq!(POLICY_DOMAIN, "xyz.tinycloud.share/policy/v2\0"); @@ -4532,26 +4524,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..66703c46 --- /dev/null +++ b/tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs @@ -0,0 +1,196 @@ +use super::*; +use k256::ecdsa::SigningKey; +use sha3::Keccak256; +use std::str::FromStr; +use tinycloud_auth::{ + authorization::{EncodingError, HeaderEncode, TinyCloudRevocation}, + cacaos::{ + siwe::{encode_eip55, Message}, + siwe_cacao::{Eip191, Payload, Signature}, + }, + siwe_recap::Capability, +}; +use tinycloud_core::{ + events::{Delegation, Revocation}, + util::DelegationInfo, +}; + +// Public historical-wire ingestion preserves the signed 0/1 recovery byte. +// This is not evidence of a retained credentials database or a full HTTP flow. +struct HistoricalWire(TinyCloudDelegation); + +impl HeaderEncode for HistoricalWire { + fn encode(&self) -> Result { + self.0.encode() + } + + fn decode(value: &str) -> Result<(Self, Vec), EncodingError> { + let bytes = decode_config(value, base64::URL_SAFE).expect("fixture header encoding"); + Ok((Self(TinyCloudDelegation::from_bytes(&bytes)?), bytes)) + } +} + +impl TryFrom for DelegationInfo { + type Error = >::Error; + + fn try_from(value: HistoricalWire) -> Result { + Self::try_from(value.0) + } +} + +fn message(address: &[u8; 20], audience: &str, nonce: &str, expiry: OffsetDateTime) -> Message { + Message::from_str(&format!( + "localhost wants you to sign in with your Ethereum account:\n0x{}\n\n\nURI: {}\nVersion: 1\nChain ID: 1\nNonce: {}\nIssued At: {}\nExpiration Time: {}", + encode_eip55(address), audience, nonce, + timestamp(OffsetDateTime::now_utc() - Duration::minutes(1)), timestamp(expiry) + )).expect("valid SIWE fixture") +} + +fn signed(key: &SigningKey, message: Message, historical: bool) -> Vec { + let (signature, recovery) = key + .sign_prehash_recoverable(&message.eip191_hash().unwrap()) + .unwrap(); + let mut bytes = [0u8; 65]; + bytes[..64].copy_from_slice(&signature.to_bytes()); + bytes[64] = recovery.to_byte() + if historical { 0 } else { 27 }; + let cacao = Payload::from(message).sign::(Signature::from(bytes)); + decode_config( + TinyCloudDelegation::Cacao(Box::new(cacao)) + .encode() + .unwrap(), + base64::URL_SAFE, + ) + .unwrap() +} + +async fn assert_consumers( + runtime: &ShareV2Runtime, + policy: &PolicyDocument, + bytes: &[u8], + revoked: bool, +) { + let cid = raw_blake3_cid(bytes); + let input = OwnerDelegationInput { + cid: cid.clone(), + dag_cbor: encode_config(bytes, URL_SAFE_NO_PAD), + }; + assert_eq!( + verify_owner_delegation(runtime, policy, &input) + .await + .is_err(), + revoked, + "actual owner verifier must agree with effective alias revocation" + ); + assert_eq!(cid_revoked(&runtime.conn, &cid).await.unwrap(), revoked); + assert_eq!( + revocation_in_ancestry(&runtime.conn, hash(bytes)) + .await + .unwrap(), + revoked + ); +} + +async fn signed_alias_consumers(reverse: bool) { + let secret = StaticSecret::new(vec![0x42; 32]).unwrap(); + let runtime = + compose_test_runtime(&secret, Some(TeeContext::derive_local(&secret)), true).await; + let key = SigningKey::from_bytes(&[7u8; 32].into()).unwrap(); + let point = key.verifying_key().to_encoded_point(false); + let address: [u8; 20] = Keccak256::digest(&point.as_bytes()[1..])[12..] + .try_into() + .unwrap(); + let (_, share_did) = tamper_keypair_and_did(); + let owner = format!("did:pkh:eip155:1:0x{}", encode_eip55(&address)); + let space = format!( + "tinycloud:pkh:eip155:1:0x{}:default", + encode_eip55(&address) + ); + let expiry = OffsetDateTime::now_utc() + Duration::hours(1); + let mut caps = Capability::<()>::default(); + caps.with_actions_convert( + format!("{space}/kv/shares/alias/document.md"), + [("tinycloud.kv/get", [])], + ) + .unwrap(); + let grant = caps + .build_message(message(&address, &share_did, "sharealias1071", expiry)) + .unwrap(); + let historical = signed(&key, grant.clone(), true); + let canonical = signed(&key, grant, false); + let unrelated = signed( + &key, + caps.build_message(message(&address, &share_did, "unrelated1071", expiry)) + .unwrap(), + false, + ); + assert_ne!(hash(&historical), hash(&canonical)); + for bytes in [&historical, &canonical, &unrelated] { + let parsed = TinyCloudDelegation::from_bytes(bytes).unwrap(); + verify_delegation_signature(&parsed).await.unwrap(); + let event = + Delegation::from_header_ser::(&encode_config(bytes, base64::URL_SAFE)) + .unwrap(); + runtime + .tinycloud + .delegate(event) + .await + .map_err(|error| error.to_string()) + .unwrap(); + let row = delegation::Entity::find_by_id(hash(bytes)) + .one(&runtime.conn) + .await + .unwrap() + .unwrap(); + assert_eq!( + maybe_decrypt(Some(&runtime.policy_encryption), &row.serialization).unwrap(), + *bytes, + "stored content and its hash must retain the actual signed wire bytes" + ); + } + let (mut envelope, _, _) = sample_policy(); + envelope.policy.owner_did = owner; + envelope.policy.share_key_did = share_did; + envelope.policy.target.space_id = space; + envelope.policy.resource.path = "shares/alias/document.md".into(); + envelope.policy.actions = vec!["tinycloud.kv/get".into()]; + envelope.policy.expires_at = timestamp(expiry - Duration::minutes(1)); + for bytes in [&historical, &canonical, &unrelated] { + assert_consumers(&runtime, &envelope.policy, bytes, false).await; + } + let target = if reverse { &canonical } else { &historical }; + let revocation_bytes = signed( + &key, + message( + &address, + &format!("ucan:{}", raw_blake3_cid(target)), + "revokeshare1071", + expiry, + ), + false, + ); + let event = Revocation::from_header_ser::(&encode_config( + revocation_bytes, + base64::URL_SAFE, + )) + .unwrap(); + runtime + .tinycloud + .revoke(event) + .await + .map_err(|error| error.to_string()) + .unwrap(); + for bytes in [&historical, &canonical] { + assert_consumers(&runtime, &envelope.policy, bytes, true).await; + } + assert_consumers(&runtime, &envelope.policy, &unrelated, false).await; +} + +#[tokio::test] +async fn signed_legacy_revocation_denies_canonical_share_owner_consumers() { + signed_alias_consumers(false).await; +} + +#[tokio::test] +async fn signed_canonical_revocation_denies_legacy_share_owner_consumers() { + signed_alias_consumers(true).await; +} diff --git a/tinycloud-node-server/src/webhook_dispatcher.rs b/tinycloud-node-server/src/webhook_dispatcher.rs index b1d52b59..831d08d2 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)) } @@ -609,13 +609,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(), @@ -836,14 +836,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 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() }