diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index ebc0cad8..1ddbc72d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -70,8 +70,20 @@ jobs: --health-interval 5s --health-timeout 5s --health-retries 5 + mysql: + image: mysql:8.0 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1 -u root" + --health-interval 5s + --health-timeout 5s + --health-retries 10 env: TINYCLOUD_TEST_POSTGRES_URL: postgres://postgres:postgres@localhost:5432/postgres + TINYCLOUD_TEST_MYSQL_URL: mysql://root@127.0.0.1:3306/mysql # TC-1054: tests/postgres_pool_timeouts.rs black-holes chosen loopback # connections with exact-4-tuple iptables DROP rules (the runner is a # throwaway VM with passwordless sudo). Unset, they fail under CI. diff --git a/CHANGELOG.md b/CHANGELOG.md index ffc71dd8..b272f6ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Add signed, scoped and global key-principal revocation with durable receipts, policy authority witnesses, and ordered admission/invocation checks (TC-1149). Existing natural session-expiry behavior is unchanged; legacy witness backfill requires a count-only snapshot dry-run before release. - CACAO/SIWE delegation ingestion normalizes ECDSA recovery IDs 0/1 to 27/28 before hashing and persisting the canonical DAG-CBOR bytes (TC-1071). Equivalent low-s signatures now share one delegation CID, including the CID used for revocation lookup. High-s signatures are rejected during decoding, matching the existing EIP-191 verifier and EIP-2 low-s convention; UCAN/JWS bytes remain unchanged. Existing stored delegation IDs are not rewritten. - Synchronize the standalone M1, W5 and load-signer Cargo locks with CACAO's `k256` dependency so `--locked` dependency resolution succeeds (TC-1071). Keep the load signer compatible with the SDK's default invocation lifetime. diff --git a/docs/delegation-flow.md b/docs/delegation-flow.md index 1a9a4cb3..55b236aa 100644 --- a/docs/delegation-flow.md +++ b/docs/delegation-flow.md @@ -506,6 +506,58 @@ support. On the same `utf8mb4`/default-strict configuration, unmodified alias migration exists. That separate full-schema prerequisite must be resolved and exercised before claiming end-to-end support for that configuration. +## Explicit Key Revocation and Policy Witness + +`POST /keys/revoke` accepts JSON with `targetDid`, `scope` (`{"kind":"global"}` +or `{"kind":"space","spaceId":"..."}`), and `authorization`, a signed UCAN or +SIWE/CACAO addressed to this node. The signed capability names the same +target and scope. Global revocation requires the target key or its explicitly +enrolled global recovery delegate. A space owner or its scoped recovery +delegate may revoke a key only in that owner's space. Generic `/revoke` +continues to revoke a single delegation CID, not a whole key. + +The signed policy-v3 JSON `/revoke` records one root's status, not a +key-wide cutoff. Fresh root events check the historical policy witness; +repeating the exact committed event remains idempotent. +Every witness-backed use checks the loaded roots' signed revocation status, +including an already-issued signed KV download ticket. This cutoff uses the +same root rows as provenance verification, without another query or lock. + +`targetDid` must be a canonical DID with valid DID syntax, not a DID URL +or a delegation CID; an empty method-specific ID is rejected. + +The committed response includes the target, scope, and original cutoff. +Repeating the exact signed request returns the same receipt even after the +signer is revoked or its recovery proof expires. Changed signed bytes with +the same nonce return `409` only for a still-authorized signer; a signer +without current target/scope authority receives `403`. First-use request +and recovery proof validity are checked after blocking authorization locks. + +On a fresh recovery request, the node verifies the signed chain before +locking its persisted proof rows. It then locks every dependency principal +in canonical DID order, taking the target and request signer exclusively +and other chain principals shared on their first visit. No principal lock +is upgraded within the transaction; the locks cover the tombstone and +receipt commit. + +The cutoff remains immutable; successful responses follow its durable commit. +Later policy mints, delegation admission, and invocations cannot use authority +derived from the revoked principal. Natural proof expiry is not an explicit +revoke. + +Policy registration persists a versioned, signed-byte-bound witness for its +authority dependencies. The signed candidate limit counts relevant closures, +not unrelated incoming grants. Preexisting registrations receive a conservative +transactional backfill at startup; inconsistent or incomplete provenance +prevents serving rather than silently narrowing dependencies. Before release, +an operator runs `policy-witness-dry-run` with the node's configuration on a +production snapshot or read replica. It opens read-only and reports only +aggregate `ok`, `missing`, `ambiguous`, and `overflow` counts. Any unresolved +rows require a release decision; the startup backfill is not a substitute for +the dry-run gate. Owner-self classification remains `unknown/legacy` in this +phase; natural sign-in expiry retains existing behavior. Signed self-session +classification is tracked separately in TC-1213. + ## Child Delegation (UCAN) Creation For sharing, child UCANs are created that delegate from the session key to another DID. diff --git a/tinycloud-core/Cargo.toml b/tinycloud-core/Cargo.toml index d166e69a..c9cc8f82 100644 --- a/tinycloud-core/Cargo.toml +++ b/tinycloud-core/Cargo.toml @@ -16,6 +16,9 @@ duckdb = ["dep:arrow", "dep:duckdb"] # build. Downstream crates enable it only on their `tinycloud-core` # dev-dependency so their own tests can assert exactly-once verification. verification-count-test-hook = [] +# Reconstructed pre-sunset signed Share-v2 fixtures only. Off by default so +# shipped authorization never accepts a caller-selected signature time. +historical-invocation-test-clock = [] tokio = [ "sea-orm/runtime-tokio-rustls", diff --git a/tinycloud-core/src/auth_graph.rs b/tinycloud-core/src/auth_graph.rs index 8246c743..2a9af614 100644 --- a/tinycloud-core/src/auth_graph.rs +++ b/tinycloud-core/src/auth_graph.rs @@ -15,9 +15,10 @@ use crate::relationships::parent_delegations; use sea_orm::{ entity::prelude::*, sea_query::{Alias, CommonTableExpression, Expr, JoinType, Query, UnionType, WithClause}, - ConnectionTrait, + ConnectionTrait, DbErr, }; use std::collections::{HashMap, HashSet}; +use tinycloud_auth::identity::principal_did; pub(crate) use crate::models::revocation::ChainTraversalError; use crate::models::revocation::MAX_CHAIN_TRAVERSAL_NODES; @@ -42,6 +43,115 @@ pub(crate) async fn load_chain_lock_ids( load_closure(db, roots, true).await.map(|(ids, _)| ids) } +/// The caller already loaded and bounded the signed proof closure. Resolve +/// its immutable signer/audience principals and canonical alias lock IDs in +/// one query; live revocation is still checked after acquiring those guards. +pub(crate) async fn invocation_authority_for_closure( + db: &C, + mut ids: Vec, +) -> Result<(Vec, Vec), DbErr> { + if ids.is_empty() { + return Ok((ids, Vec::new())); + } + let query = Query::select() + .columns([ + (delegation::Entity, delegation::Column::Id), + (delegation::Entity, delegation::Column::Delegator), + (delegation::Entity, delegation::Column::Delegatee), + ]) + .expr_as( + Expr::col(( + delegation_cid_alias::Entity, + delegation_cid_alias::Column::Canonical, + )), + Alias::new("canonical_lock"), + ) + .from(delegation::Entity) + .join( + JoinType::LeftJoin, + delegation_cid_alias::Entity, + Expr::col((delegation::Entity, delegation::Column::Id)).equals(( + delegation_cid_alias::Entity, + delegation_cid_alias::Column::Id, + )), + ) + .and_where( + Expr::col((delegation::Entity, delegation::Column::Id)).is_in(ids.iter().copied()), + ) + .to_owned(); + let rows = db + .query_all(db.get_database_backend().build(&query)) + .await?; + if rows.len() != ids.len() { + return Err(DbErr::Custom("authorization-chain-missing".into())); + } + let mut principals = Vec::with_capacity(rows.len() * 2); + let mut aliases = HashMap::with_capacity(rows.len()); + for row in rows { + let id: Hash = row.try_get("", "id")?; + if let Some(canonical) = row.try_get::>("", "canonical_lock")? { + aliases.insert(id, canonical); + } + for column in ["delegator", "delegatee"] { + let did: String = row.try_get("", column)?; + principals.push( + principal_did(&did) + .map_err(|_| DbErr::Custom("authorization-principal-invalid".into()))?, + ); + } + } + for id in &mut ids { + if let Some(canonical) = aliases.get(id) { + *id = *canonical; + } + } + principals.sort_unstable(); + principals.dedup(); + Ok((ids, principals)) +} + +/// Every signer and audience in a bounded, persisted proof closure. Missing +/// rows and unparseable principals are authorization failures, not an empty +/// dependency set. CID identity and DID-principal identity remain separate. +pub(crate) async fn load_chain_principals( + db: &C, + roots: &[Hash], +) -> Result, ChainTraversalError> { + let (ids, _) = load_closure_edges(db, roots).await?; + principals_for_ids(db, &ids).await +} + +/// Resolve the signers of an already bounded proof closure without re-running +/// its recursive query. The invocation request also needs these same IDs to +/// discover its policy session, so it can share this request-local snapshot. +pub(crate) async fn principals_for_ids( + db: &C, + ids: &[Hash], +) -> Result, ChainTraversalError> { + if ids.is_empty() { + return Ok(Vec::new()); + } + let rows = delegation::Entity::find() + .filter(delegation::Column::Id.is_in(ids.iter().copied())) + .all(db) + .await?; + if rows.len() != ids.len() { + return Err(DbErr::Custom("authorization-chain-missing".into()).into()); + } + let mut principals = Vec::with_capacity(rows.len() * 2); + for row in rows { + for did in [&row.delegator, &row.delegatee] { + principals.push( + principal_did(did) + .map_err(|_| DbErr::Custom("authorization-principal-invalid".into()))?, + ); + } + } + principals.sort_unstable(); + principals.dedup(); + Ok(principals) +} + async fn load_closure( db: &C, roots: &[Hash], diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 7d41a9e1..cef92e30 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -258,6 +258,29 @@ pub enum TxError { Encryption(#[from] crate::encryption::EncryptionError), #[error("delegation-chain-traversal-limit-exceeded")] ChainTraversalLimitExceeded, + #[error("authorization-dependency-denied")] + AuthorizationDenied, + #[error("authorization-revoke-wait-timeout")] + AuthorizationWaitTimeout, +} + +fn authority_error(error: DbErr) -> TxError { + if matches!(&error, DbErr::Custom(message) if message == "authorization-revoke-wait-timeout") { + TxError::AuthorizationWaitTimeout + } else if crate::key_authority::is_revoked_error(&error) { + TxError::AuthorizationDenied + } else { + TxError::Db(error) + } +} + +fn witness_error( + error: crate::policy_witness::WitnessError, +) -> TxError { + match error { + crate::policy_witness::WitnessError::Database(error) => authority_error(error), + _ => TxError::AuthorizationDenied, + } } #[non_exhaustive] @@ -368,6 +391,13 @@ where .await?; } delegation_cid_alias::backfill(&tx, encryption.as_ref()).await?; + // Release gate: Amos must run the read-only count inventory on a + // production snapshot before deploying this migration. Once enabled, + // legacy admissions are reconstructed atomically or startup refuses + // to serve them; neither CID indexing nor witness rows partially land. + crate::policy_witness::backfill_in_transaction(&tx, encryption.as_ref()) + .await + .map_err(|_| DbErr::Custom("policy-witness-backfill-unavailable".into()))?; tx.commit().await?; let writer_lock = (pool.get_database_backend() == sea_orm::DatabaseBackend::Sqlite) .then(|| Arc::new(tokio::sync::Mutex::new(()))); @@ -447,6 +477,11 @@ impl SpaceDatabase { &self.conn } + /// Encryption key for verifying signed authority from persisted rows. + pub fn column_encryption(&self) -> Option<&ColumnEncryption> { + self.encryption.as_ref() + } + /// Share the SQL/DuckDB cutover fence cache with all node data paths. pub fn database_fence_cache(&self) -> Arc { Arc::clone(&self.database_fence_cache) @@ -559,7 +594,19 @@ where invocation: &crate::util::InvocationInfo, now: OffsetDateTime, ) -> Result<(), crate::models::invocation::Error> { - invocation::verify_and_authorize(&self.conn, invocation, now).await + invocation::verify_and_authorize(&self.conn, invocation, now).await?; + let spaces = invocation + .spaces() + .map(ToString::to_string) + .collect::>(); + self.ticket_authority_unrevoked(&invocation.parents, &invocation.invoker, &spaces) + .await + .map_err(|_| { + crate::models::invocation::InvocationError::UnauthorizedInvoker( + "authorization-dependency-denied".into(), + ) + })?; + Ok(()) } /// [`Self::authorize_invocation`] for an invocation whose signature and @@ -571,7 +618,20 @@ where invocation: &crate::AdmittedInvocation, now: OffsetDateTime, ) -> Result<(), crate::models::invocation::Error> { - invocation::authorize_admitted(&self.conn, &invocation.invocation().0, now, None).await + let admitted = &invocation.invocation().0; + invocation::authorize_admitted(&self.conn, admitted, now, None).await?; + let spaces = admitted + .spaces() + .map(ToString::to_string) + .collect::>(); + self.ticket_authority_unrevoked(&admitted.parents, &admitted.invoker, &spaces) + .await + .map_err(|_| { + crate::models::invocation::InvocationError::UnauthorizedInvoker( + "authorization-dependency-denied".into(), + ) + })?; + Ok(()) } /// Check the persisted revocation state for cited delegations and every @@ -584,14 +644,53 @@ where let chain_ids = revocation::ancestor_chain_ids_for_roots(&self.conn, &roots) .await .map_err(|error| DbErr::Custom(error.to_string()))?; - for id in chain_ids { - if revocation::is_revoked(&self.conn, &id).await? { + for chunk in chain_ids.chunks(200) { + if !revocation::effective_rows(&self.conn, chunk) + .await? + .is_empty() + { return Ok(false); } } Ok(true) } + /// Recheck a minted capability ticket against its signed subject, proof + /// closure and registration witness. A proofless root still depends on its + /// subject; a policy session also depends on its historical registration. + pub async fn ticket_authority_unrevoked( + &self, + roots: &[Cid], + subject: &str, + spaces: &[String], + ) -> Result<(), DbErr> { + let ids = roots.iter().copied().map(Hash::from).collect::>(); + let mut principals = crate::auth_graph::load_chain_principals(&self.conn, &ids) + .await + .map_err(|error| DbErr::Custom(error.to_string()))?; + principals.push(subject.to_owned()); + let policy_cid = policy_cid_in_chain(&self.conn, &ids).await?; + if let Some(policy_cid) = policy_cid.as_deref() { + let witness = crate::policy_witness::revocation_dependencies(&self.conn, policy_cid) + .await + .map_err(|error| DbErr::Custom(error.to_string()))?; + principals.extend(witness.principals.iter().cloned()); + let checked_spaces = if spaces.is_empty() { + &witness.spaces + } else { + spaces + }; + crate::policy_witness::check_dependencies(&self.conn, &witness, checked_spaces) + .await + .map_err(|error| DbErr::Custom(error.to_string()))?; + } + crate::key_authority::check_principals(&self.conn, &principals, spaces).await?; + if !self.delegation_chains_unrevoked(roots).await? { + return Err(DbErr::Custom("hook ticket authority revoked".into())); + } + Ok(()) + } + /// Load and reparse a delegation from its exact signed Authorization /// bytes. The relational row is returned only so callers can compare all /// projections against the signed source of truth. @@ -640,6 +739,13 @@ where invocation::verify_and_authorize(&self.conn, invocation, now) .await .map_err(|_| AccountDelegationQueryError::Unauthorized)?; + let spaces = invocation + .spaces() + .map(ToString::to_string) + .collect::>(); + self.ticket_authority_unrevoked(&invocation.parents, &invocation.invoker, &spaces) + .await + .map_err(|_| AccountDelegationQueryError::Unauthorized)?; let principal = account_query_principal(&self.conn, invocation) .await? .ok_or(AccountDelegationQueryError::Unauthorized)?; @@ -1316,6 +1422,28 @@ where Ok(self.acquire_exclusive_chain_guards_for_keys(keys).await) } + /// One sorted registry acquisition for CID aliases and DID principals. + /// Separate acquisitions can deadlock when a root CID overlaps another + /// request's target principal guard. + async fn acquire_authority_chain_guards( + &self, + roots: &[Hash], + principals: &[String], + ) -> Result, TxError> { + let mut keys = crate::auth_graph::load_chain_lock_ids(&self.conn, roots) + .await + .map_err(|error| match error { + revocation::ChainTraversalError::Db(error) => TxError::Db(error), + revocation::ChainTraversalError::LimitExceeded => { + TxError::ChainTraversalLimitExceeded + } + })?; + for principal in principals { + keys.push(crate::key_authority::principal_lock_id(principal).map_err(TxError::Db)?); + } + Ok(self.acquire_exclusive_chain_guards_for_keys(keys).await) + } + /// Hold shared guards for the canonical ancestor identities of `roots`. /// /// These coordinate admission with delegation/revocation writers using @@ -1332,7 +1460,7 @@ where revocation::ChainTraversalError::Db(error) => error, error => DbErr::Custom(error.to_string()), })?; - Ok(self.acquire_shared_chain_guards_for_keys(keys).await) + self.acquire_shared_chain_guards_bounded(keys).await } /// Resolve `keys` to their per-delegation locks in a stable global order. @@ -1430,6 +1558,20 @@ where guards } + /// Never park a read indefinitely behind a pending revoke. Dropping the + /// timed-out future also releases any earlier guards in its sorted set. + async fn acquire_shared_chain_guards_bounded( + &self, + keys: Vec, + ) -> Result, DbErr> { + tokio::time::timeout( + std::time::Duration::from_secs(2), + self.acquire_shared_chain_guards_for_keys(keys), + ) + .await + .map_err(|_| DbErr::Custom("authorization-revoke-wait-timeout".into())) + } + async fn acquire_kv_object_guards( &self, keys: &[(SpaceId, Path)], @@ -1474,7 +1616,168 @@ where None => None, }; let tx = self.begin_graph_transaction().await?; - // `transact` takes the database-side sequence lock itself, first. + crate::kv_sync::lock_space_sequences(&tx, &spaces).await?; + let mut admission_principals = Vec::new(); + let mut admission_scopes = Vec::new(); + let mut admission_cids = Vec::new(); + for event in &events { + match event { + Event::Delegation(delegation) => { + let event_scopes = delegation + .0 + .capabilities + .iter() + .filter_map(|capability| { + capability.resource.space().map(ToString::to_string) + }) + .collect::>(); + let parents = delegation + .0 + .parents + .iter() + .copied() + .map(Hash::from) + .collect::>(); + if !parents.is_empty() { + let (ids, _) = crate::auth_graph::load_closure_edges(&tx, &parents) + .await + .map_err(|error| match error { + revocation::ChainTraversalError::Db(error) => TxError::Db(error), + revocation::ChainTraversalError::LimitExceeded => { + TxError::ChainTraversalLimitExceeded + } + })?; + if let Some(policy_cid) = + policy_cid_from_ids(&tx, &ids) + .await + .map_err(|error| match &error { + DbErr::Custom(message) + if message.starts_with("policy-session-proof-") => + { + TxError::AuthorizationDenied + } + _ => TxError::Db(error), + })? + { + let dependencies = + crate::policy_witness::revocation_dependencies(&tx, &policy_cid) + .await + .map_err(|error| match error { + crate::policy_witness::WitnessError::Database(source) => { + TxError::Db(source) + } + _ => TxError::AuthorizationDenied, + })?; + admission_cids.extend(dependencies.signed_cids); + admission_principals.extend(dependencies.principals); + if event_scopes.is_empty() + || delegation + .0 + .capabilities + .iter() + .any(|cap| cap.resource.space().is_none()) + { + admission_scopes.extend(dependencies.spaces); + } + } + admission_cids.extend(ids); + } + admission_principals.extend( + crate::auth_graph::load_chain_principals(&tx, &parents) + .await + .map_err(|error| TxError::Db(DbErr::Custom(error.to_string())))?, + ); + admission_principals.push(delegation.0.delegator.clone()); + admission_principals.push(delegation.0.delegate.clone()); + admission_scopes.extend(event_scopes); + } + Event::Revocation(revocation) => { + lock_revocation_alias_rows(&tx, Hash::from(revocation.0.revoked)).await?; + let parents = revocation + .0 + .parents + .iter() + .copied() + .map(Hash::from) + .collect::>(); + let target_scopes = abilities::Entity::find() + .filter(abilities::Column::Delegation.eq(Hash::from(revocation.0.revoked))) + .all(&tx) + .await? + .into_iter() + .filter_map(|row| row.resource.space().map(ToString::to_string)) + .collect::>(); + if !parents.is_empty() { + let policy_cid = policy_cid_in_chain(&tx, &parents).await.map_err( + |error| match &error { + DbErr::Custom(message) + if message.starts_with("policy-session-proof-") => + { + TxError::AuthorizationDenied + } + _ => TxError::Db(error), + }, + )?; + if let Some(policy_cid) = policy_cid { + let dependencies = + crate::policy_witness::revocation_dependencies(&tx, &policy_cid) + .await + .map_err(|error| match error { + crate::policy_witness::WitnessError::Database(source) => { + TxError::Db(source) + } + _ => TxError::AuthorizationDenied, + })?; + admission_cids.extend(dependencies.signed_cids); + admission_principals.extend(dependencies.principals); + if target_scopes.is_empty() { + admission_scopes.extend(dependencies.spaces); + } + } + } + let principals = + match crate::auth_graph::load_chain_principals(&tx, &parents).await { + Ok(principals) => principals, + Err(revocation::ChainTraversalError::Db(DbErr::Custom(reason))) + if reason == "authorization-chain-missing" => + { + // Preserve signed issuer-policy refusal when a + // claimed revocation parent was never admitted. + revocation::verify(&revocation.0.revocation).await?; + return Err(TxError::InvalidRevocation( + revocation::RevocationError::MissingParents, + )); + } + Err(revocation::ChainTraversalError::Db(error)) => { + return Err(TxError::Db(error)); + } + Err(revocation::ChainTraversalError::LimitExceeded) => { + return Err(TxError::ChainTraversalLimitExceeded); + } + }; + admission_principals.extend(principals); + admission_principals.push(revocation.0.revoker.clone()); + // The CID action is scoped to the capabilities it revokes; + // a key cutoff in an unrelated space must not block it. + admission_scopes.extend(target_scopes); + } + _ => {} + } + } + crate::key_authority::lock_signed_dependencies(&tx, &admission_cids).await?; + if !admission_cids.is_empty() + && !revocation::effective_rows(&tx, &admission_cids) + .await? + .is_empty() + { + return Err(TxError::AuthorizationDenied); + } + if !admission_principals.is_empty() { + crate::key_authority::lock_targets(&tx, &admission_principals, false).await?; + crate::key_authority::check_principals(&tx, &admission_principals, &admission_scopes) + .await + .map_err(authority_error::)?; + } let result = transact( &tx, @@ -1483,7 +1786,7 @@ where events, self.encryption.as_ref(), None, - &[], + &spaces, ) .await?; @@ -1678,6 +1981,25 @@ where } pub async fn delegate(&self, delegation: Delegation) -> Result> { + // Fresh expired/not-yet-valid statements cannot be admitted regardless + // of their parents. An exact already-registered statement retains its + // existing idempotent behavior; the normal authority guards still run + // before that existing-row fast path returns. + let retained_hash = delegation.content_hash(); + let invalid_time = match &delegation.0.delegation { + TinyCloudDelegation::Ucan(ucan) => ucan.payload().validate_time(None).is_err(), + TinyCloudDelegation::Cacao(cacao) => !cacao.payload().valid_now(), + }; + if invalid_time + && delegation::Entity::find_by_id(retained_hash) + .one(&self.conn) + .await? + .is_none() + { + return Err(TxError::InvalidDelegation( + delegation::DelegationError::InvalidTime, + )); + } let parent_hashes: Vec = delegation .0 .parents @@ -1685,13 +2007,35 @@ where .copied() .map(Hash::from) .collect(); - let retained_hash = delegation.content_hash(); // Capture expected row counts before consuming the delegation. let expected_ability_count = delegation.0.capabilities.len() as u64; let expected_parent_count = parent_hashes.len() as u64; let roots = delegation_guard_roots(retained_hash, &parent_hashes); + let mut principals = + match crate::auth_graph::load_chain_principals(&self.conn, &parent_hashes).await { + Ok(principals) => principals, + Err(revocation::ChainTraversalError::Db(DbErr::Custom(reason))) + if reason == "authorization-chain-missing" => + { + // The signature and DID method were historically checked + // before a missing parent. Do that only on this failing + // path; successful imports still verify once in process(). + delegation::verify(&delegation.0.delegation).await?; + return Err(TxError::InvalidDelegation( + delegation::DelegationError::MissingParents, + )); + } + Err(revocation::ChainTraversalError::Db(error)) => return Err(TxError::Db(error)), + Err(revocation::ChainTraversalError::LimitExceeded) => { + return Err(TxError::ChainTraversalLimitExceeded); + } + }; + principals.push(delegation.0.delegator.clone()); + principals.push(delegation.0.delegate.clone()); let authz_start = Instant::now(); - let chain_guards = self.acquire_chain_guards(&roots).await; + let chain_guards = self + .acquire_authority_chain_guards(&roots, &principals) + .await; crate::telemetry::observe_stage( crate::telemetry::InvocationStage::AuthorizationGraphLoad, crate::telemetry::StageOutcome::from(chain_guards.is_ok()), @@ -1776,6 +2120,9 @@ where let mut roots = vec![Hash::from(revocation.0.revoked)]; roots.extend(revocation.0.parents.iter().copied().map(Hash::from)); let revocation_start = Instant::now(); + // A CID revoke writes CID state, not a principal tombstone. Acquiring + // an exclusive principal lock here would queue unrelated grants from + // the same signer behind an account proof held on the target CID. let chain_guards = self.acquire_chain_guards(&roots).await; crate::telemetry::observe_stage( crate::telemetry::InvocationStage::RevocationWork, @@ -1787,6 +2134,236 @@ where .await } + /// Atomically publish a permanent DID-principal cutoff and its retryable + /// receipt. The signed request is verified by the Node route; authority + /// and nonce precedence are checked again on the transaction snapshot. + pub async fn revoke_key( + &self, + request: key_revocation::VerifiedKeyRevoke, + ) -> Result { + use key_revocation::{KeyRevokeError, KeyRevokeScope}; + let db_error = |source, write_may_have_applied| KeyRevokeError::Db { + source, + write_may_have_applied, + }; + let nonce_key = (request.issuer_did.clone(), request.nonce.clone()); + let exact_receipt = |row: &key_revocation::nonce::Model| { + if row.authorization_hash != request.authorization_hash + || row.target_did != request.target_did + { + return Ok(None); + } + let receipt: key_revocation::KeyRevokeReceipt = + serde_json::from_slice(&row.receipt_bytes) + .map_err(|_| db_error(DbErr::Custom("key-receipt-invalid".into()), false))?; + if receipt.scope != request.scope || receipt.target_did != request.target_did { + return Err(KeyRevokeError::Forbidden); + } + Ok(Some(receipt)) + }; + // This read never discloses a target. The original signed request + // proves possession of the same issuer key even after its revocation. + let existing = key_revocation::nonce::Entity::find_by_id(nonce_key.clone()) + .one(&self.conn) + .await + .map_err(|error| db_error(error, false))?; + if let Some(row) = &existing { + if let Some(receipt) = exact_receipt(row)? { + return Ok(receipt); + } + } + + let target_key = crate::key_authority::principal_lock_id(&request.target_did) + .map_err(|error| db_error(error, false))?; + let _target_guard = self + .acquire_exclusive_chain_guards_for_keys(vec![target_key]) + .await; + let _writer = match &self.writer_lock { + Some(lock) => Some(lock.lock().await), + None => None, + }; + let tx = self + .begin_graph_transaction() + .await + .map_err(|error| db_error(error, false))?; + // A concurrent request may have committed the identical self-revoke + // while this one waited for its target lock. Its receipt remains + if let Some(row) = key_revocation::nonce::Entity::find_by_id(nonce_key.clone()) + .one(&tx) + .await + .map_err(|error| db_error(error, false))? + { + if let Some(receipt) = exact_receipt(&row)? { + return Ok(receipt); + } + } + let now = OffsetDateTime::now_utc(); + let direct = if tinycloud_auth::identity::did_principal_matches( + &request.issuer_did, + &request.target_did, + ) { + true + } else if let KeyRevokeScope::Space { space_id } = &request.scope { + space_id + .parse::() + .ok() + .filter(|space| space.to_string() == *space_id) + .is_some_and(|space| { + tinycloud_auth::identity::did_principal_matches( + space.did().as_str(), + &request.issuer_did, + ) + }) + } else { + false + }; + let mut recovery_expires_at = None; + let authorized = if direct { + let spaces = match &request.scope { + KeyRevokeScope::Global => Vec::new(), + KeyRevokeScope::Space { space_id } => vec![space_id.clone()], + }; + match crate::key_authority::check_principals( + &tx, + std::slice::from_ref(&request.issuer_did), + &spaces, + ) + .await + { + Ok(()) => true, + Err(error) if crate::key_authority::is_revoked_error(&error) => false, + Err(error) => return Err(db_error(error, false)), + } + } else { + crate::recovery_authority::is_authorized( + &tx, + &request, + now, + self.encryption.as_ref(), + &mut recovery_expires_at, + ) + .await + .map_err(|error| db_error(error, false))? + }; + if !authorized { + // A second process may have committed the same signed request + // before this authority read. Its receipt is still retryable even + // when that commit has revoked the issuer itself. + if let Some(row) = key_revocation::nonce::Entity::find_by_id(nonce_key.clone()) + .one(&tx) + .await + .map_err(|error| db_error(error, false))? + { + if let Some(receipt) = exact_receipt(&row)? { + return Ok(receipt); + } + } + return Err(KeyRevokeError::Forbidden); + } + // Recovery already holds the target and issuer UPDATE locks along + // with shared proof principals in one canonical order. Direct + // self/space-owner revokes have no proof principal locks to upgrade. + if direct { + crate::key_authority::lock_targets( + &tx, + &[request.target_did.clone(), request.issuer_did.clone()], + true, + ) + .await + .map_err(|error| db_error(error, false))?; + } + // The issuer/target database row locks are the cross-process + // serialization point. Re-read the durable nonce before fresh + // liveness or time checks: a simultaneous self-revoke cannot turn an + // identical committed request into a 403. + let post_lock_nonce = key_revocation::nonce::Entity::find_by_id(nonce_key.clone()) + .one(&tx) + .await + .map_err(|error| db_error(error, false))?; + if let Some(row) = &post_lock_nonce { + if let Some(receipt) = exact_receipt(row)? { + return Ok(receipt); + } + } + let spaces = match &request.scope { + KeyRevokeScope::Global => Vec::new(), + KeyRevokeScope::Space { space_id } => vec![space_id.clone()], + }; + match crate::key_authority::check_principals( + &tx, + std::slice::from_ref(&request.issuer_did), + &spaces, + ) + .await + { + Ok(()) => {} + Err(error) if crate::key_authority::is_revoked_error(&error) => { + return Err(KeyRevokeError::Forbidden); + } + Err(error) => return Err(db_error(error, false)), + } + let cutoff_now = OffsetDateTime::now_utc(); + let now_seconds = cutoff_now.unix_timestamp(); + let issued_at = request.not_before; + if recovery_expires_at.is_some_and(|expiry| cutoff_now >= expiry) + || issued_at > now_seconds.saturating_add(30) + || request.expires_at <= now_seconds + || request.expires_at.saturating_sub(issued_at) > 60 + || request.expires_at <= issued_at + || request + .valid_after + .is_some_and(|not_before| now_seconds < not_before) + { + return Err(KeyRevokeError::Forbidden); + } + // A changed nonce is disclosed only after the fresh signer and + // signed-time checks, never to a revoked or unrelated signer. + if post_lock_nonce.is_some() { + return Err(KeyRevokeError::Conflict); + } + let scope_key = request.scope.storage_key(); + let existing_cutoff = + key_revocation::Entity::find_by_id((request.target_did.clone(), scope_key.clone())) + .one(&tx) + .await + .map_err(|error| db_error(error, false))?; + let revoked_at = match existing_cutoff { + Some(row) => row.revoked_at, + None => { + let cutoff = now_seconds; + key_revocation::ActiveModel { + target_did: Set(request.target_did.clone()), + scope_key: Set(scope_key), + revoked_at: Set(cutoff), + } + .insert(&tx) + .await + .map_err(|error| db_error(error, false))?; + cutoff + } + }; + let receipt = key_revocation::KeyRevokeReceipt { + revoked: true, + target_did: request.target_did.clone(), + scope: request.scope, + revoked_at, + }; + let receipt_bytes = serde_json::to_vec(&receipt) + .map_err(|_| db_error(DbErr::Custom("key-receipt-encode-invalid".into()), false))?; + key_revocation::nonce::ActiveModel { + issuer_did: Set(nonce_key.0), + nonce: Set(nonce_key.1), + authorization_hash: Set(request.authorization_hash.to_vec()), + target_did: Set(request.target_did), + receipt_bytes: Set(receipt_bytes), + } + .insert(&tx) + .await + .map_err(|error| db_error(error, false))?; + tx.commit().await.map_err(|error| db_error(error, true))?; + Ok(receipt) + } + pub async fn delegation_status( &self, target: Hash, @@ -1809,12 +2386,14 @@ where let mut roots = vec![target]; roots.extend(proofs.iter().copied().map(Hash::from)); - let _chain_guards = match self.acquire_chain_guards(&roots).await { + let _chain_guards = match self.guard_delegation_chains(&roots).await { Ok(guards) => guards, - Err(TxError::ChainTraversalLimitExceeded) => { + Err(DbErr::Custom(message)) + if message == "delegation-chain-traversal-limit-exceeded" => + { return Ok(Some(DelegationStatus::Unavailable)); } - Err(error) => return Err(error), + Err(error) => return Err(authority_error(error)), }; let authorized = match revocation::control_proof_decision( @@ -1846,6 +2425,34 @@ where return Ok(None); } + // Check every key dependency before reporting even a generically + // revoked target. A revoked requester or historical policy signer + // cannot recover delegation metadata through the status route. + let mut principals = crate::auth_graph::load_chain_principals(&self.conn, &roots) + .await + .map_err(|_| TxError::::AuthorizationDenied)?; + principals.push(invoker.to_owned()); + let witness = match policy_cid_in_chain(&self.conn, &roots) + .await + .map_err(|_| TxError::::AuthorizationDenied)? + { + Some(policy_cid) => { + let dependencies = + crate::policy_witness::revocation_dependencies(&self.conn, &policy_cid) + .await + .map_err(|_| TxError::::AuthorizationDenied)?; + principals.extend(dependencies.principals.iter().cloned()); + Some(dependencies) + } + None => None, + }; + let spaces = abilities + .iter() + .filter_map(|ability| ability.resource.space().map(ToString::to_string)) + .collect::>(); + crate::key_authority::check_principals(&self.conn, &principals, &spaces) + .await + .map_err(|_| TxError::::AuthorizationDenied)?; if revocation::is_revoked(&self.conn, &target).await? { return Ok(Some(DelegationStatus::Revoked)); } @@ -1857,6 +2464,11 @@ where } Err(revocation::ChainTraversalError::Db(error)) => return Err(error.into()), } + if let Some(dependencies) = witness.as_ref() { + crate::policy_witness::check_signed_cids(&self.conn, dependencies) + .await + .map_err(|_| TxError::::AuthorizationDenied)?; + } let now = OffsetDateTime::now_utc(); if delegation @@ -2013,23 +2625,57 @@ where .as_ref() .and_then(|sync| sync.retention_grant), ); - let authz_start = Instant::now(); - let closure_start = Instant::now(); - let lock_keys = crate::auth_graph::load_chain_lock_ids(&self.conn, &guard_roots) + let (guard_chain_ids, _) = crate::auth_graph::load_closure_edges(&self.conn, &guard_roots) .await - .map_err(|error| match error { - revocation::ChainTraversalError::Db(error) => TxError::Db(error), - revocation::ChainTraversalError::LimitExceeded => { - TxError::ChainTraversalLimitExceeded + .map_err(|error| { + TxStoreError::Tx(match error { + revocation::ChainTraversalError::Db(error) => TxError::Db(error), + revocation::ChainTraversalError::LimitExceeded => { + TxError::ChainTraversalLimitExceeded + } + }) + })?; + let policy_cid = if guard_roots == roots { + policy_cid_from_ids(&self.conn, &guard_chain_ids).await + } else { + policy_cid_in_chain(&self.conn, &roots).await + } + .map_err(|error| { + TxStoreError::Tx(match &error { + DbErr::Custom(message) if message.starts_with("policy-session-proof-") => { + TxError::AuthorizationDenied } - }); + _ => TxError::Db(error), + }) + })?; + let witness_dependencies = match policy_cid.as_deref() { + Some(policy_cid) => Some( + crate::policy_witness::revocation_dependencies(&self.conn, policy_cid) + .await + .map_err(|error| { + TxStoreError::Tx(match error { + crate::policy_witness::WitnessError::Database(source) => { + TxError::Db(source) + } + _ => TxError::AuthorizationDenied, + }) + })?, + ), + None => None, + }; + let authz_start = Instant::now(); + let closure_start = Instant::now(); + let authority = + crate::auth_graph::invocation_authority_for_closure(&self.conn, guard_chain_ids) + .await + .map_err(TxError::Db); crate::telemetry::observe_stage( crate::telemetry::InvocationStage::ChainClosureQuery, - crate::telemetry::StageOutcome::from(lock_keys.is_ok()), + crate::telemetry::StageOutcome::from(authority.is_ok()), closure_start.elapsed(), ); - let lock_keys = match lock_keys { - Ok(keys) => keys, + let (mut lock_keys, mut principals) = match authority { + Ok(authority) => authority, Err(error) => { crate::telemetry::observe_stage( crate::telemetry::InvocationStage::AuthorizationGraphLoad, @@ -2039,13 +2685,48 @@ where return Err(TxStoreError::Tx(error)); } }; - // TC-324: invocations take the chain guards SHARED. The full ancestor - // closure is still guarded and the guards are still held through - // commit, so a revocation (which takes them exclusive) remains - // serialized against this authorization decision. What is dropped is - // invocation-vs-invocation exclusion, which the revocation-ordering - // invariant never depended on. - let _chain_guards = self.acquire_shared_chain_guards_for_keys(lock_keys).await; + principals.push(invocation.0.invoker.clone()); + if let Some(dependencies) = &witness_dependencies { + principals.extend(dependencies.principals.iter().cloned()); + } + principals.sort_unstable(); + principals.dedup(); + let mut affected_spaces = invocation + .0 + .spaces() + .map(ToString::to_string) + .collect::>(); + if affected_spaces.is_empty() + || invocation + .0 + .capabilities + .iter() + .any(|cap| cap.resource.space().is_none()) + { + if let Some(dependencies) = &witness_dependencies { + affected_spaces.extend(dependencies.spaces.iter().cloned()); + } + } + affected_spaces.sort_unstable(); + affected_spaces.dedup(); + if let Some(dependencies) = &witness_dependencies { + lock_keys.extend( + crate::auth_graph::load_chain_lock_ids(&self.conn, &dependencies.signed_cids) + .await + .map_err(|error| match error { + revocation::ChainTraversalError::Db(error) => TxError::Db(error), + revocation::ChainTraversalError::LimitExceeded => { + TxError::ChainTraversalLimitExceeded + } + })?, + ); + } + for principal in &principals { + lock_keys.push( + crate::key_authority::principal_lock_id(principal) + .map_err(|error| TxStoreError::Tx(TxError::Db(error)))?, + ); + } let mutation_keys = invocation .0 .capabilities @@ -2067,9 +2748,35 @@ where count: mutation_keys.len(), }); } + // DID resolution may perform network I/O. Verify a public read's + // envelope before acquiring its shared revocation guards; the guarded + // authorization below rechecks the signed time and live dependencies. + if mutation_keys.is_empty() && mode != InvokeMode::Admitted { + invocation::verify_invocation(&invocation.0.invocation) + .await + .map_err(TxError::::from)?; + } + // TC-324: invocations take the chain guards SHARED. The full ancestor + // closure is still guarded and the guards are still held through + // commit, so a revocation (which takes them exclusive) remains + // serialized against this authorization decision. What is dropped is + // invocation-vs-invocation exclusion, which the revocation-ordering + // invariant never depended on. + let chain_guards = self + .acquire_shared_chain_guards_bounded(lock_keys) + .await + .map_err(|error| TxStoreError::Tx(authority_error::(error)))?; if mutation_keys.is_empty() { return self - .invoke_read_only::(invocation, options, mode, sync_target) + .invoke_read_only::( + invocation, + options, + sync_target, + chain_guards, + principals, + affected_spaces, + witness_dependencies, + ) .await; } let _kv_object_guards = self.acquire_kv_object_guards(&mutation_keys).await; @@ -2140,10 +2847,37 @@ where begin_start.elapsed(), ); let tx = tx_result?; - // TC-732: order this transaction's event sequence under the - // per-space lock. On PostgreSQL this is an advisory lock, so the - // meeting legacy guard lock below is still the first row lock. + // TC-732: sequence first, then sorted signed proof and principal + // rows, then any meeting legacy row. Mutations use this order. crate::kv_sync::lock_space_sequences(&tx, &sequence_spaces).await?; + let (mut signed_ids, _) = crate::auth_graph::load_closure_edges(&tx, &guard_roots) + .await + .map_err(|error| match error { + revocation::ChainTraversalError::Db(error) => TxStoreError::Tx(TxError::Db(error)), + revocation::ChainTraversalError::LimitExceeded => { + TxStoreError::Tx(TxError::ChainTraversalLimitExceeded) + } + })?; + if let Some(dependencies) = &witness_dependencies { + signed_ids.extend(dependencies.signed_cids.iter().copied()); + } + crate::key_authority::lock_signed_dependencies(&tx, &signed_ids).await?; + if !signed_ids.is_empty() + && !revocation::effective_rows(&tx, &signed_ids) + .await? + .is_empty() + { + return Err(TxStoreError::Tx(TxError::AuthorizationDenied)); + } + crate::key_authority::lock_targets(&tx, &principals, false).await?; + crate::key_authority::check_principals(&tx, &principals, &affected_spaces) + .await + .map_err(|error| TxStoreError::Tx(authority_error::(error)))?; + if let Some(dependencies) = &witness_dependencies { + crate::policy_witness::check_signed_cids(&tx, dependencies) + .await + .map_err(|error| TxStoreError::Tx(witness_error::(error)))?; + } // On SQLite `begin_graph_transaction` already holds the write lock, // so no read-to-write upgrade race remains. Distinct spaces are // locked in a stable order to prevent cross-space deadlocks on PG. @@ -2358,16 +3092,18 @@ where } /// Execute a successful non-mutating invocation without entering the - /// single-writer transaction. Authorization and data access remain under - /// the caller's shared chain guards, which are held across this call and - /// released only after it returns; the response waits for the grouped - /// audit commit. + /// single-writer transaction. Shared guards cover authorization only; + /// data access and the read-audit acknowledgment never retain a guard. + #[allow(clippy::too_many_arguments)] async fn invoke_read_only( &self, invocation: Invocation, options: KvInvokeOptions, - mode: InvokeMode, sync_target: Option, + chain_guards: Vec, + principals: Vec, + affected_spaces: Vec, + witness_dependencies: Option, ) -> Result<(TransactResult, Vec>), TxStoreError> where B: ImmutableWriteStore + ImmutableReadStore, @@ -2408,27 +3144,20 @@ where } None => None, }; - // TC-409: an admitted invocation already had its signature verified - // once at the admission boundary; only re-check authorization and - // signed-time validity here rather than verifying the signature a - // second time. - match mode { - InvokeMode::Admitted => { - invocation::authorize_admitted(&self.conn, &invocation.0, now, sync_graph.as_ref()) - .await - .map_err(TxError::::from)? - } - InvokeMode::Public | InvokeMode::Internal | InvokeMode::MeetingPublication => { - invocation::verify_and_authorize_with_graph( - &self.conn, - &invocation.0, - now, - sync_graph.as_ref(), - ) + // Admitted envelopes were verified at their admission boundary; + // public read envelopes were verified before acquiring these guards. + // Recheck authorization and signed time at the guarded ordering point. + invocation::authorize_admitted(&self.conn, &invocation.0, now, sync_graph.as_ref()) + .await + .map_err(TxError::::from)?; + crate::key_authority::check_principals(&self.conn, &principals, &affected_spaces) + .await + .map_err(|error| TxStoreError::Tx(authority_error::(error)))?; + if let Some(dependencies) = &witness_dependencies { + crate::policy_witness::check_signed_cids(&self.conn, dependencies) .await - .map_err(TxError::::from)? - } - }; + .map_err(|error| TxStoreError::Tx(witness_error::(error)))?; + } let requested_spaces = invocation.0.spaces().cloned().collect::>(); if !requested_spaces.is_empty() { @@ -2440,6 +3169,9 @@ where return Err(TxError::SpaceNotFound.into()); } } + // The authorization decision is the read's ordering point. A revoke + // committed later may proceed while storage or the audit ACK waits. + drop(chain_guards); let sync_outcome = match (sync_target, sync_graph.as_ref()) { (Some((space, prefix, resource)), Some(graph)) => { @@ -2623,6 +3355,170 @@ where } } +/// Locate the admitted S0 inside an invocation's bounded signed proof +/// closure. Ordinary delegations have no policy session index. Two different +/// policies in one closure are ambiguous rather than interchangeable grants. +async fn policy_cid_in_chain( + db: &C, + roots: &[Hash], +) -> Result, DbErr> { + let (ids, _) = crate::auth_graph::load_closure_edges(db, roots) + .await + .map_err(|error| DbErr::Custom(error.to_string()))?; + policy_cid_from_ids(db, &ids).await +} + +async fn policy_cid_from_ids( + db: &C, + ids: &[Hash], +) -> Result, DbErr> { + if ids.is_empty() { + return Ok(None); + } + let session_ids = ids + .iter() + .map(|id| id.to_cid(0x55).to_string()) + .collect::>(); + let sessions = policy_v3_session::Entity::find() + .filter(policy_v3_session::Column::SessionCid.is_in(session_ids.iter().map(String::as_str))) + .all(db) + .await?; + match sessions.as_slice() { + [] => { + // A signed policy root in the closure without its admitted S0 + // index is missing provenance, not an ordinary delegation. + let has_policy_root = policy_v3_root::Entity::find() + .filter(policy_v3_root::Column::RootCid.is_in(session_ids)) + .one(db) + .await? + .is_some(); + if has_policy_root { + Err(DbErr::Custom("policy-session-proof-missing".into())) + } else { + Ok(None) + } + } + [session] if session.state == "admitted" => { + if policy_session_matches_signed_binding(db, session).await? { + Ok(Some(session.policy_cid.clone())) + } else { + Err(DbErr::Custom("policy-session-proof-ambiguous".into())) + } + } + _ => Err(DbErr::Custom("policy-session-proof-ambiguous".into())), + } +} + +/// The mutable S0 index cannot reassign a signed session to another policy, +/// recipient, or proof pair. The registration witness checks those roots +/// separately; this binds the exact S0 that selected that witness. +async fn policy_session_matches_signed_binding( + db: &C, + session: &policy_v3_session::Model, +) -> Result { + let Ok(cid) = Cid::try_from(session.session_cid.as_str()) else { + return Ok(false); + }; + let id = Hash::from(cid); + if id.to_cid(0x55).to_string() != session.session_cid + || crate::hash::hash(&session.authorization_bytes) != id + { + return Ok(false); + } + let Ok(TinyCloudDelegation::Ucan(ucan)) = + TinyCloudDelegation::from_bytes(&session.authorization_bytes) + else { + return Ok(false); + }; + let payload = ucan.payload(); + let Some(facts) = payload.facts.as_ref().filter(|facts| facts.len() == 1) else { + return Ok(false); + }; + let Some(fact) = facts[0].as_object() else { + return Ok(false); + }; + let Some(registration) = policy_v3_registration::Entity::find_by_id(session.policy_cid.clone()) + .one(db) + .await? + else { + return Ok(false); + }; + if fact.get("profile").and_then(serde_json::Value::as_str) != Some("policy-session-ucan/v1") + || fact.get("policyCid").and_then(serde_json::Value::as_str) + != Some(session.policy_cid.as_str()) + || fact.get("recipientDid").and_then(serde_json::Value::as_str) + != Some(session.recipient_did.as_str()) + || fact + .get("policyDelegationCid") + .and_then(serde_json::Value::as_str) + != Some(registration.policy_root_cid.as_str()) + || fact + .get("enforcementDelegationCid") + .and_then(serde_json::Value::as_str) + != Some(registration.enforcement_root_cid.as_str()) + || fact.get("nodeAudience").and_then(serde_json::Value::as_str) + != Some(payload.issuer.as_str()) + || payload.audience.as_str() != session.recipient_did + || payload.proof.len() != 2 + || payload.proof[0].to_string() != registration.policy_root_cid + || payload.proof[1].to_string() != registration.enforcement_root_cid + { + return Ok(false); + } + let Some(graph) = delegation::Entity::find_by_id(id).one(db).await? else { + return Ok(false); + }; + if graph.delegator.as_str() != payload.issuer.as_str() + || graph.delegatee != session.recipient_did + || graph + .facts + .as_ref() + .and_then(|facts| facts.0.get("xyz.tinycloud.policy/session-fact")) + != Some(&facts[0]) + { + return Ok(false); + } + let persisted = parent_delegations::Entity::find() + .filter(parent_delegations::Column::Child.eq(id)) + .all(db) + .await?; + let signed = [Hash::from(payload.proof[0]), Hash::from(payload.proof[1])]; + Ok(signed[0] != signed[1] + && persisted.len() == 2 + && persisted.iter().any(|edge| edge.parent == signed[0]) + && persisted.iter().any(|edge| edge.parent == signed[1])) +} + +/// A generic CID revoke must conflict with recovery proofs that read any +/// equivalent signed statement in another process. Lock all stored aliases +/// before validating/persisting the revocation, not only the submitted CID. +async fn lock_revocation_alias_rows(tx: &DatabaseTransaction, target: Hash) -> Result<(), DbErr> { + if tx.get_database_backend() == DbBackend::Sqlite { + return Ok(()); + } + let canonical = delegation_cid_alias::Entity::find_by_id(target) + .one(tx) + .await? + .map_or(target, |alias| alias.canonical); + let mut ids = delegation_cid_alias::Entity::find() + .filter(delegation_cid_alias::Column::Canonical.eq(canonical)) + .all(tx) + .await? + .into_iter() + .map(|alias| alias.id) + .collect::>(); + ids.push(target); + ids.sort_unstable(); + ids.dedup(); + for id in ids { + delegation::Entity::find_by_id(id) + .lock_exclusive() + .one(tx) + .await?; + } + Ok(()) +} + /// The `(space, prefix, resource)` a `tinycloud.kv/sync` invocation reads. type KvSyncTarget = (SpaceId, Path, Resource); @@ -3912,7 +4808,7 @@ fn byte_ordered_resource(backend: DbBackend) -> SimpleExpr { } /// `column`, compared in byte order on `backend` (see `byte_ordered_resource`). -fn byte_ordered(backend: DbBackend, column: impl Into) -> SimpleExpr { +pub(crate) fn byte_ordered(backend: DbBackend, column: impl Into) -> SimpleExpr { // `$1` / `?` is sea-query's per-backend placeholder for the embedded // column expression, not for a bound value. match backend { @@ -5789,6 +6685,13 @@ mod test { .await .unwrap(); + assert!( + !crate::models::revocation::is_revoked(&db.conn, &parent_id) + .await + .unwrap(), + "the issue and existing-use checks must see a live parent before revoke" + ); + let revoke_guard = db .acquire_chain_guards(&[parent_id]) .await @@ -6514,9 +7417,13 @@ mod test { /// Resolve the ancestor closure exactly as `invoke_with_options_mode` /// does, then take the guards shared — the production invocation path. async fn invocation_chain_keys(db: &TestDb, roots: &[Hash]) -> Vec { - crate::auth_graph::load_chain_lock_ids(&db.conn, roots) + let (signed, _) = crate::auth_graph::load_closure_edges(&db.conn, roots) + .await + .expect("signed chain closure"); + crate::auth_graph::invocation_authority_for_closure(&db.conn, signed) .await - .expect("chain closure query") + .expect("canonical alias locks") + .0 } async fn acquire_invocation_guards(db: &TestDb, roots: &[Hash]) -> Vec { @@ -6700,6 +7607,279 @@ mod test { drop(after); } + /// The in-process guard registry is not shared between database handles. + /// A committed CID revoke must still be visible to another connection and + /// after a cold restart; simultaneous cross-process reads are TC-1216. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn independent_connections_observe_committed_cid_revoke_after_ack() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("revoke-cutoff.db"); + let make_db = |conn| async move { + SpaceDatabase::new( + conn, + MemoryStore::default(), + StaticSecret::new([0u8; 32].to_vec()).unwrap(), + ) + .await + .unwrap() + }; + let admitting = + make_db(sqlite_write_pool(&path, std::time::Duration::from_secs(5), 2).await).await; + let revoking = + make_db(sqlite_write_pool(&path, std::time::Duration::from_secs(5), 2).await).await; + insert_chain_guard_actors(&admitting).await; + let (parent, child) = insert_test_chain(&admitting, "cross-connection-cutoff").await; + let before = admitting.guard_delegation_chains(&[child]).await.unwrap(); + assert!(admitting + .delegation_chains_unrevoked(&[child.to_cid(0x55)]) + .await + .unwrap()); + drop(before); + + let guards = revoking + .acquire_chain_guards(&[parent]) + .await + .ok() + .expect("exclusive revoke chain guard"); + insert_test_revocation(&revoking, "cross-connection-revoke", parent).await; + drop(guards); + let after_ack = admitting.guard_delegation_chains(&[child]).await.unwrap(); + assert!( + !admitting + .delegation_chains_unrevoked(&[child.to_cid(0x55)]) + .await + .unwrap(), + "a new admission after ACK cannot reuse the signed child" + ); + drop(after_ack); + // A fresh database connection has no in-memory guard/cache from the + // admission and must still see the persisted ancestor cutoff. + let reopened = sqlite_write_pool(&path, std::time::Duration::from_secs(5), 2).await; + assert_eq!( + revocation::first_revoked_ancestor(&reopened, &child) + .await + .unwrap(), + Some(parent.to_cid(0x55).to_string()), + "a cold connection must honor the committed revoke" + ); + } + + /// The alias's canonical CID is also the key-principal registry key. + /// No request may retain its shared guard while waiting to revoke that + /// key, and later reads must fail within the bounded guard deadline. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn key_revoke_alias_overlap_bounds_waiting_reads_and_commits_cutoff() { + use sea_orm::{ActiveModelTrait, ActiveValue::Set}; + let db = get_db().await.unwrap(); + insert_chain_guard_actors(&db).await; + let target = "did:key:chain-guard-owner".to_owned(); + let key = crate::key_authority::principal_lock_id(&target).unwrap(); + let alias = insert_test_delegation(&db, "key-revoke-alias-overlap").await; + delegation_cid_alias::ActiveModel { + id: Set(alias), + canonical: Set(key), + encrypted: Set(false), + } + .insert(&db.conn) + .await + .unwrap(); + let keys = invocation_chain_keys(&db, &[alias]).await; + assert!(keys.contains(&key)); + let in_flight = db.acquire_shared_chain_guards_for_keys(keys).await; + let now = OffsetDateTime::now_utc().unix_timestamp(); + let revoking = db.clone(); + let request = key_revocation::VerifiedKeyRevoke { + target_did: target.clone(), + issuer_did: target.clone(), + scope: key_revocation::KeyRevokeScope::Global, + nonce: "key-revoke-alias-overlap".into(), + authorization_hash: [7; 32], + proof_cids: Vec::new(), + not_before: now, + valid_after: None, + expires_at: now + 40, + }; + let revoke = tokio::spawn(async move { revoking.revoke_key(request).await }); + settle().await; + assert!( + !revoke.is_finished(), + "revoke must wait for the in-flight read decision" + ); + let timed_out = db + .acquire_shared_chain_guards_bounded(vec![key]) + .await + .unwrap_err(); + assert!(matches!(&timed_out, DbErr::Custom(message) + if message == "authorization-revoke-wait-timeout")); + assert!(matches!( + authority_error::(timed_out), + TxError::AuthorizationWaitTimeout + )); + drop(in_flight); + let receipt = tokio::time::timeout(std::time::Duration::from_secs(5), revoke) + .await + .expect("same-registry alias overlap cannot deadlock") + .unwrap() + .unwrap(); + assert!(receipt.revoked); + assert!(crate::key_authority::is_revoked_error( + &crate::key_authority::check_principals(&db.conn, &[target], &[]) + .await + .unwrap_err() + )); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn overlapping_exact_self_revokes_return_the_same_receipt() { + let db = get_db().await.unwrap(); + let target = "did:key:overlapping-receipt-owner".to_owned(); + let key = crate::key_authority::principal_lock_id(&target).unwrap(); + let in_flight = db.acquire_shared_chain_guards_for_keys(vec![key]).await; + let now = OffsetDateTime::now_utc().unix_timestamp(); + let request = key_revocation::VerifiedKeyRevoke { + target_did: target.clone(), + issuer_did: target, + scope: key_revocation::KeyRevokeScope::Global, + nonce: "overlapping-exact-receipt".into(), + authorization_hash: [8; 32], + proof_cids: Vec::new(), + not_before: now, + valid_after: None, + expires_at: now + 40, + }; + let first_db = db.clone(); + let first_request = request.clone(); + let first = tokio::spawn(async move { first_db.revoke_key(first_request).await }); + let second_db = db.clone(); + let second = tokio::spawn(async move { second_db.revoke_key(request).await }); + settle().await; + assert!(!first.is_finished() && !second.is_finished()); + drop(in_flight); + let first_receipt = tokio::time::timeout(std::time::Duration::from_secs(5), first) + .await + .expect("first cutoff finishes") + .unwrap() + .unwrap(); + let second_receipt = tokio::time::timeout(std::time::Duration::from_secs(5), second) + .await + .expect("concurrent exact retry finishes") + .unwrap() + .unwrap(); + assert_eq!(first_receipt, second_receipt); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn first_use_expiry_while_waiting_for_target_guard_does_not_commit_a_cutoff() { + let db = get_db().await.unwrap(); + let target = "did:key:waited-out-key-revoke".to_owned(); + let key = crate::key_authority::principal_lock_id(&target).unwrap(); + let in_flight = db.acquire_shared_chain_guards_for_keys(vec![key]).await; + let now = OffsetDateTime::now_utc().unix_timestamp(); + let request = key_revocation::VerifiedKeyRevoke { + target_did: target.clone(), + issuer_did: target.clone(), + scope: key_revocation::KeyRevokeScope::Global, + nonce: "waited-out-key-revoke".into(), + authorization_hash: [9; 32], + proof_cids: Vec::new(), + not_before: now, + valid_after: None, + expires_at: now + 1, + }; + let revoking = db.clone(); + let pending = tokio::spawn(async move { revoking.revoke_key(request).await }); + settle().await; + assert!( + !pending.is_finished(), + "the target guard must block the revoke" + ); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + drop(in_flight); + let result = tokio::time::timeout(std::time::Duration::from_secs(5), pending) + .await + .expect("expired revoke finishes") + .unwrap(); + assert!(matches!( + result, + Err(key_revocation::KeyRevokeError::Forbidden) + )); + crate::key_authority::check_principals(&db.conn, &[target], &[]) + .await + .expect("no cutoff was committed after first-use expiry"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn key_cutoff_serializes_across_connections_and_survives_restart() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("key-cutoff.db"); + let make_db = |conn| async move { + SpaceDatabase::new( + conn, + MemoryStore::default(), + StaticSecret::new([0u8; 32].to_vec()).unwrap(), + ) + .await + .unwrap() + }; + let admitting = + make_db(sqlite_write_pool(&path, std::time::Duration::from_secs(5), 2).await).await; + let revoking = + make_db(sqlite_write_pool(&path, std::time::Duration::from_secs(5), 2).await).await; + let target = "did:key:cross-connection-key-cutoff".to_owned(); + let tx = admitting.begin_graph_transaction().await.unwrap(); + crate::key_authority::lock_targets(&tx, std::slice::from_ref(&target), false) + .await + .unwrap(); + crate::key_authority::check_principals(&tx, std::slice::from_ref(&target), &[]) + .await + .unwrap(); + let now = OffsetDateTime::now_utc().unix_timestamp(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let revoked_target = target.clone(); + let revoke = tokio::spawn(async move { + started_tx.send(()).unwrap(); + revoking + .revoke_key(key_revocation::VerifiedKeyRevoke { + target_did: revoked_target.clone(), + issuer_did: revoked_target, + scope: key_revocation::KeyRevokeScope::Global, + nonce: "cross-connection-key-cutoff".into(), + authorization_hash: [9; 32], + proof_cids: Vec::new(), + not_before: now, + valid_after: None, + expires_at: now + 40, + }) + .await + }); + started_rx.await.unwrap(); + settle().await; + assert!( + !revoke.is_finished(), + "the cutoff cannot ACK before admission completes" + ); + tx.commit().await.unwrap(); + let receipt = tokio::time::timeout(std::time::Duration::from_secs(5), revoke) + .await + .expect("cross-connection cutoff completes") + .unwrap() + .unwrap(); + assert!(receipt.revoked); + for db in [ + admitting, + make_db(sqlite_write_pool(&path, std::time::Duration::from_secs(5), 2).await).await, + ] { + let error = crate::key_authority::check_principals( + &db.conn, + std::slice::from_ref(&target), + &[], + ) + .await + .unwrap_err(); + assert!(crate::key_authority::is_revoked_error(&error)); + } + } + /// TC-324 requirement 3: delegation registration still mutually excludes /// against invocations on the same chain, in both orders. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] diff --git a/tinycloud-core/src/key_authority.rs b/tinycloud-core/src/key_authority.rs new file mode 100644 index 00000000..3c08bb11 --- /dev/null +++ b/tinycloud-core/src/key_authority.rs @@ -0,0 +1,238 @@ +//! Durable principal revocation and the database-side admission cutoff. +//! +//! Delegation CIDs identify signed statements; key tombstones identify DID +//! principals. Never use a CID alias as a substitute for a principal. + +use crate::hash::{hash, Hash}; +use crate::models::{ + delegation, + key_revocation::{self, target}, +}; +use sea_orm::{ + sea_query::{Expr, OnConflict}, + ActiveValue::Set, + ColumnTrait, Condition, ConnectionTrait, DatabaseTransaction, DbBackend, DbErr, EntityTrait, + QueryFilter, QueryOrder, QuerySelect, +}; +use tinycloud_auth::{identity::principal_did, resource::SpaceId}; + +pub const REVOKED: &str = "authorization-key-revoked"; + +pub fn is_revoked_error(error: &DbErr) -> bool { + matches!(error, DbErr::Custom(message) if message == REVOKED) +} + +/// Domain-separated registry key for a DID principal. Every scoped operation +/// also participates in this global-principal guard. +pub fn principal_lock_id(principal: &str) -> Result { + let did = principal_did(principal) + .map_err(|_| DbErr::Custom("authorization-principal-invalid".into()))?; + Ok(hash( + format!("tinycloud/key-principal-guard/v1\0{did}").as_bytes(), + )) +} + +/// Fail closed if any signer or audience in the authorization dependency set +/// was explicitly revoked globally or in one of the affected spaces. An empty +/// scope list still checks the global tombstone. +pub async fn check_principals( + db: &C, + principals: &[String], + spaces: &[String], +) -> Result<(), DbErr> { + if principals.is_empty() { + return Err(DbErr::Custom("authorization-principal-missing".into())); + } + let principals = canonical_principals(principals)?; + let scope = spaces.iter().try_fold( + Condition::any().add(key_revocation::Column::ScopeKey.eq("global")), + |scope, raw| { + let space = raw + .parse::() + .map_err(|_| DbErr::Custom("authorization-scope-invalid".into()))?; + Ok::( + scope.add(key_revocation::Column::ScopeKey.eq(format!("space:{space}"))), + ) + }, + )?; + if key_revocation::Entity::find() + .filter(key_revocation::Column::TargetDid.is_in(principals)) + .filter(scope) + .one(db) + .await? + .is_some() + { + return Err(DbErr::Custom(REVOKED.into())); + } + Ok(()) +} +/// Legacy email shares bind an owner DID, not a namespaced SpaceId. No scoped +/// tombstone can be proven unrelated to that historical authority, so deny on +/// any tombstone for its verified signers and audiences. +pub async fn check_principals_any_scope( + db: &C, + principals: &[String], +) -> Result<(), DbErr> { + if principals.is_empty() { + return Err(DbErr::Custom("authorization-principal-missing".into())); + } + if key_revocation::Entity::find() + .filter(key_revocation::Column::TargetDid.is_in(canonical_principals(principals)?)) + .one(db) + .await? + .is_some() + { + return Err(DbErr::Custom(REVOKED.into())); + } + Ok(()) +} + +/// Check every signer and audience of a persisted proof closure, including +/// the requesting signer. The bounded graph load denies missing ancestry +/// rather than silently shrinking the key-revocation dependency set. +pub async fn check_invoker_chain( + db: &C, + roots: &[Hash], + invoker: &str, + spaces: &[String], +) -> Result<(), DbErr> { + let mut principals = crate::auth_graph::load_chain_principals(db, roots) + .await + .map_err(|error| DbErr::Custom(error.to_string()))?; + principals.push(invoker.to_owned()); + check_principals(db, &principals, spaces).await +} + +/// Establish a per-principal row before checking authority in a mutating +/// transaction. Shared row locks permit independent admissions; an exclusive +/// key revoke of that DID conflicts with both global and scoped effects in +/// other node processes. SQLite's write transaction serializes these writes; +/// it does not understand SELECT FOR SHARE. Call before the revocation check +/// and keep `tx` alive through the authorized effect/commit. +pub async fn lock_targets( + tx: &DatabaseTransaction, + principals: &[String], + exclusive: bool, +) -> Result<(), DbErr> { + let principals = canonical_principals(principals)?; + if principals.is_empty() { + return Ok(()); + } + // Retain the batched row-lock query, but pin the same byte order used by + // recovery's Rust-sorted per-principal locks. PostgreSQL's default text + // collation (and MySQL's) need not order mixed-case DIDs bytewise. + ensure_target_rows(tx, &principals).await?; + if tx.get_database_backend() != DbBackend::Sqlite { + let query = target::Entity::find() + .filter(target::Column::TargetDid.is_in(principals.iter().cloned())) + .order_by_asc(crate::db::byte_ordered( + tx.get_database_backend(), + Expr::col((target::Entity, target::Column::TargetDid)), + )); + let rows = if exclusive { + query.lock_exclusive().all(tx).await? + } else { + query.lock_shared().all(tx).await? + }; + if rows.len() != principals.len() { + return Err(DbErr::Custom("authorization-key-lock-missing".into())); + } + } + Ok(()) +} + +/// Recovery authorization must lock the target and signer exclusively while +/// keeping every other live proof principal shared. Acquire the strongest +/// mode each principal needs on its first visit, across one canonical order: +/// taking intermediate SHARE locks before target UPDATE locks can deadlock +/// when two otherwise valid recovery chains cross. +pub(crate) async fn lock_recovery_targets( + tx: &DatabaseTransaction, + principals: &[String], + target_did: &str, + issuer_did: &str, +) -> Result<(), DbErr> { + let invalid = || DbErr::Custom("authorization-principal-invalid".into()); + let target_did = principal_did(target_did).map_err(|_| invalid())?; + let issuer_did = principal_did(issuer_did).map_err(|_| invalid())?; + let mut ordered = Vec::with_capacity(principals.len() + 2); + for principal in principals { + ordered.push(principal_did(principal).map_err(|_| invalid())?); + } + ordered.push(target_did.clone()); + ordered.push(issuer_did.clone()); + ordered.sort_unstable(); + ordered.dedup(); + ensure_target_rows(tx, &ordered).await?; + if tx.get_database_backend() != DbBackend::Sqlite { + for principal in ordered { + let query = target::Entity::find_by_id(principal.clone()); + let locked = if principal == target_did || principal == issuer_did { + query.lock_exclusive().one(tx).await? + } else { + query.lock_shared().one(tx).await? + }; + if locked.is_none() { + return Err(DbErr::Custom("authorization-key-lock-missing".into())); + } + } + } + Ok(()) +} + +async fn ensure_target_rows(tx: &DatabaseTransaction, principals: &[String]) -> Result<(), DbErr> { + match target::Entity::insert_many(principals.iter().cloned().map(|target_did| { + target::ActiveModel { + target_did: Set(target_did), + } + })) + .on_conflict( + OnConflict::column(target::Column::TargetDid) + .do_nothing() + .to_owned(), + ) + .exec(tx) + .await + { + Ok(_) | Err(DbErr::RecordNotInserted) => Ok(()), + Err(error) => Err(error), + } +} + +fn canonical_principals(principals: &[String]) -> Result, DbErr> { + let mut canonical = principals + .iter() + .map(|did| { + principal_did(did).map_err(|_| DbErr::Custom("authorization-principal-invalid".into())) + }) + .collect::, _>>()?; + canonical.sort_unstable(); + canonical.dedup(); + Ok(canonical) +} + +/// Keep signed proof rows shared through a mutating admission or effect. +/// Generic CID revoke locks every canonical alias row exclusively, so this +/// conflicts across processes even if the submitted CID uses another alias. +pub async fn lock_signed_dependencies( + tx: &DatabaseTransaction, + cids: &[Hash], +) -> Result<(), DbErr> { + if tx.get_database_backend() == DbBackend::Sqlite { + return Ok(()); + } + let mut ordered = cids.to_vec(); + ordered.sort_unstable(); + ordered.dedup(); + for id in ordered { + if delegation::Entity::find_by_id(id) + .lock_shared() + .one(tx) + .await? + .is_none() + { + return Err(DbErr::Custom("authorization-proof-missing".into())); + } + } + Ok(()) +} diff --git a/tinycloud-core/src/lib.rs b/tinycloud-core/src/lib.rs index 3cacd735..8bab5900 100644 --- a/tinycloud-core/src/lib.rs +++ b/tinycloud-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod encryption; pub mod encryption_network; pub mod events; pub mod hash; +pub mod key_authority; pub mod keys; pub mod kv_sync; pub mod manifest; @@ -20,7 +21,9 @@ pub mod migrations; pub mod models; pub mod policy_authority; pub mod policy_capability; +pub mod policy_witness; mod read_audit; +pub mod recovery_authority; pub mod relationships; pub mod share_email; pub mod sql; diff --git a/tinycloud-core/src/migrations/m20261016_000000_policy_witness.rs b/tinycloud-core/src/migrations/m20261016_000000_policy_witness.rs new file mode 100644 index 00000000..328705a6 --- /dev/null +++ b/tinycloud-core/src/migrations/m20261016_000000_policy_witness.rs @@ -0,0 +1,40 @@ +//! Schema only. Startup backfills legacy policies transactionally; the +//! read-only count gate must run before this migration is released. +use crate::policy_witness::{Column, Entity}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Entity) + .if_not_exists() + .col(ColumnDef::new(Column::PolicyCid).string().not_null()) + .col(ColumnDef::new(Column::RootCid).string().not_null()) + .col(ColumnDef::new(Column::CapabilityIndex).integer().not_null()) + .col(ColumnDef::new(Column::Version).integer().not_null()) + .col(ColumnDef::new(Column::RegisteredAt).string().not_null()) + .col(ColumnDef::new(Column::WitnessBytes).blob().not_null()) + .col(ColumnDef::new(Column::WitnessDigestHex).string().not_null()) + .primary_key( + Index::create() + .col(Column::PolicyCid) + .col(Column::RootCid) + .col(Column::CapabilityIndex), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Entity).to_owned()) + .await + } +} diff --git a/tinycloud-core/src/migrations/m20261016_000001_key_revocation.rs b/tinycloud-core/src/migrations/m20261016_000001_key_revocation.rs new file mode 100644 index 00000000..dce2e38b --- /dev/null +++ b/tinycloud-core/src/migrations/m20261016_000001_key_revocation.rs @@ -0,0 +1,222 @@ +use sea_orm_migration::prelude::*; + +use crate::models::key_revocation; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(key_revocation::target::Entity) + .if_not_exists() + .col( + ColumnDef::new(key_revocation::target::Column::TargetDid) + .string() + .not_null() + .primary_key(), + ) + .to_owned(), + ) + .await?; + manager + .create_table( + Table::create() + .table(key_revocation::Entity) + .if_not_exists() + .col( + ColumnDef::new(key_revocation::Column::TargetDid) + .string() + .not_null(), + ) + .col( + ColumnDef::new(key_revocation::Column::ScopeKey) + .string() + .not_null(), + ) + .col( + ColumnDef::new(key_revocation::Column::RevokedAt) + .big_integer() + .not_null(), + ) + .primary_key( + Index::create() + .col(key_revocation::Column::TargetDid) + .col(key_revocation::Column::ScopeKey), + ) + .to_owned(), + ) + .await?; + manager + .create_table( + Table::create() + .table(key_revocation::nonce::Entity) + .if_not_exists() + .col( + ColumnDef::new(key_revocation::nonce::Column::IssuerDid) + .string() + .not_null(), + ) + .col( + ColumnDef::new(key_revocation::nonce::Column::Nonce) + .string() + .not_null(), + ) + .col( + ColumnDef::new(key_revocation::nonce::Column::AuthorizationHash) + .binary_len(32) + .not_null(), + ) + .col( + ColumnDef::new(key_revocation::nonce::Column::TargetDid) + .string() + .not_null(), + ) + .col( + ColumnDef::new(key_revocation::nonce::Column::ReceiptBytes) + .blob() + .not_null(), + ) + .primary_key( + Index::create() + .col(key_revocation::nonce::Column::IssuerDid) + .col(key_revocation::nonce::Column::Nonce), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(key_revocation::nonce::Entity) + .to_owned(), + ) + .await?; + manager + .drop_table(Table::drop().table(key_revocation::Entity).to_owned()) + .await?; + manager + .drop_table( + Table::drop() + .table(key_revocation::target::Entity) + .to_owned(), + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ConnectOptions, ConnectionTrait, Database, EntityTrait, + }; + + struct MySqlDatabase { + url: String, + name: String, + } + + impl Drop for MySqlDatabase { + fn drop(&mut self) { + let (url, name) = (self.url.clone(), self.name.clone()); + std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async move { + let admin = Database::connect(ConnectOptions::new(url)).await.unwrap(); + admin + .execute_unprepared(&format!("DROP DATABASE IF EXISTS {name}")) + .await + .unwrap(); + }) + }) + .join() + .unwrap(); + } + } + + #[tokio::test] + async fn mysql_nonce_migration_persists_real_digest_and_replay_receipt() { + let url = match std::env::var("TINYCLOUD_TEST_MYSQL_URL") { + Ok(url) if !url.trim().is_empty() => url, + _ => { + assert!( + std::env::var_os("CI").is_none(), + "MySQL nonce migration requires TINYCLOUD_TEST_MYSQL_URL in CI" + ); + eprintln!("skipping MySQL nonce migration: TINYCLOUD_TEST_MYSQL_URL is unset"); + return; + } + }; + let name = format!( + "tc1149_nonce_{}_{}", + std::process::id(), + time::OffsetDateTime::now_utc().unix_timestamp_nanos() + ); + let database = MySqlDatabase { + url: url.clone(), + name: name.clone(), + }; + let admin = Database::connect(ConnectOptions::new(url.clone())) + .await + .expect("connect to disposable MySQL"); + admin + .execute_unprepared(&format!("CREATE DATABASE {name}")) + .await + .expect("create isolated MySQL database"); + admin.close().await.unwrap(); + let database_url = format!("{}/{name}", url.rsplit_once('/').unwrap().0); + let conn = Database::connect(ConnectOptions::new(database_url)) + .await + .expect("connect to isolated MySQL database"); + Migration.up(&SchemaManager::new(&conn)).await.unwrap(); + + let target = "did:key:tc1149-mysql-test-principal".to_owned(); + let receipt = key_revocation::KeyRevokeReceipt { + revoked: true, + target_did: target.clone(), + scope: key_revocation::KeyRevokeScope::Global, + revoked_at: 1_000, + }; + let receipt_bytes = serde_json::to_vec(&receipt).unwrap(); + assert!( + receipt_bytes.len() > 1, + "real signed receipt exceeds BINARY(1)" + ); + let digest = vec![0x42; 32]; + let nonce = "mysql-nonce-roundtrip".to_owned(); + key_revocation::nonce::ActiveModel { + issuer_did: Set(target.clone()), + nonce: Set(nonce.clone()), + authorization_hash: Set(digest.clone()), + target_did: Set(target.clone()), + receipt_bytes: Set(receipt_bytes.clone()), + } + .insert(&conn) + .await + .expect("persist full-width signed digest and durable receipt on MySQL"); + let stored = key_revocation::nonce::Entity::find_by_id((target, nonce)) + .one(&conn) + .await + .unwrap() + .expect("committed replay receipt"); + assert_eq!(stored.authorization_hash, digest); + assert_eq!(stored.receipt_bytes, receipt_bytes); + assert_eq!( + serde_json::from_slice::(&stored.receipt_bytes) + .unwrap(), + receipt, + ); + conn.close().await.unwrap(); + drop(database); + } +} diff --git a/tinycloud-core/src/migrations/mod.rs b/tinycloud-core/src/migrations/mod.rs index e8f7c992..1477a8d9 100644 --- a/tinycloud-core/src/migrations/mod.rs +++ b/tinycloud-core/src/migrations/mod.rs @@ -26,6 +26,8 @@ pub mod m20261007_000000_database_alias; pub mod m20261007_010000_database_identity_fence; pub mod m20261014_000000_delegation_cid_alias; pub mod m20261015_000000_delegation_cursor_index; +pub mod m20261016_000000_policy_witness; +pub mod m20261016_000001_key_revocation; pub struct Migrator; @@ -60,6 +62,8 @@ impl MigratorTrait for Migrator { Box::new(m20261007_010000_database_identity_fence::Migration), Box::new(m20261014_000000_delegation_cid_alias::Migration), Box::new(m20261015_000000_delegation_cursor_index::Migration), + Box::new(m20261016_000000_policy_witness::Migration), + Box::new(m20261016_000001_key_revocation::Migration), ] } } diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index 98fc2c03..efc03d9a 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -197,7 +197,7 @@ pub(crate) async fn process( } // verify signatures and time -async fn verify(delegation: &TinyCloudDelegation) -> Result<(), Error> { +pub(crate) async fn verify(delegation: &TinyCloudDelegation) -> Result<(), Error> { match delegation { TinyCloudDelegation::Ucan(ref ucan) => { verify_ucan_signature(ucan) @@ -242,6 +242,15 @@ async fn validate( !is_root_authority(c, &delegation.delegator) }) .collect(); + // A recovery enrollment is a deliberately narrow target- or + // space-owner-signed root, not an arbitrary parentless key capability. + if delegation.parents.is_empty() + && delegation.capabilities.iter().any(is_key_revoke_capability) + && (delegation.capabilities.len() != 1 + || !is_key_recovery_root(&delegation.capabilities[0], &delegation.delegator)) + { + return Err(DelegationError::MissingParents.into()); + } match (dependant_caps.is_empty(), delegation.parents.is_empty()) { // no dependant caps, no parents needed, must be valid @@ -786,7 +795,64 @@ async fn ensure_parent_active(db: &C, parent_id: &Hash) -> R Ok(()) } +fn is_key_revoke_capability(cap: &util::Capability) -> bool { + cap.ability.as_ref().as_ref() == "tinycloud.key/revoke" +} + +fn is_key_recovery_root(cap: &util::Capability, delegator: &str) -> bool { + use crate::models::key_revocation::KeyRevokeScope; + use tinycloud_auth::resource::SpaceId; + + if !is_key_revoke_capability(cap) || cap.caveats.0.len() != 1 { + return false; + } + let Resource::Other(uri) = &cap.resource else { + return false; + }; + let Some(target) = uri.as_str().strip_prefix("urn:tinycloud:key:") else { + return false; + }; + if target.contains(['#', '?', '/']) + || tinycloud_auth::identity::canonicalize_did(target) + .ok() + .as_deref() + != Some(target) + { + return false; + } + let Some(caveat) = cap.caveats.0.get("0") else { + return false; + }; + let Some(scope) = caveat + .get("scope") + .cloned() + .and_then(|scope| serde_json::from_value::(scope).ok()) + else { + return false; + }; + if *caveat + != serde_json::json!({ + "version": 1, + "targetDid": target, + "scope": scope, + }) + { + return false; + } + match scope { + KeyRevokeScope::Global => did_principal_matches(target, delegator), + KeyRevokeScope::Space { space_id } => space_id + .parse::() + .ok() + .filter(|space| space.to_string() == space_id) + .is_some_and(|space| did_principal_matches(space.did().as_str(), delegator)), + } +} + fn is_root_authority(cap: &util::Capability, delegator: &str) -> bool { + if is_key_recovery_root(cap, delegator) { + return true; + } if cap .resource .space() diff --git a/tinycloud-core/src/models/invocation.rs b/tinycloud-core/src/models/invocation.rs index 4602bd80..27784f7c 100644 --- a/tinycloud-core/src/models/invocation.rs +++ b/tinycloud-core/src/models/invocation.rs @@ -196,6 +196,13 @@ pub(crate) async fn authorize_admitted( } pub async fn verify_invocation(invocation: &TinyCloudInvocation) -> Result<(), Error> { + verify_invocation_at(invocation, None).await +} + +async fn verify_invocation_at( + invocation: &TinyCloudInvocation, + signed_time: Option, +) -> Result<(), Error> { verify_ucan_signature(invocation) .await .map_err(|error| match error { @@ -209,7 +216,7 @@ pub async fn verify_invocation(invocation: &TinyCloudInvocation) -> Result<(), E })?; invocation .payload() - .validate_time(None) + .validate_time(signed_time) .map_err(|_| InvocationError::InvalidTime)?; // TC-409: this is the single cryptographic-verification primitive @@ -237,6 +244,21 @@ pub async fn verify_and_authorize( verify_and_authorize_with_graph(db, invocation, now, None).await } +/// Evaluate a reconstructed signed invocation at its historical proof time. +/// Live routes use [`verify_and_authorize`], which always checks the signature +/// against the actual clock. The supplied time applies to both the invocation +/// and its cited delegation chain; neither check is bypassed. +#[cfg(feature = "historical-invocation-test-clock")] +pub async fn verify_and_authorize_with_signed_time( + db: &C, + invocation: &util::InvocationInfo, + now: OffsetDateTime, + signed_time: f64, +) -> Result<(), Error> { + verify_invocation_at(&invocation.invocation, Some(signed_time)).await?; + validate(db, invocation, Some(now), None).await +} + /// [`verify_and_authorize`] against a caller-loaded authorization snapshot, /// so the caller can derive more facts (TC-732's authority window) from the /// exact graph the decision was made on. diff --git a/tinycloud-core/src/models/key_revocation.rs b/tinycloud-core/src/models/key_revocation.rs new file mode 100644 index 00000000..d9a6a23a --- /dev/null +++ b/tinycloud-core/src/models/key_revocation.rs @@ -0,0 +1,117 @@ +use sea_orm::entity::prelude::*; +use sea_orm::DbErr; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, thiserror::Error)] +pub enum KeyRevokeError { + #[error("key revocation forbidden")] + Forbidden, + #[error("key revocation nonce conflict")] + Conflict, + #[error("key revocation database unavailable")] + Db { + #[source] + source: DbErr, + write_may_have_applied: bool, + }, +} + +/// The signed scope; a space revocation never confers global authority. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)] +pub enum KeyRevokeScope { + Global, + Space { + #[serde(rename = "spaceId")] + space_id: String, + }, +} + +impl KeyRevokeScope { + pub fn storage_key(&self) -> String { + match self { + Self::Global => "global".into(), + Self::Space { space_id } => format!("space:{space_id}"), + } + } +} + +/// Only the node's signature-verification boundary may construct this input. +/// Database authorization of the authenticated actor and proof chain follows +/// inside the transaction, before any nonce/tombstone diagnostics are exposed. +#[derive(Clone, Debug)] +pub struct VerifiedKeyRevoke { + pub target_did: String, + pub issuer_did: String, + pub scope: KeyRevokeScope, + pub nonce: String, + pub authorization_hash: [u8; 32], + pub proof_cids: Vec, + pub not_before: i64, + /// SIWE/CACAO's optional signed `nbf`; UCAN's `nbf` is `not_before` itself. + pub valid_after: Option, + pub expires_at: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyRevokeReceipt { + pub revoked: bool, + pub target_did: String, + pub scope: KeyRevokeScope, + pub revoked_at: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "key_revocation")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub target_did: String, + #[sea_orm(primary_key, auto_increment = false)] + pub scope_key: String, + pub revoked_at: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +/// A row per principal ensures that global and scoped writes targeting the +/// same DID serialize in all supported database backends. +pub mod target { + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "key_revocation_target")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub target_did: String, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} +} + +pub mod nonce { + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "key_revocation_nonce")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub issuer_did: String, + #[sea_orm(primary_key, auto_increment = false)] + pub nonce: String, + pub authorization_hash: Vec, + pub target_did: String, + pub receipt_bytes: Vec, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} +} diff --git a/tinycloud-core/src/models/mod.rs b/tinycloud-core/src/models/mod.rs index afefa687..8d5a6e15 100644 --- a/tinycloud-core/src/models/mod.rs +++ b/tinycloud-core/src/models/mod.rs @@ -18,6 +18,7 @@ pub mod hook_delivery; pub mod hook_subscription; pub mod invocation; pub mod invocation_replay; +pub mod key_revocation; pub mod kv_delete; pub mod kv_write; pub mod meeting_legacy_write_guard; diff --git a/tinycloud-core/src/models/revocation.rs b/tinycloud-core/src/models/revocation.rs index 0acd37c4..537e1f01 100644 --- a/tinycloud-core/src/models/revocation.rs +++ b/tinycloud-core/src/models/revocation.rs @@ -415,26 +415,18 @@ pub enum RevocationError { MissingParents, } -pub(crate) async fn process( - db: &C, - revocation: Revocation, -) -> Result { - let (r, serialization) = (revocation.0, revocation.1); - +pub(crate) async fn verify(revocation: &TinyCloudRevocation) -> Result<(), Error> { let t = OffsetDateTime::now_utc(); - - // W1 (audit P0 finding 5): verify both CACAO and did:key/UCAN format - // revocations. The route accepts either suite so the Policy Engine - // active_cutoff loop can rely on a single endpoint regardless of - // whether the Grant Issuer signs with SIWE or did:key. - match &r.revocation { + // Verify both CACAO and did:key/UCAN revocations before inspecting the + // referenced graph, including when an ancestor is missing. + match revocation { TinyCloudRevocation::Cacao(c) => { c.verify() .await .map_err(|_| RevocationError::InvalidSignature)?; if !c.payload().valid_at(&t) { return Err(RevocationError::InvalidTime.into()); - }; + } } TinyCloudRevocation::Ucan(u) => { verify_ucan_signature(u) @@ -452,7 +444,17 @@ pub(crate) async fn process( .validate_time(None) .map_err(|_| RevocationError::InvalidTime)?; } - }; + } + Ok(()) +} + +pub(crate) async fn process( + db: &C, + revocation: Revocation, +) -> Result { + let (r, serialization) = (revocation.0, revocation.1); + + verify(&r.revocation).await?; let hash: Hash = hash(&serialization); let delegation = delegation::Entity::find_by_id(Hash::from(r.revoked)) diff --git a/tinycloud-core/src/policy_authority/database.rs b/tinycloud-core/src/policy_authority/database.rs index 37c769de..2fa6f952 100644 --- a/tinycloud-core/src/policy_authority/database.rs +++ b/tinycloud-core/src/policy_authority/database.rs @@ -2,7 +2,7 @@ use super::*; use crate::models::{policy_challenge, policy_delegation, policy_edge, policy_issuance_audit}; use sea_orm::{ sea_query::Expr, ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseTransaction, - DbBackend, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait, + DbBackend, DbErr, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait, }; #[derive(Clone)] @@ -13,6 +13,18 @@ pub struct DatabaseAuthorityStore { race_barrier: Option>, } +async fn check_signed_principals( + db: &C, + principals: &[String], +) -> Result<(), AuthorityError> { + crate::key_authority::check_principals_any_scope(db, principals) + .await + .map_err(|error| match error { + DbErr::Custom(_) => AuthorityError::DelegationAncestorRevoked, + _ => AuthorityError::AuthorityStateUnavailable, + }) +} + impl DatabaseAuthorityStore { pub fn new(db: impl Into) -> Self { Self { @@ -415,11 +427,14 @@ impl DatabaseAuthorityStore { ) -> Result<(), AuthorityError> { let mut stack = vec![(cid.to_string(), false, 0usize)]; let mut visited = HashSet::new(); + let mut principals = Vec::new(); while let Some((current, ancestor, depth)) = stack.pop() { if depth > MAX_ANCESTRY_DEPTH || !visited.insert(current.clone()) { return Err(AuthorityError::AncestryTooDeep); } let artifact = self.ensure_live(¤t, now, ancestor).await?; + principals.push(artifact.issuer_did.clone()); + principals.push(artifact.audience_did.clone()); let edges = self.edges(¤t).await?; validate_persisted_edges(&artifact, &edges)?; stack.extend( @@ -429,7 +444,7 @@ impl DatabaseAuthorityStore { .map(|edge| (edge.parent_cid, true, depth + 1)), ); } - Ok(()) + check_signed_principals(&self.db, &principals).await } /// Revalidates the complete ancestry/revocation chain for `cid` with @@ -446,12 +461,15 @@ impl DatabaseAuthorityStore { ) -> Result<(), AuthorityError> { let mut stack = vec![(cid.to_owned(), false, 0usize)]; let mut ancestry = HashMap::new(); + let mut principals = Vec::new(); while let Some((current, ancestor, depth)) = stack.pop() { if depth > MAX_ANCESTRY_DEPTH || ancestry.contains_key(¤t) { return Err(AuthorityError::AncestryTooDeep); } let row = delegation_row(transaction, ¤t, true).await?; let verified = ensure_live_model(&row, now, ancestor)?; + principals.push(verified.issuer_did.clone()); + principals.push(verified.audience_did.clone()); let edges = edges_on(transaction, ¤t).await?; validate_persisted_edges(&verified, &edges)?; ancestry.insert(current.clone(), verified); @@ -462,6 +480,7 @@ impl DatabaseAuthorityStore { .map(|edge| (edge.parent_cid, true, depth + 1)), ); } + check_signed_principals(transaction, &principals).await?; let mut locked_artifacts = HashMap::new(); locked_artifacts.extend(ancestry); validate_locked_ancestry(transaction, cid, &locked_artifacts).await diff --git a/tinycloud-core/src/policy_witness.rs b/tinycloud-core/src/policy_witness.rs new file mode 100644 index 00000000..fa3df479 --- /dev/null +++ b/tinycloud-core/src/policy_witness.rs @@ -0,0 +1,975 @@ +//! Immutable, versioned evidence of every owner authority that could have +//! supported a registered policy at its original admission time. Expiry is +//! deliberately not a revocation signal: TC-1213 owns owner-self expiry. +use crate::{ + encryption::ColumnEncryption, + events::SerializedEvent, + hash::{hash, Hash}, + models::{ + abilities, delegation, delegation_cid_alias, policy_v3_registration, policy_v3_root, + revocation, + }, + policy_capability::{self, sql_caveat}, + relationships::parent_delegations, + types::{Caveats, Resource}, + util::{Capability, DelegationInfo}, +}; +use base64::{engine::general_purpose::URL_SAFE, Engine as _}; +use sea_orm::{ + entity::prelude::*, ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseTransaction, + EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use time::OffsetDateTime; +use tinycloud_auth::{ + authorization::TinyCloudDelegation, + identity::{did_principal_matches, principal_did}, +}; + +const VERSION: i32 = 1; +const MAX_NODES: usize = revocation::MAX_CHAIN_TRAVERSAL_NODES; +const MAX_CANDIDATES: usize = 64; +const CANDIDATE_PAGE_SIZE: u64 = 128; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "policy_witness")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub policy_cid: String, + #[sea_orm(primary_key, auto_increment = false)] + pub root_cid: String, + #[sea_orm(primary_key, auto_increment = false)] + pub capability_index: i32, + pub version: i32, + pub registered_at: String, + pub witness_bytes: Vec, + pub witness_digest_hex: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Debug, thiserror::Error)] +pub enum WitnessError { + #[error("policy-witness-unavailable")] + Database(#[from] DbErr), + #[error("policy-witness-missing")] + Missing, + #[error("policy-witness-ambiguous")] + Ambiguous, + #[error("policy-witness-traversal-overflow")] + Overflow, + #[error("policy-witness-revoked")] + Revoked, + #[error("policy-witness-invalid")] + Invalid, +} + +/// Counter-only surface: neither serialization nor Debug includes identifiers. +#[derive(Clone, Copy, Default, Debug, Serialize, PartialEq, Eq)] +pub struct BackfillCounts { + pub policies: u64, + pub witnesses: u64, + pub missing: u64, + pub ambiguous: u64, + pub overflow: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Contributor { + pub cid: String, + pub canonical_cid: String, + pub signer: String, + pub audience: String, + pub proof_cids: Vec, + /// Exact verified capability/caveat projections from the signed grant. + pub signed_capabilities: Vec, + /// UCAN facts as signed (CACAO/SIWE has no UCAN facts). + pub signed_facts: Vec, + /// Exact signed bytes, kept independent of the mutable delegation graph. + pub signed_bytes: Vec, + pub not_before: Option, + pub expiry: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CandidateClosure { + pub tip_cid: String, + pub contributors: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PolicyWitness { + pub version: i32, + pub policy_cid: String, + pub root_cid: String, + pub capability_index: i32, + pub owner_principal: String, + pub capability: Capability, + pub registered_at: String, + /// Unknown/legacy by design. An owner-self natural expiry is NOT a + /// revocation and must not change policy liveness in this phase. + pub owner_self_classification: String, + pub root: Contributor, + pub candidates: Vec, +} + +fn principal(value: &str) -> Result { + principal_did(value).map_err(|_| WitnessError::Invalid) +} + +fn signed_event(bytes: &[u8]) -> Result { + let token = match std::str::from_utf8(bytes) { + Ok(text) + if text.split('.').count() == 3 + && text.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'=') + }) => + { + text.to_owned() + } + _ => URL_SAFE.encode(bytes), + }; + SerializedEvent::::from_header_ser::(&token) + .map_err(|_| WitnessError::Invalid) +} + +async fn signed_contributor( + bytes: &[u8], + cid: &str, + event: &crate::events::Delegation, +) -> Result { + if event.content_hash().to_cid(0x55).to_string() != cid || event.serialized_bytes() != bytes { + return Err(WitnessError::Invalid); + } + match &event.0.delegation { + TinyCloudDelegation::Ucan(ucan) => { + crate::did_resolution::verify_ucan_signature(ucan) + .await + .map_err(|_| WitnessError::Invalid)?; + } + TinyCloudDelegation::Cacao(cacao) => { + cacao.verify().await.map_err(|_| WitnessError::Invalid)?; + } + } + Ok(Contributor { + cid: cid.to_owned(), + canonical_cid: cid.to_owned(), + signer: principal(&event.0.delegator)?, + audience: principal(&event.0.delegate)?, + proof_cids: event.0.parents.iter().map(ToString::to_string).collect(), + signed_capabilities: event.0.capabilities.clone(), + signed_facts: match &event.0.delegation { + TinyCloudDelegation::Ucan(ucan) => ucan.payload().facts.clone().unwrap_or_default(), + TinyCloudDelegation::Cacao(_) => Vec::new(), + }, + signed_bytes: bytes.to_vec(), + not_before: event.0.not_before.map(format_time).transpose()?, + expiry: event.0.expiry.map(format_time).transpose()?, + }) +} + +fn format_time(at: OffsetDateTime) -> Result { + at.format(&time::format_description::well_known::Rfc3339) + .map_err(|_| WitnessError::Invalid) +} + +fn active_at(row: &Contributor, at: OffsetDateTime) -> Result { + let parse = |value: &str| { + OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) + .map_err(|_| WitnessError::Invalid) + }; + Ok(row + .not_before + .as_deref() + .map(parse) + .transpose()? + .is_none_or(|start| at >= start) + && row + .expiry + .as_deref() + .map(parse) + .transpose()? + .is_none_or(|end| at < end)) +} + +fn signed_policy_artifact(delegation: &TinyCloudDelegation) -> bool { + match delegation { + TinyCloudDelegation::Ucan(ucan) => ucan.payload().facts.as_ref().is_some_and(|facts| { + facts.iter().any(|fact| { + fact.as_object().is_some_and(|object| { + object.contains_key("xyz.tinycloud.policy/session-fact") + || object.contains_key("xyz.tinycloud.policy/root-profile") + }) + }) + }), + TinyCloudDelegation::Cacao(_) => false, + } +} + +fn sql_caveat(caveats: &Caveats) -> Option { + caveats.0.values().find_map(|value| { + sql_caveat::parse(value).ok().or_else(|| { + value + .as_object() + .and_then(|object| object.get("constrained-statements")) + .and_then(|inner| sql_caveat::parse(inner).ok()) + }) + }) +} + +fn covers(held: &Capability, requested: &Capability) -> bool { + if !requested.resource.extends(&held.resource) + || !policy_capability::ability_matches( + held.ability.as_ref().as_ref(), + requested.ability.as_ref().as_ref(), + ) + { + return false; + } + // A signed ReCap `[{}]` (or no caveat) is unconstrained. A valid + // policy selector that stays inside the requested native resource only + // narrows it; do not treat the absent parent selector as a conflict. + let unconstrained = held.caveats.0.is_empty() + || (held.caveats.0.len() == 1 + && held + .caveats + .0 + .values() + .all(|value| value.as_object().is_some_and(serde_json::Map::is_empty))); + if unconstrained + && policy_capability::selector_caveats_contain(&requested.caveats.0, &requested.caveats.0) + == Some(true) + { + return requested + .caveats + .0 + .values() + .next() + .and_then(|value| value.get("value")) + .and_then(serde_json::Value::as_str) + .and_then(|value| value.parse::().ok()) + .is_some_and(|selector| selector.extends(&requested.resource)); + } + if let Some(contained) = + policy_capability::selector_caveats_contain(&held.caveats.0, &requested.caveats.0) + { + return contained; + } + match (sql_caveat(&held.caveats), sql_caveat(&requested.caveats)) { + (Some(parent), Some(child)) => sql_caveat::contains(&parent, &child).is_ok(), + (Some(_), None) => false, + (None, _) => held.caveats.0.is_empty() || held.caveats == requested.caveats, + } +} + +fn root_authority(capability: &Capability, owner: &str) -> bool { + capability + .resource + .space() + .is_some_and(|space| did_principal_matches(space.did().as_str(), owner)) + || matches!(&capability.resource, Resource::Other(uri) + if uri.as_str().parse::() + .is_ok_and(|network| did_principal_matches(network.owner_did(), owner))) +} + +async fn closure( + db: &C, + tip: Hash, + owner: &str, + capability: &Capability, + at: OffsetDateTime, + encryption: Option<&ColumnEncryption>, +) -> Result, WitnessError> { + let mut pending = vec![tip]; + let mut visited = BTreeSet::new(); + let mut nodes = BTreeMap::new(); + let mut covering = HashMap::::new(); + while let Some(id) = pending.pop() { + if !visited.insert(id) { + continue; + } + if visited.len() > MAX_NODES { + return Err(WitnessError::Overflow); + } + let row = delegation::Entity::find_by_id(id) + .one(db) + .await? + .ok_or(WitnessError::Missing)?; + let alias = delegation_cid_alias::Entity::find_by_id(id) + .one(db) + .await? + .ok_or(WitnessError::Missing)?; + let bytes = if alias.encrypted { + encryption + .ok_or(WitnessError::Missing)? + .decrypt(&row.serialization) + .map_err(|_| WitnessError::Invalid)? + } else { + row.serialization.clone() + }; + if hash(&bytes) != id + || delegation_cid_alias::canonical_hash(&bytes).map_err(|_| WitnessError::Invalid)? + != alias.canonical + { + return Err(WitnessError::Ambiguous); + } + let event = signed_event(&bytes)?; + let mut contributor = + signed_contributor(&bytes, &id.to_cid(0x55).to_string(), &event).await?; + contributor.canonical_cid = alias.canonical.to_cid(0x55).to_string(); + if row.delegator != event.0.delegator || row.delegatee != event.0.delegate { + return Err(WitnessError::Ambiguous); + } + let links = parent_delegations::Entity::find() + .filter(parent_delegations::Column::Child.eq(id)) + .order_by_asc(parent_delegations::Column::Parent) + .all(db) + .await?; + let signed_parents: BTreeSet<_> = contributor + .proof_cids + .iter() + .map(|cid| { + tinycloud_auth::ipld_core::cid::Cid::try_from(cid.as_str()) + .map(Hash::from) + .map_err(|_| WitnessError::Invalid) + }) + .collect::>()?; + let graph_parents: BTreeSet<_> = links.iter().map(|link| link.parent).collect(); + if signed_parents.len() != contributor.proof_cids.len() || signed_parents != graph_parents { + return Err(WitnessError::Ambiguous); + } + // Do not classify expiry of a signed owner sign-in as loss of policy + // authority. TC-1213 supplies the missing signed self-session fact; + // this witness retains proof dependencies without changing expiry. + // A grant revoked before admission was not an authority candidate. + // A later revocation must not erase its historical dependency. + if revocation::effective_rows(db, &[id]) + .await? + .iter() + .any(|(_, revoked)| revoked.revoked_at.is_some_and(|time| time <= at)) + { + return Ok(None); + } + let abilities = abilities::Entity::find() + .filter(abilities::Column::Delegation.eq(id)) + .all(db) + .await?; + let signed_caps = &event.0.capabilities; + if abilities.len() != signed_caps.len() + || abilities.iter().any(|ability| { + !signed_caps.iter().any(|cap| { + ability.resource == cap.resource + && ability.ability == cap.ability + && ability.caveats == cap.caveats + }) + }) + { + return Err(WitnessError::Ambiguous); + } + if signed_policy_artifact(&event.0.delegation) { + return Ok(None); + } + let has_capability = signed_caps.iter().any(|held| covers(held, capability)); + if id == tip && !has_capability { + return Ok(None); + } + covering.insert(id, has_capability); + for parent in graph_parents { + pending.push(parent); + } + nodes.insert(id, contributor); + } + let tip_row = nodes.get(&tip).ok_or(WitnessError::Missing)?; + if tip_row.audience != owner || !covering.get(&tip).copied().unwrap_or(false) { + return Ok(None); + } + // The recorded closure always contains *all* signed proof dependencies. + // A proof path must reach the true space owner and cover the capability + // throughout; unrelated signed branches remain captured for revocation. + fn reaches_root( + id: Hash, + nodes: &BTreeMap, + covering: &HashMap, + capability: &Capability, + visiting: &mut BTreeSet, + ) -> Result { + if !visiting.insert(id) { + return Err(WitnessError::Ambiguous); + } + let row = nodes.get(&id).ok_or(WitnessError::Missing)?; + let valid = if !covering.get(&id).copied().unwrap_or(false) { + false + } else if root_authority(capability, &row.signer) { + // An owner-signed grant has direct authority even when it cites + // an earlier sign-in as a dependency. Capture that signed proof + // for revocation without treating its audience as the signer. + true + } else if row.proof_cids.is_empty() { + false + } else { + let mut found = false; + for cid in &row.proof_cids { + let hash = Hash::from( + tinycloud_auth::ipld_core::cid::Cid::try_from(cid.as_str()) + .map_err(|_| WitnessError::Invalid)?, + ); + let parent = nodes.get(&hash).ok_or(WitnessError::Missing)?; + if parent.audience != row.signer { + return Err(WitnessError::Ambiguous); + } + found |= reaches_root(hash, nodes, covering, capability, visiting)?; + } + found + }; + visiting.remove(&id); + Ok(valid) + } + if !reaches_root(tip, &nodes, &covering, capability, &mut BTreeSet::new())? { + return Ok(None); + } + Ok(Some(CandidateClosure { + tip_cid: tip_row.cid.clone(), + contributors: nodes.into_values().collect(), + })) +} + +/// Call after both signed roots and graph rows have been inserted on the same +/// transaction. `at` is the original registered_at for historical backfill. +/// Revoked *now* but valid *at admission* candidates remain in the witness. +pub async fn capture_in_transaction( + db: &C, + policy_cid: &str, + owner_did: &str, + root_cids: &[String], + at: OffsetDateTime, + encryption: Option<&ColumnEncryption>, +) -> Result, WitnessError> { + if root_cids.len() != 2 || root_cids[0] == root_cids[1] { + return Err(WitnessError::Ambiguous); + } + let owner = principal(owner_did)?; + let mut result = Vec::new(); + let mut roots = Vec::new(); + for (index, root_cid) in root_cids.iter().enumerate() { + let root = policy_v3_root::Entity::find_by_id(root_cid.clone()) + .one(db) + .await? + .ok_or(WitnessError::Missing)?; + if root.policy_cid != policy_cid + || root.role + != if index == 0 { + "policy-authority" + } else { + "policy-enforcement" + } + { + return Err(WitnessError::Ambiguous); + } + let event = signed_event(&root.authorization_bytes)?; + let mut signed = signed_contributor(&root.authorization_bytes, root_cid, &event).await?; + let cid = tinycloud_auth::ipld_core::cid::Cid::try_from(root_cid.as_str()) + .map_err(|_| WitnessError::Invalid)?; + let alias = delegation_cid_alias::Entity::find_by_id(Hash::from(cid)) + .one(db) + .await? + .ok_or(WitnessError::Missing)?; + signed.canonical_cid = alias.canonical.to_cid(0x55).to_string(); + if signed.signer != owner || !signed.proof_cids.is_empty() || !active_at(&signed, at)? { + return Err(WitnessError::Ambiguous); + } + roots.push((root, signed, event)); + } + let delegatee_prefix = format!("{owner_did}#%"); + for (root, signed, event) in roots { + for (index, capability) in event.0.capabilities.into_iter().enumerate() { + if index > i32::MAX as usize { + return Err(WitnessError::Overflow); + } + let mut closures = Vec::new(); + let mut cursor = None; + loop { + let mut query = delegation::Entity::find() + .filter( + sea_orm::Condition::any() + .add(delegation::Column::Delegatee.eq(owner_did)) + .add(delegation::Column::Delegatee.like(delegatee_prefix.as_str())), + ) + .order_by_asc(delegation::Column::Id) + .limit(CANDIDATE_PAGE_SIZE); + if let Some(last) = cursor { + query = query.filter(delegation::Column::Id.gt(last)); + } + let candidates = query.all(db).await?; + if candidates.is_empty() { + break; + } + cursor = candidates.last().map(|row| row.id); + for candidate in &candidates { + if !did_principal_matches(&candidate.delegatee, &owner) { + continue; + } + if let Some(found) = + closure(db, candidate.id, &owner, &capability, at, encryption).await? + { + closures.push(found); + if closures.len() > MAX_CANDIDATES { + return Err(WitnessError::Overflow); + } + } + } + if candidates.len() < CANDIDATE_PAGE_SIZE as usize { + break; + } + } + closures.sort_by(|a, b| a.tip_cid.cmp(&b.tip_cid)); + if closures.is_empty() && !root_authority(&capability, &owner) { + return Err(WitnessError::Missing); + } + result.push(PolicyWitness { + version: VERSION, + policy_cid: policy_cid.to_owned(), + root_cid: root.root_cid.clone(), + capability_index: index as i32, + owner_principal: owner.clone(), + capability, + registered_at: format_time(at)?, + owner_self_classification: "unknown/legacy".to_owned(), + root: signed.clone(), + candidates: closures, + }); + } + } + if result.is_empty() { + return Err(WitnessError::Missing); + } + Ok(result) +} + +/// Idempotent only for byte-identical witness rows. Caller owns commit. +pub async fn persist_in_transaction( + db: &C, + witnesses: &[PolicyWitness], +) -> Result<(), WitnessError> { + if witnesses.is_empty() { + return Err(WitnessError::Missing); + } + for witness in witnesses { + let bytes = serde_json::to_vec(witness).map_err(|_| WitnessError::Invalid)?; + let digest = hex::encode(hash(&bytes).as_ref()); + if let Some(existing) = Entity::find_by_id(( + witness.policy_cid.clone(), + witness.root_cid.clone(), + witness.capability_index, + )) + .one(db) + .await? + { + if existing.version != VERSION + || existing.witness_bytes != bytes + || existing.witness_digest_hex != digest + { + return Err(WitnessError::Ambiguous); + } + continue; + } + ActiveModel { + policy_cid: Set(witness.policy_cid.clone()), + root_cid: Set(witness.root_cid.clone()), + capability_index: Set(witness.capability_index), + version: Set(VERSION), + registered_at: Set(witness.registered_at.clone()), + witness_bytes: Set(bytes), + witness_digest_hex: Set(digest), + } + .insert(db) + .await?; + } + Ok(()) +} +/// Read bounded indexed witness dependencies without traversing signed graphs +/// or re-resolving historical candidates. Callers may lock `principals` on +/// their transaction before checking and committing an authorized effect. +#[derive(Clone, Debug)] +pub struct WitnessDependencies { + pub principals: Vec, + pub spaces: Vec, + pub signed_cids: Vec, +} + +pub async fn revocation_dependencies( + db: &C, + policy_cid: &str, +) -> Result { + load_dependencies(db, policy_cid, true).await +} + +// Startup validates the entire immutable witness even when a root was later +// revoked. Live consumers still deny that already-loaded root immediately, +// without reading the other root or aliases on the denied request path. +async fn load_dependencies( + db: &C, + policy_cid: &str, + require_live_root: bool, +) -> Result { + let registration = policy_v3_registration::Entity::find_by_id(policy_cid) + .one(db) + .await? + .ok_or(WitnessError::Missing)?; + let owner = principal(®istration.owner_did)?; + let rows = Entity::find() + .filter(Column::PolicyCid.eq(policy_cid)) + .all(db) + .await?; + if rows.is_empty() || rows.len() > MAX_NODES * 2 { + return Err(WitnessError::Missing); + } + let mut principals = BTreeSet::from([owner.clone()]); + let mut spaces = BTreeSet::new(); + let mut signed_cids = BTreeSet::new(); + let mut expected_aliases = BTreeMap::new(); + let by_root: BTreeMap<_, _> = rows + .iter() + .map(|row| ((row.root_cid.as_str(), row.capability_index), row)) + .collect(); + if by_root.len() != rows.len() { + return Err(WitnessError::Ambiguous); + } + let mut indexed = BTreeSet::new(); + for (root_cid, role) in [ + (®istration.policy_root_cid, "policy-authority"), + (®istration.enforcement_root_cid, "policy-enforcement"), + ] { + let root = policy_v3_root::Entity::find_by_id(root_cid.clone()) + .one(db) + .await? + .ok_or(WitnessError::Missing)?; + // Signed root status is an explicit CID cutoff. Startup continues + // validating both historical roots and all witness dependencies; + // a live request fails closed here with no additional SQL. + if require_live_root && (root.revoked_at.is_some() || root.revocation_bytes.is_some()) { + return Err(WitnessError::Revoked); + } + if root.policy_cid != policy_cid || root.role != role { + return Err(WitnessError::Ambiguous); + } + let event = signed_event(&root.authorization_bytes)?; + if event.content_hash().to_cid(0x55).to_string() != *root_cid { + return Err(WitnessError::Ambiguous); + } + let root_audience = principal(&event.0.delegate)?; + if principal(&event.0.delegator)? != owner { + return Err(WitnessError::Ambiguous); + } + let root_facts = match &event.0.delegation { + TinyCloudDelegation::Ucan(ucan) => ucan.payload().facts.clone().unwrap_or_default(), + TinyCloudDelegation::Cacao(_) => Vec::new(), + }; + for (index, capability) in event.0.capabilities.iter().enumerate() { + let key = (root_cid.as_str(), index as i32); + let row = by_root.get(&key).ok_or(WitnessError::Missing)?; + if !indexed.insert(key) + || row.version != VERSION + || row.registered_at != registration.registered_at + || row.witness_digest_hex != hex::encode(hash(&row.witness_bytes).as_ref()) + { + return Err(WitnessError::Ambiguous); + } + let witness: PolicyWitness = + serde_json::from_slice(&row.witness_bytes).map_err(|_| WitnessError::Invalid)?; + if witness.version != VERSION + || witness.policy_cid != policy_cid + || witness.root_cid != *root_cid + || witness.capability_index != index as i32 + || witness.registered_at != row.registered_at + || witness.owner_principal != owner + || witness.owner_self_classification != "unknown/legacy" + || witness.capability != *capability + || witness.root.cid != *root_cid + || witness.root.signed_bytes != root.authorization_bytes + || witness.root.signer != owner + || witness.root.audience != root_audience + || witness.root.signed_capabilities != event.0.capabilities + || witness.root.signed_facts != root_facts + || !witness.root.proof_cids.is_empty() + { + return Err(WitnessError::Ambiguous); + } + if let Some(space) = capability.resource.space() { + spaces.insert(space.to_string()); + } + let mut tips = BTreeSet::new(); + for closure in &witness.candidates { + if !tips.insert(&closure.tip_cid) + || closure.contributors.is_empty() + || !closure + .contributors + .iter() + .any(|node| node.cid == closure.tip_cid) + { + return Err(WitnessError::Ambiguous); + } + } + for contributor in std::iter::once(&witness.root).chain( + witness + .candidates + .iter() + .flat_map(|closure| closure.contributors.iter()), + ) { + if principal(&contributor.signer)? != contributor.signer + || principal(&contributor.audience)? != contributor.audience + { + return Err(WitnessError::Invalid); + } + principals.insert(contributor.signer.clone()); + principals.insert(contributor.audience.clone()); + let cid = tinycloud_auth::ipld_core::cid::Cid::try_from(contributor.cid.as_str()) + .map_err(|_| WitnessError::Invalid)?; + let canonical = tinycloud_auth::ipld_core::cid::Cid::try_from( + contributor.canonical_cid.as_str(), + ) + .map_err(|_| WitnessError::Invalid)?; + let id = Hash::from(cid); + if id.to_cid(0x55).to_string() != contributor.cid + || Hash::from(canonical).to_cid(0x55).to_string() != contributor.canonical_cid + { + return Err(WitnessError::Ambiguous); + } + if expected_aliases + .insert(id, Hash::from(canonical)) + .is_some_and(|old| old != Hash::from(canonical)) + { + return Err(WitnessError::Ambiguous); + } + signed_cids.insert(id); + if signed_cids.len() > MAX_NODES * MAX_CANDIDATES * 2 { + return Err(WitnessError::Overflow); + } + } + } + } + if rows.len() != indexed.len() { + return Err(WitnessError::Ambiguous); + } + let ids: Vec<_> = signed_cids.iter().copied().collect(); + for chunk in ids.chunks(200) { + let aliases = delegation_cid_alias::Entity::find() + .filter(delegation_cid_alias::Column::Id.is_in(chunk.iter().copied())) + .all(db) + .await?; + if aliases.len() != chunk.len() { + return Err(WitnessError::Missing); + } + for alias in aliases { + if expected_aliases.get(&alias.id) != Some(&alias.canonical) { + return Err(WitnessError::Ambiguous); + } + } + } + Ok(WitnessDependencies { + principals: principals.into_iter().collect(), + spaces: spaces.into_iter().collect(), + signed_cids: ids, + }) +} + +/// Never treat natural expiry or unknown/legacy owner-self classification as +/// explicit revocation. Run on the invocation/mint transaction after acquiring +/// shared `key_authority::lock_targets` for `revocation_dependencies.principals`. +pub async fn check_revocations( + db: &C, + policy_cid: &str, + _encryption: Option<&ColumnEncryption>, +) -> Result<(), WitnessError> { + let dependencies = revocation_dependencies(db, policy_cid).await?; + check_dependencies(db, &dependencies, &dependencies.spaces).await +} + +pub async fn check_revocations_for_spaces( + db: &C, + policy_cid: &str, + spaces: &[String], +) -> Result<(), WitnessError> { + let dependencies = revocation_dependencies(db, policy_cid).await?; + let spaces = if spaces.is_empty() { + &dependencies.spaces + } else { + spaces + }; + check_dependencies(db, &dependencies, spaces).await +} + +/// The caller must supply every space touched by this authorization. Signed +/// CID dependencies are checked regardless of scope; only principal +/// tombstones can be limited to the requested spaces. +pub async fn check_dependencies( + db: &C, + dependencies: &WitnessDependencies, + spaces: &[String], +) -> Result<(), WitnessError> { + crate::key_authority::check_principals(db, &dependencies.principals, spaces) + .await + .map_err(|_| WitnessError::Revoked)?; + check_signed_cids(db, dependencies).await +} + +/// Call after checking all dependency principals (including witness signers) +/// in the same guarded authorization window or write transaction. CID +/// revocation has no space scope and must always be checked. +pub async fn check_signed_cids( + db: &C, + dependencies: &WitnessDependencies, +) -> Result<(), WitnessError> { + for chunk in dependencies.signed_cids.chunks(200) { + if !revocation::effective_rows(db, chunk).await?.is_empty() { + return Err(WitnessError::Revoked); + } + } + Ok(()) +} + +async fn plan_backfill( + db: &C, + encryption: Option<&ColumnEncryption>, + collect: bool, +) -> Result<(BackfillCounts, Vec), WitnessError> { + let mut counts = BackfillCounts::default(); + let mut planned = Vec::new(); + let registrations = policy_v3_registration::Entity::find().all(db).await?; + for registration in registrations { + counts.policies += 1; + // Existing immutable witnesses are never reconstructed from a graph + // that may have gained other grants since the original admission. + // Dry-run must also work on a pre-migration read replica without + // reading the not-yet-created witness table. + if collect { + let existing = Entity::find() + .filter(Column::PolicyCid.eq(registration.policy_cid.clone())) + .all(db) + .await?; + if !existing.is_empty() { + match load_dependencies(db, ®istration.policy_cid, false).await { + Ok(_) => counts.witnesses += existing.len() as u64, + Err(WitnessError::Missing) => counts.missing += 1, + Err( + WitnessError::Ambiguous | WitnessError::Invalid | WitnessError::Revoked, + ) => counts.ambiguous += 1, + Err(WitnessError::Overflow) => counts.overflow += 1, + Err(error) => return Err(error), + } + continue; + } + } + let at = match OffsetDateTime::parse( + ®istration.registered_at, + &time::format_description::well_known::Rfc3339, + ) { + Ok(at) => at, + Err(_) => { + counts.ambiguous += 1; + continue; + } + }; + match capture_in_transaction( + db, + ®istration.policy_cid, + ®istration.owner_did, + &[ + registration.policy_root_cid, + registration.enforcement_root_cid, + ], + at, + encryption, + ) + .await + { + Ok(witnesses) => { + counts.witnesses += witnesses.len() as u64; + if collect { + planned.extend(witnesses); + } + } + Err(WitnessError::Missing) => counts.missing += 1, + Err(WitnessError::Ambiguous | WitnessError::Invalid) => counts.ambiguous += 1, + Err(WitnessError::Overflow) => counts.overflow += 1, + Err(error) => return Err(error), + } + } + Ok((counts, planned)) +} + +/// Read only: callers must print/serialize counts, never witness payloads. +pub async fn dry_run_backfill( + db: &C, + encryption: Option<&ColumnEncryption>, +) -> Result { + plan_backfill(db, encryption, false) + .await + .map(|(counts, _)| counts) +} + +/// Must be called only after the operator's Amos production snapshot gate. +/// Supply the caller's transaction; any unresolved policy aborts the whole run. +pub async fn backfill_in_transaction( + db: &DatabaseTransaction, + encryption: Option<&ColumnEncryption>, +) -> Result { + let (counts, witnesses) = plan_backfill(db, encryption, true).await?; + if counts.missing != 0 { + return Err(WitnessError::Missing); + } + if counts.ambiguous != 0 { + return Err(WitnessError::Ambiguous); + } + if counts.overflow != 0 { + return Err(WitnessError::Overflow); + } + if !witnesses.is_empty() { + persist_in_transaction(db, &witnesses).await?; + } + Ok(counts) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dry_run_counts_never_serialize_identifiers() { + let value = BackfillCounts { + policies: 2, + witnesses: 4, + missing: 1, + ambiguous: 0, + overflow: 0, + }; + let text = serde_json::to_string(&value).unwrap(); + assert_eq!( + text, + r#"{"policies":2,"witnesses":4,"missing":1,"ambiguous":0,"overflow":0}"# + ); + } + + #[test] + fn expiry_is_not_a_revocation_signal() { + let expired = Contributor { + cid: String::new(), + canonical_cid: String::new(), + signer: String::new(), + audience: String::new(), + proof_cids: vec![], + signed_capabilities: vec![], + signed_facts: vec![], + signed_bytes: vec![], + not_before: None, + expiry: Some(format_time(OffsetDateTime::UNIX_EPOCH).unwrap()), + }; + assert!(!active_at(&expired, OffsetDateTime::now_utc()).unwrap()); + // Revocation checks deliberately use signed IDs, not active_at. + } +} diff --git a/tinycloud-core/src/recovery_authority.rs b/tinycloud-core/src/recovery_authority.rs new file mode 100644 index 00000000..fdfc3b08 --- /dev/null +++ b/tinycloud-core/src/recovery_authority.rs @@ -0,0 +1,393 @@ +//! Signed, live recovery enrollment checks for explicit key revocation. + +use std::collections::{HashMap, HashSet}; + +use sea_orm::{ + ColumnTrait, ConnectionTrait, DatabaseTransaction, DbBackend, DbErr, EntityTrait, QueryFilter, + QuerySelect, +}; +use time::OffsetDateTime; +use tinycloud_auth::{ + authorization::TinyCloudDelegation, + identity::{canonicalize_did, principal_did}, + ipld_core::cid::Cid, + resource::SpaceId, + ssi::jwk::Algorithm, +}; + +use crate::{ + auth_graph, + encryption::{maybe_decrypt, ColumnEncryption}, + hash::{hash, Hash}, + key_authority, + models::{ + abilities, delegation, delegation_cid_alias, invocation, key_revocation::VerifiedKeyRevoke, + revocation, + }, + types::Resource, + util::DelegationInfo, +}; + +/// A failed or ambiguous proof is a denial, not information about the target. +/// The caller must use the same transaction for this check and the tombstone. +/// Signatures are checked before shared SQL locks, so DID resolution cannot +/// hold a row lock while awaiting external work. +pub async fn is_authorized( + db: &DatabaseTransaction, + request: &VerifiedKeyRevoke, + now: OffsetDateTime, + encryption: Option<&ColumnEncryption>, + proof_expires_at: &mut Option, +) -> Result { + if request.proof_cids.is_empty() + || request.proof_cids.len() > revocation::MAX_CHAIN_TRAVERSAL_NODES + { + return Ok(false); + } + let resource = format!("urn:tinycloud:key:{}", request.target_did); + let caveat = serde_json::json!({ + "version": 1, + "targetDid": &request.target_did, + "scope": &request.scope, + }); + let root_issuer = match &request.scope { + crate::models::key_revocation::KeyRevokeScope::Global => { + std::borrow::Cow::Borrowed(request.target_did.as_str()) + } + crate::models::key_revocation::KeyRevokeScope::Space { space_id } => { + let Ok(space) = space_id.parse::() else { + return Ok(false); + }; + if space.to_string() != *space_id { + return Ok(false); + } + std::borrow::Cow::Owned(space.did().to_string()) + } + }; + if canonicalize_did(&root_issuer).ok().as_deref() != Some(root_issuer.as_ref()) { + return Ok(false); + } + let mut roots = Vec::with_capacity(request.proof_cids.len()); + for proof in &request.proof_cids { + let Ok(cid) = proof.parse::() else { + return Ok(false); + }; + if cid.codec() != 0x55 || cid.to_string() != *proof { + return Ok(false); + } + let Some(id) = resolve_proof(db, cid).await? else { + return Ok(false); + }; + if roots.contains(&id) { + return Ok(false); + } + roots.push(id); + } + let (nodes, edges) = match auth_graph::load_closure_edges(db, &roots).await { + Ok(closure) => closure, + Err(auth_graph::ChainTraversalError::LimitExceeded) => return Ok(false), + Err(auth_graph::ChainTraversalError::Db(error)) => return Err(error), + }; + if nodes.is_empty() { + return Ok(false); + } + let rows = delegation::Entity::find() + .filter(delegation::Column::Id.is_in(nodes.iter().copied())) + .all(db) + .await?; + if rows.len() != nodes.len() { + return Ok(false); + } + let ability_rows = abilities::Entity::find() + .filter(abilities::Column::Delegation.is_in(nodes.iter().copied())) + .all(db) + .await?; + let mut stored_abilities: HashMap> = HashMap::new(); + for ability in ability_rows { + stored_abilities + .entry(ability.delegation) + .or_default() + .push(ability); + } + let mut signed = HashMap::with_capacity(rows.len()); + for row in &rows { + let Ok(serialization) = maybe_decrypt(encryption, &row.serialization) else { + return Ok(false); + }; + if hash(&serialization) != row.id { + return Ok(false); + } + let Ok(canonical) = delegation_cid_alias::canonical_hash(&serialization) else { + return Ok(false); + }; + let alias = delegation_cid_alias::Entity::find_by_id(row.id) + .one(db) + .await?; + if alias.as_ref().is_some_and(|alias| { + alias.canonical != canonical + || alias.encrypted != (row.serialization.first() == Some(&0x01)) + }) || (alias.is_none() && canonical != row.id) + { + return Ok(false); + } + let Ok(token) = TinyCloudDelegation::from_bytes(&serialization) else { + return Ok(false); + }; + // A recovery chain has only offline cryptographic methods. Never + // follow a did:web (or other network) issuer under a SQL row lock. + let valid_signature = match &token { + TinyCloudDelegation::Ucan(ucan) => { + ucan.header().algorithm == Algorithm::EdDSA + && ucan.payload().issuer.as_str().starts_with("did:key:") + && crate::did_resolution::verify_ucan_signature(ucan) + .await + .is_ok() + } + TinyCloudDelegation::Cacao(cacao) => { + cacao + .payload() + .iss + .to_string() + .starts_with("did:pkh:eip155:") + && cacao.verify().await.is_ok() + } + }; + if !valid_signature { + return Ok(false); + } + let Ok(info) = DelegationInfo::try_from(token) else { + return Ok(false); + }; + let expected_terminal = + matches!(info.delegation_mode, crate::util::DelegationMode::Terminal); + let facts_match = if expected_terminal { + row.facts.as_ref().is_some_and(|facts| { + facts.0.len() == 1 + && facts.0.get(crate::util::DelegationMode::FACT_KEY) + == Some(&serde_json::json!("terminal")) + }) + } else { + row.facts.is_none() + }; + if row.delegator != info.delegator + || row.delegatee != info.delegate + || row.expiry != info.expiry + || row.not_before != info.not_before + || row.issued_at != info.issued_at + || !facts_match + || row.issued_at.is_some_and(|issued| now < issued) + || row.expiry.is_some_and(|expiry| now >= expiry) + || row.not_before.is_some_and(|start| now < start) + || info.capabilities.len() != 1 + || !exact_capability(&info.capabilities[0], &resource, &caveat) + { + return Ok(false); + } + let Some(ability) = stored_abilities.get(&row.id) else { + return Ok(false); + }; + if ability.len() != 1 + || ability[0].resource != info.capabilities[0].resource + || ability[0].ability != info.capabilities[0].ability + || ability[0].caveats != info.capabilities[0].caveats + { + return Ok(false); + } + let mut signed_parents = Vec::with_capacity(info.parents.len()); + for parent in &info.parents { + let Some(id) = resolve_proof(db, *parent).await? else { + return Ok(false); + }; + signed_parents.push(id); + } + let mut persisted_parents = edges.get(&row.id).cloned().unwrap_or_default(); + signed_parents.sort_unstable(); + persisted_parents.sort_unstable(); + if signed_parents.windows(2).any(|pair| pair[0] == pair[1]) + || signed_parents != persisted_parents + { + return Ok(false); + } + signed.insert(row.id, info); + } + // The writer's /revoke transaction takes an exclusive lock on its target + // delegation row. Lock every dependency in deterministic order, then read + // revocation state under these locks before declaring authority live. + if db.get_database_backend() != DbBackend::Sqlite { + let mut ordered = nodes; + ordered.sort_unstable(); + for id in ordered { + let Some(locked) = delegation::Entity::find_by_id(id) + .lock_shared() + .one(db) + .await? + else { + return Ok(false); + }; + if rows.iter().find(|row| row.id == id) != Some(&locked) { + return Ok(false); + } + } + } + let mut principals = Vec::with_capacity(rows.len() * 2); + for row in &rows { + let (Ok(signer), Ok(audience)) = + (principal_did(&row.delegator), principal_did(&row.delegatee)) + else { + return Ok(false); + }; + principals.push(signer); + principals.push(audience); + } + // The target and issuer need UPDATE, while other proof principals need + // SHARE. Lock the complete set once in canonical order; never upgrade an + // already-held intermediate SHARE lock to UPDATE in this transaction. + key_authority::lock_recovery_targets(db, &principals, &request.target_did, &request.issuer_did) + .await?; + // The graph and principal locks may have waited past a signed proof's + // window. Recheck every bound under those locks rather than trusting the + // entry timestamp passed by the caller. + let locked_now = OffsetDateTime::now_utc(); + if rows.iter().any(|row| { + row.issued_at.is_some_and(|issued| locked_now < issued) + || row.not_before.is_some_and(|start| locked_now < start) + || row.expiry.is_some_and(|expiry| locked_now >= expiry) + }) { + return Ok(false); + } + let spaces = match &request.scope { + crate::models::key_revocation::KeyRevokeScope::Global => Vec::new(), + crate::models::key_revocation::KeyRevokeScope::Space { space_id } => vec![space_id.clone()], + }; + match key_authority::check_principals(db, &principals, &spaces).await { + Ok(()) => {} + Err(error) if key_authority::is_revoked_error(&error) => return Ok(false), + Err(error) => return Err(error), + } + let row_lookup = rows + .iter() + .map(|row| (row.id, row)) + .collect::>(); + for (child_id, parents) in &edges { + let Some(child) = row_lookup.get(child_id) else { + return Ok(false); + }; + for parent_id in parents { + let Some(parent) = row_lookup.get(parent_id) else { + return Ok(false); + }; + let linked = matches!( + (principal_did(&parent.delegatee), principal_did(&child.delegator)), + (Ok(parent_did), Ok(child_did)) if parent_did == child_did + ); + if !linked + || matches!( + signed[parent_id].delegation_mode, + crate::util::DelegationMode::Terminal + ) + { + return Ok(false); + } + } + } + let mut memo = HashMap::new(); + for id in row_lookup.keys() { + if !is_rooted(*id, &edges, &mut memo, &mut HashSet::new()) { + return Ok(false); + } + } + for row in &rows { + if revocation::is_revoked(db, &row.id).await? { + return Ok(false); + } + let info = &signed[&row.id]; + if info.parents.is_empty() { + if principal_did(&row.delegator).ok().as_deref() != Some(root_issuer.as_ref()) { + return Ok(false); + } + } else if edges.get(&row.id).is_none_or(Vec::is_empty) { + return Ok(false); + } + } + for root in &roots { + if principal_did(&signed[root].delegate).ok().as_deref() + != Some(request.issuer_did.as_str()) + { + return Ok(false); + } + } + // The existing holder verifier checks live chain windows, direct signer + // links and caveat containment; root authority is checked separately above + // because generic invocations do not treat key URNs as space roots. + let proofs = roots.iter().map(|id| id.to_cid(0x55)).collect::>(); + let caps = &signed[&roots[0]].capabilities; + match invocation::authorize_holder(db, &request.issuer_did, caps, &proofs, locked_now).await { + Ok(()) => { + *proof_expires_at = rows.iter().filter_map(|row| row.expiry).min(); + Ok(true) + } + Err(invocation::Error::Db(error)) => Err(error), + Err(_) => Ok(false), + } +} + +fn is_rooted( + id: Hash, + edges: &HashMap>, + memo: &mut HashMap, + path: &mut HashSet, +) -> bool { + if let Some(&rooted) = memo.get(&id) { + return rooted; + } + if !path.insert(id) { + return false; + } + let rooted = edges.get(&id).is_none_or(|parents| { + parents.is_empty() + || parents + .iter() + .all(|parent| is_rooted(*parent, edges, memo, path)) + }); + path.remove(&id); + memo.insert(id, rooted); + rooted +} + +fn exact_capability( + cap: &crate::util::Capability, + resource: &str, + caveat: &serde_json::Value, +) -> bool { + matches!(&cap.resource, Resource::Other(uri) if uri.as_str() == resource) + && cap.ability.to_string() == "tinycloud.key/revoke" + && cap.caveats.0.len() == 1 + && cap.caveats.0.get("0") == Some(caveat) +} + +/// Resolve a CID only as a CID: its alias is never a DID principal. Multiple +/// historical encodings for one canonical hash are deliberately ambiguous. +async fn resolve_proof(db: &C, cid: Cid) -> Result, DbErr> { + if cid.codec() != 0x55 { + return Ok(None); + } + let id = Hash::from(cid); + let aliases = delegation_cid_alias::Entity::find() + .filter(delegation_cid_alias::Column::Canonical.eq(id)) + .all(db) + .await?; + if aliases.len() > 1 { + return Ok(None); + } + let direct = delegation::Entity::find_by_id(id).one(db).await?.is_some(); + if let Some(alias) = aliases.first() { + if direct && alias.id != id { + return Ok(None); + } + Ok(Some(alias.id)) + } else if direct { + Ok(Some(id)) + } else { + Ok(None) + } +} diff --git a/tinycloud-core/src/share_email/bridge.rs b/tinycloud-core/src/share_email/bridge.rs index 10cc8c8b..ff0a5247 100644 --- a/tinycloud-core/src/share_email/bridge.rs +++ b/tinycloud-core/src/share_email/bridge.rs @@ -1193,6 +1193,22 @@ impl DatabaseAuthorityBridge117 { let statement = authorized_statement(scope, &policy, &delegation).inspect_err(|&failure| { tracing::error!(failure = ?failure, stage = "authorized_statement", "share scope validation failed"); })?; + let source_owner = match &scope.content_source { + super::types::ContentSource::Kv { space, .. } + | super::types::ContentSource::Sql { space, .. } => space.as_str(), + }; + let principals = vec![ + policy.issuer_did.clone(), + policy.audience_did.clone(), + delegation.issuer_did.clone(), + delegation.audience_did.clone(), + source_owner.to_owned(), + enforcer.as_str().to_owned(), + scope.node_audience.as_str().to_owned(), + ]; + crate::key_authority::check_principals_any_scope(tx, &principals) + .await + .map_err(|_| PortError::Denied)?; Ok((expiry, matcher, statement, bundle.internal_delegation_cid)) } } @@ -1936,4 +1952,240 @@ mod tests { .await; assert!(matches!(result, Err(PortError::Unavailable))); } + // The checked-in vector's ceiling hash uses the generator's single-capability + // preimage, whereas the #117 kernel hashes the canonical capability ARRAY. + // Re-sign the corrected parents and their dependent evidence with the + // deterministic test-only keys declared by the vector's build.mjs. + fn sign_fixture_parent(mut parent: PolicyDelegation) -> PolicyDelegation { + use k256::ecdsa::SigningKey; + use sha3::Keccak256; + + let mut unsigned = serde_json::to_value(&parent).unwrap(); + let object = unsigned.as_object_mut().unwrap(); + object.remove("delegationCid"); + object.remove("signature"); + let unsigned = jcs::canonicalize(&unsigned); + let digest = Sha256::digest( + [ + b"xyz.tinycloud.policy/enforcement-delegation/v1\0".as_slice(), + &unsigned, + ] + .concat(), + ); + let mut preimage = b"\x19Ethereum Signed Message:\n32".to_vec(); + preimage.extend_from_slice(&digest); + let signing = SigningKey::from_bytes((&[0x55; 32]).into()).unwrap(); + let (signature, recovery) = signing + .sign_prehash_recoverable(&Keccak256::digest(preimage)) + .unwrap(); + let mut signature_bytes = signature.to_bytes().to_vec(); + signature_bytes.push(recovery.to_byte() + 27); + parent.signature.value = URL_SAFE_NO_PAD.encode(signature_bytes); + + let mut cid_value = serde_json::to_value(&parent).unwrap(); + cid_value.as_object_mut().unwrap().remove("delegationCid"); + parent.delegation_cid = Cid::new_v1( + 0x55, + Code::Blake3_256.digest(&jcs::canonicalize(&cid_value)), + ) + .to_string(); + AuthorityArtifactVerifier + .verify(&jcs::canonicalize(&serde_json::to_value(&parent).unwrap())) + .expect("re-signed owner parent verifies"); + parent + } + + fn sign_fixture_status( + status: &mut Value, + parent_cid: &str, + node: &libp2p::identity::ed25519::Keypair, + ) { + status["parentCid"] = json!(parent_cid); + let mut unsigned = status.clone(); + unsigned.as_object_mut().unwrap().remove("signature"); + let mut signed = b"xyz.tinycloud.share/authority-status/v1\0".to_vec(); + signed.extend_from_slice(&jcs::canonicalize(&unsigned)); + status["signature"]["value"] = json!(URL_SAFE_NO_PAD.encode(node.sign(&signed))); + } + + fn sign_fixture_wrapper( + wrapper: &mut Value, + material: Value, + sender: &libp2p::identity::ed25519::Keypair, + ) { + let domain = b"xyz.tinycloud.share/authority-material-bundle/v1\0"; + assert_eq!(wrapper["domain"].as_str().unwrap().as_bytes(), domain); + let canonical = jcs::canonicalize(&material); + let mut signed = domain.to_vec(); + signed.extend_from_slice(&canonical); + let signature = sender.sign(&signed); + wrapper["message"] = material; + wrapper["jcs"] = json!(std::str::from_utf8(&canonical).unwrap()); + wrapper["messageDigest"] = json!(URL_SAFE_NO_PAD.encode(Sha256::digest(&canonical))); + wrapper["signedBytesDigest"] = json!(URL_SAFE_NO_PAD.encode(Sha256::digest(&signed))); + wrapper["signatureDigest"] = json!(URL_SAFE_NO_PAD.encode(Sha256::digest(&signature))); + wrapper["signature"]["value"] = json!(URL_SAFE_NO_PAD.encode(signature)); + } + + #[tokio::test] + async fn signed_share_authority_denies_owner_and_enforcer_after_key_revoke() { + use super::super::authority::AuthenticatedAuthorityMaterialProvider; + use crate::models::key_revocation; + + let fixture: Value = serde_json::from_str(include_str!( + "../../../tinycloud-node-server/tests/fixtures/email-claim-v1/positive.json" + )) + .unwrap(); + let scenario = &fixture["scenarios"][0]; + let artifact = |name| { + scenario["artifacts"] + .as_array() + .unwrap() + .iter() + .find(|artifact| artifact["name"] == name) + .unwrap() + .clone() + }; + let mut material = scenario["authorityMaterial"].clone(); + let mut parent_bytes = Vec::new(); + for key in ["policyAuthorityBytes", "policyEnforcementBytes"] { + let bytes = URL_SAFE_NO_PAD + .decode(material[key].as_str().unwrap()) + .unwrap(); + parent_bytes.push(serde_json::from_slice::(&bytes).unwrap()); + } + let ceiling = crate::policy_capability::requested_capabilities_hash_hex( + &parent_bytes[0] + .capabilities + .iter() + .map(parse_capability) + .collect::, _>>() + .unwrap(), + ); + let node = libp2p::identity::ed25519::Keypair::from( + libp2p::identity::ed25519::SecretKey::try_from_bytes([0x42; 32]).unwrap(), + ); + let sender = libp2p::identity::ed25519::Keypair::from( + libp2p::identity::ed25519::SecretKey::try_from_bytes([0x44; 32]).unwrap(), + ); + assert_eq!( + crate::keys::public_key_to_did_key(sender.public().into()), + material["senderDid"].as_str().unwrap() + ); + for (index, (bytes_key, cid_key)) in [ + ("policyAuthorityBytes", "policyAuthorityCid"), + ("policyEnforcementBytes", "policyEnforcementCid"), + ] + .into_iter() + .enumerate() + { + let mut parent = parent_bytes[index].clone(); + parent.facts.insert( + "xyz.tinycloud.policy/capabilityCeilingHashHex".to_owned(), + ceiling.clone(), + ); + let parent = sign_fixture_parent(parent); + let cid = parent.delegation_cid.clone(); + material[bytes_key] = + json!(URL_SAFE_NO_PAD + .encode(jcs::canonicalize(&serde_json::to_value(&parent).unwrap()))); + material[cid_key] = json!(cid.as_str()); + material["mapping"][cid_key] = json!(cid.as_str()); + sign_fixture_status(&mut material["statusObservations"][index], &cid, &node); + } + let material_digest = + Sha256Digest::from_bytes(Sha256::digest(jcs::canonicalize(&material)).into()); + let mut wrapper = artifact("authorityMaterial"); + sign_fixture_wrapper(&mut wrapper, material, &sender); + let provider = AuthenticatedAuthorityMaterialProvider::from_json( + &serde_json::to_vec(&json!({ + "authorityMaterial": wrapper, + "policy": artifact("policy"), + })) + .unwrap(), + ) + .expect("authenticated re-signed authority, status, and attestation"); + let now = + OffsetDateTime::parse(scenario["evaluationTime"].as_str().unwrap(), &Rfc3339).unwrap(); + let mut requested = scope(scenario["policyCid"].as_str().unwrap()); + requested.share_cid = ShareCid::parse(scenario["shareCid"].as_str().unwrap()).unwrap(); + requested.share_id = ShareId::parse(scenario["shareId"].as_str().unwrap()).unwrap(); + requested.delegation_cid = + Some(ShareDelegationCid::parse(scenario["delegationCid"].as_str().unwrap()).unwrap()); + requested.authority_material_handle = + AuthorityMaterialHandle::parse(scenario["authorityMaterialHandle"].as_str().unwrap()) + .unwrap(); + requested.authority_material_digest = material_digest; + requested.content_source_digest = + Sha256Digest::parse(scenario["sourceDigest"].as_str().unwrap()).unwrap(); + requested.content_source = serde_json::from_value(scenario["source"].clone()).unwrap(); + requested.action = serde_json::from_value(scenario["policy"]["action"].clone()).unwrap(); + requested.allowed_actions = vec![requested.action]; + requested.resource = ExactResource::Kv { + path: Path::parse(scenario["policy"]["resource"].as_str().unwrap()).unwrap(), + }; + let bundle = provider + .resolve_exact( + &requested.policy_cid, + requested.delegation_cid.as_ref().unwrap(), + &requested.authority_material_handle, + &requested.authority_material_digest, + ) + .await + .expect("exact signed authority material resolves"); + validate_share_policy_state(&bundle.policy_state, &requested) + .expect("signed policy grants the requested share scope"); + + for target in [ + scenario["authorityMaterial"]["policyOwnerDid"] + .as_str() + .unwrap(), + scenario["authorityMaterial"]["attestation"]["enforcerDid"] + .as_str() + .unwrap(), + ] { + let db = Database::connect("sqlite::memory:").await.unwrap(); + crate::migrations::Migrator::up(&db, None).await.unwrap(); + let authority = DatabaseAuthorityStore::new(db.clone()); + let bridge = DatabaseAuthorityBridge117::new(db.clone(), authority) + .with_authority_providers( + Arc::new(provider.clone()), + Arc::new(provider.status_provider()), + Arc::new(provider.attestation_provider()), + ); + + bridge + .validate_scope(&requested, now) + .await + .expect("signed authority is live before key revoke"); + key_revocation::ActiveModel { + target_did: Set( + "did:pkh:eip155:1:0x2222222222222222222222222222222222222222".to_owned(), + ), + scope_key: Set("global".to_owned()), + revoked_at: Set(now.unix_timestamp()), + } + .insert(&db) + .await + .unwrap(); + bridge + .validate_scope(&requested, now) + .await + .expect("unrelated principal tombstone does not revoke this authority"); + + key_revocation::ActiveModel { + target_did: Set(tinycloud_auth::identity::principal_did(target).unwrap()), + scope_key: Set("global".to_owned()), + revoked_at: Set(now.unix_timestamp()), + } + .insert(&db) + .await + .unwrap(); + assert_eq!( + bridge.validate_scope(&requested, now).await, + Err(PortError::Denied), + "signed authority must be denied after {target} is revoked" + ); + } + } } diff --git a/tinycloud-core/src/util.rs b/tinycloud-core/src/util.rs index 6bcea110..c98f7d07 100644 --- a/tinycloud-core/src/util.rs +++ b/tinycloud-core/src/util.rs @@ -74,18 +74,40 @@ fn extract_ucan_caps(caps: &UcanCapabilities) -> Vec) -> (Vec, Vec) { +fn extract_siwe_cap(c: SiweCap) -> (Vec, Vec) { let (c, p) = c.into_inner(); ( c.into_inner() .into_iter() - .flat_map(|(r, acs)| { - // r is UriString, acs is BTreeMap> - acs.into_keys() // Iterate over Ability keys - .map(|ability| Capability { - resource: Resource::from(r.clone()), - ability: ability.into(), - caveats: Caveats::default(), + .flat_map(|(resource, actions)| { + actions + .into_iter() + .map(|(ability, caveats)| { + // Existing SIWE capabilities retain their historical + // caveat behavior. Key-recovery authority is the only + // new action that requires its typed signed scope. + let caveats = if ability.to_string() == "tinycloud.key/revoke" { + Caveats( + caveats + .into_inner() + .into_iter() + .enumerate() + .map(|(index, value)| { + ( + index.to_string(), + serde_json::Value::Object(value.into_iter().collect()), + ) + }) + .collect(), + ) + } else { + Caveats::default() + }; + Capability { + resource: Resource::from(resource.clone()), + ability: ability.into(), + caveats, + } }) .collect::>() }) @@ -197,8 +219,9 @@ impl TryFrom for DelegationInfo { }, TinyCloudDelegation::Cacao(ref c) => { let m: Message = c.payload().clone().try_into()?; - // Use the public extract_and_verify, which returns Result>, VerificationError> - let maybe_siwe_cap = SiweCap::extract_and_verify(&m)?; + // Keep the signed typed caveat for key recovery while + // retaining legacy caveat semantics for other SIWE actions. + let maybe_siwe_cap = SiweCap::::extract_and_verify(&m)?; let (capabilities, parents) = match maybe_siwe_cap { Some(siwe_cap) => { diff --git a/tinycloud-core/src/write_hooks.rs b/tinycloud-core/src/write_hooks.rs index 7695382d..1315726a 100644 --- a/tinycloud-core/src/write_hooks.rs +++ b/tinycloud-core/src/write_hooks.rs @@ -148,19 +148,30 @@ pub async fn hook_register_authority_holds( .expect("valid hooks/register ability"), caveats, }; + let holder_principal = match tinycloud_auth::identity::principal_did(holder) { + Ok(principal) => principal, + Err(_) => return Ok(false), + }; + let scope = [space.to_string()]; match invocation::authorize_holder(db, holder, &[cap(Caveats(Default::default()))], &[], now) .await { - Ok(()) => return Ok(true), + Ok(()) => { + let principals = [holder_principal.clone(), space.did().to_string()]; + return match crate::key_authority::check_principals(db, &principals, &scope).await { + Ok(()) => Ok(true), + Err(error) if crate::key_authority::is_revoked_error(&error) => Ok(false), + Err(error) => Err(error), + }; + } Err(invocation::Error::Db(error)) => return Err(error), Err(invocation::Error::InvalidInvocation(_)) => {} } - let principal = - tinycloud_auth::identity::principal_did(holder).unwrap_or_else(|_| holder.to_string()); + let principal = holder_principal.as_str(); let candidates = delegation::Entity::find() .filter( Condition::any() - .add(delegation::Column::Delegatee.eq(&principal)) + .add(delegation::Column::Delegatee.eq(principal)) .add(delegation::Column::Delegatee.like(format!("{principal}#%"))) .add(delegation::Column::Delegatee.eq(holder)), ) @@ -219,7 +230,31 @@ pub async fn hook_register_authority_holds( ) .await { - Ok(()) => return Ok(true), + Ok(()) => { + let mut principals = + match crate::auth_graph::load_chain_principals(db, &[candidate.id]).await { + Ok(principals) => principals, + Err(crate::auth_graph::ChainTraversalError::LimitExceeded) => continue, + Err(crate::auth_graph::ChainTraversalError::Db(DbErr::Custom( + reason, + ))) if matches!( + reason.as_str(), + "authorization-chain-missing" | "authorization-principal-invalid" + ) => + { + continue; + } + Err(crate::auth_graph::ChainTraversalError::Db(error)) => { + return Err(error) + } + }; + principals.push(holder_principal.clone()); + match crate::key_authority::check_principals(db, &principals, &scope).await { + Ok(()) => return Ok(true), + Err(error) if crate::key_authority::is_revoked_error(&error) => continue, + Err(error) => return Err(error), + } + } Err(invocation::Error::Db(error)) => return Err(error), Err(invocation::Error::InvalidInvocation(_)) => {} } diff --git a/tinycloud-core/tests/historical_cacao_revocation.rs b/tinycloud-core/tests/historical_cacao_revocation.rs index 38143a80..d0dc433c 100644 --- a/tinycloud-core/tests/historical_cacao_revocation.rs +++ b/tinycloud-core/tests/historical_cacao_revocation.rs @@ -470,15 +470,31 @@ async fn assert_redelegation_denied(db: &TestDb, fixture: &Fixture) { let error = db .delegate(Delegation::from_header_ser::(&direct).unwrap()) .await - .expect_err("revoked equivalent parent must not allow a new child") - .to_string(); - assert!(error.contains("delegation-parent-revoked"), "{error}"); + .expect_err("revoked equivalent parent must not allow a new child"); + assert!( + matches!( + error, + tinycloud_core::db::TxError::AuthorizationDenied + | tinycloud_core::db::TxError::InvalidDelegation( + tinycloud_core::models::delegation::DelegationError::ParentRevoked(_) + ) + ), + "a revoked parent must deny delegation: {error}" + ); let descendant = child_header(&fixture.resource, &row.child_cid, fixture.child_seed, 13); let error = db .delegate(Delegation::from_header_ser::(&descendant).unwrap()) .await - .expect_err("legacy child of revoked root must not allow a descendant") - .to_string(); - assert!(error.contains("delegation-ancestor-revoked"), "{error}"); + .expect_err("legacy child of revoked root must not allow a descendant"); + assert!( + matches!( + error, + tinycloud_core::db::TxError::AuthorizationDenied + | tinycloud_core::db::TxError::InvalidDelegation( + tinycloud_core::models::delegation::DelegationError::AncestorRevoked { .. } + ) + ), + "a revoked ancestor must deny delegation: {error}" + ); } } diff --git a/tinycloud-core/tests/key_revoke_cross_process_receipt.rs b/tinycloud-core/tests/key_revoke_cross_process_receipt.rs new file mode 100644 index 00000000..5a449665 --- /dev/null +++ b/tinycloud-core/tests/key_revoke_cross_process_receipt.rs @@ -0,0 +1,393 @@ +//! Two independent PostgreSQL clients must agree on the first self-revoke receipt. +use std::{ + process::{Child, Command, Stdio}, + time::Duration, +}; + +use tinycloud_core::{ + db::SpaceDatabase, + keys::{public_key_to_did_key, Keypair, StaticSecret}, + migrations::Migrator, + models::key_revocation::{self, KeyRevokeReceipt, KeyRevokeScope, VerifiedKeyRevoke}, + sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ConnectOptions, ConnectionTrait, Database, + DatabaseConnection, DbBackend, EntityTrait, Statement, TransactionTrait, + }, + sea_orm_migration::MigratorTrait, + storage::memory::MemoryStore, +}; +use tokio::time::{sleep, timeout, Instant}; + +const TEST: &str = "postgres_cross_process_self_revoke_returns_one_immutable_receipt"; +const MARKER: &str = "TC1149_CROSS_PROCESS_WORKER"; +const RECEIPT: &str = "TC1149_RECEIPT:"; +const ERROR: &str = "TC1149-REVOKE-RESULT:"; +const NONCE: &str = "tc1149-cross-process-identical-self-revoke"; +const AUTHORIZATION_HASH: [u8; 32] = [19; 32]; + +fn target_did() -> String { + public_key_to_did_key(Keypair::ed25519_from_bytes([37; 32]).unwrap().public()) +} + +fn request(now: i64) -> VerifiedKeyRevoke { + let target_did = target_did(); + VerifiedKeyRevoke { + issuer_did: target_did.clone(), + target_did, + scope: KeyRevokeScope::Global, + nonce: NONCE.into(), + authorization_hash: AUTHORIZATION_HASH, + proof_cids: Vec::new(), + not_before: now, + valid_after: None, + expires_at: now + 60, + } +} + +fn postgres_url() -> Option { + match std::env::var("TINYCLOUD_TEST_POSTGRES_URL") { + Ok(url) if !url.trim().is_empty() => Some(url), + _ => { + assert!( + std::env::var_os("CI").is_none(), + "{TEST} requires TINYCLOUD_TEST_POSTGRES_URL in CI" + ); + eprintln!("skipping {TEST}: TINYCLOUD_TEST_POSTGRES_URL is unset"); + None + } + } +} + +fn connection_options(url: &str, schema: &str) -> ConnectOptions { + let mut options = ConnectOptions::new(url); + options + .max_connections(1) + .sqlx_logging(false) + .set_schema_search_path(schema); + options +} + +// Drop is synchronous so cleanup also runs while an assertion is unwinding. +struct Schema { + url: String, + name: String, + conn: DatabaseConnection, +} + +impl Schema { + async fn new(url: String) -> Self { + let name = format!( + "tc1149_receipt_{}_{}", + std::process::id(), + time::OffsetDateTime::now_utc().unix_timestamp_nanos() + ); + let admin = Database::connect(ConnectOptions::new(&url)) + .await + .expect("connect to PG"); + admin + .execute_unprepared(&format!("CREATE SCHEMA {name}")) + .await + .expect("create isolated schema"); + admin.close().await.expect("close schema admin"); + let conn = Database::connect(connection_options(&url, &name)) + .await + .expect("connect to isolated schema"); + let fixture = Self { url, name, conn }; + Migrator::up(&fixture.conn, None) + .await + .expect("migrate isolated schema"); + fixture + } +} + +impl Drop for Schema { + fn drop(&mut self) { + let url = self.url.clone(); + let name = self.name.clone(); + let result = std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("schema cleanup runtime") + .block_on(async move { + timeout(Duration::from_secs(10), async { + let admin = Database::connect(ConnectOptions::new(url)).await?; + // A failed assertion may leave a worker waiting on our row lock. + // Only sessions bearing this schema's two unique application names are killed. + let terminate = Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity \ + WHERE application_name IN ($1, $2) AND pid <> pg_backend_pid()", + vec![format!("{name}_one").into(), format!("{name}_two").into()], + ); + admin.query_all(terminate).await?; + admin + .execute_unprepared(&format!("DROP SCHEMA IF EXISTS {name} CASCADE")) + .await?; + admin.close().await + }) + .await + }) + }) + .join(); + if !matches!(result, Ok(Ok(Ok(())))) { + eprintln!("isolated PG receipt schema cleanup failed"); + } + } +} + +// Kill and reap both OS processes even if a parent assertion panics. +struct Workers(Vec>); + +impl Drop for Workers { + fn drop(&mut self) { + for child in self.0.iter_mut().flatten() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +async fn worker(url: String, schema: String, label: &str, now: i64) { + let conn = Database::connect(connection_options(&url, &schema)) + .await + .expect("worker connects to its own PG pool"); + let db = SpaceDatabase::new( + conn, + MemoryStore::default(), + StaticSecret::new(vec![0; 32]).unwrap(), + ) + .await + .expect("worker opens database"); + db.connection() + .execute_unprepared(&format!("SET application_name = '{schema}_{label}'")) + .await + .expect("name worker PostgreSQL session"); + match db.revoke_key(request(now)).await { + Ok(receipt) => println!("{RECEIPT}{}", serde_json::to_string(&receipt).unwrap()), + Err(key_revocation::KeyRevokeError::Forbidden) => println!("{ERROR}403"), + Err(key_revocation::KeyRevokeError::Conflict) => println!("{ERROR}409"), + Err(key_revocation::KeyRevokeError::Db { .. }) => println!("{ERROR}503"), + } +} + +fn receipt_from(output: std::process::Output) -> KeyRevokeReceipt { + assert!( + output.status.success(), + "cross-process revoke worker failed" + ); + let text = std::str::from_utf8(&output.stdout).expect("worker stdout is UTF-8"); + let error = text.lines().find_map(|line| line.strip_prefix(ERROR)); + assert!( + error.is_none(), + "concurrent worker returned {error:?} instead of committed receipt" + ); + let receipts: Vec<_> = text + .lines() + .filter_map(|line| line.strip_prefix(RECEIPT)) + .collect(); + assert_eq!( + receipts.len(), + 1, + "worker must produce exactly one live receipt" + ); + serde_json::from_str(receipts[0]).expect("worker receipt is valid JSON") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn postgres_cross_process_self_revoke_returns_one_immutable_receipt() { + if let Some(label) = std::env::var_os(MARKER) { + let label = label.to_str().expect("worker label UTF-8"); + assert!(label == "one" || label == "two", "recognized worker label"); + let url = postgres_url().expect("worker requires PostgreSQL URL"); + let schema = std::env::var("TC1149_RECEIPT_SCHEMA").expect("parent supplies schema"); + let now = std::env::var("TC1149_RECEIPT_NOW") + .expect("parent supplies signed time") + .parse() + .expect("valid signed time"); + worker(url, schema, label, now).await; + return; + } + let Some(url) = postgres_url() else { return }; + let schema = Schema::new(url.clone()).await; + let target = target_did(); + key_revocation::target::ActiveModel { + target_did: Set(target.clone()), + } + .insert(&schema.conn) + .await + .expect("pre-create target lock row"); + let guard = schema + .conn + .begin() + .await + .expect("open shared-lock transaction"); + let locked = guard + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT target_did FROM key_revocation_target \ + WHERE target_did = $1 FOR SHARE", + vec![target.clone().into()], + )) + .await + .expect("hold PG shared row guard") + .expect("target row exists"); + assert_eq!(locked.try_get::("", "target_did").unwrap(), target); + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let exe = std::env::current_exe().expect("current test executable"); + let mut workers = Workers(Vec::new()); + for label in ["one", "two"] { + let mut command = Command::new(&exe); + command + .arg("--exact") + .arg(TEST) + .arg("--nocapture") + .env(MARKER, label) + .env("TC1149_RECEIPT_SCHEMA", &schema.name) + .env("TC1149_RECEIPT_NOW", now.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + workers.0.push(Some( + command.spawn().expect("spawn independent revoke worker"), + )); + } + let observer = Database::connect(ConnectOptions::new(&url)) + .await + .expect("independent PG activity observer"); + let one = format!("{}_one", schema.name); + let two = format!("{}_two", schema.name); + let deadline = Instant::now() + Duration::from_secs(30); + loop { + let blocked = observer + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT application_name, pid FROM pg_stat_activity \ + WHERE application_name IN ($1, $2) AND state = 'active' \ + AND wait_event_type = 'Lock' \ + AND query LIKE '%key_revocation_target%' AND query ILIKE '%FOR UPDATE%'", + vec![one.clone().into(), two.clone().into()], + )) + .await + .expect("inspect blocked worker backends"); + if blocked.len() == 2 { + let mut names = Vec::new(); + let mut pids = Vec::new(); + for row in blocked { + names.push(row.try_get::("", "application_name").unwrap()); + pids.push(row.try_get::("", "pid").unwrap()); + } + names.sort(); + assert_eq!( + names, + [one.clone(), two.clone()], + "both named processes reach target SQL" + ); + assert_ne!( + pids[0], pids[1], + "workers use different PostgreSQL backends" + ); + break; + } + if Instant::now() >= deadline { + let sessions = observer + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT application_name, state, wait_event_type, query FROM pg_stat_activity \ + WHERE application_name IN ($1, $2)", + vec![one.clone().into(), two.clone().into()], + )) + .await + .expect("inspect named worker sessions"); + let waiting: Vec<_> = sessions + .iter() + .map(|row| { + let name: String = row.try_get("", "application_name").unwrap(); + let state: String = row.try_get("", "state").unwrap(); + let event: Option = row.try_get("", "wait_event_type").unwrap(); + let query: String = row.try_get("", "query").unwrap(); + ( + name, + state, + event, + query.contains("key_revocation_target"), + query.to_ascii_uppercase().contains("FOR UPDATE"), + ) + }) + .collect(); + let exits: Vec<_> = workers + .0 + .iter_mut() + .map(|child| { + child + .as_mut() + .unwrap() + .try_wait() + .unwrap() + .map(|status| status.code()) + }) + .collect(); + panic!("workers did not reach target row guard: sessions {waiting:?}, exit codes {exits:?}"); + } + sleep(Duration::from_millis(20)).await; + } + guard.commit().await.expect("release FOR SHARE row guard"); + let mut receipts = Vec::new(); + let completion_deadline = Instant::now() + Duration::from_secs(20); + for worker in &mut workers.0 { + loop { + if worker + .as_mut() + .unwrap() + .try_wait() + .expect("inspect worker status") + .is_some() + { + break; + } + assert!( + Instant::now() < completion_deadline, + "blocked worker finishes after release" + ); + sleep(Duration::from_millis(20)).await; + } + let child = worker.take().unwrap(); + receipts.push(receipt_from( + child.wait_with_output().expect("read worker receipt"), + )); + } + assert_eq!( + receipts[0], receipts[1], + "concurrent requests share the immutable first receipt" + ); + assert!(receipts[0].revoked); + assert_eq!(receipts[0].target_did, target); + assert_eq!(receipts[0].scope, KeyRevokeScope::Global); + let persisted = key_revocation::nonce::Entity::find_by_id((target.clone(), NONCE.into())) + .one(&schema.conn) + .await + .expect("read durable nonce") + .expect("receipt persisted"); + assert_eq!(persisted.authorization_hash, AUTHORIZATION_HASH.to_vec()); + assert_eq!( + persisted.receipt_bytes, + serde_json::to_vec(&receipts[0]).expect("serialize immutable receipt") + ); + let fresh = SpaceDatabase::new( + Database::connect(connection_options(&url, &schema.name)) + .await + .expect("cold connection"), + MemoryStore::default(), + StaticSecret::new(vec![0; 32]).unwrap(), + ) + .await + .expect("cold database opens"); + assert_eq!( + fresh + .revoke_key(request(now)) + .await + .expect("committed receipt replays"), + receipts[0], + "replay on fresh connection preserves first receipt" + ); +} diff --git a/tinycloud-core/tests/key_revoke_recovery_order.rs b/tinycloud-core/tests/key_revoke_recovery_order.rs new file mode 100644 index 00000000..6da60938 --- /dev/null +++ b/tinycloud-core/tests/key_revoke_recovery_order.rs @@ -0,0 +1,640 @@ +//! Two live recovery chains can cross at principal locks during key revocation. +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde_json::json; +use std::{collections::BTreeMap, time::Duration}; +use time::OffsetDateTime; +use tinycloud_auth::{ + authorization::{Cid, HeaderEncode, TinyCloudDelegation}, + ssi::{ + claims::jwt::NumericDate, + jwk::{Algorithm, JWK}, + ucan::Payload, + }, + ucan_capabilities_object::Capabilities, +}; +use tinycloud_core::{ + hash::{hash, Hash}, + key_authority, + keys::{public_key_to_did_key, Keypair, StaticSecret}, + migrations::Migrator, + models::{ + abilities, actor, delegation, delegation_cid_alias, + key_revocation::{self, KeyRevokeError, KeyRevokeScope, VerifiedKeyRevoke}, + }, + recovery_authority, + relationships::parent_delegations, + sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ConnectOptions, ConnectionTrait, Database, + DatabaseConnection, DatabaseTransaction, DbBackend, EntityTrait, Statement, + TransactionTrait, + }, + sea_orm_migration::MigratorTrait, + storage::MemoryStore, + util::DelegationInfo, + SpaceDatabase, +}; + +fn signer(seed: u8) -> (JWK, String, String) { + let key = Keypair::ed25519_from_bytes([seed; 32]).unwrap(); + let public = key.clone().try_into_ed25519().unwrap().public().to_bytes(); + let did = public_key_to_did_key(key.public()); + let jwk = serde_json::from_value(json!({ + "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", + "x": URL_SAFE_NO_PAD.encode(public), + "d": URL_SAFE_NO_PAD.encode([seed; 32]), + })) + .unwrap(); + let fragment = format!("{did}#{}", did.trim_start_matches("did:key:")); + (jwk, did, fragment) +} + +fn signed_grant( + seed: u8, + audience: &str, + target: &str, + parent: Option<&str>, + nonce: &str, + now: i64, +) -> String { + let (jwk, _, issuer) = signer(seed); + let mut caps = Capabilities::::new(); + let caveat: BTreeMap = serde_json::from_value(json!({ + "version": 1, "targetDid": target, "scope": {"kind": "global"} + })) + .unwrap(); + caps.with_action( + format!("urn:tinycloud:key:{target}").parse().unwrap(), + "tinycloud.key/revoke".parse().unwrap(), + [caveat], + ); + Payload:: { + issuer: issuer.parse().unwrap(), + audience: audience.parse().unwrap(), + not_before: Some(NumericDate::try_from_seconds((now - 60) as f64).unwrap()), + expiration: NumericDate::try_from_seconds((now + 600) as f64).unwrap(), + nonce: Some(nonce.to_owned()), + facts: None, + proof: parent + .into_iter() + .map(|cid| cid.parse::().unwrap()) + .collect(), + attenuation: caps, + } + .sign(Algorithm::EdDSA, &jwk) + .unwrap() + .encode() + .unwrap() +} + +async fn persist_grant(db: &DatabaseConnection, serialized: &str) -> String { + let (_, bytes) = TinyCloudDelegation::decode(serialized).unwrap(); + let info = DelegationInfo::try_from(TinyCloudDelegation::from_bytes(&bytes).unwrap()).unwrap(); + assert_eq!(info.capabilities.len(), 1); + let id = hash(&bytes); + let cid = id.to_cid(0x55).to_string(); + assert_eq!(bytes, serialized.as_bytes()); + for did in [&info.delegator, &info.delegate] { + if actor::Entity::find_by_id(did.to_owned()) + .one(db) + .await + .unwrap() + .is_none() + { + actor::ActiveModel { + id: Set(did.to_owned()), + } + .insert(db) + .await + .unwrap(); + } + } + delegation::ActiveModel { + id: Set(id), + delegator: Set(info.delegator), + delegatee: Set(info.delegate), + expiry: Set(info.expiry), + issued_at: Set(info.issued_at), + not_before: Set(info.not_before), + facts: Set(None), + serialization: Set(bytes.clone()), + } + .insert(db) + .await + .unwrap(); + let cap = &info.capabilities[0]; + abilities::ActiveModel { + resource: Set(cap.resource.clone()), + ability: Set(cap.ability.clone()), + delegation: Set(id), + caveats: Set(cap.caveats.clone()), + } + .insert(db) + .await + .unwrap(); + delegation_cid_alias::ActiveModel { + id: Set(id), + canonical: Set(hash(&bytes)), + encrypted: Set(false), + } + .insert(db) + .await + .unwrap(); + for parent in &info.parents { + parent_delegations::ActiveModel { + parent: Set(Hash::from(*parent)), + child: Set(id), + } + .insert(db) + .await + .unwrap(); + } + cid +} + +fn request(target: &str, issuer: &str, proof: String, nonce: &str, now: i64) -> VerifiedKeyRevoke { + let mut authorization_hash = [0; 32]; + authorization_hash.copy_from_slice(hash(nonce.as_bytes()).as_ref()); + VerifiedKeyRevoke { + target_did: target.to_owned(), + issuer_did: issuer.to_owned(), + scope: KeyRevokeScope::Global, + nonce: nonce.to_owned(), + authorization_hash, + proof_cids: vec![proof], + not_before: now, + valid_after: None, + expires_at: now + 60, + } +} + +// The names consist only of our numeric PID and timestamp, never URL/user input. +struct Schema { + url: String, + name: String, +} + +impl Drop for Schema { + fn drop(&mut self) { + let (url, name) = (self.url.clone(), self.name.clone()); + let result = std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async move { + let conn = Database::connect(ConnectOptions::new(url)).await.unwrap(); + conn.execute_unprepared(&format!("DROP SCHEMA IF EXISTS {name} CASCADE")) + .await + .unwrap(); + }) + }) + .join(); + assert!(result.is_ok(), "drop isolated PostgreSQL schema"); + } +} + +async fn wait_for_target_updates( + admin: &DatabaseConnection, + names: &[&str], + holder: Option, +) -> bool { + let deadline = tokio::time::Instant::now() + Duration::from_secs(12); + let blocker_clause = holder.map_or_else( + || "cardinality(pg_blocking_pids(pid)) > 0".to_owned(), + |pid| format!("{pid} = ANY(pg_blocking_pids(pid))"), + ); + loop { + // A first waiter is pinned to our holder. A later waiter can be + // queued behind that earlier waiter rather than behind the holder. + let rows = admin + .query_all(Statement::from_string( + DbBackend::Postgres, + format!( + "SELECT application_name FROM pg_stat_activity \ + WHERE application_name IN ('{}') AND state = 'active' \ + AND wait_event_type = 'Lock' AND {blocker_clause} \ + AND query LIKE '%key_revocation_target%' AND query LIKE '%FOR UPDATE%'", + names.join("','"), + ), + )) + .await + .expect("inspect isolated PostgreSQL wait events"); + if rows.len() == names.len() { + let seen: Vec = rows + .iter() + .map(|row| row.try_get("", "application_name").unwrap()) + .collect(); + if names + .iter() + .all(|name| seen.iter().any(|seen| seen == name)) + { + return true; + } + } + if tokio::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn postgres_crossed_recovery_revokes_do_not_deadlock() { + let url = match std::env::var("TINYCLOUD_TEST_POSTGRES_URL") { + Ok(url) if !url.trim().is_empty() => url, + _ => { + assert!(std::env::var_os("CI").is_none(), + "postgres_crossed_recovery_revokes_do_not_deadlock requires TINYCLOUD_TEST_POSTGRES_URL in CI"); + eprintln!("skipping PostgreSQL-only recovery order test: TINYCLOUD_TEST_POSTGRES_URL is unset"); + return; + } + }; + let schema = Schema { + url: url.clone(), + name: format!( + "tc1149_{}_{}", + std::process::id(), + OffsetDateTime::now_utc().unix_timestamp_nanos() + ), + }; + let mut admin_options = ConnectOptions::new(url.clone()); + admin_options.max_connections(2).sqlx_logging(false); + let admin = Database::connect(admin_options) + .await + .expect("connect PostgreSQL observer"); + admin + .execute_unprepared(&format!("CREATE SCHEMA {}", schema.name)) + .await + .unwrap(); + let connect = |label: &str| { + let app = format!("{}_{}", schema.name, label); + let mut options = ConnectOptions::new(format!( + "{}{}application_name={app}", + url, + if url.contains('?') { "&" } else { "?" } + )); + options + .max_connections(1) + .sqlx_logging(false) + .set_schema_search_path(schema.name.clone()); + (app, options) + }; + let (first_name, first_options) = connect("first"); + let (second_name, second_options) = connect("second"); + let first_conn = Database::connect(first_options).await.unwrap(); + Migrator::up(&first_conn, None).await.unwrap(); + let second_conn = Database::connect(second_options).await.unwrap(); + let first = SpaceDatabase::new( + first_conn.clone(), + MemoryStore::default(), + StaticSecret::new(vec![0; 32]).unwrap(), + ) + .await + .unwrap(); + let second = SpaceDatabase::new( + second_conn, + MemoryStore::default(), + StaticSecret::new(vec![0; 32]).unwrap(), + ) + .await + .unwrap(); + + // R and S sort before either target. Both transactions reach the held + // issuer rows: the unfixed path already holds SHARE on the opposite + // intermediate, whereas the fixed path blocks before taking that SHARE. + let mut identities: Vec<(u8, String)> = (1..=8).map(|seed| (seed, signer(seed).1)).collect(); + identities.sort_by(|left, right| left.1.cmp(&right.1)); + let (_, r) = &identities[0]; + let (_, s) = &identities[1]; + let (a_seed, a) = &identities[2]; + let (b_seed, b) = &identities[3]; + assert!(r < a && s < b && a != b); + let now = OffsetDateTime::now_utc().unix_timestamp(); + let a_root = persist_grant( + &first_conn, + &signed_grant(*a_seed, b, a, None, "a-root", now), + ) + .await; + let a_tip = persist_grant( + &first_conn, + &signed_grant(*b_seed, r, a, Some(&a_root), "a-tip", now), + ) + .await; + let b_root = persist_grant( + &first_conn, + &signed_grant(*b_seed, a, b, None, "b-root", now), + ) + .await; + let b_tip = persist_grant( + &first_conn, + &signed_grant(*a_seed, s, b, Some(&b_root), "b-tip", now), + ) + .await; + let a_request = request(a, r, a_tip, "revoke-a", now); + let b_request = request(b, s, b_tip, "revoke-b", now); + + // Demonstrate both independently valid, signed recovery paths before + // arranging any waits. A Forbidden caused by fixture drift is not a red. + for req in [&a_request, &b_request] { + let tx = first_conn.begin().await.unwrap(); + let mut expiry = None; + assert!( + recovery_authority::is_authorized( + &tx, + req, + OffsetDateTime::now_utc(), + None, + &mut expiry + ) + .await + .unwrap(), + "signed recovery grant must be valid before the lock schedule" + ); + assert!(expiry.is_some()); + tx.rollback().await.unwrap(); + } + for did in [a, b, r, s] { + key_revocation::target::ActiveModel { + target_did: Set(did.clone()), + } + .insert(&first_conn) + .await + .unwrap(); + } + let holder: DatabaseTransaction = admin.begin().await.unwrap(); + let held = holder + .query_one(Statement::from_string( + DbBackend::Postgres, + format!( + "SELECT count(*) AS n FROM \ + (SELECT target_did FROM {}.key_revocation_target \ + WHERE target_did IN ('{r}', '{s}') ORDER BY target_did FOR UPDATE) AS held", + schema.name, + ), + )) + .await + .unwrap() + .unwrap(); + let held_count: i64 = held.try_get("", "n").unwrap(); + assert_eq!(held_count, 2, "test transaction must lock both issuers"); + let pid = holder + .query_one(Statement::from_string( + DbBackend::Postgres, + "SELECT pg_backend_pid() AS pid".to_owned(), + )) + .await + .unwrap() + .unwrap(); + let holder_pid: i32 = pid.try_get("", "pid").unwrap(); + let first_job = tokio::spawn(async move { first.revoke_key(a_request).await }); + let second_job = tokio::spawn(async move { second.revoke_key(b_request).await }); + let reached = + wait_for_target_updates(&admin, &[&first_name, &second_name], Some(holder_pid)).await; + holder.rollback().await.expect("release issuer row locks"); + if !reached { + first_job.abort(); + second_job.abort(); + panic!("both recovery revokes must reach their ordered exclusive target/issuer row locks"); + } + let mut committed = 0; + for job in [first_job, second_job] { + let result = tokio::time::timeout(Duration::from_secs(12), job) + .await + .expect("recovery revoke must finish without a PostgreSQL lock cycle") + .expect("revoke task must not panic"); + assert!( + matches!(&result, Ok(_) | Err(KeyRevokeError::Forbidden)), + "concurrent recovery revoke returned a database/deadlock error: {result:?}" + ); + if result.is_ok() { + committed += 1; + } + } + assert!( + committed >= 1, + "at least one live recovery revoke must commit" + ); + first_conn.close().await.unwrap(); + admin.close().await.unwrap(); +} + +/// Recovery's Rust byte order must agree with the direct/admission transaction +/// even when PostgreSQL's configured text collation orders those DIDs differently. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn postgres_mixed_direct_and_recovery_locks_share_byte_order() { + let url = match std::env::var("TINYCLOUD_TEST_POSTGRES_URL") { + Ok(url) if !url.trim().is_empty() => url, + _ => { + assert!( + std::env::var_os("CI").is_none(), + "mixed lock-order regression requires TINYCLOUD_TEST_POSTGRES_URL in CI" + ); + eprintln!("skipping PostgreSQL-only mixed lock-order test: URL is unset"); + return; + } + }; + let schema = Schema { + url: url.clone(), + name: format!( + "tc1149_mixed_{}_{}", + std::process::id(), + OffsetDateTime::now_utc().unix_timestamp_nanos() + ), + }; + let mut admin_options = ConnectOptions::new(url.clone()); + admin_options.max_connections(2).sqlx_logging(false); + let admin = Database::connect(admin_options).await.unwrap(); + admin + .execute_unprepared(&format!("CREATE SCHEMA {}", schema.name)) + .await + .unwrap(); + let connect = |label: &str| { + let app = format!("{}_{}", schema.name, label); + let mut options = ConnectOptions::new(format!( + "{}{}application_name={app}", + url, + if url.contains('?') { "&" } else { "?" } + )); + options + .max_connections(1) + .sqlx_logging(false) + .set_schema_search_path(schema.name.clone()); + (app, options) + }; + let (recovery_name, recovery_options) = connect("recovery"); + let (direct_name, direct_options) = connect("direct"); + let recovery_conn = Database::connect(recovery_options).await.unwrap(); + Migrator::up(&recovery_conn, None).await.unwrap(); + recovery_conn + .execute_unprepared( + "ALTER TABLE key_revocation_target ALTER COLUMN target_did TYPE varchar(255) COLLATE \"en-US-x-icu\"", + ) + .await + .expect("PostgreSQL must support the test's non-bytewise ICU collation"); + let direct_conn = Database::connect(direct_options).await.unwrap(); + let recovery = SpaceDatabase::new( + recovery_conn.clone(), + MemoryStore::default(), + StaticSecret::new(vec![0; 32]).unwrap(), + ) + .await + .unwrap(); + + let mut identities: Vec<(u8, String)> = (1..=64).map(|seed| (seed, signer(seed).1)).collect(); + identities.sort_by(|left, right| left.1.cmp(&right.1)); + for (_, did) in &identities { + key_revocation::target::ActiveModel { + target_did: Set(did.clone()), + } + .insert(&recovery_conn) + .await + .unwrap(); + } + let db_order = recovery_conn + .query_all(Statement::from_string( + DbBackend::Postgres, + "SELECT target_did FROM key_revocation_target ORDER BY target_did".to_owned(), + )) + .await + .unwrap() + .into_iter() + .map(|row| row.try_get::("", "target_did").unwrap()) + .collect::>(); + let mut chosen = None; + for first in 1..identities.len() { + for second in first + 1..identities.len() { + let a = &identities[first].1; + let b = &identities[second].1; + if db_order.iter().position(|did| did == b).unwrap() + < db_order.iter().position(|did| did == a).unwrap() + { + chosen = Some((identities[first].clone(), identities[second].clone())); + break; + } + } + if chosen.is_some() { + break; + } + } + let ((a_seed, a), (b_seed, b)) = + chosen.expect("fixture must assert an actual database-versus-Rust DID ordering inversion"); + let r = identities[0].1.clone(); + assert!(r < a && a < b); + assert!( + db_order.iter().position(|did| did == &b).unwrap() + < db_order.iter().position(|did| did == &a).unwrap(), + "PostgreSQL sorts B before A while canonical Rust bytes sort A before B" + ); + let now = OffsetDateTime::now_utc().unix_timestamp(); + let root = persist_grant( + &recovery_conn, + &signed_grant(a_seed, &b, &a, None, "mixed-root", now), + ) + .await; + let tip = persist_grant( + &recovery_conn, + &signed_grant(b_seed, &r, &a, Some(&root), "mixed-tip", now), + ) + .await; + let recovery_request = request(&a, &r, tip, "mixed-recovery", now); + let proof_tx = recovery_conn.begin().await.unwrap(); + let mut expiry = None; + assert!( + recovery_authority::is_authorized( + &proof_tx, + &recovery_request, + OffsetDateTime::now_utc(), + None, + &mut expiry + ) + .await + .unwrap(), + "signed recovery chain is valid before arranging either lock wait" + ); + proof_tx.rollback().await.unwrap(); + + let holder = admin.begin().await.unwrap(); + holder + .query_one(Statement::from_string( + DbBackend::Postgres, + format!( + "SELECT target_did FROM {}.key_revocation_target WHERE target_did = '{}' FOR UPDATE", + schema.name, a + ), + )) + .await + .unwrap() + .expect("holder locks A"); + let holder_pid: i32 = holder + .query_one(Statement::from_string( + DbBackend::Postgres, + "SELECT pg_backend_pid() AS pid".to_owned(), + )) + .await + .unwrap() + .unwrap() + .try_get("", "pid") + .unwrap(); + let recovered = tokio::spawn(async move { recovery.revoke_key(recovery_request).await }); + assert!( + wait_for_target_updates(&admin, &[&recovery_name], Some(holder_pid)).await, + "the recovery path must be blocked at A before direct locks are requested" + ); + let direct = tokio::spawn(async move { + let tx = direct_conn.begin().await.unwrap(); + let result = key_authority::lock_targets(&tx, &[a, b], true).await; + tx.rollback().await.unwrap(); + result + }); + let reached = wait_for_target_updates(&admin, &[&recovery_name, &direct_name], None).await; + let diagnostic = if reached { + Vec::new() + } else { + admin + .query_all(Statement::from_string( + DbBackend::Postgres, + format!( + "SELECT application_name, state, COALESCE(wait_event_type, '') AS wait, \ + cardinality(pg_blocking_pids(pid)) AS blockers, \ + query LIKE '%key_revocation_target%' AS target_sql, \ + query LIKE '%FOR UPDATE%' AS update_sql \ + FROM pg_stat_activity WHERE application_name IN ('{recovery_name}', '{direct_name}')" + ), + )) + .await + .unwrap() + .into_iter() + .map(|row| { + let app: String = row.try_get("", "application_name").unwrap(); + ( + if app == recovery_name { "recovery" } else { "direct" }, + row.try_get::("", "state").unwrap(), + row.try_get::("", "wait").unwrap(), + row.try_get::("", "blockers").unwrap(), + row.try_get::("", "target_sql").unwrap(), + row.try_get::("", "update_sql").unwrap(), + ) + }) + .collect::>() + }; + holder + .rollback() + .await + .expect("release A after both paths are waiting"); + assert!( + reached, + "both real lock paths must block at held A; count-only connection states: {diagnostic:?}" + ); + let recovered = tokio::time::timeout(Duration::from_secs(12), recovered) + .await + .expect("recovery path must not deadlock") + .expect("recovery task must not panic"); + let direct = tokio::time::timeout(Duration::from_secs(12), direct) + .await + .expect("direct path must not deadlock") + .expect("direct task must not panic"); + assert!(recovered.is_ok(), "valid recovery failed: {recovered:?}"); + assert!(direct.is_ok(), "direct/admission lock failed: {direct:?}"); + recovery_conn.close().await.unwrap(); + admin.close().await.unwrap(); +} diff --git a/tinycloud-core/tests/policy_witness_legacy.rs b/tinycloud-core/tests/policy_witness_legacy.rs new file mode 100644 index 00000000..783df29d --- /dev/null +++ b/tinycloud-core/tests/policy_witness_legacy.rs @@ -0,0 +1,487 @@ +//! Synthetic pre-TC-1149 policies: signed historical bytes, not mocks. +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde_json::json; +use std::collections::BTreeMap; +use time::OffsetDateTime; +use tinycloud_auth::{ + authorization::{Cid, HeaderEncode}, + ssi::{ + claims::jwt::NumericDate, + jwk::{Algorithm, JWK}, + ucan::Payload, + }, + ucan_capabilities_object::Capabilities, +}; +use tinycloud_core::{ + hash::{hash, Hash}, + keys::{public_key_to_did_key, Keypair}, + migrations::Migrator, + models::{ + abilities, actor, delegation, delegation_cid_alias, policy_v3_registration, policy_v3_root, + revocation, + }, + policy_witness::{ + backfill_in_transaction, capture_in_transaction, check_revocations, dry_run_backfill, + persist_in_transaction, WitnessError, + }, + relationships::parent_delegations, + sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ConnectOptions, Database, DatabaseConnection, + EntityTrait, TransactionTrait, + }, + sea_orm_migration::MigratorTrait, + types::{Ability, Caveats, Resource}, +}; + +const ADMISSION: &str = "2026-01-01T00:00:10Z"; +const SHORT_EXPIRY: &str = "2026-01-01T00:01:00Z"; +const LONG_EXPIRY: &str = "2100-01-01T00:00:00Z"; + +fn date(value: &str) -> OffsetDateTime { + OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339).unwrap() +} + +fn signer(seed: u8) -> (JWK, String, String) { + let key = Keypair::ed25519_from_bytes([seed; 32]).unwrap(); + let public = key.clone().try_into_ed25519().unwrap().public().to_bytes(); + let did = public_key_to_did_key(key.public()); + let jwk = serde_json::from_value(json!({ + "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", + "x": URL_SAFE_NO_PAD.encode(public), + "d": URL_SAFE_NO_PAD.encode([seed; 32]), + })) + .unwrap(); + let fragment = format!("{did}#{}", did.trim_start_matches("did:key:")); + (jwk, did, fragment) +} + +fn token( + seed: u8, + audience: &str, + resource: &str, + proof: &[String], + expires: &str, + nonce: &str, + role: Option<&str>, +) -> String { + let (jwk, _, issuer) = signer(seed); + let mut caps = Capabilities::::new(); + caps.with_action( + resource.parse().unwrap(), + "tinycloud.kv/list".parse().unwrap(), + [BTreeMap::new()], + ); + Payload:: { + issuer: issuer.parse().unwrap(), + audience: audience.parse().unwrap(), + not_before: Some( + NumericDate::try_from_seconds((date(ADMISSION).unix_timestamp() - 60) as f64).unwrap(), + ), + expiration: NumericDate::try_from_seconds(date(expires).unix_timestamp() as f64).unwrap(), + nonce: Some(nonce.to_owned()), + facts: role.map(|role| vec![json!({"xyz.tinycloud.policy/root-profile": {"role": role}})]), + proof: proof + .iter() + .map(|cid| cid.parse::().unwrap()) + .collect(), + attenuation: caps, + } + .sign(Algorithm::EdDSA, &jwk) + .unwrap() + .encode() + .unwrap() +} + +fn cid(token: &str) -> String { + hash(token.as_bytes()).to_cid(0x55).to_string() +} + +fn canonical_id(token: &str) -> Hash { + let (_, bytes) = tinycloud_auth::authorization::TinyCloudDelegation::decode(token).unwrap(); + hash(&bytes) +} + +fn id(cid: &str) -> Hash { + Hash::from(cid.parse::().unwrap()) +} + +async fn database() -> DatabaseConnection { + let opts = ConnectOptions::new("sqlite::memory:") + .max_connections(1) + .to_owned(); + let db = Database::connect(opts).await.unwrap(); + Migrator::up(&db, None).await.unwrap(); + db +} + +async fn ensure_actor(db: &DatabaseConnection, did: &str) { + if actor::Entity::find_by_id(did.to_owned()) + .one(db) + .await + .unwrap() + .is_none() + { + actor::ActiveModel { + id: Set(did.to_owned()), + } + .insert(db) + .await + .unwrap(); + } +} + +async fn grant( + db: &DatabaseConnection, + seed: u8, + audience: &str, + resource: &str, + proof: &[String], + nonce: &str, +) -> String { + let serialized = token(seed, audience, resource, proof, SHORT_EXPIRY, nonce, None); + let (_, delegator, _) = signer(seed); + let name = cid(&serialized); + ensure_actor(db, &delegator).await; + ensure_actor(db, audience).await; + delegation::ActiveModel { + id: Set(id(&name)), + delegator: Set(delegator), + delegatee: Set(audience.to_owned()), + expiry: Set(Some(date(SHORT_EXPIRY))), + issued_at: Set(None), + not_before: Set(Some(date(ADMISSION) - time::Duration::seconds(60))), + facts: Set(None), + serialization: Set(serialized.as_bytes().to_vec()), + } + .insert(db) + .await + .unwrap(); + abilities::ActiveModel { + resource: Set(resource.parse::().unwrap()), + ability: Set(Ability::try_from("tinycloud.kv/list".to_owned()).unwrap()), + delegation: Set(id(&name)), + caveats: Set(Caveats::default()), + } + .insert(db) + .await + .unwrap(); + delegation_cid_alias::ActiveModel { + id: Set(id(&name)), + canonical: Set(canonical_id(&serialized)), + encrypted: Set(false), + } + .insert(db) + .await + .unwrap(); + for parent in proof { + parent_delegations::ActiveModel { + parent: Set(id(parent)), + child: Set(id(&name)), + } + .insert(db) + .await + .unwrap(); + } + name +} + +async fn root(db: &DatabaseConnection, owner_seed: u8, resource: &str, role: &str) -> String { + let serialized = token( + owner_seed, + "did:tinycloud:policy:legacy", + resource, + &[], + LONG_EXPIRY, + role, + Some(role), + ); + let name = cid(&serialized); + let owner = signer(owner_seed).1; + ensure_actor(db, &owner).await; + ensure_actor(db, "did:tinycloud:policy:legacy").await; + // A policy root is parentless by profile, even when its owner is an + // authorised grantee rather than the owner of this space. + delegation::ActiveModel { + id: Set(id(&name)), + delegator: Set(owner), + delegatee: Set("did:tinycloud:policy:legacy".to_owned()), + expiry: Set(Some(date(LONG_EXPIRY))), + issued_at: Set(None), + not_before: Set(Some(date(ADMISSION) - time::Duration::seconds(60))), + facts: Set(None), + serialization: Set(serialized.as_bytes().to_vec()), + } + .insert(db) + .await + .unwrap(); + delegation_cid_alias::ActiveModel { + id: Set(id(&name)), + canonical: Set(canonical_id(&serialized)), + encrypted: Set(false), + } + .insert(db) + .await + .unwrap(); + policy_v3_root::ActiveModel { + root_cid: Set(name.clone()), + policy_cid: Set("legacy-policy".into()), + role: Set(role.to_owned()), + authorization_bytes: Set(serialized.into_bytes()), + status_checkpoint_bytes: Set(None), + previous_checkpoint_digest_hex: Set(None), + status_sequence: Set(0), + admission_epoch: Set(0), + status_checked_at: Set(None), + status_fresh_until: Set(None), + revoked_at: Set(None), + revocation_bytes: Set(None), + } + .insert(db) + .await + .unwrap(); + name +} + +async fn legacy_policy(db: &DatabaseConnection) -> (String, Vec, String, String) { + let space = signer(1).1; + let owner = signer(7).1; + let intermediary = signer(2).1; + let resource = format!( + "tinycloud:key:{}:legacy/kv", + space.trim_start_matches("did:key:") + ); + let direct = grant(db, 1, &owner, &resource, &[], "direct").await; + let ancestor = grant(db, 1, &intermediary, &resource, &[], "ancestor").await; + let chain_tip = grant(db, 2, &owner, &resource, &[ancestor], "chain-tip").await; + let policy = root(db, 7, &resource, "policy-authority").await; + let enforcement = root(db, 7, &resource, "policy-enforcement").await; + policy_v3_registration::ActiveModel { + policy_cid: Set("legacy-policy".into()), + policy_bytes: Set(b"{}".to_vec()), + policy_digest_hex: Set("fixture".into()), + owner_did: Set(owner.clone()), + policy_root_cid: Set(policy.clone()), + enforcement_root_cid: Set(enforcement.clone()), + content_source_digest_hex: Set("fixture".into()), + native_projection_hash_hex: Set("fixture".into()), + attested_enforcer_binding_bytes: Set(b"{}".to_vec()), + registered_at: Set(ADMISSION.into()), + expires_at: Set(LONG_EXPIRY.into()), + } + .insert(db) + .await + .unwrap(); + (owner, vec![policy, enforcement], direct, chain_tip) +} + +#[tokio::test] +async fn legacy_short_signin_expiry_does_not_revoke_all_historical_candidates() { + let db = database().await; + let (owner, roots, direct, chained) = legacy_policy(&db).await; + let witnesses = + capture_in_transaction(&db, "legacy-policy", &owner, &roots, date(ADMISSION), None) + .await + .unwrap(); + assert_eq!(witnesses.len(), 2); + for witness in &witnesses { + assert_eq!(witness.owner_self_classification, "unknown/legacy"); + assert_eq!(witness.candidates.len(), 2); + assert!(witness + .candidates + .iter() + .any(|closure| closure.tip_cid == direct)); + assert!(witness + .candidates + .iter() + .any(|closure| closure.tip_cid == chained && closure.contributors.len() == 2)); + } + persist_in_transaction(&db, &witnesses).await.unwrap(); + assert!(date(SHORT_EXPIRY) < OffsetDateTime::now_utc()); + check_revocations(&db, "legacy-policy", None).await.unwrap(); + let dry = dry_run_backfill(&db, None).await.unwrap(); + assert_eq!( + ( + dry.policies, + dry.witnesses, + dry.missing, + dry.ambiguous, + dry.overflow + ), + (1, 2, 0, 0, 0) + ); + let tx = db.begin().await.unwrap(); + backfill_in_transaction(&tx, None).await.unwrap(); + tx.rollback().await.unwrap(); +} + +#[tokio::test] +async fn expired_signed_ancestor_remains_a_revocation_dependency_without_expiry_denial() { + let db = database().await; + let (owner, roots, direct, chained) = legacy_policy(&db).await; + let after_signin_expired = date(SHORT_EXPIRY) + time::Duration::seconds(1); + let witnesses = capture_in_transaction( + &db, + "legacy-policy", + &owner, + &roots, + after_signin_expired, + None, + ) + .await + .unwrap(); + for witness in &witnesses { + assert!(witness + .candidates + .iter() + .any(|candidate| candidate.tip_cid == direct)); + assert!(witness + .candidates + .iter() + .any(|candidate| candidate.tip_cid == chained && candidate.contributors.len() == 2)); + } +} + +#[tokio::test] +async fn revoking_one_of_multiple_historical_candidates_denies_the_policy() { + let db = database().await; + let (owner, roots, direct, chained) = legacy_policy(&db).await; + let witnesses = + capture_in_transaction(&db, "legacy-policy", &owner, &roots, date(ADMISSION), None) + .await + .unwrap(); + assert!(witnesses.iter().all(|witness| witness + .candidates + .iter() + .any(|candidate| candidate.tip_cid == chained))); + persist_in_transaction(&db, &witnesses).await.unwrap(); + revocation::ActiveModel { + id: Set(hash(b"historical-direct-cutoff")), + revoker: Set(signer(1).1), + revoked: Set(id(&direct)), + serialization: Set(Vec::new()), + revoked_at: Set(Some(OffsetDateTime::now_utc())), + } + .insert(&db) + .await + .unwrap(); + assert!(matches!( + check_revocations(&db, "legacy-policy", None).await, + Err(WitnessError::Revoked) + )); +} + +#[tokio::test] +async fn a_policy_cannot_borrow_another_owners_signed_candidates() { + let db = database().await; + let (_owner, roots, _, _) = legacy_policy(&db).await; + let unrelated = signer(9).1; + assert!(matches!( + capture_in_transaction( + &db, + "legacy-policy", + &unrelated, + &roots, + date(ADMISSION), + None + ) + .await, + Err(WitnessError::Ambiguous) + )); +} + +#[tokio::test] +async fn legacy_missing_signed_dependency_fails_closed() { + let db = database().await; + let (owner, roots, direct, _) = legacy_policy(&db).await; + delegation_cid_alias::Entity::delete_by_id(id(&direct)) + .exec(&db) + .await + .unwrap(); + assert!(matches!( + capture_in_transaction(&db, "legacy-policy", &owner, &roots, date(ADMISSION), None).await, + Err(WitnessError::Missing) + )); + let counts = dry_run_backfill(&db, None).await.unwrap(); + assert_eq!(counts.missing, 1); +} + +#[tokio::test] +async fn legacy_spoofed_proof_edge_is_ambiguous() { + let db = database().await; + let (owner, roots, direct, _) = legacy_policy(&db).await; + parent_delegations::ActiveModel { + parent: Set(id(&roots[0])), + child: Set(id(&direct)), + } + .insert(&db) + .await + .unwrap(); + assert!(matches!( + capture_in_transaction(&db, "legacy-policy", &owner, &roots, date(ADMISSION), None).await, + Err(WitnessError::Ambiguous) + )); + assert_eq!(dry_run_backfill(&db, None).await.unwrap().ambiguous, 1); +} + +#[tokio::test] +async fn legacy_candidate_overflow_is_never_silently_narrowed() { + let db = database().await; + let (owner, roots, _, _) = legacy_policy(&db).await; + let resource = format!( + "tinycloud:key:{}:legacy/kv", + signer(1).1.trim_start_matches("did:key:") + ); + for index in 0..64 { + grant( + &db, + 1, + &owner, + &resource, + &[], + &format!("candidate-{index}"), + ) + .await; + } + assert!(matches!( + capture_in_transaction(&db, "legacy-policy", &owner, &roots, date(ADMISSION), None).await, + Err(WitnessError::Overflow) + )); + assert_eq!(dry_run_backfill(&db, None).await.unwrap().overflow, 1); +} + +#[tokio::test] +async fn unrelated_incoming_grants_do_not_exhaust_a_policy_witness_limit() { + let db = database().await; + let (owner, roots, direct, chained) = legacy_policy(&db).await; + let unrelated = format!( + "tinycloud:key:{}:unrelated/kv", + signer(3).1.trim_start_matches("did:key:") + ); + for index in 0..513 { + grant( + &db, + 3, + &owner, + &unrelated, + &[], + &format!("unrelated-{index}"), + ) + .await; + } + let witnesses = + capture_in_transaction(&db, "legacy-policy", &owner, &roots, date(ADMISSION), None) + .await + .unwrap(); + for witness in &witnesses { + assert_eq!(witness.candidates.len(), 2); + assert!(witness + .candidates + .iter() + .any(|closure| closure.tip_cid == direct)); + assert!(witness + .candidates + .iter() + .any(|closure| closure.tip_cid == chained)); + } + assert_eq!(dry_run_backfill(&db, None).await.unwrap().overflow, 0); +} diff --git a/tinycloud-node-server/Cargo.toml b/tinycloud-node-server/Cargo.toml index 67c23011..928e7ec2 100644 --- a/tinycloud-node-server/Cargo.toml +++ b/tinycloud-node-server/Cargo.toml @@ -110,7 +110,7 @@ path = "../tinycloud-auth/" [dev-dependencies.tinycloud-core] path = "../tinycloud-core/" default-features = false -features = ["sqlite", "postgres", "mysql", "tokio", "verification-count-test-hook"] +features = ["sqlite", "postgres", "mysql", "tokio", "verification-count-test-hook", "historical-invocation-test-clock"] [dev-dependencies] # Parse production Rust items in the DID resolver source guard (tests only). diff --git a/tinycloud-node-server/src/bin/policy-witness-dry-run.rs b/tinycloud-node-server/src/bin/policy-witness-dry-run.rs new file mode 100644 index 00000000..8e590715 --- /dev/null +++ b/tinycloud-node-server/src/bin/policy-witness-dry-run.rs @@ -0,0 +1,166 @@ +//! Read-only policy witness inventory. No migrations, alias backfills, or +//! signed artifacts are written; success output contains only four counts. +//! Run with the same TINYCLOUD_CONFIG_FILE / TINYCLOUD_* settings as the node. +use rocket::figment::{ + providers::{Env, Format, Serialized, Toml}, + Figment, +}; +use tinycloud::{config, resolve_keys}; +use tinycloud_core::{ + encryption::ColumnEncryption, + policy_witness::{dry_run_backfill, BackfillCounts}, + sea_orm::{ConnectOptions, ConnectionTrait, Database, DbBackend, Statement, TransactionTrait}, +}; + +fn config() -> Result { + let config_file = + std::env::var("TINYCLOUD_CONFIG_FILE").unwrap_or_else(|_| "tinycloud.toml".to_owned()); + let mut config = Figment::from(rocket::Config::default()) + .merge(Serialized::defaults(config::Config::default())) + .merge(Toml::file(config_file).nested()) + .merge(Env::prefixed("TINYCLOUD_").split("_").global()) + .merge(Env::prefixed("TINYCLOUD_").split("__").global()) + .merge(Env::prefixed("ROCKET_").global()) + .extract::() + .map_err(|_| ())?; + config.storage.resolve(); + Ok(config) +} + +fn readonly_url(url: &str) -> Result { + if url.starts_with("sqlite:") { + // A SQLite read-only open must not create a database (or be silently + // overridden by caller-supplied URI mode parameters). + if url.contains('?') || url.contains('#') || url.contains(":memory:") { + return Err(()); + } + Ok(format!("{url}?mode=ro")) + } else if url.starts_with("postgres://") + || url.starts_with("postgresql://") + || url.starts_with("mysql://") + { + Ok(url.to_owned()) + } else { + Err(()) + } +} + +async fn dry_run() -> Result { + let config = config()?; + tinycloud_core::did_resolution::configure( + tinycloud_core::did_resolution::DidResolutionPolicy { + network_methods: config.did_resolution.network_methods, + }, + ); + let key = resolve_keys(&config.keys).await.map_err(|_| ())?; + // Exactly the node's delegation-column context; no key material is logged. + let encryption = ColumnEncryption::new(key.derive_key(b"tinycloud/hooks/webhook-secrets")); + let database = readonly_url(config.storage.database())?; + let mut opts = ConnectOptions::new(database); + opts.max_connections(1) + .min_connections(1) + .sqlx_logging(false); + let conn = Database::connect(opts).await.map_err(|_| ())?; + // The SQLite connection was opened with mode=ro above. MySQL's session + // setting applies to BEGIN; verify the acquired transaction inherited it + // before issuing any inventory query, including after a pool reconnect. + let backend = conn.get_database_backend(); + match backend { + DbBackend::MySql => { + conn.execute_unprepared("SET SESSION TRANSACTION READ ONLY") + .await + .map_err(|_| ())?; + } + DbBackend::Sqlite => return scan_sqlite(&conn, &encryption).await, + DbBackend::Postgres => (), + } + let tx = conn.begin().await.map_err(|_| ())?; + match backend { + DbBackend::Postgres => { + tx.execute_unprepared("SET TRANSACTION READ ONLY") + .await + .map_err(|_| ())?; + } + DbBackend::MySql => { + let row = tx + .query_one(Statement::from_string( + backend, + "SELECT CAST(@@session.transaction_read_only AS SIGNED) AS read_only", + )) + .await + .map_err(|_| ())? + .ok_or(())?; + if row.try_get::("", "read_only").map_err(|_| ())? != 1 { + return Err(()); + } + } + DbBackend::Sqlite => return Err(()), + } + let counts = dry_run_backfill(&tx, Some(&encryption)) + .await + .map_err(|_| ())?; + tx.rollback().await.map_err(|_| ())?; + Ok(counts) +} + +async fn scan_sqlite( + conn: &tinycloud_core::sea_orm::DatabaseConnection, + encryption: &ColumnEncryption, +) -> Result { + dry_run_backfill(conn, Some(encryption)) + .await + .map_err(|_| ()) +} + +fn counts_json(counts: &BackfillCounts) -> String { + let unresolved = counts + .missing + .saturating_add(counts.ambiguous) + .saturating_add(counts.overflow); + let ok = counts.policies.saturating_sub(unresolved); + format!( + "{{\"ok\":{ok},\"missing\":{},\"ambiguous\":{},\"overflow\":{}}}", + counts.missing, counts.ambiguous, counts.overflow + ) +} + +#[tokio::main] +async fn main() { + match dry_run().await { + Ok(counts) => println!("{}", counts_json(&counts)), + Err(()) => { + eprintln!("policy witness dry-run unavailable"); + std::process::exit(1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sqlite_dry_run_forces_read_only_and_rejects_preexisting_modes() { + assert_eq!( + readonly_url("sqlite:./data/caps.db"), + Ok("sqlite:./data/caps.db?mode=ro".into()) + ); + assert!(readonly_url("sqlite:./data/caps.db?mode=rwc").is_err()); + assert!(readonly_url("sqlite::memory:").is_err()); + } + + #[test] + fn dry_run_output_contains_counts_only() { + let counts = BackfillCounts { + policies: 5, + witnesses: 10, + missing: 1, + ambiguous: 1, + overflow: 1, + }; + assert_eq!( + counts_json(&counts), + r#"{"ok":2,"missing":1,"ambiguous":1,"overflow":1}"# + ); + } +} diff --git a/tinycloud-node-server/src/key_revoke.rs b/tinycloud-node-server/src/key_revoke.rs new file mode 100644 index 00000000..9738cdcc --- /dev/null +++ b/tinycloud-node-server/src/key_revoke.rs @@ -0,0 +1,229 @@ +use rocket::{http::Status, serde::json::Json, State}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tinycloud_auth::{ + authorization::{HeaderEncode, TinyCloudDelegation}, + identity::{canonicalize_did, principal_did}, + resource::SpaceId, + siwe_recap::Capability as SiweRecap, + ssi::dids::DID, +}; +use tinycloud_core::{ + encryption_network::EncryptionService, + models::key_revocation::{KeyRevokeError, KeyRevokeReceipt, KeyRevokeScope, VerifiedKeyRevoke}, +}; + +use crate::TinyCloud; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct KeyRevokeRequest { + target_did: String, + scope: KeyRevokeScope, + authorization: String, +} + +const MALFORMED: (Status, &str) = (Status::BadRequest, "key_revocation_invalid_request"); +const UNAUTHENTICATED: (Status, &str) = (Status::Unauthorized, "key_revocation_invalid_signature"); + +/// Parse the signed grant without allowing the unsigned request body to +/// broaden its scope or replace the principal named by its capability. +async fn verify_request( + request: KeyRevokeRequest, + node_did: &str, +) -> Result { + let target_did = request.target_did; + if target_did.len() > 255 + || target_did + .bytes() + .any(|byte| matches!(byte, b'#' | b'?' | b'/')) + || DID::new(&target_did).is_err() + || canonicalize_did(&target_did).ok().as_deref() != Some(&target_did) + { + return Err(MALFORMED); + } + if let KeyRevokeScope::Space { space_id } = &request.scope { + if space_id.len() > 249 + || space_id + .parse::() + .map_or(true, |space| space.to_string() != *space_id) + { + return Err(MALFORMED); + } + } + if request.authorization.len() > 16_384 { + return Err(MALFORMED); + } + let (authorization, bytes) = + TinyCloudDelegation::decode(&request.authorization).map_err(|_| UNAUTHENTICATED)?; + let expected_resource = format!("urn:tinycloud:key:{target_did}"); + let expected_caveat = + json!({ "version": 1, "targetDid": &target_did, "scope": &request.scope }); + + let (issuer, audience, nonce, not_before, valid_after, expires_at, proof_cids) = + match authorization { + TinyCloudDelegation::Ucan(ucan) => { + if ucan.header().algorithm != tinycloud_auth::ssi::jwk::Algorithm::EdDSA + || !ucan.payload().issuer.as_str().starts_with("did:key:") + { + return Err(UNAUTHENTICATED); + } + tinycloud_core::did_resolution::verify_ucan_signature(&ucan) + .await + .map_err(|_| UNAUTHENTICATED)?; + let payload = ucan.payload(); + let attenuation = + serde_json::to_value(&payload.attenuation).map_err(|_| MALFORMED)?; + if attenuation + != json!({(expected_resource): {"tinycloud.key/revoke": [expected_caveat]}}) + { + return Err(MALFORMED); + } + let nbf = payload.not_before.ok_or(MALFORMED)?.as_seconds(); + let exp = payload.expiration.as_seconds(); + ( + payload.issuer.to_string(), + payload.audience.to_string(), + payload.nonce.clone().ok_or(MALFORMED)?, + signed_seconds(nbf)?, + None, + signed_seconds(exp)?, + payload + .proof + .iter() + .map(ToString::to_string) + .collect::>(), + ) + } + TinyCloudDelegation::Cacao(cacao) => { + cacao.verify().await.map_err(|_| UNAUTHENTICATED)?; + let payload = cacao.payload(); + if !payload.iss.to_string().starts_with("did:pkh:eip155:") { + return Err(UNAUTHENTICATED); + } + let message: tinycloud_auth::cacaos::siwe::Message = + payload.clone().try_into().map_err(|_| MALFORMED)?; + let recap = SiweRecap::::extract_and_verify(&message) + .map_err(|_| MALFORMED)? + .ok_or(MALFORMED)?; + let (capabilities, proofs) = recap.into_inner(); + let mut entries = capabilities.into_inner().into_iter(); + let (resource, abilities) = entries.next().ok_or(MALFORMED)?; + if entries.next().is_some() || resource.as_str() != expected_resource { + return Err(MALFORMED); + } + let mut actions = abilities.into_iter(); + let (action, caveats) = actions.next().ok_or(MALFORMED)?; + if actions.next().is_some() || action.to_string() != "tinycloud.key/revoke" { + return Err(MALFORMED); + } + let caveats = caveats.into_inner(); + if caveats.len() != 1 + || serde_json::to_value(&caveats[0]).map_err(|_| MALFORMED)? != expected_caveat + { + return Err(MALFORMED); + } + let issued = payload.iat.as_ref(); + let expiry = payload.exp.as_ref().ok_or(MALFORMED)?.as_ref(); + if expiry <= issued || *expiry - *issued > time::Duration::seconds(60) { + return Err(MALFORMED); + } + ( + payload.iss.to_string(), + payload.aud.to_string(), + message.nonce, + ceil_second(issued)?, + payload + .nbf + .as_ref() + .map(|value| ceil_second(value.as_ref())) + .transpose()?, + expiry.unix_timestamp(), + proofs.iter().map(ToString::to_string).collect::>(), + ) + } + }; + let issuer_did = principal_did(&issuer).map_err(|_| UNAUTHENTICATED)?; + if audience != node_did + || !(issuer_did.starts_with("did:key:") || issuer_did.starts_with("did:pkh:eip155:")) + { + return Err(UNAUTHENTICATED); + } + if nonce.is_empty() + || nonce.len() > 128 + || expires_at + .checked_sub(not_before) + .is_none_or(|seconds| !(1..=60).contains(&seconds)) + || proof_cids.len() > 64 + { + return Err(MALFORMED); + } + Ok(VerifiedKeyRevoke { + target_did, + issuer_did, + scope: request.scope, + nonce, + authorization_hash: Sha256::digest(bytes).into(), + proof_cids, + not_before, + valid_after, + expires_at, + }) +} + +fn signed_seconds(seconds: f64) -> Result { + if seconds.is_finite() + && seconds.fract() == 0.0 + && seconds >= i64::MIN as f64 + && seconds < i64::MAX as f64 + { + Ok(seconds as i64) + } else { + Err(MALFORMED) + } +} + +fn ceil_second(timestamp: &time::OffsetDateTime) -> Result { + let seconds = timestamp.unix_timestamp(); + if timestamp.nanosecond() == 0 { + Ok(seconds) + } else { + seconds.checked_add(1).ok_or(MALFORMED) + } +} + +#[post("/keys/revoke", format = "json", data = "")] +pub async fn revoke_key( + request: Json, + tinycloud: &State, + encryption: &State, +) -> Result, (Status, String)> { + let request: KeyRevokeRequest = serde_json::from_value(request.into_inner()) + .map_err(|_| (Status::BadRequest, MALFORMED.1.to_string()))?; + let verified = verify_request(request, encryption.node_did()) + .await + .map_err(|(status, message)| (status, message.to_string()))?; + let receipt = tinycloud + .revoke_key(verified) + .await + .map_err(|error| match error { + KeyRevokeError::Forbidden => (Status::Forbidden, "key_revocation_forbidden".into()), + KeyRevokeError::Conflict => (Status::Conflict, "key_revocation_conflict".into()), + KeyRevokeError::Db { + write_may_have_applied: false, + .. + } => ( + Status::ServiceUnavailable, + "unavailable_retryable: key_revocation_unavailable".into(), + ), + KeyRevokeError::Db { + write_may_have_applied: true, + .. + } => ( + Status::InternalServerError, + "outcome_unknown: key_revocation_unavailable".into(), + ), + })?; + Ok(Json(receipt)) +} diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index 886fbdff..96a7761b 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -32,6 +32,7 @@ pub mod dstack; pub mod egress; pub mod hooks; pub mod invocation_replay; +pub mod key_revoke; pub mod link; pub mod node_control; pub mod policy_v3; @@ -129,6 +130,7 @@ use routes::{ }, hooks::{create_hook_ticket, create_webhook, delete_webhook, hook_events, list_webhooks}, info, invoke, + key_revoke::revoke_key, node_keys::{node_keys, NodePublicKeys}, open_host_key, public::{public_kv_get, public_kv_head, public_kv_list, public_kv_options, RateLimiter}, @@ -452,6 +454,7 @@ fn production_routes() -> Vec { delegation_query, delegation_status, revoke, + revoke_key, create_signed_kv_url, signed_kv_get, create_hook_ticket, @@ -1151,7 +1154,7 @@ async fn run_retention_sweep( } } -pub(crate) async fn resolve_keys(keys: &Keys) -> Result { +pub async fn resolve_keys(keys: &Keys) -> Result { match keys { Keys::Static(s) => Ok(s.clone().try_into()?), #[cfg(feature = "dstack")] diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 24313081..bbf176ac 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -37,6 +37,7 @@ use tinycloud_core::{ policy_v3_challenge, policy_v3_registration, policy_v3_root, policy_v3_session, revocation, share_invitation_authorization_jti, space, }, + policy_witness, relationships::parent_delegations, sea_orm::{ sea_query::Expr, ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, @@ -213,6 +214,14 @@ impl PolicyV3Runtime { { return Err("policy-session-proof-order-invalid"); } + let spaces = event + .0 + .spaces() + .map(ToString::to_string) + .collect::>(); + policy_witness::check_revocations_for_spaces(&self.conn, ®istration.policy_cid, &spaces) + .await + .map_err(|_| "policy-authority-revoked")?; let Ok(enforcer_did) = self .authenticated_registration_enforcer(®istration, OffsetDateTime::now_utc()) .await @@ -311,13 +320,22 @@ impl PolicyV3Runtime { // Validate the whole chain up to the admitted S0, then compare the // child with its *immediate* parent (S0 for a first hop, an // earlier descendant for deeper hops). - if self - .validate_policy_chain(tinycloud, event.0.parents[0]) + let requested_spaces = event + .0 + .spaces() + .map(ToString::to_string) + .collect::>(); + let (session, _) = self + .validate_policy_chain(tinycloud, event.0.parents[0], &requested_spaces) .await - .is_err() - { - return Err("policy-session-parent-invalid"); - } + .map_err(|_| "policy-session-parent-invalid")?; + policy_witness::check_revocations_for_spaces( + &self.conn, + &session.policy_cid, + &requested_spaces, + ) + .await + .map_err(|_| "policy-authority-revoked")?; let Ok(Some((_, parent))) = tinycloud.load_signed_delegation(event.0.parents[0]).await else { return Err("policy-session-parent-invalid"); @@ -404,8 +422,12 @@ impl PolicyV3Runtime { if !capabilities_are_contained(&invocation.capabilities, &immediate.0.capabilities) { return Err("policy-invocation-capability-widening"); } + let requested_spaces = invocation + .spaces() + .map(ToString::to_string) + .collect::>(); let (row, session) = self - .validate_policy_chain(tinycloud, invocation.parents[0]) + .validate_policy_chain(tinycloud, invocation.parents[0], &requested_spaces) .await?; let payload = invocation.invocation.payload(); let invocation_expiry = @@ -445,6 +467,13 @@ impl PolicyV3Runtime { { return Err("policy-session-proof-index-mismatch"); } + policy_witness::check_revocations_for_spaces( + &self.conn, + ®istration.policy_cid, + &requested_spaces, + ) + .await + .map_err(|_| "policy-authority-revoked")?; let policy_root = policy_v3_root::Entity::find_by_id(registration.policy_root_cid.clone()) .one(&self.conn) .await @@ -554,6 +583,7 @@ impl PolicyV3Runtime { &self, tinycloud: &crate::TinyCloud, start: tinycloud_auth::ipld_core::cid::Cid, + requested_spaces: &[String], ) -> Result<(policy_v3_session::Model, Delegation), &'static str> { let mut current_cid = start; let mut child: Option = None; @@ -631,7 +661,7 @@ impl PolicyV3Runtime { { return Err("policy-session-proof-index-mismatch"); } - self.authorize_roots(®istration, OffsetDateTime::now_utc()) + self.authorize_roots(®istration, OffsetDateTime::now_utc(), requested_spaces) .await?; for root_cid in event.0.parents.iter().copied() { let Some((_, root)) = tinycloud @@ -1344,6 +1374,7 @@ pub async fn authorize_delivery( request: Json, runtime: &State, invocation: AuthHeaderGetter, + tinycloud: &State, ) -> Result, (Status, String)> { let delivery = runtime .delivery @@ -1438,6 +1469,18 @@ pub async fn authorize_delivery( .and_then(|value| value.get("kvResource")) .and_then(Value::as_str) .ok_or((Status::Forbidden, "delivery-authorization-invalid".into()))?; + let space = resource + .parse::() + .ok() + .and_then(|resource| resource.space().map(ToString::to_string)) + .ok_or((Status::Forbidden, "delivery-authorization-invalid".into()))?; + policy_witness::check_revocations(&runtime.conn, policy_cid, None) + .await + .map_err(|_| (Status::Forbidden, "delivery-authorization-invalid".into()))?; + tinycloud + .ticket_authority_unrevoked(&invocation.0 .0.parents, sender_key_did, &[space]) + .await + .map_err(|_| (Status::Forbidden, "delivery-authorization-invalid".into()))?; let policy = envelope .get("policy") .and_then(Value::as_object) @@ -1964,6 +2007,31 @@ pub async fn register_policy( .await .map_err(|e| (Status::Conflict, e.to_string()))?; } + let witnesses = policy_witness::capture_in_transaction( + &txn, + &request.policy_cid, + owner_did, + &[policy_root_cid.clone(), enforcement_root_cid.clone()], + now, + tinycloud.column_encryption(), + ) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-witness-invalid".into()))?; + policy_witness::persist_in_transaction(&txn, &witnesses) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-witness-invalid".into()))?; + let dependencies = policy_witness::revocation_dependencies(&txn, &request.policy_cid) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-witness-invalid".into()))?; + tinycloud_core::key_authority::lock_signed_dependencies(&txn, &dependencies.signed_cids) + .await + .map_err(db_error)?; + tinycloud_core::key_authority::lock_targets(&txn, &dependencies.principals, false) + .await + .map_err(db_error)?; + policy_witness::check_revocations(&txn, &request.policy_cid, tinycloud.column_encryption()) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-revoked".into()))?; txn.commit().await.map_err(db_error)?; Ok(Json(RegisterResponse { policy_cid: request.policy_cid, @@ -1984,10 +2052,6 @@ pub async fn challenge( .map_err(db_error)? .ok_or((Status::NotFound, "policy-registration-missing".into()))?; let policy = validate_registration_projection(®istration)?; - runtime - .authorize_roots(®istration, OffsetDateTime::now_utc()) - .await - .map_err(|error| (Status::Forbidden, error.into()))?; request .recipient_did .parse::() @@ -1999,6 +2063,14 @@ pub async fn challenge( .and_then(Value::as_array) .ok_or((Status::Forbidden, "policy-registration-corrupt".into()))?, )?; + runtime + .authorize_roots( + ®istration, + OffsetDateTime::now_utc(), + &requested_capability_spaces(&requested_capabilities), + ) + .await + .map_err(|error| (Status::Forbidden, error.into()))?; let mut nonce_bytes = [0u8; 32]; rand::rngs::OsRng.fill_bytes(&mut nonce_bytes); let nonce = base64::encode_config(nonce_bytes, URL_SAFE_NO_PAD); @@ -2109,8 +2181,13 @@ pub async fn mint( )?; let enforcer_did = fact(&enforcement_root.0.delegation, "enforcerDid") .ok_or((Status::Forbidden, "sibling-root-mismatch".into()))?; + let requested_spaces = challenge + .requested_capabilities + .as_array() + .map(|capabilities| requested_capability_spaces(capabilities)) + .unwrap_or_default(); runtime - .authorize_roots(®istration, now) + .authorize_roots(®istration, now, &requested_spaces) .await .map_err(|e| (Status::Forbidden, e.into()))?; // Policies registered before owners had to hold what they grant are @@ -2455,6 +2532,28 @@ pub async fn mint( tinycloud_core::kv_sync::lock_space_sequences(&txn, &sequence_spaces) .await .map_err(db_error)?; + let dependencies = policy_witness::revocation_dependencies(&txn, ®istration.policy_cid) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-witness-invalid".into()))?; + tinycloud_core::key_authority::lock_signed_dependencies(&txn, &dependencies.signed_cids) + .await + .map_err(db_error)?; + let mut mint_principals = dependencies.principals.clone(); + mint_principals.push(session.delegate.clone()); + tinycloud_core::key_authority::lock_targets(&txn, &mint_principals, false) + .await + .map_err(db_error)?; + let checked_spaces = if requested_spaces.is_empty() { + &dependencies.spaces + } else { + &requested_spaces + }; + tinycloud_core::key_authority::check_principals(&txn, &mint_principals, checked_spaces) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-revoked".into()))?; + policy_witness::check_signed_cids(&txn, &dependencies) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-revoked".into()))?; if let Some(proof) = account_owner_proof.as_ref() { validate_locked_account_owner(&txn, proof).await?; } @@ -2698,6 +2797,7 @@ impl PolicyV3Runtime { &self, registration: &policy_v3_registration::Model, now: OffsetDateTime, + requested_spaces: &[String], ) -> Result<(), &'static str> { let policy = validate_registration_projection(registration) .map_err(|_| "policy-registration-projection-mismatch")?; @@ -2751,6 +2851,13 @@ impl PolicyV3Runtime { now, ) .map_err(|_| "enforcer-binding-invalid")?; + policy_witness::check_revocations_for_spaces( + &self.conn, + ®istration.policy_cid, + requested_spaces, + ) + .await + .map_err(|_| "policy-authority-revoked")?; Ok(()) } } @@ -3388,6 +3495,9 @@ pub async fn status( { return Err((Status::Conflict, "root-revoked".into())); } + policy_witness::check_revocations(&runtime.conn, &root.policy_cid, None) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-revoked".into()))?; validate_persisted_root( &root, &request.root_cid, @@ -3491,6 +3601,11 @@ pub async fn get_status( let generic_revocation = is_root_generically_revoked(&runtime.conn, root_cid) .await .map_err(|error| (Status::ServiceUnavailable, error.into()))?; + if root.revoked_at.is_none() && !generic_revocation { + policy_witness::check_revocations(&runtime.conn, &root.policy_cid, None) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-revoked".into()))?; + } if root.revoked_at.is_none() { validate_stored_root_status( &root, @@ -3585,6 +3700,9 @@ pub async fn revoke_root( &runtime.node_did, OffsetDateTime::now_utc(), )?; + policy_witness::check_revocations(&runtime.conn, &root.policy_cid, None) + .await + .map_err(|_| (Status::Forbidden, "policy-authority-revoked".into()))?; let previous = root .status_checkpoint_bytes .as_deref() @@ -4717,6 +4835,30 @@ fn validate_requested_policy_capabilities( Ok(approved) } +/// Legacy logical KV identifiers and encryption capabilities do not establish +/// a canonical SpaceId. An empty result deliberately checks every witness +/// scope rather than treating an unrecognized scope as unrelated. +fn requested_capability_spaces(capabilities: &[Value]) -> Vec { + let mut spaces = Vec::with_capacity(capabilities.len()); + for capability in capabilities { + let Some(resource) = capability.get("resource").and_then(Value::as_str) else { + return Vec::new(); + }; + if capability.get("kind").and_then(Value::as_str) != Some("kv") + || resource.starts_with("tinycloud://") + { + return Vec::new(); + } + let Ok(resource) = resource.parse::() else { + return Vec::new(); + }; + spaces.push(resource.space().to_string()); + } + spaces.sort_unstable(); + spaces.dedup(); + spaces +} + fn attenuation_for_policy_capabilities(capabilities: &[Value]) -> Result { let mut attenuation = serde_json::Map::new(); for capability in capabilities { @@ -7948,38 +8090,97 @@ mod tests { )); } - #[tokio::test] - async fn policy_v2_admits_v3_account_and_v4_accountless_receivers() -> anyhow::Result<()> { - run_policy_v2_flow(PolicyOwner::SpaceOwner).await + #[test] + fn policy_v2_admits_v3_account_and_v4_accountless_receivers() -> anyhow::Result<()> { + run_policy_v2_flow_on_large_stack(PolicyOwner::SpaceOwner) + } + + #[test] + fn revoking_policy_authority_root_denies_admitted_sessions() -> anyhow::Result<()> { + run_policy_v2_flow_on_large_stack(PolicyOwner::SpaceOwnerPolicyRoot) + } + + #[test] + fn signed_policy_root_revoke_closes_sessions_and_replays_idempotently() -> anyhow::Result<()> { + run_policy_v2_flow_with_root_route(PolicyOwner::SpaceOwnerPolicyRoot, true) + } + + #[test] + fn signed_enforcement_root_revoke_preserves_witness_on_cold_open() -> anyhow::Result<()> { + run_policy_v2_flow_with_root_route(PolicyOwner::SpaceOwner, true) } /// The real Share and CLI shape: the policy owner is a session key the /// space owner delegated to. It registers, mints and reads; once the /// space owner revokes that delegation, no new session can be minted. - #[tokio::test] - async fn policy_owner_may_be_a_session_the_space_owner_delegated_to() -> anyhow::Result<()> { - run_policy_v2_flow(PolicyOwner::DelegatedSession).await + #[test] + fn policy_owner_may_be_a_session_the_space_owner_delegated_to() -> anyhow::Result<()> { + run_policy_v2_flow_on_large_stack(PolicyOwner::DelegatedSession) + } + + #[test] + fn registrar_key_tombstone_closes_registered_policy_side_routes() -> anyhow::Result<()> { + run_policy_v2_flow_on_large_stack(PolicyOwner::RegistrarKeyCutoff) + } + + #[test] + fn historical_owner_grant_signer_tombstone_closes_policy_side_routes() -> anyhow::Result<()> { + run_policy_v2_flow_on_large_stack(PolicyOwner::AncestorKeyCutoff) } /// The policy owner signs a valid, self-consistent policy over content in /// a space it holds no authority in (the victim hosts and seeds it). /// Registration must refuse it, so no session can ever read the bytes. - #[tokio::test] - async fn policy_registration_requires_the_owner_to_hold_what_it_grants() -> anyhow::Result<()> { - run_policy_v2_flow(PolicyOwner::Unauthorized).await + #[test] + fn policy_registration_requires_the_owner_to_hold_what_it_grants() -> anyhow::Result<()> { + run_policy_v2_flow_on_large_stack(PolicyOwner::Unauthorized) } #[derive(Clone, Copy, PartialEq, Eq)] enum PolicyOwner { /// The policy owner is the content space's owner. SpaceOwner, + /// A second owner-owned policy revoking the policy-authority root. + SpaceOwnerPolicyRoot, /// The policy owner is a session key the space owner delegated to. DelegatedSession, + /// The registered policy owner self-revokes its key, not either root CID. + RegistrarKeyCutoff, + /// The signer of the owner's historical grant is distinct from the root signer. + AncestorKeyCutoff, /// The policy owner holds nothing in the content space. Unauthorized, } - async fn run_policy_v2_flow(policy_owner: PolicyOwner) -> anyhow::Result<()> { + // This existing end-to-end fixture now exercises get and put across + // several policy descendants; its async frame exceeds the default test + // thread stack. Keep the test self-contained on CI rather than requiring + // RUST_MIN_STACK in the runner environment. + fn run_policy_v2_flow_on_large_stack(policy_owner: PolicyOwner) -> anyhow::Result<()> { + run_policy_v2_flow_with_root_route(policy_owner, false) + } + + fn run_policy_v2_flow_with_root_route( + policy_owner: PolicyOwner, + signed_root_revoke: bool, + ) -> anyhow::Result<()> { + std::thread::Builder::new() + .stack_size(16 << 20) + .spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("policy fixture runtime") + .block_on(run_policy_v2_flow(policy_owner, signed_root_revoke)) + })? + .join() + .expect("policy fixture thread panicked") + } + + async fn run_policy_v2_flow( + policy_owner: PolicyOwner, + signed_root_revoke: bool, + ) -> anyhow::Result<()> { use k256::ecdsa::SigningKey; use sha3::Keccak256; use tinycloud_auth::cacaos::siwe::encode_eip55; @@ -8016,7 +8217,11 @@ mod tests { SpaceId::new(account_owner_did.parse::()?, "credentials".parse()?); let victim_key = tinycloud_core::libp2p::identity::ed25519::Keypair::generate(); let victim_did = did(&victim_key); - let content_owner_did = if policy_owner != PolicyOwner::SpaceOwner { + let space_owner = matches!( + policy_owner, + PolicyOwner::SpaceOwner | PolicyOwner::SpaceOwnerPolicyRoot + ); + let content_owner_did = if !space_owner { &victim_did } else { &owner_did @@ -8052,19 +8257,37 @@ mod tests { let now = OffsetDateTime::now_utc().replace_nanosecond(0).unwrap(); let issued = now - Duration::seconds(5); let expires = now + Duration::seconds(600); + let requested_actions = if policy_owner != PolicyOwner::Unauthorized { + vec!["tinycloud.kv/get", "tinycloud.kv/put"] + } else { + vec!["tinycloud.kv/get"] + }; + let ceiling_actions = if policy_owner != PolicyOwner::Unauthorized { + vec![ + "tinycloud.kv/get", + "tinycloud.kv/metadata", + "tinycloud.kv/put", + ] + } else { + vec!["tinycloud.kv/get", "tinycloud.kv/metadata"] + }; let requested = vec![json!({ "kind": "kv", "resource": content_resource.to_string(), "selector": "exact", - "actions": ["tinycloud.kv/get"] + "actions": requested_actions })]; + let mut read_capabilities = requested.clone(); + read_capabilities[0]["actions"] = json!(["tinycloud.kv/get"]); + let mut write_capabilities = requested.clone(); + write_capabilities[0]["actions"] = json!(["tinycloud.kv/put"]); let encryption_resource = format!("urn:tinycloud:encryption:{content_owner_did}:mainnet"); let ceiling = vec![ json!({ "kind": "kv", "resource": content_resource.to_string(), "selector": "exact", - "actions": ["tinycloud.kv/get", "tinycloud.kv/metadata"] + "actions": ceiling_actions }), json!({ "kind": "encryption", @@ -8239,7 +8462,15 @@ mod tests { issued + Duration::seconds(301), ) .is_err()); - let db = Database::connect("sqlite::memory:").await.unwrap(); + let mut db = Database::connect("sqlite::memory:").await.unwrap(); + // The measured-head fixture executes 68 SQL statements for a policy + // descendant KV read and 23 for a signed GET. A signed-root cutoff + // must reuse its already-loaded root rows, not add read-path I/O. + let query_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = std::sync::Arc::clone(&query_count); + db.set_metric_callback(move |_| { + counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + }); let delivery_config = ShareEmailConfig { enabled: true, target_origin: "https://node.example".into(), @@ -8439,6 +8670,10 @@ mod tests { crate::routes::invoke, crate::routes::revoke, crate::routes::create_signed_kv_url, + crate::key_revoke::revoke_key, + status, + revoke_root, + get_status, crate::routes::signed_kv_get, crate::routes::hooks::create_hook_ticket ], @@ -8470,7 +8705,7 @@ mod tests { // never carries authority for this resource. // The content space's owner hosts and seeds it through the ordinary // graph: the policy owner itself, or a victim when the content is foreign. - let content_owner_key = if policy_owner != PolicyOwner::SpaceOwner { + let content_owner_key = if !space_owner { &victim_key } else { &owner_key @@ -8537,9 +8772,18 @@ mod tests { // A session the space owner delegated to may own a policy over what // that delegation covers, as the Share and CLI owners do. - let owner_delegation_cid = if policy_owner == PolicyOwner::DelegatedSession { + let owner_delegation_cid = if matches!( + policy_owner, + PolicyOwner::DelegatedSession + | PolicyOwner::RegistrarKeyCutoff + | PolicyOwner::AncestorKeyCutoff + ) { let mut owner_capabilities = Capabilities::::new(); - for ability in ["tinycloud.kv/get", "tinycloud.kv/metadata"] { + for ability in [ + "tinycloud.kv/get", + "tinycloud.kv/metadata", + "tinycloud.kv/put", + ] { owner_capabilities.with_action( content_resource.as_uri(), ability.parse::()?, @@ -8849,6 +9093,377 @@ mod tests { delivery_receipt["admission"]["audience"], "https://witness.credentials.org" ); + if matches!( + policy_owner, + PolicyOwner::RegistrarKeyCutoff | PolicyOwner::AncestorKeyCutoff + ) { + // These are real registered sibling roots. Neither root CID is + // directly revoked: the tombstone targets their signer or the + // separate signer of the owner's historical authority grant. + let policy_root_cid = registered["policyRootCid"].as_str().unwrap(); + let status_path = format!("/policy/v3/status/{policy_root_cid}"); + let before_status = client.get(&status_path).dispatch().await; + assert_eq!(before_status.status(), Status::Ok); + let before_status: Value = before_status.into_json().await.unwrap(); + assert_eq!(before_status["state"], "active"); + let previous = + checkpoint_predecessor_digest(&canonical_json_value(&before_status["checkpoint"])) + .expect("signed initial checkpoint"); + let sign_renewal = |sequence: i64, previous: &str, nonce: &str| { + let mut renewal = json!({ + "schema": "xyz.tinycloud.policy/root-status-renewal/v1", + "targetCid": policy_root_cid, + "ownerDid": owner_did, + "nodeAudience": node_did, + "issuedAt": format_time(OffsetDateTime::now_utc() - Duration::seconds(1)), + "nonce": nonce, + "sequence": sequence, + "previousCheckpointDigestHex": previous, + }); + let mut preimage = b"xyz.tinycloud.policy/RootStatusRenewal/v1\0".to_vec(); + preimage.extend_from_slice(&canonical_json_value(&renewal)); + renewal["signature"] = json!({ + "suite": "Ed25519", + "signerDid": owner_did, + "value": encode_config(owner_key.sign(&Sha256::digest(preimage)), URL_SAFE_NO_PAD), + }); + renewal + }; + let renewal = sign_renewal(2, &previous, "tc1149-status-before-key-cutoff"); + let renewed = client + .post("/policy/v3/status") + .header(ContentType::JSON) + .body(json!({"rootCid": policy_root_cid, "renewal": renewal}).to_string()) + .dispatch() + .await; + let renewal_status = renewed.status(); + let renewal_body = renewed.into_string().await.unwrap_or_default(); + assert_eq!(renewal_status, Status::Ok, "live renewal: {renewal_body}"); + let latest_status = client.get(&status_path).dispatch().await; + assert_eq!(latest_status.status(), Status::Ok); + let latest_status: Value = latest_status.into_json().await.unwrap(); + assert_eq!(latest_status["state"], "active"); + let latest_previous = + checkpoint_predecessor_digest(&canonical_json_value(&latest_status["checkpoint"])) + .expect("signed renewed checkpoint"); + let after_renewal = sign_renewal(3, &latest_previous, "tc1149-status-after-key-cutoff"); + let challenge_body = json!({ + "policyCid": policy_cid, + "recipientDid": holder_did, + "requestedCapabilities": requested, + }) + .to_string(); + let live_challenge = client + .post("/policy/v3/challenges") + .header(ContentType::JSON) + .body(challenge_body.clone()) + .dispatch() + .await; + assert_eq!(live_challenge.status(), Status::Ok); + assert!(live_challenge.into_json::().await.unwrap()["challengeId"].is_string()); + + // The owner grant is already registered and the content is seeded: + // exercise signed URLs before either its signer or its holder key + // is cut off, independently of the later policy-session mint. + let owner_grant_cid: tinycloud_auth::ipld_core::cid::Cid = owner_delegation_cid + .as_deref() + .expect("key-cutoff owner has a registered content grant") + .parse()?; + let signed_request = json!({ + "space": content_space.to_string(), + "path": "shares/tc-470/document.txt", + }) + .to_string(); + let owner_signed_invocation = |nonce: &str| -> anyhow::Result { + Ok(make_invocation( + [( + content_resource.clone(), + ["tinycloud.kv/get".parse::()?], + )], + &owner_grant_cid, + &owner_jwk, + &owner_vm, + (OffsetDateTime::now_utc() + Duration::seconds(30)).unix_timestamp() as f64, + InvocationOptions { + nonce: Some(nonce.into()), + ..InvocationOptions::default() + }, + )? + .encode()?) + }; + let live_signed = client + .post("/signed/kv") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + owner_signed_invocation("tc1149-key-cutoff-signed-before")?, + )) + .body(signed_request.clone()) + .dispatch() + .await; + let live_status = live_signed.status(); + let live_body = live_signed.into_string().await.unwrap_or_default(); + assert_eq!( + live_status, + Status::Ok, + "live signed URL before key revoke: {live_body}" + ); + let signed_url = serde_json::from_str::(&live_body)?["url"] + .as_str() + .expect("signed URL") + .to_owned(); + let live_read = client.get(&signed_url).dispatch().await; + assert_eq!(live_read.status(), Status::Ok); + let etag = live_read + .headers() + .get_one("ETag") + .expect("live signed GET ETag") + .to_owned(); + assert_eq!( + live_read.into_bytes().await.unwrap_or_default(), + b"tc-470-real-content" + ); + for (label, header, expected) in [ + ( + "range", + rocket::http::Header::new("Range", "bytes=0-1"), + Status::PartialContent, + ), + ( + "304", + rocket::http::Header::new("If-None-Match", etag.clone()), + Status::NotModified, + ), + ] { + let response = client.get(&signed_url).header(header).dispatch().await; + assert_eq!( + response.status(), + expected, + "live {label} signed GET before key revoke" + ); + } + + let (target_did, target_vm, target_jwk) = + if policy_owner == PolicyOwner::RegistrarKeyCutoff { + (&owner_did, &owner_vm, &owner_jwk) + } else { + (content_owner_did, &content_owner_vm, &content_owner_jwk) + }; + assert_ne!( + owner_did.as_str(), + content_owner_did.as_str(), + "historical grant has a distinct signer" + ); + let scope = json!({"kind": "global"}); + let mut revoke_caps = Capabilities::::new(); + revoke_caps.with_action( + format!("urn:tinycloud:key:{target_did}").parse()?, + "tinycloud.key/revoke".parse::()?, + [serde_json::from_value(json!({ + "version": 1, + "targetDid": target_did, + "scope": scope, + }))?], + ); + let key_revoke = TinyCloudDelegation::Ucan(Box::new( + Payload { + issuer: target_vm.parse()?, + audience: node_did.parse()?, + not_before: Some(NumericDate::try_from_seconds( + (OffsetDateTime::now_utc() - Duration::seconds(1)).unix_timestamp() as f64, + )?), + expiration: NumericDate::try_from_seconds( + (OffsetDateTime::now_utc() + Duration::seconds(45)).unix_timestamp() as f64, + )?, + nonce: Some(format!("tc1149-policy-{target_did}-key-cutoff")), + facts: Some(Vec::new()), + proof: Vec::new(), + attenuation: revoke_caps, + } + .sign(Algorithm::EdDSA, target_jwk)?, + )) + .encode()?; + let revoked = client + .post("/keys/revoke") + .header(ContentType::JSON) + .body( + json!({ + "targetDid": target_did, + "scope": scope, + "authorization": key_revoke, + }) + .to_string(), + ) + .dispatch() + .await; + let revoked_status = revoked.status(); + let revoked_body = revoked.into_string().await.unwrap_or_default(); + assert_eq!( + revoked_status, + Status::Ok, + "signed key tombstone: {revoked_body}" + ); + assert_eq!( + serde_json::from_str::(&revoked_body)?["revoked"], + true + ); + let old_ticket = client.get(&signed_url).dispatch().await; + assert_eq!( + old_ticket.status(), + Status::Forbidden, + "pre-revoke signed URL after owner or ancestor key cutoff" + ); + for (label, header) in [ + ("range", rocket::http::Header::new("Range", "bytes=0-1")), + ("304", rocket::http::Header::new("If-None-Match", etag)), + ] { + let response = client.get(&signed_url).header(header).dispatch().await; + assert_eq!( + response.status(), + Status::Forbidden, + "{label} signed GET after key revoke" + ); + } + let fresh_signed = client + .post("/signed/kv") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + owner_signed_invocation("tc1149-key-cutoff-fresh-signed-after")?, + )) + .body(signed_request) + .dispatch() + .await; + let status = fresh_signed.status(); + let body = fresh_signed.into_string().await.unwrap_or_default(); + assert_eq!( + status, + Status::Unauthorized, + "fresh signed URL after key revoke: {body}" + ); + for root_field in ["policyRootCid", "enforcementRootCid"] { + let cid = registered[root_field] + .as_str() + .unwrap() + .parse::()?; + assert!( + !revocation::is_revoked(&runtime.conn, &tinycloud_core::hash::Hash::from(cid)) + .await? + ); + } + let denied_challenge = client + .post("/policy/v3/challenges") + .header(ContentType::JSON) + .body(challenge_body) + .dispatch() + .await; + assert_eq!( + denied_challenge.status(), + Status::Forbidden, + "fresh challenge after key tombstone" + ); + let denied_status = client.get(&status_path).dispatch().await; + assert_eq!( + denied_status.status(), + Status::Forbidden, + "status GET after key tombstone" + ); + let denied_renewal = client + .post("/policy/v3/status") + .header(ContentType::JSON) + .body(json!({"rootCid": policy_root_cid, "renewal": after_renewal}).to_string()) + .dispatch() + .await; + assert_eq!( + denied_renewal.status(), + Status::Forbidden, + "renewal after key tombstone" + ); + + if policy_owner == PolicyOwner::RegistrarKeyCutoff { + let mut root_revocation = json!({ + "schema": ROOT_REVOCATION_V1_SCHEMA, + "targetCid": policy_root_cid, + "targetRole": "policy-authority", + "ownerDid": owner_did, + "nodeAudience": node_did, + "revokedAt": format_time(OffsetDateTime::now_utc()), + "reason": "owner-requested", + "issuerDid": owner_did, + }); + let mut preimage = b"xyz.tinycloud.policy/RootRevocation/v1\0".to_vec(); + preimage.extend_from_slice(&canonical_json_value(&root_revocation)); + root_revocation["signature"] = json!({ + "suite": "Ed25519", + "signerDid": owner_did, + "value": encode_config(owner_key.sign(&Sha256::digest(preimage)), URL_SAFE_NO_PAD), + }); + validate_root_revocation( + &root_revocation, + policy_root_cid, + "policy-authority", + &owner_did, + Some(&enforcer_did), + &node_did, + OffsetDateTime::now_utc(), + ) + .map_err(|(_, error)| anyhow::anyhow!("valid signed root revocation: {error}"))?; + let fresh_revoke = client + .post("/revoke") + .header(ContentType::JSON) + .body(json!({"revocation": root_revocation}).to_string()) + .dispatch() + .await; + let revoke_status = fresh_revoke.status(); + let revoke_body = fresh_revoke.into_string().await.unwrap_or_default(); + assert_eq!( + revoke_status, + Status::Forbidden, + "fresh signed root revoke after owner key cutoff: {revoke_body}" + ); + assert_eq!(revoke_body, "policy-authority-revoked"); + } + + if policy_owner == PolicyOwner::RegistrarKeyCutoff { + // New request/JTI and a still-live ordinary sender grant: + // denial must come from policy owner/root revocation, not + // invocation replay or an unrelated revoked content owner. + let mut fresh_delivery = delivery_request.clone(); + fresh_delivery.jti = encode_config([9_u8; 16], URL_SAFE_NO_PAD); + fresh_delivery.request_body_digest = delivery_request_digest(&fresh_delivery) + .map_err(|_| anyhow::anyhow!("fresh delivery digest"))?; + let fresh_invocation = make_invocation( + [( + content_resource.clone(), + ["tinycloud.kv/get".parse::()?], + )], + &sender_cid, + &delivery_jwk, + &format!("{holder_did}#{}", holder_did.trim_start_matches("did:key:")), + (OffsetDateTime::now_utc() + Duration::seconds(45)).unix_timestamp() as f64, + InvocationOptions { + nonce: Some("tc1149-delivery-after-owner-key-cutoff".into()), + ..InvocationOptions::default() + }, + )?; + let denied_delivery = client + .post("/policy/v3/deliveries/authorize") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + fresh_invocation.encode()?, + )) + .body(serde_json::to_string(&fresh_delivery)?) + .dispatch() + .await; + assert_eq!( + denied_delivery.status(), + Status::Forbidden, + "delivery after owner key tombstone" + ); + } + return Ok(()); + } // Model the cross-service partial failure where Node authorized the // delivery but the OpenCredentials response was lost. The SDK retries @@ -9152,7 +9767,7 @@ mod tests { facts: Some(Vec::::new()), proof: vec![child_cid], attenuation: serde_json::from_value::>( - attenuation_for_policy_capabilities(&requested) + attenuation_for_policy_capabilities(&read_capabilities) .map_err(|(_, error)| anyhow::anyhow!(error))?, )?, } @@ -9228,10 +9843,9 @@ mod tests { InvocationOptions::default(), )?; let owner_response = hooks_ticket(owner_hooks.encode()?).await; - assert_eq!(owner_response.status(), Status::Forbidden); - assert_eq!( - owner_response.into_string().await.unwrap_or_default(), - "policy-root-cannot-authorize-invocation" + assert!( + [Status::Unauthorized, Status::Forbidden].contains(&owner_response.status()), + "a policy root cannot authorize a hook ticket" ); // The accountless v4 branch reuses the issuer credential and the @@ -9389,7 +10003,7 @@ mod tests { facts: Some(Vec::::new()), proof: vec![v4_s0.content_hash().to_cid(0x55)], attenuation: serde_json::from_value::>( - attenuation_for_policy_capabilities(&requested) + attenuation_for_policy_capabilities(&read_capabilities) .map_err(|(_, error)| anyhow::anyhow!(error))?, )?, } @@ -9456,7 +10070,8 @@ mod tests { let reader_b_did = tinycloud_auth::resolver::DID_METHODS .generate(&reader_b_jwk, "key")? .to_string(); - let mut chain_tip = v4_s0; + let mut chain_tip = + decode_delegation(v4_authorization).map_err(|(_, error)| anyhow::anyhow!(error))?; for (issuer_jwk, issuer_did, audience_did, nonce) in [ (&holder_jwk, &holder_did, &reader_a_did, "tc529-child"), ( @@ -9499,13 +10114,14 @@ mod tests { facts: Some(Vec::::new()), proof: vec![grandchild_cid], attenuation: serde_json::from_value::>( - attenuation_for_policy_capabilities(&requested) + attenuation_for_policy_capabilities(&read_capabilities) .map_err(|(_, error)| anyhow::anyhow!(error))?, )?, } .sign(Algorithm::EdDSA, &reader_b_jwk)? .encode()?) }; + let kv_queries_before = query_count.load(std::sync::atomic::Ordering::Relaxed); let grandchild_read = client .post("/invoke") .header(rocket::http::Header::new( @@ -9519,6 +10135,11 @@ mod tests { grandchild_read.into_bytes().await.unwrap_or_default(), b"tc-470-real-content" ); + assert_eq!( + query_count.load(std::sync::atomic::Ordering::Relaxed) - kv_queries_before, + 68, + "ordinary KV read gained SQL round trips against the measured head" + ); // /signed/kv runs the same policy gate as /invoke. A policy // invocation within the 60-second cap mints a working URL; a longer @@ -9545,12 +10166,18 @@ mod tests { .as_str() .unwrap() .to_owned(); - let signed_read = client.get(signed_url).dispatch().await; + let signed_queries_before = query_count.load(std::sync::atomic::Ordering::Relaxed); + let signed_read = client.get(signed_url.clone()).dispatch().await; assert_eq!(signed_read.status(), Status::Ok); assert_eq!( signed_read.into_bytes().await.unwrap_or_default(), b"tc-470-real-content" ); + assert_eq!( + query_count.load(std::sync::atomic::Ordering::Relaxed) - signed_queries_before, + 23, + "signed GET gained SQL round trips against the measured head" + ); let long_signed = client .post("/signed/kv") .header(ContentType::JSON) @@ -9558,7 +10185,7 @@ mod tests { "Authorization", reader_b_invocation(90, "tc529-grandchild-long-signed-url")?, )) - .body(signed_request) + .body(signed_request.clone()) .dispatch() .await; assert_eq!(long_signed.status(), Status::Forbidden); @@ -9590,6 +10217,125 @@ mod tests { .await; assert_eq!(replay.status(), Status::Unauthorized); + let session_invocation = |key: &JWK, + principal: &str, + parent: tinycloud_auth::ipld_core::cid::Cid, + allowed: &[Value], + nonce: &str| + -> anyhow::Result { + let now = OffsetDateTime::now_utc(); + let vm = format!("{principal}#{}", principal.trim_start_matches("did:key:")); + Ok(Payload { + issuer: vm.parse()?, + audience: principal.parse()?, + not_before: Some(NumericDate::try_from_seconds(now.unix_timestamp() as f64)?), + expiration: NumericDate::try_from_seconds( + (now + Duration::seconds(30)).unix_timestamp() as f64, + )?, + nonce: Some(nonce.into()), + facts: Some(Vec::::new()), + proof: vec![parent], + attenuation: serde_json::from_value::>( + attenuation_for_policy_capabilities(allowed) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + } + .sign(Algorithm::EdDSA, key)? + .encode()?) + }; + let s0_cid = v4_s0.content_hash().to_cid(0x55); + for (key, principal, parent, nonce, content) in [ + ( + &holder_jwk, + holder_did.as_str(), + s0_cid, + "tc1149-s0-write-before", + "s0-before", + ), + ( + &reader_b_jwk, + reader_b_did.as_str(), + grandchild_cid, + "tc1149-descendant-write-before", + "descendant-before", + ), + ] { + let write = client + .post("/invoke") + .header(rocket::http::Header::new( + "Authorization", + session_invocation(key, principal, parent, &write_capabilities, nonce)?, + )) + .header(ContentType::Plain) + .body(content) + .dispatch() + .await; + let status = write.status(); + let body = write.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Ok, "pre-revoke {nonce}: {body}"); + } + let owner_read = |nonce: &str| -> anyhow::Result { + Ok(make_invocation( + [( + content_resource.clone(), + ["tinycloud.kv/get".parse::()?], + )], + &sender_cid, + &holder_jwk, + &holder_vm, + (OffsetDateTime::now_utc() + Duration::seconds(30)).unix_timestamp() as f64, + InvocationOptions { + nonce: Some(nonce.into()), + ..InvocationOptions::default() + }, + )? + .encode()?) + }; + let before = client + .post("/invoke") + .header(rocket::http::Header::new( + "Authorization", + owner_read("tc1149-owner-read-before")?, + )) + .dispatch() + .await; + assert_eq!(before.status(), Status::Ok); + assert_eq!( + before.into_bytes().await.unwrap_or_default(), + b"descendant-before" + ); + + let live_ticket_before_owner_grant_revoke = if owner_delegation_cid.is_some() { + let minted = client + .post("/signed/kv") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + reader_b_invocation(30, "tc1149-live-signed-url-before-owner-grant-revoke")?, + )) + .body(signed_request.clone()) + .dispatch() + .await; + let status = minted.status(); + let body = minted.into_string().await.unwrap_or_default(); + assert_eq!( + status, + Status::Ok, + "current signed URL before owner grant revoke: {body}" + ); + let url = serde_json::from_str::(&body)?["url"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing current signed URL"))? + .to_owned(); + assert_eq!( + client.get(&url).dispatch().await.status(), + Status::Ok, + "current ticket must redeem before owner grant revoke" + ); + Some(url) + } else { + None + }; if let Some(owner_delegation_cid) = owner_delegation_cid.as_deref() { // Revoking the delegation the policy owner held stops new // sessions, although the policy and its roots are untouched. @@ -9630,6 +10376,108 @@ mod tests { "owner session revoke: {revoked_body}" ); + let old_ticket = client + .get(live_ticket_before_owner_grant_revoke.expect("live owner-grant ticket")) + .dispatch() + .await; + assert_eq!( + old_ticket.status(), + Status::Forbidden, + "pre-revoke signed URL must not redeem after owner proof revoke" + ); + let fresh_signed = client + .post("/signed/kv") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + reader_b_invocation(30, "tc1149-fresh-signed-url-after-owner-proof-revoke")?, + )) + .body(signed_request.clone()) + .dispatch() + .await; + let status = fresh_signed.status(); + let body = fresh_signed.into_string().await.unwrap_or_default(); + assert_eq!( + status, + Status::Unauthorized, + "fresh signed URL after proof revoke: {body}" + ); + // The revoke ACK is the cutoff. Old signatures and registered + // descendants must no longer authorize reads, writes, or children. + for (key, principal, parent, label) in [ + (&holder_jwk, holder_did.as_str(), s0_cid, "s0"), + ( + &reader_b_jwk, + reader_b_did.as_str(), + grandchild_cid, + "descendant", + ), + ] { + for (action, allowed) in [("get", &read_capabilities), ("put", &write_capabilities)] + { + let authorization = session_invocation( + key, + principal, + parent, + allowed, + &format!("tc1149-after-owner-revoke-{label}-{action}"), + )?; + let response = client + .post("/invoke") + .header(rocket::http::Header::new("Authorization", authorization)) + .header(ContentType::Plain) + .body("must-not-overwrite") + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Forbidden, "{label} {action}: {body}"); + } + } + let after = client + .post("/invoke") + .header(rocket::http::Header::new( + "Authorization", + owner_read("tc1149-owner-read-after")?, + )) + .dispatch() + .await; + assert_eq!(after.status(), Status::Ok); + assert_eq!( + after.into_bytes().await.unwrap_or_default(), + b"descendant-before", + "denied policy writes must leave the owner-visible bytes unchanged" + ); + let next_reader = JWK::generate_ed25519()?; + let next_did = tinycloud_auth::resolver::DID_METHODS + .generate(&next_reader, "key")? + .to_string(); + for (parent, issuer, principal, label) in [ + (&v4_s0, &holder_jwk, holder_did.as_str(), "s0"), + ( + &chain_tip, + &reader_b_jwk, + reader_b_did.as_str(), + "descendant", + ), + ] { + let attempted = hop( + parent, + issuer, + principal, + &next_did, + &format!("tc1149-child-after-owner-revoke-{label}"), + )?; + let response = client + .post("/delegate") + .header(rocket::http::Header::new("Authorization", attempted)) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Forbidden, "{label} child: {body}"); + } + let challenge_response = client .post("/policy/v3/challenges") .header(ContentType::JSON) @@ -9643,96 +10491,399 @@ mod tests { ) .dispatch() .await; - assert_eq!(challenge_response.status(), Status::Ok); - let fresh_challenge: Value = challenge_response.into_json().await.unwrap(); - let mut fresh = presentation.clone(); - fresh["schema"] = json!(CREDENTIAL_PRESENTATION_V4_SCHEMA); - fresh["jti"] = json!("presentation-tc-597-after-owner-revoke"); - fresh["challengeId"] = fresh_challenge["challengeId"].clone(); - fresh["nonce"] = fresh_challenge["nonce"].clone(); - fresh - .as_object_mut() - .unwrap() - .remove("credentialSpaceOwnerDid"); - fresh["issuedAt"] = json!(format_time(OffsetDateTime::now_utc())); - fresh["expiresAt"] = json!(format_time( - OffsetDateTime::now_utc() + Duration::seconds(45) - )); - let mut unsigned_fresh = fresh.clone(); - unsigned_fresh.as_object_mut().unwrap().remove("signature"); - let mut fresh_preimage = CREDENTIAL_PRESENTATION_V4_DOMAIN.to_vec(); - fresh_preimage.extend_from_slice(&canonical_json_value(&unsigned_fresh)); - fresh["signature"]["value"] = json!(encode_config( - holder_key.sign(&Sha256::digest(fresh_preimage)), - URL_SAFE_NO_PAD - )); - let refused = client - .post("/policy/v3/delegations") + assert_eq!(challenge_response.status(), Status::Forbidden); + } + + // Prepare a fresh, unused admission while both roots are live. It + // cannot be created after root revoke because challenge itself closes. + let fresh_root_mint = if space_owner { + let mut pending = None; + for (label, mint_before_revoke) in [("live", true), ("after", false)] { + let response = client + .post("/policy/v3/challenges") + .header(ContentType::JSON) + .body( + json!({ + "policyCid": policy_cid, + "recipientDid": holder_did, + "requestedCapabilities": requested, + }) + .to_string(), + ) + .dispatch() + .await; + assert_eq!( + response.status(), + Status::Ok, + "fresh root challenge: {label}" + ); + let challenge: Value = response.into_json().await.unwrap(); + let mut signed = presentation.clone(); + signed["schema"] = json!(CREDENTIAL_PRESENTATION_V4_SCHEMA); + signed["jti"] = json!(format!("presentation-tc1149-{label}-root-revoke")); + signed["challengeId"] = challenge["challengeId"].clone(); + signed["nonce"] = challenge["nonce"].clone(); + signed + .as_object_mut() + .unwrap() + .remove("credentialSpaceOwnerDid"); + signed["issuedAt"] = json!(format_time(OffsetDateTime::now_utc())); + signed["expiresAt"] = json!(format_time( + OffsetDateTime::now_utc() + Duration::seconds(45) + )); + let mut unsigned = signed.clone(); + unsigned.as_object_mut().unwrap().remove("signature"); + let mut preimage = CREDENTIAL_PRESENTATION_V4_DOMAIN.to_vec(); + preimage.extend_from_slice(&canonical_json_value(&unsigned)); + signed["signature"]["value"] = json!(encode_config( + holder_key.sign(&Sha256::digest(preimage)), + URL_SAFE_NO_PAD + )); + let body = json!({ + "policyCid": policy_cid, + "challengeId": challenge["challengeId"], + "nonce": challenge["nonce"], + "requirement": requirement, + "credential": credential, + "presentation": signed, + }) + .to_string(); + if mint_before_revoke { + let minted = client + .post("/policy/v3/delegations") + .header(ContentType::JSON) + .body(body) + .dispatch() + .await; + let status = minted.status(); + let response_body = minted.into_string().await.unwrap_or_default(); + assert_eq!( + status, + Status::Ok, + "fresh mint before root revoke: {response_body}" + ); + } else { + pending = Some(body); + } + } + pending + } else { + None + }; + + // Exercise both distinct root-revocation routes: the generic CID + // tombstone and the policy root's signed status event. + let root_field = if policy_owner == PolicyOwner::SpaceOwner { + "enforcementRootCid" + } else { + "policyRootCid" + }; + // Earlier test writes may have changed the KV object's content after + // the first ticket was minted. Issue and redeem a CURRENT ticket so a + // post-ACK 403 proves revocation rather than an unrelated stale-hash + // precondition failure. + let live_ticket_before_root = if space_owner { + let minted = client + .post("/signed/kv") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + reader_b_invocation( + 30, + &format!("tc1149-live-signed-url-before-{root_field}-revoke"), + )?, + )) + .body(signed_request.clone()) + .dispatch() + .await; + let status = minted.status(); + let body = minted.into_string().await.unwrap_or_default(); + assert_eq!( + status, + Status::Ok, + "current signed URL before {root_field} revoke: {body}" + ); + let url = serde_json::from_str::(&body)?["url"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing current signed URL"))? + .to_owned(); + let still_live_status = client.get(&url).dispatch().await.status(); + assert_eq!( + still_live_status, + Status::Ok, + "current ticket must redeem before revoke" + ); + Some(url) + } else { + None + }; + let status_path = format!( + "/policy/v3/status/{}", + registered[root_field].as_str().unwrap() + ); + if space_owner { + let active_status = client.get(&status_path).dispatch().await; + assert_eq!( + active_status.status(), + Status::Ok, + "root status before revoke" + ); + assert_eq!( + active_status.into_json::().await.unwrap()["state"], + "active" + ); + } + if signed_root_revoke { + let storage = crate::storage::file_system::FileSystemConfig::new(&_storage_dir) + .open() + .await?; + crate::TinyCloud::new( + db.clone().into(), + Either::B(storage), + StaticSecret::new(vec![0; 32]).unwrap(), + ) + .await + .expect("cold-open succeeds before signed root revoke"); + } + let revoked_root_cid = registered[root_field] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing {root_field}"))? + .parse::()?; + let revoke_response = if signed_root_revoke { + let root_role = if root_field == "enforcementRootCid" { + "policy-enforcement" + } else { + "policy-authority" + }; + let mut revocation = json!({ + "schema": ROOT_REVOCATION_V1_SCHEMA, + "targetCid": revoked_root_cid.to_string(), + "targetRole": root_role, + "ownerDid": owner_did, + "nodeAudience": node_did, + "revokedAt": format_time(OffsetDateTime::now_utc() + Duration::seconds(30)), + "reason": "owner-requested", + "issuerDid": owner_did, + }); + let mut preimage = b"xyz.tinycloud.policy/RootRevocation/v1\0".to_vec(); + preimage.extend_from_slice(&canonical_json_value(&revocation)); + revocation["signature"] = json!({ + "suite": "Ed25519", + "signerDid": owner_did, + "value": encode_config(owner_key.sign(&Sha256::digest(preimage)), URL_SAFE_NO_PAD), + }); + let body = json!({"revocation": revocation}).to_string(); + let result = client + .post("/revoke") + .header(ContentType::JSON) + .body(body.clone()) + .dispatch() + .await; + assert_eq!(result.status(), Status::Ok, "live signed root revoke"); + let retry = client + .post("/revoke") + .header(ContentType::JSON) + .body(body) + .dispatch() + .await; + assert_eq!( + retry.status(), + Status::Ok, + "exact signed root revoke replay" + ); + result + } else { + let mut revoke_capabilities = Capabilities::::new(); + revoke_capabilities.with_action( + format!("urn:cid:{revoked_root_cid}").parse()?, + "tinycloud.delegation/revoke".parse::()?, + [std::collections::BTreeMap::::new()], + ); + let generic_revoke = + tinycloud_auth::authorization::TinyCloudRevocation::Ucan(Box::new( + Payload { + issuer: owner_vm.parse()?, + audience: owner_did.parse()?, + not_before: None, + expiration: NumericDate::try_from_seconds( + (OffsetDateTime::now_utc() + Duration::seconds(60)).unix_timestamp() + as f64, + )?, + nonce: Some("tc500-policy-root-generic-revoke".into()), + facts: Some(Vec::new()), + proof: Vec::new(), + attenuation: revoke_capabilities, + } + .sign(Algorithm::EdDSA, &owner_jwk)?, + )); + client + .post("/revoke") + .header(rocket::http::Header::new( + "Authorization", + generic_revoke.encode()?, + )) + .dispatch() + .await + }; + let revoke_status = revoke_response.status(); + let revoke_body = revoke_response.into_string().await.unwrap_or_default(); + assert_eq!(revoke_status, Status::Ok, "SDK root revoke: {revoke_body}"); + if space_owner { + let public_status = client.get(&status_path).dispatch().await; + assert_eq!( + public_status.status(), + Status::Ok, + "root checkpoint after revoke" + ); + let public_status: Value = public_status.into_json().await.unwrap(); + assert_eq!(public_status["state"], "revoked"); + if signed_root_revoke { + assert!( + public_status["revocation"].is_object(), + "signed root revocation checkpoint" + ); + } + } + + if let Some(signed_url) = live_ticket_before_root { + let old_ticket = client.get(signed_url).dispatch().await; + assert_eq!( + old_ticket.status(), + Status::Forbidden, + "pre-revoke signed URL must not redeem after {root_field} revoke" + ); + let fresh_signed = client + .post("/signed/kv") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + reader_b_invocation( + 30, + &format!("tc1149-fresh-signed-url-after-{root_field}-revoke"), + )?, + )) + .body(signed_request) + .dispatch() + .await; + let status = fresh_signed.status(); + let body = fresh_signed.into_string().await.unwrap_or_default(); + assert_eq!( + status, + Status::Unauthorized, + "fresh signed URL after {root_field} revoke: {body}" + ); + } + + if space_owner { + let challenge = client + .post("/policy/v3/challenges") .header(ContentType::JSON) .body( json!({ "policyCid": policy_cid, - "challengeId": fresh_challenge["challengeId"], - "nonce": fresh_challenge["nonce"], - "requirement": requirement, - "credential": credential, - "presentation": fresh, + "recipientDid": holder_did, + "requestedCapabilities": requested, }) .to_string(), ) .dispatch() .await; - let refused_status = refused.status(); - let refused_body = refused.into_string().await.unwrap_or_default(); assert_eq!( - refused_status, + challenge.status(), Status::Forbidden, - "mint after owner revoke: {refused_body}" + "{root_field} fresh challenge after revoke" ); - assert_eq!(refused_body, "policy-owner-not-authorized"); - } - - // SDK revocation writes the generic graph. Policy/v3 joins that - // graph with its signed root status, so this one root revocation must - // close the already-minted session plus new control-plane admission - // and delivery. It intentionally does not fabricate a root-status - // checkpoint. - let policy_root_cid = registered["policyRootCid"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("missing policy root cid"))? - .parse::()?; - let mut revoke_capabilities = Capabilities::::new(); - revoke_capabilities.with_action( - format!("urn:cid:{policy_root_cid}").parse()?, - "tinycloud.delegation/revoke".parse::()?, - [std::collections::BTreeMap::::new()], - ); - let generic_revoke = tinycloud_auth::authorization::TinyCloudRevocation::Ucan(Box::new( - Payload { - issuer: owner_vm.parse()?, - audience: owner_did.parse()?, - not_before: None, - expiration: NumericDate::try_from_seconds( - (OffsetDateTime::now_utc() + Duration::seconds(60)).unix_timestamp() as f64, - )?, - nonce: Some("tc500-policy-root-generic-revoke".into()), - facts: Some(Vec::new()), - proof: Vec::new(), - attenuation: revoke_capabilities, + for (key, principal, parent, label) in [ + (&holder_jwk, holder_did.as_str(), s0_cid, "s0"), + ( + &reader_b_jwk, + reader_b_did.as_str(), + grandchild_cid, + "descendant", + ), + ] { + for (action, allowed) in [("get", &read_capabilities), ("put", &write_capabilities)] + { + let authorization = session_invocation( + key, + principal, + parent, + allowed, + &format!("tc1149-after-{root_field}-revoke-{label}-{action}"), + )?; + let response = client + .post("/invoke") + .header(rocket::http::Header::new("Authorization", authorization)) + .header(ContentType::Plain) + .body("root-denied-write") + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + assert_eq!( + status, + Status::Forbidden, + "{root_field} {label} {action}: {body}" + ); + } } - .sign(Algorithm::EdDSA, &owner_jwk)?, - )); - let revoke_response = client - .post("/revoke") - .header(rocket::http::Header::new( - "Authorization", - generic_revoke.encode()?, - )) - .dispatch() - .await; - let revoke_status = revoke_response.status(); - let revoke_body = revoke_response.into_string().await.unwrap_or_default(); - assert_eq!(revoke_status, Status::Ok, "SDK root revoke: {revoke_body}"); + let unchanged = client + .post("/invoke") + .header(rocket::http::Header::new( + "Authorization", + owner_read(&format!("tc1149-owner-read-after-{root_field}"))?, + )) + .dispatch() + .await; + assert_eq!(unchanged.status(), Status::Ok); + assert_eq!( + unchanged.into_bytes().await.unwrap_or_default(), + b"descendant-before", + "revoking {root_field} must not commit denied writes" + ); + let next_reader = JWK::generate_ed25519()?; + let next_did = tinycloud_auth::resolver::DID_METHODS + .generate(&next_reader, "key")? + .to_string(); + for (parent, issuer, principal, label) in [ + (&v4_s0, &holder_jwk, holder_did.as_str(), "s0"), + ( + &chain_tip, + &reader_b_jwk, + reader_b_did.as_str(), + "descendant", + ), + ] { + let attempted = hop( + parent, + issuer, + principal, + &next_did, + &format!("tc1149-{root_field}-child-after-revoke-{label}"), + )?; + let response = client + .post("/delegate") + .header(rocket::http::Header::new("Authorization", attempted)) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + assert_eq!( + status, + Status::Forbidden, + "{root_field} {label} child: {body}" + ); + } + let refused = client + .post("/policy/v3/delegations") + .header(ContentType::JSON) + .body(fresh_root_mint.expect("pre-revoke challenge and presentation")) + .dispatch() + .await; + let status = refused.status(); + let body = refused.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Forbidden, "{root_field} fresh mint: {body}"); + } let post_revoke_now = OffsetDateTime::now_utc(); let post_revoke_read = Payload { @@ -9748,7 +10899,7 @@ mod tests { facts: Some(Vec::::new()), proof: vec![child_cid], attenuation: serde_json::from_value::>( - attenuation_for_policy_capabilities(&requested) + attenuation_for_policy_capabilities(&read_capabilities) .map_err(|(_, error)| anyhow::anyhow!(error))?, )?, } @@ -9803,6 +10954,51 @@ mod tests { .dispatch() .await; assert_eq!(post_revoke_delivery_response.status(), Status::Forbidden); + if signed_root_revoke { + let key = StaticSecret::new(vec![0; 32]).unwrap(); + let encryption = + ColumnEncryption::new(key.derive_key(b"tinycloud/hooks/webhook-secrets")); + let counts = + tinycloud_core::policy_witness::dry_run_backfill(&db, Some(&encryption)).await?; + assert_eq!( + ( + counts.policies, + counts.missing, + counts.ambiguous, + counts.overflow + ), + (1, 0, 0, 0), + "revoked signed roots remain structurally complete witnesses" + ); + let storage = crate::storage::file_system::FileSystemConfig::new(&_storage_dir) + .open() + .await?; + crate::TinyCloud::new(db.clone().into(), Either::B(storage), key) + .await + .expect("cold-open succeeds after signed root revoke"); + if root_field == "policyRootCid" { + // Revocation does not excuse the other historical root from + // full structural validation during startup. Roll back the + // deliberately corrupt row so the signed fixture stays valid. + let transaction = db.begin().await?; + let other_cid = registered["enforcementRootCid"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing enforcement root"))?; + let other = policy_v3_root::Entity::find_by_id(other_cid.to_owned()) + .one(&transaction) + .await? + .ok_or_else(|| anyhow::anyhow!("missing enforcement root row"))?; + let mut damaged: policy_v3_root::ActiveModel = other.into(); + damaged.role = Set("invalid-structural-role".to_owned()); + damaged.update(&transaction).await?; + let error = + policy_witness::backfill_in_transaction(&transaction, Some(&encryption)) + .await + .expect_err("a revoked root must not mask a corrupt sibling root"); + assert!(matches!(error, policy_witness::WitnessError::Ambiguous)); + transaction.rollback().await?; + } + } Ok(()) } diff --git a/tinycloud-node-server/src/routes/hooks.rs b/tinycloud-node-server/src/routes/hooks.rs index 3175984b..f695c7f1 100644 --- a/tinycloud-node-server/src/routes/hooks.rs +++ b/tinycloud-node-server/src/routes/hooks.rs @@ -313,6 +313,34 @@ impl HookEventStream { } } +async fn check_hook_ticket_authority( + tinycloud: &TinyCloud, + claims: &HookTicketClaims, + spaces: &[String], +) -> Result<(), (Status, String)> { + let result = rocket::tokio::time::timeout( + Duration::from_secs(2), + tinycloud.ticket_authority_unrevoked(&claims.authorizing_delegations, &claims.sub, spaces), + ) + .await + .map_err(|_| { + ( + Status::Unauthorized, + "authorization-check-timeout".to_string(), + ) + })?; + result.map_err(|error| { + let message = if tinycloud_core::key_authority::is_revoked_error(&error) { + tinycloud_core::key_authority::REVOKED + } else if matches!(&error, tinycloud_core::sea_orm::DbErr::Custom(message) if message == "hook ticket authority revoked") { + "hook ticket authority revoked" + } else { + "hook ticket authority invalid" + }; + (Status::Unauthorized, message.to_string()) + }) +} + #[get("/hooks/events?")] pub async fn hook_events<'r>( ticket: &'r str, @@ -322,23 +350,32 @@ pub async fn hook_events<'r>( let claims = hooks .verify_ticket(ticket) .map_err(|e| (Status::Unauthorized, e))?; + let spaces = claims + .scopes + .iter() + .map(|scope| { + let space = scope + .space + .parse::() + .map_err(|_| { + ( + Status::Unauthorized, + "hook ticket scope invalid".to_string(), + ) + })?; + if space.to_string() != scope.space { + return Err(( + Status::Unauthorized, + "hook ticket scope invalid".to_string(), + )); + } + Ok(scope.space.clone()) + }) + .collect::, _>>()?; let stream_deadline = hook_stream_deadline( &claims, hooks.config().max_ticket_ttl_seconds, - || async { - if tinycloud - .delegation_chains_unrevoked(&claims.authorizing_delegations) - .await - .map_err(|error| (Status::InternalServerError, error.to_string()))? - { - Ok(()) - } else { - Err(( - Status::Unauthorized, - "hook ticket authority revoked".to_string(), - )) - } - }, + || check_hook_ticket_authority(tinycloud, &claims, &spaces), OffsetDateTime::now_utc, ) .await?; @@ -363,6 +400,12 @@ pub async fn hook_events<'r>( match message { Ok(event) => { if claims.scopes.iter().any(|scope| matches_scope(&event, scope)) { + if let Err((_, message)) = + check_hook_ticket_authority(tinycloud, &claims, &spaces).await + { + yield Event::data(message).event("error"); + break; + } yield Event::json(&event) .id(event.id.clone()) .event("write"); @@ -945,15 +988,22 @@ mod tests { use rocket::http::Status; use tempfile::TempDir; use tinycloud_auth::{ - authorization::{make_invocation, InvocationOptions}, + authorization::{make_invocation, HeaderEncode, InvocationOptions, TinyCloudDelegation}, ipld_core::cid::Cid, multihash_codetable::{Code, MultihashDigest}, resolver::DID_METHODS, resource::{Path, ResourceId, Service, SpaceId}, siwe_recap::Ability, - ssi::{dids::DIDBuf, jwk::JWK}, + ssi::{ + claims::jwt::NumericDate, + dids::DIDBuf, + jwk::{Algorithm, JWK}, + ucan::Payload, + }, + ucan_capabilities_object::Capabilities, }; use tinycloud_core::{ + encryption_network::{EncryptionService, LocalOneOfOneBackend}, keys::StaticSecret, sea_orm::{ConnectOptions, Database}, storage::either::Either, @@ -1013,14 +1063,16 @@ mod tests { .manage(test_tinycloud().await?), ) .await?; - rocket::tokio::time::pause(); let now = OffsetDateTime::now_utc().unix_timestamp(); + let (_, subject) = did_key()?; + let subject = did_of(&subject).to_owned(); + let space = space_of(&subject)?.to_string(); let ticket = hooks .sign_ticket(&HookTicketClaims { v: 2, - sub: "did:key:subscriber".to_string(), + sub: subject, scopes: vec![HookSubscription { - space: "tinycloud:space".to_string(), + space: space.clone(), service: "kv".to_string(), path_prefix: None, abilities: Vec::new(), @@ -1044,7 +1096,7 @@ mod tests { hooks.bus().publish(crate::hooks::WriteEvent { event_type: "write".to_string(), id: "epoch:1".to_string(), - space: "tinycloud:space".to_string(), + space, service: "kv".to_string(), ability: "tinycloud.kv/put".to_string(), path: Some("documents/1".to_string()), @@ -1056,6 +1108,9 @@ mod tests { timestamp: "2026-01-01T00:00:00Z".to_string(), }); + // Keep database-backed authority checks on a live clock: paused Tokio + // time can auto-advance their two-second timeout while SQLite awaits. + rocket::tokio::time::pause(); rocket::tokio::time::advance(Duration::from_secs(11)).await; for response in responses { assert!( @@ -2093,6 +2148,13 @@ mod tests { } let node = StaticSecret::new(vec![5u8; 32]).unwrap(); + let encryption = EncryptionService::new_with_node_keypair( + db.clone(), + node.node_keypair(), + std::sync::Arc::new(LocalOneOfOneBackend::new(ColumnEncryption::new( + node.derive_key(b"tinycloud/encryption/network-seal"), + ))), + ); let rocket = rocket::build() .mount( "/", @@ -2101,7 +2163,8 @@ mod tests { hook_events, create_webhook, list_webhooks, - delete_webhook + delete_webhook, + crate::key_revoke::revoke_key ], ) .manage(tinycloud.clone()) @@ -2110,6 +2173,7 @@ mod tests { .manage(PolicyV3Runtime::new(db.clone(), node.node_did(), node)) .manage(InvocationReplayCache::new(db.clone())) .manage(policy) + .manage(encryption) .manage(Config::default()); Ok(HookRouteFixture { @@ -2408,6 +2472,173 @@ mod tests { Ok(()) } + #[tokio::test] + async fn webhook_crud_refuses_revoked_session_key_after_revoke_ack() -> Result<()> { + let fixture = hook_route_fixture(&[ + ("hooks", "kv/documents", "tinycloud.hooks/register"), + ("hooks", "kv/documents", "tinycloud.hooks/list"), + ("hooks", "kv/documents", "tinycloud.hooks/unregister"), + ]) + .await?; + let scope = fixture.hooks("kv/documents")?; + let (status, body) = fixture + .register( + fixture.session_header(&[(scope.clone(), &["tinycloud.hooks/register"])])?, + &fixture.space, + ) + .await; + assert_eq!(status, Status::Ok, "live webhook POST: {body}"); + let id = serde_json::from_str::(&body)?["id"] + .as_str() + .expect("created webhook id") + .to_string(); + let (status, body) = fixture + .list(fixture.session_header(&[(scope.clone(), &["tinycloud.hooks/list"])])?) + .await; + assert_eq!(status, Status::Ok, "live webhook GET: {body}"); + assert_eq!( + serde_json::from_str::(&body)?[0]["id"], + id + ); + // DELETE also needs a live, authorized control; keep the original + // subscription active for the post-ACK denial. + let (status, body) = fixture + .register( + fixture.session_header(&[(scope.clone(), &["tinycloud.hooks/register"])])?, + &fixture.space, + ) + .await; + assert_eq!(status, Status::Ok, "second live webhook POST: {body}"); + let disposable_id = serde_json::from_str::(&body)?["id"] + .as_str() + .expect("second webhook id") + .to_string(); + assert_ne!(disposable_id, id); + let (status, body) = fixture + .unregister( + fixture.session_header(&[(scope.clone(), &["tinycloud.hooks/unregister"])])?, + &disposable_id, + ) + .await; + assert_eq!(status, Status::NoContent, "live webhook DELETE: {body}"); + + let target_did = did_of(&fixture.session.1); + let scope_json = serde_json::json!({"kind": "global"}); + let mut capabilities = Capabilities::::new(); + capabilities.with_action( + format!("urn:tinycloud:key:{target_did}").parse()?, + "tinycloud.key/revoke".parse()?, + [serde_json::from_value(serde_json::json!({ + "version": 1, "targetDid": target_did, "scope": scope_json + }))?], + ); + let now = OffsetDateTime::now_utc(); + let node_did = fixture + .client + .rocket() + .state::() + .expect("mounted node encryption service") + .node_did(); + let authorization = TinyCloudDelegation::Ucan(Box::new( + Payload { + issuer: fixture.session.1.parse()?, + audience: node_did.parse()?, + not_before: Some(NumericDate::try_from_seconds( + (now - time::Duration::seconds(1)).unix_timestamp() as f64, + )?), + expiration: NumericDate::try_from_seconds( + (now + time::Duration::seconds(45)).unix_timestamp() as f64, + )?, + nonce: Some("webhook-crud-self-revoke".into()), + facts: Some(Vec::new()), + proof: Vec::new(), + attenuation: capabilities, + } + .sign(Algorithm::EdDSA, &fixture.session.0)?, + )) + .encode()?; + let revoked = fixture + .client + .post("/keys/revoke") + .header(rocket::http::ContentType::JSON) + .body( + serde_json::json!({ + "targetDid": target_did, "scope": scope_json, "authorization": authorization + }) + .to_string(), + ) + .dispatch() + .await; + let revoked_status = revoked.status(); + let revoked_body = revoked.into_string().await.unwrap_or_default(); + assert_eq!(revoked_status, Status::Ok, "key revoke ACK: {revoked_body}"); + assert_eq!( + serde_json::from_str::(&revoked_body)?["revoked"], + true + ); + + let (status, body) = fixture + .register( + fixture.session_header(&[(scope.clone(), &["tinycloud.hooks/register"])])?, + &fixture.space, + ) + .await; + assert_eq!(status, Status::Unauthorized, "revoked webhook POST: {body}"); + let (status, body) = fixture + .list(fixture.session_header(&[(scope.clone(), &["tinycloud.hooks/list"])])?) + .await; + assert_eq!(status, Status::Unauthorized, "revoked webhook GET: {body}"); + let (status, body) = fixture + .unregister( + fixture.session_header(&[(scope.clone(), &["tinycloud.hooks/unregister"])])?, + &id, + ) + .await; + assert_eq!( + status, + Status::Unauthorized, + "revoked webhook DELETE: {body}" + ); + assert!( + fixture + .tinycloud + .find_hook_subscription(&id) + .await? + .unwrap() + .active, + "denied DELETE must leave the subscription active" + ); + + let owner_header = |ability| { + signed_header( + &fixture.owner.0, + &fixture.owner.1, + vec![], + &[(scope.clone(), &[ability])], + ) + }; + let (status, body) = fixture + .register(owner_header("tinycloud.hooks/register")?, &fixture.space) + .await; + assert_eq!(status, Status::Ok, "unrevoked signer POST: {body}"); + let control_id = serde_json::from_str::(&body)?["id"] + .as_str() + .expect("control webhook id") + .to_string(); + let (status, body) = fixture.list(owner_header("tinycloud.hooks/list")?).await; + assert_eq!(status, Status::Ok, "unrevoked signer GET: {body}"); + assert!(serde_json::from_str::(&body)? + .as_array() + .unwrap() + .iter() + .any(|row| row["id"] == control_id)); + let (status, body) = fixture + .unregister(owner_header("tinycloud.hooks/unregister")?, &control_id) + .await; + assert_eq!(status, Status::NoContent, "unrevoked signer DELETE: {body}"); + Ok(()) + } + #[tokio::test] async fn refuses_hook_tickets_without_a_verified_delegation() -> Result<()> { let fixture = @@ -2852,6 +3083,255 @@ mod tests { Ok(()) } + async fn revoke_hook_test_key(fixture: &HookRouteFixture, did: &str) -> Result<()> { + use tinycloud_core::{ + models::key_revocation, + sea_orm::{ActiveModelTrait, ActiveValue::Set}, + }; + + key_revocation::ActiveModel { + target_did: Set(did.to_string()), + scope_key: Set("global".to_string()), + revoked_at: Set(OffsetDateTime::now_utc().unix_timestamp()), + } + .insert(&fixture.db) + .await?; + Ok(()) + } + + #[tokio::test] + async fn hook_stream_rejects_existing_delegated_ticket_after_subject_self_global_revoke( + ) -> Result<()> { + let fixture = + hook_route_fixture(&[("hooks", "kv/documents", "tinycloud.hooks/subscribe")]).await?; + let claim = [(fixture.hooks("kv/documents")?, SUBSCRIBE)]; + let (status, body) = fixture + .ticket( + fixture.session_header(&claim)?, + &fixture.space, + "kv", + "documents", + ) + .await; + assert_eq!(status, Status::Ok, "{body}"); + let delegated: HookTicketResponse = serde_json::from_str(&body)?; + let live = fixture + .client + .get(format!("/hooks/events?ticket={}", delegated.ticket)) + .dispatch() + .await; + assert_eq!(live.status(), Status::Ok); + drop(live); + + let owner_header = signed_header( + &fixture.owner.0, + &fixture.owner.1, + vec![], + &[(fixture.hooks("kv/documents")?, SUBSCRIBE)], + )?; + let (status, body) = fixture + .ticket(owner_header, &fixture.space, "kv", "documents") + .await; + assert_eq!(status, Status::Ok, "{body}"); + let independent: HookTicketResponse = serde_json::from_str(&body)?; + + // Model the committed self-global key tombstone without revoking the + // owner-to-session delegation CID. + revoke_hook_test_key(&fixture, did_of(&fixture.session.1)).await?; + let denied = fixture + .client + .get(format!("/hooks/events?ticket={}", delegated.ticket)) + .dispatch() + .await; + assert_eq!(denied.status(), Status::Unauthorized); + assert_eq!( + denied.into_string().await.as_deref(), + Some("authorization-key-revoked") + ); + let (status, body) = fixture + .ticket( + fixture.session_header(&claim)?, + &fixture.space, + "kv", + "documents", + ) + .await; + assert_eq!( + status, + Status::Unauthorized, + "new ticket after key revoke: {body}" + ); + let control = fixture + .client + .get(format!("/hooks/events?ticket={}", independent.ticket)) + .dispatch() + .await; + assert_eq!(control.status(), Status::Ok); + Ok(()) + } + + #[tokio::test] + async fn hook_stream_rejects_existing_proofless_root_ticket_after_owner_key_revoke( + ) -> Result<()> { + let fixture = hook_route_fixture(&[]).await?; + let independent = hook_route_fixture(&[]).await?; + let owner_header = signed_header( + &fixture.owner.0, + &fixture.owner.1, + vec![], + &[(fixture.hooks("kv/documents")?, SUBSCRIBE)], + )?; + let (status, body) = fixture + .ticket(owner_header, &fixture.space, "kv", "documents") + .await; + assert_eq!(status, Status::Ok, "{body}"); + let root: HookTicketResponse = serde_json::from_str(&body)?; + let claims = test_hook_runtime() + .verify_ticket(&root.ticket) + .map_err(anyhow::Error::msg)?; + assert!(claims.authorizing_delegations.is_empty()); + assert_eq!(claims.sub, did_of(&fixture.owner.1)); + let live = fixture + .client + .get(format!("/hooks/events?ticket={}", root.ticket)) + .dispatch() + .await; + assert_eq!(live.status(), Status::Ok); + drop(live); + + let control_header = signed_header( + &independent.owner.0, + &independent.owner.1, + vec![], + &[(independent.hooks("kv/documents")?, SUBSCRIBE)], + )?; + let (status, body) = independent + .ticket(control_header, &independent.space, "kv", "documents") + .await; + assert_eq!(status, Status::Ok, "{body}"); + let control_ticket: HookTicketResponse = serde_json::from_str(&body)?; + + revoke_hook_test_key(&fixture, did_of(&fixture.owner.1)).await?; + let denied = fixture + .client + .get(format!("/hooks/events?ticket={}", root.ticket)) + .dispatch() + .await; + assert_eq!(denied.status(), Status::Unauthorized); + assert_eq!( + denied.into_string().await.as_deref(), + Some("authorization-key-revoked") + ); + let owner_header = signed_header( + &fixture.owner.0, + &fixture.owner.1, + vec![], + &[(fixture.hooks("kv/documents")?, SUBSCRIBE)], + )?; + let (status, body) = fixture + .ticket(owner_header, &fixture.space, "kv", "documents") + .await; + assert_eq!( + status, + Status::Unauthorized, + "new root ticket after key revoke: {body}" + ); + let control = independent + .client + .get(format!("/hooks/events?ticket={}", control_ticket.ticket)) + .dispatch() + .await; + assert_eq!(control.status(), Status::Ok); + Ok(()) + } + + async fn next_hook_sse_event( + response: &mut rocket::local::asynchronous::LocalResponse<'_>, + ) -> Result { + use rocket::tokio::io::AsyncReadExt; + + rocket::tokio::time::timeout(Duration::from_secs(2), async { + let mut frame = Vec::new(); + loop { + let byte = response.read_u8().await?; + frame.push(byte); + if frame == b":\n" { + frame.clear(); + continue; + } + if frame.ends_with(b"\n\n") { + return Ok::<_, anyhow::Error>(String::from_utf8(frame)?); + } + anyhow::ensure!(frame.len() < 8192, "oversized hook SSE frame"); + } + }) + .await? + } + + #[tokio::test] + async fn open_hook_stream_sends_terminal_error_instead_of_first_event_after_key_revoke( + ) -> Result<()> { + use rocket::tokio::io::AsyncReadExt; + + let fixture = + hook_route_fixture(&[("hooks", "kv/documents", "tinycloud.hooks/subscribe")]).await?; + let (status, body) = fixture + .ticket( + fixture.session_header(&[(fixture.hooks("kv/documents")?, SUBSCRIBE)])?, + &fixture.space, + "kv", + "documents", + ) + .await; + assert_eq!(status, Status::Ok, "{body}"); + let ticket: HookTicketResponse = serde_json::from_str(&body)?; + let hooks = fixture + .client + .rocket() + .state::() + .expect("managed hooks runtime"); + let mut stream = fixture + .client + .get(format!("/hooks/events?ticket={}", ticket.ticket)) + .dispatch() + .await; + assert_eq!(stream.status(), Status::Ok); + + let event = |id: &str| crate::hooks::WriteEvent { + event_type: "write".to_string(), + id: id.to_string(), + space: fixture.space.to_string(), + service: "kv".to_string(), + ability: "tinycloud.kv/put".to_string(), + path: Some("documents/1".to_string()), + database_path: None, + table_name: None, + actor: did_of(&fixture.owner.1).to_string(), + epoch: "hook-cutoff".to_string(), + event_index: 1, + timestamp: "2026-10-15T00:00:00Z".to_string(), + }; + hooks.bus().publish(event("hook-cutoff:control")); + let first = next_hook_sse_event(&mut stream).await?; + assert!(first.contains("event:write\n"), "{first}"); + assert!(first.contains("id:hook-cutoff:control\n"), "{first}"); + + // Reading this frame ACKs the control delivery before revocation. + revoke_hook_test_key(&fixture, did_of(&fixture.session.1)).await?; + hooks.bus().publish(event("hook-cutoff:revoked")); + let terminal = next_hook_sse_event(&mut stream).await?; + assert!(terminal.contains("event:error\n"), "{terminal}"); + assert!(terminal.contains("authorization-key-revoked"), "{terminal}"); + assert!(!terminal.contains("hook-cutoff:revoked"), "{terminal}"); + let mut eof = [0u8; 1]; + assert_eq!( + rocket::tokio::time::timeout(Duration::from_secs(2), stream.read(&mut eof)).await??, + 0, + "stream must close after its terminal error" + ); + Ok(()) + } + #[tokio::test] async fn hook_stream_disables_proxy_buffering() -> Result<()> { let fixture = diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index 43031cb5..870330f2 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -71,6 +71,7 @@ pub mod hooks; pub(crate) mod kv_sync; mod meeting_publication; pub mod node_keys; +pub use crate::key_revoke; pub mod public; #[cfg(feature = "tc-bench-v1")] pub mod tc_bench; @@ -345,9 +346,8 @@ pub async fn create_signed_kv_url( ) -> Result, (Status, String)> { let invocation_info = invocation.0 .0.clone(); verify_auth("server.signed_kv.auth", invocation.0, tinycloud).await?; - // Same policy gate as `/invoke`. A policy-session invocation lives at most - // 60 s, and the ticket never outlives it, so a revoked policy stops new - // signed URLs here and existing ones within a minute. + // Mint and every redemption check the policy's current revocation + // dependencies; the ticket lifetime is not a revocation grace period. policy_v3 .authorize_invocation(tinycloud, &invocation_info, OffsetDateTime::now_utc()) .await @@ -526,8 +526,16 @@ pub async fn delegation_status( match tinycloud .delegation_status(target, &auth.invoker, &auth.parents) .await - .map_err(|_| (Status::ServiceUnavailable, "Status unavailable".to_string()))? - { + .map_err(|error| match error { + tinycloud_core::db::TxError::AuthorizationDenied => { + (Status::Forbidden, "Status authority revoked".to_string()) + } + tinycloud_core::db::TxError::AuthorizationWaitTimeout => ( + Status::Forbidden, + "Status authority wait timed out".to_string(), + ), + _ => (Status::ServiceUnavailable, "Status unavailable".to_string()), + })? { Some(status) => Ok(Custom( Status::Ok, Json(DelegationStatusResponse::from_status(request.cid, status)), @@ -602,6 +610,9 @@ pub async fn delegate( database_failure(error, write_may_have_applied, e.to_string()) } TxError::SpaceNotFound => (Status::NotFound, e.to_string()), + TxError::AuthorizationDenied | TxError::AuthorizationWaitTimeout => { + (Status::Forbidden, e.to_string()) + } _ => (Status::Unauthorized, e.to_string()), }) .and_then(|result: TransactResult| { @@ -2210,6 +2221,8 @@ async fn invoke_impl( TxStoreError::Tx(TxError::SpaceNotFound) => Status::NotFound, TxStoreError::KvSyncInvalidRequest(_) => Status::BadRequest, TxStoreError::KvPreconditionFailed => Status::PreconditionFailed, + TxStoreError::Tx(TxError::AuthorizationDenied | TxError::AuthorizationWaitTimeout) => + Status::Forbidden, TxStoreError::MeetingSnapshotProtected => Status::Forbidden, TxStoreError::LegacyMeetingFrozen => Status::Conflict, TxStoreError::KvResponseTooLarge { .. } => Status::PayloadTooLarge, @@ -4984,6 +4997,20 @@ mod tests { Status::Conflict, "exact invocation replay must be rejected" ); + let live = client + .post("/invoke") + .header(Header::new( + "Authorization", + make_auth_header("urn:uuid:00000000-0000-4000-8000-000000000003")?, + )) + .header(ContentType::JSON) + .body(serde_json::to_string(&SqlRequest::ExecuteStatement { + name: "get_val".to_string(), + params: vec![], + })?) + .dispatch() + .await; + assert_eq!(live.status(), Status::Ok, "fresh SQL read before revoke"); let revocation_hash = tinycloud_core::hash::hash(b"w1-rocket-http-revocation"); revo_model::ActiveModel { @@ -5391,9 +5418,17 @@ mod tests { #[tokio::test] async fn delegation_status_and_query_enforce_control_proofs_without_oracles() -> Result<()> { + use k256::ecdsa::SigningKey; use rocket::http::{ContentType, Header, Status}; use rocket::local::asynchronous::Client; + use sha3::Keccak256; use tinycloud_auth::authorization::Cid as AuthCid; + use tinycloud_auth::authorization::{HeaderEncode, TinyCloudDelegation}; + use tinycloud_auth::cacaos::{ + siwe::{encode_eip55, Message, Version}, + siwe_cacao::{Header as SiweHeader, Signature as SiweSignature, SiweCacao}, + }; + use tinycloud_auth::siwe_recap::Capability as RecapCapability; use tinycloud_auth::ssi::{ claims::jwt::NumericDate, dids::{DIDBuf, DIDURLBuf}, @@ -5402,11 +5437,91 @@ mod tests { }; use tinycloud_auth::ucan_capabilities_object::Capabilities; use tinycloud_core::models::{ - abilities, actor, delegation as deleg_model, revocation as revo_model, + abilities, actor, delegation as deleg_model, policy_v3_root, revocation as revo_model, }; use tinycloud_core::relationships::parent_delegations; use tinycloud_core::sea_orm::{ActiveModelTrait, ActiveValue::Set}; + fn signed_ucan_grant( + jwk: &JWK, + vm: &str, + audience: &str, + nonce: &str, + resource: &Resource, + action: &str, + expiry: f64, + ) -> Result> { + let mut capabilities = Capabilities::new(); + capabilities.with_action( + resource.to_string().parse()?, + action.parse::()?, + [std::collections::BTreeMap::::new()], + ); + Ok(Payload { + issuer: vm.parse::()?, + audience: audience.parse::()?, + not_before: None, + expiration: NumericDate::try_from_seconds(expiry)?, + nonce: Some(nonce.to_string()), + facts: Some(Vec::::new()), + proof: Vec::new(), + attenuation: capabilities, + } + .sign(jwk.get_algorithm().unwrap_or_default(), jwk)? + .encode()? + .into_bytes()) + } + + fn signed_wallet_session( + key: &SigningKey, + address: [u8; 20], + audience: &str, + resource: &Resource, + nonce: &str, + ) -> Result<(Vec, OffsetDateTime, OffsetDateTime)> { + let mut recap = RecapCapability::::new(); + for action in [ + "tinycloud.delegation/list", + "tinycloud.delegation/status", + "tinycloud.delegation/revoke", + ] { + recap.with_action( + resource.to_string().parse()?, + action.parse::()?, + [std::collections::BTreeMap::::new()], + ); + } + let issued = OffsetDateTime::now_utc() - time::Duration::minutes(1); + let expires = issued + time::Duration::hours(1); + let message = recap.build_message(Message { + scheme: Some("https".parse()?), + domain: "tc1149.test".parse()?, + address, + statement: None, + uri: audience.parse()?, + version: Version::V1, + chain_id: 1, + nonce: nonce.to_string(), + issued_at: issued.into(), + expiration_time: Some(expires.into()), + not_before: None, + request_id: None, + resources: vec![], + })?; + let (signature, recovery_id) = key.sign_prehash_recoverable(&message.eip191_hash()?)?; + let mut signature_bytes = [0u8; 65]; + signature_bytes[..64].copy_from_slice(signature.to_bytes().as_ref()); + signature_bytes[64] = u8::from(recovery_id) + 27; + let authorization = TinyCloudDelegation::Cacao(Box::new(SiweCacao::new( + message.into(), + SiweSignature::from(signature_bytes), + SiweHeader, + ))) + .encode()?; + let (_, serialization) = TinyCloudDelegation::decode(&authorization)?; + Ok((serialization, issued, expires)) + } + fn verification_method(jwk: &JWK) -> Result<(String, String)> { let did = DID_METHODS.generate(jwk, "key")?.to_string(); let fragment = did @@ -5514,8 +5629,14 @@ mod tests { let mut holder_jwk = JWK::generate_ed25519()?; holder_jwk.algorithm = Some(Algorithm::EdDSA); let (holder_did, holder_vm) = verification_method(&holder_jwk)?; - let owner_did = "did:key:zStatusOwner".to_string(); - let wallet_pkh = "did:pkh:eip155:1:0x1111111111111111111111111111111111111111".to_string(); + let mut owner_jwk = JWK::generate_ed25519()?; + owner_jwk.algorithm = Some(Algorithm::EdDSA); + let (owner_did, owner_vm) = verification_method(&owner_jwk)?; + let wallet_key = SigningKey::from_bytes(&[0x47; 32].into())?; + let wallet_public = wallet_key.verifying_key().to_encoded_point(false); + let wallet_digest = Keccak256::digest(&wallet_public.as_bytes()[1..]); + let wallet_address: [u8; 20] = wallet_digest[12..].try_into()?; + let wallet_pkh = format!("did:pkh:eip155:1:0x{}", encode_eip55(&wallet_address)); let unrelated_pkh = "did:pkh:eip155:1:0x2222222222222222222222222222222222222222".to_string(); for did in [&owner_did, &holder_did, &wallet_pkh, &unrelated_pkh] { @@ -5525,10 +5646,26 @@ mod tests { .insert(&conn) .await?; } - let parent_id = tinycloud_core::hash::hash(b"status-parent"); + let owner_control_space = SpaceId::new(owner_did.parse::()?, "control".parse()?); + let owner_control_resource = Resource::TinyCloud(owner_control_space.to_resource( + "delegation".parse()?, + None, + None, + None, + )); + let parent_serialization = signed_ucan_grant( + &owner_jwk, + &owner_vm, + &holder_did, + "status-parent", + &owner_control_resource, + "tinycloud.delegation/status", + 4_102_444_800.0, + )?; + let parent_id = tinycloud_core::hash::hash(&parent_serialization); let mut child_capabilities = Capabilities::new(); child_capabilities.with_action( - format!("urn:cid:{}", parent_id.to_cid(0x55)).parse()?, + owner_control_resource.to_string().parse()?, "tinycloud.delegation/status".parse::()?, [std::collections::BTreeMap::::new()], ); @@ -5546,30 +5683,39 @@ mod tests { .encode()? .into_bytes(); let child_id = tinycloud_core::hash::hash(&child_serialization); - let direct_id = tinycloud_core::hash::hash(b"status-direct"); - for (id, delegator, delegatee, bytes) in [ - ( - parent_id, - &owner_did, - &holder_did, - b"status-parent".as_slice(), - ), - ( - direct_id, - &owner_did, - &holder_did, - b"status-direct".as_slice(), - ), + let direct_serialization = signed_ucan_grant( + &owner_jwk, + &owner_vm, + &holder_did, + "status-direct", + &owner_control_resource, + "tinycloud.delegation/status", + 4_102_444_800.0, + )?; + let direct_id = tinycloud_core::hash::hash(&direct_serialization); + for (id, bytes) in [ + (parent_id, parent_serialization), + (direct_id, direct_serialization), ] { deleg_model::ActiveModel { id: Set(id), - delegator: Set(delegator.clone()), - delegatee: Set(delegatee.clone()), - expiry: Set(None), + delegator: Set(owner_did.clone()), + delegatee: Set(holder_did.clone()), + expiry: Set(Some(OffsetDateTime::from_unix_timestamp(4_102_444_800)?)), issued_at: Set(None), not_before: Set(None), facts: Set(None), - serialization: Set(bytes.to_vec()), + serialization: Set(bytes), + } + .insert(&conn) + .await?; + abilities::ActiveModel { + delegation: Set(id), + resource: Set(owner_control_resource.clone()), + ability: Set(Ability::try_from( + "tinycloud.delegation/status".to_string(), + )?), + caveats: Set(Default::default()), } .insert(&conn) .await?; @@ -5578,7 +5724,7 @@ mod tests { id: Set(child_id), delegator: Set(holder_did.clone()), delegatee: Set(holder_did.clone()), - expiry: Set(None), + expiry: Set(Some(OffsetDateTime::from_unix_timestamp(4_102_444_800)?)), issued_at: Set(None), not_before: Set(None), facts: Set(None), @@ -5586,7 +5732,26 @@ mod tests { } .insert(&conn) .await?; - let expired_id = tinycloud_core::hash::hash(b"status-expired"); + abilities::ActiveModel { + delegation: Set(child_id), + resource: Set(owner_control_resource.clone()), + ability: Set(Ability::try_from( + "tinycloud.delegation/status".to_string(), + )?), + caveats: Set(Default::default()), + } + .insert(&conn) + .await?; + let expired_serialization = signed_ucan_grant( + &owner_jwk, + &owner_vm, + &holder_did, + "status-expired", + &owner_control_resource, + "tinycloud.delegation/status", + 1.0, + )?; + let expired_id = tinycloud_core::hash::hash(&expired_serialization); deleg_model::ActiveModel { id: Set(expired_id), delegator: Set(owner_did.clone()), @@ -5595,37 +5760,93 @@ mod tests { issued_at: Set(None), not_before: Set(None), facts: Set(None), - serialization: Set(b"status-expired".to_vec()), + serialization: Set(expired_serialization), + } + .insert(&conn) + .await?; + abilities::ActiveModel { + delegation: Set(expired_id), + resource: Set(owner_control_resource.clone()), + ability: Set(Ability::try_from( + "tinycloud.delegation/status".to_string(), + )?), + caveats: Set(Default::default()), + } + .insert(&conn) + .await?; + let wallet_control_space = SpaceId::new(wallet_pkh.parse::()?, "control".parse()?); + let control_resource = Resource::TinyCloud(wallet_control_space.clone().to_resource( + "delegation".parse()?, + None, + None, + None, + )); + let (wallet_session_serialization, wallet_session_issued, wallet_session_expires) = + signed_wallet_session( + &wallet_key, + wallet_address, + &holder_did, + &control_resource, + "tc1149-wallet-session", + )?; + let wallet_session_id = tinycloud_core::hash::hash(&wallet_session_serialization); + deleg_model::ActiveModel { + id: Set(wallet_session_id), + delegator: Set(wallet_pkh.clone()), + delegatee: Set(holder_did.clone()), + expiry: Set(Some(wallet_session_expires)), + issued_at: Set(Some(wallet_session_issued)), + not_before: Set(None), + facts: Set(None), + serialization: Set(wallet_session_serialization), } .insert(&conn) .await?; - let wallet_session_id = tinycloud_core::hash::hash(b"wallet-session-proof"); let unrelated_session_id = tinycloud_core::hash::hash(b"unrelated-session-proof"); let wrong_target_session_id = tinycloud_core::hash::hash(b"wrong-target-session-proof"); let foreign_scope_session_id = tinycloud_core::hash::hash(b"foreign-scope-session-proof"); let narrow_scope_session_id = tinycloud_core::hash::hash(b"narrow-scope-session-proof"); let query_scope_session_id = tinycloud_core::hash::hash(b"query-scope-session-proof"); let fragment_scope_session_id = tinycloud_core::hash::hash(b"fragment-scope-session-proof"); - let wallet_child_id = tinycloud_core::hash::hash(b"wallet-pkh-child"); + let wallet_child_serialization = signed_ucan_grant( + &owner_jwk, + &owner_vm, + &wallet_pkh, + "wallet-pkh-child", + &owner_control_resource, + "tinycloud.delegation/status", + 4_102_444_800.0, + )?; + let wallet_child_id = tinycloud_core::hash::hash(&wallet_child_serialization); + deleg_model::ActiveModel { + id: Set(wallet_child_id), + delegator: Set(owner_did.clone()), + delegatee: Set(wallet_pkh.clone()), + expiry: Set(Some(OffsetDateTime::from_unix_timestamp(4_102_444_800)?)), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(wallet_child_serialization), + } + .insert(&conn) + .await?; + abilities::ActiveModel { + delegation: Set(wallet_child_id), + resource: Set(owner_control_resource.clone()), + ability: Set(Ability::try_from( + "tinycloud.delegation/status".to_string(), + )?), + caveats: Set(Default::default()), + } + .insert(&conn) + .await?; for (id, delegator, delegatee, bytes) in [ - ( - wallet_session_id, - &wallet_pkh, - &holder_did, - b"wallet-session-proof".as_slice(), - ), ( unrelated_session_id, &wallet_pkh, &holder_did, b"unrelated-session-proof".as_slice(), ), - ( - wallet_child_id, - &owner_did, - &wallet_pkh, - b"wallet-pkh-child".as_slice(), - ), ( wrong_target_session_id, &wallet_pkh, @@ -5696,6 +5917,45 @@ mod tests { None, )); let query_control_resource = control_resource.clone(); + // The wallet-space session cannot control a target that also carries + // the owner's separate control space. An exact-CID grant from the + // target's delegatee keeps the positive status/revoke path scoped. + let exact_control_resource = + Resource::Other(format!("urn:cid:{}", wallet_child_id.to_cid(0x55)).parse()?); + let (exact_serialization, exact_issued, exact_expires) = signed_wallet_session( + &wallet_key, + wallet_address, + &holder_did, + &exact_control_resource, + "tc1149-wallet-exact-session", + )?; + let wallet_exact_session_id = tinycloud_core::hash::hash(&exact_serialization); + deleg_model::ActiveModel { + id: Set(wallet_exact_session_id), + delegator: Set(wallet_pkh.clone()), + delegatee: Set(holder_did.clone()), + expiry: Set(Some(exact_expires)), + issued_at: Set(Some(exact_issued)), + not_before: Set(None), + facts: Set(None), + serialization: Set(exact_serialization), + } + .insert(&conn) + .await?; + for action in [ + "tinycloud.delegation/list", + "tinycloud.delegation/status", + "tinycloud.delegation/revoke", + ] { + abilities::ActiveModel { + delegation: Set(wallet_exact_session_id), + resource: Set(exact_control_resource.clone()), + ability: Set(Ability::try_from(action.to_string())?), + caveats: Set(Default::default()), + } + .insert(&conn) + .await?; + } for action in [ "tinycloud.delegation/status", "tinycloud.delegation/revoke", @@ -5988,12 +6248,26 @@ mod tests { &holder_vm, &holder_did, &wallet_child_cid, - "wallet-session-status", + "wallet-space-not-full-target", vec![wallet_session_id.to_cid(0x55)], )?, ) .dispatch() .await; + assert_eq!(response.status(), Status::NotFound); + let response = request( + &wallet_child_cid, + status_header_with_proofs( + &holder_jwk, + &holder_vm, + &holder_did, + &wallet_child_cid, + "wallet-exact-session-status", + vec![wallet_exact_session_id.to_cid(0x55)], + )?, + ) + .dispatch() + .await; assert_eq!(response.status(), Status::Ok); let body: serde_json::Value = serde_json::from_str(&response.into_string().await.unwrap())?; assert_eq!(body["status"], "active"); @@ -6065,6 +6339,7 @@ mod tests { } for (label, proof_id) in [ + ("wallet-space-not-full-target-revoke", wallet_session_id), ("unrelated-capability-revoke", unrelated_session_id), ("wrong-target-resource-revoke", wrong_target_session_id), ("foreign-root-scope-revoke", foreign_scope_session_id), @@ -6098,7 +6373,7 @@ mod tests { &holder_vm, &holder_did, &wallet_child_cid, - wallet_session_id.to_cid(0x55), + wallet_exact_session_id.to_cid(0x55), )?, )) .dispatch() @@ -6112,8 +6387,8 @@ mod tests { &holder_vm, &holder_did, &wallet_child_cid, - "wallet-session-post-revoke", - vec![wallet_session_id.to_cid(0x55)], + "wallet-exact-session-post-revoke", + vec![wallet_exact_session_id.to_cid(0x55)], )?, ) .dispatch() @@ -6130,7 +6405,7 @@ mod tests { .await?; revo_model::ActiveModel { id: Set(tinycloud_core::hash::hash(b"status-parent-revocation")), - revoker: Set(owner_did), + revoker: Set(owner_did.clone()), revoked: Set(parent_id), serialization: Set(b"status-parent-revocation".to_vec()), revoked_at: Set(None), @@ -6228,6 +6503,474 @@ mod tests { assert_eq!(body["status"], "not_found"); assert_eq!(body["exists"], false); + // A valid control session remains independently usable after the + // original holder's signing key is globally revoked. + use tinycloud_core::models::key_revocation; + + let direct_live_serialization = signed_ucan_grant( + &owner_jwk, + &owner_vm, + &holder_did, + "tc1149-direct-live", + &owner_control_resource, + "tinycloud.delegation/status", + 4_102_444_800.0, + )?; + let direct_live_id = tinycloud_core::hash::hash(&direct_live_serialization); + let conn = client + .rocket() + .state::() + .expect("tinycloud state") + .readable() + .await?; + deleg_model::ActiveModel { + id: Set(direct_live_id), + delegator: Set(owner_did.clone()), + delegatee: Set(holder_did.clone()), + expiry: Set(Some(OffsetDateTime::from_unix_timestamp(4_102_444_800)?)), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(direct_live_serialization), + } + .insert(&conn) + .await?; + abilities::ActiveModel { + delegation: Set(direct_live_id), + resource: Set(owner_control_resource.clone()), + ability: Set(Ability::try_from( + "tinycloud.delegation/status".to_string(), + )?), + caveats: Set(Default::default()), + } + .insert(&conn) + .await?; + + let mut control_jwk = JWK::generate_ed25519()?; + control_jwk.algorithm = Some(Algorithm::EdDSA); + let (control_did, control_vm) = verification_method(&control_jwk)?; + actor::ActiveModel { + id: Set(control_did.clone()), + } + .insert(&conn) + .await?; + let owner_control_serialization = signed_ucan_grant( + &owner_jwk, + &owner_vm, + &control_did, + "tc1149-owner-control", + &owner_control_resource, + "tinycloud.delegation/status", + 4_102_444_800.0, + )?; + let owner_control_id = tinycloud_core::hash::hash(&owner_control_serialization); + deleg_model::ActiveModel { + id: Set(owner_control_id), + delegator: Set(owner_did.clone()), + delegatee: Set(control_did.clone()), + expiry: Set(Some(OffsetDateTime::from_unix_timestamp(4_102_444_800)?)), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(owner_control_serialization), + } + .insert(&conn) + .await?; + abilities::ActiveModel { + delegation: Set(owner_control_id), + resource: Set(owner_control_resource.clone()), + ability: Set(Ability::try_from( + "tinycloud.delegation/status".to_string(), + )?), + caveats: Set(Default::default()), + } + .insert(&conn) + .await?; + let (control_serialization, control_issued, control_expires) = signed_wallet_session( + &wallet_key, + wallet_address, + &control_did, + &query_control_resource, + "tc1149-independent-session", + )?; + let control_session_id = tinycloud_core::hash::hash(&control_serialization); + deleg_model::ActiveModel { + id: Set(control_session_id), + delegator: Set(wallet_pkh.clone()), + delegatee: Set(control_did.clone()), + expiry: Set(Some(control_expires)), + issued_at: Set(Some(control_issued)), + not_before: Set(None), + facts: Set(None), + serialization: Set(control_serialization), + } + .insert(&conn) + .await?; + for action in [ + "tinycloud.delegation/list", + "tinycloud.delegation/status", + "tinycloud.delegation/revoke", + ] { + abilities::ActiveModel { + delegation: Set(control_session_id), + resource: Set(query_control_resource.clone()), + ability: Set(Ability::try_from(action.to_string())?), + caveats: Set(Default::default()), + } + .insert(&conn) + .await?; + } + conn.commit().await?; + + let direct_live_cid = direct_live_id.to_cid(0x55).to_string(); + let owner_control_cid = owner_control_id.to_cid(0x55).to_string(); + let session_cid = wallet_session_id.to_cid(0x55).to_string(); + let control_session_cid = control_session_id.to_cid(0x55).to_string(); + let holder_query = || -> Result { + query_header_with_proofs( + &holder_jwk, + &holder_vm, + &holder_did, + &query_control_resource, + "tc1149-holder-query", + vec![wallet_session_id.to_cid(0x55)], + ) + }; + let control_query = || -> Result { + query_header_with_proofs( + &control_jwk, + &control_vm, + &control_did, + &query_control_resource, + "tc1149-control-query", + vec![control_session_id.to_cid(0x55)], + ) + }; + for (label, header) in [ + ("target", holder_query()?), + ("independent session", control_query()?), + ] { + let response = query_request(header).dispatch().await; + assert_eq!( + response.status(), + Status::Ok, + "{label} query before key revoke" + ); + let body = response.into_string().await.unwrap_or_default(); + assert!(body.contains(&session_cid), "{label} query: {body}"); + } + let response = request( + &direct_live_cid, + status_header( + &holder_jwk, + &holder_vm, + &holder_did, + &direct_live_cid, + "tc1149-direct-before", + )?, + ) + .dispatch() + .await; + assert_eq!( + response.status(), + Status::Ok, + "proofless direct-root status" + ); + let body: serde_json::Value = serde_json::from_str(&response.into_string().await.unwrap())?; + assert_eq!(body["status"], "active"); + // A status read shares the registry with another reader. It must not + // queue behind an exclusive worker while a live read holds this CID. + let existing_reader = client + .rocket() + .state::() + .expect("tinycloud state") + .guard_delegation_chains(&[direct_live_id]) + .await?; + let simultaneous_status = tokio::time::timeout( + std::time::Duration::from_secs(1), + request( + &direct_live_cid, + status_header( + &holder_jwk, + &holder_vm, + &holder_did, + &direct_live_cid, + "tc1149-concurrent-reader", + )?, + ) + .dispatch(), + ) + .await + .expect("status read queued behind another shared reader"); + drop(existing_reader); + assert_eq!(simultaneous_status.status(), Status::Ok); + for (label, jwk, vm, did, proof) in [ + ( + "target", + &holder_jwk, + &holder_vm, + &holder_did, + wallet_session_id, + ), + ( + "independent session", + &control_jwk, + &control_vm, + &control_did, + control_session_id, + ), + ] { + let response = request( + &session_cid, + status_header_with_proofs( + jwk, + vm, + did, + &session_cid, + label, + vec![proof.to_cid(0x55)], + )?, + ) + .dispatch() + .await; + assert_eq!( + response.status(), + Status::Ok, + "{label} delegated status before key revoke" + ); + let body: serde_json::Value = + serde_json::from_str(&response.into_string().await.unwrap())?; + assert_eq!(body["status"], "active", "{label}"); + } + + // Model the durable self-global key tombstone. Neither the signed + // proof nor the queried delegation CID is revoked. + let conn = client + .rocket() + .state::() + .expect("tinycloud state") + .readable() + .await?; + key_revocation::ActiveModel { + target_did: Set(holder_did.clone()), + scope_key: Set("global".to_string()), + revoked_at: Set(OffsetDateTime::now_utc().unix_timestamp()), + } + .insert(&conn) + .await?; + conn.commit().await?; + + let response = query_request(holder_query()?).dispatch().await; + assert!( + response.status() == Status::Unauthorized || response.status() == Status::Forbidden, + "revoked signer queried delegation metadata: {}", + response.status() + ); + for (label, cid, header) in [ + ( + "proofless direct-root", + &direct_live_cid, + status_header( + &holder_jwk, + &holder_vm, + &holder_did, + &direct_live_cid, + "tc1149-direct-after", + )?, + ), + ( + "delegated proof", + &session_cid, + status_header_with_proofs( + &holder_jwk, + &holder_vm, + &holder_did, + &session_cid, + "tc1149-session-after", + vec![wallet_session_id.to_cid(0x55)], + )?, + ), + ] { + let response = request(cid, header).dispatch().await; + assert!( + response.status() == Status::Unauthorized || response.status() == Status::Forbidden, + "{label} status accepted revoked signer: {}", + response.status() + ); + } + let response = query_request(control_query()?).dispatch().await; + assert_eq!( + response.status(), + Status::Ok, + "independent session query after key revoke" + ); + let body = response.into_string().await.unwrap_or_default(); + assert!(body.contains(&session_cid), "{body}"); + let revoked_target = request( + &session_cid, + status_header_with_proofs( + &control_jwk, + &control_vm, + &control_did, + &session_cid, + "tc1149-control-revoked-target", + vec![control_session_id.to_cid(0x55)], + )?, + ) + .dispatch() + .await; + assert_eq!( + revoked_target.status(), + Status::Forbidden, + "an independent signer cannot activate the revoked target" + ); + let response = request( + &control_session_cid, + status_header_with_proofs( + &control_jwk, + &control_vm, + &control_did, + &control_session_cid, + "tc1149-control-after", + vec![control_session_id.to_cid(0x55)], + )?, + ) + .dispatch() + .await; + assert_eq!( + response.status(), + Status::Ok, + "independent session status after key revoke" + ); + let body: serde_json::Value = serde_json::from_str(&response.into_string().await.unwrap())?; + assert_eq!(body["status"], "active"); + let response = request( + &owner_control_cid, + status_header( + &owner_jwk, + &owner_vm, + &owner_did, + &owner_control_cid, + "tc1149-owner-after", + )?, + ) + .dispatch() + .await; + assert_eq!( + response.status(), + Status::Ok, + "independent owner status after key revoke" + ); + let body: serde_json::Value = serde_json::from_str(&response.into_string().await.unwrap())?; + assert_eq!(body["status"], "active"); + // A target CID may also have a generic revoke. Reporting that CID's + // status must not reauthorize the already-tombstoned requester key. + let conn = client + .rocket() + .state::() + .expect("tinycloud state") + .readable() + .await?; + revo_model::ActiveModel { + id: Set(tinycloud_core::hash::hash( + b"tc1149-direct-live-cid-revocation", + )), + revoker: Set(owner_did.clone()), + revoked: Set(direct_live_id), + serialization: Set(b"tc1149-direct-live-cid-revocation".to_vec()), + revoked_at: Set(None), + } + .insert(&conn) + .await?; + conn.commit().await?; + let denied_status = request( + &direct_live_cid, + status_header( + &holder_jwk, + &holder_vm, + &holder_did, + &direct_live_cid, + "tc1149-revoked-cid-and-caller-key", + )?, + ) + .dispatch() + .await; + assert_eq!(denied_status.status(), Status::Forbidden); + let owner_status = request( + &direct_live_cid, + status_header( + &owner_jwk, + &owner_vm, + &owner_did, + &direct_live_cid, + "tc1149-revoked-cid-live-owner", + )?, + ) + .dispatch() + .await; + assert_eq!( + owner_status.status(), + Status::Forbidden, + "a revoked target key cannot be reported through an independent signer" + ); + + // A control proof can be valid as an ordinary grant, but cannot + // silently stay valid when its ancestry is recorded as an admitted + // policy root without the corresponding signed session/witness. + let independent_control = client + .post("/revoke") + .header(Header::new( + "Authorization", + revocation_header( + &control_jwk, + &control_vm, + &control_did, + &unrelated_session_id.to_cid(0x55).to_string(), + control_session_id.to_cid(0x55), + )?, + )) + .dispatch() + .await; + assert_eq!(independent_control.status(), Status::Ok); + let conn = client + .rocket() + .state::() + .expect("tinycloud state") + .readable() + .await?; + policy_v3_root::ActiveModel { + root_cid: Set(control_session_cid.clone()), + policy_cid: Set("tc1149-missing-control-witness".to_owned()), + role: Set("policy-authority".to_owned()), + authorization_bytes: Set(b"spoofed-policy-root".to_vec()), + status_checkpoint_bytes: Set(None), + previous_checkpoint_digest_hex: Set(None), + status_sequence: Set(0), + admission_epoch: Set(0), + status_checked_at: Set(None), + status_fresh_until: Set(None), + revoked_at: Set(None), + revocation_bytes: Set(None), + } + .insert(&conn) + .await?; + conn.commit().await?; + let missing_witness = client + .post("/revoke") + .header(Header::new( + "Authorization", + revocation_header( + &control_jwk, + &control_vm, + &control_did, + &session_cid, + control_session_id.to_cid(0x55), + )?, + )) + .dispatch() + .await; + assert_eq!(missing_witness.status(), Status::Forbidden); + Ok(()) } diff --git a/tinycloud-node-server/src/share_v2.rs b/tinycloud-node-server/src/share_v2.rs index 710e2fd3..54345c22 100644 --- a/tinycloud-node-server/src/share_v2.rs +++ b/tinycloud-node-server/src/share_v2.rs @@ -83,10 +83,9 @@ fn legacy_v2_creation_open() -> bool { .expect("valid v2 creation cutoff") } -fn legacy_v2_reads_open() -> bool { - OffsetDateTime::now_utc() - <= OffsetDateTime::parse(crate::policy_v3::LAST_V2_READ_AT, &Rfc3339) - .expect("valid v2 read cutoff") +fn legacy_v2_reads_open(now: OffsetDateTime) -> bool { + now <= OffsetDateTime::parse(crate::policy_v3::LAST_V2_READ_AT, &Rfc3339) + .expect("valid v2 read cutoff") } fn legacy_expiry_allowed(expiry: OffsetDateTime) -> bool { @@ -173,6 +172,8 @@ pub struct ShareV2Runtime { migration_ready: bool, graph_ready: bool, verifier: Option, + #[cfg(test)] + evaluation_time: Option, } /// Probes that the `owner_share_policy` table is queryable. @@ -234,6 +235,24 @@ fn report_probe(check: &str, result: Result<(), DbErr>) -> bool { } impl ShareV2Runtime { + fn evaluation_time(&self) -> OffsetDateTime { + #[cfg(test)] + if let Some(now) = self.evaluation_time { + return now; + } + OffsetDateTime::now_utc() + } + #[cfg(test)] + fn ucan_validation_time(&self) -> Option { + self.evaluation_time + .map(|now| now.unix_timestamp_nanos() as f64 / 1_000_000_000.0) + } + + #[cfg(not(test))] + fn ucan_validation_time(&self) -> Option { + None + } + fn static_ready(&self) -> bool { self.config.enabled && TargetOrigin::parse(self.config.target_origin.clone()).is_ok() @@ -481,6 +500,8 @@ pub async fn compose( migration_ready, graph_ready, verifier, + #[cfg(test)] + evaluation_time: None, }; let probe = runtime.clone(); tokio::spawn(async move { @@ -830,8 +851,14 @@ pub async fn register_policy( ) .map_err(|_| error(Status::BadRequest, "policy_registration_invalid"))?, }; - validate_policy(&policy, &request, &runtime.config, &runtime.enforcer_did) - .map_err(|_| error(Status::Forbidden, "policy_registration_invalid"))?; + validate_policy( + &policy, + &request, + &runtime.config, + &runtime.enforcer_did, + runtime.evaluation_time(), + ) + .map_err(|_| error(Status::Forbidden, "policy_registration_invalid"))?; if request.policy.cid == request.owner_delegation.cid || request.policy.cid == request.enforcement_delegation.cid || request.owner_delegation.cid == request.enforcement_delegation.cid @@ -1115,6 +1142,7 @@ fn validate_policy( request: &RegisterRequest, config: &ShareEmailConfig, enforcer_did: &str, + now: OffsetDateTime, ) -> Result<(), ()> { if envelope.domain != POLICY_DOMAIN { return Err(()); @@ -1138,7 +1166,7 @@ fn validate_policy( } let policy_expiry = OffsetDateTime::parse(&policy.expires_at, &Rfc3339).map_err(|_| ())?; if timestamp(policy_expiry) != policy.expires_at - || policy_expiry <= OffsetDateTime::now_utc() + || policy_expiry <= now || !legacy_expiry_allowed(policy_expiry) { return Err(()); @@ -1216,10 +1244,7 @@ fn validate_policy( return Err(()); } let expiry = OffsetDateTime::parse(&policy.expires_at, &Rfc3339).map_err(|_| ())?; - if timestamp(expiry) != policy.expires_at - || expiry <= OffsetDateTime::now_utc() - || !legacy_expiry_allowed(expiry) - { + if timestamp(expiry) != policy.expires_at || expiry <= now || !legacy_expiry_allowed(expiry) { return Err(()); } Ok(()) @@ -1275,17 +1300,17 @@ async fn verify_owner_delegation( return Err(()); } let delegation = TinyCloudDelegation::from_bytes(&bytes).map_err(|_| ())?; - verify_delegation_signature(&delegation).await?; + verify_delegation_signature(&delegation, runtime.ucan_validation_time()).await?; let info = DelegationInfo::try_from(delegation).map_err(|_| ())?; if info.delegation_mode == DelegationMode::Terminal || !did_principal_matches(&info.delegator, &policy.owner_did) || !did_principal_matches(&info.delegate, &policy.share_key_did) || row .expiry - .is_none_or(|expiry| expiry <= OffsetDateTime::now_utc()) + .is_none_or(|expiry| expiry <= runtime.evaluation_time()) || row .not_before - .is_some_and(|not_before| not_before > OffsetDateTime::now_utc()) + .is_some_and(|not_before| not_before > runtime.evaluation_time()) { return Err(()); } @@ -1367,13 +1392,18 @@ fn owner_delegation_abilities_match( true } -async fn verify_delegation_signature(delegation: &TinyCloudDelegation) -> Result<(), ()> { +async fn verify_delegation_signature( + delegation: &TinyCloudDelegation, + evaluation_time: Option, +) -> Result<(), ()> { match delegation { TinyCloudDelegation::Ucan(ucan) => { tinycloud_core::did_resolution::verify_ucan_signature(ucan) .await .map_err(|_| ())?; - ucan.payload().validate_time(None).map_err(|_| ())?; + ucan.payload() + .validate_time(evaluation_time) + .map_err(|_| ())?; } TinyCloudDelegation::Cacao(cacao) => { cacao.verify().await.map_err(|_| ())?; @@ -1933,10 +1963,11 @@ async fn registered_policy( }, &runtime.config, &runtime.enforcer_did, + runtime.evaluation_time(), ) .map_err(|_| ())?; let expiry = OffsetDateTime::parse(&row.expires_at, &Rfc3339).map_err(|_| ())?; - if expiry <= OffsetDateTime::now_utc() { + if expiry <= runtime.evaluation_time() { return Err(()); } let matcher = match policy.policy.recipient_matcher.kind.as_str() { @@ -2009,7 +2040,19 @@ async fn verify_registered_ancestors( ®istered.envelope, ®istered.row.policy_cid, &enforcement, + )?; + tinycloud_core::key_authority::check_principals( + &runtime.conn, + &[ + registered.row.owner_did.clone(), + registered.row.share_key_did.clone(), + registered.row.enforcer_did.clone(), + ], + std::slice::from_ref(®istered.row.space_id), ) + .await + .map_err(|_| ())?; + Ok(()) } async fn cid_revoked(db: &C, cid: &str) -> Result { @@ -2308,7 +2351,7 @@ fn verify_runtime_delegation( ) .map_err(|_| ())?; let session_expiry = OffsetDateTime::parse(session_expires_at, &Rfc3339).map_err(|_| ())?; - if expiry <= OffsetDateTime::now_utc() || expiry > session_expiry { + if expiry <= runtime.evaluation_time() || expiry > session_expiry { return Err(()); } Ok(()) @@ -2871,7 +2914,7 @@ pub async fn invoke_v2( if !runtime.live().await { return Err(error(Status::ServiceUnavailable, "capability_unavailable")); } - if !legacy_v2_reads_open() { + if !legacy_v2_reads_open(runtime.evaluation_time()) { return Err(error(Status::Gone, "legacy_v2_reads_closed")); } let raw = read_body(data).await?; @@ -2882,7 +2925,7 @@ pub async fn invoke_v2( .await .map_err(|_| error(Status::ServiceUnavailable, "capability_unavailable"))? .ok_or_else(|| share_error("invoke_denied"))?; - let now = OffsetDateTime::now_utc(); + let now = runtime.evaluation_time(); if session.revoked_at.is_some() || OffsetDateTime::parse(&session.expires_at, &Rfc3339) .map_err(|_| share_error("invoke_denied"))? @@ -2928,6 +2971,13 @@ pub async fn invoke_v2( verify_registered_ancestors(runtime, ®istered, None) .await .map_err(|_| share_error("invoke_denied"))?; + tinycloud_core::key_authority::check_principals( + &runtime.conn, + std::slice::from_ref(&challenge.holder_did), + std::slice::from_ref(®istered.row.space_id), + ) + .await + .map_err(|_| share_error("invoke_denied"))?; let credential_digest = session .binding_json .get("credentialDigest") @@ -3267,13 +3317,24 @@ pub async fn authorize_delivery_v2( if !runtime.live().await { return Err(error(Status::ServiceUnavailable, "capability_unavailable")); } - invocation_model::verify_and_authorize( - &runtime.conn, - &invocation.0 .0, - OffsetDateTime::now_utc(), - ) - .await - .map_err(|_| error(Status::Unauthorized, "delivery_authorization_invalid"))?; + let now = runtime.evaluation_time(); + #[cfg(test)] + let authorization = match runtime.ucan_validation_time() { + Some(signed_time) => { + invocation_model::verify_and_authorize_with_signed_time( + &runtime.conn, + &invocation.0 .0, + now, + signed_time, + ) + .await + } + None => invocation_model::verify_and_authorize(&runtime.conn, &invocation.0 .0, now).await, + }; + #[cfg(not(test))] + let authorization = + invocation_model::verify_and_authorize(&runtime.conn, &invocation.0 .0, now).await; + authorization.map_err(|_| error(Status::Unauthorized, "delivery_authorization_invalid"))?; let raw = read_body(data).await?; let request: V2DeliveryRequest = serde_json::from_slice(&raw) .map_err(|_| error(Status::BadRequest, "delivery_authorization_invalid"))?; @@ -3294,9 +3355,25 @@ pub async fn authorize_delivery_v2( verify_registered_ancestors(runtime, ®istered, None) .await .map_err(|_| share_error("delivery_authorization_invalid"))?; + let proof_ids = invocation + .0 + .0 + .parents + .iter() + .copied() + .map(tinycloud_core::hash::Hash::from) + .collect::>(); + tinycloud_core::key_authority::check_invoker_chain( + &runtime.conn, + &proof_ids, + &invocation.0 .0.invoker, + std::slice::from_ref(®istered.row.space_id), + ) + .await + .map_err(|_| share_error("delivery_authorization_invalid"))?; let expires = OffsetDateTime::parse(&request.expires_at, &Rfc3339) .map_err(|_| share_error("delivery_authorization_invalid"))?; - let now = OffsetDateTime::now_utc(); + let now = runtime.evaluation_time(); if expires <= now || expires > now + time::Duration::minutes(5) || decode_canonical_b64(&request.jti, 16).map_or(true, |value| value.len() != 16) @@ -3398,11 +3475,12 @@ mod tests { listener.local_addr().unwrap().port() ); let ucan = signed_ucan(&issuer, &JWK::generate_ed25519().unwrap(), Algorithm::EdDSA); - assert!( - super::verify_delegation_signature(&TinyCloudDelegation::Ucan(Box::new(ucan))) - .await - .is_err() - ); + assert!(super::verify_delegation_signature( + &TinyCloudDelegation::Ucan(Box::new(ucan)), + None + ) + .await + .is_err()); assert_no_connections(&[&listener]).await; } use super::*; @@ -3411,6 +3489,8 @@ mod tests { #[path = "alias_revocation_tests.rs"] mod alias_revocation_tests; + #[path = "share_v2_legacy_key_tests.rs"] + mod share_v2_legacy_key_tests; #[test] fn policy_domain_matches_the_sdk_wire_contract() { @@ -3508,18 +3588,39 @@ mod tests { fn policy_validation_accepts_arbitrary_exact_email_matchers() { let (mut envelope, request, config) = sample_policy(); envelope.policy.recipient_matcher.value = "person+tag@any.example".to_owned(); - assert!(validate_policy(&envelope, &request, &config, "did:key:z6MkEnforcer").is_ok()); + assert!(validate_policy( + &envelope, + &request, + &config, + "did:key:z6MkEnforcer", + OffsetDateTime::now_utc() + ) + .is_ok()); } #[test] fn policy_validation_rejects_path_and_action_widening() { let (mut envelope, request, config) = sample_policy(); envelope.policy.resource.path = "shares/share-1/../other.md".to_owned(); - assert!(validate_policy(&envelope, &request, &config, "did:key:z6MkEnforcer").is_err()); + assert!(validate_policy( + &envelope, + &request, + &config, + "did:key:z6MkEnforcer", + OffsetDateTime::now_utc() + ) + .is_err()); let (mut envelope, request, config) = sample_policy(); envelope.policy.actions.push("tinycloud.kv/list".to_owned()); - assert!(validate_policy(&envelope, &request, &config, "did:key:z6MkEnforcer").is_err()); + assert!(validate_policy( + &envelope, + &request, + &config, + "did:key:z6MkEnforcer", + OffsetDateTime::now_utc() + ) + .is_err()); } #[test] @@ -3527,12 +3628,26 @@ mod tests { let (mut envelope, request, config) = sample_policy(); envelope.policy.resource.path = "shares/other/document.md".to_owned(); envelope.policy.content_source.path = "shares/other/document.md".to_owned(); - assert!(validate_policy(&envelope, &request, &config, "did:key:z6MkEnforcer").is_err()); + assert!(validate_policy( + &envelope, + &request, + &config, + "did:key:z6MkEnforcer", + OffsetDateTime::now_utc() + ) + .is_err()); let (mut envelope, request, config) = sample_policy(); envelope.policy.content_source_digest = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned(); - assert!(validate_policy(&envelope, &request, &config, "did:key:z6MkEnforcer").is_err()); + assert!(validate_policy( + &envelope, + &request, + &config, + "did:key:z6MkEnforcer", + OffsetDateTime::now_utc() + ) + .is_err()); } #[test] diff --git a/tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs b/tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs index 66703c46..09ffe5f9 100644 --- a/tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs +++ b/tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs @@ -126,7 +126,7 @@ async fn signed_alias_consumers(reverse: bool) { assert_ne!(hash(&historical), hash(&canonical)); for bytes in [&historical, &canonical, &unrelated] { let parsed = TinyCloudDelegation::from_bytes(bytes).unwrap(); - verify_delegation_signature(&parsed).await.unwrap(); + verify_delegation_signature(&parsed, None).await.unwrap(); let event = Delegation::from_header_ser::(&encode_config(bytes, base64::URL_SAFE)) .unwrap(); diff --git a/tinycloud-node-server/src/share_v2/tests/share_v2_legacy_key_tests.rs b/tinycloud-node-server/src/share_v2/tests/share_v2_legacy_key_tests.rs new file mode 100644 index 00000000..151e1d20 --- /dev/null +++ b/tinycloud-node-server/src/share_v2/tests/share_v2_legacy_key_tests.rs @@ -0,0 +1,781 @@ +use super::*; +use tinycloud_auth::{ + authorization::{make_invocation, InvocationOptions}, + resolver::DID_METHODS, + siwe_recap::Ability as UcanAbility, + ssi::{ + claims::jwt::NumericDate, + dids::{DIDBuf, DIDURLBuf}, + jwk::{Algorithm, JWK}, + ucan::Payload, + }, + ucan_capabilities_object::Capabilities, +}; +use tinycloud_core::{ + models::{actor, key_revocation, space}, + types::{Ability, Caveats, SpaceIdWrap}, +}; + +// Registration and challenge/session creation closed September 30. This +// signed fixture reconstructs a historical registration and short-lived +// session. A test-only clock should pin evaluation to October 13, 2026 +// 12:00 UTC. Its October 14 expiry is already past under today's real clock; +// reads remain open until January 5, while challenge/mint still return 410. +const HISTORICAL_EVALUATION_AT: &str = "2026-10-13T12:00:00.000Z"; + +fn historical_evaluation_time() -> OffsetDateTime { + OffsetDateTime::parse(HISTORICAL_EVALUATION_AT, &Rfc3339).unwrap() +} + +struct HistoricalShare { + challenge: Value, + session_id: String, + sender: JWK, + sender_did: String, + sender_vm: String, + sender_delegation_cid: tinycloud_auth::ipld_core::cid::Cid, + holder: Ed25519InvitationSigner, + registered: RegisteredPolicy, +} + +fn owner_identity() -> (JWK, String, String) { + let owner = JWK::generate_ed25519().expect("owner Ed25519 key"); + let did = DID_METHODS + .generate(&owner, "key") + .expect("owner DID") + .to_string(); + let vm = canonical_did_key_kid(&did); + (owner, did, vm) +} + +async fn historical_share( + runtime: &ShareV2Runtime, + owner: &JWK, + owner_did: &str, + owner_vm: &str, + share_id: &str, +) -> HistoricalShare { + let (share_key, share_did) = tamper_keypair_and_did(); + let (holder, holder_did) = tamper_keypair_and_did(); + let (mut sender, sender_did, sender_vm) = owner_identity(); + sender.algorithm = Some(Algorithm::EdDSA); + let path = format!("shares/{share_id}"); + let space_id = tinycloud_auth::resource::SpaceId::new( + owner_did.parse::().expect("owner DID"), + "applications".parse().expect("space name"), + ); + let space_id_string = space_id.to_string(); + let expires_at = "2026-10-14T00:00:00.000Z".to_owned(); + let actions = vec![ + "tinycloud.kv/get".to_owned(), + "tinycloud.kv/list".to_owned(), + ]; + let service = "kv".parse().expect("kv service"); + let resource_path = path.parse().expect("resource path"); + let resource = space_id.to_resource(service, Some(resource_path), None, None); + let mut capabilities = Capabilities::new(); + for action in &actions { + capabilities.with_action( + resource.as_uri(), + action.parse::().expect("UCAN action"), + [std::collections::BTreeMap::::new()], + ); + } + let owner_ucan = Payload { + issuer: owner_vm + .parse::() + .expect("owner verification method"), + audience: share_did.parse::().expect("share DID"), + not_before: None, + expiration: NumericDate::try_from_seconds(1_791_936_000.0).expect("October 14, 2026"), + nonce: Some(format!("historical-{share_id}")), + facts: Some(Vec::::new()), + proof: vec![], + attenuation: capabilities, + } + .sign(Algorithm::EdDSA, owner) + .expect("signed owner delegation") + .encode() + .expect("encoded owner delegation") + .into_bytes(); + let owner_cid = raw_blake3_cid(&owner_ucan); + let owner_id: tinycloud_core::hash::Hash = owner_cid + .parse::() + .expect("owner CID") + .into(); + actor::ActiveModel { + id: Set(share_did.clone()), + } + .insert(&runtime.conn) + .await + .expect("share-key actor"); + delegation::ActiveModel { + id: Set(owner_id), + delegator: Set(owner_did.to_owned()), + delegatee: Set(share_did.clone()), + expiry: Set(Some(OffsetDateTime::parse(&expires_at, &Rfc3339).unwrap())), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(runtime.policy_encryption.encrypt(&owner_ucan)), + } + .insert(&runtime.conn) + .await + .expect("historical signed owner delegation row"); + for action in &actions { + abilities::ActiveModel { + resource: Set(Resource::TinyCloud(resource.clone())), + ability: Set(Ability::try_from(action.clone()).expect("stored ability")), + delegation: Set(owner_id), + caveats: Set(Caveats(Default::default())), + } + .insert(&runtime.conn) + .await + .expect("owner ability row"); + } + // A separate owner-signed grant gives the sender exactly one ordinary + // capability for the delivery route's authenticated invocation. + let mut sender_capabilities = Capabilities::new(); + sender_capabilities.with_action( + resource.as_uri(), + "tinycloud.kv/get".parse::().unwrap(), + [std::collections::BTreeMap::::new()], + ); + let sender_ucan = Payload { + issuer: owner_vm.parse::().unwrap(), + audience: sender_did.parse::().unwrap(), + not_before: None, + expiration: NumericDate::try_from_seconds(1_791_936_000.0).unwrap(), + nonce: Some(format!("historical-sender-{share_id}")), + facts: Some(Vec::::new()), + proof: vec![], + attenuation: sender_capabilities, + } + .sign(Algorithm::EdDSA, owner) + .expect("signed sender grant") + .encode() + .expect("encoded sender grant") + .into_bytes(); + let sender_delegation_cid: tinycloud_auth::ipld_core::cid::Cid = + raw_blake3_cid(&sender_ucan).parse().expect("sender CID"); + actor::ActiveModel { + id: Set(sender_did.clone()), + } + .insert(&runtime.conn) + .await + .expect("sender actor"); + delegation::ActiveModel { + id: Set(sender_delegation_cid.into()), + delegator: Set(owner_did.to_owned()), + delegatee: Set(sender_did.clone()), + expiry: Set(Some(OffsetDateTime::parse(&expires_at, &Rfc3339).unwrap())), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(runtime.policy_encryption.encrypt(&sender_ucan)), + } + .insert(&runtime.conn) + .await + .expect("historical signed sender grant row"); + abilities::ActiveModel { + resource: Set(Resource::TinyCloud(resource.clone())), + ability: Set(Ability::try_from("tinycloud.kv/get".to_owned()).unwrap()), + delegation: Set(sender_delegation_cid.into()), + caveats: Set(Caveats(Default::default())), + } + .insert(&runtime.conn) + .await + .expect("sender ability row"); + let source = serde_json::json!({ + "kind": "kv", "space": space_id_string, "path": path, "action": "tinycloud.kv/get" + }); + let source_digest = b64_digest(&jcs::canonicalize(&source)); + let policy = serde_json::json!({ + "domain": POLICY_DOMAIN, + "policy": { + "type": "TinyCloudSharePolicy", "version": 2, + "shareId": share_id, "ownerDid": owner_did, "shareKeyDid": share_did, + "recipientMatcher": {"kind": "exactEmail", "value": "alice@example.com"}, + "target": {"origin": runtime.config.target_origin, "nodeAudience": runtime.config.node_audience, + "enforcerDid": runtime.enforcer_did, "spaceId": space_id_string}, + "resource": {"kind": "prefix", "path": path}, + "actions": actions, "contentSource": source, + "contentSourceDigest": source_digest, "ownerDelegationCid": owner_cid, + "expiresAt": expires_at + } + }); + let policy_bytes = jcs::canonicalize(&policy); + let policy_cid = raw_sha256_cid(&policy_bytes); + let policy_proof = encode_config( + share_key.sign(&policy_bytes).expect("policy signed"), + URL_SAFE_NO_PAD, + ); + let facts: EnforcementFacts = serde_json::from_value(serde_json::json!({ + "ownerDelegationCid": owner_cid, "policyCid": policy_cid, "shareId": share_id, + "shareKeyDid": share_did, "enforcerDid": runtime.enforcer_did, + "nodeAudience": runtime.config.node_audience, "spaceId": space_id_string, + "path": path, "actions": actions, "contentSourceDigest": source_digest, + "expiresAt": expires_at + })) + .expect("enforcement facts"); + let enforcement_wire = serde_json::json!({ + "domain": ENFORCEMENT_DOMAIN, + "unsigned": {"type": "TinyCloudSharePolicyEnforcement", "version": 2, + "issuerDid": share_did, "audienceDid": runtime.enforcer_did, "facts": facts} + }); + let enforcement_bytes = + serde_ipld_dagcbor::to_vec(&enforcement_wire).expect("canonical DAG-CBOR"); + let enforcement = EnforcementDelegationInput { + cid: raw_sha256_cid(&enforcement_bytes), + dag_cbor: encode_config(&enforcement_bytes, URL_SAFE_NO_PAD), + issuer_did: share_did.clone(), + audience_did: runtime.enforcer_did.clone(), + facts, + signature: encode_config( + share_key + .sign(&enforcement_bytes) + .expect("enforcement signed"), + URL_SAFE_NO_PAD, + ), + }; + let matcher = &policy["policy"]["recipientMatcher"]; + let registration = Registration { + registration_cid: String::new(), + policy_cid: policy_cid.clone(), + owner_delegation_cid: owner_cid.clone(), + enforcement_delegation_cid: enforcement.cid.clone(), + owner_did: owner_did.to_owned(), + share_key_did: share_did.clone(), + enforcer_did: runtime.enforcer_did.clone(), + share_id: share_id.to_owned(), + recipient_matcher: serde_json::from_value(matcher.clone()).unwrap(), + target: RegistrationTarget { + origin: runtime.config.target_origin.clone(), + node_audience: runtime.config.node_audience.clone(), + enforcer_did: runtime.enforcer_did.clone(), + space_id: space_id_string.clone(), + }, + resource: ExactResource { + kind: "prefix".into(), + path: path.clone(), + }, + actions: actions.clone(), + decryption: None, + content_source: serde_json::from_value(source.clone()).unwrap(), + content_source_digest: source_digest.clone(), + registered_at: "2026-09-29T00:00:00.000Z".into(), + expires_at: expires_at.clone(), + }; + let mut core = serde_json::to_value(®istration).unwrap(); + core.as_object_mut().unwrap().remove("registrationCid"); + let registration_cid = raw_sha256_cid(&jcs::canonicalize(&core)); + let row = owner_share_policy::ActiveModel { + policy_cid: Set(policy_cid.clone()), + registration_cid: Set(registration_cid.clone()), + owner_delegation_cid: Set(owner_cid.clone()), + enforcement_delegation_cid: Set(enforcement.cid.clone()), + policy_bytes: Set(runtime.policy_encryption.encrypt(&policy_bytes)), + enforcement_delegation_bytes: Set(runtime + .policy_encryption + .encrypt(&serde_json::to_vec(&enforcement).unwrap())), + policy_proof: Set(policy_proof.clone()), + policy_digest: Set(b64_digest(&policy_bytes)), + matcher_digest: Set(b64_digest(&jcs::canonicalize(matcher))), + content_source_digest: Set(source_digest.clone()), + owner_did: Set(owner_did.to_owned()), + share_key_did: Set(share_did.clone()), + enforcer_did: Set(runtime.enforcer_did.clone()), + node_audience: Set(runtime.config.node_audience.clone()), + target_origin: Set(runtime.config.target_origin.clone()), + space_id: Set(space_id_string.clone()), + resource_kind: Set("prefix".to_owned()), + resource_path: Set(path.clone()), + actions: Set(serde_json::to_value(&actions).unwrap()), + registered_at: Set(registration.registered_at), + expires_at: Set(expires_at.clone()), + revoked_at: Set(None), + }; + let row = row + .insert(&runtime.conn) + .await + .expect("historical policy registration row"); + // The live-clock registration/ancestor helpers reject this expired fixture + // before the route assertions. The routes verify its signed ancestors at + // the historical evaluation time once the test clock is injected. + verify_policy_proof(&share_did, &policy_bytes, &policy_proof) + .expect("historically signed policy verifies"); + let envelope: PolicyEnvelope = serde_json::from_value(policy.clone()).unwrap(); + verify_enforcement_delegation(runtime, &envelope, &policy_cid, &enforcement) + .expect("historically signed enforcement verifies"); + let registered = RegisteredPolicy { + row, + envelope, + matcher: TypedRecipientMatcher::ExactEmail("alice@example.com".to_owned()), + expiry: OffsetDateTime::parse(&expires_at, &Rfc3339).unwrap(), + }; + + let target = policy["policy"]["target"].clone(); + let envelope_identity = serde_json::json!({ + "schema": "xyz.tinycloud.share/envelope/v2", "version": 2, + "shareId": share_id, "delegationCid": owner_cid, "policyCid": policy_cid, + "target": target, "resource": {"kind": "prefix", "path": path}, + "actions": actions, "contentSource": source, "contentSourceDigest": source_digest, + "expiresAt": expires_at + }); + let envelope_cid = raw_sha256_cid(&jcs::canonicalize(&envelope_identity)); + let share_cid = raw_sha256_cid(&jcs::canonicalize(&serde_json::json!({ + "version": 2, "shareId": share_id, "policyCid": policy_cid, "envelopeCid": envelope_cid + }))); + let mut outer = envelope_identity; + let obj = outer.as_object_mut().unwrap(); + obj.insert("envelopeCid".into(), Value::String(envelope_cid.clone())); + obj.insert("shareCid".into(), Value::String(share_cid.clone())); + let mut signed_outer = b"xyz.tinycloud.share/envelope/v2\0".to_vec(); + signed_outer.extend(jcs::canonicalize(&outer)); + outer.as_object_mut().unwrap().insert("signature".into(), serde_json::json!({ + "signerDid": share_did, "algorithm": "Ed25519", + "value": encode_config(share_key.sign(&signed_outer).expect("signed outer envelope"), URL_SAFE_NO_PAD) + })); + let mut challenge = serde_json::json!({ + "envelopeCid": envelope_cid, "shareCid": share_cid, "shareId": share_id, + "registrationCid": registration_cid, "delegationCid": owner_cid, + "policyCid": policy_cid, "enforcementDelegationCid": enforcement.cid, + "enforcementDelegation": enforcement, "outerEnvelope": outer, + "contentSource": source, "contentSourceDigest": source_digest, + "holderDid": holder_did, "targetOrigin": runtime.config.target_origin, + "nodeAudience": runtime.config.node_audience, "action": "tinycloud.kv/list", + "actions": actions, "resource": path + }); + let digest = b64_digest(&jcs::canonicalize(&challenge)); + challenge + .as_object_mut() + .unwrap() + .insert("requestBodyDigest".into(), Value::String(digest)); + let challenge_request: V2ChallengeRequest = serde_json::from_value(challenge.clone()).unwrap(); + let outer_signature = decode_config( + challenge_request.outer_envelope["signature"]["value"] + .as_str() + .expect("signed outer envelope"), + URL_SAFE_NO_PAD, + ) + .expect("canonical signed outer envelope"); + verify_detached_ed25519(&share_did, &signed_outer, &outer_signature) + .expect("historically signed outer envelope verifies"); + // No post-cutoff mint route exists. Reconstruct only its durable binding + // from the real node signer and a genuine holder-signed presentation. + let session_id = format!("historical-session-{share_id}"); + let session_issued = timestamp(historical_evaluation_time()); + let session_expires = timestamp(historical_evaluation_time() + Duration::minutes(4)); + let credential_digest = b64_digest(b"synthetic credential bound to test-only session"); + let presentation = serde_json::json!({ + "type": "TinyCloudSharePolicyPresentation", "version": 2, + "challengeId": format!("synthetic-{share_id}"), "nonce": format!("nonce-{share_id}"), + "shareCid": share_cid, "shareId": share_id, "delegationCid": owner_cid, + "policyCid": policy_cid, "contentSource": source, "contentSourceDigest": source_digest, + "holderDid": holder_did, "targetOrigin": runtime.config.target_origin, + "nodeAudience": runtime.config.node_audience, "enforcerDid": runtime.enforcer_did, + "credentialDigest": credential_digest, "action": "tinycloud.kv/list", + "actions": actions, "resource": path, + "requestBodyDigest": challenge_request.request_body_digest, + "issuedAt": session_issued, "expiresAt": session_expires, "jti": format!("vp-{share_id}") + }); + let presentation_proof = signed_value(&holder, V2_SESSION_DOMAIN, &presentation) + .expect("holder-signed session presentation"); + verify_v2_proof( + &holder_did, + &presentation_proof, + V2_SESSION_DOMAIN, + &presentation, + ) + .expect("holder session proof verifies"); + let vp_digest = b64_digest(&jcs::canonicalize(&presentation)); + let runtime_delegation = create_runtime_delegation( + runtime, + ®istered, + &challenge_request, + &holder_did, + &credential_digest, + &vp_digest, + &session_expires, + ) + .await + .expect("node-signed reconstructed session delegation"); + share_session_handle::ActiveModel { + session_handle: Set(session_id.clone()), + authority_session_cid: Set(registration_cid), + binding_json: Set(serde_json::json!({ + "challenge": challenge, "holderDid": holder_did, + "credentialDigest": credential_digest, "vpDigest": vp_digest, + "runtimeDelegation": runtime_delegation + })), + holder_digest: Set(b64_digest(holder_did.as_bytes())), + issued_at: Set(session_issued), + expires_at: Set(session_expires), + revoked_at: Set(None), + } + .insert(&runtime.conn) + .await + .expect("reconstructed short-lived holder session row"); + HistoricalShare { + challenge: serde_json::to_value(challenge_request).unwrap(), + session_id, + holder, + registered, + sender, + sender_did, + sender_vm, + sender_delegation_cid, + } +} + +async fn invoke(client: &Client, share: &HistoricalShare, jti: &str) -> (Status, Value) { + let challenge = &share.challenge; + let now = historical_evaluation_time(); + let mut invocation = serde_json::json!({ + "type": "TinyCloudShareReadInvocation", "version": 2, + "sessionId": share.session_id, "envelopeCid": challenge["envelopeCid"], + "shareCid": challenge["shareCid"], "shareId": challenge["shareId"], + "registrationCid": challenge["registrationCid"], "delegationCid": challenge["delegationCid"], + "policyCid": challenge["policyCid"], + "enforcementDelegationCid": challenge["enforcementDelegationCid"], + "contentSource": challenge["contentSource"], "contentSourceDigest": challenge["contentSourceDigest"], + "holderDid": challenge["holderDid"], "nodeAudience": challenge["nodeAudience"], + "action": "tinycloud.kv/list", "actions": challenge["actions"], + "resource": challenge["resource"], "issuedAt": timestamp(now), + "expiresAt": timestamp(now + Duration::minutes(1)), "jti": jti, + "bodyDigest": null, "ifMatch": null, "contentType": null + }); + let digest = b64_digest(&jcs::canonicalize(&invocation)); + invocation + .as_object_mut() + .unwrap() + .insert("requestBodyDigest".into(), Value::String(digest.clone())); + let mut request = serde_json::json!({ + "sessionId": share.session_id, "envelopeCid": challenge["envelopeCid"], + "shareCid": challenge["shareCid"], "shareId": challenge["shareId"], + "registrationCid": challenge["registrationCid"], "delegationCid": challenge["delegationCid"], + "policyCid": challenge["policyCid"], + "enforcementDelegationCid": challenge["enforcementDelegationCid"], + "contentSource": challenge["contentSource"], "contentSourceDigest": challenge["contentSourceDigest"], + "holderDid": challenge["holderDid"], "nodeAudience": challenge["nodeAudience"], + "action": "tinycloud.kv/list", "actions": challenge["actions"], + "resource": challenge["resource"], "requestBodyDigest": digest, + "invocation": invocation + }); + let proof = signed_value(&share.holder, V2_INVOCATION_DOMAIN, &invocation) + .expect("holder signs real invocation"); + request + .as_object_mut() + .unwrap() + .insert("proof".into(), serde_json::to_value(proof).unwrap()); + let response = client + .post("/share/v2/invoke") + .header(rocket::http::ContentType::JSON) + .body(serde_json::to_vec(&serde_json::json!({"request": request})).unwrap()) + .dispatch() + .await; + let status = response.status(); + let body: Value = serde_json::from_slice(&response.into_bytes().await.unwrap()).unwrap(); + (status, body) +} + +async fn authorize_delivery(client: &Client, share: &HistoricalShare, jti: u8) -> (Status, Value) { + let challenge = &share.challenge; + let mut request = serde_json::json!({ + "envelopeCid": challenge["envelopeCid"], "shareCid": challenge["shareCid"], + "shareId": challenge["shareId"], "registrationCid": challenge["registrationCid"], + "policyCid": challenge["policyCid"], "delegationCid": challenge["delegationCid"], + "enforcementDelegationCid": challenge["enforcementDelegationCid"], + "recipientEmail": "alice@example.com", + "shareUrl": format!("https://share.tinycloud.xyz/s/{}", challenge["shareCid"].as_str().unwrap()), + "documentName": "historical document", + "jti": encode_config([jti; 16], URL_SAFE_NO_PAD), + "expiresAt": timestamp(historical_evaluation_time() + Duration::minutes(4)) + }); + let digest = request_digest_without(&request, "requestBodyDigest").expect("delivery digest"); + request + .as_object_mut() + .unwrap() + .insert("requestBodyDigest".into(), Value::String(digest)); + let space_id = share + .registered + .row + .space_id + .parse::() + .expect("registered space"); + let path = share + .registered + .row + .resource_path + .parse() + .expect("registered path"); + let resource = space_id.to_resource("kv".parse().unwrap(), Some(path), None, None); + let invocation = make_invocation( + [( + resource, + ["tinycloud.kv/get".parse::().unwrap()], + )], + &share.sender_delegation_cid, + &share.sender, + &share.sender_vm, + (historical_evaluation_time() + Duration::seconds(45)).unix_timestamp() as f64, + InvocationOptions::default(), + ) + .expect("real sender-signed ordinary invocation"); + let response = client + .post("/share/v2/deliveries/authorize") + .header(rocket::http::ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + invocation.encode().unwrap(), + )) + .body(serde_json::to_vec(&request).unwrap()) + .dispatch() + .await; + let status = response.status(); + let body: Value = serde_json::from_slice(&response.into_bytes().await.unwrap()).unwrap(); + (status, body) +} + +async fn creation_closed(client: &Client, route: &str, body: &Value) { + let response = client + .post(route) + .header(rocket::http::ContentType::JSON) + .body(serde_json::to_vec(body).unwrap()) + .dispatch() + .await; + assert_eq!( + response.status(), + Status::Gone, + "{route} must remain closed after September 30" + ); + let body: Value = serde_json::from_slice(&response.into_bytes().await.unwrap()).unwrap(); + assert_eq!(body["error"]["code"], "legacy_v2_creation_closed"); +} + +#[tokio::test] +async fn historical_signed_share_invoke_and_delivery_stop_after_share_key_tombstone_without_affecting_other_shares( +) { + let key_setup = StaticSecret::new(vec![0x59u8; 32]).expect("node test key"); + let mut runtime = + compose_test_runtime(&key_setup, Some(TeeContext::derive_local(&key_setup)), true).await; + runtime.evaluation_time = Some(historical_evaluation_time()); + assert!( + runtime.live().await, + "composed node must accept read requests" + ); + let (owner, owner_did, owner_vm) = owner_identity(); + actor::ActiveModel { + id: Set(owner_did.clone()), + } + .insert(&runtime.conn) + .await + .unwrap(); + let space_id = tinycloud_auth::resource::SpaceId::new( + owner_did.parse().unwrap(), + "applications".parse().unwrap(), + ); + space::ActiveModel { + id: Set(SpaceIdWrap(space_id)), + } + .insert(&runtime.conn) + .await + .unwrap(); + let revoked = historical_share(&runtime, &owner, &owner_did, &owner_vm, "legacy-revoked").await; + let unaffected = + historical_share(&runtime, &owner, &owner_did, &owner_vm, "legacy-control").await; + let share_did = revoked.registered.row.share_key_did.clone(); + let historically_registered = registered_policy( + &runtime, + &revoked.registered.row.registration_cid, + &revoked.registered.row.policy_cid, + ) + .await + .expect("unrevoked historical policy must validate at the pinned evaluation time"); + verify_registered_ancestors(&runtime, &historically_registered, None) + .await + .expect("unrevoked historical signed ancestors must validate"); + let client = Client::tracked( + rocket::build() + .manage(Some(runtime.clone())) + .manage(crate::BlockStage::from( + crate::config::StagingStorage::Memory, + )) + .mount( + "/", + rocket::routes![ + policy_challenge_v2, + policy_session_v2, + invoke_v2, + authorize_delivery_v2 + ], + ), + ) + .await + .expect("real Rocket client"); + + // September 30 closed *creation*, not historically registered read access. + creation_closed(&client, "/share/v2/policy/challenges", &revoked.challenge).await; + creation_closed(&client, "/share/v2/policy/session", &serde_json::json!({})).await; + let (status, body) = invoke(&client, &revoked, "pre-revoke").await; + assert_eq!( + status, + Status::Ok, + "signed historical read must work: {body}" + ); + assert_eq!(body["action"], "tinycloud.kv/list"); + assert_eq!( + invoke(&client, &unaffected, "control-before").await.0, + Status::Ok + ); + // This same signed fixture cannot reopen the read route after its + // production January 5, 2027 sunset; the cutoff precedes session lookup. + let mut after_sunset = runtime.clone(); + after_sunset.evaluation_time = Some( + OffsetDateTime::parse("2027-01-06T00:00:00.000Z", &Rfc3339) + .expect("post-sunset evaluation time"), + ); + let sunset_client = Client::tracked( + rocket::build() + .manage(Some(after_sunset)) + .manage(crate::BlockStage::from( + crate::config::StagingStorage::Memory, + )) + .mount("/", rocket::routes![invoke_v2]), + ) + .await + .expect("post-sunset Rocket route"); + let (status, body) = invoke(&sunset_client, &revoked, "after-read-sunset").await; + assert_eq!(status, Status::Gone, "legacy read sunset: {body}"); + assert_eq!(body["error"]["code"], "legacy_v2_reads_closed"); + let (status, body) = authorize_delivery(&client, &revoked, 1).await; + assert_eq!( + status, + Status::Ok, + "signed sender delivery must work: {body}" + ); + assert_eq!(body["authorization"]["senderDid"], revoked.sender_did); + assert_eq!(body["authorization"]["senderTrust"], "verified"); + assert_eq!( + authorize_delivery(&client, &unaffected, 2).await.0, + Status::Ok + ); + + // Persist the same canonical global DID tombstone written by explicit key + // revoke. No CID revocation or policy/session flag is used as a substitute. + key_revocation::ActiveModel { + target_did: Set(share_did), + scope_key: Set("global".to_owned()), + revoked_at: Set(OffsetDateTime::now_utc().unix_timestamp()), + } + .insert(&runtime.conn) + .await + .expect("durable key tombstone"); + creation_closed(&client, "/share/v2/policy/challenges", &revoked.challenge).await; + creation_closed(&client, "/share/v2/policy/session", &serde_json::json!({})).await; + let (status, body) = invoke(&client, &revoked, "post-revoke-new-jti").await; + assert_eq!( + status, + Status::Forbidden, + "revoked share key must deny invoke: {body}" + ); + assert_eq!(body["error"]["code"], "invoke_denied"); + let (status, body) = authorize_delivery(&client, &revoked, 3).await; + assert_eq!( + status, + Status::Forbidden, + "revoked share key must deny delivery: {body}" + ); + assert_eq!(body["error"]["code"], "delivery_authorization_invalid"); + let (status, body) = invoke(&client, &unaffected, "control-after").await; + assert_eq!( + status, + Status::Ok, + "unrelated share must still work: {body}" + ); + let (status, body) = authorize_delivery(&client, &unaffected, 4).await; + assert_eq!( + status, + Status::Ok, + "unrelated share delivery must still work: {body}" + ); +} + +#[tokio::test] +async fn historical_signed_delivery_denies_revoked_owner_enforcer_or_sender() { + for (index, target) in ["owner", "enforcer", "sender"].iter().enumerate() { + let key_setup = StaticSecret::new(vec![0x59u8; 32]).expect("node test key"); + let mut runtime = + compose_test_runtime(&key_setup, Some(TeeContext::derive_local(&key_setup)), true) + .await; + runtime.evaluation_time = Some(historical_evaluation_time()); + assert!( + runtime.live().await, + "composed node must accept read requests" + ); + let (owner, owner_did, owner_vm) = owner_identity(); + actor::ActiveModel { + id: Set(owner_did.clone()), + } + .insert(&runtime.conn) + .await + .unwrap(); + let space_id = tinycloud_auth::resource::SpaceId::new( + owner_did.parse().unwrap(), + "applications".parse().unwrap(), + ); + space::ActiveModel { + id: Set(SpaceIdWrap(space_id)), + } + .insert(&runtime.conn) + .await + .unwrap(); + let share = historical_share( + &runtime, + &owner, + &owner_did, + &owner_vm, + &format!("legacy-delivery-{target}"), + ) + .await; + let revoked_did = match *target { + "owner" => owner_did, + "enforcer" => runtime.enforcer_did.clone(), + "sender" => share.sender_did.clone(), + _ => unreachable!(), + }; + let client = Client::tracked(rocket::build().manage(Some(runtime.clone())).mount( + "/", + rocket::routes![ + policy_challenge_v2, + policy_session_v2, + authorize_delivery_v2 + ], + )) + .await + .expect("real Rocket client"); + creation_closed(&client, "/share/v2/policy/challenges", &share.challenge).await; + creation_closed(&client, "/share/v2/policy/session", &serde_json::json!({})).await; + let (status, body) = authorize_delivery(&client, &share, (index * 2 + 10) as u8).await; + assert_eq!(status, Status::Ok, "{target} pre-revoke delivery: {body}"); + assert_eq!(body["authorization"]["senderDid"], share.sender_did); + assert_eq!(body["authorization"]["senderTrust"], "verified"); + + key_revocation::ActiveModel { + target_did: Set(revoked_did), + scope_key: Set("global".to_owned()), + revoked_at: Set(OffsetDateTime::now_utc().unix_timestamp()), + } + .insert(&runtime.conn) + .await + .expect("durable key tombstone"); + creation_closed(&client, "/share/v2/policy/challenges", &share.challenge).await; + creation_closed(&client, "/share/v2/policy/session", &serde_json::json!({})).await; + let (status, body) = authorize_delivery(&client, &share, (index * 2 + 11) as u8).await; + assert!( + status == Status::Unauthorized || status == Status::Forbidden, + "{target} tombstone must deny delivery, got {status}: {body}" + ); + assert_eq!(body["error"]["code"], "delivery_authorization_invalid"); + } +} diff --git a/tinycloud-node-server/src/signed_urls.rs b/tinycloud-node-server/src/signed_urls.rs index f75bf213..b6b04496 100644 --- a/tinycloud-node-server/src/signed_urls.rs +++ b/tinycloud-node-server/src/signed_urls.rs @@ -413,7 +413,89 @@ pub async fn load_signed_kv_ticket( "signed URL ticket not found".to_string(), ) })?; - validate_signed_kv_ticket(&ticket)?; + let (space, _) = validate_signed_kv_ticket(&ticket)?; + let issuer = tinycloud_auth::identity::principal_did(&ticket.issuer_did).map_err(|_| { + ( + Status::Forbidden, + "signed URL authority invalid".to_string(), + ) + })?; + let subject = tinycloud_auth::identity::principal_did(&ticket.subject_did).map_err(|_| { + ( + Status::Forbidden, + "signed URL authority invalid".to_string(), + ) + })?; + if issuer != subject || space.to_string() != ticket.space_id { + return Err(( + Status::Forbidden, + "signed URL authority invalid".to_string(), + )); + } + let parents = match ticket.parent_cids_json.as_deref() { + Some(serialized) => { + let cids: Vec = serde_json::from_str(serialized).map_err(|_| { + ( + Status::Forbidden, + "signed URL authority invalid".to_string(), + ) + })?; + if cids.is_empty() { + return Err(( + Status::Forbidden, + "signed URL authority invalid".to_string(), + )); + } + let mut parents = Vec::with_capacity(cids.len()); + for value in cids { + let cid = value + .parse::() + .map_err(|_| { + ( + Status::Forbidden, + "signed URL authority invalid".to_string(), + ) + })?; + if cid.to_string() != value || parents.contains(&cid) { + return Err(( + Status::Forbidden, + "signed URL authority invalid".to_string(), + )); + } + parents.push(cid); + } + parents + } + None => { + if issuer + != tinycloud_auth::identity::principal_did(space.did().as_str()).map_err(|_| { + ( + Status::Forbidden, + "signed URL authority invalid".to_string(), + ) + })? + { + return Err(( + Status::Forbidden, + "signed URL authority invalid".to_string(), + )); + } + Vec::new() + } + }; + tinycloud + .ticket_authority_unrevoked( + &parents, + &ticket.subject_did, + std::slice::from_ref(&ticket.space_id), + ) + .await + .map_err(|_| { + ( + Status::Forbidden, + "signed URL authority revoked".to_string(), + ) + })?; Ok(ticket) } @@ -1066,8 +1148,9 @@ mod tests { assert!(!response.url.contains("?token=")); assert!(!response.url.contains(&space.to_string())); - let ticket = load_signed_kv_ticket(&tinycloud, &response.ticket_id) - .await + let ticket = tinycloud + .find_signed_kv_ticket(&response.ticket_id) + .await? .expect("minted ticket should be persisted"); let (ticket_space, ticket_path) = validate_signed_kv_ticket(&ticket).expect("persisted ticket should validate"); diff --git a/tinycloud-node-server/src/webhook_dispatcher.rs b/tinycloud-node-server/src/webhook_dispatcher.rs index 831d08d2..99898a3a 100644 --- a/tinycloud-node-server/src/webhook_dispatcher.rs +++ b/tinycloud-node-server/src/webhook_dispatcher.rs @@ -392,12 +392,31 @@ mod tests { callback_url: &str, subscription_id: &str, subscriber_did: &str, + ) -> Result<()> { + insert_subscription_in_space( + db, + encryption, + callback_url, + subscription_id, + subscriber_did, + SPACE_ID, + ) + .await + } + + async fn insert_subscription_in_space( + db: &DatabaseConnection, + encryption: &ColumnEncryption, + callback_url: &str, + subscription_id: &str, + subscriber_did: &str, + space_id: &str, ) -> Result<()> { hook_subscription::Entity::insert(hook_subscription::ActiveModel::from( hook_subscription::Model { id: subscription_id.to_string(), subscriber_did: subscriber_did.to_string(), - space_id: SPACE_ID.to_string(), + space_id: space_id.to_string(), target_service: "kv".to_string(), path_prefix: Some("documents".to_string()), abilities_json: Some( @@ -420,12 +439,22 @@ mod tests { subscription_id: &str, delivery_id: &str, attempts: i64, + ) -> Result<()> { + insert_delivery_in_space(db, subscription_id, delivery_id, attempts, SPACE_ID).await + } + + async fn insert_delivery_in_space( + db: &DatabaseConnection, + subscription_id: &str, + delivery_id: &str, + attempts: i64, + space_id: &str, ) -> Result<()> { hook_delivery::Entity::insert(hook_delivery::ActiveModel::from(hook_delivery::Model { id: delivery_id.to_string(), subscription_id: subscription_id.to_string(), event_id: delivery_id.replace("delivery", "event"), - payload_json: serde_json::json!({"id": "event_01", "space": SPACE_ID}).to_string(), + payload_json: serde_json::json!({"id": "event_01", "space": space_id}).to_string(), status: tinycloud_core::db::HOOK_DELIVERY_STATUS_PENDING.to_string(), attempts, next_attempt_at: None, @@ -650,6 +679,7 @@ mod tests { /// subscriber's DID, which only the authority check does. mod postgres_connection_loss { use super::*; + use std::collections::BTreeMap; use std::{ collections::BTreeSet, net::SocketAddr, @@ -658,12 +688,23 @@ mod tests { Arc, }, }; + use tinycloud_auth::{ + authorization::{HeaderEncode, TinyCloudDelegation}, + resolver::DID_METHODS, + resource::SpaceId, + ssi::{ + claims::jwt::NumericDate, + dids::{DIDBuf, DIDURLBuf}, + jwk::JWK, + ucan::Payload, + }, + ucan_capabilities_object::{Ability, Capabilities}, + }; + use tinycloud_core::events::Delegation; use tinycloud_core::{ db_pool::PoolRecoveryStats, - hash::hash, - models::{abilities, actor, delegation}, + models::key_revocation, sea_orm::{ConnectionTrait, DbBackend, Statement}, - types::{Ability, Caveats, Resource}, RecoveringConnection, }; use tokio::{ @@ -777,6 +818,9 @@ mod tests { encryption: ColumnEncryption, proxy: Proxy, holder: String, + owner_key: JWK, + owner_did: String, + space_id: String, callback_url: String, callbacks: tokio::sync::mpsc::UnboundedReceiver<(String, String, String)>, } @@ -798,9 +842,18 @@ mod tests { .await .unwrap(); - // A holder of `tinycloud.hooks/register` on `kv/documents` - // through the space owner's delegation, as in TC-991's tests. - let holder = format!("did:key:z6MkTc991Dispatch{unique}"); + // Register a real owner-signed UCAN grant on this fixture's space. + let owner_key = JWK::generate_ed25519().unwrap(); + let owner_did = DID_METHODS.generate(&owner_key, "key").unwrap().to_string(); + let space_id = SpaceId::new( + owner_did.parse::().unwrap(), + "files".parse().unwrap(), + ) + .to_string(); + let holder = DID_METHODS + .generate(&JWK::generate_ed25519().unwrap(), "key") + .unwrap() + .to_string(); let upstream = Url::parse(url).unwrap(); let upstream_addr: SocketAddr = format!( "{}:{}", @@ -851,7 +904,7 @@ mod tests { .sqlx_logging(false) .set_schema_search_path(schema.clone()); let setup = Database::connect(setup_options).await.unwrap(); - grant_hook_register(&setup, &holder).await; + grant_hook_register(&tinycloud, &owner_key, &owner_did, &holder, &space_id).await; let (callback_url, callbacks) = spawn_callback_server(StatusCode::OK).await.unwrap(); @@ -864,6 +917,9 @@ mod tests { encryption, proxy, holder, + owner_key, + owner_did, + space_id, callback_url, callbacks, } @@ -897,16 +953,17 @@ mod tests { self.holder, self.holder.strip_prefix("did:key:").unwrap() ); - insert_subscription( + insert_subscription_in_space( &self.setup, &self.encryption, &self.callback_url, &subscription, &subscriber, + &self.space_id, ) .await .unwrap(); - insert_delivery(&self.setup, &subscription, &delivery, 0) + insert_delivery_in_space(&self.setup, &subscription, &delivery, 0, &self.space_id) .await .unwrap(); (subscription, delivery) @@ -1038,50 +1095,263 @@ mod tests { } } - async fn grant_hook_register(db: &DatabaseConnection, holder: &str) { - let space = SPACE_ID - .parse::() + async fn grant_hook_register( + tinycloud: &TinyCloud, + owner_key: &JWK, + owner_did: &str, + holder: &str, + space_id: &str, + ) { + let space = space_id.parse::().unwrap(); + let resource = space.to_resource( + "hooks".parse().unwrap(), + Some("kv/documents".parse().unwrap()), + None, + None, + ); + let mut capabilities = Capabilities::new(); + capabilities.with_action( + resource.as_uri(), + "tinycloud.hooks/register".parse::().unwrap(), + [BTreeMap::::new()], + ); + let fragment = owner_did.rsplit_once(':').unwrap().1; + let payload = Payload { + issuer: format!("{owner_did}#{fragment}") + .parse::() + .unwrap(), + audience: holder.parse::().unwrap(), + not_before: None, + expiration: NumericDate::try_from_seconds( + OffsetDateTime::now_utc().unix_timestamp() as f64 + 3600.0, + ) + .unwrap(), + nonce: Some(format!("tc1149-{holder}-{space_id}")), + facts: Some(Vec::::new()), + proof: vec![], + attenuation: capabilities, + }; + let ucan = payload + .sign(owner_key.get_algorithm().unwrap_or_default(), owner_key) .unwrap(); - let owner = space.did().to_string(); - let now = OffsetDateTime::now_utc(); - for id in [&owner, &holder.to_string()] { - actor::ActiveModel { - id: ActiveValue::Set(id.to_string()), - } - .insert(db) + let header = TinyCloudDelegation::Ucan(Box::new(ucan)).encode().unwrap(); + let delegation = Delegation::from_header_ser::(&header).unwrap(); + tinycloud + .delegate(delegation) + .await + .unwrap_or_else(|_| panic!("signed hook grant must persist")); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn tc1149_revoked_holder_stops_queued_webhook_but_owner_stays_live() { + let Some(url) = + postgres_url("tc1149_revoked_holder_stops_queued_webhook_but_owner_stays_live") + else { + return; + }; + let mut fixture = Fixture::new(&url).await; + let dispatcher = fixture.dispatcher(); + let (subscription, before) = fixture.subscribe("holder_before").await; + dispatcher.dispatch_due_once().await.unwrap(); + fixture.assert_delivered(&before).await; + + let after = "delivery_holder_after"; + insert_delivery_in_space(&fixture.setup, &subscription, after, 0, &fixture.space_id) .await .unwrap(); + key_revocation::ActiveModel { + target_did: ActiveValue::Set(fixture.holder.clone()), + scope_key: ActiveValue::Set("global".to_string()), + revoked_at: ActiveValue::Set(OffsetDateTime::now_utc().unix_timestamp()), } - let id = hash(format!("tc991-dispatch-{holder}").as_bytes()); - delegation::ActiveModel { - id: ActiveValue::Set(id), - delegator: ActiveValue::Set(owner), - delegatee: ActiveValue::Set(holder.to_string()), - expiry: ActiveValue::Set(Some(now + TimeDuration::hours(1))), - issued_at: ActiveValue::Set(Some(now)), - not_before: ActiveValue::Set(None), - facts: ActiveValue::Set(None), - serialization: ActiveValue::Set(vec![]), + .insert(&fixture.setup) + .await + .unwrap(); + insert_subscription_in_space( + &fixture.setup, + &fixture.encryption, + &fixture.callback_url, + "sub_owner_control", + &fixture.owner_did, + &fixture.space_id, + ) + .await + .unwrap(); + insert_delivery_in_space( + &fixture.setup, + "sub_owner_control", + "delivery_owner_control", + 0, + &fixture.space_id, + ) + .await + .unwrap(); + + dispatcher.dispatch_due_once().await.unwrap(); + fixture.assert_delivered("delivery_owner_control").await; + assert!( + fixture.callbacks.try_recv().is_err(), + "revoked holder emitted a callback" + ); + let denied = fixture.delivery(after).await; + assert_eq!( + denied.status, + tinycloud_core::db::HOOK_DELIVERY_STATUS_DEAD_LETTER + ); + assert!(!fixture.subscription_active(&subscription).await); + fixture.drop_schema().await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn tc1149_revoked_owner_root_stops_queued_webhook() { + let Some(url) = postgres_url("tc1149_revoked_owner_root_stops_queued_webhook") else { + return; + }; + let mut fixture = Fixture::new(&url).await; + insert_subscription_in_space( + &fixture.setup, + &fixture.encryption, + &fixture.callback_url, + "sub_root", + &fixture.owner_did, + &fixture.space_id, + ) + .await + .unwrap(); + insert_delivery_in_space( + &fixture.setup, + "sub_root", + "delivery_root_before", + 0, + &fixture.space_id, + ) + .await + .unwrap(); + let dispatcher = fixture.dispatcher(); + dispatcher.dispatch_due_once().await.unwrap(); + fixture.assert_delivered("delivery_root_before").await; + + insert_delivery_in_space( + &fixture.setup, + "sub_root", + "delivery_root_after", + 0, + &fixture.space_id, + ) + .await + .unwrap(); + key_revocation::ActiveModel { + target_did: ActiveValue::Set(fixture.owner_did.clone()), + scope_key: ActiveValue::Set("global".to_string()), + revoked_at: ActiveValue::Set(OffsetDateTime::now_utc().unix_timestamp()), } - .insert(db) + .insert(&fixture.setup) .await .unwrap(); - abilities::ActiveModel { - delegation: ActiveValue::Set(id), - resource: ActiveValue::Set(Resource::TinyCloud(space.to_resource( - "hooks".parse().unwrap(), - Some("kv/documents".parse().unwrap()), - None, - None, - ))), - ability: ActiveValue::Set( - Ability::try_from("tinycloud.hooks/register".to_string()).unwrap(), - ), - caveats: ActiveValue::Set(Caveats::default()), + dispatcher.dispatch_due_once().await.unwrap(); + assert!( + fixture.callbacks.try_recv().is_err(), + "revoked root emitted a callback" + ); + let denied = fixture.delivery("delivery_root_after").await; + assert_eq!( + denied.status, + tinycloud_core::db::HOOK_DELIVERY_STATUS_DEAD_LETTER + ); + assert!(!fixture.subscription_active("sub_root").await); + fixture.drop_schema().await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn tc1149_space_revocation_stops_only_matching_webhook_space() { + let Some(url) = + postgres_url("tc1149_space_revocation_stops_only_matching_webhook_space") + else { + return; + }; + let mut fixture = Fixture::new(&url).await; + let other_space = SpaceId::new( + fixture.owner_did.parse::().unwrap(), + "other".parse().unwrap(), + ) + .to_string(); + grant_hook_register( + &fixture.tinycloud, + &fixture.owner_key, + &fixture.owner_did, + &fixture.holder, + &other_space, + ) + .await; + let (primary, before) = fixture.subscribe("scoped_before").await; + let subscriber = format!( + "{}#{}", + fixture.holder, + fixture.holder.strip_prefix("did:key:").unwrap() + ); + insert_subscription_in_space( + &fixture.setup, + &fixture.encryption, + &fixture.callback_url, + "sub_other_space", + &subscriber, + &other_space, + ) + .await + .unwrap(); + let dispatcher = fixture.dispatcher(); + dispatcher.dispatch_due_once().await.unwrap(); + fixture.assert_delivered(&before).await; + insert_delivery_in_space( + &fixture.setup, + "sub_other_space", + "delivery_other_before", + 0, + &other_space, + ) + .await + .unwrap(); + dispatcher.dispatch_due_once().await.unwrap(); + fixture.assert_delivered("delivery_other_before").await; + + insert_delivery_in_space( + &fixture.setup, + &primary, + "delivery_scoped_after", + 0, + &fixture.space_id, + ) + .await + .unwrap(); + insert_delivery_in_space( + &fixture.setup, + "sub_other_space", + "delivery_other_after", + 0, + &other_space, + ) + .await + .unwrap(); + key_revocation::ActiveModel { + target_did: ActiveValue::Set(fixture.holder.clone()), + scope_key: ActiveValue::Set(format!("space:{}", fixture.space_id)), + revoked_at: ActiveValue::Set(OffsetDateTime::now_utc().unix_timestamp()), } - .insert(db) + .insert(&fixture.setup) .await .unwrap(); + dispatcher.dispatch_due_once().await.unwrap(); + fixture.assert_delivered("delivery_other_after").await; + assert!( + fixture.callbacks.try_recv().is_err(), + "scoped-revoked holder emitted a callback" + ); + assert_eq!( + fixture.delivery("delivery_scoped_after").await.status, + tinycloud_core::db::HOOK_DELIVERY_STATUS_DEAD_LETTER + ); + assert!(!fixture.subscription_active(&primary).await); + assert!(fixture.subscription_active("sub_other_space").await); + fixture.drop_schema().await; } /// (a) Every idle pooled connection is terminated, then one pass diff --git a/tinycloud-node-server/tests/did_resolution_single_entry.rs b/tinycloud-node-server/tests/did_resolution_single_entry.rs deleted file mode 100644 index 9eba02ac..00000000 --- a/tinycloud-node-server/tests/did_resolution_single_entry.rs +++ /dev/null @@ -1,736 +0,0 @@ -//! Scan all production items, including those following test-only items. -use proc_macro2::{TokenStream, TokenTree}; -use quote::ToTokens; -use std::path::{Path, PathBuf}; -use syn::{ - ext::IdentExt, - visit::{self, Visit}, - Attribute, Item, -}; - -fn ident_name(ident: &syn::Ident) -> String { - ident.unraw().to_string() -} - -fn normalized_tokens(tokens: TokenStream) -> TokenStream { - tokens - .into_iter() - .map(|token| match token { - TokenTree::Ident(ident) => TokenTree::Ident(ident.unraw()), - TokenTree::Group(group) => { - let mut normalized = - proc_macro2::Group::new(group.delimiter(), normalized_tokens(group.stream())); - normalized.set_span(group.span()); - TokenTree::Group(normalized) - } - other => other, - }) - .collect() -} - -fn canonical_tokens(value: &impl ToTokens) -> String { - normalized_tokens(value.to_token_stream()).to_string() -} - -fn source_files(dir: &Path, files: &mut Vec) { - for entry in std::fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - source_files(&path, files); - } else if path.extension().is_some_and(|ext| ext == "rs") { - files.push(path); - } - } -} - -fn test_only(attrs: &[Attribute]) -> bool { - attrs.iter().any(|attr| { - canonical_tokens(attr.path()) == "cfg" - && matches!(&attr.meta, syn::Meta::List(list) if canonical_tokens(&list.tokens) == "test") - }) -} - -fn item_attrs(item: &Item) -> &[Attribute] { - match item { - Item::Const(x) => &x.attrs, - Item::Enum(x) => &x.attrs, - Item::ExternCrate(x) => &x.attrs, - Item::Fn(x) => &x.attrs, - Item::ForeignMod(x) => &x.attrs, - Item::Impl(x) => &x.attrs, - Item::Macro(x) => &x.attrs, - Item::Mod(x) => &x.attrs, - Item::Static(x) => &x.attrs, - Item::Struct(x) => &x.attrs, - Item::Trait(x) => &x.attrs, - Item::TraitAlias(x) => &x.attrs, - Item::Type(x) => &x.attrs, - Item::Union(x) => &x.attrs, - Item::Use(x) => &x.attrs, - _ => &[], - } -} - -fn resolver_entry(name: &str) -> bool { - matches!( - name, - "verify_signature" - | "resolve" - | "resolve_with" - | "resolve_representation" - | "resolve_method_representation" - | "resolve_into_any_verification_method" - ) || name == "dereference" - || name.starts_with("dereference_") - || name.starts_with("resolve_verification_method") - || name.starts_with("into_vm_resolver") -} - -fn raw_resolver_ident(name: &str) -> bool { - matches!( - name, - "AnyDidMethod" - | "DID_METHODS" - | "DIDWeb" - | "DIDTz" - | "DIDION" - | "DIDEthr" - | "VerificationMethodDIDResolver" - | "VerificationParameters" - | "did_web" - | "did_tz" - | "did_ion" - | "did_ethr" - ) -} - -fn network_did_module(name: &str) -> bool { - matches!(name, "web" | "tz" | "ion" | "ethr") -} - -#[derive(Default)] -struct Guard { - file: String, - function: String, - violations: Vec, - helper_calls: usize, - test_modules: Vec, - module_dir: PathBuf, -} - -impl Guard { - fn new(file: &str) -> Self { - let path = Path::new(file); - let mut module_dir = path.parent().unwrap().to_path_buf(); - let stem = path.file_stem().unwrap().to_str().unwrap(); - if !matches!(stem, "lib" | "main" | "mod") { - module_dir.push(stem); - } - Self { - file: file.into(), - module_dir, - ..Self::default() - } - } - - fn allowed(&self, call: &syn::ExprMethodCall) -> bool { - let expression = canonical_tokens(call); - // Exact pre-existing calls only: storage config, byte ranges, pinned - // webhook DNS, share authority, and DID reference absolutization. - // Manifest::resolve is the existing non-UCAN, caller-supplied resolver - // API. Its single call is narrowly exempted; new calls still fail. - matches!((self.file.as_str(), self.function.as_str(), expression.as_str()), - ("tinycloud-core/src/manifest.rs", "resolve", "resolver . resolve (id . did ())") - | ("tinycloud-core/src/manifest.rs", "id_from_vm", "r . resolve (did)") - | ("tinycloud-core/src/share_email/ports.rs", "resolve_exact", "self . resolve (policy , delegation)") - | ("tinycloud-core/src/storage/memory.rs", "read_range", "range . resolve (total_size)") - | ("tinycloud-node-server/src/storage/file_system.rs", "read_range", "range . resolve (total_size)") - | ("tinycloud-node-server/src/storage/s3.rs", "parse_content_range", "ByteRangeSpec :: Inclusive { start , end } . resolve (total_size)") - | ("tinycloud-node-server/src/lib.rs", "resolve_runtime_config", "resolved . storage . resolve ()") - | ("tinycloud-node-server/src/runtime.rs", "serve_config_figment", "resolved . storage . resolve ()") - | ("tinycloud-node-server/src/runtime.rs", "launch_with_figment", "tinycloud_config . storage . resolve ()") - | ("tinycloud-node-server/src/node_control/service.rs", "effective_config", "config . storage . resolve ()") - | ("tinycloud-node-server/src/node_control/control.rs", "spawn_control_plane", "effective_config . storage . resolve ()") - | ("tinycloud-node-server/src/node_control/control.rs", "effective_public_config", "config . storage . resolve ()") - | ("tinycloud-node-server/src/webhook_dispatcher.rs", "pinned_client", "Client :: builder () . timeout (self . timeout) . redirect (Policy :: none ()) . resolve (host , address)") - ) - } - - fn auth_resolver_import(&self, item: &syn::ItemUse) -> bool { - self.file == "tinycloud-auth/src/resolver.rs" - && canonical_tokens(item) == "use ssi :: dids :: AnyDidMethod ;" - } - - fn auth_resolver_definition(&self, mac: &syn::Macro) -> bool { - // Exempt only the existing offline-generation registry declaration, - // never the rest of resolver.rs or another lazy_static body. - self.file == "tinycloud-auth/src/resolver.rs" - && canonical_tokens(&mac.path) == "lazy_static :: lazy_static" - && canonical_tokens(&mac.tokens) - == "pub static ref DID_METHODS : AnyDidMethod = AnyDidMethod :: default () ;" - } - - fn offline_cacao_verification(&self, call: &syn::ExprMethodCall) -> bool { - // CACAO's argument-free SIWE verification is offline. Pin these - // existing expressions; never exempt a file, function, or receiver. - matches!( - ( - self.file.as_str(), - self.function.as_str(), - canonical_tokens(call).as_str() - ), - ( - "tinycloud-core/src/models/delegation.rs", - "verify", - "cacao . verify ()" - ) | ( - "tinycloud-core/src/models/revocation.rs", - "process", - "c . verify ()" - ) | ( - "tinycloud-node-server/src/policy_v3.rs", - "authenticate_account_owner", - "cacao . verify ()" - ) | ( - "tinycloud-node-server/src/share_v2.rs", - "verify_delegation_signature", - "cacao . verify ()" - ) - ) - } - - fn scan_macro(&mut self, tokens: TokenStream) { - // syn leaves macro bodies opaque. Inspect their tokens too, so a raw - // resolver call inside a production macro cannot evade the guard. - let tokens: Vec<_> = tokens.into_iter().collect(); - for window in tokens.windows(4) { - if matches!(&window[0], TokenTree::Ident(ident) if ident_name(ident) == "dids") - && matches!(&window[1], TokenTree::Punct(punct) if punct.as_char() == ':') - && matches!(&window[2], TokenTree::Punct(punct) if punct.as_char() == ':') - { - match &window[3] { - TokenTree::Ident(ident) if network_did_module(&ident_name(ident)) => { - self.violations - .push(format!("network DID module dids::{ident} in macro")); - } - TokenTree::Group(_) => { - // Preserve grouped import paths such as - // dids::{web as resolver, key::{self, DIDKey}}. - if let Ok(tree) = syn::parse2(window.iter().cloned().collect()) { - self.scan_use_tree(&tree, false); - } - } - _ => {} - } - } - } - for token in tokens { - match token { - TokenTree::Group(group) => self.scan_macro(group.stream()), - TokenTree::Ident(ident) => { - let name = ident_name(&ident); - if raw_resolver_ident(&name) || resolver_entry(&name) { - self.violations - .push(format!("resolver token {name} in macro")); - } - } - _ => {} - } - } - } - - fn scan_use_tree(&mut self, tree: &syn::UseTree, parent_is_dids: bool) { - match tree { - syn::UseTree::Path(path) => { - self.check_module(&path.ident, parent_is_dids); - self.scan_use_tree(&path.tree, ident_name(&path.ident) == "dids"); - } - syn::UseTree::Name(name) => self.check_module(&name.ident, parent_is_dids), - syn::UseTree::Rename(rename) => self.check_module(&rename.ident, parent_is_dids), - syn::UseTree::Group(group) => { - for tree in &group.items { - self.scan_use_tree(tree, parent_is_dids); - } - } - syn::UseTree::Glob(_) => {} - } - } - - fn check_module(&mut self, ident: &syn::Ident, parent_is_dids: bool) { - if parent_is_dids && network_did_module(&ident_name(ident)) { - self.violations - .push(format!("network DID module dids::{ident}")); - } - } -} - -impl<'ast> Visit<'ast> for Guard { - fn visit_item(&mut self, item: &'ast Item) { - if test_only(item_attrs(item)) { - if let Item::Mod(module) = item { - if module.content.is_none() { - let dir = self.module_dir.join(ident_name(&module.ident)); - self.test_modules.push(dir.with_extension("rs")); - self.test_modules.push(dir.join("mod.rs")); - } - } - return; - } - visit::visit_item(self, item); - } - - fn visit_item_mod(&mut self, module: &'ast syn::ItemMod) { - let previous = self.module_dir.clone(); - self.module_dir.push(ident_name(&module.ident)); - visit::visit_item_mod(self, module); - self.module_dir = previous; - } - - fn visit_impl_item(&mut self, item: &'ast syn::ImplItem) { - let attrs = match item { - syn::ImplItem::Const(x) => &x.attrs, - syn::ImplItem::Fn(x) => &x.attrs, - syn::ImplItem::Type(x) => &x.attrs, - syn::ImplItem::Macro(x) => &x.attrs, - _ => return, - }; - if !test_only(attrs) { - visit::visit_impl_item(self, item); - } - } - - fn visit_trait_item(&mut self, item: &'ast syn::TraitItem) { - let attrs = match item { - syn::TraitItem::Const(x) => &x.attrs, - syn::TraitItem::Fn(x) => &x.attrs, - syn::TraitItem::Type(x) => &x.attrs, - syn::TraitItem::Macro(x) => &x.attrs, - _ => return, - }; - if !test_only(attrs) { - visit::visit_trait_item(self, item); - } - } - - fn visit_signature(&mut self, signature: &'ast syn::Signature) { - self.function = ident_name(&signature.ident); - visit::visit_signature(self, signature); - } - - fn visit_ident(&mut self, ident: &'ast syn::Ident) { - if raw_resolver_ident(&ident_name(ident)) { - self.violations.push(format!("raw resolver {ident}")); - } - } - - fn visit_path(&mut self, path: &'ast syn::Path) { - for (parent, child) in path.segments.iter().zip(path.segments.iter().skip(1)) { - self.check_module(&child.ident, ident_name(&parent.ident) == "dids"); - } - visit::visit_path(self, path); - } - - fn visit_item_use(&mut self, item: &'ast syn::ItemUse) { - if self.auth_resolver_import(item) { - return; - } - self.scan_use_tree(&item.tree, false); - visit::visit_item_use(self, item); - } - - fn visit_expr_method_call(&mut self, call: &'ast syn::ExprMethodCall) { - // DID generation is offline and does not resolve an issuer. Visit its - // arguments, but exempt only the DID_METHODS.generate receiver. - if ident_name(&call.method) == "generate" - && matches!(&*call.receiver, - syn::Expr::Path(path) if path.path.segments.last().is_some_and(|s| ident_name(&s.ident) == "DID_METHODS")) - { - for argument in &call.args { - self.visit_expr(argument); - } - return; - } - if !self.offline_cacao_verification(call) - && resolver_entry(&ident_name(&call.method)) - && !self.allowed(call) - { - self.violations.push(call.to_token_stream().to_string()); - } - // Ordinary verify() also serves fixed-key cryptography; any resolver - // types, factories, or calls in its receiver/arguments are still visited. - visit::visit_expr_method_call(self, call); - } - - fn visit_expr_call(&mut self, call: &'ast syn::ExprCall) { - if let syn::Expr::Path(path) = &*call.func { - let name = ident_name(&path.path.segments.last().unwrap().ident); - // Explicit non-DID function paths; aliases and unqualified raw - // resolve calls still fail. These operate on profile paths or - // stored SQL/DuckDB publication names, never on issuer DIDs. - let function_path = canonical_tokens(path); - let non_did = matches!( - (self.file.as_str(), function_path.as_str()), - ( - "tinycloud-node-server/src/node_control/paths.rs", - "ProfilePaths :: resolve" - ) | ( - "tinycloud-node-server/src/routes/mod.rs", - "database_migration :: resolve" - ) | ( - "tinycloud-node-server/src/share_email.rs", - "tinycloud_core :: database_migration :: resolve" - ) - ); - if resolver_entry(&name) && !non_did { - self.violations.push(call.to_token_stream().to_string()); - } - if matches!( - name.as_str(), - "verify_ucan_signature" | "verify_ucan_revocation_signature" - ) { - self.helper_calls += 1; - } - } - visit::visit_expr_call(self, call); - } - - fn visit_macro(&mut self, mac: &'ast syn::Macro) { - if self.auth_resolver_definition(mac) { - return; - } - self.scan_macro(mac.tokens.clone()); - } -} - -#[test] -fn ucan_resolution_has_one_production_entry_point() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); - let mut files = Vec::new(); - for dir in [ - "tinycloud-core/src", - "tinycloud-node-server/src", - "tinycloud-auth/src", - ] { - source_files(&root.join(dir), &mut files); - } - let mut scans = Vec::new(); - for path in files { - let relative = path.strip_prefix(root).unwrap().to_str().unwrap(); - if relative == "tinycloud-core/src/did_resolution.rs" { - continue; - } - let source = std::fs::read_to_string(&path).unwrap(); - let ast = - syn::parse_file(&source).unwrap_or_else(|error| panic!("{}: {error}", path.display())); - let mut guard = Guard::new(relative); - guard.visit_file(&ast); - scans.push(guard); - } - let test_modules: Vec<_> = scans.iter().flat_map(|scan| &scan.test_modules).collect(); - let mut calls = 0; - for scan in &scans { - if test_modules - .iter() - .any(|path| Path::new(&scan.file).starts_with(path)) - { - continue; - } - assert!( - scan.violations.is_empty(), - "UCAN resolver bypass in {}: {:?}", - scan.file, - scan.violations - ); - calls += scan.helper_calls; - } - assert!( - calls >= 5, - "expected at least five production helper calls, found {calls}" - ); -} - -#[test] -fn only_test_items_are_excluded_and_all_raw_entry_points_are_checked() { - let source = r#" - #[cfg(test)] extern crate tokio; - #[cfg(test)] mod tests { fn offline() { AnyDidMethod::default().resolve(did); } } - struct Example; - impl Example { - #[cfg(test)] fn fixture() { DID_METHODS.resolve(did); } - fn production() { - let resolver = AnyDidMethod::default(); - resolver.resolve(did); - resolver.resolve_representation(did, options); - resolver.resolve_with(did, options); - resolver.resolve_into_any_verification_method(did); - resolver.dereference(url); - resolver.dereference_with(url, options); - DIDResolver::resolve(&resolver, did); - DID_METHODS.resolve(did); - wrapper!(resolver.dereference(url)); - verify_ucan_signature(ucan); - } - } - #[cfg(test)] fn other_fixture() { DID_METHODS.resolve(did); } - fn later_production() { resolver.dereference(url); } - "#; - let mut guard = Guard::new("fixture.rs"); - guard.visit_file(&syn::parse_file(source).unwrap()); - assert_eq!(guard.helper_calls, 1); - assert_eq!(guard.violations.len(), 12, "{:?}", guard.violations); - - let mut guard = Guard::new("tinycloud-core/src/storage/memory.rs"); - guard.visit_file(&syn::parse_file("fn read_range() { range.resolve(total_size); }").unwrap()); - assert!(guard.violations.is_empty()); - guard.visit_file(&syn::parse_file("fn other() { range.resolve(total_size); }").unwrap()); - assert_eq!(guard.violations.len(), 1); -} - -fn scan_fixture(code: &str) -> Guard { - let mut guard = Guard::new("fixture.rs"); - guard.visit_file(&syn::parse_file(code).unwrap()); - guard -} - -#[test] -fn raw_identifiers_cannot_hide_resolver_names() { - for source in [ - "async fn production() { u.r#verify_signature(&dids::r#AnyDidMethod::default()).await; }", - "async fn production() { dids::r#DIDWeb.r#into_vm_resolver::().r#resolve_verification_method_with(id, None, options).await; }", - "async fn production() { adapter.r#resolve(did).await; adapter.r#dereference_with(url, options).await; }", - "async fn production() { Adapter::r#into_vm_resolver_with(adapter, options); }", - "wrapper!(dids::r#DIDTz.r#into_vm_resolver::());", - "use provider::r#dids::r#web as Renamed;", - "use provider::r#dids::{r#tz as Renamed, key::DIDKey};", - "type Adapter = provider::r#dids::r#ion::Resolver;", - "wrapper!(provider::r#dids::r#ethr::Resolver);", - "wrapper!(use provider::r#dids::{r#web as Renamed});", - ] { - let raw = scan_fixture(source); - let ordinary = scan_fixture(&source.replace("r#", "")); - assert!(!raw.violations.is_empty(), "raw identifier bypass: {source}"); - assert_eq!(raw.violations.len(), ordinary.violations.len(), "{source}"); - } - for ident in [ - "AnyDidMethod", - "DID_METHODS", - "DIDWeb", - "DIDTz", - "DIDION", - "DIDEthr", - "VerificationMethodDIDResolver", - "VerificationParameters", - "did_web", - "did_tz", - "did_ion", - "did_ethr", - ] { - for source in [ - format!("use provider::r#{ident} as Renamed;"), - format!("type Adapter = provider::r#{ident};"), - format!("wrapper!(provider::r#{ident});"), - ] { - assert!(!scan_fixture(&source).violations.is_empty(), "{source}"); - } - } -} - -#[test] -fn raw_identifiers_preserve_exact_offline_and_test_exceptions() { - let source = r##" - #[r#cfg(r#test)] mod r#tests { fn fixture() { r#AnyDidMethod::default(); } } - fn production() { - r#DID_METHODS.r#generate(&key, pattern); - r#verify_ucan_signature(ucan); - wrapper!("r#DIDWeb r#into_vm_resolver r#dids::r#web"); - } - "##; - let guard = scan_fixture(source); - assert!(guard.violations.is_empty(), "{:?}", guard.violations); - assert_eq!(guard.helper_calls, 1); - let mut guard = Guard::new("tinycloud-core/src/storage/memory.rs"); - guard.visit_file( - &syn::parse_file("fn r#read_range() { r#range.r#resolve(r#total_size); }").unwrap(), - ); - assert!(guard.violations.is_empty(), "{:?}", guard.violations); - guard.visit_file(&syn::parse_file("fn other() { r#range.r#resolve(r#total_size); }").unwrap()); - assert_eq!(guard.violations.len(), 1); - - let guard = scan_fixture("fn production() { r#DID_METHODS.r#generate(r#DIDWeb, pattern); }"); - assert!( - !guard.violations.is_empty(), - "generation arguments were exempted" - ); -} - -#[test] -fn auth_registry_exception_cannot_hide_another_resolver_call() { - let registry = include_str!("../../tinycloud-auth/src/resolver.rs"); - for source in [ - registry.to_string(), - registry - .replace("AnyDidMethod", "r#AnyDidMethod") - .replace("DID_METHODS", "r#DID_METHODS"), - ] { - let mut guard = Guard::new("tinycloud-auth/src/resolver.rs"); - guard.visit_file(&syn::parse_file(&source).unwrap()); - assert!(guard.violations.is_empty(), "{:?}", guard.violations); - let mut guard = Guard::new("tinycloud-auth/src/other.rs"); - guard.visit_file(&syn::parse_file(&source).unwrap()); - assert!( - !guard.violations.is_empty(), - "registry exception spread to another file" - ); - } - for source in [ - format!("{registry}\nasync fn verify_with_any(u: &Ucan) {{ u.verify_signature(&*DID_METHODS).await; }}"), - registry.replace("AnyDidMethod::default()", "AnyDidMethod::default().resolve(did)"), - registry.replace("pub static ref", "static ref"), - format!("{registry}\nwrapper!(DID_METHODS.resolve(did));"), - ] { - let mut guard = Guard::new("tinycloud-auth/src/resolver.rs"); - guard.visit_file(&syn::parse_file(&source).unwrap()); - assert!(!guard.violations.is_empty(), "auth bypass: {source}"); - } -} - -#[test] -fn offline_cacao_exceptions_are_exact_and_do_not_hide_claims_resolvers() { - for (file, function, receiver) in [ - ("tinycloud-core/src/models/delegation.rs", "verify", "cacao"), - ("tinycloud-core/src/models/revocation.rs", "process", "c"), - ( - "tinycloud-node-server/src/policy_v3.rs", - "authenticate_account_owner", - "cacao", - ), - ( - "tinycloud-node-server/src/share_v2.rs", - "verify_delegation_signature", - "cacao", - ), - ] { - let mut guard = Guard::new(file); - guard.function = function.into(); - let call: syn::ExprMethodCall = syn::parse_str(&format!("{receiver}.verify()")).unwrap(); - assert!(guard.offline_cacao_verification(&call)); - guard.visit_expr_method_call(&call); - assert!(guard.violations.is_empty()); - let call: syn::ExprMethodCall = syn::parse_str(&format!( - "{receiver}.verify(VerificationParameters::from_resolver(DIDWeb.into_vm_resolver::()))" - )).unwrap(); - assert!(!guard.offline_cacao_verification(&call)); - guard.visit_expr_method_call(&call); - assert!( - !guard.violations.is_empty(), - "CACAO exception hid a claims resolver" - ); - guard.function = "another_function".into(); - let call = syn::parse_str(&format!("{receiver}.verify()")).unwrap(); - assert!(!guard.offline_cacao_verification(&call)); - } -} - -#[test] -fn vm_and_claims_bypasses_and_existing_raw_spellings_are_refused() { - // Review 5513828343's four surviving spellings and two already killed - // spellings. Parsing these fixtures never invokes a resolver or claims verifier. - let cases = [ - ("web VM", "DIDWeb.into_vm_resolver::().resolve_verification_method_with(id, None, options).await;"), - ("tz VM", "DIDTz::default().into_vm_resolver::().resolve_verification_method_with(id, None, options).await;"), - ("web claims", "jws.verify(VerificationParameters::from_resolver(DIDWeb.into_vm_resolver::())).await;"), - ("tz claims", "jws.verify(VerificationParameters::from_resolver(DIDTz::default().into_vm_resolver::())).await;"), - ("raw signature", "u.verify_signature(&AnyDidMethod::default()).await;"), - ("raw resolve", "DID_METHODS.resolve(d).await;"), - ]; - for (name, expression) in cases { - let production = format!("async fn production() {{ {expression} }}"); - let guard = scan_fixture(&production); - assert!(!guard.violations.is_empty(), "{name} bypass survived"); - let test_only = format!("#[cfg(test)] mod fixture {{ {production} }}"); - assert!( - scan_fixture(&test_only).violations.is_empty(), - "{name} test fixture was scanned" - ); - } -} - -#[test] -fn vm_method_families_are_refused_without_concrete_type_names() { - for expression in [ - "adapter.into_vm_resolver::();", - "adapter.into_vm_resolver_with::(options);", - "adapter.resolve_verification_method(id).await;", - "adapter.resolve_verification_method_with(id, None, options).await;", - "DIDResolver::into_vm_resolver::(adapter);", - "DIDResolver::into_vm_resolver_with::(adapter, options);", - "VerificationMethodResolver::resolve_verification_method_with(&adapter, id, None, options).await;", - "wrapper!(adapter.into_vm_resolver_with::(options));", - "wrapper!(adapter.resolve_verification_method_with(id, None, options));", - ] { - let guard = scan_fixture(&format!("async fn production() {{ {expression} }}")); - assert!(!guard.violations.is_empty(), "method bypass survived: {expression}"); - } -} - -#[test] -fn network_resolver_imports_paths_and_claims_parameters_are_refused() { - for ident in [ - "DIDWeb", - "DIDTz", - "DIDION", - "DIDEthr", - "VerificationMethodDIDResolver", - "VerificationParameters", - "did_web", - "did_tz", - "did_ion", - "did_ethr", - ] { - for source in [ - format!("use provider::{ident} as Renamed;"), - format!("type Adapter = provider::{ident};"), - format!("wrapper!(provider::{ident});"), - ] { - assert!( - !scan_fixture(&source).violations.is_empty(), - "identifier bypass survived: {source}" - ); - } - } - for module in ["web", "tz", "ion", "ethr"] { - for source in [ - format!("use provider::dids::{module} as Renamed;"), - format!("use provider::dids::{{{module} as Renamed, key::DIDKey}};"), - format!("use provider::dids::{{{module}::*, key::DIDKey}};"), - format!("type Adapter = provider::dids::{module}::Resolver;"), - format!("wrapper!(provider::dids::{module}::Resolver);"), - format!("wrapper!(use provider::dids::{{{module} as Renamed, key::DIDKey}});"), - ] { - assert!( - !scan_fixture(&source).violations.is_empty(), - "module bypass survived: {source}" - ); - } - } -} - -#[test] -fn non_did_module_names_comments_and_strings_remain_allowed() { - let source = r#" - // DIDWeb.into_vm_resolver::() is documentation, not a call. - use provider::{web, tz, ion, ethr}; - use provider::dids::{key::web as key_web, pkh}; - fn production() { - let _description = "VerificationParameters and provider::dids::web"; - web::request(); - tz::offset(); - ion::charge(); - ethr::lookup(); - wrapper!(provider::dids::key::web); - wrapper!(use provider::dids::{key::web as key_web}); - } - "#; - let guard = scan_fixture(source); - assert!(guard.violations.is_empty(), "{:?}", guard.violations); -} diff --git a/tinycloud-node-server/tests/postgres_round_trip_budget.rs b/tinycloud-node-server/tests/postgres_round_trip_budget.rs index 95e33b00..059c1b5f 100644 --- a/tinycloud-node-server/tests/postgres_round_trip_budget.rs +++ b/tinycloud-node-server/tests/postgres_round_trip_budget.rs @@ -53,12 +53,13 @@ //! cache has expired, so whether a given SQL call pays it depends on wall //! time. //! -//! The counts are deterministic without sleeping for them: every request is -//! measured between two barriers that wait until every pooled connection is -//! idle again, so the release ping of the last statement is inside the window -//! and no request overlaps another's traffic. The flows run three times -//! first, so the pool has grown to the connections it needs and the -//! statements are prepared. +//! The counts are deterministic: every request is measured between two +//! barriers that wait until every pooled connection is idle again, so the +//! release ping of the last statement is inside the window and no request +//! overlaps another's traffic. The flows run three times first, so the pool +//! has grown to the connections they need and the statements are prepared. +//! A dispatcher poll executed after warm-up is settled before measurement; +//! its connection setup is attributed separately from request traffic. //! //! # Budgets //! @@ -114,14 +115,16 @@ use tokio::{ }; /// Round trips allowed per flow; see the module docs. Measured with -/// `test_before_acquire(false)`, database identity resolve as one statement, -/// the read audit as one statement and no second sequence lock (TC-951): +/// `test_before_acquire(false)`, database identity resolve as one statement +/// and the read audit as one statement (TC-951). TC-1149 adds canonical +/// principal/ancestor cutoff checks and transactional target row locks; the +/// immutable proof signers and CID alias identities share one request query: /// -/// - sign-in 75 = peer key 0 + host `/delegate` 17 + session `/delegate` 18 -/// + account-space SQL write 40; -/// - kv put 37; -/// - kv get 22, including the read-audit commit. -const BUDGETS: [(&str, u64); 3] = [("sign-in", 75), ("kv put", 37), ("kv get", 22)]; +/// - sign-in 89 = peer key 0 + host `/delegate` 20 + session `/delegate` 21 +/// + account-space SQL write 48; +/// - kv put 49, including cross-process principal lock rows and recheck; +/// - kv get 30, including the live key tombstone and read-audit commit. +const BUDGETS: [(&str, u64); 3] = [("sign-in", 89), ("kv put", 49), ("kv get", 30)]; /// Full sign-in plus put plus get rounds run before the measured one. const WARM_UP_ROUNDS: u8 = 3; @@ -194,7 +197,7 @@ struct RoundTrip { #[derive(Default)] struct Log { - connections: u64, + started_connections: Vec, round_trips: Vec, } @@ -203,7 +206,7 @@ struct Wire(Arc>); #[derive(Clone, Copy)] struct Mark { - connections: u64, + connections: usize, round_trips: usize, } @@ -215,17 +218,80 @@ impl Wire { fn mark(&self) -> Mark { let log = self.log(); Mark { - connections: log.connections, + connections: log.started_connections.len(), round_trips: log.round_trips.len(), } } fn since(&self, mark: Mark) -> Window { let log = self.log(); - Window { - connections: log.connections - mark.connections, + let new_connections = &log.started_connections[mark.connections..]; + let mut window = Window { + connections: new_connections.len() as u64, round_trips: log.round_trips[mark.round_trips..].to_vec(), + }; + // A new connection belongs to the dispatcher only if its first + // executed non-setup SQL is an actual dispatcher SELECT. At most one + // exact startup timeout SET and bare pings can precede it. A second + // SET, a SQL batch, another statement, or a Parse without execution + // never hides request work or a request-path connection. + for &connection in new_connections { + let mut saw_setup_set = false; + let mut saw_poll_parse = false; + let mut poll_execution = None; + for (index, round_trip) in window + .round_trips + .iter() + .enumerate() + .filter(|(_, round_trip)| round_trip.connection == connection) + { + if !saw_poll_parse + && round_trip.kind == Kind::Counted + && round_trip.executed.is_empty() + && round_trip.parsed.is_empty() + { + continue; + } + if !saw_poll_parse + && !saw_setup_set + && round_trip.kind == Kind::Counted + && round_trip.parsed.is_empty() + && round_trip.executed.as_slice() == ["SET statement_timeout = 30000"] + { + saw_setup_set = true; + continue; + } + if round_trip.kind == Kind::Excluded("webhook dispatcher poll") + && round_trip + .parsed + .iter() + .all(|sql| background(sql) == Some("webhook dispatcher poll")) + { + if round_trip.executed.is_empty() && !round_trip.parsed.is_empty() { + saw_poll_parse = true; + continue; + } + if !round_trip.executed.is_empty() + && round_trip + .executed + .iter() + .all(|sql| background(sql) == Some("webhook dispatcher poll")) + { + poll_execution = Some(index); + } + } + break; + } + if let Some(poll_execution) = poll_execution { + window.connections -= 1; + for round_trip in &mut window.round_trips[..poll_execution] { + if round_trip.connection == connection && round_trip.kind == Kind::Counted { + round_trip.kind = Kind::Excluded("webhook dispatcher setup"); + } + } + } } + window } } @@ -308,7 +374,7 @@ impl FrontendParser { } if be_i32(&self.buf[4..8]) == PROTOCOL_V3 { self.started = true; - self.wire.log().connections += 1; + self.wire.log().started_connections.push(self.connection); } self.buf.drain(..len); } else { @@ -580,6 +646,127 @@ impl Window { } } +const DISPATCH_POLL_SQL: &str = r#"SELECT "hook_delivery"."id" AS "A_id""#; + +#[test] +fn dispatcher_connection_setup_is_excluded_without_hiding_request_connections() { + let wire = Wire::default(); + let mark = wire.mark(); + let trip = |connection, kind, sql: &str| RoundTrip { + connection, + kind, + executed: vec![sql.to_owned()], + parsed: Vec::new(), + }; + { + let mut log = wire.log(); + log.started_connections.extend([11, 12]); + log.round_trips.extend([ + trip(11, Kind::Counted, "SET statement_timeout = 30000"), + trip(12, Kind::Counted, "SET statement_timeout = 30000"), + RoundTrip { + connection: 11, + kind: Kind::Counted, + executed: Vec::new(), + parsed: Vec::new(), + }, + trip( + 11, + Kind::Excluded("webhook dispatcher poll"), + DISPATCH_POLL_SQL, + ), + trip(11, Kind::Counted, "SELECT current_kv"), + trip(12, Kind::Counted, "SELECT current_kv"), + trip( + 12, + Kind::Excluded("webhook dispatcher poll"), + DISPATCH_POLL_SQL, + ), + ]); + } + let window = wire.since(mark); + let counts = window.counts(); + assert_eq!(counts.connections, 1, "request connection setup is counted"); + assert_eq!( + counts.round_trips, 3, + "request and reused-connection SQL is counted" + ); + assert_eq!( + counts.excluded, 4, + "only dispatcher setup, ping and polls are excluded" + ); + assert_eq!( + window.round_trips[0].kind, + Kind::Excluded("webhook dispatcher setup") + ); + assert_eq!(window.round_trips[1].kind, Kind::Counted); + assert_eq!( + window.round_trips[2].kind, + Kind::Excluded("webhook dispatcher setup") + ); + assert_eq!(window.round_trips[4].kind, Kind::Counted); +} + +#[test] +fn dispatcher_attribution_never_hides_duplicate_setup_parse_or_prior_request_sql() { + let wire = Wire::default(); + let mark = wire.mark(); + let trip = |connection, kind, sql: &str| RoundTrip { + connection, + kind, + executed: vec![sql.to_owned()], + parsed: Vec::new(), + }; + { + let mut log = wire.log(); + log.started_connections.extend([13, 14, 15, 16]); + log.round_trips.extend([ + trip(13, Kind::Counted, "SET statement_timeout = 30000"), + trip(13, Kind::Counted, "SET statement_timeout = 30000"), + trip( + 13, + Kind::Excluded("webhook dispatcher poll"), + DISPATCH_POLL_SQL, + ), + trip(14, Kind::Counted, "SET statement_timeout = 30000"), + RoundTrip { + connection: 14, + kind: Kind::Excluded("webhook dispatcher poll"), + executed: Vec::new(), + parsed: vec![DISPATCH_POLL_SQL.to_owned()], + }, + trip(15, Kind::Counted, "SET statement_timeout = 30000"), + trip(15, Kind::Counted, "SELECT current_kv"), + trip( + 15, + Kind::Excluded("webhook dispatcher poll"), + DISPATCH_POLL_SQL, + ), + trip( + 16, + Kind::Counted, + "SET statement_timeout = 30000; SELECT current_kv", + ), + trip( + 16, + Kind::Excluded("webhook dispatcher poll"), + DISPATCH_POLL_SQL, + ), + ]); + } + let window = wire.since(mark); + let counts = window.counts(); + assert_eq!(counts.connections, 4, "all four are request-side setups"); + assert_eq!(counts.round_trips, 6, "no SET or request SQL is hidden"); + assert_eq!( + counts.excluded, 4, + "only dispatcher parse/SELECTs are excluded" + ); + for index in [0, 1, 3, 5, 6, 8] { + assert_eq!(window.round_trips[index].kind, Kind::Counted); + } +} + // --------------------------------------------------------------------------- // The node under test // --------------------------------------------------------------------------- @@ -951,6 +1138,36 @@ async fn run(url: &str, schema: &str) -> Result> { .await .with_context(|| format!("warm-up round {seed}"))?; } + // The dispatcher may open a connection after request warm-up; a + // background SET precedes its poll SQL. Wait for an actual execution + // after this mark (not an older poll or a statement-cache Parse), then + // settle the pool before starting the measured requests. + let after_warm_up = node.wire.mark(); + let poll_deadline = Instant::now() + Duration::from_secs(5); + loop { + let polled = { + let log = node.wire.log(); + log.round_trips[after_warm_up.round_trips..] + .iter() + .any(|round_trip| { + round_trip.kind == Kind::Excluded("webhook dispatcher poll") + && round_trip + .executed + .iter() + .any(|sql| background(sql) == Some("webhook dispatcher poll")) + }) + }; + if polled { + break; + } + ensure!( + Instant::now() < poll_deadline, + "no dispatcher poll executed after warm-up within five seconds" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + node.settle().await?; + println!("post-warm-up dispatcher execution settled before measured requests"); let mut measured = Vec::new(); round(&node, &Account::new(WARM_UP_ROUNDS + 1)?, &mut measured) .await @@ -1003,6 +1220,11 @@ async fn postgres_round_trip_budget() { let mut detail = String::new(); for request in measured.iter().filter(|m| m.flow == flow) { let counts = request.window.counts(); + assert_eq!( + counts.connections, 0, + "{flow} {}: a request opened an unwarmed PostgreSQL connection", + request.request + ); total += counts; let _ = writeln!( detail,