From bbfa840c0a2cf5342c62aac5db069d7da3cca75e Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:20:39 +0000 Subject: [PATCH 01/29] encryption: verify owner-signed key-delete requests and add an append-only deletion ledger Owner delete credentials (UCAN for did:key owners, SIWE CACAO for did:pkh owners) are verified by the delete path itself, never stored as delegations, with a one-time (owner, nonce) pair and one lifetime rule. The ledger records each deleted key outside the database and can be replayed onto a database. Details: TC-1777 --- .../src/encryption_network/delete_auth.rs | 442 ++++++ .../encryption_network/delete_auth_tests.rs | 1186 +++++++++++++++++ .../src/encryption_network/deletion_ledger.rs | 254 ++++ .../deletion_ledger_tests.rs | 247 ++++ tinycloud-core/src/encryption_network/mod.rs | 11 +- .../src/encryption_network/protocol.rs | 1 + .../src/encryption_network/service.rs | 30 +- tinycloud-core/src/models/delegation.rs | 33 + .../tests/fixtures/app-id-vectors.json | 375 ++++++ 9 files changed, 2575 insertions(+), 4 deletions(-) create mode 100644 tinycloud-core/src/encryption_network/delete_auth.rs create mode 100644 tinycloud-core/src/encryption_network/delete_auth_tests.rs create mode 100644 tinycloud-core/src/encryption_network/deletion_ledger.rs create mode 100644 tinycloud-core/src/encryption_network/deletion_ledger_tests.rs create mode 100644 tinycloud-core/tests/fixtures/app-id-vectors.json diff --git a/tinycloud-core/src/encryption_network/delete_auth.rs b/tinycloud-core/src/encryption_network/delete_auth.rs new file mode 100644 index 00000000..d0b826d6 --- /dev/null +++ b/tinycloud-core/src/encryption_network/delete_auth.rs @@ -0,0 +1,442 @@ +//! Owner-signed authorization for `tinycloud.encryption/key.delete`. +//! +//! `key.delete` is never a delegation. The delete route verifies the owner's +//! own credential itself and stores nothing in the delegation graph: +//! +//! - a `did:key` owner signs a UCAN invocation: invoker == owner, no proofs, +//! exactly one capability (`key.delete` on the byte-equal key or prefix URN), +//! `key-admin/v1` facts naming the node, network, body `keyPath` (a prefix +//! keeps its trailing `/`), action and body hash; +//! - a `did:pkh:eip155` owner signs a SIWE CACAO whose ReCap is exactly +//! `{ : ["tinycloud.encryption/key.delete"] }`. A CACAO +//! has no facts, so the request is bound through the signed message itself: +//! the ReCap URN equals the URN the URL network id and the body `keyPath` +//! name, the statement states the target, and `uri` names this node. +//! +//! Both forms share one lifetime rule and a one-time `(owner, nonce)` pair. + +use serde::de::{Deserialize, Deserializer, Error as _}; +use serde_json::Value; +use time::OffsetDateTime; +use tinycloud_auth::{ + authorization::{HeaderEncode, TinyCloudDelegation}, + cacaos::{ + siwe::{encode_eip55, Message}, + siwe_cacao::SiweCacao, + }, + siwe_recap::{Capability as Recap, VerificationError, RESOURCE_PREFIX}, +}; + +use crate::{did_resolution::verify_ucan_signature, hash::hash, util::InvocationInfo}; + +use super::{ + canonical::canonical_hash, + key_id::{self, EncryptionResource, KeyLocation}, + network_id::NetworkId, + protocol::{KeyAdminFacts, KEY_ADMIN_TYPE, KEY_DELETE_ACTION}, + service::{EncryptionService, EncryptionServiceError}, +}; + +/// The SIWE `uri` of an owner delete names the node it is for. +pub const KEY_DELETE_URI_PREFIX: &str = "urn:tinycloud:encryption:key-delete:"; +/// Longest `exp - iat` of an owner delete credential. +pub const MAX_LIFETIME_SECS: i64 = 300; +/// Tolerated clock skew for `iat` and `nbf`. +pub const CLOCK_SKEW_SECS: i64 = 30; +/// A consumed `(owner, nonce)` pair is kept this long after `exp`. +pub const NONCE_RETENTION_SECS: i64 = 30; +const MAX_NONCE_BYTES: usize = 256; + +fn refuse(detail: &str) -> EncryptionServiceError { + EncryptionServiceError::OwnerOnly(detail.to_string()) +} + +/// What a delete names: one key, or every key under `apps//`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeleteTarget { + location: KeyLocation, + prefix: bool, +} + +impl DeleteTarget { + /// Parses the body `keyPath`. A path without a trailing `/` is one key. A + /// path with exactly one trailing `/` is a prefix and must be + /// `apps//`: two segments, the first `apps`. Any other prefix is + /// `key_path_invalid`. + pub fn parse(network: &NetworkId, body_key_path: &str) -> Result { + let invalid = |detail: &str| EncryptionServiceError::KeyPathInvalid(detail.to_string()); + let Some(stripped) = body_key_path.strip_suffix('/') else { + let location = KeyLocation::new(network, body_key_path) + .map_err(|err| EncryptionServiceError::KeyPathInvalid(err.to_string()))?; + return Ok(Self { + location, + prefix: false, + }); + }; + let urn = format!( + "urn:tinycloud:encryption:{}:{}/{}/", + network.owner_did(), + network.name(), + stripped + ); + let Ok(EncryptionResource::KeyPrefix(location)) = key_id::parse(&urn) else { + return Err(invalid("key path is not a valid key prefix")); + }; + let mut segments = location.path().split('/'); + let app_prefix = matches!( + (segments.next(), segments.next(), segments.next()), + (Some("apps"), Some(_), None) + ); + if !app_prefix || location.path() != stripped { + return Err(invalid( + "a prefix delete names exactly apps// (two segments and one trailing slash)", + )); + } + Ok(Self { + location, + prefix: true, + }) + } + + pub fn is_prefix(&self) -> bool { + self.prefix + } + + pub fn location(&self) -> &KeyLocation { + &self.location + } + + /// The body `keyPath`: the path, plus one trailing `/` for a prefix. + pub fn key_path(&self) -> String { + if self.prefix { + format!("{}/", self.location.path()) + } else { + self.location.path().to_string() + } + } + + /// The canonical resource URN the credential must name. + pub fn urn(&self) -> String { + if self.prefix { + EncryptionResource::KeyPrefix(self.location.clone()).to_string() + } else { + EncryptionResource::Key(self.location.clone()).to_string() + } + } + + /// The SIWE statement the owner signs: the target, then the EIP-5573 + /// recap sentence for the one capability. + fn siwe_statement(&self, recap: &Recap) -> String { + let lead = if self.prefix { + format!("Delete all TinyCloud keys under {}.", self.urn()) + } else { + format!("Delete the TinyCloud key {}.", self.urn()) + }; + format!("{lead} {}", recap.to_statement()) + } +} + +/// A ReCap caveat decoder that refuses every caveat value. +struct NoCaveat; + +impl<'de> Deserialize<'de> for NoCaveat { + fn deserialize>(_: D) -> Result { + Err(D::Error::custom("delete credentials carry no caveats")) + } +} + +/// A credential that passed every check. The caller consumes `(owner, nonce)` +/// before it acts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedOwnerDelete { + pub owner: String, + pub nonce: String, + /// The credential's `exp` (unix seconds). + pub exp: i64, + /// CID of the credential bytes, the audit anchor of the delete. + pub auth_cid: String, +} + +enum Credential { + Ucan(Box), + Cacao(Box), +} + +fn decode_credential(header: &str) -> Result<(Credential, Vec), EncryptionServiceError> { + let (delegation, canonical) = TinyCloudDelegation::decode(header) + .map_err(|_| refuse("authorization is not a UCAN or a SIWE CACAO"))?; + let credential = match delegation { + TinyCloudDelegation::Ucan(ucan) => Credential::Ucan(Box::new( + InvocationInfo::try_from(*ucan) + .map_err(|_| refuse("invocation capabilities are malformed"))?, + )), + TinyCloudDelegation::Cacao(cacao) => Credential::Cacao(cacao), + }; + Ok((credential, canonical)) +} + +/// The one lifetime rule: `now < exp`; with an `iat`, `exp - iat <= 300 s` and +/// `iat <= now + 30 s`; `nbf <= now + 30 s`. A UCAN carries no `iat`, so +/// `now` stands in for it: `exp - now <= 300 s`. +fn check_lifetime( + iat: Option, + nbf: Option, + exp: i64, + now: i64, +) -> Result<(), EncryptionServiceError> { + if now >= exp { + return Err(refuse("credential has expired")); + } + let start = match iat { + Some(iat) => { + if iat > now + CLOCK_SKEW_SECS { + return Err(refuse("credential is not yet valid")); + } + if exp <= iat { + return Err(refuse("credential expires before it is issued")); + } + iat + } + None => now, + }; + if exp - start > MAX_LIFETIME_SECS { + return Err(refuse("credential lifetime exceeds 300 seconds")); + } + if nbf.is_some_and(|nbf| nbf > now + CLOCK_SKEW_SECS) { + return Err(refuse("credential is not yet valid")); + } + Ok(()) +} + +fn check_nonce(nonce: &str) -> Result<(), EncryptionServiceError> { + if nonce.is_empty() || nonce.len() > MAX_NONCE_BYTES { + return Err(refuse("credential nonce is missing or too long")); + } + Ok(()) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum OwnerKind { + DidKey, + DidPkh, +} + +fn owner_kind(owner: &str) -> Result { + if owner.starts_with("did:key:") { + Ok(OwnerKind::DidKey) + } else if owner.starts_with("did:pkh:eip155:") { + Ok(OwnerKind::DidPkh) + } else { + Err(refuse( + "the network owner's DID method does not support key deletion", + )) + } +} + +/// Verifies an owner delete credential against the request. Checks only; no +/// state changes. `now` is unix seconds. +pub async fn verify_owner_delete( + header: &str, + node_did: &str, + network: &NetworkId, + target: &DeleteTarget, + body_value: &Value, + now: i64, +) -> Result { + let owner = network.owner_did(); + let (credential, canonical) = decode_credential(header)?; + let auth_cid = hash(&canonical).to_cid(0x55).to_string(); + let (nonce, exp) = match (credential, owner_kind(owner)?) { + (Credential::Ucan(info), OwnerKind::DidKey) => { + verify_ucan(&info, node_did, network, target, body_value, now).await? + } + (Credential::Cacao(cacao), OwnerKind::DidPkh) => { + verify_cacao(&cacao, node_did, target, now).await? + } + _ => { + return Err(refuse( + "the credential type does not match the network owner's DID method", + )) + } + }; + Ok(VerifiedOwnerDelete { + owner: owner.to_string(), + nonce, + exp, + auth_cid, + }) +} + +async fn verify_ucan( + info: &InvocationInfo, + node_did: &str, + network: &NetworkId, + target: &DeleteTarget, + body_value: &Value, + now: i64, +) -> Result<(String, i64), EncryptionServiceError> { + verify_ucan_signature(&info.invocation) + .await + .map_err(|_| refuse("invocation signature is invalid"))?; + if info.invoker != network.owner_did() { + return Err(refuse("the invoker is not the network owner")); + } + if !info.parents.is_empty() { + return Err(refuse("a delete invocation cites no proofs")); + } + let payload = info.invocation.payload(); + if payload.audience != node_did { + return Err(refuse("the invocation audience is not this node")); + } + let [cap] = info.capabilities.as_slice() else { + return Err(refuse("a delete invocation carries exactly one capability")); + }; + if cap.ability.to_string() != KEY_DELETE_ACTION + || cap.resource.to_string() != target.urn() + || !cap.caveats.0.is_empty() + { + return Err(refuse( + "the capability is not key.delete on exactly the requested target", + )); + } + let fact = payload + .facts + .as_deref() + .unwrap_or_default() + .iter() + .find(|fact| fact.get("type").and_then(Value::as_str) == Some(KEY_ADMIN_TYPE)) + .ok_or_else(|| refuse("key-admin facts are missing"))?; + let facts: KeyAdminFacts = serde_json::from_value(fact.clone()) + .map_err(|_| refuse("key-admin facts are malformed"))?; + if facts.target_node != node_did + || &facts.network_id != network + || facts.key_path != target.key_path() + || facts.action != KEY_DELETE_ACTION + { + return Err(refuse("the key-admin facts do not match the request")); + } + if facts.body_hash != canonical_hash(body_value) { + return Err(refuse("the key-admin bodyHash does not match the body")); + } + let exp = payload.expiration.as_seconds() as i64; + let nbf = payload.not_before.map(|t| t.as_seconds() as i64); + check_lifetime(None, nbf, exp, now)?; + let nonce = payload.nonce.as_deref().unwrap_or_default(); + check_nonce(nonce)?; + Ok((nonce.to_string(), exp)) +} + +async fn verify_cacao( + cacao: &SiweCacao, + node_did: &str, + target: &DeleteTarget, + now: i64, +) -> Result<(String, i64), EncryptionServiceError> { + cacao + .verify() + .await + .map_err(|_| refuse("SIWE signature is invalid"))?; + let payload = cacao.payload(); + let message: Message = payload + .clone() + .try_into() + .map_err(|_| refuse("SIWE issuer is not a did:pkh:eip155 account"))?; + + // The signer is the network owner: the issuer DID, rebuilt from the + // signed chain id and address, equals the owner DID byte for byte. + let signer = format!( + "did:pkh:eip155:{}:0x{}", + message.chain_id, + encode_eip55(&message.address) + ); + let target_owner = target.location().owner_did(); + if signer != target_owner { + return Err(refuse("the SIWE signer is not the network owner")); + } + + // The message carries exactly one resource: the ReCap. + let [resource] = message.resources.as_slice() else { + return Err(refuse("the SIWE message carries exactly one resource")); + }; + if !resource.as_str().starts_with(RESOURCE_PREFIX) { + return Err(refuse("the SIWE resource is not a ReCap")); + } + let recap = Recap::::extract_and_verify(&message) + .map_err(|err| match err { + VerificationError::IncorrectStatement(_) => { + refuse("the SIWE statement does not state the delete") + } + VerificationError::Decoding(_) => { + refuse("the SIWE ReCap is malformed or carries caveats") + } + })? + .ok_or_else(|| refuse("the SIWE message carries no ReCap"))?; + if !recap.proof().is_empty() { + return Err(refuse("a delete ReCap cites no proofs")); + } + let mut capabilities = recap.abilities().iter(); + let (Some((urn, abilities)), None) = (capabilities.next(), capabilities.next()) else { + return Err(refuse("the ReCap names exactly one resource")); + }; + let mut names = abilities.keys(); + let (Some(ability), None) = (names.next(), names.next()) else { + return Err(refuse("the ReCap names exactly one ability")); + }; + // The ReCap URN equals the URN the URL network id and the body keyPath + // name, byte for byte, trailing `/` included. + if ability.to_string() != KEY_DELETE_ACTION || urn.as_str() != target.urn() { + return Err(refuse( + "the ReCap is not key.delete on exactly the requested target", + )); + } + if message.uri.as_str() != format!("{KEY_DELETE_URI_PREFIX}{node_did}") { + return Err(refuse("the SIWE uri does not name this node")); + } + if message.statement.as_deref() != Some(target.siwe_statement(&recap).as_str()) { + return Err(refuse("the SIWE statement does not state the delete")); + } + + let seconds = |t: &tinycloud_auth::cacaos::siwe::TimeStamp| -> i64 { + let instant: &OffsetDateTime = t.as_ref(); + instant.unix_timestamp() + }; + let iat = seconds(&message.issued_at); + let exp = message + .expiration_time + .as_ref() + .map(seconds) + .ok_or_else(|| refuse("the SIWE message has no expiration"))?; + let nbf = message.not_before.as_ref().map(seconds); + check_lifetime(Some(iat), nbf, exp, now)?; + check_nonce(&message.nonce)?; + Ok((message.nonce.clone(), exp)) +} + +impl EncryptionService { + /// Verifies an owner delete credential and consumes its `(owner, nonce)` + /// pair. The pair is consumed outside any later transaction, so a delete + /// that fails afterwards still burns the signature. It is kept until + /// `exp` plus [`NONCE_RETENTION_SECS`]. + pub async fn authorize_owner_delete( + &self, + header: &str, + network: &NetworkId, + target: &DeleteTarget, + body_value: &Value, + ) -> Result { + let now = OffsetDateTime::now_utc().unix_timestamp(); + let verified = + verify_owner_delete(header, &self.node_did, network, target, body_value, now).await?; + self.consume_nonce_until( + &verified.owner, + &verified.nonce, + verified.exp + NONCE_RETENTION_SECS, + ) + .await + .map_err(|err| match err { + EncryptionServiceError::NonceReplay => refuse("the credential nonce was already used"), + other => other, + })?; + Ok(verified) + } +} diff --git a/tinycloud-core/src/encryption_network/delete_auth_tests.rs b/tinycloud-core/src/encryption_network/delete_auth_tests.rs new file mode 100644 index 00000000..04bfb1c9 --- /dev/null +++ b/tinycloud-core/src/encryption_network/delete_auth_tests.rs @@ -0,0 +1,1186 @@ +//! Owner-signed delete authorization: every binding has a refusal test that +//! names the refusal's own detail, so a check removed from the verifier fails +//! the test of that check and not only an adjacent one. + +use std::collections::BTreeMap; + +use k256::ecdsa::SigningKey; +use sea_orm::{ConnectOptions, Database, DatabaseConnection, EntityTrait}; +use sea_orm_migration::MigratorTrait; +use serde_json::{json, Value}; +use sha3::{Digest, Keccak256}; +use time::OffsetDateTime; +use tinycloud_auth::{ + authorization::{Cid, HeaderEncode, TinyCloudDelegation}, + cacaos::{ + siwe::{encode_eip55, Message, TimeStamp}, + siwe_cacao::{Eip191, Payload as CacaoPayload, Signature}, + }, + multihash_codetable::{Code, MultihashDigest}, + resolver::DID_METHODS, + siwe_recap::Capability as Recap, + ssi::{ + claims::jwt::NumericDate, + dids::{DIDBuf, DIDURLBuf}, + jwk::{Algorithm, JWK}, + ucan::Payload as UcanPayload, + }, + ucan_capabilities_object::Capabilities, +}; + +use super::{ + canonical::canonical_hash, + delete_auth::{ + verify_owner_delete, DeleteTarget, VerifiedOwnerDelete, KEY_DELETE_URI_PREFIX, + NONCE_RETENTION_SECS, + }, + network_id::NetworkId, + protocol::{KEY_ADMIN_TYPE, KEY_BLOCK_ACTION, KEY_DELETE_ACTION}, + service::{EncryptionService, EncryptionServiceError}, +}; +use crate::{encryption::ColumnEncryption, migrations::Migrator, models::encryption_nonce}; + +const NODE: &str = "did:key:z6MkNodeTest"; +const OTHER_NODE: &str = "did:key:z6MkOtherNode"; + +// ------------------------------------------------------------------ wallet + +struct Wallet { + key: SigningKey, + address: [u8; 20], +} + +impl Wallet { + fn new(seed: u8) -> Self { + let key = SigningKey::from_bytes(&[seed; 32].into()).unwrap(); + let public = key.verifying_key().to_encoded_point(false); + let address = Keccak256::digest(&public.as_bytes()[1..])[12..] + .try_into() + .unwrap(); + Self { key, address } + } + + fn did(&self) -> String { + format!("did:pkh:eip155:1:0x{}", encode_eip55(&self.address)) + } + + fn sign(&self, message: &Message) -> Signature { + let (signature, recovery) = self + .key + .sign_prehash_recoverable(&message.eip191_hash().unwrap()) + .unwrap(); + let mut bytes = [0u8; 65]; + bytes[..64].copy_from_slice(signature.to_bytes().as_ref()); + bytes[64] = u8::from(recovery) + 27; + Signature::from(bytes) + } +} + +fn timestamp(unix: i64) -> TimeStamp { + TimeStamp::from(OffsetDateTime::from_unix_timestamp(unix).unwrap()) +} + +fn proof_cid() -> Cid { + Cid::new_v1(0x55, Code::Sha2_256.digest(b"parent")) +} + +/// Everything a SIWE owner delete is made of. `valid` yields an accepted +/// credential; a test changes the one field it exercises. +struct CacaoSpec<'a> { + signer: &'a Wallet, + /// The address written into the message, when it is not the signer's. + claimed: Option<&'a Wallet>, + recap: Vec<(String, Vec<&'static str>)>, + caveat: bool, + proof: bool, + /// The statement the message carries. `None` derives it from the target. + statement: Option, + uri: String, + resources_before: Vec, + resources_after: Vec, + iat: i64, + exp: Option, + nbf: Option, + nonce: String, +} + +impl<'a> CacaoSpec<'a> { + fn valid(signer: &'a Wallet, target: &DeleteTarget, now: i64) -> Self { + Self { + signer, + claimed: None, + recap: vec![(target.urn(), vec![KEY_DELETE_ACTION])], + caveat: false, + proof: false, + statement: None, + uri: format!("{KEY_DELETE_URI_PREFIX}{NODE}"), + resources_before: vec![], + resources_after: vec![], + iat: now, + exp: Some(now + 300), + nbf: None, + nonce: "nonce-0001".to_string(), + } + } + + fn header(&self, target: &DeleteTarget) -> String { + let mut recap = Recap::::default(); + for (urn, abilities) in &self.recap { + for ability in abilities { + let nb = if self.caveat { + vec![BTreeMap::from([("x".to_string(), json!(1))])] + } else { + vec![] + }; + recap + .with_action_convert(urn.as_str(), *ability, nb) + .unwrap(); + } + } + if self.proof { + recap = recap.with_proof(&proof_cid()); + } + let lead = if target.is_prefix() { + format!("Delete all TinyCloud keys under {}.", target.urn()) + } else { + format!("Delete the TinyCloud key {}.", target.urn()) + }; + let address = self.claimed.unwrap_or(self.signer).address; + let mut message = recap + .build_message(Message { + scheme: None, + domain: "example.com".parse().unwrap(), + address, + statement: Some(lead), + uri: self.uri.parse().unwrap(), + version: tinycloud_auth::cacaos::siwe::Version::V1, + chain_id: 1, + nonce: self.nonce.clone(), + issued_at: timestamp(self.iat), + expiration_time: self.exp.map(timestamp), + not_before: self.nbf.map(timestamp), + request_id: None, + resources: self + .resources_before + .iter() + .map(|r| r.parse().unwrap()) + .collect(), + }) + .unwrap(); + message + .resources + .extend(self.resources_after.iter().map(|r| r.parse().unwrap())); + if let Some(statement) = &self.statement { + message.statement = Some(statement.clone()); + } + let signature = self.signer.sign(&message); + let cacao = CacaoPayload::from(message).sign::(signature); + TinyCloudDelegation::Cacao(Box::new(cacao)) + .encode() + .unwrap() + } +} + +// -------------------------------------------------------------------- ucan + +struct UcanSpec<'a> { + signer: &'a JWK, + /// The issuer DID URL the invocation claims. + issuer_vm: String, + audience: String, + caps: Vec<(String, String)>, + caveat: bool, + facts: Vec, + proof: Vec, + exp: i64, + nbf: Option, + nonce: Option, +} + +fn did_key() -> (JWK, String, String) { + let mut jwk = JWK::generate_ed25519().unwrap(); + jwk.algorithm = Some(Algorithm::EdDSA); + let mut vm = DID_METHODS.generate(&jwk, "key").unwrap().to_string(); + let did = vm.clone(); + let fragment = vm.rsplit_once(':').unwrap().1.to_string(); + vm.push('#'); + vm.push_str(&fragment); + (jwk, did, vm) +} + +fn admin_facts(network: &NetworkId, target: &DeleteTarget, body: &Value) -> Value { + json!({ + "type": KEY_ADMIN_TYPE, + "targetNode": NODE, + "networkId": network.to_string(), + "keyPath": target.key_path(), + "action": KEY_DELETE_ACTION, + "bodyHash": canonical_hash(body), + }) +} + +impl<'a> UcanSpec<'a> { + fn valid( + signer: &'a JWK, + vm: &str, + network: &NetworkId, + target: &DeleteTarget, + body: &Value, + now: i64, + ) -> Self { + Self { + signer, + issuer_vm: vm.to_string(), + audience: NODE.to_string(), + caps: vec![(target.urn(), KEY_DELETE_ACTION.to_string())], + caveat: false, + facts: vec![admin_facts(network, target, body)], + proof: vec![], + exp: now + 300, + nbf: None, + nonce: Some("urn:uuid:nonce-0001".to_string()), + } + } + + fn header(&self) -> String { + let mut capabilities = Capabilities::new(); + for (uri, ability) in &self.caps { + let nb = if self.caveat { + BTreeMap::from([("x".to_string(), json!(1))]) + } else { + BTreeMap::new() + }; + capabilities.with_action( + uri.parse().unwrap(), + ability + .parse::() + .unwrap(), + [nb], + ); + } + UcanPayload { + issuer: self.issuer_vm.parse::().unwrap(), + audience: self.audience.parse::().unwrap(), + not_before: self + .nbf + .map(|t| NumericDate::try_from_seconds(t as f64).unwrap()), + expiration: NumericDate::try_from_seconds(self.exp as f64).unwrap(), + nonce: self.nonce.clone(), + facts: Some(self.facts.clone()), + proof: self.proof.clone(), + attenuation: capabilities, + } + .sign(Algorithm::EdDSA, self.signer) + .unwrap() + .encode() + .unwrap() + } +} + +// ------------------------------------------------------------------ helpers + +fn now() -> i64 { + OffsetDateTime::now_utc().unix_timestamp() +} + +fn pkh_network(wallet: &Wallet) -> NetworkId { + NetworkId::new(wallet.did(), "default".to_string()).unwrap() +} + +fn prefix_target(network: &NetworkId, id: &str) -> DeleteTarget { + DeleteTarget::parse(network, &format!("apps/{id}/")).unwrap() +} + +fn key_target(network: &NetworkId, path: &str) -> DeleteTarget { + DeleteTarget::parse(network, path).unwrap() +} + +fn body_for(target: &DeleteTarget) -> Value { + json!({ "keyPath": target.key_path() }) +} + +async fn verify( + header: &str, + network: &NetworkId, + target: &DeleteTarget, + body: &Value, + at: i64, +) -> Result { + verify_owner_delete(header, NODE, network, target, body, at).await +} + +/// Asserts `OwnerOnly` whose detail contains `fragment`. +fn assert_refused(result: Result, fragment: &str) { + match result { + Err(EncryptionServiceError::OwnerOnly(detail)) => assert!( + detail.contains(fragment), + "refused for another reason: {detail} (wanted {fragment})" + ), + other => panic!("expected owner_only containing {fragment:?}, got {other:?}"), + } +} + +// ------------------------------------------------------------------ targets + +#[test] +fn delete_target_parses_one_key_and_exact_app_prefixes_only() { + let wallet = Wallet::new(1); + let network = pkh_network(&wallet); + let key = DeleteTarget::parse(&network, "secrets/x").unwrap(); + assert!(!key.is_prefix()); + assert_eq!(key.key_path(), "secrets/x"); + assert!(key.urn().ends_with(":default/secrets/x")); + + let prefix = DeleteTarget::parse(&network, "apps/foo/").unwrap(); + assert!(prefix.is_prefix()); + assert_eq!(prefix.key_path(), "apps/foo/"); + assert!(prefix.urn().ends_with(":default/apps/foo/")); + + for refused in [ + "apps/", + "apps//", + "apps/foo/x/", + "apps/foo//", + "secrets/", + "secrets/x/", + "appsx/y/", + "apps.x/y/", + "apps/Foo/", + "/", + "", + "apps/foo:bar/", + ] { + assert!( + matches!( + DeleteTarget::parse(&network, refused), + Err(EncryptionServiceError::KeyPathInvalid(_)) + ), + "{refused:?} must be refused" + ); + } +} + +#[test] +fn delete_prefix_follows_the_shared_vectors() { + #[derive(serde::Deserialize)] + struct Vectors { + #[serde(rename = "appIdValid")] + app_ids: Vec, + #[serde(rename = "deletePrefix")] + delete_prefix: Vec, + } + #[derive(serde::Deserialize)] + struct AppId { + id: String, + valid: bool, + } + #[derive(serde::Deserialize)] + struct DeletePrefix { + prefix: String, + accepted: bool, + } + let vectors: Vectors = + serde_json::from_str(include_str!("../../tests/fixtures/app-id-vectors.json")).unwrap(); + let network = pkh_network(&Wallet::new(1)); + for case in vectors.delete_prefix { + // A path without a trailing slash is a key delete, not a prefix one. + if !case.prefix.ends_with('/') { + continue; + } + assert_eq!( + DeleteTarget::parse(&network, &case.prefix).is_ok(), + case.accepted, + "{:?}", + case.prefix + ); + } + for case in vectors.app_ids { + let parsed = DeleteTarget::parse(&network, &format!("apps/{}/", case.id)); + assert_eq!(parsed.is_ok(), case.valid, "app id {:?}", case.id); + } +} + +// ----------------------------------------------------------- CACAO: accepted + +#[tokio::test] +async fn cacao_owner_prefix_delete_is_accepted() { + let wallet = Wallet::new(1); + let network = pkh_network(&wallet); + let target = prefix_target(&network, "foo"); + let at = now(); + let header = CacaoSpec::valid(&wallet, &target, at).header(&target); + let verified = verify(&header, &network, &target, &body_for(&target), at) + .await + .unwrap(); + assert_eq!(verified.owner, wallet.did()); + assert_eq!(verified.nonce, "nonce-0001"); + assert_eq!(verified.exp, at + 300); + assert!(verified.auth_cid.starts_with("bafk")); +} + +#[tokio::test] +async fn cacao_owner_key_delete_is_accepted() { + let wallet = Wallet::new(1); + let network = pkh_network(&wallet); + let target = key_target(&network, "apps/foo/token"); + let at = now(); + let header = CacaoSpec::valid(&wallet, &target, at).header(&target); + verify(&header, &network, &target, &body_for(&target), at) + .await + .unwrap(); +} + +// ---------------------------------------------------------- CACAO: refusals + +#[tokio::test] +async fn cacao_signed_by_another_wallet_is_refused() { + let owner = Wallet::new(1); + let other = Wallet::new(2); + let network = pkh_network(&owner); + let target = prefix_target(&network, "foo"); + let at = now(); + // Another wallet signs and states its own address: valid signature, wrong signer. + let header = CacaoSpec::valid(&other, &target, at).header(&target); + assert_refused( + verify(&header, &network, &target, &body_for(&target), at).await, + "signer is not the network owner", + ); +} + +#[tokio::test] +async fn cacao_with_the_owner_address_and_another_signature_is_refused() { + let owner = Wallet::new(1); + let other = Wallet::new(2); + let network = pkh_network(&owner); + let target = prefix_target(&network, "foo"); + let at = now(); + let mut spec = CacaoSpec::valid(&other, &target, at); + spec.claimed = Some(&owner); + assert_refused( + verify( + &spec.header(&target), + &network, + &target, + &body_for(&target), + at, + ) + .await, + "SIWE signature is invalid", + ); +} + +#[tokio::test] +async fn cacao_for_another_prefix_is_refused() { + let owner = Wallet::new(1); + let network = pkh_network(&owner); + let target = prefix_target(&network, "foo"); + let at = now(); + for (urn, label) in [ + (prefix_target(&network, "bar").urn(), "sibling prefix"), + (prefix_target(&network, "foo.bar").urn(), "longer id"), + (key_target(&network, "apps/foo/x").urn(), "a key under it"), + // The same path without its trailing slash names a key. + ( + target.urn().strip_suffix('/').unwrap().to_string(), + "no trailing slash", + ), + ] { + let mut spec = CacaoSpec::valid(&owner, &target, at); + spec.recap = vec![(urn, vec![KEY_DELETE_ACTION])]; + let result = verify( + &spec.header(&target), + &network, + &target, + &body_for(&target), + at, + ) + .await; + assert!( + matches!(&result, Err(EncryptionServiceError::OwnerOnly(_))), + "{label}: {result:?}" + ); + } +} + +#[tokio::test] +async fn cacao_recap_must_equal_the_url_network_and_body_path() { + let owner = Wallet::new(1); + let network = pkh_network(&owner); + let other_network = NetworkId::new(owner.did(), "other".to_string()).unwrap(); + let signed_target = prefix_target(&network, "foo"); + let at = now(); + let header = CacaoSpec::valid(&owner, &signed_target, at).header(&signed_target); + + // Same credential, URL names another network. + let url_target = prefix_target(&other_network, "foo"); + assert_refused( + verify( + &header, + &other_network, + &url_target, + &body_for(&url_target), + at, + ) + .await, + "ReCap is not key.delete on exactly the requested target", + ); + // Same credential, body names another prefix. + let body_target = prefix_target(&network, "bar"); + assert_refused( + verify(&header, &network, &body_target, &body_for(&body_target), at).await, + "ReCap is not key.delete on exactly the requested target", + ); + // Same credential, body names a key. + let key = key_target(&network, "apps/foo/x"); + assert_refused( + verify(&header, &network, &key, &body_for(&key), at).await, + "ReCap is not key.delete on exactly the requested target", + ); +} + +#[tokio::test] +async fn cacao_ability_and_recap_shape_are_exact() { + let owner = Wallet::new(1); + let network = pkh_network(&owner); + let target = prefix_target(&network, "foo"); + let at = now(); + let body = body_for(&target); + + let mut spec = CacaoSpec::valid(&owner, &target, at); + spec.recap = vec![(target.urn(), vec![KEY_BLOCK_ACTION])]; + assert_refused( + verify(&spec.header(&target), &network, &target, &body, at).await, + "not key.delete on exactly the requested target", + ); + + let mut spec = CacaoSpec::valid(&owner, &target, at); + spec.recap = vec![(target.urn(), vec![KEY_DELETE_ACTION, KEY_BLOCK_ACTION])]; + assert_refused( + verify(&spec.header(&target), &network, &target, &body, at).await, + "exactly one ability", + ); + + let mut spec = CacaoSpec::valid(&owner, &target, at); + spec.recap.push(( + prefix_target(&network, "bar").urn(), + vec![KEY_DELETE_ACTION], + )); + assert_refused( + verify(&spec.header(&target), &network, &target, &body, at).await, + "exactly one resource", + ); + + let mut spec = CacaoSpec::valid(&owner, &target, at); + spec.caveat = true; + assert_refused( + verify(&spec.header(&target), &network, &target, &body, at).await, + "carries caveats", + ); + + let mut spec = CacaoSpec::valid(&owner, &target, at); + spec.proof = true; + assert_refused( + verify(&spec.header(&target), &network, &target, &body, at).await, + "cites no proofs", + ); +} + +#[tokio::test] +async fn cacao_resources_are_the_recap_only() { + let owner = Wallet::new(1); + let network = pkh_network(&owner); + let target = prefix_target(&network, "foo"); + let at = now(); + let body = body_for(&target); + + let mut spec = CacaoSpec::valid(&owner, &target, at); + spec.resources_before = vec!["https://example.com/".to_string()]; + assert_refused( + verify(&spec.header(&target), &network, &target, &body, at).await, + "exactly one resource", + ); + + let mut spec = CacaoSpec::valid(&owner, &target, at); + spec.resources_after = vec!["https://example.com/".to_string()]; + assert_refused( + verify(&spec.header(&target), &network, &target, &body, at).await, + "exactly one resource", + ); + + // No ReCap at all: only a plain resource. + let mut spec = CacaoSpec::valid(&owner, &target, at); + spec.recap = vec![]; + spec.resources_before = vec!["https://example.com/".to_string()]; + assert_refused( + verify(&spec.header(&target), &network, &target, &body, at).await, + "not a ReCap", + ); +} + +#[tokio::test] +async fn cacao_uri_must_name_this_node() { + let owner = Wallet::new(1); + let network = pkh_network(&owner); + let target = prefix_target(&network, "foo"); + let at = now(); + for uri in [ + format!("{KEY_DELETE_URI_PREFIX}{OTHER_NODE}"), + format!("urn:tinycloud:encryption:{NODE}"), + NODE.to_string(), + ] { + let mut spec = CacaoSpec::valid(&owner, &target, at); + spec.uri = uri.clone(); + assert_refused( + verify( + &spec.header(&target), + &network, + &target, + &body_for(&target), + at, + ) + .await, + "uri does not name this node", + ); + } +} + +#[tokio::test] +async fn cacao_statement_must_state_the_delete() { + let owner = Wallet::new(1); + let network = pkh_network(&owner); + let target = prefix_target(&network, "foo"); + let at = now(); + let recap_text = { + // The honest statement, to derive wrong ones from. + let spec = CacaoSpec::valid(&owner, &target, at); + let header = spec.header(&target); + let (TinyCloudDelegation::Cacao(cacao), _) = TinyCloudDelegation::decode(&header).unwrap() + else { + panic!("cacao") + }; + cacao.payload().statement.clone().unwrap() + }; + let lead = format!("Delete all TinyCloud keys under {}.", target.urn()); + let recap_sentence = recap_text + .strip_prefix(&format!("{lead} ")) + .unwrap() + .to_string(); + for statement in [ + // Another prefix in the lead sentence. + format!( + "Delete all TinyCloud keys under {}. {recap_sentence}", + prefix_target(&network, "bar").urn() + ), + // The per-key wording on a prefix. + format!( + "Delete the TinyCloud key {}. {recap_sentence}", + target.urn() + ), + // No lead sentence. + recap_sentence.clone(), + // Trailing text. + format!("{recap_text} Also something else."), + // Doubled separator. + format!("{lead} {recap_sentence}"), + ] { + let mut spec = CacaoSpec::valid(&owner, &target, at); + spec.statement = Some(statement.clone()); + assert_refused( + verify( + &spec.header(&target), + &network, + &target, + &body_for(&target), + at, + ) + .await, + "statement", + ); + } +} + +#[tokio::test] +async fn cacao_lifetime_follows_the_one_rule() { + let owner = Wallet::new(1); + let network = pkh_network(&owner); + let target = prefix_target(&network, "foo"); + let at = now(); + let body = body_for(&target); + let run = |mutate: &dyn Fn(&mut CacaoSpec)| { + let mut spec = CacaoSpec::valid(&owner, &target, at); + mutate(&mut spec); + spec.header(&target) + }; + + // exp - iat = 300 is the longest accepted lifetime. + let header = run(&|s| { + s.iat = at; + s.exp = Some(at + 300); + }); + verify(&header, &network, &target, &body, at).await.unwrap(); + // 301 is refused. + let header = run(&|s| s.exp = Some(at + 301)); + assert_refused( + verify(&header, &network, &target, &body, at).await, + "lifetime exceeds 300 seconds", + ); + // A long lifetime stays refused when `now` is late in it. + let header = run(&|s| { + s.iat = at - 3000; + s.exp = Some(at + 100); + }); + assert_refused( + verify(&header, &network, &target, &body, at).await, + "lifetime exceeds 300 seconds", + ); + // now < exp: at exp it is expired. + let header = run(&|s| { + s.iat = at - 100; + s.exp = Some(at); + }); + assert_refused( + verify(&header, &network, &target, &body, at).await, + "expired", + ); + // One second before exp it is accepted. + verify(&header, &network, &target, &body, at - 1) + .await + .unwrap(); + // No expiration. + let header = run(&|s| s.exp = None); + assert_refused( + verify(&header, &network, &target, &body, at).await, + "no expiration", + ); + // exp before iat. + let header = run(&|s| { + s.iat = at + 10; + s.exp = Some(at + 5); + }); + assert_refused( + verify(&header, &network, &target, &body, at).await, + "expires before it is issued", + ); + // iat up to 30 s ahead is skew; 31 s is not. + let header = run(&|s| { + s.iat = at + 30; + s.exp = Some(at + 330); + }); + verify(&header, &network, &target, &body, at).await.unwrap(); + let header = run(&|s| { + s.iat = at + 31; + s.exp = Some(at + 331); + }); + assert_refused( + verify(&header, &network, &target, &body, at).await, + "not yet valid", + ); + // nbf up to 30 s ahead is skew; 31 s is not. + let header = run(&|s| s.nbf = Some(at + 30)); + verify(&header, &network, &target, &body, at).await.unwrap(); + let header = run(&|s| s.nbf = Some(at + 31)); + assert_refused( + verify(&header, &network, &target, &body, at).await, + "not yet valid", + ); +} + +#[tokio::test] +async fn cacao_nonce_must_be_present_and_bounded() { + let owner = Wallet::new(1); + let network = pkh_network(&owner); + let target = prefix_target(&network, "foo"); + let at = now(); + for nonce in [String::new(), "n".repeat(257)] { + let mut spec = CacaoSpec::valid(&owner, &target, at); + spec.nonce = nonce; + assert_refused( + verify( + &spec.header(&target), + &network, + &target, + &body_for(&target), + at, + ) + .await, + "nonce", + ); + } +} + +// --------------------------------------------------------- UCAN: accepted + +fn did_key_network() -> (JWK, String, String, NetworkId) { + let (jwk, did, vm) = did_key(); + let network = NetworkId::new(did.clone(), "default".to_string()).unwrap(); + (jwk, did, vm, network) +} + +#[tokio::test] +async fn ucan_owner_prefix_and_key_deletes_are_accepted() { + let (jwk, did, vm, network) = did_key_network(); + let at = now(); + for target in [ + prefix_target(&network, "foo"), + key_target(&network, "apps/foo/token"), + ] { + let body = body_for(&target); + let header = UcanSpec::valid(&jwk, &vm, &network, &target, &body, at).header(); + let verified = verify(&header, &network, &target, &body, at).await.unwrap(); + assert_eq!(verified.owner, did); + assert_eq!(verified.nonce, "urn:uuid:nonce-0001"); + assert_eq!(verified.exp, at + 300); + } +} + +// --------------------------------------------------------- UCAN: refusals + +#[tokio::test] +async fn ucan_must_be_signed_by_the_owner() { + let (_, _, owner_vm, network) = did_key_network(); + let (other_jwk, _, other_vm) = did_key(); + let target = prefix_target(&network, "foo"); + let body = body_for(&target); + let at = now(); + + // Another key signs as itself: valid signature, not the owner. + let header = UcanSpec::valid(&other_jwk, &other_vm, &network, &target, &body, at).header(); + assert_refused( + verify(&header, &network, &target, &body, at).await, + "invoker is not the network owner", + ); + // Another key signs while claiming to be the owner: bad signature. + let header = UcanSpec::valid(&other_jwk, &owner_vm, &network, &target, &body, at).header(); + assert_refused( + verify(&header, &network, &target, &body, at).await, + "signature is invalid", + ); +} + +#[tokio::test] +async fn ucan_cites_no_proofs_and_one_capability() { + let (jwk, _, vm, network) = did_key_network(); + let target = prefix_target(&network, "foo"); + let body = body_for(&target); + let at = now(); + + let mut spec = UcanSpec::valid(&jwk, &vm, &network, &target, &body, at); + spec.proof = vec![proof_cid()]; + assert_refused( + verify(&spec.header(), &network, &target, &body, at).await, + "cites no proofs", + ); + + let mut spec = UcanSpec::valid(&jwk, &vm, &network, &target, &body, at); + spec.caps.push(( + prefix_target(&network, "bar").urn(), + KEY_DELETE_ACTION.to_string(), + )); + assert_refused( + verify(&spec.header(), &network, &target, &body, at).await, + "exactly one capability", + ); + + let mut spec = UcanSpec::valid(&jwk, &vm, &network, &target, &body, at); + spec.caveat = true; + assert_refused( + verify(&spec.header(), &network, &target, &body, at).await, + "not key.delete on exactly the requested target", + ); +} + +#[tokio::test] +async fn ucan_capability_names_exactly_the_requested_target() { + let (jwk, _, vm, network) = did_key_network(); + let target = prefix_target(&network, "foo"); + let body = body_for(&target); + let at = now(); + let wrong_resources = [ + prefix_target(&network, "bar").urn(), + key_target(&network, "apps/foo/x").urn(), + target.urn().strip_suffix('/').unwrap().to_string(), + network.to_string(), + ]; + for urn in wrong_resources { + let mut spec = UcanSpec::valid(&jwk, &vm, &network, &target, &body, at); + spec.caps = vec![(urn, KEY_DELETE_ACTION.to_string())]; + assert_refused( + verify(&spec.header(), &network, &target, &body, at).await, + "not key.delete on exactly the requested target", + ); + } + let mut spec = UcanSpec::valid(&jwk, &vm, &network, &target, &body, at); + spec.caps = vec![(target.urn(), KEY_BLOCK_ACTION.to_string())]; + assert_refused( + verify(&spec.header(), &network, &target, &body, at).await, + "not key.delete on exactly the requested target", + ); +} + +#[tokio::test] +async fn ucan_audience_and_facts_are_bound_to_the_request() { + let (jwk, _, vm, network) = did_key_network(); + let other_network = + NetworkId::new(network.owner_did().to_string(), "other".to_string()).unwrap(); + let target = prefix_target(&network, "foo"); + let body = body_for(&target); + let at = now(); + + let mut spec = UcanSpec::valid(&jwk, &vm, &network, &target, &body, at); + spec.audience = OTHER_NODE.to_string(); + assert_refused( + verify(&spec.header(), &network, &target, &body, at).await, + "audience is not this node", + ); + + let fact = |patch: &dyn Fn(&mut Value)| { + let mut fact = admin_facts(&network, &target, &body); + patch(&mut fact); + fact + }; + let facts: Vec<(&str, Value)> = vec![ + ("targetNode", fact(&|f| f["targetNode"] = json!(OTHER_NODE))), + ( + "networkId", + fact(&|f| f["networkId"] = json!(other_network.to_string())), + ), + // The path without its trailing slash. + ("keyPath", fact(&|f| f["keyPath"] = json!("apps/foo"))), + ("keyPath", fact(&|f| f["keyPath"] = json!("apps/bar/"))), + ("action", fact(&|f| f["action"] = json!(KEY_BLOCK_ACTION))), + ]; + for (field, fact) in facts { + let mut spec = UcanSpec::valid(&jwk, &vm, &network, &target, &body, at); + spec.facts = vec![fact]; + assert_refused( + verify(&spec.header(), &network, &target, &body, at).await, + "facts do not match the request", + ); + let _ = field; + } + + // The body hash covers the body the request sent. + let mut spec = UcanSpec::valid(&jwk, &vm, &network, &target, &body, at); + spec.facts = vec![fact(&|f| f["bodyHash"] = json!(canonical_hash(&json!({}))))]; + assert_refused( + verify(&spec.header(), &network, &target, &body, at).await, + "bodyHash", + ); + let other_body = json!({ "keyPath": "apps/foo/", "extra": true }); + assert_refused( + verify( + &UcanSpec::valid(&jwk, &vm, &network, &target, &body, at).header(), + &network, + &target, + &other_body, + at, + ) + .await, + "bodyHash", + ); + + // Missing and wrong-typed facts. + let mut spec = UcanSpec::valid(&jwk, &vm, &network, &target, &body, at); + spec.facts = vec![]; + assert_refused( + verify(&spec.header(), &network, &target, &body, at).await, + "facts are missing", + ); + let mut spec = UcanSpec::valid(&jwk, &vm, &network, &target, &body, at); + spec.facts = vec![fact(&|f| { + f["type"] = json!("tinycloud.encryption.key-describe/v1") + })]; + assert_refused( + verify(&spec.header(), &network, &target, &body, at).await, + "facts are missing", + ); +} + +#[tokio::test] +async fn ucan_lifetime_follows_the_rule() { + let (jwk, _, vm, network) = did_key_network(); + let target = prefix_target(&network, "foo"); + let body = body_for(&target); + let at = now(); + let spec = |mutate: &dyn Fn(&mut UcanSpec)| { + let mut spec = UcanSpec::valid(&jwk, &vm, &network, &target, &body, at); + mutate(&mut spec); + spec.header() + }; + + verify(&spec(&|s| s.exp = at + 300), &network, &target, &body, at) + .await + .unwrap(); + assert_refused( + verify(&spec(&|s| s.exp = at + 301), &network, &target, &body, at).await, + "lifetime exceeds 300 seconds", + ); + assert_refused( + verify(&spec(&|s| s.exp = at), &network, &target, &body, at).await, + "expired", + ); + assert_refused( + verify( + &spec(&|s| s.nbf = Some(at + 31)), + &network, + &target, + &body, + at, + ) + .await, + "not yet valid", + ); + verify( + &spec(&|s| s.nbf = Some(at + 30)), + &network, + &target, + &body, + at, + ) + .await + .unwrap(); + assert_refused( + verify(&spec(&|s| s.nonce = None), &network, &target, &body, at).await, + "nonce", + ); +} + +// --------------------------------------------- credential and owner method + +#[tokio::test] +async fn credential_type_must_match_the_owner_method() { + let wallet = Wallet::new(1); + let pkh = pkh_network(&wallet); + let (jwk, _, vm, key_network) = did_key_network(); + let at = now(); + + // A did:pkh owner presents a UCAN signed by some did:key. + let target = prefix_target(&pkh, "foo"); + let body = body_for(&target); + let ucan = UcanSpec::valid(&jwk, &vm, &pkh, &target, &body, at).header(); + assert_refused( + verify(&ucan, &pkh, &target, &body, at).await, + "does not match the network owner's DID method", + ); + + // A did:key owner presents a CACAO. + let target = prefix_target(&key_network, "foo"); + let body = body_for(&target); + let cacao = CacaoSpec::valid(&wallet, &target, at).header(&target); + assert_refused( + verify(&cacao, &key_network, &target, &body, at).await, + "does not match the network owner's DID method", + ); +} + +#[tokio::test] +async fn unsupported_owner_method_and_garbage_are_refused() { + let network = NetworkId::new("did:web:example.com".to_string(), "default".to_string()).unwrap(); + let target = prefix_target(&network, "foo"); + let at = now(); + let (jwk, _, vm) = did_key(); + let body = body_for(&target); + let ucan = UcanSpec::valid(&jwk, &vm, &network, &target, &body, at).header(); + assert_refused( + verify(&ucan, &network, &target, &body, at).await, + "DID method does not support key deletion", + ); + assert_refused( + verify("not a credential", &network, &target, &body, at).await, + "not a UCAN or a SIWE CACAO", + ); +} + +// ------------------------------------------------------- nonce consumption + +async fn service() -> (EncryptionService, DatabaseConnection) { + let db = Database::connect(ConnectOptions::new("sqlite::memory:".to_string())) + .await + .unwrap(); + Migrator::up(&db, None).await.unwrap(); + let backend = std::sync::Arc::new(super::LocalOneOfOneBackend::new(ColumnEncryption::new( + [0xABu8; 32], + ))); + ( + EncryptionService::new(db.clone(), NODE.to_string(), backend), + db, + ) +} + +#[tokio::test] +async fn an_owner_nonce_is_consumed_once_and_kept_past_exp() { + let wallet = Wallet::new(1); + let network = pkh_network(&wallet); + let target = prefix_target(&network, "foo"); + let body = body_for(&target); + let (service, db) = service().await; + let at = now(); + let header = CacaoSpec::valid(&wallet, &target, at).header(&target); + + service + .authorize_owner_delete(&header, &network, &target, &body) + .await + .unwrap(); + let rows = encryption_nonce::Entity::find().all(&db).await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].requester_did, wallet.did()); + assert_eq!(rows[0].nonce, "nonce-0001"); + let kept_until = OffsetDateTime::parse( + &rows[0].expires_at, + &time::format_description::well_known::Rfc3339, + ) + .unwrap() + .unix_timestamp(); + assert_eq!(kept_until, at + 300 + NONCE_RETENTION_SECS); + + // The same credential a second time is refused, not replayed. + let second = service + .authorize_owner_delete(&header, &network, &target, &body) + .await; + assert_refused(second, "nonce was already used"); +} + +#[tokio::test] +async fn a_refused_credential_does_not_burn_its_nonce() { + let wallet = Wallet::new(1); + let network = pkh_network(&wallet); + let target = prefix_target(&network, "foo"); + let body = body_for(&target); + let (service, db) = service().await; + let at = now(); + let mut spec = CacaoSpec::valid(&wallet, &target, at); + spec.uri = format!("{KEY_DELETE_URI_PREFIX}{OTHER_NODE}"); + assert_refused( + service + .authorize_owner_delete(&spec.header(&target), &network, &target, &body) + .await, + "uri", + ); + assert!(encryption_nonce::Entity::find() + .all(&db) + .await + .unwrap() + .is_empty()); +} + +#[tokio::test] +async fn ucan_nonce_is_consumed_once_per_owner() { + let (jwk, did, vm, network) = did_key_network(); + let target = prefix_target(&network, "foo"); + let body = body_for(&target); + let (service, db) = service().await; + let at = now(); + let header = UcanSpec::valid(&jwk, &vm, &network, &target, &body, at).header(); + service + .authorize_owner_delete(&header, &network, &target, &body) + .await + .unwrap(); + let rows = encryption_nonce::Entity::find().all(&db).await.unwrap(); + assert_eq!(rows[0].requester_did, did); + assert_refused( + service + .authorize_owner_delete(&header, &network, &target, &body) + .await, + "nonce was already used", + ); +} diff --git a/tinycloud-core/src/encryption_network/deletion_ledger.rs b/tinycloud-core/src/encryption_network/deletion_ledger.rs new file mode 100644 index 00000000..52c61313 --- /dev/null +++ b/tinycloud-core/src/encryption_network/deletion_ledger.rs @@ -0,0 +1,254 @@ +//! Append-only deletion ledger, kept outside the database. +//! +//! Every deleted key adds one JSON line `{networkId, keyPath, keyVersion, +//! deletedAt}`. The delete path appends and fsyncs the lines of a delete +//! before its database transaction commits, so a crash cannot lose a +//! deletion that the database recorded. [`replay`] re-applies a ledger to a +//! database (seed NULL, state `deleted`); a restore runs it as its first step. +//! +//! The file is only ever appended to. Replay is idempotent. + +use std::{ + fs::{File, OpenOptions}, + io::{Read, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; + +use sea_orm::{ActiveModelTrait, ActiveValue::Set, ConnectionTrait, EntityTrait, TransactionTrait}; +use serde::{Deserialize, Serialize}; + +use crate::{db::begin_write_transaction, models::encryption_network_key}; + +use super::service::{now_rfc3339, EncryptionServiceError}; + +/// Key row state written by a delete. +pub const KEY_STATE_DELETED: &str = "deleted"; + +/// One deleted key. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LedgerEntry { + pub network_id: String, + pub key_path: String, + pub key_version: i64, + pub deleted_at: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum DeletionLedgerError { + #[error("deletion ledger I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("deletion ledger path is invalid: {0}")] + Path(&'static str), + #[error("deletion ledger entry could not be encoded: {0}")] + Encode(#[from] serde_json::Error), + #[error("database error: {0}")] + Db(#[from] sea_orm::DbErr), +} + +impl From for EncryptionServiceError { + fn from(err: DeletionLedgerError) -> Self { + EncryptionServiceError::DeletionLedger(err.to_string()) + } +} + +/// Configuration-time check of the ledger path: absolute, with a file name. +/// Opening is separate ([`DeletionLedger::open`]) so a preflight does not +/// touch the filesystem. +pub fn validate_path(path: &Path) -> Result<(), &'static str> { + let text = path.to_str().ok_or("must be valid UTF-8")?; + if text.is_empty() || text.contains('\0') { + return Err("must not be empty"); + } + if !path.is_absolute() { + return Err("must be an absolute path"); + } + if path.file_name().is_none() || text.ends_with('/') { + return Err("must name a file"); + } + if path + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + { + return Err("must not contain `..`"); + } + Ok(()) +} + +/// An open ledger file. Appends are serialized and durable on return. +#[derive(Debug)] +pub struct DeletionLedger { + path: PathBuf, + file: Mutex, +} + +impl DeletionLedger { + /// Opens (creating if absent) the ledger for appending. The file is + /// created with mode 0600. A final line left without its newline by a + /// crash is terminated so later appends start on a fresh line. The file + /// and its directory are fsynced before this returns. + pub fn open(path: &Path) -> Result { + validate_path(path).map_err(DeletionLedgerError::Path)?; + let mut options = OpenOptions::new(); + options.read(true).append(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + let length = file.metadata()?.len(); + if length > 0 { + let mut last = [0u8; 1]; + file.seek(SeekFrom::Start(length - 1))?; + file.read_exact(&mut last)?; + if last[0] != b'\n' { + file.write_all(b"\n")?; + } + } + file.sync_all()?; + if let Some(parent) = path.parent() { + File::open(parent)?.sync_all()?; + } + Ok(Self { + path: path.to_path_buf(), + file: Mutex::new(file), + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Appends one line per entry in a single write, then fsyncs. Returns only + /// after the lines are durable. Blocking; async callers use + /// [`DeletionLedger::append_blocking_task`]. + pub fn append(&self, entries: &[LedgerEntry]) -> Result<(), DeletionLedgerError> { + if entries.is_empty() { + return Ok(()); + } + let mut lines = Vec::new(); + for entry in entries { + serde_json::to_writer(&mut lines, entry)?; + lines.push(b'\n'); + } + let mut file = self + .file + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + file.write_all(&lines)?; + file.sync_all()?; + Ok(()) + } + + /// [`DeletionLedger::append`] on the blocking pool. + pub async fn append_blocking_task( + self: &Arc, + entries: Vec, + ) -> Result<(), DeletionLedgerError> { + let ledger = Arc::clone(self); + tokio::task::spawn_blocking(move || ledger.append(&entries)) + .await + .map_err(|err| std::io::Error::other(err.to_string()))? + } +} + +/// What a ledger file holds. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct LedgerContents { + pub entries: Vec, + /// 1-based numbers of non-empty lines that are not a ledger entry. + pub malformed_lines: Vec, +} + +/// Reads every line of a ledger file. +pub fn read(path: &Path) -> Result { + let bytes = std::fs::read(path)?; + let mut contents = LedgerContents::default(); + for (index, line) in bytes.split(|byte| *byte == b'\n').enumerate() { + if line.iter().all(u8::is_ascii_whitespace) { + continue; + } + match serde_json::from_slice::(line) { + Ok(entry) => contents.entries.push(entry), + Err(_) => contents.malformed_lines.push(index + 1), + } + } + Ok(contents) +} + +/// Result of [`replay`]. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReplayReport { + pub entries: usize, + /// Rows set to `deleted` with a NULL seed. + pub applied: usize, + /// Rows already `deleted` with no seed. + pub already_deleted: usize, + /// Rows whose key version is newer than the ledger entry (a later + /// re-create): left alone. + pub newer_version: usize, + /// Entries with no row in this database. + pub missing_row: usize, + pub malformed_lines: Vec, +} + +/// Re-applies the ledger at `path`: every named key row whose version is at +/// most the entry's is set to `deleted` with a NULL seed. One transaction; +/// idempotent. Rows the database does not have are counted, not created. +pub async fn replay( + db: &C, + path: &Path, +) -> Result { + let contents = read(path)?; + let mut report = ReplayReport { + entries: contents.entries.len(), + malformed_lines: contents.malformed_lines, + ..ReplayReport::default() + }; + let tx = begin_write_transaction(db, None).await?; + match apply(&tx, &contents.entries, &mut report).await { + Ok(()) => tx.commit().await?, + Err(err) => { + let _ = tx.rollback().await; + return Err(err); + } + } + Ok(report) +} + +async fn apply( + db: &C, + entries: &[LedgerEntry], + report: &mut ReplayReport, +) -> Result<(), DeletionLedgerError> { + for entry in entries { + let row = encryption_network_key::Entity::find_by_id(( + entry.network_id.clone(), + entry.key_path.clone(), + )) + .one(db) + .await?; + let Some(row) = row else { + report.missing_row += 1; + continue; + }; + if row.key_version > entry.key_version { + report.newer_version += 1; + continue; + } + if row.state == KEY_STATE_DELETED && row.sealed_seed.is_none() { + report.already_deleted += 1; + continue; + } + let mut active: encryption_network_key::ActiveModel = row.into(); + active.state = Set(KEY_STATE_DELETED.to_string()); + active.sealed_seed = Set(None); + active.updated_at = Set(now_rfc3339()); + active.update(db).await?; + report.applied += 1; + } + Ok(()) +} diff --git a/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs b/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs new file mode 100644 index 00000000..8bed8674 --- /dev/null +++ b/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs @@ -0,0 +1,247 @@ +//! Deletion ledger: durable append, tail repair, and replay onto a database. + +use std::path::Path; + +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ConnectOptions, Database, DatabaseConnection, EntityTrait, +}; +use sea_orm_migration::MigratorTrait; + +use super::deletion_ledger::{ + read, replay, validate_path, DeletionLedger, LedgerEntry, ReplayReport, +}; +use crate::{migrations::Migrator, models::encryption_network_key}; + +const NETWORK: &str = "did:key:z6MkOwner:default"; + +fn entry(path: &str, version: i64) -> LedgerEntry { + LedgerEntry { + network_id: NETWORK.to_string(), + key_path: path.to_string(), + key_version: version, + deleted_at: "2026-10-23T12:00:00Z".to_string(), + } +} + +async fn fresh_db() -> DatabaseConnection { + let db = Database::connect(ConnectOptions::new("sqlite::memory:".to_string())) + .await + .unwrap(); + Migrator::up(&db, None).await.unwrap(); + db +} + +async fn insert_key(db: &DatabaseConnection, path: &str, state: &str, seed: bool, version: i64) { + encryption_network_key::ActiveModel { + network_id: Set(NETWORK.to_string()), + key_path: Set(path.to_string()), + kind: Set("deletable".to_string()), + state: Set(state.to_string()), + public_key: Set(vec![7; 32]), + sealed_seed: Set(seed.then(|| vec![9; 60])), + key_version: Set(version), + created_at: Set("2026-10-22T00:00:00Z".to_string()), + updated_at: Set("2026-10-22T00:00:00Z".to_string()), + } + .insert(db) + .await + .unwrap(); +} + +async fn row(db: &DatabaseConnection, path: &str) -> encryption_network_key::Model { + encryption_network_key::Entity::find_by_id((NETWORK.to_string(), path.to_string())) + .one(db) + .await + .unwrap() + .unwrap() +} + +#[test] +fn append_writes_one_json_line_per_entry_and_reopening_keeps_appending() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ledger.jsonl"); + let ledger = DeletionLedger::open(&path).unwrap(); + ledger + .append(&[entry("apps/a/x", 1), entry("apps/a/y", 3)]) + .unwrap(); + drop(ledger); + let ledger = DeletionLedger::open(&path).unwrap(); + ledger.append(&[entry("apps/b/z", 1)]).unwrap(); + + let text = std::fs::read_to_string(&path).unwrap(); + assert_eq!(text.lines().count(), 3); + assert!(text.ends_with('\n')); + let first: serde_json::Value = serde_json::from_str(text.lines().next().unwrap()).unwrap(); + assert_eq!( + first, + serde_json::json!({ + "networkId": NETWORK, + "keyPath": "apps/a/x", + "keyVersion": 1, + "deletedAt": "2026-10-23T12:00:00Z", + }) + ); + let contents = read(&path).unwrap(); + assert_eq!( + contents.entries, + vec![ + entry("apps/a/x", 1), + entry("apps/a/y", 3), + entry("apps/b/z", 1) + ] + ); + assert!(contents.malformed_lines.is_empty()); +} + +#[cfg(unix)] +#[test] +fn the_ledger_file_is_private_to_the_node_user() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ledger.jsonl"); + DeletionLedger::open(&path).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "mode {mode:o}"); +} + +#[test] +fn an_unterminated_last_line_is_closed_before_the_next_append() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ledger.jsonl"); + let mut torn = serde_json::to_string(&entry("apps/a/x", 1)).unwrap(); + torn.push('\n'); + torn.push_str("{\"networkId\":\"did:key:z6MkOw"); + std::fs::write(&path, torn).unwrap(); + + let ledger = DeletionLedger::open(&path).unwrap(); + ledger.append(&[entry("apps/b/y", 1)]).unwrap(); + + let contents = read(&path).unwrap(); + assert_eq!( + contents.entries, + vec![entry("apps/a/x", 1), entry("apps/b/y", 1)] + ); + assert_eq!(contents.malformed_lines, vec![2]); +} + +#[test] +fn appending_nothing_writes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ledger.jsonl"); + let ledger = DeletionLedger::open(&path).unwrap(); + ledger.append(&[]).unwrap(); + assert_eq!(std::fs::metadata(&path).unwrap().len(), 0); +} + +#[test] +fn the_path_is_validated_without_touching_the_filesystem() { + for ok in ["/var/lib/tinycloud/deletion-ledger.jsonl", "/a"] { + assert!(validate_path(Path::new(ok)).is_ok(), "{ok}"); + } + for bad in [ + "", + "relative/ledger.jsonl", + "ledger.jsonl", + "/var/lib/tinycloud/", + "/", + "/var/lib/../ledger.jsonl", + ] { + assert!(validate_path(Path::new(bad)).is_err(), "{bad:?}"); + } +} + +#[tokio::test] +async fn replay_sets_named_keys_deleted_with_no_seed() { + let db = fresh_db().await; + insert_key(&db, "apps/a/active", "active", true, 1).await; + insert_key(&db, "apps/a/blocked", "blocked", true, 1).await; + insert_key(&db, "apps/a/untouched", "active", true, 1).await; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ledger.jsonl"); + let ledger = DeletionLedger::open(&path).unwrap(); + ledger + .append(&[entry("apps/a/active", 1), entry("apps/a/blocked", 1)]) + .unwrap(); + + let report = replay(&db, &path).await.unwrap(); + assert_eq!( + report, + ReplayReport { + entries: 2, + applied: 2, + ..ReplayReport::default() + } + ); + for deleted in ["apps/a/active", "apps/a/blocked"] { + let row = row(&db, deleted).await; + assert_eq!(row.state, "deleted"); + assert_eq!(row.sealed_seed, None); + } + let untouched = row(&db, "apps/a/untouched").await; + assert_eq!(untouched.state, "active"); + assert!(untouched.sealed_seed.is_some()); +} + +#[tokio::test] +async fn replay_is_idempotent() { + let db = fresh_db().await; + insert_key(&db, "apps/a/x", "active", true, 1).await; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ledger.jsonl"); + DeletionLedger::open(&path) + .unwrap() + .append(&[entry("apps/a/x", 1)]) + .unwrap(); + + assert_eq!(replay(&db, &path).await.unwrap().applied, 1); + let second = replay(&db, &path).await.unwrap(); + assert_eq!(second.applied, 0); + assert_eq!(second.already_deleted, 1); + assert_eq!(row(&db, "apps/a/x").await.sealed_seed, None); +} + +#[tokio::test] +async fn replay_leaves_a_newer_key_version_and_counts_missing_rows() { + let db = fresh_db().await; + // A key deleted at version 1, then created again as version 2. + insert_key(&db, "apps/a/x", "active", true, 2).await; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ledger.jsonl"); + DeletionLedger::open(&path) + .unwrap() + .append(&[entry("apps/a/x", 1), entry("apps/a/gone", 1)]) + .unwrap(); + + let report = replay(&db, &path).await.unwrap(); + assert_eq!(report.newer_version, 1); + assert_eq!(report.missing_row, 1); + assert_eq!(report.applied, 0); + let kept = row(&db, "apps/a/x").await; + assert_eq!(kept.state, "active"); + assert!(kept.sealed_seed.is_some()); + + // The ledger entry for the version that row holds does apply. + DeletionLedger::open(&path) + .unwrap() + .append(&[entry("apps/a/x", 2)]) + .unwrap(); + assert_eq!(replay(&db, &path).await.unwrap().applied, 1); + assert_eq!(row(&db, "apps/a/x").await.sealed_seed, None); +} + +#[tokio::test] +async fn replay_applies_valid_entries_and_reports_malformed_lines() { + let db = fresh_db().await; + insert_key(&db, "apps/a/x", "active", true, 1).await; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ledger.jsonl"); + let mut text = String::from("not json\n"); + text.push_str(&serde_json::to_string(&entry("apps/a/x", 1)).unwrap()); + text.push_str("\n{\"networkId\":\"x\"}\n"); + std::fs::write(&path, text).unwrap(); + + let report = replay(&db, &path).await.unwrap(); + assert_eq!(report.applied, 1); + assert_eq!(report.malformed_lines, vec![1, 3]); + assert_eq!(row(&db, "apps/a/x").await.state, "deleted"); +} diff --git a/tinycloud-core/src/encryption_network/mod.rs b/tinycloud-core/src/encryption_network/mod.rs index 334d5ad2..0f76c1af 100644 --- a/tinycloud-core/src/encryption_network/mod.rs +++ b/tinycloud-core/src/encryption_network/mod.rs @@ -7,6 +7,8 @@ pub mod backend; pub mod canonical; +pub mod delete_auth; +pub mod deletion_ledger; pub mod key_id; pub mod keys; pub mod network_id; @@ -14,6 +16,10 @@ pub mod protocol; pub mod service; pub mod types; +#[cfg(test)] +mod delete_auth_tests; +#[cfg(test)] +mod deletion_ledger_tests; #[cfg(test)] mod tests; @@ -22,8 +28,9 @@ pub use network_id::{NetworkId, NetworkIdError}; pub use protocol::{ DecryptFacts, DecryptInvocation, DecryptRequestBody, DecryptResponseBody, InvocationCapability, NetworkAdminFacts, NetworkAdminInvocation, DECRYPT_ACTION, DECRYPT_REQUEST_TYPE, - DECRYPT_RESULT_TYPE, KEY_ADMIN_TYPE, KEY_BLOCK_ACTION, KEY_CREATE_ACTION, KEY_DESCRIBE_TYPE, - KEY_UNBLOCK_ACTION, NETWORK_ADMIN_TYPE, NETWORK_CREATE_ACTION, NETWORK_REVOKE_ACTION, + DECRYPT_RESULT_TYPE, KEY_ADMIN_TYPE, KEY_BLOCK_ACTION, KEY_CREATE_ACTION, KEY_DELETE_ACTION, + KEY_DESCRIBE_TYPE, KEY_UNBLOCK_ACTION, NETWORK_ADMIN_TYPE, NETWORK_CREATE_ACTION, + NETWORK_REVOKE_ACTION, }; pub use service::{ CreateNetworkRequest, DecryptTarget, EncryptionService, EncryptionServiceError, diff --git a/tinycloud-core/src/encryption_network/protocol.rs b/tinycloud-core/src/encryption_network/protocol.rs index 2d4bf260..af5c9e6e 100644 --- a/tinycloud-core/src/encryption_network/protocol.rs +++ b/tinycloud-core/src/encryption_network/protocol.rs @@ -25,6 +25,7 @@ pub const KEY_DESCRIBE_TYPE: &str = "tinycloud.encryption.key-describe/v1"; pub const KEY_CREATE_ACTION: &str = "tinycloud.encryption/key.create"; pub const KEY_BLOCK_ACTION: &str = "tinycloud.encryption/key.block"; pub const KEY_UNBLOCK_ACTION: &str = "tinycloud.encryption/key.unblock"; +pub const KEY_DELETE_ACTION: &str = "tinycloud.encryption/key.delete"; /// Body of a POST /encryption/networks//decrypt request. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/tinycloud-core/src/encryption_network/service.rs b/tinycloud-core/src/encryption_network/service.rs index bfb4cf6b..7f8abc39 100644 --- a/tinycloud-core/src/encryption_network/service.rs +++ b/tinycloud-core/src/encryption_network/service.rs @@ -27,6 +27,7 @@ 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::deletion_ledger::DeletionLedger; use super::key_id::{self, EncryptionResource, KeyLocation}; use super::keys::{canonical_key_urn, network_of, DEFAULT_KEY_CAP_PER_NETWORK}; use super::network_id::NetworkId; @@ -227,16 +228,21 @@ pub enum EncryptionServiceError { CorruptKeyRow, #[error("key_path_invalid: {0}")] KeyPathInvalid(String), + #[error("owner_only: {0}")] + OwnerOnly(String), + #[error("deletion_ledger_unavailable: {0}")] + DeletionLedger(String), } pub struct EncryptionService { pub(super) db: RecoveringConnection, - node_did: String, + pub(super) node_did: String, node_keypair: Option, pub(super) backend: Arc, invocation_ttl_seconds: i64, pub(super) sqlite_writer_lock: Option>>, pub(super) key_cap_per_network: u64, + pub(super) deletion_ledger: Option>, } #[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, + deletion_ledger: None, } } @@ -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, + deletion_ledger: None, } } @@ -296,6 +304,13 @@ impl EncryptionService { self } + /// The append-only ledger every key deletion is recorded in before it + /// commits. Without one, deletes are refused. + pub fn with_deletion_ledger(mut self, ledger: Option>) -> Self { + self.deletion_ledger = ledger; + self + } + pub fn node_did(&self) -> &str { &self.node_did } @@ -1165,7 +1180,18 @@ impl EncryptionService { nonce: &str, exp: i64, ) -> Result<(), EncryptionServiceError> { - let nonce_expires_at = OffsetDateTime::from_unix_timestamp(exp) + self.consume_nonce_until(requester_did, nonce, exp).await + } + + /// Records `(requester_did, nonce)` as used until `expires_at` (unix + /// seconds). A second insert of the same pair is `NonceReplay`. + pub(super) async fn consume_nonce_until( + &self, + requester_did: &str, + nonce: &str, + expires_at: i64, + ) -> Result<(), EncryptionServiceError> { + let nonce_expires_at = OffsetDateTime::from_unix_timestamp(expires_at) .unwrap_or_else(|_| OffsetDateTime::now_utc()) .format(&Rfc3339) .unwrap_or_else(|_| now_rfc3339()); diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index e9f93e43..3111259c 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -1797,6 +1797,39 @@ mod tests { )); } + /// key.delete is refused on every key and prefix resource shape, for the + /// owner and for anyone else. + #[tokio::test] + async fn key_delete_is_refused_on_every_prefix_and_key_shape() { + let db = database().await; + let base = format!("urn:tinycloud:encryption:{KEY_OWNER}:default"); + for resource in [ + format!("{base}/apps/foo/"), + format!("{base}/apps/foo/x/"), + format!("{base}/apps/foo/x"), + format!("{base}/apps/"), + format!("{base}/apps/foo"), + format!("{base}/secrets/"), + format!("{base}/appsx/y/"), + base.clone(), + ] { + for delegator in [KEY_OWNER, OTHER_DID] { + let info = + key_grant_info(delegator, &resource, "tinycloud.encryption/key.delete", &[]); + let refused = validate(&db, &info).await; + assert!( + matches!( + refused, + Err(Error::InvalidDelegation( + DelegationError::KeyDeleteNotDelegable + )) + ), + "{delegator} on {resource}: {refused:?}" + ); + } + } + } + fn key_grant_info( delegator: &str, resource: &str, diff --git a/tinycloud-core/tests/fixtures/app-id-vectors.json b/tinycloud-core/tests/fixtures/app-id-vectors.json new file mode 100644 index 00000000..7113a8b7 --- /dev/null +++ b/tinycloud-core/tests/fixtures/app-id-vectors.json @@ -0,0 +1,375 @@ +{ + "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 + } + ], + "deletePrefix": [ + { + "prefix": "apps/foo/", + "accepted": true + }, + { + "prefix": "apps/", + "accepted": false + }, + { + "prefix": "apps/foo/x/", + "accepted": false + }, + { + "prefix": "secrets/", + "accepted": false + }, + { + "prefix": "apps/foo", + "accepted": 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 + } + ] +} From ecba16b27c65d21f2ec949ab27314f606d49fc17 Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:20:39 +0000 Subject: [PATCH 02/29] encryption: deletion ledger configuration, replay command and delete credential route tests Details: TC-1777 --- tinycloud-node-server/src/cli.rs | 65 ++++ tinycloud-node-server/src/config.rs | 57 +++- tinycloud-node-server/src/lib.rs | 17 +- .../src/routes/encryption.rs | 7 +- tinycloud-node-server/src/routes/mod.rs | 1 + .../src/routes/tests/key_delete.rs | 300 ++++++++++++++++++ tinycloud.toml | 7 + 7 files changed, 448 insertions(+), 6 deletions(-) create mode 100644 tinycloud-node-server/src/routes/tests/key_delete.rs diff --git a/tinycloud-node-server/src/cli.rs b/tinycloud-node-server/src/cli.rs index c3adf1bf..fc2bbdcb 100644 --- a/tinycloud-node-server/src/cli.rs +++ b/tinycloud-node-server/src/cli.rs @@ -43,6 +43,33 @@ struct NodeArgs { command: NodeCommand, } +#[derive(Debug, Args)] +struct DeletionLedgerArgs { + #[command(subcommand)] + command: DeletionLedgerCommand, +} + +#[derive(Debug, Subcommand)] +enum DeletionLedgerCommand { + /// Re-apply the deletion ledger to the database (seed NULL, state + /// `deleted`). Idempotent. + Replay(ReplayArgs), +} + +#[derive(Debug, Args)] +struct ReplayArgs { + /// Base config file to load (the database comes from its storage section). + #[arg(long)] + config: Option, + + /// Ledger file; defaults to `encryption_network.deletion_ledger_path`. + #[arg(long)] + ledger: Option, + + #[command(flatten)] + json: JsonArgs, +} + #[derive(Debug, Subcommand)] enum NodeCommand { Service(ServiceArgs), @@ -56,6 +83,8 @@ enum NodeCommand { /// Remote HTTPS via the tinycloud.link tunnel relay (requires an /// existing `link enable`d name). Tunnel(TunnelArgs), + /// The append-only ledger of deleted encryption keys. + DeletionLedger(DeletionLedgerArgs), } #[derive(Debug, Args)] @@ -221,6 +250,7 @@ fn run_node(args: NodeArgs) -> Result<()> { NodeCommand::Key(args) => run_key(args.command), NodeCommand::Link(args) => run_link(args.command), NodeCommand::Tunnel(args) => run_tunnel(args.command), + NodeCommand::DeletionLedger(args) => run_deletion_ledger(args.command), } } @@ -308,6 +338,41 @@ fn run_service(command: ServiceCommand) -> Result<()> { } } +/// Re-apply the deletion ledger to the node database: every key the ledger +/// names is set to `deleted` with no seed. Run it as the first step of any +/// database restore, before the node starts. +fn run_deletion_ledger(command: DeletionLedgerCommand) -> Result<()> { + match command { + DeletionLedgerCommand::Replay(args) => block_on(replay_deletion_ledger(args)), + } +} + +async fn replay_deletion_ledger(args: ReplayArgs) -> Result<()> { + let config_path = args + .config + .unwrap_or_else(|| runtime::serve_profile_config_path(Profile::default_for_host())); + let figment = runtime::serve_config_figment(&config_path)?; + let config = crate::resolve_runtime_config(&figment.extract::()?)?; + let ledger = args + .ledger + .or_else(|| config.encryption_network.deletion_ledger_path.clone()) + .ok_or_else(|| { + anyhow::anyhow!( + "no deletion ledger: pass --ledger or set encryption_network.deletion_ledger_path" + ) + })?; + let db = crate::connect_database(&config).await?; + let report = tinycloud_core::encryption_network::deletion_ledger::replay(&db, &ledger).await?; + emit_json(&report, args.json.json)?; + if !report.malformed_lines.is_empty() { + anyhow::bail!( + "the ledger has {} line(s) that are not ledger entries; every valid entry was applied", + report.malformed_lines.len() + ); + } + Ok(()) +} + fn emit_json(value: &T, json: bool) -> Result<()> { if json { print!("{}", serde_json::to_string(value)?); diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index e2deada2..228d4d4e 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -63,15 +63,39 @@ pub struct Config { /// Release gates for the encryption network routes. `named_keys_v1` /// advertises the `encryption-named-keys-v1` feature in `/info` and turns on -/// named keys: the key create, describe, public key, block and unblock -/// routes are mounted, delegations may grant on key and key-prefix +/// named keys: the key create, describe, public key, block, unblock and +/// delete routes are mounted, delegations may grant on key and key-prefix /// resources, and named-key decrypt is served on `POST /invoke`. It stays off /// until named keys are live on OpenKey; while off, those routes answer 404 /// and key resources are refused with `named_keys_unavailable`. +/// +/// `deletion_ledger_path` is the append-only file, outside the database, that +/// records every deleted key (one JSON line each). It is required while +/// `named_keys_v1` is on, and must be an absolute path. #[derive(Serialize, Deserialize, Debug, Default, Clone, Hash, PartialEq, Eq)] pub struct EncryptionNetworkConfig { #[serde(default)] pub named_keys_v1: bool, + #[serde(default)] + pub deletion_ledger_path: Option, +} + +impl EncryptionNetworkConfig { + /// Startup validation. Does not touch the filesystem; the ledger file is + /// opened when the node starts. + pub fn validate(&self) -> Result<(), String> { + match (&self.deletion_ledger_path, self.named_keys_v1) { + (None, true) => Err( + "encryption_network.deletion_ledger_path is required when named_keys_v1 is on" + .to_string(), + ), + (None, false) => Ok(()), + (Some(path), _) => { + tinycloud_core::encryption_network::deletion_ledger::validate_path(path) + .map_err(|reason| format!("encryption_network.deletion_ledger_path {reason}")) + } + } + } } /// `[global.sql]` release gate for delegated SQL caveats (TC-1791). @@ -1915,6 +1939,35 @@ mod tests { assert!(config.named_keys_v1); } + #[test] + fn deletion_ledger_path_is_required_while_named_keys_are_on() { + let off: EncryptionNetworkConfig = toml::from_str("").unwrap(); + assert!(off.validate().is_ok()); + + let missing: EncryptionNetworkConfig = toml::from_str("named_keys_v1 = true").unwrap(); + assert!(missing + .validate() + .unwrap_err() + .contains("deletion_ledger_path is required")); + + let present: EncryptionNetworkConfig = toml::from_str( + "named_keys_v1 = true\ndeletion_ledger_path = \"/var/lib/tinycloud/ledger.jsonl\"", + ) + .unwrap(); + assert!(present.validate().is_ok()); + } + + #[test] + fn deletion_ledger_path_must_be_an_absolute_file_path() { + for bad in ["", "ledger.jsonl", "/var/lib/", "/a/../b"] { + let config = EncryptionNetworkConfig { + named_keys_v1: false, + deletion_ledger_path: Some(bad.into()), + }; + assert!(config.validate().is_err(), "{bad:?}"); + } + } + #[test] fn sql_delegated_caveats_is_off_by_default_and_parses_from_toml() { assert!(!Config::default().sql.delegated_caveats_enforced); diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index b0c3982a..41e31c48 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -565,6 +565,10 @@ pub fn resolve_runtime_config(tinycloud_config: &Config) -> Result { .validate() .map_err(anyhow::Error::msg)?; resolved.sql_store.validate().map_err(anyhow::Error::msg)?; + resolved + .encryption_network + .validate() + .map_err(anyhow::Error::msg)?; tracing::RequestIdOrigins::new(&resolved.log.request_id_first_party_origins) .map_err(anyhow::Error::msg)?; resolved.storage.resolve(); @@ -826,8 +830,17 @@ pub async fn app_with_control( tinycloud.set_read_audit_wait(Duration::from_millis( tinycloud_config.database.read_audit_wait_ms, )); - let encryption_service = - encryption_service.with_sqlite_writer_lock(tinycloud.sqlite_writer_lock()); + let deletion_ledger = tinycloud_config + .encryption_network + .deletion_ledger_path + .as_deref() + .map(tinycloud_core::encryption_network::deletion_ledger::DeletionLedger::open) + .transpose() + .map_err(|error| anyhow::anyhow!("failed to open the deletion ledger: {error}"))? + .map(Arc::new); + let encryption_service = encryption_service + .with_sqlite_writer_lock(tinycloud.sqlite_writer_lock()) + .with_deletion_ledger(deletion_ledger); let database_artifact_repository: Arc = Arc::new(SizeTrackingArtifactRepository::new( Arc::new(node_artifact_repository(&tinycloud)), diff --git a/tinycloud-node-server/src/routes/encryption.rs b/tinycloud-node-server/src/routes/encryption.rs index 30440cdf..a257dc5d 100644 --- a/tinycloud-node-server/src/routes/encryption.rs +++ b/tinycloud-node-server/src/routes/encryption.rs @@ -668,8 +668,11 @@ fn map_service_err(err: EncryptionServiceError) -> (Status, String) { EncryptionServiceError::DeletableUnavailable | EncryptionServiceError::KeyBlocked | EncryptionServiceError::KeyLimitExceeded - | EncryptionServiceError::KeyUnwrapFailed(_) => Status::Forbidden, - EncryptionServiceError::CorruptKeyRow => Status::InternalServerError, + | EncryptionServiceError::KeyUnwrapFailed(_) + | EncryptionServiceError::OwnerOnly(_) => Status::Forbidden, + EncryptionServiceError::CorruptKeyRow | EncryptionServiceError::DeletionLedger(_) => { + Status::InternalServerError + } EncryptionServiceError::KeyPathInvalid(_) => Status::BadRequest, EncryptionServiceError::InvalidBody(_) | EncryptionServiceError::InvalidReceiverPublicKey diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index baf996b9..b0ec1e1e 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -4248,6 +4248,7 @@ async fn verify_auth_admitted( #[cfg(test)] mod tests { mod encryption_keys; + mod key_delete; mod revocation_scope; use super::*; diff --git a/tinycloud-node-server/src/routes/tests/key_delete.rs b/tinycloud-node-server/src/routes/tests/key_delete.rs new file mode 100644 index 00000000..beafeb1a --- /dev/null +++ b/tinycloud-node-server/src/routes/tests/key_delete.rs @@ -0,0 +1,300 @@ +//! Owner-signed `key.delete` credentials through real routes: they are never +//! accepted as delegations or as revocations. + +use super::*; +use k256::ecdsa::SigningKey; +use rocket::http::Status; +use serde_json::{json, Value}; +use sha3::{Digest, Keccak256}; +use std::collections::BTreeMap; +use tinycloud_auth::{ + authorization::{HeaderEncode, TinyCloudDelegation}, + cacaos::{ + siwe::{encode_eip55, Message, TimeStamp, Version}, + siwe_cacao::{Eip191, Payload as CacaoPayload, Signature}, + }, + siwe_recap::Capability as Recap, + ssi::{ + claims::jwt::NumericDate, + dids::{DIDBuf, DIDURLBuf}, + ucan::Payload, + }, + ucan_capabilities_object::Capabilities, +}; +use tinycloud_core::models::revocation; +use tinycloud_core::sea_orm::PaginatorTrait; + +const KEY_DELETE: &str = "tinycloud.encryption/key.delete"; +const KEY_CREATE: &str = "tinycloud.encryption/key.create"; +const KEY_DELETE_URI_PREFIX: &str = "urn:tinycloud:encryption:key-delete:"; + +struct Wallet { + key: SigningKey, + address: [u8; 20], +} + +impl Wallet { + fn new(seed: u8) -> Self { + let key = SigningKey::from_bytes(&[seed; 32].into()).unwrap(); + let public = key.verifying_key().to_encoded_point(false); + let address = Keccak256::digest(&public.as_bytes()[1..])[12..] + .try_into() + .unwrap(); + Self { key, address } + } + + fn did(&self) -> String { + format!("did:pkh:eip155:1:0x{}", encode_eip55(&self.address)) + } + + fn sign(&self, message: &Message) -> Signature { + let (signature, recovery) = self + .key + .sign_prehash_recoverable(&message.eip191_hash().unwrap()) + .unwrap(); + let mut bytes = [0u8; 65]; + bytes[..64].copy_from_slice(signature.to_bytes().as_ref()); + bytes[64] = u8::from(recovery) + 27; + Signature::from(bytes) + } + + /// A SIWE CACAO carrying one ReCap capability, valid for `lifetime` seconds. + fn cacao( + &self, + resource: &str, + ability: &str, + lead: Option, + uri: &str, + nonce: &str, + lifetime: i64, + ) -> String { + let now = OffsetDateTime::now_utc().unix_timestamp(); + let stamp = |unix: i64| TimeStamp::from(OffsetDateTime::from_unix_timestamp(unix).unwrap()); + let mut recap = Recap::::default(); + recap + .with_action_convert(resource, ability, Vec::>::new()) + .unwrap(); + let message = recap + .build_message(Message { + scheme: None, + domain: "example.com".parse().unwrap(), + address: self.address, + statement: lead, + uri: uri.parse().unwrap(), + version: Version::V1, + chain_id: 1, + nonce: nonce.to_string(), + issued_at: stamp(now), + expiration_time: Some(stamp(now + lifetime)), + not_before: None, + request_id: None, + resources: vec![], + }) + .unwrap(); + let signature = self.sign(&message); + let cacao = CacaoPayload::from(message).sign::(signature); + TinyCloudDelegation::Cacao(Box::new(cacao)) + .encode() + .unwrap() + } +} + +fn prefix_urn(owner: &str, id: &str) -> String { + format!("urn:tinycloud:encryption:{owner}:default/apps/{id}/") +} + +fn key_urn(owner: &str, path: &str) -> String { + format!("urn:tinycloud:encryption:{owner}:default/{path}") +} + +/// A UCAN signed by the fixture owner naming `ability` on `resource`. +fn owner_ucan( + fixture: &KvSyncFixture, + audience: &str, + resource: &str, + ability: &str, +) -> Result { + let mut capabilities = Capabilities::new(); + capabilities.with_action( + resource.parse()?, + ability.parse::()?, + [BTreeMap::::new()], + ); + Ok(Payload { + issuer: fixture.owner_vm.parse::()?, + audience: audience.parse::()?, + not_before: None, + expiration: NumericDate::try_from_seconds( + OffsetDateTime::now_utc().unix_timestamp() as f64 + 200.0, + )?, + nonce: Some(fixture.next_nonce()), + facts: Some(vec![json!({"type": "tinycloud.encryption.key-admin/v1"})]), + proof: vec![], + attenuation: capabilities, + } + .sign( + fixture.owner.get_algorithm().unwrap_or_default(), + &fixture.owner, + )? + .encode()?) +} + +async fn fixture() -> Result { + let mut config = Config::default(); + config.encryption_network.named_keys_v1 = true; + KvSyncFixture::new_configured(config, |rocket| { + rocket.mount("/", rocket::routes![revoke]) + }) + .await +} + +async fn post(fixture: &KvSyncFixture, uri: &str, auth: String) -> (Status, String) { + let response = fixture + .client + .post(uri.to_string()) + .header(rocket::http::Header::new("Authorization", auth)) + .dispatch() + .await; + let status = response.status(); + (status, response.into_string().await.unwrap_or_default()) +} + +fn owner_did(fixture: &KvSyncFixture) -> String { + KvSyncFixture::did_of(&fixture.owner_vm) +} + +#[tokio::test] +async fn delegate_refuses_key_delete_on_every_target_shape() -> Result<()> { + let fixture = fixture().await?; + let did_key_owner = owner_did(&fixture); + let device = fixture.device_did(); + let wallet = Wallet::new(3); + let wallet_did = wallet.did(); + + // Positive control: the same owners can delegate key.create on the prefix, + // so a refusal below is the refusal of key.delete. + let control = owner_ucan( + &fixture, + &device, + &prefix_urn(&did_key_owner, "foo"), + KEY_CREATE, + )?; + let (status, body) = post(&fixture, "/delegate", control).await; + assert_eq!(status, Status::Ok, "{body}"); + let control = wallet.cacao( + &prefix_urn(&wallet_did, "foo"), + KEY_CREATE, + None, + &device, + "control0001", + 3600, + ); + let (status, body) = post(&fixture, "/delegate", control).await; + assert_eq!(status, Status::Ok, "{body}"); + + for (label, ucan_resource, cacao_resource) in [ + ( + "app prefix", + prefix_urn(&did_key_owner, "foo"), + prefix_urn(&wallet_did, "foo"), + ), + ( + "deeper prefix", + format!("urn:tinycloud:encryption:{did_key_owner}:default/apps/foo/x/"), + format!("urn:tinycloud:encryption:{wallet_did}:default/apps/foo/x/"), + ), + ( + "exact key", + key_urn(&did_key_owner, "apps/foo/token"), + key_urn(&wallet_did, "apps/foo/token"), + ), + ( + "reserved segment prefix", + format!("urn:tinycloud:encryption:{did_key_owner}:default/apps/"), + format!("urn:tinycloud:encryption:{wallet_did}:default/apps/"), + ), + ( + "other prefix", + format!("urn:tinycloud:encryption:{did_key_owner}:default/secrets/"), + format!("urn:tinycloud:encryption:{wallet_did}:default/secrets/"), + ), + ] { + let ucan = owner_ucan(&fixture, &device, &ucan_resource, KEY_DELETE)?; + let (status, body) = post(&fixture, "/delegate", ucan).await; + assert_eq!(status, Status::Unauthorized, "ucan {label}: {body}"); + assert!( + body.contains("key-delete-not-delegable"), + "ucan {label}: {body}" + ); + + let cacao = wallet.cacao( + &cacao_resource, + KEY_DELETE, + None, + &device, + "nonce0001", + 3600, + ); + let (status, body) = post(&fixture, "/delegate", cacao).await; + assert_eq!(status, Status::Unauthorized, "cacao {label}: {body}"); + assert!( + body.contains("key-delete-not-delegable"), + "cacao {label}: {body}" + ); + } + Ok(()) +} + +#[tokio::test] +async fn an_owner_delete_credential_is_not_a_delegation() -> Result<()> { + let fixture = fixture().await?; + let wallet = Wallet::new(3); + let wallet_did = wallet.did(); + let prefix = prefix_urn(&wallet_did, "foo"); + let credential = wallet.cacao( + &prefix, + KEY_DELETE, + Some(format!("Delete all TinyCloud keys under {prefix}.")), + &format!("{KEY_DELETE_URI_PREFIX}{}", fixture.node_did), + "nonce0002", + 300, + ); + let (status, body) = post(&fixture, "/delegate", credential).await; + assert_eq!(status, Status::Unauthorized, "{body}"); + assert!(body.contains("key-delete-not-delegable"), "{body}"); + Ok(()) +} + +#[tokio::test] +async fn revoke_refuses_owner_delete_credentials() -> Result<()> { + let fixture = fixture().await?; + let wallet = Wallet::new(3); + let wallet_did = wallet.did(); + let prefix = prefix_urn(&wallet_did, "foo"); + let cacao = wallet.cacao( + &prefix, + KEY_DELETE, + Some(format!("Delete all TinyCloud keys under {prefix}.")), + &format!("{KEY_DELETE_URI_PREFIX}{}", fixture.node_did), + "nonce0003", + 300, + ); + let (status, body) = post(&fixture, "/revoke", cacao).await; + assert_eq!(status, Status::Forbidden, "cacao: {body}"); + + let ucan = owner_ucan( + &fixture, + &fixture.node_did, + &prefix_urn(&owner_did(&fixture), "foo"), + KEY_DELETE, + )?; + let (status, body) = post(&fixture, "/revoke", ucan).await; + assert_eq!(status, Status::Forbidden, "ucan: {body}"); + + assert_eq!( + revocation::Entity::find().count(&fixture.conn).await?, + 0, + "no revocation is stored" + ); + Ok(()) +} diff --git a/tinycloud.toml b/tinycloud.toml index 504fcd2c..ac6af896 100644 --- a/tinycloud.toml +++ b/tinycloud.toml @@ -93,6 +93,13 @@ cors = true ## POST /invoke. Keep off until named keys are live on OpenKey. ## Env: TINYCLOUD_ENCRYPTION_NETWORK__NAMED_KEYS_V1 # named_keys_v1 = false + ## Append-only file, outside the database, that records every deleted key + ## (one JSON line: networkId, keyPath, keyVersion, deletedAt). Required + ## while `named_keys_v1` is on; an absolute path on durable storage that is + ## backed up separately from the database. A restore re-applies it with + ## `tinycloud node deletion-ledger replay`. + ## Env: TINYCLOUD_ENCRYPTION_NETWORK__DELETION_LEDGER_PATH + # deletion_ledger_path = "/var/lib/tinycloud/deletion-ledger.jsonl" [global.sql] ## Accept the `tables` / `columns` / `readOnly` caveat form on From e8b85edc783ce553b8257f29dd2e623297bacefa Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:20:42 +0000 Subject: [PATCH 03/29] encryption: apps/ depth rule, key kind from path and app id host binding A key path whose first segment is exactly apps needs three segments for a key and two for a prefix. The kind of a key follows from its path. App ids are validated and can be checked against a host name. Details: TC-1777 --- Cargo.lock | 1 + tinycloud-core/Cargo.toml | 2 + .../src/encryption_network/app_id.rs | 140 ++++++ .../src/encryption_network/key_id.rs | 376 +++++++++++--- tinycloud-core/src/encryption_network/mod.rs | 1 + ...m20261022_000000_encryption_network_key.rs | 16 +- tinycloud-core/src/models/invocation.rs | 16 +- tinycloud-core/src/types/resource.rs | 22 +- .../fixtures/encryption/app-key-vectors.json | 471 ++++++++++++++++++ 9 files changed, 955 insertions(+), 90 deletions(-) create mode 100644 tinycloud-core/src/encryption_network/app_id.rs create mode 100644 tinycloud-core/tests/fixtures/encryption/app-key-vectors.json 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/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/key_id.rs b/tinycloud-core/src/encryption_network/key_id.rs index 7f374661..201d902f 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; 21] = [ "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,6 +343,9 @@ 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", ]; @@ -303,6 +384,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 +410,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 +466,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 +487,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 +512,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 +526,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 +552,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 +570,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 +581,202 @@ 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/apps/x", OWNER)); + 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}"); + } } } 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/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/invocation.rs b/tinycloud-core/src/models/invocation.rs index 99b51569..3bed1e7e 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", @@ -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( @@ -2147,7 +2147,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 +2178,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 From 9f7378a0d5ef3e66ac11b2766323c6a95673d110 Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:20:42 +0000 Subject: [PATCH 04/29] encryption: seeded deletable keys, state guards, reinstall and uniform public GET under apps/ Deletable keys carry a sealed per-key seed opened only with the strict unseal. Create, block and unblock run under the network row lock and refuse deleted rows. Create reuses a deleted deletable row with a new seed and the next key version. The public GET answers one 404 for every path under apps/. A startup check refuses key rows under apps/ that are not deletable. Details: TC-1777 --- .../src/encryption_network/backend.rs | 241 ++++++- tinycloud-core/src/encryption_network/keys.rs | 369 +++++++++-- .../src/encryption_network/service.rs | 49 +- .../src/encryption_network/tests.rs | 617 +++++++++++++++--- tinycloud-node-server/src/lib.rs | 3 + .../src/routes/encryption.rs | 6 +- 6 files changed, 1105 insertions(+), 180 deletions(-) diff --git a/tinycloud-core/src/encryption_network/backend.rs b/tinycloud-core/src/encryption_network/backend.rs index 64865023..4d0e066f 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,105 @@ 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()); + // The lenient unseal returns input without the version byte unchanged. + // A zeroed value would therefore open as an all-zero seed; the strict + // unseal refuses it. + 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/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/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..0a6f5104 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,479 @@ 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 zeroed (not a sealed value). + svc.create_key(&key_loc("apps/foo/y"), true).await.unwrap(); + force_row(&db, "apps/foo/y", "active", Some(vec![0u8; 61])).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 is refused by the strict unseal when the key is read; the + // lenient unseal would have returned it unchanged. + 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 derived key is still served for a missing path. + 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())); +} 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/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 From 3802af9713805fb9da7161bd235787d8fac5818c Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:20:43 +0000 Subject: [PATCH 05/29] encryption: bind app ids in SIWE roots to the signing domain; route tests for app keys Details: TC-1777 --- tinycloud-core/src/models/delegation.rs | 270 +++++++++- .../src/policy_v3/named_keys_gate_tests.rs | 85 ++++ .../src/policy_v3/test_harness.rs | 14 +- .../src/routes/tests/encryption_keys.rs | 477 +++++++++++++++++- 4 files changed, 819 insertions(+), 27 deletions(-) diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index e9f93e43..b72b78ef 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,44 @@ 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 (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 domain = &cacao.payload().domain; + 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 = domain.port().is_none() + && !domain.as_str().contains('@') + && 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 +406,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 +1705,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 +1761,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 +1825,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 +1899,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 +1913,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 +1931,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 +1948,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, @@ -1925,7 +1969,7 @@ mod tests { #[tokio::test] async fn malformed_key_identifiers_are_refused_in_delegations() { let db = database().await; - let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/"); + let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/"); let mut skipped = 0; for (index, resource) in key_id::MALFORMED_KEY_IDS.iter().enumerate() { // Ids that fail URI parsing cannot be built into a capability, so there is nothing to admit or refuse. @@ -2012,9 +2056,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!( @@ -2106,4 +2150,204 @@ 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 `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(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: Some("https".parse().unwrap()), + domain: domain.parse().unwrap(), + address: [1; 20], + statement: None, + uri: "did:key:z6MkTestDelegatee".parse().unwrap(), + version: Version::V1, + chain_id: 1, + nonce: "n2bnonce".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(), + ability.parse().unwrap(), + vec![BTreeMap::::new()], + ); + } + util::DelegationInfo::try_from(wrap(recap.build_message(message(domain)).unwrap())).unwrap() + } + + 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}" + ); + } + } + + #[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 = 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(); + // A SIWE domain is a bare ASCII authority: the scheme is not part + // of it, and a name that is not ASCII cannot be one. + let Some(domain) = origin.strip_prefix("https://") else { + continue; + }; + if !domain.is_ascii() { + continue; + } + decided += 1; + let info = siwe_key_grants(domain, &[(&format!("apps/{id}/x"), KEY_CREATE)]); + assert_eq!( + validate(&db, &info).await.is_ok(), + bound, + "origin {origin} id {id}" + ); + } + assert!(decided >= 18, "decided {decided}"); + } } 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..53172337 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,88 @@ 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); diff --git a/tinycloud-node-server/src/policy_v3/test_harness.rs b/tinycloud-node-server/src/policy_v3/test_harness.rs index f84bc164..b99dee27 100644 --- a/tinycloud-node-server/src/policy_v3/test_harness.rs +++ b/tinycloud-node-server/src/policy_v3/test_harness.rs @@ -409,6 +409,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 +442,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, @@ -460,6 +470,7 @@ impl Harness { 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 +491,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/tests/encryption_keys.rs b/tinycloud-node-server/src/routes/tests/encryption_keys.rs index 5b6fe5a1..8bc2a4a4 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,427 @@ 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 still answers with its 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(()) +} From 5fe7c1d8f66a8cc7d6200936c74f26ec4f25ef1a Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:20:43 +0000 Subject: [PATCH 06/29] docs: describe named key kinds, path rule and state guards Details: TC-1777 --- CHANGELOG.md | 5 ++ docs/encryption-named-keys.md | 100 ++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 docs/encryption-named-keys.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a5d8518..ebb605c8 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 keep 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. 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/docs/encryption-named-keys.md b/docs/encryption-named-keys.md new file mode 100644 index 00000000..977cf5d7 --- /dev/null +++ b/docs/encryption-named-keys.md @@ -0,0 +1,100 @@ +# 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 domain has no port, 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. From 6320b14e2e5a988f642aafde3e104f7c0f7fa1ae Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:35:06 +0000 Subject: [PATCH 07/29] encryption: host-binding vector test skips names a SIWE domain or app id cannot carry Details: TC-1777 --- tinycloud-core/src/models/delegation.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index b72b78ef..79c2ddaa 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -2190,7 +2190,7 @@ mod tests { recap.with_action( format!("urn:tinycloud:encryption:{owner}:default/{tail}") .parse() - .unwrap(), + .unwrap_or_else(|e| panic!("resource {tail}: {e:?}")), ability.parse().unwrap(), vec![BTreeMap::::new()], ); @@ -2332,12 +2332,13 @@ mod tests { let origin = case["origin"].as_str().unwrap(); let id = case["id"].as_str().unwrap(); let bound = case["bound"].as_bool().unwrap(); - // A SIWE domain is a bare ASCII authority: the scheme is not part - // of it, and a name that is not ASCII cannot be one. + // A SIWE domain is a bare ASCII authority and an app id is ASCII: + // neither can carry a Unicode name, and a domain cannot have a + // trailing dot (those cases are decided on the host in `app_id`). let Some(domain) = origin.strip_prefix("https://") else { continue; }; - if !domain.is_ascii() { + if !domain.is_ascii() || !id.is_ascii() || domain.ends_with('.') { continue; } decided += 1; From 65e103f5e3d4c339445f562deaf8d6b42f8dc705 Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:37:12 +0000 Subject: [PATCH 08/29] encryption: format delete credential route tests Details: TC-1777 --- tinycloud-node-server/src/routes/tests/key_delete.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tinycloud-node-server/src/routes/tests/key_delete.rs b/tinycloud-node-server/src/routes/tests/key_delete.rs index beafeb1a..6ba5c461 100644 --- a/tinycloud-node-server/src/routes/tests/key_delete.rs +++ b/tinycloud-node-server/src/routes/tests/key_delete.rs @@ -142,10 +142,7 @@ fn owner_ucan( async fn fixture() -> Result { let mut config = Config::default(); config.encryption_network.named_keys_v1 = true; - KvSyncFixture::new_configured(config, |rocket| { - rocket.mount("/", rocket::routes![revoke]) - }) - .await + KvSyncFixture::new_configured(config, |rocket| rocket.mount("/", rocket::routes![revoke])).await } async fn post(fixture: &KvSyncFixture, uri: &str, auth: String) -> (Status, String) { From c1d99a86bab6424ee19466b1a548e4583c767570 Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:44:22 +0000 Subject: [PATCH 09/29] encryption: zeroed-seed row test uses a 32-byte seed Details: TC-1777 --- tinycloud-core/src/encryption_network/tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tinycloud-core/src/encryption_network/tests.rs b/tinycloud-core/src/encryption_network/tests.rs index 0a6f5104..19732ed6 100644 --- a/tinycloud-core/src/encryption_network/tests.rs +++ b/tinycloud-core/src/encryption_network/tests.rs @@ -1865,9 +1865,10 @@ async fn rows_that_cannot_be_read_as_keys_are_corrupt() { // 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 zeroed (not a sealed value). + // A deletable row whose seed is 32 zero bytes, not a sealed value: the + // lenient unseal would return it unchanged as an all-zero seed. svc.create_key(&key_loc("apps/foo/y"), true).await.unwrap(); - force_row(&db, "apps/foo/y", "active", Some(vec![0u8; 61])).await; + 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; From 102949259297ff906b32c0530dd181a3822490a6 Mon Sep 17 00:00:00 2001 From: dryrun Date: Fri, 23 Oct 2026 23:59:45 +0000 Subject: [PATCH 10/29] encryption: test that an app id extending another does not count towards its prefix cap Details: TC-1777 --- .../src/encryption_network/tests.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tinycloud-core/src/encryption_network/tests.rs b/tinycloud-core/src/encryption_network/tests.rs index 19732ed6..fbc6b53a 100644 --- a/tinycloud-core/src/encryption_network/tests.rs +++ b/tinycloud-core/src/encryption_network/tests.rs @@ -2085,3 +2085,22 @@ async fn postgres_block_and_unblock_take_and_release_the_network_row_lock() { .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:?}" + ); +} From aeb54b242a26ee47b7c0d2328a79a06c1578b772 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 00:27:23 +0000 Subject: [PATCH 11/29] encryption: document key delete and the deletion ledger; bind test app ids to the signing domain Details: TC-1777 --- CHANGELOG.md | 2 + docs/encryption-named-keys.md | 61 +++++++++++++++++++ .../src/policy_v3/named_keys_gate_tests.rs | 4 ++ .../src/routes/tests/key_delete.rs | 16 +++-- 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb605c8..026359cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - 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: `POST /encryption/networks//key-actions/delete` (served when `named_keys_v1` is on) deletes one key under `apps/` (`keyPath` without a trailing `/`) or every key under `apps//` (`keyPath` with one). The network owner signs the request: a UCAN invocation for a `did:key` owner, a SIWE CACAO for a `did:pkh` owner, valid for at most 300 seconds and usable once. The response is `{ keyPath, state, deleted, alreadyDeleted, count }`; a deterministic key answers 409 `key_not_deletable`. A deleted key keeps its path with state `deleted` and no seed. Details: TC-1777. +- Configuration: adds `[global.encryption_network] deletion_ledger_path`, required while `named_keys_v1` is on. Every deleted key is appended to this file before the database commit; `tinycloud node deletion-ledger replay` re-applies it to a database. 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 keep 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. diff --git a/docs/encryption-named-keys.md b/docs/encryption-named-keys.md index 977cf5d7..920e4419 100644 --- a/docs/encryption-named-keys.md +++ b/docs/encryption-named-keys.md @@ -87,6 +87,67 @@ 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`. +## Delete + +`POST /encryption/networks//key-actions/delete` with body +`{ "keyPath": "" }`. The network owner signs the request; it is never a +delegation and `key.delete` is refused in every delegation. + +- `keyPath` without a trailing `/` deletes one key. A deterministic key (a path + outside `apps/`) answers 409 `key_not_deletable`; a missing key answers 404 + `key_not_found`. +- `keyPath` `apps//` (exactly two segments and one trailing `/`) deletes + every key under it. Any other path ending in `/` answers 400 + `key_path_invalid`. Keys are selected by an exact byte comparison of the + prefix, so `apps/foo_bar/` never selects `apps/foo.bar/…`. If a selected row + is not a deletable key, nothing changes and the answer is 409 + `key_not_deletable`. +- A deleted key keeps its row with state `deleted` and no seed. `keyVersion` is + unchanged until the path is created again. +- The response is `{ keyPath, state: "deleted", deleted, alreadyDeleted, + count }`; `deleted` lists the keys this call deleted (in path order), + `alreadyDeleted` those that already were, and `count` is the length of + `deleted`. Repeating a delete changes nothing. +- Each deleted key adds one `encryption_audit` row (outcome `deleted`, request + hash `sha256(credential CID || 0x00 || key path)`) and one line in the + deletion ledger. + +Credential for a `did:key` owner: a UCAN invocation by the owner, no proofs, +one capability `tinycloud.encryption/key.delete` on the exact key or prefix URN +(a prefix keeps its trailing `/`), `key-admin/v1` facts naming this node, the +network, the body `keyPath`, the action and the body hash, and a nonce. + +Credential for a `did:pkh:eip155` owner: a SIWE CACAO signed by the owner whose +ReCap is exactly `{ : ["tinycloud.encryption/key.delete"] }`, +whose only resource is that ReCap, whose `uri` is +`urn:tinycloud:encryption:key-delete:`, and whose statement is +`Delete all TinyCloud keys under .` (a prefix) or +`Delete the TinyCloud key .` (one key), then a space and the ReCap +text. The URL network and the body `keyPath` must name the ReCap URN. + +Both credentials last at most 300 seconds (`exp - iat`; a UCAN has no `iat`, so +`exp - now`), with `iat` and `nbf` at most 30 seconds ahead. The `(owner, +nonce)` pair is consumed before the keys are touched, so a delete that fails +after verification still uses up the signature; it is kept until 30 seconds +after `exp`. Credential refusals answer 403 `owner_only`. + +### Deletion ledger + +`[global.encryption_network] deletion_ledger_path` names an append-only file +outside the database; it is required while `named_keys_v1` is on. One JSON line +per deleted key: `{ "networkId", "keyPath", "keyVersion", "deletedAt" }`. The +lines of a delete are written and fsynced after its database changes and before +the transaction commits, so the ledger holds at least every committed delete. If +the ledger cannot be written the delete is rolled back and answers 500 +`deletion_ledger_unavailable`. + +After restoring a database, before starting the node, run +`tinycloud node deletion-ledger replay [--config ] [--ledger ]`. It +sets every listed key to `deleted` with no seed (a row with a newer +`keyVersion` than the line is left alone), applies each line once, and exits +non-zero after applying the valid lines if the file has lines that are not +entries. + ## Startup check The node does not start while a row in `encryption_network_key` under `apps/` 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 7d92ba82..c9a31719 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 @@ -241,8 +241,12 @@ const APP_ID_NOT_BOUND: &str = "app_id_not_bound"; async fn siwe_app_grants_need_an_app_id_bound_to_the_domain( backend: Backend, ) -> anyhow::Result<()> { + // Named keys on needs a deletion ledger path. + let ledger_dir = tempfile::tempdir()?; + let ledger_path = ledger_dir.path().join("deletion-ledger.jsonl"); let on = |config: &mut crate::config::Config| { config.encryption_network.named_keys_v1 = true; + config.encryption_network.deletion_ledger_path = Some(ledger_path.clone()); }; let window = || Window::until(now() + Duration::hours(1)); diff --git a/tinycloud-node-server/src/routes/tests/key_delete.rs b/tinycloud-node-server/src/routes/tests/key_delete.rs index 92427e5a..667b624f 100644 --- a/tinycloud-node-server/src/routes/tests/key_delete.rs +++ b/tinycloud-node-server/src/routes/tests/key_delete.rs @@ -179,7 +179,7 @@ async fn delegate_refuses_key_delete_on_every_target_shape() -> Result<()> { let (status, body) = post(&fixture, "/delegate", control).await; assert_eq!(status, Status::Ok, "{body}"); let control = wallet.cacao( - &prefix_urn(&wallet_did, "foo"), + &prefix_urn(&wallet_did, "com.example.foo"), KEY_CREATE, None, &device, @@ -193,17 +193,17 @@ async fn delegate_refuses_key_delete_on_every_target_shape() -> Result<()> { ( "app prefix", prefix_urn(&did_key_owner, "foo"), - prefix_urn(&wallet_did, "foo"), + prefix_urn(&wallet_did, "com.example.foo"), ), ( "deeper prefix", format!("urn:tinycloud:encryption:{did_key_owner}:default/apps/foo/x/"), - format!("urn:tinycloud:encryption:{wallet_did}:default/apps/foo/x/"), + format!("urn:tinycloud:encryption:{wallet_did}:default/apps/com.example.foo/x/"), ), ( "exact key", key_urn(&did_key_owner, "apps/foo/token"), - key_urn(&wallet_did, "apps/foo/token"), + key_urn(&wallet_did, "apps/com.example.foo/token"), ), ( "reserved segment prefix", @@ -247,7 +247,7 @@ async fn an_owner_delete_credential_is_not_a_delegation() -> Result<()> { let fixture = fixture().await?; let wallet = Wallet::new(3); let wallet_did = wallet.did(); - let prefix = prefix_urn(&wallet_did, "foo"); + let prefix = prefix_urn(&wallet_did, "com.example.foo"); let credential = wallet.cacao( &prefix, KEY_DELETE, @@ -536,10 +536,8 @@ async fn delete_refuses_a_credential_that_does_not_match_the_request() -> Result let auth = delete_auth(&fixture, &network, "apps/foo/")?; let other_network = format!("{}x", network); let (status, text) = delete_with(&fixture, &other_network, "apps/foo/", &auth).await; - assert!( - matches!(status, Status::BadRequest | Status::Forbidden), - "{status} {text}" - ); + assert_eq!(status, Status::Forbidden, "{text}"); + assert!(owner_only(&text), "{text}"); // No Authorization header. let response = fixture From 7a5b1db05fda3f3fd661962ad9ee1cd13420b0ed Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 00:40:13 +0000 Subject: [PATCH 12/29] encryption: settle deletion ledger operations, require nbf on delete invocations, and fail closed on a moved ledger - A delete invocation (UCAN) carries nbf and lives at most 300 seconds from it. - The ledger holds one line per delete operation, settled by a committed or aborted line; open operations are settled against the database at every start; replay applies committed and open operations and skips aborted ones, keys that are not deletable, and paths the key grammar refuses. - Appends fail when the ledger file was renamed or removed; the file mode is 0600. - Expired owner-delete nonces are pruned; the request network must be the target's network. - The shared vector file carries the delete prefix and exact statement vectors. Details: TC-1777 --- .../src/encryption_network/delete_auth.rs | 90 ++-- .../encryption_network/delete_auth_tests.rs | 179 ++++++-- .../src/encryption_network/deletion_ledger.rs | 400 ++++++++++++++---- .../deletion_ledger_tests.rs | 380 +++++++++++++---- .../src/encryption_network/key_delete.rs | 97 +++-- .../encryption_network/key_delete_tests.rs | 130 +++++- .../src/encryption_network/service.rs | 7 + .../fixtures/encryption/app-key-vectors.json | 37 ++ tinycloud-node-server/src/cli.rs | 6 +- tinycloud-node-server/src/lib.rs | 18 +- .../src/routes/tests/key_delete.rs | 166 ++++++-- 11 files changed, 1208 insertions(+), 302 deletions(-) diff --git a/tinycloud-core/src/encryption_network/delete_auth.rs b/tinycloud-core/src/encryption_network/delete_auth.rs index d0b826d6..82c3aff8 100644 --- a/tinycloud-core/src/encryption_network/delete_auth.rs +++ b/tinycloud-core/src/encryption_network/delete_auth.rs @@ -27,14 +27,20 @@ use tinycloud_auth::{ siwe_recap::{Capability as Recap, VerificationError, RESOURCE_PREFIX}, }; -use crate::{did_resolution::verify_ucan_signature, hash::hash, util::InvocationInfo}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + +use crate::{ + did_resolution::verify_ucan_signature, hash::hash, models::encryption_nonce, + util::InvocationInfo, +}; use super::{ canonical::canonical_hash, key_id::{self, EncryptionResource, KeyLocation}, + keys::network_of, network_id::NetworkId, protocol::{KeyAdminFacts, KEY_ADMIN_TYPE, KEY_DELETE_ACTION}, - service::{EncryptionService, EncryptionServiceError}, + service::{now_rfc3339, EncryptionService, EncryptionServiceError}, }; /// The SIWE `uri` of an owner delete names the node it is for. @@ -125,14 +131,22 @@ impl DeleteTarget { } /// The SIWE statement the owner signs: the target, then the EIP-5573 - /// recap sentence for the one capability. - fn siwe_statement(&self, recap: &Recap) -> String { + /// recap sentence for the one capability, joined by one space. + pub fn siwe_statement(&self) -> Result { let lead = if self.prefix { format!("Delete all TinyCloud keys under {}.", self.urn()) } else { format!("Delete the TinyCloud key {}.", self.urn()) }; - format!("{lead} {}", recap.to_statement()) + let mut recap = Recap::::default(); + recap + .with_action_convert( + self.urn(), + KEY_DELETE_ACTION, + Vec::>::new(), + ) + .map_err(|_| refuse("the delete target cannot be stated"))?; + Ok(format!("{lead} {}", recap.to_statement())) } } @@ -175,11 +189,10 @@ fn decode_credential(header: &str) -> Result<(Credential, Vec), EncryptionSe Ok((credential, canonical)) } -/// The one lifetime rule: `now < exp`; with an `iat`, `exp - iat <= 300 s` and -/// `iat <= now + 30 s`; `nbf <= now + 30 s`. A UCAN carries no `iat`, so -/// `now` stands in for it: `exp - now <= 300 s`. -fn check_lifetime( - iat: Option, +/// The lifetime rule of a SIWE credential: `now < exp`, `0 < exp - iat <= 300 s`, +/// `iat <= now + 30 s` and `nbf <= now + 30 s`. +fn check_cacao_lifetime( + iat: i64, nbf: Option, exp: i64, now: i64, @@ -187,19 +200,13 @@ fn check_lifetime( if now >= exp { return Err(refuse("credential has expired")); } - let start = match iat { - Some(iat) => { - if iat > now + CLOCK_SKEW_SECS { - return Err(refuse("credential is not yet valid")); - } - if exp <= iat { - return Err(refuse("credential expires before it is issued")); - } - iat - } - None => now, - }; - if exp - start > MAX_LIFETIME_SECS { + if iat > now + CLOCK_SKEW_SECS { + return Err(refuse("credential is not yet valid")); + } + if exp <= iat { + return Err(refuse("credential expires before it is issued")); + } + if exp - iat > MAX_LIFETIME_SECS { return Err(refuse("credential lifetime exceeds 300 seconds")); } if nbf.is_some_and(|nbf| nbf > now + CLOCK_SKEW_SECS) { @@ -208,6 +215,26 @@ fn check_lifetime( Ok(()) } +/// The lifetime rule of a UCAN credential. A UCAN has no `iat`, so it must +/// carry `nbf`, the time it was signed: `nbf <= now + 30 s`, `now < exp` and +/// `0 < exp - nbf <= 300 s`. +fn check_ucan_lifetime(nbf: Option, exp: i64, now: i64) -> Result<(), EncryptionServiceError> { + let nbf = nbf.ok_or_else(|| refuse("a delete invocation must carry nbf"))?; + if nbf > now + CLOCK_SKEW_SECS { + return Err(refuse("credential is not yet valid")); + } + if now >= exp { + return Err(refuse("credential has expired")); + } + if exp <= nbf { + return Err(refuse("credential expires before it is valid")); + } + if exp - nbf > MAX_LIFETIME_SECS { + return Err(refuse("credential lifetime exceeds 300 seconds")); + } + Ok(()) +} + fn check_nonce(nonce: &str) -> Result<(), EncryptionServiceError> { if nonce.is_empty() || nonce.len() > MAX_NONCE_BYTES { return Err(refuse("credential nonce is missing or too long")); @@ -243,6 +270,10 @@ pub async fn verify_owner_delete( body_value: &Value, now: i64, ) -> Result { + // The URL network and the target name one network. + if &network_of(target.location())? != network { + return Err(refuse("the request network is not the target's network")); + } let owner = network.owner_did(); let (credential, canonical) = decode_credential(header)?; let auth_cid = hash(&canonical).to_cid(0x55).to_string(); @@ -320,7 +351,7 @@ async fn verify_ucan( } let exp = payload.expiration.as_seconds() as i64; let nbf = payload.not_before.map(|t| t.as_seconds() as i64); - check_lifetime(None, nbf, exp, now)?; + check_ucan_lifetime(nbf, exp, now)?; let nonce = payload.nonce.as_deref().unwrap_or_default(); check_nonce(nonce)?; Ok((nonce.to_string(), exp)) @@ -392,7 +423,7 @@ async fn verify_cacao( if message.uri.as_str() != format!("{KEY_DELETE_URI_PREFIX}{node_did}") { return Err(refuse("the SIWE uri does not name this node")); } - if message.statement.as_deref() != Some(target.siwe_statement(&recap).as_str()) { + if message.statement.as_deref() != Some(target.siwe_statement()?.as_str()) { return Err(refuse("the SIWE statement does not state the delete")); } @@ -407,7 +438,7 @@ async fn verify_cacao( .map(seconds) .ok_or_else(|| refuse("the SIWE message has no expiration"))?; let nbf = message.not_before.as_ref().map(seconds); - check_lifetime(Some(iat), nbf, exp, now)?; + check_cacao_lifetime(iat, nbf, exp, now)?; check_nonce(&message.nonce)?; Ok((message.nonce.clone(), exp)) } @@ -427,6 +458,13 @@ impl EncryptionService { let now = OffsetDateTime::now_utc().unix_timestamp(); let verified = verify_owner_delete(header, &self.node_did, network, target, body_value, now).await?; + // Expired nonces are of no use: a credential past its `exp` is refused + // before its nonce is looked at. Timestamps are RFC 3339 UTC and sort + // as text. + encryption_nonce::Entity::delete_many() + .filter(encryption_nonce::Column::ExpiresAt.lt(now_rfc3339())) + .exec(&self.db) + .await?; self.consume_nonce_until( &verified.owner, &verified.nonce, diff --git a/tinycloud-core/src/encryption_network/delete_auth_tests.rs b/tinycloud-core/src/encryption_network/delete_auth_tests.rs index f8917992..471ec3ff 100644 --- a/tinycloud-core/src/encryption_network/delete_auth_tests.rs +++ b/tinycloud-core/src/encryption_network/delete_auth_tests.rs @@ -237,7 +237,7 @@ impl<'a> UcanSpec<'a> { facts: vec![admin_facts(network, target, body)], proof: vec![], exp: now + 300, - nbf: None, + nbf: Some(now), nonce: Some("urn:uuid:nonce-0001".to_string()), } } @@ -366,29 +366,32 @@ fn delete_prefix_follows_the_shared_vectors() { struct Vectors { #[serde(rename = "appIdValid")] app_ids: Vec, + #[serde(rename = "deletePrefix")] + delete_prefix: Vec, } #[derive(serde::Deserialize)] struct AppId { id: String, valid: bool, } + #[derive(serde::Deserialize)] + struct DeletePrefix { + prefix: String, + accepted: bool, + } let vectors: Vectors = serde_json::from_str(include_str!( "../../tests/fixtures/encryption/app-key-vectors.json" )) .unwrap(); let network = pkh_network(&Wallet::new(1)); - // The shared `deletePrefix` vectors that end in a slash; a path without - // one is a key delete, not a prefix delete. - for (prefix, accepted) in [ - ("apps/foo/", true), - ("apps/", false), - ("apps/foo/x/", false), - ("secrets/", false), - ] { + for case in vectors.delete_prefix { + // Without a trailing slash the path is a key delete and `apps/foo` is + // not a key. assert_eq!( - DeleteTarget::parse(&network, prefix).is_ok(), - accepted, - "{prefix:?}" + DeleteTarget::parse(&network, &case.prefix).is_ok(), + case.accepted, + "{:?}", + case.prefix ); } for case in vectors.app_ids { @@ -397,6 +400,50 @@ fn delete_prefix_follows_the_shared_vectors() { } } +#[test] +fn delete_statements_match_the_shared_vectors_exactly() { + #[derive(serde::Deserialize)] + struct Vectors { + #[serde(rename = "deleteUriPrefix")] + uri_prefix: String, + #[serde(rename = "deleteStatements")] + statements: Vec, + } + #[derive(serde::Deserialize)] + struct Statement { + owner: String, + network: String, + #[serde(rename = "keyPath")] + key_path: String, + statement: String, + } + let vectors: Vectors = serde_json::from_str(include_str!( + "../../tests/fixtures/encryption/app-key-vectors.json" + )) + .unwrap(); + assert_eq!(vectors.uri_prefix, KEY_DELETE_URI_PREFIX); + assert_eq!(vectors.statements.len(), 2, "one prefix and one key vector"); + for case in vectors.statements { + let network = NetworkId::new(case.owner, case.network).unwrap(); + let target = DeleteTarget::parse(&network, &case.key_path).unwrap(); + assert_eq!(target.siwe_statement().unwrap(), case.statement); + } +} + +#[tokio::test] +async fn a_request_network_other_than_the_targets_is_refused() { + let wallet = Wallet::new(1); + let network = pkh_network(&wallet); + let other = NetworkId::new(wallet.did(), "other".to_string()).unwrap(); + let target = prefix_target(&network, "foo"); + let at = now(); + let header = CacaoSpec::valid(&wallet, &target, at).header(&target); + assert_refused( + verify(&header, &other, &target, &body_for(&target), at).await, + "the target's network", + ); +} + // ----------------------------------------------------------- CACAO: accepted #[tokio::test] @@ -994,7 +1041,7 @@ async fn ucan_audience_and_facts_are_bound_to_the_request() { } #[tokio::test] -async fn ucan_lifetime_follows_the_rule() { +async fn ucan_lifetime_runs_from_nbf() { let (jwk, _, vm, network) = did_key_network(); let target = prefix_target(&network, "foo"); let body = body_for(&target); @@ -1005,30 +1052,61 @@ async fn ucan_lifetime_follows_the_rule() { spec.header() }; - verify(&spec(&|s| s.exp = at + 300), &network, &target, &body, at) + // exp - nbf = 300 is the longest accepted lifetime. + verify(&spec(&|_| {}), &network, &target, &body, at) .await .unwrap(); assert_refused( verify(&spec(&|s| s.exp = at + 301), &network, &target, &body, at).await, "lifetime exceeds 300 seconds", ); + // A long-lived credential is refused for its whole life, also near its + // end: signed 35760 s ago for 36000 s, verified 240 s before it expires. + let long = spec(&|s| { + s.nbf = Some(at - 35_760); + s.exp = at + 240; + }); assert_refused( - verify(&spec(&|s| s.exp = at), &network, &target, &body, at).await, + verify(&long, &network, &target, &body, at).await, + "lifetime exceeds 300 seconds", + ); + // Without nbf there is no start to measure from. + assert_refused( + verify(&spec(&|s| s.nbf = None), &network, &target, &body, at).await, + "must carry nbf", + ); + // now < exp. + let ended = spec(&|s| { + s.nbf = Some(at - 100); + s.exp = at; + }); + assert_refused( + verify(&ended, &network, &target, &body, at).await, "expired", ); + verify(&ended, &network, &target, &body, at - 1) + .await + .unwrap(); + // exp must come after nbf. + let inverted = spec(&|s| { + s.nbf = Some(at + 10); + s.exp = at + 5; + }); assert_refused( - verify( - &spec(&|s| s.nbf = Some(at + 31)), - &network, - &target, - &body, - at, - ) - .await, - "not yet valid", + verify(&inverted, &network, &target, &body, at).await, + "expires before it is valid", ); + let empty = spec(&|s| { + s.nbf = Some(at); + s.exp = at + 100; + }); + verify(&empty, &network, &target, &body, at).await.unwrap(); + // nbf up to 30 s ahead is skew; 31 s is not. verify( - &spec(&|s| s.nbf = Some(at + 30)), + &spec(&|s| { + s.nbf = Some(at + 30); + s.exp = at + 330; + }), &network, &target, &body, @@ -1036,6 +1114,20 @@ async fn ucan_lifetime_follows_the_rule() { ) .await .unwrap(); + assert_refused( + verify( + &spec(&|s| { + s.nbf = Some(at + 31); + s.exp = at + 331; + }), + &network, + &target, + &body, + at, + ) + .await, + "not yet valid", + ); assert_refused( verify(&spec(&|s| s.nonce = None), &network, &target, &body, at).await, "nonce", @@ -1137,6 +1229,43 @@ async fn an_owner_nonce_is_consumed_once_and_kept_past_exp() { assert_refused(second, "nonce was already used"); } +#[tokio::test] +async fn nonces_past_their_expiry_are_pruned_and_live_ones_kept() { + use sea_orm::ActiveModelTrait; + let wallet = Wallet::new(1); + let network = pkh_network(&wallet); + let target = prefix_target(&network, "foo"); + let body = body_for(&target); + let (service, db) = service().await; + for (requester, nonce, expires_at) in [ + ("did:key:old", "n-old", "2020-01-01T00:00:00Z"), + ("did:key:live", "n-live", "2999-01-01T00:00:00Z"), + ] { + encryption_nonce::ActiveModel { + requester_did: sea_orm::ActiveValue::Set(requester.to_string()), + nonce: sea_orm::ActiveValue::Set(nonce.to_string()), + expires_at: sea_orm::ActiveValue::Set(expires_at.to_string()), + } + .insert(&db) + .await + .unwrap(); + } + let header = CacaoSpec::valid(&wallet, &target, now()).header(&target); + service + .authorize_owner_delete(&header, &network, &target, &body) + .await + .unwrap(); + let mut kept: Vec = encryption_nonce::Entity::find() + .all(&db) + .await + .unwrap() + .into_iter() + .map(|row| row.nonce) + .collect(); + kept.sort(); + assert_eq!(kept, vec!["n-live".to_string(), "nonce-0001".to_string()]); +} + #[tokio::test] async fn a_refused_credential_does_not_burn_its_nonce() { let wallet = Wallet::new(1); diff --git a/tinycloud-core/src/encryption_network/deletion_ledger.rs b/tinycloud-core/src/encryption_network/deletion_ledger.rs index dafb7990..30a42a2b 100644 --- a/tinycloud-core/src/encryption_network/deletion_ledger.rs +++ b/tinycloud-core/src/encryption_network/deletion_ledger.rs @@ -1,14 +1,25 @@ //! Append-only deletion ledger, kept outside the database. //! -//! Every deleted key adds one JSON line `{networkId, keyPath, keyVersion, -//! deletedAt}`. The delete path appends and fsyncs the lines of a delete -//! before its database transaction commits, so a crash cannot lose a -//! deletion that the database recorded. [`replay`] re-applies a ledger to a -//! database (seed NULL, state `deleted`); a restore runs it as its first step. +//! One JSON line per delete OPERATION, then one line that settles it: //! -//! The file is only ever appended to. Replay is idempotent. +//! - an intent `{op, networkId, keys: [{keyPath, keyVersion}], deletedAt}`, +//! appended and fsynced after the delete's database changes and before the +//! transaction commits (`op` is the CID of the owner's credential); +//! - `{"committed": op}` after the commit, or `{"aborted": op}` when the commit +//! failed while the process lives. +//! +//! A crash between the intent and its settling line leaves the intent open. +//! Every node start settles open intents against the database before the +//! routes mount ([`DeletionLedger::settle_open_intents`]): rows deleted at that +//! version mean the commit happened, otherwise the delete is aborted. +//! +//! [`replay`] re-applies a ledger to a database (seed NULL, state `deleted`) +//! and is the first step of a restore. It applies committed and still-open +//! intents and skips aborted ones. The file is only ever appended to, never +//! rotated. use std::{ + collections::HashMap, fs::{File, OpenOptions}, io::{Read, Seek, SeekFrom, Write}, path::{Path, PathBuf}, @@ -20,21 +31,69 @@ use serde::{Deserialize, Serialize}; use crate::{db::begin_write_transaction, models::encryption_network_key}; -use super::service::{now_rfc3339, EncryptionServiceError}; - -/// Key row state written by a delete. -pub const KEY_STATE_DELETED: &str = "deleted"; +use super::{ + key_id::{KeyKind, KeyLocation}, + keys::KeyState, + network_id::NetworkId, + service::{now_rfc3339, EncryptionServiceError}, +}; -/// One deleted key. +/// One key a delete operation deleted. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct LedgerEntry { - pub network_id: String, +pub struct LedgerKey { pub key_path: String, pub key_version: i64, +} + +/// One delete operation: every key it deleted, in one line. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Intent { + /// CID of the credential the owner signed. + pub op: String, + pub network_id: String, + pub keys: Vec, pub deleted_at: String, } +/// The operation `committed` names has committed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Committed { + pub committed: String, +} + +/// The operation `aborted` names did not commit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Aborted { + pub aborted: String, +} + +/// A ledger line. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Record { + Intent(Intent), + Committed(Committed), + Aborted(Aborted), +} + +impl Record { + pub fn committed(op: &str) -> Self { + Self::Committed(Committed { + committed: op.to_string(), + }) + } + + pub fn aborted(op: &str) -> Self { + Self::Aborted(Aborted { + aborted: op.to_string(), + }) + } +} + #[derive(Debug, thiserror::Error)] pub enum DeletionLedgerError { #[error("deletion ledger I/O error: {0}")] @@ -43,6 +102,10 @@ pub enum DeletionLedgerError { Path(&'static str), #[error("deletion ledger entry could not be encoded: {0}")] Encode(#[from] serde_json::Error), + #[error("deletion ledger file was renamed or removed")] + Moved, + #[error("deletion ledger line {0} is malformed")] + Malformed(usize), #[error("database error: {0}")] Db(#[from] sea_orm::DbErr), } @@ -81,13 +144,16 @@ pub fn validate_path(path: &Path) -> Result<(), &'static str> { pub struct DeletionLedger { path: PathBuf, file: Mutex, + dropped_tail_bytes: usize, } impl DeletionLedger { - /// Opens (creating if absent) the ledger for appending. The file is - /// created with mode 0600. A final line left without its newline by a - /// crash is terminated so later appends start on a fresh line. The file - /// and its directory are fsynced before this returns. + /// Opens (creating if absent) the ledger for appending. The file mode is + /// forced to 0600. A final line that a crash left unterminated is kept if + /// it is a whole record and otherwise dropped: a record is acted on only + /// after its append returned, so an unterminated partial line was never + /// followed by a commit. The file and its directory are fsynced before + /// this returns. pub fn open(path: &Path) -> Result { validate_path(path).map_err(DeletionLedgerError::Path)?; let mut options = OpenOptions::new(); @@ -98,15 +164,12 @@ impl DeletionLedger { options.mode(0o600); } let mut file = options.open(path)?; - let length = file.metadata()?.len(); - if length > 0 { - let mut last = [0u8; 1]; - file.seek(SeekFrom::Start(length - 1))?; - file.read_exact(&mut last)?; - if last[0] != b'\n' { - file.write_all(b"\n")?; - } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; } + let dropped_tail_bytes = repair_tail(&mut file)?; file.sync_all()?; if let Some(parent) = path.parent() { File::open(parent)?.sync_all()?; @@ -114,6 +177,7 @@ impl DeletionLedger { Ok(Self { path: path.to_path_buf(), file: Mutex::new(file), + dropped_tail_bytes, }) } @@ -123,6 +187,7 @@ impl DeletionLedger { Self { path: path.to_path_buf(), file: Mutex::new(file), + dropped_tail_bytes: 0, } } @@ -130,58 +195,180 @@ impl DeletionLedger { &self.path } - /// Appends one line per entry in a single write, then fsyncs. Returns only - /// after the lines are durable. Blocking; async callers use - /// [`DeletionLedger::append_blocking_task`]. - pub fn append(&self, entries: &[LedgerEntry]) -> Result<(), DeletionLedgerError> { - if entries.is_empty() { - return Ok(()); - } - let mut lines = Vec::new(); - for entry in entries { - serde_json::to_writer(&mut lines, entry)?; - lines.push(b'\n'); - } + /// Bytes of an unfinished final line that [`DeletionLedger::open`] removed. + pub fn dropped_tail_bytes(&self) -> usize { + self.dropped_tail_bytes + } + + /// Appends one line and fsyncs. Returns only after the line is durable, and + /// fails, writing nothing, if the file the handle names is no longer the + /// file at the ledger path. Blocking; async callers use + /// [`DeletionLedger::append_async`]. + pub fn append(&self, record: &Record) -> Result<(), DeletionLedgerError> { + let mut line = serde_json::to_vec(record)?; + line.push(b'\n'); let mut file = self .file .lock() .unwrap_or_else(|poison| poison.into_inner()); - file.write_all(&lines)?; + self.check_not_moved(&file)?; + file.write_all(&line)?; file.sync_all()?; Ok(()) } /// [`DeletionLedger::append`] on the blocking pool. - pub async fn append_blocking_task( - self: &Arc, - entries: Vec, - ) -> Result<(), DeletionLedgerError> { + pub async fn append_async(self: &Arc, record: Record) -> Result<(), DeletionLedgerError> { let ledger = Arc::clone(self); - tokio::task::spawn_blocking(move || ledger.append(&entries)) + tokio::task::spawn_blocking(move || ledger.append(&record)) .await .map_err(|err| std::io::Error::other(err.to_string()))? } + + #[cfg(unix)] + fn check_not_moved(&self, file: &File) -> Result<(), DeletionLedgerError> { + use std::os::unix::fs::MetadataExt; + let handle = file.metadata()?; + let on_disk = std::fs::metadata(&self.path).map_err(|_| DeletionLedgerError::Moved)?; + if handle.dev() != on_disk.dev() || handle.ino() != on_disk.ino() { + return Err(DeletionLedgerError::Moved); + } + Ok(()) + } + + #[cfg(not(unix))] + fn check_not_moved(&self, _file: &File) -> Result<(), DeletionLedgerError> { + Ok(()) + } + + /// Settles every open intent against the database: the delete committed + /// when each of its keys is `deleted` at the intent's key version (or at a + /// later one, after a re-create), otherwise it did not. Appends the + /// matching `committed` or `aborted` line. Runs at every node start, + /// before the routes mount. + pub async fn settle_open_intents( + &self, + db: &C, + ) -> Result { + let contents = read(&self.path)?; + let mut report = SettleReport::default(); + for intent in contents.open_intents() { + let mut committed = true; + for key in &intent.keys { + let row = encryption_network_key::Entity::find_by_id(( + intent.network_id.clone(), + key.key_path.clone(), + )) + .one(db) + .await?; + let deleted = row.is_some_and(|row| { + row.key_version > key.key_version + || (row.key_version == key.key_version + && row.state == KeyState::Deleted.as_str()) + }); + if !deleted { + committed = false; + break; + } + } + if committed { + self.append(&Record::committed(&intent.op))?; + report.committed += 1; + } else { + self.append(&Record::aborted(&intent.op))?; + report.aborted += 1; + } + } + Ok(report) + } +} + +/// Result of [`DeletionLedger::settle_open_intents`]. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] +pub struct SettleReport { + pub committed: usize, + pub aborted: usize, +} + +/// Keeps a final line that is a whole record (adding its newline) and removes +/// one that is not. Returns the bytes removed. +fn repair_tail(file: &mut File) -> Result { + file.seek(SeekFrom::Start(0))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + if bytes.last().is_none_or(|last| *last == b'\n') { + return Ok(0); + } + let start = bytes + .iter() + .rposition(|byte| *byte == b'\n') + .map_or(0, |at| at + 1); + if serde_json::from_slice::(&bytes[start..]).is_ok() { + file.write_all(b"\n")?; + return Ok(0); + } + file.set_len(start as u64)?; + Ok(bytes.len() - start) } /// What a ledger file holds. #[derive(Debug, Default, PartialEq, Eq)] pub struct LedgerContents { - pub entries: Vec, - /// 1-based numbers of non-empty lines that are not a ledger entry. - pub malformed_lines: Vec, + pub records: Vec, + /// The final line is not a whole record. + pub torn_tail: bool, } -/// Reads every line of a ledger file. +impl LedgerContents { + /// Intents that no `committed` or `aborted` line names. + pub fn open_intents(&self) -> Vec<&Intent> { + let settled: std::collections::HashSet<&str> = self + .records + .iter() + .filter_map(|record| match record { + Record::Committed(Committed { committed }) => Some(committed.as_str()), + Record::Aborted(Aborted { aborted }) => Some(aborted.as_str()), + Record::Intent(_) => None, + }) + .collect(); + self.intents() + .filter(|intent| !settled.contains(intent.op.as_str())) + .collect() + } + + fn intents(&self) -> impl Iterator { + self.records.iter().filter_map(|record| match record { + Record::Intent(intent) => Some(intent), + _ => None, + }) + } + + fn aborted_ops(&self) -> std::collections::HashSet<&str> { + self.records + .iter() + .filter_map(|record| match record { + Record::Aborted(Aborted { aborted }) => Some(aborted.as_str()), + _ => None, + }) + .collect() + } +} + +/// Reads every line of a ledger file. An unfinished final line is reported as +/// `torn_tail`; a malformed line anywhere else is an error. pub fn read(path: &Path) -> Result { let bytes = std::fs::read(path)?; + let lines: Vec<(usize, &[u8])> = bytes + .split(|byte| *byte == b'\n') + .enumerate() + .filter(|(_, line)| !line.iter().all(u8::is_ascii_whitespace)) + .collect(); let mut contents = LedgerContents::default(); - for (index, line) in bytes.split(|byte| *byte == b'\n').enumerate() { - if line.iter().all(u8::is_ascii_whitespace) { - continue; - } - match serde_json::from_slice::(line) { - Ok(entry) => contents.entries.push(entry), - Err(_) => contents.malformed_lines.push(index + 1), + for (position, (index, line)) in lines.iter().enumerate() { + match serde_json::from_slice::(line) { + Ok(record) => contents.records.push(record), + Err(_) if position + 1 == lines.len() => contents.torn_tail = true, + Err(_) => return Err(DeletionLedgerError::Malformed(index + 1)), } } Ok(contents) @@ -191,34 +378,54 @@ pub fn read(path: &Path) -> Result { #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct ReplayReport { - pub entries: usize, + /// Delete operations in the ledger. + pub operations: usize, + /// Operations skipped because they aborted. + pub aborted_operations: usize, /// Rows set to `deleted` with a NULL seed. pub applied: usize, /// Rows already `deleted` with no seed. pub already_deleted: usize, - /// Rows whose key version is newer than the ledger entry (a later - /// re-create): left alone. + /// Rows whose key version is newer than the ledger's (a later re-create): + /// left alone. pub newer_version: usize, - /// Entries with no row in this database. + /// Keys with no row in this database. pub missing_row: usize, - pub malformed_lines: Vec, + /// Keys whose row is not a deletable key: left alone. + pub not_deletable: usize, + /// Keys whose network id or path is not a valid key: skipped. + pub refused: usize, + /// The final line was not a whole record. + pub torn_tail: bool, } -/// Re-applies the ledger at `path`: every named key row whose version is at -/// most the entry's is set to `deleted` with a NULL seed. One transaction; -/// idempotent. Rows the database does not have are counted, not created. +/// Re-applies the ledger at `path`: every named key whose row is a deletable +/// key of at most the entry's version is set to `deleted` with a NULL seed. +/// Committed and still-open operations apply; aborted ones do not. One +/// transaction; idempotent. Rows the database does not have are counted, not +/// created. pub async fn replay( db: &C, path: &Path, ) -> Result { let contents = read(path)?; + let aborted = contents.aborted_ops(); let mut report = ReplayReport { - entries: contents.entries.len(), - malformed_lines: contents.malformed_lines, + torn_tail: contents.torn_tail, ..ReplayReport::default() }; + let mut to_apply = Vec::new(); + let mut seen = HashMap::new(); + for intent in contents.intents() { + report.operations += 1; + if aborted.contains(intent.op.as_str()) { + report.aborted_operations += 1; + } else if seen.insert(intent.op.as_str(), ()).is_none() { + to_apply.push(intent); + } + } let tx = begin_write_transaction(db, None).await?; - match apply(&tx, &contents.entries, &mut report).await { + match apply(&tx, &to_apply, &mut report).await { Ok(()) => tx.commit().await?, Err(err) => { let _ = tx.rollback().await; @@ -230,34 +437,51 @@ pub async fn replay( async fn apply( db: &C, - entries: &[LedgerEntry], + intents: &[&Intent], report: &mut ReplayReport, ) -> Result<(), DeletionLedgerError> { - for entry in entries { - let row = encryption_network_key::Entity::find_by_id(( - entry.network_id.clone(), - entry.key_path.clone(), - )) - .one(db) - .await?; - let Some(row) = row else { - report.missing_row += 1; + for intent in intents { + let Ok(network) = intent.network_id.parse::() else { + report.refused += intent.keys.len(); continue; }; - if row.key_version > entry.key_version { - report.newer_version += 1; - continue; - } - if row.state == KEY_STATE_DELETED && row.sealed_seed.is_none() { - report.already_deleted += 1; - continue; + for key in &intent.keys { + // A path the key grammar refuses is never applied. + if network.to_string() != intent.network_id + || KeyLocation::new(&network, &key.key_path).is_err() + { + report.refused += 1; + continue; + } + let row = encryption_network_key::Entity::find_by_id(( + intent.network_id.clone(), + key.key_path.clone(), + )) + .one(db) + .await?; + let Some(row) = row else { + report.missing_row += 1; + continue; + }; + if KeyKind::parse(&row.kind) != Some(KeyKind::Deletable) { + report.not_deletable += 1; + continue; + } + if row.key_version > key.key_version { + report.newer_version += 1; + continue; + } + if row.state == KeyState::Deleted.as_str() && row.sealed_seed.is_none() { + report.already_deleted += 1; + continue; + } + let mut active: encryption_network_key::ActiveModel = row.into(); + active.state = Set(KeyState::Deleted.as_str().to_string()); + active.sealed_seed = Set(None); + active.updated_at = Set(now_rfc3339()); + active.update(db).await?; + report.applied += 1; } - let mut active: encryption_network_key::ActiveModel = row.into(); - active.state = Set(KEY_STATE_DELETED.to_string()); - active.sealed_seed = Set(None); - active.updated_at = Set(now_rfc3339()); - active.update(db).await?; - report.applied += 1; } Ok(()) } diff --git a/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs b/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs index 8bed8674..0b55b1b2 100644 --- a/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs +++ b/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs @@ -1,4 +1,4 @@ -//! Deletion ledger: durable append, tail repair, and replay onto a database. +//! Deletion ledger: durable append, tail repair, settling and replay. use std::path::Path; @@ -8,21 +8,40 @@ use sea_orm::{ use sea_orm_migration::MigratorTrait; use super::deletion_ledger::{ - read, replay, validate_path, DeletionLedger, LedgerEntry, ReplayReport, + read, replay, validate_path, DeletionLedger, DeletionLedgerError, Intent, LedgerKey, Record, + ReplayReport, SettleReport, }; use crate::{migrations::Migrator, models::encryption_network_key}; -const NETWORK: &str = "did:key:z6MkOwner:default"; +const OWNER: &str = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"; -fn entry(path: &str, version: i64) -> LedgerEntry { - LedgerEntry { - network_id: NETWORK.to_string(), - key_path: path.to_string(), - key_version: version, +fn network() -> String { + super::NetworkId::new(OWNER.to_string(), "default".to_string()) + .unwrap() + .to_string() +} + +fn intent(op: &str, keys: &[(&str, i64)]) -> Intent { + Intent { + op: op.to_string(), + network_id: network(), + keys: keys + .iter() + .map(|(key_path, key_version)| LedgerKey { + key_path: key_path.to_string(), + key_version: *key_version, + }) + .collect(), deleted_at: "2026-10-23T12:00:00Z".to_string(), } } +fn ledger_in(dir: &tempfile::TempDir) -> (std::path::PathBuf, DeletionLedger) { + let path = dir.path().join("ledger.jsonl"); + let ledger = DeletionLedger::open(&path).unwrap(); + (path, ledger) +} + async fn fresh_db() -> DatabaseConnection { let db = Database::connect(ConnectOptions::new("sqlite::memory:".to_string())) .await @@ -31,11 +50,18 @@ async fn fresh_db() -> DatabaseConnection { db } -async fn insert_key(db: &DatabaseConnection, path: &str, state: &str, seed: bool, version: i64) { +async fn insert_key( + db: &DatabaseConnection, + path: &str, + kind: &str, + state: &str, + seed: bool, + version: i64, +) { encryption_network_key::ActiveModel { - network_id: Set(NETWORK.to_string()), + network_id: Set(network()), key_path: Set(path.to_string()), - kind: Set("deletable".to_string()), + kind: Set(kind.to_string()), state: Set(state.to_string()), public_key: Set(vec![7; 32]), sealed_seed: Set(seed.then(|| vec![9; 60])), @@ -48,8 +74,12 @@ async fn insert_key(db: &DatabaseConnection, path: &str, state: &str, seed: bool .unwrap(); } +async fn active_key(db: &DatabaseConnection, path: &str) { + insert_key(db, path, "deletable", "active", true, 1).await; +} + async fn row(db: &DatabaseConnection, path: &str) -> encryption_network_key::Model { - encryption_network_key::Entity::find_by_id((NETWORK.to_string(), path.to_string())) + encryption_network_key::Entity::find_by_id((network(), path.to_string())) .one(db) .await .unwrap() @@ -57,80 +87,140 @@ async fn row(db: &DatabaseConnection, path: &str) -> encryption_network_key::Mod } #[test] -fn append_writes_one_json_line_per_entry_and_reopening_keeps_appending() { +fn an_operation_is_one_line_and_is_settled_by_a_later_line() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("ledger.jsonl"); - let ledger = DeletionLedger::open(&path).unwrap(); + let (path, ledger) = ledger_in(&dir); + ledger + .append(&Record::Intent(intent( + "op-1", + &[("apps/a/x", 1), ("apps/a/y", 3)], + ))) + .unwrap(); + ledger.append(&Record::committed("op-1")).unwrap(); ledger - .append(&[entry("apps/a/x", 1), entry("apps/a/y", 3)]) + .append(&Record::Intent(intent("op-2", &[("apps/b/z", 1)]))) .unwrap(); + ledger.append(&Record::aborted("op-2")).unwrap(); drop(ledger); let ledger = DeletionLedger::open(&path).unwrap(); - ledger.append(&[entry("apps/b/z", 1)]).unwrap(); + ledger + .append(&Record::Intent(intent("op-3", &[("apps/c/z", 1)]))) + .unwrap(); let text = std::fs::read_to_string(&path).unwrap(); - assert_eq!(text.lines().count(), 3); + assert_eq!(text.lines().count(), 5); assert!(text.ends_with('\n')); let first: serde_json::Value = serde_json::from_str(text.lines().next().unwrap()).unwrap(); assert_eq!( first, serde_json::json!({ - "networkId": NETWORK, - "keyPath": "apps/a/x", - "keyVersion": 1, + "op": "op-1", + "networkId": network(), + "keys": [ + { "keyPath": "apps/a/x", "keyVersion": 1 }, + { "keyPath": "apps/a/y", "keyVersion": 3 }, + ], "deletedAt": "2026-10-23T12:00:00Z", }) ); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines[1], r#"{"committed":"op-1"}"#); + assert_eq!(lines[3], r#"{"aborted":"op-2"}"#); + let contents = read(&path).unwrap(); - assert_eq!( - contents.entries, - vec![ - entry("apps/a/x", 1), - entry("apps/a/y", 3), - entry("apps/b/z", 1) - ] - ); - assert!(contents.malformed_lines.is_empty()); + assert!(!contents.torn_tail); + let open: Vec<&str> = contents + .open_intents() + .into_iter() + .map(|i| i.op.as_str()) + .collect(); + assert_eq!(open, vec!["op-3"]); } #[cfg(unix)] #[test] -fn the_ledger_file_is_private_to_the_node_user() { +fn the_ledger_file_is_forced_to_mode_0600() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("ledger.jsonl"); DeletionLedger::open(&path).unwrap(); - let mode = std::fs::metadata(&path).unwrap().permissions().mode(); - assert_eq!(mode & 0o077, 0, "mode {mode:o}"); + let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&path), 0o600); + + // A file that exists with a wider mode is narrowed, not trusted. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + DeletionLedger::open(&path).unwrap(); + assert_eq!(mode(&path), 0o600); } #[test] -fn an_unterminated_last_line_is_closed_before_the_next_append() { +fn an_unfinished_final_line_is_dropped_and_a_whole_one_is_kept() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("ledger.jsonl"); - let mut torn = serde_json::to_string(&entry("apps/a/x", 1)).unwrap(); - torn.push('\n'); - torn.push_str("{\"networkId\":\"did:key:z6MkOw"); - std::fs::write(&path, torn).unwrap(); + let whole = serde_json::to_string(&Record::Intent(intent("op-1", &[("apps/a/x", 1)]))).unwrap(); + let partial = r#"{"op":"op-2","networkId":"did:key:z6Mk"#; + std::fs::write(&path, format!("{whole}\n{partial}")).unwrap(); let ledger = DeletionLedger::open(&path).unwrap(); - ledger.append(&[entry("apps/b/y", 1)]).unwrap(); - + assert_eq!(ledger.dropped_tail_bytes(), partial.len()); + ledger.append(&Record::committed("op-1")).unwrap(); let contents = read(&path).unwrap(); - assert_eq!( - contents.entries, - vec![entry("apps/a/x", 1), entry("apps/b/y", 1)] - ); - assert_eq!(contents.malformed_lines, vec![2]); + assert_eq!(contents.records.len(), 2); + assert!(!contents.torn_tail); + + // The same file with a whole last record that lacks only its newline. + let path = dir.path().join("whole.jsonl"); + std::fs::write(&path, &whole).unwrap(); + let ledger = DeletionLedger::open(&path).unwrap(); + assert_eq!(ledger.dropped_tail_bytes(), 0); + ledger.append(&Record::committed("op-1")).unwrap(); + assert_eq!(read(&path).unwrap().records.len(), 2); } #[test] -fn appending_nothing_writes_nothing() { +fn a_torn_final_line_is_reported_and_a_malformed_middle_line_is_an_error() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("ledger.jsonl"); - let ledger = DeletionLedger::open(&path).unwrap(); - ledger.append(&[]).unwrap(); - assert_eq!(std::fs::metadata(&path).unwrap().len(), 0); + let whole = serde_json::to_string(&Record::Intent(intent("op-1", &[("apps/a/x", 1)]))).unwrap(); + + std::fs::write(&path, format!("{whole}\n{{\"op\":\"op-2\",\"netw")).unwrap(); + let contents = read(&path).unwrap(); + assert!(contents.torn_tail); + assert_eq!(contents.records.len(), 1); + + std::fs::write(&path, format!("{whole}\nnot json\n{whole}\n")).unwrap(); + assert!(matches!( + read(&path), + Err(DeletionLedgerError::Malformed(2)) + )); +} + +#[test] +fn an_append_fails_closed_once_the_file_was_renamed_or_removed() { + let dir = tempfile::tempdir().unwrap(); + let (path, ledger) = ledger_in(&dir); + ledger.append(&Record::committed("before")).unwrap(); + + let moved = dir.path().join("moved.jsonl"); + std::fs::rename(&path, &moved).unwrap(); + assert!(matches!( + ledger.append(&Record::committed("after-rename")), + Err(DeletionLedgerError::Moved) + )); + assert!(!path.exists(), "nothing was created at the old path"); + assert_eq!(read(&moved).unwrap().records.len(), 1); + + // A different file at the path is not the open one either. + std::fs::write(&path, "").unwrap(); + assert!(matches!( + ledger.append(&Record::committed("after-replace")), + Err(DeletionLedgerError::Moved) + )); + std::fs::remove_file(&path).unwrap(); + assert!(matches!( + ledger.append(&Record::committed("after-remove")), + Err(DeletionLedgerError::Moved) + )); } #[test] @@ -150,47 +240,63 @@ fn the_path_is_validated_without_touching_the_filesystem() { } } +// ------------------------------------------------------------------ replay + #[tokio::test] -async fn replay_sets_named_keys_deleted_with_no_seed() { +async fn replay_applies_committed_and_open_operations_and_skips_aborted_ones() { let db = fresh_db().await; - insert_key(&db, "apps/a/active", "active", true, 1).await; - insert_key(&db, "apps/a/blocked", "blocked", true, 1).await; - insert_key(&db, "apps/a/untouched", "active", true, 1).await; + for path in [ + "apps/a/committed", + "apps/a/open", + "apps/a/aborted", + "apps/a/untouched", + ] { + active_key(&db, path).await; + } let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("ledger.jsonl"); - let ledger = DeletionLedger::open(&path).unwrap(); + let (path, ledger) = ledger_in(&dir); + ledger + .append(&Record::Intent(intent("c", &[("apps/a/committed", 1)]))) + .unwrap(); + ledger.append(&Record::committed("c")).unwrap(); ledger - .append(&[entry("apps/a/active", 1), entry("apps/a/blocked", 1)]) + .append(&Record::Intent(intent("o", &[("apps/a/open", 1)]))) .unwrap(); + ledger + .append(&Record::Intent(intent("a", &[("apps/a/aborted", 1)]))) + .unwrap(); + ledger.append(&Record::aborted("a")).unwrap(); let report = replay(&db, &path).await.unwrap(); assert_eq!( report, ReplayReport { - entries: 2, + operations: 3, + aborted_operations: 1, applied: 2, ..ReplayReport::default() } ); - for deleted in ["apps/a/active", "apps/a/blocked"] { + for deleted in ["apps/a/committed", "apps/a/open"] { let row = row(&db, deleted).await; - assert_eq!(row.state, "deleted"); - assert_eq!(row.sealed_seed, None); + assert_eq!(row.state, "deleted", "{deleted}"); + assert_eq!(row.sealed_seed, None, "{deleted}"); + } + for kept in ["apps/a/aborted", "apps/a/untouched"] { + let row = row(&db, kept).await; + assert_eq!(row.state, "active", "{kept}"); + assert!(row.sealed_seed.is_some(), "{kept}"); } - let untouched = row(&db, "apps/a/untouched").await; - assert_eq!(untouched.state, "active"); - assert!(untouched.sealed_seed.is_some()); } #[tokio::test] async fn replay_is_idempotent() { let db = fresh_db().await; - insert_key(&db, "apps/a/x", "active", true, 1).await; + active_key(&db, "apps/a/x").await; let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("ledger.jsonl"); - DeletionLedger::open(&path) - .unwrap() - .append(&[entry("apps/a/x", 1)]) + let (path, ledger) = ledger_in(&dir); + ledger + .append(&Record::Intent(intent("op", &[("apps/a/x", 1)]))) .unwrap(); assert_eq!(replay(&db, &path).await.unwrap().applied, 1); @@ -200,16 +306,89 @@ async fn replay_is_idempotent() { assert_eq!(row(&db, "apps/a/x").await.sealed_seed, None); } +#[tokio::test] +async fn a_torn_batch_applies_nothing() { + let db = fresh_db().await; + for path in ["apps/a/x", "apps/a/y", "apps/a/z"] { + active_key(&db, path).await; + } + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ledger.jsonl"); + // One operation, cut off after its first key. + let whole = serde_json::to_string(&Record::Intent(intent( + "op", + &[("apps/a/x", 1), ("apps/a/y", 1), ("apps/a/z", 1)], + ))) + .unwrap(); + let cut = whole.find("apps/a/y").unwrap(); + std::fs::write(&path, &whole[..cut]).unwrap(); + + let report = replay(&db, &path).await.unwrap(); + assert!(report.torn_tail); + assert_eq!(report.applied, 0); + for key in ["apps/a/x", "apps/a/y", "apps/a/z"] { + assert_eq!(row(&db, key).await.state, "active", "{key}"); + } +} + +#[tokio::test] +async fn replay_leaves_a_key_that_is_not_a_deletable_key() { + let db = fresh_db().await; + insert_key(&db, "docs/a", "deterministic", "active", false, 1).await; + insert_key(&db, "apps/a/stray", "deterministic", "active", false, 1).await; + active_key(&db, "apps/a/x").await; + let dir = tempfile::tempdir().unwrap(); + let (path, ledger) = ledger_in(&dir); + ledger + .append(&Record::Intent(intent( + "op", + &[("docs/a", 1), ("apps/a/stray", 1), ("apps/a/x", 1)], + ))) + .unwrap(); + + let report = replay(&db, &path).await.unwrap(); + assert_eq!(report.not_deletable, 2); + assert_eq!(report.applied, 1); + assert_eq!(row(&db, "docs/a").await.state, "active"); + assert_eq!(row(&db, "apps/a/stray").await.state, "active"); +} + +#[tokio::test] +async fn replay_refuses_keys_and_networks_the_grammar_refuses() { + let db = fresh_db().await; + active_key(&db, "apps/a").await; + active_key(&db, "apps/a/x").await; + let dir = tempfile::tempdir().unwrap(); + let (path, ledger) = ledger_in(&dir); + ledger + .append(&Record::Intent(intent( + "op", + // `apps/a` is not a key (R1); the rest of the operation applies. + &[("apps/a", 1), ("apps/A/x", 1), ("apps/a/x", 1)], + ))) + .unwrap(); + let mut other = intent("op-network", &[("apps/a/x", 1)]); + other.network_id = "not a network".to_string(); + ledger.append(&Record::Intent(other)).unwrap(); + + let report = replay(&db, &path).await.unwrap(); + assert_eq!(report.refused, 3); + assert_eq!(report.applied, 1); + assert_eq!(row(&db, "apps/a").await.state, "active"); +} + #[tokio::test] async fn replay_leaves_a_newer_key_version_and_counts_missing_rows() { let db = fresh_db().await; // A key deleted at version 1, then created again as version 2. - insert_key(&db, "apps/a/x", "active", true, 2).await; + insert_key(&db, "apps/a/x", "deletable", "active", true, 2).await; let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("ledger.jsonl"); - DeletionLedger::open(&path) - .unwrap() - .append(&[entry("apps/a/x", 1), entry("apps/a/gone", 1)]) + let (path, ledger) = ledger_in(&dir); + ledger + .append(&Record::Intent(intent( + "v1", + &[("apps/a/x", 1), ("apps/a/gone", 1)], + ))) .unwrap(); let report = replay(&db, &path).await.unwrap(); @@ -220,28 +399,55 @@ async fn replay_leaves_a_newer_key_version_and_counts_missing_rows() { assert_eq!(kept.state, "active"); assert!(kept.sealed_seed.is_some()); - // The ledger entry for the version that row holds does apply. - DeletionLedger::open(&path) - .unwrap() - .append(&[entry("apps/a/x", 2)]) + // The operation for the version that row holds does apply. + ledger + .append(&Record::Intent(intent("v2", &[("apps/a/x", 2)]))) .unwrap(); assert_eq!(replay(&db, &path).await.unwrap().applied, 1); assert_eq!(row(&db, "apps/a/x").await.sealed_seed, None); } +// ------------------------------------------------------------------ settle + #[tokio::test] -async fn replay_applies_valid_entries_and_reports_malformed_lines() { +async fn open_intents_are_settled_against_the_database() { let db = fresh_db().await; - insert_key(&db, "apps/a/x", "active", true, 1).await; + // `done` committed: its keys are deleted at the intent's version. `lost` + // did not: its key is still active. `again` committed and the key was + // created again afterwards (a later version). + insert_key(&db, "apps/a/done", "deletable", "deleted", false, 1).await; + active_key(&db, "apps/a/lost").await; + insert_key(&db, "apps/a/again", "deletable", "active", true, 2).await; let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("ledger.jsonl"); - let mut text = String::from("not json\n"); - text.push_str(&serde_json::to_string(&entry("apps/a/x", 1)).unwrap()); - text.push_str("\n{\"networkId\":\"x\"}\n"); - std::fs::write(&path, text).unwrap(); + let (path, ledger) = ledger_in(&dir); + for (op, key) in [ + ("done", ("apps/a/done", 1)), + ("lost", ("apps/a/lost", 1)), + ("again", ("apps/a/again", 1)), + ] { + ledger.append(&Record::Intent(intent(op, &[key]))).unwrap(); + } + ledger.append(&Record::committed("done")).unwrap(); + // `done` is settled already; the other two are open. + let settled = ledger.settle_open_intents(&db).await.unwrap(); + assert_eq!( + settled, + SettleReport { + committed: 1, + aborted: 1 + } + ); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains(r#"{"aborted":"lost"}"#)); + assert!(text.contains(r#"{"committed":"again"}"#)); + assert!(read(&path).unwrap().open_intents().is_empty()); + // Settled again, nothing is left to do, and replay skips the aborted one. + assert_eq!( + ledger.settle_open_intents(&db).await.unwrap(), + SettleReport::default() + ); let report = replay(&db, &path).await.unwrap(); - assert_eq!(report.applied, 1); - assert_eq!(report.malformed_lines, vec![1, 3]); - assert_eq!(row(&db, "apps/a/x").await.state, "deleted"); + assert_eq!(report.aborted_operations, 1); + assert_eq!(row(&db, "apps/a/lost").await.state, "active"); } diff --git a/tinycloud-core/src/encryption_network/key_delete.rs b/tinycloud-core/src/encryption_network/key_delete.rs index b39bf047..e6b94d2c 100644 --- a/tinycloud-core/src/encryption_network/key_delete.rs +++ b/tinycloud-core/src/encryption_network/key_delete.rs @@ -1,9 +1,9 @@ //! `key.delete`: the effect of an owner-signed delete on key rows. //! //! One transaction, taken under the network row lock that `key.create`, -//! `key.block` and `key.unblock` also take. Every row it deletes is recorded -//! in the deletion ledger before the transaction commits, so the ledger always -//! holds every committed deletion. +//! `key.block` and `key.unblock` also take. The delete's intent is appended +//! to the deletion ledger before the transaction commits and settled after it, +//! so the ledger always names every committed deletion. use sea_orm::{ sea_query::Expr, ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseTransaction, @@ -18,7 +18,7 @@ use crate::{ use super::{ canonical::hash_hex, delete_auth::{DeleteTarget, VerifiedOwnerDelete}, - deletion_ledger::{DeletionLedger, LedgerEntry, KEY_STATE_DELETED}, + deletion_ledger::{DeletionLedger, Intent, LedgerKey, Record}, key_id::KeyKind, keys::{find_key_row, lock_network_row, KeyState}, network_id::NetworkId, @@ -66,19 +66,40 @@ impl EncryptionService { let tx = begin_write_transaction(&self.db, None).await?; // Every outcome ends the transaction here, so the network row lock is // released before this call returns. - match self + let result = self .delete_keys_in(&tx, network, target, verified, &ledger) - .await - { - Ok(outcome) => { - tx.commit().await?; - Ok(outcome) - } + .await; + let (outcome, op) = match result { + Ok(done) => done, Err(err) => { let _ = tx.rollback().await; - Err(err) + return Err(err); + } + }; + #[cfg(test)] + if self + .fail_next_delete_commit + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + let _ = tx.rollback().await; + self.settle_aborted(&ledger, op.as_deref()).await; + return Err(EncryptionServiceError::Db(sea_orm::DbErr::Custom( + "injected commit failure".to_string(), + ))); + } + if let Err(err) = tx.commit().await { + // The commit did not happen: say so before answering. + self.settle_aborted(&ledger, op.as_deref()).await; + return Err(err.into()); + } + if let Some(op) = op { + // A missing `committed` line is settled from the database at the + // next start, so a failure here does not undo the delete. + if ledger.append_async(Record::committed(&op)).await.is_err() { + ::tracing::warn!("deletion ledger: committed line was not written"); } } + Ok(outcome) } async fn delete_keys_in( @@ -88,7 +109,7 @@ impl EncryptionService { target: &DeleteTarget, verified: &VerifiedOwnerDelete, ledger: &std::sync::Arc, - ) -> Result { + ) -> Result<(DeleteOutcome, Option), EncryptionServiceError> { lock_network_row(tx, network).await?; if encryption_network::Entity::find_by_id(network.to_string()) .one(tx) @@ -127,12 +148,12 @@ impl EncryptionService { let now = now_rfc3339(); let mut deleted = Vec::with_capacity(to_delete.len()); - let mut entries = Vec::with_capacity(to_delete.len()); + let mut ledger_keys = Vec::with_capacity(to_delete.len()); for row in to_delete { let updated = encryption_network_key::Entity::update_many() .col_expr( encryption_network_key::Column::State, - Expr::value(KEY_STATE_DELETED), + Expr::value(KeyState::Deleted.as_str()), ) .col_expr( encryption_network_key::Column::SealedSeed, @@ -161,24 +182,48 @@ impl EncryptionService { } .insert(tx) .await?; - entries.push(LedgerEntry { - network_id: network.to_string(), + ledger_keys.push(LedgerKey { key_path: row.key_path.clone(), key_version: row.key_version, - deleted_at: now.clone(), }); deleted.push(row.key_path); } - // The ledger lines are durable before the commit: a crash after this - // point leaves the ledger holding at least every committed deletion. - ledger.append_blocking_task(entries).await?; + // The intent is durable before the commit: a crash after this point + // leaves an open intent that the next start settles against the + // database, so the ledger always names every committed deletion. + let op = if ledger_keys.is_empty() { + None + } else { + ledger + .append_async(Record::Intent(Intent { + op: verified.auth_cid.clone(), + network_id: network.to_string(), + keys: ledger_keys, + deleted_at: now, + })) + .await?; + Some(verified.auth_cid.clone()) + }; + + Ok(( + DeleteOutcome { + key_path: target.key_path(), + deleted, + already_deleted, + }, + op, + )) + } - Ok(DeleteOutcome { - key_path: target.key_path(), - deleted, - already_deleted, - }) + /// Appends `aborted` for an operation whose commit did not happen. A + /// failure here is settled from the database at the next start. + async fn settle_aborted(&self, ledger: &std::sync::Arc, op: Option<&str>) { + if let Some(op) = op { + if ledger.append_async(Record::aborted(op)).await.is_err() { + ::tracing::warn!("deletion ledger: aborted line was not written"); + } + } } } diff --git a/tinycloud-core/src/encryption_network/key_delete_tests.rs b/tinycloud-core/src/encryption_network/key_delete_tests.rs index 80ddc055..87f6982e 100644 --- a/tinycloud-core/src/encryption_network/key_delete_tests.rs +++ b/tinycloud-core/src/encryption_network/key_delete_tests.rs @@ -12,7 +12,7 @@ use sea_orm_migration::MigratorTrait; use super::{ backend::wrap_to_public_key, delete_auth::{DeleteTarget, VerifiedOwnerDelete}, - deletion_ledger::{self, DeletionLedger}, + deletion_ledger::{self, DeletionLedger, Intent, LedgerKey, Record}, key_delete::{audit_request_hash, DeleteOutcome}, key_id::KeyLocation, network_id::NetworkId, @@ -142,12 +142,11 @@ impl Fixture { .unwrap() } + /// Key paths of every operation in the ledger, in order. fn ledger_paths(&self) -> Vec { - deletion_ledger::read(&self.ledger_path) - .unwrap() - .entries + ledger_keys(&self.ledger_path) .into_iter() - .map(|entry| entry.key_path) + .map(|key| key.key_path) .collect() } @@ -174,6 +173,20 @@ fn strings(values: &[&str]) -> Vec { values.iter().map(|v| v.to_string()).collect() } +/// The keys of every operation in a ledger, in order. +fn ledger_keys(path: &std::path::Path) -> Vec { + deletion_ledger::read(path) + .unwrap() + .records + .into_iter() + .filter_map(|record| match record { + Record::Intent(intent) => Some(intent.keys), + _ => None, + }) + .flatten() + .collect() +} + #[tokio::test] async fn a_prefix_delete_deletes_exactly_the_keys_under_it() { let f = fixture().await; @@ -240,10 +253,16 @@ async fn one_audit_row_and_one_ledger_line_per_deleted_key() { ); assert_ne!(audit_request_hash("a", "bc"), audit_request_hash("ab", "c")); assert_eq!(f.ledger_paths(), strings(&["apps/foo/a", "apps/foo/b"])); - let entries = deletion_ledger::read(&f.ledger_path).unwrap().entries; - assert!(entries - .iter() - .all(|e| e.key_version == 1 && e.network_id == network().to_string())); + // One operation line, then its committed line. + let records = deletion_ledger::read(&f.ledger_path).unwrap().records; + assert_eq!(records.len(), 2); + let Record::Intent(intent) = &records[0] else { + panic!("the first line is the operation"); + }; + assert_eq!(intent.op, "cid-audit"); + assert_eq!(intent.network_id, network().to_string()); + assert!(intent.keys.iter().all(|key| key.key_version == 1)); + assert_eq!(records[1], Record::committed("cid-audit")); } #[tokio::test] @@ -396,11 +415,9 @@ async fn deleted_keys_are_refused_everywhere_and_reinstall_is_a_new_version() { .unwrap(); assert_eq!(again.key_version, 2); f.delete(&prefix("foo"), "cid-2").await.unwrap(); - let versions: Vec = deletion_ledger::read(&f.ledger_path) - .unwrap() - .entries + let versions: Vec = ledger_keys(&f.ledger_path) .into_iter() - .map(|e| e.key_version) + .map(|key| key.key_version) .collect(); assert_eq!(versions, vec![1, 2]); } @@ -459,6 +476,87 @@ async fn a_ledger_that_cannot_be_written_rolls_the_delete_back() { assert!(f.ledger_paths().is_empty()); } +#[tokio::test] +async fn a_ledger_that_was_renamed_refuses_the_delete() { + let f = fixture().await; + f.create(&["apps/foo/a"]).await; + let moved = f.ledger_path.with_file_name("moved.jsonl"); + std::fs::rename(&f.ledger_path, &moved).unwrap(); + + let err = f.delete(&prefix("foo"), "cid-moved").await.unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::DeletionLedger(_)), + "{err:?}" + ); + f.assert_unchanged(&["apps/foo/a"]).await; + assert!(f.audit_rows().await.is_empty()); + assert!(!f.ledger_path.exists()); + assert!(deletion_ledger::read(&moved).unwrap().records.is_empty()); +} + +#[tokio::test] +async fn a_commit_that_fails_leaves_an_aborted_line_and_deletes_nothing() { + let f = fixture().await; + f.create(&["apps/foo/a", "apps/foo/b"]).await; + f.service + .fail_next_delete_commit + .store(true, std::sync::atomic::Ordering::SeqCst); + + let err = f.delete(&prefix("foo"), "cid-abort").await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::Db(_)), "{err:?}"); + f.assert_unchanged(&["apps/foo/a", "apps/foo/b"]).await; + assert!(f.audit_rows().await.is_empty()); + let records = deletion_ledger::read(&f.ledger_path).unwrap().records; + assert_eq!(records.len(), 2); + assert!(matches!(&records[0], Record::Intent(i) if i.op == "cid-abort")); + assert_eq!(records[1], Record::aborted("cid-abort")); + + // A restore replays the ledger without that operation. + let report = deletion_ledger::replay(&f.db, &f.ledger_path) + .await + .unwrap(); + assert_eq!(report.aborted_operations, 1); + assert_eq!(report.applied, 0); + f.assert_unchanged(&["apps/foo/a", "apps/foo/b"]).await; +} + +#[tokio::test] +async fn a_crash_between_the_intent_and_the_commit_is_settled_at_the_next_start() { + let f = fixture().await; + f.create(&["apps/foo/a"]).await; + // The intent is on disk; the process died before the commit. + let ledger = DeletionLedger::open(&f.ledger_path).unwrap(); + ledger + .append(&Record::Intent(Intent { + op: "cid-crash".to_string(), + network_id: network().to_string(), + keys: vec![LedgerKey { + key_path: "apps/foo/a".to_string(), + key_version: 1, + }], + deleted_at: "2026-10-23T12:00:00Z".to_string(), + })) + .unwrap(); + + let settled = ledger.settle_open_intents(&f.db).await.unwrap(); + assert_eq!((settled.committed, settled.aborted), (0, 1)); + f.assert_unchanged(&["apps/foo/a"]).await; + + // The same crash after the commit: the key is deleted, so it settles as committed. + f.delete(&key("apps/foo/a"), "cid-done").await.unwrap(); + let mut crashed = deletion_ledger::read(&f.ledger_path).unwrap().records; + crashed.retain(|record| !matches!(record, Record::Committed(_))); + let rewritten = f.ledger_path.with_file_name("crashed.jsonl"); + let lines: Vec = crashed + .iter() + .map(|record| serde_json::to_string(record).unwrap()) + .collect(); + std::fs::write(&rewritten, lines.join("\n") + "\n").unwrap(); + let crashed_ledger = DeletionLedger::open(&rewritten).unwrap(); + let settled = crashed_ledger.settle_open_intents(&f.db).await.unwrap(); + assert_eq!((settled.committed, settled.aborted), (1, 0)); +} + #[tokio::test] async fn creates_racing_a_prefix_delete_are_deleted_too_or_created_after() { let f = fixture().await; @@ -493,11 +591,9 @@ async fn creates_racing_a_prefix_delete_are_deleted_too_or_created_after() { "a key is deleted exactly when the delete listed it" ); assert_eq!( - deletion_ledger::read(&f.ledger_path) - .unwrap() - .entries + ledger_keys(&f.ledger_path) .into_iter() - .map(|e| e.key_path) + .map(|key| key.key_path) .collect::>(), deleted_rows ); diff --git a/tinycloud-core/src/encryption_network/service.rs b/tinycloud-core/src/encryption_network/service.rs index beead746..62d8594d 100644 --- a/tinycloud-core/src/encryption_network/service.rs +++ b/tinycloud-core/src/encryption_network/service.rs @@ -251,6 +251,9 @@ pub struct EncryptionService { pub(super) key_cap_per_network: u64, pub(super) deletion_ledger: Option>, pub(super) key_cap_per_app_prefix: u64, + /// Makes the next key delete fail where it would commit. + #[cfg(test)] + pub(super) fail_next_delete_commit: std::sync::atomic::AtomicBool, } #[derive(Debug, Clone)] @@ -281,6 +284,8 @@ impl EncryptionService { sqlite_writer_lock: None, key_cap_per_network: DEFAULT_KEY_CAP_PER_NETWORK, deletion_ledger: None, + #[cfg(test)] + fail_next_delete_commit: Default::default(), key_cap_per_app_prefix: DEFAULT_KEY_CAP_PER_APP_PREFIX, } } @@ -300,6 +305,8 @@ impl EncryptionService { sqlite_writer_lock: None, key_cap_per_network: DEFAULT_KEY_CAP_PER_NETWORK, deletion_ledger: None, + #[cfg(test)] + fail_next_delete_commit: Default::default(), key_cap_per_app_prefix: DEFAULT_KEY_CAP_PER_APP_PREFIX, } } diff --git a/tinycloud-core/tests/fixtures/encryption/app-key-vectors.json b/tinycloud-core/tests/fixtures/encryption/app-key-vectors.json index 612dccca..2ef127da 100644 --- a/tinycloud-core/tests/fixtures/encryption/app-key-vectors.json +++ b/tinycloud-core/tests/fixtures/encryption/app-key-vectors.json @@ -351,6 +351,43 @@ "accepted": true } ], + "deletePrefix": [ + { + "prefix": "apps/foo/", + "accepted": true + }, + { + "prefix": "apps/", + "accepted": false + }, + { + "prefix": "apps/foo/x/", + "accepted": false + }, + { + "prefix": "secrets/", + "accepted": false + }, + { + "prefix": "apps/foo", + "accepted": false + } + ], + "deleteUriPrefix": "urn:tinycloud:encryption:key-delete:", + "deleteStatements": [ + { + "owner": "did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", + "network": "default", + "keyPath": "apps/foo/", + "statement": "Delete all TinyCloud keys under urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/apps/foo/. I further authorize the stated URI to perform the following actions on my behalf: (1) 'tinycloud.encryption': 'key.delete' for 'urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/apps/foo/'." + }, + { + "owner": "did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", + "network": "default", + "keyPath": "apps/foo/x", + "statement": "Delete the TinyCloud key urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/apps/foo/x. I further authorize the stated URI to perform the following actions on my behalf: (1) 'tinycloud.encryption': 'key.delete' for 'urn:tinycloud:encryption:did:pkh:eip155:1:0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed:default/apps/foo/x'." + } + ], "hostBinding": [ { "origin": "https://listen.tinycloud.xyz", diff --git a/tinycloud-node-server/src/cli.rs b/tinycloud-node-server/src/cli.rs index fc2bbdcb..d51febfa 100644 --- a/tinycloud-node-server/src/cli.rs +++ b/tinycloud-node-server/src/cli.rs @@ -364,10 +364,10 @@ async fn replay_deletion_ledger(args: ReplayArgs) -> Result<()> { let db = crate::connect_database(&config).await?; let report = tinycloud_core::encryption_network::deletion_ledger::replay(&db, &ledger).await?; emit_json(&report, args.json.json)?; - if !report.malformed_lines.is_empty() { + if report.refused > 0 { anyhow::bail!( - "the ledger has {} line(s) that are not ledger entries; every valid entry was applied", - report.malformed_lines.len() + "the ledger has {} key entr(ies) that are not valid keys; every valid entry was applied", + report.refused ); } Ok(()) diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index 021a7358..0d0b439d 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -841,10 +841,26 @@ pub async fn app_with_control( .map(Arc::new); let encryption_service = encryption_service .with_sqlite_writer_lock(tinycloud.sqlite_writer_lock()) - .with_deletion_ledger(deletion_ledger); + .with_deletion_ledger(deletion_ledger.clone()); // 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?; + // A delete that was cut off between its ledger intent and its settling + // line is settled from the database before any route is served. + if let Some(ledger) = &deletion_ledger { + let settled = ledger + .settle_open_intents(&seed_conn) + .await + .map_err(|error| anyhow::anyhow!("failed to settle the deletion ledger: {error}"))?; + if ledger.dropped_tail_bytes() > 0 || settled != Default::default() { + ::tracing::warn!( + committed = settled.committed, + aborted = settled.aborted, + dropped_tail_bytes = ledger.dropped_tail_bytes(), + "deletion ledger settled at start" + ); + } + } let database_artifact_repository: Arc = Arc::new(SizeTrackingArtifactRepository::new( Arc::new(node_artifact_repository(&tinycloud)), diff --git a/tinycloud-node-server/src/routes/tests/key_delete.rs b/tinycloud-node-server/src/routes/tests/key_delete.rs index 667b624f..d32e8877 100644 --- a/tinycloud-node-server/src/routes/tests/key_delete.rs +++ b/tinycloud-node-server/src/routes/tests/key_delete.rs @@ -288,6 +288,35 @@ async fn revoke_refuses_owner_delete_credentials() -> Result<()> { let (status, body) = post(&fixture, "/revoke", ucan).await; assert_eq!(status, Status::Forbidden, "ucan: {body}"); + // The cause is structural: neither credential names a delegation to + // revoke (a revocation targets `ucan:`), so neither is a revocation. + use tinycloud_auth::authorization::TinyCloudRevocation; + use tinycloud_core::util::{RevocationError, RevocationInfo}; + let cacao = wallet.cacao( + &prefix, + KEY_DELETE, + Some(format!("Delete all TinyCloud keys under {prefix}.")), + &format!("{KEY_DELETE_URI_PREFIX}{}", fixture.node_did), + "nonce0004", + 300, + ); + let ucan = owner_ucan( + &fixture, + &fixture.node_did, + &prefix_urn(&owner_did(&fixture), "foo"), + KEY_DELETE, + )?; + for (label, header) in [("cacao", &cacao), ("ucan", &ucan)] { + let (decoded, _) = TinyCloudRevocation::decode(header)?; + assert!( + matches!( + RevocationInfo::try_from(decoded), + Err(RevocationError::InvalidTarget) + ), + "{label}" + ); + } + assert_eq!( revocation::Entity::find().count(&fixture.conn).await?, 0, @@ -299,8 +328,7 @@ async fn revoke_refuses_owner_delete_credentials() -> Result<()> { // ------------------------------------------------------------ delete route use super::encryption_keys::{ - admin_auth, created_key, describe_auth, describe_get, invocation, invoke_named, post_json, - setup, DecryptCase, + created_key, describe_auth, describe_get, invoke_named, post_json, setup, DecryptCase, }; use tinycloud_core::encryption_network::{ deletion_ledger, key_id::KeyLocation, CreateNetworkRequest, EncryptionService, NetworkId, @@ -312,15 +340,58 @@ fn delete_url(network: &str) -> String { format!("/encryption/networks/{network}/key-actions/delete") } +fn unix_now() -> i64 { + OffsetDateTime::now_utc().unix_timestamp() +} + +/// A UCAN by the fixture owner with the given capabilities and facts, valid +/// from `nbf` until `exp`. +fn delete_ucan( + fixture: &KvSyncFixture, + audience: &str, + caps: &[(&str, &str)], + facts: Vec, + nbf: Option, + exp: i64, +) -> Result { + let mut capabilities = Capabilities::new(); + for (resource, ability) in caps { + capabilities.with_action( + resource.parse()?, + ability.parse::()?, + [BTreeMap::::new()], + ); + } + Ok(Payload { + issuer: fixture.owner_vm.parse::()?, + audience: audience.parse::()?, + not_before: nbf + .map(|t| NumericDate::try_from_seconds(t as f64)) + .transpose()?, + expiration: NumericDate::try_from_seconds(exp as f64)?, + nonce: Some(fixture.next_nonce()), + facts: Some(facts), + proof: vec![], + attenuation: capabilities, + } + .sign( + fixture.owner.get_algorithm().unwrap_or_default(), + &fixture.owner, + )? + .encode()?) +} + /// A UCAN delete invocation by the fixture owner for `key_path` (a prefix -/// keeps its trailing `/`). +/// keeps its trailing `/`), signed now and valid for 300 seconds. fn delete_auth(fixture: &KvSyncFixture, network: &str, key_path: &str) -> Result { - admin_auth( + let now = unix_now(); + delete_ucan( fixture, - network, - key_path, - KEY_DELETE, - &json!({ "keyPath": key_path }), + &fixture.node_did, + &[(key_uri_for(network, key_path).as_str(), KEY_DELETE)], + vec![admin_fact(&fixture.node_did, network, key_path)], + Some(now), + now + 300, ) } @@ -347,9 +418,14 @@ fn ledger_paths(fixture: &KvSyncFixture) -> Vec { .expect("ledger directory is managed"); deletion_ledger::read(&dir.0.path().join("deletion-ledger.jsonl")) .expect("ledger reads") - .entries + .records .into_iter() - .map(|entry| entry.key_path) + .filter_map(|record| match record { + deletion_ledger::Record::Intent(intent) => Some(intent.keys), + _ => None, + }) + .flatten() + .map(|key| key.key_path) .collect() } @@ -494,50 +570,82 @@ async fn delete_refuses_a_credential_that_does_not_match_the_request() -> Result let (fixture, network) = setup().await?; created_key(&fixture, &network, "apps/foo/a").await; created_key(&fixture, &network, "apps/bar/a").await; - let owner_only = |text: &str| text.starts_with("owner_only"); + let refused = |status: Status, text: &str, reason: &str| { + assert_eq!(status, Status::Forbidden, "{text}"); + assert!(text.starts_with("owner_only"), "{text}"); + assert!(text.contains(reason), "wanted {reason:?}: {text}"); + }; + let not_the_target = "not key.delete on exactly the requested target"; // The body names another prefix than the credential. let auth = delete_auth(&fixture, &network, "apps/foo/")?; let (status, text) = delete_with(&fixture, &network, "apps/bar/", &auth).await; - assert_eq!(status, Status::Forbidden, "{text}"); - assert!(owner_only(&text), "{text}"); + refused(status, &text, not_the_target); // The body names the key, the credential its prefix. let auth = delete_auth(&fixture, &network, "apps/foo/")?; let (status, text) = delete_with(&fixture, &network, "apps/foo/a", &auth).await; - assert_eq!(status, Status::Forbidden, "{text}"); - assert!(owner_only(&text), "{text}"); + refused(status, &text, not_the_target); // key.block is not key.delete. - let block = admin_auth( + let now = unix_now(); + let mut block_fact = admin_fact(&fixture.node_did, &network, "apps/foo/"); + block_fact["action"] = json!(KEY_BLOCK_ACTION); + let block = delete_ucan( &fixture, - &network, - "apps/foo/", - KEY_BLOCK_ACTION, - &json!({ "keyPath": "apps/foo/" }), + &fixture.node_did, + &[( + key_uri_for(&network, "apps/foo/").as_str(), + KEY_BLOCK_ACTION, + )], + vec![block_fact], + Some(now), + now + 300, )?; let (status, text) = delete_with(&fixture, &network, "apps/foo/", &block).await; - assert_eq!(status, Status::Forbidden, "{text}"); - assert!(owner_only(&text), "{text}"); + refused(status, &text, not_the_target); // A credential addressed to another node. - let prefix = key_uri_for(&network, "apps/foo/"); - let other_node = invocation( + let other_node = delete_ucan( &fixture, "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - &[(prefix.as_str(), KEY_DELETE)], + &[(key_uri_for(&network, "apps/foo/").as_str(), KEY_DELETE)], vec![admin_fact(&fixture.node_did, &network, "apps/foo/")], + Some(now), + now + 300, )?; let (status, text) = delete_with(&fixture, &network, "apps/foo/", &other_node).await; - assert_eq!(status, Status::Forbidden, "{text}"); - assert!(owner_only(&text), "{text}"); + refused(status, &text, "audience is not this node"); // A credential for another network than the URL names. let auth = delete_auth(&fixture, &network, "apps/foo/")?; let other_network = format!("{}x", network); let (status, text) = delete_with(&fixture, &other_network, "apps/foo/", &auth).await; - assert_eq!(status, Status::Forbidden, "{text}"); - assert!(owner_only(&text), "{text}"); + refused(status, &text, not_the_target); + + // A UCAN without nbf has no start to measure its lifetime from. + let no_nbf = delete_ucan( + &fixture, + &fixture.node_did, + &[(key_uri_for(&network, "apps/foo/").as_str(), KEY_DELETE)], + vec![admin_fact(&fixture.node_did, &network, "apps/foo/")], + None, + now + 300, + )?; + let (status, text) = delete_with(&fixture, &network, "apps/foo/", &no_nbf).await; + refused(status, &text, "must carry nbf"); + + // A long-lived UCAN is refused for its whole life, also near its end. + let long_lived = delete_ucan( + &fixture, + &fixture.node_did, + &[(key_uri_for(&network, "apps/foo/").as_str(), KEY_DELETE)], + vec![admin_fact(&fixture.node_did, &network, "apps/foo/")], + Some(now - 35_760), + now + 240, + )?; + let (status, text) = delete_with(&fixture, &network, "apps/foo/", &long_lived).await; + refused(status, &text, "lifetime exceeds 300 seconds"); // No Authorization header. let response = fixture From e1f2e7168743fa02547f1c2a7c1581fb7307cd65 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 00:44:36 +0000 Subject: [PATCH 13/29] docs: describe delete invocation lifetime and the deletion ledger operations Details: TC-1777 --- docs/encryption-named-keys.md | 42 ++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/docs/encryption-named-keys.md b/docs/encryption-named-keys.md index 920e4419..db4f4756 100644 --- a/docs/encryption-named-keys.md +++ b/docs/encryption-named-keys.md @@ -125,28 +125,40 @@ whose only resource is that ReCap, whose `uri` is `Delete the TinyCloud key .` (one key), then a space and the ReCap text. The URL network and the body `keyPath` must name the ReCap URN. -Both credentials last at most 300 seconds (`exp - iat`; a UCAN has no `iat`, so -`exp - now`), with `iat` and `nbf` at most 30 seconds ahead. The `(owner, -nonce)` pair is consumed before the keys are touched, so a delete that fails -after verification still uses up the signature; it is kept until 30 seconds -after `exp`. Credential refusals answer 403 `owner_only`. +Both credentials last at most 300 seconds. A SIWE credential measures +`exp - iat`, with `iat` and `nbf` at most 30 seconds ahead. A UCAN has no `iat`, +so it must carry `nbf` (the time it was signed): `nbf` at most 30 seconds +ahead, `now < exp` and `exp - nbf` at most 300 seconds. The `(owner, nonce)` +pair is consumed before the keys are touched, so a delete that fails after +verification still uses up the signature; it is kept until 30 seconds after +`exp`, and pairs past that are removed. Credential refusals answer 403 +`owner_only`. ### Deletion ledger `[global.encryption_network] deletion_ledger_path` names an append-only file -outside the database; it is required while `named_keys_v1` is on. One JSON line -per deleted key: `{ "networkId", "keyPath", "keyVersion", "deletedAt" }`. The -lines of a delete are written and fsynced after its database changes and before -the transaction commits, so the ledger holds at least every committed delete. If -the ledger cannot be written the delete is rolled back and answers 500 -`deletion_ledger_unavailable`. +outside the database; it is required while `named_keys_v1` is on. The file is +created with mode 0600 and is never rotated. One JSON line per delete +operation: `{ "op", "networkId", "keys": [{ "keyPath", "keyVersion" }], +"deletedAt" }`, where `op` is the CID of the owner's credential. The line is +written and fsynced after the delete's database changes and before the +transaction commits. After the commit the node appends `{"committed": }`; +if the commit fails it appends `{"aborted": }` before answering. If the +ledger cannot be written, or the file was renamed or removed, the delete is +rolled back and answers 500 `deletion_ledger_unavailable`. + +At every start, before any route is served, the node settles each operation +that has neither line: it is committed when every key is `deleted` at that key +version (or at a later one), otherwise aborted. After restoring a database, before starting the node, run `tinycloud node deletion-ledger replay [--config ] [--ledger ]`. It -sets every listed key to `deleted` with no seed (a row with a newer -`keyVersion` than the line is left alone), applies each line once, and exits -non-zero after applying the valid lines if the file has lines that are not -entries. +sets every key of every committed or still-open operation to `deleted` with no +seed. It skips aborted operations, rows that are not deletable keys, rows with +a newer `keyVersion`, and entries whose network or key path is not valid +(reported as `refused`; the command then exits non-zero after applying the rest). +A final line that is not a whole record is reported as `tornTail`; a malformed +line anywhere else stops the replay. Replay is idempotent. ## Startup check From 13f31482a993feb00d71fdcdf317a8c96f29c523 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 00:46:29 +0000 Subject: [PATCH 14/29] encryption: count the delete route among the named-key routes in the mount test Details: TC-1777 --- tinycloud-node-server/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index 0d0b439d..c4beac4f 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -1355,7 +1355,7 @@ mod production_route_tests { ); } - /// The five key routes are mounted only with `named_keys_v1` on; the + /// The six key routes are mounted only with `named_keys_v1` on; the /// network routes are mounted either way. #[test] fn named_key_routes_are_mounted_only_when_named_keys_are_on() { @@ -1366,7 +1366,7 @@ mod production_route_tests { .collect() }; let key_routes = describe(named_key_routes()); - assert_eq!(key_routes.len(), 5, "{key_routes:?}"); + assert_eq!(key_routes.len(), 6, "{key_routes:?}"); let off = describe(production_routes(false)); let on = describe(production_routes(true)); for route in &key_routes { @@ -1383,7 +1383,7 @@ mod production_route_tests { assert!(off.contains(&route), "missing while off: {route}"); assert!(on.contains(&route), "missing while on: {route}"); } - assert_eq!(on.len(), off.len() + 5); + assert_eq!(on.len(), off.len() + 6); } } From acd0ad6595faf4144d9badb917e7d9d04767ad71 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 00:59:47 +0000 Subject: [PATCH 15/29] encryption: test the commit-failure path of a delete through the commit it uses Details: TC-1777 --- .../src/encryption_network/key_delete.rs | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tinycloud-core/src/encryption_network/key_delete.rs b/tinycloud-core/src/encryption_network/key_delete.rs index e6b94d2c..0e2341c3 100644 --- a/tinycloud-core/src/encryption_network/key_delete.rs +++ b/tinycloud-core/src/encryption_network/key_delete.rs @@ -76,18 +76,8 @@ impl EncryptionService { return Err(err); } }; - #[cfg(test)] - if self - .fail_next_delete_commit - .swap(false, std::sync::atomic::Ordering::SeqCst) - { - let _ = tx.rollback().await; - self.settle_aborted(&ledger, op.as_deref()).await; - return Err(EncryptionServiceError::Db(sea_orm::DbErr::Custom( - "injected commit failure".to_string(), - ))); - } - if let Err(err) = tx.commit().await { + let committed = self.commit_delete(tx).await; + if let Err(err) = committed { // The commit did not happen: say so before answering. self.settle_aborted(&ledger, op.as_deref()).await; return Err(err.into()); @@ -102,6 +92,20 @@ impl EncryptionService { Ok(outcome) } + /// Commits the delete's transaction. In tests the next call can be made + /// to fail instead, with the transaction rolled back. + async fn commit_delete(&self, tx: DatabaseTransaction) -> Result<(), sea_orm::DbErr> { + #[cfg(test)] + if self + .fail_next_delete_commit + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + tx.rollback().await?; + return Err(sea_orm::DbErr::Custom("injected commit failure".to_string())); + } + tx.commit().await + } + async fn delete_keys_in( &self, tx: &DatabaseTransaction, From a9a0d2cb4ef10282aaad9c4f5d4da1f0ef18f22e Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 01:04:23 +0000 Subject: [PATCH 16/29] encryption: SIWE app id binding requires an absent or https scheme and no empty port Details: TC-1777 --- CHANGELOG.md | 4 +- docs/encryption-named-keys.md | 3 +- tinycloud-core/src/models/delegation.rs | 297 +++++++++++++++++++++--- 3 files changed, 275 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb605c8..b73676c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,10 @@ - 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 keep the deterministic derivation. 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. 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. diff --git a/docs/encryption-named-keys.md b/docs/encryption-named-keys.md index 977cf5d7..c2a7ae4a 100644 --- a/docs/encryption-named-keys.md +++ b/docs/encryption-named-keys.md @@ -81,7 +81,8 @@ request's `keyVersion` must equal the row's version, otherwise 403 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 domain has no port, is a DNS name +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 diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index 79c2ddaa..8343ad5c 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -344,13 +344,18 @@ 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 (a host name with no port). UCAN delegations are not -/// checked. +/// 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 domain = &cacao.payload().domain; + let payload = cacao.payload(); + let domain = &payload.domain; + let https = payload + .scheme + .as_ref() + .is_none_or(|scheme| scheme.as_str() == "https"); for cap in &delegation.capabilities { if cap.ability.to_string() == crate::encryption_network::KEY_BLOCK_ACTION || !is_root_authority(cap, &delegation.delegator) @@ -370,8 +375,10 @@ fn check_app_id_domain_binding(delegation: &util::DelegationInfo) -> Result<(), let Some(app_id) = key_id::app_id_of(location.path()) else { continue; }; - let bound = domain.port().is_none() + 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); @@ -1969,7 +1976,7 @@ mod tests { #[tokio::test] async fn malformed_key_identifiers_are_refused_in_delegations() { let db = database().await; - let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/docs/"); + let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/"); let mut skipped = 0; for (index, resource) in key_id::MALFORMED_KEY_IDS.iter().enumerate() { // Ids that fail URI parsing cannot be built into a capability, so there is nothing to admit or refuse. @@ -2002,7 +2009,7 @@ mod tests { ); } assert_eq!( - skipped, 2, + skipped, 4, "URI-invalid key identifiers in MALFORMED_KEY_IDS" ); } @@ -2071,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!({}) { @@ -2153,22 +2347,27 @@ mod tests { // Domain binding of app ids in SIWE roots. - /// A SIWE CACAO from the fixed test address with `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(domain: &str, grants: &[(&str, &str)]) -> util::DelegationInfo { + /// 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: Some("https".parse().unwrap()), + 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: "n2bnonce".into(), + 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, @@ -2198,6 +2397,11 @@ mod tests { 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"; @@ -2291,6 +2495,43 @@ mod tests { } } + /// 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"); + } + + #[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; @@ -2327,28 +2568,32 @@ mod tests { .unwrap(); let cases = vectors["hostBinding"].as_array().unwrap(); assert_eq!(cases.len(), 23); - let mut decided = 0; + 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(); - // A SIWE domain is a bare ASCII authority and an app id is ASCII: - // neither can carry a Unicode name, and a domain cannot have a - // trailing dot (those cases are decided on the host in `app_id`). - let Some(domain) = origin.strip_prefix("https://") else { - continue; - }; - if !domain.is_ascii() || !id.is_ascii() || domain.ends_with('.') { + 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(domain, &[(&format!("apps/{id}/x"), KEY_CREATE)]); - assert_eq!( - validate(&db, &info).await.is_ok(), - bound, - "origin {origin} id {id}" + 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!(decided >= 18, "decided {decided}"); + assert_eq!((decided, skipped), (20, 3)); } } From f554bcfeb23d589e409607642c35a5801e22d428 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 01:04:23 +0000 Subject: [PATCH 17/29] encryption: tests for keys and prefixes under apps/ in key ids, invocation and containment Details: TC-1777 --- .../src/encryption_network/key_id.rs | 38 +++- tinycloud-core/src/models/invocation.rs | 171 +++++++++++++++++- 2 files changed, 207 insertions(+), 2 deletions(-) diff --git a/tinycloud-core/src/encryption_network/key_id.rs b/tinycloud-core/src/encryption_network/key_id.rs index 201d902f..4859d42e 100644 --- a/tinycloud-core/src/encryption_network/key_id.rs +++ b/tinycloud-core/src/encryption_network/key_id.rs @@ -326,7 +326,7 @@ pub(super) fn valid_segment(segment: &str) -> bool { /// Malformed key identifiers that the grammar must refuse. #[cfg(test)] -pub(crate) const MALFORMED_KEY_IDS: [&str; 21] = [ +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", @@ -348,6 +348,13 @@ pub(crate) const MALFORMED_KEY_IDS: [&str; 21] = [ "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)] @@ -779,4 +786,33 @@ mod tests { 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" + )); + } } diff --git a/tinycloud-core/src/models/invocation.rs b/tinycloud-core/src/models/invocation.rs index 3bed1e7e..003158fe 100644 --- a/tinycloud-core/src/models/invocation.rs +++ b/tinycloud-core/src/models/invocation.rs @@ -2041,7 +2041,7 @@ mod tests { ); } assert_eq!( - skipped, 2, + skipped, 4, "URI-invalid key identifiers in MALFORMED_KEY_IDS" ); } @@ -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; From e2a9c511ad0d24a07787924735e13043adb62076 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 01:04:23 +0000 Subject: [PATCH 18/29] encryption: tests for app keys through the release guard, network row lock waits and startup refusal Details: TC-1777 --- .../src/encryption_network/backend.rs | 5 +- .../src/encryption_network/tests.rs | 257 +++++++++++++++++- .../src/policy_v3/named_keys_gate_tests.rs | 62 +++++ .../src/policy_v3/test_harness.rs | 9 + .../src/routes/tests/encryption_keys.rs | 146 +++++++++- 5 files changed, 470 insertions(+), 9 deletions(-) diff --git a/tinycloud-core/src/encryption_network/backend.rs b/tinycloud-core/src/encryption_network/backend.rs index 4d0e066f..c65fd604 100644 --- a/tinycloud-core/src/encryption_network/backend.rs +++ b/tinycloud-core/src/encryption_network/backend.rs @@ -582,9 +582,8 @@ mod tests { #[test] fn seeds_open_only_through_the_strict_unseal() { let backend = LocalOneOfOneBackend::new(seal_key()); - // The lenient unseal returns input without the version byte unchanged. - // A zeroed value would therefore open as an all-zero seed; the strict - // unseal refuses it. + // 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], diff --git a/tinycloud-core/src/encryption_network/tests.rs b/tinycloud-core/src/encryption_network/tests.rs index fbc6b53a..50c9468c 100644 --- a/tinycloud-core/src/encryption_network/tests.rs +++ b/tinycloud-core/src/encryption_network/tests.rs @@ -1865,8 +1865,8 @@ async fn rows_that_cannot_be_read_as_keys_are_corrupt() { // 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, not a sealed value: the - // lenient unseal would return it unchanged as an all-zero seed. + // 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. @@ -1889,8 +1889,7 @@ async fn rows_that_cannot_be_read_as_keys_are_corrupt() { ); } } - // A zeroed seed is refused by the strict unseal when the key is read; the - // lenient unseal would have returned it unchanged. + // 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 @@ -1930,7 +1929,7 @@ async fn public_get_under_apps_never_answers_with_a_key() { matches!(err, EncryptionServiceError::KeyNotFound), "{err:?}" ); - // Outside apps/ the derived key is still served for a missing path. + // 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(_)), @@ -2104,3 +2103,251 @@ async fn per_app_prefix_cap_ignores_keys_of_an_app_id_that_extends_it() { "{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-node-server/src/policy_v3/named_keys_gate_tests.rs b/tinycloud-node-server/src/policy_v3/named_keys_gate_tests.rs index 53172337..d2323c9c 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 @@ -303,3 +303,65 @@ async fn siwe_app_grants_need_an_app_id_bound_to_the_domain( 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); diff --git a/tinycloud-node-server/src/policy_v3/test_harness.rs b/tinycloud-node-server/src/policy_v3/test_harness.rs index b99dee27..c4c3a9d7 100644 --- a/tinycloud-node-server/src/policy_v3/test_harness.rs +++ b/tinycloud-node-server/src/policy_v3/test_harness.rs @@ -466,6 +466,15 @@ 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, diff --git a/tinycloud-node-server/src/routes/tests/encryption_keys.rs b/tinycloud-node-server/src/routes/tests/encryption_keys.rs index 8bc2a4a4..651fbe82 100644 --- a/tinycloud-node-server/src/routes/tests/encryption_keys.rs +++ b/tinycloud-node-server/src/routes/tests/encryption_keys.rs @@ -2473,7 +2473,7 @@ async fn public_get_under_apps_is_one_uniform_not_found() -> Result<()> { 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 still answers with its derived public key. + // 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; @@ -2677,3 +2677,147 @@ async fn app_key_resources_are_refused_when_named_keys_are_off() -> Result<()> { 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(()) +} From 2508c94028c9d52c76ffa38d6a4e4c228bfcdc1d Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 01:28:15 +0000 Subject: [PATCH 19/29] encryption: format the delete commit helper Details: TC-1777 --- tinycloud-core/src/encryption_network/key_delete.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tinycloud-core/src/encryption_network/key_delete.rs b/tinycloud-core/src/encryption_network/key_delete.rs index 0e2341c3..83b1b47a 100644 --- a/tinycloud-core/src/encryption_network/key_delete.rs +++ b/tinycloud-core/src/encryption_network/key_delete.rs @@ -101,7 +101,9 @@ impl EncryptionService { .swap(false, std::sync::atomic::Ordering::SeqCst) { tx.rollback().await?; - return Err(sea_orm::DbErr::Custom("injected commit failure".to_string())); + return Err(sea_orm::DbErr::Custom( + "injected commit failure".to_string(), + )); } tx.commit().await } From 4b1cc8388851e2f89a2ad84917c14e38382d4fd8 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 01:30:15 +0000 Subject: [PATCH 20/29] build: record the idna dependency of tinycloud-core in the secondary lockfiles Details: TC-1777 --- test/m1-realdata-e2e/Cargo.lock | 1 + test/w5-policy-runtime-node-e2e/Cargo.lock | 1 + 2 files changed, 2 insertions(+) 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", From 6a94e397a78449d0e6048dcf3fa567662537beff Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 01:37:29 +0000 Subject: [PATCH 21/29] encryption: compare the SIWE scheme ignoring case; server test for the scheme rule Details: TC-1777 --- tinycloud-core/src/models/delegation.rs | 17 +++++++- .../src/policy_v3/named_keys_gate_tests.rs | 43 +++++++++++++++++++ .../src/policy_v3/test_harness.rs | 18 +++++++- 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index 8343ad5c..e0ced66c 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -355,7 +355,7 @@ fn check_app_id_domain_binding(delegation: &util::DelegationInfo) -> Result<(), let https = payload .scheme .as_ref() - .is_none_or(|scheme| scheme.as_str() == "https"); + .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) @@ -2522,6 +2522,21 @@ mod tests { 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; 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 d2323c9c..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 @@ -365,3 +365,46 @@ async fn startup_refuses_a_non_deletable_key_row_under_apps( 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 c4c3a9d7..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(), From 4af596be4222b856dadba67a4f82fda8b3a73534 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 01:47:56 +0000 Subject: [PATCH 22/29] encryption: select a prefix with a portable substr comparison, settle a failed commit from the rows, and replay the ledger at start - The prefix select builds substr(key_path, 1, n) = prefix with the query builder, so it runs on SQLite and PostgreSQL. - After a commit error the node reads the rows again and writes committed or aborted from them; if the rows cannot be read the operation stays open. - Ledger replay at start applies committed and open operations and closes the open ones; a ledger with entries that are not valid keys fails it. - Owner-delete nonces are kept for 30 seconds past their expiry before they are pruned. - Tests for each of these, for a deleted row that still holds a seed, and for a row of an unknown kind. Details: TC-1777 --- .../src/encryption_network/delete_auth.rs | 18 ++- .../encryption_network/delete_auth_tests.rs | 19 ++- .../src/encryption_network/deletion_ledger.rs | 98 +++++++----- .../deletion_ledger_tests.rs | 86 ++++++++--- .../src/encryption_network/key_delete.rs | 140 ++++++++++++------ .../encryption_network/key_delete_tests.rs | 114 +++++++++++--- .../src/encryption_network/service.rs | 8 +- 7 files changed, 348 insertions(+), 135 deletions(-) diff --git a/tinycloud-core/src/encryption_network/delete_auth.rs b/tinycloud-core/src/encryption_network/delete_auth.rs index 82c3aff8..72a26c59 100644 --- a/tinycloud-core/src/encryption_network/delete_auth.rs +++ b/tinycloud-core/src/encryption_network/delete_auth.rs @@ -40,7 +40,7 @@ use super::{ keys::network_of, network_id::NetworkId, protocol::{KeyAdminFacts, KEY_ADMIN_TYPE, KEY_DELETE_ACTION}, - service::{now_rfc3339, EncryptionService, EncryptionServiceError}, + service::{EncryptionService, EncryptionServiceError}, }; /// The SIWE `uri` of an owner delete names the node it is for. @@ -458,11 +458,19 @@ impl EncryptionService { let now = OffsetDateTime::now_utc().unix_timestamp(); let verified = verify_owner_delete(header, &self.node_did, network, target, body_value, now).await?; - // Expired nonces are of no use: a credential past its `exp` is refused - // before its nonce is looked at. Timestamps are RFC 3339 UTC and sort - // as text. + // Nonces whose expiry is more than the retention window in the past + // are of no use: a credential past its `exp` is refused before its + // nonce is looked at, and the window covers a clock that steps back. + // Timestamps are RFC 3339 UTC and sort as text. + let cutoff = OffsetDateTime::from_unix_timestamp(now - NONCE_RETENTION_SECS) + .ok() + .and_then(|at| { + at.format(&time::format_description::well_known::Rfc3339) + .ok() + }) + .ok_or_else(|| refuse("the clock is out of range"))?; encryption_nonce::Entity::delete_many() - .filter(encryption_nonce::Column::ExpiresAt.lt(now_rfc3339())) + .filter(encryption_nonce::Column::ExpiresAt.lt(cutoff)) .exec(&self.db) .await?; self.consume_nonce_until( diff --git a/tinycloud-core/src/encryption_network/delete_auth_tests.rs b/tinycloud-core/src/encryption_network/delete_auth_tests.rs index 471ec3ff..e790d2e6 100644 --- a/tinycloud-core/src/encryption_network/delete_auth_tests.rs +++ b/tinycloud-core/src/encryption_network/delete_auth_tests.rs @@ -1237,8 +1237,18 @@ async fn nonces_past_their_expiry_are_pruned_and_live_ones_kept() { let target = prefix_target(&network, "foo"); let body = body_for(&target); let (service, db) = service().await; + let ago = |seconds: i64| { + OffsetDateTime::from_unix_timestamp(now() - seconds) + .unwrap() + .format(&time::format_description::well_known::Rfc3339) + .unwrap() + }; + let (ten_seconds_ago, a_minute_ago) = (ago(10), ago(60)); for (requester, nonce, expires_at) in [ ("did:key:old", "n-old", "2020-01-01T00:00:00Z"), + // Within the 30 s window after its expiry a nonce is still kept. + ("did:key:recent", "n-recent", ten_seconds_ago.as_str()), + ("did:key:minute", "n-minute", a_minute_ago.as_str()), ("did:key:live", "n-live", "2999-01-01T00:00:00Z"), ] { encryption_nonce::ActiveModel { @@ -1263,7 +1273,14 @@ async fn nonces_past_their_expiry_are_pruned_and_live_ones_kept() { .map(|row| row.nonce) .collect(); kept.sort(); - assert_eq!(kept, vec!["n-live".to_string(), "nonce-0001".to_string()]); + assert_eq!( + kept, + vec![ + "n-live".to_string(), + "n-recent".to_string(), + "nonce-0001".to_string() + ] + ); } #[tokio::test] diff --git a/tinycloud-core/src/encryption_network/deletion_ledger.rs b/tinycloud-core/src/encryption_network/deletion_ledger.rs index 30a42a2b..965ecbb8 100644 --- a/tinycloud-core/src/encryption_network/deletion_ledger.rs +++ b/tinycloud-core/src/encryption_network/deletion_ledger.rs @@ -106,10 +106,27 @@ pub enum DeletionLedgerError { Moved, #[error("deletion ledger line {0} is malformed")] Malformed(usize), + #[error("deletion ledger holds {0} key entries that are not valid keys")] + Refused(usize), #[error("database error: {0}")] Db(#[from] sea_orm::DbErr), } +impl DeletionLedgerError { + /// A fixed name for logs: it carries no path, key or database text. + pub fn kind(&self) -> &'static str { + match self { + Self::Io(_) => "io", + Self::Path(_) => "path", + Self::Encode(_) => "encode", + Self::Moved => "moved", + Self::Malformed(_) => "malformed", + Self::Refused(_) => "refused", + Self::Db(_) => "db", + } + } +} + impl From for EncryptionServiceError { fn from(err: DeletionLedgerError) -> Self { EncryptionServiceError::DeletionLedger(err.to_string()) @@ -241,53 +258,54 @@ impl DeletionLedger { Ok(()) } - /// Settles every open intent against the database: the delete committed - /// when each of its keys is `deleted` at the intent's key version (or at a - /// later one, after a re-create), otherwise it did not. Appends the - /// matching `committed` or `aborted` line. Runs at every node start, - /// before the routes mount. - pub async fn settle_open_intents( + /// The start-time replay. Applies the ledger to the database exactly as a + /// restore does (see [`replay`]: committed and still-open operations + /// apply, aborted ones do not), then closes every operation that was + /// still open with a `committed` line, since the replay has made it + /// true. Fails, closing nothing, when the ledger cannot be read, the + /// database errors, or the ledger holds entries that are not valid keys. + pub async fn replay_at_start( &self, db: &C, - ) -> Result { - let contents = read(&self.path)?; - let mut report = SettleReport::default(); - for intent in contents.open_intents() { - let mut committed = true; - for key in &intent.keys { - let row = encryption_network_key::Entity::find_by_id(( - intent.network_id.clone(), - key.key_path.clone(), - )) - .one(db) - .await?; - let deleted = row.is_some_and(|row| { - row.key_version > key.key_version - || (row.key_version == key.key_version - && row.state == KeyState::Deleted.as_str()) - }); - if !deleted { - committed = false; - break; - } - } - if committed { - self.append(&Record::committed(&intent.op))?; - report.committed += 1; - } else { - self.append(&Record::aborted(&intent.op))?; - report.aborted += 1; - } + ) -> Result { + let report = replay(db, &self.path).await?; + if report.refused > 0 { + return Err(DeletionLedgerError::Refused(report.refused)); + } + let open: Vec = read(&self.path)? + .open_intents() + .into_iter() + .map(|intent| intent.op.clone()) + .collect(); + for op in open { + self.append(&Record::committed(&op))?; } Ok(report) } } -/// Result of [`DeletionLedger::settle_open_intents`]. -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] -pub struct SettleReport { - pub committed: usize, - pub aborted: usize, +/// Whether the delete named by `intent` committed: every key is `deleted` at +/// the intent's key version, or at a later one after a re-create. +pub(super) async fn intent_committed( + db: &C, + intent: &Intent, +) -> Result { + for key in &intent.keys { + let row = encryption_network_key::Entity::find_by_id(( + intent.network_id.clone(), + key.key_path.clone(), + )) + .one(db) + .await?; + let deleted = row.is_some_and(|row| { + row.key_version > key.key_version + || (row.key_version == key.key_version && row.state == KeyState::Deleted.as_str()) + }); + if !deleted { + return Ok(false); + } + } + Ok(true) } /// Keeps a final line that is a whole record (adding its newline) and removes diff --git a/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs b/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs index 0b55b1b2..f67a778e 100644 --- a/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs +++ b/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs @@ -9,7 +9,7 @@ use sea_orm_migration::MigratorTrait; use super::deletion_ledger::{ read, replay, validate_path, DeletionLedger, DeletionLedgerError, Intent, LedgerKey, Record, - ReplayReport, SettleReport, + ReplayReport, }; use crate::{migrations::Migrator, models::encryption_network_key}; @@ -407,47 +407,93 @@ async fn replay_leaves_a_newer_key_version_and_counts_missing_rows() { assert_eq!(row(&db, "apps/a/x").await.sealed_seed, None); } -// ------------------------------------------------------------------ settle +// ------------------------------------------------------------ replay at start #[tokio::test] -async fn open_intents_are_settled_against_the_database() { +async fn replay_at_start_applies_open_operations_and_closes_them() { let db = fresh_db().await; - // `done` committed: its keys are deleted at the intent's version. `lost` - // did not: its key is still active. `again` committed and the key was - // created again afterwards (a later version). + // `done` committed. `lost` is open and its key is still active: the + // owner authorized the delete, so it is applied. `again` is open and its + // key was created again afterwards (a later version). `aborted` did not + // commit. insert_key(&db, "apps/a/done", "deletable", "deleted", false, 1).await; active_key(&db, "apps/a/lost").await; insert_key(&db, "apps/a/again", "deletable", "active", true, 2).await; + active_key(&db, "apps/a/aborted").await; let dir = tempfile::tempdir().unwrap(); let (path, ledger) = ledger_in(&dir); for (op, key) in [ ("done", ("apps/a/done", 1)), ("lost", ("apps/a/lost", 1)), ("again", ("apps/a/again", 1)), + ("aborted", ("apps/a/aborted", 1)), ] { ledger.append(&Record::Intent(intent(op, &[key]))).unwrap(); } ledger.append(&Record::committed("done")).unwrap(); - // `done` is settled already; the other two are open. - let settled = ledger.settle_open_intents(&db).await.unwrap(); + ledger.append(&Record::aborted("aborted")).unwrap(); + + let report = ledger.replay_at_start(&db).await.unwrap(); assert_eq!( - settled, - SettleReport { - committed: 1, - aborted: 1 + report, + ReplayReport { + operations: 4, + aborted_operations: 1, + applied: 1, + already_deleted: 1, + newer_version: 1, + ..ReplayReport::default() } ); + assert_eq!(row(&db, "apps/a/lost").await.state, "deleted"); + assert_eq!(row(&db, "apps/a/lost").await.sealed_seed, None); + assert_eq!(row(&db, "apps/a/again").await.state, "active"); + assert_eq!(row(&db, "apps/a/aborted").await.state, "active"); + // Every open operation is closed, as committed, and none stays open. let text = std::fs::read_to_string(&path).unwrap(); - assert!(text.contains(r#"{"aborted":"lost"}"#)); + assert!(text.contains(r#"{"committed":"lost"}"#)); assert!(text.contains(r#"{"committed":"again"}"#)); assert!(read(&path).unwrap().open_intents().is_empty()); - // Settled again, nothing is left to do, and replay skips the aborted one. - assert_eq!( - ledger.settle_open_intents(&db).await.unwrap(), - SettleReport::default() + // A second start has nothing left to apply. + let second = ledger.replay_at_start(&db).await.unwrap(); + assert_eq!(second.applied, 0); +} + +#[tokio::test] +async fn replay_at_start_fails_and_closes_nothing_when_the_ledger_holds_a_refused_key() { + let db = fresh_db().await; + active_key(&db, "apps/a/x").await; + let dir = tempfile::tempdir().unwrap(); + let (path, ledger) = ledger_in(&dir); + ledger + .append(&Record::Intent(intent( + "op", + &[("apps/a", 1), ("apps/a/x", 1)], + ))) + .unwrap(); + + let Err(error) = ledger.replay_at_start(&db).await else { + panic!("a ledger with a refused key fails the start"); + }; + assert!( + matches!(error, DeletionLedgerError::Refused(1)), + "{error:?}" ); - let report = replay(&db, &path).await.unwrap(); - assert_eq!(report.aborted_operations, 1); - assert_eq!(row(&db, "apps/a/lost").await.state, "active"); + assert_eq!(error.kind(), "refused"); + assert_eq!(read(&path).unwrap().open_intents().len(), 1); +} + +#[tokio::test] +async fn replay_at_start_fails_on_a_malformed_middle_line() { + let db = fresh_db().await; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ledger.jsonl"); + let whole = serde_json::to_string(&Record::Intent(intent("op", &[("apps/a/x", 1)]))).unwrap(); + std::fs::write(&path, format!("{whole}\nnot json\n{whole}\n")).unwrap(); + let ledger = DeletionLedger::open(&path).unwrap(); + let Err(error) = ledger.replay_at_start(&db).await else { + panic!("a malformed middle line fails the start"); + }; + assert_eq!(error.kind(), "malformed"); } diff --git a/tinycloud-core/src/encryption_network/key_delete.rs b/tinycloud-core/src/encryption_network/key_delete.rs index 83b1b47a..7b649b8f 100644 --- a/tinycloud-core/src/encryption_network/key_delete.rs +++ b/tinycloud-core/src/encryption_network/key_delete.rs @@ -6,8 +6,10 @@ //! so the ledger always names every committed deletion. use sea_orm::{ - sea_query::Expr, ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseTransaction, - EntityTrait, QueryFilter, QueryOrder, Value, + sea_query::{Alias, Expr, Func, SimpleExpr}, + ActiveModelTrait, + ActiveValue::Set, + ColumnTrait, DatabaseTransaction, EntityTrait, QueryFilter, QueryOrder, }; use crate::{ @@ -18,7 +20,7 @@ use crate::{ use super::{ canonical::hash_hex, delete_auth::{DeleteTarget, VerifiedOwnerDelete}, - deletion_ledger::{DeletionLedger, Intent, LedgerKey, Record}, + deletion_ledger::{intent_committed, DeletionLedger, Intent, LedgerKey, Record}, key_id::KeyKind, keys::{find_key_row, lock_network_row, KeyState}, network_id::NetworkId, @@ -69,41 +71,48 @@ impl EncryptionService { let result = self .delete_keys_in(&tx, network, target, verified, &ledger) .await; - let (outcome, op) = match result { + let (outcome, intent) = match result { Ok(done) => done, Err(err) => { let _ = tx.rollback().await; return Err(err); } }; - let committed = self.commit_delete(tx).await; - if let Err(err) = committed { - // The commit did not happen: say so before answering. - self.settle_aborted(&ledger, op.as_deref()).await; + if let Err(err) = self.commit_delete(tx).await { + // PostgreSQL can commit and then lose the reply, so the answer to + // "did it commit" comes from the rows, not from this error. + self.settle_after_commit_error(&ledger, intent.as_ref()) + .await; return Err(err.into()); } - if let Some(op) = op { + if let Some(intent) = intent { // A missing `committed` line is settled from the database at the // next start, so a failure here does not undo the delete. - if ledger.append_async(Record::committed(&op)).await.is_err() { - ::tracing::warn!("deletion ledger: committed line was not written"); + if let Err(error) = ledger.append_async(Record::committed(&intent.op)).await { + ::tracing::warn!(%error, "deletion ledger: committed line was not written"); } } Ok(outcome) } - /// Commits the delete's transaction. In tests the next call can be made - /// to fail instead, with the transaction rolled back. + /// Commits the delete's transaction. Tests can make it fail before the + /// commit (rolled back) or after it (committed, reply lost). async fn commit_delete(&self, tx: DatabaseTransaction) -> Result<(), sea_orm::DbErr> { #[cfg(test)] - if self - .fail_next_delete_commit - .swap(false, std::sync::atomic::Ordering::SeqCst) { - tx.rollback().await?; - return Err(sea_orm::DbErr::Custom( - "injected commit failure".to_string(), - )); + use std::sync::atomic::Ordering::SeqCst; + if self.delete_faults.fail_before_commit.swap(false, SeqCst) { + tx.rollback().await?; + return Err(sea_orm::DbErr::Custom( + "injected failure before the commit".to_string(), + )); + } + if self.delete_faults.lose_commit_reply.swap(false, SeqCst) { + tx.commit().await?; + return Err(sea_orm::DbErr::Custom( + "injected loss of the commit reply".to_string(), + )); + } } tx.commit().await } @@ -115,7 +124,10 @@ impl EncryptionService { target: &DeleteTarget, verified: &VerifiedOwnerDelete, ledger: &std::sync::Arc, - ) -> Result<(DeleteOutcome, Option), EncryptionServiceError> { + ) -> Result<(DeleteOutcome, Option), EncryptionServiceError> { + // Serializes this delete with create, block and unblock on the network. + // The guarded updates (the key version must still match) keep each + // row's write correct without it; the lock is defense in depth. lock_network_row(tx, network).await?; if encryption_network::Entity::find_by_id(network.to_string()) .one(tx) @@ -198,18 +210,17 @@ impl EncryptionService { // The intent is durable before the commit: a crash after this point // leaves an open intent that the next start settles against the // database, so the ledger always names every committed deletion. - let op = if ledger_keys.is_empty() { + let intent = if ledger_keys.is_empty() { None } else { - ledger - .append_async(Record::Intent(Intent { - op: verified.auth_cid.clone(), - network_id: network.to_string(), - keys: ledger_keys, - deleted_at: now, - })) - .await?; - Some(verified.auth_cid.clone()) + let intent = Intent { + op: verified.auth_cid.clone(), + network_id: network.to_string(), + keys: ledger_keys, + deleted_at: now, + }; + ledger.append_async(Record::Intent(intent.clone())).await?; + Some(intent) }; Ok(( @@ -218,21 +229,57 @@ impl EncryptionService { deleted, already_deleted, }, - op, + intent, )) } - /// Appends `aborted` for an operation whose commit did not happen. A - /// failure here is settled from the database at the next start. - async fn settle_aborted(&self, ledger: &std::sync::Arc, op: Option<&str>) { - if let Some(op) = op { - if ledger.append_async(Record::aborted(op)).await.is_err() { - ::tracing::warn!("deletion ledger: aborted line was not written"); + /// After a failed commit: reads the rows again, through the pool and not + /// the failed connection, and appends `committed` when every key is + /// deleted at its version and `aborted` when the commit did not happen. + /// When the rows cannot be read the intent stays open, and the next start + /// settles it. + async fn settle_after_commit_error( + &self, + ledger: &std::sync::Arc, + intent: Option<&Intent>, + ) { + let Some(intent) = intent else { return }; + let verdict = self.reread_committed(intent).await; + let record = match verdict { + Ok(true) => Record::committed(&intent.op), + Ok(false) => Record::aborted(&intent.op), + Err(error) => { + ::tracing::warn!(%error, "deletion ledger: could not tell whether a delete committed; the operation stays open"); + return; } + }; + if let Err(error) = ledger.append_async(record).await { + ::tracing::warn!(%error, "deletion ledger: the settling line was not written"); + } + } + + async fn reread_committed(&self, intent: &Intent) -> Result { + #[cfg(test)] + if self + .delete_faults + .fail_reread + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + return Err(sea_orm::DbErr::Custom("injected read failure".to_string())); } + intent_committed(&self.db, intent).await } } +/// Switches for tests to make a delete fail around its commit. +#[cfg(test)] +#[derive(Default)] +pub(super) struct DeleteFaults { + pub(super) fail_before_commit: std::sync::atomic::AtomicBool, + pub(super) lose_commit_reply: std::sync::atomic::AtomicBool, + pub(super) fail_reread: std::sync::atomic::AtomicBool, +} + /// `sha256(auth CID || 0x00 || key path)`, hex. The audit table's primary key. pub fn audit_request_hash(auth_cid: &str, key_path: &str) -> String { let mut input = Vec::with_capacity(auth_cid.len() + 1 + key_path.len()); @@ -253,13 +300,16 @@ async fn rows_under_prefix( ) -> Result, EncryptionServiceError> { let rows = encryption_network_key::Entity::find() .filter(encryption_network_key::Column::NetworkId.eq(network.to_string())) - .filter(Expr::cust_with_values( - "substr(key_path, 1, ?) = ?", - [ - Value::Int(Some(prefix.len() as i32)), - Value::String(Some(Box::new(prefix.to_string()))), - ], - )) + // `substr(key_path, 1, n) = prefix`: an exact comparison of the + // first n characters, the same SQL on SQLite and PostgreSQL. + .filter( + Expr::expr(Func::cust(Alias::new("substr")).args([ + Expr::col(encryption_network_key::Column::KeyPath).into(), + SimpleExpr::from(1i32), + SimpleExpr::from(prefix.len() as i32), + ])) + .eq(prefix.to_string()), + ) .order_by_asc(encryption_network_key::Column::KeyPath) .all(tx) .await?; diff --git a/tinycloud-core/src/encryption_network/key_delete_tests.rs b/tinycloud-core/src/encryption_network/key_delete_tests.rs index 87f6982e..bb72d8ef 100644 --- a/tinycloud-core/src/encryption_network/key_delete_tests.rs +++ b/tinycloud-core/src/encryption_network/key_delete_tests.rs @@ -494,19 +494,26 @@ async fn a_ledger_that_was_renamed_refuses_the_delete() { assert!(deletion_ledger::read(&moved).unwrap().records.is_empty()); } +/// What the ledger holds after a delete whose commit failed: the operation +/// and what settled it, if anything. +fn ledger_records(f: &Fixture) -> Vec { + deletion_ledger::read(&f.ledger_path).unwrap().records +} + #[tokio::test] -async fn a_commit_that_fails_leaves_an_aborted_line_and_deletes_nothing() { +async fn a_commit_that_did_not_happen_is_settled_as_aborted_from_the_rows() { let f = fixture().await; f.create(&["apps/foo/a", "apps/foo/b"]).await; f.service - .fail_next_delete_commit + .delete_faults + .fail_before_commit .store(true, std::sync::atomic::Ordering::SeqCst); let err = f.delete(&prefix("foo"), "cid-abort").await.unwrap_err(); assert!(matches!(err, EncryptionServiceError::Db(_)), "{err:?}"); f.assert_unchanged(&["apps/foo/a", "apps/foo/b"]).await; assert!(f.audit_rows().await.is_empty()); - let records = deletion_ledger::read(&f.ledger_path).unwrap().records; + let records = ledger_records(&f); assert_eq!(records.len(), 2); assert!(matches!(&records[0], Record::Intent(i) if i.op == "cid-abort")); assert_eq!(records[1], Record::aborted("cid-abort")); @@ -521,7 +528,52 @@ async fn a_commit_that_fails_leaves_an_aborted_line_and_deletes_nothing() { } #[tokio::test] -async fn a_crash_between_the_intent_and_the_commit_is_settled_at_the_next_start() { +async fn a_commit_whose_reply_was_lost_is_settled_as_committed_from_the_rows() { + let f = fixture().await; + f.create(&["apps/foo/a", "apps/foo/b"]).await; + // The database commits, then the answer is lost: the call fails. + f.service + .delete_faults + .lose_commit_reply + .store(true, std::sync::atomic::Ordering::SeqCst); + + let err = f.delete(&prefix("foo"), "cid-lost").await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::Db(_)), "{err:?}"); + for path in ["apps/foo/a", "apps/foo/b"] { + assert_eq!(f.row(path).await.state, "deleted", "{path}"); + } + let records = ledger_records(&f); + assert_eq!(records.len(), 2); + assert_eq!(records[1], Record::committed("cid-lost")); + // A restore applies it: it is not skipped as aborted. + let report = deletion_ledger::replay(&f.db, &f.ledger_path) + .await + .unwrap(); + assert_eq!(report.aborted_operations, 0); +} + +#[tokio::test] +async fn a_commit_error_that_cannot_be_checked_leaves_the_operation_open() { + let f = fixture().await; + f.create(&["apps/foo/a"]).await; + f.service + .delete_faults + .fail_before_commit + .store(true, std::sync::atomic::Ordering::SeqCst); + f.service + .delete_faults + .fail_reread + .store(true, std::sync::atomic::Ordering::SeqCst); + + let err = f.delete(&prefix("foo"), "cid-open").await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::Db(_)), "{err:?}"); + let contents = deletion_ledger::read(&f.ledger_path).unwrap(); + assert_eq!(contents.records.len(), 1, "only the operation is written"); + assert_eq!(contents.open_intents().len(), 1); +} + +#[tokio::test] +async fn a_crash_between_the_intent_and_the_commit_is_applied_at_the_next_start() { let f = fixture().await; f.create(&["apps/foo/a"]).await; // The intent is on disk; the process died before the commit. @@ -538,23 +590,45 @@ async fn a_crash_between_the_intent_and_the_commit_is_settled_at_the_next_start( })) .unwrap(); - let settled = ledger.settle_open_intents(&f.db).await.unwrap(); - assert_eq!((settled.committed, settled.aborted), (0, 1)); - f.assert_unchanged(&["apps/foo/a"]).await; + // The owner authorized this delete, so the start applies it. + ledger.replay_at_start(&f.db).await.unwrap(); + assert_eq!(f.row("apps/foo/a").await.state, "deleted"); + assert_eq!(f.row("apps/foo/a").await.sealed_seed, None); + assert!(deletion_ledger::read(&f.ledger_path) + .unwrap() + .open_intents() + .is_empty()); +} - // The same crash after the commit: the key is deleted, so it settles as committed. - f.delete(&key("apps/foo/a"), "cid-done").await.unwrap(); - let mut crashed = deletion_ledger::read(&f.ledger_path).unwrap().records; - crashed.retain(|record| !matches!(record, Record::Committed(_))); - let rewritten = f.ledger_path.with_file_name("crashed.jsonl"); - let lines: Vec = crashed - .iter() - .map(|record| serde_json::to_string(record).unwrap()) - .collect(); - std::fs::write(&rewritten, lines.join("\n") + "\n").unwrap(); - let crashed_ledger = DeletionLedger::open(&rewritten).unwrap(); - let settled = crashed_ledger.settle_open_intents(&f.db).await.unwrap(); - assert_eq!((settled.committed, settled.aborted), (1, 0)); +#[tokio::test] +async fn a_deleted_row_that_still_holds_a_seed_is_deleted_again_with_no_seed() { + let f = fixture().await; + f.create(&["apps/foo/a", "apps/foo/b"]).await; + let mut row: encryption_network_key::ActiveModel = f.row("apps/foo/a").await.into(); + row.state = Set("deleted".to_string()); + row.update(&f.db).await.unwrap(); + assert!(f.row("apps/foo/a").await.sealed_seed.is_some()); + + let outcome = f.delete(&prefix("foo"), "cid-seed").await.unwrap(); + assert_eq!(outcome.deleted, strings(&["apps/foo/a", "apps/foo/b"])); + assert!(outcome.already_deleted.is_empty()); + assert_eq!(f.row("apps/foo/a").await.sealed_seed, None); +} + +#[tokio::test] +async fn a_row_of_an_unknown_kind_is_corrupt_and_nothing_is_deleted() { + let f = fixture().await; + f.create(&["apps/foo/a"]).await; + f.insert_raw("apps/foo/odd", "mystery", true).await; + + let err = f.delete(&prefix("foo"), "cid-kind").await.unwrap_err(); + assert!( + matches!(err, EncryptionServiceError::CorruptKeyRow), + "{err:?}" + ); + f.assert_unchanged(&["apps/foo/a"]).await; + assert!(f.audit_rows().await.is_empty()); + assert!(f.ledger_paths().is_empty()); } #[tokio::test] diff --git a/tinycloud-core/src/encryption_network/service.rs b/tinycloud-core/src/encryption_network/service.rs index 62d8594d..5923ab77 100644 --- a/tinycloud-core/src/encryption_network/service.rs +++ b/tinycloud-core/src/encryption_network/service.rs @@ -251,9 +251,9 @@ pub struct EncryptionService { pub(super) key_cap_per_network: u64, pub(super) deletion_ledger: Option>, pub(super) key_cap_per_app_prefix: u64, - /// Makes the next key delete fail where it would commit. + /// Makes the next key delete fail around its commit. #[cfg(test)] - pub(super) fail_next_delete_commit: std::sync::atomic::AtomicBool, + pub(super) delete_faults: super::key_delete::DeleteFaults, } #[derive(Debug, Clone)] @@ -285,7 +285,7 @@ impl EncryptionService { key_cap_per_network: DEFAULT_KEY_CAP_PER_NETWORK, deletion_ledger: None, #[cfg(test)] - fail_next_delete_commit: Default::default(), + delete_faults: Default::default(), key_cap_per_app_prefix: DEFAULT_KEY_CAP_PER_APP_PREFIX, } } @@ -306,7 +306,7 @@ impl EncryptionService { key_cap_per_network: DEFAULT_KEY_CAP_PER_NETWORK, deletion_ledger: None, #[cfg(test)] - fail_next_delete_commit: Default::default(), + delete_faults: Default::default(), key_cap_per_app_prefix: DEFAULT_KEY_CAP_PER_APP_PREFIX, } } From dc90af3fb0229122f023ddcfe9c56a5760b1313e Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 01:47:56 +0000 Subject: [PATCH 23/29] encryption: replay the deletion ledger before serving the key routes, and keep them off when it cannot be used - The node replays the ledger onto the database after the migrations and before the routes are chosen. If the ledger cannot be opened or replayed, the named-key routes stay off, an error event with a fixed name and error kind is logged, and the rest of the node serves. - The delete route takes a body with the key path only. - Tests for a restored database, an empty ledger, a malformed ledger, an unopenable ledger, a non-canonical network id and a body with another field. - Docs: commit settling, start-time replay and the restore runbook. Details: TC-1777 --- CHANGELOG.md | 2 +- docs/encryption-named-keys.md | 19 +- tinycloud-node-server/src/lib.rs | 100 ++++-- .../src/policy_v3/named_keys_gate_tests.rs | 324 ++++++++++++++++++ .../src/routes/encryption.rs | 10 +- .../src/routes/tests/key_delete.rs | 37 ++ 6 files changed, 454 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67c4adb9..af5a7cfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ - 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: `POST /encryption/networks//key-actions/delete` (served when `named_keys_v1` is on) deletes one key under `apps/` (`keyPath` without a trailing `/`) or every key under `apps//` (`keyPath` with one). The network owner signs the request: a UCAN invocation (with `nbf`) for a `did:key` owner, a SIWE CACAO for a `did:pkh` owner, valid for at most 300 seconds and usable once. The response is `{ keyPath, state, deleted, alreadyDeleted, count }`; a deterministic key answers 409 `key_not_deletable`. A deleted key keeps its path with state `deleted` and no seed. Details: TC-1777. -- Configuration: adds `[global.encryption_network] deletion_ledger_path`, required while `named_keys_v1` is on. Every delete operation is appended to this file before the database commit and settled after it, and open operations are settled at every start; `tinycloud node deletion-ledger replay` re-applies the file to a restored database. Details: TC-1777. +- Configuration: adds `[global.encryption_network] deletion_ledger_path`, required while `named_keys_v1` is on. Every delete operation is appended to this file before the database commit and settled after it. At every start the node replays the file onto the database before serving the key routes; if the file cannot be opened or replayed the key routes stay off and the node logs `deletion_ledger_open_failed` or `deletion_ledger_replay_failed`. `tinycloud node deletion-ledger replay` applies the file to a restored database. 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. diff --git a/docs/encryption-named-keys.md b/docs/encryption-named-keys.md index ac7b6ca4..12984a37 100644 --- a/docs/encryption-named-keys.md +++ b/docs/encryption-named-keys.md @@ -143,16 +143,23 @@ created with mode 0600 and is never rotated. One JSON line per delete operation: `{ "op", "networkId", "keys": [{ "keyPath", "keyVersion" }], "deletedAt" }`, where `op` is the CID of the owner's credential. The line is written and fsynced after the delete's database changes and before the -transaction commits. After the commit the node appends `{"committed": }`; -if the commit fails it appends `{"aborted": }` before answering. If the +transaction commits. After the commit the node appends `{"committed": }`. +If the commit fails, the node reads the rows again and appends `committed` +when every key is `deleted` at that version and `aborted` only when the commit +did not happen; when the rows cannot be read the operation stays open. If the ledger cannot be written, or the file was renamed or removed, the delete is rolled back and answers 500 `deletion_ledger_unavailable`. -At every start, before any route is served, the node settles each operation -that has neither line: it is committed when every key is `deleted` at that key -version (or at a later one), otherwise aborted. +At every start, before any route is served, the node replays the ledger onto +the database (as `tinycloud node deletion-ledger replay` does) and closes each +operation that was still open with a `committed` line. If the ledger cannot be +opened or replayed, the named-key routes stay off, as with `named_keys_v1` off, +the node logs the event `deletion_ledger_open_failed` or +`deletion_ledger_replay_failed` with an `error_kind`, and the rest of the node +serves. -After restoring a database, before starting the node, run +Before restoring a database, stop the node and disable its automatic restart. +After the restore, before the node starts, run `tinycloud node deletion-ledger replay [--config ] [--ledger ]`. It sets every key of every committed or still-open operation to `deleted` with no seed. It skips aborted operations, rows that are not deletable keys, rows with diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index c4beac4f..576425c4 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -698,20 +698,6 @@ pub async fn app_with_control( ::tracing::warn!("network DID resolution enabled: did:web/did:tz fetches use the unhardened ssi client (TC-1080)"); } - let routes = production_routes(tinycloud_config.encryption_network.named_keys_v1); - #[cfg(feature = "tc-bench-v1")] - let mut routes = routes; - - #[cfg(feature = "tc-bench-v1")] - routes.extend(rocket::routes![ - tc_bench_auth_verify, - tc_bench_kv_put, - tc_bench_kv_get, - tc_bench_block_put, - tc_bench_block_get, - tc_bench_health, - ]); - let key_setup: StaticSecret = match resolve_keys(&tinycloud_config.keys, &tinycloud_config.tee).await? { Some(key_setup) => key_setup, @@ -831,36 +817,90 @@ pub async fn app_with_control( tinycloud.set_read_audit_wait(Duration::from_millis( tinycloud_config.database.read_audit_wait_ms, )); - let deletion_ledger = tinycloud_config + let mut named_keys_enabled = tinycloud_config.encryption_network.named_keys_v1; + let deletion_ledger = match tinycloud_config .encryption_network .deletion_ledger_path .as_deref() .map(tinycloud_core::encryption_network::deletion_ledger::DeletionLedger::open) .transpose() - .map_err(|error| anyhow::anyhow!("failed to open the deletion ledger: {error}"))? - .map(Arc::new); + { + Ok(ledger) => ledger.map(Arc::new), + Err(error) => { + named_keys_enabled = false; + ::tracing::error!( + event = "deletion_ledger_open_failed", + error_kind = error.kind(), + "deletion ledger could not be opened: the named-key routes are off" + ); + None + } + }; let encryption_service = encryption_service .with_sqlite_writer_lock(tinycloud.sqlite_writer_lock()) .with_deletion_ledger(deletion_ledger.clone()); // 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?; - // A delete that was cut off between its ledger intent and its settling - // line is settled from the database before any route is served. + // The deletion ledger is replayed onto the database before any route is + // served, so a delete that a restore or a crash left out is applied + // first. If the replay fails the named-key routes stay off (as with + // `named_keys_v1` off) and the rest of the node serves. + // (`named_keys_enabled` is false if the ledger could not be opened.) if let Some(ledger) = &deletion_ledger { - let settled = ledger - .settle_open_intents(&seed_conn) - .await - .map_err(|error| anyhow::anyhow!("failed to settle the deletion ledger: {error}"))?; - if ledger.dropped_tail_bytes() > 0 || settled != Default::default() { - ::tracing::warn!( - committed = settled.committed, - aborted = settled.aborted, - dropped_tail_bytes = ledger.dropped_tail_bytes(), - "deletion ledger settled at start" - ); + match ledger.replay_at_start(&seed_conn).await { + Ok(report) => { + if ledger.dropped_tail_bytes() > 0 || report.applied > 0 || report.torn_tail { + ::tracing::warn!( + event = "deletion_ledger_replayed", + operations = report.operations, + aborted_operations = report.aborted_operations, + applied = report.applied, + not_deletable = report.not_deletable, + newer_version = report.newer_version, + dropped_tail_bytes = ledger.dropped_tail_bytes(), + torn_tail = report.torn_tail, + "deletion ledger replayed at start" + ); + } + } + Err(error) => { + named_keys_enabled = false; + ::tracing::error!( + event = "deletion_ledger_replay_failed", + error_kind = error.kind(), + "deletion ledger replay failed: the named-key routes are off" + ); + } } } + let tinycloud = tinycloud.with_named_keys(named_keys_enabled); + let figment_named_keys_off; + let config = if named_keys_enabled == tinycloud_config.encryption_network.named_keys_v1 { + config + } else { + figment_named_keys_off = + config + .clone() + .merge(rocket::figment::providers::Serialized::global( + "encryption_network.named_keys_v1", + false, + )); + &figment_named_keys_off + }; + let routes = production_routes(named_keys_enabled); + #[cfg(feature = "tc-bench-v1")] + let mut routes = routes; + + #[cfg(feature = "tc-bench-v1")] + routes.extend(rocket::routes![ + tc_bench_auth_verify, + tc_bench_kv_put, + tc_bench_kv_get, + tc_bench_block_put, + tc_bench_block_get, + tc_bench_health, + ]); 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 67637fda..3a64abd4 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 @@ -381,3 +381,327 @@ async fn startup_refuses_a_non_deletable_key_row_under_apps( Ok(()) } on_both_backends!(startup_refuses_a_non_deletable_key_row_under_apps); + +// --------------------------------------------------------------------------- +// Deletion ledger replay at start + +/// One delete operation as the ledger holds it. +fn ledger_op(op: &str, network: &str, paths: &[&str]) -> String { + serde_json::json!({ + "op": op, + "networkId": network, + "keys": paths + .iter() + .map(|path| serde_json::json!({ "keyPath": path, "keyVersion": 1 })) + .collect::>(), + "deletedAt": "2026-10-23T12:00:00Z", + }) + .to_string() +} + +fn append_to_ledger(path: &std::path::Path, lines: &[String]) -> anyhow::Result<()> { + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .append(true) + .create(true) + .open(path)?; + for line in lines { + writeln!(file, "{line}")?; + } + Ok(()) +} + +fn delete_route(network: &str) -> String { + format!("/encryption/networks/{network}/key-actions/delete") +} + +/// A network owned by the harness's space owner, with `paths` created as +/// deletable keys. Returns the network id. +async fn network_with_keys( + h: &Harness, + paths: &[&str], +) -> anyhow::Result { + use tinycloud_core::encryption_network::key_id::KeyLocation; + let service = h + .client + .rocket() + .state::() + .expect("EncryptionService is managed"); + service + .create_one_of_one_network(CreateNetworkRequest { + name: "mainnet".to_string(), + owner_did: h.space_owner.did().to_string(), + threshold: Threshold::one_of_one(), + }) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + let network: tinycloud_core::encryption_network::NetworkId = h.network.parse()?; + for path in paths { + service + .create_key(&KeyLocation::new(&network, path)?, true) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + } + Ok(network) +} + +async fn key_state( + client: &rocket::local::asynchronous::Client, + network: &tinycloud_core::encryption_network::NetworkId, + path: &str, +) -> anyhow::Result { + use tinycloud_core::encryption_network::key_id::KeyLocation; + let service = client + .rocket() + .state::() + .expect("EncryptionService is managed"); + let key = service + .describe_key(&KeyLocation::new(network, path)?) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + Ok(key.state.as_str().to_string()) +} + +/// A database a restore brought back, with every key still active, and a +/// ledger that knows better: when the node starts, the committed operation +/// and the still-open one are applied and the aborted one is not; only then +/// are the key routes served. +async fn a_restored_database_has_the_ledger_applied_before_the_key_routes_serve( + backend: Backend, +) -> anyhow::Result<()> { + let ledger_dir = tempfile::tempdir()?; + let ledger_path = ledger_dir.path().join("deletion-ledger.jsonl"); + let on = |config: &mut crate::config::Config| { + config.encryption_network.named_keys_v1 = true; + config.encryption_network.deletion_ledger_path = Some(ledger_path.clone()); + }; + let Some(h) = Harness::new_with( + backend, + Shape::KeySession, + "a_restored_database_has_the_ledger_applied_before_the_key_routes_serve", + on, + ) + .await? + else { + return Ok(()); + }; + let network = network_with_keys( + &h, + &[ + "apps/a/committed", + "apps/a/open", + "apps/a/aborted", + "apps/a/untouched", + ], + ) + .await?; + append_to_ledger( + &ledger_path, + &[ + ledger_op("c", &h.network, &["apps/a/committed"]), + r#"{"committed":"c"}"#.to_string(), + ledger_op("o", &h.network, &["apps/a/open"]), + ledger_op("x", &h.network, &["apps/a/aborted"]), + r#"{"aborted":"x"}"#.to_string(), + ], + )?; + + let restarted = h.boot_again(on).await?; + for (path, state) in [ + ("apps/a/committed", "deleted"), + ("apps/a/open", "deleted"), + ("apps/a/aborted", "active"), + ("apps/a/untouched", "active"), + ] { + assert_eq!( + key_state(&restarted, &network, path).await?, + state, + "{path}" + ); + } + // The open operation was closed, so the next start does not repeat it. + let text = std::fs::read_to_string(&ledger_path)?; + assert!(text.contains(r#"{"committed":"o"}"#), "{text}"); + // The key routes serve: the delete route is mounted and asks for a credential. + let (status, _, _) = answer( + restarted + .post(delete_route(&h.network)) + .header(ContentType::JSON) + .body("{}"), + ) + .await; + assert_eq!(status, Status::Unauthorized); + Ok(()) +} +on_both_backends!(a_restored_database_has_the_ledger_applied_before_the_key_routes_serve); + +/// A start with a clean database and a ledger that holds nothing changes +/// nothing and serves the key routes. +async fn a_start_with_an_empty_ledger_changes_nothing(backend: Backend) -> anyhow::Result<()> { + let ledger_dir = tempfile::tempdir()?; + let ledger_path = ledger_dir.path().join("deletion-ledger.jsonl"); + let on = |config: &mut crate::config::Config| { + config.encryption_network.named_keys_v1 = true; + config.encryption_network.deletion_ledger_path = Some(ledger_path.clone()); + }; + let Some(h) = Harness::new_with( + backend, + Shape::KeySession, + "a_start_with_an_empty_ledger_changes_nothing", + on, + ) + .await? + else { + return Ok(()); + }; + let network = network_with_keys(&h, &["apps/a/x", "apps/a/y"]).await?; + + let restarted = h.boot_again(on).await?; + for path in ["apps/a/x", "apps/a/y"] { + assert_eq!(key_state(&restarted, &network, path).await?, "active"); + } + assert_eq!(std::fs::metadata(&ledger_path)?.len(), 0); + let (status, _, _) = answer( + restarted + .post(delete_route(&h.network)) + .header(ContentType::JSON) + .body("{}"), + ) + .await; + assert_eq!(status, Status::Unauthorized); + Ok(()) +} +on_both_backends!(a_start_with_an_empty_ledger_changes_nothing); + +/// The events a test run logged, with their fields. +#[derive(Clone, Default)] +struct EventLog(std::sync::Arc>>>); + +impl ::tracing::Subscriber for EventLog { + fn enabled(&self, _metadata: &::tracing::Metadata<'_>) -> bool { + true + } + + fn new_span(&self, _attributes: &::tracing::span::Attributes<'_>) -> ::tracing::span::Id { + ::tracing::span::Id::from_u64(1) + } + + fn record(&self, _span: &::tracing::span::Id, _values: &::tracing::span::Record<'_>) {} + + fn record_follows_from(&self, _span: &::tracing::span::Id, _follows: &::tracing::span::Id) {} + + fn enter(&self, _span: &::tracing::span::Id) {} + + fn exit(&self, _span: &::tracing::span::Id) {} + + fn event(&self, event: &::tracing::Event<'_>) { + #[derive(Default)] + struct Fields(std::collections::BTreeMap); + impl ::tracing::field::Visit for Fields { + fn record_debug( + &mut self, + field: &::tracing::field::Field, + value: &dyn std::fmt::Debug, + ) { + self.0 + .insert(field.name().to_string(), format!("{value:?}")); + } + } + let mut fields = Fields::default(); + event.record(&mut fields); + self.0.lock().expect("event log").push(fields.0); + } +} + +/// A ledger the node cannot replay (or cannot open) turns the key routes off +/// as if `named_keys_v1` were off, logs one alert event with a fixed name and +/// a fixed error kind, and the rest of the node serves. Runs on one thread so +/// the thread-scoped subscriber sees every event of the start. +async fn a_failing_ledger_turns_the_key_routes_off_and_the_rest_of_the_node_serves( + ledger: impl FnOnce(&std::path::Path) -> (std::path::PathBuf, &'static str, &'static str), +) -> anyhow::Result<()> { + let events = EventLog::default(); + let _subscriber = ::tracing::subscriber::set_default(events.clone()); + let ledger_dir = tempfile::tempdir()?; + let (ledger_path, event, error_kind) = ledger(ledger_dir.path()); + let on = |config: &mut crate::config::Config| { + config.encryption_network.named_keys_v1 = true; + config.encryption_network.deletion_ledger_path = Some(ledger_path.clone()); + }; + let Some(h) = Harness::new_with( + Backend::Sqlite, + Shape::KeySession, + "a_failing_ledger_turns_the_key_routes_off", + on, + ) + .await? + else { + return Ok(()); + }; + + let unmounted = answer( + h.client + .post(format!("/encryption/networks/{}/no-such-route", h.network)) + .header(ContentType::JSON) + .body("{}"), + ) + .await; + let delete = answer( + h.client + .post(delete_route(&h.network)) + .header(ContentType::JSON) + .body("{}"), + ) + .await; + assert_eq!(unmounted.0, Status::NotFound); + assert_eq!( + delete, unmounted, + "the delete route answers as an unmounted path" + ); + let (status, body, _) = answer(h.client.get("/version")).await; + assert_eq!(status, Status::Ok, "{body}"); + + let logged = events.0.lock().expect("event log").clone(); + let alerts: Vec<_> = logged + .iter() + .filter(|fields| fields.get("event").map(String::as_str) == Some(&format!("{event:?}"))) + .collect(); + assert_eq!(alerts.len(), 1, "{logged:?}"); + assert_eq!( + alerts[0].get("error_kind").map(String::as_str), + Some(format!("{error_kind:?}").as_str()) + ); + // The alert carries the event name and a kind, and nothing else but the message. + assert!( + alerts[0] + .keys() + .all(|name| ["event", "error_kind", "message"].contains(&name.as_str())), + "{:?}", + alerts[0] + ); + Ok(()) +} + +#[tokio::test] +async fn a_ledger_with_a_malformed_line_turns_the_key_routes_off() -> anyhow::Result<()> { + a_failing_ledger_turns_the_key_routes_off_and_the_rest_of_the_node_serves(|dir| { + let path = dir.join("deletion-ledger.jsonl"); + let op = ledger_op("o", "urn:tinycloud:encryption:did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK:mainnet", &["apps/a/x"]); + std::fs::write(&path, format!("{op}\nnot a ledger line\n{op}\n")).expect("write ledger"); + (path, "deletion_ledger_replay_failed", "malformed") + }) + .await +} + +#[tokio::test] +async fn a_ledger_that_cannot_be_opened_turns_the_key_routes_off() -> anyhow::Result<()> { + a_failing_ledger_turns_the_key_routes_off_and_the_rest_of_the_node_serves(|dir| { + // The directory does not exist, so the file cannot be created. + ( + dir.join("missing").join("deletion-ledger.jsonl"), + "deletion_ledger_open_failed", + "io", + ) + }) + .await +} diff --git a/tinycloud-node-server/src/routes/encryption.rs b/tinycloud-node-server/src/routes/encryption.rs index c65c6c20..9e39f5cf 100644 --- a/tinycloud-node-server/src/routes/encryption.rs +++ b/tinycloud-node-server/src/routes/encryption.rs @@ -282,6 +282,14 @@ pub struct KeyPathBody { pub key_path: String, } +/// The body of a key delete: the key path and nothing else. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeleteKeyBody { + #[serde(rename = "keyPath")] + pub key_path: String, +} + /// Full key descriptor, returned by create, block, unblock and the /// authenticated describe GET. #[derive(Debug, Serialize)] @@ -632,7 +640,7 @@ pub async fn delete_encryption_key( service: &State, ) -> Result, (Status, String)> { let body_value = body.into_inner(); - let body: KeyPathBody = serde_json::from_value(body_value.clone()).map_err(|err| { + let body: DeleteKeyBody = serde_json::from_value(body_value.clone()).map_err(|err| { ( Status::BadRequest, format!("request body is malformed: {err}"), diff --git a/tinycloud-node-server/src/routes/tests/key_delete.rs b/tinycloud-node-server/src/routes/tests/key_delete.rs index d32e8877..48003846 100644 --- a/tinycloud-node-server/src/routes/tests/key_delete.rs +++ b/tinycloud-node-server/src/routes/tests/key_delete.rs @@ -865,3 +865,40 @@ async fn a_did_pkh_credential_must_match_the_request_and_the_node() -> Result<() assert_eq!(status, Status::Forbidden, "{text}"); Ok(()) } + +#[tokio::test] +async fn delete_refuses_a_network_id_that_is_not_in_canonical_form() -> Result<()> { + let (fixture, _) = setup().await?; + let wallet = Wallet::new(5); + let network = wallet_network(&fixture, &wallet, &["apps/foo/a"]).await?; + let credential = prefix_credential(&fixture, &wallet, &network, "foo", "n-canon-0001"); + // The same owner address in lower case names the same network but is not + // the canonical spelling of its id. + let lowercase = network.to_string().to_lowercase(); + assert_ne!(lowercase, network.to_string()); + let (status, text) = delete_with(&fixture, &lowercase, "apps/foo/", &credential).await; + assert_eq!(status, Status::BadRequest, "{text}"); + assert!(text.contains("canonical form"), "{text}"); + assert!(ledger_paths(&fixture).is_empty()); + + // Nothing was consumed: the credential still deletes through the canonical id. + let (status, text) = + delete_with(&fixture, &network.to_string(), "apps/foo/", &credential).await; + assert_eq!(status, Status::Ok, "{text}"); + Ok(()) +} + +#[tokio::test] +async fn delete_body_with_another_field_is_a_bad_request() -> Result<()> { + let (fixture, network) = setup().await?; + let (status, text) = post_json( + &fixture.client, + &delete_url(&network), + "x", + &json!({ "keyPath": "apps/foo/", "extra": true }), + ) + .await; + assert_eq!(status, Status::BadRequest, "{text}"); + assert!(text.starts_with("request body is malformed"), "{text}"); + Ok(()) +} From 2d9110e237335a5bf55ad574c40bd17fe9a4ff2d Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 01:50:30 +0000 Subject: [PATCH 24/29] encryption: test when a delete operation counts as committed Details: TC-1777 --- .../deletion_ledger_tests.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs b/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs index f67a778e..a0750d44 100644 --- a/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs +++ b/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs @@ -497,3 +497,26 @@ async fn replay_at_start_fails_on_a_malformed_middle_line() { }; assert_eq!(error.kind(), "malformed"); } + +#[tokio::test] +async fn an_operation_committed_means_every_key_is_deleted_at_its_version_or_later() { + use super::deletion_ledger::intent_committed; + let db = fresh_db().await; + insert_key(&db, "apps/a/deleted", "deletable", "deleted", false, 1).await; + active_key(&db, "apps/a/active").await; + // Deleted at version 1 and created again as version 2. + insert_key(&db, "apps/a/again", "deletable", "active", true, 2).await; + + let committed = |keys: &[(&str, i64)]| { + let intent = intent("op", keys); + let db = db.clone(); + async move { intent_committed(&db, &intent).await.unwrap() } + }; + assert!(committed(&[("apps/a/deleted", 1)]).await); + assert!(committed(&[("apps/a/deleted", 1), ("apps/a/again", 1)]).await); + assert!(!committed(&[("apps/a/active", 1)]).await); + assert!(!committed(&[("apps/a/deleted", 1), ("apps/a/active", 1)]).await); + assert!(!committed(&[("apps/a/missing", 1)]).await); + // A key that is only deleted at an older version than the operation's. + assert!(!committed(&[("apps/a/deleted", 2)]).await); +} From 0a354b85c09a79c7da7b3066180838f73841e681 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 02:03:16 +0000 Subject: [PATCH 25/29] encryption: give two named-keys start tests a ledger path - siwe_app_grants_need_an_https_or_absent_scheme: the config closure sets deletion_ledger_path in a temp dir. - startup_refuses_a_non_deletable_key_row_under_apps: the named-keys boot sets deletion_ledger_path in a temp dir, keeps its is_err check, and adds a check that the start error names the rows under apps/ that are not deletable keys. Details: TC-1777 --- .../src/policy_v3/named_keys_gate_tests.rs | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) 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 a0dd09a7..96f33e64 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 @@ -368,11 +368,24 @@ async fn startup_refuses_a_non_deletable_key_row_under_apps( .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()); + // The same with the named-keys setting on (and a ledger path, which that + // setting requires). + let ledger_dir = tempfile::tempdir()?; + let ledger_path = ledger_dir.path().join("deletion-ledger.jsonl"); + let named_keys = |config: &mut crate::config::Config| { + config.encryption_network.named_keys_v1 = true; + config.encryption_network.deletion_ledger_path = Some(ledger_path.clone()); + }; + assert!(h.boot_again(named_keys).await.is_err()); + let Err(error) = h.boot_again(named_keys).await else { + panic!("a stray row under apps/ stops the start with named keys on"); + }; + assert!( + error + .to_string() + .contains("1 key rows under apps/ are not deletable keys"), + "{error}" + ); row.delete(&db).await?; h.boot_again(|_| {}) @@ -710,11 +723,16 @@ async fn a_ledger_that_cannot_be_opened_turns_the_key_routes_off() -> anyhow::Re /// 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 ledger_dir = tempfile::tempdir()?; 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, + |config| { + config.encryption_network.named_keys_v1 = true; + config.encryption_network.deletion_ledger_path = + Some(ledger_dir.path().join("deletion-ledger.jsonl")); + }, ) .await? else { From e863ee06cba7e8efb6396b23ab7983fb1bff92d6 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 02:39:03 +0000 Subject: [PATCH 26/29] encryption: settle a failed delete commit under the network row lock - The read that settles an unknown commit outcome runs in a new transaction that first takes the network row lock (30 s lock timeout on PostgreSQL), so it waits for a commit still running on the server. Any error leaves the operation open. - A PostgreSQL test stretches the commit with a deferred trigger and checks that the ledger ends committed and a restore plus replay deletes the key. - The failing-ledger start test also checks the managed config, /info, the key delegation refusal and every key route. - The refusal detail is asserted for a ReCap path without the trailing slash. Details: TC-1777 --- .../encryption_network/delete_auth_tests.rs | 18 ++++ .../src/encryption_network/key_delete.rs | 37 ++++++++- .../encryption_network/key_delete_tests.rs | 83 +++++++++++++++++++ .../src/policy_v3/named_keys_gate_tests.rs | 78 +++++++++++++++++ 4 files changed, 213 insertions(+), 3 deletions(-) diff --git a/tinycloud-core/src/encryption_network/delete_auth_tests.rs b/tinycloud-core/src/encryption_network/delete_auth_tests.rs index e790d2e6..de675c61 100644 --- a/tinycloud-core/src/encryption_network/delete_auth_tests.rs +++ b/tinycloud-core/src/encryption_network/delete_auth_tests.rs @@ -544,6 +544,24 @@ async fn cacao_for_another_prefix_is_refused() { "{label}: {result:?}" ); } + // The refusal for the path without its trailing slash is the recap + // check itself, not a later one. + let mut spec = CacaoSpec::valid(&owner, &target, at); + spec.recap = vec![( + target.urn().strip_suffix('/').unwrap().to_string(), + vec![KEY_DELETE_ACTION], + )]; + assert_refused( + verify( + &spec.header(&target), + &network, + &target, + &body_for(&target), + at, + ) + .await, + "ReCap is not key.delete on exactly the requested target", + ); } #[tokio::test] diff --git a/tinycloud-core/src/encryption_network/key_delete.rs b/tinycloud-core/src/encryption_network/key_delete.rs index 7b649b8f..42e685d1 100644 --- a/tinycloud-core/src/encryption_network/key_delete.rs +++ b/tinycloud-core/src/encryption_network/key_delete.rs @@ -9,7 +9,8 @@ use sea_orm::{ sea_query::{Alias, Expr, Func, SimpleExpr}, ActiveModelTrait, ActiveValue::Set, - ColumnTrait, DatabaseTransaction, EntityTrait, QueryFilter, QueryOrder, + ColumnTrait, ConnectionTrait, DatabaseTransaction, DbBackend, EntityTrait, QueryFilter, + QueryOrder, TransactionTrait, }; use crate::{ @@ -96,7 +97,8 @@ impl EncryptionService { } /// Commits the delete's transaction. Tests can make it fail before the - /// commit (rolled back) or after it (committed, reply lost). + /// commit (rolled back), after it (committed, reply lost), or return + /// without waiting for a commit that is still running. async fn commit_delete(&self, tx: DatabaseTransaction) -> Result<(), sea_orm::DbErr> { #[cfg(test)] { @@ -113,6 +115,14 @@ impl EncryptionService { "injected loss of the commit reply".to_string(), )); } + if self.delete_faults.detach_commit.swap(false, SeqCst) { + tokio::spawn(async move { + let _ = tx.commit().await; + }); + return Err(sea_orm::DbErr::Custom( + "injected: the commit outcome is not awaited".to_string(), + )); + } } tx.commit().await } @@ -258,6 +268,11 @@ impl EncryptionService { } } + /// Whether the intent's delete committed, read in a new transaction that + /// first takes the network row lock. A commit that is still running holds + /// that lock, so the read waits for its outcome (at most 30 seconds on + /// PostgreSQL) instead of seeing the rows as they were before it. Any + /// error, a lock timeout included, leaves the intent open. async fn reread_committed(&self, intent: &Intent) -> Result { #[cfg(test)] if self @@ -267,7 +282,22 @@ impl EncryptionService { { return Err(sea_orm::DbErr::Custom("injected read failure".to_string())); } - intent_committed(&self.db, intent).await + let network = intent + .network_id + .parse::() + .map_err(|error| sea_orm::DbErr::Custom(error.to_string()))?; + let tx = self.db.begin().await?; + if tx.get_database_backend() == DbBackend::Postgres { + tx.execute_unprepared("SET LOCAL lock_timeout = '30s'") + .await?; + } + let verdict = async { + lock_network_row(&tx, &network).await?; + intent_committed(&tx, intent).await + } + .await; + let _ = tx.rollback().await; + verdict } } @@ -278,6 +308,7 @@ pub(super) struct DeleteFaults { pub(super) fail_before_commit: std::sync::atomic::AtomicBool, pub(super) lose_commit_reply: std::sync::atomic::AtomicBool, pub(super) fail_reread: std::sync::atomic::AtomicBool, + pub(super) detach_commit: std::sync::atomic::AtomicBool, } /// `sha256(auth CID || 0x00 || key path)`, hex. The audit table's primary key. diff --git a/tinycloud-core/src/encryption_network/key_delete_tests.rs b/tinycloud-core/src/encryption_network/key_delete_tests.rs index bb72d8ef..2b40c6ed 100644 --- a/tinycloud-core/src/encryption_network/key_delete_tests.rs +++ b/tinycloud-core/src/encryption_network/key_delete_tests.rs @@ -767,3 +767,86 @@ async fn postgres_delete_keys_ends_its_transaction_before_returning() { .expect("the network row lock was released") .unwrap(); } + +// PostgreSQL-backed; CI-only. The node does not learn how the commit ended +// while the commit is still running on the server (a deferred trigger +// stretches it to about two seconds). The settling read waits for the network +// row lock that the committing transaction holds, so the ledger ends +// `committed` and a restore plus replay deletes the key. +#[tokio::test] +async fn postgres_a_commit_still_running_is_settled_as_committed_once_it_finishes() { + use sea_orm::ConnectionTrait; + let Some(fixture) = PostgresTestSchema::migrated_through( + "postgres_a_commit_still_running_is_settled_as_committed_once_it_finishes", + None, + ) + .await + else { + return; + }; + let db = fixture.connect(4).await; + let dir = tempfile::tempdir().unwrap(); + let ledger_path = dir.path().join("ledger.jsonl"); + let ledger = Arc::new(DeletionLedger::open(&ledger_path).unwrap()); + let service = service_over(db.clone(), Some(ledger)); + create_network(&service).await; + service.create_key(&loc("apps/foo/a"), true).await.unwrap(); + db.execute_unprepared( + "CREATE FUNCTION slow_commit() RETURNS trigger LANGUAGE plpgsql AS \ + $$ BEGIN PERFORM pg_sleep(2); RETURN NULL; END $$", + ) + .await + .unwrap(); + db.execute_unprepared( + "CREATE CONSTRAINT TRIGGER slow_commit AFTER UPDATE ON encryption_network_key \ + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION slow_commit()", + ) + .await + .unwrap(); + service + .delete_faults + .detach_commit + .store(true, std::sync::atomic::Ordering::SeqCst); + + let err = service + .delete_keys(&network(), &prefix("foo"), &verified("cid-slow")) + .await + .unwrap_err(); + assert!(matches!(err, EncryptionServiceError::Db(_)), "{err:?}"); + + let records = deletion_ledger::read(&ledger_path).unwrap().records; + assert_eq!(records.len(), 2, "{records:?}"); + assert!(matches!(&records[0], Record::Intent(i) if i.op == "cid-slow")); + assert_eq!(records[1], Record::committed("cid-slow")); + let row = encryption_network_key::Entity::find_by_id(( + network().to_string(), + "apps/foo/a".to_string(), + )) + .one(&db) + .await + .unwrap() + .unwrap(); + assert_eq!(row.state, "deleted"); + + // A restore brings the row back as it was before the delete; the replay + // deletes it again. + db.execute_unprepared( + "UPDATE encryption_network_key SET state = 'active', sealed_seed = decode('09', 'hex') \ + WHERE key_path = 'apps/foo/a'", + ) + .await + .unwrap(); + let report = deletion_ledger::replay(&db, &ledger_path).await.unwrap(); + assert_eq!(report.aborted_operations, 0); + assert_eq!(report.applied, 1); + let row = encryption_network_key::Entity::find_by_id(( + network().to_string(), + "apps/foo/a".to_string(), + )) + .one(&db) + .await + .unwrap() + .unwrap(); + assert_eq!(row.state, "deleted"); + assert!(row.sealed_seed.is_none()); +} 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 96f33e64..48f12ac2 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 @@ -674,6 +674,84 @@ async fn a_failing_ledger_turns_the_key_routes_off_and_the_rest_of_the_node_serv let (status, body, _) = answer(h.client.get("/version")).await; assert_eq!(status, Status::Ok, "{body}"); + // The configuration the routes read says named keys are off. + let config = h + .client + .rocket() + .state::() + .expect("Config is managed"); + assert!(!config.encryption_network.named_keys_v1); + let (status, body, _) = answer(h.client.get("/info")).await; + assert_eq!(status, Status::Ok, "{body}"); + let info: serde_json::Value = serde_json::from_str(&body)?; + assert!( + !info["features"] + .as_array() + .expect("features") + .iter() + .any(|feature| feature == "encryption-named-keys-v1"), + "{body}" + ); + // A delegation on a key is refused as it is with named keys off. + let authorization = h.space_owner.delegate( + h.owner.did(), + &[(format!("{}/docs/a", h.network), KEY_DECRYPT)], + Window::until(now() + Duration::hours(1)), + &[], + )?; + let (status, body) = h.delegate(authorization).await; + assert_eq!(status, Status::Unauthorized, "{body}"); + assert_eq!(body, NAMED_KEYS_UNAVAILABLE); + // Every key route answers as an unmounted path does. + let network = &h.network; + let remote = || "127.0.0.1:0".parse().expect("socket address"); + let unmounted_get = answer( + h.client + .get(format!( + "/encryption/networks/{network}/no-such-route/docs/a" + )) + .remote(remote()), + ) + .await; + let authorization = || Header::new("Authorization", "not-a-token"); + for action in ["block", "unblock", "delete"] { + let got = answer( + h.client + .post(format!( + "/encryption/networks/{network}/key-actions/{action}" + )) + .header(ContentType::JSON) + .header(authorization()) + .body("{}"), + ) + .await; + assert_eq!(got, unmounted, "{action}"); + } + let create = answer( + h.client + .post(format!("/encryption/networks/{network}/keys")) + .header(ContentType::JSON) + .header(authorization()) + .body("{}"), + ) + .await; + assert_eq!(create, unmounted, "create"); + let public_key = answer( + h.client + .get(format!("/encryption/networks/{network}/keys/docs/a")) + .remote(remote()), + ) + .await; + assert_eq!(public_key, unmounted_get, "public key"); + let describe = answer( + h.client + .get(format!("/encryption/networks/{network}/keys/docs/a")) + .header(authorization()) + .remote(remote()), + ) + .await; + assert_eq!(describe, unmounted_get, "describe"); + let logged = events.0.lock().expect("event log").clone(); let alerts: Vec<_> = logged .iter() From 3d3838e734733ef9fd168c0aeb36605535a4f921 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 14:45:14 +0000 Subject: [PATCH 27/29] encryption: cut a failed ledger append back to its prior length, and create the ledger directory in the image - An append whose write or fsync fails truncates the file to its length before the call and fsyncs, so a partial line is never followed by another. If the truncation fails, later appends fail. - The module comment states what the code does: start-time replay applies committed and open operations and closes the open ones. - The image contains /var/lib/tinycloud/ledger owned by uid 1000 with mode 0700. - Test: a write that fails after 0, 10 and all-but-one bytes leaves the file unchanged, and a later append reads and replays. Details: TC-1777 --- CHANGELOG.md | 1 + Dockerfile | 4 ++ docs/encryption-named-keys.md | 8 ++- .../src/encryption_network/deletion_ledger.rs | 54 ++++++++++++++++--- .../deletion_ledger_tests.rs | 50 +++++++++++++++++ 5 files changed, 109 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af5a7cfb..56f1441b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - Named-key routes and key resources are served only when `[global.encryption_network] named_keys_v1` is enabled. Details: TC-1777. - Encryption: `POST /encryption/networks//key-actions/delete` (served when `named_keys_v1` is on) deletes one key under `apps/` (`keyPath` without a trailing `/`) or every key under `apps//` (`keyPath` with one). The network owner signs the request: a UCAN invocation (with `nbf`) for a `did:key` owner, a SIWE CACAO for a `did:pkh` owner, valid for at most 300 seconds and usable once. The response is `{ keyPath, state, deleted, alreadyDeleted, count }`; a deterministic key answers 409 `key_not_deletable`. A deleted key keeps its path with state `deleted` and no seed. Details: TC-1777. - Configuration: adds `[global.encryption_network] deletion_ledger_path`, required while `named_keys_v1` is on. Every delete operation is appended to this file before the database commit and settled after it. At every start the node replays the file onto the database before serving the key routes; if the file cannot be opened or replayed the key routes stay off and the node logs `deletion_ledger_open_failed` or `deletion_ledger_replay_failed`. `tinycloud node deletion-ledger replay` applies the file to a restored database. Details: TC-1777. +- Encryption: a deletion ledger append whose write or fsync fails is cut back to the file's length before it. The node image contains `/var/lib/tinycloud/ledger`, owned by uid 1000 with mode 0700. 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. diff --git a/Dockerfile b/Dockerfile index 827f3ba5..ab9585d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,6 +53,9 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ cp /app/target/release/tinycloud /app/tinycloud RUN addgroup -g 1000 tinycloud && adduser -u 1000 -G tinycloud -s /bin/sh -D tinycloud RUN mkdir -p /scratch-tmp && chmod 1777 /scratch-tmp +# Directory for the deletion ledger (`deletion_ledger_path`), owned by the +# runtime user so a named volume mounted here is writable by it. +RUN mkdir -p /var/lib/tinycloud/ledger && chown 1000:1000 /var/lib/tinycloud/ledger && chmod 700 /var/lib/tinycloud/ledger # Runtime stage FROM ${RUNTIME_BASE} AS runtime @@ -61,6 +64,7 @@ COPY --from=builder /etc/passwd /etc/passwd COPY --from=builder /etc/group /etc/group COPY --from=builder --chown=tinycloud:tinycloud /app/tinycloud /tinycloud COPY --from=builder --chown=tinycloud:tinycloud /app/data ./data +COPY --from=builder --chown=tinycloud:tinycloud /var/lib/tinycloud/ledger /var/lib/tinycloud/ledger COPY ./tinycloud.toml ./ COPY --from=builder /scratch-tmp /tmp USER tinycloud:tinycloud diff --git a/docs/encryption-named-keys.md b/docs/encryption-named-keys.md index 12984a37..c3e56cae 100644 --- a/docs/encryption-named-keys.md +++ b/docs/encryption-named-keys.md @@ -148,7 +148,13 @@ If the commit fails, the node reads the rows again and appends `committed` when every key is `deleted` at that version and `aborted` only when the commit did not happen; when the rows cannot be read the operation stays open. If the ledger cannot be written, or the file was renamed or removed, the delete is -rolled back and answers 500 `deletion_ledger_unavailable`. +rolled back and answers 500 `deletion_ledger_unavailable`. An append whose write +or fsync fails is cut back to the file's length before it, so the file never +holds a partial line before a later one. + +The node image contains the directory `/var/lib/tinycloud/ledger`, owned by +uid 1000 (the node's user) with mode 0700; a volume mounted there is writable +by the node. At every start, before any route is served, the node replays the ledger onto the database (as `tinycloud node deletion-ledger replay` does) and closes each diff --git a/tinycloud-core/src/encryption_network/deletion_ledger.rs b/tinycloud-core/src/encryption_network/deletion_ledger.rs index 965ecbb8..5fd1f0c2 100644 --- a/tinycloud-core/src/encryption_network/deletion_ledger.rs +++ b/tinycloud-core/src/encryption_network/deletion_ledger.rs @@ -9,9 +9,11 @@ //! failed while the process lives. //! //! A crash between the intent and its settling line leaves the intent open. -//! Every node start settles open intents against the database before the -//! routes mount ([`DeletionLedger::settle_open_intents`]): rows deleted at that -//! version mean the commit happened, otherwise the delete is aborted. +//! Every node start replays the ledger onto the database before the routes +//! mount ([`DeletionLedger::replay_at_start`]): committed and still-open +//! operations are applied, and each open operation is then closed with a +//! `committed` line. Only an operation whose commit is known not to have +//! happened is settled `aborted`. //! //! [`replay`] re-applies a ledger to a database (seed NULL, state `deleted`) //! and is the first step of a restore. It applies committed and still-open @@ -23,7 +25,10 @@ use std::{ fs::{File, OpenOptions}, io::{Read, Seek, SeekFrom, Write}, path::{Path, PathBuf}, - sync::{Arc, Mutex}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, }; use sea_orm::{ActiveModelTrait, ActiveValue::Set, ConnectionTrait, EntityTrait, TransactionTrait}; @@ -162,6 +167,9 @@ pub struct DeletionLedger { path: PathBuf, file: Mutex, dropped_tail_bytes: usize, + /// Set when a failed append left bytes that could not be removed; later + /// appends fail rather than write after them. + unrepaired: AtomicBool, } impl DeletionLedger { @@ -195,6 +203,7 @@ impl DeletionLedger { path: path.to_path_buf(), file: Mutex::new(file), dropped_tail_bytes, + unrepaired: AtomicBool::new(false), }) } @@ -205,6 +214,7 @@ impl DeletionLedger { path: path.to_path_buf(), file: Mutex::new(file), dropped_tail_bytes: 0, + unrepaired: AtomicBool::new(false), } } @@ -219,18 +229,48 @@ impl DeletionLedger { /// Appends one line and fsyncs. Returns only after the line is durable, and /// fails, writing nothing, if the file the handle names is no longer the - /// file at the ledger path. Blocking; async callers use + /// file at the ledger path. If the write or the fsync fails, the file is + /// cut back to its length before the call, so a partial line is never + /// followed by another. Blocking; async callers use /// [`DeletionLedger::append_async`]. pub fn append(&self, record: &Record) -> Result<(), DeletionLedgerError> { let mut line = serde_json::to_vec(record)?; line.push(b'\n'); + self.append_line(&line, |file, bytes| file.write_all(bytes)) + } + + /// [`DeletionLedger::append`] with the write step supplied by the caller. + pub(super) fn append_line( + &self, + line: &[u8], + write: impl FnOnce(&mut File, &[u8]) -> std::io::Result<()>, + ) -> Result<(), DeletionLedgerError> { let mut file = self .file .lock() .unwrap_or_else(|poison| poison.into_inner()); self.check_not_moved(&file)?; - file.write_all(&line)?; - file.sync_all()?; + if self.unrepaired.load(Ordering::SeqCst) { + return Err(std::io::Error::other( + "an earlier failed append left bytes in the ledger that could not be removed", + ) + .into()); + } + let length_before = file.metadata()?.len(); + if let Err(error) = write(&mut file, line).and_then(|()| file.sync_all()) { + let unchanged = file + .metadata() + .is_ok_and(|metadata| metadata.len() == length_before); + if !unchanged + && file + .set_len(length_before) + .and_then(|()| file.sync_all()) + .is_err() + { + self.unrepaired.store(true, Ordering::SeqCst); + } + return Err(error.into()); + } Ok(()) } diff --git a/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs b/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs index a0750d44..67c70dd3 100644 --- a/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs +++ b/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs @@ -223,6 +223,56 @@ fn an_append_fails_closed_once_the_file_was_renamed_or_removed() { )); } +/// A write that stores `written` bytes of the line and then fails, as a full +/// disk does. +fn fails_after(written: usize) -> impl FnOnce(&mut std::fs::File, &[u8]) -> std::io::Result<()> { + use std::io::Write; + move |file, line| { + file.write_all(&line[..written.min(line.len())])?; + Err(std::io::Error::other("no space left on device")) + } +} + +#[tokio::test] +async fn a_failed_append_leaves_no_partial_line_and_later_appends_replay() { + let dir = tempfile::tempdir().unwrap(); + let (path, ledger) = ledger_in(&dir); + let db = fresh_db().await; + active_key(&db, "apps/a/x").await; + active_key(&db, "apps/a/y").await; + ledger + .append(&Record::Intent(intent("first", &[("apps/a/x", 1)]))) + .unwrap(); + ledger.append(&Record::committed("first")).unwrap(); + let before = std::fs::read(&path).unwrap(); + + // A failure after some bytes, after none, and after all but the newline. + let mut second = + serde_json::to_vec(&Record::Intent(intent("second", &[("apps/a/y", 1)]))).unwrap(); + second.push(b'\n'); + for written in [0, 10, second.len() - 1] { + let result = ledger.append_line(&second, fails_after(written)); + assert!( + matches!(result, Err(DeletionLedgerError::Io(_))), + "{written}: {result:?}" + ); + assert_eq!(std::fs::read(&path).unwrap(), before, "{written}"); + } + + // The next append lands on a whole line and the file reads and replays. + ledger + .append(&Record::Intent(intent("second", &[("apps/a/y", 1)]))) + .unwrap(); + ledger.append(&Record::committed("second")).unwrap(); + let read = read(&path).unwrap(); + assert!(!read.torn_tail); + assert_eq!(read.records.len(), 4); + let report = replay(&db, &path).await.unwrap(); + assert_eq!(report.applied, 2); + assert_eq!(row(&db, "apps/a/x").await.state, "deleted"); + assert_eq!(row(&db, "apps/a/y").await.state, "deleted"); +} + #[test] fn the_path_is_validated_without_touching_the_filesystem() { for ok in ["/var/lib/tinycloud/deletion-ledger.jsonl", "/a"] { From 4adf38f4afc6b6584abe8e2e3fa215ec4f5432de Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 15:39:16 +0000 Subject: [PATCH 28/29] encryption: hold an exclusive lock on the deletion ledger while the node runs - open takes an exclusive flock on .lock (created 0600, never removed) before it repairs or reads the ledger file, and holds it for the life of the value. If another process holds it, open tries every 500 ms for up to 60 s, logs deletion_ledger_lock_wait once, and then fails with error kind lock_held. - A failed append that cannot be cut back logs deletion_ledger_unrepaired. - Tests: a second open waits for the first to close; it fails as lock_held past the limit and leaves a partial last line in place; the lock file is forced to 0600; a whole line followed by an error is cut back. - Three start tests that kept one ledger file open in two apps at once use a second file for the first app (setup only): siwe_app_grants_need_an_app_id_bound_to_the_domain (second harness), a_restored_database_has_the_ledger_applied_before_the_key_routes_serve, a_start_with_an_empty_ledger_changes_nothing; a_crash_between_the_intent_and_the_commit_is_applied_at_the_next_start uses the fixture's open ledger. Details: TC-1777 --- .../src/encryption_network/deletion_ledger.rs | 112 +++++++++++++++++- .../deletion_ledger_tests.rs | 99 ++++++++++++++++ .../encryption_network/key_delete_tests.rs | 2 +- .../src/policy_v3/named_keys_gate_tests.rs | 24 +++- 4 files changed, 231 insertions(+), 6 deletions(-) diff --git a/tinycloud-core/src/encryption_network/deletion_ledger.rs b/tinycloud-core/src/encryption_network/deletion_ledger.rs index 5fd1f0c2..a1824b66 100644 --- a/tinycloud-core/src/encryption_network/deletion_ledger.rs +++ b/tinycloud-core/src/encryption_network/deletion_ledger.rs @@ -29,6 +29,7 @@ use std::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, }, + time::{Duration, Instant}, }; use sea_orm::{ActiveModelTrait, ActiveValue::Set, ConnectionTrait, EntityTrait, TransactionTrait}; @@ -109,6 +110,8 @@ pub enum DeletionLedgerError { Encode(#[from] serde_json::Error), #[error("deletion ledger file was renamed or removed")] Moved, + #[error("deletion ledger is locked by another process")] + LockHeld, #[error("deletion ledger line {0} is malformed")] Malformed(usize), #[error("deletion ledger holds {0} key entries that are not valid keys")] @@ -125,6 +128,7 @@ impl DeletionLedgerError { Self::Path(_) => "path", Self::Encode(_) => "encode", Self::Moved => "moved", + Self::LockHeld => "lock_held", Self::Malformed(_) => "malformed", Self::Refused(_) => "refused", Self::Db(_) => "db", @@ -161,7 +165,89 @@ pub fn validate_path(path: &Path) -> Result<(), &'static str> { Ok(()) } -/// An open ledger file. Appends are serialized and durable on return. +/// How often `open` tries the lock while another process holds it. +const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(500); +/// How long `open` waits for the lock before it fails with +/// [`DeletionLedgerError::LockHeld`]. +const LOCK_WAIT_LIMIT: Duration = Duration::from_secs(60); + +/// The lock file of a ledger: the ledger path with `.lock` appended. +fn lock_path(path: &Path) -> PathBuf { + let mut name = path.as_os_str().to_os_string(); + name.push(".lock"); + PathBuf::from(name) +} + +/// Takes a non-blocking exclusive `flock`. `Ok(false)` means another open +/// file description holds it. +#[cfg(unix)] +fn try_lock_exclusive(file: &File) -> std::io::Result { + use std::os::unix::io::AsRawFd; + loop { + // SAFETY: `flock` takes a descriptor that `file` keeps open for the + // duration of the call and reads or writes no memory of ours. + let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if rc == 0 { + return Ok(true); + } + let error = std::io::Error::last_os_error(); + match error.kind() { + std::io::ErrorKind::WouldBlock => return Ok(false), + std::io::ErrorKind::Interrupted => continue, + _ => return Err(error), + } + } +} + +#[cfg(not(unix))] +fn try_lock_exclusive(_file: &File) -> std::io::Result { + Ok(true) +} + +/// Creates (never deletes) the lock file next to the ledger with mode 0600 +/// and takes its exclusive lock, retrying every `interval` for up to `limit`. +/// The lock lasts as long as the returned file is open. +fn acquire_lock( + path: &Path, + interval: Duration, + limit: Duration, +) -> Result { + let mut options = OpenOptions::new(); + options.write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let lock_file = options.open(lock_path(path))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + lock_file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + let started = Instant::now(); + let mut waited = false; + loop { + if try_lock_exclusive(&lock_file)? { + return Ok(lock_file); + } + if !waited { + waited = true; + ::tracing::warn!( + event = "deletion_ledger_lock_wait", + "deletion ledger is locked by another process: waiting for it" + ); + } + if started.elapsed() >= limit { + return Err(DeletionLedgerError::LockHeld); + } + std::thread::sleep(interval); + } +} + +/// An open ledger file. Appends are serialized and durable on return. While +/// the value lives, an exclusive lock on `.lock` keeps another +/// process from opening the same ledger. #[derive(Debug)] pub struct DeletionLedger { path: PathBuf, @@ -170,17 +256,33 @@ pub struct DeletionLedger { /// Set when a failed append left bytes that could not be removed; later /// appends fail rather than write after them. unrepaired: AtomicBool, + /// Holds the lock; closed with the ledger. + _lock: Option, } impl DeletionLedger { - /// Opens (creating if absent) the ledger for appending. The file mode is + /// Opens (creating if absent) the ledger for appending. Before it reads or + /// changes the ledger file it takes an exclusive lock on + /// `.lock`, waiting up to 60 seconds (one try every 500 ms) + /// when another process holds it, then fails with + /// [`DeletionLedgerError::LockHeld`]. The file mode is /// forced to 0600. A final line that a crash left unterminated is kept if /// it is a whole record and otherwise dropped: a record is acted on only /// after its append returned, so an unterminated partial line was never /// followed by a commit. The file and its directory are fsynced before /// this returns. pub fn open(path: &Path) -> Result { + Self::open_waiting(path, LOCK_RETRY_INTERVAL, LOCK_WAIT_LIMIT) + } + + /// [`DeletionLedger::open`] with the lock retry interval and limit given. + pub(super) fn open_waiting( + path: &Path, + lock_interval: Duration, + lock_limit: Duration, + ) -> Result { validate_path(path).map_err(DeletionLedgerError::Path)?; + let lock = acquire_lock(path, lock_interval, lock_limit)?; let mut options = OpenOptions::new(); options.read(true).append(true).create(true); #[cfg(unix)] @@ -204,6 +306,7 @@ impl DeletionLedger { file: Mutex::new(file), dropped_tail_bytes, unrepaired: AtomicBool::new(false), + _lock: Some(lock), }) } @@ -215,6 +318,7 @@ impl DeletionLedger { file: Mutex::new(file), dropped_tail_bytes: 0, unrepaired: AtomicBool::new(false), + _lock: None, } } @@ -268,6 +372,10 @@ impl DeletionLedger { .is_err() { self.unrepaired.store(true, Ordering::SeqCst); + ::tracing::error!( + event = "deletion_ledger_unrepaired", + "a failed ledger append left bytes that could not be removed: later appends fail" + ); } return Err(error.into()); } diff --git a/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs b/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs index 67c70dd3..31d2d354 100644 --- a/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs +++ b/tinycloud-core/src/encryption_network/deletion_ledger_tests.rs @@ -273,6 +273,105 @@ async fn a_failed_append_leaves_no_partial_line_and_later_appends_replay() { assert_eq!(row(&db, "apps/a/y").await.state, "deleted"); } +/// The whole line is written and the write step then reports an error, as a +/// failed fsync does: the line is cut back too. +#[test] +fn a_whole_line_followed_by_an_error_is_cut_back() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let (path, ledger) = ledger_in(&dir); + ledger.append(&Record::committed("before")).unwrap(); + let before = std::fs::read(&path).unwrap(); + + let mut line = serde_json::to_vec(&Record::committed("after")).unwrap(); + line.push(b'\n'); + let result = ledger.append_line(&line, |file, bytes| { + file.write_all(bytes)?; + Err(std::io::Error::other("fsync failed")) + }); + assert!( + matches!(result, Err(DeletionLedgerError::Io(_))), + "{result:?}" + ); + assert_eq!(std::fs::read(&path).unwrap(), before); + + ledger.append(&Record::committed("later")).unwrap(); + assert_eq!(read(&path).unwrap().records.len(), 2); +} + +const SHORT: std::time::Duration = std::time::Duration::from_millis(20); + +#[test] +fn a_second_open_waits_for_the_first_to_close() { + let dir = tempfile::tempdir().unwrap(); + let (path, first) = ledger_in(&dir); + first.append(&Record::committed("one")).unwrap(); + + let closer = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(200)); + drop(first); + }); + let started = std::time::Instant::now(); + let second = + DeletionLedger::open_waiting(&path, SHORT, std::time::Duration::from_secs(30)).unwrap(); + assert!(started.elapsed() >= std::time::Duration::from_millis(100)); + closer.join().unwrap(); + // The second holds the lock now, and sees the first's line. + second.append(&Record::committed("two")).unwrap(); + assert_eq!(read(&path).unwrap().records.len(), 2); + let lock = std::path::PathBuf::from(format!("{}.lock", path.display())); + assert!(matches!( + DeletionLedger::open_waiting(&path, SHORT, SHORT), + Err(DeletionLedgerError::LockHeld) + )); + drop(second); + // The lock file stays after the ledger closes and is free again. + assert!(lock.exists()); + DeletionLedger::open_waiting(&path, SHORT, SHORT).unwrap(); +} + +#[test] +fn a_second_open_fails_as_lock_held_and_leaves_the_file_as_it_was() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let (path, first) = ledger_in(&dir); + first.append(&Record::committed("one")).unwrap(); + // An unfinished final line, as a crash would leave. + std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap() + .write_all(b"{\"committed\":\"tw") + .unwrap(); + let before = std::fs::read(&path).unwrap(); + + let error = DeletionLedger::open_waiting(&path, SHORT, std::time::Duration::from_millis(100)) + .unwrap_err(); + assert!(matches!(error, DeletionLedgerError::LockHeld), "{error:?}"); + assert_eq!(error.kind(), "lock_held"); + // The refused open did not trim the partial line. + assert_eq!(std::fs::read(&path).unwrap(), before); + + drop(first); + let reopened = DeletionLedger::open(&path).unwrap(); + assert!(reopened.dropped_tail_bytes() > 0); +} + +#[cfg(unix)] +#[test] +fn the_lock_file_is_forced_to_mode_0600() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let lock = dir.path().join("ledger.jsonl.lock"); + std::fs::write(&lock, "").unwrap(); + std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o644)).unwrap(); + let (_path, _ledger) = ledger_in(&dir); + assert_eq!( + std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777, + 0o600 + ); +} + #[test] fn the_path_is_validated_without_touching_the_filesystem() { for ok in ["/var/lib/tinycloud/deletion-ledger.jsonl", "/a"] { diff --git a/tinycloud-core/src/encryption_network/key_delete_tests.rs b/tinycloud-core/src/encryption_network/key_delete_tests.rs index 2b40c6ed..6f9882a3 100644 --- a/tinycloud-core/src/encryption_network/key_delete_tests.rs +++ b/tinycloud-core/src/encryption_network/key_delete_tests.rs @@ -577,7 +577,7 @@ async fn a_crash_between_the_intent_and_the_commit_is_applied_at_the_next_start( let f = fixture().await; f.create(&["apps/foo/a"]).await; // The intent is on disk; the process died before the commit. - let ledger = DeletionLedger::open(&f.ledger_path).unwrap(); + let ledger = f.service.deletion_ledger.clone().unwrap(); ledger .append(&Record::Intent(Intent { op: "cid-crash".to_string(), 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 48f12ac2..4eeb55a8 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 @@ -248,6 +248,12 @@ async fn siwe_app_grants_need_an_app_id_bound_to_the_domain( config.encryption_network.named_keys_v1 = true; config.encryption_network.deletion_ledger_path = Some(ledger_path.clone()); }; + // The second harness below runs while the first is alive: its own file. + let on_ucan = |config: &mut crate::config::Config| { + config.encryption_network.named_keys_v1 = true; + config.encryption_network.deletion_ledger_path = + Some(ledger_dir.path().join("deletion-ledger-ucan.jsonl")); + }; let window = || Window::until(now() + Duration::hours(1)); let Some(h) = Harness::new_with( @@ -304,7 +310,7 @@ async fn siwe_app_grants_need_an_app_id_bound_to_the_domain( backend, Shape::KeySession, "siwe_app_grants_need_an_app_id_bound_to_the_domain_ucan", - on, + on_ucan, ) .await? else { @@ -488,11 +494,18 @@ async fn a_restored_database_has_the_ledger_applied_before_the_key_routes_serve( config.encryption_network.named_keys_v1 = true; config.encryption_network.deletion_ledger_path = Some(ledger_path.clone()); }; + // The first boot has its own ledger file; the restart reads the restored + // one, which only one process opens at a time. + let first_boot = |config: &mut crate::config::Config| { + config.encryption_network.named_keys_v1 = true; + config.encryption_network.deletion_ledger_path = + Some(ledger_dir.path().join("deletion-ledger-first.jsonl")); + }; let Some(h) = Harness::new_with( backend, Shape::KeySession, "a_restored_database_has_the_ledger_applied_before_the_key_routes_serve", - on, + first_boot, ) .await? else { @@ -557,11 +570,16 @@ async fn a_start_with_an_empty_ledger_changes_nothing(backend: Backend) -> anyho config.encryption_network.named_keys_v1 = true; config.encryption_network.deletion_ledger_path = Some(ledger_path.clone()); }; + let first_boot = |config: &mut crate::config::Config| { + config.encryption_network.named_keys_v1 = true; + config.encryption_network.deletion_ledger_path = + Some(ledger_dir.path().join("deletion-ledger-first.jsonl")); + }; let Some(h) = Harness::new_with( backend, Shape::KeySession, "a_start_with_an_empty_ledger_changes_nothing", - on, + first_boot, ) .await? else { From 4644aafbb4ce40afce8729a4e085ff86b756ff80 Mon Sep 17 00:00:00 2001 From: dryrun Date: Sat, 24 Oct 2026 15:39:27 +0000 Subject: [PATCH 29/29] encryption: document the deletion ledger lock Details: TC-1777 --- CHANGELOG.md | 1 + docs/encryption-named-keys.md | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f1441b..2c9c67ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Encryption: `POST /encryption/networks//key-actions/delete` (served when `named_keys_v1` is on) deletes one key under `apps/` (`keyPath` without a trailing `/`) or every key under `apps//` (`keyPath` with one). The network owner signs the request: a UCAN invocation (with `nbf`) for a `did:key` owner, a SIWE CACAO for a `did:pkh` owner, valid for at most 300 seconds and usable once. The response is `{ keyPath, state, deleted, alreadyDeleted, count }`; a deterministic key answers 409 `key_not_deletable`. A deleted key keeps its path with state `deleted` and no seed. Details: TC-1777. - Configuration: adds `[global.encryption_network] deletion_ledger_path`, required while `named_keys_v1` is on. Every delete operation is appended to this file before the database commit and settled after it. At every start the node replays the file onto the database before serving the key routes; if the file cannot be opened or replayed the key routes stay off and the node logs `deletion_ledger_open_failed` or `deletion_ledger_replay_failed`. `tinycloud node deletion-ledger replay` applies the file to a restored database. Details: TC-1777. - Encryption: a deletion ledger append whose write or fsync fails is cut back to the file's length before it. The node image contains `/var/lib/tinycloud/ledger`, owned by uid 1000 with mode 0700. Details: TC-1777. +- Encryption: the deletion ledger is opened under an exclusive lock on `.lock`, held while the node runs. A second process waits up to 60 seconds (one try every 500 ms, logging `deletion_ledger_lock_wait`), then fails to open the ledger with `deletion_ledger_open_failed` and `error_kind` `lock_held`, and the named-key routes stay off. A failed append that could not be cut back logs `deletion_ledger_unrepaired`. 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. diff --git a/docs/encryption-named-keys.md b/docs/encryption-named-keys.md index c3e56cae..15e319b0 100644 --- a/docs/encryption-named-keys.md +++ b/docs/encryption-named-keys.md @@ -152,6 +152,15 @@ rolled back and answers 500 `deletion_ledger_unavailable`. An append whose write or fsync fails is cut back to the file's length before it, so the file never holds a partial line before a later one. +While the node runs, the ledger is locked: it takes an exclusive lock (`flock`) +on the file `.lock`, created with mode 0600 and never +removed. A second process that opens the same ledger tries the lock every +500 ms for up to 60 seconds and logs `deletion_ledger_lock_wait` once. After +that it fails to open the ledger: the node logs `deletion_ledger_open_failed` +with `error_kind` `lock_held`, the named-key routes stay off, and the rest of +the node serves. A failed append that could not be cut back logs +`deletion_ledger_unrepaired`, and later appends fail. + The node image contains the directory `/var/lib/tinycloud/ledger`, owned by uid 1000 (the node's user) with mode 0700; a volume mounted there is writable by the node.