diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e5aa871..ff0fb662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Changes - Deploys: the workflow supports an explicit, reason-recorded rollback to an earlier released dstack image on the unchanged N0 compose. It checks image labels, release tag, ancestry, and identical migration sources while preserving the normal forward-release guard. +- A new Policy/v3 registration records the share key its content source names (`share/` on the owner's `default` network) in the registration's transaction, so publishing a share needs no `tinycloud.encryption/key.create` grant: the publisher encrypts to the key's public key and registers. A missing key is created at `keyVersion` 1 and counts toward the network's key limit; at the limit the registration answers 403 `key_limit_exceeded` and stores nothing. An existing key keeps its state. The registration now also requires the source's network to exist and be active. Docs: `docs/encryption-named-keys.md`. Details: TC-2525 ## [1.25.17] - 2026-11-03 diff --git a/docs/encryption-named-keys.md b/docs/encryption-named-keys.md index 73cd59a1..72753d24 100644 --- a/docs/encryption-named-keys.md +++ b/docs/encryption-named-keys.md @@ -51,6 +51,26 @@ reused. - A `deleted` deletable row is reused: new seed, state `active`, `keyVersion` + 1. - A `deleted` deterministic row answers `key_deleted` (403). +### Share keys recorded by a policy registration + +A Policy/v3 registration (`POST /policy/v3/policies`) whose `contentSource` +names `encryptionKeyPath` `share/` records that key on the source's +`default` network in the registration's own transaction, under the network +row lock, so a publisher encrypts to the public key and never calls create. +The registration already requires its owner to hold `decrypt` on that exact +key as the network owner or through the owner's own delegations (a policy +session is never that authority). + +- A missing key is created as `create` would (`keyVersion` 1, state + `active`) and counts toward the same per-network limit. At the limit the + registration answers 403 `key_limit_exceeded` and stores nothing. +- An existing key is left as it is, whatever its state: a `blocked` key stays + blocked. +- The network must exist and be active (`network_not_found` 404, + `network_not_active` 409); otherwise nothing is stored. +- A registration the node already stores answers as before and records + nothing. + ## State guards Create, block and unblock run under the network row lock. Block and unblock diff --git a/tinycloud-core/src/encryption_network/keys.rs b/tinycloud-core/src/encryption_network/keys.rs index b6b5dc60..d297f6ff 100644 --- a/tinycloud-core/src/encryption_network/keys.rs +++ b/tinycloud-core/src/encryption_network/keys.rs @@ -261,6 +261,34 @@ impl EncryptionService { } } + /// Records the deterministic key at `loc` inside the caller's write + /// transaction, as `key.create` would, for a caller that has already + /// authorized it. A missing row is created under the network row lock and + /// the key caps; an existing row is left as it is, whatever its state, so a + /// blocked key stays blocked. The network must be active. A deletable key + /// path is refused: its seed is made only by `key.create`. + pub async fn record_deterministic_key_in( + &self, + tx: &DatabaseTransaction, + loc: &KeyLocation, + ) -> Result<(), EncryptionServiceError> { + if loc.kind() != KeyKind::Deterministic { + return Err(EncryptionServiceError::KeyPathInvalid( + "only a deterministic key is recorded here".to_string(), + )); + } + let network = network_of(loc)?; + match self.create_key_in(tx, &network, loc).await? { + CreateStep::Inserted(_) | CreateStep::Existing(_) => Ok(()), + // A deterministic row is never reinstalled. The network row lock + // (PostgreSQL) or the writer lock (SQLite) serializes key writes, + // so a lost insert race means another writer bypassed them. + CreateStep::Reinstalled(_) | CreateStep::Raced => { + Err(EncryptionServiceError::KeyExists) + } + } + } + async fn create_key_in( &self, tx: &DatabaseTransaction, diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index b5c65afd..93ee50ee 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -27,8 +27,8 @@ use tinycloud_auth::{ resource::{ResourceId, SpaceId}, }; use tinycloud_core::{ - encryption_network::key_id::{self, EncryptionResource}, - encryption_network::NetworkId, + encryption_network::key_id::{self, EncryptionResource, KeyLocation}, + encryption_network::{EncryptionService, EncryptionServiceError, NetworkId}, events::{Delegation, SerializedEvent}, hash::hash, keys::StaticSecret, @@ -2048,6 +2048,7 @@ pub async fn register_policy( request: Json, runtime: &State, tinycloud: &State, + encryption: &State, ) -> Result, (Status, String)> { let request = request.into_inner(); let policy_bytes = canonical_json(&request.policy)?; @@ -2142,8 +2143,10 @@ 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. + // bind its content source to one share key, which the node records in + // the registration's transaction. require_bound_content_source(&request.policy)?; + let share_key = share_key_location(&request.policy)?; let now = OffsetDateTime::now_utc(); let policy_status = initial_status_checkpoint( @@ -2225,6 +2228,15 @@ pub async fn register_policy( roots, ) .await; + // The owner was shown above to hold decrypt on this exact key, so the + // registration records it; a full key limit refuses the registration. + let written = match written { + Ok(()) => encryption + .record_deterministic_key_in(&txn, &share_key) + .await + .map_err(share_key_error), + Err(err) => Err(err), + }; match written { Ok(()) => txn.commit().await.map_err(db_error)?, Err(err) => { @@ -4668,6 +4680,28 @@ fn require_bound_content_source(policy: &Value) -> Result<(), (Status, String)> Ok(()) } +/// The share key a new registration's content source names, as +/// `registration_projections` validated it: `/`. +fn share_key_location(policy: &Value) -> Result { + let source = policy.get("contentSource"); + let field = |name| source.and_then(|s| s.get(name)).and_then(Value::as_str); + let (Some(network), Some(path)) = (field("encryptionNetwork"), field("encryptionKeyPath")) + else { + return Err((Status::BadRequest, "content-source-invalid".into())); + }; + match key_id::parse(&format!("{network}/{path}")) { + Ok(EncryptionResource::Key(loc)) => Ok(loc), + _ => Err((Status::BadRequest, "content-source-invalid".into())), + } +} + +fn share_key_error(err: EncryptionServiceError) -> (Status, String) { + match err { + EncryptionServiceError::Db(err) => db_error(err), + other => crate::routes::encryption::map_service_err(other), + } +} + fn registration_projections(policy: &Value) -> Result { let object = policy .as_object() @@ -12066,3 +12100,7 @@ mod delivery_admission_tests; #[cfg(test)] #[path = "policy_v3/unbound_source_tests.rs"] mod unbound_source_tests; + +#[cfg(test)] +#[path = "policy_v3/share_key_record_tests.rs"] +mod share_key_record_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 index b8cdcfe8..d50aa77f 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 @@ -884,7 +884,7 @@ pub(super) struct ShareDecrypt { } impl ShareDecrypt { - async fn new() -> anyhow::Result { + pub(super) async fn new() -> anyhow::Result { Self::with_source(Some(TC2192_KEY_PATH), "immutable").await } @@ -925,9 +925,11 @@ impl ShareDecrypt { threshold: Threshold::one_of_one(), }) .await?; + // A share key is never created by the publisher: it encrypts to the + // public key, and the registration below records the key. let public_key = if let Some(path) = key_path { service - .create_key(&KeyLocation::new(&network.network_id, path)?, false) + .public_key(&KeyLocation::new(&network.network_id, path)?) .await? .public_encryption_key } else { diff --git a/tinycloud-node-server/src/policy_v3/share_key_record_tests.rs b/tinycloud-node-server/src/policy_v3/share_key_record_tests.rs new file mode 100644 index 00000000..802be0c6 --- /dev/null +++ b/tinycloud-node-server/src/policy_v3/share_key_record_tests.rs @@ -0,0 +1,264 @@ +//! A Policy/v3 registration records the share key its content source names +//! (`:default/share/`), in the registration's transaction, so +//! a publisher never creates the key. Only a policy owner that holds decrypt +//! on the key through the owner's own grants can record one, and the +//! network's key limit applies. + +use super::named_keys_gate_tests::ShareDecrypt; +use super::test_harness::*; +use super::*; +use rocket::http::Status; +use serde_json::json; +use tinycloud_core::encryption_network::{ + keys::{KeyState, DEFAULT_KEY_CAP_PER_NETWORK}, + CreateNetworkRequest, EncryptionService, Threshold, +}; +use tinycloud_core::models::encryption_network_key; +use tinycloud_core::sea_orm::PaginatorTrait; + +/// A harness with named keys on, the space owner's `default` network, and a +/// policy owner the space owner granted decrypt on `/share/`, as a +/// Share sign-in does. +struct Publisher { + h: Harness, + network: NetworkId, + _ledger: tempfile::TempDir, +} + +impl Publisher { + async fn new(backend: Backend) -> anyhow::Result> { + let ledger = tempfile::tempdir()?; + let Some(mut h) = + Harness::new_with(backend, Shape::KeySession, "sharekeyrecord", |config| { + config.encryption_network.named_keys_v1 = true; + config.encryption_network.deletion_ledger_path = + Some(ledger.path().join("deletion-ledger.jsonl")); + }) + .await? + else { + return Ok(None); + }; + h.network = format!("urn:tinycloud:encryption:{}:default", h.space_owner.did()); + let network = h + .client + .rocket() + .state::() + .unwrap() + .create_one_of_one_network(CreateNetworkRequest { + name: "default".into(), + owner_did: h.space_owner.did().into(), + threshold: Threshold::one_of_one(), + }) + .await? + .network_id; + let mut grants = h.owner_grants(); + grants[2].0 = format!("{}/share/", h.network); + h.grant_owner_with(&grants, Window::until(now() + Duration::days(2))) + .await?; + Ok(Some(Self { + h, + network, + _ledger: ledger, + })) + } + + fn service(&self) -> &EncryptionService { + self.h.client.rocket().state::().unwrap() + } + + fn location(&self, share_id: &str) -> anyhow::Result { + Ok(KeyLocation::new( + &self.network, + &format!("share/{share_id}"), + )?) + } + + /// Registers a new policy over the share key `share/`: each + /// call is a distinct policy (its ciphertext digest differs). + async fn register(&self, share_id: &str) -> anyhow::Result<(Status, String, Registered)> { + let leaf = format!("{}/share/{share_id}", self.h.network); + let ciphertext = hex::encode(Sha256::digest(unique("ciphertext").as_bytes())); + self.h + .register_with(policy_window(), |policy| { + policy["contentSource"]["shareId"] = json!(share_id); + policy["contentSource"]["encryptionKeyPath"] = json!(format!("share/{share_id}")); + policy["contentSource"]["initialCiphertextDigestHex"] = json!(ciphertext); + policy["capabilityCeiling"][1]["resource"] = json!(leaf); + }) + .await + } + + async fn key_rows(&self) -> anyhow::Result { + let conn = &self + .h + .client + .rocket() + .state::() + .unwrap() + .conn; + Ok(encryption_network_key::Entity::find() + .filter(encryption_network_key::Column::NetworkId.eq(self.network.to_string())) + .count(conn) + .await?) + } +} + +/// The publisher encrypts to the public key and never creates the key; the +/// registration records it, active at version 1, and the recipient's session +/// decrypts. The policy owner here is a delegate: it holds decrypt on the key +/// only through the space owner's grant. +#[tokio::test] +async fn a_registration_records_its_share_key_and_the_recipient_decrypts() -> anyhow::Result<()> { + let share = ShareDecrypt::new().await?; + let service = share + .h + .client + .rocket() + .state::() + .unwrap(); + let network: NetworkId = share.h.network.parse()?; + let key = service + .describe_key(&KeyLocation::new(&network, "share/tc-2192")?) + .await?; + assert_eq!(key.state, KeyState::Active); + assert_eq!(key.key_version, 1); + assert_eq!(key.kind, "deterministic"); + + let (status, response) = share + .invoke(&share.leaf(), &share.body(), None, Value::Null) + .await?; + assert_eq!(status, Status::Ok, "{response}"); + Ok(()) +} + +/// A recipient's policy session is not owner authority: the recipient cannot +/// register a policy over the key it may decrypt, and no key is recorded. +#[tokio::test] +async fn a_recipient_session_cannot_register_or_record_the_key() -> anyhow::Result<()> { + let mut share = ShareDecrypt::new().await?; + let network: NetworkId = share.h.network.parse()?; + let loc = KeyLocation::new(&network, "share/tc-2192")?; + // Forget the key the publisher's registration recorded, so a recipient + // registration that succeeded would have to record it again. + let conn = share + .h + .client + .rocket() + .state::() + .unwrap() + .conn + .clone(); + encryption_network_key::Entity::delete_by_id((network.to_string(), loc.path().to_string())) + .exec(&conn) + .await?; + + // The recipient (who holds the minted session) signs the policy and its + // roots as their owner. + std::mem::swap(&mut share.h.owner, &mut share.h.holder); + let leaf = share.leaf(); + let (status, body, registered) = share + .h + .register_with(policy_window(), |policy| { + policy["contentSource"]["shareId"] = json!("tc-2192"); + policy["contentSource"]["encryptionKeyPath"] = json!("share/tc-2192"); + policy["capabilityCeiling"][1]["resource"] = json!(leaf); + }) + .await?; + assert_eq!(status, Status::Forbidden, "{body}"); + assert!(body.contains("policy-owner-not-authorized"), "{body}"); + assert!( + policy_v3_registration::Entity::find_by_id(registered.policy_cid) + .one(&conn) + .await? + .is_none() + ); + let service = share + .h + .client + .rocket() + .state::() + .unwrap(); + assert!(matches!( + service.describe_key(&loc).await, + Err(EncryptionServiceError::KeyNotFound) + )); + Ok(()) +} + +/// With the network at its key limit, a registration that would record a new +/// key is refused and stores neither the registration nor the key. A key +/// that is already recorded needs no new row, so its registration succeeds. +async fn a_full_key_limit_refuses_the_registration_and_stores_nothing( + backend: Backend, +) -> anyhow::Result<()> { + let Some(p) = Publisher::new(backend).await? else { + return Ok(()); + }; + let (status, body, _) = p.register("recorded").await?; + assert_eq!(status, Status::Ok, "{body}"); + + let conn = + p.h.client + .rocket() + .state::() + .unwrap() + .conn + .clone(); + let stamp = format_time(OffsetDateTime::now_utc()); + let filler = (1..DEFAULT_KEY_CAP_PER_NETWORK).map(|i| encryption_network_key::ActiveModel { + network_id: Set(p.network.to_string()), + key_path: Set(format!("docs/filler-{i}")), + kind: Set("deterministic".into()), + state: Set("active".into()), + public_key: Set(vec![0; 32]), + sealed_seed: Set(None), + key_version: Set(1), + created_at: Set(stamp.clone()), + updated_at: Set(stamp.clone()), + }); + encryption_network_key::Entity::insert_many(filler) + .exec(&conn) + .await?; + assert_eq!(p.key_rows().await?, DEFAULT_KEY_CAP_PER_NETWORK); + + let (status, body, registered) = p.register("over-limit").await?; + assert_eq!(status, Status::Forbidden, "{body}"); + assert!(body.starts_with("key_limit_exceeded"), "{body}"); + assert!( + policy_v3_registration::Entity::find_by_id(registered.policy_cid) + .one(&conn) + .await? + .is_none() + ); + assert_eq!(p.key_rows().await?, DEFAULT_KEY_CAP_PER_NETWORK); + assert!(matches!( + p.service().describe_key(&p.location("over-limit")?).await, + Err(EncryptionServiceError::KeyNotFound) + )); + + let (status, body, _) = p.register("recorded").await?; + assert_eq!(status, Status::Ok, "{body}"); + Ok(()) +} +on_both_backends!(a_full_key_limit_refuses_the_registration_and_stores_nothing); + +/// A key that is already recorded is left as it is: a key an older client +/// created, then the owner blocked, stays blocked at its version through +/// further registrations over it. +async fn a_recorded_key_is_left_as_it_is(backend: Backend) -> anyhow::Result<()> { + let Some(p) = Publisher::new(backend).await? else { + return Ok(()); + }; + let loc = p.location("existing")?; + p.service().create_key(&loc, false).await?; + let blocked = p.service().block_key(&loc).await?; + + for _ in 0..2 { + let (status, body, _) = p.register("existing").await?; + assert_eq!(status, Status::Ok, "{body}"); + } + assert_eq!(p.service().describe_key(&loc).await?, blocked); + assert_eq!(p.key_rows().await?, 1); + Ok(()) +} +on_both_backends!(a_recorded_key_is_left_as_it_is); 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 4a5e0cff..338793b5 100644 --- a/tinycloud-node-server/src/policy_v3/unbound_source_tests.rs +++ b/tinycloud-node-server/src/policy_v3/unbound_source_tests.rs @@ -17,24 +17,35 @@ const CONTENT_SOURCE_INVALID: &str = "content-source-invalid"; 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. +/// A node with named keys on, which a path-bound share key needs, and the +/// space owner's `default` network, where a registration records the share +/// key: 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> { + use tinycloud_core::encryption_network::{CreateNetworkRequest, Threshold}; 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)), - ) + let Some(h) = Harness::new_with(backend, Shape::KeySession, test, named_keys).await? else { + return Ok(None); + }; + h.client + .rocket() + .state::() + .expect("encryption service") + .create_one_of_one_network(CreateNetworkRequest { + name: "default".into(), + owner_did: h.space_owner.did().into(), + threshold: Threshold::one_of_one(), + }) + .await?; + Ok(Some((h, ledger))) } /// The policy owner holds kv reads and decrypt on `key`. diff --git a/tinycloud-node-server/src/routes/encryption.rs b/tinycloud-node-server/src/routes/encryption.rs index 4f06a540..7ec7bac1 100644 --- a/tinycloud-node-server/src/routes/encryption.rs +++ b/tinycloud-node-server/src/routes/encryption.rs @@ -781,7 +781,7 @@ fn map_key_service_err(err: EncryptionServiceError) -> (Status, String) { } } -fn map_service_err(err: EncryptionServiceError) -> (Status, String) { +pub(crate) fn map_service_err(err: EncryptionServiceError) -> (Status, String) { let status = match err { EncryptionServiceError::NetworkNotFound => Status::NotFound, EncryptionServiceError::NetworkAlreadyExists => Status::Conflict,