From 605ae3ea9b8f8ea79314d61bea284e2cf9414ad6 Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 19:40:36 +0000 Subject: [PATCH 1/2] encryption: serve named-key routes and resources only when named keys are enabled Details: TC-1777 --- CHANGELOG.md | 1 + tinycloud-core/src/db.rs | 18 +- .../src/encryption_network/key_id.rs | 9 + tinycloud-core/src/models/delegation.rs | 64 ++++- tinycloud-core/src/types/resource.rs | 6 + tinycloud-node-server/src/config.rs | 8 +- tinycloud-node-server/src/lib.rs | 67 +++++- tinycloud-node-server/src/policy_v3.rs | 4 + .../src/policy_v3/named_keys_gate_tests.rs | 220 ++++++++++++++++++ tinycloud-node-server/src/routes/mod.rs | 28 ++- .../src/routes/tests/encryption_keys.rs | 153 +++++++++++- tinycloud.toml | 6 +- 12 files changed, 559 insertions(+), 25 deletions(-) create mode 100644 tinycloud-node-server/src/policy_v3/named_keys_gate_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b71cf765..b9d4ab67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changes +- Named-key routes and key resources are served only when [global.encryption_network] named_keys_v1 is enabled. Details: TC-1777. - The node logs one structured diagnostic line (status, code, service, route, elapsed time, failure kind, a keyed database pseudonym `db_hash`) when a request ends in a database failure or a busy/timeout 5xx. Details: TC-1796. - Log sidecar: the node's one warning line per database-failure 5xx (`database failure answered with a 5xx`) ships to Better Stack as an individual `node_warning` event with `event_code` `db_failure_5xx` and exactly eight fields: `status`, `code`, `service`, `route`, `elapsed_ms`, `failure_kind`, `write_may_have_applied` and, when known, `db_hash`. They are fixed vocabulary, the route template, a duration, a flag and a keyed database pseudonym; no SQL, ids, DIDs, paths or messages. A line with any other field or an unexpected value is counted as `unclassified` as before. Details: TC-1840. - **N0 compose.** The embedded Vector configuration is regenerated for the new event. The reviewed N0 compose template changes to `7c3456dc…` (raw `d0e72bde…`; `docs/releasing.md`). diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 9912837a..6cbc4b40 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -166,6 +166,7 @@ pub struct SpaceDatabase { encryption: Option, sql_sizes: SqlSizes, delegated_sql_caveats: bool, + named_keys: bool, revocation_chain_locks: ChainLockRegistry, kv_object_locks: KvObjectLockRegistry, space_sequence_locks: SpaceSequenceLockRegistry, @@ -425,6 +426,7 @@ where encryption, sql_sizes: SqlSizes::default(), delegated_sql_caveats: false, + named_keys: false, revocation_chain_locks: Arc::new(tokio::sync::Mutex::new(HashMap::new())), kv_object_locks: Arc::new(tokio::sync::Mutex::new(HashMap::new())), space_sequence_locks: Arc::new(tokio::sync::Mutex::new(HashMap::new())), @@ -444,6 +446,14 @@ where self } + /// Serve named-key resources (keys and key prefixes): delegations that + /// grant on them are admitted. Off by default: such a delegation is + /// refused with `named_keys_unavailable`. + pub fn with_named_keys(mut self, enabled: bool) -> Self { + self.named_keys = enabled; + self + } + pub fn with_sql_sizes(mut self, sql_sizes: SqlSizes) -> Self { self.sql_sizes = sql_sizes; self @@ -1681,6 +1691,7 @@ where None, &[], self.delegated_sql_caveats, + self.named_keys, ) .await?; @@ -1972,6 +1983,7 @@ where None, &[], self.delegated_sql_caveats, + self.named_keys, ) .await } @@ -2447,6 +2459,7 @@ where // Taken on this `tx` right after BEGIN, above. &sequence_spaces, self.delegated_sql_caveats, + self.named_keys, ) .await?; @@ -3335,6 +3348,7 @@ pub(crate) async fn transact( auth_graph: Option<&crate::auth_graph::AuthGraphSnapshot>, sequence_locks_held: &[SpaceId], delegated_sql_caveats: bool, + named_keys: bool, ) -> Result> { // for each event, get the hash and the relevent space(s) let event_hashes = events @@ -3359,7 +3373,9 @@ pub(crate) async fn transact( let mut registered: HashMap = HashMap::new(); for (hash, event) in &event_hashes { if let Event::Delegation(d) = event { - let reg = delegation::process_with(db, d, encryption, delegated_sql_caveats).await?; + let reg = + delegation::process_with(db, d, encryption, delegated_sql_caveats, named_keys) + .await?; registered.insert(*hash, reg); } } diff --git a/tinycloud-core/src/encryption_network/key_id.rs b/tinycloud-core/src/encryption_network/key_id.rs index 30562edc..7f374661 100644 --- a/tinycloud-core/src/encryption_network/key_id.rs +++ b/tinycloud-core/src/encryption_network/key_id.rs @@ -165,6 +165,15 @@ pub(crate) fn owned_by(uri: &str, principal: &str) -> bool { parse(uri).is_ok_and(|resource| did_principal_matches(resource.owner_did(), principal)) } +/// Whether `uri` names a key or a key prefix (not a bare network). A string +/// that does not parse as an encryption resource is not one. +pub fn is_named_key(uri: &str) -> bool { + matches!( + parse(uri), + Ok(EncryptionResource::Key(_) | EncryptionResource::KeyPrefix(_)) + ) +} + fn is_below(ancestor: &str, path: &str) -> bool { path.strip_prefix(ancestor) .is_some_and(|rest| rest.starts_with('/')) diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index f23ee7bd..e9f93e43 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -187,6 +187,10 @@ pub enum DelegationError { /// Key deletion is never delegated. Code: `key-delete-not-delegable`. #[error("key-delete-not-delegable")] KeyDeleteNotDelegable, + /// Named-key resources (keys and key prefixes) are served only while the + /// node's named-keys setting is on. + #[error("named_keys_unavailable: named keys are not enabled on this node")] + NamedKeysUnavailable, } /// Verify, validate, and register a delegation inside the caller's transaction. @@ -200,22 +204,24 @@ pub(crate) async fn process( delegation: &Delegation, encryption: Option<&ColumnEncryption>, ) -> Result { - process_with(db, delegation, encryption, false).await + process_with(db, delegation, encryption, false, true).await } /// As `process`; with `delegated_sql_caveats` a SQL capability may carry the -/// delegated `tables` / `columns` / `readOnly` caveat form (TC-1791). +/// delegated `tables` / `columns` / `readOnly` caveat form (TC-1791). Without +/// `named_keys` a capability on a key or key prefix is refused. pub(crate) async fn process_with( db: &C, delegation: &Delegation, encryption: Option<&ColumnEncryption>, delegated_sql_caveats: bool, + named_keys: bool, ) -> Result { verify(&delegation.0.delegation).await?; for space in delegation.0.spaces() { crate::request_telemetry::observe_space(space); } - validate_with(db, &delegation.0, delegated_sql_caveats).await?; + validate_with(db, &delegation.0, delegated_sql_caveats, named_keys).await?; save(db, delegation.0.clone(), delegation.1.clone(), encryption).await } @@ -337,14 +343,26 @@ async fn validate( db: &C, delegation: &util::DelegationInfo, ) -> Result<(), Error> { - validate_with(db, delegation, false).await + validate_with(db, delegation, false, true).await } async fn validate_with( db: &C, delegation: &util::DelegationInfo, delegated_sql_caveats: bool, + named_keys: bool, ) -> Result<(), Error> { + // Without named keys, a capability on a key or key prefix is refused for + // every delegation, before the root-authority filter, so a delegation + // issued by the key's owner is covered too. + if !named_keys + && delegation + .capabilities + .iter() + .any(|cap| cap.resource.is_named_key()) + { + return Err(DelegationError::NamedKeysUnavailable.into()); + } // TC-1791: SQL and DuckDB caveats are checked for every delegation, // before the root-authority filter. for cap in &delegation.capabilities { @@ -1692,6 +1710,44 @@ mod tests { )); } + /// Without named keys, a grant on a key or a key prefix is refused for + /// the key's owner and for anyone else, before any parent is looked up; + /// a grant on the bare network is not affected. + #[tokio::test] + async fn named_key_grants_are_refused_when_named_keys_are_off() { + let db = database().await; + let key = KEY_RESOURCE.to_string(); + let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/"); + let network = format!("urn:tinycloud:encryption:{KEY_OWNER}:default"); + for delegator in [KEY_OWNER, OTHER_DID] { + for resource in [&key, &prefix] { + let info = key_grant_info(delegator, resource, KEY_DECRYPT, &[]); + let refused = validate_with(&db, &info, false, false).await; + assert!( + matches!( + refused, + Err(Error::InvalidDelegation( + DelegationError::NamedKeysUnavailable + )) + ), + "{delegator} on {resource}: {refused:?}" + ); + } + } + assert_eq!( + DelegationError::NamedKeysUnavailable.to_string(), + "named_keys_unavailable: named keys are not enabled on this node" + ); + let info = key_grant_info(KEY_OWNER, &network, KEY_DECRYPT, &[]); + validate_with(&db, &info, false, false) + .await + .expect("the owner's bare network grant is not a named-key grant"); + let info = key_grant_info(KEY_OWNER, &key, KEY_DECRYPT, &[]); + validate_with(&db, &info, false, true) + .await + .expect("the same grant is admitted with named keys on"); + } + /// key.delete is refused even when the owner issues it. #[tokio::test] async fn owner_issued_key_delete_is_refused() { diff --git a/tinycloud-core/src/types/resource.rs b/tinycloud-core/src/types/resource.rs index 01e0589d..7a459764 100644 --- a/tinycloud-core/src/types/resource.rs +++ b/tinycloud-core/src/types/resource.rs @@ -34,6 +34,12 @@ impl Resource { } } + /// Whether this resource names a key or a key prefix under an encryption + /// network; a bare network is not one. + pub fn is_named_key(&self) -> bool { + matches!(self, Resource::Other(uri) if key_id::is_named_key(uri.as_str())) + } + /// `child.extends(&parent)` is true when `parent` covers `child`. /// The receiver is the requested (child) resource; the argument is the grant. pub fn extends(&self, other: &Self) -> bool { diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index 0dd87414..e2deada2 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -62,8 +62,12 @@ pub struct Config { } /// Release gates for the encryption network routes. `named_keys_v1` -/// advertises the `encryption-named-keys-v1` feature in `/info`; it stays off -/// until named keys are live on OpenKey. +/// advertises the `encryption-named-keys-v1` feature in `/info` and turns on +/// named keys: the key create, describe, public key, block and unblock +/// routes are mounted, delegations may grant on key and key-prefix +/// resources, and named-key decrypt is served on `POST /invoke`. It stays off +/// until named keys are live on OpenKey; while off, those routes answer 404 +/// and key resources are refused with `named_keys_unavailable`. #[derive(Serialize, Deserialize, Debug, Default, Clone, Hash, PartialEq, Eq)] pub struct EncryptionNetworkConfig { #[serde(default)] diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index 1f52b2d6..b0c3982a 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -580,11 +580,23 @@ pub fn resolve_runtime_config(tinycloud_config: &Config) -> Result { Ok(resolved) } +/// The named-key routes. Mounted only while `named_keys_v1` is on, so with it +/// off these paths answer as any unmounted path does. +fn named_key_routes() -> Vec { + rocket::routes![ + create_encryption_key, + describe_encryption_key, + public_encryption_key, + block_encryption_key, + unblock_encryption_key, + ] +} + /// The public Node surface keeps policy admission separate from the generic /// delegation and invocation data plane. Share-specific data routes are not /// mounted. -fn production_routes() -> Vec { - rocket::routes![ +fn production_routes(named_keys_v1: bool) -> Vec { + let mut routes = rocket::routes![ healthcheck, cors, info, @@ -619,11 +631,6 @@ fn production_routes() -> Vec { encryption_well_known, encryption_decrypt, revoke_encryption_network, - create_encryption_key, - describe_encryption_key, - public_encryption_key, - block_encryption_key, - unblock_encryption_key, register_policy_v3, get_policy_v3_status, policy_v3_enforcer_binding, @@ -632,7 +639,11 @@ fn production_routes() -> Vec { policy_v3_mint, policy_v3_status, revoke_policy_v3_root, - ] + ]; + if named_keys_v1 { + routes.extend(named_key_routes()); + } + routes } pub async fn app_with_control( @@ -682,7 +693,7 @@ pub async fn app_with_control( ::tracing::warn!("network DID resolution enabled: did:web/did:tz fetches use the unhardened ssi client (TC-1080)"); } - let routes = production_routes(); + let routes = production_routes(tinycloud_config.encryption_network.named_keys_v1); #[cfg(feature = "tc-bench-v1")] let mut routes = routes; @@ -810,7 +821,8 @@ pub async fn app_with_control( ) .await? .with_sql_sizes(sql_sizes.clone()) - .with_delegated_sql_caveats(tinycloud_config.sql.delegated_caveats_enforced); + .with_delegated_sql_caveats(tinycloud_config.sql.delegated_caveats_enforced) + .with_named_keys(tinycloud_config.encryption_network.named_keys_v1); tinycloud.set_read_audit_wait(Duration::from_millis( tinycloud_config.database.read_audit_wait_ms, )); @@ -1265,11 +1277,11 @@ fn is_mounted_share_path(path: &str) -> bool { #[cfg(test)] mod production_route_tests { - use super::production_routes; + use super::{named_key_routes, production_routes}; #[test] fn generic_data_plane_and_embedded_policy_admission_are_mounted_without_share_data_routes() { - let routes: std::collections::HashSet<_> = production_routes() + let routes: std::collections::HashSet<_> = production_routes(true) .into_iter() .map(|route| format!("{} {}", route.method, route.uri)) .collect(); @@ -1308,6 +1320,37 @@ mod production_route_tests { "production routes must not mount any /share/* handler" ); } + + /// The five key routes are mounted only with `named_keys_v1` on; the + /// network routes are mounted either way. + #[test] + fn named_key_routes_are_mounted_only_when_named_keys_are_on() { + let describe = |routes: Vec| -> Vec { + routes + .into_iter() + .map(|route| format!("{} {}", route.method, route.uri)) + .collect() + }; + let key_routes = describe(named_key_routes()); + assert_eq!(key_routes.len(), 5, "{key_routes:?}"); + let off = describe(production_routes(false)); + let on = describe(production_routes(true)); + for route in &key_routes { + assert!(!off.contains(route), "mounted while off: {route}"); + assert!(on.contains(route), "missing while on: {route}"); + } + for route in [ + "POST /encryption/networks", + "GET /encryption/networks/", + "POST /encryption/networks//decrypt", + "POST /encryption/networks//revoke", + ] { + let route = route.to_string(); + assert!(off.contains(&route), "missing while off: {route}"); + assert!(on.contains(&route), "missing while on: {route}"); + } + assert_eq!(on.len(), off.len() + 5); + } } /// One telemetry sample tick: update the DB pool gauges, probe pool-acquire diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 6aebd198..28337963 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -10778,6 +10778,10 @@ mod account_alias_tests; #[path = "policy_v3/test_harness.rs"] mod test_harness; +#[cfg(test)] +#[path = "policy_v3/named_keys_gate_tests.rs"] +mod named_keys_gate_tests; + #[cfg(test)] #[path = "policy_v3/root_revocation_tests.rs"] mod root_revocation_tests; 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 new file mode 100644 index 00000000..c3474d88 --- /dev/null +++ b/tinycloud-node-server/src/policy_v3/named_keys_gate_tests.rs @@ -0,0 +1,220 @@ +//! TC-1777: with `[global.encryption_network] named_keys_v1` off (the +//! default), the production app does not mount the named-key routes and +//! `/delegate` refuses delegations on keys and key prefixes, for UCAN and +//! SIWE (CACAO) delegations alike. Booted through `app_with_control`, so the +//! route set is the one a node serves. + +use super::test_harness::*; +use super::*; +use rocket::http::{ContentType, Header}; +use tinycloud_core::encryption_network::{CreateNetworkRequest, EncryptionService, Threshold}; + +const NAMED_KEYS_UNAVAILABLE: &str = + "named_keys_unavailable: named keys are not enabled on this node"; +const KEY_DECRYPT: &str = "tinycloud.encryption/decrypt"; +const KEY_CREATE: &str = "tinycloud.encryption/key.create"; + +/// Status, body and content type of a response. +async fn answer( + request: rocket::local::asynchronous::LocalRequest<'_>, +) -> (Status, String, String) { + let response = request.dispatch().await; + let status = response.status(); + let content_type = response + .content_type() + .map(|content_type| content_type.to_string()) + .unwrap_or_default(); + ( + status, + response.into_string().await.unwrap_or_default(), + content_type, + ) +} + +/// The four named-key routes answer exactly as an unmounted path does, and the +/// network routes still answer. +async fn named_key_routes_are_unmounted_and_network_routes_are_not( + backend: Backend, +) -> anyhow::Result<()> { + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "named_key_routes_are_unmounted_and_network_routes_are_not", + ) + .await? + else { + return Ok(()); + }; + let network = h + .client + .rocket() + .state::() + .expect("EncryptionService is managed") + .create_one_of_one_network(CreateNetworkRequest { + name: "net".to_string(), + owner_did: h.space_owner.did().to_string(), + threshold: Threshold::one_of_one(), + }) + .await + .map_err(|error| anyhow::anyhow!("{error}"))? + .network_id + .to_string(); + let json = || ContentType::JSON; + let authorization = || Header::new("Authorization", "not-a-token"); + let remote = || "127.0.0.1:0".parse().expect("socket address"); + + let unmounted_post = answer( + h.client + .post(format!("/encryption/networks/{network}/no-such-route")) + .header(json()) + .body("{}"), + ) + .await; + let unmounted_get = answer( + h.client + .get(format!( + "/encryption/networks/{network}/no-such-route/docs/a" + )) + .remote(remote()), + ) + .await; + assert_eq!(unmounted_post.0, Status::NotFound); + assert_eq!(unmounted_get.0, Status::NotFound); + + let key_routes = [ + ( + "create", + answer( + h.client + .post(format!("/encryption/networks/{network}/keys")) + .header(json()) + .header(authorization()) + .body("{}"), + ) + .await, + &unmounted_post, + ), + ( + "public key", + answer( + h.client + .get(format!("/encryption/networks/{network}/keys/docs/a")) + .remote(remote()), + ) + .await, + &unmounted_get, + ), + ( + "describe", + answer( + h.client + .get(format!("/encryption/networks/{network}/keys/docs/a")) + .header(authorization()) + .remote(remote()), + ) + .await, + &unmounted_get, + ), + ( + "block", + answer( + h.client + .post(format!("/encryption/networks/{network}/key-actions/block")) + .header(json()) + .header(authorization()) + .body("{}"), + ) + .await, + &unmounted_post, + ), + ( + "unblock", + answer( + h.client + .post(format!( + "/encryption/networks/{network}/key-actions/unblock" + )) + .header(json()) + .header(authorization()) + .body("{}"), + ) + .await, + &unmounted_post, + ), + ]; + for (label, got, unmounted) in key_routes { + assert_eq!(got.0, Status::NotFound, "{label}: {got:?}"); + assert_eq!(&got, unmounted, "{label} differs from an unmounted path"); + } + + // The network routes keep their own answers. + let (status, body, _) = answer(h.client.get(format!("/encryption/networks/{network}"))).await; + assert_eq!(status, Status::Ok, "{body}"); + let (status, body, _) = answer(h.client.get("/encryption/networks/not-a-network")).await; + assert_eq!(status, Status::BadRequest, "{body}"); + for path in ["decrypt", "revoke"] { + let (status, body, _) = answer( + h.client + .post(format!("/encryption/networks/{network}/{path}")) + .header(json()) + .header(authorization()) + .body("{}"), + ) + .await; + assert_ne!(status, Status::NotFound, "{path}: {body}"); + } + Ok(()) +} +on_both_backends!(named_key_routes_are_unmounted_and_network_routes_are_not); + +/// An owner-signed delegation on a key or a key prefix is refused with the +/// code and nothing is stored, for a did:key owner (UCAN) and a did:pkh +/// owner (SIWE CACAO); the owner's grant on the bare network is admitted. +async fn delegations_on_named_keys_are_refused(backend: Backend) -> anyhow::Result<()> { + for (shape, label) in [ + ( + Shape::KeySession, + "delegations_on_named_keys_are_refused_ucan", + ), + ( + Shape::PkhSession, + "delegations_on_named_keys_are_refused_cacao", + ), + ] { + let Some(h) = Harness::new(backend, shape, label).await? else { + return Ok(()); + }; + let window = || Window::until(now() + Duration::hours(1)); + let key = format!("{}/docs/a", h.network); + let prefix = format!("{}/docs/", h.network); + for (what, grants) in [ + ("key", vec![(key.clone(), KEY_DECRYPT)]), + ("prefix", vec![(prefix.clone(), KEY_DECRYPT)]), + ("key create", vec![(key.clone(), KEY_CREATE)]), + ( + "network and prefix", + vec![ + (h.network.clone(), KEY_DECRYPT), + (prefix.clone(), KEY_DECRYPT), + ], + ), + ] { + let authorization = h + .space_owner + .delegate(h.owner.did(), &grants, window(), &[])?; + let (status, body) = h.delegate(authorization).await; + assert_eq!(status, Status::Unauthorized, "{label} {what}: {body}"); + assert_eq!(body, NAMED_KEYS_UNAVAILABLE, "{label} {what}"); + } + let authorization = h.space_owner.delegate( + h.owner.did(), + &[(h.network.clone(), KEY_DECRYPT)], + window(), + &[], + )?; + let (status, body) = h.delegate(authorization).await; + assert_eq!(status, Status::Ok, "{label} bare network: {body}"); + } + Ok(()) +} +on_both_backends!(delegations_on_named_keys_are_refused); diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index d3ef5010..baf996b9 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -1795,6 +1795,25 @@ async fn invoke_impl( tinycloud_core::request_telemetry::observe_space(space); } + // Named keys are served only while `named_keys_v1` is on: with it + // off, an invocation on a key or key prefix is refused before any + // specialized dispatch. The bare network resource is unaffected. + if !config.encryption_network.named_keys_v1 + && admitted + .invocation() + .0 + .capabilities + .iter() + .any(|capability| capability.resource.is_named_key()) + { + return Err(( + Status::Forbidden, + tinycloud_core::models::delegation::DelegationError::NamedKeysUnavailable + .to_string(), + ) + .into()); + } + // TC-732: `kv/sync` must be the invocation's only capability and // target a KV resource. The KV root is the whole-space feed. Check // shape before any specialized dispatch so mixed invocations fail @@ -10291,7 +10310,8 @@ mod tests { StaticSecret::new(vec![0u8; 32]).unwrap(), ) .await? - .with_delegated_sql_caveats(config.sql.delegated_caveats_enforced); + .with_delegated_sql_caveats(config.sql.delegated_caveats_enforced) + .with_named_keys(config.encryption_network.named_keys_v1); let conn = db; space_model::ActiveModel { id: Set(SpaceIdWrap(space.clone())), @@ -10775,10 +10795,14 @@ mod tests { } /// [`Self::new`] with extra routes or managed state on the same rocket. + /// Named keys are on, so the fixture serves the key routes and + /// resources; use [`Self::new_configured`] for a node with them off. async fn new_with( extend: impl FnOnce(rocket::Rocket) -> rocket::Rocket, ) -> Result { - Self::new_configured(Config::default(), extend).await + let mut config = Config::default(); + config.encryption_network.named_keys_v1 = true; + Self::new_configured(config, extend).await } /// [`Self::new_with`] with `config` instead of the default. diff --git a/tinycloud-node-server/src/routes/tests/encryption_keys.rs b/tinycloud-node-server/src/routes/tests/encryption_keys.rs index 70154758..bfb3acf5 100644 --- a/tinycloud-node-server/src/routes/tests/encryption_keys.rs +++ b/tinycloud-node-server/src/routes/tests/encryption_keys.rs @@ -29,9 +29,20 @@ fn owner_did(fixture: &KvSyncFixture) -> String { .to_string() } -/// Fixture with the key routes mounted and one active 1-of-1 network. +/// Fixture with the key routes mounted, named keys on, and one active 1-of-1 +/// network. async fn setup() -> Result<(KvSyncFixture, String)> { - let fixture = KvSyncFixture::new_with(|rocket| { + setup_with(true).await +} + +/// As [`setup`], with `[encryption_network] named_keys_v1` set to +/// `named_keys`. The fixture mounts the key handlers either way; production +/// mounting is covered by `policy_v3::named_keys_gate_tests`. What this +/// setting changes here is what `/delegate` and `/invoke` accept. +async fn setup_with(named_keys: bool) -> Result<(KvSyncFixture, String)> { + let mut config = Config::default(); + config.encryption_network.named_keys_v1 = named_keys; + let fixture = KvSyncFixture::new_configured(config, |rocket| { rocket .mount( "/", @@ -2051,3 +2062,141 @@ async fn doubled_network_segment_in_a_key_url_is_a_bad_request() -> Result<()> { } Ok(()) } + +const NAMED_KEYS_UNAVAILABLE: &str = + "named_keys_unavailable: named keys are not enabled on this node"; + +/// `POST /delegate` of `caps` (resource, ability), signed by the fixture +/// owner with no proofs, to the fixture device. +async fn delegate_caps(fixture: &KvSyncFixture, caps: &[(&str, &str)]) -> Result<(Status, String)> { + use tinycloud_auth::ssi::{ + dids::{DIDBuf, DIDURLBuf}, + ucan::Payload, + }; + use tinycloud_auth::ucan_capabilities_object::Capabilities; + let mut capabilities = Capabilities::new(); + for (uri, action) in caps { + capabilities.with_action( + uri.parse()?, + action.parse::()?, + [std::collections::BTreeMap::::new()], + ); + } + let token = Payload { + issuer: fixture.owner_vm.parse::()?, + audience: fixture.device_did().parse::()?, + not_before: None, + expiration: NumericDate::try_from_seconds(4_102_444_800.0)?, + nonce: Some(fixture.next_nonce()), + facts: Some(Vec::::new()), + proof: vec![], + attenuation: capabilities, + } + .sign( + fixture.owner.get_algorithm().unwrap_or_default(), + &fixture.owner, + )? + .encode()?; + let response = fixture + .client + .post("/delegate") + .header(Header::new("Authorization", token)) + .dispatch() + .await; + let status = response.status(); + Ok((status, response.into_string().await.unwrap_or_default())) +} + +async fn stored_delegations(fixture: &KvSyncFixture) -> Result { + use tinycloud_core::sea_orm::{EntityTrait, PaginatorTrait}; + Ok(tinycloud_core::models::delegation::Entity::find() + .count(&fixture.conn) + .await?) +} + +/// With named keys off, `/delegate` refuses every capability on a key or a +/// key prefix, including a grant the key's owner signs with no proofs, and +/// stores nothing. A grant on the bare network is unaffected. +#[tokio::test] +async fn delegate_refuses_named_key_resources_when_named_keys_are_off() -> Result<()> { + let (fixture, network) = setup_with(false).await?; + let key = key_uri(&network, "docs/a"); + let prefix = format!("{network}/docs/"); + let before = stored_delegations(&fixture).await?; + let cases: [(&str, Vec<(&str, &str)>); 5] = [ + ("key", vec![(key.as_str(), DECRYPT_ACTION)]), + ("prefix", vec![(prefix.as_str(), DECRYPT_ACTION)]), + ("key create", vec![(key.as_str(), KEY_CREATE_ACTION)]), + ("prefix block", vec![(prefix.as_str(), KEY_BLOCK_ACTION)]), + ( + "network and key", + vec![ + (network.as_str(), DECRYPT_ACTION), + (key.as_str(), DECRYPT_ACTION), + ], + ), + ]; + for (label, caps) in cases { + let (status, text) = delegate_caps(&fixture, &caps).await?; + assert_eq!(status, Status::Unauthorized, "{label}: {text}"); + assert_eq!(text, NAMED_KEYS_UNAVAILABLE, "{label}"); + } + assert_eq!(stored_delegations(&fixture).await?, before); + + let (status, text) = delegate_caps(&fixture, &[(network.as_str(), DECRYPT_ACTION)]).await?; + assert_eq!(status, Status::Ok, "bare network grant: {text}"); + assert_eq!(stored_delegations(&fixture).await?, before + 1); + Ok(()) +} + +/// With named keys on, the same owner-signed grants on a key and on a key +/// prefix are admitted. +#[tokio::test] +async fn delegate_admits_named_key_resources_when_named_keys_are_on() -> Result<()> { + let (fixture, network) = setup().await?; + let key = key_uri(&network, "docs/a"); + let prefix = format!("{network}/docs/"); + for (label, resource) in [("key", key.as_str()), ("prefix", prefix.as_str())] { + let (status, text) = delegate_caps(&fixture, &[(resource, DECRYPT_ACTION)]).await?; + assert_eq!(status, Status::Ok, "{label}: {text}"); + } + Ok(()) +} + +/// With named keys off, `/invoke` answers 403 with the code for a decrypt +/// on a key or a key prefix, even for a key that exists; the bare network +/// decrypt on the same route is not refused with it. +#[tokio::test] +async fn invoke_refuses_named_key_resources_when_named_keys_are_off() -> Result<()> { + let (fixture, network) = setup_with(false).await?; + let key_public = created_key(&fixture, &network, "docs/a").await; + let case = DecryptCase::wrapped_to(&key_public); + let (status, text) = invoke_named(&fixture, &network, "docs/a", &case).await; + assert_eq!(status, Status::Forbidden, "{text}"); + assert_eq!(text, NAMED_KEYS_UNAVAILABLE); + + let prefix = format!("{network}/docs/"); + let (body, facts) = decrypt_request(&fixture, &network, &case, Some("docs/a")); + let auth = invocation( + &fixture, + &fixture.node_did, + &[(prefix.as_str(), DECRYPT_ACTION)], + vec![facts], + )?; + let (status, text) = invoke(&fixture, &auth, &body).await; + assert_eq!(status, Status::Forbidden, "{text}"); + assert_eq!(text, NAMED_KEYS_UNAVAILABLE); + + let root_public = root_public_key(&fixture, &network).await; + let case = DecryptCase::wrapped_to(&root_public); + let (body, facts) = decrypt_request(&fixture, &network, &case, None); + let auth = invocation( + &fixture, + &fixture.node_did, + &[(network.as_str(), DIRECT_DECRYPT_ABILITY)], + vec![facts], + )?; + let (status, text) = invoke(&fixture, &auth, &body).await; + assert_eq!(status, Status::Ok, "bare network decrypt: {text}"); + Ok(()) +} diff --git a/tinycloud.toml b/tinycloud.toml index c61e0785..504fcd2c 100644 --- a/tinycloud.toml +++ b/tinycloud.toml @@ -87,8 +87,10 @@ cors = true # network_methods = false [global.encryption_network] - ## Advertise the `encryption-named-keys-v1` feature in GET /info. - ## Keep off until named keys are live on OpenKey. Off = feature withheld. + ## Serve named keys and advertise the `encryption-named-keys-v1` feature in + ## GET /info. Only while on are the key routes mounted, key and key-prefix + ## resources accepted in delegations and named-key decrypt served on + ## POST /invoke. Keep off until named keys are live on OpenKey. ## Env: TINYCLOUD_ENCRYPTION_NETWORK__NAMED_KEYS_V1 # named_keys_v1 = false From 9248aa079c2f305037d81fa6cf22dcd3c0a5b6ea Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 19:52:32 +0000 Subject: [PATCH 2/2] changelog: state the named_keys_v1 condition on the named-key entries Details: TC-1777 --- CHANGELOG.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9d4ab67..3906cd06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,20 +4,20 @@ ### Changes -- Named-key routes and key resources are served only when [global.encryption_network] named_keys_v1 is enabled. Details: TC-1777. +- Named-key routes and key resources are served only when `[global.encryption_network] named_keys_v1` is enabled. Details: TC-1777. - The node logs one structured diagnostic line (status, code, service, route, elapsed time, failure kind, a keyed database pseudonym `db_hash`) when a request ends in a database failure or a busy/timeout 5xx. Details: TC-1796. - Log sidecar: the node's one warning line per database-failure 5xx (`database failure answered with a 5xx`) ships to Better Stack as an individual `node_warning` event with `event_code` `db_failure_5xx` and exactly eight fields: `status`, `code`, `service`, `route`, `elapsed_ms`, `failure_kind`, `write_may_have_applied` and, when known, `db_hash`. They are fixed vocabulary, the route template, a duration, a flag and a keyed database pseudonym; no SQL, ids, DIDs, paths or messages. A line with any other field or an unexpected value is counted as `unclassified` as before. Details: TC-1840. - **N0 compose.** The embedded Vector configuration is regenerated for the new event. The reviewed N0 compose template changes to `7c3456dc…` (raw `d0e72bde…`; `docs/releasing.md`). - Policy registration, policy status changes, SQL store manifest publication and share-email state writes now end their database transaction on every exit, so locks are released when the call returns. Details: TC-1836. - Creating a named key now ends its database transaction on every exit, so the network row lock is released when the call returns. Details: TC-1834. -- Encryption: clearer error for percent-encoded key route URLs. The public-key and describe routes for named keys read the network id and key path as sent; a percent-escape returns 400 `key_path_invalid` with a message naming the escape. Details: TC-1777. +- Encryption: clearer error for percent-encoded key route URLs. The public-key and describe routes for named keys read the network id and key path as sent; a percent-escape returns 400 `key_path_invalid` with a message naming the escape. These routes are served when `named_keys_v1` is on. Details: TC-1777. - SQL, DuckDB and encryption: 413 responses for oversized requests and responses start with a stable code: `sql_request_too_large`, `duckdb_request_too_large`, `request_too_large` (encryption decrypt on `/invoke`), `sql_response_too_large` or `duckdb_response_too_large`. The detail after the code states the limit applied. A SQL `maxRows` refusal now reads `sql_response_too_large: Response too large: row limit exceeded (limit N rows)` instead of reporting bytes. Docs: added `docs/sql-duckdb-size-limits.md`. Details: TC-1823. -- Encryption: parsing and containment for key resource identifiers; owners are root authority for their keys; key prefixes are refused as invocation targets, and key.delete is refused in delegations. Named keys can be created, blocked and unblocked, and their public key is derived; the routes are listed in the next entry. Root-key decrypt on both live routes now uses strict unwrap: wrapped keys must carry the 0x01 version byte, and non-contributory key exchanges are refused. On a non-active network, named-key decrypt on `POST /invoke` returns 403 (`network_not_active`, refused before replay); the direct root-key decrypt route returns 409 for a non-active network and 401 for a revoked one; create and public key return 409. Describe, block and unblock do not check network state. Details: TC-1777. -- Encryption: key create, public key, describe, block and unblock routes. Create is `POST /encryption/networks//keys`; public key and describe are `GET …/keys/`, and block and unblock are `POST …/key-actions/block|unblock`. The public key read needs no authorization and returns the same shape for missing, blocked and active keys. Describe takes a `key-describe/v1` invocation, and create, block and unblock take a `key-admin/v1` invocation, each on the exact key. Details: TC-1777. +- Encryption: parsing and containment for key resource identifiers; owners are root authority for their keys; key prefixes are refused as invocation targets, and key.delete is refused in delegations. Key resources are accepted only when `named_keys_v1` is on. When it is on, named keys can be created, blocked and unblocked, and their public key is derived; the routes are listed in the next entry. Root-key decrypt on both live routes now uses strict unwrap: wrapped keys must carry the 0x01 version byte, and non-contributory key exchanges are refused. On a non-active network, named-key decrypt on `POST /invoke` returns 403 (`network_not_active`, refused before replay); the direct root-key decrypt route returns 409 for a non-active network and 401 for a revoked one; create and public key return 409. Describe, block and unblock do not check network state. Details: TC-1777. +- Encryption: when `named_keys_v1` is on, key create, public key, describe, block and unblock routes. Create is `POST /encryption/networks//keys`; public key and describe are `GET …/keys/`, and block and unblock are `POST …/key-actions/block|unblock`. The public key read needs no authorization and returns the same shape for missing, blocked and active keys. Describe takes a `key-describe/v1` invocation, and create, block and unblock take a `key-admin/v1` invocation, each on the exact key. Details: TC-1777. - Tests that read `TINYCLOUD_TEST_POSTGRES_URL` connect only on GitHub Actions (`GITHUB_ACTIONS=true`) or with `TINYCLOUD_ALLOW_LOCAL_PG=1` set for an isolated server you started yourself, and skip otherwise. `CI=true` alone no longer connects, since automation shells set it by default. `tinycloud-node-server/tests/at_rest_harness.rs` (reads `TC887_PG_URL`) is excluded. Details: TC-1748. -- Encryption: key and prefix network names must match `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`; bare network resources keep the existing network name rule. Details: TC-1777. -- Encryption: key route errors start with a stable code: `network_not_found:`, `unauthorized:`, `key_not_found:` or `key_path_invalid:`. Request bodies that fail to parse on the key routes return 400 with the text `request body is malformed: `. Key paths are read from the request as sent, so a path with encoded or relative segments is refused rather than normalized. Network ids are accepted only in canonical form. Details: TC-1777. -- Encryption: named-key decrypt is served on `POST /invoke` (a key capability plus `keyPath` in the body); the direct network decrypt route refuses key resources and `keyPath`. A root-key unwrap failure on the direct route returns 403 `key_unwrap_failed`. Decrypt requests whose receiver key is not a valid 32-byte X25519 public key, or is a low-order point, are refused with 400 and the code `receiver_key_invalid`. Details: TC-1777. +- Encryption: key and prefix network names must match `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`; bare network resources keep the existing network name rule. Key and prefix resources are accepted when `named_keys_v1` is on. Details: TC-1777. +- Encryption: key route errors (served when `named_keys_v1` is on) start with a stable code: `network_not_found:`, `unauthorized:`, `key_not_found:` or `key_path_invalid:`. Request bodies that fail to parse on the key routes return 400 with the text `request body is malformed: `. Key paths are read from the request as sent, so a path with encoded or relative segments is refused rather than normalized. Network ids are accepted only in canonical form. Details: TC-1777. +- Encryption: when `named_keys_v1` is on, named-key decrypt is served on `POST /invoke` (a key capability plus `keyPath` in the body); the direct network decrypt route refuses key resources and `keyPath`. A root-key unwrap failure on the direct route returns 403 `key_unwrap_failed`. Decrypt requests whose receiver key is not a valid 32-byte X25519 public key, or is a low-order point, are refused with 400 and the code `receiver_key_invalid`. Details: TC-1777. - Configuration: adds `[encryption_network] named_keys_v1`, off by default. Details: TC-1777. - SQL: a `tinycloud.sql/*` delegation can carry `tables`, `columns` and `readOnly` caveats (names match ASCII-case-insensitively; each hop must be at least as narrow as its parent). A request is judged by the intersection of these caveats over every stored delegation on the chain it cites, and a request they do not allow fails with 403 `sql_caveat_violation: write`, `table` or `column`, the whole request refused. Under a `tables` or `columns` limit the PRAGMA schema reports and checks (`sql_caveat_violation: table`), the PRAGMAs that set `user_version` or `application_id` (`sql_caveat_violation: write`) and the database export are refused; DDL on a table needs that table listed. A chain whose caveat cannot be read fails with `sql_caveat_violation: chain`. Behind `[global.sql] delegated_caveats_enforced` (env `TINYCLOUD_SQL__DELEGATED_CAVEATS_ENFORCED`), off by default; when on, `GET /info` lists `sql-delegated-caveats-v1`. With the setting off, these delegations are refused as an unsupported caveat. DuckDB takes empty caveats only. Docs: added `docs/sql-delegated-caveats.md`. Details: TC-1791.