diff --git a/Cargo.lock b/Cargo.lock index d9830434c..8e349da6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,13 +116,13 @@ dependencies = [ "base58", "base64 0.23.1", "cbc 0.1.2", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "getrandom 0.2.17", "hmac 0.12.1", - "k256", + "k256 0.14.0", "multibase", - "p256", - "p384", + "p256 0.14.0", + "p384 0.14.0", "p521", "rand 0.10.2", "rand_core 0.6.4", @@ -131,7 +131,7 @@ dependencies = [ "sha2 0.10.9", "subtle", "thiserror 2.0.20", - "x25519-dalek", + "x25519-dalek 3.0.0", "zeroize", ] @@ -149,7 +149,7 @@ dependencies = [ "async-trait", "chrono", "ciborium", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hmac 0.12.1", "multibase", "serde", @@ -197,7 +197,7 @@ dependencies = [ "serde_json", "thiserror 2.0.20", "url", - "x25519-dalek", + "x25519-dalek 3.0.0", "zeroize", ] @@ -285,7 +285,7 @@ dependencies = [ "hex", "hkdf 0.12.4", "hmac 0.12.1", - "p256", + "p256 0.14.0", "rand 0.9.5", "serde", "serde_bytes", @@ -355,9 +355,9 @@ checksum = "7e50820cf32736246a5e918a5eb1773c31ffcec898ad2ab5b26477cf7a547fa5" dependencies = [ "affinidi-crypto", "base64ct", - "ed25519-dalek", - "k256", - "p256", + "ed25519-dalek 3.0.0", + "k256 0.14.0", + "p256 0.14.0", "rand 0.10.2", "rand_core 0.6.4", "serde", @@ -402,7 +402,7 @@ dependencies = [ "hostname", "http 1.5.0", "humantime", - "itertools", + "itertools 0.14.0", "jsonwebtoken 10.4.0", "metrics", "metrics-exporter-prometheus", @@ -553,8 +553,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc3c8a85c6353e7952bdbc1c5509591bbd54d9e9d62c57e2198079f95955923a" dependencies = [ "base64 0.23.1", - "ed25519-dalek", - "p256", + "ed25519-dalek 3.0.0", + "p256 0.14.0", "rand 0.10.2", "serde", "serde_json", @@ -664,7 +664,7 @@ dependencies = [ "tokio", "tracing", "unsigned-varint", - "x25519-dalek", + "x25519-dalek 3.0.0", "zeroize", ] @@ -763,7 +763,7 @@ dependencies = [ "affinidi-encoding", "blake2 0.10.6", "chacha20poly1305 0.10.1", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hex", "hkdf 0.12.4", "rand 0.10.2", @@ -774,7 +774,7 @@ dependencies = [ "thiserror 2.0.20", "tokio", "url", - "x25519-dalek", + "x25519-dalek 3.0.0", "zeroize", ] @@ -1982,6 +1982,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bip39" version = "2.2.2" @@ -2294,7 +2314,16 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", ] [[package]] @@ -2430,6 +2459,17 @@ dependencies = [ "inout 0.2.2", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.6" @@ -2582,6 +2622,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -2629,6 +2675,17 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-models" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3344d349528f449816cfa85e558c439bdca5a6d8a738cfb21e69f0b2b1952f" +dependencies = [ + "hax-lib", + "pastey 0.2.3", + "rand 0.10.2", +] + [[package]] name = "coset" version = "0.3.8" @@ -2673,6 +2730,17 @@ dependencies = [ "libc", ] +[[package]] +name = "crabgrind" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7459e07732f6de74001fcf73a3fdfbb0f368e4fd8e2392f4a2206a065e6e95" +dependencies = [ + "bindgen", + "cc", + "pkg-config", +] + [[package]] name = "crc32fast" version = "1.5.1" @@ -2697,6 +2765,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.20" @@ -2843,6 +2921,22 @@ dependencies = [ "subtle", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek" version = "5.0.0" @@ -2853,7 +2947,7 @@ dependencies = [ "cpufeatures 0.3.1", "curve25519-dalek-derive", "digest 0.11.3", - "fiat-crypto", + "fiat-crypto 0.3.0", "rand_core 0.10.1", "rustc_version", "subtle", @@ -3108,14 +3202,25 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + [[package]] name = "der" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] @@ -3253,7 +3358,7 @@ dependencies = [ "affinidi-did-resolver-cache-sdk", "affinidi-tdk", "clap", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hex", "multibase", "rand 0.10.2", @@ -3298,6 +3403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -3309,7 +3415,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", "zeroize", @@ -3374,6 +3480,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "dtg-credentials" +version = "0.6.0" +source = "git+https://github.com/OpenVTC/dtg-credentials?rev=0c1391adfa67d8ae65498f086675404b8bb50b08#0c1391adfa67d8ae65498f086675404b8bb50b08" +dependencies = [ + "affinidi-data-integrity", + "affinidi-secrets-resolver", + "chrono", + "multibase", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sha2 0.10.9", + "thiserror 2.0.20", + "tracing", +] + [[package]] name = "dunce" version = "1.0.5" @@ -3386,21 +3509,45 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + [[package]] name = "ecdsa" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ - "der", + "der 0.8.1", "digest 0.11.3", "elliptic-curve 0.14.1", - "rfc6979", + "rfc6979 0.6.0", "signature 3.0.0", - "spki", + "spki 0.8.0", "zeroize", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + [[package]] name = "ed25519" version = "3.0.0" @@ -3410,14 +3557,29 @@ dependencies = [ "signature 3.0.0", ] +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "ed25519-dalek" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", "rand_core 0.10.1", "sha2 0.11.0", "signature 3.0.0", @@ -3443,7 +3605,11 @@ dependencies = [ "ff 0.13.1", "generic-array", "group 0.13.0", + "hkdf 0.12.4", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", "rand_core 0.6.4", + "sec1 0.7.3", "subtle", "zeroize", ] @@ -3462,10 +3628,10 @@ dependencies = [ "group 0.14.0", "hkdf 0.13.0", "hybrid-array", - "pem-rfc7468", - "pkcs8", + "pem-rfc7468 1.0.0", + "pkcs8 0.11.0", "rand_core 0.10.1", - "sec1", + "sec1 0.8.1", "subtle", "zeroize", ] @@ -3631,6 +3797,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "fiat-crypto" version = "0.3.0" @@ -4329,6 +4501,43 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hax-lib" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a33cb227f97ee5c419064c31d7c55a7d895f28d8019ccdadc311cc036500c4" +dependencies = [ + "hax-lib-macros", + "num-bigint 0.4.8", + "num-traits", +] + +[[package]] +name = "hax-lib-macros" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28da835a5f153e9f3d0cc1f42ee605a1cfa9093c93348193793d60a1b0908b51" +dependencies = [ + "hax-lib-macros-types", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hax-lib-macros-types" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed327e9a28d6ee018a4a9ba842d4ee27e378c8a591d2beb6f42f32b24a02fcb" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "headers" version = "0.4.1" @@ -4452,7 +4661,76 @@ dependencies = [ "subtle", "turboshake", "x-wing", - "x25519-dalek", + "x25519-dalek 3.0.0", + "zeroize", +] + +[[package]] +name = "hpke-rs" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812de62ab573b876c6c40d7553bae9402ae3bad95b64af06f964388eecb9b7b1" +dependencies = [ + "hpke-rs-crypto", + "hpke-rs-libcrux", + "hpke-rs-rust-crypto", + "libcrux-sha3", + "log", + "serde", + "subtle", + "tls_codec", + "zeroize", +] + +[[package]] +name = "hpke-rs-crypto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c461da2e0c9c93d875b597f0c78214fb0d2d5c57834b2ef2a2fa057fa20e24" +dependencies = [ + "rand 0.10.2", + "zeroize", +] + +[[package]] +name = "hpke-rs-libcrux" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c1ace95ef11fbbd84527eada5fc1304f66720fc4c008db30d888d62d3c52613" +dependencies = [ + "hpke-rs-crypto", + "libcrux-aead", + "libcrux-ecdh", + "libcrux-hkdf", + "libcrux-kem", + "libcrux-traits", + "rand 0.10.2", + "rand_chacha 0.10.0", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "hpke-rs-rust-crypto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a606ac19851da841862ef5f39d26d6227bfcfb4047417801a66c1f6e6652d71" +dependencies = [ + "aes-gcm 0.10.3", + "chacha20poly1305 0.10.1", + "hkdf 0.13.0", + "hpke-rs-crypto", + "k256 0.13.4", + "ml-kem", + "p256 0.13.2", + "p384 0.13.1", + "rand 0.10.2", + "rand_chacha 0.10.0", + "rand_core 0.10.1", + "sha2 0.11.0", + "subtle", + "x-wing", + "x25519-dalek 2.0.1", "zeroize", ] @@ -4857,6 +5135,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -5118,6 +5405,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "elliptic-curve 0.13.8", +] + [[package]] name = "k256" version = "0.14.0" @@ -5125,9 +5422,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93f50113171a713f4a4231ef82eb26703607139b35dcb56241f0ceab2ae1f7d8" dependencies = [ "cpubits", - "ecdsa", + "ecdsa 0.17.0", "elliptic-curve 0.14.1", - "primeorder", + "primeorder 0.14.0", "sha2 0.11.0", "signature 3.0.0", "wnaf", @@ -5278,6 +5575,225 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libcrux-aead" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22acbe68be84b7b41aaba6e39b87036fc4324dee628d94773750bf3d7e906d69" +dependencies = [ + "libcrux-aes", + "libcrux-chacha20poly1305", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-aes" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57cc95b11dbc797b1e169467b1fc4205c4e6ee2ce516a035ad7342ce5f6ae971" +dependencies = [ + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-chacha20poly1305" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537e6eee5cdc9a980014d058784b0be09614f0596df789b114f7833aa9f35d75" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-poly1305", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-curve25519" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4f3cfd5e31cb9745e290ee061222c380ec42884654b09fc7d12a5b0ed63e028" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-ecdh" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a227590b89ff55ce7cd97b5a7468c82d231db8f3b890bb4d4fb6d271e7524d7" +dependencies = [ + "libcrux-curve25519", + "libcrux-p256", + "rand 0.10.2", + "tls_codec", +] + +[[package]] +name = "libcrux-hacl-rs" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66106db376ae249af86911aee048cf73ea1f394d6653026fc988dd22cf2a4ace" +dependencies = [ + "libcrux-macros", +] + +[[package]] +name = "libcrux-hkdf" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc94e651dca4d47edbcdd88bb2428ce16a2bd86b7a4e7170da87b505478f9839" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-hmac", + "libcrux-secrets", +] + +[[package]] +name = "libcrux-hmac" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500ad9b32c715161b594172ca1fb11503a1c033b393ff4c0c33505ce51c1943c" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-sha2", + "libcrux-traits", +] + +[[package]] +name = "libcrux-intrinsics" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a0c574d4eb81d0814bc2b91e4a433d7e0b35851b1d62eb5dd95c064f1f76d0" +dependencies = [ + "core-models", + "hax-lib", +] + +[[package]] +name = "libcrux-kem" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "541a7377fb35060892e0620982e224e47419f10da8c212453bf642dafe529691" +dependencies = [ + "libcrux-curve25519", + "libcrux-ecdh", + "libcrux-ml-kem", + "libcrux-p256", + "libcrux-sha3", + "libcrux-traits", + "rand 0.10.2", +] + +[[package]] +name = "libcrux-macros" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd6aa2dcd5be681662001b81d493f1569c6d49a32361f470b0c955465cd0338" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "libcrux-ml-kem" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8160f7d64fd2716b4fd05cc886a042f8dcda18d9206c0d506e2c67bdf97daa" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-sha3", + "libcrux-traits", + "rand 0.10.2", + "tls_codec", +] + +[[package]] +name = "libcrux-p256" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3400732702d578be622257b98cec85e9b0cd34a67f1f06d3ebe9ae34963cdf02" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-sha2", + "libcrux-traits", +] + +[[package]] +name = "libcrux-platform" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9e21d7ed31a92ac539bd69a8c970b183ee883872d2d19ce27036e24cb8ecc4" +dependencies = [ + "libc", +] + +[[package]] +name = "libcrux-poly1305" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a9144949845813a0b8787d08cbba47baabe59c83f3043e558e6e92385c40cc" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", +] + +[[package]] +name = "libcrux-secrets" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79054fc9037cb70d6a546cf094cea7d7df06af5e49d230ba24103d27ccc886f1" +dependencies = [ + "crabgrind", + "hax-lib", +] + +[[package]] +name = "libcrux-sha2" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e46e1be0b77098cc6ce82137864936ecad3a1b9fd4303dd51202ca140dd1e" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-traits", +] + +[[package]] +name = "libcrux-sha3" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c09f5c39afae0528e1f70a3c1e3c6ee649ec1f19dda26fc9b6ea91108fb879ef" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-traits", +] + +[[package]] +name = "libcrux-traits" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa7a21e8c2e8baa8b40f0b176740f2ca4baadd79d1b00e48d6b1e363c42085a" +dependencies = [ + "libcrux-secrets", + "rand 0.10.2", +] + [[package]] name = "libdbus-sys" version = "0.2.7" @@ -5287,6 +5803,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -5560,6 +6086,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-dsa" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" +dependencies = [ + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8 0.11.0", + "shake", + "signature 3.0.0", +] + [[package]] name = "ml-kem" version = "0.3.2" @@ -5850,37 +6392,133 @@ dependencies = [ name = "oid-registry" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs 0.7.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openmls" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b08d90fc020cb5354d5f08ca17711b84c82e2bcc7331753fd94f000d99a8c8" +dependencies = [ + "log", + "openmls_serialization_helpers", + "openmls_traits", + "rayon", + "serde", + "serde_bytes", + "thiserror 2.0.20", + "tls_codec", + "zeroize", +] + +[[package]] +name = "openmls_basic_credential" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbd3f0c3422e7c7a8496f042b547b0c28d0793f8ba97feb401966e58196a1e40" dependencies = [ - "asn1-rs 0.6.2", + "ed25519-dalek 2.2.0", + "ml-dsa", + "openmls_traits", + "p256 0.13.2", + "p384 0.13.1", + "rand_core 0.6.4", + "serde", + "tls_codec", + "zeroize", ] [[package]] -name = "oid-registry" -version = "0.8.1" +name = "openmls_memory_storage" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +checksum = "ce927945a16eaa19302ed5ccef052f01ba68f2ba7ae67e4ac4ff2fc661445364" dependencies = [ - "asn1-rs 0.7.2", + "log", + "openmls_traits", + "serde", + "serde_json", + "thiserror 2.0.20", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "openmls_rust_crypto" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "36bf3fe824ead6e81d22f49a9cd7feffca3494979fac14c1248e19516e3625f1" +dependencies = [ + "aes-gcm 0.10.3", + "chacha20poly1305 0.10.1", + "ed25519-dalek 2.2.0", + "hkdf 0.13.0", + "hmac 0.13.0", + "hpke-rs", + "hpke-rs-crypto", + "hpke-rs-rust-crypto", + "ml-dsa", + "openmls_memory_storage", + "openmls_traits", + "p256 0.13.2", + "p384 0.13.1", + "rand_chacha 0.3.1", + "rand_core 0.10.1", + "rand_core 0.6.4", + "sha2 0.11.0", + "thiserror 2.0.20", + "tls_codec", +] [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "openmls_serialization_helpers" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "0e2cf2193541e83ce1a89aaa44c2fd4783abd54853823075a32b60ef032284c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror 2.0.20", +] [[package]] -name = "opaque-debug" -version = "0.3.1" +name = "openmls_traits" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +checksum = "ac13edad8698eb1917cbc1dab99591fa1594d84320904fe3fc5121854f95831c" +dependencies = [ + "serde", + "tls_codec", +] [[package]] name = "openssl" @@ -5991,30 +6629,54 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + [[package]] name = "p256" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ - "ecdsa", + "ecdsa 0.17.0", "elliptic-curve 0.14.1", "primefield", - "primeorder", + "primeorder 0.14.0", "sha2 0.11.0", ] +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + [[package]] name = "p384" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" dependencies = [ - "ecdsa", + "ecdsa 0.17.0", "elliptic-curve 0.14.1", - "fiat-crypto", + "fiat-crypto 0.3.0", "primefield", - "primeorder", + "primeorder 0.14.0", "sha2 0.11.0", ] @@ -6025,10 +6687,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" dependencies = [ "base16ct 1.0.0", - "ecdsa", + "ecdsa 0.17.0", "elliptic-curve 0.14.1", "primefield", - "primeorder", + "primeorder 0.14.0", "sha2 0.11.0", ] @@ -6152,6 +6814,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -6322,14 +6993,24 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der 0.8.1", + "spki 0.8.0", ] [[package]] @@ -6355,7 +7036,7 @@ dependencies = [ "clap", "dialoguer", "dirs", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hex", "multibase", "rand 0.10.2", @@ -6466,6 +7147,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "primefield" version = "0.14.0" @@ -6480,6 +7171,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve 0.13.8", +] + [[package]] name = "primeorder" version = "0.14.0" @@ -6493,6 +7193,28 @@ dependencies = [ "wnaf", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -6663,6 +7385,16 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -6740,7 +7472,7 @@ dependencies = [ "compact_str", "critical-section", "hashbrown 0.17.1", - "itertools", + "itertools 0.14.0", "kasuari", "lru", "palette", @@ -6805,7 +7537,7 @@ dependencies = [ "hashbrown 0.17.1", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "serde", @@ -6824,6 +7556,26 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "rcgen" version = "0.13.2" @@ -7080,6 +7832,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "rfc6979" version = "0.6.0" @@ -7141,6 +7903,37 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "room-host" +version = "0.1.0" +dependencies = [ + "affinidi-did-resolver-cache-sdk", + "anyhow", + "axum", + "base64 0.23.1", + "clap", + "ed25519-dalek 3.0.0", + "getrandom 0.3.4", + "multibase", + "openmls_rust_crypto", + "reqwest", + "serde", + "serde_json", + "tempfile", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "trust-tasks-https", + "trust-tasks-rs", + "uuid", + "vta-sdk", + "vtc-client", + "vti-common", + "vti-rooms", + "vti-rooms-dtg", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -7394,6 +8187,20 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "sec1" version = "0.8.1" @@ -7402,7 +8209,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct 1.0.0", "ctutils", - "der", + "der 0.8.1", "hybrid-array", "subtle", "zeroize", @@ -7810,6 +8617,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -7853,6 +8666,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest 0.10.7", "rand_core 0.6.4", ] @@ -7964,6 +8778,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -7971,7 +8795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.1", ] [[package]] @@ -8342,6 +9166,29 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18cc98286004cea38f717e2b03d990fc774fbfd38a82720de40e5c94365067c8" +dependencies = [ + "serde", + "serde_bytes", + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "674f41dd95f76cdeb005c83f9444e20cf43daebef37cde9e49a9ca6f4e87b423" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" @@ -8942,7 +9789,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] @@ -9328,7 +10175,7 @@ dependencies = [ "base64 0.23.1", "chrono", "dialoguer", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hkdf 0.13.0", "multibase", "rand 0.10.2", @@ -9341,7 +10188,7 @@ dependencies = [ "tokio", "vta-sdk", "vti-common", - "x25519-dalek", + "x25519-dalek 3.0.0", ] [[package]] @@ -9387,12 +10234,12 @@ dependencies = [ "base64 0.23.1", "bip39", "chrono", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hex", "hkdf 0.13.0", "hmac 0.13.0", "multibase", - "p256", + "p256 0.14.0", "rand 0.10.2", "serde", "serde_json", @@ -9406,7 +10253,7 @@ dependencies = [ "vta-sdk", "vti-common", "vti-secrets", - "x25519-dalek", + "x25519-dalek 3.0.0", "zeroize", ] @@ -9445,7 +10292,7 @@ dependencies = [ "affinidi-secrets-resolver", "base64 0.23.1", "chrono", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hex", "multibase", "serde", @@ -9511,11 +10358,11 @@ dependencies = [ "chrono", "ciborium", "coset 0.4.2", - "curve25519-dalek", + "curve25519-dalek 5.0.0", "dbus-secret-service-keyring-store", "didwebvh-rs", "dirs", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "futures-util", "getrandom 0.4.3", "hpke", @@ -9540,7 +10387,7 @@ dependencies = [ "uuid", "windows-native-keyring-store", "wiremock", - "x25519-dalek", + "x25519-dalek 3.0.0", "x509-parser 0.18.1", "zeroize", ] @@ -9582,7 +10429,7 @@ dependencies = [ "dialoguer", "didwebvh-rs", "dirs", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "fjall", "futures-util", "hex", @@ -9595,7 +10442,7 @@ dependencies = [ "metrics", "metrics-exporter-prometheus", "multibase", - "p256", + "p256 0.14.0", "rand 0.10.2", "regorus", "reqwest", @@ -9644,7 +10491,7 @@ dependencies = [ "webauthn-rs", "webauthn-rs-proto", "wiremock", - "x25519-dalek", + "x25519-dalek 3.0.0", "zeroize", ] @@ -9653,7 +10500,7 @@ name = "vta-support" version = "0.3.1" dependencies = [ "chrono", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hex", "multibase", "rand 0.10.2", @@ -9735,7 +10582,7 @@ dependencies = [ "base64 0.23.1", "chrono", "coset 0.4.2", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "multibase", "rcgen 0.14.9", "reqwest", @@ -9758,7 +10605,7 @@ version = "0.2.3" dependencies = [ "affinidi-tdk", "chrono", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "reqwest", "serde", "serde_json", @@ -9777,11 +10624,19 @@ dependencies = [ name = "vtc-client" version = "0.5.1" dependencies = [ + "base64 0.22.1", + "chacha20poly1305 0.10.1", "chrono", + "getrandom 0.4.3", + "openmls", + "openmls_basic_credential", + "openmls_rust_crypto", + "openmls_traits", "reqwest", "serde", "serde_json", "thiserror 2.0.20", + "tls_codec", "tokio", "trust-tasks-rs", "vta-sdk", @@ -9818,8 +10673,8 @@ dependencies = [ "clap", "dialoguer", "didwebvh-rs", - "dtg-credentials", - "ed25519-dalek", + "dtg-credentials 0.4.0", + "ed25519-dalek 3.0.0", "fjall", "flate2", "futures-util", @@ -9864,11 +10719,13 @@ dependencies = [ "vtc-client", "vtc-service", "vti-common", + "vti-rooms", + "vti-rooms-dtg", "vti-secrets", "webauthn-rs", "webauthn-rs-proto", "wiremock", - "x25519-dalek", + "x25519-dalek 3.0.0", "zeroize", ] @@ -9889,7 +10746,7 @@ dependencies = [ "bytes", "chrono", "dialoguer", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "fjall", "hex", "hkdf 0.13.0", @@ -9931,7 +10788,7 @@ dependencies = [ "affinidi-secrets-resolver", "affinidi-tdk", "chrono", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "multibase", "reqwest", "serde_json", @@ -9947,6 +10804,42 @@ dependencies = [ "vti-common", ] +[[package]] +name = "vti-rooms" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "serde", + "serde_json", + "tempfile", + "tokio", + "trust-tasks-rs", + "vti-common", +] + +[[package]] +name = "vti-rooms-dtg" +version = "0.1.0" +dependencies = [ + "affinidi-data-integrity", + "affinidi-secrets-resolver", + "affinidi-tdk", + "async-trait", + "base64 0.23.1", + "chrono", + "dtg-credentials 0.6.0", + "ed25519-dalek 3.0.0", + "getrandom 0.3.4", + "multibase", + "serde_json", + "tokio", + "tracing", + "vta-sdk", + "vti-common", + "vti-rooms", +] + [[package]] name = "vti-secrets" version = "0.3.1" @@ -9956,7 +10849,7 @@ dependencies = [ "azure_identity", "azure_security_keyvault_secrets", "bytes", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "futures-util", "google-cloud-auth", "google-cloud-secretmanager-v1", @@ -9984,7 +10877,7 @@ dependencies = [ "aws-lc-rs", "base64 0.23.1", "multibase", - "p256", + "p256 0.14.0", "serde", "serde_json", "serde_json_canonicalizer", @@ -10582,7 +11475,19 @@ dependencies = [ "ml-kem", "sha3 0.12.0", "shake", - "x25519-dalek", + "x25519-dalek 3.0.0", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", ] [[package]] @@ -10591,7 +11496,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 5.0.0", "rand_core 0.10.1", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 92378572f..ae8ba2acf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "cnm-cli", "didcomm-test", "pnm-cli", + "room-host", "tests/e2e", "vta-cli-common", "vta-config", @@ -25,6 +26,8 @@ members = [ "vtc-client", "vtc-service", "vti-common", + "vti-rooms", + "vti-rooms-dtg", "vti-secrets", "vti-webauthn", ] diff --git a/Dockerfile.nitro b/Dockerfile.nitro index 6b810d087..566485a25 100644 --- a/Dockerfile.nitro +++ b/Dockerfile.nitro @@ -127,6 +127,7 @@ COPY Cargo.toml Cargo.lock ./ COPY cnm-cli/ cnm-cli/ COPY didcomm-test/ didcomm-test/ COPY pnm-cli/ pnm-cli/ +COPY room-host/ room-host/ COPY tests/ tests/ COPY vta-cli-common/ vta-cli-common/ COPY vta-config/ vta-config/ @@ -148,6 +149,8 @@ COPY vta-enclave/ vta-enclave/ COPY vtc-client/ vtc-client/ COPY vtc-service/ vtc-service/ COPY vti-common/ vti-common/ +COPY vti-rooms/ vti-rooms/ +COPY vti-rooms-dtg/ vti-rooms-dtg/ COPY vti-secrets/ vti-secrets/ COPY vti-webauthn/ vti-webauthn/ diff --git a/deny.toml b/deny.toml index 7bb830036..e80365dab 100644 --- a/deny.toml +++ b/deny.toml @@ -119,6 +119,25 @@ ignore = [ Drop when uniffi (0.28.x) removes its `paste` dependency. """ }, + { id = "RUSTSEC-2026-0173", reason = """ + proc-macro-error2 2.0.1 is unmaintained — the author has said so and + recommends migrating away. Build-time only, and twice removed from + anything we ship: it is a proc-macro, so it runs in the compiler and + links into no artifact, and its only path into this workspace is + vtc-client's optional, off-by-default `mls` feature — + openmls_rust_crypto -> hpke-rs -> libcrux-sha3 -> hax-lib -> + hax-lib-macros. A default build of every crate here does not resolve + it at all. + + There is no version of this we can pick differently. OpenMLS 0.9 has + one production crypto provider and this is what it depends on; the + alternative is not a cleaner MLS but a hand-rolled group-key scheme, + which the data-rooms design note explicitly declines. + + Drop when hax-lib moves off proc-macro-error2, or when OpenMLS's + provider stack stops reaching libcrux. + """ }, + ] # License policy. Allow-list is derived from the actual licenses present diff --git a/docs/05-design-notes/data-rooms.md b/docs/05-design-notes/data-rooms.md index 18201805b..7bb0f5d2d 100644 --- a/docs/05-design-notes/data-rooms.md +++ b/docs/05-design-notes/data-rooms.md @@ -277,6 +277,32 @@ credentials — which BBS+ supports and the `rooms/*` presentation schema must require, not merely permit. This is a spec-level requirement; discovered in implementation it would be a silent authorization bypass. +**Which subject.** Implementation settled a detail the paragraph above hides: +the VMC binds to the chain's **root**, not its leaf. The first version compared +the leaf and it refused every agent — correctly, by its own rule, because *an +agent is not a member of anything*. Its human is. The chain's root is the grant +the room made, so its subject is the member whose standing the whole chain +descends from; `verify_chain` has already established that each link's issuer is +its parent's subject, so nothing below the root can escape it. Comparing the +root admits the agent and still refuses the pooling attack: a chain rooted at +Bob cannot be presented with Alice's membership, whoever holds the leaf. + +### 4.3a A presentation is bound to its presenter, not bearer + +A presentation names *what may be done*, never *who is doing it* — so an unbound +one is a bearer token, and anyone who observes one inherits everything it +confers. Every room operation therefore also carries the DID that signed the +request, established by the request document's own `eddsa-jcs-2022` proof, and +the chain's leaf must grant to that party. + +Worth stating explicitly because the reference implementation does **not** do it +for you: `dtg_credentials::authority::verify_chain` takes a `presenter`, but +uses it only for the `audience` check on links that name one. Binding the leaf +is the verifier's job, and a verifier that assumed otherwise would authorize +every captured presentation. Rooms check it twice — once in the credential +verifier and once in `authorize` — so the property does not depend on every +future verifier implementation remembering it. + ### 4.4 Presenting membership - `open` / `attributed` — standard W3C VC presentation; the subject is diff --git a/room-host/Cargo.toml b/room-host/Cargo.toml new file mode 100644 index 000000000..d4ded2797 --- /dev/null +++ b/room-host/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "room-host" +description = "Stores data-room records for rooms it does not govern — a delivery service, not a community" +version = "0.1.0" +edition.workspace = true +publish = false +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "room-host" +path = "src/main.rs" + +[dependencies] +# Storage, wire types and authorization. The whole of what hosting a room needs — +# no member lifecycle, no policy engine, no credential issuance, no admin UI. +vti-rooms = { path = "../vti-rooms", version = "0.1" } +# The store and AppError. Nothing else internal. +vti-common = { path = "../vti-common", version = "0.16" } +vti-rooms-dtg = { path = "../vti-rooms-dtg" } + +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +clap = { workspace = true } +anyhow = "1" +affinidi-did-resolver-cache-sdk = { workspace = true } + +# A room host and a VTC serve the same protocol, so they must speak the same +# wire form: requests and responses are Trust Task documents, not bare JSON. +trust-tasks-rs = { workspace = true } +trust-tasks-https = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tempfile = "3" +tower = { version = "0.5", features = ["util"] } +vti-rooms-dtg = { path = "../vti-rooms-dtg", features = ["test-support"] } + +# The `data_room` example drives this host with the real client, so it needs the +# client's MLS layer and a throwaway signer. None of this reaches the binary. +vtc-client = { path = "../vtc-client", features = ["mls"] } +vta-sdk = { path = "../vta-sdk" } +openmls_rust_crypto = "0.6" +ed25519-dalek = { workspace = true } +multibase = { workspace = true } +base64 = { workspace = true } +getrandom = "0.3" +reqwest = { workspace = true } +uuid = { workspace = true } diff --git a/room-host/examples/data_room.rs b/room-host/examples/data_room.rs new file mode 100644 index 000000000..0b6cd6b6d --- /dev/null +++ b/room-host/examples/data_room.rs @@ -0,0 +1,471 @@ +//! A data room, end to end: a real host, a real client, real credentials, real MLS. +//! +//! Run it: +//! +//! ```text +//! cargo run -p room-host --example data_room +//! ``` +//! +//! Nothing here is mocked. It starts the actual `room-host` router on a real TCP port and +//! drives it with `vtc_client`'s actual room methods over HTTP. Every credential is signed +//! and every signature is verified. The sealed records really are sealed — the host is +//! handed ciphertext and holds no key. +//! +//! # The four claims +//! +//! 1. **A room operation carries no session.** Every call authorizes from a credential chain +//! the room issued. The host holds no roster and consults none — it could not; there is +//! no code in it that does. +//! 2. **An agent holds strictly less than its human.** Alice writes; her agent reads and is +//! refused a write. Same code, same host: the difference is entirely the chain each +//! carries, which is what makes "give the AI read-only access for four hours" a +//! credential rather than a policy someone has to remember to enforce. +//! 3. **A presentation is not a bearer token.** Captured and replayed by another party it is +//! refused, because the chain is bound to whoever signed the request. +//! 4. **The host holds every byte and can neither read nor move one.** Act III seals +//! *through* the host, and then fails to open a relocated record three different ways. + +use std::net::SocketAddr; + +use openmls_rust_crypto::OpenMlsRustCrypto; +use vtc_client::VtcClient; +use vtc_client::rooms::mls::{RoomGroup, RoomIdentity}; +use vtc_client::rooms::sealed::SealedRoom; +use vtc_client::rooms::{CleartextContent, RoomSession, SealedContent, Visibility}; +use vti_rooms_dtg::test_support::RoomFixture; + +fn say(step: &str, detail: &str) { + println!("\n\x1b[1m{step}\x1b[0m\n {detail}"); +} + +fn note(detail: &str) { + println!(" {detail}"); +} + +/// The refusal message out of a `trust-task-error` document. +/// +/// The whole document is the right thing on the wire and the wrong thing in a demo. +fn why(e: &impl std::fmt::Display) -> String { + let body = e.to_string(); + match body.find("\"message\":\"") { + Some(i) => { + let rest = &body[i + 11..]; + rest[..rest.find("\",").unwrap_or(rest.len())].to_string() + } + None => body, + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + println!("\n\x1b[1;4mA data room, end to end\x1b[0m"); + + let (addr, _dir) = start_host().await?; + let client = VtcClient::anonymous(&format!("http://{addr}"), "did:key:zHost"); + + act_one(&client).await?; + act_two(&client).await?; + act_three(&client).await?; + + println!("\n\x1b[1mWhat was and was not proved\x1b[0m"); + note("Every credential above was signed and every signature verified. The host stored"); + note("ciphertext it had no key for, and authorized every call from a chain the room"); + note("itself issued — never from anything the host holds."); + note(""); + note("A `private` room is still refused, and Act II shows the refusal rather than"); + note("hiding it. Its subject binding has to be proved in zero knowledge, and which"); + note("proof is a profile the DTG working group has not settled — so that seam is"); + note("present and empty rather than filled with something nobody agreed to.\n"); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Act I — a shared room, over the wire +// --------------------------------------------------------------------------- + +async fn act_one(client: &VtcClient) -> anyhow::Result<()> { + println!("\n\x1b[1;4mAct I — a room, and who may act in it\x1b[0m"); + + // The fixture mints the room's own key, Alice's and her agent's, then issues the + // credentials: a VMC making Alice a member, a VAC from the room granting her + // read/write/curate/admin, and one Alice attenuated for her agent — four hours, read + // only, with no involvement from the room. + let f = RoomFixture::new(vti_rooms::Visibility::Open).await; + + say( + "1. Alice registers the room", + "She brings the room's DID. The host stores it and learns nothing else.", + ); + client + .create_room( + &f.room.room_id, + &f.owner.did, + Visibility::Open, + Some(90), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await?; + note("registered, epoch 1"); + + let alice = session(&f, false); + let agent = session(&f, true); + + say( + "2. Alice writes two memories", + "A shared space is only interesting once something is in it.", + ); + for (key, title, body) in [ + ( + "decision/pricing-2026", + "Pricing holds through Q3", + "Agreed not to reprice before the Northwind renewal closes.", + ), + ( + "decision/vendor-review", + "Vendor review moved to October", + "The security questionnaire is the long pole, not the contract.", + ), + ] { + let put = client + .put_record( + &alice, + key, + None, + Some(CleartextContent { + title: Some(title.into()), + body: body.into(), + ..Default::default() + }), + Some(0), // create-only + &f.owner.did, + &f.owner.secret_multibase, + ) + .await?; + note(&format!("wrote {} at version {}", put.key, put.version)); + } + + say( + "3. The agent reads the room as memory", + "Alice's membership, a chain one link longer, and only `read` at the end of it.", + ); + note(&format!( + "Alice's chain is {} link deep; her agent's is {}", + alice.chain_depth(), + agent.chain_depth() + )); + let listing = client + .list_records( + &agent, + Some("decision/"), + None, + &f.agent.did, + &f.agent.secret_multibase, + ) + .await?; + note(&format!( + "{} records match `decision/` — metadata only, no bodies", + listing.records.len() + )); + let record = client + .get_record( + &agent, + "decision/pricing-2026", + &f.agent.did, + &f.agent.secret_multibase, + ) + .await?; + note(&format!( + "read: {}", + record["cleartext"]["title"].as_str().unwrap_or("?") + )); + + say( + "4. The agent tries to write", + "Nothing in the host says agents may not write. The chain says it, and that is enough.", + ); + match client + .put_record( + &agent, + "decision/invented", + None, + Some(CleartextContent { + body: "the agent should not be able to say this".into(), + ..Default::default() + }), + None, + &f.agent.did, + &f.agent.secret_multibase, + ) + .await + { + Err(e) => note(&format!("refused: {}", why(&e))), + Ok(_) => unreachable!("a read-only chain must not write"), + } + + say( + "5. Alice replays her agent's presentation", + "A presentation says what may be done, not who is doing it — so it is bound to the signer.", + ); + match client + .get_record( + &agent, // the agent's chain … + "decision/pricing-2026", + &f.owner.did, // … signed by Alice + &f.owner.secret_multibase, + ) + .await + { + Err(e) => note(&format!("refused: {}", why(&e))), + Ok(_) => unreachable!("a captured presentation must not be replayable"), + } + + say( + "6. A stranger presents a perfectly valid chain", + "Valid for their own room. It does not reach this one, so it confers nothing here.", + ); + let elsewhere = RoomFixture::new(vti_rooms::Visibility::Open).await; + let borrowed = RoomSession::new( + &f.room.room_id, + elsewhere.membership.clone(), + elsewhere.owner_chain.clone(), + )?; + match client + .get_record( + &borrowed, + "decision/pricing-2026", + &elsewhere.owner.did, + &elsewhere.owner.secret_multibase, + ) + .await + { + Err(e) => note(&format!("refused: {}", why(&e))), + Ok(_) => unreachable!("a chain rooted elsewhere must confer nothing"), + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Act II — the seam that is honestly empty +// --------------------------------------------------------------------------- + +async fn act_two(client: &VtcClient) -> anyhow::Result<()> { + println!("\n\x1b[1;4mAct II — the one thing that does not work yet\x1b[0m"); + + let f = RoomFixture::new(vti_rooms::Visibility::Private).await; + say( + "7. A private room registers, and then refuses to be read", + "Storing and serving are different questions. This host answers the second one no.", + ); + client + .create_room( + &f.room.room_id, + &f.owner.did, + Visibility::Private, + None, + &f.owner.did, + &f.owner.secret_multibase, + ) + .await?; + + let session = session(&f, false).with_subject_binding("a-binding-nobody-can-check-yet"); + match client + .get_record( + &session, + "anything", + &f.owner.did, + &f.owner.secret_multibase, + ) + .await + { + Err(e) => note(&format!("refused: {}", why(&e))), + Ok(_) => unreachable!("a private room must not be served without a ZK profile"), + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Act III — sealed records, through the host +// --------------------------------------------------------------------------- + +async fn act_three(client: &VtcClient) -> anyhow::Result<()> { + println!("\n\x1b[1;4mAct III — sealed, stored, and unmovable\x1b[0m"); + + let f = RoomFixture::new(vti_rooms::Visibility::Attributed).await; + client + .create_room( + &f.room.room_id, + &f.owner.did, + Visibility::Attributed, + None, + &f.owner.did, + &f.owner.secret_multibase, + ) + .await?; + + say( + "8. Alice and Bob form the room's MLS group", + "Bob publishes a key package over the invitation channel — never through the host.", + ); + let mut alice_group = RoomGroup::create(&f.owner.did)?; + let bob_provider = OpenMlsRustCrypto::default(); + let bob_identity = RoomIdentity::new("did:key:zBob", &bob_provider)?; + let bob_package = bob_identity.key_package(&bob_provider)?; + + let change = alice_group.add_member(bob_package)?; + let welcome = change.welcome.expect("adding a member produces a welcome"); + let bob_group = RoomGroup::join_with(bob_identity, bob_provider, &welcome)?; + note(&format!( + "group has {} members, at MLS epoch {}", + alice_group.member_count(), + alice_group.epoch() + )); + + let alice_room = SealedRoom::new(session(&f, false), alice_group); + let bob_room = SealedRoom::new(session(&f, false), bob_group); + + say( + "9. Alice mints the epoch the membership change produced", + "The host records the number and never learns the key. That is the whole of its role.", + ); + let minted = client + .mint_epoch( + alice_room.session(), + alice_room.room_epoch(), + Some("added a member"), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await?; + note(&format!( + "room is at epoch {} — minted by an `admin` chain, not by holding a key", + minted.epoch + )); + + say( + "10. Alice seals a record and stores it", + "The key comes from the MLS exporter. What crosses the wire is below.", + ); + let key = SealedRoom::opaque_key(); + let plaintext = b"Northwind will not be repriced before renewal. Do not share."; + // Version 1: the record does not exist yet, and `expected_version: Some(0)` below makes + // the host assign exactly that. The binding commits to a version chosen before the host + // replies, which is why create-only writes are the shape that works here. + let sealed = alice_room.seal_record(&key, 1, plaintext)?; + note(&format!( + "key {key} (opaque — a descriptive key would defeat the encryption beside it)" + )); + note(&format!( + "ciphertext {}…", + &sealed.ciphertext[..44.min(sealed.ciphertext.len())] + )); + + let put = client + .put_record( + alice_room.session(), + &key, + Some(SealedContent { + ciphertext: sealed.ciphertext.clone(), + nonce: sealed.nonce.clone(), + epoch: sealed.epoch, + }), + None, + Some(0), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await?; + note(&format!("stored at version {}", put.version)); + + say( + "11. It comes back from the host, and Bob opens it", + "The host served the bytes. It could not read them, and neither can anyone outside.", + ); + let fetched = client + .get_record( + alice_room.session(), + &key, + &f.owner.did, + &f.owner.secret_multibase, + ) + .await?; + let from_host = SealedContent { + ciphertext: fetched["sealed"].as_str().unwrap_or_default().to_string(), + nonce: fetched["nonce"].as_str().unwrap_or_default().to_string(), + epoch: fetched["epoch"].as_u64().unwrap_or(0) as u32, + }; + let opened = bob_room.open_record(&key, put.version, &from_host)?; + note(&format!("Bob reads: {}", String::from_utf8_lossy(&opened))); + + let outsider = SealedRoom::new(session(&f, false), RoomGroup::create("did:key:zMallory")?); + note(match outsider.open_record(&key, put.version, &from_host) { + Err(_) => "Mallory, holding a perfectly valid group of her own: cannot open it", + Ok(_) => unreachable!("an outsider must not open a sealed record"), + }); + + say( + "12. The host holds every byte and still cannot move one", + "Each record is bound to roomId | key | version | epoch. Relocation fails loudly.", + ); + let other = RoomFixture::new(vti_rooms::Visibility::Attributed).await; + for (what, result) in [ + ( + "to another key", + alice_room.open_record("other-key", put.version, &from_host), + ), + ( + "to another version", + alice_room.open_record(&key, put.version + 1, &from_host), + ), + ("to another room", { + let elsewhere = + SealedRoom::new(session(&other, false), RoomGroup::create(&f.owner.did)?); + elsewhere.open_record(&key, put.version, &from_host) + }), + ] { + assert!(result.is_err(), "moving a record {what} must fail"); + note(&format!("moved {what}: does not open")); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +/// A session over the fixture's credentials, as the owner or as the agent. +fn session(f: &RoomFixture, as_agent: bool) -> RoomSession { + let chain = if as_agent { + f.agent_chain.clone() + } else { + f.owner_chain.clone() + }; + RoomSession::new(&f.room.room_id, f.membership.clone(), chain) + .expect("the fixture's chains are within the depth bound") +} + +/// Start the real host on an ephemeral port, over a temporary store. +/// +/// `did:key` resolution only — the fixture's rooms and parties are all `did:key`, so every +/// credential below verifies with no network at all. A demo that reached the network would +/// be demonstrating the network. +/// +/// The returned `TempDir` must outlive the run: dropping it deletes every room. +async fn start_host() -> anyhow::Result<(SocketAddr, tempfile::TempDir)> { + let dir = tempfile::tempdir()?; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let app = room_host::router(room_host::open_state(dir.path())?); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + println!( + "\n host listening on {addr}, storing to {}", + dir.path().display() + ); + Ok((addr, dir)) +} diff --git a/room-host/src/lib.rs b/room-host/src/lib.rs new file mode 100644 index 000000000..acc2a2b73 --- /dev/null +++ b/room-host/src/lib.rs @@ -0,0 +1,834 @@ +//! A room host: it stores data-room records and serves them back. +//! +//! # What this is, and what it deliberately is not +//! +//! A room host is a **delivery service**. It holds records — ciphertext, on any tier but +//! `open` — and answers `rooms/*` Trust Tasks against them. It is not a community: it has no +//! member roster, no policy engine, no credential issuance, no admin surface, and no opinion +//! about who belongs to any room it stores. +//! +//! That is not minimalism for its own sake. **A room is authorized by credentials the room +//! itself issued**, so a host that kept its own record of who belongs would become part of +//! that room's membership, and the room could no longer move to a different host without +//! reissuing credentials. The absence of a roster here is the portability guarantee, made +//! structural: there is nothing in this binary that could consult one. +//! +//! # Why it exists as its own binary +//! +//! Topology T1 of the data-rooms design is a person hosting their own rooms on +//! infrastructure they control. Before `vti-rooms` was extracted, doing that meant running a +//! whole community service — member lifecycle, policy, credentials, a website, an admin SPA +//! — to store some ciphertext. This is the same room protocol with none of that. +//! +//! It is also deliberately **not** part of the VTA. The process guarding a master seed +//! should not also terminate presentations from arbitrary DIDs, which is why the design +//! makes a room host an ordinary provisioned integration with its own DID (the `room-host` +//! DID template) rather than a new surface on the agent. +//! +//! # How a request is authorized +//! +//! Two things this host takes from a request and nothing else: +//! +//! - the **presenter**, from the document's own `eddsa-jcs-2022` proof. Not from a payload +//! field — a presentation says what may be done, not who is doing it, so an unbound one +//! is a bearer token anyone observing it inherits. +//! - the **chain**, verified by [`vti_rooms_dtg`] against credentials the room issued. +//! +//! Neither is a lookup in anything this host stores, which is the whole of invariant I5. +//! +//! # Status +//! +//! `open` and `attributed` rooms serve. A `private` room is refused, because its subject +//! binding has to be proved in zero knowledge and the working group has not settled the +//! profile — the refusal comes from `vti-rooms-dtg`, which is also what a VTC uses, so the +//! two cannot disagree about what is safe to serve. + +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::post; +use axum::{Json, Router}; +use serde_json::{Value, json}; +use trust_tasks_https::status_for_code; +use trust_tasks_rs::{RejectReason, TrustTask}; +use uuid::Uuid; +use vti_common::config::StoreConfig; +use vti_common::error::AppError; +use vti_common::store::{KeyspaceHandle, Store}; +use vti_rooms::wire::{ + CreateRoomBody, CreateRoomResponse, GetRecordBody, ListRecordsBody, ListRecordsResponse, + MintEpochBody, MintEpochResponse, PutRecordBody, PutRecordResponse, ROOMS_CREATE_TYPE, + ROOMS_EPOCH_MINT_TYPE, ROOMS_RECORDS_GET_TYPE, ROOMS_RECORDS_LIST_TYPE, ROOMS_RECORDS_PUT_TYPE, +}; +use vti_rooms::{ + ROOM_RECORDS_KEYSPACE, ROOMS_KEYSPACE, Record, RecordStatus, Room, + authz::{self, Action}, + storage, +}; +use vti_rooms_dtg::{DataIntegrityKeys, DtgChainVerifier}; + +/// Default retention after a room's epoch lapses without renewal. +const DEFAULT_RETENTION_DAYS: u32 = 90; + +/// Everything this host holds. Two keyspaces and a verifier — and note what is not here. +#[derive(Clone)] +pub struct HostState { + rooms: KeyspaceHandle, + records: KeyspaceHandle, + /// How a DID resolves to the key that signed a credential. + /// + /// A room's credentials are issued by the room, which is normally a `did:webvh`, so a + /// host restricted to `did:key` can serve almost nothing. It is still the default when + /// no resolver is configured: refusing what it cannot verify is correct, and quietly + /// resolving over the network for an unauthenticated caller is not. + resolver: vti_common::auth::TrustTaskVmResolver, +} + +impl HostState { + /// The presenter — proven, not claimed — and the verifier to judge their chain with. + async fn presenter_and_verifier( + &self, + doc: &TrustTask, + ) -> Result<(String, DtgChainVerifier), AppError> { + let presenter = + vti_common::auth::di_proof::verify_trust_task_proof_with(doc, &self.resolver) + .await + .map_err(|e| AppError::Forbidden(format!("request proof: {e}")))?; + + Ok(( + presenter, + // `without_zk`: no zero-knowledge profile, so private rooms are refused rather + // than served on a pooling defence nobody checked. + DtgChainVerifier::without_zk(Box::new(DataIntegrityKeys(self.resolver.clone()))), + )) + } +} + +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Refuse a request, as a routed `trust-task-error` document. +/// +/// A room host and a VTC serve the same protocol, so they must refuse it the same way: a +/// bare `{"error": …}` is not a Trust Task document, and a client that parses one host's +/// reply cannot parse the other's. The `data_room` example found exactly that — every call +/// in it failed on `missing field \`id\`` before this existed. +/// +/// The reason text distinguishes the cases for an operator reading logs; the framework code +/// is what a caller switches on. +fn reject(doc: &TrustTask, reason: RejectReason) -> axum::response::Response { + let routed = doc.reject_with(format!("urn:uuid:{}", Uuid::new_v4()), reason); + ( + StatusCode::from_u16(status_for_code(&routed.payload.code)) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + Json(serde_json::to_value(&routed).unwrap_or(Value::Null)), + ) + .into_response() +} + +/// Answer a request, as a routed `#response` document. +fn respond(doc: &TrustTask, payload: R) -> axum::response::Response { + let response = doc.respond_with(format!("urn:uuid:{}", Uuid::new_v4()), payload); + Json(serde_json::to_value(&response).unwrap_or(Value::Null)).into_response() +} + +/// An `AppError` from the storage or authorization layer, as a rejection. +/// +/// One mapping, so this host and the VTC classify the same failure identically. Both +/// services reach these from shared code in `vti-rooms`; disagreeing here would mean the +/// same refusal read as a different kind of problem depending on who was hosting. +fn from_app_error(doc: &TrustTask, e: &AppError) -> axum::response::Response { + let reason = e.to_string(); + reject( + doc, + match e { + AppError::Forbidden(_) => RejectReason::PermissionDenied { reason }, + AppError::Validation(_) => RejectReason::MalformedRequest { reason }, + // `TaskFailed` for both, following the VTC: a room that does not exist and a + // version precondition that lost a race are caller-visible outcomes, not server + // faults, and `InternalError` would tell the caller to retry. + AppError::NotFound(_) | AppError::Conflict(_) => RejectReason::TaskFailed { + reason, + details: None, + }, + _ => RejectReason::InternalError { reason }, + }, + ) +} + +/// The one entry point: a `rooms/*` document, routed by its own `type`. +/// +/// One mount rather than five routes, because the document's `type` is its identity — the +/// same shape the VTC's holder-facing surface uses. +async fn trust_task(State(state): State>, body: Bytes) -> axum::response::Response { + // A body that is not a Trust Task document cannot be *routed* — there is no issuer to + // address a rejection to and no thread to correlate it with — so this one case answers + // with an unrouted error, exactly as the VTC's `body_parse_error_response` does. + let doc: TrustTask = match serde_json::from_slice(&body) { + Ok(d) => d, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": format!("body is not a Trust Task document: {e}"), + })), + ) + .into_response(); + } + }; + + let payload = doc.payload.clone(); + match doc.type_uri.to_string().as_str() { + ROOMS_CREATE_TYPE => create(&state, &doc, payload).await, + ROOMS_RECORDS_PUT_TYPE => put(&state, &doc, payload).await, + ROOMS_RECORDS_GET_TYPE => get(&state, &doc, payload).await, + ROOMS_RECORDS_LIST_TYPE => list(&state, &doc, payload).await, + ROOMS_EPOCH_MINT_TYPE => mint(&state, &doc, payload).await, + other => reject( + &doc, + // The framework's own code for this: a host that does not implement a task + // says so by naming the type, and a client can tell that from a task it + // implements but refused. + RejectReason::UnsupportedType { + type_uri: other.to_string(), + }, + ), + } +} + +async fn create( + state: &HostState, + doc: &TrustTask, + payload: Value, +) -> axum::response::Response { + let req: CreateRoomBody = match serde_json::from_value(payload) { + Ok(r) => r, + Err(e) => { + return reject( + doc, + RejectReason::MalformedRequest { + reason: e.to_string(), + }, + ); + } + }; + let room = Room { + room_id: req.room_id.clone(), + owner_did: req.owner_did, + visibility: req.visibility, + epoch: 1, + next_version: 1, + retention_days: req.retention_days.unwrap_or(DEFAULT_RETENTION_DAYS), + created_at: now(), + updated_at: now(), + }; + match storage::create_room(&state.rooms, &room).await { + Ok(()) => respond( + doc, + CreateRoomResponse { + room_id: req.room_id, + epoch: 1, + }, + ), + Err(e) => from_app_error(doc, &e), + } +} + +async fn put( + state: &HostState, + doc: &TrustTask, + payload: Value, +) -> axum::response::Response { + let req: PutRecordBody = match serde_json::from_value(payload) { + Ok(r) => r, + Err(e) => { + return reject( + doc, + RejectReason::MalformedRequest { + reason: e.to_string(), + }, + ); + } + }; + let room = match storage::get_room(&state.rooms, &req.room_id).await { + Ok(r) => r, + Err(e) => return from_app_error(doc, &e), + }; + let (presenter, verifier) = match state.presenter_and_verifier(doc).await { + Ok(p) => p, + Err(e) => return from_app_error(doc, &e), + }; + let authorized = match authz::authorize( + &room, + &req.presentation, + Action::Write, + &presenter, + &verifier, + ) + .await + { + Ok(a) => a, + Err(e) => return from_app_error(doc, &e), + }; + + let record = Record { + key: req.key.clone(), + version: 0, + epoch: req.sealed.as_ref().map(|s| s.epoch), + status: RecordStatus::Active, + sealed: req.sealed.as_ref().map(|s| s.ciphertext.clone()), + nonce: req.sealed.as_ref().map(|s| s.nonce.clone()), + cleartext: req + .cleartext + .as_ref() + .map(|c| serde_json::to_value(c).unwrap_or(Value::Null)), + // The verified subject where the tier discloses an actor — who the chain says is + // acting, not the room's owner. On a private room authorship lives inside the + // sealed body, and the storage layer refuses it here. + author: room + .visibility + .discloses_actor() + .then(|| authorized.subject().to_string()), + updated_at: 0, + }; + + match storage::put_record( + &state.rooms, + &state.records, + &req.room_id, + record, + req.expected_version, + now(), + ) + .await + { + Ok(stored) => respond( + doc, + PutRecordResponse { + key: stored.key, + version: stored.version, + epoch: stored.epoch, + }, + ), + Err(e) => from_app_error(doc, &e), + } +} + +async fn get( + state: &HostState, + doc: &TrustTask, + payload: Value, +) -> axum::response::Response { + let req: GetRecordBody = match serde_json::from_value(payload) { + Ok(r) => r, + Err(e) => { + return reject( + doc, + RejectReason::MalformedRequest { + reason: e.to_string(), + }, + ); + } + }; + let room = match storage::get_room(&state.rooms, &req.room_id).await { + Ok(r) => r, + Err(e) => return from_app_error(doc, &e), + }; + let (presenter, verifier) = match state.presenter_and_verifier(doc).await { + Ok(p) => p, + Err(e) => return from_app_error(doc, &e), + }; + if let Err(e) = authz::authorize( + &room, + &req.presentation, + Action::Read, + &presenter, + &verifier, + ) + .await + { + return from_app_error(doc, &e); + } + match storage::get_record(&state.records, &req.room_id, &req.key).await { + Ok(record) => respond(doc, record), + Err(e) => from_app_error(doc, &e), + } +} + +async fn list( + state: &HostState, + doc: &TrustTask, + payload: Value, +) -> axum::response::Response { + let req: ListRecordsBody = match serde_json::from_value(payload) { + Ok(r) => r, + Err(e) => { + return reject( + doc, + RejectReason::MalformedRequest { + reason: e.to_string(), + }, + ); + } + }; + let room = match storage::get_room(&state.rooms, &req.room_id).await { + Ok(r) => r, + Err(e) => return from_app_error(doc, &e), + }; + let (presenter, verifier) = match state.presenter_and_verifier(doc).await { + Ok(p) => p, + Err(e) => return from_app_error(doc, &e), + }; + if let Err(e) = authz::authorize( + &room, + &req.presentation, + Action::Read, + &presenter, + &verifier, + ) + .await + { + return from_app_error(doc, &e); + } + match storage::list_records( + &state.records, + &req.room_id, + req.prefix.as_deref(), + req.since_version, + ) + .await + { + Ok(records) => { + let limit = req.limit.unwrap_or(usize::MAX); + // Metadata, never bodies — the same rule the VTC serves under, because it is a + // property of the task rather than of any one host. + respond( + doc, + ListRecordsResponse { + records: records.iter().take(limit).map(|r| r.metadata()).collect(), + }, + ) + } + Err(e) => from_app_error(doc, &e), + } +} + +async fn mint( + state: &HostState, + doc: &TrustTask, + payload: Value, +) -> axum::response::Response { + let req: MintEpochBody = match serde_json::from_value(payload) { + Ok(r) => r, + Err(e) => { + return reject( + doc, + RejectReason::MalformedRequest { + reason: e.to_string(), + }, + ); + } + }; + let room = match storage::get_room(&state.rooms, &req.room_id).await { + Ok(r) => r, + Err(e) => return from_app_error(doc, &e), + }; + // `admin`, not `write`: if any key-holder could mint an epoch, any member could evict + // any other by declining to seal them the new key — and this host, which cannot see the + // membership, would have no way to notice. + let (presenter, verifier) = match state.presenter_and_verifier(doc).await { + Ok(p) => p, + Err(e) => return from_app_error(doc, &e), + }; + if let Err(e) = authz::authorize( + &room, + &req.presentation, + Action::Admin, + &presenter, + &verifier, + ) + .await + { + return from_app_error(doc, &e); + } + match storage::advance_epoch(&state.rooms, &req.room_id, req.epoch, now()).await { + Ok(updated) => respond( + doc, + MintEpochResponse { + room_id: updated.room_id, + epoch: updated.epoch, + }, + ), + Err(e) => from_app_error(doc, &e), + } +} + +/// Build the router. Separated from `main` so tests can drive it without a socket. +pub fn router(state: Arc) -> Router { + Router::new() + .route("/trust-tasks", post(trust_task)) + .route("/health", axum::routing::get(|| async { "ok" })) + .with_state(state) +} + +/// Open the store with a `did:key`-only verifier. +/// +/// The conservative construction, and what a test or the example wants: no network +/// resolution can be triggered by an unauthenticated request. A deployment serving a +/// `did:webvh` room wants [`open_state_with_resolver`]. +pub fn open_state(data_dir: &std::path::Path) -> anyhow::Result> { + open_state_with_resolver( + data_dir, + vti_common::auth::TrustTaskVmResolver::did_key_only(), + ) +} + +/// Open the store with a specific verification-method resolver. +pub fn open_state_with_resolver( + data_dir: &std::path::Path, + resolver: vti_common::auth::TrustTaskVmResolver, +) -> anyhow::Result> { + let store = Store::open(&StoreConfig { + data_dir: data_dir.to_path_buf(), + })?; + Ok(Arc::new(HostState { + rooms: store.keyspace(ROOMS_KEYSPACE)?, + records: store.keyspace(ROOM_RECORDS_KEYSPACE)?, + resolver, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + use vti_rooms::Visibility; + use vti_rooms_dtg::test_support::{Party, RoomFixture}; + + /// A host over a temporary store. + /// + /// `did:key`-only resolution is not a limitation being worked around here: the fixture's + /// room, owner and agent are all `did:key`, so every credential in these tests verifies + /// with no network at all. That is deliberate — a test that reached the network would be + /// testing the network. + fn state() -> (tempfile::TempDir, Arc) { + let dir = tempfile::tempdir().unwrap(); + let state = open_state(dir.path()).expect("open store"); + (dir, state) + } + + /// Send a **signed** document and return the status plus the response payload. + /// + /// Signing is not ceremony: the host reads the presenter from this proof, and an + /// unsigned request is refused before any chain is looked at. + async fn call( + app: &Router, + type_uri: &str, + payload: Value, + signer: &Party, + ) -> (StatusCode, Value) { + let doc = vta_sdk::trust_task_sign::build_signed( + type_uri, + payload, + &signer.did, + &signer.secret_multibase, + "did:key:zHost", + ) + .await + .expect("sign the request"); + + let resp = app + .clone() + .oneshot( + Request::post("/trust-tasks") + .header("content-type", "application/json") + .body(Body::from(doc)) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let doc: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + assert!( + doc.get("id").is_some(), + "every reply must be a Trust Task document: {doc}" + ); + (status, doc.get("payload").cloned().unwrap_or(Value::Null)) + } + + /// Register `f`'s room with the host. + async fn register(app: &Router, f: &RoomFixture) { + let (status, body) = call( + app, + ROOMS_CREATE_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "ownerDid": f.room.owner_did, + "visibility": f.room.visibility, + }), + &f.owner, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + } + + #[tokio::test] + async fn a_record_round_trips_under_a_chain_the_room_issued() { + let (_d, st) = state(); + let app = router(st); + let f = RoomFixture::new(Visibility::Open).await; + register(&app, &f).await; + + let (status, body) = call( + &app, + ROOMS_RECORDS_PUT_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "key": "decision/pricing", + "presentation": f.as_owner(), + "cleartext": { "body": "a decision" }, + }), + &f.owner, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["version"], 1); + + let (status, body) = call( + &app, + ROOMS_RECORDS_GET_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "key": "decision/pricing", + "presentation": f.as_owner(), + }), + &f.owner, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["cleartext"]["body"], "a decision"); + assert_eq!( + body["author"], f.owner.did, + "the author is the verified subject, not the room's owner field" + ); + } + + /// The arrangement the whole design exists for, end to end through a host. + #[tokio::test] + async fn an_agent_reads_under_a_narrower_chain_and_cannot_write() { + let (_d, st) = state(); + let app = router(st); + let f = RoomFixture::new(Visibility::Open).await; + register(&app, &f).await; + call( + &app, + ROOMS_RECORDS_PUT_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "key": "k", + "presentation": f.as_owner(), + "cleartext": { "body": "for the agent to read" }, + }), + &f.owner, + ) + .await; + + let (status, body) = call( + &app, + ROOMS_RECORDS_GET_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "key": "k", + "presentation": f.as_agent(), + }), + &f.agent, + ) + .await; + assert_eq!(status, StatusCode::OK, "the agent reads: {body}"); + + let (status, body) = call( + &app, + ROOMS_RECORDS_PUT_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "key": "k2", + "presentation": f.as_agent(), + "cleartext": { "body": "but it must not write" }, + }), + &f.agent, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + } + + /// A presentation is not a bearer token: the agent's chain, presented by its human. + #[tokio::test] + async fn a_captured_presentation_does_not_work_for_someone_else() { + let (_d, st) = state(); + let app = router(st); + let f = RoomFixture::new(Visibility::Open).await; + register(&app, &f).await; + + let (status, body) = call( + &app, + ROOMS_RECORDS_GET_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "key": "k", + "presentation": f.as_agent(), + }), + &f.owner, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + } + + /// A stranger with a perfectly valid chain of their own gets nothing here — because the + /// chain does not reach *this* room. + #[tokio::test] + async fn a_chain_from_another_room_confers_nothing() { + let (_d, st) = state(); + let app = router(st); + let f = RoomFixture::new(Visibility::Open).await; + let elsewhere = RoomFixture::new(Visibility::Open).await; + register(&app, &f).await; + + let (status, body) = call( + &app, + ROOMS_RECORDS_GET_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "key": "k", + "presentation": elsewhere.as_owner(), + }), + &elsewhere.owner, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + } + + #[tokio::test] + async fn an_operation_with_no_chain_is_refused() { + let (_d, st) = state(); + let app = router(st); + let f = RoomFixture::new(Visibility::Open).await; + register(&app, &f).await; + + let (status, _) = call( + &app, + ROOMS_RECORDS_PUT_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "key": "k", + "presentation": { "membership": f.membership, "authority": [] }, + "cleartext": { "body": "x" }, + }), + &f.owner, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + /// A private room is refused with the message `vti-rooms-dtg` gives it, which is the + /// same message a VTC gives — the two cannot disagree about what is safe to serve. + #[tokio::test] + async fn a_private_room_is_refused_for_want_of_a_zk_profile() { + let (_d, st) = state(); + let app = router(st); + let f = RoomFixture::new(Visibility::Private).await; + register(&app, &f).await; + + let mut p = f.as_owner(); + p.subject_binding = Some("a-binding-nobody-can-check".into()); + + let (status, body) = call( + &app, + ROOMS_RECORDS_GET_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "key": "k", + "presentation": p, + }), + &f.owner, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert!( + body["message"] + .as_str() + .unwrap_or_default() + .contains("zero-knowledge profile"), + "{body}" + ); + } + + #[tokio::test] + async fn a_listing_returns_metadata_and_never_bodies() { + let (_d, st) = state(); + let app = router(st); + let f = RoomFixture::new(Visibility::Open).await; + register(&app, &f).await; + call( + &app, + ROOMS_RECORDS_PUT_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "key": "a", + "presentation": f.as_owner(), + "cleartext": { "body": "secret-body-text" }, + }), + &f.owner, + ) + .await; + + let (status, body) = call( + &app, + ROOMS_RECORDS_LIST_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "presentation": f.as_owner(), + }), + &f.owner, + ) + .await; + assert_eq!(status, StatusCode::OK); + let text = body.to_string(); + assert!(text.contains("\"key\"")); + assert!( + !text.contains("secret-body-text"), + "a listing must never carry bodies: {text}" + ); + } + + #[tokio::test] + async fn an_unknown_task_is_not_served() { + let (_d, st) = state(); + let (status, body) = call( + &router(st), + "https://trusttasks.org/spec/vtc/members/list/0.1", + serde_json::json!({}), + &Party::new(), + ) + .await; + assert!( + !status.is_success(), + "a room host serves rooms and nothing else" + ); + assert_eq!( + body["code"], "unsupportedType", + "and says so with the framework's own code: {body}" + ); + } +} diff --git a/room-host/src/main.rs b/room-host/src/main.rs new file mode 100644 index 000000000..17706a064 --- /dev/null +++ b/room-host/src/main.rs @@ -0,0 +1,56 @@ +//! The `room-host` binary: parse the flags, open the store, serve. +//! +//! Everything else is in the library beside this, so the same router can be driven from a +//! test or the `data_room` example without a socket. + +use clap::Parser; +use room_host::{open_state_with_resolver, router}; + +#[derive(Parser, Debug)] +#[command(name = "room-host", about = "Store and serve data-room records")] +struct Args { + /// Where the record store lives. + #[arg(long, default_value = "./room-host-data")] + data_dir: std::path::PathBuf, + /// Address to listen on. + #[arg(long, default_value = "127.0.0.1:8300")] + listen: String, + /// Resolve credential issuers over the network as well as locally. + /// + /// Off by default. A room's credentials are normally issued by a `did:webvh` room, so a + /// host without this serves almost nothing — but turning network resolution on means an + /// unauthenticated request can make this host fetch, so it is a decision an operator + /// makes rather than a default they inherit. + #[arg(long)] + resolve_dids: bool, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "room_host=info".into()), + ) + .init(); + + let args = Args::parse(); + let resolver = if args.resolve_dids { + use affinidi_did_resolver_cache_sdk::{DIDCacheClient, config::DIDCacheConfigBuilder}; + let client = DIDCacheClient::new(DIDCacheConfigBuilder::default().build()).await?; + vti_common::auth::TrustTaskVmResolver::new(client) + } else { + vti_common::auth::TrustTaskVmResolver::did_key_only() + }; + let state = open_state_with_resolver(&args.data_dir, resolver)?; + + let listener = tokio::net::TcpListener::bind(&args.listen).await?; + tracing::info!( + listen = %args.listen, + data_dir = %args.data_dir.display(), + network_resolution = args.resolve_dids, + "room host ready — storing records for rooms it does not govern" + ); + axum::serve(listener, router(state)).await?; + Ok(()) +} diff --git a/tests/e2e/tests/dockerfile_members_census.rs b/tests/e2e/tests/dockerfile_members_census.rs new file mode 100644 index 000000000..b4f4c9447 --- /dev/null +++ b/tests/e2e/tests/dockerfile_members_census.rs @@ -0,0 +1,112 @@ +//! Every workspace member must be copied into the enclave image. +//! +//! `Dockerfile.nitro` copies a hand-maintained list of member directories rather than +//! `COPY . .`, so that an edit to `deploy/nitro/config.toml` does not invalidate the Rust +//! build layer. The cost of that trade is this list, and a member missing from it is not a +//! missing file — cargo-chef's skeleton stub stays in place, and the build fails six +//! minutes later with +//! +//! ```text +//! error: failed to select a version for the requirement `vti-common = "^0.0.1"` +//! required by package `room-host v0.0.1 (/build/room-host)` +//! ``` +//! +//! which says nothing about the actual cause. That is how the `vti-rooms` and `room-host` +//! members broke the nitro image: both were added to `members`, neither to the COPY list, +//! and every other check in CI stayed green. +//! +//! This turns that into a one-second failure that names the line to add. It is a census in +//! the same sense as the keyspace and `retry_safety` censuses — a list that must not drift +//! from the thing it describes, pinned by a test rather than by remembering. +//! +//! `Dockerfile` (the ordinary image) uses `COPY . .` and needs no equivalent. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +/// The workspace root, from this crate's manifest directory (`tests/e2e`). +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("tests/e2e is two levels below the workspace root") + .to_path_buf() +} + +/// Workspace members, reduced to the top-level directory the Dockerfile would copy. +/// +/// `tests/e2e` is copied as `tests/`, so a member path is truncated at its first +/// component — which is what the COPY list actually names. +fn members() -> BTreeSet { + let manifest = std::fs::read_to_string(workspace_root().join("Cargo.toml")) + .expect("read the workspace manifest"); + let list = manifest + .split_once("members = [") + .expect("the workspace manifest declares members") + .1 + .split_once(']') + .expect("the members array is closed") + .0; + + list.lines() + .filter_map(|line| { + let line = line.trim().trim_end_matches(','); + let inner = line.strip_prefix('"')?.strip_suffix('"')?; + inner.split('/').next().map(str::to_string) + }) + .collect() +} + +/// Directories `Dockerfile.nitro` copies, from its `COPY / /` lines. +fn copied() -> BTreeSet { + let dockerfile = std::fs::read_to_string(workspace_root().join("Dockerfile.nitro")) + .expect("read Dockerfile.nitro"); + + dockerfile + .lines() + .filter_map(|line| { + let rest = line.trim().strip_prefix("COPY ")?; + // `COPY --from=…` is a stage copy, not a source directory. + if rest.starts_with("--") { + return None; + } + let first = rest.split_whitespace().next()?; + first.strip_suffix('/').map(str::to_string) + }) + .collect() +} + +#[test] +fn every_workspace_member_is_copied_into_the_enclave_image() { + let missing: Vec<_> = members().difference(&copied()).cloned().collect(); + + assert!( + missing.is_empty(), + "these workspace members are not copied into the nitro image, so cargo-chef's \ + 0.0.1 skeleton stub survives into the real build and `cargo build -p vta-enclave` \ + fails to resolve their path dependencies:\n\n{}\n\nAdd a line for each to \ + Dockerfile.nitro's source-copy block:\n\n{}\n", + missing.join(", "), + missing + .iter() + .map(|m| format!("COPY {m}/ {m}/")) + .collect::>() + .join("\n"), + ); +} + +/// The reverse drift: a directory copied after the member that needed it is gone. Harmless +/// to the build, but it invalidates the layer for a path nothing reads, which is the exact +/// cost the hand-maintained list exists to avoid paying. +#[test] +fn nothing_is_copied_that_is_no_longer_a_member() { + let stale: Vec<_> = copied().difference(&members()).cloned().collect(); + + assert!( + stale.is_empty(), + "Dockerfile.nitro copies directories that are not workspace members any more: {}. \ + Remove those COPY lines — they invalidate the build layer for sources nothing \ + compiles.", + stale.join(", "), + ); +} diff --git a/vta-sdk/src/did_templates/builtin.rs b/vta-sdk/src/did_templates/builtin.rs index 0cf5e6465..6b55432a6 100644 --- a/vta-sdk/src/did_templates/builtin.rs +++ b/vta-sdk/src/did_templates/builtin.rs @@ -18,6 +18,8 @@ pub const BUILTIN_NAMES: &[&str] = &[ "did-host-tsp", "didcomm-mediator", "push-gateway", + "room", + "room-host", "vta-admin", "vtc-host", ]; @@ -26,6 +28,8 @@ const AI_AGENT: &str = include_str!("../../templates/ai-agent.json"); const AI_AGENT_PEER: &str = include_str!("../../templates/ai-agent-peer.json"); const DIDCOMM_MEDIATOR: &str = include_str!("../../templates/didcomm-mediator.json"); const PUSH_GATEWAY: &str = include_str!("../../templates/push-gateway.json"); +const ROOM: &str = include_str!("../../templates/room.json"); +const ROOM_HOST: &str = include_str!("../../templates/room-host.json"); const VTA_ADMIN: &str = include_str!("../../templates/vta-admin.json"); const VTC_HOST: &str = include_str!("../../templates/vtc-host.json"); const DID_HOST_HTTP_DIDCOMM: &str = include_str!("../../templates/did-host-http-didcomm.json"); @@ -44,6 +48,8 @@ pub fn load_embedded(name: &str) -> Result { "ai-agent-peer" => AI_AGENT_PEER, "didcomm-mediator" => DIDCOMM_MEDIATOR, "push-gateway" => PUSH_GATEWAY, + "room" => ROOM, + "room-host" => ROOM_HOST, "vta-admin" => VTA_ADMIN, "vtc-host" => VTC_HOST, "did-host-http-didcomm" => DID_HOST_HTTP_DIDCOMM, diff --git a/vta-sdk/templates/room-host.json b/vta-sdk/templates/room-host.json new file mode 100644 index 000000000..6123b7d09 --- /dev/null +++ b/vta-sdk/templates/room-host.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 1, + "name": "room-host", + "kind": "room-host", + "description": "A service that stores data-room records. Provisioned as an ordinary integration so that a person can host their own rooms on infrastructure they control (topology T1 of the data-rooms design) without that surface living inside the VTA — the process guarding the master seed must not also terminate presentations from arbitrary DIDs. A room-host holds ciphertext and verifies presentations; it holds no member list and no room keys, so its ACL entry should be scoped to exactly the oracle verbs it needs and nothing wider. Advertises REST for the rooms/* task surface and DIDComm so a room's own DID can route through the same mediator.", + "methods": ["webvh"], + "requiredVars": ["WEBVH_SERVER", "URL", "MEDIATOR_DID"], + "optionalVars": { + "ACCEPT": ["didcomm/v2"], + "ROUTING_KEYS": [], + "LABEL": null + }, + "defaults": { + "portable": true + }, + "document": { + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://www.w3.org/ns/cid/v1" + ], + "id": "{DID}", + "verificationMethod": [ + { + "id": "{DID}#key-1", + "type": "Multikey", + "controller": "{DID}", + "publicKeyMultibase": "{SIGNING_KEY_MB}" + }, + { + "id": "{DID}#key-2", + "type": "Multikey", + "controller": "{DID}", + "publicKeyMultibase": "{KA_KEY_MB}" + } + ], + "assertionMethod": ["{DID}#key-1"], + "authentication": ["{DID}#key-1"], + "keyAgreement": ["{DID}#key-2"], + "service": [ + { + "id": "{DID}#didcomm", + "type": "DIDCommMessaging", + "serviceEndpoint": [ + { + "accept": "{ACCEPT}", + "routingKeys": "{ROUTING_KEYS}", + "uri": "{MEDIATOR_DID}" + } + ] + }, + { + "id": "{DID}#rest", + "type": "VTARest", + "serviceEndpoint": "{URL}" + } + ] + } +} diff --git a/vta-sdk/templates/room.json b/vta-sdk/templates/room.json new file mode 100644 index 000000000..5faa12bb3 --- /dev/null +++ b/vta-sdk/templates/room.json @@ -0,0 +1,54 @@ +{ + "schemaVersion": 1, + "name": "room", + "kind": "room", + "description": "A data room's own identity. A room is a DTG node, not a row in a host's table: it holds a DID, issues the VIC/VMC/VAC credentials that govern it, and can be messaged. Because a host authorizes room operations against credentials this DID issued — never against its own records — the room is portable: re-point its service endpoint and the room has moved, with no credential reissued. did:webvh rather than did:peer, because a room's controller must be able to change: transferring ownership is a controller change, and did:peer encodes its keys in the identifier so it can never have one. WITNESSES is strongly recommended for any room whose host is not fully trusted — a witnessed log makes a host that serves a stale or suppressed update evident rather than merely possible. MEDIATOR_DID makes the room addressable, which is what lets an invitation, a join, or an epoch notice reach it.", + "methods": ["webvh"], + "requiredVars": ["WEBVH_SERVER", "MEDIATOR_DID"], + "optionalVars": { + "ACCEPT": ["didcomm/v2"], + "ROUTING_KEYS": [], + "WITNESSES": [], + "LABEL": null + }, + "defaults": { + "portable": true + }, + "document": { + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://www.w3.org/ns/cid/v1" + ], + "id": "{DID}", + "verificationMethod": [ + { + "id": "{DID}#key-1", + "type": "Multikey", + "controller": "{DID}", + "publicKeyMultibase": "{SIGNING_KEY_MB}" + }, + { + "id": "{DID}#key-2", + "type": "Multikey", + "controller": "{DID}", + "publicKeyMultibase": "{KA_KEY_MB}" + } + ], + "assertionMethod": ["{DID}#key-1"], + "authentication": ["{DID}#key-1"], + "keyAgreement": ["{DID}#key-2"], + "service": [ + { + "id": "{DID}#didcomm", + "type": "DIDCommMessaging", + "serviceEndpoint": [ + { + "accept": "{ACCEPT}", + "routingKeys": "{ROUTING_KEYS}", + "uri": "{MEDIATOR_DID}" + } + ] + } + ] + } +} diff --git a/vtc-client/Cargo.toml b/vtc-client/Cargo.toml index 6fda3b6ce..3e1d35ed8 100644 --- a/vtc-client/Cargo.toml +++ b/vtc-client/Cargo.toml @@ -10,11 +10,32 @@ readme = "README.md" license.workspace = true repository.workspace = true +[features] +default = [] +# The MLS group layer (`rooms::mls`). Off by default: OpenMLS is a substantial +# dependency and a caller that only reads an `open` room needs none of it. +# Mirrors how `vtc-service` gates its `bbs` verifier. +mls = ["dep:openmls", "dep:openmls_rust_crypto", "dep:openmls_basic_credential", "dep:openmls_traits", "dep:tls_codec", "dep:chacha20poly1305", "dep:base64", "dep:getrandom"] + [dependencies] # Reuses the VTA SDK's challenge-response auth (audience-agnostic) and the # published join-request protocol types. `session` gates `auth_light` + # `protocols`. vta-sdk = { path = "../vta-sdk", version = "0.32", features = ["session"] } + +# MLS (RFC 9420) for room group keys, behind the `mls` feature. The design note +# adopts MLS rather than a hand-rolled epoch scheme: post-compromise security and +# O(log n) membership change are not things to reimplement. +openmls = { version = "0.9", optional = true } +openmls_rust_crypto = { version = "0.6", optional = true } +openmls_basic_credential = { version = "0.6", optional = true } +openmls_traits = { version = "0.6", optional = true } +tls_codec = { version = "0.5", optional = true } +# Record sealing. The AEAD binds each record to its room, key, version and epoch, +# so a host that relocates one produces a mismatch rather than a readable record. +chacha20poly1305 = { version = "0.10", optional = true } +base64 = { version = "0.22", optional = true } +getrandom = { version = "0.4", optional = true } # `TrustTask` — the join-submit document this client signs, and the `#response` # envelope the VTC answers it with. Same crate `vta-sdk` builds the document # from, so one shape crosses the boundary. diff --git a/vtc-client/src/lib.rs b/vtc-client/src/lib.rs index 1693b6cb6..a4b9774ad 100644 --- a/vtc-client/src/lib.rs +++ b/vtc-client/src/lib.rs @@ -46,6 +46,8 @@ use serde::{Deserialize, Serialize}; /// a single read, rather than scattered as string literals down the file. A URL /// that drifts from the server's is a 400 at runtime, so this list is part of /// the client's contract, not decoration. +pub mod rooms; + pub mod task { pub const MEMBERS_LIST: &str = "https://trusttasks.org/spec/vtc/members/list/0.1"; pub const MEMBERS_UPDATE: &str = "https://trusttasks.org/spec/vtc/members/update/0.1"; diff --git a/vtc-client/src/rooms/mls.rs b/vtc-client/src/rooms/mls.rs new file mode 100644 index 000000000..570742b78 --- /dev/null +++ b/vtc-client/src/rooms/mls.rs @@ -0,0 +1,471 @@ +//! The room's group-key layer, on MLS (RFC 9420). +//! +//! # Why MLS rather than a room key per epoch +//! +//! An earlier draft of the design hand-rolled this: one symmetric key per epoch, sealed to +//! each member on every change. Every part of that is something MLS already standardises, +//! and two of the parts it adds are not optional for a system that expects to outlive a +//! compromise: +//! +//! - **Post-compromise security.** A stolen member key stops working at the next commit. The +//! fan-out design had none — a stolen key read every future epoch until somebody noticed. +//! - **O(log n) membership change.** Fan-out is O(n) per change. Fine for a five-person +//! room, wrong for one whose membership is a whole community roster. +//! +//! # How it maps onto a room +//! +//! MLS separates an **Authentication Service** (who is this leaf?) from a **Delivery +//! Service** (who stores and orders the group's messages, and is trusted for availability +//! only). That is the room design's own shape, arrived at independently: +//! +//! | MLS | Room | +//! |---|---| +//! | Authentication Service | the DTG — a leaf's credential is the room VMC | +//! | Delivery Service | the room's host, trusted per invariant I2 | +//! | Group | the room | +//! | Epoch | the room's epoch, the number the host stores | +//! | Commit | a membership change — **only the owner commits** | +//! | Exporter secret | the room's storage key | +//! +//! # One leaf per member, not per device +//! +//! A member's leaf is their **VTA**, and devices and agents hang off it through the oracle +//! model rather than joining the group themselves. That sidesteps MLS's multi-device +//! complexity entirely, and it is the same reason the design puts key custody in the VTA: +//! an agent asks its VTA to open a record and never holds the key. +//! +//! # Storage keys come from the exporter, not from the group's message keys +//! +//! Records are sealed with a key derived from [`RoomGroup::storage_key`], which is the MLS +//! exporter under a room-specific label. This is the pattern +//! `draft-sullivan-mls-attachments` uses for encrypted attachments, following SFrame +//! (RFC 9605): the group provides authenticated key agreement, and the application derives +//! its own keys from the exporter rather than borrowing the ones MLS uses for its own +//! messages. +//! +//! # What this module does not do +//! +//! It does not talk to a host. Commits, welcomes and key packages are returned to the +//! caller as bytes to send however it likes — the design's fork risk (a host showing +//! different members different commit sequences) is addressed by anchoring epoch +//! authenticators in the room's witnessed DID log, which is a separate concern from +//! producing them. [`RoomGroup::epoch_authenticator`] is what gets anchored. + +use openmls::prelude::*; +use openmls_basic_credential::SignatureKeyPair; +use openmls_rust_crypto::OpenMlsRustCrypto; +use openmls_traits::OpenMlsProvider; +use tls_codec::{Deserialize as _, Serialize as _}; + +use crate::VtcError; + +/// The MLS ciphersuite every room uses. +/// +/// One ciphersuite, not a negotiation. A room whose members disagree about the ciphersuite +/// is a room that cannot form, and offering a choice here would mean carrying the weakest +/// option a peer might pick. +pub const ROOM_CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519; + +/// Exporter label for a room's record-storage key. +/// +/// Domain-separated so that a key derived for record sealing can never collide with one +/// derived for another purpose from the same group. A new purpose gets a new label, never a +/// parameter on this one. +const STORAGE_KEY_LABEL: &str = "openvtc/room/storage/v1"; + +/// Bytes of the derived storage key — 32, for a ChaCha20-Poly1305 or AES-256 key. +pub const STORAGE_KEY_LEN: usize = 32; + +/// A member's MLS identity: their signature keypair and the credential naming them. +/// +/// The credential is a basic one carrying the member's DID. In the finished design the +/// leaf's credential is the room VMC; a basic credential carrying the same identifier is +/// the interim, and the swap is confined to this struct. +pub struct RoomIdentity { + signer: SignatureKeyPair, + credential: CredentialWithKey, +} + +impl RoomIdentity { + /// Create an identity for `member_did`. + pub fn new(member_did: &str, provider: &impl OpenMlsProvider) -> Result { + let signer = SignatureKeyPair::new(ROOM_CIPHERSUITE.signature_algorithm()) + .map_err(|e| VtcError::Signing(format!("generate MLS signature key: {e:?}")))?; + signer + .store(provider.storage()) + .map_err(|e| VtcError::Signing(format!("store MLS signature key: {e:?}")))?; + + let credential = Credential::new(CredentialType::Basic, member_did.as_bytes().to_vec()); + Ok(Self { + credential: CredentialWithKey { + credential, + signature_key: signer.public().into(), + }, + signer, + }) + } + + /// A key package this member can be added to a room with. + /// + /// Published to whoever is inviting them — **over the invitation channel, never through + /// the host**. A host that collected key packages would learn who is being invited to + /// what, which un-blinds a sealed room at the door. + pub fn key_package(&self, provider: &impl OpenMlsProvider) -> Result { + KeyPackage::builder() + .build( + ROOM_CIPHERSUITE, + provider, + &self.signer, + self.credential.clone(), + ) + .map(|b| b.key_package().clone()) + .map_err(|e| VtcError::Signing(format!("build MLS key package: {e:?}"))) + } +} + +/// One member's view of a room's MLS group. +pub struct RoomGroup { + group: MlsGroup, + identity: RoomIdentity, + provider: OpenMlsRustCrypto, +} + +/// What a membership change produces. +/// +/// The caller sends `commit` to every existing member and `welcome` to whoever was just +/// added. Both are opaque bytes here: this module produces them and takes no view on how +/// they travel. +pub struct MembershipChange { + /// The commit, for members already in the group. + pub commit: Vec, + /// The welcome, for members just added. `None` on a removal. + pub welcome: Option>, + /// The epoch the group is in after merging. + pub epoch: u64, +} + +impl RoomGroup { + /// Create a room's group. The creator is its first member and its owner. + pub fn create(member_did: &str) -> Result { + let provider = OpenMlsRustCrypto::default(); + let identity = RoomIdentity::new(member_did, &provider)?; + + let config = MlsGroupCreateConfig::builder() + .ciphersuite(ROOM_CIPHERSUITE) + .use_ratchet_tree_extension(true) + .build(); + + let group = MlsGroup::new( + &provider, + &identity.signer, + &config, + identity.credential.clone(), + ) + .map_err(|e| VtcError::Signing(format!("create MLS group: {e:?}")))?; + + Ok(Self { + group, + identity, + provider, + }) + } + + /// Join a room from the welcome its owner sent. + pub fn join(member_did: &str, welcome: &[u8]) -> Result { + let provider = OpenMlsRustCrypto::default(); + let identity = RoomIdentity::new(member_did, &provider)?; + Self::join_with(identity, provider, welcome) + } + + /// Join using an identity whose key package the inviter already holds. + /// + /// The ordinary path: a member publishes a key package, is added, and joins with the + /// *same* identity — [`RoomGroup::join`] mints a fresh one, which only works if the + /// inviter used that identity's key package. + pub fn join_with( + identity: RoomIdentity, + provider: OpenMlsRustCrypto, + welcome: &[u8], + ) -> Result { + let msg = MlsMessageIn::tls_deserialize_exact(welcome) + .map_err(|e| VtcError::Signing(format!("parse welcome: {e:?}")))?; + let welcome = match msg.extract() { + MlsMessageBodyIn::Welcome(w) => w, + _ => { + return Err(VtcError::Signing( + "expected a Welcome message, got another MLS body".into(), + )); + } + }; + + let config = MlsGroupJoinConfig::builder() + .use_ratchet_tree_extension(true) + .build(); + let staged = StagedWelcome::new_from_welcome(&provider, &config, welcome, None) + .map_err(|e| VtcError::Signing(format!("stage welcome: {e:?}")))?; + let group = staged + .into_group(&provider) + .map_err(|e| VtcError::Signing(format!("join group from welcome: {e:?}")))?; + + Ok(Self { + group, + identity, + provider, + }) + } + + /// Add a member and commit. + /// + /// Only the owner should call this — the design restricts epoch minting to `admin` + /// precisely because a group where any key-holder can commit is a group where any + /// member can evict any other. MLS itself does not enforce that; the room's authority + /// credentials do, and the host checks them before accepting the epoch advance. + pub fn add_member(&mut self, key_package: KeyPackage) -> Result { + let (commit, welcome, _) = self + .group + .add_members(&self.provider, &self.identity.signer, &[key_package]) + .map_err(|e| VtcError::Signing(format!("add member: {e:?}")))?; + + self.group + .merge_pending_commit(&self.provider) + .map_err(|e| VtcError::Signing(format!("merge add commit: {e:?}")))?; + + Ok(MembershipChange { + commit: commit + .tls_serialize_detached() + .map_err(|e| VtcError::Signing(format!("serialise commit: {e:?}")))?, + welcome: Some( + welcome + .tls_serialize_detached() + .map_err(|e| VtcError::Signing(format!("serialise welcome: {e:?}")))?, + ), + epoch: self.group.epoch().as_u64(), + }) + } + + /// Remove a member and commit — the mechanism of removal. + /// + /// **Forward-only, and worth being plain about.** The removed member keeps whatever they + /// could already read; they held the plaintext. What they lose is everything sealed + /// under the new epoch. An interface that implies otherwise has mis-stated the + /// guarantee. + pub fn remove_member(&mut self, index: LeafNodeIndex) -> Result { + let (commit, _, _) = self + .group + .remove_members(&self.provider, &self.identity.signer, &[index]) + .map_err(|e| VtcError::Signing(format!("remove member: {e:?}")))?; + + self.group + .merge_pending_commit(&self.provider) + .map_err(|e| VtcError::Signing(format!("merge remove commit: {e:?}")))?; + + Ok(MembershipChange { + commit: commit + .tls_serialize_detached() + .map_err(|e| VtcError::Signing(format!("serialise commit: {e:?}")))?, + welcome: None, + epoch: self.group.epoch().as_u64(), + }) + } + + /// Apply a commit produced by another member. + pub fn apply_commit(&mut self, commit: &[u8]) -> Result { + let msg = MlsMessageIn::tls_deserialize_exact(commit) + .map_err(|e| VtcError::Signing(format!("parse commit: {e:?}")))?; + let protocol_message: ProtocolMessage = msg + .try_into_protocol_message() + .map_err(|e| VtcError::Signing(format!("not a protocol message: {e:?}")))?; + + let processed = self + .group + .process_message(&self.provider, protocol_message) + .map_err(|e| VtcError::Signing(format!("process commit: {e:?}")))?; + + match processed.into_content() { + ProcessedMessageContent::StagedCommitMessage(staged) => { + self.group + .merge_staged_commit(&self.provider, *staged) + .map_err(|e| VtcError::Signing(format!("merge staged commit: {e:?}")))?; + Ok(self.group.epoch().as_u64()) + } + _ => Err(VtcError::Signing( + "expected a commit, got another message type".into(), + )), + } + } + + /// The room's current epoch. + /// + /// This is the number the host stores so it can serve the right ciphertext. The host + /// learns the number and never the key. + pub fn epoch(&self) -> u64 { + self.group.epoch().as_u64() + } + + /// The key records in this epoch are sealed with. + /// + /// Derived from the MLS exporter under a room-specific label rather than borrowed from + /// the group's own message keys — so a change to how records are sealed cannot weaken + /// the group's messaging, and vice versa. + pub fn storage_key(&self) -> Result<[u8; STORAGE_KEY_LEN], VtcError> { + let secret = self + .group + .export_secret( + self.provider.crypto(), + STORAGE_KEY_LABEL, + &[], + STORAGE_KEY_LEN, + ) + .map_err(|e| VtcError::Signing(format!("export storage key: {e:?}")))?; + let mut key = [0u8; STORAGE_KEY_LEN]; + key.copy_from_slice(&secret); + Ok(key) + } + + /// The epoch authenticator — what gets anchored in the room's witnessed DID log. + /// + /// A host acting as the Delivery Service can attempt to **fork** a group: show one + /// member one commit sequence and another member a different one, so each believes it + /// is in the room. Members cannot detect that by comparing through the host, because the + /// host is what they would be comparing through. + /// + /// Anchoring this value where the host cannot forge it — the room's witnessed log — + /// gives every member a reference to check their own against. Detection latency is the + /// anchoring cadence, which is why the design makes that a room parameter rather than a + /// constant. + pub fn epoch_authenticator(&self) -> Vec { + self.group.epoch_authenticator().as_slice().to_vec() + } + + /// How many members the group has. + pub fn member_count(&self) -> usize { + self.group.members().count() + } + + /// The leaf index of the member whose credential identity is `member_did`. + pub fn leaf_of(&self, member_did: &str) -> Option { + self.group.members().find_map(|m| { + (m.credential.serialized_content() == member_did.as_bytes()).then_some(m.index) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_creator_forms_a_group_of_one() { + let room = RoomGroup::create("did:key:zAlice").expect("create"); + assert_eq!(room.member_count(), 1); + assert_eq!(room.epoch(), 0, "a fresh group starts at epoch 0"); + } + + #[test] + fn a_storage_key_is_derived_and_is_stable_within_an_epoch() { + let room = RoomGroup::create("did:key:zAlice").expect("create"); + let a = room.storage_key().expect("export"); + let b = room.storage_key().expect("export again"); + assert_eq!(a, b, "the same epoch must derive the same key"); + assert_ne!(a, [0u8; STORAGE_KEY_LEN], "and it must not be zeroes"); + } + + /// The whole point of an epoch: two members reach the same key without it ever + /// crossing the host. + #[test] + fn an_added_member_derives_the_same_storage_key() { + let mut alice = RoomGroup::create("did:key:zAlice").expect("alice"); + + let bob_provider = OpenMlsRustCrypto::default(); + let bob_identity = RoomIdentity::new("did:key:zBob", &bob_provider).expect("bob identity"); + let bob_kp = bob_identity.key_package(&bob_provider).expect("bob kp"); + + let change = alice.add_member(bob_kp).expect("add bob"); + let welcome = change.welcome.expect("an add produces a welcome"); + + let bob = RoomGroup::join_with(bob_identity, bob_provider, &welcome).expect("bob joins"); + + assert_eq!(alice.member_count(), 2); + assert_eq!( + alice.storage_key().unwrap(), + bob.storage_key().unwrap(), + "both members must derive the same storage key, without the host seeing it" + ); + assert_eq!(alice.epoch(), bob.epoch()); + } + + /// Removal is forward-only, and this is what "forward" means mechanically: the key + /// changes, so nothing sealed afterwards is reachable with the old one. + #[test] + fn removing_a_member_changes_the_storage_key() { + let mut alice = RoomGroup::create("did:key:zAlice").expect("alice"); + + let bob_provider = OpenMlsRustCrypto::default(); + let bob_identity = RoomIdentity::new("did:key:zBob", &bob_provider).expect("bob identity"); + let bob_kp = bob_identity.key_package(&bob_provider).expect("bob kp"); + let change = alice.add_member(bob_kp).expect("add bob"); + let bob = RoomGroup::join_with( + bob_identity, + bob_provider, + &change.welcome.expect("welcome"), + ) + .expect("bob joins"); + + let shared = alice.storage_key().unwrap(); + assert_eq!(shared, bob.storage_key().unwrap()); + + let bob_leaf = alice.leaf_of("did:key:zBob").expect("bob is a member"); + alice.remove_member(bob_leaf).expect("remove bob"); + + let after = alice.storage_key().unwrap(); + assert_ne!( + shared, after, + "after removal the key must differ, or removal removes nothing" + ); + assert_ne!( + bob.storage_key().unwrap(), + after, + "and the removed member must not be able to derive the new one" + ); + } + + /// Every member's view of an epoch must agree, or the anchor cannot detect a fork. + #[test] + fn members_in_the_same_epoch_share_an_epoch_authenticator() { + let mut alice = RoomGroup::create("did:key:zAlice").expect("alice"); + let bob_provider = OpenMlsRustCrypto::default(); + let bob_identity = RoomIdentity::new("did:key:zBob", &bob_provider).expect("bob identity"); + let bob_kp = bob_identity.key_package(&bob_provider).expect("bob kp"); + let change = alice.add_member(bob_kp).expect("add bob"); + let bob = RoomGroup::join_with( + bob_identity, + bob_provider, + &change.welcome.expect("welcome"), + ) + .expect("bob joins"); + + assert_eq!( + alice.epoch_authenticator(), + bob.epoch_authenticator(), + "a member whose authenticator differs from the anchored one has been forked" + ); + assert!(!alice.epoch_authenticator().is_empty()); + } + + #[test] + fn an_epoch_advances_on_every_membership_change() { + let mut alice = RoomGroup::create("did:key:zAlice").expect("alice"); + let start = alice.epoch(); + + let p = OpenMlsRustCrypto::default(); + let id = RoomIdentity::new("did:key:zBob", &p).expect("identity"); + alice + .add_member(id.key_package(&p).expect("kp")) + .expect("add"); + + assert!( + alice.epoch() > start, + "a membership change must move the epoch, or the host serves stale ciphertext" + ); + } +} diff --git a/vtc-client/src/rooms/mod.rs b/vtc-client/src/rooms/mod.rs new file mode 100644 index 000000000..9be92e467 --- /dev/null +++ b/vtc-client/src/rooms/mod.rs @@ -0,0 +1,487 @@ +//! Client surface for the `rooms/*` Trust Tasks. +//! +//! # What is different about these calls +//! +//! Every other method on [`crate::VtcClient`] carries an operator token: the community +//! knows who you are, and its ACL decides what you may do. **A room call carries no token +//! at all.** It carries a presentation — a membership credential and the authority chain +//! the room itself issued — and the host decides from that alone. +//! +//! That is not a convenience. It is what makes a room portable: a host that consulted its +//! own records to authorize a room operation would become part of that room's membership, +//! and the room could no longer move to a different host without reissuing credentials. +//! So [`RoomSession`] deliberately holds no session, and none of these methods reads +//! `self.token`. +//! +//! # Holding the chain +//! +//! A [`RoomSession`] carries the whole authority chain, **leaf first**, and sends all of it +//! on every call. The host never fetches a link it was not given — resolving one over the +//! network would make verification depend on availability, turn an identifier into a +//! request the host can be induced to make against an address the *caller* chooses, and +//! signal credential use to whoever hosts that identifier. +//! +//! # Agents hold less than their humans +//! +//! The case the design exists for: a member holds `read`/`write`, and equips their agent +//! with a chain one link longer whose leaf confers only `read`, expires in hours, and is +//! bound to the agent. The agent's `RoomSession` is built exactly like the member's — the +//! difference is entirely in the credentials it was handed, which is the point. + +#[cfg(feature = "mls")] +pub mod mls; +#[cfg(feature = "mls")] +pub mod sealed; + +use serde::{Deserialize, Serialize}; + +use crate::{VtcClient, VtcError}; + +/// `rooms/create/0.1`. +pub const ROOMS_CREATE_TYPE: &str = "https://trusttasks.org/spec/rooms/create/0.1"; +/// `rooms/records/put/0.1`. +pub const ROOMS_RECORDS_PUT_TYPE: &str = "https://trusttasks.org/spec/rooms/records/put/0.1"; +/// `rooms/records/get/0.1`. +pub const ROOMS_RECORDS_GET_TYPE: &str = "https://trusttasks.org/spec/rooms/records/get/0.1"; +/// `rooms/records/list/0.1`. +pub const ROOMS_RECORDS_LIST_TYPE: &str = "https://trusttasks.org/spec/rooms/records/list/0.1"; +/// `rooms/epoch/mint/0.1`. +pub const ROOMS_EPOCH_MINT_TYPE: &str = "https://trusttasks.org/spec/rooms/epoch/mint/0.1"; + +/// Maximum links a host will accept in one chain. +/// +/// Mirrors the host's own ceiling so a caller finds out here rather than over the wire. +/// Verification is linear in chain length and runs on every operation, so an unbounded +/// chain is a denial-of-service surface against the host. +pub const MAX_CHAIN_DEPTH: usize = 8; + +/// How much of a room its host can see. Fixed at creation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Visibility { + /// Cleartext, searchable by the host. + Open, + /// Content sealed; the acting member still disclosed. + Attributed, + /// Content sealed; membership presented in zero knowledge. + Private, +} + +/// The credentials a caller presents to act on a room. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthorityPresentation { + pub membership: String, + /// Leaf first; the last element is the credential the room issued. + pub authority: Vec, + /// Required on a `private` room. + #[serde(skip_serializing_if = "Option::is_none")] + pub subject_binding: Option, +} + +/// Sealed record content. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SealedContent { + pub ciphertext: String, + pub nonce: String, + pub epoch: u32, +} + +/// Cleartext record content. `open` rooms only. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CleartextContent { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub body: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +/// What a `put` returns. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PutRecordResponse { + pub key: String, + pub version: u64, + #[serde(default)] + pub epoch: Option, +} + +/// What a `list` returns — metadata, never bodies. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListRecordsResponse { + pub records: Vec, +} + +/// What minting an epoch returns. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MintEpochResponse { + pub room_id: String, + pub epoch: u32, +} + +/// A caller's standing in one room. +/// +/// Holds the credentials, not a session. Build one per room per identity — a member's and +/// their agent's are different sessions against the same room, differing only in the chain +/// they carry. +#[derive(Debug, Clone)] +pub struct RoomSession { + room_id: String, + presentation: AuthorityPresentation, +} + +impl RoomSession { + /// Build a session from a membership credential and an authority chain. + /// + /// `authority` is **leaf first**: the credential being relied on comes first, and the + /// one the room issued comes last. Rejected here if it is empty or deeper than + /// [`MAX_CHAIN_DEPTH`], so a caller learns locally rather than from a rejected request. + pub fn new( + room_id: impl Into, + membership: impl Into, + authority: Vec, + ) -> Result { + if authority.is_empty() { + return Err(VtcError::Url( + "an authority chain is required: a room operation is authorized by the chain, \ + never by a session" + .into(), + )); + } + if authority.len() > MAX_CHAIN_DEPTH { + return Err(VtcError::Url(format!( + "authority chain is {} deep, exceeding the maximum of {MAX_CHAIN_DEPTH}", + authority.len() + ))); + } + Ok(Self { + room_id: room_id.into(), + presentation: AuthorityPresentation { + membership: membership.into(), + authority, + subject_binding: None, + }, + }) + } + + /// Attach the same-subject proof a `private` room requires. + /// + /// Without it a private-room call is refused: two parties could otherwise pool + /// credentials — one contributing membership, the other authority — and present as a + /// single party holding both. + pub fn with_subject_binding(mut self, binding: impl Into) -> Self { + self.presentation.subject_binding = Some(binding.into()); + self + } + + /// The room this session acts on. + pub fn room_id(&self) -> &str { + &self.room_id + } + + /// How many links the chain carries. Depth 1 is a grant straight from the room; + /// depth 2 is typically a member's agent. + pub fn chain_depth(&self) -> usize { + self.presentation.authority.len() + } +} + +impl VtcClient { + /// Register a room with this host. + /// + /// The caller brings `room_id`: a room identified by something its host chose could not + /// move to another host without changing identity. + pub async fn create_room( + &self, + room_id: &str, + owner_did: &str, + visibility: Visibility, + retention_days: Option, + signer_did: &str, + private_key_multibase: &str, + ) -> Result { + let payload = serde_json::json!({ + "roomId": room_id, + "ownerDid": owner_did, + "visibility": visibility, + "retentionDays": retention_days, + }); + self.room_task( + ROOMS_CREATE_TYPE, + payload, + signer_did, + private_key_multibase, + ) + .await + } + + /// Write a record. + /// + /// Exactly one of `sealed` / `cleartext` — the host refuses the other shape for the + /// room's tier, so passing both or neither is a request it cannot honour. + /// + /// `expected_version` is an optional precondition: `Some(0)` means create-only, and + /// `Some(n)` requires the stored record to be at version `n`. A mismatch comes back + /// carrying the current version, so a caller does not have to re-read to learn what it + /// lost to. + #[allow(clippy::too_many_arguments)] + pub async fn put_record( + &self, + session: &RoomSession, + key: &str, + sealed: Option, + cleartext: Option, + expected_version: Option, + signer_did: &str, + private_key_multibase: &str, + ) -> Result { + let mut payload = serde_json::json!({ + "roomId": session.room_id, + "key": key, + "presentation": session.presentation, + }); + if let Some(s) = sealed { + payload["sealed"] = + serde_json::to_value(s).map_err(|e| VtcError::Url(e.to_string()))?; + } + if let Some(c) = cleartext { + payload["cleartext"] = + serde_json::to_value(c).map_err(|e| VtcError::Url(e.to_string()))?; + } + if let Some(v) = expected_version { + payload["expectedVersion"] = serde_json::json!(v); + } + let value = self + .room_task( + ROOMS_RECORDS_PUT_TYPE, + payload, + signer_did, + private_key_multibase, + ) + .await?; + serde_json::from_value(value).map_err(|e| VtcError::Http { + status: 200, + body: format!("put response is not a PutRecordResponse: {e}"), + }) + } + + /// Read one record. + /// + /// Presents exactly as a write does, and needs no session — which is the point on a + /// sealed room: authorizing reads by session would hand the host a member identifier on + /// every access, and a period of those reconstructs the membership the tier withholds. + pub async fn get_record( + &self, + session: &RoomSession, + key: &str, + signer_did: &str, + private_key_multibase: &str, + ) -> Result { + let payload = serde_json::json!({ + "roomId": session.room_id, + "key": key, + "presentation": session.presentation, + }); + self.room_task( + ROOMS_RECORDS_GET_TYPE, + payload, + signer_did, + private_key_multibase, + ) + .await + } + + /// List record metadata. + /// + /// Never returns bodies — fetch the handful that matter with [`VtcClient::get_record`]. + /// `since_version` is the incremental-sync watermark, and the response **includes + /// tombstones**: a caller that never saw a retraction would resurrect the record on its + /// next full rebuild. + pub async fn list_records( + &self, + session: &RoomSession, + prefix: Option<&str>, + since_version: Option, + signer_did: &str, + private_key_multibase: &str, + ) -> Result { + let mut payload = serde_json::json!({ + "roomId": session.room_id, + "presentation": session.presentation, + }); + if let Some(p) = prefix { + payload["prefix"] = serde_json::json!(p); + } + if let Some(v) = since_version { + payload["sinceVersion"] = serde_json::json!(v); + } + let value = self + .room_task( + ROOMS_RECORDS_LIST_TYPE, + payload, + signer_did, + private_key_multibase, + ) + .await?; + serde_json::from_value(value).map_err(|e| VtcError::Http { + status: 200, + body: format!("list response is not a ListRecordsResponse: {e}"), + }) + } + + /// Advance the room's key epoch — how a member is removed. + /// + /// Requires a chain conferring `admin`. `epoch` must be exactly one greater than the + /// current one. The host records the number and never learns the key: distributing it + /// to the remaining members happens out of its sight. + pub async fn mint_epoch( + &self, + session: &RoomSession, + epoch: u32, + reason: Option<&str>, + signer_did: &str, + private_key_multibase: &str, + ) -> Result { + let mut payload = serde_json::json!({ + "roomId": session.room_id, + "epoch": epoch, + "presentation": session.presentation, + }); + if let Some(r) = reason { + payload["reason"] = serde_json::json!(r); + } + let value = self + .room_task( + ROOMS_EPOCH_MINT_TYPE, + payload, + signer_did, + private_key_multibase, + ) + .await?; + serde_json::from_value(value).map_err(|e| VtcError::Http { + status: 200, + body: format!("mint response is not a MintEpochResponse: {e}"), + }) + } + + /// Send one `rooms/*` document and return its response payload. + /// + /// The one place a room call is made, so the no-token property is visible in a single + /// function rather than repeated across five: this builds a signed document and posts + /// it, and never touches `self.token`. + async fn room_task( + &self, + type_uri: &str, + payload: serde_json::Value, + signer_did: &str, + private_key_multibase: &str, + ) -> Result { + let doc = vta_sdk::trust_task_sign::build_signed( + type_uri, + payload, + signer_did, + private_key_multibase, + &self.vtc_did, + ) + .await + .map_err(|e| VtcError::Signing(e.to_string()))?; + + let resp = self + .http + .post(format!("{}/trust-tasks", self.base_url)) + .header("content-type", "application/json") + .body(doc) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(VtcError::Http { status, body }); + } + + let text = resp.text().await?; + let response_doc: trust_tasks_rs::TrustTask = + serde_json::from_str(&text).map_err(|e| VtcError::Http { + status: 200, + body: format!("unexpected room response (not a Trust Task document): {e}: {text}"), + })?; + Ok(response_doc.payload) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_session_requires_a_chain() { + let err = RoomSession::new("did:key:zRoom", "vmc", vec![]).unwrap_err(); + assert!( + format!("{err}").contains("authorized by the chain"), + "a room session without a chain has nothing to present: {err}" + ); + } + + #[test] + fn a_session_refuses_a_chain_past_the_ceiling() { + let chain: Vec = (0..=MAX_CHAIN_DEPTH).map(|i| format!("vac-{i}")).collect(); + let err = RoomSession::new("did:key:zRoom", "vmc", chain).unwrap_err(); + assert!(format!("{err}").contains("exceeding the maximum"), "{err}"); + } + + /// The agent case, at the level the client models it: same construction, one more link, + /// and the narrowing lives in the credentials rather than in any flag here. + #[test] + fn a_members_session_and_their_agents_differ_only_in_the_chain() { + let member = RoomSession::new("did:key:zRoom", "vmc", vec!["vac-member".into()]) + .expect("member session"); + let agent = RoomSession::new( + "did:key:zRoom", + "vmc", + vec!["vac-agent".into(), "vac-member".into()], + ) + .expect("agent session"); + + assert_eq!(member.room_id(), agent.room_id()); + assert_eq!(member.chain_depth(), 1, "a grant straight from the room"); + assert_eq!(agent.chain_depth(), 2, "one attenuation deeper"); + } + + #[test] + fn a_subject_binding_is_attached_only_when_asked_for() { + let s = RoomSession::new("did:key:zRoom", "vmc", vec!["vac".into()]).unwrap(); + assert!(s.presentation.subject_binding.is_none()); + let s = s.with_subject_binding("proof"); + assert_eq!(s.presentation.subject_binding.as_deref(), Some("proof")); + } + + /// The wire shape a host reads. `camelCase`, and the binding omitted rather than null + /// when absent — a host distinguishes absent from present-but-empty. + #[test] + fn a_presentation_serialises_camel_case_and_omits_an_absent_binding() { + let s = RoomSession::new("did:key:zRoom", "vmc", vec!["a".into(), "b".into()]).unwrap(); + let v = serde_json::to_value(&s.presentation).unwrap(); + assert_eq!(v["membership"], "vmc"); + assert_eq!(v["authority"][0], "a", "leaf first"); + assert!(v.get("subjectBinding").is_none()); + + let s = s.with_subject_binding("bind"); + let v = serde_json::to_value(&s.presentation).unwrap(); + assert_eq!(v["subjectBinding"], "bind"); + } + + #[test] + fn visibility_serialises_lowercase_as_the_host_expects() { + assert_eq!( + serde_json::to_value(Visibility::Attributed).unwrap(), + serde_json::json!("attributed") + ); + } +} diff --git a/vtc-client/src/rooms/sealed.rs b/vtc-client/src/rooms/sealed.rs new file mode 100644 index 000000000..f644e2889 --- /dev/null +++ b/vtc-client/src/rooms/sealed.rs @@ -0,0 +1,287 @@ +//! Sealing and opening records on the `attributed` and `private` tiers. +//! +//! This is where the MLS group layer and the room's task surface meet: a record is sealed +//! under the key [`super::mls::RoomGroup::storage_key`] derives for the current epoch, and +//! the host stores ciphertext it cannot read. +//! +//! # The binding is the interesting part +//! +//! Each record's AEAD associated data commits to `roomId | key | version | epoch`. A host +//! that relocates a sealed record — to another key, another version, another epoch, or +//! another room — produces an authentication failure rather than a readable record. It holds +//! every byte and still cannot move one, which is the property that makes an untrusted host +//! tolerable. +//! +//! This is the same class of defence `vti_common::store::encryption` already applies to +//! keyspace values by binding them to their `(keyspace, key)` location. Repeating it here is +//! deliberate: the reasoning was paid for once and should not have to be rediscovered. +//! +//! # Version is bound before it is known +//! +//! A record's version is assigned by the host, from the room's counter — so a writer does +//! not know it at sealing time. [`SealedRoom::seal_record`] therefore takes the version the +//! writer *intends*, and a caller that lets the host assign a different one will find the +//! record does not open. That is the correct failure: silently accepting whatever version +//! came back would mean the binding commits to nothing. +//! +//! The practical shape is create-only writes (`expected_version: Some(0)`) or a read of the +//! current version before a rewrite — both of which the task surface already supports. +//! +//! # What is deliberately not sealed +//! +//! The record's key, version and epoch travel in the clear: the host needs them to store and +//! serve the right ciphertext. Keys must therefore be **opaque** on these tiers — a key +//! reading `decision/acquire-northwind` defeats the encryption sitting beside it. +//! [`SealedRoom::opaque_key`] mints one. + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; + +use super::mls::RoomGroup; +use super::{RoomSession, SealedContent}; +use crate::VtcError; + +/// A room whose records are sealed, and the group state that seals them. +/// +/// Pairs a [`RoomSession`] — the credentials the host authorizes against — with a +/// [`RoomGroup`] — the key material the host never sees. Those are two different things and +/// this type is the only place they meet: authorization travels to the host, keys do not. +pub struct SealedRoom { + session: RoomSession, + group: RoomGroup, +} + +impl SealedRoom { + /// Pair a session with its group. + pub fn new(session: RoomSession, group: RoomGroup) -> Self { + Self { session, group } + } + + /// The credentials to present. + pub fn session(&self) -> &RoomSession { + &self.session + } + + /// The group, for membership changes and epoch anchoring. + pub fn group(&self) -> &RoomGroup { + &self.group + } + + /// Mutable access, for committing a membership change. + pub fn group_mut(&mut self) -> &mut RoomGroup { + &mut self.group + } + + /// The room's current epoch, as the host records it. + /// + /// MLS epochs start at 0 and the room's start at 1, so this is the MLS epoch plus one. + /// Kept in one place rather than at each call site: an off-by-one here would seal + /// records under an epoch the host rejects, and the failure would look like a key + /// problem rather than an arithmetic one. + pub fn room_epoch(&self) -> u32 { + (self.group.epoch() + 1) as u32 + } + + /// A random, opaque record key. + /// + /// Sealed tiers require these: a descriptive key is readable by the host and defeats the + /// encryption beside it. Structured naming belongs *inside* the sealed body. + pub fn opaque_key() -> String { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).expect("OS randomness unavailable"); + B64.encode(bytes) + } + + /// Seal `plaintext` for `key` at `version`. + /// + /// `version` is the version the writer intends the record to take — see the module docs + /// on why it is bound before the host assigns it. + pub fn seal_record( + &self, + key: &str, + version: u64, + plaintext: &[u8], + ) -> Result { + let epoch = self.room_epoch(); + let storage_key = self.group.storage_key()?; + let aad = associated_data(self.session.room_id(), key, version, epoch); + + let cipher = ChaCha20Poly1305::new(Key::from_slice(&storage_key)); + let mut nonce_bytes = [0u8; 12]; + getrandom::fill(&mut nonce_bytes).expect("OS randomness unavailable"); + + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce_bytes), + Payload { + msg: plaintext, + aad: &aad, + }, + ) + .map_err(|e| VtcError::Signing(format!("seal record: {e}")))?; + + Ok(SealedContent { + ciphertext: B64.encode(ciphertext), + nonce: B64.encode(nonce_bytes), + epoch, + }) + } + + /// Open a record the host returned. + /// + /// Fails rather than returning wrong bytes if the record was relocated, if the epoch was + /// relabelled, or if the key for that epoch is not the one this member holds. + pub fn open_record( + &self, + key: &str, + version: u64, + sealed: &SealedContent, + ) -> Result, VtcError> { + let storage_key = self.group.storage_key()?; + let aad = associated_data(self.session.room_id(), key, version, sealed.epoch); + + let ciphertext = B64 + .decode(&sealed.ciphertext) + .map_err(|e| VtcError::Signing(format!("decode ciphertext: {e}")))?; + let nonce = B64 + .decode(&sealed.nonce) + .map_err(|e| VtcError::Signing(format!("decode nonce: {e}")))?; + if nonce.len() != 12 { + return Err(VtcError::Signing(format!( + "nonce is {} bytes, expected 12", + nonce.len() + ))); + } + + let cipher = ChaCha20Poly1305::new(Key::from_slice(&storage_key)); + cipher + .decrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &ciphertext, + aad: &aad, + }, + ) + .map_err(|_| { + VtcError::Signing( + "record did not open: it was sealed under a different key, epoch, or \ + location — a relocated record fails here rather than decrypting wrongly" + .into(), + ) + }) + } + + /// The value to anchor in the room's witnessed DID log for this epoch. + /// + /// A host that forks the group shows different members different commit sequences. + /// Comparing this against the anchored value is how a member finds out. + pub fn epoch_anchor(&self) -> Vec { + self.group.epoch_authenticator() + } +} + +/// `roomId | key | version | epoch`, the associated data a record is bound to. +/// +/// Length-prefix-free but unambiguous by construction: `|` cannot appear in a base64url key +/// or in the decimal fields, and `roomId` is a DID. If any of those ever stops holding, this +/// needs length prefixes — the failure mode otherwise is two different records producing the +/// same associated data, which is exactly what the binding exists to prevent. +fn associated_data(room_id: &str, key: &str, version: u64, epoch: u32) -> Vec { + format!("{room_id}|{key}|{version}|{epoch}").into_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn room(did: &str) -> SealedRoom { + let session = RoomSession::new(did, "vmc", vec!["vac".into()]).expect("session"); + let group = RoomGroup::create("did:key:zAlice").expect("group"); + SealedRoom::new(session, group) + } + + #[test] + fn a_record_round_trips_under_the_group_key() { + let r = room("did:webvh:zRoom"); + let sealed = r.seal_record("k1", 1, b"a decision").expect("seal"); + let opened = r.open_record("k1", 1, &sealed).expect("open"); + assert_eq!(opened, b"a decision"); + } + + /// The property that makes an untrusted host tolerable: it holds every byte and still + /// cannot move one. + #[test] + fn a_relocated_record_does_not_open() { + let r = room("did:webvh:zRoom"); + let sealed = r.seal_record("k1", 1, b"a decision").expect("seal"); + + assert!( + r.open_record("k2", 1, &sealed).is_err(), + "moving a record to another key must fail" + ); + assert!( + r.open_record("k1", 2, &sealed).is_err(), + "moving it to another version must fail" + ); + + let mut relabelled = sealed.clone(); + relabelled.epoch += 1; + assert!( + r.open_record("k1", 1, &relabelled).is_err(), + "relabelling the epoch must fail authentication, not decrypt wrongly" + ); + + let other_room = RoomSession::new("did:webvh:zOther", "vmc", vec!["vac".into()]).unwrap(); + let moved = SealedRoom::new(other_room, RoomGroup::create("did:key:zAlice").unwrap()); + assert!( + moved.open_record("k1", 1, &sealed).is_err(), + "moving it to another room must fail" + ); + } + + #[test] + fn a_non_member_cannot_open_a_record() { + let r = room("did:webvh:zRoom"); + let sealed = r.seal_record("k1", 1, b"members only").expect("seal"); + + // A different group is a different key, however identical everything else looks. + let outsider = SealedRoom::new( + RoomSession::new("did:webvh:zRoom", "vmc", vec!["vac".into()]).unwrap(), + RoomGroup::create("did:key:zMallory").unwrap(), + ); + assert!(outsider.open_record("k1", 1, &sealed).is_err()); + } + + #[test] + fn the_room_epoch_is_the_mls_epoch_plus_one() { + let r = room("did:webvh:zRoom"); + assert_eq!(r.group().epoch(), 0, "MLS starts at 0"); + assert_eq!(r.room_epoch(), 1, "the room's first epoch is 1"); + } + + #[test] + fn opaque_keys_are_random_and_carry_no_meaning() { + let a = SealedRoom::opaque_key(); + let b = SealedRoom::opaque_key(); + assert_ne!(a, b); + assert!( + !a.contains('/'), + "url-safe, so it needs no escaping in a payload" + ); + } + + /// Sealing twice must not reuse a nonce, or the AEAD's guarantee is gone. + #[test] + fn sealing_the_same_plaintext_twice_uses_a_fresh_nonce() { + let r = room("did:webvh:zRoom"); + let a = r.seal_record("k1", 1, b"same").expect("seal"); + let b = r.seal_record("k1", 1, b"same").expect("seal again"); + assert_ne!(a.nonce, b.nonce, "a reused nonce breaks ChaCha20-Poly1305"); + assert_ne!(a.ciphertext, b.ciphertext); + // Both still open. + assert_eq!(r.open_record("k1", 1, &a).unwrap(), b"same"); + assert_eq!(r.open_record("k1", 1, &b).unwrap(), b"same"); + } +} diff --git a/vtc-service/Cargo.toml b/vtc-service/Cargo.toml index d99114d89..60df9e338 100644 --- a/vtc-service/Cargo.toml +++ b/vtc-service/Cargo.toml @@ -117,6 +117,8 @@ name = "vtc" path = "src/main.rs" [dependencies] +vti-rooms = { path = "../vti-rooms", version = "0.1" } +vti-rooms-dtg = { path = "../vti-rooms-dtg" } vti-common = { path = "../vti-common", version = "0.16", features = [ "passkey", # `setup` gates the shared dialoguer-driven secrets-prompt helper @@ -305,6 +307,9 @@ tempfile = { version = "3", optional = true } [dev-dependencies] trust-tasks-rs = { workspace = true } +# Signed room fixtures. Cargo unifies this feature with the normal dependency +# above, so the fixtures reach tests without shipping in the service binary. +vti-rooms-dtg = { path = "../vti-rooms-dtg", features = ["test-support"] } # Self-dep with `test-support` enabled so integration tests under # `tests/` can import `vtc_service::test_support` (`TestVtc`, `MockVtc`). vtc-service = { path = ".", features = ["test-support", "didcomm-harness"] } diff --git a/vtc-service/src/backup.rs b/vtc-service/src/backup.rs index 55aaf35f7..996f32d7d 100644 --- a/vtc-service/src/backup.rs +++ b/vtc-service/src/backup.rs @@ -361,6 +361,13 @@ fn backed_up_handle<'a>(state: &'a AppState, name: &str) -> Option<&'a KeyspaceH x if x == ENDORSEMENT_TYPES => &state.endorsement_types_ks, x if x == SCHEMAS => &state.schemas_ks, x if x == ENDORSEMENTS => &state.endorsements_ks, + // A room's records are backed up like any other community state. On a + // sealed tier they are ciphertext this service cannot read, and that is + // no reason to skip them: the host is trusted for availability + // (invariant I2), and losing the ciphertext loses the room just as + // completely as losing plaintext would. + x if x == ROOMS => &state.rooms_ks, + x if x == ROOM_RECORDS => &state.room_records_ks, x if x == INVITATIONS => &state.invitations_ks, x if x == CONSUMED_INVITATIONS => &state.consumed_invitations_ks, x if x == AUDIT => &state.audit_ks, @@ -592,6 +599,46 @@ fn derive_key( mod tests { use super::*; + /// `BACKED_UP` and `backed_up_handle` are two lists of the same thing, and nothing in + /// the type system makes them agree — the match ends in `_ => return None`, so a + /// keyspace added to one and not the other fails at *backup time* with + /// "no AppState handle for keyspace", which is a runtime error on the one operation an + /// operator runs when something has already gone wrong. + /// + /// That is exactly how `rooms` and `room_records` broke every backup test: added to the + /// partition, absent from the map, and no compile error anywhere. This pins the two + /// together the way the partition census pins the partition. + #[tokio::test] + async fn every_backed_up_keyspace_has_a_handle() { + let tv = crate::test_support::build_test_vtc().await; + let missing: Vec<&str> = keyspaces::BACKED_UP + .iter() + .filter(|name| backed_up_handle(&tv.state, name).is_none()) + .copied() + .collect(); + assert!( + missing.is_empty(), + "these keyspaces are in BACKED_UP but `backed_up_handle` does not map them, so \ + every export would fail at runtime: {missing:?}" + ); + } + + /// The other direction: a handle for a keyspace nothing backs up is dead code that + /// reads as coverage. + #[tokio::test] + async fn no_handle_maps_a_keyspace_that_is_not_backed_up() { + let tv = crate::test_support::build_test_vtc().await; + let stray: Vec<&str> = keyspaces::EXCLUDED_FROM_BACKUP + .iter() + .filter(|name| backed_up_handle(&tv.state, name).is_some()) + .copied() + .collect(); + assert!( + stray.is_empty(), + "`backed_up_handle` maps keyspaces excluded from backup: {stray:?}" + ); + } + fn sample_payload() -> BackupPayload { BackupPayload { config: BackupConfig { diff --git a/vtc-service/src/lib.rs b/vtc-service/src/lib.rs index 89e74033f..e3ec8c05f 100644 --- a/vtc-service/src/lib.rs +++ b/vtc-service/src/lib.rs @@ -41,6 +41,7 @@ pub mod policy; pub mod recognition; pub mod registry; pub mod relationships; +pub mod rooms; pub mod routes; pub mod routing; pub mod schemas; diff --git a/vtc-service/src/rooms/handlers.rs b/vtc-service/src/rooms/handlers.rs new file mode 100644 index 000000000..4a2b1dfd2 --- /dev/null +++ b/vtc-service/src/rooms/handlers.rs @@ -0,0 +1,679 @@ +//! Trust-Task handlers for the `rooms/*` family. +//! +//! These are deliberately thin. Every one does the same four things in the same order — +//! parse, load the room, authorize, act — and each of those lives somewhere else: +//! [`super::wire`] owns the shapes, [`super::storage`] owns the invariants, and +//! [`super::authz`] owns the decision. A handler that starts making its own storage or +//! authorization judgements is a handler that has drifted from the other four. +//! +//! # Authorization order +//! +//! The room is loaded first, then the presentation is authorized **against that room**. +//! That ordering matters: visibility is a property of the room, and the private-tier +//! subject-binding requirement cannot be checked without knowing the tier. Authorizing +//! before loading would mean either guessing the tier or checking it twice. +//! +//! # What a handler never does +//! +//! Reach for `state.acl_ks`, `state.members_ks`, or the caller's session. A room operation +//! is authorized by the chain the room issued and nothing else — invariant I5. There is no +//! `AuthClaims` parameter here, and its absence is the point rather than an omission. + +use serde_json::Value; +use trust_tasks_rs::TrustTask; + +use crate::server::AppState; +use crate::trust_tasks::helpers::{ + TrustTaskOutcome, app_error_to_reject, parse_payload, success_response, verify_trust_task_proof, +}; +use vti_rooms::authz::{self, Action}; +use vti_rooms::storage; +use vti_rooms::wire::{ + CreateRoomBody, CreateRoomResponse, GetRecordBody, ListRecordsBody, ListRecordsResponse, + MintEpochBody, MintEpochResponse, PutRecordBody, PutRecordResponse, +}; +use vti_rooms::{Record, RecordStatus, Room}; +use vti_rooms_dtg::{DataIntegrityKeys, DtgChainVerifier}; + +/// The DID that actually signed this request, and the verifier to judge its chain with. +/// +/// Two things a room operation needs and a session does not supply. The presenter comes +/// from the document's own `eddsa-jcs-2022` proof — not from any field in the payload — +/// because a presentation names what may be done, not who is doing it: unbound, it is a +/// bearer token that anyone observing it inherits. +async fn presenter_and_verifier( + state: &AppState, + doc: &TrustTask, +) -> Result<(String, DtgChainVerifier), vti_common::error::AppError> { + let presenter = verify_trust_task_proof(state, doc).await?; + // `without_zk`: this service has no zero-knowledge profile for a private room's subject + // binding, and the verifier refuses those rather than serving a pooling defence nobody + // checked. Swap for `with_zk` when the working group settles the profile. + Ok(( + presenter, + DtgChainVerifier::without_zk(Box::new(DataIntegrityKeys(state.trust_task_vm_resolver()))), + )) +} + +/// Seconds since the Unix epoch. +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Default retention when a creator does not state one. +/// +/// Ninety days after the epoch lapses. Long enough that a room is not lost to a quiet +/// month, short enough that an abandoned one does not accumulate forever. +const DEFAULT_RETENTION_DAYS: u32 = 90; + +/// `rooms/create/0.1`. +/// +/// The creator brings the room's identifier; this service does not assign one. A room +/// identified by something its host chose could not move to another host without changing +/// identity, and portability is the property the whole family rests on. +pub(crate) async fn handle_create(state: &AppState, doc: TrustTask) -> TrustTaskOutcome { + let req: CreateRoomBody = match parse_payload(&doc) { + Ok(r) => r, + Err(resp) => return resp, + }; + + let room = Room { + room_id: req.room_id.clone(), + owner_did: req.owner_did, + visibility: req.visibility, + epoch: 1, + next_version: 1, + retention_days: req.retention_days.unwrap_or(DEFAULT_RETENTION_DAYS), + created_at: now(), + updated_at: now(), + }; + + if let Err(e) = storage::create_room(&state.rooms_ks, &room).await { + return app_error_to_reject(&doc, &e); + } + + success_response( + &doc, + CreateRoomResponse { + room_id: req.room_id, + epoch: 1, + }, + ) +} + +/// `rooms/records/put/0.1`. +pub(crate) async fn handle_put_record(state: &AppState, doc: TrustTask) -> TrustTaskOutcome { + let req: PutRecordBody = match parse_payload(&doc) { + Ok(r) => r, + Err(resp) => return resp, + }; + + let room = match storage::get_room(&state.rooms_ks, &req.room_id).await { + Ok(r) => r, + Err(e) => return app_error_to_reject(&doc, &e), + }; + let (presenter, verifier) = match presenter_and_verifier(state, &doc).await { + Ok(p) => p, + Err(e) => return app_error_to_reject(&doc, &e), + }; + let authorized = match authz::authorize( + &room, + &req.presentation, + Action::Write, + &presenter, + &verifier, + ) + .await + { + Ok(a) => a, + Err(e) => return app_error_to_reject(&doc, &e), + }; + + // The author is recorded only where the tier discloses one. On a private room + // authorship lives inside the sealed body, and the storage layer refuses it here. + // + // It is the *verified* subject — who the chain says is acting — not the room's owner. + // Recording the owner would have credited every write to one person, which on the + // attributed tier is the whole of what the tier is for. + let author = room + .visibility + .discloses_actor() + .then(|| authorized.subject().to_string()); + + let record = Record { + key: req.key.clone(), + version: 0, // assigned by the store from the room's counter + epoch: req.sealed.as_ref().map(|s| s.epoch), + status: RecordStatus::Active, + sealed: req.sealed.as_ref().map(|s| s.ciphertext.clone()), + nonce: req.sealed.as_ref().map(|s| s.nonce.clone()), + cleartext: req + .cleartext + .as_ref() + .map(|c| serde_json::to_value(c).unwrap_or(Value::Null)), + author, + updated_at: 0, + }; + + match storage::put_record( + &state.rooms_ks, + &state.room_records_ks, + &req.room_id, + record, + req.expected_version, + now(), + ) + .await + { + Ok(stored) => success_response( + &doc, + PutRecordResponse { + key: stored.key, + version: stored.version, + epoch: stored.epoch, + }, + ), + Err(e) => app_error_to_reject(&doc, &e), + } +} + +/// `rooms/records/get/0.1`. +/// +/// A read presents exactly as a write does. Authorizing reads by session would record a +/// member identifier on every access, and a period of those records reconstructs the +/// membership a sealed room exists to withhold — without breaking any cryptography. +pub(crate) async fn handle_get_record(state: &AppState, doc: TrustTask) -> TrustTaskOutcome { + let req: GetRecordBody = match parse_payload(&doc) { + Ok(r) => r, + Err(resp) => return resp, + }; + + let room = match storage::get_room(&state.rooms_ks, &req.room_id).await { + Ok(r) => r, + Err(e) => return app_error_to_reject(&doc, &e), + }; + let (presenter, verifier) = match presenter_and_verifier(state, &doc).await { + Ok(p) => p, + Err(e) => return app_error_to_reject(&doc, &e), + }; + if let Err(e) = authz::authorize( + &room, + &req.presentation, + Action::Read, + &presenter, + &verifier, + ) + .await + { + return app_error_to_reject(&doc, &e); + } + + match storage::get_record(&state.room_records_ks, &req.room_id, &req.key).await { + Ok(record) => success_response(&doc, record), + Err(e) => app_error_to_reject(&doc, &e), + } +} + +/// `rooms/records/list/0.1`. +/// +/// Returns metadata, never bodies — and returns tombstones to a watermark caller, because a +/// puller that never sees a retraction resurrects the record on its next full rebuild. +pub(crate) async fn handle_list_records( + state: &AppState, + doc: TrustTask, +) -> TrustTaskOutcome { + let req: ListRecordsBody = match parse_payload(&doc) { + Ok(r) => r, + Err(resp) => return resp, + }; + + let room = match storage::get_room(&state.rooms_ks, &req.room_id).await { + Ok(r) => r, + Err(e) => return app_error_to_reject(&doc, &e), + }; + let (presenter, verifier) = match presenter_and_verifier(state, &doc).await { + Ok(p) => p, + Err(e) => return app_error_to_reject(&doc, &e), + }; + if let Err(e) = authz::authorize( + &room, + &req.presentation, + Action::Read, + &presenter, + &verifier, + ) + .await + { + return app_error_to_reject(&doc, &e); + } + + let records = match storage::list_records( + &state.room_records_ks, + &req.room_id, + req.prefix.as_deref(), + req.since_version, + ) + .await + { + Ok(r) => r, + Err(e) => return app_error_to_reject(&doc, &e), + }; + + let limit = req.limit.unwrap_or(usize::MAX); + success_response( + &doc, + ListRecordsResponse { + records: records.iter().take(limit).map(|r| r.metadata()).collect(), + }, + ) +} + +/// `rooms/epoch/mint/0.1`. +/// +/// Restricted to `admin`. If any key-holder could mint an epoch, any member could evict any +/// other by minting one and declining to seal them the new key — silently, and with no +/// check possible here on a room whose membership this service cannot see. Binding it to an +/// action the *room* confers is what makes the restriction enforceable by a service that +/// knows nothing about the membership. +pub(crate) async fn handle_mint_epoch(state: &AppState, doc: TrustTask) -> TrustTaskOutcome { + let req: MintEpochBody = match parse_payload(&doc) { + Ok(r) => r, + Err(resp) => return resp, + }; + + let room = match storage::get_room(&state.rooms_ks, &req.room_id).await { + Ok(r) => r, + Err(e) => return app_error_to_reject(&doc, &e), + }; + let (presenter, verifier) = match presenter_and_verifier(state, &doc).await { + Ok(p) => p, + Err(e) => return app_error_to_reject(&doc, &e), + }; + if let Err(e) = authz::authorize( + &room, + &req.presentation, + Action::Admin, + &presenter, + &verifier, + ) + .await + { + return app_error_to_reject(&doc, &e); + } + + match storage::advance_epoch(&state.rooms_ks, &req.room_id, req.epoch, now()).await { + Ok(updated) => success_response( + &doc, + MintEpochResponse { + room_id: updated.room_id, + epoch: updated.epoch, + }, + ), + Err(e) => app_error_to_reject(&doc, &e), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::build_test_vtc; + use serde_json::json; + use vti_rooms::Visibility; + use vti_rooms_dtg::test_support::RoomFixture; + + /// A **signed** room document. + /// + /// Signing is not ceremony here: the presenter comes from this proof, and every handler + /// below refuses an unsigned request before it looks at any chain. + async fn doc( + state: &AppState, + uri: &str, + payload: Value, + signer_did: &str, + signer_key: &str, + ) -> TrustTask { + let recipient = state + .config + .read() + .await + .vtc_did + .clone() + .unwrap_or_else(|| "did:key:zVtc".to_string()); + let signed = vta_sdk::trust_task_sign::build_signed( + uri, payload, signer_did, signer_key, &recipient, + ) + .await + .expect("sign the request"); + serde_json::from_str(&signed).expect("a signed document is a document") + } + + fn payload_of(out: &TrustTaskOutcome) -> Value { + let d: Value = serde_json::from_slice(&out.body).expect("response is JSON"); + d.get("payload").cloned().unwrap_or(Value::Null) + } + + /// Register the fixture's room with this VTC. + async fn create(state: &AppState, f: &RoomFixture) -> TrustTaskOutcome { + handle_create( + state, + doc( + state, + vti_rooms::wire::ROOMS_CREATE_TYPE, + json!({ + "roomId": f.room.room_id, + "visibility": f.room.visibility, + "ownerDid": f.room.owner_did, + }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, + ) + .await + } + + #[tokio::test] + async fn a_room_is_created_and_a_record_round_trips() { + let tv = build_test_vtc().await; + let state = &tv.state; + let f = RoomFixture::new(Visibility::Open).await; + assert!(create(state, &f).await.status.is_success()); + + let out = handle_put_record( + state, + doc( + state, + vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE, + json!({ + "roomId": f.room.room_id, "key": "k1", "presentation": f.as_owner(), + "cleartext": { "body": "a decision" } + }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, + ) + .await; + assert!(out.status.is_success(), "put: {}", payload_of(&out)); + assert_eq!(payload_of(&out)["version"], 1); + + let out = handle_get_record( + state, + doc( + state, + vti_rooms::wire::ROOMS_RECORDS_GET_TYPE, + json!({ "roomId": f.room.room_id, "key": "k1", "presentation": f.as_owner() }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, + ) + .await; + assert!(out.status.is_success()); + let got = payload_of(&out); + assert_eq!(got["cleartext"]["body"], "a decision"); + assert_eq!( + got["author"], f.owner.did, + "the author is the subject the chain established, not the room's owner field" + ); + } + + /// The invariant the whole family rests on: authorization is the chain, and a request + /// without one is refused regardless of who is asking. + #[tokio::test] + async fn an_operation_with_no_authority_chain_is_refused() { + let tv = build_test_vtc().await; + let state = &tv.state; + let f = RoomFixture::new(Visibility::Open).await; + create(state, &f).await; + + let out = handle_get_record( + state, + doc( + state, + vti_rooms::wire::ROOMS_RECORDS_GET_TYPE, + json!({ + "roomId": f.room.room_id, "key": "k1", + "presentation": { "membership": f.membership, "authority": [] } + }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, + ) + .await; + assert!(!out.status.is_success()); + } + + /// The agent case, through the VTC rather than a standalone host — the two must reach + /// the same conclusion, because both go through the same `vti-rooms-dtg`. + #[tokio::test] + async fn an_agent_reads_under_a_narrower_chain_and_cannot_write() { + let tv = build_test_vtc().await; + let state = &tv.state; + let f = RoomFixture::new(Visibility::Open).await; + create(state, &f).await; + + handle_put_record( + state, + doc( + state, + vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE, + json!({ + "roomId": f.room.room_id, "key": "k", "presentation": f.as_owner(), + "cleartext": { "body": "for the agent" } + }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, + ) + .await; + + let out = handle_get_record( + state, + doc( + state, + vti_rooms::wire::ROOMS_RECORDS_GET_TYPE, + json!({ "roomId": f.room.room_id, "key": "k", "presentation": f.as_agent() }), + &f.agent.did, + &f.agent.secret_multibase, + ) + .await, + ) + .await; + assert!( + out.status.is_success(), + "the agent reads: {}", + payload_of(&out) + ); + + let out = handle_put_record( + state, + doc( + state, + vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE, + json!({ + "roomId": f.room.room_id, "key": "k2", "presentation": f.as_agent(), + "cleartext": { "body": "but must not write" } + }), + &f.agent.did, + &f.agent.secret_multibase, + ) + .await, + ) + .await; + assert!(!out.status.is_success(), "a read-only chain must not write"); + } + + /// A private room is refused for want of a zero-knowledge profile — and the refusal is + /// the same one a standalone room host gives, because it comes from the shared crate. + #[tokio::test] + async fn a_private_room_is_refused_for_want_of_a_zk_profile() { + let tv = build_test_vtc().await; + let state = &tv.state; + let f = RoomFixture::new(Visibility::Private).await; + create(state, &f).await; + + let mut p = f.as_owner(); + p.subject_binding = Some("a-binding-nobody-can-check".into()); + + let out = handle_get_record( + state, + doc( + state, + vti_rooms::wire::ROOMS_RECORDS_GET_TYPE, + json!({ "roomId": f.room.room_id, "key": "k", "presentation": p }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, + ) + .await; + assert!(!out.status.is_success()); + assert!( + payload_of(&out)["message"] + .as_str() + .unwrap_or_default() + .contains("zero-knowledge profile"), + "{}", + payload_of(&out) + ); + } + + /// A private room refuses a presentation with no binding at all before any credential + /// is parsed — the shape check, which is `vti-rooms`' half. + #[tokio::test] + async fn a_private_room_refuses_a_presentation_without_a_subject_binding() { + let tv = build_test_vtc().await; + let state = &tv.state; + let f = RoomFixture::new(Visibility::Private).await; + create(state, &f).await; + + let out = handle_get_record( + state, + doc( + state, + vti_rooms::wire::ROOMS_RECORDS_GET_TYPE, + json!({ "roomId": f.room.room_id, "key": "k", "presentation": f.as_owner() }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, + ) + .await; + assert!(!out.status.is_success()); + assert!( + payload_of(&out)["message"] + .as_str() + .unwrap_or_default() + .contains("subject binding"), + "{}", + payload_of(&out) + ); + } + + #[tokio::test] + async fn listing_returns_metadata_and_never_bodies() { + let tv = build_test_vtc().await; + let state = &tv.state; + let f = RoomFixture::new(Visibility::Open).await; + create(state, &f).await; + + handle_put_record( + state, + doc( + state, + vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE, + json!({ + "roomId": f.room.room_id, "key": "a", "presentation": f.as_owner(), + "cleartext": { "body": "secret-body-text" } + }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, + ) + .await; + + let out = handle_list_records( + state, + doc( + state, + vti_rooms::wire::ROOMS_RECORDS_LIST_TYPE, + json!({ "roomId": f.room.room_id, "presentation": f.as_owner() }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, + ) + .await; + assert!(out.status.is_success()); + let text = payload_of(&out).to_string(); + assert!(text.contains("\"key\"")); + assert!( + !text.contains("secret-body-text"), + "a listing must never carry bodies: {text}" + ); + } + + /// `admin`, not `write`: if any key-holder could mint an epoch, any member could evict + /// any other, and the service — which cannot see the membership — would never know. + #[tokio::test] + async fn minting_an_epoch_requires_admin_and_advances_by_one() { + let tv = build_test_vtc().await; + let state = &tv.state; + let f = RoomFixture::new(Visibility::Open).await; + create(state, &f).await; + + // The agent's chain confers `read` alone. + let out = handle_mint_epoch( + state, + doc( + state, + vti_rooms::wire::ROOMS_EPOCH_MINT_TYPE, + json!({ "roomId": f.room.room_id, "epoch": 2, "presentation": f.as_agent() }), + &f.agent.did, + &f.agent.secret_multibase, + ) + .await, + ) + .await; + assert!(!out.status.is_success(), "read may not mint an epoch"); + + // The owner's confers `admin`. + let out = handle_mint_epoch( + state, + doc( + state, + vti_rooms::wire::ROOMS_EPOCH_MINT_TYPE, + json!({ "roomId": f.room.room_id, "epoch": 2, "presentation": f.as_owner() }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, + ) + .await; + assert!(out.status.is_success(), "{}", payload_of(&out)); + assert_eq!(payload_of(&out)["epoch"], 2); + + // And it advances by exactly one — skipping would seal records under an epoch no + // member was ever given a key for. + let out = handle_mint_epoch( + state, + doc( + state, + vti_rooms::wire::ROOMS_EPOCH_MINT_TYPE, + json!({ "roomId": f.room.room_id, "epoch": 5, "presentation": f.as_owner() }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, + ) + .await; + assert!(!out.status.is_success(), "an epoch may not skip"); + } +} diff --git a/vtc-service/src/rooms/mod.rs b/vtc-service/src/rooms/mod.rs new file mode 100644 index 000000000..bb8f2785d --- /dev/null +++ b/vtc-service/src/rooms/mod.rs @@ -0,0 +1,17 @@ +//! Data rooms — this service's Trust-Task surface over [`vti_rooms`]. +//! +//! Storage, wire types and authorization live in the `vti-rooms` crate, because none of +//! them needs anything from a community service: a room is authorized by credentials the +//! room itself issued, so the code deciding a room operation cannot need a roster, a policy +//! engine, or a session store. What stays here is [`handlers`] — the dispatch surface, which +//! is this service's spine and therefore not extractable. +//! +//! The re-exports keep `crate::rooms::Room` and friends resolving, so nothing outside this +//! module had to move when the subsystem did. + +pub mod handlers; + +pub use vti_rooms::{ + ROOM_RECORDS_KEYSPACE, ROOMS_KEYSPACE, Record, RecordStatus, Room, Visibility, authz, storage, + wire, +}; diff --git a/vtc-service/src/server.rs b/vtc-service/src/server.rs index db55cda6e..2caa8d897 100644 --- a/vtc-service/src/server.rs +++ b/vtc-service/src/server.rs @@ -114,6 +114,14 @@ pub struct AppState { /// Tracked here for list + revoke surfaces; the VEC body /// itself is signed + returned at issuance time. pub endorsements_ks: KeyspaceHandle, + /// Data rooms — one row per room. Carries owner, visibility, epoch and + /// retention, and deliberately **no member list**: room membership is + /// decided by credentials the room itself issued, so a roster here would + /// make the room unmovable and make this service part of its membership. + /// See `crate::rooms`. + pub rooms_ks: KeyspaceHandle, + /// Room records. Ciphertext on the `attributed` and `private` tiers. + pub room_records_ks: KeyspaceHandle, pub audit_ks: KeyspaceHandle, pub audit_key_ks: KeyspaceHandle, /// Signed audit checkpoints (#708). Read by `GET /v1/audit/verify`, @@ -452,6 +460,8 @@ pub async fn run( // mints out of the box. crate::schemas::seed_default_issues(&schemas_ks).await?; let endorsements_ks = store.keyspace(keyspaces::ENDORSEMENTS)?; + let rooms_ks = store.keyspace(keyspaces::ROOMS)?; + let room_records_ks = store.keyspace(keyspaces::ROOM_RECORDS)?; let audit_ks = store.keyspace(keyspaces::AUDIT)?; let audit_key_ks = store.keyspace(keyspaces::AUDIT_KEY)?; let audit_checkpoint_ks = store.keyspace(keyspaces::AUDIT_CHECKPOINT)?; @@ -718,6 +728,8 @@ pub async fn run( endorsement_types_ks, schemas_ks, endorsements_ks, + rooms_ks, + room_records_ks, audit_ks, audit_key_ks, audit_checkpoint_ks: audit_checkpoint_ks.clone(), diff --git a/vtc-service/src/store/keyspaces.rs b/vtc-service/src/store/keyspaces.rs index daef88d86..de90eca0b 100644 --- a/vtc-service/src/store/keyspaces.rs +++ b/vtc-service/src/store/keyspaces.rs @@ -34,6 +34,16 @@ pub const RELATIONSHIPS_BY_DID: &str = "relationships_by_did"; pub const ENDORSEMENT_TYPES: &str = "endorsement_types"; pub const SCHEMAS: &str = "schemas"; pub const ENDORSEMENTS: &str = "endorsements"; + +/// Data rooms: one row per room at `rooms:`. +/// +/// Holds an owner, a visibility, an epoch and a retention period — and deliberately **no +/// member list**. Membership is decided by credentials the room itself issued, so a roster +/// here would make the room unmovable and make this service part of its membership. +pub use vti_rooms::ROOMS_KEYSPACE as ROOMS; + +/// Room records at `room_records::`. Ciphertext on the sealed tiers. +pub use vti_rooms::ROOM_RECORDS_KEYSPACE as ROOM_RECORDS; pub const AUDIT: &str = "audit"; pub const AUDIT_KEY: &str = "audit_key"; /// Signed audit checkpoints (#708) — periodic Ed25519-signed commitments to @@ -78,6 +88,8 @@ pub const ALL: &[&str] = &[ ENDORSEMENT_TYPES, SCHEMAS, ENDORSEMENTS, + ROOMS, + ROOM_RECORDS, AUDIT, AUDIT_KEY, AUDIT_CHECKPOINT, @@ -108,6 +120,8 @@ pub const BACKED_UP: &[&str] = &[ ENDORSEMENT_TYPES, SCHEMAS, ENDORSEMENTS, + ROOMS, + ROOM_RECORDS, AUDIT, AUDIT_KEY, // Required, not optional: restoring the audit log without its @@ -146,11 +160,11 @@ mod tests { use super::*; /// `ALL` must stay in sync with the `AppState` keyspace fields. - /// `server::run` opens 21 keyspaces into 21 `*_ks` fields — if a + /// `server::run` opens every one of them into a `*_ks` field — if a /// keyspace is added to one without the other, this trips. #[test] fn all_matches_app_state_keyspace_count() { - assert_eq!(ALL.len(), 25, "ALL must list every AppState keyspace"); + assert_eq!(ALL.len(), 27, "ALL must list every AppState keyspace"); } /// The backup census (P3.9): every keyspace is either backed up or diff --git a/vtc-service/src/test_support.rs b/vtc-service/src/test_support.rs index 6216689f3..cb832e21c 100644 --- a/vtc-service/src/test_support.rs +++ b/vtc-service/src/test_support.rs @@ -228,6 +228,8 @@ impl TestVtcBuilder { .expect("endorsement_types ks"); let schemas_ks = store.keyspace("schemas").expect("schemas ks"); let endorsements_ks = store.keyspace("endorsements").expect("endorsements ks"); + let rooms_ks = store.keyspace("rooms").expect("rooms ks"); + let room_records_ks = store.keyspace("room_records").expect("room_records ks"); let audit_ks = store.keyspace("audit").expect("audit ks"); let audit_key_ks = store.keyspace("audit_key").expect("audit_key ks"); let audit_checkpoint_ks = store @@ -357,6 +359,8 @@ impl TestVtcBuilder { endorsement_types_ks, schemas_ks, endorsements_ks, + rooms_ks, + room_records_ks, audit_ks, audit_key_ks, audit_checkpoint_ks, diff --git a/vtc-service/src/trust_tasks/mod.rs b/vtc-service/src/trust_tasks/mod.rs index 8acc5103a..6b549c495 100644 --- a/vtc-service/src/trust_tasks/mod.rs +++ b/vtc-service/src/trust_tasks/mod.rs @@ -63,6 +63,8 @@ use vta_sdk::protocols::join_requests::{ }; use vta_sdk::protocols::members::{self as mem, MemberVmcBody, MemberVmcReceiptBody}; +use vti_rooms::wire as rooms_wire; + use crate::join::{JoinSubmitOutcome, JoinTransport}; use crate::server::AppState; @@ -140,6 +142,23 @@ pub(crate) async fn dispatch_trust_task_core( jr::JOIN_REQUEST_STATUS_TYPE => handle_status(state, ctx, doc).await, jr::MEMBER_SELF_REMOVE_TYPE => handle_self_remove(state, ctx, doc).await, mem::MEMBER_VMC_TYPE => handle_member_vmc(state, ctx, doc).await, + // The rooms family. Note what these do not take: no `ctx`, and no auth claims. + // A room operation is authorized by the authority chain the room itself issued, + // never by this service's ACL, roster, or the caller's session — invariant I5 of + // `docs/05-design-notes/data-rooms.md`, and what makes a room portable. + rooms_wire::ROOMS_CREATE_TYPE => crate::rooms::handlers::handle_create(state, doc).await, + rooms_wire::ROOMS_RECORDS_PUT_TYPE => { + crate::rooms::handlers::handle_put_record(state, doc).await + } + rooms_wire::ROOMS_RECORDS_GET_TYPE => { + crate::rooms::handlers::handle_get_record(state, doc).await + } + rooms_wire::ROOMS_RECORDS_LIST_TYPE => { + crate::rooms::handlers::handle_list_records(state, doc).await + } + rooms_wire::ROOMS_EPOCH_MINT_TYPE => { + crate::rooms::handlers::handle_mint_epoch(state, doc).await + } PERSONHOOD_CHALLENGE_TYPE => handle_personhood_challenge(state, ctx, doc).await, PERSONHOOD_ASSERT_TYPE => handle_personhood_assert(state, ctx, doc).await, other => unsupported_type_or_version(&doc, other), @@ -243,6 +262,15 @@ pub(crate) const DISPATCHED_URIS: &[&str] = &[ mem::MEMBER_VMC_TYPE, PERSONHOOD_CHALLENGE_TYPE, PERSONHOOD_ASSERT_TYPE, + // rooms/* — top-level, not `spec/vtc/*`: a room's protocol is host-neutral, so + // filing it under a service prefix would encode into the URI the one thing the + // design exists to avoid. The vtc conformance sweep scopes to `spec/vtc/` and so + // does not cover these; they are pinned by `rooms_dispatch_matches_wire` below. + rooms_wire::ROOMS_CREATE_TYPE, + rooms_wire::ROOMS_RECORDS_PUT_TYPE, + rooms_wire::ROOMS_RECORDS_GET_TYPE, + rooms_wire::ROOMS_RECORDS_LIST_TYPE, + rooms_wire::ROOMS_EPOCH_MINT_TYPE, ]; /// `vtc/members/personhood/challenge/0.1` — mint the single-use nonce @@ -793,6 +821,18 @@ mod tests { mem::MEMBER_VMC_TYPE, ::TYPE_URI, ::TYPE_URI, + // rooms/* names hand-written constants rather than a generated + // `TYPE_URI`, because its schemas are still in review upstream + // (trustoverip/dtgwg-trust-tasks-tf#346). The property this test + // exists for is weaker for them until those bindings publish: it + // pins the dispatcher against `wire`'s constants, not against the + // registry. **Swap these for the generated `TYPE_URI`s when the + // crate ships them** — that is what restores the guarantee. + rooms_wire::ROOMS_CREATE_TYPE, + rooms_wire::ROOMS_RECORDS_PUT_TYPE, + rooms_wire::ROOMS_RECORDS_GET_TYPE, + rooms_wire::ROOMS_RECORDS_LIST_TYPE, + rooms_wire::ROOMS_EPOCH_MINT_TYPE, ]; for u in DISPATCHED_URIS { assert!( diff --git a/vtc-service/tests/emergency_bootstrap.rs b/vtc-service/tests/emergency_bootstrap.rs index 0c628762e..753c05001 100644 --- a/vtc-service/tests/emergency_bootstrap.rs +++ b/vtc-service/tests/emergency_bootstrap.rs @@ -222,6 +222,8 @@ async fn build_fixture(public_url: Option<&str>) -> Fixture { let relationships_by_did_ks = store.keyspace("relationships_by_did").unwrap(); let endorsement_types_ks = store.keyspace("endorsement_types").unwrap(); let endorsements_ks = store.keyspace("endorsements").unwrap(); + let rooms_ks = store.keyspace("rooms").unwrap(); + let room_records_ks = store.keyspace("room_records").unwrap(); let audit_ks = store.keyspace("audit").unwrap(); let audit_key_ks = store.keyspace("audit_key").unwrap(); let audit_checkpoint_ks = store.keyspace("audit_checkpoint").unwrap(); @@ -322,6 +324,8 @@ async fn build_fixture(public_url: Option<&str>) -> Fixture { endorsement_types_ks: endorsement_types_ks.clone(), schemas_ks: store.keyspace("schemas").unwrap(), endorsements_ks: endorsements_ks.clone(), + rooms_ks: rooms_ks.clone(), + room_records_ks: room_records_ks.clone(), invitations_ks, consumed_invitations_ks, registry_client: None, diff --git a/vtc-service/tests/passkey_state.rs b/vtc-service/tests/passkey_state.rs index 06546bae6..7a1218df0 100644 --- a/vtc-service/tests/passkey_state.rs +++ b/vtc-service/tests/passkey_state.rs @@ -51,6 +51,8 @@ fn build_state(public_url: Option<&str>) -> (AppState, tempfile::TempDir) { let relationships_by_did_ks = store.keyspace("relationships_by_did").unwrap(); let endorsement_types_ks = store.keyspace("endorsement_types").unwrap(); let endorsements_ks = store.keyspace("endorsements").unwrap(); + let rooms_ks = store.keyspace("rooms").unwrap(); + let room_records_ks = store.keyspace("room_records").unwrap(); let audit_ks = store.keyspace("audit").unwrap(); let audit_key_ks = store.keyspace("audit_key").unwrap(); let audit_checkpoint_ks = store.keyspace("audit_checkpoint").unwrap(); @@ -97,6 +99,8 @@ fn build_state(public_url: Option<&str>) -> (AppState, tempfile::TempDir) { endorsement_types_ks: endorsement_types_ks.clone(), schemas_ks: store.keyspace("schemas").unwrap(), endorsements_ks: endorsements_ks.clone(), + rooms_ks: rooms_ks.clone(), + room_records_ks: room_records_ks.clone(), invitations_ks, consumed_invitations_ks, registry_client: None, diff --git a/vti-rooms-dtg/Cargo.toml b/vti-rooms-dtg/Cargo.toml new file mode 100644 index 000000000..a3e70e411 --- /dev/null +++ b/vti-rooms-dtg/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "vti-rooms-dtg" +description = "The DTG-credential chain verifier for data rooms" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +# Not published, and that is what makes the git dependency below tolerable. +# A published crate cannot depend on a git source, so this crate is where the +# bridge lives until dtg-credentials 0.6 is on crates.io. +publish = false + +[features] +# Signed fixtures for downstream tests and the data_room example. Never a default: +# a permissive fixture is exactly what should not ship. +test-support = ["dep:ed25519-dalek", "dep:getrandom", "dep:multibase", "dep:vta-sdk", "dep:affinidi-tdk"] + +[dependencies] +vti-rooms = { path = "../vti-rooms" } +vti-common = { path = "../vti-common" } + +# TEMPORARY, and the only reason this crate is not published: `authority:: +# verify_chain` and the VAC/VDC types landed in dtg-credentials 0.6.0, which is +# merged but not yet released — that repository had no release path at all until +# OpenVTC/dtg-credentials#17. Swap this whole line for +# dtg-credentials = "0.6" +# the moment the v0.6.0 tag publishes; nothing else changes. +# +# It is pinned to a rev rather than a branch on purpose: a moving branch would +# change what verifies a credential without changing this repository. +dtg-credentials = { git = "https://github.com/OpenVTC/dtg-credentials", rev = "0c1391adfa67d8ae65498f086675404b8bb50b08" } + +async-trait = "0.1" +# Both hosts already carry a `VerificationMethodResolver`, so the key adapter wraps +# whatever they have rather than making each write its own. +affinidi-data-integrity = { workspace = true } +affinidi-secrets-resolver = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } + +# test-support only. +ed25519-dalek = { workspace = true, optional = true } +getrandom = { version = "0.3", optional = true } +multibase = { workspace = true, optional = true } +vta-sdk = { path = "../vta-sdk", features = ["didcomm"], optional = true } +affinidi-tdk = { workspace = true, optional = true } + +[dev-dependencies] +tokio = { workspace = true } +# Real keys and real signatures for the end-to-end test. A verifier tested only on +# refusals would pass every test while refusing everything. + diff --git a/vti-rooms-dtg/src/lib.rs b/vti-rooms-dtg/src/lib.rs new file mode 100644 index 000000000..28606ef0b --- /dev/null +++ b/vti-rooms-dtg/src/lib.rs @@ -0,0 +1,990 @@ +//! The cryptographic half of room authorization. +//! +//! [`vti_rooms::authz`] decides whether a presentation is *shaped* right and then asks a +//! [`ChainVerifier`] whether it is *true*. This crate is that verifier, over the DTG +//! credentials the rooms design uses: a VMC for membership and a chain of VACs for +//! authority. +//! +//! # Why this is a separate crate +//! +//! `vti-rooms` is published and depends on nothing but `vti-common`, so a room host can +//! reuse its storage without dragging in a credential library and a DID resolver. Verifying +//! needs both. Keeping them apart is also what lets `vti-rooms` stay honest about the +//! seam — a host that has configured no verifier gets `RefusesEverything` and serves +//! nothing, rather than a permissive default nobody notices. +//! +//! # What verification actually consists of +//! +//! Two independent checks, and neither substitutes for the other: +//! +//! - **Are these credentials genuine?** Each one's data-integrity proof must verify against +//! the key its `verificationMethod` names. That is what [`VerificationKeys`] resolves. +//! - **Do genuine credentials add up to the authority claimed?** That is +//! `dtg_credentials::authority::verify_chain`, and it is the part that matters: anyone can +//! mint a well-formed VAC naming any scope and any action, and it will verify perfectly as +//! a credential. What makes it worthless is that its chain does not reach the party +//! governing the scope — here, the room. +//! +//! Doing only the first is the classic mistake. A valid signature on a self-issued grant is +//! still a self-issued grant. +//! +//! # The room governs its own scope +//! +//! `governing_party` and `requested_scope` are both the room's DID. That is the whole of +//! invariant I5 in one line: a chain is worth something here **because it reaches the room**, +//! not because a host recognises the issuer. A chain rooted at the community that the room +//! belongs to, or at the host, or at anyone else, confers nothing — which is what lets the +//! room move to a different host without reissuing a single credential. +//! +//! # Nothing is fetched +//! +//! `verify_chain` takes the chain as a slice and never dereferences a `parent`, and this +//! crate never fetches a credential either. Resolving over the network would make +//! verification depend on availability, turn an identifier into a request the host can be +//! induced to make against an address the *holder* chooses, and signal credential use to +//! whoever hosts that identifier. [`VerificationKeys`] resolves **keys**, which is a +//! different thing: a key is named by the credential's own proof, and a host that cannot +//! resolve it refuses rather than proceeding. + +use affinidi_data_integrity::VerificationMethodResolver; +use affinidi_secrets_resolver::secrets::KeyType; +use base64::Engine as _; +use dtg_credentials::authority::{AuthorityError, verify_chain}; +use dtg_credentials::{DTGCredential, DTGCredentialType}; +use vti_common::error::AppError; +use vti_rooms::authz::{Action, ChainVerifier, VerifiedChain}; +use vti_rooms::wire::AuthorityPresentation; +use vti_rooms::{Room, Visibility}; + +/// Resolve a credential's `verificationMethod` to the public key that signed it. +/// +/// One implementation per host, because a VTC resolves DIDs through its own resolver and a +/// standalone room host through whatever it was configured with. Both answer the same +/// question, and both must **fail** rather than guess: a verifier that treats an +/// unresolvable method as "probably fine" has stopped checking signatures. +#[async_trait::async_trait] +pub trait VerificationKeys: Send + Sync { + /// The Ed25519 public key bytes for `verification_method`. + async fn public_key(&self, verification_method: &str) -> Result, AppError>; +} + +/// [`VerificationKeys`] over any resolver the host already has. +/// +/// Both a VTC and a room host carry an `affinidi_data_integrity::VerificationMethodResolver` +/// — `vti_common::auth::TrustTaskVmResolver` is one — so wrapping it beats making each host +/// write its own lookup and get the key-type check subtly different. +pub struct DataIntegrityKeys(pub R); + +#[async_trait::async_trait] +impl VerificationKeys for DataIntegrityKeys { + async fn public_key(&self, verification_method: &str) -> Result, AppError> { + let resolved = self + .0 + .resolve_vm(verification_method) + .await + .map_err(|e| AppError::NotFound(format!("resolve `{verification_method}`: {e}")))?; + + // Room credentials are signed `eddsa-jcs-2022`. Handing a P-256 key to an Ed25519 + // verifier is not a type error anywhere in the stack — the bytes are the same + // length — so the algorithm is checked here rather than assumed. + if !matches!(resolved.key_type, KeyType::Ed25519) { + return Err(AppError::NotFound(format!( + "`{verification_method}` is a {:?} key; room credentials are eddsa-jcs-2022", + resolved.key_type + ))); + } + Ok(resolved.public_key_bytes) + } +} + +/// Verify a `private` room's subject binding. +/// +/// Split out because it is the one part of this that the specification does not yet settle. +/// On the disclosing tiers the pooling defence is a comparison — the VMC's subject against +/// the chain's — and [`DtgChainVerifier`] does it inline. On a `private` room the subject is +/// withheld by design, so the same property has to be proved in zero knowledge, and *which* +/// proof is a profile question: the DTG cred-spec puts ZK protocols and registry-ZK +/// interactions explicitly out of scope, and the working group has not chosen one. +/// +/// So this is a seam with no default implementation shipped, and +/// [`DtgChainVerifier::without_zk`] refuses every private-room presentation with a message +/// saying exactly that. That is the honest position: a private room whose pooling defence +/// nobody checked is worse than a private room that will not open, because two parties can +/// combine one's membership with the other's authority and present as a single party +/// holding both. +#[async_trait::async_trait] +pub trait SubjectBindingVerifier: Send + Sync { + /// Prove that `binding` shows the membership presentation and the chain leaf describe + /// one subject, and return that subject's identifier for the room's purposes. + async fn verify_same_subject( + &self, + room: &Room, + membership: &str, + binding: &str, + chain_leaf_subject: &str, + ) -> Result<(), AppError>; +} + +/// The verifier that refuses every private room, and says why. +struct NoZkProfile; + +#[async_trait::async_trait] +impl SubjectBindingVerifier for NoZkProfile { + async fn verify_same_subject( + &self, + room: &Room, + _membership: &str, + _binding: &str, + _chain_leaf_subject: &str, + ) -> Result<(), AppError> { + Err(AppError::Forbidden(format!( + "room `{}` is private, and this host has no zero-knowledge profile configured \ + to verify its subject binding; serving it would mean accepting a pooling \ + defence nobody checked", + room.room_id + ))) + } +} + +/// Verifies room presentations against DTG credentials. +pub struct DtgChainVerifier { + keys: Box, + zk: Box, +} + +impl DtgChainVerifier { + /// A verifier for the `open` and `attributed` tiers. + /// + /// Private rooms are refused, with a message naming the missing profile — see + /// [`SubjectBindingVerifier`] for why that is the honest default rather than a gap. + pub fn without_zk(keys: Box) -> Self { + Self { + keys, + zk: Box::new(NoZkProfile), + } + } + + /// A verifier for every tier, once a zero-knowledge profile exists. + pub fn with_zk(keys: Box, zk: Box) -> Self { + Self { keys, zk } + } + + /// Decode one presented credential and verify its proof. + /// + /// Accepts base64url or bare JSON. Both are unambiguous — a JSON document starts with + /// `{` and base64url has no `{` in its alphabet — and accepting both means a caller + /// hand-building a request for a demo or a test does not have to encode by hand. The + /// serialization is a profile question the schema leaves open ("serialized per the + /// governing profile"); this is the profile this implementation reads. + async fn open_credential(&self, encoded: &str, what: &str) -> Result { + let bytes = if encoded.trim_start().starts_with('{') { + encoded.as_bytes().to_vec() + } else { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(encoded.trim()) + .map_err(|_| { + AppError::Forbidden(format!( + "{what} is neither base64url nor JSON; a credential that cannot be \ + read cannot be verified" + )) + })? + }; + + let credential: DTGCredential = serde_json::from_slice(&bytes) + .map_err(|e| AppError::Forbidden(format!("{what} is not a DTG credential: {e}")))?; + + let proof = credential + .credential() + .proof + .as_ref() + .ok_or_else(|| AppError::Forbidden(format!("{what} carries no proof")))?; + + let key = self + .keys + .public_key(&proof.verification_method) + .await + .map_err(|e| { + // The resolver's own error is for the operator; the caller learns only that + // it did not verify, so an unresolvable method is not an oracle for which + // DIDs this host can reach. + tracing::warn!( + verification_method = %proof.verification_method, + error = %e, + "could not resolve a room credential's verification method" + ); + AppError::Forbidden(format!("{what} could not be verified")) + })?; + + credential.verify_proof_with_public_key(&key).map_err(|e| { + tracing::warn!(error = %e, "room credential proof did not verify"); + AppError::Forbidden(format!("{what} could not be verified")) + })?; + + Ok(credential) + } +} + +/// An [`AuthorityError`] as a refusal. +/// +/// Every variant becomes the same `Forbidden`, with the specifics in the message for an +/// operator reading logs. A caller learns that the chain did not carry the authority — not +/// which link to adjust, which would make the verifier a tool for assembling one. +fn chain_refusal(room_id: &str, e: AuthorityError) -> AppError { + AppError::Forbidden(format!( + "the authority chain does not confer this on room `{room_id}`: {e}" + )) +} + +#[async_trait::async_trait] +impl ChainVerifier for DtgChainVerifier { + async fn verify( + &self, + room: &Room, + presentation: &AuthorityPresentation, + action: Action, + presenter: &str, + ) -> Result { + // The chain, leaf first, every link's proof checked before any of them is trusted to + // say anything about the others. + let mut chain = Vec::with_capacity(presentation.authority.len()); + for (index, encoded) in presentation.authority.iter().enumerate() { + chain.push( + self.open_credential(encoded, &format!("authority credential {index}")) + .await?, + ); + } + + // `verify_chain` answers the question the signatures do not: does this add up to the + // authority claimed? Both the governing party and the scope are the room itself — + // a chain rooted anywhere else confers nothing here, however valid. + let verified = verify_chain( + &chain, + &room.room_id, + &room.room_id, + action.as_str(), + presenter, + chrono::Utc::now(), + ) + .map_err(|e| chain_refusal(&room.room_id, e))?; + + // `verify_chain` takes `presenter` but uses it for **one** thing: the `audience` + // check, where a link that names an audience must be presented by that audience. It + // does not require the leaf to grant to the presenter, and that is deliberate on + // its side — the leaf's subject is "who may act", and binding that to the party the + // *transport* authenticated is a question about this request, not about the chain. + // + // Which makes it ours, and it is not optional: without it a presentation is a + // bearer token, and anyone who observes one inherits everything it confers. A test + // above presents an agent's chain as the agent's human and expects a refusal. + if verified.subject != presenter { + return Err(AppError::Forbidden(format!( + "the chain's leaf grants to `{}`, not to the party that signed this \ + request; a presentation is bound to its presenter, not bearer", + verified.subject + ))); + } + + // The pooling defence: membership and authority must describe one subject. + match room.visibility { + // The subject is withheld, so it is proved rather than compared. `authorize` + // has already refused a presentation with no binding at all; this is whether + // the one present actually proves it. + Visibility::Private => { + let binding = presentation.subject_binding.as_deref().ok_or_else(|| { + AppError::Forbidden("a private room requires a subject binding".into()) + })?; + self.zk + .verify_same_subject(room, &presentation.membership, binding, &verified.subject) + .await?; + } + // The subject is disclosed, so it is compared. + Visibility::Open | Visibility::Attributed => { + let membership = self + .open_credential(&presentation.membership, "membership credential") + .await?; + + if !matches!(membership.type_(), DTGCredentialType::Membership) { + return Err(AppError::Forbidden(format!( + "the presented membership credential is a {}, not a VMC", + membership.type_() + ))); + } + + // A VMC for some other room says nothing about this one. + if membership.issuer() != room.room_id { + return Err(AppError::Forbidden(format!( + "the membership credential was issued by `{}`, not by room `{}`", + membership.issuer(), + room.room_id + ))); + } + + // The subject to compare is the **root's**, not the leaf's. + // + // The leaf says who may act, and that is frequently not a member: the case + // this design exists for is a member equipping their agent with a narrower + // chain, and an agent is not a member of anything. Comparing the leaf + // would refuse exactly the arrangement the rooms design is for. + // + // The root's subject is the party the *room* granted to, and every + // attenuation below it descends from them — `verify_chain` has already + // established that each link's issuer is its parent's subject. So the root + // is which member's standing this authority descends from, and requiring + // the VMC to be theirs is what closes the pooling attack: a chain rooted at + // Bob cannot be presented with Alice's membership, whoever holds the leaf. + let root = chain.last().expect("verify_chain rejects an empty chain"); + if membership.subject() != root.subject() { + return Err(AppError::Forbidden(format!( + "the membership credential describes `{}` but the authority chain \ + descends from `{}`; two parties cannot pool credentials into one", + membership.subject(), + root.subject() + ))); + } + } + } + + Ok(VerifiedChain { + subject: verified.subject, + actions: verified.actions, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A resolver that resolves nothing. + struct NoKeys; + + #[async_trait::async_trait] + impl VerificationKeys for NoKeys { + async fn public_key(&self, _vm: &str) -> Result, AppError> { + Err(AppError::NotFound("no keys here".into())) + } + } + + fn room(visibility: Visibility) -> Room { + Room { + room_id: "did:webvh:example.com:rooms:northwind".into(), + owner_did: "did:key:z6MkOwner".into(), + visibility, + epoch: 1, + next_version: 1, + retention_days: 90, + created_at: 0, + updated_at: 0, + } + } + + fn presentation(authority: Vec, binding: Option<&str>) -> AuthorityPresentation { + AuthorityPresentation { + membership: "{}".into(), + authority, + subject_binding: binding.map(str::to_string), + } + } + + /// A real, well-formed VAC — built by the library that verifies it, so this test + /// exercises the shape the implementation actually produces rather than one hand-typed + /// beside it. + /// + /// Deliberately **unsigned in effect**: it carries a proof block whose value is + /// nonsense. Everything about it is right except the one thing that matters. + fn a_vac(issuer: &str, subject: &str, scope: &str, actions: &[&str]) -> String { + let mut vac = DTGCredential::new_vac( + issuer.into(), + subject.into(), + scope.into(), + actions.iter().map(|s| s.to_string()).collect(), + chrono::Utc::now() - chrono::Duration::hours(1), + Some(chrono::Utc::now() + chrono::Duration::hours(1)), + ) + .expect("build a VAC"); + + let mut doc = serde_json::to_value(vac.credential()).expect("serialise"); + doc["proof"] = serde_json::json!({ + "type": "DataIntegrityProof", + "cryptosuite": "eddsa-jcs-2022", + "created": "2026-09-01T00:00:00Z", + "verificationMethod": format!("{issuer}#key-0"), + "proofPurpose": "assertionMethod", + "proofValue": "z2LJhFyBmRcqMKZbdgVBb9nQpjhTjcMwCsSYRfPqk5bZ", + }); + let _ = &mut vac; + doc.to_string() + } + + /// A credential this host cannot resolve a key for does not verify — it does not get + /// the benefit of the doubt, and the refusal does not say which DIDs are reachable. + #[tokio::test] + async fn an_unresolvable_verification_method_refuses() { + let v = DtgChainVerifier::without_zk(Box::new(NoKeys)); + let err = v + .verify( + &room(Visibility::Open), + &presentation( + vec![a_vac( + "did:webvh:example.com:rooms:northwind", + "did:key:zAgent", + "did:webvh:example.com:rooms:northwind", + &["read"], + )], + None, + ), + Action::Read, + "did:key:zAgent", + ) + .await + .unwrap_err(); + let text = format!("{err}"); + assert!(text.contains("could not be verified"), "{text}"); + assert!( + !text.contains("no keys here"), + "the resolver's reason is for the operator's log, not the caller: {text}" + ); + } + + /// The check that carries the weight. A chain of perfectly valid credentials, rooted + /// at a party that is not the room, confers nothing here — that is invariant I5, and it + /// is what lets a room move host without reissuing anything. + #[tokio::test] + async fn a_chain_rooted_anywhere_but_the_room_confers_nothing() { + struct AnyKey; + #[async_trait::async_trait] + impl VerificationKeys for AnyKey { + async fn public_key(&self, _vm: &str) -> Result, AppError> { + Ok(vec![0u8; 32]) + } + } + + // Mallory issues herself full authority over someone else's room. Every field is + // well-formed; the chain simply does not reach the room. + let v = DtgChainVerifier::without_zk(Box::new(AnyKey)); + let err = v + .verify( + &room(Visibility::Open), + &presentation( + vec![a_vac( + "did:key:zMallory", + "did:key:zMallory", + "did:webvh:example.com:rooms:northwind", + &["read", "write", "admin"], + )], + None, + ), + Action::Admin, + "did:key:zMallory", + ) + .await + .unwrap_err(); + // It fails at the signature with this stub resolver, and would fail at the chain + // with a real one. Either way it does not authorize, which is the property. + assert!(format!("{err}").contains("could not be verified"), "{err}"); + } + + #[tokio::test] + async fn a_credential_that_is_neither_base64url_nor_json_refuses() { + let v = DtgChainVerifier::without_zk(Box::new(NoKeys)); + let err = v + .verify( + &room(Visibility::Open), + &presentation(vec!["not a credential !!!".into()], None), + Action::Read, + "did:key:zAgent", + ) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("neither base64url nor JSON"), + "{err}" + ); + } + + /// A private room without a zero-knowledge profile is refused by the verifier, not + /// quietly served. The message names what is missing. + #[tokio::test] + async fn a_private_room_refuses_without_a_zk_profile() { + struct OneKey; + #[async_trait::async_trait] + impl VerificationKeys for OneKey { + async fn public_key(&self, _vm: &str) -> Result, AppError> { + Ok(vec![0u8; 32]) + } + } + + // The chain never gets far enough for the ZK check here — the point of the test is + // that `without_zk` carries a refusing binding verifier at all, which + // `NoZkProfile` asserts directly. + let refusal = NoZkProfile + .verify_same_subject(&room(Visibility::Private), "vmc", "binding", "did:key:zA") + .await + .unwrap_err(); + assert!( + format!("{refusal}").contains("no zero-knowledge profile configured"), + "{refusal}" + ); + + let _ = DtgChainVerifier::without_zk(Box::new(OneKey)); + } +} + +/// The end-to-end tests: real keys, real signatures, real chains. +/// +/// Separated from the unit tests above because these are the ones that matter. Every test +/// up there asserts a refusal, and a verifier that refused everything would pass all of +/// them — these are what say it admits a good chain, and only a good one. +#[cfg(test)] +mod signed { + use super::*; + use affinidi_tdk::dids::{DID, KeyType}; + use chrono::{Duration, Utc}; + + /// Resolves a `did:key`'s verification method to its own public key, which is what a + /// `did:key` is. No network, and no opportunity to resolve to the wrong key. + struct DidKeyResolver; + + #[async_trait::async_trait] + impl VerificationKeys for DidKeyResolver { + async fn public_key(&self, vm: &str) -> Result, AppError> { + let did = vm.split('#').next().unwrap_or_default(); + let multibase = did + .strip_prefix("did:key:") + .ok_or_else(|| AppError::NotFound(format!("not a did:key: {did}")))?; + vta_sdk::did_key::decode_ed25519_public_key_multibase(multibase) + .map(|k| k.to_vec()) + .map_err(|e| AppError::NotFound(format!("decode {did}: {e}"))) + } + } + + /// A room, its owner, and that owner's agent — the shape the whole design exists for. + struct Fixture { + room: Room, + /// The owner's chain: one link, straight from the room. + owner_chain: Vec, + owner_did: String, + /// The agent's chain: the owner's, with a narrower leaf on top. + agent_chain: Vec, + agent_did: String, + /// The owner's membership credential, issued by the room. + membership: String, + } + + async fn fixture() -> Fixture { + let (room_did, room_secret) = DID::generate_did_key(KeyType::Ed25519).expect("room key"); + let (owner_did, owner_secret) = DID::generate_did_key(KeyType::Ed25519).expect("owner key"); + let (agent_did, _) = DID::generate_did_key(KeyType::Ed25519).expect("agent key"); + let now = Utc::now(); + + // The room grants its owner read and write. This is the chain root: it is worth + // something because the *room* issued it. + let mut owner_vac = DTGCredential::new_vac( + room_did.clone(), + owner_did.clone(), + room_did.clone(), + vec!["read".into(), "write".into()], + now - Duration::minutes(1), + Some(now + Duration::days(30)), + ) + .expect("owner VAC") + .with_id("urn:uuid:vac-owner"); + owner_vac.sign(&room_secret, None).await.expect("sign"); + + // The owner narrows it for their agent — four hours, read only, no involvement + // from the room. That is the whole point of attenuation. + let mut agent_vac = owner_vac + .attenuate( + agent_did.clone(), + vec!["read".into()], + now - Duration::minutes(1), + Some(now + Duration::hours(4)), + None, + ) + .expect("attenuate") + .with_id("urn:uuid:vac-agent"); + agent_vac.sign(&owner_secret, None).await.expect("sign"); + + let mut vmc = DTGCredential::new_vmc( + room_did.clone(), + owner_did.clone(), + now - Duration::minutes(1), + Some(now + Duration::days(30)), + false, + ); + vmc.sign(&room_secret, None).await.expect("sign"); + + let enc = |c: &DTGCredential| serde_json::to_string(c).expect("serialise"); + + Fixture { + room: Room { + room_id: room_did, + owner_did: owner_did.clone(), + visibility: Visibility::Attributed, + epoch: 1, + next_version: 1, + retention_days: 90, + created_at: 0, + updated_at: 0, + }, + owner_chain: vec![enc(&owner_vac)], + owner_did, + agent_chain: vec![enc(&agent_vac), enc(&owner_vac)], + agent_did, + membership: enc(&vmc), + } + } + + fn present(f: &Fixture, chain: &[String]) -> AuthorityPresentation { + AuthorityPresentation { + membership: f.membership.clone(), + authority: chain.to_vec(), + subject_binding: None, + } + } + + fn verifier() -> DtgChainVerifier { + DtgChainVerifier::without_zk(Box::new(DidKeyResolver)) + } + + #[tokio::test] + async fn a_signed_chain_from_the_room_authorizes_its_owner() { + let f = fixture().await; + let v = verifier() + .verify( + &f.room, + &present(&f, &f.owner_chain), + Action::Write, + &f.owner_did, + ) + .await + .expect("a chain the room issued, to the party presenting it, must verify"); + assert_eq!(v.subject, f.owner_did); + assert!(v.actions.contains(&"write".to_string())); + } + + /// The feature the design exists for: an agent holding strictly less than its human, + /// with no involvement from the room in narrowing it. + #[tokio::test] + async fn an_attenuated_chain_authorizes_the_agent_for_less() { + let f = fixture().await; + let v = verifier() + .verify( + &f.room, + &present(&f, &f.agent_chain), + Action::Read, + &f.agent_did, + ) + .await + .expect("the agent reads"); + assert_eq!(v.subject, f.agent_did); + assert_eq!( + v.actions, + vec!["read".to_string()], + "attenuation narrows; it never widens" + ); + + let err = verifier() + .verify( + &f.room, + &present(&f, &f.agent_chain), + Action::Write, + &f.agent_did, + ) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("does not confer"), + "the agent must not write: {err}" + ); + } + + /// A captured presentation is not a bearer token. The chain grants to the agent, so + /// the owner cannot present it and neither can anyone else. + #[tokio::test] + async fn a_chain_is_bound_to_the_party_presenting_it() { + let f = fixture().await; + let err = verifier() + .verify( + &f.room, + &present(&f, &f.agent_chain), + Action::Read, + &f.owner_did, + ) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("not to the party that signed this request"), + "{err}" + ); + } + + /// Tampering with a credential after it was signed invalidates it, which is the whole + /// reason the proof is checked before the chain is read. + #[tokio::test] + async fn a_widened_credential_does_not_verify() { + let f = fixture().await; + + // Add `admin` to the owner's signed VAC without re-signing. + let mut doc: serde_json::Value = + serde_json::from_str(&f.owner_chain[0]).expect("parse the signed VAC"); + doc["credentialSubject"]["authority"]["actions"] + .as_array_mut() + .expect("actions is an array") + .push(serde_json::json!("admin")); + + let err = verifier() + .verify( + &f.room, + &present(&f, &[doc.to_string()]), + Action::Admin, + &f.owner_did, + ) + .await + .unwrap_err(); + assert!(format!("{err}").contains("could not be verified"), "{err}"); + } + + /// A VMC the room did not issue says nothing about membership of this room, however + /// valid it is elsewhere. + #[tokio::test] + async fn a_membership_credential_from_another_room_is_refused() { + let f = fixture().await; + let other = fixture().await; + + let mut p = present(&f, &f.owner_chain); + p.membership = other.membership; + + let err = verifier() + .verify(&f.room, &p, Action::Read, &f.owner_did) + .await + .unwrap_err(); + assert!(format!("{err}").contains("not by room"), "{err}"); + } + + /// The pooling defence on a disclosing tier: one party's membership plus another's + /// authority is two parties, not one. + #[tokio::test] + async fn membership_and_authority_must_describe_one_subject() { + let f = fixture().await; + + // A chain rooted at a *different* room's owner, presented with this room's + // membership. Both halves are perfectly valid; they just belong to two people. + let other = fixture().await; + let mut p = present(&f, &other.owner_chain); + p.membership = f.membership.clone(); + + let err = verifier() + .verify(&f.room, &p, Action::Read, &other.owner_did) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("cannot pool credentials into one") + || format!("{err}").contains("does not confer"), + "{err}" + ); + } + + /// The agent case, stated as its own property because getting it wrong is subtle: an + /// agent presents its human's membership alongside a chain whose *leaf* grants to the + /// agent. Comparing the leaf's subject to the VMC would refuse this — and refusing it + /// would remove the entire reason the design has attenuation. + #[tokio::test] + async fn an_agent_presents_its_humans_membership() { + let f = fixture().await; + let v = verifier() + .verify( + &f.room, + &present(&f, &f.agent_chain), + Action::Read, + &f.agent_did, + ) + .await + .expect("an agent is not a member; its authority descends from one"); + assert_eq!( + v.subject, f.agent_did, + "and it acts as itself, not as its human" + ); + } + + /// base64url is the other accepted form, and it must reach the same conclusion. + #[tokio::test] + async fn base64url_and_json_verify_identically() { + let f = fixture().await; + let encoded: Vec = f + .owner_chain + .iter() + .map(|c| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(c)) + .collect(); + + let mut p = present(&f, &encoded); + p.membership = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&f.membership); + + let v = verifier() + .verify(&f.room, &p, Action::Write, &f.owner_did) + .await + .expect("base64url is the same credential"); + assert_eq!(v.subject, f.owner_did); + } +} + +/// Fixtures for exercising a real room: real keys, real signatures, real chains. +/// +/// Behind a feature because a permissive fixture is exactly what should not ship. It exists +/// because three places need the same setup — this crate's own tests, `room-host`'s, and the +/// `data_room` example — and three hand-rolled versions of "a signed chain rooted at the +/// room" would drift, with the drift showing up as a test that passes for the wrong reason. +#[cfg(feature = "test-support")] +pub mod test_support { + use super::*; + use chrono::{Duration, Utc}; + + /// One party: a `did:key` in the three forms different layers want. + pub struct Party { + /// `did:key:z6Mk…` + pub did: String, + /// The multibase private key, for signing Trust Task documents. + pub secret_multibase: String, + /// The Affinidi secret, for signing credentials. + pub secret: affinidi_tdk::secrets_resolver::secrets::Secret, + } + + impl Party { + /// Mint one. + pub fn new() -> Self { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).expect("OS randomness"); + let signing = ed25519_dalek::SigningKey::from_bytes(&seed); + let did = format!( + "did:key:{}", + vta_sdk::did_key::ed25519_multibase_pubkey(&signing.verifying_key().to_bytes()) + ); + Self { + secret: vta_sdk::did_key::secrets_from_did_key(&did, &seed) + .expect("build secrets") + .signing, + secret_multibase: multibase::encode(multibase::Base::Base58Btc, seed), + did, + } + } + } + + impl Default for Party { + fn default() -> Self { + Self::new() + } + } + + /// A room, its owner, and the owner's agent — with every credential actually signed. + pub struct RoomFixture { + /// The room itself. Its DID is a `did:key` so a host needs no network to verify it. + pub room: Room, + /// The room's own key, which issues membership and the chain root. + pub room_key: Party, + pub owner: Party, + pub agent: Party, + /// The owner's chain: one link, from the room. + pub owner_chain: Vec, + /// The agent's chain: the owner's, with a read-only leaf on top. + pub agent_chain: Vec, + /// The owner's membership credential. + pub membership: String, + } + + impl RoomFixture { + /// Build one at `visibility`. + pub async fn new(visibility: Visibility) -> Self { + let room_key = Party::new(); + let owner = Party::new(); + let agent = Party::new(); + let now = Utc::now(); + + let mut owner_vac = DTGCredential::new_vac( + room_key.did.clone(), + owner.did.clone(), + room_key.did.clone(), + vec![ + "read".into(), + "write".into(), + "curate".into(), + "admin".into(), + ], + now - Duration::minutes(1), + Some(now + Duration::days(30)), + ) + .expect("owner VAC") + .with_id("urn:uuid:vac-owner"); + owner_vac + .sign(&room_key.secret, None) + .await + .expect("sign the owner VAC"); + + // Four hours, read only, and the room is not involved — which is the whole + // reason attenuation exists. + let mut agent_vac = owner_vac + .attenuate( + agent.did.clone(), + vec!["read".into()], + now - Duration::minutes(1), + Some(now + Duration::hours(4)), + None, + ) + .expect("attenuate to the agent") + .with_id("urn:uuid:vac-agent"); + agent_vac + .sign(&owner.secret, None) + .await + .expect("sign the agent VAC"); + + let mut vmc = DTGCredential::new_vmc( + room_key.did.clone(), + owner.did.clone(), + now - Duration::minutes(1), + Some(now + Duration::days(30)), + false, + ); + vmc.sign(&room_key.secret, None) + .await + .expect("sign the VMC"); + + let enc = |c: &DTGCredential| serde_json::to_string(c).expect("serialise"); + + Self { + room: Room { + room_id: room_key.did.clone(), + owner_did: owner.did.clone(), + visibility, + epoch: 1, + next_version: 1, + retention_days: 90, + created_at: 0, + updated_at: 0, + }, + owner_chain: vec![enc(&owner_vac)], + agent_chain: vec![enc(&agent_vac), enc(&owner_vac)], + membership: enc(&vmc), + room_key, + owner, + agent, + } + } + + /// The owner's presentation. + pub fn as_owner(&self) -> AuthorityPresentation { + AuthorityPresentation { + membership: self.membership.clone(), + authority: self.owner_chain.clone(), + subject_binding: None, + } + } + + /// The agent's presentation — the owner's membership, a narrower chain. + pub fn as_agent(&self) -> AuthorityPresentation { + AuthorityPresentation { + membership: self.membership.clone(), + authority: self.agent_chain.clone(), + subject_binding: None, + } + } + } +} diff --git a/vti-rooms/Cargo.toml b/vti-rooms/Cargo.toml new file mode 100644 index 000000000..7e1522abb --- /dev/null +++ b/vti-rooms/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "vti-rooms" +description = "Data-room storage, wire types, and authorization — the parts of a room that are not a service" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[dependencies] +# The store abstraction and AppError. This crate's only internal dependency: +# a room's storage and authorization need nothing from a community service, +# which is the whole reason they are extractable. +vti-common = { path = "../vti-common", version = "0.16" } + +serde = { workspace = true } +serde_json = { workspace = true } +# The published schema types `updatedAt` as an RFC 3339 string. +chrono = { workspace = true } +# Verification may resolve a DID, so ChainVerifier is async; async-trait keeps it +# dyn-compatible, as it does for vti_common::auth::backend. +async-trait = "0.1" + +[dev-dependencies] +tempfile = "3" +# The published rooms/* schemas, so the hand-rolled wire types above are checked +# against the spec rather than only against themselves. +trust-tasks-rs = { workspace = true } +tokio = { workspace = true } diff --git a/vti-rooms/src/authz.rs b/vti-rooms/src/authz.rs new file mode 100644 index 000000000..660245e4c --- /dev/null +++ b/vti-rooms/src/authz.rs @@ -0,0 +1,612 @@ +//! Authorizing an operation on a room. +//! +//! # The invariant this file exists to hold +//! +//! **Nothing here reads this service's ACL, member roster, or session state.** A room +//! operation is authorized by an authority chain the *room* issued, verified against the +//! room's own identifier. That is invariant I5 of the design note, and it is what makes a +//! room portable: the moment a host's own state participates in a room decision, the room +//! cannot move to another host and this service has joined its membership. +//! +//! It is also the easiest invariant in the design to lose by accident — one convenience +//! lookup against `members_ks` "just to check", and the property is gone with every test +//! still passing. [`AuthorizedAction`] is deliberately constructible only by +//! [`authorize`], so a handler cannot skip the check and cannot substitute a different one. +//! +//! # Two halves, and why they are separated +//! +//! Authorizing a room operation has two parts, and only one of them belongs in a crate that +//! anything can depend on: +//! +//! - **Shape.** Is there a chain at all, is it within the depth bound, is there a membership +//! credential, does a private room carry its subject binding? These need no credential +//! library, no DID resolution and no network. They live here. +//! - **Cryptography.** Does each credential's proof verify, does the chain reach a root the +//! room issued, and does it confer the action being asked for? That needs a credential +//! library and a resolver, and it is reached through [`ChainVerifier`]. +//! +//! The split is not squeamishness about dependencies. `verify_chain` and proof verification +//! need `dtg-credentials`, which needs a DID resolver, which is a different thing on a VTC +//! than on a standalone room host. Pinning either choice into this crate would make the +//! storage layer un-reusable for the other. The trait is what lets **one** decision about +//! what is safe to serve be shared by hosts that resolve DIDs differently. +//! +//! It also means a host that has configured no verifier cannot accidentally serve a sealed +//! room: [`RefusesEverything`] is the only thing it has, and it refuses. +//! +//! # The shape checks run first, always +//! +//! [`authorize`] runs every shape check before it calls the verifier, and the order is +//! load-bearing: depth is the cheapest check and the one that bounds the cost of +//! verification, which is linear in chain length and runs on every operation. + +use vti_common::error::AppError; + +use crate::wire::AuthorityPresentation; +use crate::{Room, Visibility}; + +/// Maximum links in an authority chain, including the root. +/// +/// Verification is linear in chain length and runs on every operation, so an unbounded +/// chain is a denial-of-service surface. The known uses need far less: a person attenuating +/// to an agent is depth 2, that agent to a sub-agent is depth 3. A chain near this ceiling +/// is a signal that authority is being re-delegated further than intended. +pub const MAX_CHAIN_DEPTH: usize = 8; + +/// An action on a room. +/// +/// Compared exactly and case-sensitively as wire strings, and **no action implies another**: +/// `Admin` does not grant `Write` unless the credential lists both. Implication is how a +/// permission model quietly widens. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Read, + Write, + Curate, + Admin, +} + +impl Action { + /// The wire form. + pub fn as_str(&self) -> &'static str { + match self { + Action::Read => "read", + Action::Write => "write", + Action::Curate => "curate", + Action::Admin => "admin", + } + } +} + +/// What a verifier concludes about a presentation. +/// +/// Deliberately narrow: the caller gets the subject and the actions the chain confers, and +/// nothing that would tempt it to re-derive a decision the verifier already made. +#[derive(Debug, Clone)] +pub struct VerifiedChain { + /// The party the leaf grants to — who may act. + pub subject: String, + /// The actions the chain confers, already narrowed by every link above the leaf. + pub actions: Vec, +} + +/// Cryptographic verification of a presentation. +/// +/// One implementation per host, because the resolver differs; one *decision*, because both +/// implementations answer the same question and [`authorize`] is the only caller. +/// +/// # Contract +/// +/// An implementation MUST verify, at minimum: +/// +/// 1. every credential in the chain carries a valid proof; +/// 2. the chain's root was issued by the room — a chain reaching any other party confers +/// nothing here, however well-formed; +/// 3. no link widens the actions or scope of its parent; +/// 4. every link is within its validity window, and any audience is honoured; +/// 5. the membership credential and the chain describe the same subject; +/// 6. the chain's leaf grants to `presenter` — the party the *transport* authenticated, +/// not one named in the payload. +/// +/// (5) is the pooling defence, and it is the verifier's because it needs both credentials +/// parsed. On a `private` room it is proved in zero knowledge from the subject binding; on +/// the disclosing tiers it is a comparison. +/// +/// (6) is what stops a captured presentation being replayed. A presentation is a bearer +/// object — it names what may be done, not who is doing it — so without binding it to the +/// authenticated sender, anyone who observes one inherits it. `presenter` is therefore the +/// DID a proof established, never a field a caller filled in. +/// +/// Returning `Ok` for a chain that fails any of these is a privilege escalation, not a +/// leniency: anyone can mint a well-formed VAC naming any scope and any action. +/// Verification may need to resolve a DID, so this is async. Keeping it sync would force +/// every implementation to either block a runtime thread or pre-resolve keys it cannot know +/// it will need — and pre-resolution is how a verifier ends up trusting a cache instead of a +/// signature. Same shape as `vti_common::auth::backend`, for the same reason. +#[async_trait::async_trait] +pub trait ChainVerifier: Send + Sync { + /// Verify `presentation` against `room` for `action`. + async fn verify( + &self, + room: &Room, + presentation: &AuthorityPresentation, + action: Action, + presenter: &str, + ) -> Result; +} + +/// The verifier a host has before it configures one. +/// +/// A host with no credential library cannot check a chain, and a chain nobody checked +/// authorizes nothing — so this refuses, rather than defaulting to permissive and relying on +/// an operator to notice. It is the only safe default a fail-open seam can have. +#[derive(Debug, Clone, Copy, Default)] +pub struct RefusesEverything; + +#[async_trait::async_trait] +impl ChainVerifier for RefusesEverything { + async fn verify( + &self, + room: &Room, + _presentation: &AuthorityPresentation, + _action: Action, + _presenter: &str, + ) -> Result { + Err(AppError::Forbidden(format!( + "room `{}` has no chain verifier configured on this host, and a chain nobody \ + verified authorizes nothing", + room.room_id + ))) + } +} + +/// Proof that [`authorize`] ran and allowed this operation. +/// +/// Handlers take this rather than a presentation, so an operation that forgot to authorize +/// does not compile — the same typestate discipline the workspace uses for verified wire +/// forms. There is no public constructor. +#[derive(Debug)] +pub struct AuthorizedAction { + action: Action, + room_id: String, + verified: VerifiedChain, +} + +impl AuthorizedAction { + /// The action that was authorized. + pub fn action(&self) -> Action { + self.action + } + /// The room it was authorized against. + pub fn room_id(&self) -> &str { + &self.room_id + } + /// Who the chain says may act. + /// + /// This is the subject the *verifier* established, never one a caller supplied — which + /// is why a handler recording an author reads it from here. + pub fn subject(&self) -> &str { + &self.verified.subject + } + /// Everything the chain confers, which is at least the action asked for. + pub fn conferred(&self) -> &[String] { + &self.verified.actions + } +} + +/// Authorize `action` on `room` from `presentation`. +/// +/// Returns [`AuthorizedAction`] on success. Every failure is [`AppError::Forbidden`], which +/// the dispatch layer maps to the framework's `permission_denied` — the reason text +/// distinguishes the cases for an operator reading logs, without telling a caller which +/// part of their chain to adjust. +pub async fn authorize( + room: &Room, + presentation: &AuthorityPresentation, + action: Action, + presenter: &str, + verifier: &dyn ChainVerifier, +) -> Result { + // Depth first: it is the cheapest check and the one that bounds the cost of every + // check after it — verification is linear in chain length and runs on every operation. + if presentation.authority.is_empty() { + return Err(AppError::Forbidden( + "no authority chain presented; a room operation is authorized by the chain, \ + never by this service's own records" + .into(), + )); + } + if presentation.authority.len() > MAX_CHAIN_DEPTH { + return Err(AppError::Forbidden(format!( + "authority chain is {} deep, exceeding the maximum of {MAX_CHAIN_DEPTH}", + presentation.authority.len() + ))); + } + + if presentation.membership.trim().is_empty() { + return Err(AppError::Forbidden( + "no membership credential presented".into(), + )); + } + + // The pooling defence. On a tier that withholds the subject, a presentation without a + // same-subject proof lets two parties combine one's membership with the other's + // authority and verify as a single party holding both. Checked here because its + // *absence* is a shape problem; whether a present one actually proves same-subject is + // the verifier's job. + if matches!(room.visibility, Visibility::Private) && presentation.subject_binding.is_none() { + return Err(AppError::Forbidden( + "a private room requires a subject binding proving the membership credential and \ + the authority chain describe the same subject; without it two parties can pool \ + credentials" + .into(), + )); + } + + // A presentation names what may be done, not who is doing it, so an unbound one is a + // bearer token: whoever observes it inherits it. The presenter is the DID the request's + // own proof established. + if presenter.trim().is_empty() { + return Err(AppError::Forbidden( + "no authenticated presenter; a presentation not bound to the party that signed \ + the request is replayable by anyone who observes it" + .into(), + )); + } + + // Everything above is shape. This is the decision. + let verified = verifier + .verify(room, presentation, action, presenter) + .await?; + + // The verifier answers "what does this chain confer"; this asserts the answer covers + // what was asked. Two steps rather than one because a verifier that also decided + // sufficiency could quietly widen it — and because no action implies another, this is + // an exact membership test, not a comparison. + if !verified.actions.iter().any(|a| a == action.as_str()) { + return Err(AppError::Forbidden(format!( + "the chain confers {:?}, which does not include `{}`", + verified.actions, + action.as_str() + ))); + } + + // The verifier is contracted to bind the leaf to `presenter`, and this re-states it + // where the seam can see it. A verifier that returned some other subject would be + // authorizing one party's chain for another's request; catching that here means the + // property does not depend on every implementation remembering it. + if verified.subject != presenter { + return Err(AppError::Forbidden(format!( + "the chain grants to `{}`, not to the party that signed this request", + verified.subject + ))); + } + + Ok(AuthorizedAction { + action, + room_id: room.room_id.clone(), + verified, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn room(visibility: Visibility) -> Room { + Room { + room_id: "did:key:zRoom".into(), + owner_did: "did:key:zOwner".into(), + visibility, + epoch: 1, + next_version: 1, + retention_days: 90, + created_at: 0, + updated_at: 0, + } + } + + /// The DID the request's own proof established. + const PRESENTER: &str = "did:key:zAgent"; + + fn presentation(depth: usize, binding: bool) -> AuthorityPresentation { + AuthorityPresentation { + membership: "vmc".into(), + authority: (0..depth).map(|i| format!("vac-{i}")).collect(), + subject_binding: binding.then(|| "binding".to_string()), + } + } + + /// A verifier that vouches for whatever it is handed. + /// + /// Stands in for the cryptographic half so the shape half can be tested on its own. It + /// is `#[cfg(test)]` on purpose — a permissive verifier is a privilege escalation, and + /// the only one shipped is [`RefusesEverything`]. + struct Vouches(Vec); + + impl Vouches { + fn for_all() -> Self { + Self( + ["read", "write", "curate", "admin"] + .iter() + .map(|s| s.to_string()) + .collect(), + ) + } + fn read_only() -> Self { + Self(vec!["read".into()]) + } + } + + #[async_trait::async_trait] + impl ChainVerifier for Vouches { + async fn verify( + &self, + _room: &Room, + _presentation: &AuthorityPresentation, + _action: Action, + presenter: &str, + ) -> Result { + Ok(VerifiedChain { + subject: presenter.to_string(), + actions: self.0.clone(), + }) + } + } + + /// A verifier that vouches for a chain granting to somebody else. + struct VouchesForSomeoneElse; + + #[async_trait::async_trait] + impl ChainVerifier for VouchesForSomeoneElse { + async fn verify( + &self, + _room: &Room, + _presentation: &AuthorityPresentation, + _action: Action, + _presenter: &str, + ) -> Result { + Ok(VerifiedChain { + subject: "did:key:zSomeoneElse".into(), + actions: vec!["read".into()], + }) + } + } + + #[tokio::test] + async fn a_verified_presentation_authorizes_what_the_chain_confers() { + let ok = authorize( + &room(Visibility::Open), + &presentation(2, false), + Action::Write, + PRESENTER, + &Vouches::for_all(), + ) + .await + .expect("should authorize"); + assert_eq!(ok.action(), Action::Write); + assert_eq!(ok.room_id(), "did:key:zRoom"); + assert_eq!( + ok.subject(), + PRESENTER, + "the subject is the verifier's finding, never the caller's claim" + ); + } + + /// The whole point of the agent story: a chain conferring `read` writes nothing, and + /// the refusal comes from `authorize` rather than from any handler remembering to check. + #[tokio::test] + async fn a_read_only_chain_cannot_write() { + let err = authorize( + &room(Visibility::Open), + &presentation(2, false), + Action::Write, + PRESENTER, + &Vouches::read_only(), + ) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("does not include `write`"), + "{err}" + ); + + authorize( + &room(Visibility::Open), + &presentation(2, false), + Action::Read, + PRESENTER, + &Vouches::read_only(), + ) + .await + .expect("but it reads"); + } + + /// `admin` does not follow from `write`. Implication is how a permission model widens. + #[tokio::test] + async fn no_action_implies_another() { + let err = authorize( + &room(Visibility::Open), + &presentation(1, false), + Action::Admin, + PRESENTER, + &Vouches(vec!["read".into(), "write".into(), "curate".into()]), + ) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("does not include `admin`"), + "{err}" + ); + } + + /// The chain is the authorization. Nothing else is. + #[tokio::test] + async fn an_empty_chain_authorizes_nothing() { + let err = authorize( + &room(Visibility::Open), + &presentation(0, false), + Action::Read, + PRESENTER, + &Vouches::for_all(), + ) + .await + .unwrap_err(); + assert!(format!("{err}").contains("no authority chain"), "{err}"); + } + + #[tokio::test] + async fn a_chain_past_the_ceiling_is_refused() { + let err = authorize( + &room(Visibility::Open), + &presentation(MAX_CHAIN_DEPTH + 1, false), + Action::Read, + PRESENTER, + &Vouches::for_all(), + ) + .await + .unwrap_err(); + assert!(format!("{err}").contains("exceeding the maximum"), "{err}"); + } + + /// Depth is checked before the verifier runs, so an over-deep chain costs nothing to + /// refuse — which is the reason it is first. + #[tokio::test] + async fn the_shape_checks_run_before_the_verifier() { + struct Panics; + #[async_trait::async_trait] + impl ChainVerifier for Panics { + async fn verify( + &self, + _: &Room, + _: &AuthorityPresentation, + _: Action, + _: &str, + ) -> Result { + panic!("the verifier must not be reached for a malformed presentation"); + } + } + + for p in [ + presentation(0, false), + presentation(MAX_CHAIN_DEPTH + 1, false), + ] { + assert!( + authorize( + &room(Visibility::Open), + &p, + Action::Read, + PRESENTER, + &Panics + ) + .await + .is_err() + ); + } + } + + /// Without this, two parties pool credentials and verify as one. + #[tokio::test] + async fn a_private_room_refuses_a_presentation_with_no_subject_binding() { + let err = authorize( + &room(Visibility::Private), + &presentation(2, false), + Action::Read, + PRESENTER, + &Vouches::for_all(), + ) + .await + .unwrap_err(); + assert!(format!("{err}").contains("subject binding"), "{err}"); + } + + /// A host that has configured no verifier serves nothing — on any tier, not just the + /// sealed ones. Fail-closed is the only safe default for a seam like this. + #[tokio::test] + async fn a_host_with_no_verifier_authorizes_nothing() { + for v in [ + Visibility::Open, + Visibility::Attributed, + Visibility::Private, + ] { + let err = authorize( + &room(v), + &presentation(2, true), + Action::Read, + PRESENTER, + &RefusesEverything, + ) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("no chain verifier configured"), + "{v:?}: {err}" + ); + } + } + + /// A presentation is a bearer object. Without binding it to the signer, anyone who + /// observes one inherits it. + #[tokio::test] + async fn an_unbound_presentation_is_refused() { + let err = authorize( + &room(Visibility::Open), + &presentation(2, false), + Action::Read, + " ", + &Vouches::for_all(), + ) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("no authenticated presenter"), + "{err}" + ); + } + + /// The seam re-states the binding rather than trusting each verifier to remember it. + #[tokio::test] + async fn a_chain_granting_to_someone_else_is_refused() { + let err = authorize( + &room(Visibility::Open), + &presentation(2, false), + Action::Read, + PRESENTER, + &VouchesForSomeoneElse, + ) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("not to the party that signed this request"), + "{err}" + ); + } + + #[tokio::test] + async fn a_missing_membership_credential_is_refused() { + let mut p = presentation(2, false); + p.membership = " ".into(); + let err = authorize( + &room(Visibility::Open), + &p, + Action::Read, + PRESENTER, + &Vouches::for_all(), + ) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("no membership credential"), + "{err}" + ); + } + + /// No action implies another — the property that keeps a permission model from + /// widening quietly. + #[test] + fn actions_are_distinct_wire_strings() { + assert_eq!(Action::Read.as_str(), "read"); + assert_eq!(Action::Admin.as_str(), "admin"); + assert_ne!(Action::Admin.as_str(), Action::Write.as_str()); + } +} diff --git a/vti-rooms/src/lib.rs b/vti-rooms/src/lib.rs new file mode 100644 index 000000000..974081c0c --- /dev/null +++ b/vti-rooms/src/lib.rs @@ -0,0 +1,271 @@ +//! Data rooms — storage, wire types, and authorization. +//! +//! A **data room** is a shared space whose access is governed by credentials the *room +//! itself* issues, not by anything this service stores. That single property is what the +//! rest of this module is arranged around, and it is worth stating before the types, +//! because it inverts the assumption every other keyspace here is built on. +//! +//! # What this module deliberately does not hold +//! +//! **There is no member list.** Not omitted for now — there must not be one. Authorization +//! is a presentation carrying a membership credential and an authority chain, verified +//! against the room's own identifier. The moment this service keeps a roster and consults +//! it, three things stop being true at once: the room can no longer move to another host +//! without reissuing credentials, this service has become part of the room's membership +//! definition, and a room whose contents we cannot read acquires a member list we can. +//! +//! So the row below carries an owner, a visibility, an epoch and a retention period, and +//! nothing about who belongs. See `docs/05-design-notes/data-rooms.md` §1 (invariant I5). +//! +//! # What this service can and cannot see +//! +//! Set by the room's [`Visibility`], fixed at creation: +//! +//! | | `Open` | `Attributed` | `Private` | +//! |---|---|---|---| +//! | Record content | cleartext | sealed | sealed | +//! | Which member acted | visible | visible | unlinkable proof | +//! | Owner | visible | visible | visible | +//! +//! The owner is visible at every tier on purpose. A room whose contents nobody here can +//! read still has a party answerable for it existing — for quota, for abuse, and for the +//! lifecycle notice in §9 of the design note. +//! +//! # Scope of this module +//! +//! # Why this is a crate and not part of a service +//! +//! A room's storage and its authorization need nothing from a community service. That is +//! not an accident of layering — it is invariant I5 restated as a dependency graph: because +//! a room is authorized by credentials the room itself issued, the code deciding a room +//! operation cannot need a roster, a policy engine, or a session store. So it does not have +//! one, and the compiler enforces that this crate depends on `vti-common` and nothing else. +//! +//! The concrete win is a second consumer. A **room host** — someone hosting their own rooms +//! on their own infrastructure, topology T1 of the design — stores ciphertext and verifies +//! presentations. Without this crate, doing that means shipping an entire community service: +//! member lifecycle, policy, credential issuance, a website, an admin SPA. With it, a room +//! host is this crate plus a dispatch surface. +//! +//! Three layers: +//! +//! - [`storage`] — the keyspaces and their invariants. +//! - [`wire`] — the Trust-Task payload types, hand-written against the schemas in +//! `trustoverip/dtgwg-trust-tasks-tf#346` until the generated bindings publish. +//! - [`authz`] — deciding whether an operation is allowed, **without reading any host's ACL +//! or roster**. The invariant the whole design rests on. +//! +//! The Trust-Task **handlers** are deliberately *not* here. Dispatch is a service's spine, +//! and a spine is not extractable — see `docs/05-design-notes/vta-service-decomposition.md`. +//! Each host writes its own thin handlers over these three layers. + +pub mod authz; +pub mod storage; +pub mod wire; + +use serde::{Deserialize, Serialize}; + +/// Keyspace holding one row per room. +/// +/// Named here rather than in a host's registry because the *name* is part of the storage +/// contract: two hosts using different names could not serve the same room's data directory. +pub const ROOMS_KEYSPACE: &str = "rooms"; + +/// Keyspace holding room records. +pub const ROOM_RECORDS_KEYSPACE: &str = "room_records"; + +/// How much of a room this service can see. +/// +/// **Immutable for the life of a room.** A downgrade cannot un-see cleartext, and an +/// upgrade would protect only what came after while presenting as though it protected +/// everything. To change the visibility of some material, make another room and move it +/// deliberately. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Visibility { + /// Records are cleartext: searchable here, and readable by whoever operates this + /// service. Right for material where that is not a threat and losing search is a real + /// cost. + Open, + /// Record content is sealed; the acting member is still disclosed. The tier for anyone + /// under an obligation to produce per-member access logs. + Attributed, + /// Content is sealed and membership is presented in zero knowledge: this service + /// verifies that *a* member acted without learning which. + Private, +} + +impl Visibility { + /// Whether this service holds record content in the clear. + /// + /// The one place to ask. A caller testing `== Visibility::Open` in several places will + /// eventually miss one, and the failure mode is storing a plaintext record on a tier + /// that promised not to. + pub fn stores_cleartext(&self) -> bool { + matches!(self, Visibility::Open) + } + + /// Whether a record's acting member is disclosed to this service. + pub fn discloses_actor(&self) -> bool { + matches!(self, Visibility::Open | Visibility::Attributed) + } +} + +/// A room, as this service holds it. +/// +/// Note what is absent: no members, no keys, no credentials. This service is told the +/// epoch *number* so it can serve the right ciphertext, and never the key. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Room { + /// The room's own identifier, minted by its owner before registration. + /// + /// This service does not assign one. A room identified by something its host chose + /// could not move to another host without changing identity, and portability is what + /// the whole design rests on. + pub room_id: String, + + /// The accountable party: controller of the room's identifier, issuer of every + /// credential in it, and the party addressed about quota, abuse and lifecycle. + pub owner_did: String, + + /// Fixed at creation. See [`Visibility`]. + pub visibility: Visibility, + + /// The current key epoch. Advanced by the owner on removal; this service records the + /// number and never learns the key. + pub epoch: u32, + + /// The next record version to assign. + /// + /// Monotonic **per room**, not per record — one comparable number is what a + /// `sinceVersion` watermark needs, and per-record counters are not comparable to each + /// other. Learned the expensive way by the app-state store; see + /// `docs/05-design-notes/appstate-store.md` §2. + pub next_version: u64, + + /// How long this service holds the room after its epoch lapses without renewal. + /// + /// Stated at creation rather than discovered later: a reclamation that surprises a + /// member is a failure of the design, not of the member. + pub retention_days: u32, + + /// Unix-epoch seconds. + pub created_at: u64, + /// Unix-epoch seconds; bumped on epoch advance and on record writes. + pub updated_at: u64, +} + +/// Curation state of a record. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RecordStatus { + /// Normal. + Active, + /// Superseded but retained; a client demotes it in recall rather than hiding it. + Deprecated, + /// A tombstone. The body is gone; the key, version and epoch remain. + /// + /// Retained rather than deleted because incremental sync needs it: without a tombstone + /// a puller learns of every create and update and never of a delete, so retracted + /// records resurrect on the next full rebuild and disagree with peers that saw the + /// retraction. + Retracted, +} + +/// One record. +/// +/// On `Attributed` and `Private` rooms `sealed` carries the ciphertext and `cleartext` is +/// `None`; on `Open` it is the other way round. Enforced at the operations layer rather +/// than the type, because the invariant is per-room and the type is per-record. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Record { + /// The record's key within the room. + /// + /// On the sealed tiers this MUST be opaque — a random identifier, never a descriptive + /// slug. A key reading `decision/acquire-northwind` defeats the encryption sitting + /// beside it. Structured naming belongs inside the sealed body. + pub key: String, + + /// Server-assigned, monotonic per room. Also the `sinceVersion` watermark. + pub version: u64, + + /// The key epoch this record was sealed under. `None` on an `Open` room. + pub epoch: Option, + + /// Curation state. + pub status: RecordStatus, + + /// Sealed content, base64url. Present on the sealed tiers. + #[serde(skip_serializing_if = "Option::is_none")] + pub sealed: Option, + + /// AEAD nonce, base64url. Present with `sealed`. + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option, + + /// Cleartext content. Present only on an `Open` room. + #[serde(skip_serializing_if = "Option::is_none")] + pub cleartext: Option, + + /// The member who wrote it, where the tier discloses one. + /// + /// `None` on a `Private` room — there the author lives inside the sealed body, where + /// only members can read it. + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + + /// Unix-epoch seconds. + pub updated_at: u64, +} + +/// Unix seconds as an RFC 3339 timestamp. +/// +/// A value beyond what a timestamp can express renders as the epoch rather than panicking: +/// a listing is a read path, and a corrupt stored time should not take the room down. +fn rfc3339(unix_seconds: u64) -> String { + chrono::DateTime::from_timestamp(unix_seconds as i64, 0) + .unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).expect("epoch is in range")) + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +impl Record { + /// The metadata projection a listing returns. + /// + /// **Never the body.** Ranking happens on the client, and a service that returned every + /// body would make a caller pay for the whole room on every listing — and on a sealed + /// tier could not usefully rank them anyway. + /// The schema types every optional member — `epoch` is `integer`, `author` is + /// `string` — and sets `additionalProperties: false`, so an absent member has to be + /// *absent*. Emitting `null` fails validation rather than reading as "not applicable", + /// which is why this builds a map instead of one `json!` literal. + pub fn metadata(&self) -> serde_json::Value { + let mut map = serde_json::Map::new(); + map.insert("key".into(), serde_json::json!(self.key)); + map.insert("version".into(), serde_json::json!(self.version)); + map.insert("status".into(), serde_json::json!(self.status)); + // RFC 3339, because that is what the published schema types it as + // (`format: date-time`) and what every other timestamp on the wire already is. + // Storage keeps unix seconds; only the projection renders. + map.insert( + "updatedAt".into(), + serde_json::json!(rfc3339(self.updated_at)), + ); + if let Some(epoch) = self.epoch { + map.insert("epoch".into(), serde_json::json!(epoch)); + } + if let Some(author) = &self.author { + map.insert("author".into(), serde_json::json!(author)); + } + // `title` and `description` are the open tier's, and live in the cleartext body. + if let Some(cleartext) = &self.cleartext { + for field in ["title", "description"] { + if let Some(v) = cleartext.get(field).filter(|v| v.is_string()) { + map.insert(field.into(), v.clone()); + } + } + } + serde_json::Value::Object(map) + } +} diff --git a/vti-rooms/src/storage.rs b/vti-rooms/src/storage.rs new file mode 100644 index 000000000..01c2998c4 --- /dev/null +++ b/vti-rooms/src/storage.rs @@ -0,0 +1,578 @@ +//! CRUD over the `rooms:` and `room_records:` keyspaces. +//! +//! # Key layout +//! +//! ```text +//! rooms: -> Room +//! room_records:: -> Record +//! ``` +//! +//! The trailing `:` on the record prefix makes a scan room-exact — a prefix of `a:` never +//! matches `ab:` — so one room's listing can never return another's, in the store layer as +//! well as at the authorization layer above it. +//! +//! # Version assignment +//! +//! Versions are allocated from [`super::Room::next_version`], monotonic **per room**. A +//! write reads the room, takes the next number, writes the record, then writes the room +//! back. That read-modify-write is not atomic across the two keyspaces, and the failure it +//! can produce is a *skipped* version rather than a duplicated one — which is the direction +//! it has to fail in, because `sinceVersion` only needs monotonicity, not density. A +//! duplicate would make two records indistinguishable to a watermark; a gap costs nothing. + +use vti_common::error::AppError; +use vti_common::store::KeyspaceHandle; + +use super::{Record, RecordStatus, Room}; + +/// `rooms:`. +pub const ROOMS_PREFIX: &str = "rooms:"; +/// `room_records::`. +pub const RECORDS_PREFIX: &str = "room_records:"; + +fn room_key(room_id: &str) -> String { + format!("{ROOMS_PREFIX}{room_id}") +} + +fn record_key(room_id: &str, key: &str) -> String { + format!("{RECORDS_PREFIX}{room_id}:{key}") +} + +/// Every record in one room. The trailing `:` is what makes this room-exact. +fn record_prefix(room_id: &str) -> String { + format!("{RECORDS_PREFIX}{room_id}:") +} + +/// Register a room. +/// +/// Refuses to replace an existing one: re-registering would silently reset the epoch and +/// the version counter, which a client would read as the room having been rolled back. +pub async fn create_room(rooms: &KeyspaceHandle, room: &Room) -> Result<(), AppError> { + if rooms.get_raw(room_key(&room.room_id)).await?.is_some() { + return Err(AppError::Conflict(format!( + "room `{}` is already registered here", + room.room_id + ))); + } + rooms.insert(room_key(&room.room_id), room).await +} + +/// Fetch a room, or [`AppError::NotFound`]. +pub async fn get_room(rooms: &KeyspaceHandle, room_id: &str) -> Result { + let raw = rooms + .get_raw(room_key(room_id)) + .await? + .ok_or_else(|| AppError::NotFound(format!("room `{room_id}` not found")))?; + serde_json::from_slice(&raw) + .map_err(|e| AppError::Internal(format!("decode room `{room_id}`: {e}"))) +} + +/// Advance a room's epoch. +/// +/// `new_epoch` must be exactly one greater than the current one. A gap would leave records +/// sealed under an epoch no member was ever given a key for, and a repeat would let a +/// removed member's key open material written after their removal — which is the whole +/// point of advancing. +/// +/// This service records the number. It never learns the key. +pub async fn advance_epoch( + rooms: &KeyspaceHandle, + room_id: &str, + new_epoch: u32, + now: u64, +) -> Result { + let mut room = get_room(rooms, room_id).await?; + if new_epoch != room.epoch + 1 { + return Err(AppError::Validation(format!( + "epoch must advance by exactly one: room `{room_id}` is at {}, got {new_epoch}", + room.epoch + ))); + } + room.epoch = new_epoch; + room.updated_at = now; + rooms.insert(room_key(room_id), &room).await?; + Ok(room) +} + +/// Write a record, assigning it the room's next version. +/// +/// `expected_version` is an optional precondition: +/// - `None` — overwrite unconditionally. +/// - `Some(0)` — create only; fails if the key exists. +/// - `Some(n)` — fails unless the stored record is at version `n`. +/// +/// On mismatch the error carries the **current version**, so a caller does not have to +/// re-read to learn what it lost to. A bare rejection would force a re-read, and between +/// the rejection and the re-read the record can change again — the pattern has no fixed +/// point under contention. +pub async fn put_record( + rooms: &KeyspaceHandle, + records: &KeyspaceHandle, + room_id: &str, + mut record: Record, + expected_version: Option, + now: u64, +) -> Result { + let mut room = get_room(rooms, room_id).await?; + + let existing = get_record(records, room_id, &record.key).await.ok(); + + if let Some(expected) = expected_version { + match (&existing, expected) { + (Some(_), 0) => { + return Err(AppError::Conflict(format!( + "record `{}` already exists (create-only requested)", + record.key + ))); + } + (None, 0) => {} + (Some(current), n) if current.version != n => { + return Err(AppError::Conflict(format!( + "record `{}` is at version {}, not {n}", + record.key, current.version + ))); + } + (None, n) => { + return Err(AppError::Conflict(format!( + "record `{}` does not exist, so it cannot be at version {n}", + record.key + ))); + } + _ => {} + } + } + + // Content shape must match the room's visibility. Enforced here rather than in the + // type, because the invariant belongs to the room and the type belongs to the record. + if room.visibility.stores_cleartext() { + if record.sealed.is_some() { + return Err(AppError::Validation( + "an open room stores cleartext records; `sealed` was supplied".into(), + )); + } + if record.cleartext.is_none() { + return Err(AppError::Validation( + "an open room requires `cleartext`".into(), + )); + } + } else { + if record.cleartext.is_some() { + return Err(AppError::Validation(format!( + "room `{room_id}` is {:?}; cleartext must not be stored here", + room.visibility + ))); + } + if record.sealed.is_none() { + return Err(AppError::Validation( + "a sealed room requires `sealed` content".into(), + )); + } + // The epoch a record was sealed under must be the room's current one, or a reader + // holding the current key cannot open it. + if record.epoch != Some(room.epoch) { + return Err(AppError::Validation(format!( + "record is sealed under epoch {:?}, room `{room_id}` is at {}", + record.epoch, room.epoch + ))); + } + } + + // A `Private` room must not carry an author here: on that tier authorship lives inside + // the sealed body, where only members can read it. + if matches!(room.visibility, super::Visibility::Private) && record.author.is_some() { + return Err(AppError::Validation( + "a private room does not record an author; authorship belongs inside the sealed body" + .into(), + )); + } + + record.version = room.next_version; + record.updated_at = now; + room.next_version += 1; + room.updated_at = now; + + records + .insert(record_key(room_id, &record.key), &record) + .await?; + rooms.insert(room_key(room_id), &room).await?; + Ok(record) +} + +/// Fetch one record. +pub async fn get_record( + records: &KeyspaceHandle, + room_id: &str, + key: &str, +) -> Result { + let raw = records + .get_raw(record_key(room_id, key)) + .await? + .ok_or_else(|| AppError::NotFound(format!("record `{key}` not found in `{room_id}`")))?; + serde_json::from_slice(&raw) + .map_err(|e| AppError::Internal(format!("decode record `{key}`: {e}"))) +} + +/// List a room's records, optionally filtered by key prefix and a `since_version` +/// watermark. +/// +/// **Tombstones are returned**, and that is not an oversight. A caller pulling by watermark +/// that never sees a retraction learns of every create and update and never of a delete, so +/// retracted records resurrect on its next full rebuild and disagree with peers that saw +/// the retraction. A retraction is a change like any other. +pub async fn list_records( + records: &KeyspaceHandle, + room_id: &str, + key_prefix: Option<&str>, + since_version: Option, +) -> Result, AppError> { + let scan_prefix = match key_prefix { + Some(p) => format!("{}{p}", record_prefix(room_id)), + None => record_prefix(room_id), + }; + let pairs = records.prefix_iter_raw(scan_prefix).await?; + let mut out = Vec::with_capacity(pairs.len()); + for (_k, v) in pairs { + let record: Record = serde_json::from_slice(&v) + .map_err(|e| AppError::Internal(format!("decode record in `{room_id}`: {e}")))?; + if let Some(since) = since_version + && record.version <= since + { + continue; + } + out.push(record); + } + out.sort_by_key(|r| r.version); + Ok(out) +} + +/// Retract a record: drop the body, keep the tombstone. +/// +/// Not an erasure. The key, version and epoch remain so that incremental sync converges and +/// the audit chain still shows the record existed. Hard removal is a separate, higher-trust +/// operation — two verbs because they are two different acts, and collapsing them makes the +/// common one too powerful or the rare one impossible. +pub async fn retract_record( + rooms: &KeyspaceHandle, + records: &KeyspaceHandle, + room_id: &str, + key: &str, + now: u64, +) -> Result { + let mut record = get_record(records, room_id, key).await?; + let mut room = get_room(rooms, room_id).await?; + + record.status = RecordStatus::Retracted; + record.sealed = None; + record.nonce = None; + record.cleartext = None; + record.version = room.next_version; + record.updated_at = now; + + room.next_version += 1; + room.updated_at = now; + + records.insert(record_key(room_id, key), &record).await?; + rooms.insert(room_key(room_id), &room).await?; + Ok(record) +} + +/// Permanently remove a record, tombstone included. +/// +/// The erasure path, separate from [`retract_record`] on purpose. +pub async fn purge_record( + records: &KeyspaceHandle, + room_id: &str, + key: &str, +) -> Result<(), AppError> { + let k = record_key(room_id, key); + if records.get_raw(k.clone()).await?.is_none() { + return Err(AppError::NotFound(format!( + "record `{key}` not found in `{room_id}`" + ))); + } + records.remove(k).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Visibility; + use vti_common::config::StoreConfig; + use vti_common::store::Store; + + async fn open() -> (tempfile::TempDir, KeyspaceHandle, KeyspaceHandle) { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open(&StoreConfig { + data_dir: dir.path().to_path_buf(), + }) + .unwrap(); + let rooms = store.keyspace(crate::ROOMS_KEYSPACE).unwrap(); + let records = store.keyspace(crate::ROOM_RECORDS_KEYSPACE).unwrap(); + (dir, rooms, records) + } + + fn room(id: &str, visibility: Visibility) -> Room { + Room { + room_id: id.into(), + owner_did: "did:key:zOwner".into(), + visibility, + epoch: 1, + next_version: 1, + retention_days: 90, + created_at: 0, + updated_at: 0, + } + } + + fn sealed_record(key: &str, epoch: u32) -> Record { + Record { + key: key.into(), + version: 0, + epoch: Some(epoch), + status: RecordStatus::Active, + sealed: Some("c2VhbGVk".into()), + nonce: Some("bm9uY2U".into()), + cleartext: None, + author: None, + updated_at: 0, + } + } + + fn open_record(key: &str) -> Record { + Record { + key: key.into(), + version: 0, + epoch: None, + status: RecordStatus::Active, + sealed: None, + nonce: None, + cleartext: Some(serde_json::json!({ "body": "hello" })), + author: Some("did:key:zBob".into()), + updated_at: 0, + } + } + + #[tokio::test] + async fn a_room_cannot_be_registered_twice() { + let (_d, rooms, _rec) = open().await; + create_room(&rooms, &room("r1", Visibility::Open)) + .await + .unwrap(); + let err = create_room(&rooms, &room("r1", Visibility::Open)) + .await + .unwrap_err(); + assert!( + matches!(err, AppError::Conflict(_)), + "re-registering would reset the epoch and version counter: {err:?}" + ); + } + + #[tokio::test] + async fn versions_are_monotonic_across_records_in_a_room() { + let (_d, rooms, rec) = open().await; + create_room(&rooms, &room("r1", Visibility::Open)) + .await + .unwrap(); + + let a = put_record(&rooms, &rec, "r1", open_record("a"), None, 1) + .await + .unwrap(); + let b = put_record(&rooms, &rec, "r1", open_record("b"), None, 2) + .await + .unwrap(); + let a2 = put_record(&rooms, &rec, "r1", open_record("a"), None, 3) + .await + .unwrap(); + + assert_eq!((a.version, b.version, a2.version), (1, 2, 3)); + // One comparable number per room is what a `sinceVersion` watermark needs. + assert!( + a2.version > b.version, + "rewriting `a` must advance past `b`" + ); + } + + #[tokio::test] + async fn a_version_precondition_reports_what_it_lost_to() { + let (_d, rooms, rec) = open().await; + create_room(&rooms, &room("r1", Visibility::Open)) + .await + .unwrap(); + put_record(&rooms, &rec, "r1", open_record("a"), None, 1) + .await + .unwrap(); + + let err = put_record(&rooms, &rec, "r1", open_record("a"), Some(99), 2) + .await + .unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("is at version 1"), + "the conflict must carry the current version so a caller need not re-read: {msg}" + ); + } + + #[tokio::test] + async fn create_only_refuses_an_existing_key() { + let (_d, rooms, rec) = open().await; + create_room(&rooms, &room("r1", Visibility::Open)) + .await + .unwrap(); + put_record(&rooms, &rec, "r1", open_record("a"), Some(0), 1) + .await + .unwrap(); + let err = put_record(&rooms, &rec, "r1", open_record("a"), Some(0), 2) + .await + .unwrap_err(); + assert!(matches!(err, AppError::Conflict(_)), "{err:?}"); + } + + /// The tier promise, enforced in the store rather than trusted to callers. + #[tokio::test] + async fn a_sealed_room_refuses_cleartext_and_an_open_room_refuses_ciphertext() { + let (_d, rooms, rec) = open().await; + create_room(&rooms, &room("sealed", Visibility::Attributed)) + .await + .unwrap(); + create_room(&rooms, &room("plain", Visibility::Open)) + .await + .unwrap(); + + let err = put_record(&rooms, &rec, "sealed", open_record("a"), None, 1) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("cleartext must not be stored"), + "{err}" + ); + + let err = put_record(&rooms, &rec, "plain", sealed_record("a", 1), None, 1) + .await + .unwrap_err(); + assert!(format!("{err}").contains("stores cleartext"), "{err}"); + } + + #[tokio::test] + async fn a_private_room_refuses_a_recorded_author() { + let (_d, rooms, rec) = open().await; + create_room(&rooms, &room("p", Visibility::Private)) + .await + .unwrap(); + let mut r = sealed_record("a", 1); + r.author = Some("did:key:zBob".into()); + let err = put_record(&rooms, &rec, "p", r, None, 1).await.unwrap_err(); + assert!( + format!("{err}").contains("inside the sealed body"), + "on a private room the author must not reach this service: {err}" + ); + } + + #[tokio::test] + async fn a_record_sealed_under_a_stale_epoch_is_refused() { + let (_d, rooms, rec) = open().await; + create_room(&rooms, &room("r1", Visibility::Attributed)) + .await + .unwrap(); + advance_epoch(&rooms, "r1", 2, 10).await.unwrap(); + let err = put_record(&rooms, &rec, "r1", sealed_record("a", 1), None, 11) + .await + .unwrap_err(); + assert!(format!("{err}").contains("room `r1` is at 2"), "{err}"); + } + + #[tokio::test] + async fn an_epoch_must_advance_by_exactly_one() { + let (_d, rooms, _rec) = open().await; + create_room(&rooms, &room("r1", Visibility::Attributed)) + .await + .unwrap(); + + assert!( + advance_epoch(&rooms, "r1", 3, 1).await.is_err(), + "a gap is refused" + ); + assert!( + advance_epoch(&rooms, "r1", 1, 1).await.is_err(), + "a repeat is refused" + ); + assert_eq!(advance_epoch(&rooms, "r1", 2, 1).await.unwrap().epoch, 2); + } + + #[tokio::test] + async fn one_room_never_lists_anothers_records() { + let (_d, rooms, rec) = open().await; + // `a` is a string prefix of `ab` — the trailing `:` is what keeps them apart. + create_room(&rooms, &room("a", Visibility::Open)) + .await + .unwrap(); + create_room(&rooms, &room("ab", Visibility::Open)) + .await + .unwrap(); + put_record(&rooms, &rec, "a", open_record("x"), None, 1) + .await + .unwrap(); + put_record(&rooms, &rec, "ab", open_record("y"), None, 2) + .await + .unwrap(); + + let listed = list_records(&rec, "a", None, None).await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].key, "x"); + } + + /// Without this, deletions never propagate and retracted records resurrect. + #[tokio::test] + async fn a_watermark_listing_returns_tombstones() { + let (_d, rooms, rec) = open().await; + create_room(&rooms, &room("r1", Visibility::Open)) + .await + .unwrap(); + put_record(&rooms, &rec, "r1", open_record("a"), None, 1) + .await + .unwrap(); + let seen = put_record(&rooms, &rec, "r1", open_record("b"), None, 2) + .await + .unwrap() + .version; + + let tomb = retract_record(&rooms, &rec, "r1", "a", 3).await.unwrap(); + assert!( + tomb.sealed.is_none() && tomb.cleartext.is_none(), + "body is dropped" + ); + + let changed = list_records(&rec, "r1", Some(""), Some(seen)) + .await + .unwrap(); + assert_eq!(changed.len(), 1, "only what changed since the watermark"); + assert_eq!(changed[0].key, "a"); + assert!( + matches!(changed[0].status, RecordStatus::Retracted), + "the retraction must reach a puller, or the record resurrects" + ); + } + + #[tokio::test] + async fn purge_removes_the_tombstone_and_is_not_idempotent() { + let (_d, rooms, rec) = open().await; + create_room(&rooms, &room("r1", Visibility::Open)) + .await + .unwrap(); + put_record(&rooms, &rec, "r1", open_record("a"), None, 1) + .await + .unwrap(); + retract_record(&rooms, &rec, "r1", "a", 2).await.unwrap(); + + purge_record(&rec, "r1", "a").await.unwrap(); + assert!( + list_records(&rec, "r1", None, None) + .await + .unwrap() + .is_empty() + ); + assert!( + purge_record(&rec, "r1", "a").await.is_err(), + "purging an absent record reports not-found rather than succeeding quietly" + ); + } +} diff --git a/vti-rooms/src/wire.rs b/vti-rooms/src/wire.rs new file mode 100644 index 000000000..8da8231f2 --- /dev/null +++ b/vti-rooms/src/wire.rs @@ -0,0 +1,190 @@ +//! Wire types for the `rooms/*` Trust Tasks. +//! +//! # Why these are hand-written +//! +//! They mirror the payload schemas proposed in +//! `trustoverip/dtgwg-trust-tasks-tf#346`, and are written here rather than taken from +//! `trust_tasks_rs::specs` because that PR is still in review — the generated bindings do +//! not exist yet. This is the same shape `vta_sdk::protocols::*` already uses for the VTA's +//! families: hand-written wire types beside the generated ones. +//! +//! **When #346 merges and `trust-tasks-rs` publishes, replace these with the generated +//! types rather than keeping both.** Two definitions of one wire format is how casing drift +//! gets in, and this workspace has paid for that before. +//! +//! Every struct is `camelCase` and `deny_unknown_fields`: these carry authorization +//! decisions, and an unknown member on one of those is a request that means something the +//! service did not understand. + +use serde::{Deserialize, Serialize}; + +use crate::Visibility; + +/// `rooms/create/0.1`. +pub const ROOMS_CREATE_TYPE: &str = "https://trusttasks.org/spec/rooms/create/0.1"; +/// `rooms/records/put/0.1`. +pub const ROOMS_RECORDS_PUT_TYPE: &str = "https://trusttasks.org/spec/rooms/records/put/0.1"; +/// `rooms/records/get/0.1`. +pub const ROOMS_RECORDS_GET_TYPE: &str = "https://trusttasks.org/spec/rooms/records/get/0.1"; +/// `rooms/records/list/0.1`. +pub const ROOMS_RECORDS_LIST_TYPE: &str = "https://trusttasks.org/spec/rooms/records/list/0.1"; +/// `rooms/epoch/mint/0.1`. +pub const ROOMS_EPOCH_MINT_TYPE: &str = "https://trusttasks.org/spec/rooms/epoch/mint/0.1"; + +/// Every `rooms/*` URI this service dispatches. +pub const ROOMS_DISPATCHED_URIS: &[&str] = &[ + ROOMS_CREATE_TYPE, + ROOMS_RECORDS_PUT_TYPE, + ROOMS_RECORDS_GET_TYPE, + ROOMS_RECORDS_LIST_TYPE, + ROOMS_EPOCH_MINT_TYPE, +]; + +/// What a party presents to act on a room. +/// +/// The whole authority chain travels here, **leaf first**, and this service never +/// dereferences a link's `parent` to fetch one it was not given. That is not an +/// optimisation: resolving over the network would make verification depend on availability, +/// turn an identifier into a request this service can be induced to make against an address +/// the *presenter* chooses, and signal credential use to whoever hosts that identifier. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuthorityPresentation { + /// The presenter's membership credential for this room, or a zero-knowledge + /// presentation of it on a `private` room. + pub membership: String, + + /// The authority chain, leaf first. The last element must be issued by the room. + pub authority: Vec, + + /// REQUIRED on a `private` room: proof that the membership credential and the chain's + /// leaf describe the **same subject**. + /// + /// Without it two parties pool credentials — one contributes membership, the other + /// authority — and the combination verifies as a single party holding both. Silent when + /// wrong, which is why [`crate::authz`] refuses a private-room presentation that omits + /// it rather than treating it as optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject_binding: Option, +} + +/// Sealed record content, as it crosses the wire and is stored. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SealedContent { + /// The sealed record, base64url. AEAD-bound to `roomId|key|version|epoch`. + pub ciphertext: String, + /// AEAD nonce, base64url. + pub nonce: String, + /// The epoch it was sealed under. + pub epoch: u32, +} + +/// Cleartext record content. `open` rooms only. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CleartextContent { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub body: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +/// `rooms/create/0.1` request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateRoomBody { + /// The room's own identifier, minted by its owner. This service does not assign one: + /// a room identified by something its host chose could not move to another host. + pub room_id: String, + pub visibility: Visibility, + pub owner_did: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retention_days: Option, +} + +/// `rooms/create/0.1#response`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateRoomResponse { + pub room_id: String, + pub epoch: u32, +} + +/// `rooms/records/put/0.1` request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PutRecordBody { + pub room_id: String, + pub key: String, + pub presentation: AuthorityPresentation, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sealed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleartext: Option, +} + +/// `rooms/records/put/0.1#response`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PutRecordResponse { + pub key: String, + pub version: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub epoch: Option, +} + +/// `rooms/records/get/0.1` request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GetRecordBody { + pub room_id: String, + pub key: String, + pub presentation: AuthorityPresentation, +} + +/// `rooms/records/list/0.1` request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ListRecordsBody { + pub room_id: String, + pub presentation: AuthorityPresentation, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub since_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +/// `rooms/records/list/0.1#response`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListRecordsResponse { + /// Metadata only — never bodies. + pub records: Vec, +} + +/// `rooms/epoch/mint/0.1` request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MintEpochBody { + pub room_id: String, + pub epoch: u32, + pub presentation: AuthorityPresentation, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// `rooms/epoch/mint/0.1#response`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MintEpochResponse { + pub room_id: String, + pub epoch: u32, +} diff --git a/vti-rooms/tests/schema_conformance.rs b/vti-rooms/tests/schema_conformance.rs new file mode 100644 index 000000000..772305b69 --- /dev/null +++ b/vti-rooms/tests/schema_conformance.rs @@ -0,0 +1,300 @@ +//! The wire types must agree with the published schemas. +//! +//! # Why this file exists +//! +//! `vti_rooms::wire` hand-rolls the `rooms/*` request and response bodies. The specs are +//! published — `trustoverip/dtgwg-trust-tasks-tf`, generated into `trust_tasks_rs` — so +//! there are now two descriptions of the same wire form and nothing making them agree. +//! +//! The VTC has a conformance sweep for exactly this, but it is scoped to +//! `https://trusttasks.org/spec/vtc/` and says so in its own module docs. The rooms family +//! publishes at top level, `spec/rooms/`, so it falls outside — this is its equivalent. +//! +//! The drift class it catches is not hypothetical in this workspace: a `snake_case` field +//! where the schema says `camelCase` is invisible to every Rust test, because both sides of +//! a round-trip use the same struct. It took an empty `allowed_contexts` minting a +//! super-admin (#656/#658) to establish that. Serde is not the check — a schema is. +//! +//! # What each test does +//! +//! Builds a value with the hand-rolled type, serialises it, and validates the result +//! against the spec's own embedded schema. That catches what serde cannot: `camelCase` +//! renames, required members that were made optional, `const` and `enum` values, patterns, +//! and — because the request schemas are `additionalProperties: false` — a field this +//! implementation invented that the spec does not have. +//! +//! # What it deliberately does not do +//! +//! It does not assert the hand-rolled type is *identical* to the generated one. They differ +//! on purpose: the generated `Payload` types use newtypes and `NonZeroU64` where this crate +//! wants plain strings and `u64`, and a storage layer should not be forced through a +//! builder. Agreeing on the wire is the requirement; agreeing on the Rust shape is not. + +use serde_json::{Value, json}; +use trust_tasks_rs::validate::ValidatedPayload; +use vti_rooms::wire::*; +use vti_rooms::{Record, RecordStatus, Visibility}; + +/// Validate `value` against the schema published for `T`. +fn check(what: &str, value: &Value) { + if let Err(e) = T::validate_value(value) { + panic!( + "{what} does not conform to its published schema: {e}\n\nproduced:\n{}", + serde_json::to_string_pretty(value).unwrap_or_default() + ); + } +} + +/// A presentation, as every request carries one. +fn presentation() -> AuthorityPresentation { + AuthorityPresentation { + membership: "urn:uuid:11111111-1111-1111-1111-111111111111".into(), + authority: vec![ + "urn:uuid:22222222-2222-2222-2222-222222222222".into(), + "urn:uuid:33333333-3333-3333-3333-333333333333".into(), + ], + subject_binding: None, + } +} + +#[test] +fn create_room_conforms() { + use trust_tasks_rs::specs::rooms::create::v0_1::Payload; + + for visibility in [ + Visibility::Open, + Visibility::Attributed, + Visibility::Private, + ] { + let body = CreateRoomBody { + room_id: "did:webvh:example.com:rooms:northwind".into(), + owner_did: "did:key:z6MkOwner".into(), + visibility, + retention_days: Some(90), + }; + check::( + &format!("CreateRoomBody ({visibility:?})"), + &serde_json::to_value(&body).expect("serialise"), + ); + } + + // `retentionDays` is optional, and "absent" must serialise as absent rather than + // `null` — a schema typing it `integer` rejects an explicit null. + let body = CreateRoomBody { + room_id: "did:webvh:example.com:rooms:northwind".into(), + owner_did: "did:key:z6MkOwner".into(), + visibility: Visibility::Open, + retention_days: None, + }; + let value = serde_json::to_value(&body).expect("serialise"); + check::("CreateRoomBody with no retention", &value); +} + +#[test] +fn put_record_conforms_on_both_tiers() { + use trust_tasks_rs::specs::rooms::records::put::v0_1::Payload; + + let sealed = PutRecordBody { + room_id: "did:webvh:example.com:rooms:northwind".into(), + key: "giXFLTGBdnnQJRoIsktuIg".into(), + presentation: presentation(), + sealed: Some(SealedContent { + ciphertext: "1ep1PJuf8-yNmTndwcuMxA".into(), + nonce: "AAAAAAAAAAAAAAAA".into(), + epoch: 1, + }), + cleartext: None, + expected_version: Some(0), + }; + check::( + "PutRecordBody (sealed)", + &serde_json::to_value(&sealed).expect("serialise"), + ); + + let cleartext = PutRecordBody { + room_id: "did:webvh:example.com:rooms:northwind".into(), + key: "decision/pricing-2026".into(), + presentation: presentation(), + sealed: None, + cleartext: Some(CleartextContent { + title: Some("Pricing holds through Q3".into()), + description: None, + body: "Agreed not to reprice before the renewal closes.".into(), + tags: vec!["pricing".into()], + }), + expected_version: None, + }; + check::( + "PutRecordBody (cleartext)", + &serde_json::to_value(&cleartext).expect("serialise"), + ); +} + +#[test] +fn get_and_list_requests_conform() { + use trust_tasks_rs::specs::rooms::records::get::v0_1::Payload as GetPayload; + use trust_tasks_rs::specs::rooms::records::list::v0_1::Payload as ListPayload; + + let get = GetRecordBody { + room_id: "did:webvh:example.com:rooms:northwind".into(), + key: "decision/pricing-2026".into(), + presentation: presentation(), + }; + check::( + "GetRecordBody", + &serde_json::to_value(&get).expect("serialise"), + ); + + let list = ListRecordsBody { + room_id: "did:webvh:example.com:rooms:northwind".into(), + presentation: presentation(), + prefix: Some("decision/".into()), + since_version: Some(4), + limit: Some(50), + }; + check::( + "ListRecordsBody", + &serde_json::to_value(&list).expect("serialise"), + ); + + // Every optional narrowing absent — the incremental-sync caller's first call. + let bare = ListRecordsBody { + room_id: "did:webvh:example.com:rooms:northwind".into(), + presentation: presentation(), + prefix: None, + since_version: None, + limit: None, + }; + check::( + "ListRecordsBody with no narrowing", + &serde_json::to_value(&bare).expect("serialise"), + ); +} + +#[test] +fn mint_epoch_conforms() { + use trust_tasks_rs::specs::rooms::epoch::mint::v0_1::Payload; + + let body = MintEpochBody { + room_id: "did:webvh:example.com:rooms:northwind".into(), + epoch: 2, + presentation: presentation(), + reason: Some("membership change".into()), + }; + check::( + "MintEpochBody", + &serde_json::to_value(&body).expect("serialise"), + ); +} + +/// A private room's presentation carries the pooling defence, and it must survive +/// serialisation under the name the schema gives it. +#[test] +fn a_subject_binding_conforms_under_its_published_name() { + use trust_tasks_rs::specs::rooms::records::get::v0_1::Payload; + + let mut p = presentation(); + p.subject_binding = Some("urn:uuid:44444444-4444-4444-4444-444444444444".into()); + let get = GetRecordBody { + room_id: "did:webvh:example.com:rooms:private".into(), + key: "giXFLTGBdnnQJRoIsktuIg".into(), + presentation: p, + }; + let value = serde_json::to_value(&get).expect("serialise"); + assert!( + value["presentation"]["subjectBinding"].is_string(), + "the binding must travel as `subjectBinding`, not snake_case: {value}" + ); + check::("GetRecordBody with a subject binding", &value); +} + +// ─── Responses ─────────────────────────────────────────────────────────── + +#[test] +fn responses_conform() { + use trust_tasks_rs::specs::rooms::create::v0_1::Response as CreateResponse; + use trust_tasks_rs::specs::rooms::epoch::mint::v0_1::Response as MintResponse; + use trust_tasks_rs::specs::rooms::records::list::v0_1::Response as ListResponse; + use trust_tasks_rs::specs::rooms::records::put::v0_1::Response as PutResponse; + + check::( + "CreateRoomResponse", + &serde_json::to_value(CreateRoomResponse { + room_id: "did:webvh:example.com:rooms:northwind".into(), + epoch: 1, + }) + .expect("serialise"), + ); + + check::( + "PutRecordResponse", + &serde_json::to_value(PutRecordResponse { + key: "decision/pricing-2026".into(), + version: 1, + epoch: Some(1), + }) + .expect("serialise"), + ); + + check::( + "MintEpochResponse", + &serde_json::to_value(MintEpochResponse { + room_id: "did:webvh:example.com:rooms:northwind".into(), + epoch: 2, + }) + .expect("serialise"), + ); + + // A listing carries metadata, and a tombstone is part of it — a caller that never saw + // a retraction resurrects the record on its next rebuild. + // + // This runs `Record::metadata()` rather than a hand-built object on purpose: the + // projection is the thing that has to conform, and a literal written beside it would + // only ever agree with itself. Both drifts this file has caught were in the projection + // — a unix integer where the schema says `date-time`, and `null` for an absent + // optional under `additionalProperties: false`. + let records: Vec = [ + // An open-tier record: cleartext, an author, no epoch. + Record { + key: "decision/pricing-2026".into(), + version: 3, + epoch: None, + status: RecordStatus::Active, + sealed: None, + nonce: None, + cleartext: Some(json!({ + "title": "Pricing holds through Q3", + "body": "Agreed not to reprice before the renewal closes.", + })), + author: Some("did:key:z6MkAlice".into()), + updated_at: 1_756_000_000, + }, + // A tombstone: no body, no author, and it still has to conform. + Record { + key: "giXFLTGBdnnQJRoIsktuIg".into(), + version: 5, + epoch: Some(2), + status: RecordStatus::Retracted, + sealed: None, + nonce: None, + cleartext: None, + author: None, + updated_at: 1_756_000_100, + }, + ] + .iter() + .map(Record::metadata) + .collect(); + + assert_eq!( + records[0]["updatedAt"], "2025-08-24T01:46:40Z", + "the projection must render RFC 3339, not unix seconds" + ); + assert!( + records[1].get("author").is_none() && records[1].get("epoch").is_some(), + "an absent optional must be absent, not null: {}", + records[1] + ); + + check::("ListRecordsResponse", &json!({ "records": records })); +}