From b4eef9957b835de3e588be9f64d9e92969798a07 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 16:36:38 +0000 Subject: [PATCH 1/9] test(TC-1423): policy source network must belong to the content owner (RED) Reproduction from the N1b design: a policy owner holding only kv/get and kv/metadata on the content registers a Policy/v3 whose source network is named after itself and was never created. Today registration is accepted. --- tinycloud-node-server/src/policy_v3.rs | 204 ++++++++++++++++++++++++- 1 file changed, 197 insertions(+), 7 deletions(-) diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 31e1139b..242869e5 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -7999,6 +7999,20 @@ mod tests { run_policy_v2_flow(PolicyOwner::Unauthorized).await } + /// TC-1423 (B2, N1b): a policy owner that holds only `kv/get` and + /// `kv/metadata` on the content (no decrypt on the content owner's + /// network) registers a policy whose content source names a network + /// called after the policy owner itself, which needs no grant: a DID is + /// root authority over networks that name it. Registration must refuse + /// it: the source network must exist and belong to the content space's + /// owner, and the owner's decrypt on it must come through its authority + /// chain. Until then this test records what the node does instead and + /// fails. + #[tokio::test] + async fn policy_source_network_must_belong_to_the_content_owner() -> anyhow::Result<()> { + run_policy_v2_flow(PolicyOwner::KvOnlySelfNamedNetwork).await + } + #[derive(Clone, Copy, PartialEq, Eq)] enum PolicyOwner { /// The policy owner is the content space's owner. @@ -8007,6 +8021,135 @@ mod tests { DelegatedSession, /// The policy owner holds nothing in the content space. Unauthorized, + /// The policy owner holds only kv/get + kv/metadata through a + /// delegation from the space owner (no decrypt), and the policy's + /// source network is one named after the policy owner itself. + KvOnlySelfNamedNetwork, + } + + /// Observations for `policy_source_network_must_belong_to_the_content_owner` + /// once registration was accepted: the recorded owner binding (N1, if + /// present), whether its closure holds the owner's delegation, and what an + /// admitted session gets after the space owner revokes that delegation. + #[allow(clippy::too_many_arguments)] + async fn kv_only_self_network_report( + client: &rocket::local::asynchronous::Client, + policy_cid: &String, + owner_delegation_cid: Option, + content_owner_vm: &String, + content_owner_did: &String, + content_owner_jwk: &JWK, + reader_vm: &String, + reader_did: &String, + reader_jwk: &JWK, + child_cid: tinycloud_auth::ipld_core::cid::Cid, + requested: &Vec, + ) -> anyhow::Result<(String, String, String)> { + use tinycloud_auth::{ + siwe_recap::Ability as RecapAbility, ucan_capabilities_object::Capabilities, + }; + use tinycloud_core::sea_orm::{ConnectionTrait as _, Statement}; + let owner_delegation_cid = owner_delegation_cid + .clone() + .ok_or_else(|| anyhow::anyhow!("missing owner delegation"))?; + let conn = &client.rocket().state::().unwrap().conn; + // What authority the node recorded, if it records any (N1, #358). + let backend = conn.get_database_backend(); + let binding = conn + .query_one(Statement::from_sql_and_values( + backend, + "SELECT state FROM policy_v3_owner_binding WHERE policy_cid = ?", + [policy_cid.clone().into()], + )) + .await; + let binding_state = match binding { + Ok(Some(row)) => row.try_get::("", "state").unwrap_or_default(), + Ok(None) => "no binding row".to_owned(), + Err(_) => "no binding table (pre-N1)".to_owned(), + }; + let owner_hash = tinycloud_core::hash::Hash::from( + owner_delegation_cid.parse::()?, + ); + let closure = conn + .query_one(Statement::from_sql_and_values( + backend, + "SELECT COUNT(*) AS n FROM policy_v3_owner_authority \ + WHERE policy_cid = ? AND delegation_id = ?", + [policy_cid.clone().into(), owner_hash.into()], + )) + .await; + let closure_has_share_root = match closure { + Ok(Some(row)) => format!("{}", row.try_get::("", "n").unwrap_or(-1) > 0), + Ok(None) => "no row".to_owned(), + Err(_) => "no closure table (pre-N1)".to_owned(), + }; + + // The space owner revokes the owner's (kv-only) delegation: the + // share root of the D3 design. + let mut capabilities = Capabilities::::new(); + capabilities.with_action( + format!("urn:cid:{owner_delegation_cid}").parse()?, + "tinycloud.delegation/revoke".parse::()?, + [std::collections::BTreeMap::::new()], + ); + let revocation = tinycloud_auth::authorization::TinyCloudRevocation::Ucan(Box::new( + Payload { + issuer: content_owner_vm.parse()?, + audience: content_owner_did.parse()?, + not_before: None, + expiration: NumericDate::try_from_seconds( + (OffsetDateTime::now_utc() + Duration::seconds(60)).unix_timestamp() as f64, + )?, + nonce: Some("tc1423-kv-only-owner-revoke".into()), + facts: Some(Vec::new()), + proof: Vec::new(), + attenuation: capabilities, + } + .sign(Algorithm::EdDSA, &content_owner_jwk)?, + )); + let revoke_status = client + .post("/revoke") + .header(rocket::http::Header::new( + "Authorization", + revocation.encode()?, + )) + .dispatch() + .await + .status(); + assert_eq!(revoke_status, Status::Ok, "owner revoke of the share root"); + + let reread_now = OffsetDateTime::now_utc(); + let reread = Payload { + issuer: reader_vm.parse()?, + audience: reader_did.parse()?, + not_before: Some(NumericDate::try_from_seconds( + reread_now.unix_timestamp() as f64 + )?), + expiration: NumericDate::try_from_seconds( + (reread_now + Duration::seconds(30)).unix_timestamp() as f64, + )?, + nonce: Some("tc1423-kv-only-reread".into()), + facts: Some(Vec::::new()), + proof: vec![child_cid], + attenuation: serde_json::from_value::>( + attenuation_for_policy_capabilities(&requested) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + } + .sign(Algorithm::EdDSA, &reader_jwk)?; + let reread_response = client + .post("/invoke") + .header(rocket::http::Header::new("Authorization", reread.encode()?)) + .dispatch() + .await; + let reread_status = reread_response.status(); + let reread_body = reread_response.into_string().await.unwrap_or_default(); + let reread_outcome = if reread_status == Status::Ok { + format!("200, {} bytes (survives)", reread_body.len()) + } else { + format!("{} {reread_body}", reread_status.code) + }; + Ok((binding_state, closure_has_share_root, reread_outcome)) } async fn run_policy_v2_flow(policy_owner: PolicyOwner) -> anyhow::Result<()> { @@ -8088,7 +8231,11 @@ mod tests { "selector": "exact", "actions": ["tinycloud.kv/get"] })]; - let encryption_resource = format!("urn:tinycloud:encryption:{content_owner_did}:mainnet"); + let encryption_resource = if policy_owner == PolicyOwner::KvOnlySelfNamedNetwork { + format!("urn:tinycloud:encryption:{owner_did}:share") + } else { + format!("urn:tinycloud:encryption:{content_owner_did}:mainnet") + }; let ceiling = vec![ json!({ "kind": "kv", @@ -8567,7 +8714,10 @@ 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::KvOnlySelfNamedNetwork + ) { let mut owner_capabilities = Capabilities::::new(); for ability in ["tinycloud.kv/get", "tinycloud.kv/metadata"] { owner_capabilities.with_action( @@ -8576,11 +8726,13 @@ mod tests { [std::collections::BTreeMap::::new()], ); } - owner_capabilities.with_action( - encryption_resource.parse()?, - "tinycloud.encryption/decrypt".parse::()?, - [std::collections::BTreeMap::::new()], - ); + if policy_owner == PolicyOwner::DelegatedSession { + owner_capabilities.with_action( + encryption_resource.parse()?, + "tinycloud.encryption/decrypt".parse::()?, + [std::collections::BTreeMap::::new()], + ); + } let authorization = TinyCloudDelegation::Ucan(Box::new( Payload { issuer: content_owner_vm.parse()?, @@ -8787,6 +8939,15 @@ mod tests { assert!(policy_v3_root::Entity::find().one(conn).await?.is_none()); return Ok(()); } + if policy_owner == PolicyOwner::KvOnlySelfNamedNetwork && register_status != Status::Ok { + assert_eq!( + register_status, + Status::Forbidden, + "register: {register_body}" + ); + assert_eq!(register_body, "content-source-network-invalid"); + return Ok(()); + } assert_eq!(register_status, Status::Ok, "register: {register_body}"); let expected_identity = tinycloud_core::request_telemetry::RequestContext::new( uuid::Uuid::new_v4(), @@ -9216,6 +9377,35 @@ mod tests { assert_eq!(read_status, Status::Ok, "read response: {read_body:?}"); assert_eq!(read_body, b"tc-470-real-content"); + if policy_owner == PolicyOwner::KvOnlySelfNamedNetwork { + // Boxed: the shared flow's future is already close to the + // default test-thread stack. + let (binding_state, closure_has_share_root, reread_outcome) = + Box::pin(kv_only_self_network_report( + &client, + &policy_cid, + owner_delegation_cid.clone(), + &content_owner_vm, + content_owner_did, + &content_owner_jwk, + &reader_vm, + &reader_did, + &reader_jwk, + child_cid, + &requested, + )) + .await?; + panic!( + "TC-1423 N1b RED: registration over a self-named network was ACCEPTED \ + (200; source network {encryption_resource} is named by the kv-only \ + policy owner and was never created); the admitted recipient read the \ + content (200, {} bytes); owner binding state: {binding_state}; closure \ + contains the share root: {closure_has_share_root}; read after the space \ + owner revoked the share root: {reread_outcome}", + read_body.len() + ); + } + // TC-541: hooks requests pass the same Policy/v3 gate as `/invoke`. // Policies can only grant `kv` and `encryption` capabilities, so no // policy session can hold `tinycloud.hooks/*`: the reader's chain is From 81560c67faf8b97017599e2983f4b92119850fb5 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 16:41:30 +0000 Subject: [PATCH 2/9] test(TC-1423): source network owner and state cases at registration (RED) Foreign, missing, inactive, chain-mismatched and self-named networks, every ceiling network, the legacy source form, and the Share/D3 did:pkh shapes over the real /delegate, /encryption/networks and registration routes. Adds the refusal counter the tests read. --- tinycloud-node-server/src/policy_v3.rs | 4 + .../src/policy_v3/source_network_tests.rs | 791 ++++++++++++++++++ tinycloud-node-server/src/prometheus.rs | 16 + 3 files changed, 811 insertions(+) create mode 100644 tinycloud-node-server/src/policy_v3/source_network_tests.rs diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 242869e5..9a36c159 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -10423,3 +10423,7 @@ mod tests { #[cfg(test)] #[path = "policy_v3/account_alias_tests.rs"] mod account_alias_tests; + +#[cfg(test)] +#[path = "policy_v3/source_network_tests.rs"] +mod source_network_tests; diff --git a/tinycloud-node-server/src/policy_v3/source_network_tests.rs b/tinycloud-node-server/src/policy_v3/source_network_tests.rs new file mode 100644 index 00000000..e87a80c5 --- /dev/null +++ b/tinycloud-node-server/src/policy_v3/source_network_tests.rs @@ -0,0 +1,791 @@ +//! TC-1423 N1b: every encryption network a Policy/v3 names must be the +//! content owner's, and active on this node, when the policy is registered. +//! +//! Registration-only fixtures over the real routes: `/delegate` imports the +//! registrant's grants (UCAN from a did:key owner, SIWE CACAO from a did:pkh +//! owner), `POST /encryption/networks` creates the Share-shaped network, and +//! `POST /policy/v3/policies` registers. Every refused case is otherwise +//! registrable: the registrant holds every root capability, so the refusal +//! comes from the source-network check alone. + +use super::*; +use k256::ecdsa::SigningKey; +use rocket::{http::ContentType, local::asynchronous::Client}; +use serde_json::json; +use sha3::Keccak256; +use std::future::Future; +use tinycloud_auth::{ + authorization::HeaderEncode, + cacaos::{ + siwe::{encode_eip55, Message, Version}, + siwe_cacao::{Header as SiweHeader, 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::{ + encryption_network::{ + canonical::canonical_hash, CreateNetworkRequest, EncryptionService, NetworkId, Threshold, + NETWORK_ADMIN_TYPE, NETWORK_CREATE_ACTION, + }, + models::encryption_network, + sea_orm::Database, +}; + +const KV_PATH: &str = "shares/tc-1423-n1b/document.txt"; +const DECRYPT: &str = "tinycloud.encryption/decrypt"; + +type Ed25519Keypair = tinycloud_core::libp2p::identity::ed25519::Keypair; + +/// A did:key principal: the policy owner (a session key, as in Share and the +/// CLI) or a did:key content owner. +struct KeyPrincipal { + key: Ed25519Keypair, + did: String, + vm: String, + jwk: JWK, +} + +impl KeyPrincipal { + fn generate() -> Self { + let key = Ed25519Keypair::generate(); + let did = tinycloud_core::keys::public_key_to_did_key( + tinycloud_core::libp2p::identity::Keypair::from(key.clone()).public(), + ); + let mut jwk = JWK::from(Params::OKP(OctetParams { + curve: "Ed25519".to_owned(), + public_key: Base64urlUInt(key.public().to_bytes().to_vec()), + private_key: Some(Base64urlUInt(key.secret().as_ref().to_vec())), + })); + jwk.algorithm = Some(Algorithm::EdDSA); + let vm = format!("{did}#{}", did.trim_start_matches("did:key:")); + Self { key, did, vm, jwk } + } +} + +/// An Ethereum account: a did:pkh content owner, as in Share. +struct PkhPrincipal { + key: SigningKey, + address: [u8; 20], +} + +impl PkhPrincipal { + fn new(seed: u8) -> anyhow::Result { + let key = SigningKey::from_bytes(&[seed; 32].into())?; + let public = key.verifying_key().to_encoded_point(false); + let digest = Keccak256::digest(&public.as_bytes()[1..]); + let principal = Self { + key, + address: digest[12..].try_into()?, + }; + // The case-insensitivity cases mean something only if the checksum + // has upper-case letters. + assert_ne!(principal.did(1), principal.lowercase_did(1)); + Ok(principal) + } + + /// The EIP-55 spelling space ids and session DIDs carry. + fn did(&self, chain_id: u64) -> String { + format!( + "did:pkh:eip155:{chain_id}:0x{}", + encode_eip55(&self.address) + ) + } + + /// The spelling Share puts in its network URN (`ownerEncryptionNetwork`). + fn lowercase_did(&self, chain_id: u64) -> String { + format!("did:pkh:eip155:{chain_id}:0x{}", hex::encode(self.address)) + } +} + +fn network(owner_did: &str, name: &str) -> String { + format!("urn:tinycloud:encryption:{owner_did}:{name}") +} + +fn kv_resource(owner_did: &str) -> anyhow::Result { + Ok(SpaceId::new(owner_did.parse::()?, "applications".parse()?).to_resource( + "kv".parse()?, + Some(KV_PATH.parse()?), + None, + None, + )) +} + +/// The registrant's grants on the content: kv get + metadata on the content +/// resource, and decrypt on each listed network. +fn content_grants(kv: &ResourceId, decrypt: &[&str]) -> Vec<(String, &'static str)> { + let mut grants = vec![ + (kv.as_uri().to_string(), "tinycloud.kv/get"), + (kv.as_uri().to_string(), "tinycloud.kv/metadata"), + ]; + grants.extend(decrypt.iter().map(|network| (network.to_string(), DECRYPT))); + grants +} + +fn refused_total(reason: &str) -> u64 { + crate::prometheus::POLICY_SOURCE_NETWORK_REFUSED + .with_label_values(&[reason]) + .get() +} + +struct Harness { + client: Client, + node_did: String, + enforcer: KeyPrincipal, + credential_requirement: Value, +} + +impl Harness { + async fn new() -> anyhow::Result { + use tinycloud_core::{ + database_artifacts::SeaOrmDatabaseArtifactRepository, + encryption::ColumnEncryption, + encryption_network::LocalOneOfOneBackend, + sql::SqlService, + storage::{either::Either, StorageConfig as _}, + }; + let vector: Value = serde_json::from_str(include_str!( + "../../test-fixtures/tc-470-policy-credential-requirement.json" + ))?; + let db = Database::connect("sqlite::memory:").await?; + let node_secret = + StaticSecret::new(vec![29; 32]).map_err(|_| anyhow::anyhow!("invalid secret"))?; + let node_did = node_secret.node_did(); + let runtime = PolicyV3Runtime::new(db.clone(), node_did.clone(), node_secret); + let storage_dir = tempfile::TempDir::new()?; + let storage = crate::storage::file_system::FileSystemConfig::new(storage_dir.path()) + .open() + .await?; + let _storage_dir = storage_dir.keep(); + let tinycloud = crate::TinyCloud::new( + db.clone().into(), + Either::B(storage), + StaticSecret::new(vec![0; 32]).map_err(|_| anyhow::anyhow!("invalid secret"))?, + ) + .await?; + let sql_dir = tempfile::TempDir::new()?; + let sql_service = SqlService::new( + sql_dir.path().to_string_lossy().to_string(), + u64::MAX, + Arc::new(SeaOrmDatabaseArtifactRepository::new(db.clone())), + ); + let _sql_dir = sql_dir.keep(); + let encryption = EncryptionService::new_with_node_keypair( + db.clone(), + runtime.signer.node_keypair(), + Arc::new(LocalOneOfOneBackend::new(ColumnEncryption::new( + runtime + .signer + .derive_key(b"tinycloud/encryption/network-seal"), + ))), + ); + assert_eq!(encryption.node_did(), node_did); + let rocket = rocket::build() + .mount( + "/", + rocket::routes![ + register_policy, + issue_enforcer_binding, + crate::routes::delegate, + crate::routes::encryption::create_network, + ], + ) + .attach(crate::tracing::TracingFairing::for_test( + &crate::config::Config::default().log.tracing, + )) + .manage(tinycloud) + .manage(runtime) + .manage(sql_service) + .manage(crate::config::Config::default()) + .manage(crate::quota::QuotaCache::new(None, None)) + .manage(crate::invocation_replay::InvocationReplayCache::new( + db.clone(), + )) + .manage(crate::BlockStage::from( + crate::config::StagingStorage::Memory, + )) + .manage(encryption); + Ok(Self { + client: Client::tracked(rocket).await?, + node_did, + enforcer: KeyPrincipal::generate(), + credential_requirement: vector["policyProjection"].clone(), + }) + } + + fn encryption(&self) -> &EncryptionService { + self.client.rocket().state::().unwrap() + } + + fn conn(&self) -> &RecoveringConnection { + &self.client.rocket().state::().unwrap().conn + } + + async fn post_delegation(&self, authorization: String) -> anyhow::Result { + let response = self + .client + .post("/delegate") + .header(rocket::http::Header::new("Authorization", authorization)) + .dispatch() + .await; + let status = response.status(); + let body = response.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Ok, "/delegate: {body}"); + Ok(serde_json::from_str::(&body)?["cid"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing delegation cid"))? + .to_owned()) + } + + /// A did:key issuer delegates `grants` to `audience` (a UCAN). + async fn delegate_ucan( + &self, + issuer: &KeyPrincipal, + audience: &str, + grants: &[(String, &str)], + ) -> anyhow::Result { + let now = OffsetDateTime::now_utc(); + let mut attenuation = Capabilities::::new(); + for (resource, ability) in grants { + attenuation.with_action( + resource.parse()?, + ability.parse::()?, + [std::collections::BTreeMap::::new()], + ); + } + let authorization = TinyCloudDelegation::Ucan(Box::new( + Payload { + issuer: issuer.vm.parse()?, + audience: audience.parse()?, + not_before: Some(NumericDate::try_from_seconds( + (now - Duration::seconds(5)).unix_timestamp() as f64, + )?), + expiration: NumericDate::try_from_seconds( + (now + Duration::seconds(900)).unix_timestamp() as f64, + )?, + nonce: Some(format!("n1b-ucan-{}", uuid::Uuid::new_v4())), + facts: Some(vec![]), + proof: vec![], + attenuation, + } + .sign(Algorithm::EdDSA, &issuer.jwk)?, + )) + .encode()?; + self.post_delegation(authorization).await + } + + /// A did:pkh account on `chain_id` delegates `grants` to `audience` (a + /// SIWE ReCap CACAO, as a Share session key receives). + async fn delegate_siwe( + &self, + issuer: &PkhPrincipal, + chain_id: u64, + audience: &str, + grants: &[(String, &str)], + ) -> anyhow::Result { + let now = OffsetDateTime::now_utc().replace_nanosecond(0)?; + let mut recap = RecapCapability::::new(); + for (resource, ability) in grants { + recap.with_action( + resource.parse()?, + ability.parse::()?, + [std::collections::BTreeMap::::new()], + ); + } + let message = recap.build_message(Message { + scheme: Some("https".parse()?), + domain: "n1b.test".parse()?, + address: issuer.address, + statement: None, + uri: audience.parse()?, + version: Version::V1, + chain_id, + nonce: format!("n1b{}", uuid::Uuid::new_v4().simple()), + issued_at: (now - Duration::seconds(5)).into(), + expiration_time: Some((now + Duration::seconds(900)).into()), + not_before: None, + request_id: None, + resources: vec![], + })?; + let (signature, recovery_id) = issuer + .key + .sign_prehash_recoverable(&message.eip191_hash()?)?; + let mut signature_bytes = [0_u8; 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()?; + self.post_delegation(authorization).await + } + + /// `POST /encryption/networks` as the SDK sends it: the session key + /// invokes `network.create` on `invocation_network` under its CACAO. + async fn create_network_via_route( + &self, + invoker: &KeyPrincipal, + proof: &str, + invocation_network: &str, + body: Value, + ) -> anyhow::Result { + let now = OffsetDateTime::now_utc(); + let mut attenuation = Capabilities::::new(); + attenuation.with_action( + invocation_network.parse()?, + NETWORK_CREATE_ACTION.parse::()?, + [std::collections::BTreeMap::::new()], + ); + let invocation = Payload { + issuer: invoker.vm.parse()?, + audience: self.node_did.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(format!("n1b-create-{}", uuid::Uuid::new_v4())), + facts: Some(vec![json!({ + "type": NETWORK_ADMIN_TYPE, + "targetNode": self.node_did, + "networkId": invocation_network, + "bodyHash": canonical_hash(&body), + "action": NETWORK_CREATE_ACTION, + })]), + proof: vec![proof.parse()?], + attenuation, + } + .sign(Algorithm::EdDSA, &invoker.jwk)?; + let response = self + .client + .post("/encryption/networks") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + invocation.encode()?, + )) + .body(body.to_string()) + .dispatch() + .await; + let status = response.status(); + let text = response.into_string().await.unwrap_or_default(); + assert_eq!(status, Status::Ok, "create network: {text}"); + Ok(status) + } + + async fn create_network(&self, owner_did: &str, name: &str) -> anyhow::Result<()> { + self.encryption() + .create_one_of_one_network(CreateNetworkRequest { + name: name.to_owned(), + owner_did: owner_did.to_owned(), + threshold: Threshold::one_of_one(), + }) + .await?; + Ok(()) + } + + async fn set_network_state(&self, urn: &str, state: &str) -> anyhow::Result<()> { + let updated = encryption_network::Entity::update_many() + .col_expr(encryption_network::Column::State, Expr::value(state)) + .filter(encryption_network::Column::NetworkId.eq(urn.parse::()?.to_string())) + .exec(self.conn()) + .await?; + assert_eq!(updated.rows_affected, 1); + Ok(()) + } + + /// Sign and register a policy owned by `owner` over `kv_resource`, + /// sourced from `source_network`, whose ceiling also decrypts `extra`. + async fn register( + &self, + owner: &KeyPrincipal, + kv_resource: &str, + source_network: &str, + extra: &[&str], + ) -> anyhow::Result<(Status, String)> { + let now = OffsetDateTime::now_utc().replace_nanosecond(0)?; + let issued = now - Duration::seconds(5); + let expires = now + Duration::seconds(600); + let mut ceiling = vec![json!({ + "kind": "kv", + "resource": kv_resource, + "selector": "exact", + "actions": ["tinycloud.kv/get", "tinycloud.kv/metadata"] + })]; + for network in std::iter::once(&source_network).chain(extra) { + ceiling.push(json!({"kind": "encryption", "resource": network, "action": DECRYPT})); + } + let mut policy = json!({ + "schema": POLICY_V2_SCHEMA, + "policyId": "pending", + "ownerDid": owner.did, + "createdAt": format_time(issued), + "expiresAt": format_time(expires), + "contentSource": { + "shareId": "share-tc-1423-n1b", + "kvResource": kv_resource, + "selector": "exact", + "encryptionNetwork": source_network, + "encryptedSymmetricKeyDigestHex": "aa".repeat(32), + "keyVersion": 1, + "mode": "immutable", + "initialCiphertextDigestHex": "bb".repeat(32) + }, + "capabilityCeiling": ceiling, + "credentialRequirement": self.credential_requirement, + "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( + owner.key.sign(&Sha256::digest(preimage)), + URL_SAFE_NO_PAD + )); + let policy_cid = tinycloud_auth::ipld_core::cid::Cid::new_v1( + 0x55, + tinycloud_auth::multihash_codetable::Code::Sha2_256 + .digest(&canonical_json_value(&policy)), + ) + .to_string(); + let projections = + registration_projections(&policy).map_err(|(_, error)| anyhow::anyhow!(error))?; + let policy_digest = + policy_digest_hex(&policy).map_err(|(_, error)| anyhow::anyhow!(error))?; + let root = |audience: &str, role: &str, mode: &str, enforcer: Option<&str>| { + let mut facts = json!({ + "ownerDid": owner.did, + "policyId": policy["policyId"], + "policyDigestHex": policy_digest, + "policyCid": policy_cid, + "contentSourceDigestHex": projections.content_source_digest_hex, + "capabilityCeilingHashHex": projections.capability_ceiling_hash_hex, + "nativeProjectionHashHex": projections.native_projection_hash_hex, + "nodeAudience": self.node_did, + "role": role, + "mode": mode, + }); + if let Some(enforcer) = enforcer { + facts["enforcerDid"] = json!(enforcer); + } + let header = json!({ + "alg": "EdDSA", + "jwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "kty": "OKP", + "x": encode_config(owner.key.public().to_bytes(), URL_SAFE_NO_PAD), + }, + "typ": "JWT", + "ucv": "0.10.0", + }); + let payload = json!({ + "att": projections.attenuation, + "aud": audience, + "exp": expires.unix_timestamp(), + "fct": [facts], + "iss": owner.vm, + "nbf": issued.unix_timestamp(), + "nnc": format!("tc-1423-n1b-{role}"), + "prf": [], + }); + let signing_input = format!( + "{}.{}", + encode_config(canonical_json_value(&header), URL_SAFE_NO_PAD), + encode_config(canonical_json_value(&payload), URL_SAFE_NO_PAD) + ); + format!( + "{signing_input}.{}", + encode_config(owner.key.sign(signing_input.as_bytes()), URL_SAFE_NO_PAD) + ) + }; + let policy_root = root( + &format!("did:tinycloud:policy:{policy_digest}"), + "policy-authority", + "policy-source", + None, + ); + let enforcement_root = root( + &self.enforcer.did, + "policy-enforcement", + "conditional-mint", + Some(&self.enforcer.did), + ); + let binding = self + .client + .post("/policy/v3/enforcer-bindings") + .header(ContentType::JSON) + .body( + json!({"rootExpiresAt": format_time(expires), "enforcerDid": self.enforcer.did}) + .to_string(), + ) + .dispatch() + .await; + assert_eq!(binding.status(), Status::Ok); + 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(); + if status != Status::Ok { + assert!( + policy_v3_registration::Entity::find_by_id(policy_cid) + .one(self.conn()) + .await? + .is_none(), + "a refused registration must write nothing" + ); + } + Ok((status, body)) + } +} + +/// A did:key content owner V and a session key K that V delegated kv +/// get + metadata on the content, plus decrypt on `decrypt`. +struct KeyOwnerCase { + harness: Harness, + owner: KeyPrincipal, + session: KeyPrincipal, + kv: ResourceId, +} + +impl KeyOwnerCase { + async fn new(decrypt: &[&str]) -> anyhow::Result { + let harness = Harness::new().await?; + let owner = KeyPrincipal::generate(); + let session = KeyPrincipal::generate(); + let kv = kv_resource(&owner.did)?; + let networks = decrypt + .iter() + .map(|name| network(&owner.did, name)) + .collect::>(); + let networks = networks.iter().map(String::as_str).collect::>(); + harness + .delegate_ucan(&owner, &session.did, &content_grants(&kv, &networks)) + .await?; + Ok(Self { + harness, + owner, + session, + kv, + }) + } + + fn network(&self, name: &str) -> String { + network(&self.owner.did, name) + } + + async fn register(&self, source: &str, extra: &[&str]) -> anyhow::Result<(Status, String)> { + self.harness + .register(&self.session, &self.kv.to_string(), source, extra) + .await + } +} + +async fn assert_refused( + outcome: impl Future>, + reason: &str, +) -> anyhow::Result<()> { + let before = refused_total(reason); + let (status, body) = outcome.await?; + assert_eq!(status, Status::Forbidden, "register: {body}"); + assert_eq!(body, "content-source-network-invalid"); + assert!( + refused_total(reason) > before, + "refusal counted under reason {reason}" + ); + Ok(()) +} + +fn assert_registered((status, body): (Status, String)) { + assert_eq!(status, Status::Ok, "register: {body}"); +} + +/// The content owner's own network, delegated to the session, but never +/// created: no recipient could ever decrypt through it here. +#[tokio::test] +async fn source_network_must_exist() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&["default"]).await?; + assert_refused(case.register(&case.network("default"), &[]), "missing").await +} + +/// Decrypt requires an `active` network; registration matches it. +#[tokio::test] +async fn source_network_must_be_active() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&["default"]).await?; + let source = case.network("default"); + case.harness.create_network(&case.owner.did, "default").await?; + for state in ["pending", "rotating"] { + case.harness.set_network_state(&source, state).await?; + assert_refused(case.register(&source, &[]), "inactive").await?; + } + case.harness.set_network_state(&source, "active").await?; + assert_registered(case.register(&source, &[]).await?); + case.harness + .encryption() + .revoke_network(&source.parse()?) + .await?; + assert_refused(case.register(&source, &[]), "inactive").await +} + +/// Every network in the ceiling is checked, not only the source: one of the +/// session's own (self-named, root by name, never created) is refused, and so +/// is a second owner network that doesn't exist. +#[tokio::test] +async fn every_policy_network_is_checked() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&["default", "backup"]).await?; + let source = case.network("default"); + let backup = case.network("backup"); + case.harness.create_network(&case.owner.did, "default").await?; + let self_named = network(&case.session.did, "share"); + assert_refused(case.register(&source, &[&self_named]), "foreign_owner").await?; + assert_refused(case.register(&source, &[&backup]), "missing").await?; + case.harness.create_network(&case.owner.did, "backup").await?; + assert_registered(case.register(&source, &[&backup]).await?); + Ok(()) +} + +/// A network that exists and is active, but belongs to another principal X, +/// is refused even though the session holds X's decrypt grant on it. +#[tokio::test] +async fn existing_foreign_network_is_refused() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&[]).await?; + let other = KeyPrincipal::generate(); + let foreign = network(&other.did, "default"); + case.harness.create_network(&other.did, "default").await?; + case.harness + .delegate_ucan(&other, &case.session.did, &[(foreign.clone(), DECRYPT)]) + .await?; + assert_refused(case.register(&foreign, &[]), "foreign_owner").await +} + +/// `tinycloud://` source resources name no space owner to compare with; no +/// client emits them, so new registrations refuse them. +#[tokio::test] +async fn legacy_source_resource_is_refused() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&["default"]).await?; + let source = case.network("default"); + case.harness.create_network(&case.owner.did, "default").await?; + assert_refused( + case.harness.register( + &case.session, + &format!("tinycloud://applications/kv/{KV_PATH}"), + &source, + &[], + ), + "legacy_resource", + ) + .await +} + +/// A network id whose owner isn't a valid principal can't be compared. +#[tokio::test] +async fn unparsable_network_is_refused() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&["default"]).await?; + let source = case.network("default"); + case.harness.create_network(&case.owner.did, "default").await?; + assert_refused( + case.register( + &source, + &["urn:tinycloud:encryption:did:pkh:eip155:1:0xnotanaddress:default"], + ), + "unparsable", + ) + .await +} + +/// A did:pkh content owner and its Share-style session key: one CACAO grants +/// kv get + metadata, decrypt and network.create, with the network named in +/// Share's lower-case spelling. The session creates the network through the +/// real route, as the SDK does at sign-in (checksummed invocation resource). +async fn pkh_owner_registers(name: &str) -> anyhow::Result<()> { + let harness = Harness::new().await?; + let account = PkhPrincipal::new(0x5a)?; + let session = KeyPrincipal::generate(); + let kv = kv_resource(&account.did(1))?; + assert_eq!(kv.space().did().as_str(), account.did(1)); + let lowercase = network(&account.lowercase_did(1), name); + let mut grants = content_grants(&kv, &[&lowercase]); + grants.push((lowercase.clone(), NETWORK_CREATE_ACTION)); + let cacao = harness + .delegate_siwe(&account, 1, &session.did, &grants) + .await?; + harness + .create_network_via_route( + &session, + &cacao, + &network(&account.did(1), name), + json!({"name": name, "ownerDid": account.did(1), "threshold": {"n": 1, "t": 1}}), + ) + .await?; + assert_registered( + harness + .register(&session, &kv.to_string(), &lowercase, &[]) + .await?, + ); + Ok(()) +} + +/// Released Share: `:default` against a checksummed space. +#[tokio::test] +async fn share_shape_registers() -> anyhow::Result<()> { + pkh_owner_registers("default").await +} + +/// D3: the owner's dedicated `:share` network. +#[tokio::test] +async fn share_network_of_pkh_owner_registers() -> anyhow::Result<()> { + pkh_owner_registers("share").await +} + +/// Same account, other chain: a different principal, so a different owner. +#[tokio::test] +async fn chain_id_mismatch_is_refused() -> anyhow::Result<()> { + let harness = Harness::new().await?; + let account = PkhPrincipal::new(0x5a)?; + let session = KeyPrincipal::generate(); + let kv = kv_resource(&account.did(1))?; + let other_chain = network(&account.lowercase_did(137), "default"); + harness + .delegate_siwe(&account, 1, &session.did, &content_grants(&kv, &[])) + .await?; + harness + .delegate_siwe( + &account, + 137, + &session.did, + &[(other_chain.clone(), DECRYPT)], + ) + .await?; + harness.create_network(&account.did(137), "default").await?; + assert_refused( + harness.register(&session, &kv.to_string(), &other_chain, &[]), + "foreign_owner", + ) + .await +} diff --git a/tinycloud-node-server/src/prometheus.rs b/tinycloud-node-server/src/prometheus.rs index 5686f848..f36e24f7 100644 --- a/tinycloud-node-server/src/prometheus.rs +++ b/tinycloud-node-server/src/prometheus.rs @@ -32,12 +32,28 @@ pub fn observe_did_resolution_refused(method: &str, reason: &str) { .inc(); } +/// A Policy/v3 registration refused because one of its encryption networks +/// is not the content owner's active network on this node. `reason` is a +/// fixed label: `unparsable`, `legacy_resource`, `foreign_owner`, `missing` +/// or `inactive`. +pub fn observe_policy_source_network_refused(reason: &'static str) { + POLICY_SOURCE_NETWORK_REFUSED + .with_label_values(&[reason]) + .inc(); +} + lazy_static! { pub static ref DID_RESOLUTION_REFUSED: IntCounterVec = register_int_counter_vec!( "tinycloud_did_resolution_refused_total", "UCAN issuer resolution refusals by method and reason.", &["method", "reason"] ).unwrap(); + pub static ref POLICY_SOURCE_NETWORK_REFUSED: IntCounterVec = register_int_counter_vec!( + "tinycloud_policy_source_network_refused_total", + "Policy/v3 registrations refused because an encryption network is not the content owner's active network on this node, by reason.", + &["reason"] + ) + .unwrap(); pub static ref REQUEST_HISTOGRAM: HistogramVec = register_histogram_vec!( "tinycloud_http_request_duration_seconds", "HTTP request latencies in seconds.", From 3cee65781453e59a809373bd073258800c37d714 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 16:54:36 +0000 Subject: [PATCH 3/9] fix(TC-1423): check the owner and existence of policy source networks at registration Every encryption network a new Policy/v3 names (the content source's and each encryption ceiling entry) must parse, belong to the owner of the content source's space (principal match: EIP-55 case-insensitive, chain id exact) and be active on this node; otherwise registration returns 403 content-source-network-invalid. New registrations with a legacy tinycloud:// kvResource are refused the same way. Refusals are counted in tinycloud_policy_source_network_refused_total{reason}. The Policy/v2 flow fixtures now create the content owner's network they register against. --- CHANGELOG.md | 8 + tinycloud-node-server/src/lib.rs | 1 + tinycloud-node-server/src/policy_v3.rs | 288 ++++++++---------- .../src/policy_v3/source_network_tests.rs | 147 ++++++--- 4 files changed, 240 insertions(+), 204 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f67c4401..f2f44446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Changes + +- `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). +- Every response that carries an `ETag` now also carries `Cache-Control: no-transform` (TC-1432): KV get/head/metadata (200 and 304), KV put, the public KV read routes (200, 304 and metadata), signed-URL reads (200, 206, 304, 416) and the share-email responses that set an `ETag`. A CDN that re-compresses a response must weaken its strong `ETag` to `W/"…"`, and `If-Match` compare-and-swap only accepts the strong `"blake3-"` value. Existing `Cache-Control` directives (`private, no-cache`, `private, no-store`, `public, max-age=60`, `no-store`) are kept and `no-transform` is appended; responses without an `ETag` (KV list, SQL, error responses including 412) are unchanged. Bodies, `ETag` values and status codes are unchanged. +- Policy/v3 registration records which of the owner's delegations granted what the policy roots carry, with their ancestor closure, in two new tables (migration `m20261019_000000_policy_v3_owner_authority`; its `down` drops both). Delegations from the owner of the capability's space or encryption network are tried before the general window of recent delegations to the owner that touch that space or network; a delegation whose ancestry includes one issued by the policy owner is never recorded; a grant that outlives the root is preferred. Policies registered before the migration are classified in the background on first use (steady-state reads add no query), or by an opt-in sweep (`policy_v3.owner_binding_backfill.enabled`, off by default). The legacy cutover defaults to the migration's applied-at time, is logged at boot, and `policy_v3.legacy_cutover` overrides it after a rollback and re-upgrade. New `POST /policy/v3/authorized` lists the policies whose recorded authority includes a delegation, authorized like `/delegation/status`. The record does not change admission yet. See `docs/policy-owner-authority.md` (TC-1423). +- Policy/v3 registration checks the owner and existence of the policy's encryption networks. Every network the policy names (`contentSource.encryptionNetwork` and each `encryption` ceiling entry) must be owned by the owner of the content source's space, compared as principals (a `did:pkh` matches regardless of EIP-55 letter case; the chain id must match), and must exist on this node in state `active`. Otherwise registration returns `403 content-source-network-invalid` and writes nothing. A new registration whose `kvResource` uses the legacy `tinycloud://` form gets the same refusal; no client emits it. Share and CLI registrations name the space owner's `:default` network, which the SDK creates or finds at sign-in, so they are unaffected; registered policies are not re-checked. Refusals are counted as `tinycloud_policy_source_network_refused_total{reason}`, with reasons `unparsable`, `legacy_resource`, `foreign_owner`, `missing` and `inactive` (TC-1423). + ## [1.21.0] - 2026-10-18 ### Release notes diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index d9f12c4e..bc35ad8c 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -518,6 +518,7 @@ pub async fn app_with_control( prometheus::observe_did_resolution_refused, ); lazy_static::initialize(&prometheus::DID_RESOLUTION_REFUSED); + lazy_static::initialize(&prometheus::POLICY_SOURCE_NETWORK_REFUSED); // Ensure local storage directories exist. // SQLite file paths and local dirs are resources the server owns — auto-create them. diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 9a36c159..a2a7acbc 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -29,13 +29,14 @@ use tinycloud_auth::{ resource::{ResourceId, SpaceId}, }; use tinycloud_core::{ + encryption_network::{NetworkId, NetworkState}, events::{Delegation, SerializedEvent}, hash::hash, keys::StaticSecret, models::{ - abilities, delegation as delegation_model, invocation as invocation_model, - policy_v3_challenge, policy_v3_registration, policy_v3_root, policy_v3_session, revocation, - share_invitation_authorization_jti, space, + abilities, delegation as delegation_model, encryption_network as encryption_network_model, + invocation as invocation_model, policy_v3_challenge, policy_v3_registration, + policy_v3_root, policy_v3_session, revocation, share_invitation_authorization_jti, space, }, relationships::parent_delegations, sea_orm::{ @@ -1849,6 +1850,13 @@ pub async fn register_policy( { return Err((Status::Forbidden, "policy-digest-mismatch".into())); } + // Only signed, self-consistent requests reach the network lookups; the + // check runs before the owner-authority check, which would otherwise + // accept decrypt on a network that names the registrant. + if let Some(refusal) = source_network_refusal(&runtime.conn, &request.policy).await? { + crate::prometheus::observe_policy_source_network_refused(refusal.as_str()); + return Err((Status::Forbidden, "content-source-network-invalid".into())); + } let owner_did = request .policy .get("ownerDid") @@ -4613,6 +4621,91 @@ fn validate_policy_encryption_resource(value: &str) -> Result<(), (Status, Strin Ok(()) } +/// Why a new policy's encryption networks were refused; the metric label. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SourceNetworkRefusal { + Unparsable, + LegacyResource, + ForeignOwner, + Missing, + Inactive, +} + +impl SourceNetworkRefusal { + const fn as_str(self) -> &'static str { + match self { + Self::Unparsable => "unparsable", + Self::LegacyResource => "legacy_resource", + Self::ForeignOwner => "foreign_owner", + Self::Missing => "missing", + Self::Inactive => "inactive", + } + } +} + +/// TC-1423 N1b: every encryption network a new policy names (the content +/// source's and every `encryption` ceiling entry) must belong to the owner of +/// the content source's space and be active on this node. +/// +/// A DID is root authority over every network URN that names it, created or +/// not. Without this check, a registrant holding only kv on the content could +/// name a network of its own and hold its decrypt by name. With it, a +/// registrant other than the space owner holds decrypt on these networks only +/// through a delegation rooted at that owner. +/// +/// The owner is the space's DID (`ResourceId::space().did()`), compared with +/// `did_principal_matches`: EIP-55 letter case is ignored, the chain id is +/// not. Existence and state use the row key and the `active` rule that +/// decrypt uses. `tinycloud://` sources name no space owner, and no client +/// emits them, so new registrations refuse them. +async fn source_network_refusal( + conn: &C, + policy: &Value, +) -> Result, (Status, String)> { + let source = policy.get("contentSource"); + let kv_resource = source + .and_then(|source| source.get("kvResource")) + .and_then(Value::as_str) + .ok_or((Status::BadRequest, "content-source-invalid".into()))?; + if kv_resource.starts_with("tinycloud://") { + return Ok(Some(SourceNetworkRefusal::LegacyResource)); + } + let resource = kv_resource + .parse::() + .map_err(|_| (Status::BadRequest, "kv-resource-invalid".into()))?; + let content_owner = resource.space().did(); + let ceiling_networks = policy + .get("capabilityCeiling") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|capability| capability.get("kind").and_then(Value::as_str) == Some("encryption")) + .map(|capability| capability.get("resource")); + let networks = std::iter::once(source.and_then(|source| source.get("encryptionNetwork"))) + .chain(ceiling_networks) + .map(|network| network.and_then(Value::as_str)) + .collect::>(); + for network in networks { + let Some(network_id) = network.and_then(|network| network.parse::().ok()) else { + return Ok(Some(SourceNetworkRefusal::Unparsable)); + }; + if !did_principal_matches(network_id.owner_did(), content_owner.as_str()) { + return Ok(Some(SourceNetworkRefusal::ForeignOwner)); + } + let Some(row) = encryption_network_model::Entity::find_by_id(network_id.to_string()) + .one(conn) + .await + .map_err(db_error)? + else { + return Ok(Some(SourceNetworkRefusal::Missing)); + }; + if NetworkState::parse(&row.state) != Some(NetworkState::Active) { + return Ok(Some(SourceNetworkRefusal::Inactive)); + } + } + Ok(None) +} + fn canonical_policy_actions( value: Option<&Value>, kv: bool, @@ -7980,7 +8073,7 @@ mod tests { #[tokio::test] async fn policy_v2_admits_v3_account_and_v4_accountless_receivers() -> anyhow::Result<()> { - run_policy_v2_flow(PolicyOwner::SpaceOwner).await + Box::pin(run_policy_v2_flow(PolicyOwner::SpaceOwner)).await } /// The real Share and CLI shape: the policy owner is a session key the @@ -7988,7 +8081,7 @@ mod tests { /// 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 + Box::pin(run_policy_v2_flow(PolicyOwner::DelegatedSession)).await } /// The policy owner signs a valid, self-consistent policy over content in @@ -7996,21 +8089,18 @@ mod tests { /// 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 + Box::pin(run_policy_v2_flow(PolicyOwner::Unauthorized)).await } /// TC-1423 (B2, N1b): a policy owner that holds only `kv/get` and /// `kv/metadata` on the content (no decrypt on the content owner's /// network) registers a policy whose content source names a network /// called after the policy owner itself, which needs no grant: a DID is - /// root authority over networks that name it. Registration must refuse - /// it: the source network must exist and belong to the content space's - /// owner, and the owner's decrypt on it must come through its authority - /// chain. Until then this test records what the node does instead and - /// fails. + /// root authority over networks that name it. Registration refuses it: + /// the source network must exist and belong to the content space's owner. #[tokio::test] async fn policy_source_network_must_belong_to_the_content_owner() -> anyhow::Result<()> { - run_policy_v2_flow(PolicyOwner::KvOnlySelfNamedNetwork).await + Box::pin(run_policy_v2_flow(PolicyOwner::KvOnlySelfNamedNetwork)).await } #[derive(Clone, Copy, PartialEq, Eq)] @@ -8027,131 +8117,6 @@ mod tests { KvOnlySelfNamedNetwork, } - /// Observations for `policy_source_network_must_belong_to_the_content_owner` - /// once registration was accepted: the recorded owner binding (N1, if - /// present), whether its closure holds the owner's delegation, and what an - /// admitted session gets after the space owner revokes that delegation. - #[allow(clippy::too_many_arguments)] - async fn kv_only_self_network_report( - client: &rocket::local::asynchronous::Client, - policy_cid: &String, - owner_delegation_cid: Option, - content_owner_vm: &String, - content_owner_did: &String, - content_owner_jwk: &JWK, - reader_vm: &String, - reader_did: &String, - reader_jwk: &JWK, - child_cid: tinycloud_auth::ipld_core::cid::Cid, - requested: &Vec, - ) -> anyhow::Result<(String, String, String)> { - use tinycloud_auth::{ - siwe_recap::Ability as RecapAbility, ucan_capabilities_object::Capabilities, - }; - use tinycloud_core::sea_orm::{ConnectionTrait as _, Statement}; - let owner_delegation_cid = owner_delegation_cid - .clone() - .ok_or_else(|| anyhow::anyhow!("missing owner delegation"))?; - let conn = &client.rocket().state::().unwrap().conn; - // What authority the node recorded, if it records any (N1, #358). - let backend = conn.get_database_backend(); - let binding = conn - .query_one(Statement::from_sql_and_values( - backend, - "SELECT state FROM policy_v3_owner_binding WHERE policy_cid = ?", - [policy_cid.clone().into()], - )) - .await; - let binding_state = match binding { - Ok(Some(row)) => row.try_get::("", "state").unwrap_or_default(), - Ok(None) => "no binding row".to_owned(), - Err(_) => "no binding table (pre-N1)".to_owned(), - }; - let owner_hash = tinycloud_core::hash::Hash::from( - owner_delegation_cid.parse::()?, - ); - let closure = conn - .query_one(Statement::from_sql_and_values( - backend, - "SELECT COUNT(*) AS n FROM policy_v3_owner_authority \ - WHERE policy_cid = ? AND delegation_id = ?", - [policy_cid.clone().into(), owner_hash.into()], - )) - .await; - let closure_has_share_root = match closure { - Ok(Some(row)) => format!("{}", row.try_get::("", "n").unwrap_or(-1) > 0), - Ok(None) => "no row".to_owned(), - Err(_) => "no closure table (pre-N1)".to_owned(), - }; - - // The space owner revokes the owner's (kv-only) delegation: the - // share root of the D3 design. - let mut capabilities = Capabilities::::new(); - capabilities.with_action( - format!("urn:cid:{owner_delegation_cid}").parse()?, - "tinycloud.delegation/revoke".parse::()?, - [std::collections::BTreeMap::::new()], - ); - let revocation = tinycloud_auth::authorization::TinyCloudRevocation::Ucan(Box::new( - Payload { - issuer: content_owner_vm.parse()?, - audience: content_owner_did.parse()?, - not_before: None, - expiration: NumericDate::try_from_seconds( - (OffsetDateTime::now_utc() + Duration::seconds(60)).unix_timestamp() as f64, - )?, - nonce: Some("tc1423-kv-only-owner-revoke".into()), - facts: Some(Vec::new()), - proof: Vec::new(), - attenuation: capabilities, - } - .sign(Algorithm::EdDSA, &content_owner_jwk)?, - )); - let revoke_status = client - .post("/revoke") - .header(rocket::http::Header::new( - "Authorization", - revocation.encode()?, - )) - .dispatch() - .await - .status(); - assert_eq!(revoke_status, Status::Ok, "owner revoke of the share root"); - - let reread_now = OffsetDateTime::now_utc(); - let reread = Payload { - issuer: reader_vm.parse()?, - audience: reader_did.parse()?, - not_before: Some(NumericDate::try_from_seconds( - reread_now.unix_timestamp() as f64 - )?), - expiration: NumericDate::try_from_seconds( - (reread_now + Duration::seconds(30)).unix_timestamp() as f64, - )?, - nonce: Some("tc1423-kv-only-reread".into()), - facts: Some(Vec::::new()), - proof: vec![child_cid], - attenuation: serde_json::from_value::>( - attenuation_for_policy_capabilities(&requested) - .map_err(|(_, error)| anyhow::anyhow!(error))?, - )?, - } - .sign(Algorithm::EdDSA, &reader_jwk)?; - let reread_response = client - .post("/invoke") - .header(rocket::http::Header::new("Authorization", reread.encode()?)) - .dispatch() - .await; - let reread_status = reread_response.status(); - let reread_body = reread_response.into_string().await.unwrap_or_default(); - let reread_outcome = if reread_status == Status::Ok { - format!("200, {} bytes (survives)", reread_body.len()) - } else { - format!("{} {reread_body}", reread_status.code) - }; - Ok((binding_state, closure_has_share_root, reread_outcome)) - } - async fn run_policy_v2_flow(policy_owner: PolicyOwner) -> anyhow::Result<()> { use k256::ecdsa::SigningKey; use sha3::Keccak256; @@ -8603,6 +8568,16 @@ mod tests { runtime.signer.node_keypair(), encryption_backend, ); + // Registration requires the source network to exist on this node and + // to be the content owner's (TC-1423 N1b). The self-named network of + // `KvOnlySelfNamedNetwork` is deliberately never created. + encryption + .create_one_of_one_network(tinycloud_core::encryption_network::CreateNetworkRequest { + name: "mainnet".into(), + owner_did: content_owner_did.clone(), + threshold: tinycloud_core::encryption_network::Threshold::one_of_one(), + }) + .await?; let rocket = rocket::build() .mount( "/", @@ -8939,13 +8914,21 @@ mod tests { assert!(policy_v3_root::Entity::find().one(conn).await?.is_none()); return Ok(()); } - if policy_owner == PolicyOwner::KvOnlySelfNamedNetwork && register_status != Status::Ok { + if policy_owner == PolicyOwner::KvOnlySelfNamedNetwork { assert_eq!( register_status, Status::Forbidden, "register: {register_body}" ); assert_eq!(register_body, "content-source-network-invalid"); + let conn = &client.rocket().state::().unwrap().conn; + assert!( + policy_v3_registration::Entity::find_by_id(policy_cid.clone()) + .one(conn) + .await? + .is_none() + ); + assert!(policy_v3_root::Entity::find().one(conn).await?.is_none()); return Ok(()); } assert_eq!(register_status, Status::Ok, "register: {register_body}"); @@ -9377,35 +9360,6 @@ mod tests { assert_eq!(read_status, Status::Ok, "read response: {read_body:?}"); assert_eq!(read_body, b"tc-470-real-content"); - if policy_owner == PolicyOwner::KvOnlySelfNamedNetwork { - // Boxed: the shared flow's future is already close to the - // default test-thread stack. - let (binding_state, closure_has_share_root, reread_outcome) = - Box::pin(kv_only_self_network_report( - &client, - &policy_cid, - owner_delegation_cid.clone(), - &content_owner_vm, - content_owner_did, - &content_owner_jwk, - &reader_vm, - &reader_did, - &reader_jwk, - child_cid, - &requested, - )) - .await?; - panic!( - "TC-1423 N1b RED: registration over a self-named network was ACCEPTED \ - (200; source network {encryption_resource} is named by the kv-only \ - policy owner and was never created); the admitted recipient read the \ - content (200, {} bytes); owner binding state: {binding_state}; closure \ - contains the share root: {closure_has_share_root}; read after the space \ - owner revoked the share root: {reread_outcome}", - read_body.len() - ); - } - // TC-541: hooks requests pass the same Policy/v3 gate as `/invoke`. // Policies can only grant `kv` and `encryption` capabilities, so no // policy session can hold `tinycloud.hooks/*`: the reader's chain is diff --git a/tinycloud-node-server/src/policy_v3/source_network_tests.rs b/tinycloud-node-server/src/policy_v3/source_network_tests.rs index e87a80c5..4db14b6a 100644 --- a/tinycloud-node-server/src/policy_v3/source_network_tests.rs +++ b/tinycloud-node-server/src/policy_v3/source_network_tests.rs @@ -71,18 +71,20 @@ struct PkhPrincipal { } impl PkhPrincipal { - fn new(seed: u8) -> anyhow::Result { - let key = SigningKey::from_bytes(&[seed; 32].into())?; - let public = key.verifying_key().to_encoded_point(false); - let digest = Keccak256::digest(&public.as_bytes()[1..]); - let principal = Self { - key, - address: digest[12..].try_into()?, - }; - // The case-insensitivity cases mean something only if the checksum - // has upper-case letters. - assert_ne!(principal.did(1), principal.lowercase_did(1)); - Ok(principal) + /// A random account whose EIP-55 spelling has upper-case letters, so the + /// case-insensitivity cases mean something. + fn generate() -> Self { + loop { + let key = SigningKey::random(&mut rand::rngs::OsRng); + let public = key.verifying_key().to_encoded_point(false); + let digest = Keccak256::digest(&public.as_bytes()[1..]); + let mut address = [0_u8; 20]; + address.copy_from_slice(&digest[12..]); + let principal = Self { key, address }; + if principal.did(1) != principal.lowercase_did(1) { + return principal; + } + } } /// The EIP-55 spelling space ids and session DIDs carry. @@ -104,12 +106,14 @@ fn network(owner_did: &str, name: &str) -> String { } fn kv_resource(owner_did: &str) -> anyhow::Result { - Ok(SpaceId::new(owner_did.parse::()?, "applications".parse()?).to_resource( - "kv".parse()?, - Some(KV_PATH.parse()?), - None, - None, - )) + Ok( + SpaceId::new(owner_did.parse::()?, "applications".parse()?).to_resource( + "kv".parse()?, + Some(KV_PATH.parse()?), + None, + None, + ), + ) } /// The registrant's grants on the content: kv get + metadata on the content @@ -138,6 +142,23 @@ struct Harness { impl Harness { async fn new() -> anyhow::Result { + Self::on(Database::connect("sqlite::memory:").await?).await + } + + /// A fresh schema on the PostgreSQL server at `url`. + async fn postgres(url: &str) -> anyhow::Result { + let schema = format!("tc1423_n1b_{}", uuid::Uuid::new_v4().simple()); + Database::connect(url) + .await? + .execute_unprepared(&format!("CREATE SCHEMA {schema}")) + .await?; + let mut url = reqwest::Url::parse(url)?; + url.query_pairs_mut() + .append_pair("options", &format!("-c search_path={schema}")); + Self::on(Database::connect(url.as_str()).await?).await + } + + async fn on(db: tinycloud_core::sea_orm::DatabaseConnection) -> anyhow::Result { use tinycloud_core::{ database_artifacts::SeaOrmDatabaseArtifactRepository, encryption::ColumnEncryption, @@ -148,7 +169,6 @@ impl Harness { let vector: Value = serde_json::from_str(include_str!( "../../test-fixtures/tc-470-policy-credential-requirement.json" ))?; - let db = Database::connect("sqlite::memory:").await?; let node_secret = StaticSecret::new(vec![29; 32]).map_err(|_| anyhow::anyhow!("invalid secret"))?; let node_did = node_secret.node_did(); @@ -219,7 +239,12 @@ impl Harness { } fn conn(&self) -> &RecoveringConnection { - &self.client.rocket().state::().unwrap().conn + &self + .client + .rocket() + .state::() + .unwrap() + .conn } async fn post_delegation(&self, authorization: String) -> anyhow::Result { @@ -424,7 +449,9 @@ impl Harness { "createdAt": format_time(issued), "expiresAt": format_time(expires), "contentSource": { - "shareId": "share-tc-1423-n1b", + // Unique per call, so a refused policy never shares a CID + // with one registered earlier in the same test. + "shareId": format!("share-tc-1423-n1b-{}", uuid::Uuid::new_v4()), "kvResource": kv_resource, "selector": "exact", "encryptionNetwork": source_network, @@ -443,10 +470,7 @@ impl Harness { 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["policyId"] = json!(format!("pol_{}", base32_lower(&Sha256::digest(&preimage)))); policy["signature"]["value"] = json!(encode_config( owner.key.sign(&Sha256::digest(preimage)), URL_SAFE_NO_PAD @@ -576,7 +600,10 @@ struct KeyOwnerCase { impl KeyOwnerCase { async fn new(decrypt: &[&str]) -> anyhow::Result { - let harness = Harness::new().await?; + Self::on(Harness::new().await?, decrypt).await + } + + async fn on(harness: Harness, decrypt: &[&str]) -> anyhow::Result { let owner = KeyPrincipal::generate(); let session = KeyPrincipal::generate(); let kv = kv_resource(&owner.did)?; @@ -639,7 +666,9 @@ async fn source_network_must_exist() -> anyhow::Result<()> { async fn source_network_must_be_active() -> anyhow::Result<()> { let case = KeyOwnerCase::new(&["default"]).await?; let source = case.network("default"); - case.harness.create_network(&case.owner.did, "default").await?; + case.harness + .create_network(&case.owner.did, "default") + .await?; for state in ["pending", "rotating"] { case.harness.set_network_state(&source, state).await?; assert_refused(case.register(&source, &[]), "inactive").await?; @@ -661,11 +690,15 @@ async fn every_policy_network_is_checked() -> anyhow::Result<()> { let case = KeyOwnerCase::new(&["default", "backup"]).await?; let source = case.network("default"); let backup = case.network("backup"); - case.harness.create_network(&case.owner.did, "default").await?; + case.harness + .create_network(&case.owner.did, "default") + .await?; let self_named = network(&case.session.did, "share"); assert_refused(case.register(&source, &[&self_named]), "foreign_owner").await?; assert_refused(case.register(&source, &[&backup]), "missing").await?; - case.harness.create_network(&case.owner.did, "backup").await?; + case.harness + .create_network(&case.owner.did, "backup") + .await?; assert_registered(case.register(&source, &[&backup]).await?); Ok(()) } @@ -690,7 +723,9 @@ async fn existing_foreign_network_is_refused() -> anyhow::Result<()> { async fn legacy_source_resource_is_refused() -> anyhow::Result<()> { let case = KeyOwnerCase::new(&["default"]).await?; let source = case.network("default"); - case.harness.create_network(&case.owner.did, "default").await?; + case.harness + .create_network(&case.owner.did, "default") + .await?; assert_refused( case.harness.register( &case.session, @@ -708,7 +743,9 @@ async fn legacy_source_resource_is_refused() -> anyhow::Result<()> { async fn unparsable_network_is_refused() -> anyhow::Result<()> { let case = KeyOwnerCase::new(&["default"]).await?; let source = case.network("default"); - case.harness.create_network(&case.owner.did, "default").await?; + case.harness + .create_network(&case.owner.did, "default") + .await?; assert_refused( case.register( &source, @@ -723,9 +760,8 @@ async fn unparsable_network_is_refused() -> anyhow::Result<()> { /// kv get + metadata, decrypt and network.create, with the network named in /// Share's lower-case spelling. The session creates the network through the /// real route, as the SDK does at sign-in (checksummed invocation resource). -async fn pkh_owner_registers(name: &str) -> anyhow::Result<()> { - let harness = Harness::new().await?; - let account = PkhPrincipal::new(0x5a)?; +async fn pkh_owner_registers(harness: Harness, name: &str) -> anyhow::Result<()> { + let account = PkhPrincipal::generate(); let session = KeyPrincipal::generate(); let kv = kv_resource(&account.did(1))?; assert_eq!(kv.space().did().as_str(), account.did(1)); @@ -754,20 +790,20 @@ async fn pkh_owner_registers(name: &str) -> anyhow::Result<()> { /// Released Share: `:default` against a checksummed space. #[tokio::test] async fn share_shape_registers() -> anyhow::Result<()> { - pkh_owner_registers("default").await + pkh_owner_registers(Harness::new().await?, "default").await } /// D3: the owner's dedicated `:share` network. #[tokio::test] async fn share_network_of_pkh_owner_registers() -> anyhow::Result<()> { - pkh_owner_registers("share").await + pkh_owner_registers(Harness::new().await?, "share").await } /// Same account, other chain: a different principal, so a different owner. #[tokio::test] async fn chain_id_mismatch_is_refused() -> anyhow::Result<()> { let harness = Harness::new().await?; - let account = PkhPrincipal::new(0x5a)?; + let account = PkhPrincipal::generate(); let session = KeyPrincipal::generate(); let kv = kv_resource(&account.did(1))?; let other_chain = network(&account.lowercase_did(137), "default"); @@ -789,3 +825,40 @@ async fn chain_id_mismatch_is_refused() -> anyhow::Result<()> { ) .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 + } + } +} + +/// The same lookups against PostgreSQL: the Share shape registers; a missing, +/// inactive or self-named source network is refused. +#[tokio::test] +async fn postgres_source_network_check() -> anyhow::Result<()> { + let Some(url) = postgres_url("postgres_source_network_check") else { + return Ok(()); + }; + pkh_owner_registers(Harness::postgres(&url).await?, "default").await?; + let case = KeyOwnerCase::on(Harness::postgres(&url).await?, &["default"]).await?; + let source = case.network("default"); + assert_refused(case.register(&source, &[]), "missing").await?; + case.harness + .create_network(&case.owner.did, "default") + .await?; + case.harness.set_network_state(&source, "rotating").await?; + assert_refused(case.register(&source, &[]), "inactive").await?; + case.harness.set_network_state(&source, "active").await?; + let self_named = network(&case.session.did, "share"); + assert_refused(case.register(&self_named, &[]), "foreign_owner").await?; + assert_registered(case.register(&source, &[]).await?); + Ok(()) +} From 1bfa6cf240bba0bd028a0f61dfa2d62480e0ecdd Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 17:01:16 +0000 Subject: [PATCH 4/9] refactor(TC-1423): split the pure network owner predicate from the existence lookup content_owner_networks (parse, owner, legacy form) is a pure function so stored registrations can be classified with the same rule; source_network_refusal adds the existence and state lookup for new registrations, after every network has passed the owner check. --- tinycloud-node-server/src/policy_v3.rs | 59 ++++++++++++++++---------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index a2a7acbc..5180a342 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -4643,9 +4643,10 @@ impl SourceNetworkRefusal { } } -/// TC-1423 N1b: every encryption network a new policy names (the content -/// source's and every `encryption` ceiling entry) must belong to the owner of -/// the content source's space and be active on this node. +/// TC-1423 N1b: the encryption networks a policy names (the content source's +/// and every `encryption` ceiling entry), when every one parses and belongs to +/// the owner of the content source's space; otherwise why not. Pure, so it +/// applies to stored registrations as well as new ones. /// /// A DID is root authority over every network URN that names it, created or /// not. Without this check, a registrant holding only kv on the content could @@ -4655,24 +4656,19 @@ impl SourceNetworkRefusal { /// /// The owner is the space's DID (`ResourceId::space().did()`), compared with /// `did_principal_matches`: EIP-55 letter case is ignored, the chain id is -/// not. Existence and state use the row key and the `active` rule that -/// decrypt uses. `tinycloud://` sources name no space owner, and no client -/// emits them, so new registrations refuse them. -async fn source_network_refusal( - conn: &C, - policy: &Value, -) -> Result, (Status, String)> { +/// not. `tinycloud://` sources name no space owner, and no client emits them. +fn content_owner_networks(policy: &Value) -> Result, SourceNetworkRefusal> { let source = policy.get("contentSource"); let kv_resource = source .and_then(|source| source.get("kvResource")) .and_then(Value::as_str) - .ok_or((Status::BadRequest, "content-source-invalid".into()))?; + .unwrap_or_default(); if kv_resource.starts_with("tinycloud://") { - return Ok(Some(SourceNetworkRefusal::LegacyResource)); + return Err(SourceNetworkRefusal::LegacyResource); } let resource = kv_resource .parse::() - .map_err(|_| (Status::BadRequest, "kv-resource-invalid".into()))?; + .map_err(|_| SourceNetworkRefusal::Unparsable)?; let content_owner = resource.space().did(); let ceiling_networks = policy .get("capabilityCeiling") @@ -4681,17 +4677,36 @@ async fn source_network_refusal( .flatten() .filter(|capability| capability.get("kind").and_then(Value::as_str) == Some("encryption")) .map(|capability| capability.get("resource")); - let networks = std::iter::once(source.and_then(|source| source.get("encryptionNetwork"))) + std::iter::once(source.and_then(|source| source.get("encryptionNetwork"))) .chain(ceiling_networks) .map(|network| network.and_then(Value::as_str)) - .collect::>(); - for network in networks { - let Some(network_id) = network.and_then(|network| network.parse::().ok()) else { - return Ok(Some(SourceNetworkRefusal::Unparsable)); - }; - if !did_principal_matches(network_id.owner_did(), content_owner.as_str()) { - return Ok(Some(SourceNetworkRefusal::ForeignOwner)); - } + .collect::>() + .into_iter() + .map(|network| { + let network_id = network + .and_then(|network| network.parse::().ok()) + .ok_or(SourceNetworkRefusal::Unparsable)?; + if did_principal_matches(network_id.owner_did(), content_owner.as_str()) { + Ok(network_id) + } else { + Err(SourceNetworkRefusal::ForeignOwner) + } + }) + .collect() +} + +/// [`content_owner_networks`], and each network exists on this node in state +/// `active`: the row key and the rule decrypt uses. A policy on any other +/// network could never be opened here. +async fn source_network_refusal( + conn: &C, + policy: &Value, +) -> Result, (Status, String)> { + let networks = match content_owner_networks(policy) { + Ok(networks) => networks, + Err(refusal) => return Ok(Some(refusal)), + }; + for network_id in networks { let Some(row) = encryption_network_model::Entity::find_by_id(network_id.to_string()) .one(conn) .await From 4a8b42fe7d1a2898aa083fa39f168f7362bf79bf Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 17:48:23 +0000 Subject: [PATCH 5/9] test(TC-1423): kv ceiling entries outside the content owner (RED) From the #363 review (B1): a kv-only key moves the content source to its own space on a network it created, and adds a second kv ceiling entry on the space owner's content. Registration is accepted at 1bfa6cf. Regression tests in the Policy/v2 flow and the registration harness, plus controls: the key sharing its own space, several kv entries all in the owner's spaces, and legacy or unparsable kv entries. --- tinycloud-node-server/src/policy_v3.rs | 64 +++++++- .../src/policy_v3/source_network_tests.rs | 147 +++++++++++++++++- 2 files changed, 202 insertions(+), 9 deletions(-) diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 5180a342..48622313 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -8118,6 +8118,17 @@ mod tests { Box::pin(run_policy_v2_flow(PolicyOwner::KvOnlySelfNamedNetwork)).await } + /// TC-1423 (N1b, review B1): the same kv-only policy owner moves the + /// content source to its own space, on a network it created, and adds a + /// second kv ceiling entry on the space owner's content. Every kv entry + /// must name a space of the content source's owner, so registration + /// refuses it; otherwise recipients would read the space owner's content + /// through a policy that outlives the owner's revocation. + #[tokio::test] + async fn policy_kv_entries_must_belong_to_the_content_owner() -> anyhow::Result<()> { + Box::pin(run_policy_v2_flow(PolicyOwner::KvOnlyOwnSpaceSource)).await + } + #[derive(Clone, Copy, PartialEq, Eq)] enum PolicyOwner { /// The policy owner is the content space's owner. @@ -8130,6 +8141,10 @@ mod tests { /// delegation from the space owner (no decrypt), and the policy's /// source network is one named after the policy owner itself. KvOnlySelfNamedNetwork, + /// As `KvOnlySelfNamedNetwork`, but the content source is the policy + /// owner's own space on its own (created) network, and a second kv + /// ceiling entry names the space owner's content. + KvOnlyOwnSpaceSource, } async fn run_policy_v2_flow(policy_owner: PolicyOwner) -> anyhow::Result<()> { @@ -8184,6 +8199,14 @@ mod tests { None, None, ); + // `KvOnlyOwnSpaceSource`: the same path in the policy owner's space. + let own_source_resource = + SpaceId::new(owner_did.parse::()?, "applications".parse()?).to_resource( + "kv".parse()?, + Some("shares/tc-470/document.txt".parse()?), + None, + None, + ); let node_secret = StaticSecret::new(vec![29; 32]).unwrap(); let node_did = node_secret.node_did(); let issuer_did = projection["issuerDid"].as_str().unwrap(); @@ -8211,12 +8234,15 @@ mod tests { "selector": "exact", "actions": ["tinycloud.kv/get"] })]; - let encryption_resource = if policy_owner == PolicyOwner::KvOnlySelfNamedNetwork { + let encryption_resource = if matches!( + policy_owner, + PolicyOwner::KvOnlySelfNamedNetwork | PolicyOwner::KvOnlyOwnSpaceSource + ) { format!("urn:tinycloud:encryption:{owner_did}:share") } else { format!("urn:tinycloud:encryption:{content_owner_did}:mainnet") }; - let ceiling = vec![ + let mut ceiling = vec![ json!({ "kind": "kv", "resource": content_resource.to_string(), @@ -8229,9 +8255,20 @@ mod tests { "action": "tinycloud.encryption/decrypt" }), ]; + let source_kv = if policy_owner == PolicyOwner::KvOnlyOwnSpaceSource { + ceiling.push(json!({ + "kind": "kv", + "resource": own_source_resource.to_string(), + "selector": "exact", + "actions": ["tinycloud.kv/get", "tinycloud.kv/metadata"] + })); + own_source_resource.to_string() + } else { + content_resource.to_string() + }; let content_source = json!({ "shareId": "share-tc-470", - "kvResource": content_resource.to_string(), + "kvResource": source_kv, "selector": "exact", "encryptionNetwork": encryption_resource, "encryptedSymmetricKeyDigestHex": "aa".repeat(32), @@ -8593,6 +8630,18 @@ mod tests { threshold: tinycloud_core::encryption_network::Threshold::one_of_one(), }) .await?; + if policy_owner == PolicyOwner::KvOnlyOwnSpaceSource { + // The policy owner's own network, which any DID may create. + encryption + .create_one_of_one_network( + tinycloud_core::encryption_network::CreateNetworkRequest { + name: "share".into(), + owner_did: owner_did.clone(), + threshold: tinycloud_core::encryption_network::Threshold::one_of_one(), + }, + ) + .await?; + } let rocket = rocket::build() .mount( "/", @@ -8706,7 +8755,9 @@ mod tests { // that delegation covers, as the Share and CLI owners do. let owner_delegation_cid = if matches!( policy_owner, - PolicyOwner::DelegatedSession | PolicyOwner::KvOnlySelfNamedNetwork + PolicyOwner::DelegatedSession + | PolicyOwner::KvOnlySelfNamedNetwork + | PolicyOwner::KvOnlyOwnSpaceSource ) { let mut owner_capabilities = Capabilities::::new(); for ability in ["tinycloud.kv/get", "tinycloud.kv/metadata"] { @@ -8929,7 +8980,10 @@ mod tests { assert!(policy_v3_root::Entity::find().one(conn).await?.is_none()); return Ok(()); } - if policy_owner == PolicyOwner::KvOnlySelfNamedNetwork { + if matches!( + policy_owner, + PolicyOwner::KvOnlySelfNamedNetwork | PolicyOwner::KvOnlyOwnSpaceSource + ) { assert_eq!( register_status, Status::Forbidden, diff --git a/tinycloud-node-server/src/policy_v3/source_network_tests.rs b/tinycloud-node-server/src/policy_v3/source_network_tests.rs index 4db14b6a..a07ad4dc 100644 --- a/tinycloud-node-server/src/policy_v3/source_network_tests.rs +++ b/tinycloud-node-server/src/policy_v3/source_network_tests.rs @@ -116,6 +116,11 @@ fn kv_resource(owner_did: &str) -> anyhow::Result { ) } +/// The create-network body for a network `:share`. +fn own_network_body(owner_did: &str) -> Value { + json!({"name": "share", "ownerDid": owner_did, "threshold": {"n": 1, "t": 1}}) +} + /// The registrant's grants on the content: kv get + metadata on the content /// resource, and decrypt on each listed network. fn content_grants(kv: &ResourceId, decrypt: &[&str]) -> Vec<(String, &'static str)> { @@ -349,11 +354,12 @@ impl Harness { } /// `POST /encryption/networks` as the SDK sends it: the session key - /// invokes `network.create` on `invocation_network` under its CACAO. + /// invokes `network.create` on `invocation_network` under its CACAO, or + /// with no proof when it names its own network (root authority by name). async fn create_network_via_route( &self, invoker: &KeyPrincipal, - proof: &str, + proof: Option<&str>, invocation_network: &str, body: Value, ) -> anyhow::Result { @@ -379,7 +385,10 @@ impl Harness { "bodyHash": canonical_hash(&body), "action": NETWORK_CREATE_ACTION, })]), - proof: vec![proof.parse()?], + proof: proof + .map(str::parse) + .into_iter() + .collect::>()?, attenuation, } .sign(Algorithm::EdDSA, &invoker.jwk)?; @@ -429,6 +438,19 @@ impl Harness { kv_resource: &str, source_network: &str, extra: &[&str], + ) -> anyhow::Result<(Status, String)> { + self.register_full(owner, kv_resource, source_network, extra, &[]) + .await + } + + /// [`Self::register`], with `extra_kv` as more kv ceiling entries. + async fn register_full( + &self, + owner: &KeyPrincipal, + kv_resource: &str, + source_network: &str, + extra: &[&str], + extra_kv: &[&str], ) -> anyhow::Result<(Status, String)> { let now = OffsetDateTime::now_utc().replace_nanosecond(0)?; let issued = now - Duration::seconds(5); @@ -439,6 +461,14 @@ impl Harness { "selector": "exact", "actions": ["tinycloud.kv/get", "tinycloud.kv/metadata"] })]; + for kv in extra_kv { + ceiling.push(json!({ + "kind": "kv", + "resource": kv, + "selector": "exact", + "actions": ["tinycloud.kv/get", "tinycloud.kv/metadata"] + })); + } for network in std::iter::once(&source_network).chain(extra) { ceiling.push(json!({"kind": "encryption", "resource": network, "action": DECRYPT})); } @@ -774,7 +804,7 @@ async fn pkh_owner_registers(harness: Harness, name: &str) -> anyhow::Result<()> harness .create_network_via_route( &session, - &cacao, + Some(&cacao), &network(&account.did(1), name), json!({"name": name, "ownerDid": account.did(1), "threshold": {"n": 1, "t": 1}}), ) @@ -826,6 +856,115 @@ async fn chain_id_mismatch_is_refused() -> anyhow::Result<()> { .await } +/// The B1 shape (review of #363): K holds only kv on V's content. K creates a +/// network named after itself (any DID may, with no proof) and registers a +/// policy whose source is K's own space on that network, with a second kv +/// ceiling entry on V's content. Every kv entry must name a space of the +/// source's owner, so it is refused. +#[tokio::test] +async fn kv_entry_outside_the_content_owner_is_refused() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&[]).await?; + let k = &case.session; + let own_network = network(&k.did, "share"); + case.harness + .create_network_via_route(k, None, &own_network, own_network_body(&k.did)) + .await?; + let own_source = kv_resource(&k.did)?.to_string(); + assert_refused( + case.harness + .register_full(k, &own_source, &own_network, &[], &[&case.kv.to_string()]), + "foreign_space", + ) + .await +} + +/// K sharing its own space on its own network is unaffected. +#[tokio::test] +async fn own_space_on_own_network_registers() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&[]).await?; + let k = &case.session; + let own_network = network(&k.did, "share"); + case.harness + .create_network_via_route(k, None, &own_network, own_network_body(&k.did)) + .await?; + assert_registered( + case.harness + .register(k, &kv_resource(&k.did)?.to_string(), &own_network, &[]) + .await?, + ); + Ok(()) +} + +/// Several kv entries pass when every one names a space of the content +/// owner, here a second space of V's. +#[tokio::test] +async fn kv_entries_all_owned_by_the_content_owner_register() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&["default"]).await?; + case.harness + .create_network(&case.owner.did, "default") + .await?; + let notes = SpaceId::new(case.owner.did.parse::()?, "notes".parse()?).to_resource( + "kv".parse()?, + Some(KV_PATH.parse()?), + None, + None, + ); + case.harness + .delegate_ucan(&case.owner, &case.session.did, &content_grants(¬es, &[])) + .await?; + assert_registered( + case.harness + .register_full( + &case.session, + &case.kv.to_string(), + &case.network("default"), + &[], + &[¬es.to_string()], + ) + .await?, + ); + Ok(()) +} + +/// A legacy `tinycloud://` kv entry names no space owner: refused like a +/// legacy source. A kv entry that doesn't parse never gets this far +/// (`400 kv-resource-invalid`). +#[tokio::test] +async fn legacy_or_unparsable_kv_entry_is_refused() -> anyhow::Result<()> { + let case = KeyOwnerCase::new(&["default"]).await?; + case.harness + .create_network(&case.owner.did, "default") + .await?; + let source = case.network("default"); + let legacy = format!("tinycloud://applications/kv/{KV_PATH}"); + assert_refused( + case.harness.register_full( + &case.session, + &case.kv.to_string(), + &source, + &[], + &[&legacy], + ), + "legacy_resource", + ) + .await?; + let (status, body) = case + .harness + .register_full( + &case.session, + &case.kv.to_string(), + &source, + &[], + &["not-a-resource"], + ) + .await?; + assert_eq!( + (status, body.as_str()), + (Status::BadRequest, "kv-resource-invalid") + ); + Ok(()) +} + fn postgres_url(test: &str) -> Option { match std::env::var("TINYCLOUD_TEST_POSTGRES_URL") { Ok(url) if !url.trim().is_empty() => Some(url), From 85fee4a6d976da6bd8096d2b40b4e59f5aed58d6 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Oct 2026 17:51:36 +0000 Subject: [PATCH 6/9] fix(TC-1423): every kv ceiling entry must name a space of the content owner Review B1 on #363: the network check bound the networks to the owner of contentSource.kvResource only, so a kv-only key could move the source to its own space on its own network and add the space owner's content as a second kv entry. content_owner_networks now also requires every kv ceiling entry to be non-legacy, to parse, and to name a space of the content source's owner (refusal reason foreign_space); a ceiling entry of any other kind fails it. The predicate stays pure, so N1's classifier reuses it unchanged. --- CHANGELOG.md | 2 +- tinycloud-node-server/src/policy_v3.rs | 74 ++++++++++++------- .../src/policy_v3/source_network_tests.rs | 57 +++++++++----- tinycloud-node-server/src/prometheus.rs | 9 ++- 4 files changed, 92 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2f44446..1a1aa325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - 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). - Every response that carries an `ETag` now also carries `Cache-Control: no-transform` (TC-1432): KV get/head/metadata (200 and 304), KV put, the public KV read routes (200, 304 and metadata), signed-URL reads (200, 206, 304, 416) and the share-email responses that set an `ETag`. A CDN that re-compresses a response must weaken its strong `ETag` to `W/"…"`, and `If-Match` compare-and-swap only accepts the strong `"blake3-"` value. Existing `Cache-Control` directives (`private, no-cache`, `private, no-store`, `public, max-age=60`, `no-store`) are kept and `no-transform` is appended; responses without an `ETag` (KV list, SQL, error responses including 412) are unchanged. Bodies, `ETag` values and status codes are unchanged. - Policy/v3 registration records which of the owner's delegations granted what the policy roots carry, with their ancestor closure, in two new tables (migration `m20261019_000000_policy_v3_owner_authority`; its `down` drops both). Delegations from the owner of the capability's space or encryption network are tried before the general window of recent delegations to the owner that touch that space or network; a delegation whose ancestry includes one issued by the policy owner is never recorded; a grant that outlives the root is preferred. Policies registered before the migration are classified in the background on first use (steady-state reads add no query), or by an opt-in sweep (`policy_v3.owner_binding_backfill.enabled`, off by default). The legacy cutover defaults to the migration's applied-at time, is logged at boot, and `policy_v3.legacy_cutover` overrides it after a rollback and re-upgrade. New `POST /policy/v3/authorized` lists the policies whose recorded authority includes a delegation, authorized like `/delegation/status`. The record does not change admission yet. See `docs/policy-owner-authority.md` (TC-1423). -- Policy/v3 registration checks the owner and existence of the policy's encryption networks. Every network the policy names (`contentSource.encryptionNetwork` and each `encryption` ceiling entry) must be owned by the owner of the content source's space, compared as principals (a `did:pkh` matches regardless of EIP-55 letter case; the chain id must match), and must exist on this node in state `active`. Otherwise registration returns `403 content-source-network-invalid` and writes nothing. A new registration whose `kvResource` uses the legacy `tinycloud://` form gets the same refusal; no client emits it. Share and CLI registrations name the space owner's `:default` network, which the SDK creates or finds at sign-in, so they are unaffected; registered policies are not re-checked. Refusals are counted as `tinycloud_policy_source_network_refused_total{reason}`, with reasons `unparsable`, `legacy_resource`, `foreign_owner`, `missing` and `inactive` (TC-1423). +- Policy/v3 registration checks the owner and existence of the policy's encryption networks. Every network the policy names (`contentSource.encryptionNetwork` and each `encryption` ceiling entry) must be owned by the owner of the content source's space, compared as principals (a `did:pkh` matches regardless of EIP-55 letter case; the chain id must match), and must exist on this node in state `active`. Every `kv` ceiling entry must name a space of that same owner. Otherwise registration returns `403 content-source-network-invalid` and writes nothing. A new registration whose `kvResource` or any `kv` ceiling entry uses the legacy `tinycloud://` form gets the same refusal; no client emits it. Share and CLI registrations carry one `kv` entry, equal to the source, and name the space owner's `:default` network, which the SDK creates or finds at sign-in, so they are unaffected; registered policies are not re-checked. Refusals are counted as `tinycloud_policy_source_network_refused_total{reason}`, with reasons `unparsable`, `legacy_resource`, `foreign_owner`, `foreign_space`, `missing` and `inactive` (TC-1423). ## [1.21.0] - 2026-10-18 diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 48622313..ae03f5bc 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -4621,12 +4621,13 @@ fn validate_policy_encryption_resource(value: &str) -> Result<(), (Status, Strin Ok(()) } -/// Why a new policy's encryption networks were refused; the metric label. +/// Why a new policy's resources were refused; the metric label. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SourceNetworkRefusal { Unparsable, LegacyResource, ForeignOwner, + ForeignSpace, Missing, Inactive, } @@ -4637,6 +4638,7 @@ impl SourceNetworkRefusal { Self::Unparsable => "unparsable", Self::LegacyResource => "legacy_resource", Self::ForeignOwner => "foreign_owner", + Self::ForeignSpace => "foreign_space", Self::Missing => "missing", Self::Inactive => "inactive", } @@ -4645,42 +4647,64 @@ impl SourceNetworkRefusal { /// TC-1423 N1b: the encryption networks a policy names (the content source's /// and every `encryption` ceiling entry), when every one parses and belongs to -/// the owner of the content source's space; otherwise why not. Pure, so it -/// applies to stored registrations as well as new ones. +/// the owner of the content source's space, and every `kv` ceiling entry +/// names a space of that owner; otherwise why not. Pure, so it applies to +/// stored registrations as well as new ones. /// /// A DID is root authority over every network URN that names it, created or -/// not. Without this check, a registrant holding only kv on the content could -/// name a network of its own and hold its decrypt by name. With it, a -/// registrant other than the space owner holds decrypt on these networks only +/// not, and any DID may create such a network. Without this check, a +/// registrant holding only kv on someone's content could hand that content +/// out under a network of its own: named in the source, or with the source +/// moved to its own space and the content added as a second kv entry. With +/// it, every resource the policy grants belongs to one owner, and a +/// registrant other than that owner holds decrypt on its networks only /// through a delegation rooted at that owner. /// /// The owner is the space's DID (`ResourceId::space().did()`), compared with /// `did_principal_matches`: EIP-55 letter case is ignored, the chain id is -/// not. `tinycloud://` sources name no space owner, and no client emits them. +/// not. `tinycloud://` resources name no space owner, and no client emits +/// them. A ceiling carries only `kv` and `encryption` entries +/// (`registration_projections`); any other kind is refused as unparsable. fn content_owner_networks(policy: &Value) -> Result, SourceNetworkRefusal> { + let kv_owner = |resource: Option<&str>| { + let resource = resource.unwrap_or_default(); + if resource.starts_with("tinycloud://") { + return Err(SourceNetworkRefusal::LegacyResource); + } + resource + .parse::() + .map(|resource| resource.space().did().to_owned()) + .map_err(|_| SourceNetworkRefusal::Unparsable) + }; let source = policy.get("contentSource"); - let kv_resource = source - .and_then(|source| source.get("kvResource")) - .and_then(Value::as_str) - .unwrap_or_default(); - if kv_resource.starts_with("tinycloud://") { - return Err(SourceNetworkRefusal::LegacyResource); - } - let resource = kv_resource - .parse::() - .map_err(|_| SourceNetworkRefusal::Unparsable)?; - let content_owner = resource.space().did(); - let ceiling_networks = policy + let content_owner = kv_owner( + source + .and_then(|source| source.get("kvResource")) + .and_then(Value::as_str), + )?; + let mut networks = std::collections::BTreeSet::from([source + .and_then(|source| source.get("encryptionNetwork")) + .and_then(Value::as_str)]); + for capability in policy .get("capabilityCeiling") .and_then(Value::as_array) .into_iter() .flatten() - .filter(|capability| capability.get("kind").and_then(Value::as_str) == Some("encryption")) - .map(|capability| capability.get("resource")); - std::iter::once(source.and_then(|source| source.get("encryptionNetwork"))) - .chain(ceiling_networks) - .map(|network| network.and_then(Value::as_str)) - .collect::>() + { + let resource = capability.get("resource").and_then(Value::as_str); + match capability.get("kind").and_then(Value::as_str) { + Some("kv") => { + if !did_principal_matches(kv_owner(resource)?.as_str(), content_owner.as_str()) { + return Err(SourceNetworkRefusal::ForeignSpace); + } + } + Some("encryption") => { + networks.insert(resource); + } + _ => return Err(SourceNetworkRefusal::Unparsable), + } + } + networks .into_iter() .map(|network| { let network_id = network diff --git a/tinycloud-node-server/src/policy_v3/source_network_tests.rs b/tinycloud-node-server/src/policy_v3/source_network_tests.rs index a07ad4dc..9ca7a0eb 100644 --- a/tinycloud-node-server/src/policy_v3/source_network_tests.rs +++ b/tinycloud-node-server/src/policy_v3/source_network_tests.rs @@ -927,42 +927,59 @@ async fn kv_entries_all_owned_by_the_content_owner_register() -> anyhow::Result< } /// A legacy `tinycloud://` kv entry names no space owner: refused like a -/// legacy source. A kv entry that doesn't parse never gets this far -/// (`400 kv-resource-invalid`). +/// legacy source. #[tokio::test] -async fn legacy_or_unparsable_kv_entry_is_refused() -> anyhow::Result<()> { +async fn legacy_kv_entry_is_refused() -> anyhow::Result<()> { let case = KeyOwnerCase::new(&["default"]).await?; case.harness .create_network(&case.owner.did, "default") .await?; - let source = case.network("default"); let legacy = format!("tinycloud://applications/kv/{KV_PATH}"); assert_refused( case.harness.register_full( &case.session, &case.kv.to_string(), - &source, + &case.network("default"), &[], &[&legacy], ), "legacy_resource", ) - .await?; - let (status, body) = case - .harness - .register_full( - &case.session, - &case.kv.to_string(), - &source, - &[], - &["not-a-resource"], - ) - .await?; - assert_eq!( - (status, body.as_str()), - (Status::BadRequest, "kv-resource-invalid") + .await +} + +/// Registration refuses a kv entry that doesn't parse, or a ceiling entry of +/// another kind, earlier (`400` from `registration_projections`). The pure +/// predicate, which also classifies stored rows, refuses both on its own. +#[test] +fn unparsable_or_unknown_ceiling_entry_fails_the_predicate() { + let owner = KeyPrincipal::generate(); + let kv = kv_resource(&owner.did).unwrap().to_string(); + let source = network(&owner.did, "default"); + let policy = |extra: Value| { + json!({ + "contentSource": {"kvResource": kv, "encryptionNetwork": source}, + "capabilityCeiling": [ + {"kind": "kv", "resource": kv}, + {"kind": "encryption", "resource": source}, + extra, + ], + }) + }; + assert!( + content_owner_networks(&policy(json!({"kind": "encryption", "resource": source}))).is_ok() ); - Ok(()) + for extra in [ + json!({"kind": "kv", "resource": "not-a-resource"}), + json!({"kind": "kv"}), + json!({"kind": "sql", "resource": kv}), + ] { + assert_eq!( + content_owner_networks(&policy(extra.clone())).err(), + Some(SourceNetworkRefusal::Unparsable), + "{extra}" + ); + } } fn postgres_url(test: &str) -> Option { diff --git a/tinycloud-node-server/src/prometheus.rs b/tinycloud-node-server/src/prometheus.rs index f36e24f7..0da892fe 100644 --- a/tinycloud-node-server/src/prometheus.rs +++ b/tinycloud-node-server/src/prometheus.rs @@ -33,9 +33,10 @@ pub fn observe_did_resolution_refused(method: &str, reason: &str) { } /// A Policy/v3 registration refused because one of its encryption networks -/// is not the content owner's active network on this node. `reason` is a -/// fixed label: `unparsable`, `legacy_resource`, `foreign_owner`, `missing` -/// or `inactive`. +/// is not the content owner's active network on this node, or one of its kv +/// entries names a space of another owner. `reason` is a fixed label: +/// `unparsable`, `legacy_resource`, `foreign_owner`, `foreign_space`, +/// `missing` or `inactive`. pub fn observe_policy_source_network_refused(reason: &'static str) { POLICY_SOURCE_NETWORK_REFUSED .with_label_values(&[reason]) @@ -50,7 +51,7 @@ lazy_static! { ).unwrap(); pub static ref POLICY_SOURCE_NETWORK_REFUSED: IntCounterVec = register_int_counter_vec!( "tinycloud_policy_source_network_refused_total", - "Policy/v3 registrations refused because an encryption network is not the content owner's active network on this node, by reason.", + "Policy/v3 registrations refused because an encryption network is not the content owner's active network on this node, or a kv entry names another owner's space, by reason.", &["reason"] ) .unwrap(); From d8d3df09bc82728e87ba7b4636b39cf918fe22c4 Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:26:14 +0000 Subject: [PATCH 7/9] Create the content owner's network in the Policy/v3 test harness --- .../src/policy_v3/test_harness.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tinycloud-node-server/src/policy_v3/test_harness.rs b/tinycloud-node-server/src/policy_v3/test_harness.rs index f84bc164..e959c15d 100644 --- a/tinycloud-node-server/src/policy_v3/test_harness.rs +++ b/tinycloud-node-server/src/policy_v3/test_harness.rs @@ -447,6 +447,21 @@ impl Harness { vector, }; harness.seed_content().await?; + // Registration requires the content owner's network to exist and be + // active (TC-1423 N1b), so the harness creates the network its + // policies name. + harness + .client + .rocket() + .state::() + .expect("EncryptionService is managed") + .create_one_of_one_network(tinycloud_core::encryption_network::CreateNetworkRequest { + name: "mainnet".to_owned(), + owner_did: harness.space_owner.did().to_owned(), + threshold: tinycloud_core::encryption_network::Threshold::one_of_one(), + }) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; Ok(Some(harness)) } From c88e837ddd95e1b1f02b1e6cd96ad9d52dd36297 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 07:32:48 +0000 Subject: [PATCH 8/9] Neutral wording for the source-network comments and changelog entry --- CHANGELOG.md | 2 +- tinycloud-node-server/src/policy_v3.rs | 32 ++++++++----------- .../src/policy_v3/source_network_tests.rs | 9 +++--- 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ba1da8f..497de979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ - SQL local durability and reservation before commit (`docs/sql-local-durability.md`). Postgres stays the master copy. File-backed SQL databases run `PRAGMA synchronous = FULL` (`[global.storage.sql] synchronous = "full"|"normal"`, env `TINYCLOUD_STORAGE__SQL__SYNCHRONOUS`; `normal` selects the earlier fsync behaviour), and the directories and renames that make a database file or a hydrated cache visible are fsynced. A write is checked before COMMIT and rolled back whole if it would pass the owner's remaining storage budget (`402`), and, when configured, the per-database cap (`max_database_bytes`, `507 sql_database_size_limit`) or the disk headroom (`min_free_bytes`, bounded by the WAL and checkpoint growth the transaction can cause and shared by all databases on the node, `507 sql_disk_headroom`). `max_database_bytes` and `min_free_bytes` default to `0` (off) and are set by config. A COMMIT that fails on disk answers `500 sql_commit_failed` and the node re-hydrates the database from Postgres. A write that fails because the disk is full answers `507 sql_disk_headroom`, whatever `min_free_bytes` is, not the owner's quota or the size limit. Every write request runs in one transaction (a single statement, a batch, a nested write (`WITH … INSERT`) and a request that carries a `PRAGMA` included), so such a request counts two more native SQL calls in request telemetry, and all of them get the same owner budget, size cap and disk gate. A request that wrote nothing is not gated and reserves nothing. The growth of the main file that the next checkpoint will cause stays held in the disk reservation from the commit until a checkpoint runs or the database's connection closes. No migration. Details: TC-1491. - `MemorySink` caps its size hint at 64 MiB and treats a failed reservation as no hint. Details: TC-1491. - A local SQL commit failure (answered 500) records the same diagnostic failure note as other SQL 500 responses. Details: TC-1491. -- Policy/v3 registration checks the owner and existence of the policy's encryption networks. Every network the policy names (`contentSource.encryptionNetwork` and each `encryption` ceiling entry) must be owned by the owner of the content source's space, compared as principals (a `did:pkh` matches regardless of EIP-55 letter case; the chain id must match), and must exist on this node in state `active`. Every `kv` ceiling entry must name a space of that same owner. Otherwise registration returns `403 content-source-network-invalid` and writes nothing. A new registration whose `kvResource` or any `kv` ceiling entry uses the legacy `tinycloud://` form gets the same refusal; no client emits it. Share and CLI registrations carry one `kv` entry, equal to the source, and name the space owner's `:default` network, which the SDK creates or finds at sign-in, so they are unaffected; registered policies are not re-checked. Refusals are counted as `tinycloud_policy_source_network_refused_total{reason}`, with reasons `unparsable`, `legacy_resource`, `foreign_owner`, `foreign_space`, `missing` and `inactive` (TC-1423). +- Policy/v3 registration checks the owner and existence of the policy's encryption networks. Every network the policy names (`contentSource.encryptionNetwork` and each `encryption` ceiling entry) must be owned by the owner of the content source's space, compared as principals (a `did:pkh` matches regardless of EIP-55 letter case; the chain id must match), and must exist on this node in state `active`. Every `kv` ceiling entry must name a space of that same owner. Otherwise registration returns `403 content-source-network-invalid` and writes nothing. A new registration whose `kvResource` or any `kv` ceiling entry uses the legacy `tinycloud://` form gets the same refusal; no client emits it. Share and CLI registrations carry one `kv` entry, equal to the source, and name the space owner's `:default` network, which the SDK creates or finds at sign-in, so they are unaffected; registered policies are not re-checked. Refusals are counted as `tinycloud_policy_source_network_refused_total{reason}`, with reasons `unparsable`, `legacy_resource`, `foreign_owner`, `foreign_space`, `missing` and `inactive`. Details: TC-1423. ## [1.22.0] - 2026-10-23 diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 68002154..e1a6b2f9 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -4695,14 +4695,13 @@ impl SourceNetworkRefusal { /// names a space of that owner; otherwise why not. Pure, so it applies to /// stored registrations as well as new ones. /// -/// A DID is root authority over every network URN that names it, created or -/// not, and any DID may create such a network. Without this check, a -/// registrant holding only kv on someone's content could hand that content -/// out under a network of its own: named in the source, or with the source -/// moved to its own space and the content added as a second kv entry. With -/// it, every resource the policy grants belongs to one owner, and a -/// registrant other than that owner holds decrypt on its networks only -/// through a delegation rooted at that owner. +/// A network URN is rooted at the DID it names, whether or not the network +/// exists, and any DID may create a network that names itself. Every +/// resource a policy grants therefore has to belong to one owner: the +/// content source's network and every ceiling network are that owner's, and +/// every `kv` entry names that owner's space. A registrant other than that +/// owner holds decrypt on those networks only through a delegation rooted at +/// that owner. /// /// The owner is the space's DID (`ResourceId::space().did()`), compared with /// `did_principal_matches`: EIP-55 letter case is ignored, the chain id is @@ -8672,23 +8671,20 @@ mod tests { Box::pin(run_policy_v2_flow(PolicyOwner::Unauthorized)).await } - /// TC-1423 (B2, N1b): a policy owner that holds only `kv/get` and + /// TC-1423 (N1b): a policy owner that holds only `kv/get` and /// `kv/metadata` on the content (no decrypt on the content owner's /// network) registers a policy whose content source names a network - /// called after the policy owner itself, which needs no grant: a DID is - /// root authority over networks that name it. Registration refuses it: - /// the source network must exist and belong to the content space's owner. + /// called after the policy owner itself. Registration refuses it: the + /// source network must exist and belong to the content space's owner. #[tokio::test] async fn policy_source_network_must_belong_to_the_content_owner() -> anyhow::Result<()> { Box::pin(run_policy_v2_flow(PolicyOwner::KvOnlySelfNamedNetwork)).await } - /// TC-1423 (N1b, review B1): the same kv-only policy owner moves the - /// content source to its own space, on a network it created, and adds a - /// second kv ceiling entry on the space owner's content. Every kv entry - /// must name a space of the content source's owner, so registration - /// refuses it; otherwise recipients would read the space owner's content - /// through a policy that outlives the owner's revocation. + /// TC-1423 (N1b): the same kv-only policy owner moves the content source + /// to its own space, on a network it created, and adds a second kv + /// ceiling entry on the space owner's content. Every kv entry must name + /// a space of the content source's owner, so registration refuses it. #[tokio::test] async fn policy_kv_entries_must_belong_to_the_content_owner() -> anyhow::Result<()> { Box::pin(run_policy_v2_flow(PolicyOwner::KvOnlyOwnSpaceSource)).await diff --git a/tinycloud-node-server/src/policy_v3/source_network_tests.rs b/tinycloud-node-server/src/policy_v3/source_network_tests.rs index 9ca7a0eb..cfe513c2 100644 --- a/tinycloud-node-server/src/policy_v3/source_network_tests.rs +++ b/tinycloud-node-server/src/policy_v3/source_network_tests.rs @@ -856,11 +856,10 @@ async fn chain_id_mismatch_is_refused() -> anyhow::Result<()> { .await } -/// The B1 shape (review of #363): K holds only kv on V's content. K creates a -/// network named after itself (any DID may, with no proof) and registers a -/// policy whose source is K's own space on that network, with a second kv -/// ceiling entry on V's content. Every kv entry must name a space of the -/// source's owner, so it is refused. +/// A policy owner K that holds only kv on V's content creates a network named +/// after itself and registers a policy whose source is K's own space on that +/// network, with a second kv ceiling entry on V's content. Every kv entry must +/// name a space of the source's owner, so registration is refused. #[tokio::test] async fn kv_entry_outside_the_content_owner_is_refused() -> anyhow::Result<()> { let case = KeyOwnerCase::new(&[]).await?; From 86fc01bf4fdfa2f0a90b3411c4ccb1dbdb51fa75 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 10:49:11 +0000 Subject: [PATCH 9/9] Remove finding labels from source-network comments --- tinycloud-node-server/src/policy_v3.rs | 8 ++++---- .../src/policy_v3/source_network_tests.rs | 2 +- tinycloud-node-server/src/policy_v3/test_harness.rs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index e1a6b2f9..0c97aecd 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -4689,7 +4689,7 @@ impl SourceNetworkRefusal { } } -/// TC-1423 N1b: the encryption networks a policy names (the content source's +/// TC-1423: the encryption networks a policy names (the content source's /// and every `encryption` ceiling entry), when every one parses and belongs to /// the owner of the content source's space, and every `kv` ceiling entry /// names a space of that owner; otherwise why not. Pure, so it applies to @@ -8671,7 +8671,7 @@ mod tests { Box::pin(run_policy_v2_flow(PolicyOwner::Unauthorized)).await } - /// TC-1423 (N1b): a policy owner that holds only `kv/get` and + /// TC-1423: a policy owner that holds only `kv/get` and /// `kv/metadata` on the content (no decrypt on the content owner's /// network) registers a policy whose content source names a network /// called after the policy owner itself. Registration refuses it: the @@ -8681,7 +8681,7 @@ mod tests { Box::pin(run_policy_v2_flow(PolicyOwner::KvOnlySelfNamedNetwork)).await } - /// TC-1423 (N1b): the same kv-only policy owner moves the content source + /// TC-1423: the same kv-only policy owner moves the content source /// to its own space, on a network it created, and adds a second kv /// ceiling entry on the space owner's content. Every kv entry must name /// a space of the content source's owner, so registration refuses it. @@ -9180,7 +9180,7 @@ mod tests { encryption_backend, ); // Registration requires the source network to exist on this node and - // to be the content owner's (TC-1423 N1b). The self-named network of + // to be the content owner's (TC-1423). The self-named network of // `KvOnlySelfNamedNetwork` is deliberately never created. encryption .create_one_of_one_network(tinycloud_core::encryption_network::CreateNetworkRequest { diff --git a/tinycloud-node-server/src/policy_v3/source_network_tests.rs b/tinycloud-node-server/src/policy_v3/source_network_tests.rs index cfe513c2..41da5a7c 100644 --- a/tinycloud-node-server/src/policy_v3/source_network_tests.rs +++ b/tinycloud-node-server/src/policy_v3/source_network_tests.rs @@ -1,4 +1,4 @@ -//! TC-1423 N1b: every encryption network a Policy/v3 names must be the +//! TC-1423: every encryption network a Policy/v3 names must be the //! content owner's, and active on this node, when the policy is registered. //! //! Registration-only fixtures over the real routes: `/delegate` imports the diff --git a/tinycloud-node-server/src/policy_v3/test_harness.rs b/tinycloud-node-server/src/policy_v3/test_harness.rs index e959c15d..1156662e 100644 --- a/tinycloud-node-server/src/policy_v3/test_harness.rs +++ b/tinycloud-node-server/src/policy_v3/test_harness.rs @@ -448,7 +448,7 @@ impl Harness { }; harness.seed_content().await?; // Registration requires the content owner's network to exist and be - // active (TC-1423 N1b), so the harness creates the network its + // active (TC-1423), so the harness creates the network its // policies name. harness .client