From 8302ed25364162c6d7d3d1e65deb6df1ac94d484 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:18:30 +0000 Subject: [PATCH 1/3] fix(policy-v3): refuse new registrations with an unbound content source. Details: TC-2242 --- CHANGELOG.md | 1 + tinycloud-node-server/src/policy_v3.rs | 93 +++- .../src/policy_v3/registration_probe_tests.rs | 10 +- .../src/policy_v3/test_harness.rs | 411 +++++++++++++++++- .../src/policy_v3/unbound_source_tests.rs | 329 ++++++++++++++ 5 files changed, 798 insertions(+), 46 deletions(-) create mode 100644 tinycloud-node-server/src/policy_v3/unbound_source_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 879515b0..e29ef07c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changes - Hook SSE subscriptions emit an authorized `ready` control event and close after broadcast lag. Successful SQL and DuckDB writes publish notifications keyed by the signed invocation CID; reads and failed writes emit none, and webhook enqueue remains independent. Details: TC-2207 - Fresh SQL artifacts use a full checkpoint directly instead of attempting an unavailable WAL-delta update. Details: TC-2207 +- New Policy/v3 registrations must name their share key (`encryptionKeyPath`) and, for immutable content, its digest; registrations already stored are unchanged. Details: TC-2242 ## [1.25.12] - 2026-10-29 diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index d93b75ab..1286bd24 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -2039,6 +2039,9 @@ pub async fn register_policy( } return Err((Status::Conflict, "policy-registration-conflict".into())); } + // A registration the node already stores answered above. A new one must + // bind its content source to one share key. + require_bound_content_source(&request.policy)?; let now = OffsetDateTime::now_utc(); let policy_status = initial_status_checkpoint( @@ -4529,6 +4532,32 @@ struct RegistrationProjections { attenuation: Value, } +/// A new registration's content source names the one share key it opens +/// (`encryptionKeyPath`) and, when immutable, that key's digest. This runs +/// only when a registration is stored; `registration_projections` also +/// validates stored registrations, which keep their original shape. +fn require_bound_content_source(policy: &Value) -> Result<(), (Status, String)> { + let source = policy + .get("contentSource") + .and_then(Value::as_object) + .ok_or((Status::BadRequest, "content-source-invalid".into()))?; + if !source.contains_key("encryptionKeyPath") { + return Err(( + Status::BadRequest, + "content-source-key-path-required".into(), + )); + } + if source.get("mode").and_then(Value::as_str) == Some("immutable") + && !source.contains_key("encryptedSymmetricKeyDigestHex") + { + return Err(( + Status::BadRequest, + "content-source-key-digest-required".into(), + )); + } + Ok(()) +} + fn registration_projections(policy: &Value) -> Result { let object = policy .as_object() @@ -10225,28 +10254,44 @@ mod tests { &StaticSecret::new(vec![29; 32]).unwrap(), )), ); - let register_request = client - .post("/policy/v3/policies") - .header(ContentType::JSON) - .body( - json!({ - "policyCid": policy_cid, - "policy": policy, - "policyRoot": policy_root_authorization, - "enforcementRoot": enforcement_root_authorization, - "contentSourceDigestHex": projections.content_source_digest_hex, - "nativeProjectionHashHex": projections.native_projection_hash_hex, - "attestedEnforcerBinding": live_binding, - }) - .to_string(), - ); - let register_response = tinycloud_core::request_telemetry::scope( - telemetry.clone(), - register_request.dispatch(), - ) - .await; - let register_status = register_response.status(); - let register_body = register_response.into_string().await.unwrap_or_default(); + let registration = json!({ + "policyCid": policy_cid, + "policy": policy, + "policyRoot": policy_root_authorization, + "enforcementRoot": enforcement_root_authorization, + "contentSourceDigestHex": projections.content_source_digest_hex, + "nativeProjectionHashHex": projections.native_projection_hash_hex, + "attestedEnforcerBinding": live_binding, + }); + let (register_status, register_body) = if policy_owner == PolicyOwner::Unauthorized { + let register_response = tinycloud_core::request_telemetry::scope( + telemetry.clone(), + client + .post("/policy/v3/policies") + .header(ContentType::JSON) + .body(registration.to_string()) + .dispatch(), + ) + .await; + ( + register_response.status(), + register_response.into_string().await.unwrap_or_default(), + ) + } else { + // TC-2242: this policy has no `encryptionKeyPath`, a shape the + // route refuses for a new registration. What follows is about a + // registration that is already stored, so it is stored as one. + // Boxed: this test's future is already near the test thread's stack. + let stored = tinycloud_core::request_telemetry::scope( + telemetry.clone(), + Box::pin(super::test_harness::Harness::store_registration_on( + &client, + registration, + )), + ) + .await?; + (Status::Ok, serde_json::to_string(&stored)?) + }; if policy_owner == PolicyOwner::Unauthorized { assert_eq!( register_status, @@ -11901,3 +11946,7 @@ mod registration_probe_tests; #[cfg(test)] #[path = "policy_v3/delivery_admission_tests.rs"] mod delivery_admission_tests; + +#[cfg(test)] +#[path = "policy_v3/unbound_source_tests.rs"] +mod unbound_source_tests; diff --git a/tinycloud-node-server/src/policy_v3/registration_probe_tests.rs b/tinycloud-node-server/src/policy_v3/registration_probe_tests.rs index 208a94c9..855d200b 100644 --- a/tinycloud-node-server/src/policy_v3/registration_probe_tests.rs +++ b/tinycloud-node-server/src/policy_v3/registration_probe_tests.rs @@ -13,6 +13,7 @@ //! main as well. PostgreSQL only; the probe runs in CI, not locally. use super::test_harness::*; +use super::unbound_source_tests::{boot, bound_source, grant_owner_key}; use super::*; use std::future::Future; use tinycloud_core::sea_orm::{ @@ -106,15 +107,18 @@ fn key_free_and_rows(url: String, key: i64) -> anyhow::Result<(bool, Rows)> { #[tokio::test] async fn postgres_refused_registration_releases_its_space_lock() -> anyhow::Result<()> { let test = "postgres_refused_registration_releases_its_space_lock"; - let Some(h) = Harness::new(Backend::Postgres, Shape::KeySession, test).await? else { + // TC-2242: the route refuses a pathless registration before it opens the + // transaction, so the registration is path-bound (this needs named keys). + let Some((h, _ledger)) = boot(Backend::Postgres, test).await? else { return Ok(()); }; - h.grant_owner(policy_window()).await?; + let (source, leaf) = bound_source(&h, "immutable", Some(&"aa".repeat(32))); + grant_owner_key(&h, &leaf).await?; let url = h.database_url().to_owned(); install_refusal(url.clone())?; let before = rows(url.clone())?; - let (status, body, _) = h.register(policy_window()).await?; + let (status, body, _) = h.register_source(policy_window(), source, &leaf).await?; anyhow::ensure!( status == Status::Conflict, "refused register: {status} {body}" diff --git a/tinycloud-node-server/src/policy_v3/test_harness.rs b/tinycloud-node-server/src/policy_v3/test_harness.rs index 3ec27035..9b00a882 100644 --- a/tinycloud-node-server/src/policy_v3/test_harness.rs +++ b/tinycloud-node-server/src/policy_v3/test_harness.rs @@ -656,7 +656,10 @@ impl Harness { } /// Register a policy over the content whose roots span `root`, signed - /// by the policy owner. + /// by the policy owner. TC-2242: the policy has no `encryptionKeyPath`, a + /// shape the route refuses for a new registration, so it is stored as a + /// registration that predates the refusal. The tests built on it are + /// about what a stored registration does. pub async fn register(&self, root: Window) -> anyhow::Result<(Status, String, Registered)> { let owner_did = self.owner.did().to_owned(); let projection = self.vector["policyProjection"].clone(); @@ -797,26 +800,17 @@ impl Harness { .await; anyhow::ensure!(binding.status() == Status::Ok, "enforcer binding"); let binding: Value = binding.into_json().await.unwrap(); - let response = self - .client - .post("/policy/v3/policies") - .header(ContentType::JSON) - .body( - json!({ - "policyCid": policy_cid, - "policy": policy, - "policyRoot": policy_root, - "enforcementRoot": enforcement_root, - "contentSourceDigestHex": projections.content_source_digest_hex, - "nativeProjectionHashHex": projections.native_projection_hash_hex, - "attestedEnforcerBinding": binding, - }) - .to_string(), - ) - .dispatch() - .await; - let status = response.status(); - let body = response.into_string().await.unwrap_or_default(); + let registration = 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, + }); + let stored = Self::store_registration_on(&self.client, registration).await?; + let (status, body) = (Status::Ok, serde_json::to_string(&stored)?); Ok(( status, body, @@ -1285,3 +1279,378 @@ impl Harness { Ok((status, body)) } } + +/// TC-2242: registrations over a content source the test chooses, and a +/// stored registration that skips the new-registration boundary. +impl Harness { + /// The `default` network of the content space's owner, the only network a + /// path-bound share key can live on. + pub fn default_network(&self) -> String { + format!( + "urn:tinycloud:encryption:{}:default", + self.space_owner.did() + ) + } + + /// The request body of `POST /policy/v3/policies` over `content_source`, + /// whose ceiling grants decrypt on `decrypt_resource`, signed by the + /// policy owner with roots spanning `root`. + pub async fn registration_request( + &self, + root: Window, + content_source: Value, + decrypt_resource: &str, + ) -> anyhow::Result<(Value, Registered)> { + self.registration_request_for( + root, + content_source, + decrypt_resource, + self.vector["policyProjection"].clone(), + ) + .await + } + + /// As [`Harness::registration_request`], for a policy whose + /// `credentialRequirement` is `requirement`. + pub async fn registration_request_for( + &self, + root: Window, + content_source: Value, + decrypt_resource: &str, + requirement: Value, + ) -> anyhow::Result<(Value, Registered)> { + let owner_did = self.owner.did().to_owned(); + let created = (now() - Duration::seconds(60)).min(root.not_before); + let mut policy = json!({ + "schema": POLICY_V2_SCHEMA, + "policyId": "pending", + "ownerDid": owner_did, + "createdAt": format_time(created), + "expiresAt": format_time(root.expiry + Duration::hours(1)), + "contentSource": content_source, + "capabilityCeiling": [ + { + "kind": "kv", + "resource": self.content_resource.to_string(), + "selector": "exact", + "actions": [KV_GET, KV_METADATA], + }, + { + "kind": "encryption", + "resource": decrypt_resource, + "action": DECRYPT, + }, + ], + "credentialRequirement": 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( + self.owner.keypair().sign(&Sha256::digest(preimage)), + URL_SAFE_NO_PAD + )); + let policy_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 digest = policy_digest_hex(&policy).map_err(|(_, error)| anyhow::anyhow!(error))?; + let common = json!({ + "ownerDid": owner_did, + "policyId": policy["policyId"], + "policyDigestHex": digest, + "policyCid": policy_cid, + "contentSourceDigestHex": projections.content_source_digest_hex, + "capabilityCeilingHashHex": projections.capability_ceiling_hash_hex, + "nativeProjectionHashHex": projections.native_projection_hash_hex, + "nodeAudience": self.node_did(), + }); + let Signer::Key { keypair, vm, .. } = &self.owner else { + unreachable!() + }; + let root_authorization = + |audience: &str, role: &str, mode: &str, enforcer: Option<&str>| { + let mut facts = common.as_object().unwrap().clone(); + facts.insert("role".into(), json!(role)); + facts.insert("mode".into(), json!(mode)); + if let Some(enforcer) = enforcer { + facts.insert("enforcerDid".into(), json!(enforcer)); + } + let header = json!({ + "alg": "EdDSA", + "jwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "kty": "OKP", + "x": encode_config(keypair.public().to_bytes(), URL_SAFE_NO_PAD), + }, + "typ": "JWT", + "ucv": "0.10.0", + }); + let payload = json!({ + "att": projections.attenuation.clone(), + "aud": audience, + "exp": root.expiry.unix_timestamp(), + "fct": [Value::Object(facts)], + "iss": vm, + "nbf": root.not_before.unix_timestamp(), + "nnc": unique(&format!("tc-2242-{role}-")), + "prf": [], + }); + let protected = encode_config(canonical_json_value(&header), URL_SAFE_NO_PAD); + let payload = encode_config(canonical_json_value(&payload), URL_SAFE_NO_PAD); + let signing_input = format!("{protected}.{payload}"); + format!( + "{signing_input}.{}", + encode_config(keypair.sign(signing_input.as_bytes()), URL_SAFE_NO_PAD) + ) + }; + let policy_root = root_authorization( + &format!("did:tinycloud:policy:{digest}"), + "policy-authority", + "policy-source", + None, + ); + let enforcement_root = root_authorization( + self.enforcer.did(), + "policy-enforcement", + "conditional-mint", + Some(self.enforcer.did()), + ); + let policy_root_cid = decode_root(&policy_root) + .map_err(|(_, error)| anyhow::anyhow!(error))? + .0; + let enforcement_root_cid = decode_root(&enforcement_root) + .map_err(|(_, error)| anyhow::anyhow!(error))? + .0; + let binding = self + .client + .post("/policy/v3/enforcer-bindings") + .header(ContentType::JSON) + .body( + json!({"rootExpiresAt": format_time(root.expiry), "enforcerDid": self.enforcer.did()}) + .to_string(), + ) + .dispatch() + .await; + anyhow::ensure!(binding.status() == Status::Ok, "enforcer binding"); + let binding: Value = binding.into_json().await.unwrap(); + Ok(( + 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, + }), + Registered { + policy_cid, + policy_root_cid, + enforcement_root_cid, + }, + )) + } + + /// Register over `content_source` through the HTTP route. + pub async fn register_source( + &self, + root: Window, + content_source: Value, + decrypt_resource: &str, + ) -> anyhow::Result<(Status, String, Registered)> { + let requirement = self.vector["policyProjection"].clone(); + self.register_source_for(root, content_source, decrypt_resource, requirement) + .await + } + + /// As [`Harness::register_source`], for a policy whose + /// `credentialRequirement` is `requirement`. + pub async fn register_source_for( + &self, + root: Window, + content_source: Value, + decrypt_resource: &str, + requirement: Value, + ) -> anyhow::Result<(Status, String, Registered)> { + let (body, registered) = self + .registration_request_for(root, content_source, decrypt_resource, requirement) + .await?; + let (status, body) = self.post_registration(body).await; + Ok((status, body, registered)) + } + + /// `POST /policy/v3/policies` with a registration request. + pub async fn post_registration(&self, body: Value) -> (Status, String) { + let response = self + .client + .post("/policy/v3/policies") + .header(ContentType::JSON) + .body(body.to_string()) + .dispatch() + .await; + let status = response.status(); + (status, response.into_string().await.unwrap_or_default()) + } + + /// A requirement for a credential that is not an email proof, over the + /// same issuer. + pub fn non_email_requirement(&self) -> Value { + let mut requirement = self.vector["policyProjection"].clone(); + requirement["credentialType"] = json!({"id": "opencredentials.domain/v1", "version": 1}); + requirement["profile"] = json!({"id": "tinycloud.domain-proof/v1", "version": 1}); + requirement["requirementDigest"] = json!(canonical_digest_base64url(&json!("tc-2242-req"))); + requirement["descriptorDigest"] = json!(canonical_digest_base64url(&json!("tc-2242-desc"))); + requirement + } + + /// Store a registration request as the node did before the + /// new-registration boundary refused unbound content sources: the same + /// writes as the route, without its checks. The stand-in for a row that + /// is already in the database. + pub async fn store_registration(&self, body: Value) -> anyhow::Result<()> { + Self::store_registration_on(&self.client, body).await?; + Ok(()) + } + + /// As [`Harness::store_registration`], on any booted app, answering what + /// the route answers. Like the route, it records the spaces of the roots + /// in the request's telemetry. + pub async fn store_registration_on( + client: &Client, + body: Value, + ) -> anyhow::Result { + let request: RegisterRequest = serde_json::from_value(body)?; + let runtime = client.rocket().state::().unwrap(); + let tinycloud = client.rocket().state::().unwrap(); + let projections = registration_projections(&request.policy) + .map_err(|(_, error)| anyhow::anyhow!(error))?; + let (policy_root_cid, policy_root) = + decode_root(&request.policy_root).map_err(|(_, error)| anyhow::anyhow!(error))?; + let (enforcement_root_cid, enforcement_root) = + decode_root(&request.enforcement_root).map_err(|(_, error)| anyhow::anyhow!(error))?; + for root in [&policy_root, &enforcement_root] { + for space in root.0.spaces() { + tinycloud_core::request_telemetry::observe_space(space); + } + } + let at = OffsetDateTime::now_utc(); + let binding = validate_attested_enforcer_binding( + &request.attested_enforcer_binding, + fact(&enforcement_root.0.delegation, "enforcerDid").unwrap(), + &runtime.node_did, + enforcement_root.0.expiry.unwrap(), + at, + ) + .map_err(|(_, error)| anyhow::anyhow!(error))?; + let policy_status = initial_status_checkpoint( + runtime, + &policy_root_cid, + "policy-authority", + &policy_root.0, + at, + ) + .map_err(|(_, error)| anyhow::anyhow!(error))?; + let enforcement_status = initial_status_checkpoint( + runtime, + &enforcement_root_cid, + "policy-enforcement", + &enforcement_root.0, + at, + ) + .map_err(|(_, error)| anyhow::anyhow!(error))?; + let registration = policy_v3_registration::ActiveModel { + policy_cid: Set(request.policy_cid.clone()), + policy_bytes: Set(canonical_json_value(&request.policy)), + policy_digest_hex: Set( + policy_digest_hex(&request.policy).map_err(|(_, error)| anyhow::anyhow!(error))? + ), + owner_did: Set(request.policy["ownerDid"].as_str().unwrap().to_owned()), + policy_root_cid: Set(policy_root_cid.clone()), + enforcement_root_cid: Set(enforcement_root_cid.clone()), + content_source_digest_hex: Set(projections.content_source_digest_hex), + native_projection_hash_hex: Set(projections.native_projection_hash_hex), + attested_enforcer_binding_bytes: Set(binding), + registered_at: Set(format_time(at)), + expires_at: Set(root_expiry(&request.policy_root).unwrap()), + }; + let root_delegations = vec![ + decode_delegation(&request.policy_root).map_err(|(_, error)| anyhow::anyhow!(error))?, + decode_delegation(&request.enforcement_root) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + ]; + let sequence_spaces = root_delegations + .iter() + .flat_map(|delegation| delegation.0.spaces().cloned()) + .collect::>(); + let _sequence_guards = tinycloud + .acquire_space_sequence_guards(&sequence_spaces) + .await; + let _writer = match &runtime.sqlite_writer_lock { + Some(lock) => Some(lock.lock().await), + None => None, + }; + let txn = tinycloud_core::db::begin_write_transaction(&runtime.conn, None).await?; + let written = register_policy_writes( + &txn, + tinycloud, + &sequence_spaces, + root_delegations, + registration, + &request.policy_cid, + [ + ( + policy_root_cid.clone(), + "policy-authority", + request.policy_root.into_bytes(), + policy_status, + ), + ( + enforcement_root_cid.clone(), + "policy-enforcement", + request.enforcement_root.into_bytes(), + enforcement_status, + ), + ], + ) + .await; + match written { + Ok(()) => txn.commit().await?, + Err((status, error)) => { + let _ = txn.rollback().await; + anyhow::bail!("store registration: {status} {error}"); + } + } + Ok(RegisterResponse { + policy_cid: request.policy_cid, + policy_root_cid, + enforcement_root_cid, + }) + } + + /// Rows in `policy_v3_registration`, `policy_v3_root` and the delegation + /// graph. + pub async fn stored_rows(&self) -> anyhow::Result<(u64, u64, u64)> { + use tinycloud_core::sea_orm::{EntityTrait, PaginatorTrait}; + let runtime = self.client.rocket().state::().unwrap(); + Ok(( + policy_v3_registration::Entity::find() + .count(&runtime.conn) + .await?, + policy_v3_root::Entity::find().count(&runtime.conn).await?, + delegation_model::Entity::find() + .count(&runtime.conn) + .await?, + )) + } +} diff --git a/tinycloud-node-server/src/policy_v3/unbound_source_tests.rs b/tinycloud-node-server/src/policy_v3/unbound_source_tests.rs new file mode 100644 index 00000000..ee4dc12a --- /dev/null +++ b/tinycloud-node-server/src/policy_v3/unbound_source_tests.rs @@ -0,0 +1,329 @@ +//! TC-2242: a new Policy/v3 registration must bind its content source to one +//! share key. A registration without `encryptionKeyPath` (mutable or +//! immutable) or an immutable one without `encryptedSymmetricKeyDigestHex` is +//! refused at the registration route and stores nothing. A registration that +//! is already stored keeps validating, so links minted before the refusal +//! still open. + +use super::test_harness::*; +use super::*; +use serde_json::json; + +const KEY_PATH_REQUIRED: &str = "content-source-key-path-required"; +const KEY_DIGEST_REQUIRED: &str = "content-source-key-digest-required"; +const KV_GET: &str = "tinycloud.kv/get"; +const DECRYPT: &str = "tinycloud.encryption/decrypt"; + +/// A node with named keys on, which a path-bound share key needs: the owner +/// of the policy is granted decrypt on the key itself. The ledger directory +/// lives as long as the harness. +pub(super) async fn boot( + backend: Backend, + test: &str, +) -> anyhow::Result> { + let ledger = tempfile::tempdir()?; + let path = ledger.path().join("deletion-ledger.jsonl"); + let named_keys = move |config: &mut crate::config::Config| { + config.encryption_network.named_keys_v1 = true; + config.encryption_network.deletion_ledger_path = Some(path); + }; + Ok( + Harness::new_with(backend, Shape::KeySession, test, named_keys) + .await? + .map(|h| (h, ledger)), + ) +} + +/// The policy owner holds kv reads and decrypt on `key`. +pub(super) async fn grant_owner_key(h: &Harness, key: &str) -> anyhow::Result<()> { + let grants = [ + (h.content_resource.to_string(), KV_GET), + (h.content_resource.to_string(), "tinycloud.kv/metadata"), + (key.to_owned(), DECRYPT), + ]; + h.grant_owner_with(&grants, Window::until(now() + Duration::days(2))) + .await?; + Ok(()) +} + +/// A content source over the harness content. `key_path` and `digest` are left +/// out of the document when `None`. +fn content_source( + h: &Harness, + network: &str, + mode: &str, + key_path: Option<&str>, + digest: Option<&str>, +) -> Value { + let mut source = json!({ + "shareId": unique("share-tc-2242-"), + "kvResource": h.content_resource.to_string(), + "selector": "exact", + "encryptionNetwork": network, + "keyVersion": 1, + "mode": mode, + }); + let object = source.as_object_mut().unwrap(); + if mode == "immutable" { + object.insert("initialCiphertextDigestHex".into(), json!("bb".repeat(32))); + } + if let Some(path) = key_path { + object.insert("encryptionKeyPath".into(), json!(path)); + } + if let Some(digest) = digest { + object.insert("encryptedSymmetricKeyDigestHex".into(), json!(digest)); + } + source +} + +/// A path-bound source: its key is `share/` on the default network. +pub(super) fn bound_source(h: &Harness, mode: &str, digest: Option<&str>) -> (Value, String) { + let mut source = content_source(h, &h.default_network(), mode, None, digest); + let key_path = format!("share/{}", source["shareId"].as_str().unwrap()); + source["encryptionKeyPath"] = json!(key_path); + let leaf = format!("{}/{key_path}", h.default_network()); + (source, leaf) +} + +/// What a challenge over the content and `decrypt` answers. +async fn challenge_decrypt( + h: &Harness, + policy: &Registered, + decrypt: &str, +) -> anyhow::Result<(Status, String)> { + let response = h + .client + .post("/policy/v3/challenges") + .header(rocket::http::ContentType::JSON) + .body( + json!({ + "policyCid": policy.policy_cid, + "recipientDid": h.holder.did(), + "requestedCapabilities": [ + { + "kind": "kv", + "resource": h.content_resource.to_string(), + "selector": "exact", + "actions": [KV_GET], + }, + {"kind": "encryption", "resource": decrypt, "action": DECRYPT}, + ], + }) + .to_string(), + ) + .dispatch() + .await; + let status = response.status(); + Ok((status, response.into_string().await.unwrap_or_default())) +} + +/// A refused registration answers `400` with its reason and stores nothing: +/// no registration, no root, no delegation, and the policy is unknown. +async fn assert_refused_and_unstored( + h: &Harness, + outcome: (Status, String, Registered), + reason: &str, + before: (u64, u64, u64), +) -> anyhow::Result<()> { + let (status, body, registered) = outcome; + assert_eq!( + (status, body.as_str()), + (Status::BadRequest, reason), + "refusal" + ); + assert_eq!(h.stored_rows().await?, before, "rows after {reason}"); + assert_eq!( + h.challenge(®istered).await, + (Status::NotFound, "policy-registration-missing".to_owned()) + ); + Ok(()) +} + +/// [a] a pathless network-wide decrypt, [b] a mutable source without a key +/// path, [c] an immutable source without the key digest: each is refused, and +/// nothing is stored. +async fn unbound_content_sources_are_refused_and_nothing_is_stored( + backend: Backend, +) -> anyhow::Result<()> { + let Some((h, _ledger)) = boot( + backend, + "unbound_content_sources_are_refused_and_nothing_is_stored", + ) + .await? + else { + return Ok(()); + }; + h.grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let (bound, leaf) = bound_source(&h, "immutable", None); + grant_owner_key(&h, &leaf).await?; + let before = h.stored_rows().await?; + let digest = "aa".repeat(32); + + // [a] The whole network's decrypt, with the digest an immutable source + // carries. + let network = h.network.clone(); + let source = content_source(&h, &network, "immutable", None, Some(&digest)); + let outcome = h.register_source(policy_window(), source, &network).await?; + assert_refused_and_unstored(&h, outcome, KEY_PATH_REQUIRED, before).await?; + + // [b] A mutable source without a key path. + let source = content_source(&h, &network, "mutable", None, None); + let outcome = h.register_source(policy_window(), source, &network).await?; + assert_refused_and_unstored(&h, outcome, KEY_PATH_REQUIRED, before).await?; + + // [c] An immutable source bound to its key path, without the key digest. + let outcome = h.register_source(policy_window(), bound, &leaf).await?; + assert_refused_and_unstored(&h, outcome, KEY_DIGEST_REQUIRED, before).await +} +on_both_backends!(unbound_content_sources_are_refused_and_nothing_is_stored); + +/// A registration stored before the refusal keeps opening: the same pathless +/// source is refused as a new registration, and once stored it answers a +/// challenge, mints, reads, passes the decrypt gate, and a replay of the +/// identical registration is still accepted. +async fn a_stored_legacy_registration_still_opens(backend: Backend) -> anyhow::Result<()> { + let Some((h, _ledger)) = boot(backend, "a_stored_legacy_registration_still_opens").await? + else { + return Ok(()); + }; + h.grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let network = h.network.clone(); + let source = content_source(&h, &network, "immutable", None, Some(&"aa".repeat(32))); + let (body, legacy) = h + .registration_request(policy_window(), source, &network) + .await?; + + let before = h.stored_rows().await?; + let refused = h + .client + .post("/policy/v3/policies") + .header(rocket::http::ContentType::JSON) + .body(body.to_string()) + .dispatch() + .await; + assert_eq!(refused.status(), Status::BadRequest); + assert_eq!(h.stored_rows().await?, before); + + h.store_registration(body.clone()).await?; + assert_eq!(h.stored_rows().await?.0, before.0 + 1); + + let session = h.mint_ok(&legacy).await?; + let (status, content) = h.read(&session).await?; + assert_eq!( + (status, text(&content)), + (Status::Ok, "tc-1423-content".to_owned()) + ); + // The decrypt route admits the invocation (a policy refusal would be a + // `403` naming the policy); it stops at the empty request body. + let (status, answer) = h.decrypt(&session).await?; + assert_ne!(status, Status::Unauthorized, "{answer}"); + assert!(!answer.contains("policy"), "{answer}"); + + let replay = h + .client + .post("/policy/v3/policies") + .header(rocket::http::ContentType::JSON) + .body(body.to_string()) + .dispatch() + .await; + assert_eq!(replay.status(), Status::Ok); + assert_eq!(h.stored_rows().await?.0, before.0 + 1); + Ok(()) +} +on_both_backends!(a_stored_legacy_registration_still_opens); + +/// An exact path-bound registration (`share/`, with its digest) is +/// stored, and the policy admits decrypt on that leaf only: not the network, +/// not a sibling key. The same share without its key path is refused. +async fn an_exact_path_bound_registration_succeeds_and_admits_only_its_leaf( + backend: Backend, +) -> anyhow::Result<()> { + let Some((h, _ledger)) = boot( + backend, + "an_exact_path_bound_registration_succeeds_and_admits_only_its_leaf", + ) + .await? + else { + return Ok(()); + }; + h.grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let before = h.stored_rows().await?; + + let (source, leaf) = bound_source(&h, "immutable", Some(&"aa".repeat(32))); + grant_owner_key(&h, &leaf).await?; + let (status, body, bound) = h.register_source(policy_window(), source, &leaf).await?; + assert_eq!(status, Status::Ok, "{body}"); + assert_eq!(h.stored_rows().await?.0, before.0 + 1); + + assert_eq!(challenge_decrypt(&h, &bound, &leaf).await?.0, Status::Ok); + for refused in [ + h.default_network(), + format!("{}/share/other", h.default_network()), + ] { + let (status, body) = challenge_decrypt(&h, &bound, &refused).await?; + assert!( + status.class().is_client_error(), + "{refused}: {status} {body}" + ); + } + + // The key digest is only the immutable source's rule: a mutable source + // bound to its key path registers without one. + let (source, leaf) = bound_source(&h, "mutable", None); + grant_owner_key(&h, &leaf).await?; + let (status, body, _) = h.register_source(policy_window(), source, &leaf).await?; + assert_eq!(status, Status::Ok, "{body}"); + + // The same kind of source without a key path is the unbound shape. + let rows = h.stored_rows().await?; + let network = h.network.clone(); + let unbound = content_source(&h, &network, "mutable", None, None); + let outcome = h + .register_source(policy_window(), unbound, &network) + .await?; + assert_refused_and_unstored(&h, outcome, KEY_PATH_REQUIRED, rows).await +} +on_both_backends!(an_exact_path_bound_registration_succeeds_and_admits_only_its_leaf); + +/// The refusal reads the content source only: a policy whose credential +/// requirement is not an email proof is refused for the same three shapes, and +/// its path-bound form registers. +async fn a_non_email_target_is_refused_the_same_way(backend: Backend) -> anyhow::Result<()> { + let Some((h, _ledger)) = boot(backend, "a_non_email_target_is_refused_the_same_way").await? + else { + return Ok(()); + }; + h.grant_owner(Window::until(now() + Duration::days(2))) + .await?; + let (bound, leaf) = bound_source(&h, "immutable", Some(&"aa".repeat(32))); + let (no_digest, no_digest_leaf) = bound_source(&h, "immutable", None); + grant_owner_key(&h, &leaf).await?; + grant_owner_key(&h, &no_digest_leaf).await?; + let before = h.stored_rows().await?; + let network = h.network.clone(); + let requirement = h.non_email_requirement(); + + let pathless = content_source(&h, &network, "immutable", None, Some(&"aa".repeat(32))); + let mutable = content_source(&h, &network, "mutable", None, None); + for (source, decrypt, reason) in [ + (pathless, network.as_str(), KEY_PATH_REQUIRED), + (mutable, network.as_str(), KEY_PATH_REQUIRED), + (no_digest, no_digest_leaf.as_str(), KEY_DIGEST_REQUIRED), + ] { + let outcome = h + .register_source_for(policy_window(), source, decrypt, requirement.clone()) + .await?; + assert_refused_and_unstored(&h, outcome, reason, before).await?; + } + + let (status, body, _) = h + .register_source_for(policy_window(), bound, &leaf, requirement) + .await?; + assert_eq!(status, Status::Ok, "{body}"); + assert_eq!(h.stored_rows().await?.0, before.0 + 1); + Ok(()) +} +on_both_backends!(a_non_email_target_is_refused_the_same_way); From 3d80b4ab6ace408f978f745a41402887032673f7 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:48:53 +0000 Subject: [PATCH 2/3] fix(policy-v3): require the key digest of every new path-bound registration. Details: TC-2242 --- CHANGELOG.md | 2 +- tinycloud-node-server/src/policy_v3.rs | 11 +++--- .../src/policy_v3/unbound_source_tests.rs | 35 +++++++++++++++---- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e29ef07c..25dbc641 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Changes - Hook SSE subscriptions emit an authorized `ready` control event and close after broadcast lag. Successful SQL and DuckDB writes publish notifications keyed by the signed invocation CID; reads and failed writes emit none, and webhook enqueue remains independent. Details: TC-2207 - Fresh SQL artifacts use a full checkpoint directly instead of attempting an unavailable WAL-delta update. Details: TC-2207 -- New Policy/v3 registrations must name their share key (`encryptionKeyPath`) and, for immutable content, its digest; registrations already stored are unchanged. Details: TC-2242 +- New Policy/v3 registrations must name their share key (`encryptionKeyPath`) and its digest (`encryptedSymmetricKeyDigestHex`); registrations already stored are unchanged. Details: TC-2242 ## [1.25.12] - 2026-10-29 diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 1286bd24..a9bc490c 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -4533,9 +4533,10 @@ struct RegistrationProjections { } /// A new registration's content source names the one share key it opens -/// (`encryptionKeyPath`) and, when immutable, that key's digest. This runs -/// only when a registration is stored; `registration_projections` also -/// validates stored registrations, which keep their original shape. +/// (`encryptionKeyPath`) and that key's digest (`encryptedSymmetricKeyDigestHex`), +/// mutable or immutable. This runs only when a registration is stored; +/// `registration_projections` also validates stored registrations, which keep +/// their original shape. fn require_bound_content_source(policy: &Value) -> Result<(), (Status, String)> { let source = policy .get("contentSource") @@ -4547,9 +4548,7 @@ fn require_bound_content_source(policy: &Value) -> Result<(), (Status, String)> "content-source-key-path-required".into(), )); } - if source.get("mode").and_then(Value::as_str) == Some("immutable") - && !source.contains_key("encryptedSymmetricKeyDigestHex") - { + if !source.contains_key("encryptedSymmetricKeyDigestHex") { return Err(( Status::BadRequest, "content-source-key-digest-required".into(), diff --git a/tinycloud-node-server/src/policy_v3/unbound_source_tests.rs b/tinycloud-node-server/src/policy_v3/unbound_source_tests.rs index ee4dc12a..5d66b159 100644 --- a/tinycloud-node-server/src/policy_v3/unbound_source_tests.rs +++ b/tinycloud-node-server/src/policy_v3/unbound_source_tests.rs @@ -1,9 +1,9 @@ //! TC-2242: a new Policy/v3 registration must bind its content source to one //! share key. A registration without `encryptionKeyPath` (mutable or -//! immutable) or an immutable one without `encryptedSymmetricKeyDigestHex` is -//! refused at the registration route and stores nothing. A registration that -//! is already stored keeps validating, so links minted before the refusal -//! still open. +//! immutable), or without `encryptedSymmetricKeyDigestHex` (path-bound, +//! mutable or immutable), is refused at the registration route and stores +//! nothing. A registration that is already stored keeps validating, so links +//! minted before the refusal still open. use super::test_harness::*; use super::*; @@ -270,9 +270,8 @@ async fn an_exact_path_bound_registration_succeeds_and_admits_only_its_leaf( ); } - // The key digest is only the immutable source's rule: a mutable source - // bound to its key path registers without one. - let (source, leaf) = bound_source(&h, "mutable", None); + // A mutable source bound to its key path registers, with its digest. + let (source, leaf) = bound_source(&h, "mutable", Some(&"aa".repeat(32))); grant_owner_key(&h, &leaf).await?; let (status, body, _) = h.register_source(policy_window(), source, &leaf).await?; assert_eq!(status, Status::Ok, "{body}"); @@ -288,6 +287,28 @@ async fn an_exact_path_bound_registration_succeeds_and_admits_only_its_leaf( } on_both_backends!(an_exact_path_bound_registration_succeeds_and_admits_only_its_leaf); +/// The key digest is required of every path-bound source, mutable or +/// immutable: a mutable source bound to its key path without +/// `encryptedSymmetricKeyDigestHex` is refused, and nothing is stored. +async fn a_path_bound_mutable_source_without_the_key_digest_is_refused( + backend: Backend, +) -> anyhow::Result<()> { + let Some((h, _ledger)) = boot( + backend, + "a_path_bound_mutable_source_without_the_key_digest_is_refused", + ) + .await? + else { + return Ok(()); + }; + let (source, leaf) = bound_source(&h, "mutable", None); + grant_owner_key(&h, &leaf).await?; + let before = h.stored_rows().await?; + let outcome = h.register_source(policy_window(), source, &leaf).await?; + assert_refused_and_unstored(&h, outcome, KEY_DIGEST_REQUIRED, before).await +} +on_both_backends!(a_path_bound_mutable_source_without_the_key_digest_is_refused); + /// The refusal reads the content source only: a policy whose credential /// requirement is not an email proof is refused for the same three shapes, and /// its path-bound form registers. From bdbdc6d360c9b0a6f86b2edc2bd31d1856b58e03 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:20:41 +0000 Subject: [PATCH 3/3] test(policy-v3): adapt #485 tests to the merged named-key checks. Details: TC-2242 --- tinycloud-node-server/src/policy_v3.rs | 6 +-- .../src/policy_v3/named_keys_gate_tests.rs | 15 ++++--- .../src/policy_v3/test_harness.rs | 36 +++++++++++++---- .../src/policy_v3/unbound_source_tests.rs | 40 ++++++++++++++----- 4 files changed, 70 insertions(+), 27 deletions(-) diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index d017e333..a1f946cb 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -8805,7 +8805,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 @@ -8813,7 +8813,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 @@ -8821,7 +8821,7 @@ 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 } #[derive(Clone, Copy, PartialEq, Eq)] diff --git a/tinycloud-node-server/src/policy_v3/named_keys_gate_tests.rs b/tinycloud-node-server/src/policy_v3/named_keys_gate_tests.rs index 7bcaf8c0..b8cdcfe8 100644 --- a/tinycloud-node-server/src/policy_v3/named_keys_gate_tests.rs +++ b/tinycloud-node-server/src/policy_v3/named_keys_gate_tests.rs @@ -873,8 +873,8 @@ enum SourceDigest { Missing, } -struct ShareDecrypt { - h: Harness, +pub(super) struct ShareDecrypt { + pub(super) h: Harness, session: String, public_key: Vec, wrapped: Vec, @@ -888,7 +888,10 @@ impl ShareDecrypt { Self::with_source(Some(TC2192_KEY_PATH), "immutable").await } - async fn with_source(key_path: Option<&'static str>, mode: &str) -> anyhow::Result { + pub(super) async fn with_source( + key_path: Option<&'static str>, + mode: &str, + ) -> anyhow::Result { let digest_format = if key_path.is_some() { SourceDigest::RawWrappedBytes } else { @@ -980,7 +983,7 @@ impl ShareDecrypt { }) } - fn body(&self) -> Value { + pub(super) fn body(&self) -> Value { use tinycloud_core::encryption_network::{canonical::canonical_hash, DECRYPT_REQUEST_TYPE}; let wrapped = json!(base64::encode(&self.wrapped)); let receiver = json!(base64::encode( @@ -1003,7 +1006,7 @@ impl ShareDecrypt { body } - async fn invoke( + pub(super) async fn invoke( &self, resource: &str, body: &Value, @@ -1072,7 +1075,7 @@ impl ShareDecrypt { Ok((status, body)) } - fn leaf(&self) -> String { + pub(super) fn leaf(&self) -> String { match self.key_path { Some(path) => format!("{}/{path}", self.h.network), None => self.h.network.clone(), diff --git a/tinycloud-node-server/src/policy_v3/test_harness.rs b/tinycloud-node-server/src/policy_v3/test_harness.rs index 66ddae2a..73ff24a4 100644 --- a/tinycloud-node-server/src/policy_v3/test_harness.rs +++ b/tinycloud-node-server/src/policy_v3/test_harness.rs @@ -657,10 +657,7 @@ impl Harness { } /// Register a policy over the content whose roots span `root`, signed - /// by the policy owner. TC-2242: the policy has no `encryptionKeyPath`, a - /// shape the route refuses for a new registration, so it is stored as a - /// registration that predates the refusal. The tests built on it are - /// about what a stored registration does. + /// by the policy owner. pub async fn register(&self, root: Window) -> anyhow::Result<(Status, String, Registered)> { self.register_with(root, |_| {}).await } @@ -822,8 +819,19 @@ impl Harness { "nativeProjectionHashHex": projections.native_projection_hash_hex, "attestedEnforcerBinding": binding, }); - let stored = Self::store_registration_on(&self.client, registration).await?; - let (status, body) = (Status::Ok, serde_json::to_string(&stored)?); + // TC-2242: the route refuses a source without `encryptionKeyPath` for a + // new registration. The tests that register one are about what a + // stored registration does, so it is stored as one; path-bound + // sources still go through the route. + let (status, body) = if registration["policy"]["contentSource"] + .get("encryptionKeyPath") + .is_none() + { + let stored = Self::store_registration_on(&self.client, registration).await?; + (Status::Ok, serde_json::to_string(&stored)?) + } else { + self.post_registration(registration).await + }; Ok(( status, body, @@ -1375,8 +1383,17 @@ impl Harness { .digest(&canonical_json_value(&policy)), ) .to_string(); + // A source the registration's own validation refuses (such as a + // path-bound one without its digest) has no projections. The route + // refuses it before it reads these fields, so placeholders stand in + // and the request still reaches that refusal. let projections = - registration_projections(&policy).map_err(|(_, error)| anyhow::anyhow!(error))?; + registration_projections(&policy).unwrap_or_else(|_| RegistrationProjections { + content_source_digest_hex: "00".repeat(32), + capability_ceiling_hash_hex: "00".repeat(32), + native_projection_hash_hex: "00".repeat(32), + attenuation: json!({}), + }); let digest = policy_digest_hex(&policy).map_err(|(_, error)| anyhow::anyhow!(error))?; let common = json!({ "ownerDid": owner_did, @@ -1472,6 +1489,11 @@ impl Harness { policy_cid, policy_root_cid, enforcement_root_cid, + requested_capabilities: { + let mut requested = self.requested(); + requested[1]["resource"] = json!(decrypt_resource); + requested + }, }, )) } diff --git a/tinycloud-node-server/src/policy_v3/unbound_source_tests.rs b/tinycloud-node-server/src/policy_v3/unbound_source_tests.rs index 5d66b159..4a5e0cff 100644 --- a/tinycloud-node-server/src/policy_v3/unbound_source_tests.rs +++ b/tinycloud-node-server/src/policy_v3/unbound_source_tests.rs @@ -5,12 +5,15 @@ //! nothing. A registration that is already stored keeps validating, so links //! minted before the refusal still open. +use super::named_keys_gate_tests::ShareDecrypt; use super::test_harness::*; use super::*; use serde_json::json; const KEY_PATH_REQUIRED: &str = "content-source-key-path-required"; -const KEY_DIGEST_REQUIRED: &str = "content-source-key-digest-required"; +/// What the registration's own validation answers for a path-bound source +/// without its key digest, before the new-registration check runs. +const CONTENT_SOURCE_INVALID: &str = "content-source-invalid"; const KV_GET: &str = "tinycloud.kv/get"; const DECRYPT: &str = "tinycloud.encryption/decrypt"; @@ -174,14 +177,14 @@ async fn unbound_content_sources_are_refused_and_nothing_is_stored( // [c] An immutable source bound to its key path, without the key digest. let outcome = h.register_source(policy_window(), bound, &leaf).await?; - assert_refused_and_unstored(&h, outcome, KEY_DIGEST_REQUIRED, before).await + assert_refused_and_unstored(&h, outcome, CONTENT_SOURCE_INVALID, before).await } on_both_backends!(unbound_content_sources_are_refused_and_nothing_is_stored); /// A registration stored before the refusal keeps opening: the same pathless /// source is refused as a new registration, and once stored it answers a -/// challenge, mints, reads, passes the decrypt gate, and a replay of the -/// identical registration is still accepted. +/// challenge, mints, reads, and a replay of the identical registration is +/// still accepted. async fn a_stored_legacy_registration_still_opens(backend: Backend) -> anyhow::Result<()> { let Some((h, _ledger)) = boot(backend, "a_stored_legacy_registration_still_opens").await? else { @@ -215,11 +218,6 @@ async fn a_stored_legacy_registration_still_opens(backend: Backend) -> anyhow::R (status, text(&content)), (Status::Ok, "tc-1423-content".to_owned()) ); - // The decrypt route admits the invocation (a policy refusal would be a - // `403` naming the policy); it stops at the empty request body. - let (status, answer) = h.decrypt(&session).await?; - assert_ne!(status, Status::Unauthorized, "{answer}"); - assert!(!answer.contains("policy"), "{answer}"); let replay = h .client @@ -305,7 +303,7 @@ async fn a_path_bound_mutable_source_without_the_key_digest_is_refused( grant_owner_key(&h, &leaf).await?; let before = h.stored_rows().await?; let outcome = h.register_source(policy_window(), source, &leaf).await?; - assert_refused_and_unstored(&h, outcome, KEY_DIGEST_REQUIRED, before).await + assert_refused_and_unstored(&h, outcome, CONTENT_SOURCE_INVALID, before).await } on_both_backends!(a_path_bound_mutable_source_without_the_key_digest_is_refused); @@ -332,7 +330,7 @@ async fn a_non_email_target_is_refused_the_same_way(backend: Backend) -> anyhow: for (source, decrypt, reason) in [ (pathless, network.as_str(), KEY_PATH_REQUIRED), (mutable, network.as_str(), KEY_PATH_REQUIRED), - (no_digest, no_digest_leaf.as_str(), KEY_DIGEST_REQUIRED), + (no_digest, no_digest_leaf.as_str(), CONTENT_SOURCE_INVALID), ] { let outcome = h .register_source_for(policy_window(), source, decrypt, requirement.clone()) @@ -348,3 +346,23 @@ async fn a_non_email_target_is_refused_the_same_way(backend: Backend) -> anyhow: Ok(()) } on_both_backends!(a_non_email_target_is_refused_the_same_way); + +/// A registration stored before the refusal still decrypts: a legacy pathless +/// Share source opens through `/invoke` with a well-formed decrypt body, while +/// the same shape is refused as a new registration. +#[tokio::test] +async fn a_stored_legacy_registration_still_decrypts() -> anyhow::Result<()> { + let share = ShareDecrypt::with_source(None, "immutable").await?; + let h = &share.h; + let network = h.network.clone(); + let source = content_source(h, &network, "immutable", None, Some(&"aa".repeat(32))); + let before = h.stored_rows().await?; + let outcome = h.register_source(policy_window(), source, &network).await?; + assert_refused_and_unstored(h, outcome, KEY_PATH_REQUIRED, before).await?; + + let (status, response) = share + .invoke(&share.leaf(), &share.body(), None, Value::Null) + .await?; + assert_eq!(status, Status::Ok, "{response}"); + Ok(()) +}