From 34c1e2be86cbeb25f1394e922e531a30f293538a Mon Sep 17 00:00:00 2001 From: Samuel Gbafa Date: Wed, 3 Jun 2026 00:31:46 -0400 Subject: [PATCH 1/6] feat: add encryption network module --- Cargo.lock | 5 + tinycloud-auth/src/authorization.rs | 23 +- tinycloud-core/Cargo.toml | 5 + tinycloud-core/src/db.rs | 7 +- .../src/encryption_network/backend.rs | 203 ++++ .../src/encryption_network/canonical.rs | 120 ++ tinycloud-core/src/encryption_network/mod.rs | 32 + .../src/encryption_network/network_id.rs | 154 +++ .../src/encryption_network/protocol.rs | 295 +++++ .../src/encryption_network/service.rs | 1017 +++++++++++++++++ .../src/encryption_network/tests.rs | 768 +++++++++++++ .../src/encryption_network/types.rs | 170 +++ tinycloud-core/src/keys.rs | 17 + tinycloud-core/src/lib.rs | 1 + .../m20260601_000000_encryption_networks.rs | 272 +++++ tinycloud-core/src/migrations/mod.rs | 2 + tinycloud-core/src/models/delegation.rs | 26 +- tinycloud-core/src/models/encryption_audit.rs | 18 + .../src/models/encryption_ceremony.rs | 20 + .../src/models/encryption_network.rs | 25 + .../src/models/encryption_network_member.rs | 18 + tinycloud-core/src/models/encryption_nonce.rs | 16 + tinycloud-core/src/models/invocation.rs | 33 +- tinycloud-core/src/models/mod.rs | 5 + tinycloud-node-server/src/lib.rs | 26 + .../src/routes/encryption.rs | 208 ++++ tinycloud-node-server/src/routes/mod.rs | 13 +- tinycloud-sdk-wasm/src/definitions.rs | 2 + tinycloud-sdk-wasm/src/session.rs | 85 +- 29 files changed, 3543 insertions(+), 43 deletions(-) create mode 100644 tinycloud-core/src/encryption_network/backend.rs create mode 100644 tinycloud-core/src/encryption_network/canonical.rs create mode 100644 tinycloud-core/src/encryption_network/mod.rs create mode 100644 tinycloud-core/src/encryption_network/network_id.rs create mode 100644 tinycloud-core/src/encryption_network/protocol.rs create mode 100644 tinycloud-core/src/encryption_network/service.rs create mode 100644 tinycloud-core/src/encryption_network/tests.rs create mode 100644 tinycloud-core/src/encryption_network/types.rs create mode 100644 tinycloud-core/src/migrations/m20260601_000000_encryption_networks.rs create mode 100644 tinycloud-core/src/models/encryption_audit.rs create mode 100644 tinycloud-core/src/models/encryption_ceremony.rs create mode 100644 tinycloud-core/src/models/encryption_network.rs create mode 100644 tinycloud-core/src/models/encryption_network_member.rs create mode 100644 tinycloud-core/src/models/encryption_nonce.rs create mode 100644 tinycloud-node-server/src/routes/encryption.rs diff --git a/Cargo.lock b/Cargo.lock index ffae73a..4ad700d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9206,9 +9206,12 @@ dependencies = [ "arrow", "async-std", "async-trait", + "base64 0.22.1", "dashmap", "duckdb", "futures", + "hex", + "iri-string", "libp2p", "multihash-derive 0.9.1", "pin-project", @@ -9219,6 +9222,7 @@ dependencies = [ "serde", "serde_ipld_dagcbor 0.3.0", "serde_json", + "sha2 0.10.9", "sqlparser", "tempfile", "thiserror 2.0.18", @@ -9227,6 +9231,7 @@ dependencies = [ "tokio", "tracing", "ucan-capabilities-object", + "x25519-dalek", ] [[package]] diff --git a/tinycloud-auth/src/authorization.rs b/tinycloud-auth/src/authorization.rs index 1293bfb..6341209 100644 --- a/tinycloud-auth/src/authorization.rs +++ b/tinycloud-auth/src/authorization.rs @@ -1,6 +1,7 @@ use crate::resource::ResourceId; use base64::{engine::general_purpose::URL_SAFE, Engine as _}; use cacaos::siwe_cacao::SiweCacao; +use iri_string::types::UriString; use iri_string::validate::Error as UriStringError; use ssi::{ claims::jwt::NumericDate, @@ -115,6 +116,26 @@ pub fn make_invocation>( verification_method: &str, expiration: f64, options: InvocationOptions, +) -> Result { + make_invocation_from_uris( + invocation_target + .into_iter() + .map(|(resource, abilities)| (resource.as_uri(), abilities)), + delegation, + jwk, + verification_method, + expiration, + options, + ) +} + +pub fn make_invocation_from_uris>( + invocation_target: impl IntoIterator, + delegation: &Cid, + jwk: &JWK, + verification_method: &str, + expiration: f64, + options: InvocationOptions, ) -> Result { Ok(Payload { issuer: DIDURLBuf::from_str(verification_method)?, @@ -140,7 +161,7 @@ pub fn make_invocation>( attenuation: { let mut caps = ucan_capabilities_object::Capabilities::new(); for (resource, abilities) in invocation_target { - caps.with_actions(resource.as_uri(), abilities.into_iter().map(|a| (a, []))); + caps.with_actions(resource, abilities.into_iter().map(|a| (a, []))); } caps }, diff --git a/tinycloud-core/Cargo.toml b/tinycloud-core/Cargo.toml index e20f76d..99fc16c 100644 --- a/tinycloud-core/Cargo.toml +++ b/tinycloud-core/Cargo.toml @@ -39,8 +39,13 @@ arrow = { version = "56", features = ["ipc"] } tempfile = "3" aes-gcm = "0.10" rand = "0.8" +x25519-dalek = { version = "2.0", features = ["static_secrets"] } +hex.workspace = true +base64.workspace = true +sha2 = "0.10" [dev-dependencies] sea-orm = { version = "1.1", features = ["runtime-tokio-rustls", "sqlx-sqlite"] } async-std = { version = "1", features = ["attributes"] } tokio.workspace = true +iri-string.workspace = true diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 1096d92..83d3c4d 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -1091,8 +1091,11 @@ pub(crate) async fn transact( let cid = delegation::process(db, *d, encryption).await?; delegation_cids.push(cid); } - Event::Invocation(_, _) | Event::Revocation(_) => { - unreachable!("non-delegation events with empty event_spaces") + Event::Invocation(i, _ops) => { + invocation::process(db, *i, Vec::new(), encryption).await?; + } + Event::Revocation(r) => { + revocation::process(db, *r).await?; } }; } diff --git a/tinycloud-core/src/encryption_network/backend.rs b/tinycloud-core/src/encryption_network/backend.rs new file mode 100644 index 0000000..7a35c45 --- /dev/null +++ b/tinycloud-core/src/encryption_network/backend.rs @@ -0,0 +1,203 @@ +//! Key backends for the encryption module. +//! +//! 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 +//! ever assembling the full network private key. + +use rand::rngs::OsRng; +use thiserror::Error; +use x25519_dalek::{PublicKey, StaticSecret}; + +use crate::encryption::{ColumnEncryption, EncryptionError}; + +use super::types::ALG_X25519_AES256GCM; + +#[derive(Debug, Error)] +pub enum KeyBackendError { + #[error("private key material unavailable")] + SealedKeyMissing, + #[error("invalid sealed key length")] + InvalidSealedKey, + #[error("invalid public key length")] + InvalidPublicKey, + #[error("invalid wrapped key envelope")] + InvalidWrappedKey, + #[error("aead error: {0}")] + Aead(String), + #[error(transparent)] + Encryption(#[from] EncryptionError), +} + +pub struct GeneratedKey { + pub public_key: Vec, + pub sealed_private_key: Vec, + pub alg: String, +} + +/// Trait implemented by network key backends. +/// +/// `unwrap` returns the raw symmetric key after decrypting the wrapped key with +/// the network private key. `rewrap` seals that key to a per-request receiver +/// public key for transport back to the client. Higher-level code is +/// responsible for never persisting the raw symmetric key. +pub trait KeyBackend: Send + Sync { + fn kind(&self) -> super::types::KeyBackendKind; + + fn generate(&self) -> Result; + + fn unwrap( + &self, + sealed_private_key: &[u8], + wrapped_key: &[u8], + ) -> Result, KeyBackendError>; + + fn rewrap( + &self, + symmetric_key: &[u8], + receiver_public_key: &[u8], + ) -> Result, KeyBackendError>; +} + +/// X25519 ECIES-style key wrap backed by AES-256-GCM. Used both for wrapping to +/// the network public key (client side) and to the receiver public key (decrypt +/// response). +pub fn wrap_to_public_key( + recipient_public_key: &[u8], + plaintext: &[u8], +) -> Result, KeyBackendError> { + if recipient_public_key.len() != 32 { + return Err(KeyBackendError::InvalidPublicKey); + } + let mut recipient_array = [0u8; 32]; + recipient_array.copy_from_slice(recipient_public_key); + let recipient = PublicKey::from(recipient_array); + + let ephemeral = StaticSecret::random_from_rng(OsRng); + let ephemeral_pub = PublicKey::from(&ephemeral); + let shared = ephemeral.diffie_hellman(&recipient); + let cipher = ColumnEncryption::new(*shared.as_bytes()); + let mut envelope = Vec::with_capacity(32 + plaintext.len() + 32); + envelope.extend_from_slice(ephemeral_pub.as_bytes()); + let ct = cipher.encrypt(plaintext); + envelope.extend_from_slice(&ct); + Ok(envelope) +} + +fn unwrap_with_secret(secret: &StaticSecret, wrapped: &[u8]) -> Result, KeyBackendError> { + if wrapped.len() < 32 { + return Err(KeyBackendError::InvalidWrappedKey); + } + let mut peer = [0u8; 32]; + peer.copy_from_slice(&wrapped[..32]); + let peer_pub = PublicKey::from(peer); + let shared = secret.diffie_hellman(&peer_pub); + let cipher = ColumnEncryption::new(*shared.as_bytes()); + let pt = cipher.decrypt(&wrapped[32..])?; + Ok(pt) +} + +/// Local one-of-one backend. The DB encryption key is reused to seal the +/// network private key at rest — the same protection used elsewhere for +/// sensitive DB columns. +pub struct LocalOneOfOneBackend { + seal: ColumnEncryption, +} + +impl LocalOneOfOneBackend { + pub fn new(seal: ColumnEncryption) -> Self { + Self { seal } + } + + fn open_private(&self, sealed: &[u8]) -> Result { + let opened = self.seal.decrypt(sealed)?; + if opened.len() != 32 { + return Err(KeyBackendError::InvalidSealedKey); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&opened); + Ok(StaticSecret::from(arr)) + } +} + +impl KeyBackend for LocalOneOfOneBackend { + fn kind(&self) -> super::types::KeyBackendKind { + super::types::KeyBackendKind::LocalOneOfOne + } + + fn generate(&self) -> Result { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + let sealed = self.seal.encrypt(secret.as_bytes()); + Ok(GeneratedKey { + public_key: public.as_bytes().to_vec(), + sealed_private_key: sealed, + alg: ALG_X25519_AES256GCM.to_string(), + }) + } + + fn unwrap( + &self, + sealed_private_key: &[u8], + wrapped_key: &[u8], + ) -> Result, KeyBackendError> { + let secret = self.open_private(sealed_private_key)?; + unwrap_with_secret(&secret, wrapped_key) + } + + fn rewrap( + &self, + symmetric_key: &[u8], + receiver_public_key: &[u8], + ) -> Result, KeyBackendError> { + wrap_to_public_key(receiver_public_key, symmetric_key) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seal_key() -> ColumnEncryption { + ColumnEncryption::new([7u8; 32]) + } + + #[test] + fn generate_and_unwrap_round_trip() { + let backend = LocalOneOfOneBackend::new(seal_key()); + let gk = backend.generate().unwrap(); + let symmetric = [0xABu8; 32]; + let wrapped = wrap_to_public_key(&gk.public_key, &symmetric).unwrap(); + let recovered = backend.unwrap(&gk.sealed_private_key, &wrapped).unwrap(); + assert_eq!(recovered, symmetric); + } + + #[test] + fn rewrap_to_receiver_key() { + let backend = LocalOneOfOneBackend::new(seal_key()); + let symmetric = [0x12u8; 32]; + + let receiver_secret = StaticSecret::random_from_rng(OsRng); + let receiver_pub = PublicKey::from(&receiver_secret); + let rewrapped = backend.rewrap(&symmetric, receiver_pub.as_bytes()).unwrap(); + let recovered = unwrap_with_secret(&receiver_secret, &rewrapped).unwrap(); + assert_eq!(recovered, symmetric); + } + + #[test] + fn unwrap_fails_when_sealed_key_corrupted() { + let backend = LocalOneOfOneBackend::new(seal_key()); + let gk = backend.generate().unwrap(); + let wrapped = wrap_to_public_key(&gk.public_key, &[1u8; 32]).unwrap(); + let mut corrupted = gk.sealed_private_key.clone(); + corrupted[5] ^= 0xFF; + assert!(backend.unwrap(&corrupted, &wrapped).is_err()); + } + + #[test] + fn rejects_short_receiver_pubkey() { + let backend = LocalOneOfOneBackend::new(seal_key()); + let err = backend.rewrap(&[0u8; 32], &[1u8; 10]).unwrap_err(); + assert!(matches!(err, KeyBackendError::InvalidPublicKey)); + } +} diff --git a/tinycloud-core/src/encryption_network/canonical.rs b/tinycloud-core/src/encryption_network/canonical.rs new file mode 100644 index 0000000..f3fa439 --- /dev/null +++ b/tinycloud-core/src/encryption_network/canonical.rs @@ -0,0 +1,120 @@ +//! Canonical hashing used by decrypt invocations. +//! +//! `bodyHash` and `encryptedSymmetricKeyHash` are SHA-256 hashes of canonical +//! JSON bytes (objects emitted with lexicographically sorted keys). The output +//! is hex-encoded so it can travel through string-only JSON fields safely. + +use serde_json::Value; +use sha2::{Digest, Sha256}; + +pub fn hex(bytes: &[u8]) -> String { + hex_lower(bytes) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + } + out +} + +/// SHA-256 of raw bytes, hex-encoded. +pub fn hash_hex(input: &[u8]) -> String { + hex_lower(&Sha256::digest(input)) +} + +/// Canonicalize a JSON value to bytes with sorted object keys. +/// +/// Arrays preserve order; numbers/strings/booleans/null serialize directly. +/// Objects are re-emitted with keys sorted lexicographically. This is *not* a +/// general dag-json canonicalizer — it exists only so request bodies hash +/// deterministically regardless of how the client serialized them. +pub fn canonical_json_bytes(value: &Value) -> Vec { + let mut out = Vec::new(); + write_canonical(value, &mut out); + out +} + +pub fn canonical_hash(value: &Value) -> String { + hash_hex(&canonical_json_bytes(value)) +} + +fn write_canonical(value: &Value, out: &mut Vec) { + match value { + Value::Null => out.extend_from_slice(b"null"), + Value::Bool(b) => { + out.extend_from_slice(if *b { b"true" } else { b"false" }); + } + Value::Number(n) => { + out.extend_from_slice(n.to_string().as_bytes()); + } + Value::String(s) => { + // serde_json escaping suffices; Value::String round-trips. + let encoded = serde_json::to_string(s).expect("string encoding"); + out.extend_from_slice(encoded.as_bytes()); + } + Value::Array(items) => { + out.push(b'['); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(b','); + } + write_canonical(item, out); + } + out.push(b']'); + } + Value::Object(map) => { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + out.push(b'{'); + for (i, k) in keys.into_iter().enumerate() { + if i > 0 { + out.push(b','); + } + let encoded = serde_json::to_string(k).expect("key encoding"); + out.extend_from_slice(encoded.as_bytes()); + out.push(b':'); + write_canonical(&map[k], out); + } + out.push(b'}'); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn canonical_sorts_object_keys() { + let a = json!({ "b": 1, "a": 2 }); + let b = json!({ "a": 2, "b": 1 }); + assert_eq!(canonical_hash(&a), canonical_hash(&b)); + } + + #[test] + fn canonical_preserves_array_order() { + let a = json!([1, 2, 3]); + let b = json!([3, 2, 1]); + assert_ne!(canonical_hash(&a), canonical_hash(&b)); + } + + #[test] + fn canonical_differs_for_nested_changes() { + let a = json!({ "outer": { "x": 1 } }); + let b = json!({ "outer": { "x": 2 } }); + assert_ne!(canonical_hash(&a), canonical_hash(&b)); + } + + #[test] + fn hash_hex_stable() { + let h1 = hash_hex(b"hello"); + let h2 = hash_hex(b"hello"); + assert_eq!(h1, h2); + assert_eq!(h1.len(), 64); + } +} diff --git a/tinycloud-core/src/encryption_network/mod.rs b/tinycloud-core/src/encryption_network/mod.rs new file mode 100644 index 0000000..ff6452f --- /dev/null +++ b/tinycloud-core/src/encryption_network/mod.rs @@ -0,0 +1,32 @@ +//! Network-scoped encryption module. +//! +//! Implements the node-side responsibilities of the TinyCloud encryption +//! architecture: network lifecycle, key custody, ceremony state, and decrypt +//! invocation verification. The module deliberately does not expose a node-side +//! encrypt API — clients encrypt to the network public key locally. + +pub mod backend; +pub mod canonical; +pub mod network_id; +pub mod protocol; +pub mod service; +pub mod types; + +#[cfg(test)] +mod tests; + +pub use backend::{KeyBackend, KeyBackendError, LocalOneOfOneBackend}; +pub use network_id::{NetworkId, NetworkIdError}; +pub use protocol::{ + DecryptFacts, DecryptInvocation, DecryptRequestBody, DecryptResponseBody, InvocationCapability, + NetworkAdminFacts, NetworkAdminInvocation, DECRYPT_ACTION, DECRYPT_REQUEST_TYPE, + DECRYPT_RESULT_TYPE, NETWORK_ADMIN_TYPE, NETWORK_CREATE_ACTION, NETWORK_REVOKE_ACTION, +}; +pub use service::{ + CreateNetworkRequest, EncryptionService, EncryptionServiceError, VerifiedDecrypt, + WellKnownRecord, +}; +pub use types::{ + InlineEnvelope, KeyBackendKind, NetworkDescriptor, NetworkState, Threshold, + ALG_X25519_AES256GCM, +}; diff --git a/tinycloud-core/src/encryption_network/network_id.rs b/tinycloud-core/src/encryption_network/network_id.rs new file mode 100644 index 0000000..ae11745 --- /dev/null +++ b/tinycloud-core/src/encryption_network/network_id.rs @@ -0,0 +1,154 @@ +//! Parsing for `urn:tinycloud:encryption::` network identifiers. +//! +//! The principal is the root authority for the network. The network name disambiguates +//! multiple networks owned by the same principal. + +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; +use thiserror::Error; + +const NETWORK_ID_PREFIX: &str = "urn:tinycloud:encryption:"; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum NetworkIdError { + #[error("missing urn:tinycloud:encryption: prefix")] + MissingPrefix, + #[error("empty principal")] + EmptyPrincipal, + #[error("empty network name")] + EmptyName, + #[error("missing principal/name separator")] + MissingSeparator, + #[error("network name may not contain ':' or '/'")] + InvalidName, +} + +/// Owned, validated network id. Round-trips through [`Display`] and [`FromStr`]. +#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct NetworkId { + principal: String, + name: String, +} + +impl NetworkId { + pub fn new( + principal: impl Into, + name: impl Into, + ) -> Result { + let principal = principal.into(); + let name = name.into(); + if principal.is_empty() { + return Err(NetworkIdError::EmptyPrincipal); + } + if name.is_empty() { + return Err(NetworkIdError::EmptyName); + } + if name.contains(':') || name.contains('/') { + return Err(NetworkIdError::InvalidName); + } + Ok(Self { principal, name }) + } + + pub fn principal(&self) -> &str { + &self.principal + } + + pub fn name(&self) -> &str { + &self.name + } +} + +impl fmt::Display for NetworkId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{NETWORK_ID_PREFIX}{}:{}", self.principal, self.name) + } +} + +impl fmt::Debug for NetworkId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "NetworkId({self})") + } +} + +impl FromStr for NetworkId { + type Err = NetworkIdError; + + fn from_str(s: &str) -> Result { + let rest = s + .strip_prefix(NETWORK_ID_PREFIX) + .ok_or(NetworkIdError::MissingPrefix)?; + // The principal itself is a DID-like value that may contain colons + // (e.g. did:key:z6Mk...). The network name is the final colon-delimited + // segment, which is constrained to contain no further ':' or '/'. + let (principal, name) = rest + .rsplit_once(':') + .ok_or(NetworkIdError::MissingSeparator)?; + Self::new(principal.to_string(), name.to_string()) + } +} + +impl TryFrom for NetworkId { + type Error = NetworkIdError; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +impl From for String { + fn from(id: NetworkId) -> Self { + id.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_valid_did_key_network_id() { + let id: NetworkId = "urn:tinycloud:encryption:did:key:z6MkExampleAbcd:default" + .parse() + .unwrap(); + assert_eq!(id.principal(), "did:key:z6MkExampleAbcd"); + assert_eq!(id.name(), "default"); + assert_eq!( + id.to_string(), + "urn:tinycloud:encryption:did:key:z6MkExampleAbcd:default" + ); + } + + #[test] + fn rejects_missing_prefix() { + let err: Result = "did:key:z6Mk:default".parse(); + assert_eq!(err.unwrap_err(), NetworkIdError::MissingPrefix); + } + + #[test] + fn rejects_missing_name() { + let err: Result = "urn:tinycloud:encryption:did:key:z6Mk:".parse(); + assert_eq!(err.unwrap_err(), NetworkIdError::EmptyName); + } + + #[test] + fn rejects_empty_principal_with_explicit_name() { + let err: Result = "urn:tinycloud:encryption::default".parse(); + assert_eq!(err.unwrap_err(), NetworkIdError::EmptyPrincipal); + } + + #[test] + fn rejects_name_with_separator() { + let err = NetworkId::new("did:key:abc", "bad/name").unwrap_err(); + assert_eq!(err, NetworkIdError::InvalidName); + let err = NetworkId::new("did:key:abc", "bad:name").unwrap_err(); + assert_eq!(err, NetworkIdError::InvalidName); + } + + #[test] + fn rejects_no_separator() { + let err: Result = "urn:tinycloud:encryption:standalone".parse(); + assert_eq!(err.unwrap_err(), NetworkIdError::MissingSeparator); + } +} diff --git a/tinycloud-core/src/encryption_network/protocol.rs b/tinycloud-core/src/encryption_network/protocol.rs new file mode 100644 index 0000000..f26f446 --- /dev/null +++ b/tinycloud-core/src/encryption_network/protocol.rs @@ -0,0 +1,295 @@ +//! Decrypt request/response shapes plus a minimal UCAN-style invocation envelope +//! sufficient to enforce the architecture invariants in v1. +//! +//! NOTE: This is intentionally a self-contained envelope, not the existing +//! TinyCloud CACAO/Ucan invocation. The architecture targets a network resource +//! (`urn:tinycloud:encryption:...`), which the existing `Resource` system does +//! not model natively, and the request flow is a dedicated endpoint rather than +//! the general `/invoke` path. The shape is forward-compatible with promoting +//! `tinycloud.encryption/decrypt` to a first-class capability action later. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use super::canonical::canonical_hash; +use super::network_id::NetworkId; + +pub const DECRYPT_REQUEST_TYPE: &str = "tinycloud.encryption.decrypt/v1"; +pub const DECRYPT_RESULT_TYPE: &str = "tinycloud.encryption.decrypt-result/v1"; +pub const NETWORK_ADMIN_TYPE: &str = "tinycloud.encryption.network-admin/v1"; +pub const DECRYPT_ACTION: &str = "tinycloud.encryption/decrypt"; +pub const NETWORK_CREATE_ACTION: &str = "tinycloud.encryption/network.create"; +pub const NETWORK_REVOKE_ACTION: &str = "tinycloud.encryption/network.revoke"; + +/// Body of a POST /encryption/networks//decrypt request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecryptRequestBody { + #[serde(rename = "type")] + pub ty: String, + #[serde(rename = "targetNode")] + pub target_node: String, + #[serde(rename = "networkId")] + pub network_id: NetworkId, + pub alg: String, + #[serde(rename = "keyVersion")] + pub key_version: i64, + /// Base64-encoded wrapped symmetric key. + #[serde(rename = "encryptedSymmetricKey")] + pub encrypted_symmetric_key: String, + #[serde(rename = "encryptedSymmetricKeyHash")] + pub encrypted_symmetric_key_hash: String, + /// Base64-encoded receiver public key (per-request). + #[serde(rename = "receiverPublicKey")] + pub receiver_public_key: String, + #[serde(rename = "receiverPublicKeyHash")] + pub receiver_public_key_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecryptResponseBody { + #[serde(rename = "type")] + pub ty: String, + #[serde(rename = "targetNode")] + pub target_node: String, + #[serde(rename = "networkId")] + pub network_id: NetworkId, + #[serde(rename = "invocationCid")] + pub invocation_cid: String, + #[serde(rename = "encryptedSymmetricKeyHash")] + pub encrypted_symmetric_key_hash: String, + #[serde(rename = "receiverPublicKeyHash")] + pub receiver_public_key_hash: String, + /// Base64-encoded symmetric key wrapped to the receiver public key. + #[serde(rename = "wrappedKey")] + pub wrapped_key: String, + pub alg: String, + #[serde(rename = "keyVersion")] + pub key_version: i64, + #[serde(rename = "requestHash")] + pub request_hash: String, + #[serde(rename = "nodeId")] + pub node_id: String, + #[serde(rename = "nodeSignature")] + pub node_signature: String, +} + +/// UCAN-style invocation envelope for the decrypt action. +/// +/// `issuer` is the requester session DID. `audience` MUST equal the serving +/// node's DID. `proof_cid` references the delegation chain that authorizes the +/// decrypt action; verification rooted in the principal embedded in +/// `network_id` is performed by [`crate::encryption_network::service`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecryptInvocation { + #[serde(rename = "iss")] + pub issuer: String, + #[serde(rename = "aud")] + pub audience: String, + pub att: Vec, + pub facts: DecryptFacts, + pub nonce: String, + #[serde(rename = "nbf", skip_serializing_if = "Option::is_none")] + pub not_before: Option, + pub exp: i64, + #[serde(rename = "prf", default)] + pub proof_cid: Vec, + /// Signature over the canonical encoding of {iss, aud, att, facts, nonce, + /// nbf, exp, prf}. The signature scheme is left to the caller; v1 + /// verifies it by recomputing `invocationCid` and validating against the + /// requester key bound to the invocation issuer. + pub sig: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InvocationCapability { + /// Resource URN. For decrypt this MUST equal the network id. + pub with: String, + /// Capability action. For decrypt this MUST equal `tinycloud.encryption/decrypt`. + pub can: String, + /// Caveats. Decrypt has no caveats in v1. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub nb: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecryptFacts { + #[serde(rename = "type")] + pub ty: String, + #[serde(rename = "targetNode")] + pub target_node: String, + #[serde(rename = "networkId")] + pub network_id: NetworkId, + #[serde(rename = "bodyHash")] + pub body_hash: String, + #[serde(rename = "encryptedSymmetricKeyHash")] + pub encrypted_symmetric_key_hash: String, + #[serde(rename = "receiverPublicKeyHash")] + pub receiver_public_key_hash: String, + pub alg: String, + #[serde(rename = "keyVersion")] + pub key_version: i64, +} + +/// UCAN-style invocation envelope for encryption-network lifecycle actions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkAdminInvocation { + #[serde(rename = "iss")] + pub issuer: String, + #[serde(rename = "aud")] + pub audience: String, + pub att: Vec, + pub facts: NetworkAdminFacts, + pub nonce: String, + #[serde(rename = "nbf", skip_serializing_if = "Option::is_none")] + pub not_before: Option, + pub exp: i64, + #[serde(rename = "prf", default)] + pub proof_cid: Vec, + pub sig: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkAdminFacts { + #[serde(rename = "type")] + pub ty: String, + #[serde(rename = "targetNode")] + pub target_node: String, + #[serde(rename = "networkId")] + pub network_id: NetworkId, + #[serde(rename = "bodyHash")] + pub body_hash: String, + pub action: String, +} + +impl DecryptInvocation { + pub fn unsigned_payload(&self) -> serde_json::Value { + serde_json::json!({ + "iss": self.issuer, + "aud": self.audience, + "att": self.att, + "facts": self.facts, + "nonce": self.nonce, + "nbf": self.not_before, + "exp": self.exp, + "prf": self.proof_cid, + }) + } + + /// Stable identifier for the invocation. Used as `invocationCid` in + /// responses and as part of the request-hash used for audit/replay. + pub fn cid(&self) -> String { + canonical_hash(&self.unsigned_payload()) + } +} + +impl NetworkAdminInvocation { + pub fn unsigned_payload(&self) -> serde_json::Value { + serde_json::json!({ + "iss": self.issuer, + "aud": self.audience, + "att": self.att, + "facts": self.facts, + "nonce": self.nonce, + "nbf": self.not_before, + "exp": self.exp, + "prf": self.proof_cid, + }) + } + + pub fn cid(&self) -> String { + canonical_hash(&self.unsigned_payload()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sample_invocation() -> DecryptInvocation { + let net: NetworkId = "urn:tinycloud:encryption:did:key:z6MkPrincipal:default" + .parse() + .unwrap(); + DecryptInvocation { + issuer: "did:key:z6MkRequester".to_string(), + audience: "did:key:z6MkNode".to_string(), + att: vec![InvocationCapability { + with: net.to_string(), + can: DECRYPT_ACTION.to_string(), + nb: BTreeMap::new(), + }], + facts: DecryptFacts { + ty: DECRYPT_REQUEST_TYPE.to_string(), + target_node: "did:key:z6MkNode".to_string(), + network_id: net, + body_hash: "aa".repeat(32), + encrypted_symmetric_key_hash: "bb".repeat(32), + receiver_public_key_hash: "cc".repeat(32), + alg: "x25519-aes256gcm/v1".to_string(), + key_version: 1, + }, + nonce: "nonce-1".to_string(), + not_before: None, + exp: 1_900_000_000, + proof_cid: vec!["bafy".to_string()], + sig: "sig".to_string(), + } + } + + #[test] + fn cid_changes_with_facts() { + let mut a = sample_invocation(); + let cid_a = a.cid(); + a.facts.body_hash = "dd".repeat(32); + let cid_b = a.cid(); + assert_ne!(cid_a, cid_b); + } + + #[test] + fn cid_changes_with_audience() { + let mut a = sample_invocation(); + let cid_a = a.cid(); + a.audience = "did:key:other".to_string(); + let cid_b = a.cid(); + assert_ne!(cid_a, cid_b); + } + + #[test] + fn cid_changes_with_capabilities() { + let mut a = sample_invocation(); + let cid_a = a.cid(); + a.att[0].can = "tinycloud.encryption/other".to_string(); + let cid_b = a.cid(); + assert_ne!(cid_a, cid_b); + } + + #[test] + fn signature_is_not_part_of_cid() { + let mut a = sample_invocation(); + let cid_a = a.cid(); + a.sig = "another".to_string(); + let cid_b = a.cid(); + assert_eq!(cid_a, cid_b); + } + + #[test] + fn body_round_trips() { + let body = DecryptRequestBody { + ty: DECRYPT_REQUEST_TYPE.to_string(), + target_node: "did:key:z6MkNode".to_string(), + network_id: "urn:tinycloud:encryption:did:key:z6Mk:default" + .parse() + .unwrap(), + alg: "x25519-aes256gcm/v1".to_string(), + key_version: 1, + encrypted_symmetric_key: "AQID".to_string(), + encrypted_symmetric_key_hash: "aa".repeat(32), + receiver_public_key: "BAUG".to_string(), + receiver_public_key_hash: "bb".repeat(32), + }; + let json = serde_json::to_value(&body).unwrap(); + assert_eq!(json["type"], json!(DECRYPT_REQUEST_TYPE)); + let parsed: DecryptRequestBody = serde_json::from_value(json).unwrap(); + assert_eq!(parsed.encrypted_symmetric_key, body.encrypted_symmetric_key); + } +} diff --git a/tinycloud-core/src/encryption_network/service.rs b/tinycloud-core/src/encryption_network/service.rs new file mode 100644 index 0000000..23c55fa --- /dev/null +++ b/tinycloud-core/src/encryption_network/service.rs @@ -0,0 +1,1017 @@ +//! Encryption network service. +//! +//! Owns: network lifecycle, ceremony state, key backend access, decrypt +//! invocation verification, audit and nonce protection. +//! +//! Non-goals (v1): payload encryption API, envelope CRUD, plaintext payload +//! handling, recipient-list authorization. + +use std::sync::Arc; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, + QueryFilter, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; + +use crate::hash::hash; +use crate::keys::{public_key_to_did_key, Keypair, PublicKey}; +use crate::models::{ + encryption_audit, encryption_ceremony, encryption_network, encryption_network_member, + encryption_nonce, +}; +use crate::util::InvocationInfo; + +use super::backend::{KeyBackend, KeyBackendError}; +use super::canonical::{canonical_hash, canonical_json_bytes, hash_hex}; +use super::network_id::NetworkId; +use super::protocol::{ + DecryptFacts, DecryptInvocation, DecryptRequestBody, DecryptResponseBody, NetworkAdminFacts, + NetworkAdminInvocation, DECRYPT_ACTION, DECRYPT_REQUEST_TYPE, DECRYPT_RESULT_TYPE, + NETWORK_ADMIN_TYPE, +}; +use super::types::{ + KeyBackendKind, NetworkDescriptor, NetworkMemberDescriptor, NetworkState, Threshold, +}; + +const DEFAULT_INVOCATION_TTL_SECONDS: i64 = 300; + +#[derive(Debug, Error)] +pub enum EncryptionServiceError { + #[error("db error: {0}")] + Db(#[from] DbErr), + #[error("network not found")] + NetworkNotFound, + #[error("network already exists")] + NetworkAlreadyExists, + #[error("network is not active (state: {0})")] + NetworkNotActive(String), + #[error("network has been revoked")] + NetworkRevoked, + #[error("decrypt action not permitted")] + Unauthorized, + #[error("invocation audience does not match this node")] + AudienceMismatch, + #[error("invocation target node does not match this node")] + TargetNodeMismatch, + #[error("invocation root principal does not match network principal")] + PrincipalMismatch, + #[error("invocation references a different network")] + NetworkMismatch, + #[error("invocation expired")] + Expired, + #[error("invocation not yet valid")] + NotYetValid, + #[error("invocation hash mismatch: {0}")] + HashMismatch(&'static str), + #[error("invocation alg / key version does not match network")] + AlgKeyVersionMismatch, + #[error("invocation signature is invalid: {0}")] + SignatureInvalid(String), + #[error("invocation type is not a decrypt invocation")] + WrongInvocationType, + #[error("nonce already used")] + NonceReplay, + #[error("key backend error: {0}")] + Backend(#[from] KeyBackendError), + #[error("decrypt request body is malformed: {0}")] + InvalidBody(String), + #[error("hex decode error: {0}")] + Base64(&'static str), + #[error("node response signing failed: {0}")] + Signing(String), +} + +pub struct EncryptionService { + db: DatabaseConnection, + node_did: String, + node_keypair: Option, + backend: Arc, + invocation_ttl_seconds: i64, +} + +#[derive(Debug, Clone)] +pub struct CreateNetworkRequest { + pub name: String, + pub principal: String, + pub threshold: Threshold, +} + +#[derive(Debug, Clone)] +pub struct VerifiedDecrypt { + pub response: DecryptResponseBody, + pub request_hash: String, +} + +impl EncryptionService { + pub fn new(db: DatabaseConnection, node_did: String, backend: Arc) -> Self { + Self { + db, + node_did, + node_keypair: None, + backend, + invocation_ttl_seconds: DEFAULT_INVOCATION_TTL_SECONDS, + } + } + + pub fn new_with_node_keypair( + db: DatabaseConnection, + node_keypair: Keypair, + backend: Arc, + ) -> Self { + let node_did = public_key_to_did_key(node_keypair.public()); + Self { + db, + node_did, + node_keypair: Some(node_keypair), + backend, + invocation_ttl_seconds: DEFAULT_INVOCATION_TTL_SECONDS, + } + } + + pub fn node_did(&self) -> &str { + &self.node_did + } + + pub fn backend_kind(&self) -> KeyBackendKind { + self.backend.kind() + } + + /// Create a new network and complete a one-of-one ceremony in a single + /// step. The resulting network is `Active`. + pub async fn create_one_of_one_network( + &self, + req: CreateNetworkRequest, + ) -> Result { + let network_id = NetworkId::new(req.principal.clone(), req.name.clone()) + .map_err(|e| EncryptionServiceError::InvalidBody(format!("invalid network id: {e}")))?; + + // Reject duplicates up-front so callers see a clear error instead of a + // generic unique-constraint failure. + if encryption_network::Entity::find_by_id(network_id.to_string()) + .one(&self.db) + .await? + .is_some() + { + return Err(EncryptionServiceError::NetworkAlreadyExists); + } + + let now = now_rfc3339(); + let ceremony_id = format!("ceremony:{}:{now}", network_id); + + encryption_ceremony::ActiveModel { + ceremony_id: Set(ceremony_id.clone()), + network_id: Set(network_id.to_string()), + kind: Set("initial".to_string()), + state: Set("started".to_string()), + transcript_hash: Set(None), + started_at: Set(now.clone()), + completed_at: Set(None), + failure: Set(None), + } + .insert(&self.db) + .await?; + + let generated = self.backend.generate()?; + let transcript = hash_hex(&generated.public_key); + + let model = encryption_network::ActiveModel { + network_id: Set(network_id.to_string()), + principal: Set(req.principal.clone()), + name: Set(req.name.clone()), + alg: Set(generated.alg.clone()), + key_version: Set(1), + public_key: Set(generated.public_key.clone()), + state: Set(NetworkState::Active.as_str().to_string()), + threshold_n: Set(req.threshold.n), + threshold_t: Set(req.threshold.t), + key_backend: Set(self.backend.kind().as_str().to_string()), + sealed_private_key: Set(Some(generated.sealed_private_key)), + created_at: Set(now.clone()), + updated_at: Set(now.clone()), + }; + model.insert(&self.db).await?; + + encryption_network_member::ActiveModel { + network_id: Set(network_id.to_string()), + node_id: Set(self.node_did.clone()), + role: Set("primary".to_string()), + share_index: Set(0), + joined_at: Set(now.clone()), + } + .insert(&self.db) + .await?; + + let mut ceremony_active: encryption_ceremony::ActiveModel = + encryption_ceremony::Entity::find_by_id(ceremony_id.clone()) + .one(&self.db) + .await? + .ok_or(EncryptionServiceError::NetworkNotFound)? + .into(); + ceremony_active.state = Set("completed".to_string()); + ceremony_active.transcript_hash = Set(Some(transcript)); + ceremony_active.completed_at = Set(Some(now.clone())); + ceremony_active.update(&self.db).await?; + + Ok(NetworkDescriptor { + network_id, + principal: req.principal, + name: req.name, + members: vec![NetworkMemberDescriptor { + node_id: self.node_did.clone(), + role: "primary".to_string(), + }], + threshold: req.threshold, + state: NetworkState::Active, + public_encryption_key: generated.public_key, + alg: generated.alg, + key_version: 1, + key_backend: self.backend.kind(), + created_at: now.clone(), + updated_at: now, + }) + } + + pub async fn get_network( + &self, + network_id: &NetworkId, + ) -> Result { + let model = encryption_network::Entity::find_by_id(network_id.to_string()) + .one(&self.db) + .await? + .ok_or(EncryptionServiceError::NetworkNotFound)?; + + let members = encryption_network_member::Entity::find() + .filter(encryption_network_member::Column::NetworkId.eq(network_id.to_string())) + .all(&self.db) + .await? + .into_iter() + .map(|m| NetworkMemberDescriptor { + node_id: m.node_id, + role: m.role, + }) + .collect(); + + let state = NetworkState::parse(&model.state).ok_or_else(|| { + EncryptionServiceError::InvalidBody(format!("unknown network state: {}", model.state)) + })?; + let backend = + KeyBackendKind::parse(&model.key_backend).unwrap_or(KeyBackendKind::LocalOneOfOne); + + Ok(NetworkDescriptor { + network_id: network_id.clone(), + principal: model.principal, + name: model.name, + members, + threshold: Threshold { + n: model.threshold_n, + t: model.threshold_t, + }, + state, + public_encryption_key: model.public_key, + alg: model.alg, + key_version: model.key_version, + key_backend: backend, + created_at: model.created_at, + updated_at: model.updated_at, + }) + } + + pub async fn get_network_by_name( + &self, + name: &str, + principal: Option<&str>, + ) -> Result { + if let Some(principal) = principal { + let network_id = + NetworkId::new(principal.to_string(), name.to_string()).map_err(|e| { + EncryptionServiceError::InvalidBody(format!("invalid network id: {e}")) + })?; + return self.get_network(&network_id).await; + } + + let model = encryption_network::Entity::find() + .filter(encryption_network::Column::Name.eq(name)) + .filter(encryption_network::Column::State.eq(NetworkState::Active.as_str())) + .one(&self.db) + .await? + .ok_or(EncryptionServiceError::NetworkNotFound)?; + let network_id: NetworkId = model.network_id.parse().map_err(|e| { + EncryptionServiceError::InvalidBody(format!("invalid stored network id: {e}")) + })?; + self.get_network(&network_id).await + } + + pub async fn revoke_network( + &self, + network_id: &NetworkId, + ) -> Result<(), EncryptionServiceError> { + let existing = encryption_network::Entity::find_by_id(network_id.to_string()) + .one(&self.db) + .await? + .ok_or(EncryptionServiceError::NetworkNotFound)?; + + let mut active: encryption_network::ActiveModel = existing.into(); + active.state = Set(NetworkState::Revoked.as_str().to_string()); + active.updated_at = Set(now_rfc3339()); + active.update(&self.db).await?; + Ok(()) + } + + pub async fn verify_network_admin_invocation( + &self, + network_id: &NetworkId, + action: &str, + invocation: &NetworkAdminInvocation, + body_value: &Value, + ) -> Result<(), EncryptionServiceError> { + if invocation.facts.ty != NETWORK_ADMIN_TYPE { + return Err(EncryptionServiceError::WrongInvocationType); + } + if invocation.audience != self.node_did { + return Err(EncryptionServiceError::AudienceMismatch); + } + if invocation.facts.target_node != self.node_did { + return Err(EncryptionServiceError::TargetNodeMismatch); + } + if &invocation.facts.network_id != network_id { + return Err(EncryptionServiceError::NetworkMismatch); + } + if invocation.facts.action != action { + return Err(EncryptionServiceError::Unauthorized); + } + let cap = invocation + .att + .iter() + .find(|c| c.can == action) + .ok_or(EncryptionServiceError::Unauthorized)?; + if cap.with != network_id.to_string() { + return Err(EncryptionServiceError::NetworkMismatch); + } + if invocation.issuer != network_id.principal() { + return Err(EncryptionServiceError::PrincipalMismatch); + } + let expected_body_hash = canonical_hash(body_value); + if expected_body_hash != invocation.facts.body_hash { + return Err(EncryptionServiceError::HashMismatch("bodyHash")); + } + + self.validate_invocation_time(invocation.not_before, invocation.exp)?; + self.verify_signature( + &invocation.issuer, + &invocation.sig, + &invocation.unsigned_payload(), + )?; + self.consume_nonce(&invocation.issuer, &invocation.nonce, invocation.exp) + .await?; + Ok(()) + } + + pub async fn verify_network_admin_authorized( + &self, + network_id: &NetworkId, + action: &str, + invocation: &InvocationInfo, + body_value: &Value, + ) -> Result<(), EncryptionServiceError> { + let facts = native_network_admin_facts(invocation)?; + if facts.ty != NETWORK_ADMIN_TYPE { + return Err(EncryptionServiceError::WrongInvocationType); + } + if facts.target_node != self.node_did { + return Err(EncryptionServiceError::TargetNodeMismatch); + } + if &facts.network_id != network_id { + return Err(EncryptionServiceError::NetworkMismatch); + } + if facts.action != action { + return Err(EncryptionServiceError::Unauthorized); + } + let cap = invocation + .capabilities + .iter() + .find(|c| c.ability.to_string() == action) + .ok_or(EncryptionServiceError::Unauthorized)?; + if cap.resource.to_string() != network_id.to_string() { + return Err(EncryptionServiceError::NetworkMismatch); + } + let expected_body_hash = canonical_hash(body_value); + if expected_body_hash != facts.body_hash { + return Err(EncryptionServiceError::HashMismatch("bodyHash")); + } + + let exp = native_invocation_exp(invocation)?; + self.consume_nonce( + &invocation.invoker, + native_invocation_nonce(invocation)?, + exp, + ) + .await?; + Ok(()) + } + + /// Verify a decrypt invocation + body and produce a signed response with + /// the symmetric key rewrapped to the receiver public key. + /// + /// `body_value` is the raw JSON the client posted. We canonicalize it + /// ourselves so the body-hash binding does not depend on client-side + /// formatting. + pub async fn decrypt( + &self, + network_id: &NetworkId, + invocation: &DecryptInvocation, + body_value: &Value, + ) -> Result { + let body: DecryptRequestBody = serde_json::from_value(body_value.clone()) + .map_err(|e| EncryptionServiceError::InvalidBody(e.to_string()))?; + + // ---- Static invariants ---- + if body.ty != DECRYPT_REQUEST_TYPE || invocation.facts.ty != DECRYPT_REQUEST_TYPE { + return Err(EncryptionServiceError::WrongInvocationType); + } + if invocation.audience != self.node_did { + self.record_audit(invocation, network_id, "denied:audience") + .await?; + return Err(EncryptionServiceError::AudienceMismatch); + } + if invocation.facts.target_node != self.node_did || body.target_node != self.node_did { + self.record_audit(invocation, network_id, "denied:target-node") + .await?; + return Err(EncryptionServiceError::TargetNodeMismatch); + } + if &invocation.facts.network_id != network_id || &body.network_id != network_id { + return Err(EncryptionServiceError::NetworkMismatch); + } + self.verify_invocation_signature(invocation)?; + + // Capability binding. + let cap = invocation + .att + .iter() + .find(|c| c.can == DECRYPT_ACTION) + .ok_or(EncryptionServiceError::Unauthorized)?; + if cap.with != network_id.to_string() { + return Err(EncryptionServiceError::NetworkMismatch); + } + if invocation.issuer != network_id.principal() { + return Err(EncryptionServiceError::PrincipalMismatch); + } + + // ---- Network state ---- + let descriptor = self.get_network(network_id).await?; + match descriptor.state { + NetworkState::Active => {} + NetworkState::Revoked => { + self.record_audit(invocation, network_id, "denied:revoked") + .await?; + return Err(EncryptionServiceError::NetworkRevoked); + } + other => { + self.record_audit(invocation, network_id, "denied:state") + .await?; + return Err(EncryptionServiceError::NetworkNotActive( + other.as_str().to_string(), + )); + } + } + if descriptor.alg != body.alg || descriptor.key_version != body.key_version { + return Err(EncryptionServiceError::AlgKeyVersionMismatch); + } + if descriptor.alg != invocation.facts.alg + || descriptor.key_version != invocation.facts.key_version + { + return Err(EncryptionServiceError::AlgKeyVersionMismatch); + } + + // ---- Time bounds ---- + self.validate_invocation_time(invocation.not_before, invocation.exp)?; + + // ---- Hash bindings ---- + let receiver_key_bytes = + decode_base64(&body.receiver_public_key).map_err(EncryptionServiceError::Base64)?; + let wrapped_key_bytes = + decode_base64(&body.encrypted_symmetric_key).map_err(EncryptionServiceError::Base64)?; + + let expected_receiver_hash = + canonical_hash(&Value::String(body.receiver_public_key.clone())); + if expected_receiver_hash != body.receiver_public_key_hash + || expected_receiver_hash != invocation.facts.receiver_public_key_hash + { + self.record_audit(invocation, network_id, "denied:receiver-hash") + .await?; + return Err(EncryptionServiceError::HashMismatch( + "receiverPublicKeyHash", + )); + } + + let expected_key_hash = + canonical_hash(&Value::String(body.encrypted_symmetric_key.clone())); + if expected_key_hash != body.encrypted_symmetric_key_hash + || expected_key_hash != invocation.facts.encrypted_symmetric_key_hash + { + self.record_audit(invocation, network_id, "denied:key-hash") + .await?; + return Err(EncryptionServiceError::HashMismatch( + "encryptedSymmetricKeyHash", + )); + } + + let expected_body_hash = canonical_hash(body_value); + if expected_body_hash != invocation.facts.body_hash { + self.record_audit(invocation, network_id, "denied:body-hash") + .await?; + return Err(EncryptionServiceError::HashMismatch("bodyHash")); + } + + // ---- Replay protection ---- + let invocation_cid = invocation.cid(); + let request_hash = + hash_hex(&[invocation_cid.as_bytes(), expected_body_hash.as_bytes()].concat()); + if let Err(err) = self + .consume_nonce(&invocation.issuer, &invocation.nonce, invocation.exp) + .await + { + if matches!(err, EncryptionServiceError::NonceReplay) { + self.record_audit(invocation, network_id, "denied:replay") + .await?; + } + return Err(err); + } + + // ---- Unwrap + rewrap ---- + let sealed = descriptor.key_backend; // unused but documents the choice + let _ = sealed; + let network_row = encryption_network::Entity::find_by_id(network_id.to_string()) + .one(&self.db) + .await? + .ok_or(EncryptionServiceError::NetworkNotFound)?; + let sealed_private_key = + network_row + .sealed_private_key + .as_deref() + .ok_or(EncryptionServiceError::Backend( + KeyBackendError::SealedKeyMissing, + ))?; + let symmetric = self + .backend + .unwrap(sealed_private_key, &wrapped_key_bytes)?; + let rewrapped = self.backend.rewrap(&symmetric, &receiver_key_bytes)?; + // Best-effort: zeroize the transient symmetric key copy. + drop(symmetric); + + let mut response = DecryptResponseBody { + ty: DECRYPT_RESULT_TYPE.to_string(), + target_node: self.node_did.clone(), + network_id: network_id.clone(), + invocation_cid: invocation_cid.clone(), + encrypted_symmetric_key_hash: expected_key_hash, + receiver_public_key_hash: expected_receiver_hash, + wrapped_key: encode_base64(&rewrapped), + alg: descriptor.alg.clone(), + key_version: descriptor.key_version, + request_hash: request_hash.clone(), + node_id: self.node_did.clone(), + node_signature: String::new(), + }; + response.node_signature = self.sign_response(&response)?; + + self.record_audit(invocation, network_id, "allowed").await?; + + Ok(VerifiedDecrypt { + response, + request_hash, + }) + } + + /// Decrypt path for native TinyCloud UCAN invocations produced by + /// node-sdk's `invokeAny`. Signature/proof-chain validation is performed + /// by the node auth DAG before this method is called. + pub async fn decrypt_authorized( + &self, + network_id: &NetworkId, + invocation: &InvocationInfo, + body_value: &Value, + ) -> Result { + let body: DecryptRequestBody = serde_json::from_value(body_value.clone()) + .map_err(|e| EncryptionServiceError::InvalidBody(e.to_string()))?; + let facts = native_decrypt_facts(invocation)?; + + if body.ty != DECRYPT_REQUEST_TYPE || facts.ty != DECRYPT_REQUEST_TYPE { + return Err(EncryptionServiceError::WrongInvocationType); + } + if facts.target_node != self.node_did || body.target_node != self.node_did { + self.record_native_audit(invocation, network_id, &facts, "denied:target-node") + .await?; + return Err(EncryptionServiceError::TargetNodeMismatch); + } + if &facts.network_id != network_id || &body.network_id != network_id { + return Err(EncryptionServiceError::NetworkMismatch); + } + + let cap = invocation + .capabilities + .iter() + .find(|c| c.ability.to_string() == DECRYPT_ACTION) + .ok_or(EncryptionServiceError::Unauthorized)?; + if cap.resource.to_string() != network_id.to_string() { + return Err(EncryptionServiceError::NetworkMismatch); + } + + let descriptor = self.get_network(network_id).await?; + match descriptor.state { + NetworkState::Active => {} + NetworkState::Revoked => { + self.record_native_audit(invocation, network_id, &facts, "denied:revoked") + .await?; + return Err(EncryptionServiceError::NetworkRevoked); + } + other => { + self.record_native_audit(invocation, network_id, &facts, "denied:state") + .await?; + return Err(EncryptionServiceError::NetworkNotActive( + other.as_str().to_string(), + )); + } + } + if descriptor.alg != body.alg || descriptor.key_version != body.key_version { + return Err(EncryptionServiceError::AlgKeyVersionMismatch); + } + if descriptor.alg != facts.alg || descriptor.key_version != facts.key_version { + return Err(EncryptionServiceError::AlgKeyVersionMismatch); + } + + let exp = native_invocation_exp(invocation)?; + self.validate_invocation_time(native_invocation_not_before(invocation), exp)?; + + let receiver_key_bytes = + decode_base64(&body.receiver_public_key).map_err(EncryptionServiceError::Base64)?; + let wrapped_key_bytes = + decode_base64(&body.encrypted_symmetric_key).map_err(EncryptionServiceError::Base64)?; + + let expected_receiver_hash = + canonical_hash(&Value::String(body.receiver_public_key.clone())); + if expected_receiver_hash != body.receiver_public_key_hash + || expected_receiver_hash != facts.receiver_public_key_hash + { + self.record_native_audit(invocation, network_id, &facts, "denied:receiver-hash") + .await?; + return Err(EncryptionServiceError::HashMismatch( + "receiverPublicKeyHash", + )); + } + + let expected_key_hash = + canonical_hash(&Value::String(body.encrypted_symmetric_key.clone())); + if expected_key_hash != body.encrypted_symmetric_key_hash + || expected_key_hash != facts.encrypted_symmetric_key_hash + { + self.record_native_audit(invocation, network_id, &facts, "denied:key-hash") + .await?; + return Err(EncryptionServiceError::HashMismatch( + "encryptedSymmetricKeyHash", + )); + } + + let expected_body_hash = canonical_hash(body_value); + if expected_body_hash != facts.body_hash { + self.record_native_audit(invocation, network_id, &facts, "denied:body-hash") + .await?; + return Err(EncryptionServiceError::HashMismatch("bodyHash")); + } + + let invocation_cid = native_invocation_cid(invocation)?; + let request_hash = + hash_hex(&[invocation_cid.as_bytes(), expected_body_hash.as_bytes()].concat()); + if let Err(err) = self + .consume_nonce( + &invocation.invoker, + native_invocation_nonce(invocation)?, + exp, + ) + .await + { + if matches!(err, EncryptionServiceError::NonceReplay) { + self.record_native_audit(invocation, network_id, &facts, "denied:replay") + .await?; + } + return Err(err); + } + + let network_row = encryption_network::Entity::find_by_id(network_id.to_string()) + .one(&self.db) + .await? + .ok_or(EncryptionServiceError::NetworkNotFound)?; + let sealed_private_key = + network_row + .sealed_private_key + .as_deref() + .ok_or(EncryptionServiceError::Backend( + KeyBackendError::SealedKeyMissing, + ))?; + let symmetric = self + .backend + .unwrap(sealed_private_key, &wrapped_key_bytes)?; + let rewrapped = self.backend.rewrap(&symmetric, &receiver_key_bytes)?; + drop(symmetric); + + let mut response = DecryptResponseBody { + ty: DECRYPT_RESULT_TYPE.to_string(), + target_node: self.node_did.clone(), + network_id: network_id.clone(), + invocation_cid: invocation_cid.clone(), + encrypted_symmetric_key_hash: expected_key_hash, + receiver_public_key_hash: expected_receiver_hash, + wrapped_key: encode_base64(&rewrapped), + alg: descriptor.alg.clone(), + key_version: descriptor.key_version, + request_hash: request_hash.clone(), + node_id: self.node_did.clone(), + node_signature: String::new(), + }; + response.node_signature = self.sign_response(&response)?; + + self.record_native_audit(invocation, network_id, &facts, "allowed") + .await?; + + Ok(VerifiedDecrypt { + response, + request_hash, + }) + } + + fn sign_response( + &self, + response: &DecryptResponseBody, + ) -> Result { + let mut value = serde_json::to_value(response) + .map_err(|err| EncryptionServiceError::Signing(err.to_string()))?; + if let Value::Object(map) = &mut value { + map.remove("nodeSignature"); + } + let message = canonical_json_bytes(&value); + if let Some(keypair) = &self.node_keypair { + let signature = keypair + .sign(&message) + .map_err(|err| EncryptionServiceError::Signing(err.to_string()))?; + return Ok(encode_base64(&signature)); + } + + // Unit tests can construct the service with only a node DID. Keep the + // wire format as base64 bytes even for that unsigned test mode. + Ok(encode_base64(&hash_hex(&message).into_bytes())) + } + + fn verify_invocation_signature( + &self, + invocation: &DecryptInvocation, + ) -> Result<(), EncryptionServiceError> { + self.verify_signature( + &invocation.issuer, + &invocation.sig, + &invocation.unsigned_payload(), + ) + } + + fn verify_signature( + &self, + issuer: &str, + sig: &str, + payload: &Value, + ) -> Result<(), EncryptionServiceError> { + let public_key = + did_key_public_key(issuer).map_err(EncryptionServiceError::SignatureInvalid)?; + let sig = decode_base64(sig) + .map_err(|err| EncryptionServiceError::SignatureInvalid(err.to_string()))?; + let message = canonical_json_bytes(payload); + if public_key.verify(&message, &sig) { + return Ok(()); + } + Err(EncryptionServiceError::SignatureInvalid( + "signature does not verify for issuer did:key".to_string(), + )) + } + + fn validate_invocation_time( + &self, + not_before: Option, + exp: i64, + ) -> Result<(), EncryptionServiceError> { + let now_ts = OffsetDateTime::now_utc().unix_timestamp(); + if let Some(nbf) = not_before { + if now_ts < nbf { + return Err(EncryptionServiceError::NotYetValid); + } + } + if exp <= now_ts { + return Err(EncryptionServiceError::Expired); + } + if exp - now_ts > self.invocation_ttl_seconds { + return Err(EncryptionServiceError::Expired); + } + Ok(()) + } + + async fn consume_nonce( + &self, + requester_did: &str, + nonce: &str, + exp: i64, + ) -> Result<(), EncryptionServiceError> { + let nonce_expires_at = OffsetDateTime::from_unix_timestamp(exp) + .unwrap_or_else(|_| OffsetDateTime::now_utc()) + .format(&Rfc3339) + .unwrap_or_else(|_| now_rfc3339()); + let insert = encryption_nonce::ActiveModel { + requester_did: Set(requester_did.to_string()), + nonce: Set(nonce.to_string()), + expires_at: Set(nonce_expires_at), + }; + if let Err(err) = insert.insert(&self.db).await { + if is_unique_violation(&err) { + return Err(EncryptionServiceError::NonceReplay); + } + return Err(err.into()); + } + Ok(()) + } + + async fn record_audit( + &self, + invocation: &DecryptInvocation, + network_id: &NetworkId, + outcome: &str, + ) -> Result<(), EncryptionServiceError> { + let cid = invocation.cid(); + let request_hash = + hash_hex(&[cid.as_bytes(), invocation.facts.body_hash.as_bytes()].concat()); + // Upsert-style: if the same request hash + outcome arrives twice (e.g. + // replay attempt) we keep the first decision. Ignore unique conflicts. + let row = encryption_audit::ActiveModel { + request_hash: Set(request_hash), + requester: Set(invocation.issuer.clone()), + network_id: Set(network_id.to_string()), + node_id: Set(self.node_did.clone()), + outcome: Set(outcome.to_string()), + decided_at: Set(now_rfc3339()), + }; + if let Err(err) = row.insert(&self.db).await { + if !is_unique_violation(&err) { + return Err(err.into()); + } + } + Ok(()) + } + + async fn record_native_audit( + &self, + invocation: &InvocationInfo, + network_id: &NetworkId, + facts: &DecryptFacts, + outcome: &str, + ) -> Result<(), EncryptionServiceError> { + let cid = native_invocation_cid(invocation)?; + let request_hash = hash_hex(&[cid.as_bytes(), facts.body_hash.as_bytes()].concat()); + let row = encryption_audit::ActiveModel { + request_hash: Set(request_hash), + requester: Set(invocation.invoker.clone()), + network_id: Set(network_id.to_string()), + node_id: Set(self.node_did.clone()), + outcome: Set(outcome.to_string()), + decided_at: Set(now_rfc3339()), + }; + if let Err(err) = row.insert(&self.db).await { + if !is_unique_violation(&err) { + return Err(err.into()); + } + } + Ok(()) + } +} + +fn native_decrypt_facts( + invocation: &InvocationInfo, +) -> Result { + native_fact(invocation) +} + +fn native_network_admin_facts( + invocation: &InvocationInfo, +) -> Result { + native_fact(invocation) +} + +fn native_fact(invocation: &InvocationInfo) -> Result +where + T: for<'de> Deserialize<'de>, +{ + invocation + .invocation + .payload() + .facts + .as_ref() + .and_then(|facts| { + facts + .iter() + .find_map(|fact| serde_json::from_value::(fact.clone()).ok()) + }) + .ok_or_else(|| { + EncryptionServiceError::InvalidBody("missing encryption invocation facts".to_string()) + }) +} + +fn native_invocation_exp(invocation: &InvocationInfo) -> Result { + Ok(invocation.invocation.payload().expiration.as_seconds() as i64) +} + +fn native_invocation_not_before(invocation: &InvocationInfo) -> Option { + invocation + .invocation + .payload() + .not_before + .map(|time| time.as_seconds() as i64) +} + +fn native_invocation_nonce(invocation: &InvocationInfo) -> Result<&str, EncryptionServiceError> { + invocation + .invocation + .payload() + .nonce + .as_deref() + .ok_or_else(|| EncryptionServiceError::InvalidBody("missing nonce".to_string())) +} + +fn native_invocation_cid(invocation: &InvocationInfo) -> Result { + let encoded = invocation + .invocation + .encode() + .map_err(|err| EncryptionServiceError::InvalidBody(err.to_string()))?; + Ok(hash(encoded.as_bytes()).to_cid(0x55).to_string()) +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WellKnownRecord { + pub network_id: String, + pub principal: String, + pub name: String, + pub alg: String, + pub key_version: i64, + #[serde(rename = "publicEncryptionKey")] + pub public_encryption_key: String, + pub state: String, + pub key_backend: String, +} + +impl From<&NetworkDescriptor> for WellKnownRecord { + fn from(d: &NetworkDescriptor) -> Self { + Self { + network_id: d.network_id.to_string(), + principal: d.principal.clone(), + name: d.name.clone(), + alg: d.alg.clone(), + key_version: d.key_version, + public_encryption_key: encode_base64(&d.public_encryption_key), + state: d.state.as_str().to_string(), + key_backend: d.key_backend.as_str().to_string(), + } + } +} + +fn now_rfc3339() -> String { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) +} + +fn is_unique_violation(err: &DbErr) -> bool { + let s = err.to_string().to_lowercase(); + s.contains("unique") || s.contains("primary") || s.contains("duplicate") +} + +fn encode_base64(bytes: &[u8]) -> String { + STANDARD.encode(bytes) +} + +fn decode_base64(s: &str) -> Result, &'static str> { + STANDARD.decode(s).map_err(|_| "invalid base64") +} + +fn did_key_public_key(did: &str) -> Result { + let encoded = did + .strip_prefix("did:key:") + .ok_or_else(|| "issuer must be a did:key".to_string())?; + let (_base, bytes) = tinycloud_auth::ipld_core::cid::multibase::decode(encoded) + .map_err(|err| err.to_string())?; + let key_bytes = match bytes.as_slice() { + [0xed, rest @ ..] if rest.len() == 32 => rest, + [0xed, 0x01, rest @ ..] if rest.len() == 32 => rest, + _ => return Err("issuer did:key is not an ed25519 public key".to_string()), + }; + let ed = libp2p::identity::ed25519::PublicKey::try_from_bytes(key_bytes) + .map_err(|err| err.to_string())?; + Ok(ed.into()) +} diff --git a/tinycloud-core/src/encryption_network/tests.rs b/tinycloud-core/src/encryption_network/tests.rs new file mode 100644 index 0000000..b542d7e --- /dev/null +++ b/tinycloud-core/src/encryption_network/tests.rs @@ -0,0 +1,768 @@ +//! Integration tests covering decrypt authorization, hash bindings, replay +//! protection, ceremony state, and network lookup. These exercise the public +//! API of [`EncryptionService`] against a sqlite in-memory database. + +use std::collections::BTreeMap; +use std::convert::TryInto; +use std::sync::Arc; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use sea_orm::{ConnectOptions, Database, DatabaseConnection, EntityTrait}; +use sea_orm_migration::MigratorTrait; +use serde_json::Value; +use time::OffsetDateTime; +use x25519_dalek::{PublicKey, StaticSecret as X25519StaticSecret}; + +use tinycloud_auth::{ + authorization::{make_invocation_from_uris, Cid, InvocationOptions}, + multihash_codetable::{Code, MultihashDigest}, + resolver::DID_METHODS, + ssi::jwk::{Algorithm, JWK}, +}; + +use crate::encryption::ColumnEncryption; +use crate::encryption_network::backend::{wrap_to_public_key, LocalOneOfOneBackend}; +use crate::encryption_network::canonical::{canonical_hash, canonical_json_bytes}; +use crate::encryption_network::network_id::NetworkId; +use crate::encryption_network::protocol::{ + DecryptFacts, DecryptInvocation, DecryptRequestBody, InvocationCapability, NetworkAdminFacts, + DECRYPT_ACTION, DECRYPT_REQUEST_TYPE, NETWORK_ADMIN_TYPE, NETWORK_CREATE_ACTION, +}; +use crate::encryption_network::service::{ + CreateNetworkRequest, EncryptionService, EncryptionServiceError, WellKnownRecord, +}; +use crate::encryption_network::types::{ + KeyBackendKind, NetworkMemberDescriptor, NetworkState, Threshold, ALG_X25519_AES256GCM, +}; +use crate::keys::{public_key_to_did_key, Keypair, StaticSecret as NodeStaticSecret}; +use crate::migrations::Migrator; +use crate::models::encryption_ceremony; + +const NODE_DID: &str = "did:key:z6MkNodeTest"; +const NETWORK_NAME: &str = "default"; + +async fn fresh_db() -> DatabaseConnection { + let db = Database::connect(ConnectOptions::new("sqlite::memory:".to_string())) + .await + .expect("connect sqlite"); + Migrator::up(&db, None).await.expect("migrate"); + db +} + +fn make_service(db: DatabaseConnection) -> EncryptionService { + let seal = ColumnEncryption::new([0xABu8; 32]); + let backend = Arc::new(LocalOneOfOneBackend::new(seal)); + EncryptionService::new(db, NODE_DID.to_string(), backend) +} + +fn principal_keypair() -> Keypair { + NodeStaticSecret::new(vec![0x11; 32]) + .expect("static principal secret") + .node_keypair() +} + +fn attacker_keypair() -> Keypair { + NodeStaticSecret::new(vec![0x22; 32]) + .expect("static attacker secret") + .node_keypair() +} + +fn did_for(keypair: &Keypair) -> String { + public_key_to_did_key(keypair.public()) +} + +fn principal_did() -> String { + did_for(&principal_keypair()) +} + +fn network_id() -> NetworkId { + NetworkId::new(principal_did(), NETWORK_NAME.to_string()).unwrap() +} + +struct ClientCtx { + receiver_secret: X25519StaticSecret, + receiver_pub: Vec, + wrapped_key: Vec, + symmetric: Vec, +} + +fn make_client_request(network_pub: &[u8]) -> ClientCtx { + let symmetric = vec![0xCDu8; 32]; + let wrapped_key = wrap_to_public_key(network_pub, &symmetric).unwrap(); + let receiver_secret = X25519StaticSecret::random_from_rng(rand::rngs::OsRng); + let receiver_pub_arr = PublicKey::from(&receiver_secret); + ClientCtx { + receiver_secret, + receiver_pub: receiver_pub_arr.as_bytes().to_vec(), + wrapped_key, + symmetric, + } +} + +fn build_body(ctx: &ClientCtx, net: &NetworkId) -> (DecryptRequestBody, Value) { + let encrypted_symmetric_key = STANDARD.encode(&ctx.wrapped_key); + let receiver_public_key = STANDARD.encode(&ctx.receiver_pub); + let body = DecryptRequestBody { + ty: DECRYPT_REQUEST_TYPE.to_string(), + target_node: NODE_DID.to_string(), + network_id: net.clone(), + alg: ALG_X25519_AES256GCM.to_string(), + key_version: 1, + encrypted_symmetric_key: encrypted_symmetric_key.clone(), + encrypted_symmetric_key_hash: canonical_hash(&Value::String(encrypted_symmetric_key)), + receiver_public_key: receiver_public_key.clone(), + receiver_public_key_hash: canonical_hash(&Value::String(receiver_public_key)), + }; + let value = serde_json::to_value(&body).unwrap(); + (body, value) +} + +fn build_invocation( + net: &NetworkId, + body_value: &Value, + ctx: &ClientCtx, + overrides: impl FnOnce(&mut DecryptInvocation), +) -> DecryptInvocation { + let now = OffsetDateTime::now_utc().unix_timestamp(); + let mut inv = DecryptInvocation { + issuer: principal_did(), + audience: NODE_DID.to_string(), + att: vec![InvocationCapability { + with: net.to_string(), + can: DECRYPT_ACTION.to_string(), + nb: BTreeMap::new(), + }], + facts: DecryptFacts { + ty: DECRYPT_REQUEST_TYPE.to_string(), + target_node: NODE_DID.to_string(), + network_id: net.clone(), + body_hash: canonical_hash(body_value), + encrypted_symmetric_key_hash: canonical_hash(&Value::String( + STANDARD.encode(&ctx.wrapped_key), + )), + receiver_public_key_hash: canonical_hash(&Value::String( + STANDARD.encode(&ctx.receiver_pub), + )), + alg: ALG_X25519_AES256GCM.to_string(), + key_version: 1, + }, + nonce: format!("nonce-{now}"), + not_before: None, + exp: now + 60, + proof_cid: Vec::new(), + sig: String::new(), + }; + overrides(&mut inv); + sign_invocation(&mut inv, &principal_keypair()); + inv +} + +fn build_session_invocation_info( + resource: &NetworkId, + action: &str, + facts: Vec, +) -> crate::util::InvocationInfo { + let mut jwk = JWK::generate_ed25519().expect("session jwk"); + jwk.algorithm = Some(Algorithm::EdDSA); + + let mut verification_method = DID_METHODS + .generate(&jwk, "key") + .expect("session did") + .to_string(); + let fragment = verification_method + .rsplit_once(':') + .expect("fragment") + .1 + .to_string(); + verification_method.push('#'); + verification_method.push_str(&fragment); + + let resource_uri: iri_string::types::UriString = + resource.to_string().parse().expect("network uri"); + let capability: tinycloud_auth::ucan_capabilities_object::Ability = + action.to_string().try_into().expect("capability"); + let delegation_cid = Cid::new_v1(0x55, Code::Blake3_256.digest(b"network-session-delegation")); + + let invocation = make_invocation_from_uris( + std::iter::once((resource_uri, vec![capability])), + &delegation_cid, + &jwk, + &verification_method, + (OffsetDateTime::now_utc().unix_timestamp() + 60) as f64, + InvocationOptions { + facts: Some(facts), + ..Default::default() + }, + ) + .expect("session invocation"); + + crate::util::InvocationInfo::try_from(invocation).expect("invocation info") +} + +fn sign_invocation(inv: &mut DecryptInvocation, keypair: &Keypair) { + let message = canonical_json_bytes(&inv.unsigned_payload()); + inv.sig = STANDARD.encode(keypair.sign(&message).expect("sign decrypt invocation")); +} + +fn unwrap_with_secret(secret: &X25519StaticSecret, wrapped: &[u8]) -> Vec { + let mut peer = [0u8; 32]; + peer.copy_from_slice(&wrapped[..32]); + let pub_peer = PublicKey::from(peer); + let shared = secret.diffie_hellman(&pub_peer); + ColumnEncryption::new(*shared.as_bytes()) + .decrypt(&wrapped[32..]) + .expect("decrypt rewrapped key") +} + +#[tokio::test] +async fn create_one_of_one_network_initializes_active_state() { + let db = fresh_db().await; + let svc = make_service(db.clone()); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + + assert_eq!(descriptor.state, NetworkState::Active); + assert_eq!(descriptor.alg, ALG_X25519_AES256GCM); + assert_eq!(descriptor.threshold.n, 1); + assert_eq!(descriptor.threshold.t, 1); + assert_eq!(descriptor.key_backend, KeyBackendKind::LocalOneOfOne); + assert_eq!(descriptor.public_encryption_key.len(), 32); + assert_eq!( + descriptor.members, + vec![NetworkMemberDescriptor { + node_id: NODE_DID.to_string(), + role: "primary".to_string() + }] + ); + + let ceremonies = encryption_ceremony::Entity::find() + .all(&db) + .await + .expect("ceremonies"); + assert_eq!(ceremonies.len(), 1); + assert_eq!(ceremonies[0].network_id, descriptor.network_id.to_string()); + assert_eq!(ceremonies[0].state, "completed"); + assert!(ceremonies[0].transcript_hash.is_some()); +} + +#[tokio::test] +async fn create_network_rejects_duplicate() { + let svc = make_service(fresh_db().await); + svc.create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let err = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap_err(); + assert!(matches!(err, EncryptionServiceError::NetworkAlreadyExists)); +} + +#[tokio::test] +async fn get_network_returns_descriptor() { + let db = fresh_db().await; + let svc = make_service(db.clone()); + let net = network_id(); + svc.create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + + let fetched = svc.get_network(&net).await.unwrap(); + assert_eq!(fetched.network_id, net); + assert_eq!(fetched.principal, principal_did()); + assert_eq!(fetched.state, NetworkState::Active); +} + +#[tokio::test] +async fn get_network_by_name_returns_discovery_view() { + let db = fresh_db().await; + let svc = make_service(db); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + + let by_principal = svc + .get_network_by_name(NETWORK_NAME, Some(&principal_did())) + .await + .unwrap(); + let by_name = svc.get_network_by_name(NETWORK_NAME, None).await.unwrap(); + + assert_eq!(by_principal.network_id, net); + assert_eq!(by_name.network_id, net); + + let well_known = WellKnownRecord::from(&descriptor); + let serialized = serde_json::to_value(&well_known).unwrap(); + assert_eq!(serialized["networkId"], net.to_string()); + assert_eq!(serialized["keyVersion"], 1); + assert_eq!(serialized["keyBackend"], "local-one-of-one"); + assert_eq!( + serialized["publicEncryptionKey"], + STANDARD.encode(&descriptor.public_encryption_key) + ); +} + +#[tokio::test] +async fn network_admin_authorized_accepts_session_invoker() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let body_value = serde_json::json!({ + "name": NETWORK_NAME, + "principal": principal_did(), + "threshold": { "n": 1, "t": 1 } + }); + let facts = serde_json::to_value(NetworkAdminFacts { + ty: NETWORK_ADMIN_TYPE.to_string(), + target_node: NODE_DID.to_string(), + network_id: net.clone(), + body_hash: canonical_hash(&body_value), + action: NETWORK_CREATE_ACTION.to_string(), + }) + .unwrap(); + let invocation = build_session_invocation_info(&net, NETWORK_CREATE_ACTION, vec![facts]); + + svc.verify_network_admin_authorized(&net, NETWORK_CREATE_ACTION, &invocation, &body_value) + .await + .unwrap(); + assert_ne!(invocation.invoker, principal_did()); +} + +#[tokio::test] +async fn decrypt_round_trip_returns_rewrapped_key() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (_, body_value) = build_body(&ctx, &net); + let inv = build_invocation(&net, &body_value, &ctx, |_| {}); + + let verified = svc.decrypt(&net, &inv, &body_value).await.unwrap(); + assert_eq!(verified.response.target_node, NODE_DID); + assert_eq!(verified.response.network_id, net); + + // Client unwraps the rewrapped key with the receiver private key. + let rewrapped = STANDARD + .decode(&verified.response.wrapped_key) + .expect("base64 wrapped key"); + let recovered = unwrap_with_secret(&ctx.receiver_secret, &rewrapped); + assert_eq!(recovered, ctx.symmetric); +} + +#[tokio::test] +async fn decrypt_authorized_accepts_session_invoker() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (_, body_value) = build_body(&ctx, &net); + let facts = serde_json::to_value(DecryptFacts { + ty: DECRYPT_REQUEST_TYPE.to_string(), + target_node: NODE_DID.to_string(), + network_id: net.clone(), + body_hash: canonical_hash(&body_value), + encrypted_symmetric_key_hash: canonical_hash(&Value::String( + STANDARD.encode(&ctx.wrapped_key), + )), + receiver_public_key_hash: canonical_hash(&Value::String( + STANDARD.encode(&ctx.receiver_pub), + )), + alg: ALG_X25519_AES256GCM.to_string(), + key_version: 1, + }) + .unwrap(); + let invocation = build_session_invocation_info(&net, DECRYPT_ACTION, vec![facts]); + + let verified = svc + .decrypt_authorized(&net, &invocation, &body_value) + .await + .unwrap(); + let rewrapped = STANDARD + .decode(&verified.response.wrapped_key) + .expect("base64 wrapped key"); + let recovered = unwrap_with_secret(&ctx.receiver_secret, &rewrapped); + assert_eq!(recovered, ctx.symmetric); + assert_ne!(invocation.invoker, principal_did()); +} + +#[tokio::test] +async fn decrypt_rejects_audience_mismatch() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (_, body_value) = build_body(&ctx, &net); + let inv = build_invocation(&net, &body_value, &ctx, |inv| { + inv.audience = "did:key:z6MkSomeOtherNode".to_string(); + }); + let err = svc.decrypt(&net, &inv, &body_value).await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::AudienceMismatch)); +} + +#[tokio::test] +async fn decrypt_rejects_target_node_mismatch_in_body() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (mut body, _) = build_body(&ctx, &net); + body.target_node = "did:key:z6MkOther".to_string(); + let body_value = serde_json::to_value(&body).unwrap(); + let inv = build_invocation(&net, &body_value, &ctx, |_| {}); + let err = svc.decrypt(&net, &inv, &body_value).await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::TargetNodeMismatch)); +} + +#[tokio::test] +async fn decrypt_rejects_target_node_mismatch_in_invocation() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (_, body_value) = build_body(&ctx, &net); + let mut inv = build_invocation(&net, &body_value, &ctx, |_| {}); + inv.facts.target_node = "did:key:z6MkOther".to_string(); + let err = svc.decrypt(&net, &inv, &body_value).await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::TargetNodeMismatch)); +} + +#[tokio::test] +async fn decrypt_rejects_body_hash_mismatch() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (_, body_value) = build_body(&ctx, &net); + let inv = build_invocation(&net, &body_value, &ctx, |inv| { + inv.facts.body_hash = "ff".repeat(32); + }); + let err = svc.decrypt(&net, &inv, &body_value).await.unwrap_err(); + assert!(matches!( + err, + EncryptionServiceError::HashMismatch("bodyHash") + )); +} + +#[tokio::test] +async fn decrypt_rejects_receiver_public_key_substitution() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (mut body, _) = build_body(&ctx, &net); + // Substitute a different receiver pubkey but leave the declared hash intact + let attacker = X25519StaticSecret::random_from_rng(rand::rngs::OsRng); + let attacker_pub = PublicKey::from(&attacker); + body.receiver_public_key = STANDARD.encode(attacker_pub.as_bytes()); + let body_value = serde_json::to_value(&body).unwrap(); + let inv = build_invocation(&net, &body_value, &ctx, |_| {}); + let err = svc.decrypt(&net, &inv, &body_value).await.unwrap_err(); + assert!(matches!( + err, + EncryptionServiceError::HashMismatch("receiverPublicKeyHash") + )); +} + +#[tokio::test] +async fn decrypt_rejects_encrypted_key_substitution() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (mut body, _) = build_body(&ctx, &net); + // Replace the wrapped key bytes; leave the declared hash intact. + let other_wrapped = wrap_to_public_key(&descriptor.public_encryption_key, &[0u8; 32]).unwrap(); + body.encrypted_symmetric_key = STANDARD.encode(&other_wrapped); + let body_value = serde_json::to_value(&body).unwrap(); + let inv = build_invocation(&net, &body_value, &ctx, |_| {}); + let err = svc.decrypt(&net, &inv, &body_value).await.unwrap_err(); + assert!(matches!( + err, + EncryptionServiceError::HashMismatch("encryptedSymmetricKeyHash") + )); +} + +#[tokio::test] +async fn decrypt_rejects_wrong_network() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let other_net = NetworkId::new(principal_did(), "wrong".to_string()).unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (_, body_value) = build_body(&ctx, &net); + let inv = build_invocation(&net, &body_value, &ctx, |_| {}); + let err = svc + .decrypt(&other_net, &inv, &body_value) + .await + .unwrap_err(); + assert!(matches!(err, EncryptionServiceError::NetworkMismatch)); +} + +#[tokio::test] +async fn decrypt_rejects_invocation_network_mismatch() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (_, body_value) = build_body(&ctx, &net); + let other_net = NetworkId::new(principal_did(), "wrong".to_string()).unwrap(); + let mut inv = build_invocation(&net, &body_value, &ctx, |_| {}); + inv.facts.network_id = other_net; + let err = svc.decrypt(&net, &inv, &body_value).await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::NetworkMismatch)); +} + +#[tokio::test] +async fn decrypt_rejects_wrong_principal() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (_, body_value) = build_body(&ctx, &net); + let attacker = attacker_keypair(); + let mut inv = build_invocation(&net, &body_value, &ctx, |inv| { + inv.issuer = did_for(&attacker); + // no delegation proof attached + }); + sign_invocation(&mut inv, &attacker); + let err = svc.decrypt(&net, &inv, &body_value).await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::PrincipalMismatch)); +} + +#[tokio::test] +async fn decrypt_rejects_signature_mismatch() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (_, body_value) = build_body(&ctx, &net); + let mut inv = build_invocation(&net, &body_value, &ctx, |_| {}); + inv.issuer = did_for(&attacker_keypair()); + let err = svc.decrypt(&net, &inv, &body_value).await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::SignatureInvalid(_))); +} + +#[tokio::test] +async fn decrypt_rejects_replay() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (_, body_value) = build_body(&ctx, &net); + let inv = build_invocation(&net, &body_value, &ctx, |_| {}); + svc.decrypt(&net, &inv, &body_value).await.unwrap(); + let err = svc.decrypt(&net, &inv, &body_value).await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::NonceReplay)); +} + +#[tokio::test] +async fn decrypt_rejects_expired_invocation() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (_, body_value) = build_body(&ctx, &net); + let inv = build_invocation(&net, &body_value, &ctx, |inv| { + inv.exp = OffsetDateTime::now_utc().unix_timestamp() - 1; + }); + let err = svc.decrypt(&net, &inv, &body_value).await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::Expired)); +} + +#[tokio::test] +async fn decrypt_rejects_wrong_capability_action() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (_, body_value) = build_body(&ctx, &net); + let inv = build_invocation(&net, &body_value, &ctx, |inv| { + inv.att[0].can = "tinycloud.kv/get".to_string(); + }); + let err = svc.decrypt(&net, &inv, &body_value).await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::Unauthorized)); +} + +#[tokio::test] +async fn revoked_network_refuses_decrypt() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + svc.revoke_network(&net).await.unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (_, body_value) = build_body(&ctx, &net); + let inv = build_invocation(&net, &body_value, &ctx, |_| {}); + let err = svc.decrypt(&net, &inv, &body_value).await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::NetworkRevoked)); +} + +#[tokio::test] +async fn unknown_network_returns_not_found() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let err = svc.get_network(&net).await.unwrap_err(); + assert!(matches!(err, EncryptionServiceError::NetworkNotFound)); +} + +#[tokio::test] +async fn unique_request_hashes_per_decrypt() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let ctx1 = make_client_request(&descriptor.public_encryption_key); + let (_, body1) = build_body(&ctx1, &net); + let inv1 = build_invocation(&net, &body1, &ctx1, |inv| { + inv.nonce = "n1".to_string(); + }); + let r1 = svc.decrypt(&net, &inv1, &body1).await.unwrap(); + + let ctx2 = make_client_request(&descriptor.public_encryption_key); + let (_, body2) = build_body(&ctx2, &net); + let inv2 = build_invocation(&net, &body2, &ctx2, |inv| { + inv.nonce = "n2".to_string(); + }); + let r2 = svc.decrypt(&net, &inv2, &body2).await.unwrap(); + + assert_ne!(r1.request_hash, r2.request_hash); +} diff --git a/tinycloud-core/src/encryption_network/types.rs b/tinycloud-core/src/encryption_network/types.rs new file mode 100644 index 0000000..4882c57 --- /dev/null +++ b/tinycloud-core/src/encryption_network/types.rs @@ -0,0 +1,170 @@ +//! Shared data shapes for the encryption module. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +use super::network_id::NetworkId; + +pub const ALG_X25519_AES256GCM: &str = "x25519-aes256gcm/v1"; + +/// Lifecycle state of a network. V1 transitions: Pending → Generating → Active. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NetworkState { + Pending, + Generating, + Active, + Rotating, + Revoked, + Failed, +} + +impl NetworkState { + pub fn as_str(self) -> &'static str { + match self { + NetworkState::Pending => "pending", + NetworkState::Generating => "generating", + NetworkState::Active => "active", + NetworkState::Rotating => "rotating", + NetworkState::Revoked => "revoked", + NetworkState::Failed => "failed", + } + } + + pub fn parse(s: &str) -> Option { + Some(match s { + "pending" => NetworkState::Pending, + "generating" => NetworkState::Generating, + "active" => NetworkState::Active, + "rotating" => NetworkState::Rotating, + "revoked" => NetworkState::Revoked, + "failed" => NetworkState::Failed, + _ => return None, + }) + } +} + +impl fmt::Display for NetworkState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Identifies which KeyBackend produced and holds the network key material. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum KeyBackendKind { + /// Local one-of-one: private key sealed with the node DB key. + LocalOneOfOne, + /// DStack-derived key management. + Dstack, + /// Future threshold backend (not implemented in v1). + Threshold, +} + +impl KeyBackendKind { + pub fn as_str(self) -> &'static str { + match self { + KeyBackendKind::LocalOneOfOne => "local-one-of-one", + KeyBackendKind::Dstack => "dstack", + KeyBackendKind::Threshold => "threshold", + } + } + + pub fn parse(s: &str) -> Option { + Some(match s { + "local-one-of-one" => KeyBackendKind::LocalOneOfOne, + "dstack" => KeyBackendKind::Dstack, + "threshold" => KeyBackendKind::Threshold, + _ => return None, + }) + } +} + +/// Public network descriptor. +/// +/// `members` are node DIDs participating in key custody. V1 has exactly one +/// member matching this node. `threshold` is preserved in the data shape so +/// callers can distinguish V1 from future threshold deployments at parse time. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NetworkMemberDescriptor { + #[serde(rename = "nodeId")] + pub node_id: String, + pub role: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkDescriptor { + #[serde(rename = "networkId")] + pub network_id: NetworkId, + pub principal: String, + pub name: String, + pub members: Vec, + pub threshold: Threshold, + pub state: NetworkState, + #[serde(rename = "publicEncryptionKey", with = "base64_bytes")] + pub public_encryption_key: Vec, + pub alg: String, + #[serde(rename = "keyVersion")] + pub key_version: i64, + #[serde(rename = "keyBackend")] + pub key_backend: KeyBackendKind, + #[serde(rename = "createdAt")] + pub created_at: String, + #[serde(rename = "updatedAt")] + pub updated_at: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct Threshold { + pub n: i32, + pub t: i32, +} + +impl Threshold { + pub const fn one_of_one() -> Self { + Self { n: 1, t: 1 } + } +} + +/// Inline encrypted envelope persisted alongside KV/SQL records. +/// +/// Encryption shape (v1): +/// - `encryptedSymmetricKey` is the symmetric key sealed to the network public key. +/// - `ciphertext` is the AES-256-GCM payload (clients encrypt locally). +/// - `aad` is application-bound associated data; the node never reads it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InlineEnvelope { + pub v: u32, + #[serde(rename = "networkId")] + pub network_id: NetworkId, + pub alg: String, + #[serde(rename = "keyVersion")] + pub key_version: i64, + #[serde(rename = "encryptedSymmetricKey", with = "base64_bytes")] + pub encrypted_symmetric_key: Vec, + #[serde(rename = "encryptedSymmetricKeyHash")] + pub encrypted_symmetric_key_hash: String, + #[serde(with = "base64_bytes")] + pub ciphertext: Vec, + #[serde(with = "base64_bytes")] + pub aad: Vec, + #[serde(default)] + pub metadata: serde_json::Map, +} + +mod base64_bytes { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(bytes: &Vec, ser: S) -> Result { + ser.serialize_str(&STANDARD.encode(bytes)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result, D::Error> { + let s = String::deserialize(de)?; + STANDARD + .decode(s) + .map_err(|err| serde::de::Error::custom(err.to_string())) + } +} diff --git a/tinycloud-core/src/keys.rs b/tinycloud-core/src/keys.rs index cf7db7d..299f79d 100644 --- a/tinycloud-core/src/keys.rs +++ b/tinycloud-core/src/keys.rs @@ -13,6 +13,10 @@ pub use libp2p::{ }; pub(crate) fn get_did_key(key: PublicKey) -> String { + public_key_to_did_key(key) +} + +pub fn public_key_to_did_key(key: PublicKey) -> String { use tinycloud_auth::ipld_core::cid::multibase; // only ed25519 feature is enabled, so this unwrap should never fail let ed25519_pk_bytes = key.try_into_ed25519().unwrap().to_bytes(); @@ -68,6 +72,19 @@ impl StaticSecret { key.copy_from_slice(&derived[..32]); key } + + /// Derive a stable node-level did:key. The keypair is deterministic for a + /// given static secret. Used by node-wide identity contexts (encryption + /// module audience, signed responses). + pub fn node_did(&self) -> String { + public_key_to_did_key(self.node_keypair().public()) + } + + pub fn node_keypair(&self) -> Keypair { + let derived = self.derive_key(b"tinycloud/node/identity"); + let secret = SecretKey::try_from_bytes(derived).expect("32 bytes"); + EdKP::from(secret).into() + } } #[async_trait] diff --git a/tinycloud-core/src/lib.rs b/tinycloud-core/src/lib.rs index e4cff9a..2bd9e8c 100644 --- a/tinycloud-core/src/lib.rs +++ b/tinycloud-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod database_artifacts; pub mod db; pub mod duckdb; pub mod encryption; +pub mod encryption_network; pub mod events; pub mod hash; pub mod keys; diff --git a/tinycloud-core/src/migrations/m20260601_000000_encryption_networks.rs b/tinycloud-core/src/migrations/m20260601_000000_encryption_networks.rs new file mode 100644 index 0000000..e6863ef --- /dev/null +++ b/tinycloud-core/src/migrations/m20260601_000000_encryption_networks.rs @@ -0,0 +1,272 @@ +use sea_orm_migration::prelude::*; + +use crate::models::{ + encryption_audit, encryption_ceremony, encryption_network, encryption_network_member, + encryption_nonce, +}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(encryption_network::Entity) + .if_not_exists() + .col( + ColumnDef::new(encryption_network::Column::NetworkId) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_network::Column::Principal) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_network::Column::Name) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_network::Column::Alg) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_network::Column::KeyVersion) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(encryption_network::Column::PublicKey) + .blob() + .not_null(), + ) + .col( + ColumnDef::new(encryption_network::Column::State) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_network::Column::ThresholdN) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(encryption_network::Column::ThresholdT) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(encryption_network::Column::KeyBackend) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_network::Column::SealedPrivateKey) + .blob() + .null(), + ) + .col( + ColumnDef::new(encryption_network::Column::CreatedAt) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_network::Column::UpdatedAt) + .string() + .not_null(), + ) + .primary_key(Index::create().col(encryption_network::Column::NetworkId)) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(encryption_network_member::Entity) + .if_not_exists() + .col( + ColumnDef::new(encryption_network_member::Column::NetworkId) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_network_member::Column::NodeId) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_network_member::Column::Role) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_network_member::Column::ShareIndex) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(encryption_network_member::Column::JoinedAt) + .string() + .not_null(), + ) + .primary_key( + Index::create() + .col(encryption_network_member::Column::NetworkId) + .col(encryption_network_member::Column::NodeId), + ) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(encryption_ceremony::Entity) + .if_not_exists() + .col( + ColumnDef::new(encryption_ceremony::Column::CeremonyId) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_ceremony::Column::NetworkId) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_ceremony::Column::Kind) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_ceremony::Column::State) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_ceremony::Column::TranscriptHash) + .string() + .null(), + ) + .col( + ColumnDef::new(encryption_ceremony::Column::StartedAt) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_ceremony::Column::CompletedAt) + .string() + .null(), + ) + .col( + ColumnDef::new(encryption_ceremony::Column::Failure) + .string() + .null(), + ) + .primary_key(Index::create().col(encryption_ceremony::Column::CeremonyId)) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(encryption_nonce::Entity) + .if_not_exists() + .col( + ColumnDef::new(encryption_nonce::Column::RequesterDid) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_nonce::Column::Nonce) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_nonce::Column::ExpiresAt) + .string() + .not_null(), + ) + .primary_key( + Index::create() + .col(encryption_nonce::Column::RequesterDid) + .col(encryption_nonce::Column::Nonce), + ) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(encryption_audit::Entity) + .if_not_exists() + .col( + ColumnDef::new(encryption_audit::Column::RequestHash) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_audit::Column::Requester) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_audit::Column::NetworkId) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_audit::Column::NodeId) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_audit::Column::Outcome) + .string() + .not_null(), + ) + .col( + ColumnDef::new(encryption_audit::Column::DecidedAt) + .string() + .not_null(), + ) + .primary_key(Index::create().col(encryption_audit::Column::RequestHash)) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(encryption_audit::Entity).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(encryption_nonce::Entity).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(encryption_ceremony::Entity).to_owned()) + .await?; + manager + .drop_table( + Table::drop() + .table(encryption_network_member::Entity) + .to_owned(), + ) + .await?; + manager + .drop_table(Table::drop().table(encryption_network::Entity).to_owned()) + .await?; + Ok(()) + } +} diff --git a/tinycloud-core/src/migrations/mod.rs b/tinycloud-core/src/migrations/mod.rs index e03e7e1..44dc17b 100644 --- a/tinycloud-core/src/migrations/mod.rs +++ b/tinycloud-core/src/migrations/mod.rs @@ -4,6 +4,7 @@ pub mod m20260218_sql_database; pub mod m20260409_000000_hook_tables; pub mod m20260512_000000_signed_kv_tickets; pub mod m20260516_000000_database_artifacts; +pub mod m20260601_000000_encryption_networks; pub struct Migrator; @@ -16,6 +17,7 @@ impl MigratorTrait for Migrator { Box::new(m20260409_000000_hook_tables::Migration), Box::new(m20260512_000000_signed_kv_tickets::Migration), Box::new(m20260516_000000_database_artifacts::Migration), + Box::new(m20260601_000000_encryption_networks::Migration), ] } } diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index 660c1d6..d4f616b 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -1,4 +1,5 @@ use crate::encryption::ColumnEncryption; +use crate::encryption_network::NetworkId; use crate::hash::Hash; use crate::types::{Ability, Facts, Resource}; use crate::{events::Delegation, models::*, relationships::*, util}; @@ -171,10 +172,7 @@ async fn validate( .iter() .filter(|c| { // remove caps for which the delegator is the root authority - c.resource - .space() - .map(|o| **o.did() != *delegation.delegator) - .unwrap_or(true) + !is_root_authority(c, &delegation.delegator) }) .collect(); @@ -262,6 +260,26 @@ async fn validate( } } +fn is_root_authority(cap: &util::Capability, delegator: &str) -> bool { + if cap + .resource + .space() + .map(|o| o.did().as_str() == delegator) + .unwrap_or(false) + { + return true; + } + + match &cap.resource { + Resource::Other(uri) => uri + .as_str() + .parse::() + .map(|network_id| network_id.principal() == delegator) + .unwrap_or(false), + Resource::TinyCloud(_) => false, + } +} + async fn save( db: &C, delegation: util::DelegationInfo, diff --git a/tinycloud-core/src/models/encryption_audit.rs b/tinycloud-core/src/models/encryption_audit.rs new file mode 100644 index 0000000..3ec9139 --- /dev/null +++ b/tinycloud-core/src/models/encryption_audit.rs @@ -0,0 +1,18 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "encryption_audit")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false, unique)] + pub request_hash: String, + pub requester: String, + pub network_id: String, + pub node_id: String, + pub outcome: String, + pub decided_at: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/tinycloud-core/src/models/encryption_ceremony.rs b/tinycloud-core/src/models/encryption_ceremony.rs new file mode 100644 index 0000000..1590fbd --- /dev/null +++ b/tinycloud-core/src/models/encryption_ceremony.rs @@ -0,0 +1,20 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "encryption_ceremony")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false, unique)] + pub ceremony_id: String, + pub network_id: String, + pub kind: String, + pub state: String, + pub transcript_hash: Option, + pub started_at: String, + pub completed_at: Option, + pub failure: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/tinycloud-core/src/models/encryption_network.rs b/tinycloud-core/src/models/encryption_network.rs new file mode 100644 index 0000000..50fce26 --- /dev/null +++ b/tinycloud-core/src/models/encryption_network.rs @@ -0,0 +1,25 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "encryption_network")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false, unique)] + pub network_id: String, + pub principal: String, + pub name: String, + pub alg: String, + pub key_version: i64, + pub public_key: Vec, + pub state: String, + pub threshold_n: i32, + pub threshold_t: i32, + pub key_backend: String, + pub sealed_private_key: Option>, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/tinycloud-core/src/models/encryption_network_member.rs b/tinycloud-core/src/models/encryption_network_member.rs new file mode 100644 index 0000000..d7dfe30 --- /dev/null +++ b/tinycloud-core/src/models/encryption_network_member.rs @@ -0,0 +1,18 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "encryption_network_member")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub network_id: String, + #[sea_orm(primary_key, auto_increment = false)] + pub node_id: String, + pub role: String, + pub share_index: i32, + pub joined_at: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/tinycloud-core/src/models/encryption_nonce.rs b/tinycloud-core/src/models/encryption_nonce.rs new file mode 100644 index 0000000..b1f1c5a --- /dev/null +++ b/tinycloud-core/src/models/encryption_nonce.rs @@ -0,0 +1,16 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "encryption_nonce")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub requester_did: String, + #[sea_orm(primary_key, auto_increment = false)] + pub nonce: String, + pub expires_at: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/tinycloud-core/src/models/invocation.rs b/tinycloud-core/src/models/invocation.rs index 328e8a5..d992ea8 100644 --- a/tinycloud-core/src/models/invocation.rs +++ b/tinycloud-core/src/models/invocation.rs @@ -1,4 +1,5 @@ use super::super::{ + encryption_network::NetworkId, events::{Invocation, VersionedOperation}, models::*, relationships::*, @@ -119,10 +120,7 @@ async fn validate( .iter() .filter(|c| { // remove caps for which the invoker is the root authority - c.resource - .space() - .map(|o| **o.did() != *invocation.invoker) - .unwrap_or(true) + !is_root_authority(c, &invocation.invoker) }) .collect(); @@ -149,10 +147,9 @@ async fn validate( if p.delegatee != invocation.invoker && !invocation.invoker.starts_with(&p.delegatee) { - return Err(InvocationError::UnauthorizedInvoker( - invocation.invoker.clone(), - ) - .into()); + return Err( + InvocationError::UnauthorizedInvoker(invocation.invoker.clone()).into(), + ); } } @@ -185,6 +182,26 @@ async fn validate( } } +fn is_root_authority(cap: &util::Capability, invoker: &str) -> bool { + if cap + .resource + .space() + .map(|o| o.did().as_str() == invoker) + .unwrap_or(false) + { + return true; + } + + match &cap.resource { + Resource::Other(uri) => uri + .as_str() + .parse::() + .map(|network_id| network_id.principal() == invoker) + .unwrap_or(false), + Resource::TinyCloud(_) => false, + } +} + async fn save( db: &C, invocation: util::InvocationInfo, diff --git a/tinycloud-core/src/models/mod.rs b/tinycloud-core/src/models/mod.rs index 722215e..ff9f896 100644 --- a/tinycloud-core/src/models/mod.rs +++ b/tinycloud-core/src/models/mod.rs @@ -2,6 +2,11 @@ pub mod abilities; pub mod actor; pub mod database_artifact; pub mod delegation; +pub mod encryption_audit; +pub mod encryption_ceremony; +pub mod encryption_network; +pub mod encryption_network_member; +pub mod encryption_nonce; pub mod epoch; pub mod hook_delivery; pub mod hook_subscription; diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index b0e4054..5ce0bfc 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -32,6 +32,11 @@ use routes::{ admin::{delete_quota, get_quota, list_quotas, set_quota}, attestation::attestation, create_signed_kv_url, delegate, + encryption::{ + create_network as create_encryption_network, decrypt as encryption_decrypt, + get_network as get_encryption_network, revoke_network as revoke_encryption_network, + well_known_network as encryption_well_known, + }, hooks::{create_hook_ticket, create_webhook, delete_webhook, hook_events, list_webhooks}, info, invoke, open_host_key, public::{public_kv_get, public_kv_head, public_kv_list, public_kv_options, RateLimiter}, @@ -47,6 +52,7 @@ use tee::TeeContext; use tinycloud_core::{ database_artifacts::SeaOrmDatabaseArtifactRepository, duckdb::DuckDbService, + encryption_network::{EncryptionService, LocalOneOfOneBackend}, keys::{SecretsSetup, StaticSecret}, sea_orm::{ConnectOptions, Database, DatabaseConnection}, sql::SqlService, @@ -132,6 +138,11 @@ pub async fn app(config: &Figment) -> Result> { delete_quota, get_quota, list_quotas, + create_encryption_network, + get_encryption_network, + encryption_well_known, + encryption_decrypt, + revoke_encryption_network, ]; let key_setup: StaticSecret = resolve_keys(&tinycloud_config.keys).await?; @@ -200,6 +211,20 @@ pub async fn app(config: &Figment) -> Result> { database_connection.clone(), )); + // Encryption module: seal network private keys with the same kind of derived + // key used for DB column encryption. In DStack mode the seal is rooted in + // the dstack-derived `key_setup`, so this naturally lifts the network + // private key into DStack-derived key management. + let encryption_seal = + ColumnEncryption::new(key_setup.derive_key(b"tinycloud/encryption/network-seal")); + let encryption_backend = std::sync::Arc::new(LocalOneOfOneBackend::new(encryption_seal)); + let node_keypair = key_setup.node_keypair(); + let encryption_service = EncryptionService::new_with_node_keypair( + database_connection.clone(), + node_keypair, + encryption_backend, + ); + let tinycloud = TinyCloud::new( database_connection, tinycloud_config.storage.blocks.open().await?, @@ -259,6 +284,7 @@ pub async fn app(config: &Figment) -> Result> { .manage(webhook_encryption) .manage(rate_limiter) .manage(tee_context) + .manage(encryption_service) .manage(tinycloud_config.storage.staging.open().await?); if tinycloud_config.cors { diff --git a/tinycloud-node-server/src/routes/encryption.rs b/tinycloud-node-server/src/routes/encryption.rs new file mode 100644 index 0000000..b8d0189 --- /dev/null +++ b/tinycloud-node-server/src/routes/encryption.rs @@ -0,0 +1,208 @@ +//! HTTP routes for the encryption network module. +//! +//! Layout: +//! - `POST /encryption/networks` — create + ceremony for a network +//! - `GET /encryption/networks/` — fetch authoritative descriptor +//! - `GET /.well-known/encryption/network/` — public discovery record +//! - `POST /encryption/networks//decrypt` — UCAN-style decrypt invocation +//! - `POST /encryption/networks//revoke` — admin revoke (placeholder) + +use rocket::{http::Status, serde::json::Json, State}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::{authorization::AuthHeaderGetter, BlockStage, TinyCloud}; +use tinycloud_core::encryption_network::{ + CreateNetworkRequest, DecryptResponseBody, EncryptionService, EncryptionServiceError, + NetworkDescriptor, NetworkId, Threshold, WellKnownRecord, NETWORK_CREATE_ACTION, + NETWORK_REVOKE_ACTION, +}; +use tinycloud_core::{events::Invocation, util::InvocationInfo}; + +#[derive(Debug, Deserialize)] +pub struct CreateNetworkBody { + pub name: String, + pub principal: String, + #[serde(default = "default_threshold")] + pub threshold: Threshold, +} + +fn default_threshold() -> Threshold { + Threshold::one_of_one() +} + +#[derive(Debug, Serialize)] +pub struct DescriptorView { + pub descriptor: NetworkDescriptor, +} + +#[post("/encryption/networks", format = "json", data = "")] +pub async fn create_network( + authorization: AuthHeaderGetter, + body: Json, + service: &State, + tinycloud: &State, +) -> Result, (Status, String)> { + let invocation = authorization.0; + let invocation_info = invocation.0.clone(); + verify_auth(invocation, tinycloud).await?; + + let body_value = body.into_inner(); + let body: CreateNetworkBody = serde_json::from_value(body_value.clone()) + .map_err(|err| (Status::BadRequest, err.to_string()))?; + let network_id = NetworkId::new(body.principal.clone(), body.name.clone()) + .map_err(|err| (Status::BadRequest, err.to_string()))?; + service + .verify_network_admin_authorized( + &network_id, + NETWORK_CREATE_ACTION, + &invocation_info, + &body_value, + ) + .await + .map_err(map_service_err)?; + let req = CreateNetworkRequest { + name: body.name, + principal: body.principal, + threshold: body.threshold, + }; + let descriptor = service + .create_one_of_one_network(req) + .await + .map_err(map_service_err)?; + Ok(Json(DescriptorView { descriptor })) +} + +#[get("/encryption/networks/")] +pub async fn get_network( + network_id: &str, + service: &State, +) -> Result, (Status, String)> { + let net: NetworkId = + network_id + .parse() + .map_err(|e: tinycloud_core::encryption_network::NetworkIdError| { + (Status::BadRequest, e.to_string()) + })?; + let descriptor = service.get_network(&net).await.map_err(map_service_err)?; + Ok(Json(DescriptorView { descriptor })) +} + +/// Discovery record published as `.well-known/encryption/network/`. +/// Authoritative state still lives in the node DB; this endpoint just renders a +/// cache-friendly view of the active network for the given name. +#[get("/.well-known/encryption/network/?")] +pub async fn well_known_network( + name: &str, + principal: Option<&str>, + service: &State, +) -> Result, (Status, String)> { + // V1 supports principal-qualified discovery when the caller has it, and a + // name-only fallback for single-principal nodes. + let descriptor = service + .get_network_by_name(name, principal) + .await + .map_err(map_service_err)?; + Ok(Json(WellKnownRecord::from(&descriptor))) +} + +#[post( + "/encryption/networks//decrypt", + format = "json", + data = "" +)] +pub async fn decrypt( + network_id: &str, + authorization: AuthHeaderGetter, + body: Json, + service: &State, + tinycloud: &State, +) -> Result, (Status, String)> { + let invocation = authorization.0; + let invocation_info = invocation.0.clone(); + verify_auth(invocation, tinycloud).await?; + + let net: NetworkId = + network_id + .parse() + .map_err(|e: tinycloud_core::encryption_network::NetworkIdError| { + (Status::BadRequest, e.to_string()) + })?; + let body = body.into_inner(); + let verified = service + .decrypt_authorized(&net, &invocation_info, &body) + .await + .map_err(map_service_err)?; + Ok(Json(verified.response)) +} + +#[post("/encryption/networks//revoke")] +pub async fn revoke_network( + network_id: &str, + authorization: AuthHeaderGetter, + service: &State, + tinycloud: &State, +) -> Result { + let invocation = authorization.0; + let invocation_info = invocation.0.clone(); + verify_auth(invocation, tinycloud).await?; + + let net: NetworkId = + network_id + .parse() + .map_err(|e: tinycloud_core::encryption_network::NetworkIdError| { + (Status::BadRequest, e.to_string()) + })?; + let body = serde_json::json!({}); + service + .verify_network_admin_authorized(&net, NETWORK_REVOKE_ACTION, &invocation_info, &body) + .await + .map_err(map_service_err)?; + service + .revoke_network(&net) + .await + .map_err(map_service_err)?; + Ok(Status::NoContent) +} + +async fn verify_auth( + invocation: Invocation, + tinycloud: &State, +) -> Result<(), (Status, String)> { + tinycloud + .invoke::(invocation, HashMap::new()) + .await + .map(|_| ()) + .map_err(|err| (Status::Unauthorized, err.to_string())) +} + +fn map_service_err(err: EncryptionServiceError) -> (Status, String) { + let status = match err { + EncryptionServiceError::NetworkNotFound => Status::NotFound, + EncryptionServiceError::NetworkAlreadyExists => Status::Conflict, + EncryptionServiceError::Db(_) => Status::InternalServerError, + EncryptionServiceError::Backend(_) => Status::InternalServerError, + EncryptionServiceError::Signing(_) => Status::InternalServerError, + EncryptionServiceError::AudienceMismatch + | EncryptionServiceError::TargetNodeMismatch + | EncryptionServiceError::PrincipalMismatch + | EncryptionServiceError::NetworkMismatch + | EncryptionServiceError::Unauthorized + | EncryptionServiceError::NetworkRevoked + | EncryptionServiceError::NonceReplay + | EncryptionServiceError::Expired + | EncryptionServiceError::NotYetValid + | EncryptionServiceError::SignatureInvalid(_) + | EncryptionServiceError::WrongInvocationType + | EncryptionServiceError::AlgKeyVersionMismatch + | EncryptionServiceError::HashMismatch(_) => Status::Unauthorized, + EncryptionServiceError::NetworkNotActive(_) => Status::Conflict, + EncryptionServiceError::InvalidBody(_) | EncryptionServiceError::Base64(_) => { + Status::BadRequest + } + }; + (status, err.to_string()) +} + +#[cfg(feature = "dstack")] +pub fn _docs() {} diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index b09a12a..c937c09 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -23,6 +23,7 @@ use crate::{ }; use tinycloud_core::{ duckdb::{DuckDbCaveats, DuckDbError, DuckDbRequest, DuckDbResponse, DuckDbService}, + encryption_network::EncryptionService, events::Invocation, models::{hook_delivery, hook_subscription, kv_delete, kv_write}, sea_orm::DbErr, @@ -37,6 +38,7 @@ use tinycloud_core::{ pub mod admin; pub mod attestation; +pub mod encryption; pub mod hooks; pub mod public; pub mod util; @@ -47,6 +49,8 @@ pub struct NodeInfo { pub protocol: u32, pub version: String, pub features: Vec<&'static str>, + #[serde(rename = "nodeId")] + pub node_id: String, #[serde(rename = "inTEE")] pub in_tee: bool, #[serde(skip_serializing_if = "Option::is_none")] @@ -56,6 +60,7 @@ pub struct NodeInfo { fn build_info( tee: &State>, quota_cache: &State, + encryption: &State, ) -> NodeInfo { #[allow(unused_mut)] let mut features = vec![ @@ -66,6 +71,7 @@ fn build_info( "duckdb", "hooks", "signed-urls", + "encryption", ]; #[cfg(feature = "dstack")] features.push("tee"); @@ -73,6 +79,7 @@ fn build_info( protocol: tinycloud_auth::protocol::PROTOCOL_VERSION, version: env!("CARGO_PKG_VERSION").to_string(), features, + node_id: encryption.node_did().to_string(), in_tee: tee.inner().is_some(), quota_url: quota_cache.quota_url().map(|s| s.to_string()), } @@ -82,16 +89,18 @@ fn build_info( pub fn info( tee: &State>, quota_cache: &State, + encryption: &State, ) -> Json { - Json(build_info(tee, quota_cache)) + Json(build_info(tee, quota_cache, encryption)) } #[get("/version")] pub fn version( tee: &State>, quota_cache: &State, + encryption: &State, ) -> Json { - Json(build_info(tee, quota_cache)) + Json(build_info(tee, quota_cache, encryption)) } #[allow(clippy::let_unit_value)] diff --git a/tinycloud-sdk-wasm/src/definitions.rs b/tinycloud-sdk-wasm/src/definitions.rs index cf9bdf4..d956671 100644 --- a/tinycloud-sdk-wasm/src/definitions.rs +++ b/tinycloud-sdk-wasm/src/definitions.rs @@ -8,6 +8,8 @@ const TS_DEF: &'static str = r#" export type SessionConfig = { /** Actions that the session key will be permitted to perform, organized by service and path */ actions: { [service: string]: { [key: string]: string[] }}, + /** Non-space resources to include directly in the ReCap, keyed by raw resource URI */ + rawAbilities?: { [resource: string]: string[] }, /** Ethereum address. */ address: string, /** Chain ID. */ diff --git a/tinycloud-sdk-wasm/src/session.rs b/tinycloud-sdk-wasm/src/session.rs index 03e403a..c8267df 100644 --- a/tinycloud-sdk-wasm/src/session.rs +++ b/tinycloud-sdk-wasm/src/session.rs @@ -5,8 +5,8 @@ use serde_with::{serde_as, DisplayFromStr}; use std::collections::HashMap; use tinycloud_auth::{ authorization::{ - make_invocation, InvocationError, InvocationOptions, TinyCloudDelegation, - TinyCloudInvocation, + make_invocation, make_invocation_from_uris, InvocationError, InvocationOptions, + TinyCloudDelegation, TinyCloudInvocation, }, cacaos::{ siwe::{generate_nonce, Message, TimeStamp, Version as SIWEVersion}, @@ -16,7 +16,7 @@ use tinycloud_auth::{ multihash_codetable::{Code, MultihashDigest}, resolver::DID_METHODS, resource::{ - iri_string::types::{UriFragmentString, UriQueryString}, + iri_string::types::{UriFragmentString, UriQueryString, UriString}, Path, ResourceId, Service, SpaceId, }, siwe_recap::{Ability, Capability}, @@ -48,6 +48,10 @@ pub struct SessionConfig { /// spaces. #[serde(default)] pub space_abilities: Option>, + /// Optional non-space resources to include directly in the ReCap. + /// Used by network-scoped capabilities such as TinyCloud encryption. + #[serde(default)] + pub raw_abilities: HashMap>, #[serde(with = "tinycloud_sdk_rs::serde_siwe::address")] pub address: [u8; 20], pub chain_id: u64, @@ -148,30 +152,38 @@ impl SessionConfig { } }; - space_abilities - .into_iter() - .fold( - Capability::::default(), - |caps, (space_id, abilities)| { - abilities.iter().fold(caps, |caps, (service, actions)| { - actions.iter().fold(caps, |mut caps, (path, action)| { - let path_opt = if path.as_str().is_empty() { - None - } else { - Some(path.clone()) - }; - caps.with_actions( - space_id - .clone() - .to_resource(service.clone(), path_opt, None, None) - .as_uri(), - action.iter().map(|a| (a.clone(), [])), - ); - caps - }) + let caps = space_abilities.into_iter().fold( + Capability::::default(), + |caps, (space_id, abilities)| { + abilities.iter().fold(caps, |caps, (service, actions)| { + actions.iter().fold(caps, |mut caps, (path, action)| { + let path_opt = if path.as_str().is_empty() { + None + } else { + Some(path.clone()) + }; + caps.with_actions( + space_id + .clone() + .to_resource(service.clone(), path_opt, None, None) + .as_uri(), + action.iter().map(|a| (a.clone(), [])), + ); + caps }) - }, - ) + }) + }, + ); + + self.raw_abilities + .into_iter() + .try_fold(caps, |mut caps, (resource, actions)| { + let resource: UriString = resource + .parse() + .map_err(|err| format!("invalid raw resource URI: {err}"))?; + caps.with_actions(resource, actions.into_iter().map(|a| (a, []))); + Ok::<_, String>(caps) + })? .with_proofs(match &self.parents { Some(p) => p.as_slice(), None => &[], @@ -222,6 +234,27 @@ impl Session { ) } + pub fn invoke_any_uri>( + &self, + actions: impl IntoIterator, + facts: Option>, + ) -> Result { + use tinycloud_auth::ssi::claims::chrono; + let now = chrono::Utc::now(); + let exp = ((now.timestamp() + 60i64) as f64) + (now.nanosecond() as f64 / 1_000_000_000.0); + make_invocation_from_uris( + actions, + &self.delegation_cid, + &self.jwk, + &self.verification_method, + exp, + InvocationOptions { + facts, + ..Default::default() + }, + ) + } + pub fn invoke>( &self, actions: impl IntoIterator< From 4f91388ec661e1b544f2a38d7875a21af0baeae9 Mon Sep 17 00:00:00 2001 From: Samuel Gbafa Date: Wed, 3 Jun 2026 02:30:03 -0400 Subject: [PATCH 2/6] fix: bind encryption invocations to node audience --- .../src/encryption_network/service.rs | 8 ++ .../src/encryption_network/tests.rs | 88 ++++++++++++++--- .../src/routes/encryption.rs | 13 ++- tinycloud-sdk-wasm/src/session.rs | 99 ++++++++++++++++--- 4 files changed, 176 insertions(+), 32 deletions(-) diff --git a/tinycloud-core/src/encryption_network/service.rs b/tinycloud-core/src/encryption_network/service.rs index 23c55fa..cfa37eb 100644 --- a/tinycloud-core/src/encryption_network/service.rs +++ b/tinycloud-core/src/encryption_network/service.rs @@ -382,6 +382,9 @@ impl EncryptionService { if facts.ty != NETWORK_ADMIN_TYPE { return Err(EncryptionServiceError::WrongInvocationType); } + if invocation.invocation.payload().audience.to_string() != self.node_did { + return Err(EncryptionServiceError::AudienceMismatch); + } if facts.target_node != self.node_did { return Err(EncryptionServiceError::TargetNodeMismatch); } @@ -603,6 +606,11 @@ impl EncryptionService { if body.ty != DECRYPT_REQUEST_TYPE || facts.ty != DECRYPT_REQUEST_TYPE { return Err(EncryptionServiceError::WrongInvocationType); } + if invocation.invocation.payload().audience.to_string() != self.node_did { + self.record_native_audit(invocation, network_id, &facts, "denied:audience") + .await?; + return Err(EncryptionServiceError::AudienceMismatch); + } if facts.target_node != self.node_did || body.target_node != self.node_did { self.record_native_audit(invocation, network_id, &facts, "denied:target-node") .await?; diff --git a/tinycloud-core/src/encryption_network/tests.rs b/tinycloud-core/src/encryption_network/tests.rs index b542d7e..69ff2c7 100644 --- a/tinycloud-core/src/encryption_network/tests.rs +++ b/tinycloud-core/src/encryption_network/tests.rs @@ -14,10 +14,15 @@ use time::OffsetDateTime; use x25519_dalek::{PublicKey, StaticSecret as X25519StaticSecret}; use tinycloud_auth::{ - authorization::{make_invocation_from_uris, Cid, InvocationOptions}, + authorization::Cid, multihash_codetable::{Code, MultihashDigest}, resolver::DID_METHODS, - ssi::jwk::{Algorithm, JWK}, + ssi::{ + claims::jwt::NumericDate, + dids::{DIDBuf, DIDURLBuf}, + jwk::{Algorithm, JWK}, + ucan::Payload, + }, }; use crate::encryption::ColumnEncryption; @@ -161,6 +166,7 @@ fn build_session_invocation_info( resource: &NetworkId, action: &str, facts: Vec, + audience: &str, ) -> crate::util::InvocationInfo { let mut jwk = JWK::generate_ed25519().expect("session jwk"); jwk.algorithm = Some(Algorithm::EdDSA); @@ -183,18 +189,24 @@ fn build_session_invocation_info( action.to_string().try_into().expect("capability"); let delegation_cid = Cid::new_v1(0x55, Code::Blake3_256.digest(b"network-session-delegation")); - let invocation = make_invocation_from_uris( - std::iter::once((resource_uri, vec![capability])), - &delegation_cid, - &jwk, - &verification_method, - (OffsetDateTime::now_utc().unix_timestamp() + 60) as f64, - InvocationOptions { - facts: Some(facts), - ..Default::default() - }, - ) - .expect("session invocation"); + let now = OffsetDateTime::now_utc().unix_timestamp(); + let mut attenuation = tinycloud_auth::ucan_capabilities_object::Capabilities::new(); + attenuation.with_actions(resource_uri, std::iter::once((capability, []))); + let payload = Payload { + issuer: verification_method + .parse::() + .expect("session issuer"), + audience: audience.parse::().expect("session audience"), + not_before: None, + expiration: NumericDate::try_from_seconds((now + 60) as f64).expect("expiration"), + nonce: Some(format!("session-nonce-{now}")), + facts: Some(facts), + proof: vec![delegation_cid], + attenuation, + }; + let invocation = payload + .sign(Algorithm::EdDSA, &jwk) + .expect("session invocation"); crate::util::InvocationInfo::try_from(invocation).expect("invocation info") } @@ -342,7 +354,8 @@ async fn network_admin_authorized_accepts_session_invoker() { action: NETWORK_CREATE_ACTION.to_string(), }) .unwrap(); - let invocation = build_session_invocation_info(&net, NETWORK_CREATE_ACTION, vec![facts]); + let invocation = + build_session_invocation_info(&net, NETWORK_CREATE_ACTION, vec![facts], NODE_DID); svc.verify_network_admin_authorized(&net, NETWORK_CREATE_ACTION, &invocation, &body_value) .await @@ -407,7 +420,7 @@ async fn decrypt_authorized_accepts_session_invoker() { key_version: 1, }) .unwrap(); - let invocation = build_session_invocation_info(&net, DECRYPT_ACTION, vec![facts]); + let invocation = build_session_invocation_info(&net, DECRYPT_ACTION, vec![facts], NODE_DID); let verified = svc .decrypt_authorized(&net, &invocation, &body_value) @@ -421,6 +434,49 @@ async fn decrypt_authorized_accepts_session_invoker() { assert_ne!(invocation.invoker, principal_did()); } +#[tokio::test] +async fn decrypt_authorized_rejects_audience_mismatch() { + let svc = make_service(fresh_db().await); + let net = network_id(); + let descriptor = svc + .create_one_of_one_network(CreateNetworkRequest { + name: NETWORK_NAME.to_string(), + principal: principal_did(), + threshold: Threshold::one_of_one(), + }) + .await + .unwrap(); + let ctx = make_client_request(&descriptor.public_encryption_key); + let (_, body_value) = build_body(&ctx, &net); + let facts = serde_json::to_value(DecryptFacts { + ty: DECRYPT_REQUEST_TYPE.to_string(), + target_node: NODE_DID.to_string(), + network_id: net.clone(), + body_hash: canonical_hash(&body_value), + encrypted_symmetric_key_hash: canonical_hash(&Value::String( + STANDARD.encode(&ctx.wrapped_key), + )), + receiver_public_key_hash: canonical_hash(&Value::String( + STANDARD.encode(&ctx.receiver_pub), + )), + alg: ALG_X25519_AES256GCM.to_string(), + key_version: 1, + }) + .unwrap(); + let invocation = build_session_invocation_info( + &net, + DECRYPT_ACTION, + vec![facts], + "did:key:z6MkSomeOtherNode", + ); + + let err = svc + .decrypt_authorized(&net, &invocation, &body_value) + .await + .unwrap_err(); + assert!(matches!(err, EncryptionServiceError::AudienceMismatch)); +} + #[tokio::test] async fn decrypt_rejects_audience_mismatch() { let svc = make_service(fresh_db().await); diff --git a/tinycloud-node-server/src/routes/encryption.rs b/tinycloud-node-server/src/routes/encryption.rs index b8d0189..4d8daca 100644 --- a/tinycloud-node-server/src/routes/encryption.rs +++ b/tinycloud-node-server/src/routes/encryption.rs @@ -45,7 +45,7 @@ pub async fn create_network( ) -> Result, (Status, String)> { let invocation = authorization.0; let invocation_info = invocation.0.clone(); - verify_auth(invocation, tinycloud).await?; + verify_auth(invocation, tinycloud, service.node_did()).await?; let body_value = body.into_inner(); let body: CreateNetworkBody = serde_json::from_value(body_value.clone()) @@ -120,7 +120,7 @@ pub async fn decrypt( ) -> Result, (Status, String)> { let invocation = authorization.0; let invocation_info = invocation.0.clone(); - verify_auth(invocation, tinycloud).await?; + verify_auth(invocation, tinycloud, service.node_did()).await?; let net: NetworkId = network_id @@ -145,7 +145,7 @@ pub async fn revoke_network( ) -> Result { let invocation = authorization.0; let invocation_info = invocation.0.clone(); - verify_auth(invocation, tinycloud).await?; + verify_auth(invocation, tinycloud, service.node_did()).await?; let net: NetworkId = network_id @@ -168,7 +168,14 @@ pub async fn revoke_network( async fn verify_auth( invocation: Invocation, tinycloud: &State, + node_did: &str, ) -> Result<(), (Status, String)> { + if invocation.0.invocation.payload().audience.to_string() != node_did { + return Err(( + Status::Unauthorized, + EncryptionServiceError::AudienceMismatch.to_string(), + )); + } tinycloud .invoke::(invocation, HashMap::new()) .await diff --git a/tinycloud-sdk-wasm/src/session.rs b/tinycloud-sdk-wasm/src/session.rs index c8267df..097486b 100644 --- a/tinycloud-sdk-wasm/src/session.rs +++ b/tinycloud-sdk-wasm/src/session.rs @@ -543,19 +543,6 @@ pub fn parse_recap_from_siwe(siwe_string: &str) -> Result, let mut entries: Vec = Vec::new(); for (resource_uri, ability_map) in abilities_map.iter() { - let resource: ResourceId = resource_uri.as_str().parse().map_err( - |e: tinycloud_auth::resource::KRIParseError| { - ParseRecapError::InvalidResourceUri(resource_uri.to_string(), e.to_string()) - }, - )?; - - let space = resource.space().to_string(); - let service = resource.service().to_string(); - let path = resource - .path() - .map(|p| p.as_str().to_string()) - .unwrap_or_default(); - // Collect the full-URN ability strings. `ability_map` is a `BTreeMap` // keyed by `Ability`, so iteration yields keys in sorted order — this // gives us a deterministic action list without an extra sort pass. @@ -564,6 +551,34 @@ pub fn parse_recap_from_siwe(siwe_string: &str) -> Result, .map(|ability| ability.to_string()) .collect(); + let (space, service, path) = match resource_uri.as_str().parse::() { + Ok(resource) => ( + resource.space().to_string(), + resource.service().to_string(), + resource + .path() + .map(|p| p.as_str().to_string()) + .unwrap_or_default(), + ), + Err(e) => { + if resource_uri + .as_str() + .starts_with("urn:tinycloud:encryption:") + { + ( + "encryption".to_string(), + "encryption".to_string(), + resource_uri.to_string(), + ) + } else { + return Err(ParseRecapError::InvalidResourceUri( + resource_uri.to_string(), + e.to_string(), + )); + } + } + }; + entries.push(ParsedRecapEntry { service, space, @@ -948,6 +963,64 @@ pub mod test { ); } + #[test] + fn parse_recap_with_raw_encryption_ability() { + let network_id = + "urn:tinycloud:encryption:did:pkh:eip155:1:0x7BD63AA37326a64d458559F44432103e3d6eEDE9:default"; + let secrets_space = + "tinycloud:pkh:eip155:1:0x7BD63AA37326a64d458559F44432103e3d6eEDE9:secrets"; + let config = json!({ + "abilities": {}, + "spaceAbilities": { + secrets_space: { + "kv": { + "vault/secrets/": vec![ + "tinycloud.kv/get", + "tinycloud.kv/put", + ], + }, + }, + }, + "rawAbilities": { + network_id: vec![ + "tinycloud.encryption/decrypt", + "tinycloud.encryption/network.create", + ], + }, + "address": "0x7BD63AA37326a64d458559F44432103e3d6eEDE9", + "chainId": 1u8, + "domain": "example.com", + "issuedAt": "2022-01-01T00:00:00.000Z", + "spaceId": secrets_space, + "expirationTime": "3000-01-01T00:00:00.000Z", + }); + + let prepared = prepare_session(serde_json::from_value(config).unwrap()).unwrap(); + let entries = parse_recap_from_siwe(&prepared.siwe.to_string()) + .expect("parse_recap_from_siwe should support raw encryption resources"); + + let encryption_entry = entries + .iter() + .find(|entry| entry.service == "encryption") + .expect("encryption recap entry"); + assert_eq!(encryption_entry.space, "encryption"); + assert_eq!(encryption_entry.path, network_id); + assert_eq!( + encryption_entry.actions, + vec![ + "tinycloud.encryption/decrypt".to_string(), + "tinycloud.encryption/network.create".to_string(), + ] + ); + + let secrets_entry = entries + .iter() + .find(|entry| entry.service == "kv") + .expect("secrets kv recap entry"); + assert_eq!(secrets_entry.space, secrets_space); + assert_eq!(secrets_entry.path, "vault/secrets/"); + } + #[test] fn session_with_additional_spaces() { let config = json!({ From 1f141fb498a0818e6e188b08663a098161017b37 Mon Sep 17 00:00:00 2001 From: Samuel Gbafa Date: Wed, 3 Jun 2026 02:35:19 -0400 Subject: [PATCH 3/6] fix: emit canonical ed25519 did key --- tinycloud-core/src/keys.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinycloud-core/src/keys.rs b/tinycloud-core/src/keys.rs index 299f79d..6f33528 100644 --- a/tinycloud-core/src/keys.rs +++ b/tinycloud-core/src/keys.rs @@ -20,7 +20,7 @@ pub fn public_key_to_did_key(key: PublicKey) -> String { use tinycloud_auth::ipld_core::cid::multibase; // only ed25519 feature is enabled, so this unwrap should never fail let ed25519_pk_bytes = key.try_into_ed25519().unwrap().to_bytes(); - let multicodec_pk = [[0xed].as_slice(), ed25519_pk_bytes.as_slice()].concat(); + let multicodec_pk = [[0xed, 0x01].as_slice(), ed25519_pk_bytes.as_slice()].concat(); format!( "did:key:{}", multibase::encode(multibase::Base::Base58Btc, multicodec_pk) From 01176a8ceb01a1192d688436376d122843b72efa Mon Sep 17 00:00:00 2001 From: Samuel Gbafa Date: Wed, 3 Jun 2026 02:47:08 -0400 Subject: [PATCH 4/6] fix: delegate encryption networks as raw resources --- tinycloud-sdk-wasm/src/session.rs | 58 ++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/tinycloud-sdk-wasm/src/session.rs b/tinycloud-sdk-wasm/src/session.rs index 097486b..6423f2b 100644 --- a/tinycloud-sdk-wasm/src/session.rs +++ b/tinycloud-sdk-wasm/src/session.rs @@ -366,9 +366,18 @@ impl Session { Some(path.clone()) }; - let resource = space_id - .clone() - .to_resource(service.clone(), path_opt, None, None); + let resource_uri: UriString = if service.as_str() == "encryption" + && path.as_str().starts_with("urn:tinycloud:encryption:") + { + path.as_str() + .parse() + .map_err(|err| DelegationError::InvalidRawResource(format!("{err}")))? + } else { + space_id + .clone() + .to_resource(service.clone(), path_opt, None, None) + .as_uri() + }; let action_strings: Vec = path_actions.iter().map(|a| a.to_string()).collect(); @@ -376,7 +385,7 @@ impl Session { // Extend the capability object with this (resource, actions) // pair. The ucan-capabilities-object crate keys internally by // resource URI, so each iteration adds a distinct entry. - caps.with_actions(resource.as_uri(), path_actions.into_iter().map(|a| (a, []))); + caps.with_actions(resource_uri, path_actions.into_iter().map(|a| (a, []))); resources.push(DelegatedResource { service: service.to_string(), @@ -481,6 +490,8 @@ pub enum DelegationError { InvalidNotBefore(tinycloud_auth::ssi::claims::jwt::NumericDateConversionError), #[error("invalid expiration timestamp: {0}")] InvalidExpiration(tinycloud_auth::ssi::claims::jwt::NumericDateConversionError), + #[error("invalid raw resource URI: {0}")] + InvalidRawResource(String), #[error("failed to sign UCAN: {0}")] SigningError(tinycloud_auth::ssi::ucan::error::Error), #[error("failed to encode UCAN: {0}")] @@ -1221,6 +1232,45 @@ pub mod test { ); } + #[test] + fn create_delegation_encodes_encryption_network_as_raw_resource() { + let session = rich_test_session(); + let delegate_did = "did:pkh:eip155:1:0xBEEFBEEFBEEFBEEFBEEFBEEFBEEFBEEFBEEFBEEF"; + let network_id = + "urn:tinycloud:encryption:did:pkh:eip155:1:0x7bd63aa37326a64d458559f44432103e3d6eede9:default"; + + let abilities = + build_abilities(&[("encryption", network_id, &["tinycloud.encryption/decrypt"])]); + + let result = session + .create_delegation( + delegate_did, + &session.space_id, + abilities, + 4_000_000_000.0, + None, + ) + .expect("encryption network delegation should succeed"); + + assert_eq!(result.resources.len(), 1); + assert_eq!(result.resources[0].service, "encryption"); + assert_eq!(result.resources[0].space, session.space_id.to_string()); + assert_eq!(result.resources[0].path, network_id); + assert_eq!( + result.resources[0].actions, + vec!["tinycloud.encryption/decrypt".to_string()] + ); + + assert_eq!( + decode_delegation_pairs(&result.delegation), + vec![( + network_id.to_string(), + "tinycloud.encryption/decrypt".to_string() + )], + "encryption delegations must attenuate the raw network resource" + ); + } + #[test] fn create_delegation_multi_service_same_path() { // Listen's real use case: grant KV + SQL on the same app path in one From 276720a54d33f20302257b7bee5d09b29fcbe2b8 Mon Sep 17 00:00:00 2001 From: Samuel Gbafa Date: Wed, 3 Jun 2026 13:52:02 -0400 Subject: [PATCH 5/6] Fix clippy audience comparison --- tinycloud-node-server/src/routes/encryption.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinycloud-node-server/src/routes/encryption.rs b/tinycloud-node-server/src/routes/encryption.rs index 4d8daca..8f0e270 100644 --- a/tinycloud-node-server/src/routes/encryption.rs +++ b/tinycloud-node-server/src/routes/encryption.rs @@ -170,7 +170,7 @@ async fn verify_auth( tinycloud: &State, node_did: &str, ) -> Result<(), (Status, String)> { - if invocation.0.invocation.payload().audience.to_string() != node_did { + if invocation.0.invocation.payload().audience != node_did { return Err(( Status::Unauthorized, EncryptionServiceError::AudienceMismatch.to_string(), From b2229de729192fb1fba5b88acb3c6a13129762a5 Mon Sep 17 00:00:00 2001 From: Samuel Gbafa Date: Wed, 3 Jun 2026 19:02:14 -0400 Subject: [PATCH 6/6] fix: enforce exact encryption network resources --- .../src/encryption_network/service.rs | 4 +- tinycloud-core/src/types/resource.rs | 42 ++++++++++++++++++- tinycloud-sdk-wasm/src/session.rs | 14 ++++--- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/tinycloud-core/src/encryption_network/service.rs b/tinycloud-core/src/encryption_network/service.rs index cfa37eb..f827d14 100644 --- a/tinycloud-core/src/encryption_network/service.rs +++ b/tinycloud-core/src/encryption_network/service.rs @@ -382,7 +382,7 @@ impl EncryptionService { if facts.ty != NETWORK_ADMIN_TYPE { return Err(EncryptionServiceError::WrongInvocationType); } - if invocation.invocation.payload().audience.to_string() != self.node_did { + if invocation.invocation.payload().audience != self.node_did.as_str() { return Err(EncryptionServiceError::AudienceMismatch); } if facts.target_node != self.node_did { @@ -606,7 +606,7 @@ impl EncryptionService { if body.ty != DECRYPT_REQUEST_TYPE || facts.ty != DECRYPT_REQUEST_TYPE { return Err(EncryptionServiceError::WrongInvocationType); } - if invocation.invocation.payload().audience.to_string() != self.node_did { + if invocation.invocation.payload().audience != self.node_did.as_str() { self.record_native_audit(invocation, network_id, &facts, "denied:audience") .await?; return Err(EncryptionServiceError::AudienceMismatch); diff --git a/tinycloud-core/src/types/resource.rs b/tinycloud-core/src/types/resource.rs index 9f66264..7627ab2 100644 --- a/tinycloud-core/src/types/resource.rs +++ b/tinycloud-core/src/types/resource.rs @@ -1,3 +1,4 @@ +use crate::encryption_network::NetworkId; use sea_orm::{entity::prelude::*, sea_query::ValueTypeErr}; use serde::{Deserialize, Serialize}; use std::{fmt::Display, str::FromStr}; @@ -27,7 +28,16 @@ impl Resource { pub fn extends(&self, other: &Self) -> bool { match (self, other) { (Resource::TinyCloud(a), Resource::TinyCloud(b)) => a.extends(b).is_ok(), - (Resource::Other(a), Resource::Other(b)) => a.as_str().starts_with(b.as_str()), + (Resource::Other(a), Resource::Other(b)) => { + match ( + a.as_str().parse::(), + b.as_str().parse::(), + ) { + (Ok(a), Ok(b)) => a == b, + (Ok(_), Err(_)) | (Err(_), Ok(_)) => false, + (Err(_), Err(_)) => a.as_str().starts_with(b.as_str()), + } + } _ => false, } } @@ -169,3 +179,33 @@ impl sea_orm::TryFromU64 for Resource { Err(DbErr::ConvertFromU64(stringify!($type))) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encryption_network_resources_extend_only_exact_network_ids() { + let default: Resource = "urn:tinycloud:encryption:did:pkh:eip155:1:0xabc:default" + .parse() + .unwrap(); + let default_again: Resource = "urn:tinycloud:encryption:did:pkh:eip155:1:0xabc:default" + .parse() + .unwrap(); + let default_evil: Resource = "urn:tinycloud:encryption:did:pkh:eip155:1:0xabc:default-evil" + .parse() + .unwrap(); + + assert!(default.extends(&default_again)); + assert!(!default_evil.extends(&default)); + assert!(!default.extends(&default_evil)); + } + + #[test] + fn non_network_raw_resources_keep_prefix_containment() { + let child: Resource = "urn:example:resource:child".parse().unwrap(); + let parent: Resource = "urn:example:resource".parse().unwrap(); + + assert!(child.extends(&parent)); + } +} diff --git a/tinycloud-sdk-wasm/src/session.rs b/tinycloud-sdk-wasm/src/session.rs index 6423f2b..8cff6fb 100644 --- a/tinycloud-sdk-wasm/src/session.rs +++ b/tinycloud-sdk-wasm/src/session.rs @@ -366,9 +366,9 @@ impl Session { Some(path.clone()) }; - let resource_uri: UriString = if service.as_str() == "encryption" - && path.as_str().starts_with("urn:tinycloud:encryption:") - { + let is_raw_encryption_resource = service.as_str() == "encryption" + && path.as_str().starts_with("urn:tinycloud:encryption:"); + let resource_uri: UriString = if is_raw_encryption_resource { path.as_str() .parse() .map_err(|err| DelegationError::InvalidRawResource(format!("{err}")))? @@ -389,7 +389,11 @@ impl Session { resources.push(DelegatedResource { service: service.to_string(), - space: space_id.to_string(), + space: if is_raw_encryption_resource { + "encryption".to_string() + } else { + space_id.to_string() + }, path: path.to_string(), actions: action_strings, }); @@ -1254,7 +1258,7 @@ pub mod test { assert_eq!(result.resources.len(), 1); assert_eq!(result.resources[0].service, "encryption"); - assert_eq!(result.resources[0].space, session.space_id.to_string()); + assert_eq!(result.resources[0].space, "encryption"); assert_eq!(result.resources[0].path, network_id); assert_eq!( result.resources[0].actions,