From 93b3cb388b6ea8de085e35f468f893aa9f862bb7 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 18:04:19 +0000 Subject: [PATCH 1/2] test(TC-1423): cover who can revoke a policy root Tests on SQLite and PostgreSQL through the production app: the space owner's wallet (did:pkh, SIWE) and a did:key space owner revoke an agent-signed policy root over the generic route, with the cascade to admitted sessions, re-shared children, challenge and mint, and the status routes; a revoke control naming the space's delegations and the encryption network revokes it, a space-only control cannot; an unrelated key, another agent of the wallet and the recipient are refused on both routes; the JSON route keeps its signer and enforcer rules. Adds a shared Policy/v3 test harness and documents the client rule. --- CHANGELOG.md | 1 + docs/policy-root-revocation.md | 55 + tinycloud-node-server/src/policy_v3.rs | 9 + .../src/policy_v3/root_revocation_tests.rs | 305 ++++ .../src/policy_v3/test_harness.rs | 1435 +++++++++++++++++ 5 files changed, 1805 insertions(+) create mode 100644 docs/policy-root-revocation.md create mode 100644 tinycloud-node-server/src/policy_v3/root_revocation_tests.rs create mode 100644 tinycloud-node-server/src/policy_v3/test_harness.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 65969a63..ecc4155e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - `POST /revoke` authorizes the delegation's issuer, the owner of a space its abilities target, or the holder of a revoke control grant issued by one of them. The delegatee alone is no longer an authorized revoker, directly or through a control grant it issued; an intermediate delegator still revokes the delegations it issued. Delegation status visibility is unchanged (TC-1436). - Revoke and status control grants may name an encryption network (`urn:tinycloud:encryption::`) owned by the control's grantor. Only the network's owner can issue such a control without a parent, because the owner is already the network's root authority (`/delegate` accepted this before); naming the network in a control grants no decrypt or other network ability, so this is not an escalation. Each resource of a control is typed as an exact `urn:cid:`, a `/delegation` root, or a network; any other resource is ignored. A control covers a target only when every one of the target's resources is covered: a space resource by a named space, a network resource by the same parsed network id (owner DID including chain id, and name), so lowercase and EIP-55 checksummed spellings of an address name the same network, and a resource that does not parse as a network is never covered. A control that names a network never counts as an exact-CID control, and a network named by a control issued by someone other than its owner is ignored. A control such as `/delegation` + `/delegation` + `:share` therefore revokes or reads the status of share roots that carry `:share` decrypt, but not grants on `:default` or any other network (TC-1423). +- Tests and docs (no behaviour change): a Policy/v3 root is revoked by its signer, the owner of a space its abilities touch (including a did:pkh wallet over SIWE), or the holder of a revoke control naming the space's delegations and the root's encryption network, over the generic `POST /revoke`; the JSON `RootRevocation/v1` route stays limited to the root's signer and, for the enforcement root, the enforcer. See `docs/policy-root-revocation.md` (TC-1423). ## [1.21.0] - 2026-10-18 diff --git a/docs/policy-root-revocation.md b/docs/policy-root-revocation.md new file mode 100644 index 00000000..7d96c4bd --- /dev/null +++ b/docs/policy-root-revocation.md @@ -0,0 +1,55 @@ +# Revoking a Policy/v3 root (TC-1423) + +A policy has two roots, the policy root and the enforcement root. Both are +signed by the policy owner (`ownerDid`), which may be an agent or session key +of the space owner. There are two ways to revoke a root, both `POST /revoke`. + +## Generic route (SIWE or UCAN) + +The root is an ordinary delegation in the graph, so the core revocation rules +apply. These principals may revoke it: + +- the root's signer (its delegator, `ownerDid`); +- the owner of a space that the root's abilities touch, for example the + wallet (did:pkh, over SIWE) that owns the content space of an agent-signed + root; +- the holder of a revoke control that one of them granted. A + `/delegation` control covers the root's kv rows. The root's + `encryption/decrypt` row needs a control that names that exact encryption + network (typed control scope), so a space-only control gets 403 on a + decrypt-bearing root. + +Recipients, session holders, other agents of the same owner, and unrelated +principals get 403. The revocation applies on every later use: + +- admitted sessions' reads, and reads by their re-shared children, get `403 + root-revoked`; +- the decrypt route authorizes the session chain before the policy checks, so + a decrypt gets the core `401 delegation-ancestor-revoked`; +- challenge and mint are refused. + +## JSON route (`RootRevocation/v1`) + +Only the root's own signer may use it, or the enforcer for the enforcement +root. The signature is Ed25519. Any other issuer, including the space owner, +gets `403 root-revocation-issuer-invalid`. + +## Client rule + +- The root's own signer (or the enforcer, for the enforcement root) uses the + JSON route. +- Every other revoker uses the generic route on the root CID: the owner's + wallet over SIWE, or a control holder citing its control. + +## Confirming a revocation + +Confirm on the CID you revoked, with `POST /delegation/status`, or with the +owner listing where the node offers it. + +After a generic revocation of a root, `GET /policy/v3/status/` returns +`state: revoked` over the last signed checkpoint, whose own `state` stays +`active`. Clients that require the two to match (the released SDK) report an +error, so this route is not a confirmation. Renewal (`POST +/policy/v3/status`) returns `409 root-revoked`. + +After a JSON-route revocation, both `state` values are `revoked`. diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 31e1139b..f508cab7 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -10233,3 +10233,12 @@ mod tests { #[cfg(test)] #[path = "policy_v3/account_alias_tests.rs"] mod account_alias_tests; + +#[cfg(test)] +#[macro_use] +#[path = "policy_v3/test_harness.rs"] +mod test_harness; + +#[cfg(test)] +#[path = "policy_v3/root_revocation_tests.rs"] +mod root_revocation_tests; diff --git a/tinycloud-node-server/src/policy_v3/root_revocation_tests.rs b/tinycloud-node-server/src/policy_v3/root_revocation_tests.rs new file mode 100644 index 00000000..e2f62060 --- /dev/null +++ b/tinycloud-node-server/src/policy_v3/root_revocation_tests.rs @@ -0,0 +1,305 @@ +//! TC-1423 (design ยง15): who can revoke a Policy/v3 root, and what follows. +//! +//! The owner of the content space revokes any policy root issued in it over +//! the generic `POST /revoke` (SIWE or UCAN), as does a holder of an +//! owner-granted revoke control. The JSON `RootRevocation/v1` route stays +//! limited to the root's own signer and, for the enforcement root, the +//! enforcer. Everyone else is refused on both routes. + +use super::test_harness::*; +use super::*; +use serde_json::json; + +const ROOT_REVOKED: &str = "root-revoked"; +const POLICY_AUTHORITY: &str = "policy-authority"; +const POLICY_ENFORCEMENT: &str = "policy-enforcement"; + +/// A registered policy with an admitted session that reads, and a re-shared +/// child that reads. +struct Admitted { + policy: Registered, + session: String, + reader: Signer, + child: tinycloud_auth::ipld_core::cid::Cid, +} + +async fn admitted(h: &Harness) -> anyhow::Result { + h.grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let policy = h.register_ok(policy_window()).await?; + let session = h.mint_ok(&policy).await?; + let (status, _) = h.read(&session).await?; + anyhow::ensure!(status == Status::Ok, "read before revoke: {status}"); + let (status, body, reader, child) = h.reshare(&session).await?; + anyhow::ensure!(status == Status::Ok, "re-share: {body}"); + let child = child.ok_or_else(|| anyhow::anyhow!("re-share cid"))?; + anyhow::ensure!(h.read_as(&reader, child).await?.0 == Status::Ok); + Ok(Admitted { + policy, + session, + reader, + child, + }) +} + +/// After a generic revocation of the policy root: the admitted session, +/// its decrypt and its re-shared child stop with `root-revoked`, new +/// sessions are refused, and the status routes report it. +async fn assert_root_revoked_cascade(h: &Harness, admitted: &Admitted) -> anyhow::Result<()> { + let (status, body) = h.read(&admitted.session).await?; + assert_eq!( + (status, text(&body).as_str()), + (Status::Forbidden, ROOT_REVOKED) + ); + // The decrypt route authorizes the invocation chain before the policy + // check, so a revoked root surfaces as the core ancestor revocation. + let (status, body) = h.decrypt(&admitted.session).await?; + assert_eq!(status, Status::Unauthorized, "{body}"); + assert!(body.starts_with("delegation-ancestor-revoked"), "{body}"); + assert_eq!( + h.read_as(&admitted.reader, admitted.child).await?, + (Status::Forbidden, ROOT_REVOKED.to_owned()) + ); + assert_eq!(h.challenge(&admitted.policy).await.0, Status::Forbidden); + assert_eq!( + h.mint(&admitted.policy).await?.unwrap_err().0, + Status::Forbidden + ); + // The signer of the root sees it revoked. + assert_eq!( + h.delegation_status(&h.owner, &admitted.policy.policy_root_cid) + .await?["status"], + json!("revoked") + ); + // The public status reports `revoked` over the last (active) + // checkpoint, and renewal is refused. + let status = h.root_status(&admitted.policy.policy_root_cid).await; + assert_eq!(status["state"], json!("revoked"), "{status}"); + assert_eq!(status["checkpoint"]["state"], json!("active"), "{status}"); + assert_eq!( + h.renew_status(&admitted.policy.policy_root_cid).await, + (Status::Conflict, ROOT_REVOKED.to_owned()) + ); + Ok(()) +} + +/// The root is untouched: still active on both status routes, and the +/// admitted session still reads. +async fn assert_root_live(h: &Harness, admitted: &Admitted) -> anyhow::Result<()> { + assert_eq!(h.read(&admitted.session).await?.0, Status::Ok); + let status = h.root_status(&admitted.policy.policy_root_cid).await; + assert_eq!(status["state"], json!("active"), "{status}"); + Ok(()) +} + +/// [O-1] The wallet that owns the space (did:pkh) revokes the agent-signed +/// root over the generic route with SIWE. +async fn the_space_owner_wallet_revokes_an_agent_signed_root( + backend: Backend, +) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::PkhSession, + "the_space_owner_wallet_revokes_an_agent_signed_root", + ) + .await? + else { + return Ok(()); + }; + let admitted = admitted(&h).await?; + // Policy sessions hold only kv and decrypt: no hook ticket, so no stream + // to close. + let (status, body) = h + .hook_ticket(&[session_cid(&admitted.session)?.to_string()]) + .await?; + assert_ne!(status, Status::Ok, "{body}"); + h.revoke(&h.space_owner, &admitted.policy.policy_root_cid) + .await?; + assert_root_revoked_cascade(&h, &admitted).await +} +on_both_backends!(the_space_owner_wallet_revokes_an_agent_signed_root); + +/// [O-2] A did:key space owner revokes the agent-signed root over the +/// generic route (UCAN); the JSON route still refuses it. +async fn a_key_space_owner_revokes_over_the_generic_route_only( + backend: Backend, +) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "a_key_space_owner_revokes_over_the_generic_route_only", + ) + .await? + else { + return Ok(()); + }; + let admitted = admitted(&h).await?; + let (status, body) = h + .revoke_root_json( + &h.space_owner, + &admitted.policy.policy_root_cid, + POLICY_AUTHORITY, + ) + .await?; + assert_eq!( + (status, body.as_str()), + (Status::Forbidden, "root-revocation-issuer-invalid") + ); + assert_root_live(&h, &admitted).await?; + h.revoke(&h.space_owner, &admitted.policy.policy_root_cid) + .await?; + assert_root_revoked_cascade(&h, &admitted).await +} +on_both_backends!(a_key_space_owner_revokes_over_the_generic_route_only); + +/// [O-3][O-4][O-5] An unrelated key, another agent session of the same +/// wallet, and the recipient are refused on both routes; nothing changes. +async fn others_cannot_revoke_a_policy_root(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::PkhSession, + "others_cannot_revoke_a_policy_root", + ) + .await? + else { + return Ok(()); + }; + let admitted = admitted(&h).await?; + let other_agent = Signer::key(); + h.import(h.space_owner.delegate( + other_agent.did(), + &h.owner_grants(), + Window::until(now() + Duration::days(2)), + &[], + )?) + .await?; + let stranger = Signer::key(); + let root = admitted.policy.policy_root_cid.clone(); + for (label, signer) in [ + ("unrelated principal", &stranger), + ("another agent of the wallet", &other_agent), + ("recipient", &h.holder), + ] { + let (status, body) = h.post_revoke(signer.revoke(&root)?).await; + assert!( + [Status::Unauthorized, Status::Forbidden].contains(&status), + "{label} generic: {status} {body}" + ); + let (status, body) = h.revoke_root_json(signer, &root, POLICY_AUTHORITY).await?; + assert_eq!(status, Status::Forbidden, "{label} JSON: {body}"); + assert_root_live(&h, &admitted).await?; + } + assert_eq!( + h.delegation_status(&h.owner, &root).await?["status"], + json!("active") + ); + Ok(()) +} +on_both_backends!(others_cannot_revoke_a_policy_root); + +/// [O-6] A key holding the wallet's revoke control over the space's +/// delegations and the encryption network revokes a decrypt-bearing root; +/// the same control without the network row cannot. +async fn a_revoke_control_naming_the_network_revokes_a_policy_root( + backend: Backend, +) -> anyhow::Result<()> { + for with_network in [false, true] { + let Some(h) = Harness::new( + backend, + Shape::PkhSession, + "a_revoke_control_naming_the_network_revokes_a_policy_root", + ) + .await? + else { + return Ok(()); + }; + let admitted = admitted(&h).await?; + let device = Signer::key(); + let delegation_root = h + .content_space + .clone() + .to_resource("delegation".parse()?, None, None, None) + .to_string(); + let mut rows = vec![(delegation_root, "tinycloud.delegation/revoke")]; + if with_network { + rows.push((h.network.clone(), "tinycloud.delegation/revoke")); + } + let control = h + .import(h.space_owner.delegate( + device.did(), + &rows, + Window::until(now() + Duration::hours(1)), + &[], + )?) + .await?; + let (status, body) = h + .post_revoke( + device.revoke_citing(&admitted.policy.policy_root_cid, &[control.parse()?])?, + ) + .await; + if with_network { + assert_eq!(status, Status::Ok, "{body}"); + assert_root_revoked_cascade(&h, &admitted).await?; + } else { + assert!( + [Status::Unauthorized, Status::Forbidden].contains(&status), + "space-only control: {status} {body}" + ); + assert_root_live(&h, &admitted).await?; + } + } + Ok(()) +} +on_both_backends!(a_revoke_control_naming_the_network_revokes_a_policy_root); + +/// [O-7] The root's signer revokes over JSON, and the status the released +/// SDK parses stays consistent (`state` equals the checkpoint's). The +/// enforcer revokes the enforcement root over JSON but not the policy root. +async fn the_json_route_keeps_its_signer_and_enforcer_rules( + backend: Backend, +) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::PkhSession, + "the_json_route_keeps_its_signer_and_enforcer_rules", + ) + .await? + else { + return Ok(()); + }; + let first = admitted(&h).await?; + let (status, body) = h + .revoke_root_json(&h.enforcer, &first.policy.policy_root_cid, POLICY_AUTHORITY) + .await?; + assert_eq!( + status, + Status::Forbidden, + "enforcer on the policy root: {body}" + ); + let (status, body) = h + .revoke_root_json( + &h.enforcer, + &first.policy.enforcement_root_cid, + POLICY_ENFORCEMENT, + ) + .await?; + assert_eq!( + status, + Status::Ok, + "enforcer on the enforcement root: {body}" + ); + let status = h.root_status(&first.policy.enforcement_root_cid).await; + assert_eq!(status["state"], json!("revoked"), "{status}"); + assert_eq!(status["state"], status["checkpoint"]["state"], "{status}"); + + let policy = h.register_ok(policy_window()).await?; + let (status, body) = h + .revoke_root_json(&h.owner, &policy.policy_root_cid, POLICY_AUTHORITY) + .await?; + assert_eq!(status, Status::Ok, "signer: {body}"); + let status = h.root_status(&policy.policy_root_cid).await; + assert_eq!(status["state"], json!("revoked"), "{status}"); + assert_eq!(status["state"], status["checkpoint"]["state"], "{status}"); + Ok(()) +} +on_both_backends!(the_json_route_keeps_its_signer_and_enforcer_rules); diff --git a/tinycloud-node-server/src/policy_v3/test_harness.rs b/tinycloud-node-server/src/policy_v3/test_harness.rs new file mode 100644 index 00000000..7176a9e7 --- /dev/null +++ b/tinycloud-node-server/src/policy_v3/test_harness.rs @@ -0,0 +1,1435 @@ +//! TC-1423: a Policy/v3 test harness over the production app, on SQLite and +//! on PostgreSQL: principals (did:key and did:pkh), owner grants, policy +//! registration, sessions, reads, decrypts, re-shares and revocations. +//! +//! PostgreSQL runs need `TINYCLOUD_TEST_POSTGRES_URL` (each test uses its own +//! schema); they are skipped when it is unset outside CI. + +// Shared by several test modules; each uses a subset. +#![allow(dead_code)] + +use super::*; +use rocket::{ + figment::{providers::Serialized, Figment}, + http::{ContentType, Header}, + local::asynchronous::Client, +}; +use serde_json::json; +use std::collections::BTreeMap; +use tinycloud_auth::{ + authorization::{make_invocation, HeaderEncode, InvocationOptions, TinyCloudRevocation}, + cacaos::{ + siwe::{encode_eip55, Message, Version}, + siwe_cacao::{ + Eip191, Header as SiweHeader, Payload as CacaoPayload, Signature as SiweSignature, + SiweCacao, + }, + }, + siwe_recap::{Ability as RecapAbility, Capability as RecapCapability}, + ssi::{claims::jwt::NumericDate, ucan::Payload}, + ucan_capabilities_object::Capabilities, +}; +use tinycloud_core::sea_orm::{ConnectOptions, Database, DbBackend, Statement}; + +type Cid = tinycloud_auth::ipld_core::cid::Cid; + +const CONTENT: &[u8] = b"tc-1423-content"; +const KV_GET: &str = "tinycloud.kv/get"; +const KV_METADATA: &str = "tinycloud.kv/metadata"; +const DECRYPT: &str = "tinycloud.encryption/decrypt"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Backend { + Sqlite, + Postgres, +} + +/// Run an async test body once per backend. +macro_rules! on_both_backends { + ($(#[$meta:meta])* $name:ident) => { + mod $name { + use super::*; + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn sqlite() -> anyhow::Result<()> { + super::$name(Backend::Sqlite).await + } + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn postgres() -> anyhow::Result<()> { + super::$name(Backend::Postgres).await + } + } + }; +} + +fn postgres_url(test: &str) -> 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, which is unset in CI (TC-381)" + ); + eprintln!("skipping {test}: TINYCLOUD_TEST_POSTGRES_URL is unset"); + None + } + } +} + +pub(super) fn unique(label: &str) -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + format!( + "{label}{}x{}x{}", + std::process::id(), + OffsetDateTime::now_utc().unix_timestamp_nanos() % 1_000_000_000_000, + NEXT.fetch_add(1, Ordering::SeqCst) + ) +} + +/// A database the production app can be booted on, more than once. +pub(super) struct TestDatabase { + pub backend: Backend, + url: String, + _dir: tempfile::TempDir, +} + +impl TestDatabase { + pub async fn new(backend: Backend, test: &str) -> anyhow::Result> { + let dir = tempfile::tempdir()?; + let url = match backend { + Backend::Sqlite => format!("sqlite://{}/node.db?mode=rwc", dir.path().display()), + Backend::Postgres => { + let Some(url) = postgres_url(test) else { + return Ok(None); + }; + let schema = unique("tc1423_"); + let admin = Database::connect(ConnectOptions::new(url.clone())).await?; + admin + .execute_unprepared(&format!("CREATE SCHEMA {schema}")) + .await?; + let mut parsed = reqwest::Url::parse(&url)?; + parsed + .query_pairs_mut() + .append_pair("options", &format!("-c search_path={schema}")); + parsed.to_string() + } + }; + Ok(Some(Self { + backend, + url, + _dir: dir, + })) + } + + pub fn url(&self) -> &str { + &self.url + } + + pub fn datadir(&self) -> &std::path::Path { + self._dir.path() + } +} + +/// A principal that signs delegations, invocations and revocations. +#[allow(clippy::large_enum_variant)] +pub(super) enum Signer { + Key { + keypair: tinycloud_core::libp2p::identity::ed25519::Keypair, + jwk: JWK, + did: String, + vm: String, + }, + Pkh { + key: k256::ecdsa::SigningKey, + address: [u8; 20], + did: String, + }, +} + +#[derive(Clone, Copy)] +pub(super) struct Window { + pub not_before: OffsetDateTime, + pub expiry: OffsetDateTime, + /// The CACAO issue time; UCANs carry none. + pub issued_at: Option, +} + +impl Window { + pub fn until(expiry: OffsetDateTime) -> Self { + Self { + not_before: now() - Duration::seconds(30), + expiry, + issued_at: None, + } + } + + pub fn issued(mut self, issued_at: OffsetDateTime) -> Self { + self.issued_at = Some(issued_at); + self + } +} + +pub(super) fn now() -> OffsetDateTime { + OffsetDateTime::now_utc().replace_nanosecond(0).unwrap() +} + +fn seconds(value: OffsetDateTime) -> anyhow::Result { + Ok(NumericDate::try_from_seconds(value.unix_timestamp() as f64)?) +} + +fn capabilities(grants: &[(String, &str)]) -> anyhow::Result> { + let mut capabilities = Capabilities::::new(); + for (resource, ability) in grants { + capabilities.with_action( + resource.parse()?, + ability.parse::()?, + [BTreeMap::::new()], + ); + } + Ok(capabilities) +} + +impl Signer { + pub fn key() -> Self { + let keypair = tinycloud_core::libp2p::identity::ed25519::Keypair::generate(); + let did = tinycloud_core::keys::public_key_to_did_key( + tinycloud_core::libp2p::identity::Keypair::from(keypair.clone()).public(), + ); + let mut jwk = JWK::from(Params::OKP(OctetParams { + curve: "Ed25519".to_owned(), + public_key: Base64urlUInt(keypair.public().to_bytes().to_vec()), + private_key: Some(Base64urlUInt(keypair.secret().as_ref().to_vec())), + })); + jwk.algorithm = Some(Algorithm::EdDSA); + let vm = format!("{did}#{}", did.trim_start_matches("did:key:")); + Self::Key { + keypair, + jwk, + did, + vm, + } + } + + pub fn pkh() -> Self { + use sha3::Keccak256; + let mut secret = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut secret); + let key = k256::ecdsa::SigningKey::from_bytes(&secret.into()).expect("k256 key"); + let public = key.verifying_key().to_encoded_point(false); + let digest = Keccak256::digest(&public.as_bytes()[1..]); + let address: [u8; 20] = digest[12..].try_into().unwrap(); + let did = format!("did:pkh:eip155:1:0x{}", encode_eip55(&address)); + Self::Pkh { key, address, did } + } + + pub fn did(&self) -> &str { + match self { + Self::Key { did, .. } | Self::Pkh { did, .. } => did, + } + } + + fn keypair(&self) -> &tinycloud_core::libp2p::identity::ed25519::Keypair { + match self { + Self::Key { keypair, .. } => keypair, + Self::Pkh { .. } => panic!("not an Ed25519 signer"), + } + } + + fn sign_siwe(&self, message: &Message) -> anyhow::Result { + let Self::Pkh { key, .. } = self else { + anyhow::bail!("not a did:pkh signer"); + }; + let (signature, recovery) = key.sign_prehash_recoverable(&message.eip191_hash()?)?; + let mut bytes = [0u8; 65]; + bytes[..64].copy_from_slice(signature.to_bytes().as_ref()); + bytes[64] = u8::from(recovery) + 27; + Ok(SiweSignature::from(bytes)) + } + + fn siwe_message( + &self, + uri: &str, + nonce: &str, + issued_at: OffsetDateTime, + not_before: Option, + expiry: OffsetDateTime, + ) -> anyhow::Result { + let Self::Pkh { address, .. } = self else { + anyhow::bail!("not a did:pkh signer"); + }; + Ok(Message { + scheme: Some("https".parse()?), + domain: "tc1423.test".parse()?, + address: *address, + statement: None, + uri: uri.parse()?, + version: Version::V1, + chain_id: 1, + nonce: nonce.replace(['-', '_'], ""), + issued_at: issued_at.into(), + expiration_time: Some(expiry.into()), + not_before: not_before.map(Into::into), + request_id: None, + resources: vec![], + }) + } + + /// An encoded delegation to `audience`. + pub fn delegate( + &self, + audience: &str, + grants: &[(String, &str)], + window: Window, + proofs: &[Cid], + ) -> anyhow::Result { + let nonce = unique("tc1423delegation"); + match self { + Self::Key { jwk, vm, .. } => Ok(TinyCloudDelegation::Ucan(Box::new( + Payload { + issuer: vm.parse()?, + audience: audience.parse()?, + not_before: Some(seconds(window.not_before)?), + expiration: seconds(window.expiry)?, + nonce: Some(nonce), + facts: Some(vec![]), + proof: proofs.to_vec(), + attenuation: capabilities(grants)?, + } + .sign(Algorithm::EdDSA, jwk)?, + )) + .encode()?), + Self::Pkh { .. } => { + anyhow::ensure!(proofs.is_empty(), "SIWE fixtures carry no proofs"); + let mut recap = RecapCapability::::new(); + for (resource, ability) in grants { + recap.with_action( + resource.parse()?, + ability.parse::()?, + [BTreeMap::::new()], + ); + } + let message = recap.build_message(self.siwe_message( + audience, + &nonce, + window.issued_at.unwrap_or(window.not_before), + Some(window.not_before), + window.expiry, + )?)?; + let signature = self.sign_siwe(&message)?; + Ok(TinyCloudDelegation::Cacao(Box::new(SiweCacao::new( + message.into(), + signature, + SiweHeader, + ))) + .encode()?) + } + } + } + + /// An encoded revocation of `cid`. + pub fn revoke(&self, cid: &str) -> anyhow::Result { + self.revoke_citing(cid, &[]) + } + + /// An encoded revocation of `cid` citing `proofs` (a revoke control). + pub fn revoke_citing(&self, cid: &str, proofs: &[Cid]) -> anyhow::Result { + match self { + Self::Key { jwk, vm, did, .. } => Ok(TinyCloudRevocation::Ucan(Box::new( + Payload { + issuer: vm.parse()?, + audience: did.parse()?, + not_before: None, + expiration: seconds(now() + Duration::seconds(60))?, + nonce: Some(unique("tc1423revoke")), + facts: Some(Vec::new()), + proof: proofs.to_vec(), + attenuation: capabilities(&[( + format!("urn:cid:{cid}"), + "tinycloud.delegation/revoke", + )])?, + } + .sign(Algorithm::EdDSA, jwk)?, + )) + .encode()?), + Self::Pkh { .. } => { + anyhow::ensure!(proofs.is_empty(), "SIWE fixtures carry no proofs"); + let message = self.siwe_message( + &format!("ucan:{cid}"), + &unique("tc1423revoke"), + now(), + None, + now() + Duration::seconds(60), + )?; + let signature = self.sign_siwe(&message)?; + Ok(TinyCloudRevocation::Cacao(Box::new( + CacaoPayload::from(message).sign::(signature), + )) + .encode()?) + } + } + } + + /// An encoded invocation of `attenuation` citing `proof`. + pub fn invoke(&self, attenuation: Capabilities, proof: Cid) -> anyhow::Result { + self.invoke_for(self.did(), attenuation, proof) + } + + /// An encoded invocation addressed to `audience`. + pub fn invoke_for( + &self, + audience: &str, + attenuation: Capabilities, + proof: Cid, + ) -> anyhow::Result { + let Self::Key { jwk, vm, .. } = self else { + anyhow::bail!("invocations are signed by keys"); + }; + let at = now(); + Ok(Payload { + issuer: vm.parse()?, + audience: audience.parse()?, + not_before: Some(seconds(at)?), + expiration: seconds(at + Duration::seconds(30))?, + nonce: Some(unique("tc1423invoke")), + facts: Some(Vec::::new()), + proof: vec![proof], + attenuation, + } + .sign(Algorithm::EdDSA, jwk)? + .encode()?) + } +} + +#[derive(Debug, Clone)] +pub(super) struct Registered { + pub policy_cid: String, + pub policy_root_cid: String, + pub enforcement_root_cid: String, +} + +/// Which principal owns the content space, and who owns the policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Shape { + /// A did:key space owner delegates (UCAN) to the policy owner's key. + KeySession, + /// A did:pkh space owner signs SIWE to the policy owner's key, as Share + /// and the CLI do. + PkhSession, + /// The policy owner owns the content space. + SpaceOwner, +} + +pub(super) struct Harness { + pub client: Client, + pub db: TestDatabase, + pub space_owner: Signer, + /// The policy owner (a key: it signs the policy and its roots). + pub owner: Signer, + pub holder: Signer, + pub enforcer: Signer, + pub content_space: SpaceId, + pub content_resource: tinycloud_auth::resource::ResourceId, + pub network: String, + issuer: tinycloud_core::libp2p::identity::ed25519::Keypair, + vector: Value, +} + +/// Settings for one boot of the production app. +#[derive(Clone, Default)] +pub(super) struct Boot { + /// The `[policy_v3]` section, passed through figment. + pub policy_v3: Option, + /// The `[hooks]` section, passed through figment. + pub hooks: Option, +} + +impl Harness { + pub async fn new(backend: Backend, shape: Shape, test: &str) -> anyhow::Result> { + Self::with_boot(backend, shape, test, Boot::default()).await + } + + pub async fn with_boot( + backend: Backend, + shape: Shape, + test: &str, + boot: Boot, + ) -> anyhow::Result> { + let Some(db) = TestDatabase::new(backend, test).await? else { + return Ok(None); + }; + let vector: Value = serde_json::from_str(include_str!( + "../../test-fixtures/tc-470-policy-credential-requirement.json" + ))?; + let issuer = tinycloud_core::libp2p::identity::ed25519::Keypair::generate(); + let owner = Signer::key(); + let space_owner = match shape { + Shape::KeySession => Signer::key(), + Shape::PkhSession => Signer::pkh(), + // The same key: a separate value keeps the struct simple. + Shape::SpaceOwner => match &owner { + Signer::Key { + keypair, + jwk, + did, + vm, + } => Signer::Key { + keypair: keypair.clone(), + jwk: jwk.clone(), + did: did.clone(), + vm: vm.clone(), + }, + Signer::Pkh { .. } => unreachable!(), + }, + }; + let content_space = SpaceId::new( + space_owner.did().parse::()?, + "applications".parse()?, + ); + let content_resource = content_space.clone().to_resource( + "kv".parse()?, + Some("shares/tc-1423/document.txt".parse()?), + None, + None, + ); + let network = format!("urn:tinycloud:encryption:{}:mainnet", space_owner.did()); + let client = Self::boot_app(&db, &vector, &issuer, &boot).await?; + let harness = Self { + client, + db, + space_owner, + owner, + holder: Signer::key(), + enforcer: Signer::key(), + content_space, + content_resource, + network, + issuer, + vector, + }; + harness.seed_content().await?; + Ok(Some(harness)) + } + + async fn boot_app( + db: &TestDatabase, + vector: &Value, + issuer: &tinycloud_core::libp2p::identity::ed25519::Keypair, + boot: &Boot, + ) -> anyhow::Result { + let mut config = crate::config::Config::default(); + config.storage.datadir = db.datadir().into(); + config.storage.database = Some(db.url().to_owned()); + config.keys = serde_json::from_value(json!({ + "type": "static", + "secret": encode_config([53u8; 32], URL_SAFE_NO_PAD), + }))?; + config.share_email.issuer_did = vector["policyProjection"]["issuerDid"] + .as_str() + .unwrap() + .to_owned(); + config.share_email.issuer_vct = "opencredentials.email/v1".to_owned(); + config.share_email.issuer_key_version = 1; + config.share_email.issuer_kid = vector["policyProjection"]["issuerKid"] + .as_str() + .unwrap() + .to_owned(); + config.share_email.issuer_public_key = + Some(encode_config(issuer.public().to_bytes(), URL_SAFE_NO_PAD)); + let mut figment = Figment::from(rocket::Config { + address: "127.0.0.1".parse()?, + log_level: rocket::config::LogLevel::Off, + ..rocket::Config::default() + }) + .merge(Serialized::defaults(&config)); + if let Some(policy_v3) = &boot.policy_v3 { + figment = figment.merge(Serialized::default("policy_v3", policy_v3)); + } + if let Some(hooks) = &boot.hooks { + figment = figment.merge(Serialized::default("hooks", hooks)); + } + let config: crate::config::Config = figment.extract()?; + Ok(Client::tracked(crate::app_with_control(&figment, &config, None).await?).await?) + } + + /// Boot the production app again on the same database. + pub async fn reboot(&mut self, boot: Boot) -> anyhow::Result<()> { + let client = Self::boot_app(&self.db, &self.vector, &self.issuer, &boot).await?; + self.client = client; + Ok(()) + } + + pub fn conn(&self) -> &tinycloud_core::RecoveringConnection { + self.client + .rocket() + .state::() + .expect("node pool") + } + + pub fn node_did(&self) -> String { + self.client + .rocket() + .state::() + .expect("policy runtime") + .node_did + .clone() + } + + /// What a policy over the content grants, and what the owner must hold. + pub fn owner_grants(&self) -> Vec<(String, &'static str)> { + vec![ + (self.content_resource.to_string(), KV_GET), + (self.content_resource.to_string(), KV_METADATA), + (self.network.clone(), DECRYPT), + ] + } + + pub async fn delegate(&self, authorization: String) -> (Status, String) { + let response = self + .client + .post("/delegate") + .header(Header::new("Authorization", authorization)) + .dispatch() + .await; + let status = response.status(); + (status, response.into_string().await.unwrap_or_default()) + } + + /// Import a delegation and return its CID. + pub async fn import(&self, authorization: String) -> anyhow::Result { + let (status, body) = self.delegate(authorization).await; + anyhow::ensure!(status == Status::Ok, "/delegate: {status} {body}"); + Ok(serde_json::from_str::(&body)?["cid"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing cid"))? + .to_owned()) + } + + /// The space owner grants the policy owner what a policy over the + /// content needs. + pub async fn grant_owner(&self, window: Window) -> anyhow::Result { + self.grant_owner_with(&self.owner_grants(), window).await + } + + pub async fn grant_owner_with( + &self, + grants: &[(String, &str)], + window: Window, + ) -> anyhow::Result { + self.import( + self.space_owner + .delegate(self.owner.did(), grants, window, &[])?, + ) + .await + } + + async fn seed_content(&self) -> anyhow::Result<()> { + let space_resource = + self.content_space + .clone() + .to_resource("space".parse()?, None, None, None); + let grant = self + .import(self.space_owner.delegate( + self.holder.did(), + &[ + (space_resource.to_string(), "tinycloud.space/host"), + (self.content_resource.to_string(), "tinycloud.kv/put"), + (self.content_resource.to_string(), KV_GET), + ], + Window::until(now() + Duration::hours(2)), + &[], + )?) + .await?; + let Signer::Key { jwk, vm, .. } = &self.holder else { + unreachable!() + }; + let seed = make_invocation( + [( + self.content_resource.clone(), + ["tinycloud.kv/put".parse::()?], + )], + &grant.parse()?, + jwk, + vm, + (now() + Duration::seconds(45)).unix_timestamp() as f64, + InvocationOptions { + nonce: Some(unique("tc1423seed")), + ..InvocationOptions::default() + }, + )?; + let response = self + .client + .post("/invoke") + .header(Header::new("Authorization", seed.encode()?)) + .header(ContentType::Plain) + .body(CONTENT) + .dispatch() + .await; + anyhow::ensure!( + response.status() == Status::Ok, + "seed: {}", + response.status() + ); + Ok(()) + } + + fn requested(&self) -> Vec { + vec![self.requested_kv(), self.requested_decrypt()] + } + + fn requested_kv(&self) -> Value { + json!({ + "kind": "kv", + "resource": self.content_resource.to_string(), + "selector": "exact", + "actions": [KV_GET], + }) + } + + fn requested_decrypt(&self) -> Value { + json!({ + "kind": "encryption", + "resource": self.network, + "action": DECRYPT, + }) + } + + /// Register a policy over the content whose roots span `root`, signed + /// by the policy owner. + pub async fn register(&self, root: Window) -> anyhow::Result<(Status, String, Registered)> { + let owner_did = self.owner.did().to_owned(); + let projection = self.vector["policyProjection"].clone(); + let created = (now() - Duration::seconds(60)).min(root.not_before); + let ceiling = vec![ + json!({ + "kind": "kv", + "resource": self.content_resource.to_string(), + "selector": "exact", + "actions": [KV_GET, KV_METADATA], + }), + json!({ + "kind": "encryption", + "resource": self.network, + "action": DECRYPT, + }), + ]; + let mut policy = json!({ + "schema": POLICY_V2_SCHEMA, + "policyId": "pending", + "ownerDid": owner_did, + "createdAt": format_time(created), + // The policy may outlive its roots; the roots' expiry is what + // the tests exercise. + "expiresAt": format_time(root.expiry + Duration::hours(1)), + "contentSource": { + "shareId": unique("share-tc-1423-"), + "kvResource": self.content_resource.to_string(), + "selector": "exact", + "encryptionNetwork": self.network, + "encryptedSymmetricKeyDigestHex": "aa".repeat(32), + "keyVersion": 1, + "mode": "immutable", + "initialCiphertextDigestHex": "bb".repeat(32) + }, + "capabilityCeiling": ceiling, + "credentialRequirement": projection, + "signature": {"suite": "Ed25519", "signerDid": owner_did, "value": ""} + }); + let mut unsigned = policy.clone(); + unsigned.as_object_mut().unwrap().remove("policyId"); + unsigned.as_object_mut().unwrap().remove("signature"); + let mut preimage = POLICY_V2_SCHEMA.as_bytes().to_vec(); + preimage.push(0); + preimage.extend_from_slice(&canonical_json_value(&unsigned)); + policy["policyId"] = json!(format!("pol_{}", base32_lower(&Sha256::digest(&preimage)))); + policy["signature"]["value"] = json!(encode_config( + self.owner.keypair().sign(&Sha256::digest(preimage)), + URL_SAFE_NO_PAD + )); + let policy_bytes = canonical_json_value(&policy); + let policy_cid = Cid::new_v1( + 0x55, + tinycloud_auth::multihash_codetable::Code::Sha2_256.digest(&policy_bytes), + ) + .to_string(); + let projections = + registration_projections(&policy).map_err(|(_, error)| anyhow::anyhow!(error))?; + let digest = policy_digest_hex(&policy).map_err(|(_, error)| anyhow::anyhow!(error))?; + let node_did = self.node_did(); + let common = json!({ + "ownerDid": owner_did, + "policyId": policy["policyId"], + "policyDigestHex": digest, + "policyCid": policy_cid, + "contentSourceDigestHex": projections.content_source_digest_hex, + "capabilityCeilingHashHex": projections.capability_ceiling_hash_hex, + "nativeProjectionHashHex": projections.native_projection_hash_hex, + "nodeAudience": node_did, + }); + let Signer::Key { keypair, vm, .. } = &self.owner else { + unreachable!() + }; + let root_authorization = + |audience: &str, role: &str, mode: &str, enforcer: Option<&str>| { + let mut facts = common.as_object().unwrap().clone(); + facts.insert("role".into(), json!(role)); + facts.insert("mode".into(), json!(mode)); + if let Some(enforcer) = enforcer { + facts.insert("enforcerDid".into(), json!(enforcer)); + } + let header = json!({ + "alg": "EdDSA", + "jwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "kty": "OKP", + "x": encode_config(keypair.public().to_bytes(), URL_SAFE_NO_PAD), + }, + "typ": "JWT", + "ucv": "0.10.0", + }); + let payload = json!({ + "att": projections.attenuation.clone(), + "aud": audience, + "exp": root.expiry.unix_timestamp(), + "fct": [Value::Object(facts)], + "iss": vm, + "nbf": root.not_before.unix_timestamp(), + "nnc": unique(&format!("tc-1423-{role}-")), + "prf": [], + }); + let protected = encode_config(canonical_json_value(&header), URL_SAFE_NO_PAD); + let payload = encode_config(canonical_json_value(&payload), URL_SAFE_NO_PAD); + let signing_input = format!("{protected}.{payload}"); + format!( + "{signing_input}.{}", + encode_config(keypair.sign(signing_input.as_bytes()), URL_SAFE_NO_PAD) + ) + }; + let policy_root = root_authorization( + &format!("did:tinycloud:policy:{digest}"), + "policy-authority", + "policy-source", + None, + ); + let enforcement_root = root_authorization( + self.enforcer.did(), + "policy-enforcement", + "conditional-mint", + Some(self.enforcer.did()), + ); + let policy_root_cid = decode_root(&policy_root) + .map_err(|(_, error)| anyhow::anyhow!(error))? + .0; + let enforcement_root_cid = decode_root(&enforcement_root) + .map_err(|(_, error)| anyhow::anyhow!(error))? + .0; + let binding = self + .client + .post("/policy/v3/enforcer-bindings") + .header(ContentType::JSON) + .body( + json!({"rootExpiresAt": format_time(root.expiry), "enforcerDid": self.enforcer.did()}) + .to_string(), + ) + .dispatch() + .await; + anyhow::ensure!(binding.status() == Status::Ok, "enforcer binding"); + let binding: Value = binding.into_json().await.unwrap(); + let response = self + .client + .post("/policy/v3/policies") + .header(ContentType::JSON) + .body( + json!({ + "policyCid": policy_cid, + "policy": policy, + "policyRoot": policy_root, + "enforcementRoot": enforcement_root, + "contentSourceDigestHex": projections.content_source_digest_hex, + "nativeProjectionHashHex": projections.native_projection_hash_hex, + "attestedEnforcerBinding": binding, + }) + .to_string(), + ) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + Ok(( + status, + body, + Registered { + policy_cid, + policy_root_cid, + enforcement_root_cid, + }, + )) + } + + /// Register and require success. + pub async fn register_ok(&self, root: Window) -> anyhow::Result { + let (status, body, registered) = self.register(root).await?; + anyhow::ensure!(status == Status::Ok, "register: {status} {body}"); + Ok(registered) + } + + pub async fn challenge(&self, policy: &Registered) -> (Status, String) { + let response = self + .client + .post("/policy/v3/challenges") + .header(ContentType::JSON) + .body( + json!({ + "policyCid": policy.policy_cid, + "recipientDid": self.holder.did(), + "requestedCapabilities": self.requested(), + }) + .to_string(), + ) + .dispatch() + .await; + let status = response.status(); + (status, response.into_string().await.unwrap_or_default()) + } + + /// Challenge and mint an accountless (v4) session for the holder. + pub async fn mint( + &self, + policy: &Registered, + ) -> anyhow::Result> { + let (status, body) = self.challenge(policy).await; + if status != Status::Ok { + return Ok(Err((status, body))); + } + let challenge: Value = serde_json::from_str(&body)?; + let issuer_did = self.vector["policyProjection"]["issuerDid"] + .as_str() + .unwrap(); + let issued = now() - Duration::seconds(5); + let expires = now() + Duration::seconds(600); + let holder = self.holder.did(); + let disclosure = encode_config( + canonical_json_value(&json!(["salt-tc-1423", "email", "alice@example.test"])), + URL_SAFE_NO_PAD, + ); + let credential_jti = unique("credential-tc-1423-"); + let payload = json!({ + "iss": issuer_did, + "sub": holder, + "iat": issued.unix_timestamp(), + "nbf": issued.unix_timestamp(), + "exp": expires.unix_timestamp(), + "jti": credential_jti, + "vct": "opencredentials.email/v1", + "profile": "tinycloud.email-proof/v1", + "profileVersion": 1, + "descriptorDigest": self.vector["policyProjection"]["descriptorDigest"], + "holderBinding": {"did": holder, "signingDomain": "tinycloud.credentials/holder-binding/v1"}, + "_sd_alg": "sha-256", + "_sd": [encode_config(Sha256::digest(disclosure.as_bytes()), URL_SAFE_NO_PAD)] + }); + let header = json!({ + "alg": "EdDSA", + "typ": "vc+sd-jwt", + "kid": self.vector["policyProjection"]["issuerKid"] + }); + let signing_input = format!( + "{}.{}", + encode_config(canonical_json_value(&header), URL_SAFE_NO_PAD), + encode_config(canonical_json_value(&payload), URL_SAFE_NO_PAD) + ); + let compact = format!( + "{signing_input}.{}~{disclosure}", + encode_config(self.issuer.sign(signing_input.as_bytes()), URL_SAFE_NO_PAD) + ); + let claims = json!({"email": "alice@example.test"}); + let credential = json!({ + "type": "OpenCredentialsIssuedCredential", + "version": 1, + "protocol": "tinycloud.credentials/acquisition/v1", + "profile": {"id": "tinycloud.email-proof/v1", "version": 1}, + "credentialType": {"id": "opencredentials.email/v1", "version": 1}, + "schema": "opencredentials.email/v1", + "format": "vc+sd-jwt", + "issuerDid": issuer_did, + "issuerKid": self.vector["policyProjection"]["issuerKid"], + "subjectDid": holder, + "holderDid": holder, + "claims": claims, + "claimsDigest": canonical_digest_base64url(&claims), + "descriptorDigest": self.vector["policyProjection"]["descriptorDigest"], + "credentialId": credential_jti, + "issuedAt": format_time(issued), + "notBefore": format_time(issued), + "expiresAt": format_time(expires), + "status": {"method": "none", "freshnessSeconds": 300}, + "credential": compact + }); + let mut presentation = json!({ + "schema": CREDENTIAL_PRESENTATION_V4_SCHEMA, + "jti": unique("presentation-tc-1423-"), + "challengeId": challenge["challengeId"], + "nonce": challenge["nonce"], + "policyCid": policy.policy_cid, + "nodeAudience": self.node_did(), + "holderDid": holder, + "subjectDid": holder, + "credentialDigest": encode_config(Sha256::digest(compact.as_bytes()), URL_SAFE_NO_PAD), + "requirementDigest": self.vector["requirementDigest"], + "descriptorDigest": self.vector["policyProjection"]["descriptorDigest"], + "requestedCapabilities": self.requested(), + "issuedAt": format_time(OffsetDateTime::now_utc()), + "expiresAt": format_time(OffsetDateTime::now_utc() + Duration::seconds(45)), + "signature": {"suite": "Ed25519", "signerDid": holder, "value": ""} + }); + let mut unsigned = presentation.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)); + presentation["signature"]["value"] = json!(encode_config( + self.holder.keypair().sign(&Sha256::digest(preimage)), + URL_SAFE_NO_PAD + )); + let response = self + .client + .post("/policy/v3/delegations") + .header(ContentType::JSON) + .body( + json!({ + "policyCid": policy.policy_cid, + "challengeId": challenge["challengeId"], + "nonce": challenge["nonce"], + "requirement": self.vector["sdkRequirement"], + "credential": credential, + "presentation": presentation, + "requestedExpiresAt": format_time(now() + Duration::seconds(300)), + }) + .to_string(), + ) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + if status != Status::Ok { + return Ok(Err((status, body))); + } + Ok(Ok(serde_json::from_str::(&body)?["authorization"] + .as_str() + .unwrap() + .to_owned())) + } + + pub async fn mint_ok(&self, policy: &Registered) -> anyhow::Result { + self.mint(policy) + .await? + .map_err(|(status, body)| anyhow::anyhow!("mint: {status} {body}")) + } + + /// The holder reads the content under a minted session. + pub async fn read(&self, session: &str) -> anyhow::Result<(Status, Vec)> { + let s0 = decode_delegation(session).map_err(|(_, error)| anyhow::anyhow!(error))?; + let invocation = self.holder.invoke( + serde_json::from_value( + attenuation_for_policy_capabilities(&[self.requested_kv()]) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + s0.content_hash().to_cid(0x55), + )?; + let response = self + .client + .post("/invoke") + .header(Header::new("Authorization", invocation)) + .dispatch() + .await; + let status = response.status(); + Ok((status, response.into_bytes().await.unwrap_or_default())) + } + + pub async fn revoke(&self, signer: &Signer, cid: &str) -> anyhow::Result<()> { + let response = self + .client + .post("/revoke") + .header(Header::new("Authorization", signer.revoke(cid)?)) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + anyhow::ensure!(status == Status::Ok, "revoke: {status} {body}"); + Ok(()) + } + + fn backend(&self) -> DbBackend { + match self.db.backend { + Backend::Sqlite => DbBackend::Sqlite, + Backend::Postgres => DbBackend::Postgres, + } + } + + /// `POST /revoke` with an encoded revocation. + pub async fn post_revoke(&self, authorization: String) -> (Status, String) { + let response = self + .client + .post("/revoke") + .header(Header::new("Authorization", authorization)) + .dispatch() + .await; + let status = response.status(); + (status, response.into_string().await.unwrap_or_default()) + } + + /// `POST /revoke` with a `RootRevocation/v1` signed by `issuer` (Ed25519). + pub async fn revoke_root_json( + &self, + issuer: &Signer, + root_cid: &str, + role: &str, + ) -> anyhow::Result<(Status, String)> { + let mut revocation = json!({ + "schema": ROOT_REVOCATION_V1_SCHEMA, + "targetCid": root_cid, + "targetRole": role, + "ownerDid": self.owner.did(), + "nodeAudience": self.node_did(), + "revokedAt": format_time(now()), + "reason": "tc-1423-test", + "issuerDid": issuer.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": issuer.did(), + "value": encode_config(issuer.keypair().sign(&Sha256::digest(preimage)), URL_SAFE_NO_PAD), + }); + let response = self + .client + .post("/revoke") + .header(ContentType::JSON) + .body(json!({"revocation": revocation}).to_string()) + .dispatch() + .await; + let status = response.status(); + Ok((status, response.into_string().await.unwrap_or_default())) + } + + /// `POST /policy/v3/status` (renewal) for `root_cid`. + pub async fn renew_status(&self, root_cid: &str) -> (Status, String) { + let response = self + .client + .post("/policy/v3/status") + .header(ContentType::JSON) + .body(json!({"rootCid": root_cid, "renewal": {}}).to_string()) + .dispatch() + .await; + let status = response.status(); + (status, response.into_string().await.unwrap_or_default()) + } + + pub async fn execute(&self, sql: &str) -> anyhow::Result<()> { + self.conn() + .execute(Statement::from_string(self.backend(), sql.to_owned())) + .await?; + Ok(()) + } +} + +pub(super) fn policy_window() -> Window { + Window::until(now() + Duration::days(1)) +} + +pub(super) fn assert_opaque(label: &str, body: &str) { + assert!( + !body.contains("bafy") && !body.contains("bafk") && !body.contains("did:"), + "{label} leaks an identifier: {body}" + ); +} + +pub(super) fn session_cid(session: &str) -> anyhow::Result { + Ok(decode_delegation(session) + .map_err(|(_, error)| anyhow::anyhow!(error))? + .content_hash() + .to_cid(0x55)) +} + +pub(super) fn text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +impl Harness { + /// The holder decrypts through the network route under a session. + pub async fn decrypt(&self, session: &str) -> anyhow::Result<(Status, String)> { + let invocation = self.holder.invoke_for( + &self.node_did(), + serde_json::from_value( + attenuation_for_policy_capabilities(&[self.requested_decrypt()]) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + session_cid(session)?, + )?; + let response = self + .client + .post(format!("/encryption/networks/{}/decrypt", self.network)) + .header(ContentType::JSON) + .header(Header::new("Authorization", invocation)) + .body("{}") + .dispatch() + .await; + let status = response.status(); + Ok((status, response.into_string().await.unwrap_or_default())) + } + + /// The holder re-shares a session to a fresh reader; returns the reader + /// and the child CID. + pub async fn reshare( + &self, + session: &str, + ) -> anyhow::Result<(Status, String, Signer, Option)> { + let s0 = decode_delegation(session).map_err(|(_, error)| anyhow::anyhow!(error))?; + let reader = Signer::key(); + let child_not_before = s0.0.not_before.unwrap().unix_timestamp() + 1; + let mut facts = match &s0.0.delegation { + TinyCloudDelegation::Ucan(ucan) => ucan.payload().facts.clone().unwrap(), + TinyCloudDelegation::Cacao(_) => unreachable!(), + }; + facts[0]["remainingRedelegationDepth"] = json!(7); + let Signer::Key { jwk, vm, .. } = &self.holder else { + unreachable!() + }; + let child = Payload { + issuer: vm.parse()?, + audience: reader.did().parse()?, + // Whole seconds (sub-microsecond times don't survive PostgreSQL + // timestamps), after the parent's start; we wait for it below. + not_before: Some(NumericDate::try_from_seconds(child_not_before as f64)?), + expiration: NumericDate::try_from_seconds( + (s0.0.expiry.unwrap().unix_timestamp() - 1) as f64, + )?, + nonce: Some(unique("tc1423child")), + facts: Some(facts), + proof: vec![s0.content_hash().to_cid(0x55)], + attenuation: serde_json::from_value( + attenuation_for_policy_capabilities(&[self.requested_kv()]) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + } + .sign(Algorithm::EdDSA, jwk)?; + // The child must be valid when it is imported. + let wait = child_not_before - OffsetDateTime::now_utc().unix_timestamp(); + if wait >= 0 { + tokio::time::sleep(std::time::Duration::from_millis(wait as u64 * 1000 + 50)).await; + } + let (status, body) = self + .delegate(TinyCloudDelegation::Ucan(Box::new(child)).encode()?) + .await; + let cid = (status == Status::Ok) + .then(|| serde_json::from_str::(&body).ok()) + .flatten() + .and_then(|value| value["cid"].as_str().and_then(|cid| cid.parse().ok())); + Ok((status, body, reader, cid)) + } + + /// `reader` reads under the re-shared child `child`. + pub async fn read_as(&self, reader: &Signer, child: Cid) -> anyhow::Result<(Status, String)> { + let invocation = reader.invoke( + serde_json::from_value( + attenuation_for_policy_capabilities(&[self.requested_kv()]) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + child, + )?; + let response = self + .client + .post("/invoke") + .header(Header::new("Authorization", invocation)) + .dispatch() + .await; + let status = response.status(); + Ok(( + status, + text(&response.into_bytes().await.unwrap_or_default()), + )) + } + + /// The holder asks for a signed KV URL under a session. + pub async fn signed_kv(&self, session: &str) -> anyhow::Result<(Status, String)> { + let invocation = self.holder.invoke( + serde_json::from_value( + attenuation_for_policy_capabilities(&[self.requested_kv()]) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + session_cid(session)?, + )?; + let path = "shares/tc-1423/document.txt"; + let response = self + .client + .post("/signed/kv") + .header(ContentType::JSON) + .header(Header::new("Authorization", invocation)) + .body(json!({"space": self.content_space.to_string(), "path": path}).to_string()) + .dispatch() + .await; + let status = response.status(); + Ok((status, response.into_string().await.unwrap_or_default())) + } + + /// `/delegation/status` on `cid`, invoked by `signer`. + pub async fn delegation_status(&self, signer: &Signer, cid: &str) -> anyhow::Result { + let Signer::Key { jwk, vm, did, .. } = signer else { + anyhow::bail!("status is invoked by keys"); + }; + let at = now(); + let invocation = Payload { + issuer: vm.parse()?, + audience: did.parse()?, + not_before: Some(seconds(at)?), + expiration: seconds(at + Duration::seconds(30))?, + nonce: Some(unique("tc1423status")), + facts: Some(Vec::::new()), + proof: Vec::new(), + attenuation: capabilities(&[( + format!("urn:cid:{cid}"), + "tinycloud.delegation/status", + )])?, + } + .sign(Algorithm::EdDSA, jwk)? + .encode()?; + let response = self + .client + .post("/delegation/status") + .header(ContentType::JSON) + .header(Header::new("Authorization", invocation)) + .body(json!({"cid": cid}).to_string()) + .dispatch() + .await; + Ok(response.into_json().await.unwrap_or(Value::Null)) + } + + pub async fn root_status(&self, root_cid: &str) -> Value { + self.client + .get(format!("/policy/v3/status/{root_cid}")) + .dispatch() + .await + .into_json() + .await + .unwrap_or(Value::Null) + } +} + +impl Harness { + /// The space owner grants the holder hooks subscription on the content + /// prefix; returns the grant CIDs. + pub async fn grant_hooks(&self, count: usize) -> anyhow::Result> { + let resource = self.content_space.clone().to_resource( + "hooks".parse()?, + Some("kv/shares".parse()?), + None, + None, + ); + let mut grants = Vec::new(); + for _ in 0..count { + grants.push( + self.import(self.space_owner.delegate( + self.holder.did(), + &[(resource.to_string(), "tinycloud.hooks/subscribe")], + Window::until(now() + Duration::hours(2)), + &[], + )?) + .await?, + ); + } + Ok(grants) + } + + /// The holder mints a hook ticket on the content prefix citing `proofs`. + pub async fn hook_ticket(&self, proofs: &[String]) -> anyhow::Result<(Status, String)> { + let resource = self.content_space.clone().to_resource( + "hooks".parse()?, + Some("kv/shares".parse()?), + None, + None, + ); + let Signer::Key { jwk, vm, did, .. } = &self.holder else { + unreachable!() + }; + let at = now(); + let invocation = Payload { + issuer: vm.parse()?, + audience: did.parse()?, + not_before: Some(seconds(at)?), + expiration: seconds(at + Duration::seconds(60))?, + nonce: Some(unique("tc1423hooks")), + facts: Some(Vec::::new()), + proof: proofs + .iter() + .map(|cid| cid.parse()) + .collect::>()?, + attenuation: capabilities(&[(resource.to_string(), "tinycloud.hooks/subscribe")])?, + } + .sign(Algorithm::EdDSA, jwk)? + .encode()?; + let response = self + .client + .post("/hooks/tickets") + .header(ContentType::JSON) + .header(Header::new("Authorization", invocation)) + .body( + json!({ + "subscriptions": [{ + "space": self.content_space.to_string(), + "service": "kv", + "pathPrefix": "shares", + }], + "ttlSeconds": 120, + }) + .to_string(), + ) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + Ok((status, body)) + } + + /// The holder writes the content again, emitting a hook event. + pub async fn write_content(&self, body: &'static [u8]) -> anyhow::Result<()> { + let grant = self + .import(self.space_owner.delegate( + self.holder.did(), + &[(self.content_resource.to_string(), "tinycloud.kv/put")], + Window::until(now() + Duration::hours(1)), + &[], + )?) + .await?; + let Signer::Key { jwk, vm, .. } = &self.holder else { + unreachable!() + }; + let write = make_invocation( + [( + self.content_resource.clone(), + ["tinycloud.kv/put".parse::()?], + )], + &grant.parse()?, + jwk, + vm, + (now() + Duration::seconds(45)).unix_timestamp() as f64, + InvocationOptions { + nonce: Some(unique("tc1423write")), + ..InvocationOptions::default() + }, + )?; + let response = self + .client + .post("/invoke") + .header(Header::new("Authorization", write.encode()?)) + .header(ContentType::Plain) + .body(body) + .dispatch() + .await; + anyhow::ensure!( + response.status() == Status::Ok, + "write: {}", + response.status() + ); + Ok(()) + } +} From 185b79851d9faa422bb8ddcb0e74185b52609a3f Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 18:38:34 +0000 Subject: [PATCH 2/2] test(TC-1423): pin generic-route refusals of policy-root revokers - The enforcer, the delegatee of the enforcement root, is refused on the generic route on both roots (403 Unauthorized Revoker). - Generic refusals pin 403 Unauthorized Revoker; the hook-ticket refusal is pinned before (Unauthorized Action) and after the revoke (delegation-ancestor-revoked). - The shared harness drops helpers no test uses, and its dead-code allow. - Docs: policy sessions can't mint hook tickets, so the hook-stream bound doesn't apply; the released parser's 300-second freshness limit (TC-1450). --- docs/policy-root-revocation.md | 10 + .../src/policy_v3/root_revocation_tests.rs | 55 +++-- .../src/policy_v3/test_harness.rs | 199 +----------------- 3 files changed, 62 insertions(+), 202 deletions(-) diff --git a/docs/policy-root-revocation.md b/docs/policy-root-revocation.md index 7d96c4bd..fcec1f14 100644 --- a/docs/policy-root-revocation.md +++ b/docs/policy-root-revocation.md @@ -28,6 +28,11 @@ principals get 403. The revocation applies on every later use: a decrypt gets the core `401 delegation-ancestor-revoked`; - challenge and mint are refused. +A policy session grants only kv and decrypt, so it can't mint a hook ticket +(`401 Unauthorized Action`). There is then no hook stream to close, and the +300-second bound for open streams after a JSON-route revocation doesn't apply +to policy roots. + ## JSON route (`RootRevocation/v1`) Only the root's own signer may use it, or the enforcer for the enforcement @@ -53,3 +58,8 @@ error, so this route is not a confirmation. Renewal (`POST /policy/v3/status`) returns `409 root-revoked`. After a JSON-route revocation, both `state` values are `revoked`. + +The released SDK parser also rejects a checkpoint whose `freshUntil` is more +than 300 seconds after `checkedAt`. The node sets `freshUntil` to the root's +expiry, so the parser may reject a root that lives longer than 5 minutes even +while it is active (TC-1450). diff --git a/tinycloud-node-server/src/policy_v3/root_revocation_tests.rs b/tinycloud-node-server/src/policy_v3/root_revocation_tests.rs index e2f62060..815ef03f 100644 --- a/tinycloud-node-server/src/policy_v3/root_revocation_tests.rs +++ b/tinycloud-node-server/src/policy_v3/root_revocation_tests.rs @@ -16,6 +16,9 @@ const POLICY_ENFORCEMENT: &str = "policy-enforcement"; /// A registered policy with an admitted session that reads, and a re-shared /// child that reads. +/// The generic route's refusal of a principal that may not revoke. +const UNAUTHORIZED_REVOKER: &str = "Unauthorized Revoker"; + struct Admitted { policy: Registered, session: String, @@ -107,15 +110,28 @@ async fn the_space_owner_wallet_revokes_an_agent_signed_root( return Ok(()); }; let admitted = admitted(&h).await?; - // Policy sessions hold only kv and decrypt: no hook ticket, so no stream - // to close. + assert_no_hook_ticket(&h, &admitted, "Unauthorized Action").await?; + h.revoke(&h.space_owner, &admitted.policy.policy_root_cid) + .await?; + assert_root_revoked_cascade(&h, &admitted).await?; + // Now the chain check refuses it first. + assert_no_hook_ticket(&h, &admitted, "delegation-ancestor-revoked").await +} + +/// Policy sessions hold only kv and decrypt, so a hook ticket over one is +/// refused (401, with the cause `refusal` names): there is no hook stream +/// to close after a root revocation. +async fn assert_no_hook_ticket( + h: &Harness, + admitted: &Admitted, + refusal: &str, +) -> anyhow::Result<()> { let (status, body) = h .hook_ticket(&[session_cid(&admitted.session)?.to_string()]) .await?; - assert_ne!(status, Status::Ok, "{body}"); - h.revoke(&h.space_owner, &admitted.policy.policy_root_cid) - .await?; - assert_root_revoked_cascade(&h, &admitted).await + assert_eq!(status, Status::Unauthorized, "{body}"); + assert!(body.starts_with(refusal), "{body}"); + Ok(()) } on_both_backends!(the_space_owner_wallet_revokes_an_agent_signed_root); @@ -181,9 +197,10 @@ async fn others_cannot_revoke_a_policy_root(backend: Backend) -> anyhow::Result< ("recipient", &h.holder), ] { let (status, body) = h.post_revoke(signer.revoke(&root)?).await; - assert!( - [Status::Unauthorized, Status::Forbidden].contains(&status), - "{label} generic: {status} {body}" + assert_eq!( + (status, body.as_str()), + (Status::Forbidden, UNAUTHORIZED_REVOKER), + "{label} generic" ); let (status, body) = h.revoke_root_json(signer, &root, POLICY_AUTHORITY).await?; assert_eq!(status, Status::Forbidden, "{label} JSON: {body}"); @@ -241,9 +258,10 @@ async fn a_revoke_control_naming_the_network_revokes_a_policy_root( assert_eq!(status, Status::Ok, "{body}"); assert_root_revoked_cascade(&h, &admitted).await?; } else { - assert!( - [Status::Unauthorized, Status::Forbidden].contains(&status), - "space-only control: {status} {body}" + assert_eq!( + (status, body.as_str()), + (Status::Forbidden, UNAUTHORIZED_REVOKER), + "space-only control" ); assert_root_live(&h, &admitted).await?; } @@ -268,6 +286,19 @@ async fn the_json_route_keeps_its_signer_and_enforcer_rules( return Ok(()); }; let first = admitted(&h).await?; + // The enforcer is the delegatee of the enforcement root. The generic + // route refuses a delegatee on either root. + for root in [ + &first.policy.policy_root_cid, + &first.policy.enforcement_root_cid, + ] { + let (status, body) = h.post_revoke(h.enforcer.revoke(root)?).await; + assert_eq!( + (status, body.as_str()), + (Status::Forbidden, UNAUTHORIZED_REVOKER) + ); + } + assert_root_live(&h, &first).await?; let (status, body) = h .revoke_root_json(&h.enforcer, &first.policy.policy_root_cid, POLICY_AUTHORITY) .await?; diff --git a/tinycloud-node-server/src/policy_v3/test_harness.rs b/tinycloud-node-server/src/policy_v3/test_harness.rs index 7176a9e7..571d78bf 100644 --- a/tinycloud-node-server/src/policy_v3/test_harness.rs +++ b/tinycloud-node-server/src/policy_v3/test_harness.rs @@ -5,9 +5,6 @@ //! PostgreSQL runs need `TINYCLOUD_TEST_POSTGRES_URL` (each test uses its own //! schema); they are skipped when it is unset outside CI. -// Shared by several test modules; each uses a subset. -#![allow(dead_code)] - use super::*; use rocket::{ figment::{providers::Serialized, Figment}, @@ -29,7 +26,7 @@ use tinycloud_auth::{ ssi::{claims::jwt::NumericDate, ucan::Payload}, ucan_capabilities_object::Capabilities, }; -use tinycloud_core::sea_orm::{ConnectOptions, Database, DbBackend, Statement}; +use tinycloud_core::sea_orm::{ConnectOptions, Database}; type Cid = tinycloud_auth::ipld_core::cid::Cid; @@ -86,9 +83,8 @@ pub(super) fn unique(label: &str) -> String { ) } -/// A database the production app can be booted on, more than once. +/// A database the production app is booted on. pub(super) struct TestDatabase { - pub backend: Backend, url: String, _dir: tempfile::TempDir, } @@ -114,11 +110,7 @@ impl TestDatabase { parsed.to_string() } }; - Ok(Some(Self { - backend, - url, - _dir: dir, - })) + Ok(Some(Self { url, _dir: dir })) } pub fn url(&self) -> &str { @@ -150,8 +142,6 @@ pub(super) enum Signer { pub(super) struct Window { pub not_before: OffsetDateTime, pub expiry: OffsetDateTime, - /// The CACAO issue time; UCANs carry none. - pub issued_at: Option, } impl Window { @@ -159,14 +149,8 @@ impl Window { Self { not_before: now() - Duration::seconds(30), expiry, - issued_at: None, } } - - pub fn issued(mut self, issued_at: OffsetDateTime) -> Self { - self.issued_at = Some(issued_at); - self - } } pub(super) fn now() -> OffsetDateTime { @@ -311,7 +295,7 @@ impl Signer { let message = recap.build_message(self.siwe_message( audience, &nonce, - window.issued_at.unwrap_or(window.not_before), + window.not_before, Some(window.not_before), window.expiry, )?)?; @@ -415,13 +399,12 @@ pub(super) enum Shape { /// A did:pkh space owner signs SIWE to the policy owner's key, as Share /// and the CLI do. PkhSession, - /// The policy owner owns the content space. - SpaceOwner, } pub(super) struct Harness { pub client: Client, - pub db: TestDatabase, + /// Kept for the app's lifetime (the SQLite directory). + _db: TestDatabase, pub space_owner: Signer, /// The policy owner (a key: it signs the policy and its roots). pub owner: Signer, @@ -434,26 +417,8 @@ pub(super) struct Harness { vector: Value, } -/// Settings for one boot of the production app. -#[derive(Clone, Default)] -pub(super) struct Boot { - /// The `[policy_v3]` section, passed through figment. - pub policy_v3: Option, - /// The `[hooks]` section, passed through figment. - pub hooks: Option, -} - impl Harness { pub async fn new(backend: Backend, shape: Shape, test: &str) -> anyhow::Result> { - Self::with_boot(backend, shape, test, Boot::default()).await - } - - pub async fn with_boot( - backend: Backend, - shape: Shape, - test: &str, - boot: Boot, - ) -> anyhow::Result> { let Some(db) = TestDatabase::new(backend, test).await? else { return Ok(None); }; @@ -465,21 +430,6 @@ impl Harness { let space_owner = match shape { Shape::KeySession => Signer::key(), Shape::PkhSession => Signer::pkh(), - // The same key: a separate value keeps the struct simple. - Shape::SpaceOwner => match &owner { - Signer::Key { - keypair, - jwk, - did, - vm, - } => Signer::Key { - keypair: keypair.clone(), - jwk: jwk.clone(), - did: did.clone(), - vm: vm.clone(), - }, - Signer::Pkh { .. } => unreachable!(), - }, }; let content_space = SpaceId::new( space_owner.did().parse::()?, @@ -492,10 +442,10 @@ impl Harness { None, ); let network = format!("urn:tinycloud:encryption:{}:mainnet", space_owner.did()); - let client = Self::boot_app(&db, &vector, &issuer, &boot).await?; + let client = Self::boot_app(&db, &vector, &issuer).await?; let harness = Self { client, - db, + _db: db, space_owner, owner, holder: Signer::key(), @@ -514,7 +464,6 @@ impl Harness { db: &TestDatabase, vector: &Value, issuer: &tinycloud_core::libp2p::identity::ed25519::Keypair, - boot: &Boot, ) -> anyhow::Result { let mut config = crate::config::Config::default(); config.storage.datadir = db.datadir().into(); @@ -535,36 +484,16 @@ impl Harness { .to_owned(); config.share_email.issuer_public_key = Some(encode_config(issuer.public().to_bytes(), URL_SAFE_NO_PAD)); - let mut figment = Figment::from(rocket::Config { + let figment = Figment::from(rocket::Config { address: "127.0.0.1".parse()?, log_level: rocket::config::LogLevel::Off, ..rocket::Config::default() }) .merge(Serialized::defaults(&config)); - if let Some(policy_v3) = &boot.policy_v3 { - figment = figment.merge(Serialized::default("policy_v3", policy_v3)); - } - if let Some(hooks) = &boot.hooks { - figment = figment.merge(Serialized::default("hooks", hooks)); - } let config: crate::config::Config = figment.extract()?; Ok(Client::tracked(crate::app_with_control(&figment, &config, None).await?).await?) } - /// Boot the production app again on the same database. - pub async fn reboot(&mut self, boot: Boot) -> anyhow::Result<()> { - let client = Self::boot_app(&self.db, &self.vector, &self.issuer, &boot).await?; - self.client = client; - Ok(()) - } - - pub fn conn(&self) -> &tinycloud_core::RecoveringConnection { - self.client - .rocket() - .state::() - .expect("node pool") - } - pub fn node_did(&self) -> String { self.client .rocket() @@ -1058,13 +987,6 @@ impl Harness { Ok(()) } - fn backend(&self) -> DbBackend { - match self.db.backend { - Backend::Sqlite => DbBackend::Sqlite, - Backend::Postgres => DbBackend::Postgres, - } - } - /// `POST /revoke` with an encoded revocation. pub async fn post_revoke(&self, authorization: String) -> (Status, String) { let response = self @@ -1124,26 +1046,12 @@ impl Harness { let status = response.status(); (status, response.into_string().await.unwrap_or_default()) } - - pub async fn execute(&self, sql: &str) -> anyhow::Result<()> { - self.conn() - .execute(Statement::from_string(self.backend(), sql.to_owned())) - .await?; - Ok(()) - } } pub(super) fn policy_window() -> Window { Window::until(now() + Duration::days(1)) } -pub(super) fn assert_opaque(label: &str, body: &str) { - assert!( - !body.contains("bafy") && !body.contains("bafk") && !body.contains("did:"), - "{label} leaks an identifier: {body}" - ); -} - pub(super) fn session_cid(session: &str) -> anyhow::Result { Ok(decode_delegation(session) .map_err(|(_, error)| anyhow::anyhow!(error))? @@ -1250,28 +1158,6 @@ impl Harness { )) } - /// The holder asks for a signed KV URL under a session. - pub async fn signed_kv(&self, session: &str) -> anyhow::Result<(Status, String)> { - let invocation = self.holder.invoke( - serde_json::from_value( - attenuation_for_policy_capabilities(&[self.requested_kv()]) - .map_err(|(_, error)| anyhow::anyhow!(error))?, - )?, - session_cid(session)?, - )?; - let path = "shares/tc-1423/document.txt"; - let response = self - .client - .post("/signed/kv") - .header(ContentType::JSON) - .header(Header::new("Authorization", invocation)) - .body(json!({"space": self.content_space.to_string(), "path": path}).to_string()) - .dispatch() - .await; - let status = response.status(); - Ok((status, response.into_string().await.unwrap_or_default())) - } - /// `/delegation/status` on `cid`, invoked by `signer`. pub async fn delegation_status(&self, signer: &Signer, cid: &str) -> anyhow::Result { let Signer::Key { jwk, vm, did, .. } = signer else { @@ -1316,30 +1202,6 @@ impl Harness { } impl Harness { - /// The space owner grants the holder hooks subscription on the content - /// prefix; returns the grant CIDs. - pub async fn grant_hooks(&self, count: usize) -> anyhow::Result> { - let resource = self.content_space.clone().to_resource( - "hooks".parse()?, - Some("kv/shares".parse()?), - None, - None, - ); - let mut grants = Vec::new(); - for _ in 0..count { - grants.push( - self.import(self.space_owner.delegate( - self.holder.did(), - &[(resource.to_string(), "tinycloud.hooks/subscribe")], - Window::until(now() + Duration::hours(2)), - &[], - )?) - .await?, - ); - } - Ok(grants) - } - /// The holder mints a hook ticket on the content prefix citing `proofs`. pub async fn hook_ticket(&self, proofs: &[String]) -> anyhow::Result<(Status, String)> { let resource = self.content_space.clone().to_resource( @@ -1389,47 +1251,4 @@ impl Harness { let body = response.into_string().await.unwrap_or_default(); Ok((status, body)) } - - /// The holder writes the content again, emitting a hook event. - pub async fn write_content(&self, body: &'static [u8]) -> anyhow::Result<()> { - let grant = self - .import(self.space_owner.delegate( - self.holder.did(), - &[(self.content_resource.to_string(), "tinycloud.kv/put")], - Window::until(now() + Duration::hours(1)), - &[], - )?) - .await?; - let Signer::Key { jwk, vm, .. } = &self.holder else { - unreachable!() - }; - let write = make_invocation( - [( - self.content_resource.clone(), - ["tinycloud.kv/put".parse::()?], - )], - &grant.parse()?, - jwk, - vm, - (now() + Duration::seconds(45)).unix_timestamp() as f64, - InvocationOptions { - nonce: Some(unique("tc1423write")), - ..InvocationOptions::default() - }, - )?; - let response = self - .client - .post("/invoke") - .header(Header::new("Authorization", write.encode()?)) - .header(ContentType::Plain) - .body(body) - .dispatch() - .await; - anyhow::ensure!( - response.status() == Status::Ok, - "write: {}", - response.status() - ); - Ok(()) - } }