From f50398480b4b063b1a4d576c9d4e45a924c87a9b Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:50:28 +0000 Subject: [PATCH 1/3] feat(encryption): v2 key wrap envelope Details: TC-2518 --- CHANGELOG.md | 1 + docs/encryption-named-keys.md | 18 + .../src/encryption_network/backend.rs | 678 +++++++++++++++++- tinycloud-core/src/encryption_network/keys.rs | 24 +- tinycloud-core/src/encryption_network/mod.rs | 2 +- .../src/encryption_network/protocol.rs | 2 + .../src/encryption_network/service.rs | 135 +++- .../src/encryption_network/tests.rs | 250 +++++++ .../src/encryption_network/types.rs | 1 + .../tests/fixtures/key-wrap-v2.json | 89 +++ tinycloud-node-server/src/config.rs | 14 + tinycloud-node-server/src/lib.rs | 3 +- tinycloud.toml | 3 + 13 files changed, 1163 insertions(+), 57 deletions(-) create mode 100644 tinycloud-core/tests/fixtures/key-wrap-v2.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e174d0bf1..766d08c4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] ### Changes +- Encryption root and named keys read both v1 and TCW2 key wraps. Response wraps remain v1 by default; `[global.encryption_network] key_wrap_v2 = true` selects v2, with the response algorithm describing its actual format. Descriptors and payload schemas are unchanged. Details: TC-2518 - 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 - Share-email issuer trust bundles can pin one next key for the same issuer DID during an explicit UTC overlap window. The legacy key stops at `issuerNotAfter`; malformed, duplicate, unbounded or expired overlaps fail startup and `--validate-config`. Existing single-key bundles remain unchanged. This is node-side trust support only: the OpenCredentials witness and js-sdk policy contract must also support key rotation before activation. Older node images reject overlap fields; restore the previous single-key bundle before rollback. See `deploy/share-email/production-trust-bundle-contract.md`. diff --git a/docs/encryption-named-keys.md b/docs/encryption-named-keys.md index 72753d24b..f057406e7 100644 --- a/docs/encryption-named-keys.md +++ b/docs/encryption-named-keys.md @@ -7,6 +7,24 @@ are refused. A key is named by `urn:tinycloud:encryption::/`. A prefix grant is the same URN with one trailing `/`. +## Key-wrap formats + +Root and named keys accept both `x25519-aes256gcm/v1` wraps and TCW2 +`x25519-hkdf-aes256gcm/v2` wraps. Network and key descriptors keep the v1 +algorithm identifier because their key material supports either format. +Request `alg` identifies the incoming wrap; response `alg` identifies the +returned receiver wrap independently. + +`[global.encryption_network] key_wrap_v2 = false` keeps response writes on v1. +Set it to `true` (or `TINYCLOUD_ENCRYPTION_NETWORK__KEY_WRAP_V2=true`) only when +receiving clients support v2. The flag does not restrict either read format. +TCW2 uses X25519, HKDF-SHA256 and AES-256-GCM with a canonical authenticated +header binding the recipient, purpose, network, exact key path, key version +and string metadata. Receiver wraps use empty metadata. Key version is checked +against the network or named-key row before opening. + +Details: TC-2518 + ## Key paths A path is 1 to 8 segments of `[a-z0-9][a-z0-9._-]{0,63}` joined by `/`, at most diff --git a/tinycloud-core/src/encryption_network/backend.rs b/tinycloud-core/src/encryption_network/backend.rs index c65fd604a..79b8dd164 100644 --- a/tinycloud-core/src/encryption_network/backend.rs +++ b/tinycloud-core/src/encryption_network/backend.rs @@ -6,8 +6,16 @@ //! random seed, sealed the same way. Future threshold backends must avoid //! ever assembling the full network private key. +use std::collections::BTreeMap; + +use aes_gcm::{ + aead::{Aead, KeyInit, Payload}, + Aes256Gcm, Nonce, +}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; use hkdf::Hkdf; use rand::{rngs::OsRng, RngCore}; +use serde::{Deserialize, Serialize}; use sha2::Sha256; use thiserror::Error; use x25519_dalek::{PublicKey, StaticSecret}; @@ -15,7 +23,8 @@ use zeroize::{Zeroize, Zeroizing}; use crate::encryption::{ColumnEncryption, EncryptionError}; -use super::types::ALG_X25519_AES256GCM; +use super::network_id::NetworkId; +use super::types::{ALG_X25519_AES256GCM, ALG_X25519_HKDF_AES256GCM}; #[derive(Debug, Error)] pub enum KeyBackendError { @@ -43,6 +52,285 @@ pub struct GeneratedKey { pub alg: String, } +/// Wire format selected for newly written wraps; readers accept both. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyWrapVersion { + V1, + V2, +} + +impl KeyWrapVersion { + /// Algorithm identifier describing this wrap's bytes. + pub fn alg(self) -> &'static str { + match self { + Self::V1 => ALG_X25519_AES256GCM, + Self::V2 => ALG_X25519_HKDF_AES256GCM, + } + } +} + +/// Intended recipient role, authenticated in a v2 wrap. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum KeyWrapPurpose { + NetworkKey, + ReceiverKey, +} + +/// Authoritative context for a v2 wrap. `None` metadata authenticates the +/// carried map without comparing an outer map (the node has no payload). +#[derive(Debug, Clone, Copy)] +pub struct KeyWrapContext<'a> { + pub purpose: KeyWrapPurpose, + pub network_id: &'a str, + pub key_path: &'a str, + pub key_version: i64, + pub metadata: Option<&'a BTreeMap>, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct WrapHeader { + v: u32, + alg: String, + kdf: String, + aead: String, + salt: String, + ephemeral_public_key: String, + recipient_public_key: String, + purpose: KeyWrapPurpose, + network_id: String, + key_path: String, + key_version: i64, + metadata: BTreeMap, +} + +const WRAP_V2_MAGIC: &[u8; 4] = b"TCW2"; +const WRAP_V2_INFO: &[u8] = b"tinycloud.encryption.key-wrap/v2"; +const MAX_WRAP_HEADER: usize = 64 * 1024; + +// JSON.stringify uses UTF-16 key order. Keep this separate from the existing +// invocation canonicalizer, whose historical ordering must not change. +fn canonical_header_bytes(value: &serde_json::Value) -> Vec { + fn write(value: &serde_json::Value, out: &mut Vec) { + if let serde_json::Value::Object(map) = value { + let mut entries: Vec<_> = map.iter().collect(); + entries.sort_unstable_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16())); + out.push(b'{'); + for (index, (key, value)) in entries.into_iter().enumerate() { + if index != 0 { + out.push(b','); + } + serde_json::to_writer(&mut *out, key).expect("JSON string serialization"); + out.push(b':'); + write(value, out); + } + out.push(b'}'); + } else { + serde_json::to_writer(out, value).expect("JSON value serialization"); + } + } + let mut out = Vec::new(); + write(value, &mut out); + out +} + +/// Identifies a wrap without falling back after the v2 magic is recognized. +pub fn wrapped_key_version(wrapped: &[u8]) -> KeyWrapVersion { + if wrapped.starts_with(WRAP_V2_MAGIC) { + KeyWrapVersion::V2 + } else { + KeyWrapVersion::V1 + } +} + +fn valid_context(context: &KeyWrapContext<'_>) -> bool { + (1..=9_007_199_254_740_991).contains(&context.key_version) + && (context.network_id.is_empty() + || context + .network_id + .parse::() + .is_ok_and(|network| network.to_string() == context.network_id)) +} + +fn decode_header_key(encoded: &str) -> Result<[u8; 32], KeyBackendError> { + let bytes = STANDARD + .decode(encoded) + .map_err(|_| KeyBackendError::InvalidWrappedKey)?; + if STANDARD.encode(&bytes) != encoded { + return Err(KeyBackendError::InvalidWrappedKey); + } + bytes + .try_into() + .map_err(|_| KeyBackendError::InvalidWrappedKey) +} + +fn wrap_cipher( + shared: &[u8; 32], + salt: &[u8; 32], + ephemeral: &[u8; 32], + recipient: &[u8; 32], +) -> Aes256Gcm { + let mut info = [0u8; WRAP_V2_INFO.len() + 64]; + info[..WRAP_V2_INFO.len()].copy_from_slice(WRAP_V2_INFO); + info[WRAP_V2_INFO.len()..WRAP_V2_INFO.len() + 32].copy_from_slice(ephemeral); + info[WRAP_V2_INFO.len() + 32..].copy_from_slice(recipient); + let mut key = Zeroizing::new([0u8; 32]); + Hkdf::::new(Some(salt), shared) + .expand(&info, key.as_mut_slice()) + .expect("32 bytes is a valid HKDF-SHA256 output length"); + Aes256Gcm::new_from_slice(key.as_slice()).expect("32-byte AES-256 key") +} + +/// Wrap to a network or receiver using the selected wire format. +pub fn wrap_to_public_key_with_context( + recipient_public_key: &[u8], + plaintext: &[u8], + context: &KeyWrapContext<'_>, + version: KeyWrapVersion, +) -> Result, KeyBackendError> { + if version == KeyWrapVersion::V1 { + return wrap_to_public_key(recipient_public_key, plaintext); + } + let ephemeral = StaticSecret::random_from_rng(OsRng); + let mut salt = [0u8; 32]; + let mut nonce = [0u8; 12]; + OsRng.fill_bytes(&mut salt); + OsRng.fill_bytes(&mut nonce); + wrap_v2_with_material( + recipient_public_key, + plaintext, + context, + &ephemeral, + &salt, + &nonce, + ) +} + +fn wrap_v2_with_material( + recipient_public_key: &[u8], + plaintext: &[u8], + context: &KeyWrapContext<'_>, + ephemeral: &StaticSecret, + salt: &[u8; 32], + nonce: &[u8; 12], +) -> Result, KeyBackendError> { + if !valid_context(context) { + return Err(KeyBackendError::InvalidWrappedKey); + } + let recipient_bytes: [u8; 32] = recipient_public_key + .try_into() + .map_err(|_| KeyBackendError::InvalidPublicKey)?; + let recipient = PublicKey::from(recipient_bytes); + let ephemeral_public = PublicKey::from(ephemeral); + let shared = ephemeral.diffie_hellman(&recipient); + if !shared.was_contributory() { + return Err(KeyBackendError::NonContributory); + } + let header = WrapHeader { + v: 2, + alg: ALG_X25519_HKDF_AES256GCM.into(), + kdf: "hkdf-sha256".into(), + aead: "aes-256-gcm".into(), + salt: STANDARD.encode(salt), + ephemeral_public_key: STANDARD.encode(ephemeral_public.as_bytes()), + recipient_public_key: STANDARD.encode(recipient_public_key), + purpose: context.purpose, + network_id: context.network_id.into(), + key_path: context.key_path.into(), + key_version: context.key_version, + metadata: context.metadata.cloned().unwrap_or_default(), + }; + let value = serde_json::to_value(header).map_err(|_| KeyBackendError::InvalidWrappedKey)?; + let header_bytes = canonical_header_bytes(&value); + if header_bytes.len() > MAX_WRAP_HEADER { + return Err(KeyBackendError::InvalidWrappedKey); + } + let mut frame = Vec::with_capacity(8 + header_bytes.len() + 12 + plaintext.len() + 16); + frame.extend_from_slice(WRAP_V2_MAGIC); + frame.extend_from_slice(&(header_bytes.len() as u32).to_be_bytes()); + frame.extend_from_slice(&header_bytes); + let cipher = wrap_cipher( + shared.as_bytes(), + salt, + ephemeral_public.as_bytes(), + &recipient_bytes, + ); + let encrypted = cipher + .encrypt( + &Nonce::from(*nonce), + Payload { + msg: plaintext, + aad: &frame, + }, + ) + .map_err(|e| KeyBackendError::Aead(e.to_string()))?; + frame.extend_from_slice(nonce); + frame.extend_from_slice(&encrypted); + Ok(frame) +} + +/// Open either wire format, checking all supplied v2 context before decrypting. +pub fn open_wrapped_key( + secret: &StaticSecret, + wrapped: &[u8], + context: &KeyWrapContext<'_>, +) -> Result, KeyBackendError> { + if wrapped_key_version(wrapped) == KeyWrapVersion::V1 { + return unwrap_with_secret(secret, wrapped); + } + if wrapped.len() < 8 || !valid_context(context) { + return Err(KeyBackendError::InvalidWrappedKey); + } + let length = u32::from_be_bytes(wrapped[4..8].try_into().expect("four-byte length")) as usize; + if length > MAX_WRAP_HEADER || wrapped.len() < 8 + length + 12 + 16 { + return Err(KeyBackendError::InvalidWrappedKey); + } + let end = 8 + length; + let header: WrapHeader = + serde_json::from_slice(&wrapped[8..end]).map_err(|_| KeyBackendError::InvalidWrappedKey)?; + let value = serde_json::to_value(&header).map_err(|_| KeyBackendError::InvalidWrappedKey)?; + if canonical_header_bytes(&value) != wrapped[8..end] + || header.v != 2 + || header.alg != ALG_X25519_HKDF_AES256GCM + || header.kdf != "hkdf-sha256" + || header.aead != "aes-256-gcm" + || header.purpose != context.purpose + || header.network_id != context.network_id + || header.key_path != context.key_path + || header.key_version != context.key_version + || context + .metadata + .is_some_and(|metadata| metadata != &header.metadata) + { + return Err(KeyBackendError::InvalidWrappedKey); + } + let salt = decode_header_key(&header.salt)?; + let ephemeral = decode_header_key(&header.ephemeral_public_key)?; + let recipient = decode_header_key(&header.recipient_public_key)?; + let actual_recipient = PublicKey::from(secret); + if recipient != *actual_recipient.as_bytes() { + return Err(KeyBackendError::InvalidWrappedKey); + } + let shared = secret.diffie_hellman(&PublicKey::from(ephemeral)); + if !shared.was_contributory() { + return Err(KeyBackendError::NonContributory); + } + let cipher = wrap_cipher(shared.as_bytes(), &salt, &ephemeral, &recipient); + cipher + .decrypt( + &Nonce::from( + <[u8; 12]>::try_from(&wrapped[end..end + 12]) + .map_err(|_| KeyBackendError::InvalidWrappedKey)?, + ), + Payload { + msg: &wrapped[end + 12..], + aad: &wrapped[..end], + }, + ) + .map_err(|e| KeyBackendError::Aead(e.to_string())) +} + /// Trait implemented by network key backends. /// /// `unwrap` returns the raw symmetric key after decrypting the wrapped key with @@ -58,12 +346,15 @@ pub trait KeyBackend: Send + Sync { &self, sealed_private_key: &[u8], wrapped_key: &[u8], + context: &KeyWrapContext<'_>, ) -> Result, KeyBackendError>; fn rewrap( &self, symmetric_key: &[u8], receiver_public_key: &[u8], + context: &KeyWrapContext<'_>, + version: KeyWrapVersion, ) -> Result, KeyBackendError>; /// Unseal the network private key. A failure here is a backend fault, @@ -91,6 +382,7 @@ pub trait KeyBackend: Send + Sync { canonical_urn: &str, seed: Option<&KeySeed>, wrapped_key: &[u8], + context: &KeyWrapContext<'_>, ) -> Result, KeyBackendError>; /// A fresh 32-byte seed from the OS CSPRNG, with its sealed form for @@ -263,11 +555,12 @@ impl KeyBackend for LocalOneOfOneBackend { &self, sealed_private_key: &[u8], wrapped_key: &[u8], + context: &KeyWrapContext<'_>, ) -> Result, KeyBackendError> { let secret = self .open_network_secret(sealed_private_key) .map_err(|_| KeyBackendError::SealedKeyUnreadable)?; - unwrap_with_secret(&secret, wrapped_key) + open_wrapped_key(&secret, wrapped_key, context) } fn derive_named_public_key( @@ -286,9 +579,10 @@ impl KeyBackend for LocalOneOfOneBackend { canonical_urn: &str, seed: Option<&KeySeed>, wrapped_key: &[u8], + context: &KeyWrapContext<'_>, ) -> Result, KeyBackendError> { let secret = named_key_secret(network_secret.as_bytes(), canonical_urn, seed); - unwrap_with_secret(&secret, wrapped_key) + open_wrapped_key(&secret, wrapped_key, context) } fn generate_seed(&self) -> Result<(KeySeed, Vec), KeyBackendError> { @@ -311,8 +605,10 @@ impl KeyBackend for LocalOneOfOneBackend { &self, symmetric_key: &[u8], receiver_public_key: &[u8], + context: &KeyWrapContext<'_>, + version: KeyWrapVersion, ) -> Result, KeyBackendError> { - wrap_to_public_key(receiver_public_key, symmetric_key) + wrap_to_public_key_with_context(receiver_public_key, symmetric_key, context, version) } } @@ -324,13 +620,325 @@ mod tests { ColumnEncryption::new([7u8; 32]) } + fn legacy_context() -> KeyWrapContext<'static> { + KeyWrapContext { + purpose: KeyWrapPurpose::NetworkKey, + network_id: "", + key_path: "", + key_version: 1, + metadata: None, + } + } + + fn wrap_context<'a>( + metadata: &'a std::collections::BTreeMap, + ) -> KeyWrapContext<'a> { + KeyWrapContext { + purpose: KeyWrapPurpose::NetworkKey, + network_id: "urn:tinycloud:encryption:did:key:z6Mk:default", + key_path: "docs/alpha", + key_version: 1, + metadata: Some(metadata), + } + } + + #[test] + fn v2_wrap_binds_context_and_both_public_keys() { + let metadata = std::collections::BTreeMap::from([ + ("contentType".into(), "text/plain".into()), + ("\u{10000}".into(), "non-BMP".into()), + ("\u{e000}".into(), "BMP".into()), + ]); + let empty = std::collections::BTreeMap::new(); + let context = wrap_context(&metadata); + let secret = StaticSecret::from([9u8; 32]); + let public = PublicKey::from(&secret); + let wrapped = wrap_to_public_key_with_context( + public.as_bytes(), + &[0x42; 32], + &context, + KeyWrapVersion::V2, + ) + .unwrap(); + assert!(wrapped.starts_with(b"TCW2")); + assert_eq!( + open_wrapped_key(&secret, &wrapped, &context).unwrap(), + [0x42; 32] + ); + for wrong in [ + KeyWrapContext { + purpose: KeyWrapPurpose::ReceiverKey, + ..context + }, + KeyWrapContext { + network_id: "urn:tinycloud:encryption:did:key:z6Mk:other", + ..context + }, + KeyWrapContext { + key_path: "docs/beta", + ..context + }, + KeyWrapContext { + key_version: 2, + ..context + }, + KeyWrapContext { + metadata: Some(&empty), + ..context + }, + ] { + assert!(open_wrapped_key(&secret, &wrapped, &wrong).is_err()); + } + assert!(open_wrapped_key(&StaticSecret::from([8u8; 32]), &wrapped, &context).is_err()); + // The node authenticates carried metadata without an outer payload to compare. + assert!(open_wrapped_key( + &secret, + &wrapped, + &KeyWrapContext { + metadata: None, + ..context + } + ) + .is_ok()); + assert!( + wrap_to_public_key_with_context(&[0; 32], &[1; 32], &context, KeyWrapVersion::V2) + .is_err() + ); + for key_version in [0, -1, 9_007_199_254_740_992] { + let invalid = KeyWrapContext { + key_version, + ..context + }; + assert!(wrap_to_public_key_with_context( + public.as_bytes(), + &[1; 32], + &invalid, + KeyWrapVersion::V2 + ) + .is_err()); + } + } + + fn replace_v2_header(wrapped: &[u8], mutate: impl FnOnce(&mut serde_json::Value)) -> Vec { + let length = u32::from_be_bytes(wrapped[4..8].try_into().unwrap()) as usize; + let mut header: serde_json::Value = + serde_json::from_slice(&wrapped[8..8 + length]).unwrap(); + mutate(&mut header); + let bytes = canonical_header_bytes(&header); + let mut changed = b"TCW2".to_vec(); + changed.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + changed.extend_from_slice(&bytes); + changed.extend_from_slice(&wrapped[8 + length..]); + changed + } + + #[test] + fn v2_rejects_tampering_noncanonical_and_truncated_frames() { + use serde_json::json; + let metadata = std::collections::BTreeMap::new(); + let context = wrap_context(&metadata); + let secret = StaticSecret::from([9u8; 32]); + let wrapped = wrap_to_public_key_with_context( + PublicKey::from(&secret).as_bytes(), + &[0x42; 32], + &context, + KeyWrapVersion::V2, + ) + .unwrap(); + for (field, value) in [ + ("v", json!(3)), + ("alg", json!("x25519-aes256gcm/v1")), + ("kdf", json!("other")), + ("aead", json!("other")), + ("purpose", json!("receiver-key")), + ("networkId", json!("other")), + ("keyPath", json!("docs/beta")), + ("keyVersion", json!(2)), + ("metadata", json!({"different": "value"})), + ("salt", json!(STANDARD.encode([3; 32]))), + ("ephemeralPublicKey", json!(STANDARD.encode([0; 32]))), + ("recipientPublicKey", json!(STANDARD.encode([3; 32]))), + ("extra", json!(true)), + ("v", json!("2")), + ("keyVersion", json!(0)), + ("keyVersion", json!(-1)), + ("keyVersion", json!(1.5)), + ("keyVersion", json!(9_007_199_254_740_992i64)), + ("metadata", json!({"bad": 1})), + ("metadata", json!([])), + ("salt", json!("not-base64")), + ("salt", json!(STANDARD.encode([1; 31]))), + ("ephemeralPublicKey", json!(STANDARD.encode([1; 31]))), + ("recipientPublicKey", json!(STANDARD.encode([1; 31]))), + ( + "ephemeralPublicKey", + json!(STANDARD.encode(PublicKey::from(&StaticSecret::from([7; 32])).as_bytes())), + ), + ] { + let changed = replace_v2_header(&wrapped, |header| header[field] = value); + assert!( + open_wrapped_key(&secret, &changed, &context).is_err(), + "{field}" + ); + } + let carried_metadata = replace_v2_header(&wrapped, |header| { + header["metadata"] = json!({"carried": "changed"}) + }); + let node_context = KeyWrapContext { + metadata: None, + ..context + }; + assert!(open_wrapped_key(&secret, &carried_metadata, &node_context).is_err()); + for field in ["metadata", "salt", "keyVersion", "purpose"] { + let changed = replace_v2_header(&wrapped, |header| { + header.as_object_mut().unwrap().remove(field); + }); + assert!( + open_wrapped_key(&secret, &changed, &context).is_err(), + "missing {field}" + ); + } + for length in 4..wrapped.len() { + assert!( + open_wrapped_key(&secret, &wrapped[..length], &context).is_err(), + "{length}" + ); + } + let mut oversized = b"TCW2".to_vec(); + oversized.extend_from_slice(&65537u32.to_be_bytes()); + assert!(open_wrapped_key(&secret, &oversized, &context).is_err()); + let header_len = u32::from_be_bytes(wrapped[4..8].try_into().unwrap()) as usize; + let mut noncanonical = wrapped.clone(); + noncanonical.insert(8, b' '); + noncanonical[4..8].copy_from_slice(&((header_len + 1) as u32).to_be_bytes()); + assert!(open_wrapped_key(&secret, &noncanonical, &context).is_err()); + for index in [8 + header_len, wrapped.len() - 1] { + let mut changed = wrapped.clone(); + changed[index] ^= 1; + assert!(open_wrapped_key(&secret, &changed, &context).is_err()); + } + } + + #[test] + fn v2_writes_use_fresh_material_and_v1_stays_available() { + let metadata = BTreeMap::new(); + let context = wrap_context(&metadata); + let secret = StaticSecret::from([9; 32]); + let public = PublicKey::from(&secret); + let first = wrap_to_public_key_with_context( + public.as_bytes(), + &[1; 32], + &context, + KeyWrapVersion::V2, + ) + .unwrap(); + let second = wrap_to_public_key_with_context( + public.as_bytes(), + &[1; 32], + &context, + KeyWrapVersion::V2, + ) + .unwrap(); + let parse = |frame: &[u8]| { + let length = u32::from_be_bytes(frame[4..8].try_into().unwrap()) as usize; + ( + serde_json::from_slice::(&frame[8..8 + length]).unwrap(), + frame[8 + length..8 + length + 12].to_vec(), + ) + }; + let (first_header, first_nonce) = parse(&first); + let (second_header, second_nonce) = parse(&second); + assert_ne!(first_header["salt"], second_header["salt"]); + assert_ne!( + first_header["ephemeralPublicKey"], + second_header["ephemeralPublicKey"] + ); + assert_ne!(first_nonce, second_nonce); + let legacy = wrap_to_public_key_with_context( + public.as_bytes(), + &[1; 32], + &context, + KeyWrapVersion::V1, + ) + .unwrap(); + assert_eq!(legacy[32], 1); + assert_eq!( + open_wrapped_key(&secret, &legacy, &context).unwrap(), + [1; 32] + ); + } + + #[test] + fn shared_key_wrap_vectors_open_and_reemit() { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("../../tests/fixtures/key-wrap-v2.json")).unwrap(); + for vector in fixture["vectors"].as_array().unwrap() { + let decode = |field: &str| STANDARD.decode(vector[field].as_str().unwrap()).unwrap(); + let secret = + StaticSecret::from(<[u8; 32]>::try_from(decode("recipientPrivateKey")).unwrap()); + let ephemeral = + StaticSecret::from(<[u8; 32]>::try_from(decode("ephemeralPrivateKey")).unwrap()); + let metadata = serde_json::from_value(vector["context"]["metadata"].clone()).unwrap(); + let context = KeyWrapContext { + purpose: serde_json::from_value(vector["purpose"].clone()).unwrap(), + network_id: vector["context"]["networkId"].as_str().unwrap(), + key_path: vector["context"]["keyPath"].as_str().unwrap(), + key_version: vector["context"]["keyVersion"].as_i64().unwrap(), + metadata: Some(&metadata), + }; + assert_eq!( + PublicKey::from(&secret).as_bytes().as_slice(), + decode("recipientPublicKey") + ); + assert_eq!( + PublicKey::from(&ephemeral).as_bytes().as_slice(), + decode("ephemeralPublicKey") + ); + let wrapped = decode("wrappedKey"); + let plaintext = decode("plaintext"); + assert_eq!( + open_wrapped_key(&secret, &wrapped, &context).unwrap(), + plaintext, + "{}", + vector["name"] + ); + let nonce: [u8; 12] = decode("nonce").try_into().unwrap(); + let emitted = if vector["version"] == 2 { + wrap_v2_with_material( + PublicKey::from(&secret).as_bytes(), + &plaintext, + &context, + &ephemeral, + &decode("salt").try_into().unwrap(), + &nonce, + ) + .unwrap() + } else { + let shared = ephemeral.diffie_hellman(&PublicKey::from(&secret)); + let cipher = Aes256Gcm::new_from_slice(shared.as_bytes()).unwrap(); + let mut frame = PublicKey::from(&ephemeral).as_bytes().to_vec(); + frame.push(1); + frame.extend_from_slice(&nonce); + frame.extend_from_slice( + &cipher + .encrypt(&Nonce::from(nonce), plaintext.as_slice()) + .unwrap(), + ); + frame + }; + assert_eq!(emitted, wrapped, "{}", vector["name"]); + } + } + #[test] fn generate_and_unwrap_round_trip() { let backend = LocalOneOfOneBackend::new(seal_key()); let gk = backend.generate().unwrap(); let symmetric = [0xABu8; 32]; let wrapped = wrap_to_public_key(&gk.public_key, &symmetric).unwrap(); - let recovered = backend.unwrap(&gk.sealed_private_key, &wrapped).unwrap(); + let recovered = backend + .unwrap(&gk.sealed_private_key, &wrapped, &legacy_context()) + .unwrap(); assert_eq!(recovered, symmetric); } @@ -341,7 +949,14 @@ mod tests { let receiver_secret = StaticSecret::random_from_rng(OsRng); let receiver_pub = PublicKey::from(&receiver_secret); - let rewrapped = backend.rewrap(&symmetric, receiver_pub.as_bytes()).unwrap(); + let rewrapped = backend + .rewrap( + &symmetric, + receiver_pub.as_bytes(), + &legacy_context(), + KeyWrapVersion::V1, + ) + .unwrap(); let recovered = unwrap_with_secret(&receiver_secret, &rewrapped).unwrap(); assert_eq!(recovered, symmetric); } @@ -353,13 +968,22 @@ mod tests { let wrapped = wrap_to_public_key(&gk.public_key, &[1u8; 32]).unwrap(); let mut corrupted = gk.sealed_private_key.clone(); corrupted[5] ^= 0xFF; - assert!(backend.unwrap(&corrupted, &wrapped).is_err()); + assert!(backend + .unwrap(&corrupted, &wrapped, &legacy_context()) + .is_err()); } #[test] fn rejects_short_receiver_pubkey() { let backend = LocalOneOfOneBackend::new(seal_key()); - let err = backend.rewrap(&[0u8; 32], &[1u8; 10]).unwrap_err(); + let err = backend + .rewrap( + &[0u8; 32], + &[1u8; 10], + &legacy_context(), + KeyWrapVersion::V1, + ) + .unwrap_err(); assert!(matches!(err, KeyBackendError::InvalidPublicKey)); } @@ -515,14 +1139,16 @@ mod tests { let symmetric = [0x5Au8; 32]; let wrapped = wrap_to_public_key(&named_pub, &symmetric).unwrap(); let recovered = backend - .unwrap_named(&network, KAT_URN_NOTES, None, &wrapped) + .unwrap_named(&network, KAT_URN_NOTES, None, &wrapped, &legacy_context()) .unwrap(); assert_eq!(recovered, symmetric); // Wrapped to the notes key: a sibling key and the root key cannot open it. assert!(backend - .unwrap_named(&network, KAT_URN_A, None, &wrapped) + .unwrap_named(&network, KAT_URN_A, None, &wrapped, &legacy_context()) + .is_err()); + assert!(backend + .unwrap(&gk.sealed_private_key, &wrapped, &legacy_context()) .is_err()); - assert!(backend.unwrap(&gk.sealed_private_key, &wrapped).is_err()); } #[test] @@ -541,21 +1167,41 @@ mod tests { let wrapped = wrap_to_public_key(&public, &symmetric).unwrap(); assert_eq!( backend - .unwrap_named(&network, KAT_URN_NOTES, Some(&opened), &wrapped) + .unwrap_named( + &network, + KAT_URN_NOTES, + Some(&opened), + &wrapped, + &legacy_context() + ) .unwrap(), symmetric ); // Another seed, no seed, another URN and the network key cannot open it. assert!(backend - .unwrap_named(&network, KAT_URN_NOTES, Some(&other_seed), &wrapped) + .unwrap_named( + &network, + KAT_URN_NOTES, + Some(&other_seed), + &wrapped, + &legacy_context() + ) + .is_err()); + assert!(backend + .unwrap_named(&network, KAT_URN_NOTES, None, &wrapped, &legacy_context()) .is_err()); assert!(backend - .unwrap_named(&network, KAT_URN_NOTES, None, &wrapped) + .unwrap_named( + &network, + KAT_URN_A, + Some(&seed), + &wrapped, + &legacy_context() + ) .is_err()); assert!(backend - .unwrap_named(&network, KAT_URN_A, Some(&seed), &wrapped) + .unwrap(&gk.sealed_private_key, &wrapped, &legacy_context()) .is_err()); - assert!(backend.unwrap(&gk.sealed_private_key, &wrapped).is_err()); // The same URN with and without a seed names different keys. assert_ne!( public, diff --git a/tinycloud-core/src/encryption_network/keys.rs b/tinycloud-core/src/encryption_network/keys.rs index d297f6ff9..828445356 100644 --- a/tinycloud-core/src/encryption_network/keys.rs +++ b/tinycloud-core/src/encryption_network/keys.rs @@ -14,7 +14,7 @@ use sea_orm::{ use crate::db::begin_write_transaction; use crate::models::{encryption_network, encryption_network_key}; -use super::backend::{KeyBackendError, KeySeed}; +use super::backend::{KeyBackendError, KeySeed, KeyWrapContext, KeyWrapPurpose}; use super::key_id::{app_id_of, is_apps_path, EncryptionResource, KeyKind, KeyLocation}; use super::network_id::NetworkId; use super::service::{is_unique_violation, now_rfc3339, EncryptionService, EncryptionServiceError}; @@ -564,12 +564,10 @@ impl EncryptionService { }) } - /// Release-guard state read and unwrap. Order: network ACTIVE, then key row - /// exists and is ACTIVE, then (deletable keys) the requested key version - /// equals the row's, then strict unwrap. Callers must invoke this after - /// replay reservation. Any ECIES unwrap failure is `KeyUnwrapFailed`; a - /// failure to unseal the network key is `Backend`; a seed that does not - /// open strictly is `CorruptKeyRow`. + /// Release-guard state read and unwrap. Checks the network, key state and + /// authoritative key version before opening. Call after replay reservation. + /// Wrap failures are `KeyUnwrapFailed`; network unsealing is `Backend`; + /// a seed that does not open strictly is `CorruptKeyRow`. pub async fn unwrap_named_key( &self, loc: &KeyLocation, @@ -590,6 +588,9 @@ impl EncryptionService { if kind == KeyKind::Deletable && row.key_version != requested_key_version { return Err(EncryptionServiceError::KeyDeleted); } + if row.key_version != requested_key_version { + return Err(EncryptionServiceError::AlgKeyVersionMismatch); + } let sealed = net .sealed_private_key .as_deref() @@ -609,6 +610,14 @@ impl EncryptionService { ) } }; + let network_urn = network.to_string(); + let context = KeyWrapContext { + purpose: KeyWrapPurpose::NetworkKey, + network_id: &network_urn, + key_path: loc.path(), + key_version: row.key_version, + metadata: None, + }; let symmetric = self .backend .unwrap_named( @@ -616,6 +625,7 @@ impl EncryptionService { &canonical_key_urn(loc), seed.as_ref(), wrapped_key, + &context, ) .map_err(EncryptionServiceError::KeyUnwrapFailed)?; Ok((row.key_version, symmetric)) diff --git a/tinycloud-core/src/encryption_network/mod.rs b/tinycloud-core/src/encryption_network/mod.rs index ebea53791..700100db6 100644 --- a/tinycloud-core/src/encryption_network/mod.rs +++ b/tinycloud-core/src/encryption_network/mod.rs @@ -42,5 +42,5 @@ pub use service::{ }; pub use types::{ InlineEnvelope, KeyBackendKind, NetworkDescriptor, NetworkState, Threshold, - ALG_X25519_AES256GCM, + ALG_X25519_AES256GCM, ALG_X25519_HKDF_AES256GCM, }; diff --git a/tinycloud-core/src/encryption_network/protocol.rs b/tinycloud-core/src/encryption_network/protocol.rs index af5c9e6e1..cd2dd3d5e 100644 --- a/tinycloud-core/src/encryption_network/protocol.rs +++ b/tinycloud-core/src/encryption_network/protocol.rs @@ -37,6 +37,7 @@ pub struct DecryptRequestBody { pub target_node: String, #[serde(rename = "networkId")] pub network_id: NetworkId, + /// Algorithm of `encryptedSymmetricKey`, independent of descriptor/response algorithms. pub alg: String, #[serde(rename = "keyVersion")] pub key_version: i64, @@ -72,6 +73,7 @@ pub struct DecryptResponseBody { /// Base64-encoded symmetric key wrapped to the receiver public key. #[serde(rename = "wrappedKey")] pub wrapped_key: String, + /// Algorithm of the returned wrap, selected by the node's writer setting. pub alg: String, #[serde(rename = "keyVersion")] pub key_version: i64, diff --git a/tinycloud-core/src/encryption_network/service.rs b/tinycloud-core/src/encryption_network/service.rs index 46acca865..c1b5fce95 100644 --- a/tinycloud-core/src/encryption_network/service.rs +++ b/tinycloud-core/src/encryption_network/service.rs @@ -15,6 +15,7 @@ use serde_json::Value; use thiserror::Error; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; use tinycloud_auth::identity::did_principal_matches; +use zeroize::Zeroizing; use crate::db_pool::RecoveringConnection; use crate::hash::hash; @@ -25,7 +26,10 @@ use crate::models::{ }; use crate::util::InvocationInfo; -use super::backend::{validate_receiver_public_key, KeyBackend, KeyBackendError}; +use super::backend::{ + validate_receiver_public_key, wrapped_key_version, KeyBackend, KeyBackendError, KeyWrapContext, + KeyWrapPurpose, KeyWrapVersion, +}; use super::canonical::{canonical_hash, canonical_json_bytes, hash_hex}; use super::deletion_ledger::DeletionLedger; use super::key_id::{self, EncryptionResource, KeyLocation}; @@ -41,6 +45,7 @@ use super::protocol::{ }; use super::types::{ KeyBackendKind, NetworkDescriptor, NetworkMemberDescriptor, NetworkState, Threshold, + ALG_X25519_AES256GCM, ALG_X25519_HKDF_AES256GCM, }; const DEFAULT_INVOCATION_TTL_SECONDS: i64 = 300; @@ -134,16 +139,24 @@ fn check_decrypt_scope( } } -/// Checks alg and key version. Root keys are versioned by the network and -/// checked here; named keys are versioned by their own row and checked -/// against the body after unwrap in `decrypt_authorized`. +/// Accept both wrap algorithms independently of the response writer flag. +/// Root versions come from the network; named versions are checked against +/// the key row before opening in `unwrap_named_key`. fn check_alg_and_version( target: &DecryptTarget, descriptor: &NetworkDescriptor, body: &DecryptRequestBody, facts: &DecryptFacts, ) -> Result<(), EncryptionServiceError> { - if descriptor.alg != body.alg || descriptor.alg != facts.alg { + if descriptor.alg != ALG_X25519_AES256GCM + || !matches!( + body.alg.as_str(), + ALG_X25519_AES256GCM | ALG_X25519_HKDF_AES256GCM + ) + || body.alg != facts.alg + || body.key_version <= 0 + || body.key_version != facts.key_version + { return Err(EncryptionServiceError::AlgKeyVersionMismatch); } if matches!(target, DecryptTarget::Root(_)) @@ -155,6 +168,13 @@ fn check_alg_and_version( Ok(()) } +fn check_wrapped_algorithm(alg: &str, wrapped: &[u8]) -> Result<(), EncryptionServiceError> { + if wrapped_key_version(wrapped).alg() != alg { + return Err(EncryptionServiceError::AlgKeyVersionMismatch); + } + Ok(()) +} + /// Audit outcome for a named-key refusal after reservation. fn key_denial_outcome(err: &EncryptionServiceError) -> &'static str { match err { @@ -162,6 +182,7 @@ fn key_denial_outcome(err: &EncryptionServiceError) -> &'static str { EncryptionServiceError::KeyDeleted => "denied:key-deleted", EncryptionServiceError::KeyNotFound => "denied:key-not-found", EncryptionServiceError::KeyUnwrapFailed(_) => "denied:key-unwrap", + EncryptionServiceError::AlgKeyVersionMismatch => "denied:key-version", EncryptionServiceError::NetworkNotActive(_) | EncryptionServiceError::NetworkNotFound => { "denied:state" } @@ -247,6 +268,7 @@ pub struct EncryptionService { node_keypair: Option, pub(super) backend: Arc, invocation_ttl_seconds: i64, + key_wrap_version: KeyWrapVersion, pub(super) sqlite_writer_lock: Option>>, pub(super) key_cap_per_network: u64, pub(super) deletion_ledger: Option>, @@ -281,6 +303,7 @@ impl EncryptionService { node_keypair: None, backend, invocation_ttl_seconds: DEFAULT_INVOCATION_TTL_SECONDS, + key_wrap_version: KeyWrapVersion::V1, sqlite_writer_lock: None, key_cap_per_network: DEFAULT_KEY_CAP_PER_NETWORK, deletion_ledger: None, @@ -302,6 +325,7 @@ impl EncryptionService { node_keypair: Some(node_keypair), backend, invocation_ttl_seconds: DEFAULT_INVOCATION_TTL_SECONDS, + key_wrap_version: KeyWrapVersion::V1, sqlite_writer_lock: None, key_cap_per_network: DEFAULT_KEY_CAP_PER_NETWORK, deletion_ledger: None, @@ -311,6 +335,16 @@ impl EncryptionService { } } + /// Select v2 for response wraps only. Both request formats remain readable. + pub fn with_key_wrap_v2(mut self, enabled: bool) -> Self { + self.key_wrap_version = if enabled { + KeyWrapVersion::V2 + } else { + KeyWrapVersion::V1 + }; + self + } + pub fn with_sqlite_writer_lock(mut self, lock: Option>>) -> Self { self.sqlite_writer_lock = lock; self @@ -768,6 +802,12 @@ impl EncryptionService { if !network_id_matches(&cap.with, network_id) { return Err(EncryptionServiceError::NetworkMismatch); } + check_decrypt_scope( + &DecryptTarget::Root(network_id.clone()), + &cap.with, + &body, + &invocation.facts, + )?; if !did_principal_matches(&invocation.issuer, network_id.owner_did()) { return Err(EncryptionServiceError::OwnerMismatch); } @@ -789,14 +829,12 @@ impl EncryptionService { )); } } - if descriptor.alg != body.alg || descriptor.key_version != body.key_version { - return Err(EncryptionServiceError::AlgKeyVersionMismatch); - } - if descriptor.alg != invocation.facts.alg - || descriptor.key_version != invocation.facts.key_version - { - return Err(EncryptionServiceError::AlgKeyVersionMismatch); - } + check_alg_and_version( + &DecryptTarget::Root(network_id.clone()), + &descriptor, + &body, + &invocation.facts, + )?; // ---- Time bounds ---- self.validate_invocation_time(invocation.not_before, invocation.exp)?; @@ -806,6 +844,8 @@ impl EncryptionService { decode_base64(&body.receiver_public_key).map_err(EncryptionServiceError::Base64)?; let wrapped_key_bytes = decode_base64(&body.encrypted_symmetric_key).map_err(EncryptionServiceError::Base64)?; + check_receiver_public_key(&receiver_key_bytes)?; + check_wrapped_algorithm(&body.alg, &wrapped_key_bytes)?; let expected_receiver_hash = canonical_hash(&Value::String(body.receiver_public_key.clone())); @@ -854,8 +894,6 @@ impl EncryptionService { } // ---- Unwrap + rewrap ---- - let sealed = descriptor.key_backend; // unused but documents the choice - let _ = sealed; let network_row = encryption_network::Entity::find_by_id(network_id.to_string()) .one(&self.db) .await? @@ -867,12 +905,29 @@ impl EncryptionService { .ok_or(EncryptionServiceError::Backend( KeyBackendError::SealedKeyMissing, ))?; - let symmetric = self - .backend - .unwrap(sealed_private_key, &wrapped_key_bytes)?; - let rewrapped = self.backend.rewrap(&symmetric, &receiver_key_bytes)?; - // Best-effort: zeroize the transient symmetric key copy. - drop(symmetric); + let network_urn = network_id.to_string(); + let context = KeyWrapContext { + purpose: KeyWrapPurpose::NetworkKey, + network_id: &network_urn, + key_path: "", + key_version: descriptor.key_version, + metadata: None, + }; + let symmetric = Zeroizing::new(self.backend.unwrap( + sealed_private_key, + &wrapped_key_bytes, + &context, + )?); + let response_context = KeyWrapContext { + purpose: KeyWrapPurpose::ReceiverKey, + ..context + }; + let rewrapped = self.backend.rewrap( + &symmetric, + &receiver_key_bytes, + &response_context, + self.key_wrap_version, + )?; let mut response = DecryptResponseBody { ty: DECRYPT_RESULT_TYPE.to_string(), @@ -882,7 +937,7 @@ impl EncryptionService { encrypted_symmetric_key_hash: expected_key_hash, receiver_public_key_hash: expected_receiver_hash, wrapped_key: encode_base64(&rewrapped), - alg: descriptor.alg.clone(), + alg: self.key_wrap_version.alg().to_string(), key_version: descriptor.key_version, request_hash: request_hash.clone(), node_id: self.node_did.clone(), @@ -943,7 +998,9 @@ impl EncryptionService { let receiver_key = decode_base64(&body.receiver_public_key).map_err(EncryptionServiceError::Base64)?; check_receiver_public_key(&receiver_key)?; - decode_base64(&body.encrypted_symmetric_key).map_err(EncryptionServiceError::Base64)?; + let wrapped = + decode_base64(&body.encrypted_symmetric_key).map_err(EncryptionServiceError::Base64)?; + check_wrapped_algorithm(&body.alg, &wrapped)?; let receiver_hash = canonical_hash(&Value::String(body.receiver_public_key.clone())); let key_hash = canonical_hash(&Value::String(body.encrypted_symmetric_key.clone())); if receiver_hash != body.receiver_public_key_hash @@ -1018,6 +1075,7 @@ impl EncryptionService { check_receiver_public_key(&receiver_key_bytes)?; let wrapped_key_bytes = decode_base64(&body.encrypted_symmetric_key).map_err(EncryptionServiceError::Base64)?; + check_wrapped_algorithm(&body.alg, &wrapped_key_bytes)?; let expected_receiver_hash = canonical_hash(&Value::String(body.receiver_public_key.clone())); @@ -1058,6 +1116,14 @@ impl EncryptionService { // encryption_nonce table a second time. The direct encryption route also reaches this // helper without a reservation of its own; that gap is unchanged here. + let network_urn = network_id.to_string(); + let context = KeyWrapContext { + purpose: KeyWrapPurpose::NetworkKey, + network_id: &network_urn, + key_path: body.key_path.as_deref().unwrap_or(""), + key_version: body.key_version, + metadata: None, + }; let (key_version, symmetric) = match target { DecryptTarget::Root(_) => { let sealed_private_key = @@ -1068,7 +1134,7 @@ impl EncryptionService { ))?; let symmetric = self .backend - .unwrap(sealed_private_key, &wrapped_key_bytes) + .unwrap(sealed_private_key, &wrapped_key_bytes, &context) .map_err(|err| { if matches!( err, @@ -1101,16 +1167,21 @@ impl EncryptionService { return Err(err); } }; - if key_version != body.key_version || key_version != facts.key_version { - self.record_native_audit(invocation, target, &facts, "denied:key-version") - .await?; - return Err(EncryptionServiceError::AlgKeyVersionMismatch); - } (key_version, symmetric) } }; - let rewrapped = self.backend.rewrap(&symmetric, &receiver_key_bytes)?; - drop(symmetric); + let symmetric = Zeroizing::new(symmetric); + let response_context = KeyWrapContext { + purpose: KeyWrapPurpose::ReceiverKey, + key_version, + ..context + }; + let rewrapped = self.backend.rewrap( + &symmetric, + &receiver_key_bytes, + &response_context, + self.key_wrap_version, + )?; let mut response = DecryptResponseBody { ty: DECRYPT_RESULT_TYPE.to_string(), @@ -1120,7 +1191,7 @@ impl EncryptionService { encrypted_symmetric_key_hash: expected_key_hash, receiver_public_key_hash: expected_receiver_hash, wrapped_key: encode_base64(&rewrapped), - alg: descriptor.alg.clone(), + alg: self.key_wrap_version.alg().to_string(), key_version, request_hash: request_hash.clone(), node_id: self.node_did.clone(), diff --git a/tinycloud-core/src/encryption_network/tests.rs b/tinycloud-core/src/encryption_network/tests.rs index 01e930863..79908cdcb 100644 --- a/tinycloud-core/src/encryption_network/tests.rs +++ b/tinycloud-core/src/encryption_network/tests.rs @@ -2405,3 +2405,253 @@ async fn postgres_key_writes_wait_for_the_network_row_lock() { result.unwrap_or_else(|e| panic!("{operation}: {e}")); } } + +#[tokio::test] +async fn wrap_versions_are_independent_on_native_root_and_named_decrypt() { + use super::backend::{ + open_wrapped_key, wrap_to_public_key_with_context, KeyWrapContext, KeyWrapPurpose, + KeyWrapVersion, + }; + for writer_v2 in [false, true] { + for incoming in [KeyWrapVersion::V1, KeyWrapVersion::V2] { + for path in ["", "docs/alpha", "apps/notes/alpha"] { + let svc = make_service(fresh_db().await).with_key_wrap_v2(writer_v2); + create_network(&svc).await; + let net = network_id(); + let public = if path.is_empty() { + svc.get_network(&net).await.unwrap().public_encryption_key + } else { + svc.create_key(&key_loc(path), path.starts_with("apps/")) + .await + .unwrap() + .public_encryption_key + }; + assert_eq!( + svc.get_network(&net).await.unwrap().alg, + ALG_X25519_AES256GCM + ); + let network = net.to_string(); + let metadata = BTreeMap::from([("contentType".into(), "text/plain".into())]); + let context = KeyWrapContext { + purpose: KeyWrapPurpose::NetworkKey, + network_id: &network, + key_path: path, + key_version: 1, + metadata: Some(&metadata), + }; + let mut ctx = make_client_request(&public); + ctx.wrapped_key = + wrap_to_public_key_with_context(&public, &ctx.symmetric, &context, incoming) + .unwrap(); + let (mut body, _) = build_body(&ctx, &net); + body.alg = incoming.alg().to_string(); + body.key_path = (!path.is_empty()).then(|| path.to_string()); + let body_value = serde_json::to_value(&body).unwrap(); + let facts = serde_json::to_value(DecryptFacts { + ty: DECRYPT_REQUEST_TYPE.into(), + target_node: NODE_DID.into(), + network_id: net.clone(), + body_hash: canonical_hash(&body_value), + encrypted_symmetric_key_hash: body.encrypted_symmetric_key_hash.clone(), + receiver_public_key_hash: body.receiver_public_key_hash.clone(), + alg: body.alg.clone(), + key_version: 1, + key_path: body.key_path.clone(), + }) + .unwrap(); + let target = if path.is_empty() { + DecryptTarget::Root(net.clone()) + } else { + DecryptTarget::from_resource(&canonical_key_urn(&key_loc(path))).unwrap() + }; + let resource = if path.is_empty() { + network.clone() + } else { + canonical_key_urn(&key_loc(path)) + }; + let invocation = + build_session_invocation_for(&resource, DECRYPT_ACTION, vec![facts], NODE_DID); + svc.validate_authorized_decrypt_request(&target, &invocation, &body_value) + .await + .unwrap(); + let response = svc + .decrypt_authorized(&target, &invocation, &body_value) + .await + .unwrap() + .response; + let output_version = if writer_v2 { + KeyWrapVersion::V2 + } else { + KeyWrapVersion::V1 + }; + assert_eq!(response.alg, output_version.alg()); + let empty = BTreeMap::new(); + let expected = KeyWrapContext { + purpose: KeyWrapPurpose::ReceiverKey, + metadata: Some(&empty), + ..context + }; + assert_eq!( + open_wrapped_key( + &ctx.receiver_secret, + &STANDARD.decode(response.wrapped_key).unwrap(), + &expected + ) + .unwrap(), + ctx.symmetric, + ); + } + } + } +} + +#[tokio::test] +async fn standalone_decrypt_reads_both_versions_and_uses_writer_flag() { + use super::backend::{ + open_wrapped_key, wrap_to_public_key_with_context, KeyWrapContext, KeyWrapPurpose, + KeyWrapVersion, + }; + for writer_v2 in [false, true] { + for incoming in [KeyWrapVersion::V1, KeyWrapVersion::V2] { + let svc = make_service(fresh_db().await).with_key_wrap_v2(writer_v2); + create_network(&svc).await; + let net = network_id(); + let public = svc.get_network(&net).await.unwrap().public_encryption_key; + let network = net.to_string(); + let empty = BTreeMap::new(); + let context = KeyWrapContext { + purpose: KeyWrapPurpose::NetworkKey, + network_id: &network, + key_path: "", + key_version: 1, + metadata: Some(&empty), + }; + let mut ctx = make_client_request(&public); + ctx.wrapped_key = + wrap_to_public_key_with_context(&public, &ctx.symmetric, &context, incoming) + .unwrap(); + let (mut body, _) = build_body(&ctx, &net); + body.alg = incoming.alg().into(); + let body_value = serde_json::to_value(body).unwrap(); + let inv = build_invocation(&net, &body_value, &ctx, |inv| { + inv.facts.alg = incoming.alg().into() + }); + let response = svc.decrypt(&net, &inv, &body_value).await.unwrap().response; + assert_eq!( + response.alg, + if writer_v2 { + KeyWrapVersion::V2.alg() + } else { + KeyWrapVersion::V1.alg() + } + ); + let expected = KeyWrapContext { + purpose: KeyWrapPurpose::ReceiverKey, + ..context + }; + assert_eq!( + open_wrapped_key( + &ctx.receiver_secret, + &STANDARD.decode(response.wrapped_key).unwrap(), + &expected + ) + .unwrap(), + ctx.symmetric + ); + } + } +} + +#[tokio::test] +async fn native_decrypt_checks_wrap_context_and_outer_algorithm() { + use super::backend::{ + wrap_to_public_key_with_context, KeyWrapContext, KeyWrapPurpose, KeyWrapVersion, + }; + for path in ["", "docs/alpha", "apps/notes/alpha"] { + for mismatch in [ + "network", + "path", + "version", + "purpose", + "algorithm", + "algorithm-v1", + ] { + let svc = make_service(fresh_db().await); + create_network(&svc).await; + let net = network_id(); + let network = net.to_string(); + let public = if path.is_empty() { + svc.get_network(&net).await.unwrap().public_encryption_key + } else { + svc.create_key(&key_loc(path), path.starts_with("apps/")) + .await + .unwrap() + .public_encryption_key + }; + let context = KeyWrapContext { + purpose: if mismatch == "purpose" { + KeyWrapPurpose::ReceiverKey + } else { + KeyWrapPurpose::NetworkKey + }, + network_id: if mismatch == "network" { + "urn:tinycloud:encryption:did:key:z6Mk:other" + } else { + &network + }, + key_path: if mismatch == "path" { + "docs/beta" + } else { + path + }, + key_version: if mismatch == "version" { 2 } else { 1 }, + metadata: None, + }; + let mut ctx = make_client_request(&public); + let incoming = if mismatch == "algorithm-v1" { + KeyWrapVersion::V1 + } else { + KeyWrapVersion::V2 + }; + ctx.wrapped_key = + wrap_to_public_key_with_context(&public, &ctx.symmetric, &context, incoming) + .unwrap(); + let (mut body, _) = build_body(&ctx, &net); + body.key_path = (!path.is_empty()).then(|| path.to_string()); + body.alg = if mismatch == "algorithm" { + KeyWrapVersion::V1 + } else { + KeyWrapVersion::V2 + } + .alg() + .into(); + let body_value = serde_json::to_value(&body).unwrap(); + let facts = serde_json::to_value(DecryptFacts { + ty: DECRYPT_REQUEST_TYPE.into(), + target_node: NODE_DID.into(), + network_id: net.clone(), + body_hash: canonical_hash(&body_value), + encrypted_symmetric_key_hash: body.encrypted_symmetric_key_hash.clone(), + receiver_public_key_hash: body.receiver_public_key_hash.clone(), + alg: body.alg.clone(), + key_version: 1, + key_path: body.key_path.clone(), + }) + .unwrap(); + let resource = if path.is_empty() { + network.clone() + } else { + canonical_key_urn(&key_loc(path)) + }; + let target = DecryptTarget::from_resource(&resource).unwrap(); + let invocation = + build_session_invocation_for(&resource, DECRYPT_ACTION, vec![facts], NODE_DID); + assert!( + svc.decrypt_authorized(&target, &invocation, &body_value) + .await + .is_err(), + "{path}: {mismatch}" + ); + } + } +} diff --git a/tinycloud-core/src/encryption_network/types.rs b/tinycloud-core/src/encryption_network/types.rs index 890f7dadf..64797b1bb 100644 --- a/tinycloud-core/src/encryption_network/types.rs +++ b/tinycloud-core/src/encryption_network/types.rs @@ -6,6 +6,7 @@ use std::fmt; use super::network_id::NetworkId; pub const ALG_X25519_AES256GCM: &str = "x25519-aes256gcm/v1"; +pub const ALG_X25519_HKDF_AES256GCM: &str = "x25519-hkdf-aes256gcm/v2"; /// Lifecycle state of a network. V1 transitions: Pending → Generating → Active. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/tinycloud-core/tests/fixtures/key-wrap-v2.json b/tinycloud-core/tests/fixtures/key-wrap-v2.json new file mode 100644 index 000000000..e7eee97fc --- /dev/null +++ b/tinycloud-core/tests/fixtures/key-wrap-v2.json @@ -0,0 +1,89 @@ +{ + "description": "Static public interoperability test material. Reference vectors use Node.js crypto; sdk-generated-v2 uses the SDK sealKeyV2 with scoped deterministic test randomness.", + "vectors": [ + { + "name": "legacy-v1", + "version": 1, + "purpose": "network-key", + "recipientPrivateKey": "ERERERERERERERERERERERERERERERERERERERERERE=", + "recipientPublicKey": "e06Qm75//kTEZaIgA31gjuNYl9Me+XLwf3SJLLD3PxM=", + "ephemeralPrivateKey": "EhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhI=", + "ephemeralPublicKey": "BSpQdzrI2Rdz8tyWYuEvDe/pFeQVuKHI4gpaPWqyuEM=", + "plaintext": "ERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzA=", + "salt": "", + "nonce": "FBQUFBQUFBQUFBQU", + "context": { + "networkId": "urn:tinycloud:encryption:did:key:z6MkWrapFixture:default", + "keyPath": "", + "keyVersion": 1, + "metadata": {} + }, + "wrappedKey": "BSpQdzrI2Rdz8tyWYuEvDe/pFeQVuKHI4gpaPWqyuEMBFBQUFBQUFBQUFBQUJ54pMnxu+ki2vhGwb6+s3rJpT9dZa3QISXbC/rfHwhxCGd1IGQPqmmYC1oB+PRSI" + }, + { + "name": "sdk-generated-v2", + "version": 2, + "purpose": "network-key", + "recipientPrivateKey": "ISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISE=", + "recipientPublicKey": "fTSkgV+muYJTXmCvO9m0lVaBYIDxZB/4HSt8iugmikQ=", + "ephemeralPrivateKey": "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI=", + "ephemeralPublicKey": "D6poTtKIZ7l/Smot7l34zpdOdrcBjj8iocTPJnhXDyA=", + "plaintext": "ISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A=", + "salt": "IyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyM=", + "nonce": "JCQkJCQkJCQkJCQk", + "context": { + "networkId": "urn:tinycloud:encryption:did:key:z6MkWrapFixture:default", + "keyPath": "", + "keyVersion": 1, + "metadata": { + "app": "fixture", + "contentType": "application/octet-stream" + } + }, + "wrappedKey": "VENXMgAAAdB7ImFlYWQiOiJhZXMtMjU2LWdjbSIsImFsZyI6IngyNTUxOS1oa2RmLWFlczI1NmdjbS92MiIsImVwaGVtZXJhbFB1YmxpY0tleSI6IkQ2cG9UdEtJWjdsL1Ntb3Q3bDM0enBkT2RyY0Jqajhpb2NUUEpuaFhEeUE9Iiwia2RmIjoiaGtkZi1zaGEyNTYiLCJrZXlQYXRoIjoiIiwia2V5VmVyc2lvbiI6MSwibWV0YWRhdGEiOnsiYXBwIjoiZml4dHVyZSIsImNvbnRlbnRUeXBlIjoiYXBwbGljYXRpb24vb2N0ZXQtc3RyZWFtIn0sIm5ldHdvcmtJZCI6InVybjp0aW55Y2xvdWQ6ZW5jcnlwdGlvbjpkaWQ6a2V5Ono2TWtXcmFwRml4dHVyZTpkZWZhdWx0IiwicHVycG9zZSI6Im5ldHdvcmsta2V5IiwicmVjaXBpZW50UHVibGljS2V5IjoiZlRTa2dWK211WUpUWG1Ddk85bTBsVmFCWUlEeFpCLzRIU3Q4aXVnbWlrUT0iLCJzYWx0IjoiSXlNakl5TWpJeU1qSXlNakl5TWpJeU1qSXlNakl5TWpJeU1qSXlNakl5TT0iLCJ2IjoyfSQkJCQkJCQkJCQkJBPZw+o5yk+HWnNRCtR5QYtaVQEI0tZQgDttBu6zV40W4EGbPNZizXZzWmJBmH5FCA==" + }, + { + "name": "named-v2", + "version": 2, + "purpose": "network-key", + "recipientPrivateKey": "MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTE=", + "recipientPublicKey": "BPXykWLDGo3voY5udCIk7oBvwXGKJ4voWbpWIEArjzo=", + "ephemeralPrivateKey": "MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjI=", + "ephemeralPublicKey": "WdkiVHNFHv/+azbbyu/b97GJXeYghFCaf1tYvwHQZBg=", + "plaintext": "MTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1A=", + "salt": "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM=", + "nonce": "NDQ0NDQ0NDQ0NDQ0", + "context": { + "networkId": "urn:tinycloud:encryption:did:key:z6MkWrapFixture:default", + "keyPath": "apps/fixture/key", + "keyVersion": 3, + "metadata": { + "label": "caf\u0000é", + "order": "✓", + "𐀀": "astral", + "": "bmp" + } + }, + "wrappedKey": "VENXMgAAAed7ImFlYWQiOiJhZXMtMjU2LWdjbSIsImFsZyI6IngyNTUxOS1oa2RmLWFlczI1NmdjbS92MiIsImVwaGVtZXJhbFB1YmxpY0tleSI6Ildka2lWSE5GSHYvK2F6YmJ5dS9iOTdHSlhlWWdoRkNhZjF0WXZ3SFFaQmc9Iiwia2RmIjoiaGtkZi1zaGEyNTYiLCJrZXlQYXRoIjoiYXBwcy9maXh0dXJlL2tleSIsImtleVZlcnNpb24iOjMsIm1ldGFkYXRhIjp7ImxhYmVsIjoiY2FmXHUwMDAww6kiLCJvcmRlciI6IuKckyIsIvCQgIAiOiJhc3RyYWwiLCLugIAiOiJibXAifSwibmV0d29ya0lkIjoidXJuOnRpbnljbG91ZDplbmNyeXB0aW9uOmRpZDprZXk6ejZNa1dyYXBGaXh0dXJlOmRlZmF1bHQiLCJwdXJwb3NlIjoibmV0d29yay1rZXkiLCJyZWNpcGllbnRQdWJsaWNLZXkiOiJCUFh5a1dMREdvM3ZvWTV1ZENJazdvQnZ3WEdLSjR2b1dicFdJRUFyanpvPSIsInNhbHQiOiJNek16TXpNek16TXpNek16TXpNek16TXpNek16TXpNek16TXpNek16TXpNPSIsInYiOjJ9NDQ0NDQ0NDQ0NDQ0owCQJmjuROmk8dl40gqCjKq6+2bteJ0dsA5BN3J99rDHn+RgUIf2vontFlU4K8Qc" + }, + { + "name": "receiver-v2", + "version": 2, + "purpose": "receiver-key", + "recipientPrivateKey": "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE=", + "recipientPublicKey": "ehpOcJvwhaxJSroEabmx7aCrH3ixaqu3n/7akGI+hSI=", + "ephemeralPrivateKey": "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=", + "ephemeralPublicKey": "EyxEK+AQ+9V+cmAzKKp25x/MwVA6riGTJ9FNnJmT9HI=", + "plaintext": "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2A=", + "salt": "Q0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0M=", + "nonce": "RERERERERERERERE", + "context": { + "networkId": "urn:tinycloud:encryption:did:key:z6MkWrapFixture:default", + "keyPath": "apps/fixture/key", + "keyVersion": 3, + "metadata": {} + }, + "wrappedKey": "VENXMgAAAal7ImFlYWQiOiJhZXMtMjU2LWdjbSIsImFsZyI6IngyNTUxOS1oa2RmLWFlczI1NmdjbS92MiIsImVwaGVtZXJhbFB1YmxpY0tleSI6IkV5eEVLK0FRKzlWK2NtQXpLS3AyNXgvTXdWQTZyaUdUSjlGTm5KbVQ5SEk9Iiwia2RmIjoiaGtkZi1zaGEyNTYiLCJrZXlQYXRoIjoiYXBwcy9maXh0dXJlL2tleSIsImtleVZlcnNpb24iOjMsIm1ldGFkYXRhIjp7fSwibmV0d29ya0lkIjoidXJuOnRpbnljbG91ZDplbmNyeXB0aW9uOmRpZDprZXk6ejZNa1dyYXBGaXh0dXJlOmRlZmF1bHQiLCJwdXJwb3NlIjoicmVjZWl2ZXIta2V5IiwicmVjaXBpZW50UHVibGljS2V5IjoiZWhwT2NKdndoYXhKU3JvRWFibXg3YUNySDNpeGFxdTNuLzdha0dJK2hTST0iLCJzYWx0IjoiUTBORFEwTkRRME5EUTBORFEwTkRRME5EUTBORFEwTkRRME5EUTBORFEwTT0iLCJ2IjoyfURERERERERERERERFXmKeN/IoR0D5LDhXka5iOqjE0g6XLYhAI5yoSEm4nyYHVj7DnkNCCVhFwHwmFgXA==" + } + ] +} diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index 0b49fba87..b759e09e7 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -77,6 +77,9 @@ pub struct Config { pub struct EncryptionNetworkConfig { #[serde(default)] pub named_keys_v1: bool, + /// Select v2 response wraps; requests in either format are always accepted. + #[serde(default)] + pub key_wrap_v2: bool, #[serde(default)] pub deletion_ledger_path: Option, } @@ -2236,6 +2239,16 @@ mod tests { assert!(config.named_keys_v1); } + #[test] + fn encryption_network_wrap_v2_is_default_off_and_opt_in() { + assert!(!Config::default().encryption_network.key_wrap_v2); + let default: EncryptionNetworkConfig = toml::from_str("").unwrap(); + assert!(!default.key_wrap_v2); + let enabled: EncryptionNetworkConfig = toml::from_str("key_wrap_v2 = true").unwrap(); + assert!(enabled.key_wrap_v2); + assert!(enabled.validate().is_ok()); + } + #[test] fn deletion_ledger_path_is_required_while_named_keys_are_on() { let off: EncryptionNetworkConfig = toml::from_str("").unwrap(); @@ -2259,6 +2272,7 @@ mod tests { for bad in ["", "ledger.jsonl", "/var/lib/", "/a/../b"] { let config = EncryptionNetworkConfig { named_keys_v1: false, + key_wrap_v2: false, deletion_ledger_path: Some(bad.into()), }; assert!(config.validate().is_err(), "{bad:?}"); diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index 681a85b8d..f3738fa69 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -818,7 +818,8 @@ pub async fn app_with_control( database_connection.clone(), node_keypair, encryption_backend, - ); + ) + .with_key_wrap_v2(tinycloud_config.encryption_network.key_wrap_v2); // Migrate before opening S3: its one bucket listing both reconstructs // SpaceSizes and records which in-flight DELETE fences were counted. diff --git a/tinycloud.toml b/tinycloud.toml index 167e353b5..54561192a 100644 --- a/tinycloud.toml +++ b/tinycloud.toml @@ -94,6 +94,9 @@ cors = true # network_methods = false [global.encryption_network] + ## Emit TCW2 response wraps. Both v1 and v2 requests are accepted regardless. + ## Env: TINYCLOUD_ENCRYPTION_NETWORK__KEY_WRAP_V2 + key_wrap_v2 = false ## 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 From fc8ead5c2da4d41788134f2be307e8fcb5e5bd50 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:15:36 +0000 Subject: [PATCH 2/3] fix(encryption): configure wrap readers and sealed key formats Details: TC-2518, TC-2517 --- CHANGELOG.md | 2 +- docs/encryption-named-keys.md | 10 ++- .../src/encryption_network/backend.rs | 73 ++++++++++++++++++- .../src/encryption_network/service.rs | 22 +++++- .../src/encryption_network/tests.rs | 65 ++++++++++++++--- tinycloud-node-server/src/config.rs | 18 ++++- tinycloud-node-server/src/lib.rs | 3 +- tinycloud.toml | 5 +- 8 files changed, 180 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 766d08c4a..7941f686f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] ### Changes -- Encryption root and named keys read both v1 and TCW2 key wraps. Response wraps remain v1 by default; `[global.encryption_network] key_wrap_v2 = true` selects v2, with the response algorithm describing its actual format. Descriptors and payload schemas are unchanged. Details: TC-2518 +- Encryption root and named keys read both v1 and TCW2 key wraps by default. The independent, default-off `key_wrap_require_v2` policy requires v2 incoming network-key wraps without restricting receiver responses. Response wraps remain v1 by default; `key_wrap_v2 = true` selects v2. V1 writers avoid the reserved TCW2 prefix, malformed surrogate headers are refused, and stored network secrets require encrypted seals. Descriptors and payload schemas are unchanged. Details: TC-2518 - 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 - Share-email issuer trust bundles can pin one next key for the same issuer DID during an explicit UTC overlap window. The legacy key stops at `issuerNotAfter`; malformed, duplicate, unbounded or expired overlaps fail startup and `--validate-config`. Existing single-key bundles remain unchanged. This is node-side trust support only: the OpenCredentials witness and js-sdk policy contract must also support key rotation before activation. Older node images reject overlap fields; restore the previous single-key bundle before rollback. See `deploy/share-email/production-trust-bundle-contract.md`. diff --git a/docs/encryption-named-keys.md b/docs/encryption-named-keys.md index f057406e7..2f57e913d 100644 --- a/docs/encryption-named-keys.md +++ b/docs/encryption-named-keys.md @@ -17,11 +17,19 @@ returned receiver wrap independently. `[global.encryption_network] key_wrap_v2 = false` keeps response writes on v1. Set it to `true` (or `TINYCLOUD_ENCRYPTION_NETWORK__KEY_WRAP_V2=true`) only when -receiving clients support v2. The flag does not restrict either read format. +receiving clients support v2. Independently, `key_wrap_require_v2 = true` (or +`TINYCLOUD_ENCRYPTION_NETWORK__KEY_WRAP_REQUIRE_V2=true`) rejects v1 incoming +network-key wraps for root and named-key decrypt. This reader policy defaults +to `false` and does not require v2 receiver responses: v2 requests may still +receive signed v1 responses while `key_wrap_v2` remains off. TCW2 uses X25519, HKDF-SHA256 and AES-256-GCM with a canonical authenticated header binding the recipient, purpose, network, exact key path, key version and string metadata. Receiver wraps use empty metadata. Key version is checked against the network or named-key row before opening. +V1 writers reroll ephemeral public keys whose prefix is `TCW2`, keeping format +detection unambiguous. Header strings must be valid Unicode; malformed escaped +surrogates are rejected by JSON parsing. Stored network secrets and named-key +seeds require versioned encrypted seals, never plaintext. Details: TC-2518 diff --git a/tinycloud-core/src/encryption_network/backend.rs b/tinycloud-core/src/encryption_network/backend.rs index 79b8dd164..37e3bfdfd 100644 --- a/tinycloud-core/src/encryption_network/backend.rs +++ b/tinycloud-core/src/encryption_network/backend.rs @@ -395,6 +395,10 @@ pub trait KeyBackend: Send + Sync { fn open_seed(&self, sealed_seed: &[u8]) -> Result; } +fn valid_v1_ephemeral_prefix(public: &[u8]) -> bool { + !public.starts_with(WRAP_V2_MAGIC) +} + /// X25519 ECIES-style key wrap backed by AES-256-GCM. Used both for wrapping to /// the network public key (client side) and to the receiver public key (decrypt /// response). @@ -409,8 +413,13 @@ pub fn wrap_to_public_key( recipient_array.copy_from_slice(recipient_public_key); let recipient = PublicKey::from(recipient_array); - let ephemeral = StaticSecret::random_from_rng(OsRng); - let ephemeral_pub = PublicKey::from(&ephemeral); + let (ephemeral, ephemeral_pub) = loop { + let ephemeral = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&ephemeral); + if valid_v1_ephemeral_prefix(public.as_bytes()) { + break (ephemeral, public); + } + }; let shared = ephemeral.diffie_hellman(&recipient); if !shared.was_contributory() { return Err(KeyBackendError::NonContributory); @@ -529,7 +538,7 @@ impl KeyBackend for LocalOneOfOneBackend { &self, sealed_private_key: &[u8], ) -> Result { - let opened = Zeroizing::new(self.seal.decrypt(sealed_private_key)?); + let opened = Zeroizing::new(self.seal.decrypt_strict(sealed_private_key)?); if opened.len() != 32 { return Err(KeyBackendError::InvalidSealedKey); } @@ -642,6 +651,64 @@ mod tests { } } + #[test] + fn v1_ephemeral_prefix_excludes_v2_magic() { + let mut public = [0u8; 32]; + public[..4].copy_from_slice(b"TCW2"); + assert!(!valid_v1_ephemeral_prefix(&public)); + public[3] = b'1'; + assert!(valid_v1_ephemeral_prefix(&public)); + let recipient = StaticSecret::from([9u8; 32]); + let wrapped = + wrap_to_public_key(PublicKey::from(&recipient).as_bytes(), &[42; 32]).unwrap(); + assert!(valid_v1_ephemeral_prefix(&wrapped[..32])); + assert_eq!(unwrap_with_secret(&recipient, &wrapped).unwrap(), [42; 32]); + } + + #[test] + fn network_secrets_require_sealed_storage_and_new_seals_are_versioned() { + let backend = LocalOneOfOneBackend::new(seal_key()); + assert!(backend.open_network_secret(&[7u8; 32]).is_err()); + let generated = backend.generate().unwrap(); + assert_eq!(generated.sealed_private_key[0], 0x01); + assert!(backend + .open_network_secret(&generated.sealed_private_key) + .is_ok()); + let (_, seed) = backend.generate_seed().unwrap(); + assert_eq!(seed[0], 0x01); + assert!(backend.open_seed(&seed).is_ok()); + } + + #[test] + fn malformed_surrogates_are_rejected_at_header_parse_boundary() { + let metadata = BTreeMap::from([("key".into(), "value".into())]); + let secret = StaticSecret::from([9u8; 32]); + let context = wrap_context(&metadata); + let wrapped = wrap_to_public_key_with_context( + PublicKey::from(&secret).as_bytes(), + &[42; 32], + &context, + KeyWrapVersion::V2, + ) + .unwrap(); + let length = u32::from_be_bytes(wrapped[4..8].try_into().unwrap()) as usize; + let header = std::str::from_utf8(&wrapped[8..8 + length]).unwrap(); + for surrogate in [r#""\ud800""#, r#""\udfff""#] { + for original in [r#""key""#, r#""value""#, r#""docs/alpha""#] { + let malformed = header.replace(original, surrogate); + assert!(serde_json::from_str::(&malformed).is_err()); + let mut frame = WRAP_V2_MAGIC.to_vec(); + frame.extend_from_slice(&(malformed.len() as u32).to_be_bytes()); + frame.extend_from_slice(malformed.as_bytes()); + frame.extend_from_slice(&wrapped[8 + length..]); + assert!(matches!( + open_wrapped_key(&secret, &frame, &context), + Err(KeyBackendError::InvalidWrappedKey) + )); + } + } + } + #[test] fn v2_wrap_binds_context_and_both_public_keys() { let metadata = std::collections::BTreeMap::from([ diff --git a/tinycloud-core/src/encryption_network/service.rs b/tinycloud-core/src/encryption_network/service.rs index c1b5fce95..ac9094d52 100644 --- a/tinycloud-core/src/encryption_network/service.rs +++ b/tinycloud-core/src/encryption_network/service.rs @@ -269,6 +269,7 @@ pub struct EncryptionService { pub(super) backend: Arc, invocation_ttl_seconds: i64, key_wrap_version: KeyWrapVersion, + key_wrap_require_v2: bool, pub(super) sqlite_writer_lock: Option>>, pub(super) key_cap_per_network: u64, pub(super) deletion_ledger: Option>, @@ -304,6 +305,7 @@ impl EncryptionService { backend, invocation_ttl_seconds: DEFAULT_INVOCATION_TTL_SECONDS, key_wrap_version: KeyWrapVersion::V1, + key_wrap_require_v2: false, sqlite_writer_lock: None, key_cap_per_network: DEFAULT_KEY_CAP_PER_NETWORK, deletion_ledger: None, @@ -326,6 +328,7 @@ impl EncryptionService { backend, invocation_ttl_seconds: DEFAULT_INVOCATION_TTL_SECONDS, key_wrap_version: KeyWrapVersion::V1, + key_wrap_require_v2: false, sqlite_writer_lock: None, key_cap_per_network: DEFAULT_KEY_CAP_PER_NETWORK, deletion_ledger: None, @@ -335,7 +338,7 @@ impl EncryptionService { } } - /// Select v2 for response wraps only. Both request formats remain readable. + /// Select v2 for response wraps only, independently of request policy. pub fn with_key_wrap_v2(mut self, enabled: bool) -> Self { self.key_wrap_version = if enabled { KeyWrapVersion::V2 @@ -345,6 +348,21 @@ impl EncryptionService { self } + /// Require v2 incoming network-key wraps without restricting receiver responses. + pub fn with_key_wrap_require_v2(mut self, enabled: bool) -> Self { + self.key_wrap_require_v2 = enabled; + self + } + + fn check_key_wrap_policy(&self, wrapped: &[u8]) -> Result<(), EncryptionServiceError> { + if self.key_wrap_require_v2 && wrapped_key_version(wrapped) != KeyWrapVersion::V2 { + return Err(EncryptionServiceError::KeyUnwrapFailed( + KeyBackendError::InvalidWrappedKey, + )); + } + Ok(()) + } + pub fn with_sqlite_writer_lock(mut self, lock: Option>>) -> Self { self.sqlite_writer_lock = lock; self @@ -846,6 +864,7 @@ impl EncryptionService { decode_base64(&body.encrypted_symmetric_key).map_err(EncryptionServiceError::Base64)?; check_receiver_public_key(&receiver_key_bytes)?; check_wrapped_algorithm(&body.alg, &wrapped_key_bytes)?; + self.check_key_wrap_policy(&wrapped_key_bytes)?; let expected_receiver_hash = canonical_hash(&Value::String(body.receiver_public_key.clone())); @@ -1076,6 +1095,7 @@ impl EncryptionService { let wrapped_key_bytes = decode_base64(&body.encrypted_symmetric_key).map_err(EncryptionServiceError::Base64)?; check_wrapped_algorithm(&body.alg, &wrapped_key_bytes)?; + self.check_key_wrap_policy(&wrapped_key_bytes)?; let expected_receiver_hash = canonical_hash(&Value::String(body.receiver_public_key.clone())); diff --git a/tinycloud-core/src/encryption_network/tests.rs b/tinycloud-core/src/encryption_network/tests.rs index 79908cdcb..922a32245 100644 --- a/tinycloud-core/src/encryption_network/tests.rs +++ b/tinycloud-core/src/encryption_network/tests.rs @@ -2406,16 +2406,47 @@ async fn postgres_key_writes_wait_for_the_network_row_lock() { } } +#[tokio::test] +async fn plaintext_stored_network_secret_is_refused() { + use crate::models::encryption_network; + let db = fresh_db().await; + let svc = make_service(db.clone()); + create_network(&svc).await; + let net = network_id(); + let secret = X25519StaticSecret::from([7u8; 32]); + let mut row: encryption_network::ActiveModel = + encryption_network::Entity::find_by_id(net.to_string()) + .one(&db) + .await + .unwrap() + .unwrap() + .into(); + row.public_key = Set(PublicKey::from(&secret).as_bytes().to_vec()); + row.sealed_private_key = Set(Some(secret.as_bytes().to_vec())); + row.update(&db).await.unwrap(); + let ctx = make_client_request(PublicKey::from(&secret).as_bytes()); + let (_, body) = build_body(&ctx, &net); + let invocation = build_invocation(&net, &body, &ctx, |_| {}); + assert!(matches!( + svc.decrypt(&net, &invocation, &body).await, + Err(EncryptionServiceError::Backend( + super::backend::KeyBackendError::SealedKeyUnreadable + )) + )); +} + #[tokio::test] async fn wrap_versions_are_independent_on_native_root_and_named_decrypt() { use super::backend::{ open_wrapped_key, wrap_to_public_key_with_context, KeyWrapContext, KeyWrapPurpose, KeyWrapVersion, }; - for writer_v2 in [false, true] { + for (writer_v2, require_v2) in [(false, false), (true, false), (false, true), (true, true)] { for incoming in [KeyWrapVersion::V1, KeyWrapVersion::V2] { for path in ["", "docs/alpha", "apps/notes/alpha"] { - let svc = make_service(fresh_db().await).with_key_wrap_v2(writer_v2); + let svc = make_service(fresh_db().await) + .with_key_wrap_v2(writer_v2) + .with_key_wrap_require_v2(require_v2); create_network(&svc).await; let net = network_id(); let public = if path.is_empty() { @@ -2474,11 +2505,17 @@ async fn wrap_versions_are_independent_on_native_root_and_named_decrypt() { svc.validate_authorized_decrypt_request(&target, &invocation, &body_value) .await .unwrap(); - let response = svc + let result = svc .decrypt_authorized(&target, &invocation, &body_value) - .await - .unwrap() - .response; + .await; + if require_v2 && incoming == KeyWrapVersion::V1 { + assert!(matches!( + result, + Err(EncryptionServiceError::KeyUnwrapFailed(_)) + )); + continue; + } + let response = result.unwrap().response; let output_version = if writer_v2 { KeyWrapVersion::V2 } else { @@ -2511,9 +2548,11 @@ async fn standalone_decrypt_reads_both_versions_and_uses_writer_flag() { open_wrapped_key, wrap_to_public_key_with_context, KeyWrapContext, KeyWrapPurpose, KeyWrapVersion, }; - for writer_v2 in [false, true] { + for (writer_v2, require_v2) in [(false, false), (true, false), (false, true), (true, true)] { for incoming in [KeyWrapVersion::V1, KeyWrapVersion::V2] { - let svc = make_service(fresh_db().await).with_key_wrap_v2(writer_v2); + let svc = make_service(fresh_db().await) + .with_key_wrap_v2(writer_v2) + .with_key_wrap_require_v2(require_v2); create_network(&svc).await; let net = network_id(); let public = svc.get_network(&net).await.unwrap().public_encryption_key; @@ -2536,7 +2575,15 @@ async fn standalone_decrypt_reads_both_versions_and_uses_writer_flag() { let inv = build_invocation(&net, &body_value, &ctx, |inv| { inv.facts.alg = incoming.alg().into() }); - let response = svc.decrypt(&net, &inv, &body_value).await.unwrap().response; + let result = svc.decrypt(&net, &inv, &body_value).await; + if require_v2 && incoming == KeyWrapVersion::V1 { + assert!(matches!( + result, + Err(EncryptionServiceError::KeyUnwrapFailed(_)) + )); + continue; + } + let response = result.unwrap().response; assert_eq!( response.alg, if writer_v2 { diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index b759e09e7..dce9c110d 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -77,9 +77,12 @@ pub struct Config { pub struct EncryptionNetworkConfig { #[serde(default)] pub named_keys_v1: bool, - /// Select v2 response wraps; requests in either format are always accepted. + /// Select v2 response wraps independently of incoming-wrap policy. #[serde(default)] pub key_wrap_v2: bool, + /// Require v2 incoming network-key wraps. + #[serde(default)] + pub key_wrap_require_v2: bool, #[serde(default)] pub deletion_ledger_path: Option, } @@ -2249,6 +2252,18 @@ mod tests { assert!(enabled.validate().is_ok()); } + #[test] + fn encryption_network_require_v2_is_default_off_and_independent() { + assert!(!Config::default().encryption_network.key_wrap_require_v2); + let default: EncryptionNetworkConfig = toml::from_str("").unwrap(); + assert!(!default.key_wrap_require_v2); + let enabled: EncryptionNetworkConfig = + toml::from_str("key_wrap_require_v2 = true").unwrap(); + assert!(enabled.key_wrap_require_v2); + assert!(!enabled.key_wrap_v2); + assert!(enabled.validate().is_ok()); + } + #[test] fn deletion_ledger_path_is_required_while_named_keys_are_on() { let off: EncryptionNetworkConfig = toml::from_str("").unwrap(); @@ -2273,6 +2288,7 @@ mod tests { let config = EncryptionNetworkConfig { named_keys_v1: false, key_wrap_v2: false, + key_wrap_require_v2: false, deletion_ledger_path: Some(bad.into()), }; assert!(config.validate().is_err(), "{bad:?}"); diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index f3738fa69..a70939a2e 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -819,7 +819,8 @@ pub async fn app_with_control( node_keypair, encryption_backend, ) - .with_key_wrap_v2(tinycloud_config.encryption_network.key_wrap_v2); + .with_key_wrap_v2(tinycloud_config.encryption_network.key_wrap_v2) + .with_key_wrap_require_v2(tinycloud_config.encryption_network.key_wrap_require_v2); // Migrate before opening S3: its one bucket listing both reconstructs // SpaceSizes and records which in-flight DELETE fences were counted. diff --git a/tinycloud.toml b/tinycloud.toml index 54561192a..1be38b5af 100644 --- a/tinycloud.toml +++ b/tinycloud.toml @@ -94,9 +94,12 @@ cors = true # network_methods = false [global.encryption_network] - ## Emit TCW2 response wraps. Both v1 and v2 requests are accepted regardless. + ## Emit TCW2 response wraps independently of incoming-wrap policy. ## Env: TINYCLOUD_ENCRYPTION_NETWORK__KEY_WRAP_V2 key_wrap_v2 = false + ## Require TCW2 incoming network-key wraps; receiver responses are independent. + ## Env: TINYCLOUD_ENCRYPTION_NETWORK__KEY_WRAP_REQUIRE_V2 + key_wrap_require_v2 = false ## 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 From d7ae323731aad434448fd201b48da13d1890b472 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:34:13 +0000 Subject: [PATCH 3/3] fix(encryption): apply wrap policy during invocation preflight Details: TC-2518 --- CHANGELOG.md | 1 + .../src/encryption_network/service.rs | 1 + .../src/encryption_network/tests.rs | 14 +++- tinycloud-node-server/src/routes/mod.rs | 10 ++- .../src/routes/tests/encryption_keys.rs | 68 +++++++++++++++++++ 5 files changed, 90 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7941f686f..2c99aeb7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Changes - Encryption root and named keys read both v1 and TCW2 key wraps by default. The independent, default-off `key_wrap_require_v2` policy requires v2 incoming network-key wraps without restricting receiver responses. Response wraps remain v1 by default; `key_wrap_v2 = true` selects v2. V1 writers avoid the reserved TCW2 prefix, malformed surrogate headers are refused, and stored network secrets require encrypted seals. Descriptors and payload schemas are unchanged. Details: TC-2518 +- Encryption network reader policy is checked during `/invoke` preflight, before replay reservation. Details: TC-2518 - 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 - Share-email issuer trust bundles can pin one next key for the same issuer DID during an explicit UTC overlap window. The legacy key stops at `issuerNotAfter`; malformed, duplicate, unbounded or expired overlaps fail startup and `--validate-config`. Existing single-key bundles remain unchanged. This is node-side trust support only: the OpenCredentials witness and js-sdk policy contract must also support key rotation before activation. Older node images reject overlap fields; restore the previous single-key bundle before rollback. See `deploy/share-email/production-trust-bundle-contract.md`. diff --git a/tinycloud-core/src/encryption_network/service.rs b/tinycloud-core/src/encryption_network/service.rs index ac9094d52..bdc03e468 100644 --- a/tinycloud-core/src/encryption_network/service.rs +++ b/tinycloud-core/src/encryption_network/service.rs @@ -1020,6 +1020,7 @@ impl EncryptionService { let wrapped = decode_base64(&body.encrypted_symmetric_key).map_err(EncryptionServiceError::Base64)?; check_wrapped_algorithm(&body.alg, &wrapped)?; + self.check_key_wrap_policy(&wrapped)?; let receiver_hash = canonical_hash(&Value::String(body.receiver_public_key.clone())); let key_hash = canonical_hash(&Value::String(body.encrypted_symmetric_key.clone())); if receiver_hash != body.receiver_public_key_hash diff --git a/tinycloud-core/src/encryption_network/tests.rs b/tinycloud-core/src/encryption_network/tests.rs index 922a32245..9181a40c8 100644 --- a/tinycloud-core/src/encryption_network/tests.rs +++ b/tinycloud-core/src/encryption_network/tests.rs @@ -2502,9 +2502,17 @@ async fn wrap_versions_are_independent_on_native_root_and_named_decrypt() { }; let invocation = build_session_invocation_for(&resource, DECRYPT_ACTION, vec![facts], NODE_DID); - svc.validate_authorized_decrypt_request(&target, &invocation, &body_value) - .await - .unwrap(); + let preflight = svc + .validate_authorized_decrypt_request(&target, &invocation, &body_value) + .await; + if require_v2 && incoming == KeyWrapVersion::V1 { + assert!(matches!( + preflight, + Err(EncryptionServiceError::KeyUnwrapFailed(_)) + )); + } else { + preflight.unwrap(); + } let result = svc .decrypt_authorized(&target, &invocation, &body_value) .await; diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index bc5fadb9e..5b52352f7 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -4499,6 +4499,8 @@ mod tests { #[allow(dead_code)] struct LedgerDir(TempDir); + struct ReplayDb(tinycloud_core::sea_orm::DatabaseConnection); + fn manage_tc405_test_state( rocket: rocket::Rocket, conn: tinycloud_core::sea_orm::DatabaseConnection, @@ -4517,7 +4519,12 @@ mod tests { .expect("deletion ledger"); let encryption = EncryptionService::new_with_node_keypair(conn.clone(), secret.node_keypair(), backend) - .with_deletion_ledger(Some(std::sync::Arc::new(ledger))); + .with_deletion_ledger(Some(std::sync::Arc::new(ledger))) + .with_key_wrap_require_v2( + rocket + .state::() + .is_some_and(|config| config.encryption_network.key_wrap_require_v2), + ); rocket .manage(crate::policy_v3::PolicyV3Runtime::new( @@ -11116,6 +11123,7 @@ mod tests { .manage(None::) .manage(config) .manage(QuotaCache::new(None, None)) + .manage(ReplayDb(replay_db.clone())) .manage(InvocationReplayCache::new(replay_db)) .manage(hook_runtime) .manage(BlockStage::from(crate::config::StagingStorage::Memory)); diff --git a/tinycloud-node-server/src/routes/tests/encryption_keys.rs b/tinycloud-node-server/src/routes/tests/encryption_keys.rs index a1de7a955..0dd4a3fa6 100644 --- a/tinycloud-node-server/src/routes/tests/encryption_keys.rs +++ b/tinycloud-node-server/src/routes/tests/encryption_keys.rs @@ -40,8 +40,16 @@ pub(super) async fn setup() -> Result<(KvSyncFixture, String)> { /// 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)> { + setup_with_reader_policy(named_keys, false).await +} + +async fn setup_with_reader_policy( + named_keys: bool, + require_v2: bool, +) -> Result<(KvSyncFixture, String)> { let mut config = Config::default(); config.encryption_network.named_keys_v1 = named_keys; + config.encryption_network.key_wrap_require_v2 = require_v2; let fixture = KvSyncFixture::new_configured(config, |rocket| { rocket .mount( @@ -1911,6 +1919,66 @@ async fn replayed_named_key_decrypt_is_a_conflict() -> Result<()> { Ok(()) } +#[tokio::test] +async fn encryption_network_required_wrap_policy_preserves_invoke_nonce() -> Result<()> { + use tinycloud_core::encryption_network::backend::{ + wrap_to_public_key_with_context, KeyWrapContext, KeyWrapPurpose, KeyWrapVersion, + }; + + let (fixture, network) = setup_with_reader_policy(true, true).await?; + let replay = &fixture + .client + .rocket() + .state::() + .expect("replay database is managed") + .0; + let before = tc341_replay_row_count(replay).await?; + let public = root_public_key(&fixture, &network).await; + let mut case = DecryptCase::wrapped_to(&public); + let (body, facts) = decrypt_request(&fixture, &network, &case, None); + let auth = invocation( + &fixture, + &fixture.node_did, + &[(network.as_str(), DECRYPT_ACTION)], + vec![facts], + )?; + let (status, text) = invoke(&fixture, &auth, &body).await; + assert_eq!(status, Status::Forbidden, "{text}"); + assert_eq!(tc341_replay_row_count(replay).await?, before); + + case.wrapped = wrap_to_public_key_with_context( + &public, + &case.symmetric, + &KeyWrapContext { + purpose: KeyWrapPurpose::NetworkKey, + network_id: &network, + key_path: "", + key_version: 1, + metadata: None, + }, + KeyWrapVersion::V2, + )?; + let (mut body, mut facts) = decrypt_request(&fixture, &network, &case, None); + body["alg"] = json!(KeyWrapVersion::V2.alg()); + facts["alg"] = body["alg"].clone(); + facts["bodyHash"] = json!(canonical_hash(&body)); + let auth = invocation( + &fixture, + &fixture.node_did, + &[(network.as_str(), DECRYPT_ACTION)], + vec![facts], + )?; + let (status, text) = invoke(&fixture, &auth, &body).await; + assert_eq!(status, Status::Ok, "{text}"); + let response: Value = serde_json::from_str(&text)?; + let wrapped = base64::decode(response["wrappedKey"].as_str().expect("wrappedKey"))?; + assert_eq!(case.open(&wrapped), case.symmetric); + assert_eq!(tc341_replay_row_count(replay).await?, before + 1); + let (status, text) = invoke(&fixture, &auth, &body).await; + assert_eq!(status, Status::Conflict, "{text}"); + Ok(()) +} + #[tokio::test] async fn bare_network_grant_cannot_decrypt_a_named_key() -> Result<()> { let (fixture, network) = setup().await?;