diff --git a/CHANGELOG.md b/CHANGELOG.md index fb211081..497de979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +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`. Details: TC-1423. ## [1.22.0] - 2026-10-23 diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index c8927dda..7483f1e2 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -676,6 +676,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 28337963..0c97aecd 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -30,13 +30,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::{ @@ -1856,6 +1857,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") @@ -4657,6 +4665,129 @@ fn validate_policy_encryption_resource(value: &str) -> Result<(), (Status, Strin Ok(()) } +/// 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, +} + +impl SourceNetworkRefusal { + const fn as_str(self) -> &'static str { + match self { + Self::Unparsable => "unparsable", + Self::LegacyResource => "legacy_resource", + Self::ForeignOwner => "foreign_owner", + Self::ForeignSpace => "foreign_space", + Self::Missing => "missing", + Self::Inactive => "inactive", + } + } +} + +/// 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 +/// stored registrations as well as new ones. +/// +/// 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 +/// 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 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() + { + 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 + .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 + .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, @@ -8521,7 +8652,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 @@ -8529,7 +8660,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 @@ -8537,7 +8668,26 @@ 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: 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 + /// 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: 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 } #[derive(Clone, Copy, PartialEq, Eq)] @@ -8548,6 +8698,14 @@ 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, + /// 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<()> { @@ -8602,6 +8760,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(); @@ -8629,8 +8795,15 @@ mod tests { "selector": "exact", "actions": ["tinycloud.kv/get"] })]; - let encryption_resource = format!("urn:tinycloud:encryption:{content_owner_did}:mainnet"); - let ceiling = vec![ + 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 mut ceiling = vec![ json!({ "kind": "kv", "resource": content_resource.to_string(), @@ -8643,9 +8816,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), @@ -8995,6 +9179,28 @@ 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). 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?; + 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( "/", @@ -9106,7 +9312,12 @@ 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 + | PolicyOwner::KvOnlyOwnSpaceSource + ) { let mut owner_capabilities = Capabilities::::new(); for ability in ["tinycloud.kv/get", "tinycloud.kv/metadata"] { owner_capabilities.with_action( @@ -9115,11 +9326,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()?, @@ -9326,6 +9539,26 @@ mod tests { assert!(policy_v3_root::Entity::find().one(conn).await?.is_none()); return Ok(()); } + if matches!( + policy_owner, + PolicyOwner::KvOnlySelfNamedNetwork | PolicyOwner::KvOnlyOwnSpaceSource + ) { + 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}"); let expected_identity = tinycloud_core::request_telemetry::RequestContext::new( uuid::Uuid::new_v4(), @@ -10789,3 +11022,7 @@ mod root_revocation_tests; #[cfg(test)] #[path = "policy_v3/registration_probe_tests.rs"] mod registration_probe_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..41da5a7c --- /dev/null +++ b/tinycloud-node-server/src/policy_v3/source_network_tests.rs @@ -0,0 +1,1019 @@ +//! 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 +//! 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 { + /// 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. + 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 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)> { + 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 { + 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, + 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 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, or + /// with no proof when it names its own network (root authority by name). + async fn create_network_via_route( + &self, + invoker: &KeyPrincipal, + proof: Option<&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: proof + .map(str::parse) + .into_iter() + .collect::>()?, + 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)> { + 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); + 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 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})); + } + let mut policy = json!({ + "schema": POLICY_V2_SCHEMA, + "policyId": "pending", + "ownerDid": owner.did, + "createdAt": format_time(issued), + "expiresAt": format_time(expires), + "contentSource": { + // 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, + "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 { + 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)?; + 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(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)); + 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, + Some(&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(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(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::generate(); + 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 +} + +/// 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?; + 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. +#[tokio::test] +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 legacy = format!("tinycloud://applications/kv/{KV_PATH}"); + assert_refused( + case.harness.register_full( + &case.session, + &case.kv.to_string(), + &case.network("default"), + &[], + &[&legacy], + ), + "legacy_resource", + ) + .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() + ); + 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 { + 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(()) +} diff --git a/tinycloud-node-server/src/policy_v3/test_harness.rs b/tinycloud-node-server/src/policy_v3/test_harness.rs index f84bc164..1156662e 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), 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)) } diff --git a/tinycloud-node-server/src/prometheus.rs b/tinycloud-node-server/src/prometheus.rs index 26c0bf04..78035a6b 100644 --- a/tinycloud-node-server/src/prometheus.rs +++ b/tinycloud-node-server/src/prometheus.rs @@ -32,12 +32,29 @@ 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, 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]) + .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, or a kv entry names another owner's space, by reason.", + &["reason"] + ) + .unwrap(); pub static ref REQUEST_HISTOGRAM: HistogramVec = register_histogram_vec!( "tinycloud_http_request_duration_seconds", "HTTP request latencies in seconds.",