diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a5d8518..b73676c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ - A SIWE (CACAO) delegation whose ReCap gives a caveat a non-null value on any ability is refused as an unsupported caveat; empty caveat entries and entries whose values are all null are accepted as no restriction. Details: TC-1844. - Named-key routes and key resources are served only when `[global.encryption_network] named_keys_v1` is enabled. Details: TC-1777. +- Encryption: named keys under the path segment `apps/` are deletable keys. A key path whose first segment is exactly `apps` must be `apps//` (a prefix `apps//`); `apps`, `apps/` and `apps/` are refused in delegations and invocations. The create body is `{ keyPath, deletable }` and `deletable` must equal what the path implies (400 `key_path_invalid` otherwise). A deletable key is derived from the network secret, the path and a per-key random seed sealed with the column key; other keys use the deterministic derivation. Details: TC-1777. +- Encryption: create, block and unblock run under the network row lock. On a `deleted` key block and unblock answer 403 `key_deleted`; create on a `deleted` deletable key reuses the row with a new seed and the next `keyVersion`, and a decrypt for another `keyVersion` answers 403 `key_deleted`. Per `apps//` prefix at most 100 key rows count towards `key_limit_exceeded`. Details: TC-1777. +- Encryption: the unauthenticated key GET answers 404 `key_not_found` for every path under `apps/`; the authenticated GET returns the key with its `kind` and `state`. Details: TC-1777. +- Encryption: a SIWE delegation issued by the key owner that grants any ability other than `key.block` under `apps/` is refused with `app_id_not_bound` unless the app id is bound to the SIWE domain and the SIWE scheme is absent or `https`. Details: TC-1777. +- Encryption: the node does not start while a key row under `apps/` has a kind other than `deletable`; the error carries the query that lists them. Docs: `docs/encryption-named-keys.md`. Details: TC-1777. - The node logs one structured diagnostic line (status, code, service, route, elapsed time, failure kind, a keyed database pseudonym `db_hash`) when a request ends in a database failure or a busy/timeout 5xx. Details: TC-1796. - Log sidecar: the node's one warning line per database-failure 5xx (`database failure answered with a 5xx`) ships to Better Stack as an individual `node_warning` event with `event_code` `db_failure_5xx` and exactly eight fields: `status`, `code`, `service`, `route`, `elapsed_ms`, `failure_kind`, `write_may_have_applied` and, when known, `db_hash`. They are fixed vocabulary, the route template, a duration, a flag and a keyed database pseudonym; no SQL, ids, DIDs, paths or messages. A line with any other field or an unexpected value is counted as `unclassified` as before. Details: TC-1840. - **N0 compose.** The embedded Vector configuration is regenerated for the new event. The reviewed N0 compose template changes to `7c3456dc…` (raw `d0e72bde…`; `docs/releasing.md`). diff --git a/Cargo.lock b/Cargo.lock index b91d61da..cbe178df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9896,6 +9896,7 @@ dependencies = [ "hex", "hkdf", "hmac 0.12.1", + "idna", "iri-string", "k256", "libp2p", diff --git a/docs/encryption-named-keys.md b/docs/encryption-named-keys.md new file mode 100644 index 00000000..c2a7ae4a --- /dev/null +++ b/docs/encryption-named-keys.md @@ -0,0 +1,101 @@ +# Named encryption keys + +Named keys are served only while `[global.encryption_network] named_keys_v1` is +on (default off). With it off the key routes are not mounted and key resources +are refused. + +A key is named by `urn:tinycloud:encryption::/`. A prefix +grant is the same URN with one trailing `/`. + +## Key paths + +A path is 1 to 8 segments of `[a-z0-9][a-z0-9._-]{0,63}` joined by `/`, at most +255 bytes. + +The first segment `apps` is reserved. A path whose first segment is exactly +`apps` must be `apps//[/]` for a key (at least three +segments) and `apps//[/]` for a prefix (at least two segments +before the trailing `/`). `apps`, `apps/` and the prefix `apps/` are +refused in delegations and invocations. `appsx/...` and `apps.x/...` are +ordinary paths. An app id matches `^[a-z0-9][a-z0-9._-]{0,63}$` as a whole +string; it is validated, never changed. + +## Key kinds + +The kind follows from the path. + +| Path | Kind | Secret | +| --- | --- | --- | +| first segment `apps` | `deletable` | HKDF-SHA256(ikm = HKDF-Extract(salt = seed, ikm = network secret), salt = `tinycloud.encryption.named-key/v1`, info = `seeded\n` + key URN) | +| any other | `deterministic` | HKDF-SHA256(ikm = network secret, salt = `tinycloud.encryption.named-key/v1`, info = `deterministic\n` + key URN) | + +The seed of a deletable key is 32 bytes from the operating system's random +source, made at `key.create`, sealed with the same column key as the network +secret and stored in `encryption_network_key.sealed_seed`. A seed is opened only +with the strict unseal. A deletable row that is not `deleted` and has no +seed, and a row whose kind the path does not imply, is a corrupt row (500). + +## Create + +`POST /encryption/networks//keys` with `{ "keyPath": "...", "deletable": bool }`. +`deletable` must equal the kind the path implies (`true` under `apps/`, `false` +elsewhere), otherwise 400 `key_path_invalid`. Unknown fields are refused with +400. Per network at most 1000 key rows, and per `apps//` prefix at most +100, count towards `key_limit_exceeded`; `deleted` rows count until they are +reused. + +- A new path creates the row (`keyVersion` 1, state `active`). +- An existing `active` deterministic key answers with its descriptor. +- Any other existing row answers `key_exists` (409). A `blocked` row is not + changed. +- A `deleted` deletable row is reused: new seed, state `active`, `keyVersion` + 1. +- A `deleted` deterministic row answers `key_deleted` (403). + +## State guards + +Create, block and unblock run under the network row lock. Block and unblock +write only while the stored state is `active` or `blocked`; on a `deleted` row +they answer 403 `key_deleted` and change nothing. + +## Public key GET + +`GET /encryption/networks//keys/` without `Authorization`: + +- Under `apps/` every path answers 404 `key_not_found`, with the same body and + headers whether the key exists, is blocked or deleted, is missing, or + belongs to another owner or network. +- Elsewhere it answers the derived public key for every valid path on an active + network. + +The authenticated GET (a `key-describe/v1` invocation) returns the full +descriptor, including `kind` (`deterministic` or `deletable`) and `state` +(`active`, `blocked` or `deleted`). + +## Decrypt + +`POST /invoke` with `decrypt` on the exact key. For a deletable key the +request's `keyVersion` must equal the row's version, otherwise 403 +`key_deleted`; a `deleted` row answers 403 `key_deleted`. + +## SIWE roots under `apps/` + +A SIWE (CACAO) delegation issued by the key owner that carries any ability +other than `key.block` on a key or prefix under `apps/` requires the +app id to be bound to the SIWE `domain`. The SIWE `scheme` is absent or +`https`. The domain has no port (and no empty port or user info), is a DNS name +of at least two labels (no IP literal, no `localhost`, no trailing dot), and the +app id equals its labels reversed and joined with `.`, or starts with that and +`.` (the domain is compared in its lowercase ASCII form). UCAN delegations are +not checked. A refusal answers `app_id_not_bound`. + +## Startup check + +The node does not start while a row in `encryption_network_key` under `apps/` +(or at the path `apps`) has a kind other than `deletable`. List such rows with: + +```sql +SELECT network_id, key_path, kind FROM encryption_network_key +WHERE (key_path = 'apps' OR substr(key_path, 1, 5) = 'apps/') AND kind <> 'deletable'; +``` + +The node deletes no data; remove or move the rows, then start the node. diff --git a/test/m1-realdata-e2e/Cargo.lock b/test/m1-realdata-e2e/Cargo.lock index bbab0f48..5c7e841d 100644 --- a/test/m1-realdata-e2e/Cargo.lock +++ b/test/m1-realdata-e2e/Cargo.lock @@ -7985,6 +7985,7 @@ dependencies = [ "hex", "hkdf", "hmac 0.12.1", + "idna", "k256", "libp2p", "multihash-derive 0.9.1", diff --git a/test/w5-policy-runtime-node-e2e/Cargo.lock b/test/w5-policy-runtime-node-e2e/Cargo.lock index 38dda23d..cfb7609c 100644 --- a/test/w5-policy-runtime-node-e2e/Cargo.lock +++ b/test/w5-policy-runtime-node-e2e/Cargo.lock @@ -8433,6 +8433,7 @@ dependencies = [ "hex", "hkdf", "hmac 0.12.1", + "idna", "k256", "libp2p", "multihash-derive 0.9.1", diff --git a/tinycloud-core/Cargo.toml b/tinycloud-core/Cargo.toml index 04683d8b..7ed5b6a4 100644 --- a/tinycloud-core/Cargo.toml +++ b/tinycloud-core/Cargo.toml @@ -80,6 +80,8 @@ ryu-js = "0.2.2" # canonicalization. Replaces the hand-coded subset that only composed a # narrow Latin precomposed-pair table. unicode-normalization = "0.1" +# App ids are bound to the ASCII (punycode) form of a host name. +idna = "1" [dev-dependencies] sea-orm = { version = "1.1", features = ["runtime-tokio-rustls", "sqlx-sqlite"] } diff --git a/tinycloud-core/src/encryption_network/app_id.rs b/tinycloud-core/src/encryption_network/app_id.rs new file mode 100644 index 00000000..1a6f8332 --- /dev/null +++ b/tinycloud-core/src/encryption_network/app_id.rs @@ -0,0 +1,140 @@ +//! App ids under the reserved `apps/` key segment. +//! +//! A key path that starts with `apps/` is `apps//[/]`. The +//! app id is validated, never transformed, and must match +//! `^[a-z0-9][a-z0-9._-]{0,63}$` as a whole string (ASCII only; a trailing +//! newline fails). +//! +//! An app id is bound to a host when it equals the host's labels reversed and +//! joined with `.`, or starts with that string followed by `.`. The host is +//! compared in its lowercase ASCII (punycode) form and needs at least two +//! labels. + +use super::key_id::valid_segment; + +/// Whether `id` is a valid app id. +pub fn app_id_valid(id: &str) -> bool { + valid_segment(id) +} + +/// Whether `app_id` is bound to `host`. `host` is a bare host name: no scheme, +/// no port. IP literals, `localhost` names, a trailing dot and single-label +/// hosts are never bound. +pub fn app_id_bound_to_host(host: &str, app_id: &str) -> bool { + if !app_id_valid(app_id) { + return false; + } + let Some(reversed) = reversed_ascii_host(host) else { + return false; + }; + app_id == reversed + || app_id + .strip_prefix(reversed.as_str()) + .is_some_and(|rest| rest.starts_with('.')) +} + +/// The host in lowercase ASCII form, labels reversed and joined with `.`, or +/// `None` when the host is not an eligible DNS name. +fn reversed_ascii_host(host: &str) -> Option { + if host.is_empty() || host.ends_with('.') || host.starts_with('[') { + return None; + } + let ascii = idna::domain_to_ascii(host).ok()?; + let labels: Vec<&str> = ascii.split('.').collect(); + if labels.len() < 2 || !labels.iter().all(|label| dns_label(label)) { + return None; + } + let last = labels.last().copied().unwrap_or_default(); + // An IPv4 form (`1.0.0.127`, `127.1`, `0x7f.1`) is not a DNS name. + if last.bytes().all(|b| b.is_ascii_digit()) || last.starts_with("0x") { + return None; + } + if last == "localhost" { + return None; + } + let mut reversed = String::with_capacity(ascii.len()); + for (index, label) in labels.iter().rev().enumerate() { + if index > 0 { + reversed.push('.'); + } + reversed.push_str(label); + } + Some(reversed) +} + +fn dns_label(label: &str) -> bool { + let bytes = label.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 63 + && bytes[0] != b'-' + && bytes[bytes.len() - 1] != b'-' + && bytes + .iter() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-') +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + fn vectors() -> Value { + serde_json::from_str(include_str!( + "../../tests/fixtures/encryption/app-key-vectors.json" + )) + .expect("vector file parses") + } + + /// Host of an `https://` origin with no port, as the shared vectors + /// phrase it. Any other origin shape is not bound. + fn origin_host(origin: &str) -> Option<&str> { + let host = origin.strip_prefix("https://")?; + if host.contains(['/', ':', '@', '?', '#']) { + return None; + } + Some(host) + } + + #[test] + fn shared_app_id_vectors() { + let vectors = vectors(); + let cases = vectors["appIdValid"].as_array().unwrap(); + assert_eq!(cases.len(), 39); + for case in cases { + let id = case["id"].as_str().unwrap(); + let valid = case["valid"].as_bool().unwrap(); + assert_eq!(app_id_valid(id), valid, "app id {id:?}"); + } + } + + #[test] + fn shared_host_binding_vectors() { + let vectors = vectors(); + let cases = vectors["hostBinding"].as_array().unwrap(); + assert_eq!(cases.len(), 23); + for case in cases { + let origin = case["origin"].as_str().unwrap(); + let id = case["id"].as_str().unwrap(); + let bound = case["bound"].as_bool().unwrap(); + let actual = origin_host(origin).is_some_and(|host| app_id_bound_to_host(host, id)); + assert_eq!(actual, bound, "origin {origin:?} id {id:?}"); + } + } + + #[test] + fn bare_hosts_with_a_port_or_scheme_are_not_bound() { + assert!(!app_id_bound_to_host( + "listen.tinycloud.xyz:8443", + "xyz.tinycloud.listen" + )); + assert!(!app_id_bound_to_host( + "https://listen.tinycloud.xyz", + "xyz.tinycloud.listen" + )); + assert!(!app_id_bound_to_host("", "a")); + assert!(app_id_bound_to_host( + "LISTEN.tinycloud.xyz", + "xyz.tinycloud.listen" + )); + } +} diff --git a/tinycloud-core/src/encryption_network/backend.rs b/tinycloud-core/src/encryption_network/backend.rs index 64865023..c65fd604 100644 --- a/tinycloud-core/src/encryption_network/backend.rs +++ b/tinycloud-core/src/encryption_network/backend.rs @@ -2,11 +2,12 @@ //! //! V1 ships the `LocalOneOfOneBackend`, which generates a single X25519 keypair //! per network and stores the private key sealed with the same key material the -//! node uses for DB column encryption. Future threshold backends must avoid +//! node uses for DB column encryption. Deletable named keys carry a per-key +//! random seed, sealed the same way. Future threshold backends must avoid //! ever assembling the full network private key. use hkdf::Hkdf; -use rand::rngs::OsRng; +use rand::{rngs::OsRng, RngCore}; use sha2::Sha256; use thiserror::Error; use x25519_dalek::{PublicKey, StaticSecret}; @@ -73,20 +74,33 @@ pub trait KeyBackend: Send + Sync { ) -> Result; /// Public key of the named key at `canonical_urn` under `network_secret`. + /// `seed` is the per-key seed of a deletable key and `None` for a + /// deterministic key. fn derive_named_public_key( &self, network_secret: &StaticSecret, canonical_urn: &str, + seed: Option<&KeySeed>, ) -> Result, KeyBackendError>; /// Unwrap a key wrapped to the named key at `canonical_urn` under - /// `network_secret`. + /// `network_secret` and `seed`. fn unwrap_named( &self, network_secret: &StaticSecret, canonical_urn: &str, + seed: Option<&KeySeed>, wrapped_key: &[u8], ) -> Result, KeyBackendError>; + + /// A fresh 32-byte seed from the OS CSPRNG, with its sealed form for + /// storage. + fn generate_seed(&self) -> Result<(KeySeed, Vec), KeyBackendError>; + + /// Opens a sealed seed. Only the strict unseal is used: input that is not + /// a versioned sealed value, or that does not hold exactly 32 bytes, is + /// refused. + fn open_seed(&self, sealed_seed: &[u8]) -> Result; } /// X25519 ECIES-style key wrap backed by AES-256-GCM. Used both for wrapping to @@ -146,18 +160,56 @@ fn unwrap_with_secret(secret: &StaticSecret, wrapped: &[u8]) -> Result, } const NAMED_KEY_SALT: &[u8] = b"tinycloud.encryption.named-key/v1"; -const NAMED_KEY_INFO_PREFIX: &[u8] = b"deterministic\n"; +const DETERMINISTIC_INFO_PREFIX: &[u8] = b"deterministic\n"; +const SEEDED_INFO_PREFIX: &[u8] = b"seeded\n"; + +/// Per-key seed of a deletable key. Zeroized on drop. +pub struct KeySeed(Zeroizing<[u8; 32]>); + +impl KeySeed { + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(Zeroizing::new(bytes)) + } -/// Deterministic named-key secret = HKDF-SHA256(ikm = raw network secret, -/// salt = fixed label, info = `deterministic\n` || canonical key URN). -fn named_key_secret(network_secret: &[u8; 32], canonical_urn: &str) -> StaticSecret { - let mut info = Vec::with_capacity(NAMED_KEY_INFO_PREFIX.len() + canonical_urn.len()); - info.extend_from_slice(NAMED_KEY_INFO_PREFIX); + fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +/// Named-key secret. With no seed (deterministic key): HKDF-SHA256(ikm = raw +/// network secret, salt = fixed label, info = `deterministic\n` || canonical +/// key URN). With a seed (deletable key): the same with ikm = HKDF-Extract( +/// salt = seed, ikm = raw network secret) and info = `seeded\n` || URN. +fn named_key_secret( + network_secret: &[u8; 32], + canonical_urn: &str, + seed: Option<&KeySeed>, +) -> StaticSecret { + let prefix = if seed.is_some() { + SEEDED_INFO_PREFIX + } else { + DETERMINISTIC_INFO_PREFIX + }; + let mut info = Vec::with_capacity(prefix.len() + canonical_urn.len()); + info.extend_from_slice(prefix); info.extend_from_slice(canonical_urn.as_bytes()); - let hk = Hkdf::::new(Some(NAMED_KEY_SALT), network_secret); let mut okm = [0u8; 32]; - hk.expand(&info, &mut okm) - .expect("32 bytes is a valid HKDF-SHA256 output length"); + match seed { + None => { + Hkdf::::new(Some(NAMED_KEY_SALT), network_secret) + .expand(&info, &mut okm) + .expect("32 bytes is a valid HKDF-SHA256 output length"); + } + Some(seed) => { + let (extracted, _) = + Hkdf::::extract(Some(seed.as_bytes().as_slice()), network_secret); + let mut prk: [u8; 32] = extracted.into(); + Hkdf::::new(Some(NAMED_KEY_SALT), &prk) + .expand(&info, &mut okm) + .expect("32 bytes is a valid HKDF-SHA256 output length"); + prk.zeroize(); + } + } let secret = StaticSecret::from(okm); okm.zeroize(); secret @@ -222,8 +274,9 @@ impl KeyBackend for LocalOneOfOneBackend { &self, network_secret: &StaticSecret, canonical_urn: &str, + seed: Option<&KeySeed>, ) -> Result, KeyBackendError> { - let secret = named_key_secret(network_secret.as_bytes(), canonical_urn); + let secret = named_key_secret(network_secret.as_bytes(), canonical_urn, seed); Ok(PublicKey::from(&secret).as_bytes().to_vec()) } @@ -231,12 +284,29 @@ impl KeyBackend for LocalOneOfOneBackend { &self, network_secret: &StaticSecret, canonical_urn: &str, + seed: Option<&KeySeed>, wrapped_key: &[u8], ) -> Result, KeyBackendError> { - let secret = named_key_secret(network_secret.as_bytes(), canonical_urn); + let secret = named_key_secret(network_secret.as_bytes(), canonical_urn, seed); unwrap_with_secret(&secret, wrapped_key) } + fn generate_seed(&self) -> Result<(KeySeed, Vec), KeyBackendError> { + let mut bytes = Zeroizing::new([0u8; 32]); + OsRng.fill_bytes(bytes.as_mut_slice()); + let sealed = self.seal.encrypt(bytes.as_slice()); + Ok((KeySeed::from_bytes(*bytes), sealed)) + } + + fn open_seed(&self, sealed_seed: &[u8]) -> Result { + let opened = Zeroizing::new(self.seal.decrypt_strict(sealed_seed)?); + let bytes: [u8; 32] = opened + .as_slice() + .try_into() + .map_err(|_| KeyBackendError::InvalidSealedKey)?; + Ok(KeySeed::from_bytes(bytes)) + } + fn rewrap( &self, symmetric_key: &[u8], @@ -358,6 +428,16 @@ mod tests { const KAT_URN_NOTES: &str = "urn:tinycloud:encryption:did:key:z6Mk:default/apps/notes"; const KAT_URN_A: &str = "urn:tinycloud:encryption:did:key:z6Mk:default/a"; + const KAT_URN_NOTES_A: &str = "urn:tinycloud:encryption:did:key:z6Mk:default/apps/notes/a"; + + /// Seed bytes 0x20..=0x3f, used by the seeded known-answer vectors. + fn kat_seed() -> KeySeed { + let mut s = [0u8; 32]; + for (i, b) in s.iter_mut().enumerate() { + *b = 0x20 + i as u8; + } + KeySeed::from_bytes(s) + } /// Known-answer vectors generated independently with Python `cryptography`: /// HKDF-SHA256(ikm = network secret, salt = "tinycloud.encryption.named-key/v1", @@ -377,7 +457,44 @@ mod tests { ), ]; for (urn, secret_hex, public_hex) in cases { - let secret = named_key_secret(&kat_network_secret(), urn); + let secret = named_key_secret(&kat_network_secret(), urn, None); + assert_eq!(hex(secret.as_bytes()), secret_hex, "secret for {urn}"); + assert_eq!( + hex(PublicKey::from(&secret).as_bytes()), + public_hex, + "public key for {urn}" + ); + } + } + + /// Seeded known-answer vectors generated independently with Python + /// `cryptography`/`hmac`: prk = HKDF-Extract(salt = seed, ikm = network + /// secret), then HKDF-SHA256(ikm = prk, salt = + /// "tinycloud.encryption.named-key/v1", info = "seeded\n" || URN), then + /// X25519 public-key derivation. Network secret bytes 0x00..=0x1f, seed + /// bytes 0x20..=0x3f. + #[test] + fn seeded_named_key_secret_known_answer() { + let cases = [ + ( + KAT_URN_NOTES, + "6dfc78d8bfad5426a807a0d28d215b9d0724f399017b7208bcc0a0059d56699b", + "a4ce43c1b98d2289215722623801c8ca0896122872427557e32547b6f393477f", + ), + ( + KAT_URN_A, + "cb3f04c506504e1148d6e6d567a2cb7a4675297434bda9d30532b893259a8ed5", + "d9dd603f57bbae80ba23995bc322f0dc2c8c681e8a049ac8fe5469d5ee4d5e65", + ), + ( + KAT_URN_NOTES_A, + "5b66844f3beed84737dc6e3b5a389c313c92d9a22dd9311d3c4371aa904f5a3d", + "55fea451945fed9ab2e6a3a7922f2e175dee1bb2c7e076caa8971a69aaf56f53", + ), + ]; + let seed = kat_seed(); + for (urn, secret_hex, public_hex) in cases { + let secret = named_key_secret(&kat_network_secret(), urn, Some(&seed)); assert_eq!(hex(secret.as_bytes()), secret_hex, "secret for {urn}"); assert_eq!( hex(PublicKey::from(&secret).as_bytes()), @@ -393,17 +510,104 @@ mod tests { let gk = backend.generate().unwrap(); let network = backend.open_network_secret(&gk.sealed_private_key).unwrap(); let named_pub = backend - .derive_named_public_key(&network, KAT_URN_NOTES) + .derive_named_public_key(&network, KAT_URN_NOTES, None) .unwrap(); let symmetric = [0x5Au8; 32]; let wrapped = wrap_to_public_key(&named_pub, &symmetric).unwrap(); let recovered = backend - .unwrap_named(&network, KAT_URN_NOTES, &wrapped) + .unwrap_named(&network, KAT_URN_NOTES, None, &wrapped) .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, &wrapped).is_err()); + assert!(backend + .unwrap_named(&network, KAT_URN_A, None, &wrapped) + .is_err()); + assert!(backend.unwrap(&gk.sealed_private_key, &wrapped).is_err()); + } + + #[test] + fn seeded_key_round_trip_and_isolation() { + let backend = LocalOneOfOneBackend::new(seal_key()); + let gk = backend.generate().unwrap(); + let network = backend.open_network_secret(&gk.sealed_private_key).unwrap(); + let (seed, sealed_seed) = backend.generate_seed().unwrap(); + let (other_seed, _) = backend.generate_seed().unwrap(); + let opened = backend.open_seed(&sealed_seed).unwrap(); + + let public = backend + .derive_named_public_key(&network, KAT_URN_NOTES, Some(&seed)) + .unwrap(); + let symmetric = [0x5Au8; 32]; + let wrapped = wrap_to_public_key(&public, &symmetric).unwrap(); + assert_eq!( + backend + .unwrap_named(&network, KAT_URN_NOTES, Some(&opened), &wrapped) + .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) + .is_err()); + assert!(backend + .unwrap_named(&network, KAT_URN_NOTES, None, &wrapped) + .is_err()); + assert!(backend + .unwrap_named(&network, KAT_URN_A, Some(&seed), &wrapped) + .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, + backend + .derive_named_public_key(&network, KAT_URN_NOTES, None) + .unwrap() + ); + } + + #[test] + fn generated_seeds_differ_and_open_to_the_stored_bytes() { + let backend = LocalOneOfOneBackend::new(seal_key()); + let (first, first_sealed) = backend.generate_seed().unwrap(); + let (second, second_sealed) = backend.generate_seed().unwrap(); + assert_ne!(first.as_bytes(), second.as_bytes()); + assert_ne!(first_sealed, second_sealed); + assert_ne!(first.as_bytes(), &[0u8; 32]); + assert_eq!( + backend.open_seed(&first_sealed).unwrap().as_bytes(), + first.as_bytes() + ); + } + + #[test] + fn seeds_open_only_through_the_strict_unseal() { + let backend = LocalOneOfOneBackend::new(seal_key()); + // A seed opens only when it is a versioned, authenticated sealed value + // of exactly 32 bytes; an all-zero value of any length is refused. + for refused in [ + vec![0u8; 32], + vec![0u8; 45], + vec![], + vec![0x02; 60], + vec![0x01; 10], + ] { + assert!( + backend.open_seed(&refused).is_err(), + "opened {} bytes", + refused.len() + ); + } + // A sealed value that is not 32 bytes is refused. + let short = ColumnEncryption::new([7u8; 32]).encrypt(&[1u8; 16]); + assert!(matches!( + backend.open_seed(&short), + Err(KeyBackendError::InvalidSealedKey) + )); + // A seed sealed under another key does not open. + let (_, other) = LocalOneOfOneBackend::new(ColumnEncryption::new([8u8; 32])) + .generate_seed() + .unwrap(); + assert!(backend.open_seed(&other).is_err()); } #[test] diff --git a/tinycloud-core/src/encryption_network/key_id.rs b/tinycloud-core/src/encryption_network/key_id.rs index 7f374661..4859d42e 100644 --- a/tinycloud-core/src/encryption_network/key_id.rs +++ b/tinycloud-core/src/encryption_network/key_id.rs @@ -6,6 +6,11 @@ //! //! Paths are `/`-separated segments of `[a-z0-9][a-z0-9._-]*`. //! Key and prefix networks are `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`; bare networks follow [`NetworkId`]. +//! +//! The first path segment `apps` is reserved for deletable keys. A key under +//! it is `apps//[/]` (at least three segments) and a prefix +//! is `apps//[/]` (at least two segments before the trailing +//! `/`). Every other path is a deterministic key. use super::network_id::{NetworkId, NetworkIdError}; use std::fmt; @@ -17,6 +22,10 @@ const PREFIX: &str = "urn:tinycloud:encryption:"; const MAX_PATH_BYTES: usize = 255; const MAX_SEGMENTS: usize = 8; const MAX_SEGMENT_BYTES: usize = 64; +/// First path segment reserved for deletable keys. +const APPS_SEGMENT: &str = "apps"; +const MIN_APPS_KEY_SEGMENTS: usize = 3; +const MIN_APPS_PREFIX_SEGMENTS: usize = 2; #[derive(Debug, Error, PartialEq, Eq)] pub enum KeyIdError { @@ -38,6 +47,10 @@ pub enum KeyIdError { TooManySegments, #[error("invalid key path segment")] InvalidSegment, + #[error( + "a path under apps/ is apps// for a key and apps// for a prefix" + )] + AppsPathTooShort, } /// A key or key prefix under one network. `path` never has a trailing `/`. @@ -61,6 +74,12 @@ impl KeyLocation { &self.path } + /// The kind a key at this path has: a path under `apps/` is deletable, + /// every other path is deterministic. + pub fn kind(&self) -> KeyKind { + KeyKind::for_path(&self.path) + } + fn same_network(&self, other: &Self) -> bool { self.owner == other.owner && self.network == other.network } @@ -70,7 +89,7 @@ impl KeyLocation { pub fn new(network: &NetworkId, path: &str) -> Result { validate_owner(network.owner_did())?; validate_network(network.name())?; - validate_path(path)?; + validate_path(path, false)?; Ok(Self { owner: network.owner_did().to_string(), network: network.name().to_string(), @@ -146,7 +165,7 @@ pub fn parse(input: &str) -> Result { Some(stripped) => (stripped, true), None => (path, false), }; - validate_path(path)?; + validate_path(path, is_prefix)?; let location = KeyLocation { owner: owner.to_string(), network: network.to_string(), @@ -174,6 +193,54 @@ pub fn is_named_key(uri: &str) -> bool { ) } +/// Whether keys at `path` are deletable (first segment exactly `apps`). +pub fn is_apps_path(path: &str) -> bool { + path.split('/').next() == Some(APPS_SEGMENT) +} + +/// The app id of a path under `apps/`: its second segment. +pub fn app_id_of(path: &str) -> Option<&str> { + if !is_apps_path(path) { + return None; + } + path.split('/').nth(1) +} + +/// Key kind. A key's kind follows from its path alone. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum KeyKind { + /// Derived from the network secret and the path; never destroyed. + Deterministic, + /// Derived from the network secret, the path and a per-key random seed; + /// destroyed by deleting the seed. + Deletable, +} + +impl KeyKind { + pub fn for_path(path: &str) -> Self { + if is_apps_path(path) { + Self::Deletable + } else { + Self::Deterministic + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Deterministic => "deterministic", + Self::Deletable => "deletable", + } + } + + pub fn parse(stored: &str) -> Option { + match stored { + "deterministic" => Some(Self::Deterministic), + "deletable" => Some(Self::Deletable), + _ => None, + } + } +} + fn is_below(ancestor: &str, path: &str) -> bool { path.strip_prefix(ancestor) .is_some_and(|rest| rest.starts_with('/')) @@ -219,7 +286,9 @@ fn validate_network(network: &str) -> Result<(), KeyIdError> { } } -fn validate_path(path: &str) -> Result<(), KeyIdError> { +/// Validates a key path (`is_prefix` false) or a prefix path without its +/// trailing `/` (`is_prefix` true). +fn validate_path(path: &str, is_prefix: bool) -> Result<(), KeyIdError> { if path.is_empty() { return Err(KeyIdError::EmptyPath); } @@ -230,14 +299,23 @@ fn validate_path(path: &str) -> Result<(), KeyIdError> { if segments.len() > MAX_SEGMENTS { return Err(KeyIdError::TooManySegments); } - if segments.iter().all(|segment| valid_segment(segment)) { - Ok(()) - } else { - Err(KeyIdError::InvalidSegment) + if !segments.iter().all(|segment| valid_segment(segment)) { + return Err(KeyIdError::InvalidSegment); + } + if is_apps_path(path) { + let minimum = if is_prefix { + MIN_APPS_PREFIX_SEGMENTS + } else { + MIN_APPS_KEY_SEGMENTS + }; + if segments.len() < minimum { + return Err(KeyIdError::AppsPathTooShort); + } } + Ok(()) } -fn valid_segment(segment: &str) -> bool { +pub(super) fn valid_segment(segment: &str) -> bool { let bytes = segment.as_bytes(); bytes.len() <= MAX_SEGMENT_BYTES && matches!(bytes.first(), Some(b) if b.is_ascii_lowercase() || b.is_ascii_digit()) @@ -248,7 +326,7 @@ fn valid_segment(segment: &str) -> bool { /// Malformed key identifiers that the grammar must refuse. #[cfg(test)] -pub(crate) const MALFORMED_KEY_IDS: [&str; 18] = [ +pub(crate) const MALFORMED_KEY_IDS: [&str; 28] = [ "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/apps/x//", "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/apps//x", "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default//apps", @@ -265,8 +343,18 @@ pub(crate) const MALFORMED_KEY_IDS: [&str; 18] = [ "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/.hidden", "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/-x", "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:-default/apps/x", + "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/apps/", + "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/apps", + "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/apps/foo", "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:.a/apps/x", "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/apps/x", + "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/apps/x/a%2Fb", + "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/apps/x/a:b", + "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/apps/x/a b", + "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/apps/x/café", + "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:-default/apps/x/n", + "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:.a/apps/x/n", + "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/apps/x/n", ]; #[cfg(test)] @@ -303,6 +391,17 @@ mod tests { segments.join("/") } + fn network() -> NetworkId { + NetworkId::new(OWNER, "default").unwrap() + } + + fn vectors() -> serde_json::Value { + serde_json::from_str(include_str!( + "../../tests/fixtures/encryption/app-key-vectors.json" + )) + .expect("vector file parses") + } + #[test] fn parses_network_key_and_prefix_forms() { assert!(matches!( @@ -318,26 +417,27 @@ mod tests { Ok(prefix("apps/tinychat")) ); assert_eq!( - parse(&uri("default/apps/v1.2_x-y")), - Ok(key("apps/v1.2_x-y")) + parse(&uri("default/apps/v1.2_x-y/n")), + Ok(key("apps/v1.2_x-y/n")) ); assert_eq!(parse(&uri("default/apps/x/")), Ok(prefix("apps/x"))); + assert_eq!(parse(&uri("default/apps/x/sub/")), Ok(prefix("apps/x/sub"))); } #[test] fn accepts_did_key_did_web_and_mixed_case_network_owners() { - let did_key = "urn:tinycloud:encryption:did:key:z6MkjHi79SMX4doJyo2w7jDTM6Fa7iX6EVZWXNbJ6RzBm3Wi:default/apps/tinychat"; + let did_key = "urn:tinycloud:encryption:did:key:z6MkjHi79SMX4doJyo2w7jDTM6Fa7iX6EVZWXNbJ6RzBm3Wi:default/docs/tinychat"; assert_eq!( parse(did_key).map(|r| r.owner_did().to_string()), Ok("did:key:z6MkjHi79SMX4doJyo2w7jDTM6Fa7iX6EVZWXNbJ6RzBm3Wi".to_string()) ); - let did_web = "urn:tinycloud:encryption:did:web:example.com:user:alice:default/apps/x"; + let did_web = "urn:tinycloud:encryption:did:web:example.com:user:alice:default/docs/x"; assert_eq!( parse(did_web).map(|r| r.owner_did().to_string()), Ok("did:web:example.com:user:alice".to_string()) ); assert!(matches!( - parse(&format!("{PREFIX}{OWNER}:Default/apps/x")), + parse(&format!("{PREFIX}{OWNER}:Default/docs/x")), Ok(EncryptionResource::Key(loc)) if loc.network() == "Default" )); } @@ -373,9 +473,9 @@ mod tests { uri("default"), uri("default/secrets/software-factory"), uri("default/apps/tinychat/"), - uri("default/apps/v1.2_x-y"), + uri("default/apps/v1.2_x-y/n"), uri("default/apps/x/"), - format!("{PREFIX}{OWNER}:Default/apps/x"), + format!("{PREFIX}{OWNER}:Default/apps/x/n"), ] { assert_eq!(parse(&input).unwrap().to_string(), input); } @@ -394,23 +494,23 @@ mod tests { // empty prefix (vector 22) uri("default/"), // owner not canonical (vector 23) - "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed:default/apps/x".to_string(), + "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed:default/docs/x".to_string(), // `#` in owner, `#` and `?` in network, `%` in owner (vectors 25-28) - "urn:tinycloud:encryption:did:key:z6MkjHi79SMX4doJyo2w7jDTM6Fa7iX6EVZWXNbJ6RzBm3Wi#x:default/apps/x".to_string(), - uri("default#x/apps/x"), - uri("default?x/apps/x"), - "urn:tinycloud:encryption:did:web:example.com%3A8443:default/apps/x".to_string(), + "urn:tinycloud:encryption:did:key:z6MkjHi79SMX4doJyo2w7jDTM6Fa7iX6EVZWXNbJ6RzBm3Wi#x:default/docs/x".to_string(), + uri("default#x/docs/x"), + uri("default?x/docs/x"), + "urn:tinycloud:encryption:did:web:example.com%3A8443:default/docs/x".to_string(), // path grammar (vectors 7-16, 20, 21) - uri("default/apps/x//"), - uri("default/apps//x"), - uri("default//apps"), - uri("default/Apps/x"), - uri("default/apps/../x"), - uri("default/apps/./x"), - uri("default/apps/a%2Fb"), - uri("default/apps/a:b"), - uri("default/apps/a b"), - uri("default/apps/café"), + uri("default/docs/x//"), + uri("default/docs//x"), + uri("default//docs"), + uri("default/Docs/x"), + uri("default/docs/../x"), + uri("default/docs/./x"), + uri("default/docs/a%2Fb"), + uri("default/docs/a:b"), + uri("default/docs/a b"), + uri("default/docs/café"), uri("default/.hidden"), uri("default/-x"), // count and length limits (vectors 17-19, 34) @@ -419,8 +519,8 @@ mod tests { uri(&format!("default/{path_256}")), uri(&format!("default/{path_256}/")), // uppercase scheme or namespace is not the encryption namespace (vectors 29, 30) - format!("URN:tinycloud:encryption:{OWNER}:default/apps/x"), - format!("urn:TinyCloud:encryption:{OWNER}:default/apps/x"), + format!("URN:tinycloud:encryption:{OWNER}:default/docs/x"), + format!("urn:TinyCloud:encryption:{OWNER}:default/docs/x"), ]; for input in cases { assert!(parse(&input).is_err(), "accepted: {input}"); @@ -433,22 +533,22 @@ mod tests { let longer_than_64 = "a".repeat(65); let cases = [ // bidi override, key and prefix - uri("de\u{202e}fault/apps/x"), - uri("de\u{202e}fault/apps/x/"), + uri("de\u{202e}fault/docs/x"), + uri("de\u{202e}fault/docs/x/"), // zero-width space and byte-order mark - uri("de\u{200b}fault/apps/x"), - uri("\u{feff}default/apps/x"), + uri("de\u{200b}fault/docs/x"), + uri("\u{feff}default/docs/x"), // control character and non-ASCII letter - uri("de\u{7}fault/apps/x"), - uri("d\u{e9}fault/apps/x"), + uri("de\u{7}fault/docs/x"), + uri("d\u{e9}fault/docs/x"), // leading `-` and `.` - uri("-default/apps/x"), - uri(".a/apps/x"), + uri("-default/docs/x"), + uri(".a/docs/x"), // longer than 64 characters - uri(&format!("{longer_than_64}/apps/x")), + uri(&format!("{longer_than_64}/docs/x")), // newline inside the network, and newline after the path - uri("default\n/apps/x"), - uri("default/apps/x\n"), + uri("default\n/docs/x"), + uri("default/docs/x\n"), ]; for input in cases { assert!(parse(&input).is_err(), "accepted: {input:?}"); @@ -459,7 +559,7 @@ mod tests { fn accepts_network_names_at_the_class_boundaries() { let longest = "a".repeat(64); for network in [longest.as_str(), "DeFault", "0.x_-y"] { - match parse(&uri(&format!("{network}/apps/x"))) { + match parse(&uri(&format!("{network}/docs/x"))) { Ok(EncryptionResource::Key(loc)) => assert_eq!(loc.network(), network), other => panic!("expected key for {network:?}, got {other:?}"), } @@ -477,10 +577,10 @@ mod tests { #[test] fn prefix_covers_keys_and_prefixes_strictly_below_it() { - let granted = parse(&uri("default/apps/")).unwrap(); - assert!(granted.extends(&parse(&uri("default/apps/foo/")).unwrap())); - assert!(granted.extends(&parse(&uri("default/apps/foo/bar")).unwrap())); - assert!(!granted.extends(&parse(&uri("default/apps")).unwrap())); + let granted = parse(&uri("default/docs/")).unwrap(); + assert!(granted.extends(&parse(&uri("default/docs/foo/")).unwrap())); + assert!(granted.extends(&parse(&uri("default/docs/foo/bar")).unwrap())); + assert!(!granted.extends(&parse(&uri("default/docs")).unwrap())); } #[test] @@ -488,45 +588,231 @@ mod tests { let p = |tail: &str| parse(&uri(tail)).unwrap(); // granted does not cover requested assert!(!p("default/apps/foo/").extends(&p("default/apps/foobar/x"))); - assert!(!p("default/apps/foo/").extends(&p("default/apps/foo"))); - assert!(!p("default").extends(&p("default/apps/x"))); - assert!(!p("default/apps/x").extends(&p("default"))); + assert!(!p("default/docs/foo/").extends(&p("default/docs/foo"))); + assert!(!p("default").extends(&p("default/docs/x"))); + assert!(!p("default/docs/x").extends(&p("default"))); // granted covers requested - assert!(p("default/apps/").extends(&p("default/apps/foo/"))); + assert!(p("default/docs/").extends(&p("default/docs/foo/"))); assert!(p("default/apps/foo/").extends(&p("default/apps/foo/a/b"))); + assert!(p("default/apps/foo/").extends(&p("default/apps/foo/a/"))); } #[test] fn keys_and_networks_never_cover_each_other() { let p = |tail: &str| parse(&uri(tail)).unwrap(); - assert!(!p("default").extends(&p("default/apps/x"))); + assert!(!p("default").extends(&p("default/docs/x"))); assert!(p("default").extends(&p("default"))); - assert!(p("default/apps/x").extends(&p("default/apps/x"))); - assert!(!p("default/apps/x").extends(&p("default/apps/y"))); + assert!(p("default/docs/x").extends(&p("default/docs/x"))); + assert!(!p("default/docs/x").extends(&p("default/docs/y"))); } #[test] fn prefix_does_not_cover_other_owner_or_network() { let other_owner = parse( - "urn:tinycloud:encryption:did:key:z6MkjHi79SMX4doJyo2w7jDTM6Fa7iX6EVZWXNbJ6RzBm3Wi:default/apps/x", + "urn:tinycloud:encryption:did:key:z6MkjHi79SMX4doJyo2w7jDTM6Fa7iX6EVZWXNbJ6RzBm3Wi:default/docs/x", ) .unwrap(); - let granted = parse(&uri("default/apps/")).unwrap(); + let granted = parse(&uri("default/docs/")).unwrap(); assert!(!granted.extends(&other_owner)); - let other_network = parse(&uri("other/apps/x")).unwrap(); + let other_network = parse(&uri("other/docs/x")).unwrap(); assert!(!granted.extends(&other_network)); } #[test] fn owned_by_checks_owner_of_any_encryption_resource() { - assert!(owned_by(&uri("default/apps/x"), OWNER)); - assert!(owned_by(&uri("default/apps/"), OWNER)); + assert!(owned_by(&uri("default/docs/x"), OWNER)); + assert!(owned_by(&uri("default/docs/"), OWNER)); assert!(owned_by(&uri("default"), OWNER)); assert!(!owned_by( - &uri("default/apps/x"), + &uri("default/docs/x"), + "did:key:z6MkjHi79SMX4doJyo2w7jDTM6Fa7iX6EVZWXNbJ6RzBm3Wi" + )); + assert!(!owned_by("urn:tinycloud:encryption:default/docs/x", OWNER)); + } + + // Reserved-segment depth rule. + + #[test] + fn apps_paths_need_an_app_id_and_a_name() { + for refused in ["apps", "apps/foo"] { + assert!( + parse(&uri(&format!("default/{refused}"))).is_err(), + "key accepted: {refused}" + ); + assert!( + KeyLocation::new(&network(), refused).is_err(), + "location accepted: {refused}" + ); + } + for refused in ["apps/", "apps//"] { + assert!( + parse(&uri(&format!("default/{refused}"))).is_err(), + "prefix accepted: {refused}" + ); + } + assert_eq!( + KeyLocation::new(&network(), "apps").unwrap_err(), + KeyIdError::AppsPathTooShort + ); + assert!(parse(&uri("default/apps/foo/x")).is_ok()); + assert!(parse(&uri("default/apps/foo/")).is_ok()); + assert!(parse(&uri("default/apps/foo/x/")).is_ok()); + } + + #[test] + fn only_the_exact_first_segment_apps_is_reserved() { + for ordinary in ["appsx/y", "apps.x/y", "app/y", "xapps/y/z", "docs/apps"] { + assert!( + parse(&uri(&format!("default/{ordinary}"))).is_ok(), + "refused: {ordinary}" + ); + assert_eq!(KeyKind::for_path(ordinary), KeyKind::Deterministic); + } + assert!(parse(&uri("default/appsx/")).is_ok()); + assert!(parse(&uri("default/apps.x/")).is_ok()); + assert_eq!(KeyKind::for_path("apps/foo/x"), KeyKind::Deletable); + } + + #[test] + fn key_kind_follows_the_path() { + let loc = |path: &str| KeyLocation::new(&network(), path).unwrap(); + assert_eq!(loc("apps/foo/x").kind(), KeyKind::Deletable); + assert_eq!(loc("apps/foo/x/y").kind(), KeyKind::Deletable); + assert_eq!(loc("docs/a").kind(), KeyKind::Deterministic); + assert_eq!(loc("appsx/y").kind(), KeyKind::Deterministic); + assert_eq!(app_id_of("apps/foo/x"), Some("foo")); + assert_eq!(app_id_of("apps"), None); + assert_eq!(app_id_of("appsx/y"), None); + assert_eq!(KeyKind::parse("deletable"), Some(KeyKind::Deletable)); + assert_eq!(KeyKind::parse("seeded"), None); + } + + #[test] + fn shared_vectors_key_paths_under_apps() { + let vectors = vectors(); + let cases = vectors["nodeKeyPathUnderApps"].as_array().unwrap(); + assert_eq!(cases.len(), 10); + for case in cases { + let path = case["keyPath"].as_str().unwrap(); + let accepted = case["accepted"].as_bool().unwrap(); + assert_eq!( + KeyLocation::new(&network(), path).is_ok(), + accepted, + "location {path:?}" + ); + assert_eq!( + matches!( + parse(&uri(&format!("default/{path}"))), + Ok(EncryptionResource::Key(_)) + ), + accepted, + "parsed key {path:?}" + ); + } + } + + #[test] + fn shared_vectors_paths_refused_by_the_key_grammar() { + let vectors = vectors(); + let cases = vectors["keyPathsRefusedByGrammar"].as_array().unwrap(); + assert_eq!(cases.len(), 4); + for case in cases { + let path = case["keyPath"].as_str().unwrap(); + assert!( + KeyLocation::new(&network(), path).is_err(), + "accepted as a key path: {path:?}" + ); + } + } + + #[test] + fn shared_vectors_prefix_validity() { + let vectors = vectors(); + let cases = vectors["prefixValid"].as_array().unwrap(); + assert_eq!(cases.len(), 9); + for case in cases { + let prefix_path = case["prefix"].as_str().unwrap(); + let valid = case["valid"].as_bool().unwrap(); + let parsed = parse(&uri(&format!("default/{prefix_path}"))); + assert_eq!( + matches!(parsed, Ok(EncryptionResource::KeyPrefix(_))), + valid, + "prefix {prefix_path:?}" + ); + } + } + + /// A prefix covers a key by plain byte prefix on grammar-valid paths only: + /// a key path the grammar refuses is never covered. + #[test] + fn shared_vectors_prefix_coverage() { + let vectors = vectors(); + let cases = vectors["prefixCovers"].as_array().unwrap(); + assert_eq!(cases.len(), 15); + for case in cases { + let granted = case["prefix"].as_str().unwrap(); + let requested = case["key"].as_str().unwrap(); + let covers = case["covers"].as_bool().unwrap(); + let granted_resource = parse(&uri(&format!("default/{granted}"))) + .unwrap_or_else(|e| panic!("prefix {granted:?} refused: {e}")); + let actual = match parse(&uri(&format!("default/{requested}"))) { + Ok(requested) => granted_resource.extends(&requested), + Err(_) => false, + }; + assert_eq!(actual, covers, "{granted:?} vs {requested:?}"); + } + } + + #[test] + fn only_an_app_prefix_reaches_keys_under_the_reserved_segment() { + let reserved_key = parse(&uri("default/apps/foo/x")).unwrap(); + assert!(parse(&uri("default/apps/")).is_err()); + assert!(parse(&uri("default/")).is_err()); + assert!(!parse(&uri("default/appsx/")) + .unwrap() + .extends(&reserved_key)); + assert!(parse(&uri("default/apps/foo/")) + .unwrap() + .extends(&reserved_key)); + assert!(!parse(&uri("default/apps/bar/")) + .unwrap() + .extends(&reserved_key)); + } + + #[test] + fn every_malformed_id_is_refused() { + for id in MALFORMED_KEY_IDS { + assert!(parse(id).is_err(), "accepted: {id}"); + } + } + + #[test] + fn app_keys_and_networks_never_cover_each_other() { + let p = |tail: &str| parse(&uri(tail)).unwrap(); + assert!(!p("default").extends(&p("default/apps/foo/x"))); + assert!(!p("default").extends(&p("default/apps/foo/"))); + assert!(!p("default/apps/foo/x").extends(&p("default"))); + assert!(!p("default/apps/foo/").extends(&p("default"))); + assert!(p("default/apps/foo/x").extends(&p("default/apps/foo/x"))); + assert!(!p("default/apps/foo/x").extends(&p("default/apps/foo/y"))); + } + + #[test] + fn an_app_prefix_does_not_cover_another_owner_or_network() { + let other_owner = parse( + "urn:tinycloud:encryption:did:key:z6MkjHi79SMX4doJyo2w7jDTM6Fa7iX6EVZWXNbJ6RzBm3Wi:default/apps/foo/x", + ) + .unwrap(); + let granted = parse(&uri("default/apps/foo/")).unwrap(); + assert!(!granted.extends(&other_owner)); + assert!(!granted.extends(&parse(&uri("other/apps/foo/x")).unwrap())); + assert!(!granted.extends(&parse(&uri("other/apps/foo/")).unwrap())); + assert!(owned_by(&uri("default/apps/foo/x"), OWNER)); + assert!(owned_by(&uri("default/apps/foo/"), OWNER)); + assert!(!owned_by( + &uri("default/apps/foo/x"), "did:key:z6MkjHi79SMX4doJyo2w7jDTM6Fa7iX6EVZWXNbJ6RzBm3Wi" )); - assert!(!owned_by("urn:tinycloud:encryption:default/apps/x", OWNER)); } } diff --git a/tinycloud-core/src/encryption_network/keys.rs b/tinycloud-core/src/encryption_network/keys.rs index f47c7bb7..11cca856 100644 --- a/tinycloud-core/src/encryption_network/keys.rs +++ b/tinycloud-core/src/encryption_network/keys.rs @@ -1,36 +1,40 @@ //! Named-key rows: create, block/unblock, public-key derivation, and the //! state-checked unwrap used by the decrypt release guard. //! -//! Routes, wire shapes, delete, and the decrypt-path wiring that calls -//! `unwrap_named_key` after replay reservation are not implemented in this -//! module. +//! A key's kind follows from its path: a path under `apps/` is a deletable +//! key (derived from the network secret, the path and a per-key random seed), +//! every other path is a deterministic key (no seed). The create, block and +//! unblock writes all run under the network row lock. use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, DatabaseTransaction, - DbBackend, DbErr, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, + sea_query::Expr, ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, + DatabaseTransaction, DbBackend, DbErr, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, }; use crate::db::begin_write_transaction; use crate::models::{encryption_network, encryption_network_key}; -use super::backend::KeyBackendError; -use super::key_id::{EncryptionResource, KeyLocation}; +use super::backend::{KeyBackendError, KeySeed}; +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}; use super::types::{NetworkState, ALG_X25519_AES256GCM}; -pub const KEY_KIND_DETERMINISTIC: &str = "deterministic"; /// Interim hard cap per network. Counts every key row per network, deleted /// rows included. Placeholder value pending the node-config owner. pub const DEFAULT_KEY_CAP_PER_NETWORK: u64 = 1000; +/// Interim hard cap per `apps//` prefix. Counts every key row under +/// the prefix, deleted rows included, until a deleted row is reused. +pub const DEFAULT_KEY_CAP_PER_APP_PREFIX: u64 = 100; const KEY_VERSION_INITIAL: i64 = 1; -/// Key row state. Any stored state other than `active` or `blocked` is refused -/// as a corrupt row. +/// Key row state. Any stored state other than `active`, `blocked` or `deleted` +/// is refused as a corrupt row. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KeyState { Active, Blocked, + Deleted, } impl KeyState { @@ -38,6 +42,7 @@ impl KeyState { match self { KeyState::Active => "active", KeyState::Blocked => "blocked", + KeyState::Deleted => "deleted", } } @@ -45,6 +50,7 @@ impl KeyState { match stored { "active" => Ok(KeyState::Active), "blocked" => Ok(KeyState::Blocked), + "deleted" => Ok(KeyState::Deleted), _ => Err(EncryptionServiceError::CorruptKeyRow), } } @@ -85,7 +91,30 @@ pub(crate) fn canonical_key_urn(loc: &KeyLocation) -> String { EncryptionResource::Key(loc.clone()).to_string() } +/// A stored key row whose kind, state and seed are consistent with its path. +struct CheckedRow { + row: encryption_network_key::Model, + kind: KeyKind, + state: KeyState, +} + +/// Refuses a row that is not readable as a key: an unknown state or kind, a +/// kind the path does not imply, or a deletable row that has not been deleted +/// and holds no seed. +fn check_row(row: encryption_network_key::Model) -> Result { + let state = KeyState::parse(&row.state)?; + let kind = KeyKind::parse(&row.kind).ok_or(EncryptionServiceError::CorruptKeyRow)?; + if kind != KeyKind::for_path(&row.key_path) { + return Err(EncryptionServiceError::CorruptKeyRow); + } + if kind == KeyKind::Deletable && state != KeyState::Deleted && row.sealed_seed.is_none() { + return Err(EncryptionServiceError::CorruptKeyRow); + } + Ok(CheckedRow { row, kind, state }) +} + fn descriptor(row: encryption_network_key::Model) -> Result { + let CheckedRow { row, state, .. } = check_row(row)?; Ok(KeyDescriptor { network_id: row.network_id, key_path: row.key_path, @@ -93,7 +122,7 @@ fn descriptor(row: encryption_network_key::Model) -> Result( } /// The answer `key.create` gives for a row that already exists: the descriptor -/// for an ACTIVE deterministic key, otherwise `key_exists`. +/// for an ACTIVE deterministic key, `key_deleted` for a deleted row (the +/// deletable case never reaches here: it is reinstalled), otherwise +/// `key_exists`. fn existing_create_answer( row: encryption_network_key::Model, ) -> Result { - let key = descriptor(row)?; - if key.state == KeyState::Active && key.kind == KEY_KIND_DETERMINISTIC { - return Ok(key); + let CheckedRow { row, kind, state } = check_row(row)?; + match state { + KeyState::Deleted => Err(EncryptionServiceError::KeyDeleted), + KeyState::Active if kind == KeyKind::Deterministic => descriptor(row), + _ => Err(EncryptionServiceError::KeyExists), } - Err(EncryptionServiceError::KeyExists) } -/// Takes the row lock that serializes `key.create` for one network on +/// Takes the row lock that serializes the key writes of one network on /// PostgreSQL. On SQLite the writer transaction already serializes writers, /// so this is a no-op there. async fn lock_network_row(db: &C, network: &NetworkId) -> Result<(), DbErr> { @@ -158,27 +190,37 @@ async fn lock_network_row(db: &C, network: &NetworkId) -> Re Ok(()) } +/// The unsealed network secret with, for a deletable key, a new seed and its +/// sealed form. +type NetworkSecretAndSeed = (x25519_dalek::StaticSecret, Option, Option>); + /// What `key.create` found or did inside its transaction. enum CreateStep { Inserted(encryption_network_key::Model), + /// A deleted deletable row was reused with a fresh seed. + Reinstalled(encryption_network_key::Model), Existing(encryption_network_key::Model), /// The insert lost a unique-key race to another writer. Raced, } impl EncryptionService { - /// `key.create`. Idempotent for an identical ACTIVE deterministic key - /// (returns the existing descriptor). Any other existing row, in any state, - /// is `key_exists`. The existence check and the insert share one - /// transaction. On PostgreSQL the network row lock serializes concurrent - /// creates for the same network. + /// `key.create`. `deletable` must equal what the path implies (true under + /// `apps/`, false elsewhere). Idempotent for an identical ACTIVE + /// deterministic key (returns the existing descriptor). A `deleted` + /// deletable row is reused with a fresh seed, state `active` and the next + /// key version; any other existing row is `key_exists`, and a deleted + /// deterministic row is `key_deleted`. The existence check and the write + /// share one transaction taken under the network row lock. pub async fn create_key( &self, loc: &KeyLocation, deletable: bool, ) -> Result { - if deletable { - return Err(EncryptionServiceError::DeletableUnavailable); + if deletable != (loc.kind() == KeyKind::Deletable) { + return Err(EncryptionServiceError::KeyPathInvalid( + "deletable must be true for key paths under apps/ and false elsewhere".to_string(), + )); } let _writer = match &self.sqlite_writer_lock { Some(lock) => Some(lock.lock().await), @@ -190,7 +232,7 @@ impl EncryptionService { // rolled back by a task sqlx spawns, and the network row lock would // outlive this call until that task runs. match self.create_key_in(&tx, &network, loc).await { - Ok(CreateStep::Inserted(row)) => { + Ok(CreateStep::Inserted(row) | CreateStep::Reinstalled(row)) => { tx.commit().await?; descriptor(row) } @@ -224,36 +266,33 @@ impl EncryptionService { ) -> Result { lock_network_row(tx, network).await?; let net = load_active_network(tx, network).await?; + let kind = loc.kind(); - if let Some(existing) = find_key_row(tx, network, loc.path()).await? { - return Ok(CreateStep::Existing(existing)); + if let Some(row) = find_key_row(tx, network, loc.path()).await? { + let checked = check_row(row)?; + if checked.state == KeyState::Deleted && checked.kind == KeyKind::Deletable { + return self.reinstall_key_in(tx, &net, loc, checked.row).await; + } + return Ok(CreateStep::Existing(checked.row)); } - let count = encryption_network_key::Entity::find() - .filter(encryption_network_key::Column::NetworkId.eq(network.to_string())) - .count(tx) - .await?; - if count >= self.key_cap_per_network { - return Err(EncryptionServiceError::KeyLimitExceeded); - } + self.check_key_caps(tx, network, loc).await?; - let sealed = net - .sealed_private_key - .as_deref() - .ok_or(KeyBackendError::SealedKeyMissing)?; - let network_secret = self.backend.open_network_secret(sealed)?; - let public = self - .backend - .derive_named_public_key(&network_secret, &canonical_key_urn(loc))?; + let (network_secret, seed, sealed_seed) = self.open_secret_and_new_seed(&net, kind)?; + let public = self.backend.derive_named_public_key( + &network_secret, + &canonical_key_urn(loc), + seed.as_ref(), + )?; let now = now_rfc3339(); let inserted = encryption_network_key::ActiveModel { network_id: Set(network.to_string()), key_path: Set(loc.path().to_string()), - kind: Set(KEY_KIND_DETERMINISTIC.to_string()), + kind: Set(kind.as_str().to_string()), state: Set(KeyState::Active.as_str().to_string()), public_key: Set(public), - sealed_seed: Set(None), + sealed_seed: Set(sealed_seed), key_version: Set(KEY_VERSION_INITIAL), created_at: Set(now.clone()), updated_at: Set(now), @@ -267,8 +306,102 @@ impl EncryptionService { } } - /// `key.block`. Idempotent; returns the descriptor. Does not check the - /// network's state. + /// Unseals the network secret and, for a deletable key, makes a fresh + /// seed. Returns the sealed seed to store. + fn open_secret_and_new_seed( + &self, + net: &encryption_network::Model, + kind: KeyKind, + ) -> Result { + let sealed = net + .sealed_private_key + .as_deref() + .ok_or(KeyBackendError::SealedKeyMissing)?; + let network_secret = self.backend.open_network_secret(sealed)?; + match kind { + KeyKind::Deterministic => Ok((network_secret, None, None)), + KeyKind::Deletable => { + let (seed, sealed_seed) = self.backend.generate_seed()?; + Ok((network_secret, Some(seed), Some(sealed_seed))) + } + } + } + + /// The per-network and per-app-prefix caps for a new row. + async fn check_key_caps( + &self, + tx: &DatabaseTransaction, + network: &NetworkId, + loc: &KeyLocation, + ) -> Result<(), EncryptionServiceError> { + let count = encryption_network_key::Entity::find() + .filter(encryption_network_key::Column::NetworkId.eq(network.to_string())) + .count(tx) + .await?; + if count >= self.key_cap_per_network { + return Err(EncryptionServiceError::KeyLimitExceeded); + } + if let Some(app_id) = app_id_of(loc.path()) { + let prefix = format!("apps/{app_id}/"); + // Compared as bytes here, never with a LIKE pattern or a range. + let paths: Vec = encryption_network_key::Entity::find() + .select_only() + .column(encryption_network_key::Column::KeyPath) + .filter(encryption_network_key::Column::NetworkId.eq(network.to_string())) + .into_tuple() + .all(tx) + .await?; + let under_prefix = paths.iter().filter(|p| p.starts_with(&prefix)).count() as u64; + if under_prefix >= self.key_cap_per_app_prefix { + return Err(EncryptionServiceError::KeyLimitExceeded); + } + } + Ok(()) + } + + /// Reuses a `deleted` deletable row: fresh seed, state `active`, key + /// version + 1. The row is updated only while it is still `deleted` at + /// the version read. + async fn reinstall_key_in( + &self, + tx: &DatabaseTransaction, + net: &encryption_network::Model, + loc: &KeyLocation, + row: encryption_network_key::Model, + ) -> Result { + let (network_secret, seed, sealed_seed) = + self.open_secret_and_new_seed(net, KeyKind::Deletable)?; + let public = self.backend.derive_named_public_key( + &network_secret, + &canonical_key_urn(loc), + seed.as_ref(), + )?; + let updated = encryption_network_key::Entity::update_many() + .set(encryption_network_key::ActiveModel { + state: Set(KeyState::Active.as_str().to_string()), + public_key: Set(public), + sealed_seed: Set(sealed_seed), + key_version: Set(row.key_version + 1), + updated_at: Set(now_rfc3339()), + ..Default::default() + }) + .filter(encryption_network_key::Column::NetworkId.eq(row.network_id.clone())) + .filter(encryption_network_key::Column::KeyPath.eq(row.key_path.clone())) + .filter(encryption_network_key::Column::State.eq(KeyState::Deleted.as_str())) + .filter(encryption_network_key::Column::KeyVersion.eq(row.key_version)) + .exec(tx) + .await?; + if updated.rows_affected != 1 { + return Ok(CreateStep::Raced); + } + let row = find_key_row(tx, &network_of(loc)?, loc.path()) + .await? + .ok_or(EncryptionServiceError::KeyNotFound)?; + Ok(CreateStep::Reinstalled(row)) + } + + /// `key.block`. Idempotent; returns the descriptor. A deleted key is + /// `key_deleted`. Does not check the network's state. pub async fn block_key( &self, loc: &KeyLocation, @@ -276,8 +409,8 @@ impl EncryptionService { self.set_key_state(loc, KeyState::Blocked).await } - /// `key.unblock`. Idempotent; returns the descriptor. Does not check the - /// network's state. + /// `key.unblock`. Idempotent; returns the descriptor. A deleted key is + /// `key_deleted`. Does not check the network's state. pub async fn unblock_key( &self, loc: &KeyLocation, @@ -295,16 +428,67 @@ impl EncryptionService { None => None, }; let network = network_of(loc)?; - let row = find_key_row(&self.db, &network, loc.path()) + let tx = begin_write_transaction(&self.db, None).await?; + match self.set_key_state_in(&tx, &network, loc, target).await { + Ok(row) => { + tx.commit().await?; + descriptor(row) + } + Err(err) => { + let _ = tx.rollback().await; + Err(err) + } + } + } + + /// Under the network row lock: reads the row and writes the new state + /// only while the stored state is `active` or `blocked`. + async fn set_key_state_in( + &self, + tx: &DatabaseTransaction, + network: &NetworkId, + loc: &KeyLocation, + target: KeyState, + ) -> Result { + lock_network_row(tx, network).await?; + let row = find_key_row(tx, network, loc.path()) + .await? + .ok_or(EncryptionServiceError::KeyNotFound)?; + let checked = check_row(row)?; + match checked.state { + KeyState::Deleted => return Err(EncryptionServiceError::KeyDeleted), + state if state == target => return Ok(checked.row), + _ => {} + } + let updated = encryption_network_key::Entity::update_many() + .col_expr( + encryption_network_key::Column::State, + Expr::value(target.as_str()), + ) + .col_expr( + encryption_network_key::Column::UpdatedAt, + Expr::value(now_rfc3339()), + ) + .filter(encryption_network_key::Column::NetworkId.eq(network.to_string())) + .filter(encryption_network_key::Column::KeyPath.eq(loc.path())) + .filter( + encryption_network_key::Column::State + .is_in([KeyState::Active.as_str(), KeyState::Blocked.as_str()]), + ) + .exec(tx) + .await?; + let row = find_key_row(tx, network, loc.path()) .await? .ok_or(EncryptionServiceError::KeyNotFound)?; - if KeyState::parse(&row.state)? == target { - return descriptor(row); + if updated.rows_affected != 1 { + // The row left `active`/`blocked` before the write: answer from + // what is stored now. + return match check_row(row)?.state { + KeyState::Deleted => Err(EncryptionServiceError::KeyDeleted), + _ => Err(EncryptionServiceError::CorruptKeyRow), + }; } - let mut active: encryption_network_key::ActiveModel = row.into(); - active.state = Set(target.as_str().to_string()); - active.updated_at = Set(now_rfc3339()); - descriptor(active.update(&self.db).await?) + Ok(row) } /// Authenticated descriptor GET. `key_not_found` is only reported here. @@ -320,11 +504,16 @@ impl EncryptionService { descriptor(row) } - /// Unauthenticated public GET. Requires an ACTIVE network. Always derives - /// from the network secret, never from the stored row, so missing, blocked - /// and active keys return identical output. Writes nothing and never - /// reports key state or absence. + /// Unauthenticated public GET. Under `apps/` every path answers + /// `key_not_found` without reading anything. Elsewhere it requires an + /// ACTIVE network and always derives from the network secret, never from + /// the stored row, so missing, blocked and active keys return identical + /// output. Writes nothing and never reports key state or absence outside + /// `apps/`. pub async fn public_key(&self, loc: &KeyLocation) -> Result { + if is_apps_path(loc.path()) { + return Err(EncryptionServiceError::KeyNotFound); + } let network = network_of(loc)?; let net = load_active_network(&self.db, &network).await?; let sealed = net @@ -332,9 +521,9 @@ impl EncryptionService { .as_deref() .ok_or(KeyBackendError::SealedKeyMissing)?; let network_secret = self.backend.open_network_secret(sealed)?; - let public = self - .backend - .derive_named_public_key(&network_secret, &canonical_key_urn(loc))?; + let public = + self.backend + .derive_named_public_key(&network_secret, &canonical_key_urn(loc), None)?; Ok(KeyPublic { network_id: network.to_string(), key_path: loc.path().to_string(), @@ -345,32 +534,78 @@ impl EncryptionService { } /// Release-guard state read and unwrap. Order: network ACTIVE, then key row - /// exists and is ACTIVE, 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`. + /// 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`. pub async fn unwrap_named_key( &self, loc: &KeyLocation, wrapped_key: &[u8], + requested_key_version: i64, ) -> Result<(i64, Vec), EncryptionServiceError> { let network = network_of(loc)?; let net = load_active_network(&self.db, &network).await?; let row = find_key_row(&self.db, &network, loc.path()) .await? .ok_or(EncryptionServiceError::KeyNotFound)?; - match KeyState::parse(&row.state)? { + let CheckedRow { row, kind, state } = check_row(row)?; + match state { KeyState::Active => {} KeyState::Blocked => return Err(EncryptionServiceError::KeyBlocked), + KeyState::Deleted => return Err(EncryptionServiceError::KeyDeleted), + } + if kind == KeyKind::Deletable && row.key_version != requested_key_version { + return Err(EncryptionServiceError::KeyDeleted); } let sealed = net .sealed_private_key .as_deref() .ok_or(KeyBackendError::SealedKeyMissing)?; let network_secret = self.backend.open_network_secret(sealed)?; + let seed = match kind { + KeyKind::Deterministic => None, + KeyKind::Deletable => { + let sealed_seed = row + .sealed_seed + .as_deref() + .ok_or(EncryptionServiceError::CorruptKeyRow)?; + Some( + self.backend + .open_seed(sealed_seed) + .map_err(|_| EncryptionServiceError::CorruptKeyRow)?, + ) + } + }; let symmetric = self .backend - .unwrap_named(&network_secret, &canonical_key_urn(loc), wrapped_key) + .unwrap_named( + &network_secret, + &canonical_key_urn(loc), + seed.as_ref(), + wrapped_key, + ) .map_err(EncryptionServiceError::KeyUnwrapFailed)?; Ok((row.key_version, symmetric)) } } + +/// Startup check: no key row under `apps/` (or at the path `apps`) may be +/// anything other than a deletable key. Such a row cannot be read as a key, +/// and a path under `apps/` is only ever deletable. +pub async fn ensure_no_deterministic_app_keys( + db: &C, +) -> Result<(), EncryptionServiceError> { + let stray = encryption_network_key::Entity::find() + .filter( + Expr::cust("(key_path = 'apps' OR substr(key_path, 1, 5) = 'apps/')") + .and(encryption_network_key::Column::Kind.ne(KeyKind::Deletable.as_str())), + ) + .count(db) + .await?; + if stray == 0 { + return Ok(()); + } + Err(EncryptionServiceError::StrayAppKeyRows(stray)) +} diff --git a/tinycloud-core/src/encryption_network/mod.rs b/tinycloud-core/src/encryption_network/mod.rs index 334d5ad2..ccbf849d 100644 --- a/tinycloud-core/src/encryption_network/mod.rs +++ b/tinycloud-core/src/encryption_network/mod.rs @@ -5,6 +5,7 @@ //! invocation verification. The module deliberately does not expose a node-side //! encrypt API — clients encrypt to the network public key locally. +pub mod app_id; pub mod backend; pub mod canonical; pub mod key_id; diff --git a/tinycloud-core/src/encryption_network/service.rs b/tinycloud-core/src/encryption_network/service.rs index bfb4cf6b..fa5f552a 100644 --- a/tinycloud-core/src/encryption_network/service.rs +++ b/tinycloud-core/src/encryption_network/service.rs @@ -28,7 +28,9 @@ use crate::util::InvocationInfo; use super::backend::{validate_receiver_public_key, KeyBackend, KeyBackendError}; use super::canonical::{canonical_hash, canonical_json_bytes, hash_hex}; use super::key_id::{self, EncryptionResource, KeyLocation}; -use super::keys::{canonical_key_urn, network_of, DEFAULT_KEY_CAP_PER_NETWORK}; +use super::keys::{ + canonical_key_urn, network_of, DEFAULT_KEY_CAP_PER_APP_PREFIX, DEFAULT_KEY_CAP_PER_NETWORK, +}; use super::network_id::NetworkId; use super::protocol::{ DecryptFacts, DecryptInvocation, DecryptRequestBody, DecryptResponseBody, KeyAdminFacts, @@ -156,6 +158,7 @@ fn check_alg_and_version( fn key_denial_outcome(err: &EncryptionServiceError) -> &'static str { match err { EncryptionServiceError::KeyBlocked => "denied:key-blocked", + EncryptionServiceError::KeyDeleted => "denied:key-deleted", EncryptionServiceError::KeyNotFound => "denied:key-not-found", EncryptionServiceError::KeyUnwrapFailed(_) => "denied:key-unwrap", EncryptionServiceError::NetworkNotActive(_) | EncryptionServiceError::NetworkNotFound => { @@ -211,8 +214,8 @@ pub enum EncryptionServiceError { Base64(&'static str), #[error("node response signing failed: {0}")] Signing(String), - #[error("deletable_unavailable: deletable keys are not available in this release")] - DeletableUnavailable, + #[error("key_deleted: the key has been deleted")] + KeyDeleted, #[error("key_exists: a key already exists at this path")] KeyExists, #[error("key_not_found: no key exists at this path")] @@ -227,6 +230,8 @@ pub enum EncryptionServiceError { CorruptKeyRow, #[error("key_path_invalid: {0}")] KeyPathInvalid(String), + #[error("{0} key rows under apps/ are not deletable keys; list them with: SELECT network_id, key_path, kind FROM encryption_network_key WHERE (key_path = 'apps' OR substr(key_path, 1, 5) = 'apps/') AND kind <> 'deletable'")] + StrayAppKeyRows(u64), } pub struct EncryptionService { @@ -237,6 +242,7 @@ pub struct EncryptionService { invocation_ttl_seconds: i64, pub(super) sqlite_writer_lock: Option>>, pub(super) key_cap_per_network: u64, + pub(super) key_cap_per_app_prefix: u64, } #[derive(Debug, Clone)] @@ -266,6 +272,7 @@ impl EncryptionService { invocation_ttl_seconds: DEFAULT_INVOCATION_TTL_SECONDS, sqlite_writer_lock: None, key_cap_per_network: DEFAULT_KEY_CAP_PER_NETWORK, + key_cap_per_app_prefix: DEFAULT_KEY_CAP_PER_APP_PREFIX, } } @@ -283,6 +290,7 @@ impl EncryptionService { invocation_ttl_seconds: DEFAULT_INVOCATION_TTL_SECONDS, sqlite_writer_lock: None, key_cap_per_network: DEFAULT_KEY_CAP_PER_NETWORK, + key_cap_per_app_prefix: DEFAULT_KEY_CAP_PER_APP_PREFIX, } } @@ -296,6 +304,11 @@ impl EncryptionService { self } + pub fn with_key_cap_per_app_prefix(mut self, cap: u64) -> Self { + self.key_cap_per_app_prefix = cap; + self + } + pub fn node_did(&self) -> &str { &self.node_did } @@ -1037,20 +1050,22 @@ impl EncryptionService { DecryptTarget::Key { loc, .. } => { // Network and key state are read inside `unwrap_named_key`, // after reservation and immediately before unwrap. - let (key_version, symmetric) = - match self.unwrap_named_key(loc, &wrapped_key_bytes).await { - Ok(unwrapped) => unwrapped, - Err(err) => { - self.record_native_audit( - invocation, - target, - &facts, - key_denial_outcome(&err), - ) - .await?; - return Err(err); - } - }; + let (key_version, symmetric) = match self + .unwrap_named_key(loc, &wrapped_key_bytes, body.key_version) + .await + { + Ok(unwrapped) => unwrapped, + Err(err) => { + self.record_native_audit( + invocation, + target, + &facts, + key_denial_outcome(&err), + ) + .await?; + 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?; diff --git a/tinycloud-core/src/encryption_network/tests.rs b/tinycloud-core/src/encryption_network/tests.rs index 37e953ec..50c9468c 100644 --- a/tinycloud-core/src/encryption_network/tests.rs +++ b/tinycloud-core/src/encryption_network/tests.rs @@ -876,12 +876,12 @@ async fn create_network(svc: &EncryptionService) { async fn create_key_returns_active_deterministic_descriptor() { let svc = make_service(fresh_db().await); create_network(&svc).await; - let key = svc.create_key(&key_loc("apps/alpha"), false).await.unwrap(); + let key = svc.create_key(&key_loc("docs/alpha"), false).await.unwrap(); assert_eq!(key.state, KeyState::Active); assert_eq!(key.kind, "deterministic"); assert_eq!(key.key_version, 1); assert_eq!(key.alg, ALG_X25519_AES256GCM); - assert_eq!(key.key_path, "apps/alpha"); + assert_eq!(key.key_path, "docs/alpha"); assert_eq!(key.public_encryption_key.len(), 32); } @@ -889,8 +889,8 @@ async fn create_key_returns_active_deterministic_descriptor() { async fn create_key_is_idempotent_for_active_key() { let svc = make_service(fresh_db().await); create_network(&svc).await; - let first = svc.create_key(&key_loc("apps/alpha"), false).await.unwrap(); - let second = svc.create_key(&key_loc("apps/alpha"), false).await.unwrap(); + let first = svc.create_key(&key_loc("docs/alpha"), false).await.unwrap(); + let second = svc.create_key(&key_loc("docs/alpha"), false).await.unwrap(); assert_eq!(first, second); } @@ -898,31 +898,20 @@ async fn create_key_is_idempotent_for_active_key() { async fn create_key_rejects_existing_blocked_key() { let svc = make_service(fresh_db().await); create_network(&svc).await; - svc.create_key(&key_loc("apps/alpha"), false).await.unwrap(); - svc.block_key(&key_loc("apps/alpha")).await.unwrap(); + svc.create_key(&key_loc("docs/alpha"), false).await.unwrap(); + svc.block_key(&key_loc("docs/alpha")).await.unwrap(); let err = svc - .create_key(&key_loc("apps/alpha"), false) + .create_key(&key_loc("docs/alpha"), false) .await .unwrap_err(); assert!(matches!(err, EncryptionServiceError::KeyExists)); } -#[tokio::test] -async fn create_key_rejects_deletable() { - let svc = make_service(fresh_db().await); - create_network(&svc).await; - let err = svc - .create_key(&key_loc("apps/alpha"), true) - .await - .unwrap_err(); - assert!(matches!(err, EncryptionServiceError::DeletableUnavailable)); -} - #[tokio::test] async fn create_key_requires_existing_network() { let svc = make_service(fresh_db().await); let err = svc - .create_key(&key_loc("apps/alpha"), false) + .create_key(&key_loc("docs/alpha"), false) .await .unwrap_err(); assert!(matches!(err, EncryptionServiceError::NetworkNotFound)); @@ -934,7 +923,7 @@ async fn create_key_refuses_revoked_network() { create_network(&svc).await; svc.revoke_network(&network_id()).await.unwrap(); let err = svc - .create_key(&key_loc("apps/alpha"), false) + .create_key(&key_loc("docs/alpha"), false) .await .unwrap_err(); assert!( @@ -958,14 +947,14 @@ async fn create_key_cap_counts_blocked_rows() { async fn block_and_unblock_are_idempotent_round_trip() { let svc = make_service(fresh_db().await); create_network(&svc).await; - svc.create_key(&key_loc("apps/alpha"), false).await.unwrap(); - let blocked = svc.block_key(&key_loc("apps/alpha")).await.unwrap(); + svc.create_key(&key_loc("docs/alpha"), false).await.unwrap(); + let blocked = svc.block_key(&key_loc("docs/alpha")).await.unwrap(); assert_eq!(blocked.state, KeyState::Blocked); assert_eq!( - svc.block_key(&key_loc("apps/alpha")).await.unwrap(), + svc.block_key(&key_loc("docs/alpha")).await.unwrap(), blocked ); - let active = svc.unblock_key(&key_loc("apps/alpha")).await.unwrap(); + let active = svc.unblock_key(&key_loc("docs/alpha")).await.unwrap(); assert_eq!(active.state, KeyState::Active); } @@ -973,7 +962,7 @@ async fn block_and_unblock_are_idempotent_round_trip() { async fn block_missing_key_is_not_found() { let svc = make_service(fresh_db().await); create_network(&svc).await; - let err = svc.block_key(&key_loc("apps/missing")).await.unwrap_err(); + let err = svc.block_key(&key_loc("docs/missing")).await.unwrap_err(); assert!(matches!(err, EncryptionServiceError::KeyNotFound)); } @@ -981,7 +970,7 @@ async fn block_missing_key_is_not_found() { async fn public_get_derives_without_writing_and_matches_created_key() { let svc = make_service(fresh_db().await); create_network(&svc).await; - let loc = key_loc("apps/beta"); + let loc = key_loc("docs/beta"); let derived = svc.public_key(&loc).await.unwrap(); let err = svc.describe_key(&loc).await.unwrap_err(); assert!(matches!(err, EncryptionServiceError::KeyNotFound)); @@ -993,7 +982,7 @@ async fn public_get_derives_without_writing_and_matches_created_key() { async fn public_get_is_identical_for_missing_active_and_blocked_keys() { let svc = make_service(fresh_db().await); create_network(&svc).await; - let loc = key_loc("apps/gamma"); + let loc = key_loc("docs/gamma"); let missing = svc.public_key(&loc).await.unwrap(); svc.create_key(&loc, false).await.unwrap(); let active = svc.public_key(&loc).await.unwrap(); @@ -1007,11 +996,11 @@ async fn public_get_is_identical_for_missing_active_and_blocked_keys() { async fn unwrap_named_key_round_trip_for_active_key() { let svc = make_service(fresh_db().await); create_network(&svc).await; - let loc = key_loc("apps/alpha"); + let loc = key_loc("docs/alpha"); let key = svc.create_key(&loc, false).await.unwrap(); let symmetric = vec![0x5Au8; 32]; let wrapped = wrap_to_public_key(&key.public_encryption_key, &symmetric).unwrap(); - let (_, unwrapped) = svc.unwrap_named_key(&loc, &wrapped).await.unwrap(); + let (_, unwrapped) = svc.unwrap_named_key(&loc, &wrapped, 1).await.unwrap(); assert_eq!(unwrapped, symmetric); } @@ -1019,11 +1008,11 @@ async fn unwrap_named_key_round_trip_for_active_key() { async fn unwrap_named_key_refuses_blocked_key() { let svc = make_service(fresh_db().await); create_network(&svc).await; - let loc = key_loc("apps/alpha"); + let loc = key_loc("docs/alpha"); let key = svc.create_key(&loc, false).await.unwrap(); let wrapped = wrap_to_public_key(&key.public_encryption_key, &[0x5Au8; 32]).unwrap(); svc.block_key(&loc).await.unwrap(); - let err = svc.unwrap_named_key(&loc, &wrapped).await.unwrap_err(); + let err = svc.unwrap_named_key(&loc, &wrapped, 1).await.unwrap_err(); assert!(matches!(err, EncryptionServiceError::KeyBlocked)); } @@ -1032,7 +1021,7 @@ async fn unwrap_named_key_refuses_missing_row() { let svc = make_service(fresh_db().await); create_network(&svc).await; let err = svc - .unwrap_named_key(&key_loc("apps/alpha"), &[0u8; 96]) + .unwrap_named_key(&key_loc("docs/alpha"), &[0u8; 96], 1) .await .unwrap_err(); assert!(matches!(err, EncryptionServiceError::KeyNotFound)); @@ -1042,11 +1031,11 @@ async fn unwrap_named_key_refuses_missing_row() { async fn unwrap_named_key_rejects_wrapping_to_other_key() { let svc = make_service(fresh_db().await); create_network(&svc).await; - let alpha = svc.create_key(&key_loc("apps/alpha"), false).await.unwrap(); - svc.create_key(&key_loc("apps/beta"), false).await.unwrap(); + let alpha = svc.create_key(&key_loc("docs/alpha"), false).await.unwrap(); + svc.create_key(&key_loc("docs/beta"), false).await.unwrap(); let wrapped = wrap_to_public_key(&alpha.public_encryption_key, &[0x5Au8; 32]).unwrap(); let err = svc - .unwrap_named_key(&key_loc("apps/beta"), &wrapped) + .unwrap_named_key(&key_loc("docs/beta"), &wrapped, 1) .await .unwrap_err(); assert!(matches!(err, EncryptionServiceError::KeyUnwrapFailed(_))); @@ -1056,11 +1045,11 @@ async fn unwrap_named_key_rejects_wrapping_to_other_key() { async fn unwrap_named_key_refuses_revoked_network() { let svc = make_service(fresh_db().await); create_network(&svc).await; - let loc = key_loc("apps/alpha"); + let loc = key_loc("docs/alpha"); let key = svc.create_key(&loc, false).await.unwrap(); let wrapped = wrap_to_public_key(&key.public_encryption_key, &[0x5Au8; 32]).unwrap(); svc.revoke_network(&network_id()).await.unwrap(); - let err = svc.unwrap_named_key(&loc, &wrapped).await.unwrap_err(); + let err = svc.unwrap_named_key(&loc, &wrapped, 1).await.unwrap_err(); assert!( matches!(&err, EncryptionServiceError::NetworkNotActive(s) if s == "revoked"), "unwrap after network revoke must be refused, got {err:?}" @@ -1071,7 +1060,7 @@ async fn unwrap_named_key_refuses_revoked_network() { async fn public_key_refuses_revoked_network() { let svc = make_service(fresh_db().await); create_network(&svc).await; - let loc = key_loc("apps/alpha"); + let loc = key_loc("docs/alpha"); svc.create_key(&loc, false).await.unwrap(); svc.revoke_network(&network_id()).await.unwrap(); let err = svc.public_key(&loc).await.unwrap_err(); @@ -1086,22 +1075,22 @@ async fn stored_unknown_state_is_refused_as_corrupt() { let db = fresh_db().await; let svc = make_service(db.clone()); create_network(&svc).await; - let loc = key_loc("apps/alpha"); + let loc = key_loc("docs/alpha"); let key = svc.create_key(&loc, false).await.unwrap(); let wrapped = wrap_to_public_key(&key.public_encryption_key, &[0x5Au8; 32]).unwrap(); // A raw row in a state this build does not know, as a future build might write. let mut row: encryption_network_key::ActiveModel = encryption_network_key::Entity::find_by_id( - (network_id().to_string(), "apps/alpha".to_string()), + (network_id().to_string(), "docs/alpha".to_string()), ) .one(&db) .await .unwrap() .unwrap() .into(); - row.state = Set("deleted".to_string()); + row.state = Set("retired".to_string()); row.update(&db).await.unwrap(); - let err = svc.unwrap_named_key(&loc, &wrapped).await.unwrap_err(); + let err = svc.unwrap_named_key(&loc, &wrapped, 1).await.unwrap_err(); assert!( matches!(err, EncryptionServiceError::CorruptKeyRow), "unwrap: {err:?}" @@ -1146,7 +1135,7 @@ async fn postgres_concurrent_identical_creates_return_one_row() { let results = futures::future::join_all((0..8).map(|_| { let svc = svc.clone(); - async move { svc.create_key(&key_loc("apps/alpha"), false).await } + async move { svc.create_key(&key_loc("docs/alpha"), false).await } })) .await; let descriptors: Vec<_> = results @@ -1220,18 +1209,18 @@ async fn postgres_create_key_ends_its_transaction_before_returning() { let svc = make_service(db.clone()).with_key_cap_per_network(1); create_network(&svc).await; - svc.create_key(&key_loc("apps/alpha"), false).await.unwrap(); + svc.create_key(&key_loc("docs/alpha"), false).await.unwrap(); fixture .execute_blocking(LOCK) .expect("a create that inserts releases the network row lock"); - svc.create_key(&key_loc("apps/alpha"), false).await.unwrap(); + svc.create_key(&key_loc("docs/alpha"), false).await.unwrap(); fixture .execute_blocking(LOCK) .expect("an identical create answered from the existing row releases the network row lock"); let err = svc - .create_key(&key_loc("apps/beta"), false) + .create_key(&key_loc("docs/beta"), false) .await .unwrap_err(); assert!( @@ -1297,11 +1286,11 @@ async fn key_decrypt_round_trip_returns_rewrapped_key() { let svc = make_service(fresh_db().await); create_network(&svc).await; let net = network_id(); - let loc = key_loc("apps/alpha"); + let loc = key_loc("docs/alpha"); let key = svc.create_key(&loc, false).await.unwrap(); let ctx = make_client_request(&key.public_encryption_key); - let (_, body_value) = key_body(&ctx, &net, "apps/alpha", 1); - let invocation = key_invocation(&loc, key_facts(&ctx, &net, &body_value, "apps/alpha", 1)); + let (_, body_value) = key_body(&ctx, &net, "docs/alpha", 1); + let invocation = key_invocation(&loc, key_facts(&ctx, &net, &body_value, "docs/alpha", 1)); let target = DecryptTarget::from_resource(&canonical_key_urn(&loc)).unwrap(); let verified = svc @@ -1322,11 +1311,11 @@ async fn key_decrypt_refuses_body_key_path_mismatch() { let svc = make_service(fresh_db().await); create_network(&svc).await; let net = network_id(); - let alpha = key_loc("apps/alpha"); + let alpha = key_loc("docs/alpha"); let key = svc.create_key(&alpha, false).await.unwrap(); let ctx = make_client_request(&key.public_encryption_key); - let (_, body_value) = key_body(&ctx, &net, "apps/beta", 1); - let invocation = key_invocation(&alpha, key_facts(&ctx, &net, &body_value, "apps/beta", 1)); + let (_, body_value) = key_body(&ctx, &net, "docs/beta", 1); + let invocation = key_invocation(&alpha, key_facts(&ctx, &net, &body_value, "docs/beta", 1)); let target = DecryptTarget::from_resource(&canonical_key_urn(&alpha)).unwrap(); let err = svc @@ -1341,13 +1330,13 @@ async fn key_decrypt_refuses_capability_for_sibling_key() { let svc = make_service(fresh_db().await); create_network(&svc).await; let net = network_id(); - let alpha = key_loc("apps/alpha"); - let beta = key_loc("apps/beta"); + let alpha = key_loc("docs/alpha"); + let beta = key_loc("docs/beta"); let key = svc.create_key(&alpha, false).await.unwrap(); svc.create_key(&beta, false).await.unwrap(); let ctx = make_client_request(&key.public_encryption_key); - let (_, body_value) = key_body(&ctx, &net, "apps/alpha", 1); - let invocation = key_invocation(&beta, key_facts(&ctx, &net, &body_value, "apps/alpha", 1)); + let (_, body_value) = key_body(&ctx, &net, "docs/alpha", 1); + let invocation = key_invocation(&beta, key_facts(&ctx, &net, &body_value, "docs/alpha", 1)); let target = DecryptTarget::from_resource(&canonical_key_urn(&alpha)).unwrap(); let err = svc @@ -1370,11 +1359,11 @@ async fn root_decrypt_refuses_body_key_path() { .await .unwrap(); let ctx = make_client_request(&descriptor.public_encryption_key); - let (_, body_value) = key_body(&ctx, &net, "apps/alpha", 1); + let (_, body_value) = key_body(&ctx, &net, "docs/alpha", 1); let invocation = build_session_invocation_info( &net, DECRYPT_ACTION, - vec![key_facts(&ctx, &net, &body_value, "apps/alpha", 1)], + vec![key_facts(&ctx, &net, &body_value, "docs/alpha", 1)], NODE_DID, ); @@ -1390,12 +1379,12 @@ async fn key_decrypt_refuses_blocked_key() { let svc = make_service(fresh_db().await); create_network(&svc).await; let net = network_id(); - let loc = key_loc("apps/alpha"); + let loc = key_loc("docs/alpha"); let key = svc.create_key(&loc, false).await.unwrap(); svc.block_key(&loc).await.unwrap(); let ctx = make_client_request(&key.public_encryption_key); - let (_, body_value) = key_body(&ctx, &net, "apps/alpha", 1); - let invocation = key_invocation(&loc, key_facts(&ctx, &net, &body_value, "apps/alpha", 1)); + let (_, body_value) = key_body(&ctx, &net, "docs/alpha", 1); + let invocation = key_invocation(&loc, key_facts(&ctx, &net, &body_value, "docs/alpha", 1)); let target = DecryptTarget::from_resource(&canonical_key_urn(&loc)).unwrap(); let err = svc @@ -1410,11 +1399,11 @@ async fn key_decrypt_refuses_missing_key() { let svc = make_service(fresh_db().await); create_network(&svc).await; let net = network_id(); - let loc = key_loc("apps/missing"); + let loc = key_loc("docs/missing"); let derived = svc.public_key(&loc).await.unwrap(); let ctx = make_client_request(&derived.public_encryption_key); - let (_, body_value) = key_body(&ctx, &net, "apps/missing", 1); - let invocation = key_invocation(&loc, key_facts(&ctx, &net, &body_value, "apps/missing", 1)); + let (_, body_value) = key_body(&ctx, &net, "docs/missing", 1); + let invocation = key_invocation(&loc, key_facts(&ctx, &net, &body_value, "docs/missing", 1)); let target = DecryptTarget::from_resource(&canonical_key_urn(&loc)).unwrap(); let err = svc @@ -1429,13 +1418,13 @@ async fn key_decrypt_wrong_key_material_is_key_unwrap_failed() { let svc = make_service(fresh_db().await); create_network(&svc).await; let net = network_id(); - let alpha = key_loc("apps/alpha"); - let beta = key_loc("apps/beta"); + let alpha = key_loc("docs/alpha"); + let beta = key_loc("docs/beta"); svc.create_key(&alpha, false).await.unwrap(); let other = svc.create_key(&beta, false).await.unwrap(); let ctx = make_client_request(&other.public_encryption_key); - let (_, body_value) = key_body(&ctx, &net, "apps/alpha", 1); - let invocation = key_invocation(&alpha, key_facts(&ctx, &net, &body_value, "apps/alpha", 1)); + let (_, body_value) = key_body(&ctx, &net, "docs/alpha", 1); + let invocation = key_invocation(&alpha, key_facts(&ctx, &net, &body_value, "docs/alpha", 1)); let target = DecryptTarget::from_resource(&canonical_key_urn(&alpha)).unwrap(); let err = svc @@ -1450,11 +1439,11 @@ async fn key_decrypt_refuses_key_version_mismatch() { let svc = make_service(fresh_db().await); create_network(&svc).await; let net = network_id(); - let loc = key_loc("apps/alpha"); + let loc = key_loc("docs/alpha"); let key = svc.create_key(&loc, false).await.unwrap(); let ctx = make_client_request(&key.public_encryption_key); - let (_, body_value) = key_body(&ctx, &net, "apps/alpha", 2); - let invocation = key_invocation(&loc, key_facts(&ctx, &net, &body_value, "apps/alpha", 2)); + let (_, body_value) = key_body(&ctx, &net, "docs/alpha", 2); + let invocation = key_invocation(&loc, key_facts(&ctx, &net, &body_value, "docs/alpha", 2)); let target = DecryptTarget::from_resource(&canonical_key_urn(&loc)).unwrap(); let err = svc @@ -1471,13 +1460,13 @@ fn decrypt_target_classifies_resources() { &DecryptTarget::from_resource(&net.to_string()), Ok(DecryptTarget::Root(n)) if n == &net )); - let loc = key_loc("apps/alpha"); + let loc = key_loc("docs/alpha"); assert!(matches!( DecryptTarget::from_resource(&canonical_key_urn(&loc)), Ok(DecryptTarget::Key { .. }) )); let prefix = format!( - "urn:tinycloud:encryption:{}:{}/apps/", + "urn:tinycloud:encryption:{}:{}/docs/", owner_did(), NETWORK_NAME ); @@ -1505,9 +1494,9 @@ async fn root_decrypt_refuses_a_key_capability() { .unwrap(); let ctx = make_client_request(&descriptor.public_encryption_key); let (_, body_value) = build_body(&ctx, &net); - let mut facts = key_facts(&ctx, &net, &body_value, "apps/alpha", 1); + let mut facts = key_facts(&ctx, &net, &body_value, "docs/alpha", 1); facts.as_object_mut().unwrap().remove("keyPath"); - let loc = key_loc("apps/alpha"); + let loc = key_loc("docs/alpha"); let invocation = key_invocation(&loc, facts); let err = svc @@ -1544,7 +1533,7 @@ async fn denied_audit_row_for_root_target_never_takes_the_request_key_path() { receiver_public_key_hash: canonical_hash(&Value::String( STANDARD.encode(&ctx.receiver_pub), )), - key_path: Some("apps/alpha".to_string()), + key_path: Some("docs/alpha".to_string()), alg: ALG_X25519_AES256GCM.to_string(), key_version: 1, }) @@ -1619,3 +1608,746 @@ async fn decrypt_authorized_refuses_a_low_order_receiver_key() { "{err}" ); } + +// Deletable keys (paths under `apps/`), state guards, reinstall and caps. + +use crate::encryption_network::backend::KeyBackend; +use crate::encryption_network::keys::{ensure_no_deterministic_app_keys, KeyDescriptor}; + +fn key_vectors() -> Value { + serde_json::from_str(include_str!( + "../../tests/fixtures/encryption/app-key-vectors.json" + )) + .expect("vector file parses") +} + +async fn key_row(db: &DatabaseConnection, path: &str) -> encryption_network_key::Model { + encryption_network_key::Entity::find_by_id((network_id().to_string(), path.to_string())) + .one(db) + .await + .unwrap() + .expect("key row exists") +} + +/// Writes `state` and `sealed_seed` straight into a key row. +async fn force_row(db: &DatabaseConnection, path: &str, state: &str, sealed_seed: Option>) { + let mut row: encryption_network_key::ActiveModel = key_row(db, path).await.into(); + row.state = Set(state.to_string()); + row.sealed_seed = Set(sealed_seed); + row.update(db).await.unwrap(); +} + +async fn insert_raw_row(db: &DatabaseConnection, path: &str, kind: &str, seed: Option>) { + encryption_network_key::ActiveModel { + network_id: Set(network_id().to_string()), + key_path: Set(path.to_string()), + kind: Set(kind.to_string()), + state: Set("active".to_string()), + public_key: Set(vec![7u8; 32]), + sealed_seed: Set(seed), + key_version: Set(1), + created_at: Set("2026-01-01T00:00:00Z".to_string()), + updated_at: Set("2026-01-01T00:00:00Z".to_string()), + } + .insert(db) + .await + .unwrap(); +} + +async fn service_with_network() -> (DatabaseConnection, EncryptionService) { + let db = fresh_db().await; + let svc = make_service(db.clone()); + create_network(&svc).await; + (db, svc) +} + +async fn deleted_app_key( + db: &DatabaseConnection, + svc: &EncryptionService, + path: &str, +) -> KeyDescriptor { + let created = svc.create_key(&key_loc(path), true).await.unwrap(); + force_row(db, path, "deleted", None).await; + created +} + +#[tokio::test] +async fn app_key_is_deletable_seeded_and_differs_from_the_deterministic_derivation() { + let (db, svc) = service_with_network().await; + let loc = key_loc("apps/foo/x"); + let created = svc.create_key(&loc, true).await.unwrap(); + assert_eq!(created.kind, "deletable"); + assert_eq!(created.state, KeyState::Active); + assert_eq!(created.key_version, 1); + + let row = key_row(&db, "apps/foo/x").await; + let sealed = row.sealed_seed.expect("a deletable key stores a seed"); + assert_eq!(sealed[0], 0x01, "sealed with the column key"); + let backend = LocalOneOfOneBackend::new(ColumnEncryption::new([0xABu8; 32])); + assert!(backend.open_seed(&sealed).is_ok()); + + // The same URN derived without the seed is a different key. + let network_row = + crate::models::encryption_network::Entity::find_by_id(network_id().to_string()) + .one(&db) + .await + .unwrap() + .unwrap(); + let network_secret = backend + .open_network_secret(network_row.sealed_private_key.as_deref().unwrap()) + .unwrap(); + let unseeded = backend + .derive_named_public_key(&network_secret, &canonical_key_urn(&loc), None) + .unwrap(); + assert_ne!(created.public_encryption_key, unseeded); + + // Two deletable keys never share a seed. + svc.create_key(&key_loc("apps/foo/y"), true).await.unwrap(); + assert_ne!( + key_row(&db, "apps/foo/y").await.sealed_seed, + key_row(&db, "apps/foo/x").await.sealed_seed + ); +} + +#[tokio::test] +async fn deterministic_key_stores_no_seed_and_deletable_flag_must_match_the_path() { + let (db, svc) = service_with_network().await; + let created = svc.create_key(&key_loc("docs/a"), false).await.unwrap(); + assert_eq!(created.kind, "deterministic"); + assert!(key_row(&db, "docs/a").await.sealed_seed.is_none()); + + let vectors = key_vectors(); + let cases = vectors["createFlagMatchesPath"].as_array().unwrap(); + assert_eq!(cases.len(), 4); + for case in cases { + let path = case["keyPath"].as_str().unwrap(); + let deletable = case["deletable"].as_bool().unwrap(); + let accepted = case["accepted"].as_bool().unwrap(); + let result = svc.create_key(&key_loc(path), deletable).await; + if accepted { + result.unwrap_or_else(|e| panic!("{path} {deletable}: {e}")); + } else { + assert!( + matches!(result, Err(EncryptionServiceError::KeyPathInvalid(_))), + "{path} {deletable}: {result:?}" + ); + } + } +} + +#[tokio::test] +async fn app_key_unwraps_only_at_its_own_version_and_path() { + let (_, svc) = service_with_network().await; + let loc = key_loc("apps/foo/x"); + let key = svc.create_key(&loc, true).await.unwrap(); + let symmetric = vec![0x5Au8; 32]; + let wrapped = wrap_to_public_key(&key.public_encryption_key, &symmetric).unwrap(); + let (version, opened) = svc.unwrap_named_key(&loc, &wrapped, 1).await.unwrap(); + assert_eq!((version, opened), (1, symmetric)); + + // A request for another version is refused before any unwrap. + for requested in [0, 2] { + let err = svc + .unwrap_named_key(&loc, &wrapped, requested) + .await + .unwrap_err(); + assert!(matches!(err, EncryptionServiceError::KeyDeleted), "{err:?}"); + } + // A sibling app key cannot open it. + svc.create_key(&key_loc("apps/foo/y"), true).await.unwrap(); + let err = svc + .unwrap_named_key(&key_loc("apps/foo/y"), &wrapped, 1) + .await + .unwrap_err(); + assert!(matches!(err, EncryptionServiceError::KeyUnwrapFailed(_))); +} + +#[tokio::test] +async fn deleted_row_refuses_block_unblock_unwrap_and_keeps_its_state() { + let (db, svc) = service_with_network().await; + let loc = key_loc("apps/foo/x"); + let created = deleted_app_key(&db, &svc, "apps/foo/x").await; + let wrapped = wrap_to_public_key(&created.public_encryption_key, &[1u8; 32]).unwrap(); + + for (name, err) in [ + ("block", svc.block_key(&loc).await.unwrap_err()), + ("unblock", svc.unblock_key(&loc).await.unwrap_err()), + ( + "unwrap", + svc.unwrap_named_key(&loc, &wrapped, 1).await.unwrap_err(), + ), + ] { + assert!( + matches!(err, EncryptionServiceError::KeyDeleted), + "{name}: {err:?}" + ); + } + let row = key_row(&db, "apps/foo/x").await; + assert_eq!(row.state, "deleted"); + assert!(row.sealed_seed.is_none()); + assert_eq!(row.key_version, 1); + let described = svc.describe_key(&loc).await.unwrap(); + assert_eq!(described.state, KeyState::Deleted); +} + +#[tokio::test] +async fn deleted_deterministic_row_stays_burned() { + let (db, svc) = service_with_network().await; + let loc = key_loc("docs/a"); + svc.create_key(&loc, false).await.unwrap(); + force_row(&db, "docs/a", "deleted", None).await; + let err = svc.create_key(&loc, false).await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::KeyDeleted), "{err:?}"); + let err = svc.unblock_key(&loc).await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::KeyDeleted), "{err:?}"); + let row = key_row(&db, "docs/a").await; + assert_eq!((row.state.as_str(), row.key_version), ("deleted", 1)); +} + +#[tokio::test] +async fn create_on_a_deleted_app_key_reinstalls_it_with_a_fresh_seed_and_next_version() { + let (db, svc) = service_with_network().await; + let loc = key_loc("apps/foo/x"); + let old = deleted_app_key(&db, &svc, "apps/foo/x").await; + let old_wrapped = wrap_to_public_key(&old.public_encryption_key, &[9u8; 32]).unwrap(); + + let fresh = svc.create_key(&loc, true).await.unwrap(); + assert_eq!(fresh.state, KeyState::Active); + assert_eq!(fresh.key_version, 2); + assert_eq!(fresh.created_at, old.created_at); + assert_ne!(fresh.public_encryption_key, old.public_encryption_key); + let row = key_row(&db, "apps/foo/x").await; + assert!(row.sealed_seed.is_some()); + + // Data wrapped to the old key and the old version are not released. + let err = svc + .unwrap_named_key(&loc, &old_wrapped, 1) + .await + .unwrap_err(); + assert!(matches!(err, EncryptionServiceError::KeyDeleted), "{err:?}"); + let err = svc + .unwrap_named_key(&loc, &old_wrapped, 2) + .await + .unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::KeyUnwrapFailed(_)), + "{err:?}" + ); + // The new key works at its version. + let wrapped = wrap_to_public_key(&fresh.public_encryption_key, &[3u8; 32]).unwrap(); + let (version, opened) = svc.unwrap_named_key(&loc, &wrapped, 2).await.unwrap(); + assert_eq!((version, opened), (2, vec![3u8; 32])); + + // Another create on the now-active key does not replace the seed. + let seed_before = key_row(&db, "apps/foo/x").await.sealed_seed; + let err = svc.create_key(&loc, true).await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::KeyExists), "{err:?}"); + assert_eq!(key_row(&db, "apps/foo/x").await.sealed_seed, seed_before); +} + +#[tokio::test] +async fn blocked_app_key_is_not_reinstalled_by_create() { + let (db, svc) = service_with_network().await; + let loc = key_loc("apps/foo/x"); + svc.create_key(&loc, true).await.unwrap(); + svc.block_key(&loc).await.unwrap(); + let seed = key_row(&db, "apps/foo/x").await.sealed_seed; + let err = svc.create_key(&loc, true).await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::KeyExists), "{err:?}"); + let row = key_row(&db, "apps/foo/x").await; + assert_eq!((row.state.as_str(), row.key_version), ("blocked", 1)); + assert_eq!(row.sealed_seed, seed); +} + +#[tokio::test] +async fn rows_that_cannot_be_read_as_keys_are_corrupt() { + let (db, svc) = service_with_network().await; + // A deletable row that is not deleted and has no seed. + svc.create_key(&key_loc("apps/foo/x"), true).await.unwrap(); + force_row(&db, "apps/foo/x", "active", None).await; + // A deletable row whose seed is 32 zero bytes, which is not a sealed + // value: a seed opens only through the versioned, authenticated unseal. + svc.create_key(&key_loc("apps/foo/y"), true).await.unwrap(); + force_row(&db, "apps/foo/y", "active", Some(vec![0u8; 32])).await; + // A deterministic row under apps/, and a deletable row outside it. + insert_raw_row(&db, "apps/foo/z", "deterministic", None).await; + insert_raw_row(&db, "docs/seeded", "deletable", Some(vec![1u8; 61])).await; + + for path in ["apps/foo/x", "apps/foo/z", "docs/seeded"] { + let loc = key_loc(path); + let deletable = loc.kind() == crate::encryption_network::key_id::KeyKind::Deletable; + for result in [ + svc.unwrap_named_key(&loc, &[0u8; 96], 1).await.map(|_| ()), + svc.describe_key(&loc).await.map(|_| ()), + svc.block_key(&loc).await.map(|_| ()), + svc.unblock_key(&loc).await.map(|_| ()), + svc.create_key(&loc, deletable).await.map(|_| ()), + ] { + assert!( + matches!(result, Err(EncryptionServiceError::CorruptKeyRow)), + "{path}: {result:?}" + ); + } + } + // A zeroed seed does not open when the key is read. + let err = svc + .unwrap_named_key(&key_loc("apps/foo/y"), &[0u8; 96], 1) + .await + .unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::CorruptKeyRow), + "{err:?}" + ); +} + +#[tokio::test] +async fn public_get_under_apps_never_answers_with_a_key() { + let (db, svc) = service_with_network().await; + svc.create_key(&key_loc("apps/foo/x"), true).await.unwrap(); + svc.create_key(&key_loc("apps/foo/blocked"), true) + .await + .unwrap(); + svc.block_key(&key_loc("apps/foo/blocked")).await.unwrap(); + deleted_app_key(&db, &svc, "apps/foo/gone").await; + for path in [ + "apps/foo/x", + "apps/foo/blocked", + "apps/foo/gone", + "apps/foo/missing", + "apps/other/missing", + ] { + let err = svc.public_key(&key_loc(path)).await.unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::KeyNotFound), + "{path}: {err:?}" + ); + } + // The answer does not read the network: it is the same for a revoked one. + svc.revoke_network(&network_id()).await.unwrap(); + let err = svc.public_key(&key_loc("apps/foo/x")).await.unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::KeyNotFound), + "{err:?}" + ); + // Outside apps/ the network state is checked: a revoked network is refused. + let err = svc.public_key(&key_loc("docs/a")).await.unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::NetworkNotActive(_)), + "{err:?}" + ); +} + +#[tokio::test] +async fn per_app_prefix_cap_counts_deleted_rows_until_they_are_reused() { + let db = fresh_db().await; + let svc = make_service(db.clone()).with_key_cap_per_app_prefix(2); + create_network(&svc).await; + svc.create_key(&key_loc("apps/a/x"), true).await.unwrap(); + svc.create_key(&key_loc("apps/a/y/deep"), true) + .await + .unwrap(); + let err = svc + .create_key(&key_loc("apps/a/z"), true) + .await + .unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::KeyLimitExceeded), + "{err:?}" + ); + // Other prefixes, including one that only shares leading bytes, are separate. + svc.create_key(&key_loc("apps/ab/x"), true).await.unwrap(); + svc.create_key(&key_loc("apps/b/x"), true).await.unwrap(); + svc.create_key(&key_loc("docs/a"), false).await.unwrap(); + + // A deleted row still counts; reusing it adds no row. + force_row(&db, "apps/a/x", "deleted", None).await; + let err = svc + .create_key(&key_loc("apps/a/z"), true) + .await + .unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::KeyLimitExceeded), + "{err:?}" + ); + let reused = svc.create_key(&key_loc("apps/a/x"), true).await.unwrap(); + assert_eq!(reused.key_version, 2); +} + +#[tokio::test] +async fn per_network_cap_counts_deleted_rows() { + let db = fresh_db().await; + let svc = make_service(db.clone()).with_key_cap_per_network(1); + create_network(&svc).await; + svc.create_key(&key_loc("apps/a/x"), true).await.unwrap(); + force_row(&db, "apps/a/x", "deleted", None).await; + let err = svc.create_key(&key_loc("docs/a"), false).await.unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::KeyLimitExceeded), + "{err:?}" + ); + svc.create_key(&key_loc("apps/a/x"), true).await.unwrap(); +} + +#[tokio::test] +async fn startup_check_finds_rows_under_apps_that_are_not_deletable() { + let (db, svc) = service_with_network().await; + svc.create_key(&key_loc("docs/a"), false).await.unwrap(); + svc.create_key(&key_loc("appsx/y"), false).await.unwrap(); + svc.create_key(&key_loc("apps/foo/x"), true).await.unwrap(); + ensure_no_deterministic_app_keys(&db).await.unwrap(); + + insert_raw_row(&db, "apps/foo/old", "deterministic", None).await; + insert_raw_row(&db, "apps", "deterministic", None).await; + let err = ensure_no_deterministic_app_keys(&db).await.unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::StrayAppKeyRows(2)), + "{err:?}" + ); + assert!(err.to_string().contains("SELECT network_id, key_path")); +} + +// PostgreSQL-backed; CI-only. Concurrent creates on one deleted app key +// reinstall it exactly once. +#[tokio::test] +async fn postgres_concurrent_reinstall_creates_bump_the_version_once() { + let Some(fixture) = PostgresTestSchema::migrated_through( + "postgres_concurrent_reinstall_creates_bump_the_version_once", + None, + ) + .await + else { + return; + }; + let db = fixture.connect(8).await; + let svc = Arc::new(make_service(db.clone())); + create_network(&svc).await; + deleted_app_key(&db, &svc, "apps/foo/x").await; + + let results = futures::future::join_all((0..8).map(|_| { + let svc = svc.clone(); + async move { svc.create_key(&key_loc("apps/foo/x"), true).await } + })) + .await; + assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1); + assert!(results + .iter() + .filter_map(|r| r.as_ref().err()) + .all(|e| matches!(e, EncryptionServiceError::KeyExists))); + assert_eq!(key_row(&db, "apps/foo/x").await.key_version, 2); +} + +// PostgreSQL-backed; CI-only. Block, unblock and create hold the network row +// lock, so a lock held elsewhere stalls them and they release it when done. +#[tokio::test] +async fn postgres_block_and_unblock_take_and_release_the_network_row_lock() { + let Some(fixture) = PostgresTestSchema::migrated_through( + "postgres_block_and_unblock_take_and_release_the_network_row_lock", + None, + ) + .await + else { + return; + }; + const LOCK: &str = "SELECT 1 FROM encryption_network FOR UPDATE NOWAIT"; + let db = fixture.connect(4).await; + let svc = Arc::new(make_service(db.clone())); + create_network(&svc).await; + svc.create_key(&key_loc("apps/foo/x"), true).await.unwrap(); + + svc.block_key(&key_loc("apps/foo/x")).await.unwrap(); + fixture + .execute_blocking(LOCK) + .expect("block releases the network row lock"); + svc.unblock_key(&key_loc("apps/foo/x")).await.unwrap(); + fixture + .execute_blocking(LOCK) + .expect("unblock releases the network row lock"); + let err = svc + .block_key(&key_loc("apps/foo/missing")) + .await + .unwrap_err(); + assert!(matches!(err, EncryptionServiceError::KeyNotFound)); + fixture + .execute_blocking(LOCK) + .expect("a refused block releases the network row lock"); + + let results = futures::future::join_all((0..8).map(|i| { + let svc = svc.clone(); + async move { + if i % 2 == 0 { + svc.block_key(&key_loc("apps/foo/x")).await + } else { + svc.unblock_key(&key_loc("apps/foo/x")).await + } + } + })) + .await; + assert!(results.iter().all(|r| r.is_ok())); +} + +#[tokio::test] +async fn per_app_prefix_cap_ignores_keys_of_an_app_id_that_extends_it() { + let svc = make_service(fresh_db().await).with_key_cap_per_app_prefix(2); + create_network(&svc).await; + svc.create_key(&key_loc("apps/ab/x"), true).await.unwrap(); + svc.create_key(&key_loc("apps/ab/y"), true).await.unwrap(); + svc.create_key(&key_loc("apps/ab.c/x"), true).await.unwrap(); + svc.create_key(&key_loc("apps/a/x"), true).await.unwrap(); + svc.create_key(&key_loc("apps/a/y"), true).await.unwrap(); + let err = svc + .create_key(&key_loc("apps/a/z"), true) + .await + .unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::KeyLimitExceeded), + "{err:?}" + ); +} + +// `apps/` keys through the release guard and the key lifecycle. + +/// A decrypt request for `body_path` at `version` carrying `key_public` as the +/// wrapping key, authorized by a capability on `capability_loc`. +async fn app_decrypt( + svc: &EncryptionService, + key_public: &[u8], + capability_loc: &KeyLocation, + body_path: &str, + version: i64, +) -> Result<(), EncryptionServiceError> { + let net = network_id(); + let ctx = make_client_request(key_public); + let (_, body_value) = key_body(&ctx, &net, body_path, version); + let invocation = key_invocation( + capability_loc, + key_facts(&ctx, &net, &body_value, body_path, version), + ); + let target = DecryptTarget::from_resource(&canonical_key_urn(capability_loc)).unwrap(); + svc.decrypt_authorized(&target, &invocation, &body_value) + .await + .map(|_| ()) +} + +#[tokio::test] +async fn app_key_decrypt_round_trip_returns_rewrapped_key_at_its_version() { + let svc = make_service(fresh_db().await); + create_network(&svc).await; + let net = network_id(); + let loc = key_loc("apps/foo/alpha"); + let key = svc.create_key(&loc, true).await.unwrap(); + let ctx = make_client_request(&key.public_encryption_key); + let (_, body_value) = key_body(&ctx, &net, "apps/foo/alpha", 1); + let invocation = key_invocation( + &loc, + key_facts(&ctx, &net, &body_value, "apps/foo/alpha", 1), + ); + let target = DecryptTarget::from_resource(&canonical_key_urn(&loc)).unwrap(); + let verified = svc + .decrypt_authorized(&target, &invocation, &body_value) + .await + .unwrap(); + assert_eq!(verified.response.key_version, 1); + let rewrapped = STANDARD + .decode(&verified.response.wrapped_key) + .expect("base64 wrapped key"); + assert_eq!( + unwrap_with_secret(&ctx.receiver_secret, &rewrapped), + ctx.symmetric + ); +} + +#[tokio::test] +async fn app_key_decrypt_refuses_body_key_path_mismatch_and_sibling_capability() { + let svc = make_service(fresh_db().await); + create_network(&svc).await; + let alpha = key_loc("apps/foo/alpha"); + let beta = key_loc("apps/foo/beta"); + let key = svc.create_key(&alpha, true).await.unwrap(); + svc.create_key(&beta, true).await.unwrap(); + let public = key.public_encryption_key; + let err = app_decrypt(&svc, &public, &alpha, "apps/foo/beta", 1) + .await + .unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::KeyPathInvalid(_)), + "{err:?}" + ); + // The request names alpha while the invocation's capability is on beta. + let net = network_id(); + let ctx = make_client_request(&public); + let (_, body_value) = key_body(&ctx, &net, "apps/foo/alpha", 1); + let invocation = key_invocation( + &beta, + key_facts(&ctx, &net, &body_value, "apps/foo/alpha", 1), + ); + let target = DecryptTarget::from_resource(&canonical_key_urn(&alpha)).unwrap(); + let err = svc + .decrypt_authorized(&target, &invocation, &body_value) + .await + .unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::Unauthorized), + "{err:?}" + ); +} + +#[tokio::test] +async fn app_key_decrypt_refuses_blocked_missing_and_other_version() { + let svc = make_service(fresh_db().await); + create_network(&svc).await; + let loc = key_loc("apps/foo/alpha"); + let key = svc.create_key(&loc, true).await.unwrap(); + let public = key.public_encryption_key; + let err = app_decrypt(&svc, &public, &loc, "apps/foo/alpha", 2) + .await + .unwrap_err(); + assert!(matches!(err, EncryptionServiceError::KeyDeleted), "{err:?}"); + + svc.block_key(&loc).await.unwrap(); + let err = app_decrypt(&svc, &public, &loc, "apps/foo/alpha", 1) + .await + .unwrap_err(); + assert!(matches!(err, EncryptionServiceError::KeyBlocked), "{err:?}"); + svc.unblock_key(&loc).await.unwrap(); + app_decrypt(&svc, &public, &loc, "apps/foo/alpha", 1) + .await + .expect("an unblocked key decrypts"); + + let missing = key_loc("apps/foo/missing"); + let err = app_decrypt(&svc, &public, &missing, "apps/foo/missing", 1) + .await + .unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::KeyNotFound), + "{err:?}" + ); +} + +#[tokio::test] +async fn unwrap_named_key_refuses_blocked_and_missing_app_keys() { + let svc = make_service(fresh_db().await); + create_network(&svc).await; + let loc = key_loc("apps/foo/alpha"); + let key = svc.create_key(&loc, true).await.unwrap(); + let wrapped = wrap_to_public_key(&key.public_encryption_key, &[0x5Au8; 32]).unwrap(); + svc.block_key(&loc).await.unwrap(); + let err = svc.unwrap_named_key(&loc, &wrapped, 1).await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::KeyBlocked), "{err:?}"); + let err = svc + .unwrap_named_key(&key_loc("apps/foo/missing"), &wrapped, 1) + .await + .unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::KeyNotFound), + "{err:?}" + ); +} + +#[tokio::test] +async fn app_keys_need_an_existing_active_network() { + let svc = make_service(fresh_db().await); + let loc = key_loc("apps/foo/alpha"); + let err = svc.create_key(&loc, true).await.unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::NetworkNotFound), + "{err:?}" + ); + + create_network(&svc).await; + let key = svc.create_key(&loc, true).await.unwrap(); + let wrapped = wrap_to_public_key(&key.public_encryption_key, &[0x5Au8; 32]).unwrap(); + svc.revoke_network(&network_id()).await.unwrap(); + let err = svc + .create_key(&key_loc("apps/foo/beta"), true) + .await + .unwrap_err(); + assert!( + matches!(&err, EncryptionServiceError::NetworkNotActive(s) if s == "revoked"), + "{err:?}" + ); + let err = svc.unwrap_named_key(&loc, &wrapped, 1).await.unwrap_err(); + assert!( + matches!(&err, EncryptionServiceError::NetworkNotActive(s) if s == "revoked"), + "{err:?}" + ); +} + +#[tokio::test] +async fn unknown_stored_state_on_an_app_key_is_a_corrupt_row() { + let (db, svc) = service_with_network().await; + let loc = key_loc("apps/foo/alpha"); + let key = svc.create_key(&loc, true).await.unwrap(); + let wrapped = wrap_to_public_key(&key.public_encryption_key, &[0x5Au8; 32]).unwrap(); + let seed = key_row(&db, "apps/foo/alpha").await.sealed_seed; + force_row(&db, "apps/foo/alpha", "retired", seed).await; + for result in [ + svc.unwrap_named_key(&loc, &wrapped, 1).await.map(|_| ()), + svc.describe_key(&loc).await.map(|_| ()), + svc.block_key(&loc).await.map(|_| ()), + svc.unblock_key(&loc).await.map(|_| ()), + svc.create_key(&loc, true).await.map(|_| ()), + ] { + assert!( + matches!(result, Err(EncryptionServiceError::CorruptKeyRow)), + "{result:?}" + ); + } +} + +// PostgreSQL-backed; CI-only. Block, unblock and create wait for the network +// row lock: with a transaction on a second pool holding it, each operation is +// still pending after 800 ms and completes once the lock is released. +#[tokio::test] +async fn postgres_key_writes_wait_for_the_network_row_lock() { + use sea_orm::{ConnectionTrait, TransactionTrait}; + use std::time::Duration; + + let Some(fixture) = PostgresTestSchema::migrated_through( + "postgres_key_writes_wait_for_the_network_row_lock", + None, + ) + .await + else { + return; + }; + let db = fixture.connect(4).await; + let holder_pool = fixture.connect(1).await; + let svc = Arc::new(make_service(db.clone())); + create_network(&svc).await; + svc.create_key(&key_loc("apps/foo/x"), true).await.unwrap(); + svc.create_key(&key_loc("apps/foo/gone"), true) + .await + .unwrap(); + force_row(&db, "apps/foo/gone", "deleted", None).await; + + for operation in ["block", "unblock", "create", "reinstall"] { + let lock = holder_pool.begin().await.unwrap(); + lock.execute_unprepared("SELECT 1 FROM encryption_network FOR UPDATE") + .await + .unwrap(); + let pending = { + let svc = svc.clone(); + tokio::spawn(async move { + match operation { + "block" => svc.block_key(&key_loc("apps/foo/x")).await, + "unblock" => svc.unblock_key(&key_loc("apps/foo/x")).await, + "create" => svc.create_key(&key_loc("apps/foo/new"), true).await, + _ => svc.create_key(&key_loc("apps/foo/gone"), true).await, + } + }) + }; + let mut pending = pending; + assert!( + tokio::time::timeout(Duration::from_millis(800), &mut pending) + .await + .is_err(), + "{operation} finished while the network row lock was held" + ); + lock.commit().await.unwrap(); + let result = tokio::time::timeout(Duration::from_secs(20), pending) + .await + .unwrap_or_else(|_| panic!("{operation} did not finish after the lock was released")) + .unwrap(); + result.unwrap_or_else(|e| panic!("{operation}: {e}")); + } +} diff --git a/tinycloud-core/src/migrations/m20261022_000000_encryption_network_key.rs b/tinycloud-core/src/migrations/m20261022_000000_encryption_network_key.rs index 4235a5f9..aff5cb85 100644 --- a/tinycloud-core/src/migrations/m20261022_000000_encryption_network_key.rs +++ b/tinycloud-core/src/migrations/m20261022_000000_encryption_network_key.rs @@ -149,7 +149,7 @@ mod tests { Migrator::up(&db, None).await.expect("second up is a no-op"); Migrator::down(&db, Some(1)).await.expect("down"); Migrator::up(&db, None).await.expect("up after down"); - key_row("net", "apps/app-one/key") + key_row("net", "docs/app-one/key") .insert(&db) .await .expect("insert after reapply"); @@ -158,13 +158,13 @@ mod tests { #[tokio::test] async fn key_row_round_trips_with_null_sealed_seed() { let db = fresh_db().await; - key_row("net", "apps/app-one/key") + key_row("net", "docs/app-one/key") .insert(&db) .await .expect("insert"); let row = encryption_network_key::Entity::find_by_id(( "net".to_string(), - "apps/app-one/key".to_string(), + "docs/app-one/key".to_string(), )) .one(&db) .await @@ -253,7 +253,7 @@ mod tests { )) .await .expect("audit insert without key_path after re-apply"); - key_row("net", "apps/app-one/key") + key_row("net", "docs/app-one/key") .insert(db) .await .expect("key row insert after re-apply"); @@ -280,14 +280,14 @@ mod tests { #[tokio::test] async fn up_is_a_no_op_when_table_and_column_already_exist() { let db = fresh_db().await; - key_row("net", "apps/app-one/key") + key_row("net", "docs/app-one/key") .insert(&db) .await .expect("insert key row"); db.execute(Statement::from_string( DbBackend::Sqlite, "INSERT INTO encryption_audit (request_hash, requester, network_id, node_id, outcome, decided_at, key_path) \ - VALUES ('h3', 'did:key:z6MkRequester', 'default', 'did:key:z6MkNode', 'allowed', '2026-10-22T00:00:00Z', 'apps/app-one/key')" + VALUES ('h3', 'did:key:z6MkRequester', 'default', 'did:key:z6MkNode', 'allowed', '2026-10-22T00:00:00Z', 'docs/app-one/key')" .to_string(), )) .await @@ -301,7 +301,7 @@ mod tests { let key = encryption_network_key::Entity::find_by_id(( "net".to_string(), - "apps/app-one/key".to_string(), + "docs/app-one/key".to_string(), )) .one(&db) .await @@ -313,6 +313,6 @@ mod tests { .await .expect("query") .expect("audit row survives"); - assert_eq!(audit.key_path, Some("apps/app-one/key".to_string())); + assert_eq!(audit.key_path, Some("docs/app-one/key".to_string())); } } diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index e9f93e43..e0ced66c 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -191,6 +191,11 @@ pub enum DelegationError { /// node's named-keys setting is on. #[error("named_keys_unavailable: named keys are not enabled on this node")] NamedKeysUnavailable, + /// A SIWE root grants an ability other than `key.block` on a key or + /// prefix under `apps/`, and the app id is not bound to the + /// SIWE domain. + #[error("app_id_not_bound: the app id in a key grant under apps/ is not bound to the signing domain")] + AppIdNotBound, } /// Verify, validate, and register a delegation inside the caller's transaction. @@ -337,6 +342,51 @@ fn check_sql_caveats_supported( } } +/// A SIWE (CACAO) delegation issued by the owner of a key or key prefix under +/// `apps/` carries only `key.block` on it, unless the app id is bound +/// to the SIWE domain: the SIWE scheme is absent or `https`, and the domain is +/// a host name with no port. UCAN delegations are not checked. +fn check_app_id_domain_binding(delegation: &util::DelegationInfo) -> Result<(), DelegationError> { + let TinyCloudDelegation::Cacao(cacao) = &delegation.delegation else { + return Ok(()); + }; + let payload = cacao.payload(); + let domain = &payload.domain; + let https = payload + .scheme + .as_ref() + .is_none_or(|scheme| scheme.as_str().eq_ignore_ascii_case("https")); + for cap in &delegation.capabilities { + if cap.ability.to_string() == crate::encryption_network::KEY_BLOCK_ACTION + || !is_root_authority(cap, &delegation.delegator) + { + continue; + } + let Resource::Other(uri) = &cap.resource else { + continue; + }; + let Ok( + key_id::EncryptionResource::Key(location) + | key_id::EncryptionResource::KeyPrefix(location), + ) = key_id::parse(uri.as_str()) + else { + continue; + }; + let Some(app_id) = key_id::app_id_of(location.path()) else { + continue; + }; + let bound = https + && domain.port().is_none() + && !domain.as_str().contains('@') + && !domain.as_str().ends_with(':') + && crate::encryption_network::app_id::app_id_bound_to_host(domain.host(), app_id); + if !bound { + return Err(DelegationError::AppIdNotBound); + } + } + Ok(()) +} + // verify parenthood and authorization #[cfg(test)] async fn validate( @@ -363,6 +413,7 @@ async fn validate_with( { return Err(DelegationError::NamedKeysUnavailable.into()); } + check_app_id_domain_binding(delegation)?; // TC-1791: SQL and DuckDB caveats are checked for every delegation, // before the root-authority filter. for cap in &delegation.capabilities { @@ -1661,7 +1712,7 @@ mod tests { } const KEY_OWNER: &str = "did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"; - const KEY_RESOURCE: &str = "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/apps/tinychat"; + const KEY_RESOURCE: &str = "urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/docs/tinychat"; const OTHER_DID: &str = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"; fn key_delegation_info(delegator: &str, ability: &str) -> util::DelegationInfo { @@ -1717,7 +1768,7 @@ mod tests { async fn named_key_grants_are_refused_when_named_keys_are_off() { let db = database().await; let key = KEY_RESOURCE.to_string(); - let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/"); + let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/"); let network = format!("urn:tinycloud:encryption:{KEY_OWNER}:default"); for delegator in [KEY_OWNER, OTHER_DID] { for resource in [&key, &prefix] { @@ -1781,7 +1832,7 @@ mod tests { async fn owner_issued_key_delete_with_parent_is_refused() { let db = database().await; let parent = crate::hash::hash(b"owner-delete-with-parent"); - let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/"); + let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/"); insert_parent_grant(&db, parent, KEY_OWNER, OTHER_DID, &prefix, KEY_DECRYPT).await; let info = key_grant_info( KEY_OWNER, @@ -1855,9 +1906,9 @@ mod tests { async fn key_prefix_grant_covers_key_below_it_through_parent() { let db = database().await; let parent = crate::hash::hash(b"prefix-grant-covers-key"); - let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/"); + let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/foo/"); insert_parent_grant(&db, parent, KEY_OWNER, OTHER_DID, &prefix, KEY_DECRYPT).await; - let key = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/x"); + let key = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/foo/x"); let info = key_grant_info(OTHER_DID, &key, KEY_DECRYPT, &[parent]); validate(&db, &info) .await @@ -1869,9 +1920,9 @@ mod tests { async fn broader_child_prefix_is_not_covered_by_parent() { let db = database().await; let parent = crate::hash::hash(b"prefix-grant-narrow"); - let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/"); + let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/foo/"); insert_parent_grant(&db, parent, KEY_OWNER, OTHER_DID, &prefix, KEY_DECRYPT).await; - let child = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/"); + let child = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/"); let info = key_grant_info(OTHER_DID, &child, KEY_DECRYPT, &[parent]); assert!(matches!( validate(&db, &info).await, @@ -1887,9 +1938,9 @@ mod tests { const OTHER_OWNER: &str = "did:pkh:eip155:1:0x0000000000000000000000000000000000000001"; let db = database().await; let parent = crate::hash::hash(b"prefix-grant-other-owner"); - let prefix = format!("urn:tinycloud:encryption:{OTHER_OWNER}:default/apps/foo/"); + let prefix = format!("urn:tinycloud:encryption:{OTHER_OWNER}:default/docs/foo/"); insert_parent_grant(&db, parent, OTHER_OWNER, OTHER_DID, &prefix, KEY_DECRYPT).await; - let key = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/x"); + let key = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/foo/x"); let info = key_grant_info(OTHER_DID, &key, KEY_DECRYPT, &[parent]); assert!(matches!( validate(&db, &info).await, @@ -1904,7 +1955,7 @@ mod tests { async fn chained_key_delete_is_refused_with_parent_cited() { let db = database().await; let parent = crate::hash::hash(b"prefix-grant-for-chained-delete"); - let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/"); + let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/"); insert_parent_grant(&db, parent, KEY_OWNER, OTHER_DID, &prefix, KEY_DECRYPT).await; let info = key_grant_info( OTHER_DID, @@ -1958,7 +2009,7 @@ mod tests { ); } assert_eq!( - skipped, 2, + skipped, 4, "URI-invalid key identifiers in MALFORMED_KEY_IDS" ); } @@ -2012,9 +2063,9 @@ mod tests { async fn terminal_parent_grant_cannot_be_redelegated() { let db = database().await; let parent = crate::hash::hash(b"terminal-parent-grant"); - let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/"); + let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/foo/"); insert_terminal_parent_grant(&db, parent, KEY_OWNER, OTHER_DID, &prefix, KEY_DECRYPT).await; - let key = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/x"); + let key = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/foo/x"); let info = key_grant_info(OTHER_DID, &key, KEY_DECRYPT, &[parent]); assert!( matches!( @@ -2027,6 +2078,193 @@ mod tests { ); } + fn app_key() -> String { + format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/x") + } + + fn app_prefix() -> String { + format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/") + } + + const KEY_DELETE: &str = "tinycloud.encryption/key.delete"; + + /// key.delete on a key or prefix under `apps/` is refused in every shape + /// of delegation: owner root, non-owner, with a parent, chained, and a SIWE + /// root whose app id is bound to its domain. + #[tokio::test] + async fn app_key_delete_is_refused_in_every_delegation() { + let db = database().await; + let parent = crate::hash::hash(b"app-prefix-grant-for-delete"); + insert_parent_grant( + &db, + parent, + KEY_OWNER, + OTHER_DID, + &app_prefix(), + KEY_DECRYPT, + ) + .await; + let refused = |result: Result<(), Error>| { + matches!( + result, + Err(Error::InvalidDelegation( + DelegationError::KeyDeleteNotDelegable + )) + ) + }; + for resource in [app_key(), app_prefix()] { + let owner = key_grant_info(KEY_OWNER, &resource, KEY_DELETE, &[]); + assert!( + refused(validate(&db, &owner).await), + "owner root {resource}" + ); + let stranger = key_grant_info(OTHER_DID, &resource, KEY_DELETE, &[]); + assert!( + refused(validate(&db, &stranger).await), + "non-owner {resource}" + ); + let owner_with_parent = key_grant_info(KEY_OWNER, &resource, KEY_DELETE, &[parent]); + assert!( + refused(validate(&db, &owner_with_parent).await), + "owner with parent {resource}" + ); + let chained = key_grant_info(OTHER_DID, &resource, KEY_DELETE, &[parent]); + assert!(refused(validate(&db, &chained).await), "chained {resource}"); + } + let tail = ["apps/xyz.tinycloud.listen/x", "apps/xyz.tinycloud.listen/"]; + for tail in tail { + let info = siwe_key_grants("listen.tinycloud.xyz", &[(tail, KEY_DELETE)]); + assert!( + refused(validate(&db, &info).await), + "bound SIWE root {tail}" + ); + } + } + + /// A prefix grant cited as a parent covers a key and a narrower prefix + /// below it, under `apps/`. + #[tokio::test] + async fn app_prefix_grant_covers_app_keys_below_it_through_parent() { + let db = database().await; + let parent = crate::hash::hash(b"app-prefix-grant-covers"); + insert_parent_grant( + &db, + parent, + KEY_OWNER, + OTHER_DID, + &app_prefix(), + KEY_DECRYPT, + ) + .await; + for child in [ + app_key(), + format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/a/b"), + format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/sub/"), + ] { + let info = key_grant_info(OTHER_DID, &child, KEY_DECRYPT, &[parent]); + validate(&db, &info) + .await + .unwrap_or_else(|e| panic!("{child}: {e}")); + } + // Not the prefix's own key name, a sibling app id, nor the owner's other namespaces. + for child in [ + format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foobar/x"), + format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo.bar/x"), + format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/bar/x"), + format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/foo/x"), + format!("urn:tinycloud:encryption:{KEY_OWNER}:other/apps/foo/x"), + ] { + let info = key_grant_info(OTHER_DID, &child, KEY_DECRYPT, &[parent]); + assert!( + matches!( + validate(&db, &info).await, + Err(Error::InvalidDelegation( + DelegationError::UnauthorizedCapability(_, _) + )) + ), + "{child} covered" + ); + } + } + + /// A child prefix wider than the parent's prefix is not covered, under `apps/`. + #[tokio::test] + async fn broader_app_prefix_is_not_covered_by_parent() { + let db = database().await; + let parent = crate::hash::hash(b"app-prefix-grant-narrow"); + let narrow = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/x/"); + insert_parent_grant(&db, parent, KEY_OWNER, OTHER_DID, &narrow, KEY_DECRYPT).await; + let info = key_grant_info(OTHER_DID, &app_prefix(), KEY_DECRYPT, &[parent]); + assert!(matches!( + validate(&db, &info).await, + Err(Error::InvalidDelegation( + DelegationError::UnauthorizedCapability(_, _) + )) + )); + } + + /// A parent issued in another owner's namespace does not cover an `apps/` key. + #[tokio::test] + async fn app_parent_for_other_owner_does_not_cover_key() { + const OTHER_OWNER: &str = "did:pkh:eip155:1:0x0000000000000000000000000000000000000001"; + let db = database().await; + let parent = crate::hash::hash(b"app-prefix-grant-other-owner"); + let prefix = format!("urn:tinycloud:encryption:{OTHER_OWNER}:default/apps/foo/"); + insert_parent_grant(&db, parent, OTHER_OWNER, OTHER_DID, &prefix, KEY_DECRYPT).await; + let info = key_grant_info(OTHER_DID, &app_key(), KEY_DECRYPT, &[parent]); + assert!(matches!( + validate(&db, &info).await, + Err(Error::InvalidDelegation( + DelegationError::UnauthorizedCapability(_, _) + )) + )); + } + + /// A terminal grant on an `apps/` prefix cannot be cited for a redelegation. + #[tokio::test] + async fn terminal_app_parent_grant_cannot_be_redelegated() { + let db = database().await; + let parent = crate::hash::hash(b"terminal-app-parent-grant"); + insert_terminal_parent_grant( + &db, + parent, + KEY_OWNER, + OTHER_DID, + &app_prefix(), + KEY_DECRYPT, + ) + .await; + let info = key_grant_info(OTHER_DID, &app_key(), KEY_DECRYPT, &[parent]); + assert!(matches!( + validate(&db, &info).await, + Err(Error::InvalidDelegation( + DelegationError::TerminalParentCannotRedelegate + )) + )); + } + + /// With named keys off, a grant on an `apps/` key or prefix is refused for + /// the owner and for anyone else, before any parent is looked up. + #[tokio::test] + async fn app_key_grants_are_refused_when_named_keys_are_off() { + let db = database().await; + for delegator in [KEY_OWNER, OTHER_DID] { + for resource in [app_key(), app_prefix()] { + let info = key_grant_info(delegator, &resource, KEY_DECRYPT, &[]); + let refused = validate_with(&db, &info, false, false).await; + assert!( + matches!( + refused, + Err(Error::InvalidDelegation( + DelegationError::NamedKeysUnavailable + )) + ), + "{delegator} on {resource}: {refused:?}" + ); + } + } + } + fn wire(list: serde_json::Value) -> Caveats { let entries = list.as_array().expect("a list"); if entries.len() == 1 && entries[0] == serde_json::json!({}) { @@ -2106,4 +2344,271 @@ mod tests { assert!(off(json!([{"tables": ["a"]}]), json!([{"tables": ["A"]}])).is_err()); assert!(off(json!([{"tables": ["a"]}]), json!([{}])).is_err()); } + + // Domain binding of app ids in SIWE roots. + + /// A SIWE CACAO from the fixed test address with `scheme` (None: absent) + /// and `domain`, granting each `(resource tail under the key owner's + /// `default` network, ability)`. The delegator is the address's did:pkh; + /// resources use that owner. + fn siwe_key_grants_with_scheme( + scheme: Option<&str>, + domain: &str, + grants: &[(&str, &str)], + ) -> util::DelegationInfo { + use std::collections::BTreeMap; + use tinycloud_auth::cacaos::siwe::{Message, Version}; + use tinycloud_auth::cacaos::siwe_cacao::{Header, Signature, SiweCacao}; + let message = |domain: &str| Message { + scheme: scheme.map(|scheme| scheme.parse().unwrap()), + domain: domain.parse().unwrap(), + address: [1; 20], + statement: None, + uri: "did:key:z6MkTestDelegatee".parse().unwrap(), + version: Version::V1, + chain_id: 1, + nonce: "siwenonce".into(), + issued_at: "2020-01-01T00:00:00Z".parse().unwrap(), + expiration_time: Some("2100-01-01T00:00:00Z".parse().unwrap()), + not_before: None, + request_id: None, + resources: vec![], + }; + let wrap = |message: Message| { + tinycloud_auth::authorization::TinyCloudDelegation::Cacao(Box::new(SiweCacao::new( + message.into(), + Signature::from([0u8; 65]), + Header, + ))) + }; + let owner = util::DelegationInfo::try_from(wrap(message(domain))) + .unwrap() + .delegator; + let mut recap = tinycloud_auth::siwe_recap::Capability::::new(); + for (tail, ability) in grants { + recap.with_action( + format!("urn:tinycloud:encryption:{owner}:default/{tail}") + .parse() + .unwrap_or_else(|e| panic!("resource {tail}: {e:?}")), + ability.parse().unwrap(), + vec![BTreeMap::::new()], + ); + } + util::DelegationInfo::try_from(wrap(recap.build_message(message(domain)).unwrap())).unwrap() + } + + /// As [`siwe_key_grants_with_scheme`], with scheme `https`. + fn siwe_key_grants(domain: &str, grants: &[(&str, &str)]) -> util::DelegationInfo { + siwe_key_grants_with_scheme(Some("https"), domain, grants) + } + + const KEY_CREATE: &str = "tinycloud.encryption/key.create"; + const KEY_BLOCK: &str = "tinycloud.encryption/key.block"; + const KEY_UNBLOCK: &str = "tinycloud.encryption/key.unblock"; + + fn app_binding_refused(result: Result<(), Error>) -> bool { + matches!( + result, + Err(Error::InvalidDelegation(DelegationError::AppIdNotBound)) + ) + } + + #[tokio::test] + async fn siwe_root_needs_the_app_id_bound_to_its_domain() { + let db = database().await; + let domain = "listen.tinycloud.xyz"; + for tail in [ + "apps/xyz.tinycloud.listen/x", + "apps/xyz.tinycloud.listen.digest/x/y", + "apps/xyz.tinycloud.listen/", + "apps/xyz.tinycloud.listen.digest/sub/", + ] { + for ability in [KEY_CREATE, KEY_DECRYPT, KEY_UNBLOCK, KEY_BLOCK] { + let info = siwe_key_grants(domain, &[(tail, ability)]); + validate(&db, &info) + .await + .unwrap_or_else(|e| panic!("{tail} {ability}: {e}")); + } + } + for tail in [ + "apps/xyz.tinycloud.listenx/x", + "apps/xyz.tinycloud/x", + "apps/xyz.tinycloud.other/x", + "apps/xyz.tinycloud.other/", + "apps/io.github.alice.app/x", + ] { + for ability in [KEY_CREATE, KEY_DECRYPT, KEY_UNBLOCK] { + let info = siwe_key_grants(domain, &[(tail, ability)]); + assert!( + app_binding_refused(validate(&db, &info).await), + "{tail} {ability}" + ); + } + } + } + + /// `key.block` on any app prefix is the one ability a root may grant + /// without a bound domain; any other ability beside it is checked. + #[tokio::test] + async fn siwe_root_block_on_a_foreign_app_id_is_the_only_unbound_grant() { + let db = database().await; + let domain = "notes.tinycloud.xyz"; + let foreign = "apps/xyz.tinycloud.listen/"; + let info = siwe_key_grants(domain, &[(foreign, KEY_BLOCK)]); + validate(&db, &info).await.expect("block is exempt"); + let info = siwe_key_grants( + domain, + &[ + (foreign, KEY_BLOCK), + ("apps/xyz.tinycloud.notes/", KEY_CREATE), + ], + ); + validate(&db, &info).await.expect("own app id is bound"); + let info = siwe_key_grants( + domain, + &[ + ("apps/xyz.tinycloud.notes/", KEY_CREATE), + (foreign, KEY_DECRYPT), + ], + ); + assert!(app_binding_refused(validate(&db, &info).await)); + let info = siwe_key_grants(domain, &[(foreign, KEY_BLOCK), (foreign, KEY_UNBLOCK)]); + assert!(app_binding_refused(validate(&db, &info).await)); + } + + #[tokio::test] + async fn siwe_domain_with_a_port_or_userinfo_binds_nothing() { + let db = database().await; + for domain in [ + "listen.tinycloud.xyz:8443", + "listen.tinycloud.xyz:443", + "user@listen.tinycloud.xyz", + "localhost", + "tinycloud", + "127.0.0.1", + ] { + let info = siwe_key_grants(domain, &[("apps/xyz.tinycloud.listen/", KEY_CREATE)]); + assert!( + app_binding_refused(validate(&db, &info).await), + "domain {domain}" + ); + } + } + + /// The SIWE scheme must be absent (EIP-4361: https) or `https`. + #[tokio::test] + async fn siwe_scheme_other_than_https_binds_nothing() { + let db = database().await; + let grants = [("apps/xyz.tinycloud.listen/x", KEY_CREATE)]; + for scheme in ["http", "wss", "ws", "ftp"] { + let info = siwe_key_grants_with_scheme(Some(scheme), "listen.tinycloud.xyz", &grants); + assert!( + app_binding_refused(validate(&db, &info).await), + "scheme {scheme}" + ); + } + for scheme in [None, Some("https")] { + let info = siwe_key_grants_with_scheme(scheme, "listen.tinycloud.xyz", &grants); + validate(&db, &info) + .await + .unwrap_or_else(|e| panic!("scheme {scheme:?}: {e}")); + } + // `key.block` is exempt whatever the scheme. + let info = siwe_key_grants_with_scheme( + Some("http"), + "listen.tinycloud.xyz", + &[("apps/xyz.tinycloud.listen/x", KEY_BLOCK)], + ); + validate(&db, &info).await.expect("block is exempt"); + } + + /// Schemes are case-insensitive (RFC 3986). + #[tokio::test] + async fn siwe_scheme_https_is_matched_ignoring_case() { + let db = database().await; + let grants = [("apps/xyz.tinycloud.listen/x", KEY_CREATE)]; + for scheme in ["HTTPS", "Https"] { + let info = siwe_key_grants_with_scheme(Some(scheme), "listen.tinycloud.xyz", &grants); + validate(&db, &info) + .await + .unwrap_or_else(|e| panic!("scheme {scheme}: {e}")); + } + let info = siwe_key_grants_with_scheme(Some("HTTP"), "listen.tinycloud.xyz", &grants); + assert!(app_binding_refused(validate(&db, &info).await)); + } + + #[tokio::test] + async fn siwe_domain_with_an_empty_port_binds_nothing() { + let db = database().await; + let info = siwe_key_grants( + "listen.tinycloud.xyz:", + &[("apps/xyz.tinycloud.listen/x", KEY_CREATE)], + ); + assert!(app_binding_refused(validate(&db, &info).await)); + } + + #[tokio::test] + async fn domain_binding_covers_only_keys_under_apps_and_only_siwe_roots() { + let db = database().await; + // Outside `apps/` any domain may be used. + let info = siwe_key_grants("localhost", &[("docs/a", KEY_CREATE)]); + validate(&db, &info).await.expect("ordinary key"); + let info = siwe_key_grants("localhost", &[("appsx/y", KEY_DECRYPT)]); + validate(&db, &info).await.expect("ordinary key"); + // A UCAN root is not checked. + let resource = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/anything/x"); + let info = key_grant_info(KEY_OWNER, &resource, KEY_CREATE, &[]); + validate(&db, &info) + .await + .expect("UCAN roots are unchecked"); + // A SIWE that is not issued by the key's owner is not a root: it is + // judged by its parents. + let mut info = siwe_key_grants("listen.tinycloud.xyz", &[("docs/a", KEY_CREATE)]); + info.capabilities[0].resource = + format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/other.app/x") + .parse() + .unwrap(); + assert!(matches!( + validate(&db, &info).await, + Err(Error::InvalidDelegation(DelegationError::MissingParents)) + )); + } + + #[tokio::test] + async fn shared_host_binding_vectors_decide_siwe_roots() { + let db = database().await; + let vectors: serde_json::Value = serde_json::from_str(include_str!( + "../../tests/fixtures/encryption/app-key-vectors.json" + )) + .unwrap(); + let cases = vectors["hostBinding"].as_array().unwrap(); + assert_eq!(cases.len(), 23); + let (mut decided, mut skipped) = (0, 0); + for case in cases { + let origin = case["origin"].as_str().unwrap(); + let id = case["id"].as_str().unwrap(); + let bound = case["bound"].as_bool().unwrap(); + let (scheme, domain) = origin.split_once("://").unwrap(); + // A SIWE domain is an ASCII authority and an app id is ASCII: a + // Unicode host name or id cannot be carried (those cases are + // decided on the host in `app_id`). + if !domain.is_ascii() || !id.is_ascii() { + skipped += 1; + continue; + } + decided += 1; + let info = siwe_key_grants_with_scheme( + Some(scheme), + domain, + &[(&format!("apps/{id}/x"), KEY_CREATE)], + ); + let result = validate(&db, &info).await; + if bound { + result.unwrap_or_else(|e| panic!("origin {origin} id {id}: {e}")); + } else { + assert!(app_binding_refused(result), "origin {origin} id {id}"); + } + } + assert_eq!((decided, skipped), (20, 3)); + } } diff --git a/tinycloud-core/src/models/invocation.rs b/tinycloud-core/src/models/invocation.rs index 99b51569..003158fe 100644 --- a/tinycloud-core/src/models/invocation.rs +++ b/tinycloud-core/src/models/invocation.rs @@ -1930,7 +1930,7 @@ mod tests { #[tokio::test] async fn owner_invokes_own_key_without_parents() { let db = empty_db().await; - let resource = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/tinychat"); + let resource = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/tinychat"); let capability = caveat_capability( &resource, "tinycloud.encryption/key.create", @@ -1952,7 +1952,7 @@ mod tests { #[tokio::test] async fn key_prefix_is_not_invocable_even_by_owner() { let db = empty_db().await; - let resource = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/tinychat/"); + let resource = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/tinychat/"); let capability = caveat_capability( &resource, "tinycloud.encryption/key.create", @@ -1980,7 +1980,7 @@ mod tests { #[tokio::test] async fn non_owner_key_invocation_without_parents_is_refused() { let db = empty_db().await; - let resource = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/tinychat"); + let resource = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/tinychat"); let capability = caveat_capability( &resource, "tinycloud.encryption/key.create", @@ -2041,7 +2041,7 @@ mod tests { ); } assert_eq!( - skipped, 2, + skipped, 4, "URI-invalid key identifiers in MALFORMED_KEY_IDS" ); } @@ -2062,8 +2062,8 @@ mod tests { } let prefix_grant = crate::hash::hash(b"owner-prefix-grant"); let key_grant = crate::hash::hash(b"middle-exact-key-grant"); - let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/"); - let key = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/x"); + let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/foo/"); + let key = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/foo/x"); for (id, delegator, delegatee, resource) in [ (prefix_grant, KEY_OWNER, KEY_MIDDLE, prefix.as_str()), (key_grant, KEY_MIDDLE, KEY_OTHER, key.as_str()), @@ -2114,7 +2114,7 @@ mod tests { .await .expect("chained exact-key grant admits its key"); - let sibling = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foobar/x"); + let sibling = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/foobar/x"); let capability = caveat_capability(&sibling, "tinycloud.encryption/decrypt", Caveats::default()); let err = crate::models::invocation::authorize_holder( @@ -2135,6 +2135,175 @@ mod tests { ); } + /// The owner invoking an `apps/` key under its own namespace needs no parents. + #[tokio::test] + async fn owner_invokes_own_app_key_without_parents() { + let db = empty_db().await; + let resource = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/tinychat/x"); + let capability = caveat_capability( + &resource, + "tinycloud.encryption/key.create", + Caveats::default(), + ); + crate::models::invocation::authorize_holder( + &db, + KEY_OWNER, + &[capability], + &[], + OffsetDateTime::now_utc(), + ) + .await + .expect("owner is root authority for own app keys"); + } + + /// An `apps/` prefix is refused as an invocation target, including for its owner. + #[tokio::test] + async fn app_key_prefix_is_not_invocable_even_by_owner() { + let db = empty_db().await; + let resource = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/tinychat/"); + let capability = caveat_capability( + &resource, + "tinycloud.encryption/key.create", + Caveats::default(), + ); + let err = crate::models::invocation::authorize_holder( + &db, + KEY_OWNER, + &[capability], + &[], + OffsetDateTime::now_utc(), + ) + .await + .unwrap_err(); + assert!( + matches!( + &err, + Error::InvalidInvocation(InvocationError::EncryptionPrefixNotInvocable) + ), + "unexpected rejection: {err:?}" + ); + } + + /// An `apps/` key invoked by a principal that does not own it must cite parents. + #[tokio::test] + async fn non_owner_app_key_invocation_without_parents_is_refused() { + let db = empty_db().await; + let resource = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/tinychat/x"); + let capability = caveat_capability( + &resource, + "tinycloud.encryption/key.create", + Caveats::default(), + ); + let err = crate::models::invocation::authorize_holder( + &db, + KEY_OTHER, + &[capability], + &[], + OffsetDateTime::now_utc(), + ) + .await + .unwrap_err(); + assert!( + matches!( + &err, + Error::InvalidInvocation(InvocationError::MissingParents) + ), + "unexpected rejection: {err:?}" + ); + } + + /// An owner-issued `apps/foo/` prefix grant, re-delegated for one exact key, + /// admits an invocation of that key and refuses the key of an app id that + /// only shares its text. + #[tokio::test] + async fn chained_app_key_grant_admits_exact_key_and_refuses_sibling_app() { + const KEY_MIDDLE: &str = "did:pkh:eip155:1:0x0000000000000000000000000000000000000001"; + let db = empty_db().await; + for actor_id in [KEY_OWNER, KEY_MIDDLE, KEY_OTHER] { + actor::ActiveModel { + id: Set(actor_id.to_string()), + } + .insert(&db) + .await + .unwrap(); + } + let prefix_grant = crate::hash::hash(b"owner-app-prefix-grant"); + let key_grant = crate::hash::hash(b"middle-exact-app-key-grant"); + let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/"); + let key = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/x"); + for (id, delegator, delegatee, resource) in [ + (prefix_grant, KEY_OWNER, KEY_MIDDLE, prefix.as_str()), + (key_grant, KEY_MIDDLE, KEY_OTHER, key.as_str()), + ] { + delegation::ActiveModel { + id: Set(id), + delegator: Set(delegator.to_string()), + delegatee: Set(delegatee.to_string()), + expiry: Set(None), + issued_at: Set(None), + not_before: Set(None), + facts: Set(None), + serialization: Set(id.as_ref().to_vec()), + } + .insert(&db) + .await + .unwrap(); + abilities::ActiveModel { + delegation: Set(id), + resource: Set(resource.parse().unwrap()), + ability: Set("tinycloud.encryption/decrypt" + .to_string() + .try_into() + .unwrap()), + caveats: Set(Caveats::default()), + } + .insert(&db) + .await + .unwrap(); + } + parent_delegations::ActiveModel { + parent: Set(prefix_grant), + child: Set(key_grant), + } + .insert(&db) + .await + .unwrap(); + + let capability = + caveat_capability(&key, "tinycloud.encryption/decrypt", Caveats::default()); + crate::models::invocation::authorize_holder( + &db, + KEY_OTHER, + &[capability], + &[key_grant.to_cid(0x55)], + OffsetDateTime::now_utc(), + ) + .await + .expect("chained exact-key grant admits its app key"); + + for sibling in ["apps/foobar/x", "apps/foo.bar/x", "apps/foo/y"] { + let sibling = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/{sibling}"); + let capability = + caveat_capability(&sibling, "tinycloud.encryption/decrypt", Caveats::default()); + let err = crate::models::invocation::authorize_holder( + &db, + KEY_OTHER, + &[capability], + &[key_grant.to_cid(0x55)], + OffsetDateTime::now_utc(), + ) + .await + .unwrap_err(); + assert!( + matches!( + &err, + Error::InvalidInvocation(InvocationError::UnauthorizedAction(_, _)) + ), + "{sibling}: unexpected rejection: {err:?}" + ); + } + } + /// Persists one terminal grant (`disableSubDelegation: true`) from KEY_OWNER to KEY_OTHER. async fn terminal_grant_db() -> (sea_orm::DatabaseConnection, Hash, String) { let db = empty_db().await; @@ -2147,7 +2316,7 @@ mod tests { .unwrap(); } let parent = crate::hash::hash(b"terminal-grant-invocation"); - let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/"); + let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/foo/"); delegation::ActiveModel { id: Set(parent), delegator: Set(KEY_OWNER.to_string()), @@ -2178,7 +2347,7 @@ mod tests { .insert(&db) .await .unwrap(); - let key = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/foo/x"); + let key = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/foo/x"); (db, parent, key) } diff --git a/tinycloud-core/src/types/resource.rs b/tinycloud-core/src/types/resource.rs index 7a459764..9d60aa25 100644 --- a/tinycloud-core/src/types/resource.rs +++ b/tinycloud-core/src/types/resource.rs @@ -245,11 +245,11 @@ mod tests { }; let covered = [ - (uri("default/apps/foo/a/b"), uri("default/apps/foo/")), - (uri("default/apps/foo/x"), uri("default/apps/foo/")), - (uri("default/apps/foo/"), uri("default/apps/")), - (uri("default/apps/foo/"), uri("default/apps/foo/")), - (uri("default/apps/x"), uri("default/apps/x")), + (uri("default/docs/foo/a/b"), uri("default/docs/foo/")), + (uri("default/docs/foo/x"), uri("default/docs/foo/")), + (uri("default/docs/foo/"), uri("default/docs/")), + (uri("default/docs/foo/"), uri("default/docs/foo/")), + (uri("default/docs/x"), uri("default/docs/x")), ]; for (child, parent) in &covered { assert!( @@ -259,12 +259,12 @@ mod tests { } let not_covered = [ - (uri("default/apps/foobar/x"), uri("default/apps/foo/")), - (uri("default/apps/foo"), uri("default/apps/foo/")), - (uri("default/apps/x"), uri("default")), - (uri("default"), uri("default/apps/x")), - (uri("default/apps/"), uri("default/apps/foo/")), - (uri("default/apps/x"), uri("default/apps/y")), + (uri("default/docs/foobar/x"), uri("default/docs/foo/")), + (uri("default/docs/foo"), uri("default/docs/foo/")), + (uri("default/docs/x"), uri("default")), + (uri("default"), uri("default/docs/x")), + (uri("default/docs/"), uri("default/docs/foo/")), + (uri("default/docs/x"), uri("default/docs/y")), ]; for (child, parent) in ¬_covered { assert!( diff --git a/tinycloud-core/tests/fixtures/encryption/app-key-vectors.json b/tinycloud-core/tests/fixtures/encryption/app-key-vectors.json new file mode 100644 index 00000000..612dccca --- /dev/null +++ b/tinycloud-core/tests/fixtures/encryption/app-key-vectors.json @@ -0,0 +1,471 @@ +{ + "appIdRegex": "^[a-z0-9][a-z0-9._-]{0,63}$", + "appIdValid": [ + { + "id": "a", + "valid": true + }, + { + "id": "tinychat", + "valid": true + }, + { + "id": "xyz.tinycloud.tinychat", + "valid": true + }, + { + "id": "a-b.c", + "valid": true + }, + { + "id": "0abc", + "valid": true + }, + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "valid": true + }, + { + "id": "a.b.c", + "valid": true + }, + { + "id": "a--b", + "valid": true + }, + { + "id": "a..b", + "valid": true + }, + { + "id": "x1", + "valid": true + }, + { + "id": "foo_bar", + "valid": true + }, + { + "id": "", + "valid": false + }, + { + "id": "Foo", + "valid": false + }, + { + "id": "xyz.App", + "valid": false + }, + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "valid": false + }, + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "valid": false + }, + { + "id": "-foo", + "valid": false + }, + { + "id": ".foo", + "valid": false + }, + { + "id": "_foo", + "valid": false + }, + { + "id": "foo/bar", + "valid": false + }, + { + "id": "foo/", + "valid": false + }, + { + "id": "foo:bar", + "valid": false + }, + { + "id": "foo bar", + "valid": false + }, + { + "id": " foo", + "valid": false + }, + { + "id": "foo ", + "valid": false + }, + { + "id": "foo\n", + "valid": false + }, + { + "id": "fo\u00f6", + "valid": false + }, + { + "id": "f\u043eo", + "valid": false + }, + { + "id": "\u212aelvin", + "valid": false + }, + { + "id": "foo\u200b", + "valid": false + }, + { + "id": "foo%41", + "valid": false + }, + { + "id": "foo#x", + "valid": false + }, + { + "id": "foo?x", + "valid": false + }, + { + "id": "foo\u0000", + "valid": false + }, + { + "id": "\u0130stanbul", + "valid": false + }, + { + "id": "foo\r", + "valid": false + }, + { + "id": "foo\t", + "valid": false + }, + { + "id": "\uff46oo", + "valid": false + }, + { + "id": "xn--bcher-kva.example", + "valid": true + } + ], + "prefixCovers": [ + { + "prefix": "apps/foo/", + "key": "apps/foo/x", + "covers": true + }, + { + "prefix": "apps/foo/", + "key": "apps/foo/x/y", + "covers": true + }, + { + "prefix": "apps/foo/", + "key": "apps/foo/x/y/z", + "covers": true + }, + { + "prefix": "apps/foo/", + "key": "apps/foo", + "covers": false + }, + { + "prefix": "apps/foo/", + "key": "apps/foobar/x", + "covers": false + }, + { + "prefix": "apps/foo/", + "key": "apps/foo.bar/x", + "covers": false + }, + { + "prefix": "apps/foo/", + "key": "apps/foo-bar/x", + "covers": false + }, + { + "prefix": "apps/foo/", + "key": "apps/fo/x", + "covers": false + }, + { + "prefix": "apps/foo/", + "key": "other/foo/x", + "covers": false + }, + { + "prefix": "apps/foo/", + "key": "secrets/apps/foo/x", + "covers": false + }, + { + "prefix": "apps/foo.bar/", + "key": "apps/foo.bar/x", + "covers": true + }, + { + "prefix": "apps/foo_bar/", + "key": "apps/foo.bar/x", + "covers": false + }, + { + "prefix": "apps/foo_bar/", + "key": "apps/foo-bar/x", + "covers": false + }, + { + "prefix": "apps/foo_bar/", + "key": "apps/fooxbar/x", + "covers": false + }, + { + "prefix": "apps/foo/x/", + "key": "apps/foo/x", + "covers": false + } + ], + "nodeKeyPathUnderApps": [ + { + "keyPath": "apps/foo/x", + "accepted": true + }, + { + "keyPath": "apps/foo/x/y", + "accepted": true + }, + { + "keyPath": "apps/foo", + "accepted": false + }, + { + "keyPath": "apps", + "accepted": false + }, + { + "keyPath": "apps/Foo/x", + "accepted": false + }, + { + "keyPath": "apps/foo_bar/x", + "accepted": true + }, + { + "keyPath": "apps/foo/x/", + "accepted": false + }, + { + "keyPath": "secrets/x", + "accepted": true + }, + { + "keyPath": "appsx/y", + "accepted": true + }, + { + "keyPath": "apps.x/y", + "accepted": true + } + ], + "keyPathsRefusedByGrammar": [ + { + "keyPath": "apps/Foo/x" + }, + { + "keyPath": "apps//x" + }, + { + "keyPath": "apps/foo//x" + }, + { + "keyPath": "apps/foo/" + } + ], + "prefixValid": [ + { + "prefix": "apps/", + "valid": false + }, + { + "prefix": "apps/foo/", + "valid": true + }, + { + "prefix": "apps/foo/x/", + "valid": true + }, + { + "prefix": "apps/Foo/", + "valid": false + }, + { + "prefix": "apps/foo//", + "valid": false + }, + { + "prefix": "appsx/", + "valid": true + }, + { + "prefix": "apps.x/", + "valid": true + }, + { + "prefix": "apps", + "valid": false + }, + { + "prefix": "apps/foo", + "valid": false + } + ], + "createFlagMatchesPath": [ + { + "keyPath": "apps/foo/x", + "deletable": false, + "accepted": false + }, + { + "keyPath": "apps/foo/x", + "deletable": true, + "accepted": true + }, + { + "keyPath": "secrets/x", + "deletable": true, + "accepted": false + }, + { + "keyPath": "secrets/x", + "deletable": false, + "accepted": true + } + ], + "hostBinding": [ + { + "origin": "https://listen.tinycloud.xyz", + "id": "xyz.tinycloud.listen", + "bound": true + }, + { + "origin": "https://listen.tinycloud.xyz", + "id": "xyz.tinycloud.listen.digest", + "bound": true + }, + { + "origin": "https://listen.tinycloud.xyz", + "id": "xyz.tinycloud", + "bound": false + }, + { + "origin": "https://listen.tinycloud.xyz", + "id": "xyz.tinycloud.listenx", + "bound": false + }, + { + "origin": "https://notes.tinycloud.xyz", + "id": "xyz.tinycloud.listen", + "bound": false + }, + { + "origin": "https://alice.github.io", + "id": "io.github.bob.app", + "bound": false + }, + { + "origin": "https://alice.github.io", + "id": "io.github.alice.app", + "bound": true + }, + { + "origin": "https://listen.tinycloud.xyz:8443", + "id": "xyz.tinycloud.listen", + "bound": false + }, + { + "origin": "http://listen.tinycloud.xyz", + "id": "xyz.tinycloud.listen", + "bound": false + }, + { + "origin": "https://LISTEN.tinycloud.xyz", + "id": "xyz.tinycloud.listen", + "bound": true + }, + { + "origin": "https://localhost", + "id": "localhost", + "bound": false + }, + { + "origin": "https://127.0.0.1", + "id": "1.0.0.127", + "bound": false + }, + { + "origin": "https://listen.tinycloud.xyz.", + "id": "xyz.tinycloud.listen", + "bound": false + }, + { + "origin": "https://intranet", + "id": "intranet", + "bound": false + }, + { + "origin": "https://xn--bcher-kva.example", + "id": "example.xn--bcher-kva", + "bound": true + }, + { + "origin": "https://xn--bcher-kva.example", + "id": "example.b\u00fccher", + "bound": false + }, + { + "origin": "https://b\u00fccher.example", + "id": "example.xn--bcher-kva", + "bound": true + }, + { + "origin": "https://b\u00fccher.example", + "id": "example.b\u00fccher", + "bound": false + }, + { + "origin": "https://a.b.example.com", + "id": "com.example.b.a", + "bound": true + }, + { + "origin": "https://a.b.example.com", + "id": "com.example", + "bound": false + }, + { + "origin": "https://example.com", + "id": "com.example", + "bound": true + }, + { + "origin": "https://example.com", + "id": "com.example.app", + "bound": true + }, + { + "origin": "https://EXAMPLE.com", + "id": "com.example.app", + "bound": true + } + ] +} \ No newline at end of file diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index b0c3982a..ab474e2d 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -828,6 +828,9 @@ pub async fn app_with_control( )); let encryption_service = encryption_service.with_sqlite_writer_lock(tinycloud.sqlite_writer_lock()); + // Runs after the migrations: a key row under `apps/` that is not a + // deletable key stops the node from starting. + tinycloud_core::encryption_network::keys::ensure_no_deterministic_app_keys(&seed_conn).await?; let database_artifact_repository: Arc = Arc::new(SizeTrackingArtifactRepository::new( Arc::new(node_artifact_repository(&tinycloud)), 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 c3474d88..1248c2da 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 @@ -218,3 +218,193 @@ async fn delegations_on_named_keys_are_refused(backend: Backend) -> anyhow::Resu Ok(()) } on_both_backends!(delegations_on_named_keys_are_refused); + +const KEY_BLOCK: &str = "tinycloud.encryption/key.block"; +const APP_ID_NOT_BOUND: &str = "app_id_not_bound"; + +/// With named keys on, a SIWE (CACAO) root grants an ability other than +/// `key.block` under `apps/` only when the app id is bound to the +/// SIWE domain (`tc1423.test` here, so `test.tc1423` and `test.tc1423.`); +/// a UCAN root is not checked. +async fn siwe_app_grants_need_an_app_id_bound_to_the_domain( + backend: Backend, +) -> anyhow::Result<()> { + let on = |config: &mut crate::config::Config| { + config.encryption_network.named_keys_v1 = true; + }; + let window = || Window::until(now() + Duration::hours(1)); + + let Some(h) = Harness::new_with( + backend, + Shape::PkhSession, + "siwe_app_grants_need_an_app_id_bound_to_the_domain_cacao", + on, + ) + .await? + else { + return Ok(()); + }; + let key = |tail: &str| format!("{}/{tail}", h.network); + for (what, grants) in [ + ("bound key", vec![(key("apps/test.tc1423/x"), KEY_DECRYPT)]), + ( + "bound prefix", + vec![(key("apps/test.tc1423.notes/"), KEY_CREATE)], + ), + ("foreign block", vec![(key("apps/other.app/"), KEY_BLOCK)]), + ("ordinary key", vec![(key("docs/a"), KEY_DECRYPT)]), + ("reserved lookalike", vec![(key("appsx/y"), KEY_DECRYPT)]), + ] { + let authorization = h + .space_owner + .delegate(h.owner.did(), &grants, window(), &[])?; + let (status, body) = h.delegate(authorization).await; + assert_eq!(status, Status::Ok, "{what}: {body}"); + } + for (what, grants) in [ + ("foreign key", vec![(key("apps/other.app/x"), KEY_DECRYPT)]), + ("foreign prefix", vec![(key("apps/other.app/"), KEY_CREATE)]), + ( + "prefix of the domain only", + vec![(key("apps/test.tc1423x/"), KEY_CREATE)], + ), + ( + "bound block, foreign decrypt", + vec![ + (key("apps/test.tc1423/"), KEY_BLOCK), + (key("apps/other.app/x"), KEY_DECRYPT), + ], + ), + ] { + let authorization = h + .space_owner + .delegate(h.owner.did(), &grants, window(), &[])?; + let (status, body) = h.delegate(authorization).await; + assert_ne!(status, Status::Ok, "{what}: {body}"); + assert!(body.starts_with(APP_ID_NOT_BOUND), "{what}: {body}"); + } + + let Some(h) = Harness::new_with( + backend, + Shape::KeySession, + "siwe_app_grants_need_an_app_id_bound_to_the_domain_ucan", + on, + ) + .await? + else { + return Ok(()); + }; + let grants = vec![(format!("{}/apps/other.app/x", h.network), KEY_DECRYPT)]; + let authorization = h + .space_owner + .delegate(h.owner.did(), &grants, window(), &[])?; + let (status, body) = h.delegate(authorization).await; + assert_eq!(status, Status::Ok, "UCAN root: {body}"); + Ok(()) +} +on_both_backends!(siwe_app_grants_need_an_app_id_bound_to_the_domain); + +/// The app refuses to start while a key row under `apps/` is not a deletable +/// key, and starts once the row is gone. +async fn startup_refuses_a_non_deletable_key_row_under_apps( + backend: Backend, +) -> anyhow::Result<()> { + use tinycloud_core::models::encryption_network_key; + use tinycloud_core::sea_orm::{ActiveModelTrait, ActiveValue::Set, Database, ModelTrait}; + + let Some(h) = Harness::new( + backend, + Shape::KeySession, + "startup_refuses_a_non_deletable_key_row_under_apps", + ) + .await? + else { + return Ok(()); + }; + h.boot_again(|_| {}).await.expect("boots with no stray row"); + + let db = Database::connect(h.database_url()).await?; + let stray = |path: &str, kind: &str| encryption_network_key::ActiveModel { + network_id: Set(h.network.clone()), + key_path: Set(path.to_string()), + kind: Set(kind.to_string()), + state: Set("active".to_string()), + public_key: Set(vec![7u8; 32]), + sealed_seed: Set(None), + key_version: Set(1), + created_at: Set("2026-01-01T00:00:00Z".to_string()), + updated_at: Set("2026-01-01T00:00:00Z".to_string()), + }; + // Rows outside the rule do not stop the start. + stray("docs/a", "deterministic").insert(&db).await?; + stray("appsx/y", "deterministic").insert(&db).await?; + h.boot_again(|_| {}) + .await + .expect("boots with ordinary rows"); + + let row = stray("apps/foo/old", "deterministic").insert(&db).await?; + let Err(error) = h.boot_again(|_| {}).await else { + panic!("a stray row under apps/ stops the start"); + }; + assert!( + error + .to_string() + .contains("1 key rows under apps/ are not deletable keys"), + "{error}" + ); + // The same with the named-keys setting on. + assert!(h + .boot_again(|config| config.encryption_network.named_keys_v1 = true) + .await + .is_err()); + + row.delete(&db).await?; + h.boot_again(|_| {}) + .await + .expect("boots once the row is gone"); + Ok(()) +} +on_both_backends!(startup_refuses_a_non_deletable_key_row_under_apps); + +/// With named keys on, `/delegate` refuses an `apps/` grant from a SIWE whose +/// scheme is not `https` even when its domain is bound to the app id, and +/// admits it when the scheme is `https` (any case) or absent. +async fn siwe_app_grants_need_an_https_or_absent_scheme(backend: Backend) -> anyhow::Result<()> { + let Some(h) = Harness::new_with( + backend, + Shape::PkhSession, + "siwe_app_grants_need_an_https_or_absent_scheme", + |config| config.encryption_network.named_keys_v1 = true, + ) + .await? + else { + return Ok(()); + }; + let window = || Window::until(now() + Duration::hours(1)); + // `tc1423.test` is the harness SIWE domain, so `test.tc1423` is bound to it. + let grants = vec![(format!("{}/apps/test.tc1423/x", h.network), KEY_CREATE)]; + for scheme in ["http", "wss", "ws"] { + let authorization = h.space_owner.delegate_with_scheme( + Some(scheme), + h.owner.did(), + &grants, + window(), + &[], + )?; + let (status, body) = h.delegate(authorization).await; + assert_ne!(status, Status::Ok, "scheme {scheme}: {body}"); + assert!( + body.starts_with(APP_ID_NOT_BOUND), + "scheme {scheme}: {body}" + ); + } + for scheme in [Some("https"), Some("HTTPS"), None] { + let authorization = + h.space_owner + .delegate_with_scheme(scheme, h.owner.did(), &grants, window(), &[])?; + let (status, body) = h.delegate(authorization).await; + assert_eq!(status, Status::Ok, "scheme {scheme:?}: {body}"); + } + Ok(()) +} +on_both_backends!(siwe_app_grants_need_an_https_or_absent_scheme); diff --git a/tinycloud-node-server/src/policy_v3/test_harness.rs b/tinycloud-node-server/src/policy_v3/test_harness.rs index f84bc164..3ec27035 100644 --- a/tinycloud-node-server/src/policy_v3/test_harness.rs +++ b/tinycloud-node-server/src/policy_v3/test_harness.rs @@ -222,6 +222,7 @@ impl Signer { fn siwe_message( &self, + scheme: Option<&str>, uri: &str, nonce: &str, issued_at: OffsetDateTime, @@ -232,7 +233,7 @@ impl Signer { anyhow::bail!("not a did:pkh signer"); }; Ok(Message { - scheme: Some("https".parse()?), + scheme: scheme.map(|scheme| scheme.parse()).transpose()?, domain: "tc1423.test".parse()?, address: *address, statement: None, @@ -255,6 +256,19 @@ impl Signer { grants: &[(String, &str)], window: Window, proofs: &[Cid], + ) -> anyhow::Result { + self.delegate_with_scheme(Some("https"), audience, grants, window, proofs) + } + + /// As [`Signer::delegate`], with the SIWE `scheme` of a did:pkh signer + /// (`None`: absent). + pub fn delegate_with_scheme( + &self, + scheme: Option<&str>, + audience: &str, + grants: &[(String, &str)], + window: Window, + proofs: &[Cid], ) -> anyhow::Result { let nonce = unique("tc1423delegation"); match self { @@ -283,6 +297,7 @@ impl Signer { ); } let message = recap.build_message(self.siwe_message( + scheme, audience, &nonce, window.not_before, @@ -328,6 +343,7 @@ impl Signer { Self::Pkh { .. } => { anyhow::ensure!(proofs.is_empty(), "SIWE fixtures carry no proofs"); let message = self.siwe_message( + Some("https"), &format!("ucan:{cid}"), &unique("tc1423revoke"), now(), @@ -409,6 +425,16 @@ pub(super) struct Harness { impl Harness { pub async fn new(backend: Backend, shape: Shape, test: &str) -> anyhow::Result> { + Self::new_with(backend, shape, test, |_| {}).await + } + + /// As [`Harness::new`], with `configure` applied to the node config. + pub async fn new_with( + backend: Backend, + shape: Shape, + test: &str, + configure: impl FnOnce(&mut crate::config::Config), + ) -> anyhow::Result> { let Some(db) = TestDatabase::new(backend, test).await? else { return Ok(None); }; @@ -432,7 +458,7 @@ impl Harness { None, ); let network = format!("urn:tinycloud:encryption:{}:mainnet", space_owner.did()); - let client = Self::boot_app(&db, &vector, &issuer).await?; + let client = Self::boot_app(&db, &vector, &issuer, configure).await?; let harness = Self { client, _db: db, @@ -456,10 +482,20 @@ impl Harness { self._db.url() } + /// Boots another app on this harness's database with `configure` applied + /// to the node config, and returns the boot error if there is one. + pub async fn boot_again( + &self, + configure: impl FnOnce(&mut crate::config::Config), + ) -> anyhow::Result { + Self::boot_app(&self._db, &self.vector, &self.issuer, configure).await + } + async fn boot_app( db: &TestDatabase, vector: &Value, issuer: &tinycloud_core::libp2p::identity::ed25519::Keypair, + configure: impl FnOnce(&mut crate::config::Config), ) -> anyhow::Result { let mut config = crate::config::Config::default(); config.storage.datadir = db.datadir().into(); @@ -480,6 +516,7 @@ impl Harness { .to_owned(); config.share_email.issuer_public_key = Some(encode_config(issuer.public().to_bytes(), URL_SAFE_NO_PAD)); + configure(&mut config); let figment = Figment::from(rocket::Config { address: "127.0.0.1".parse()?, log_level: rocket::config::LogLevel::Off, diff --git a/tinycloud-node-server/src/routes/encryption.rs b/tinycloud-node-server/src/routes/encryption.rs index 30440cdf..fd05793b 100644 --- a/tinycloud-node-server/src/routes/encryption.rs +++ b/tinycloud-node-server/src/routes/encryption.rs @@ -665,11 +665,13 @@ fn map_service_err(err: EncryptionServiceError) -> (Status, String) { EncryptionServiceError::NetworkNotActive(_) => Status::Conflict, EncryptionServiceError::KeyExists => Status::Conflict, EncryptionServiceError::KeyNotFound => Status::NotFound, - EncryptionServiceError::DeletableUnavailable + EncryptionServiceError::KeyDeleted | EncryptionServiceError::KeyBlocked | EncryptionServiceError::KeyLimitExceeded | EncryptionServiceError::KeyUnwrapFailed(_) => Status::Forbidden, - EncryptionServiceError::CorruptKeyRow => Status::InternalServerError, + EncryptionServiceError::CorruptKeyRow | EncryptionServiceError::StrayAppKeyRows(_) => { + Status::InternalServerError + } EncryptionServiceError::KeyPathInvalid(_) => Status::BadRequest, EncryptionServiceError::InvalidBody(_) | EncryptionServiceError::InvalidReceiverPublicKey diff --git a/tinycloud-node-server/src/routes/tests/encryption_keys.rs b/tinycloud-node-server/src/routes/tests/encryption_keys.rs index 5b6fe5a1..651fbe82 100644 --- a/tinycloud-node-server/src/routes/tests/encryption_keys.rs +++ b/tinycloud-node-server/src/routes/tests/encryption_keys.rs @@ -665,15 +665,35 @@ async fn create_refuses_a_grammar_invalid_key_path() -> Result<()> { } #[tokio::test] -async fn create_refuses_deletable_keys() -> Result<()> { - let (fixture, network) = setup().await?; - let (status, text) = create(&fixture, &network, "docs/a", true).await?; - assert_eq!(status, Status::Forbidden, "{text}"); - assert!(text.starts_with("deletable_unavailable"), "{text}"); - let auth = describe_auth(&fixture, &network, "docs/a", KEY_CREATE_ACTION)?; - let (status, text) = describe_get(&fixture, &network, "docs/a", &auth).await; - assert_eq!(status, Status::NotFound, "no key after refusal: {text}"); - assert!(text.starts_with("key_not_found"), "{text}"); +async fn create_requires_the_deletable_flag_to_match_the_path() -> Result<()> { + let (fixture, network) = setup().await?; + for (path, deletable) in [("docs/a", true), ("apps/foo/x", false)] { + let (status, text) = create(&fixture, &network, path, deletable).await?; + assert_eq!(status, Status::BadRequest, "{path} {deletable}: {text}"); + assert!(text.starts_with("key_path_invalid"), "{text}"); + let auth = describe_auth(&fixture, &network, path, KEY_CREATE_ACTION)?; + let (status, text) = describe_get(&fixture, &network, path, &auth).await; + assert_eq!(status, Status::NotFound, "no key after refusal: {text}"); + assert!(text.starts_with("key_not_found"), "{text}"); + } + // An omitted flag is `false`. + let body = json!({"keyPath": "apps/foo/x"}); + let auth = admin_auth(&fixture, &network, "apps/foo/x", KEY_CREATE_ACTION, &body)?; + let (status, text) = post_json(&fixture.client, &keys_url(&network), &auth, &body).await; + assert_eq!(status, Status::BadRequest, "{text}"); + + let (status, text) = create(&fixture, &network, "apps/foo/x", true).await?; + assert_eq!(status, Status::Ok, "{text}"); + let value: Value = serde_json::from_str(&text)?; + assert_eq!(value["kind"], "deletable"); + assert_eq!(value["state"], "active"); + assert_eq!(value["keyVersion"], 1); + let (status, text) = create(&fixture, &network, "docs/a", false).await?; + assert_eq!(status, Status::Ok, "{text}"); + assert_eq!( + serde_json::from_str::(&text)?["kind"], + "deterministic" + ); Ok(()) } @@ -1587,15 +1607,22 @@ async fn device_decrypt( invoke(fixture, &auth, &body).await } -/// Creates a named key and returns its public key from the public GET route. +/// Creates a named key (deletable under `apps/`) and returns its public key +/// from the authenticated describe GET. async fn created_key(fixture: &KvSyncFixture, network: &str, key_path: &str) -> Vec { - let (status, text) = create(fixture, network, key_path, false) + let (status, text) = create(fixture, network, key_path, key_path.starts_with("apps/")) .await .expect("create invocation"); assert_eq!(status, Status::Ok, "{text}"); - let (status, text) = public_get(fixture, network, key_path).await; + described_public_key(fixture, network, key_path).await +} + +/// Public key of an existing key from the authenticated describe GET. +async fn described_public_key(fixture: &KvSyncFixture, network: &str, key_path: &str) -> Vec { + let auth = describe_auth(fixture, network, key_path, KEY_CREATE_ACTION).expect("describe auth"); + let (status, text) = describe_get(fixture, network, key_path, &auth).await; assert_eq!(status, Status::Ok, "{text}"); - let value: Value = serde_json::from_str(&text).expect("public key json"); + let value: Value = serde_json::from_str(&text).expect("descriptor json"); base64::decode( value["publicEncryptionKey"] .as_str() @@ -2226,3 +2253,571 @@ async fn invoke_refuses_named_key_resources_when_named_keys_are_off() -> Result< assert_eq!(status, Status::Ok, "bare network decrypt: {text}"); Ok(()) } + +// ------------------------------------------------------------ app keys + +fn app_key_vectors() -> Value { + serde_json::from_str(include_str!( + "../../../../tinycloud-core/tests/fixtures/encryption/app-key-vectors.json" + )) + .expect("vector file parses") +} + +fn key_row_filter(network: &str, key_path: &str) -> (String, String) { + (network.to_string(), key_path.to_string()) +} + +async fn stored_key_row( + fixture: &KvSyncFixture, + network: &str, + key_path: &str, +) -> Option { + use tinycloud_core::sea_orm::EntityTrait; + tinycloud_core::models::encryption_network_key::Entity::find_by_id(key_row_filter( + network, key_path, + )) + .one(&fixture.conn) + .await + .expect("key row query") +} + +/// Writes a state and seed straight into a key row, as a delete would. +async fn force_key_row( + fixture: &KvSyncFixture, + network: &str, + key_path: &str, + state: &str, + sealed_seed: Option>, +) { + use tinycloud_core::sea_orm::{ActiveModelTrait, ActiveValue::Set}; + let mut row: tinycloud_core::models::encryption_network_key::ActiveModel = + stored_key_row(fixture, network, key_path) + .await + .expect("row exists") + .into(); + row.state = Set(state.to_string()); + row.sealed_seed = Set(sealed_seed); + row.update(&fixture.conn).await.expect("row update"); +} + +/// Status, headers (sorted, without the per-request id) and body of a GET. +async fn get_full(fixture: &KvSyncFixture, uri: &str) -> (Status, Vec<(String, String)>, String) { + let response = fixture + .client + .get(uri.to_string()) + .remote("127.0.0.1:0".parse().expect("valid socket address")) + .dispatch() + .await; + let status = response.status(); + let mut headers: Vec<(String, String)> = response + .headers() + .iter() + .filter(|h| !h.name().as_str().eq_ignore_ascii_case("x-request-id")) + .map(|h| (h.name().to_string(), h.value().to_string())) + .collect(); + headers.sort(); + ( + status, + headers, + response.into_string().await.unwrap_or_default(), + ) +} + +/// Decrypt of `case` for `key_path` at `version`, authorized by the owner. +async fn invoke_named_at_version( + fixture: &KvSyncFixture, + network: &str, + key_path: &str, + case: &DecryptCase, + version: i64, +) -> (Status, String) { + let (mut body, mut facts) = decrypt_request(fixture, network, case, Some(key_path)); + body["keyVersion"] = json!(version); + facts["keyVersion"] = json!(version); + facts["bodyHash"] = json!(canonical_hash(&body)); + let resource = key_uri(network, key_path); + let auth = invocation( + fixture, + &fixture.node_did, + &[(resource.as_str(), DECRYPT_ACTION)], + vec![facts], + ) + .expect("decrypt invocation"); + invoke(fixture, &auth, &body).await +} + +#[tokio::test] +async fn apps_key_paths_follow_the_shared_depth_vectors_on_the_create_route() -> Result<()> { + let (fixture, network) = setup().await?; + let vectors = app_key_vectors(); + let cases = vectors["nodeKeyPathUnderApps"].as_array().unwrap(); + assert_eq!(cases.len(), 10); + for case in cases { + let path = case["keyPath"].as_str().unwrap(); + let accepted = case["accepted"].as_bool().unwrap(); + let deletable = path.split('/').next() == Some("apps"); + if accepted { + let (status, text) = create(&fixture, &network, path, deletable).await?; + assert_eq!(status, Status::Ok, "{path}: {text}"); + let kind = serde_json::from_str::(&text)?["kind"].clone(); + assert_eq!( + kind, + if deletable { + "deletable" + } else { + "deterministic" + } + ); + } else { + // The body names a path the grammar refuses while the invocation + // is valid for another key. + let body = json!({"keyPath": path, "deletable": deletable}); + let auth = admin_auth(&fixture, &network, "docs/a", KEY_CREATE_ACTION, &body)?; + let (status, text) = + post_json(&fixture.client, &keys_url(&network), &auth, &body).await; + assert_eq!(status, Status::BadRequest, "{path}: {text}"); + assert!(text.starts_with("key_path_invalid"), "{path}: {text}"); + // The same path as the invocation's own resource is refused too. + let (status, text) = create(&fixture, &network, path, deletable).await?; + assert_ne!(status, Status::Ok, "{path}: {text}"); + assert!( + stored_key_row(&fixture, &network, path).await.is_none(), + "{path} stored" + ); + } + } + Ok(()) +} + +#[tokio::test] +async fn apps_prefix_depth_is_enforced_for_delegation_and_invocation() -> Result<()> { + let (fixture, network) = setup().await?; + let before = stored_delegations(&fixture).await?; + let vectors = app_key_vectors(); + let cases = vectors["prefixValid"].as_array().unwrap(); + assert_eq!(cases.len(), 9); + let mut admitted = 0; + for case in cases { + let prefix = case["prefix"].as_str().unwrap(); + let valid = case["valid"].as_bool().unwrap(); + let resource = format!("{network}/{prefix}"); + for ability in [DECRYPT_ACTION, KEY_CREATE_ACTION, KEY_BLOCK_ACTION] { + let (status, text) = delegate_caps(&fixture, &[(resource.as_str(), ability)]).await?; + assert_eq!(status == Status::Ok, valid, "{prefix} {ability}: {text}"); + admitted += usize::from(valid); + } + } + assert_eq!( + stored_delegations(&fixture).await?, + before + admitted as u64 + ); + + // A key with fewer than three segments under `apps/` is refused as a + // grant and as a decrypt target. + for path in ["apps", "apps/foo"] { + let resource = key_uri(&network, path); + let (status, text) = + delegate_caps(&fixture, &[(resource.as_str(), DECRYPT_ACTION)]).await?; + assert_ne!(status, Status::Ok, "{path}: {text}"); + let case = DecryptCase::wrapped_to(&root_public_key(&fixture, &network).await); + let (status, text) = invoke_named(&fixture, &network, path, &case).await; + assert_eq!(status, Status::Forbidden, "{path}: {text}"); + } + // A prefix is never an invocation target, whatever its depth. + let case = DecryptCase::wrapped_to(&root_public_key(&fixture, &network).await); + let (body, facts) = decrypt_request(&fixture, &network, &case, Some("apps/foo/x")); + let prefix = format!("{network}/apps/foo/"); + let auth = invocation( + &fixture, + &fixture.node_did, + &[(prefix.as_str(), DECRYPT_ACTION)], + vec![facts], + )?; + let (status, text) = invoke(&fixture, &auth, &body).await; + assert_eq!(status, Status::Forbidden, "{text}"); + Ok(()) +} + +#[tokio::test] +async fn public_get_under_apps_is_one_uniform_not_found() -> Result<()> { + let (fixture, network) = setup().await?; + created_key(&fixture, &network, "apps/foo/active").await; + created_key(&fixture, &network, "apps/foo/blocked").await; + let (status, text) = key_action(&fixture, &network, "apps/foo/blocked", "block").await?; + assert_eq!(status, Status::Ok, "{text}"); + created_key(&fixture, &network, "apps/foo/gone").await; + force_key_row(&fixture, &network, "apps/foo/gone", "deleted", None).await; + let other_owner = other_owner_network(&fixture).await?; + let absent = NetworkId::new(owner_did(&fixture), "absent")?.to_string(); + + let mut seen = Vec::new(); + for (net, path) in [ + (&network, "apps/foo/active"), + (&network, "apps/foo/blocked"), + (&network, "apps/foo/gone"), + (&network, "apps/foo/missing"), + (&network, "apps/foo/deep/er/missing"), + (&network, "apps/nobody/missing"), + (&other_owner, "apps/foo/active"), + (&other_owner, "apps/foo/missing"), + (&absent, "apps/foo/missing"), + ] { + seen.push(get_full(&fixture, &key_url(net, path)).await); + let (status, _, body) = seen.last().unwrap(); + assert_eq!(*status, Status::NotFound, "{net} {path}: {body}"); + } + assert!( + seen.windows(2).all(|pair| pair[0] == pair[1]), + "answers differ: {seen:?}" + ); + assert!(seen[0].2.starts_with("key_not_found"), "{}", seen[0].2); + assert!(!seen[0].2.contains("foo") && !seen[0].2.contains(&network)); + + // Outside `apps/` a missing key has a derived public key. + let (status, text) = public_get(&fixture, &network, "docs/never-created").await; + assert_eq!(status, Status::Ok, "{text}"); + created_key(&fixture, &network, "appsx/y").await; + let (status, text) = public_get(&fixture, &network, "appsx/y").await; + assert_eq!(status, Status::Ok, "{text}"); + Ok(()) +} + +#[tokio::test] +async fn describe_returns_app_keys_with_their_kind_and_state() -> Result<()> { + let (fixture, network) = setup().await?; + let (status, text) = create(&fixture, &network, "apps/foo/x", true).await?; + assert_eq!(status, Status::Ok, "{text}"); + let created: Value = serde_json::from_str(&text)?; + let auth = describe_auth(&fixture, &network, "apps/foo/x", DECRYPT_ACTION)?; + let (status, text) = describe_get(&fixture, &network, "apps/foo/x", &auth).await; + assert_eq!(status, Status::Ok, "{text}"); + let described: Value = serde_json::from_str(&text)?; + assert_eq!(described, created); + assert_eq!(described["kind"], "deletable"); + + force_key_row(&fixture, &network, "apps/foo/x", "deleted", None).await; + let auth = describe_auth(&fixture, &network, "apps/foo/x", DECRYPT_ACTION)?; + let (status, text) = describe_get(&fixture, &network, "apps/foo/x", &auth).await; + assert_eq!(status, Status::Ok, "{text}"); + assert_eq!(state_of(&text)?, "deleted"); + Ok(()) +} + +#[tokio::test] +async fn deleted_app_key_is_refused_by_block_unblock_and_decrypt_and_reinstalled_by_create( +) -> Result<()> { + let (fixture, network) = setup().await?; + let key = "apps/foo/x"; + let old_public = created_key(&fixture, &network, key).await; + let old_case = DecryptCase::wrapped_to(&old_public); + let (status, text) = invoke_named(&fixture, &network, key, &old_case).await; + assert_eq!(status, Status::Ok, "decrypt before delete: {text}"); + let value: Value = serde_json::from_str(&text)?; + assert_eq!(value["keyVersion"], 1); + + force_key_row(&fixture, &network, key, "deleted", None).await; + for action in ["block", "unblock"] { + let (status, text) = key_action(&fixture, &network, key, action).await?; + assert_eq!(status, Status::Forbidden, "{action}: {text}"); + assert!(text.starts_with("key_deleted"), "{action}: {text}"); + } + let row = stored_key_row(&fixture, &network, key).await.unwrap(); + assert_eq!(row.state, "deleted"); + assert!(row.sealed_seed.is_none()); + let (status, text) = invoke_named(&fixture, &network, key, &old_case).await; + assert_eq!(status, Status::Forbidden, "{text}"); + assert!(text.starts_with("key_deleted"), "{text}"); + + // Create reuses the row with a new seed and the next version. + let (status, text) = create(&fixture, &network, key, true).await?; + assert_eq!(status, Status::Ok, "{text}"); + let value: Value = serde_json::from_str(&text)?; + assert_eq!( + (value["state"].as_str(), value["keyVersion"].as_i64()), + (Some("active"), Some(2)) + ); + let new_public = described_public_key(&fixture, &network, key).await; + assert_ne!(new_public, old_public); + assert!(stored_key_row(&fixture, &network, key) + .await + .unwrap() + .sealed_seed + .is_some()); + + // Ciphertext for the earlier key is refused at the earlier version and + // cannot be opened at the current one. + let (status, text) = invoke_named_at_version(&fixture, &network, key, &old_case, 1).await; + assert_eq!(status, Status::Forbidden, "{text}"); + assert!(text.starts_with("key_deleted"), "{text}"); + let old_case = DecryptCase::wrapped_to(&old_public); + let (status, text) = invoke_named_at_version(&fixture, &network, key, &old_case, 2).await; + assert_eq!(status, Status::Forbidden, "{text}"); + assert!(text.starts_with("key_unwrap_failed"), "{text}"); + // The new key decrypts at version 2 only. + let new_case = DecryptCase::wrapped_to(&new_public); + let (status, text) = invoke_named_at_version(&fixture, &network, key, &new_case, 1).await; + assert_eq!(status, Status::Forbidden, "{text}"); + assert!(text.starts_with("key_deleted"), "{text}"); + let new_case = DecryptCase::wrapped_to(&new_public); + let (status, text) = invoke_named_at_version(&fixture, &network, key, &new_case, 2).await; + assert_eq!(status, Status::Ok, "{text}"); + assert_eq!(serde_json::from_str::(&text)?["keyVersion"], 2); + Ok(()) +} + +#[tokio::test] +async fn deleted_row_that_is_not_deletable_stays_deleted_on_create() -> Result<()> { + let (fixture, network) = setup().await?; + let (status, text) = create(&fixture, &network, "docs/a", false).await?; + assert_eq!(status, Status::Ok, "{text}"); + force_key_row(&fixture, &network, "docs/a", "deleted", None).await; + let (status, text) = create(&fixture, &network, "docs/a", false).await?; + assert_eq!(status, Status::Forbidden, "{text}"); + assert!(text.starts_with("key_deleted"), "{text}"); + let (status, text) = key_action(&fixture, &network, "docs/a", "unblock").await?; + assert_eq!(status, Status::Forbidden, "{text}"); + let row = stored_key_row(&fixture, &network, "docs/a").await.unwrap(); + assert_eq!((row.state.as_str(), row.key_version), ("deleted", 1)); + Ok(()) +} + +#[tokio::test] +async fn concurrent_creates_of_one_app_key_make_one_key() -> Result<()> { + let (fixture, network) = setup().await?; + let results = futures::future::join_all((0..4).map(|_| { + let fixture = &fixture; + let network = &network; + async move { create(fixture, network, "apps/foo/x", true).await } + })) + .await; + let statuses: Vec = results.into_iter().map(|r| r.unwrap().0).collect(); + assert_eq!( + statuses.iter().filter(|s| **s == Status::Ok).count(), + 1, + "{statuses:?}" + ); + assert!(statuses + .iter() + .all(|s| *s == Status::Ok || *s == Status::Conflict)); + let row = stored_key_row(&fixture, &network, "apps/foo/x") + .await + .unwrap(); + assert_eq!((row.state.as_str(), row.key_version), ("active", 1)); + Ok(()) +} + +#[tokio::test] +async fn concurrent_reinstall_creates_bump_the_version_once() -> Result<()> { + let (fixture, network) = setup().await?; + created_key(&fixture, &network, "apps/foo/x").await; + force_key_row(&fixture, &network, "apps/foo/x", "deleted", None).await; + let results = futures::future::join_all((0..4).map(|_| { + let fixture = &fixture; + let network = &network; + async move { create(fixture, network, "apps/foo/x", true).await } + })) + .await; + let statuses: Vec = results.into_iter().map(|r| r.unwrap().0).collect(); + assert_eq!( + statuses.iter().filter(|s| **s == Status::Ok).count(), + 1, + "{statuses:?}" + ); + let row = stored_key_row(&fixture, &network, "apps/foo/x") + .await + .unwrap(); + assert_eq!((row.state.as_str(), row.key_version), ("active", 2)); + Ok(()) +} + +#[tokio::test] +async fn concurrent_block_and_unblock_leave_a_consistent_key() -> Result<()> { + let (fixture, network) = setup().await?; + created_key(&fixture, &network, "apps/foo/x").await; + let results = futures::future::join_all((0..6).map(|i| { + let fixture = &fixture; + let network = &network; + async move { + let action = if i % 2 == 0 { "block" } else { "unblock" }; + key_action(fixture, network, "apps/foo/x", action).await + } + })) + .await; + for result in results { + let (status, text) = result?; + assert_eq!(status, Status::Ok, "{text}"); + } + let row = stored_key_row(&fixture, &network, "apps/foo/x") + .await + .unwrap(); + assert!(matches!(row.state.as_str(), "active" | "blocked")); + assert!(row.sealed_seed.is_some()); + Ok(()) +} + +/// With named keys off, app key and app prefix resources are refused like +/// every other key resource. +#[tokio::test] +async fn app_key_resources_are_refused_when_named_keys_are_off() -> Result<()> { + let (fixture, network) = setup_with(false).await?; + let before = stored_delegations(&fixture).await?; + let key = key_uri(&network, "apps/foo/x"); + let prefix = format!("{network}/apps/foo/"); + for resource in [key.as_str(), prefix.as_str()] { + for ability in [DECRYPT_ACTION, KEY_CREATE_ACTION, KEY_BLOCK_ACTION] { + let (status, text) = delegate_caps(&fixture, &[(resource, ability)]).await?; + assert_eq!(status, Status::Unauthorized, "{resource} {ability}: {text}"); + assert_eq!(text, NAMED_KEYS_UNAVAILABLE); + } + } + assert_eq!(stored_delegations(&fixture).await?, before); + let case = DecryptCase::wrapped_to(&root_public_key(&fixture, &network).await); + let (status, text) = invoke_named(&fixture, &network, "apps/foo/x", &case).await; + assert_eq!(status, Status::Forbidden, "{text}"); + assert_eq!(text, NAMED_KEYS_UNAVAILABLE); + Ok(()) +} + +#[tokio::test] +async fn invoke_refuses_a_blocked_missing_or_revoked_app_key() -> Result<()> { + let (fixture, network) = setup().await?; + let key = "apps/foo/x"; + let key_public = created_key(&fixture, &network, key).await; + let (status, text) = invoke_named( + &fixture, + &network, + key, + &DecryptCase::wrapped_to(&key_public), + ) + .await; + assert_eq!(status, Status::Ok, "active: {text}"); + + let (status, text) = key_action(&fixture, &network, key, "block").await?; + assert_eq!(status, Status::Ok, "block: {text}"); + let (status, text) = invoke_named( + &fixture, + &network, + key, + &DecryptCase::wrapped_to(&key_public), + ) + .await; + assert_eq!(status, Status::Forbidden, "{text}"); + assert!(text.starts_with("key_blocked"), "{text}"); + assert!(!text.contains("wrappedKey"), "{text}"); + let (status, text) = key_action(&fixture, &network, key, "unblock").await?; + assert_eq!(status, Status::Ok, "unblock: {text}"); + + let (status, text) = invoke_named( + &fixture, + &network, + "apps/foo/never-created", + &DecryptCase::wrapped_to(&key_public), + ) + .await; + assert_eq!(status, Status::Forbidden, "{text}"); + assert!(text.starts_with("key_not_found"), "{text}"); + + let service = fixture + .client + .rocket() + .state::() + .expect("encryption service is managed"); + let id: NetworkId = network.parse()?; + service.revoke_network(&id).await.expect("revoke network"); + let (status, text) = invoke_named( + &fixture, + &network, + key, + &DecryptCase::wrapped_to(&key_public), + ) + .await; + assert_eq!(status, Status::Forbidden, "{text}"); + assert!(!text.contains("wrappedKey"), "{text}"); + let (status, text) = create(&fixture, &network, "apps/foo/y", true).await?; + assert_eq!(status, Status::Conflict, "create after revoke: {text}"); + Ok(()) +} + +#[tokio::test] +async fn invoke_refuses_an_app_key_path_that_differs_from_the_capability() -> Result<()> { + let (fixture, network) = setup().await?; + created_key(&fixture, &network, "apps/foo/a").await; + let b_public = created_key(&fixture, &network, "apps/foo/b").await; + let case = DecryptCase::wrapped_to(&b_public); + let (body, facts) = decrypt_request(&fixture, &network, &case, Some("apps/foo/b")); + let resource = key_uri(&network, "apps/foo/a"); + let auth = invocation( + &fixture, + &fixture.node_did, + &[(resource.as_str(), DECRYPT_ACTION)], + vec![facts], + )?; + let (status, text) = invoke(&fixture, &auth, &body).await; + assert_eq!(status, Status::Forbidden, "{text}"); + assert!(!text.contains("wrappedKey"), "{text}"); + assert!(text.contains("key_path_invalid"), "{text}"); + Ok(()) +} + +#[tokio::test] +async fn bare_network_grant_cannot_decrypt_an_app_key() -> Result<()> { + let (fixture, network) = setup().await?; + let key_public = created_key(&fixture, &network, "apps/foo/x").await; + let case = DecryptCase::wrapped_to(&key_public); + let (body, facts) = decrypt_request(&fixture, &network, &case, Some("apps/foo/x")); + let auth = invocation( + &fixture, + &fixture.node_did, + &[(network.as_str(), DECRYPT_ACTION)], + vec![facts.clone()], + )?; + let (status, text) = invoke(&fixture, &auth, &body).await; + assert_eq!(status, Status::Forbidden, "{text}"); + assert!(!text.contains("wrappedKey"), "{text}"); + let auth = invocation( + &fixture, + &fixture.node_did, + &[(network.as_str(), DIRECT_DECRYPT_ABILITY)], + vec![facts], + )?; + let (status, text) = post_json(&fixture.client, &decrypt_url(&network), &auth, &body).await; + assert_eq!(status, Status::BadRequest, "{text}"); + assert!(text.starts_with("key_path_invalid: "), "{text}"); + Ok(()) +} + +#[tokio::test] +async fn prefix_grant_decrypts_deterministic_keys_below_it_but_not_a_sibling() -> Result<()> { + let (fixture, network) = setup().await?; + let inside = created_key(&fixture, &network, "docs/foo/x").await; + let deep = created_key(&fixture, &network, "docs/foo/deep/y").await; + let sibling = created_key(&fixture, &network, "docs/foobar/x").await; + let prefix = format!("{network}/docs/foo/"); + let proof = grant_owner_to_device( + &fixture, + "docs-foo-prefix", + &[(prefix.as_str(), DECRYPT_ACTION)], + ) + .await?; + for (path, public) in [("docs/foo/x", &inside), ("docs/foo/deep/y", &deep)] { + let (status, text) = device_decrypt( + &fixture, + proof, + &network, + path, + &DecryptCase::wrapped_to(public), + ) + .await; + assert_eq!(status, Status::Ok, "{path}: {text}"); + } + let (status, text) = device_decrypt( + &fixture, + proof, + &network, + "docs/foobar/x", + &DecryptCase::wrapped_to(&sibling), + ) + .await; + assert_eq!(status, Status::Forbidden, "{text}"); + Ok(()) +}