diff --git a/Cargo.lock b/Cargo.lock index f329a81..9f2eec5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,7 +33,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -45,7 +45,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -62,6 +62,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0453232ace82dee0dd0b4c87a59bd90f7b53b314f3e0f61fe2ee7c8a16482289" + [[package]] name = "ahash" version = "0.7.8" @@ -209,7 +215,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -631,7 +637,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -643,6 +649,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "blocking" version = "1.6.2" @@ -1151,17 +1166,30 @@ dependencies = [ name = "capsule-core" version = "0.1.0" dependencies = [ + "aead", + "aes-gcm", + "argon2", "chrono", "ciborium", + "ed25519-dalek", + "getrandom 0.3.4", "globset", + "half", "hex", + "hkdf", + "hmac", "indexmap 2.12.1", "kamadak-exif", "log", + "ml-dsa", + "ml-kem", "rusqlite", "serde", + "serde_bytes", "serde_json", "sha2", + "sharks", + "tar", "tempfile", "thiserror 2.0.17", "tzf-rs", @@ -1294,7 +1322,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -1347,6 +1375,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "color-eyre" version = "0.6.5" @@ -1446,6 +1480,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -1513,6 +1553,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -1581,6 +1630,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.2", + "hybrid-array", + "rand_core 0.10.1", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1590,6 +1650,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1597,9 +1666,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -1699,11 +1768,21 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -1800,12 +1879,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", +] + [[package]] name = "directories" version = "6.0.0" @@ -1873,12 +1962,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", - "digest", + "der 0.7.10", + "digest 0.10.7", "elliptic-curve", "rfc6979", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", ] [[package]] @@ -1887,8 +1976,8 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.10.2", + "signature 2.2.0", ] [[package]] @@ -1922,13 +2011,13 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", "hkdf", "pem-rfc7468", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", "sec1", "subtle", @@ -2362,11 +2451,25 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + [[package]] name = "getset" version = "0.1.6" @@ -2469,6 +2572,15 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "hashbrown" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" +dependencies = [ + "ahash 0.4.8", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2584,7 +2696,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -2650,6 +2762,16 @@ dependencies = [ "libm", ] +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "ctutils", + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -2866,6 +2988,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -3034,7 +3162,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "signature", + "signature 2.2.0", "simple_asn1", ] @@ -3047,6 +3175,26 @@ dependencies = [ "mutate_once", ] +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3056,6 +3204,12 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.180" @@ -3177,7 +3331,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -3248,6 +3402,47 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-dsa" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67011732d2747886c7b64f2eceef9d6eb2645a0aa528a26828b949ece257285" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sha3", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "multer" version = "3.1.0" @@ -3854,9 +4049,9 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", ] [[package]] @@ -3865,8 +4060,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "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 0.8.0", + "spki 0.8.0", ] [[package]] @@ -3882,7 +4087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4271,6 +4476,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -4336,6 +4547,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "redis" version = "1.0.2" @@ -4564,16 +4781,16 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "subtle", "zeroize", ] @@ -5220,9 +5437,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "subtle", "zeroize", ] @@ -5292,6 +5509,16 @@ dependencies = [ "xml", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_cbor_2" version = "0.13.0" @@ -5445,8 +5672,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -5462,8 +5689,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", ] [[package]] @@ -5475,6 +5723,17 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "sharks" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "902b1e955f8a2e429fb1bad49f83fb952e6195d3c360ac547ff00fb826388753" +dependencies = [ + "hashbrown 0.9.1", + "rand 0.8.5", + "zeroize", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -5503,10 +5762,20 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -5572,9 +5841,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", ] +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.0", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "sqlx" version = "0.8.6" @@ -5681,7 +5966,7 @@ dependencies = [ "bytes", "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -5980,6 +6265,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.24.0" @@ -6542,9 +6838,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typify" @@ -6681,7 +6977,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -6835,7 +7131,16 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -6902,6 +7207,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.12.1", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -6915,6 +7242,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.12.1", + "semver", +] + [[package]] name = "web-sys" version = "0.3.83" @@ -7467,6 +7806,94 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.12.1", + "prettyplease", + "syn 2.0.111", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.111", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.12.1", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.12.1", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.2" @@ -7596,6 +8023,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] [[package]] name = "zerotrie" diff --git a/DEFERRED.md b/DEFERRED.md new file mode 100644 index 0000000..b62ce76 --- /dev/null +++ b/DEFERRED.md @@ -0,0 +1,78 @@ +# Deferred Work + +This file records what the **core data-plane implementation** (the `capsule-core` +cryptographic core + offline lifecycle + `capsule demo`) intentionally left for later, why, +and the seam that was left in place so it can drop in without reworking what exists. It +complements the design docs in `capsule-docs/src/content/docs/design/`. + +## What is implemented and validated (offline, real crypto) + +`capsule-core` implements and exhaustively unit-tests the full offline data plane: + +- **Canonical CBOR** (RFC 8949 §4.2) — the byte-identity contract for every signature/hash. +- **Crypto primitives** — SHA-256 (streaming), HKDF-SHA512, Argon2id, AES-256-GCM (STREAM + + standalone metadata-blob), **hybrid Ed25519 + ML-DSA-65** signatures (both halves required), + **ML-KEM-768** DEK. +- **Key hierarchy** — master key, default-album-id derivation, AMKs + per-file/blob keys, + software keystore (account ↔ encrypted `AccountFile`), signed device directory. +- **Encryption** — STREAM asset encryption with independent ranged-chunk decryption; + exact metadata-blob wire format. +- **Provenance** — signed `AssetManifest`/`DerivativeManifest`, append-only hash-chained + provenance, and the single **`verify_asset`** chokepoint (Accept / TerminalReject / Pending) + with an exhaustive negative-case suite. +- **Validation invariants** — the key-less protocol handshake + structural envelope checks + + idempotency keys. +- **CRDT metadata + Sidecar v1** — OR-set tags, LWW caption/rating with superseded log, + monotonic add-id counter; signed `SidecarV1` (schema as CBOR field 0); privacy-on-export. +- **Backup** — deterministic signed tar artifact, AMK ledger, master-key escrow, Shamir + 2-of-3, and dry-run/commit restore with chain reconciliation. +- **Lifecycle `Workspace`** — ties it together and is showcased end-to-end by `capsule demo`. + +## Deferred — with the seam in place + +### Real MLS / OpenMLS group state +- **Why:** the design's MLS ciphersuite (`MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519`, + `0x004D`) exists in `openmls` only via a C (`libcrux`) backend on a non-final IETF draft, + with no IANA codepoint and no RustCrypto PQ backend yet (openmls#1940). +- **Seam:** `capsule_core::crypto::authority::AlbumAuthority` is the trait `verify_asset` + consumes (epoch ceiling, per-epoch write-tier pubkey, AMK presence, admin-chain validity). + `ReferenceAuthority` (an admin-signed epoch ledger) stands in for live MLS and is honored + only via `&dyn AlbumAuthority`, so an `OpenMlsAuthority` drops in unchanged. +- **Consequence:** albums are **single-epoch** in the offline core. Epoch rotation, + membership add/remove, the `Welcome`/history-delivery flow, and the album upgrade ceremony + are deferred with OpenMLS. + +### X-Wing hybrid DEK +- `crypto::keys::kem` implements the post-quantum **ML-KEM-768** half (full encapsulate/ + decapsulate round-trip). The X25519 classical half and the X-Wing combiner land with + OpenMLS (the seam is byte-string `encapsulate`/`decapsulate`, combiner-agnostic). + +### Hardware-bound key storage +- Device keys are kept in a **software keystore** (private keys sealed under the + passphrase-wrapped master key). Secure Enclave / StrongBox / TPM adapters + (`capsule-sdk::hardware-keys`) are per-platform glue, deferred. + +### Networked server/client +- All transport is out of scope here: the HTTP/TUS upload server, GraphQL resolvers, the + `/sync` feed, federation, peering, and the `capsule-sdk` network client. The **pure** + refuse-by-default validation invariants those paths need are implemented in + `capsule_core::validation` and ready to wire into `capsule-api`. + +### ML / AI +- Embeddings, `sqlite-vec` vector search, the model registry, semantic/face features, and + moderation are deferred (explicitly out of scope). The sidecar reserves `tags_ai` + (separate OR-set) and the manifest reserves `model_id`/`model_version` for them. + +### Other +- Thumbnail/LQIP generation beyond `capsule-media`'s existing utilities. +- Fusing the crypto data plane into the **existing plaintext import executor** + (`capsule_core::import::executor`): that pipeline still writes the legacy `AssetSidecar`. + The crypto-integrated lifecycle lives in `capsule_core::lifecycle::Workspace` (used by + `capsule demo`); unifying the two import paths is a follow-up. + +## How to see it working + +``` +cargo test --workspace --exclude capsule-sdk # full unit + e2e test surface +cargo run -p capsule-cli -- demo --workdir /tmp/capsule-demo # narrated end-to-end showcase +``` diff --git a/capsule-cli/src/cli/commands.rs b/capsule-cli/src/cli/commands.rs index 0b4a374..d6600c0 100644 --- a/capsule-cli/src/cli/commands.rs +++ b/capsule-cli/src/cli/commands.rs @@ -28,6 +28,15 @@ pub enum Commands { #[command(subcommand)] command: LibraryCommands, }, + /// Run the offline end-to-end data-plane showcase (real cryptography, no network) + Demo { + /// Working directory for the demo libraries (a temp dir is used if omitted) + #[arg(long, value_name = "PATH")] + workdir: Option, + /// A real image/file to import (a small synthetic file is used if omitted) + #[arg(long, value_name = "PATH")] + image: Option, + }, /// Sync local and remote data Sync { /// Force sync even if there are conflicts diff --git a/capsule-cli/src/demo.rs b/capsule-cli/src/demo.rs new file mode 100644 index 0000000..2a8cf94 --- /dev/null +++ b/capsule-cli/src/demo.rs @@ -0,0 +1,221 @@ +//! `capsule demo` — an offline, end-to-end showcase of the cryptographic data plane. +//! +//! Runs the whole flow with **real cryptography and no network**: account + keys → album + +//! authority → import (encrypt, signed manifest, provenance, signed sidecar, `verify_asset`) +//! → CRDT metadata edits → soft-delete + restore → backup export → restore into a fresh +//! library → byte-equal verification → Shamir 2-of-3 recovery. Every step writes real +//! artifacts the user can inspect. + +use std::path::PathBuf; + +use colored::*; +use eyre::{Result, eyre}; + +use capsule_core::backup::{recover_seed, split_seed_2of3}; +use capsule_core::crypto::primitives::Argon2Params; +use capsule_core::crypto::verify_asset::VerifyOutcome; +use capsule_core::lifecycle::Workspace; + +/// Fast Argon2id parameters — this is a demo; the wrap-key strength is not the point. +const DEMO_KDF: Argon2Params = Argon2Params { + mem_kib: 8 * 1024, + t_cost: 1, + p_cost: 1, +}; + +fn step(n: u32, title: &str) { + println!("\n{} {}", format!("[{n}]").bold().cyan(), title.bold()); +} + +fn ok(msg: impl AsRef) { + println!(" {} {}", "✓".green(), msg.as_ref()); +} + +fn info(label: &str, value: impl std::fmt::Display) { + println!(" {} {}", format!("{label}:").dimmed(), value); +} + +/// Run the showcase. `workdir` defaults to a fresh temp directory; `image` defaults to a +/// small synthetic file. +pub fn run(workdir: Option, image: Option) -> Result<()> { + let root = match workdir { + Some(p) => p, + None => std::env::temp_dir().join(format!("capsule-demo-{}", std::process::id())), + }; + std::fs::create_dir_all(&root)?; + let source_lib = root.join("source-library"); + let fresh_lib = root.join("restored-library"); + let backup_path = root.join("backup.tar"); + + println!( + "{}", + "Capsule offline data-plane showcase (real crypto, no network)" + .bold() + .underline() + ); + info("workdir", root.display()); + + // ── 1. Account + device keys ──────────────────────────────────────────── + step(1, "Create account + device keys"); + let mut ws = Workspace::create_with_params(&source_lib, b"demo-passphrase", DEMO_KDF) + .map_err(|e| eyre!("create workspace: {e}"))?; + ok( + "master key generated; identity (IK), device signing (DSK), and device encryption (DEK) keys created", + ); + info("user_id", ws.user_id()); + info( + "default album id (derived from master key)", + ws.default_album_id(), + ); + + // ── 2. Album + MLS-attested authority ─────────────────────────────────── + step( + 2, + "Create a container album (mint AMK + write-tier + admin keys)", + ); + let album = ws.create_album("Trip to the Coast"); + ok("AMK_v1 minted; admin-signed authority attests epoch 1"); + info("album_id", album); + + // ── 3. Import a real file ──────────────────────────────────────────────── + step( + 3, + "Import an asset (encrypt → sign manifest → provenance → signed sidecar → verify_asset)", + ); + let image_path = match image { + Some(p) => p, + None => { + let p = root.join("sample.jpg"); + // A small synthetic JPEG-ish payload. + let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0]; + bytes.extend((0..4096).map(|i| (i % 256) as u8)); + std::fs::write(&p, &bytes)?; + p + } + }; + info("source file", image_path.display()); + let asset = ws + .import_asset(album, &image_path) + .map_err(|e| eyre!("import: {e}"))?; + let st = ws.asset(&asset).ok_or_else(|| eyre!("asset missing"))?; + let head = &st.chain.records().last().unwrap().manifest; + ok("encrypted with AES-256-GCM STREAM; manifest signed (device + write-tier hybrid sigs)"); + info("asset_id", asset); + info("ciphertext hash", head.core.ciphertext_hash); + info( + "plaintext size", + format!("{} bytes", head.core.plaintext_size), + ); + ok("signed sidecar + provenance chain written to disk under media/"); + + // ── 4. verify_asset chokepoint ─────────────────────────────────────────── + step(4, "Acknowledge via the verify_asset chokepoint"); + match ws.verify(&asset).map_err(|e| eyre!("verify: {e}"))? { + VerifyOutcome::Accept => { + ok("verify_asset → ACCEPT (both signatures, epoch, chain, AMK all valid)") + } + other => return Err(eyre!("unexpected verify outcome: {other:?}")), + } + + // ── 5. CRDT metadata edits ─────────────────────────────────────────────── + step(5, "Collaborative metadata edits (CRDT, provenance-tracked)"); + ws.tag_add(&asset, "coast").map_err(|e| eyre!("tag: {e}"))?; + ws.tag_add(&asset, "sunset") + .map_err(|e| eyre!("tag: {e}"))?; + ws.set_caption(&asset, "golden hour over the bay") + .map_err(|e| eyre!("caption: {e}"))?; + let st = ws.asset(&asset).unwrap(); + let tags: Vec = st.sidecar.tags_user.value().into_iter().collect(); + ok(format!("tags (OR-set): {tags:?}")); + ok(format!( + "caption (LWW): {:?}", + st.sidecar.caption.get().cloned().unwrap_or_default() + )); + info("provenance records", st.chain.records().len()); + + // ── 6. Lifecycle: soft delete + restore ────────────────────────────────── + step(6, "Soft-delete (signed retention window) then restore"); + ws.soft_delete(&asset, 30) + .map_err(|e| eyre!("delete: {e}"))?; + ok("delete manifest signed with retention_until = now + 30 days"); + ws.restore(&asset).map_err(|e| eyre!("restore: {e}"))?; + ok("trash-restore appended; the delete record is preserved in the chain (audit trail)"); + let st = ws.asset(&asset).unwrap(); + let actions: Vec = st + .chain + .records() + .iter() + .map(|r| format!("{:?}", r.manifest.core.action).to_lowercase()) + .collect(); + info("chain", actions.join(" → ")); + + // ── 7. Backup export ───────────────────────────────────────────────────── + step(7, "Export a portable, signed backup artifact"); + ws.export_backup(&backup_path, b"recovery-passphrase") + .map_err(|e| eyre!("export: {e}"))?; + let size = std::fs::metadata(&backup_path)?.len(); + ok("deterministic tar with HMAC + hybrid-signed MANIFEST + sealed AMK ledger"); + info( + "backup", + format!("{} ({} bytes)", backup_path.display(), size), + ); + + // ── 8. Restore into a fresh library ────────────────────────────────────── + step(8, "Restore into a FRESH library and verify byte-equality"); + let exporter_pub = ws.exporter_verifying_key(); + let mut fresh = Workspace::create_with_params(&fresh_lib, b"new-device-pass", DEMO_KDF) + .map_err(|e| eyre!("create fresh: {e}"))?; + let added = fresh + .import_backup(&backup_path, b"recovery-passphrase", &exporter_pub) + .map_err(|e| eyre!("import backup: {e}"))?; + ok(format!( + "restored {added} asset(s) after verifying the exporter signature + AMK completeness" + )); + let original = ws + .read_plaintext(&asset) + .map_err(|e| eyre!("read src: {e}"))?; + let restored = fresh + .read_plaintext(&asset) + .map_err(|e| eyre!("read restored: {e}"))?; + if original == restored { + ok(format!( + "{} restored plaintext is byte-identical to the source", + "PASS:".green().bold() + )); + } else { + return Err(eyre!("restored plaintext differs from source")); + } + + // A wrong exporter key (untrusted device) is refused. + let bogus = Workspace::create_with_params(&root.join("bogus"), b"x", DEMO_KDF) + .map_err(|e| eyre!("bogus ws: {e}"))? + .exporter_verifying_key(); + let mut reject_lib = Workspace::create_with_params(&root.join("reject"), b"x", DEMO_KDF) + .map_err(|e| eyre!("reject ws: {e}"))?; + match reject_lib.import_backup(&backup_path, b"recovery-passphrase", &bogus) { + Err(_) => ok("a backup signed by an untrusted exporter is refused"), + Ok(_) => return Err(eyre!("untrusted exporter backup was wrongly accepted")), + } + + // ── 9. Shamir social recovery ──────────────────────────────────────────── + step(9, "Opt-in Shamir 2-of-3 recovery-seed sharing"); + let seed = [0x5Au8; 32]; + let shares = split_seed_2of3(&seed); + let recovered = + recover_seed(&[shares[0].clone(), shares[2].clone()]).map_err(|e| eyre!("shamir: {e}"))?; + if recovered == seed { + ok("split into 3 shares; any 2 reconstruct the seed (1 alone reveals nothing)"); + } else { + return Err(eyre!("shamir reconstruction failed")); + } + + println!( + "\n{} Every layer of the design exercised offline with real cryptography.", + "DONE.".green().bold() + ); + println!( + "{}", + format!("Inspect the on-disk artifacts under {}", root.display()).dimmed() + ); + Ok(()) +} diff --git a/capsule-cli/src/import/plan.rs b/capsule-cli/src/import/plan.rs index ac74be5..0f8d2e6 100644 --- a/capsule-cli/src/import/plan.rs +++ b/capsule-cli/src/import/plan.rs @@ -2,9 +2,9 @@ use std::path::PathBuf; -use eyre::{Result, eyre}; use capsule_core::import::ImportActionPlan; use capsule_core::import::scanner::scan; +use eyre::{Result, eyre}; /// Scan a file or directory and build an import action plan. /// diff --git a/capsule-cli/src/main.rs b/capsule-cli/src/main.rs index 0c51803..6f41ca4 100644 --- a/capsule-cli/src/main.rs +++ b/capsule-cli/src/main.rs @@ -1,11 +1,6 @@ use std::path::Path; use capitalize::Capitalize; -use clap::Parser; -use cli::{AuthCommands, Cli, Commands, LibraryCommands}; -use colored::*; -use dialoguer::Confirm; -use eyre::{Result, eyre}; use capsule_core::domain::ImportMode; use capsule_core::import::scanner::scan as scan_files; use capsule_core::import::{ @@ -13,6 +8,11 @@ use capsule_core::import::{ }; use capsule_core::library::{Library, LibraryError, init_library, open_library, rebuild_index}; use capsule_core::metadata::FileMetadata; +use clap::Parser; +use cli::{AuthCommands, Cli, Commands, LibraryCommands}; +use colored::*; +use dialoguer::Confirm; +use eyre::{Result, eyre}; use tracing::trace; use tracing_subscriber::prelude::*; use tracing_subscriber::{EnvFilter, fmt}; @@ -22,6 +22,7 @@ use crate::utils::directories::{get_cache_dir, get_config_dir, get_data_dir}; mod cli; mod config; mod db; +mod demo; mod import; mod status; mod utils; @@ -237,6 +238,11 @@ async fn main() -> Result<()> { .map_err(|e| eyre!("Failed to close library: {e}"))?; } + // ── Demo ────────────────────────────────────────────────────────── + Commands::Demo { workdir, image } => { + demo::run(workdir, image)?; + } + // ── Sync ────────────────────────────────────────────────────────── Commands::Sync { force, dry_run } => { println!("{}", "Syncing local and remote data...".green()); diff --git a/capsule-core/Cargo.toml b/capsule-core/Cargo.toml index 7de9050..0e95a9a 100644 --- a/capsule-core/Cargo.toml +++ b/capsule-core/Cargo.toml @@ -7,19 +7,35 @@ publish.workspace = true [dependencies] chrono = { workspace = true } ciborium = "0.2" +half = "2" # IEEE-754 f16 for RFC 8949 §4.2 shortest-float canonical CBOR globset = "0.4.16" indexmap = { workspace = true } kamadak-exif = "0.5" log = { workspace = true } rusqlite = { version = "0.32", features = ["bundled"] } serde = { workspace = true } +serde_bytes = "0.11" # CBOR byte-string (de)serialization for keys/signatures serde_json = { workspace = true } thiserror = { workspace = true } tzf-rs = "0.4" -uuid = { workspace = true, features = ["v7", "serde"] } +uuid = { workspace = true, features = ["v4", "v7", "serde"] } walkdir = "2" sha2 = { workspace = true } hex = { workspace = true } +# ── Cryptography (data plane) ────────────────────────────────────────────── +# Pin the STABLE RustCrypto line: aes-gcm 0.11 / aead 0.6 deleted the STREAM module. +aes-gcm = { version = "0.10", features = ["aes"] } +aead = { version = "0.5", features = ["stream", "alloc"] } +hkdf = "0.12" +hmac = "0.12" +argon2 = { workspace = true } +ed25519-dalek = "2" +ml-dsa = "0.1" # RustCrypto FIPS-204 (ML-DSA-65) — the PQ signature half +ml-kem = "0.3" # RustCrypto FIPS-203 (ML-KEM-768) — KEM for the device encryption key +getrandom = "0.3" # OS CSPRNG for keys, salts, nonce prefixes +tar = "0.4" # deterministic uncompressed POSIX tar backup container +sharks = "0.5" # Shamir secret sharing (opt-in 2-of-3 recovery) + [dev-dependencies] tempfile = "3" diff --git a/capsule-core/src/backup/artifact.rs b/capsule-core/src/backup/artifact.rs new file mode 100644 index 0000000..abc5f6a --- /dev/null +++ b/capsule-core/src/backup/artifact.rs @@ -0,0 +1,778 @@ +//! The backup artifact: a deterministic, self-describing, signed tar container +//! (SSoT: [Backup — Backup Artifact]). +//! +//! Layout (entries after the header are sorted by `(album_id, asset_id, blob_role)`): +//! ```text +//! VERSION # plaintext: format version, crypto_suite_id, wrap salt + params +//! MANIFEST.cbor # entry list/hashes + provenance heads; HMAC + hybrid exporter sig +//! keys/amk-ledger.cbor # the AMKs needed, sealed under the passphrase-derived wrap key +//! blobs/{ciphertext_hash} # encrypted asset ciphertext +//! meta/{asset_id} # encrypted metadata blob +//! provenance/{asset_id} # the full per-asset provenance chain (canonical CBOR) +//! ``` +//! +//! The MANIFEST is authenticated **two ways**: an HMAC under the wrap key (catches +//! tamper/truncation before any decrypt) and a hybrid exporter signature (defeats a +//! wrap-key thief who could otherwise re-HMAC). Restore is a **chain reconciliation**, never +//! a blind overwrite, and **dry-run is the default**. +//! +//! [Backup — Backup Artifact]: https://docs/design/backup-recovery/#backup-artifact + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Read; + +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use uuid::Uuid; + +use super::{ARTIFACT_FORMAT_VERSION, BackupError}; +use crate::cbor; +use crate::crypto::encryption::stream; +use crate::crypto::hash::{self, Hash32}; +use crate::crypto::keys::{Amk, HybridSigningKey, HybridVerifyingKey}; +use crate::crypto::primitives::{Argon2Params, CRYPTO_SUITE_ID, PROTOCOL_VERSION, info}; +use crate::crypto::provenance::ProvenanceRecord; +use crate::crypto::{kdf, pwkdf, rng}; + +type HmacSha256 = Hmac; + +/// Argon2id params for the backup wrap key, recorded in VERSION so restore reproduces the +/// key. Production uses the normal-tier cost; tests use a trivially-fast cost (the wrap-key +/// strength is orthogonal to the format/round-trip correctness the tests exercise). +#[cfg(not(test))] +const WRAP_PARAMS: Argon2Params = Argon2Params { + mem_kib: 256 * 1024, + t_cost: 3, + p_cost: 1, +}; +#[cfg(test)] +const WRAP_PARAMS: Argon2Params = Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, +}; + +/// One asset to back up: its ciphertext, metadata blob, and full provenance chain. +#[derive(Debug, Clone)] +pub struct BackupAsset { + /// Album the asset belongs to. + pub album_id: Uuid, + /// The asset id. + pub asset_id: Uuid, + /// STREAM ciphertext blob. + pub ciphertext: Vec, + /// Encrypted metadata blob (wire format). + pub metadata_blob: Vec, + /// The asset's provenance chain (oldest first). + pub provenance: Vec, +} + +impl BackupAsset { + fn head(&self) -> Result<&crate::crypto::provenance::AssetManifest, BackupError> { + self.provenance + .last() + .map(|r| &r.manifest) + .ok_or(BackupError::Auth("asset has empty provenance chain")) + } +} + +/// Everything needed to assemble an artifact. +pub struct BackupInput { + /// The assets to include. + pub assets: Vec, + /// The AMK bytes for every `(album_id, amk_version)` an asset references. + pub amks: BTreeMap<(Uuid, u32), [u8; 32]>, + /// The exporting device id. + pub exporter_device: Uuid, + /// Source library version string. + pub source_library_version: String, + /// RFC3339 export timestamp. + pub export_timestamp: String, +} + +/// A manifest entry: a path and the SHA-256 + size of its content. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct EntryRef { + path: String, + hash: Hash32, + size: u64, +} + +/// The signed core of the backup MANIFEST. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ManifestCore { + artifact_version: u16, + crypto_suite_id: u16, + min_protocol_version: String, + exporter_device: Uuid, + export_timestamp: String, + source_library_version: String, + entries: Vec, + provenance_heads: Vec<(Uuid, Hash32)>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Manifest { + core: ManifestCore, + #[serde(with = "serde_bytes")] + hmac: Vec, + exporter_sig: crate::crypto::keys::HybridSignature, +} + +/// The AMK ledger plaintext: every AMK needed to decrypt the included assets. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +struct AmkLedger { + /// `(album_id, amk_version) -> amk bytes`. + entries: Vec<(Uuid, u32, [u8; 32])>, +} + +fn blob_role_order(role: &str) -> u8 { + match role { + "blobs" => 0, + "meta" => 1, + "provenance" => 2, + _ => 3, + } +} + +// ── tar I/O (deterministic) ───────────────────────────────────────────────── + +fn tar_append(builder: &mut tar::Builder>, path: &str, data: &[u8]) { + let mut h = tar::Header::new_gnu(); + h.set_size(data.len() as u64); + h.set_mode(0o644); + h.set_mtime(0); + h.set_uid(0); + h.set_gid(0); + h.set_entry_type(tar::EntryType::Regular); + builder + .append_data(&mut h, path, data) + .expect("tar append is infallible for an in-memory writer"); +} + +fn tar_read(bytes: &[u8]) -> Result)>, BackupError> { + let mut archive = tar::Archive::new(bytes); + let mut out = Vec::new(); + for entry in archive + .entries() + .map_err(|e| BackupError::Format(e.to_string()))? + { + let mut e = entry.map_err(|e| BackupError::Format(e.to_string()))?; + let path = e + .path() + .map_err(|e| BackupError::Format(e.to_string()))? + .to_string_lossy() + .into_owned(); + let mut buf = Vec::new(); + e.read_to_end(&mut buf) + .map_err(|e| BackupError::Format(e.to_string()))?; + out.push((path, buf)); + } + Ok(out) +} + +fn version_blob(salt: &[u8; 32]) -> Vec { + format!( + "artifact_format={ARTIFACT_FORMAT_VERSION}\ncrypto_suite_id={CRYPTO_SUITE_ID}\nmin_protocol_version={PROTOCOL_VERSION}\nwrap_salt={}\nwrap_mem_kib={}\nwrap_t={}\nwrap_p={}\n", + hex::encode(salt), + WRAP_PARAMS.mem_kib, + WRAP_PARAMS.t_cost, + WRAP_PARAMS.p_cost, + ) + .into_bytes() +} + +fn parse_version(blob: &[u8]) -> Result<([u8; 32], Argon2Params), BackupError> { + let text = + std::str::from_utf8(blob).map_err(|_| BackupError::Format("VERSION not utf8".into()))?; + let mut salt = None; + let (mut mem, mut t, mut p) = (None, None, None); + for line in text.lines() { + if let Some((k, v)) = line.split_once('=') { + match k { + "wrap_salt" => { + salt = Hash32::from_hex(v).ok().map(|h| h.0); + } + "wrap_mem_kib" => mem = v.parse().ok(), + "wrap_t" => t = v.parse().ok(), + "wrap_p" => p = v.parse().ok(), + _ => {} + } + } + } + Ok(( + salt.ok_or(BackupError::Format("VERSION missing wrap_salt".into()))?, + Argon2Params { + mem_kib: mem.ok_or(BackupError::Format("VERSION missing wrap_mem_kib".into()))?, + t_cost: t.ok_or(BackupError::Format("VERSION missing wrap_t".into()))?, + p_cost: p.ok_or(BackupError::Format("VERSION missing wrap_p".into()))?, + }, + )) +} + +/// Seal the AMK ledger under the wrap key with a deterministic (key-derived) nonce, so a +/// re-export with the same salt+content is byte-identical. Production uses a fresh random +/// salt per export, so the (key, nonce) pair never repeats across distinct plaintexts. +fn seal_ledger(wrap_key: &[u8; 32], ledger: &AmkLedger) -> Vec { + use aes_gcm::aead::{Aead, KeyInit}; + use aes_gcm::{Aes256Gcm, Key, Nonce}; + let plaintext = cbor::to_canonical_vec(ledger).expect("ledger serializes"); + let nonce_bytes = kdf::derive_key32(wrap_key, b"amk-ledger-nonce", info::METADATA_BLOB_V1); + let nonce = &nonce_bytes[..12]; + let cipher = Aes256Gcm::new(Key::::from_slice(wrap_key)); + cipher + .encrypt(Nonce::from_slice(nonce), plaintext.as_slice()) + .expect("ledger seal") +} + +fn open_ledger(wrap_key: &[u8; 32], sealed: &[u8]) -> Result { + use aes_gcm::aead::{Aead, KeyInit}; + use aes_gcm::{Aes256Gcm, Key, Nonce}; + let nonce_bytes = kdf::derive_key32(wrap_key, b"amk-ledger-nonce", info::METADATA_BLOB_V1); + let nonce = &nonce_bytes[..12]; + let cipher = Aes256Gcm::new(Key::::from_slice(wrap_key)); + let plaintext = cipher + .decrypt(Nonce::from_slice(nonce), sealed) + .map_err(|_| BackupError::Auth("AMK ledger decryption failed"))?; + cbor::from_slice(&plaintext).map_err(|e| BackupError::Format(e.to_string())) +} + +// ── Export ────────────────────────────────────────────────────────────────── + +/// Assemble a backup artifact with an explicit wrap salt (deterministic; used by tests). +pub fn export_with_salt( + input: &BackupInput, + passphrase: &[u8], + salt: [u8; 32], + exporter: &HybridSigningKey, +) -> Result, BackupError> { + let wrap_key = pwkdf::derive_wrap_key(passphrase, &salt, WRAP_PARAMS)?; + + // Build the AMK ledger, asserting completeness for every referenced epoch. + let mut ledger = AmkLedger::default(); + let mut needed: BTreeSet<(Uuid, u32)> = BTreeSet::new(); + for a in &input.assets { + let head = a.head()?; + needed.insert((a.album_id, head.core.amk_version.0)); + } + for (album, epoch) in &needed { + let amk = input + .amks + .get(&(*album, *epoch)) + .ok_or_else(|| BackupError::AmkIncomplete(format!("album {album} epoch {epoch}")))?; + ledger.entries.push((*album, *epoch, *amk)); + } + ledger.entries.sort(); + let sealed_ledger = seal_ledger(&wrap_key, &ledger); + + // Sort assets deterministically by (album_id, asset_id). + let mut assets: Vec<&BackupAsset> = input.assets.iter().collect(); + assets.sort_by_key(|a| (a.album_id, a.asset_id)); + + // Collect entries (sorted by album, asset, then blob role) and provenance heads. + let mut entries: Vec = Vec::new(); + let mut payloads: Vec<(String, Vec)> = Vec::new(); + let mut provenance_heads: Vec<(Uuid, Hash32)> = Vec::new(); + + entries.push(EntryRef { + path: "keys/amk-ledger.cbor".into(), + hash: hash::hash_bytes(&sealed_ledger), + size: sealed_ledger.len() as u64, + }); + + for a in &assets { + let head = a.head()?; + let ct_path = format!("blobs/{}", head.core.ciphertext_hash.to_hex()); + let meta_path = format!("meta/{}", a.asset_id); + let prov_bytes = cbor::to_canonical_vec(&a.provenance).expect("provenance serializes"); + let prov_path = format!("provenance/{}", a.asset_id); + + for (path, data) in [ + (ct_path, &a.ciphertext), + (meta_path, &a.metadata_blob), + (prov_path, &prov_bytes), + ] { + entries.push(EntryRef { + path: path.clone(), + hash: hash::hash_bytes(data), + size: data.len() as u64, + }); + payloads.push((path, data.clone())); + } + let head_rec = a.provenance.last().unwrap().record_hash(); + provenance_heads.push((a.asset_id, head_rec)); + } + entries.sort_by(|x, y| { + let rank = |p: &str| { + let role = p.split('/').next().unwrap_or(""); + blob_role_order(role) + }; + x.path + .split('/') + .next() + .cmp(&y.path.split('/').next()) + .then(rank(&x.path).cmp(&rank(&y.path))) + .then(x.path.cmp(&y.path)) + }); + + let core = ManifestCore { + artifact_version: ARTIFACT_FORMAT_VERSION, + crypto_suite_id: CRYPTO_SUITE_ID, + min_protocol_version: PROTOCOL_VERSION.into(), + exporter_device: input.exporter_device, + export_timestamp: input.export_timestamp.clone(), + source_library_version: input.source_library_version.clone(), + entries, + provenance_heads, + }; + let core_bytes = cbor::to_canonical_vec(&core).expect("manifest core serializes"); + let mut mac = HmacSha256::new_from_slice(&wrap_key).expect("hmac key"); + mac.update(&core_bytes); + let hmac = mac.finalize().into_bytes().to_vec(); + let exporter_sig = exporter.sign(&core_bytes); + let manifest = Manifest { + core, + hmac, + exporter_sig, + }; + let manifest_bytes = cbor::to_canonical_vec(&manifest).expect("manifest serializes"); + + // Write the tar: VERSION, MANIFEST, ledger, then sorted payloads. + let mut builder = tar::Builder::new(Vec::new()); + tar_append(&mut builder, "VERSION", &version_blob(&salt)); + tar_append(&mut builder, "MANIFEST.cbor", &manifest_bytes); + tar_append(&mut builder, "keys/amk-ledger.cbor", &sealed_ledger); + // Re-sort payloads to match the manifest entry order. + payloads.sort_by(|x, y| { + x.0.split('/') + .next() + .cmp(&y.0.split('/').next()) + .then(x.0.cmp(&y.0)) + }); + for (path, data) in &payloads { + tar_append(&mut builder, path, data); + } + builder + .into_inner() + .map_err(|e| BackupError::Format(e.to_string())) +} + +/// Assemble a backup artifact, drawing a fresh random wrap salt (production path). +pub fn export( + input: &BackupInput, + passphrase: &[u8], + exporter: &HybridSigningKey, +) -> Result, BackupError> { + export_with_salt(input, passphrase, rng::random_array::<32>(), exporter) +} + +// ── Restore ───────────────────────────────────────────────────────────────── + +/// How aggressively a restore acts. Dry-run is the safe default. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RestoreMode { + /// Verify structure only; no decrypt, no write. + Preview, + /// Verify decryption + hashes and compute the diff; **no write** (default). + DryRun, + /// Apply (the caller writes the returned assets); never a silent overwrite. + Commit, +} + +/// A decrypted asset ready to write into a library (returned only on `Commit`). +#[derive(Debug, Clone)] +pub struct RestoredAsset { + /// Album id. + pub album_id: Uuid, + /// Asset id. + pub asset_id: Uuid, + /// Decrypted plaintext bytes. + pub plaintext: Vec, + /// The metadata blob (still encrypted; written verbatim). + pub metadata_blob: Vec, + /// The provenance chain. + pub provenance: Vec, +} + +/// The outcome of a restore (chain-reconciliation; newer local state always wins). +#[derive(Debug, Default)] +pub struct RestoreReport { + /// Entries whose content hash (and, past preview, decryption) verified. + pub verified: usize, + /// Assets absent locally — applied on commit. + pub would_add: Vec, + /// Assets already current locally — no-op. + pub identical: Vec, + /// Assets whose local head differs — not applied (quarantined for explicit merge). + pub conflicts: Vec, + /// Decrypted assets to write (populated only on `Commit`). + pub applied: Vec, +} + +/// A verified, opened backup artifact, ready to restore. +pub struct BackupArtifact { + files: BTreeMap>, + ledger: BTreeMap<(Uuid, u32), [u8; 32]>, + core: ManifestCore, +} + +impl BackupArtifact { + /// Open and fully verify an artifact: HMAC (wrap key) + exporter signature + per-entry + /// content hashes + AMK-ledger completeness. The exporter key is looked up by the caller + /// in the user's device directory. + pub fn open( + bytes: &[u8], + passphrase: &[u8], + exporter_pub: &HybridVerifyingKey, + ) -> Result { + let files: BTreeMap> = tar_read(bytes)?.into_iter().collect(); + let version = files + .get("VERSION") + .ok_or(BackupError::Format("missing VERSION".into()))?; + let (salt, params) = parse_version(version)?; + let wrap_key = pwkdf::derive_wrap_key(passphrase, &salt, params)?; + + let manifest_bytes = files + .get("MANIFEST.cbor") + .ok_or(BackupError::Format("missing MANIFEST.cbor".into()))?; + let manifest: Manifest = + cbor::from_slice(manifest_bytes).map_err(|e| BackupError::Format(e.to_string()))?; + let core_bytes = cbor::to_canonical_vec(&manifest.core).expect("manifest core serializes"); + + // (1) HMAC under the wrap key (catches tamper before any decrypt). + let mut mac = HmacSha256::new_from_slice(&wrap_key).expect("hmac key"); + mac.update(&core_bytes); + mac.verify_slice(&manifest.hmac).map_err(|_| { + BackupError::Auth("MANIFEST HMAC mismatch (tamper or wrong passphrase)") + })?; + // (2) Exporter hybrid signature (defeats a wrap-key thief). + if !exporter_pub.verify(&core_bytes, &manifest.exporter_sig) { + return Err(BackupError::Auth("MANIFEST exporter signature invalid")); + } + + // (3) Per-entry content hashes. + for entry in &manifest.core.entries { + let data = files + .get(&entry.path) + .ok_or_else(|| BackupError::Corrupt(format!("missing entry {}", entry.path)))?; + if hash::hash_bytes(data) != entry.hash || data.len() as u64 != entry.size { + return Err(BackupError::Corrupt(entry.path.clone())); + } + } + + // (4) Unseal + load the AMK ledger. + let sealed = files + .get("keys/amk-ledger.cbor") + .ok_or(BackupError::Format("missing AMK ledger".into()))?; + let ledger = open_ledger(&wrap_key, sealed)?; + let ledger: BTreeMap<(Uuid, u32), [u8; 32]> = ledger + .entries + .into_iter() + .map(|(album, epoch, amk)| ((album, epoch), amk)) + .collect(); + + Ok(Self { + files, + ledger, + core: manifest.core, + }) + } + + /// Restore against a target library's current provenance heads (`asset_id -> head hash`). + /// `Preview` checks structure only; `DryRun` (default) also decrypts to verify; `Commit` + /// returns the decrypted assets to write. Never overwrites newer local state. + pub fn restore( + &self, + mode: RestoreMode, + local_heads: &BTreeMap, + ) -> Result { + let mut report = RestoreReport::default(); + + for (asset_id, head_hash) in &self.core.provenance_heads { + report.verified += 1; + + // Reconcile against local state (newer local always wins; no silent overwrite). + match local_heads.get(asset_id) { + Some(local) if local == head_hash => { + report.identical.push(*asset_id); + continue; + } + Some(_) => { + report.conflicts.push(*asset_id); + continue; + } + None => report.would_add.push(*asset_id), + } + + if mode == RestoreMode::Preview { + continue; + } + + // Decrypt to verify (DryRun) / to return for writing (Commit). + let restored = self.decrypt_asset(asset_id)?; + if mode == RestoreMode::Commit { + report.applied.push(restored); + } + } + Ok(report) + } + + /// Decrypt one asset from the artifact using the ledger AMK + its head manifest. + fn decrypt_asset(&self, asset_id: &Uuid) -> Result { + let prov_bytes = self + .files + .get(&format!("provenance/{asset_id}")) + .ok_or_else(|| BackupError::Corrupt(format!("missing provenance {asset_id}")))?; + let provenance: Vec = + cbor::from_slice(prov_bytes).map_err(|e| BackupError::Format(e.to_string()))?; + let head = provenance + .last() + .ok_or(BackupError::Auth("empty provenance chain"))? + .manifest + .clone(); + + let ct = self + .files + .get(&format!("blobs/{}", head.core.ciphertext_hash.to_hex())) + .ok_or_else(|| BackupError::Corrupt(format!("missing ciphertext for {asset_id}")))?; + // Confirm the content hash before decrypting. + if hash::hash_bytes(ct) != head.core.ciphertext_hash { + return Err(BackupError::Corrupt(format!( + "ciphertext hash for {asset_id}" + ))); + } + + let amk_bytes = self + .ledger + .get(&(head.core.album_id, head.core.amk_version.0)) + .ok_or_else(|| { + BackupError::AmkIncomplete(format!( + "album {} epoch {}", + head.core.album_id, head.core.amk_version.0 + )) + })?; + let amk = Amk::from_bytes(*amk_bytes); + let file_key = amk.derive_file_key(&head.core.file_id); + let plaintext = stream::decrypt_asset_vec(&file_key, &head.core.nonce_prefix, ct) + .map_err(|_| BackupError::Auth("asset decryption failed"))?; + + let metadata_blob = self + .files + .get(&format!("meta/{asset_id}")) + .cloned() + .unwrap_or_default(); + + Ok(RestoredAsset { + album_id: head.core.album_id, + asset_id: *asset_id, + plaintext, + metadata_blob, + provenance, + }) + } + + /// The exporter device id recorded in the manifest (provenance: who exported). + pub fn exporter_device(&self) -> Uuid { + self.core.exporter_device + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::keys::{Amk, AmkVersion}; + use crate::crypto::primitives::PROTOCOL_VERSION; + use crate::crypto::provenance::action::Action; + use crate::crypto::provenance::manifest::{ASSET_MANIFEST_VERSION, ManifestCore as MCore}; + + const ALBUM: u128 = 0xA1; + + struct Fix { + device: HybridSigningKey, + write: HybridSigningKey, + amk: [u8; 32], + } + + impl Fix { + fn new() -> Self { + Self { + device: HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]), + write: HybridSigningKey::from_seed_bytes(&[3; 32], &[4; 32]), + amk: [0x55; 32], + } + } + + /// Build a backup asset with a real STREAM ciphertext + create manifest. + fn asset(&self, asset_id: u128, plaintext: &[u8]) -> BackupAsset { + let amk = Amk::from_bytes(self.amk); + let file_id = Uuid::from_u128(asset_id); + let file_key = amk.derive_file_key(&file_id); + let (enc, ct) = stream::encrypt_asset_vec_full(&file_key, plaintext); + + let core = MCore { + version: ASSET_MANIFEST_VERSION.into(), + crypto_suite_id: CRYPTO_SUITE_ID, + protocol_version: PROTOCOL_VERSION.into(), + file_id, + album_id: Uuid::from_u128(ALBUM), + amk_version: AmkVersion(1), + ciphertext_hash: enc.ciphertext_hash, + plaintext_size: enc.plaintext_size, + chunk_size: enc.chunk_size, + nonce_prefix: enc.nonce_prefix, + created_by_user: Uuid::from_u128(0x05E2), + created_by_device: Uuid::from_u128(0xD1), + client_version: "t".into(), + timestamp: "2026-05-31T00:00:00Z".into(), + action: Action::Create, + prior_provenance_hash: None, + retention_until: None, + }; + let manifest = core.sign(&self.device, &self.write); + let record = ProvenanceRecord { + asset_id: file_id, + manifest, + prior_provenance_hash: None, + }; + BackupAsset { + album_id: Uuid::from_u128(ALBUM), + asset_id: file_id, + ciphertext: ct, + metadata_blob: crate::crypto::encryption::seal_blob( + &amk.derive_blob_key(&file_id), + b"{sidecar}", + ), + provenance: vec![record], + } + } + + fn input(&self, assets: Vec) -> BackupInput { + let mut amks = BTreeMap::new(); + amks.insert((Uuid::from_u128(ALBUM), 1u32), self.amk); + BackupInput { + assets, + amks, + exporter_device: Uuid::from_u128(0xD1), + source_library_version: "1".into(), + export_timestamp: "2026-05-31T00:00:00Z".into(), + } + } + } + + #[test] + fn export_is_byte_identical_for_same_input_and_salt() { + let f = Fix::new(); + let input = f.input(vec![f.asset(1, b"alpha"), f.asset(2, b"beta")]); + let salt = [0x11; 32]; + let a = export_with_salt(&input, b"pw", salt, &f.device).unwrap(); + let b = export_with_salt(&input, b"pw", salt, &f.device).unwrap(); + assert_eq!(a, b, "deterministic export must be byte-identical"); + } + + #[test] + fn open_verifies_and_restore_to_fresh_library_recovers_plaintext() { + let f = Fix::new(); + let input = f.input(vec![ + f.asset(1, b"hello world"), + f.asset(2, b"second asset"), + ]); + let bytes = export(&input, b"pw", &f.device).unwrap(); + + let art = BackupArtifact::open(&bytes, b"pw", &f.device.verifying_key()).unwrap(); + // Fresh library (no local heads) → everything applies. + let report = art.restore(RestoreMode::Commit, &BTreeMap::new()).unwrap(); + assert_eq!(report.verified, 2); + assert_eq!(report.would_add.len(), 2); + assert_eq!(report.applied.len(), 2); + + let mut plaintexts: Vec> = + report.applied.iter().map(|a| a.plaintext.clone()).collect(); + plaintexts.sort(); + assert_eq!( + plaintexts, + vec![b"hello world".to_vec(), b"second asset".to_vec()] + ); + } + + #[test] + fn wrong_passphrase_fails_to_open() { + let f = Fix::new(); + let bytes = export(&f.input(vec![f.asset(1, b"x")]), b"right", &f.device).unwrap(); + assert!(BackupArtifact::open(&bytes, b"wrong", &f.device.verifying_key()).is_err()); + } + + #[test] + fn tampering_an_entry_is_detected() { + let f = Fix::new(); + let bytes = export(&f.input(vec![f.asset(1, b"x")]), b"pw", &f.device).unwrap(); + // Flip a byte somewhere in the archive body (a blob) → entry-hash or HMAC mismatch. + let mut t = bytes.clone(); + let mid = t.len() / 2; + t[mid] ^= 0x01; + assert!(BackupArtifact::open(&t, b"pw", &f.device.verifying_key()).is_err()); + } + + #[test] + fn wrong_exporter_key_is_rejected() { + let f = Fix::new(); + let bytes = export(&f.input(vec![f.asset(1, b"x")]), b"pw", &f.device).unwrap(); + let imposter = HybridSigningKey::from_seed_bytes(&[9; 32], &[9; 32]).verifying_key(); + assert!(BackupArtifact::open(&bytes, b"pw", &imposter).is_err()); + } + + #[test] + fn amk_incomplete_is_detected_at_export() { + let f = Fix::new(); + // Build input whose ledger omits the needed AMK. + let mut input = f.input(vec![f.asset(1, b"x")]); + input.amks.clear(); + assert!(matches!( + export(&input, b"pw", &f.device), + Err(BackupError::AmkIncomplete(_)) + )); + } + + #[test] + fn restore_reconciliation_matrix() { + let f = Fix::new(); + let asset = f.asset(1, b"content"); + let head = asset.provenance.last().unwrap().record_hash(); + let asset_id = asset.asset_id; + let bytes = export(&f.input(vec![asset]), b"pw", &f.device).unwrap(); + let art = BackupArtifact::open(&bytes, b"pw", &f.device.verifying_key()).unwrap(); + + // Identical local head → no-op. + let mut heads = BTreeMap::new(); + heads.insert(asset_id, head); + let r = art.restore(RestoreMode::DryRun, &heads).unwrap(); + assert_eq!(r.identical, vec![asset_id]); + assert!(r.applied.is_empty()); + + // Divergent local head → conflict, not applied. + let mut heads = BTreeMap::new(); + heads.insert(asset_id, Hash32([0xEE; 32])); + let r = art.restore(RestoreMode::Commit, &heads).unwrap(); + assert_eq!(r.conflicts, vec![asset_id]); + assert!( + r.applied.is_empty(), + "never silently overwrite divergent local state" + ); + + // Absent locally → applied. + let r = art.restore(RestoreMode::Commit, &BTreeMap::new()).unwrap(); + assert_eq!(r.would_add, vec![asset_id]); + assert_eq!(r.applied.len(), 1); + } + + #[test] + fn dry_run_writes_nothing() { + let f = Fix::new(); + let bytes = export(&f.input(vec![f.asset(1, b"x")]), b"pw", &f.device).unwrap(); + let art = BackupArtifact::open(&bytes, b"pw", &f.device.verifying_key()).unwrap(); + let r = art.restore(RestoreMode::DryRun, &BTreeMap::new()).unwrap(); + // DryRun verifies (decrypts) but returns nothing to write. + assert_eq!(r.verified, 1); + assert!(r.applied.is_empty()); + } +} diff --git a/capsule-core/src/backup/mod.rs b/capsule-core/src/backup/mod.rs new file mode 100644 index 0000000..9f752fd --- /dev/null +++ b/capsule-core/src/backup/mod.rs @@ -0,0 +1,153 @@ +//! The portable backup artifact and recovery mechanisms (SSoT: [Backup and Recovery]). +//! +//! Two distinct things are kept separate: +//! - the **[backup artifact](artifact)** — a deterministic, self-describing, signed tar of a +//! library's encrypted blobs, metadata blobs, provenance chains, and the AMK ledger needed +//! to decrypt them; +//! - the **[master-key escrow](escrow_master_key)** — a small passphrase-wrapped blob that +//! reconstructs the key hierarchy. +//! +//! Recovery rests on one rule: holding the recovery secret restores every asset, even after +//! every device is lost. The artifact carries its own AMK ledger, so it is self-sufficient. +//! +//! [Backup and Recovery]: https://docs/design/backup-recovery/ + +pub mod artifact; + +pub use artifact::{ + BackupArtifact, BackupAsset, BackupInput, RestoreMode, RestoreReport, export, export_with_salt, +}; + +use thiserror::Error; + +use crate::crypto::primitives::DeviceTier; +use crate::crypto::pwkdf::{self, WrappedSecret}; +use crate::crypto::{CryptoError, rng}; + +/// The backup artifact format version. +pub const ARTIFACT_FORMAT_VERSION: u16 = 1; + +/// Errors from backup export/restore and recovery. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum BackupError { + /// A reader/writer or archive structural failure. + #[error("backup io/format error: {0}")] + Format(String), + /// MANIFEST HMAC or exporter signature failed (tamper). + #[error("backup authentication failed: {0}")] + Auth(&'static str), + /// An entry's content hash did not match the manifest (corruption). + #[error("backup entry corrupt: {0}")] + Corrupt(String), + /// The AMK ledger is missing an `amk_version` an included asset references. + #[error("backup AMK ledger incomplete: missing {0}")] + AmkIncomplete(String), + /// Underlying cryptographic failure. + #[error(transparent)] + Crypto(#[from] CryptoError), +} + +// ── Master-key escrow ─────────────────────────────────────────────────────── + +/// Escrow the account master key under a recovery passphrase (Argon2id + AES-256-GCM). +pub fn escrow_master_key( + master: &[u8; 32], + passphrase: &[u8], + tier: DeviceTier, +) -> Result { + Ok(pwkdf::wrap(master, passphrase, tier)?) +} + +/// Recover the master key from its escrow blob and the recovery passphrase. +pub fn recover_master_key( + blob: &WrappedSecret, + passphrase: &[u8], +) -> Result<[u8; 32], BackupError> { + let bytes = pwkdf::unwrap(blob, passphrase)?; + bytes + .as_slice() + .try_into() + .map_err(|_| BackupError::Auth("escrowed master key wrong length")) +} + +// ── Opt-in Shamir 2-of-3 social recovery ──────────────────────────────────── + +/// Split a 32-byte recovery seed into 3 Shamir shares; any 2 reconstruct it, 1 reveals +/// nothing. The default scheme from [Backup — Opt-in: Shamir Secret Sharing]. +/// +/// [Backup — Opt-in: Shamir Secret Sharing]: https://docs/design/backup-recovery/#opt-in-shamir-secret-sharing +pub fn split_seed_2of3(seed: &[u8; 32]) -> Vec> { + let sharks = sharks::Sharks(2); + let dealer = sharks.dealer(seed); + dealer.take(3).map(|s| Vec::from(&s)).collect() +} + +/// Reconstruct a seed from a set of Shamir shares (≥ 2 of the 3). +pub fn recover_seed(shares: &[Vec]) -> Result, BackupError> { + let sharks = sharks::Sharks(2); + let parsed: Result, _> = shares + .iter() + .map(|b| sharks::Share::try_from(b.as_slice())) + .collect(); + let parsed = parsed.map_err(|_| BackupError::Format("malformed Shamir share".into()))?; + sharks + .recover(parsed.iter()) + .map(|v| v.to_vec()) + .map_err(|e| BackupError::Format(format!("Shamir recover: {e}"))) +} + +/// Draw a fresh random 32-byte recovery seed. +pub fn new_recovery_seed() -> [u8; 32] { + rng::random_array::<32>() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn master_key_escrow_round_trip() { + let master = [0x42u8; 32]; + // Fast params for the test. + let blob = pwkdf::wrap_with( + &master, + b"recovery passphrase", + crate::crypto::primitives::Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, + }, + ) + .unwrap(); + assert_eq!( + recover_master_key(&blob, b"recovery passphrase").unwrap(), + master + ); + assert!(recover_master_key(&blob, b"wrong").is_err()); + } + + #[test] + fn shamir_2_of_3_reconstructs_from_any_two() { + let seed = [0x7u8; 32]; + let shares = split_seed_2of3(&seed); + assert_eq!(shares.len(), 3); + + // Any 2 of the 3 reconstruct the seed. + for pair in [[0, 1], [0, 2], [1, 2]] { + let subset = vec![shares[pair[0]].clone(), shares[pair[1]].clone()]; + assert_eq!(recover_seed(&subset).unwrap(), seed); + } + } + + #[test] + fn shamir_single_share_does_not_reconstruct() { + let seed = [0x9u8; 32]; + let shares = split_seed_2of3(&seed); + // A single share is below threshold → cannot recover the seed. + let one = vec![shares[0].clone()]; + match recover_seed(&one) { + Err(_) => {} + Ok(v) => assert_ne!(v, seed, "one share must not reveal the seed"), + } + } +} diff --git a/capsule-core/src/cbor/encode.rs b/capsule-core/src/cbor/encode.rs new file mode 100644 index 0000000..16906fa --- /dev/null +++ b/capsule-core/src/cbor/encode.rs @@ -0,0 +1,128 @@ +//! The canonical CBOR byte encoder over [`ciborium::value::Value`]. +//! +//! Implements RFC 8949 §4.2 *Core Deterministic Encoding*: +//! - definite-length items only, +//! - shortest-form (preferred) integer and float encodings, +//! - map entries sorted by the **bytewise lexicographic order of their encoded keys**. +//! +//! `ciborium` itself preserves map wire order and does not offer a deterministic mode, +//! so this encoder owns the framing; `ciborium` is used only to model values. + +use ciborium::value::{Integer, Value}; + +const MT_UINT: u8 = 0; +const MT_NINT: u8 = 1; +const MT_BYTES: u8 = 2; +const MT_TEXT: u8 = 3; +const MT_ARRAY: u8 = 4; +const MT_MAP: u8 = 5; +const MT_TAG: u8 = 6; + +/// Emit a major-type head with its argument in the shortest of the 1/2/3/5/9-byte forms. +fn encode_head(major: u8, arg: u64, out: &mut Vec) { + let mt = major << 5; + if arg < 24 { + out.push(mt | arg as u8); + } else if arg < 0x100 { + out.push(mt | 24); + out.push(arg as u8); + } else if arg < 0x1_0000 { + out.push(mt | 25); + out.extend_from_slice(&(arg as u16).to_be_bytes()); + } else if arg < 0x1_0000_0000 { + out.push(mt | 26); + out.extend_from_slice(&(arg as u32).to_be_bytes()); + } else { + out.push(mt | 27); + out.extend_from_slice(&arg.to_be_bytes()); + } +} + +fn encode_integer(i: Integer, out: &mut Vec) { + // CBOR integers span -2^64 ..= 2^64-1, all of which fit in i128. + let n: i128 = i.into(); + if n >= 0 { + encode_head(MT_UINT, n as u64, out); + } else { + // Negative integers encode the argument as (-1 - n), itself in 0 ..= 2^64-1. + encode_head(MT_NINT, (-1 - n) as u64, out); + } +} + +/// RFC 8949 §4.2.1 preferred float serialization: the shortest of f16/f32/f64 that +/// round-trips the value exactly. All NaNs collapse to the canonical quiet NaN `0xf97e00`. +fn encode_float(f: f64, out: &mut Vec) { + if f.is_nan() { + out.extend_from_slice(&[0xf9, 0x7e, 0x00]); + return; + } + let h = half::f16::from_f64(f); + if h.to_f64().to_bits() == f.to_bits() { + out.push(0xf9); + out.extend_from_slice(&h.to_be_bytes()); + return; + } + let s = f as f32; + if (s as f64).to_bits() == f.to_bits() { + out.push(0xfa); + out.extend_from_slice(&s.to_be_bytes()); + return; + } + out.push(0xfb); + out.extend_from_slice(&f.to_be_bytes()); +} + +fn encode_map(entries: &[(Value, Value)], out: &mut Vec) { + // Encode each (key, value) to its own bytes, then sort entries by the encoded key + // bytes (bytewise lexicographic), per RFC 8949 §4.2.1. + let mut encoded: Vec<(Vec, Vec)> = entries + .iter() + .map(|(k, v)| { + let mut kb = Vec::new(); + encode_value(k, &mut kb); + let mut vb = Vec::new(); + encode_value(v, &mut vb); + (kb, vb) + }) + .collect(); + encoded.sort_by(|a, b| a.0.cmp(&b.0)); + + encode_head(MT_MAP, encoded.len() as u64, out); + for (kb, vb) in encoded { + out.extend_from_slice(&kb); + out.extend_from_slice(&vb); + } +} + +/// Recursively encode a value in canonical form. +pub(crate) fn encode_value(value: &Value, out: &mut Vec) { + match value { + Value::Integer(i) => encode_integer(*i, out), + Value::Bytes(b) => { + encode_head(MT_BYTES, b.len() as u64, out); + out.extend_from_slice(b); + } + Value::Text(s) => { + encode_head(MT_TEXT, s.len() as u64, out); + out.extend_from_slice(s.as_bytes()); + } + Value::Array(a) => { + encode_head(MT_ARRAY, a.len() as u64, out); + for e in a { + encode_value(e, out); + } + } + Value::Map(m) => encode_map(m, out), + Value::Tag(t, inner) => { + encode_head(MT_TAG, *t, out); + encode_value(inner, out); + } + Value::Bool(false) => out.push(0xf4), + Value::Bool(true) => out.push(0xf5), + Value::Null => out.push(0xf6), + Value::Float(f) => encode_float(*f, out), + // `ciborium::Value` is `#[non_exhaustive]`; any future variant has no defined + // canonical form here, so encode it as CBOR null rather than panic. + _ => out.push(0xf6), + } +} diff --git a/capsule-core/src/cbor/mod.rs b/capsule-core/src/cbor/mod.rs new file mode 100644 index 0000000..519584c --- /dev/null +++ b/capsule-core/src/cbor/mod.rs @@ -0,0 +1,261 @@ +//! Canonical CBOR (RFC 8949 §4.2 deterministic encoding). +//! +//! This is the **shared, load-bearing** encoder for every byte string a signature or +//! content hash commits to: the [sidecar](crate::sidecar), the encrypted +//! [metadata blob](crate::crypto::encryption) plaintext, and the signed +//! [manifests](crate::crypto::provenance). Two correct implementations MUST produce +//! byte-identical output for the same logical document, or signatures fail to verify +//! across peers — so this module is a **blocking cross-language conformance gate**, not +//! advisory. Conformance is pinned by the golden hex vectors in the tests below. +//! +//! SSoT for the ruleset: [Metadata — Canonical CBOR Encoding]. +//! +//! [Metadata — Canonical CBOR Encoding]: https://docs/design/metadata/#canonical-cbor-encoding + +mod encode; + +use ciborium::value::Value; +use serde::{Serialize, de::DeserializeOwned}; +use thiserror::Error; + +/// Errors from (de)serializing through the canonical codec. +#[derive(Debug, Error)] +pub enum CanonicalError { + /// The value could not be modeled as CBOR before canonicalization. + #[error("CBOR serialize failed: {0}")] + Serialize(String), + /// The input bytes were not valid CBOR. + #[error("CBOR deserialize failed: {0}")] + Deserialize(String), +} + +/// Canonically encode an already-built CBOR [`Value`]. Infallible. +pub fn value_to_canonical_vec(value: &Value) -> Vec { + let mut out = Vec::new(); + encode::encode_value(value, &mut out); + out +} + +/// Serialize a `T` and return its canonical CBOR bytes. +pub fn to_canonical_vec(value: &T) -> Result, CanonicalError> { + Ok(value_to_canonical_vec(&to_value(value)?)) +} + +/// Decode CBOR bytes into a `T`. Decoding is tolerant of non-canonical input; callers +/// that require canonical bytes re-encode via [`canonicalize`] or [`to_canonical_vec`]. +pub fn from_slice(bytes: &[u8]) -> Result { + ciborium::de::from_reader(bytes).map_err(|e| CanonicalError::Deserialize(e.to_string())) +} + +/// Re-encode arbitrary CBOR bytes into their canonical form. Used on the receive path: +/// decode what arrived, then verify a signature against the canonical re-encoding. +pub fn canonicalize(bytes: &[u8]) -> Result, CanonicalError> { + let value: Value = + ciborium::de::from_reader(bytes).map_err(|e| CanonicalError::Deserialize(e.to_string()))?; + Ok(value_to_canonical_vec(&value)) +} + +/// Model a `T` as a CBOR [`Value`] (the intermediate non-canonical encoding is irrelevant; +/// canonicalization happens on the way out). +fn to_value(value: &T) -> Result { + let mut buf = Vec::new(); + ciborium::ser::into_writer(value, &mut buf) + .map_err(|e| CanonicalError::Serialize(e.to_string()))?; + ciborium::de::from_reader(buf.as_slice()) + .map_err(|e| CanonicalError::Deserialize(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use ciborium::value::{Integer, Value}; + + fn enc(v: Value) -> Vec { + value_to_canonical_vec(&v) + } + fn int(n: i128) -> Value { + Value::Integer(Integer::try_from(n).unwrap()) + } + + // ── Shortest-form integers (RFC 8949 Appendix A) ──────────────────────────── + #[test] + fn integer_shortest_form() { + assert_eq!(enc(int(0)), [0x00]); + assert_eq!(enc(int(23)), [0x17]); + assert_eq!(enc(int(24)), [0x18, 0x18]); + assert_eq!(enc(int(255)), [0x18, 0xff]); + assert_eq!(enc(int(256)), [0x19, 0x01, 0x00]); + assert_eq!(enc(int(1000)), [0x19, 0x03, 0xe8]); + assert_eq!(enc(int(1_000_000)), [0x1a, 0x00, 0x0f, 0x42, 0x40]); + assert_eq!( + enc(int(1_000_000_000_000)), + [0x1b, 0x00, 0x00, 0x00, 0xe8, 0xd4, 0xa5, 0x10, 0x00] + ); + assert_eq!(enc(int(-1)), [0x20]); + assert_eq!(enc(int(-1000)), [0x39, 0x03, 0xe7]); + // Full unsigned range boundary: 2^64 - 1. + assert_eq!( + enc(int(18_446_744_073_709_551_615)), + [0x1b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff] + ); + } + + // ── Shortest-form floats (RFC 8949 Appendix A preferred serializations) ────── + #[test] + fn float_shortest_form() { + let f = |x: f64| enc(Value::Float(x)); + assert_eq!(f(0.0), [0xf9, 0x00, 0x00]); + assert_eq!(f(-0.0), [0xf9, 0x80, 0x00]); + assert_eq!(f(1.0), [0xf9, 0x3c, 0x00]); + assert_eq!(f(1.5), [0xf9, 0x3e, 0x00]); + assert_eq!(f(65504.0), [0xf9, 0x7b, 0xff]); // largest f16 + assert_eq!(f(5.960464477539063e-8), [0xf9, 0x00, 0x01]); // smallest f16 subnormal + assert_eq!(f(100000.0), [0xfa, 0x47, 0xc3, 0x50, 0x00]); // needs f32 + assert_eq!(f(3.4028234663852886e38), [0xfa, 0x7f, 0x7f, 0xff, 0xff]); // f32 max + assert_eq!( + f(1.1), + [0xfb, 0x3f, 0xf1, 0x99, 0x99, 0x99, 0x99, 0x99, 0x9a] + ); // needs f64 + assert_eq!(f(f64::INFINITY), [0xf9, 0x7c, 0x00]); + assert_eq!(f(f64::NEG_INFINITY), [0xf9, 0xfc, 0x00]); + assert_eq!(f(f64::NAN), [0xf9, 0x7e, 0x00]); // canonical quiet NaN + // A real GPS coordinate cannot round-trip through f16/f32, so it stays f64. + assert_eq!(enc(Value::Float(40.7128))[0], 0xfb); + } + + // ── Map key ordering: bytewise lexicographic on ENCODED keys ──────────────── + #[test] + fn map_keys_sorted_by_encoded_bytes() { + // Deliberately out of order. Encoded keys: + // 10 -> 0x0a (integer key sorts before any text key) + // "a" -> 0x61 0x61 + // "b" -> 0x61 0x62 + // "aa" -> 0x62 0x61 0x61 (shorter "b" sorts before longer "aa" — bytewise, not length-first) + let m = Value::Map(vec![ + (Value::Text("b".into()), int(1)), + (Value::Text("aa".into()), int(2)), + (int(10), int(3)), + (Value::Text("a".into()), int(4)), + ]); + let got = enc(m); + let expected = vec![ + 0xa4, // map(4) + 0x0a, 0x03, // 10: 3 + 0x61, 0x61, 0x04, // "a": 4 + 0x61, 0x62, 0x01, // "b": 1 + 0x62, 0x61, 0x61, 0x02, // "aa": 2 + ]; + assert_eq!(got, expected); + } + + #[test] + fn definite_length_only_and_nested() { + // Arrays + nested maps use definite length heads. + let v = Value::Array(vec![ + int(1), + Value::Map(vec![(Value::Text("k".into()), Value::Bool(true))]), + Value::Bytes(vec![0xde, 0xad]), + ]); + assert_eq!( + enc(v), + vec![0x83, 0x01, 0xa1, 0x61, 0x6b, 0xf5, 0x42, 0xde, 0xad] + ); + } + + // ── Idempotence + round-trip stability ────────────────────────────────────── + #[test] + fn canonicalize_is_idempotent() { + let m = Value::Map(vec![ + (Value::Text("z".into()), Value::Float(1.0)), + (Value::Text("a".into()), int(2)), + ]); + let once = enc(m); + let twice = canonicalize(&once).unwrap(); + assert_eq!(once, twice, "canonicalize must be a fixed point"); + // Decoding then re-encoding is stable too. + assert_eq!(canonicalize(&twice).unwrap(), once); + } + + #[test] + fn unknown_keys_resorted_among_known() { + // Mimics a sidecar's `_unknown` map merged with known fields: a future key + // ("zzz") and an early key ("aaa") must interleave by encoded order, not append. + let m = Value::Map(vec![ + (Value::Text("sidecar_schema".into()), int(1)), + (Value::Text("zzz_future".into()), Value::Bool(true)), + (Value::Text("aaa_future".into()), int(7)), + (Value::Text("uuid".into()), Value::Text("x".into())), + ]); + let bytes = enc(m); + let decoded: Value = ciborium::de::from_reader(bytes.as_slice()).unwrap(); + // Re-canonicalizing the decoded form yields identical bytes (sort is total + stable). + assert_eq!(canonicalize(&bytes).unwrap(), bytes); + // The bytewise sort is over the *encoded* key, whose first byte is the text + // length head (major 3 | len). So "uuid" (head 0x64, len 4) sorts before the + // longer keys "aaa_future"/"zzz_future" (head 0x6a, len 10) and "sidecar_schema" + // (head 0x6e, len 14) — length dominates content here. Full order: + // uuid, aaa_future, zzz_future, sidecar_schema. + if let Value::Map(entries) = decoded { + let keys: Vec = entries + .iter() + .map(|(k, _)| match k { + Value::Text(s) => s.clone(), + _ => unreachable!(), + }) + .collect(); + assert_eq!(keys, ["uuid", "aaa_future", "zzz_future", "sidecar_schema"]); + } else { + panic!("expected map"); + } + } + + // ── Golden vector: the cross-language conformance contract ─────────────────── + #[test] + fn golden_vector_is_stable() { + // A fixed logical document → a fixed hex string. Any port (Swift/Kotlin/JS) MUST + // reproduce this exactly. Changing it is a breaking, signature-invalidating change. + let doc = Value::Map(vec![ + (Value::Text("schema".into()), int(1)), + (Value::Text("ok".into()), Value::Bool(true)), + (Value::Text("ratio".into()), Value::Float(0.5)), + ( + Value::Text("digest".into()), + Value::Bytes(vec![0x01, 0x02, 0x03]), + ), + ( + Value::Text("nested".into()), + Value::Array(vec![int(-1), int(256)]), + ), + ]); + let hex = hex::encode(enc(doc)); + // Keys sort by encoded bytes: "ok"(62) < "ratio"(65) < "digest"(66 64) < + // "nested"(66 6e) < "schema"(66 73). 0.5 -> f16 0xf93800; bytes -> 0x43..; + // [-1,256] -> 0x82 20 19 0100; 1 -> 0x01. + let expected = concat!( + "a5", + "626f6b", + "f5", + "657261746", + "96f", + "f93800", + "6664696765737443010203", + "666e65737465648220190100", + "66736368656d6101", + ); + assert_eq!(hex, expected); + } + + #[test] + fn struct_serializes_through_canonical() { + #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)] + struct S { + b: u32, + a: u32, + } + // Field order in the struct is (b, a) but canonical output sorts keys "a" < "b". + let bytes = to_canonical_vec(&S { b: 2, a: 1 }).unwrap(); + assert_eq!(bytes, vec![0xa2, 0x61, 0x61, 0x01, 0x61, 0x62, 0x02]); + let back: S = from_slice(&bytes).unwrap(); + assert_eq!(back, S { b: 2, a: 1 }); + } +} diff --git a/capsule-core/src/crypto/authority/mod.rs b/capsule-core/src/crypto/authority/mod.rs new file mode 100644 index 0000000..412acac --- /dev/null +++ b/capsule-core/src/crypto/authority/mod.rs @@ -0,0 +1,57 @@ +//! The album authorization seam — exactly what [`verify_asset`] needs to learn from MLS +//! about an album, behind a trait so the real OpenMLS group state can drop in later. +//! +//! [`verify_asset`] needs only three facts about an album, and the *authority* on all +//! three is the album's admin-signed MLS commit chain — never the server: +//! 1. the monotonic **epoch ceiling** (the highest `amk_version` the chain attests), +//! 2. the **write-tier public key** for a given epoch (only writers at that epoch held the +//! private half), and +//! 3. whether the **AMK content key** for an epoch is *locally held* (to tell a key still +//! in flight apart from a forged epoch). +//! +//! Real OpenMLS integration is deferred (its PQ ciphersuite is a non-final draft on a C +//! backend — see `DEFERRED.md`); [`ReferenceAuthority`] is a deterministic, +//! admin-signature-backed stand-in that preserves every property `verify_asset` tests for. +//! Because `verify_asset` consumes only `&dyn AlbumAuthority`, swapping in an +//! `OpenMlsAuthority` later is transparent. +//! +//! [`verify_asset`]: crate::crypto::verify_asset +//! SSoT for the rules this seam encodes: [Keys — Write Authorization]. +//! +//! [Keys — Write Authorization]: https://docs/design/cryptography/keys/#write-authorization + +mod reference; + +pub use reference::ReferenceAuthority; + +use uuid::Uuid; + +use crate::crypto::keys::{AmkVersion, HybridVerifyingKey}; + +/// One album's MLS-attested authorization state, as needed by `verify_asset`. +/// +/// An instance represents a single album. All methods reflect the album's admin-signed +/// commit chain; an implementation must never let server-asserted state substitute for it. +pub trait AlbumAuthority { + /// The album this authority speaks for. + fn album_id(&self) -> Uuid; + + /// The monotonic epoch ceiling: the highest `amk_version` the admin chain attests. A + /// manifest claiming a higher epoch is terminal-rejected (the server cannot fabricate + /// a future epoch a client will honor). + fn epoch_ceiling(&self) -> AmkVersion; + + /// The write-tier public key for `epoch`, or `None` if the chain attests no such epoch. + /// `verify_asset` checks the manifest's `write_sig` against this key. + fn write_tier_pubkey(&self, epoch: AmkVersion) -> Option; + + /// Whether the AMK *content key* for `epoch` is held locally. When an epoch is within + /// the attested range but its AMK has not yet arrived over MLS, the asset is *pending*, + /// not forged. + fn has_amk(&self, epoch: AmkVersion) -> bool; + + /// Whether the admin-signed attestation chain itself verifies. If this is false, the + /// authority is untrusted and `verify_asset` must terminal-reject everything — an + /// implementation must never trust an unsigned or forged ledger. + fn admin_chain_verifies(&self) -> bool; +} diff --git a/capsule-core/src/crypto/authority/reference.rs b/capsule-core/src/crypto/authority/reference.rs new file mode 100644 index 0000000..66f7b33 --- /dev/null +++ b/capsule-core/src/crypto/authority/reference.rs @@ -0,0 +1,255 @@ +//! A deterministic, admin-signature-backed [`AlbumAuthority`] used in place of a live +//! OpenMLS group (deferred). It is an **admin-signed epoch ledger**: each epoch entry +//! binds `(album_id, epoch, write_tier_pubkey)` under the album's admin-tier signing key, +//! plus a local-only flag for whether that epoch's AMK content key has arrived. +//! +//! Guardrails that keep the deferral honest: +//! - The admin signature on every entry is **mandatory** and verified by +//! [`admin_chain_verifies`](AlbumAuthority::admin_chain_verifies); a forged or unsigned +//! entry makes the whole authority untrusted. +//! - The epoch ceiling is **data, not honor system**: it is the max attested epoch and +//! never regresses across `attest_epoch` calls. +//! - Minting an epoch sets the write-tier key and (optionally) the AMK presence **together** +//! — the design's "AMK epoch bump + write-tier rotation are one commit" atomicity. + +use std::collections::BTreeMap; + +use serde::Serialize; +use uuid::Uuid; + +use super::AlbumAuthority; +use crate::cbor; +use crate::crypto::keys::{AmkVersion, HybridSignature, HybridSigningKey, HybridVerifyingKey}; + +/// The canonical bytes an admin signs to attest one epoch. +#[derive(Serialize)] +struct EpochAttestation { + album_id: Uuid, + epoch: u32, + #[serde(with = "serde_bytes")] + write_tier_pub: Vec, +} + +struct Entry { + write_tier_pub: HybridVerifyingKey, + admin_sig: HybridSignature, + amk_present: bool, +} + +/// A reference album authority backed by an admin-signed epoch ledger. +pub struct ReferenceAuthority { + album_id: Uuid, + admin_pub: HybridVerifyingKey, + ceiling: AmkVersion, + entries: BTreeMap, +} + +fn attestation_bytes( + album_id: Uuid, + epoch: AmkVersion, + write_tier_pub: &HybridVerifyingKey, +) -> Vec { + cbor::to_canonical_vec(&EpochAttestation { + album_id, + epoch: epoch.0, + write_tier_pub: write_tier_pub.to_bytes(), + }) + .expect("attestation serializes") +} + +impl ReferenceAuthority { + /// An empty authority for `album_id` whose attestations are signed by `admin`. + pub fn new(album_id: Uuid, admin_pub: HybridVerifyingKey) -> Self { + Self { + album_id, + admin_pub, + ceiling: AmkVersion(0), + entries: BTreeMap::new(), + } + } + + /// Attest an epoch: bind `(album_id, epoch, write_tier_pub)` with an admin signature and + /// record whether this epoch's AMK is locally held. Advances the ceiling monotonically. + /// The `admin` key's public half must match this authority's `admin_pub`. + pub fn attest_epoch( + &mut self, + admin: &HybridSigningKey, + epoch: AmkVersion, + write_tier_pub: &HybridVerifyingKey, + amk_present: bool, + ) { + debug_assert_eq!( + admin.verifying_key(), + self.admin_pub, + "attesting admin key must match the authority's admin public key" + ); + let admin_sig = admin.sign(&attestation_bytes(self.album_id, epoch, write_tier_pub)); + self.entries.insert( + epoch.0, + Entry { + write_tier_pub: write_tier_pub.clone(), + admin_sig, + amk_present, + }, + ); + if epoch > self.ceiling { + self.ceiling = epoch; + } + } + + /// Builder-style variant of [`attest_epoch`](Self::attest_epoch). + pub fn with_epoch( + mut self, + admin: &HybridSigningKey, + epoch: AmkVersion, + write_tier_pub: &HybridVerifyingKey, + amk_present: bool, + ) -> Self { + self.attest_epoch(admin, epoch, write_tier_pub, amk_present); + self + } + + /// Mark an epoch's AMK content key as now locally held (e.g. an in-flight + /// `AlbumKeyDistribution` arrived), flipping a *pending* asset to verifiable. + pub fn mark_amk_present(&mut self, epoch: AmkVersion) { + if let Some(e) = self.entries.get_mut(&epoch.0) { + e.amk_present = true; + } + } +} + +impl AlbumAuthority for ReferenceAuthority { + fn album_id(&self) -> Uuid { + self.album_id + } + + fn epoch_ceiling(&self) -> AmkVersion { + self.ceiling + } + + fn write_tier_pubkey(&self, epoch: AmkVersion) -> Option { + self.entries.get(&epoch.0).map(|e| e.write_tier_pub.clone()) + } + + fn has_amk(&self, epoch: AmkVersion) -> bool { + self.entries.get(&epoch.0).is_some_and(|e| e.amk_present) + } + + fn admin_chain_verifies(&self) -> bool { + // Ceiling must equal the highest attested epoch (no fabricated/rewound ceiling)... + let max = self.entries.keys().copied().max().unwrap_or(0); + if self.ceiling.0 != max { + return false; + } + // ...and every entry's admin signature must verify against admin_pub. + self.entries.iter().all(|(epoch, e)| { + let bytes = attestation_bytes(self.album_id, AmkVersion(*epoch), &e.write_tier_pub); + self.admin_pub.verify(&bytes, &e.admin_sig) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup() -> (Uuid, HybridSigningKey, HybridSigningKey, HybridSigningKey) { + // album, admin key, write-tier epoch 1 key, write-tier epoch 2 key + ( + Uuid::from_u128(0xA1), + HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]), + HybridSigningKey::from_seed_bytes(&[3; 32], &[4; 32]), + HybridSigningKey::from_seed_bytes(&[5; 32], &[6; 32]), + ) + } + + #[test] + fn lookups_reflect_attested_epochs() { + let (album, admin, w1, w2) = setup(); + let auth = ReferenceAuthority::new(album, admin.verifying_key()) + .with_epoch(&admin, AmkVersion(1), &w1.verifying_key(), true) + .with_epoch(&admin, AmkVersion(2), &w2.verifying_key(), false); + + assert!(auth.admin_chain_verifies()); + assert_eq!(auth.epoch_ceiling(), AmkVersion(2)); + assert_eq!( + auth.write_tier_pubkey(AmkVersion(1)), + Some(w1.verifying_key()) + ); + assert_eq!( + auth.write_tier_pubkey(AmkVersion(2)), + Some(w2.verifying_key()) + ); + assert_eq!(auth.write_tier_pubkey(AmkVersion(3)), None); + // Epoch 1 AMK held; epoch 2 not yet (pending territory). + assert!(auth.has_amk(AmkVersion(1))); + assert!(!auth.has_amk(AmkVersion(2))); + } + + #[test] + fn mark_amk_present_flips_pending() { + let (album, admin, w1, _) = setup(); + let mut auth = ReferenceAuthority::new(album, admin.verifying_key()).with_epoch( + &admin, + AmkVersion(1), + &w1.verifying_key(), + false, + ); + assert!(!auth.has_amk(AmkVersion(1))); + auth.mark_amk_present(AmkVersion(1)); + assert!(auth.has_amk(AmkVersion(1))); + } + + #[test] + fn forged_attestation_fails_admin_chain() { + let (album, admin, w1, _) = setup(); + let mut auth = ReferenceAuthority::new(album, admin.verifying_key()).with_epoch( + &admin, + AmkVersion(1), + &w1.verifying_key(), + true, + ); + // Tamper an entry's signature: the admin chain must no longer verify. + auth.entries.get_mut(&1).unwrap().admin_sig = + HybridSigningKey::from_seed_bytes(&[9; 32], &[9; 32]).sign(b"not the attestation"); + assert!(!auth.admin_chain_verifies()); + } + + #[test] + fn attestation_signed_by_wrong_admin_fails() { + let (album, admin, w1, _) = setup(); + let imposter = HybridSigningKey::from_seed_bytes(&[7; 32], &[8; 32]); + // Build with the imposter signing, but claim the real admin's public key. + let mut auth = ReferenceAuthority::new(album, admin.verifying_key()); + // Bypass the debug_assert by inserting a wrongly-signed entry directly. + let wt = w1.verifying_key(); + let sig = imposter.sign(&attestation_bytes(album, AmkVersion(1), &wt)); + auth.entries.insert( + 1, + Entry { + write_tier_pub: wt, + admin_sig: sig, + amk_present: true, + }, + ); + auth.ceiling = AmkVersion(1); + assert!( + !auth.admin_chain_verifies(), + "an attestation not signed by the declared admin must be rejected" + ); + } + + #[test] + fn ceiling_inconsistent_with_entries_fails() { + let (album, admin, w1, _) = setup(); + let mut auth = ReferenceAuthority::new(album, admin.verifying_key()).with_epoch( + &admin, + AmkVersion(1), + &w1.verifying_key(), + true, + ); + // Fabricate a higher ceiling than any attested epoch. + auth.ceiling = AmkVersion(5); + assert!(!auth.admin_chain_verifies()); + } +} diff --git a/capsule-core/src/crypto/encryption/blob.rs b/capsule-core/src/crypto/encryption/blob.rs new file mode 100644 index 0000000..c2bbde3 --- /dev/null +++ b/capsule-core/src/crypto/encryption/blob.rs @@ -0,0 +1,155 @@ +//! Standalone AEAD for metadata blobs — a single contiguous byte string with a fixed +//! wire format. Used for the encrypted CBOR sidecar / metadata blob the server stores. +//! +//! **Implementations MUST produce and consume exactly this layout** so two correct +//! implementations compute identical content hashes byte-for-byte: +//! +//! ```text +//! +---------------------+------------------+----------------------+----------------+ +//! | crypto_suite_id (2) | nonce (12 bytes) | ciphertext (variable)| tag (16 bytes) | +//! | big-endian u16 | fresh CSPRNG | AES-256-GCM(plaintext)| GCM tag | +//! +---------------------+------------------+----------------------+----------------+ +//! ``` +//! +//! The key is derived per blob via [`crate::crypto::keys::Amk::derive_blob_key`]. The +//! `ciphertext_hash` committed to by the manifest is computed over the **full** byte +//! string. SSoT: [Cryptography — Encryption § Metadata Blob Wire Format]. +//! +//! [Cryptography — Encryption § Metadata Blob Wire Format]: https://docs/design/cryptography/encryption/#metadata-blob-wire-format + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Key, Nonce}; + +use crate::crypto::hash::{self, Hash32}; +use crate::crypto::primitives::{CRYPTO_SUITE_ID, SuiteId}; +use crate::crypto::{CryptoError, rng}; + +const SUITE_LEN: usize = 2; +const NONCE_LEN: usize = 12; +const TAG_LEN: usize = 16; +/// Minimum valid blob length: header + nonce + empty ciphertext + tag. +pub const MIN_BLOB_LEN: usize = SUITE_LEN + NONCE_LEN + TAG_LEN; + +/// Seal `plaintext` (canonical CBOR) into the metadata-blob wire format under `blob_key`, +/// tagging it with the current `crypto_suite_id`. A fresh nonce is drawn per call. +pub fn seal_blob(blob_key: &[u8; 32], plaintext: &[u8]) -> Vec { + let nonce = rng::random_array::(); + let cipher = Aes256Gcm::new(Key::::from_slice(blob_key)); + let ct_and_tag = cipher + .encrypt(Nonce::from_slice(&nonce), plaintext) + .expect("AES-256-GCM seal is infallible for a valid key/nonce"); + + let mut out = Vec::with_capacity(SUITE_LEN + NONCE_LEN + ct_and_tag.len()); + out.extend_from_slice(&CRYPTO_SUITE_ID.to_be_bytes()); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ct_and_tag); + out +} + +/// Open a metadata-blob wire string under `blob_key`. Rejects an unknown `crypto_suite_id` +/// (fail-closed) before attempting decryption, and rejects tampered ciphertext. +pub fn open_blob(blob_key: &[u8; 32], wire: &[u8]) -> Result, CryptoError> { + if wire.len() < MIN_BLOB_LEN { + return Err(CryptoError::Malformed("metadata blob shorter than minimum")); + } + let suite = u16::from_be_bytes([wire[0], wire[1]]); + if SuiteId::from_u16(suite).is_none() { + return Err(CryptoError::UnknownSuite(suite)); + } + let nonce = &wire[SUITE_LEN..SUITE_LEN + NONCE_LEN]; + let ct_and_tag = &wire[SUITE_LEN + NONCE_LEN..]; + let cipher = Aes256Gcm::new(Key::::from_slice(blob_key)); + cipher + .decrypt(Nonce::from_slice(nonce), ct_and_tag) + .map_err(|_| CryptoError::Auth("metadata blob authentication failed")) +} + +/// The content hash of a metadata blob: SHA-256 over the **full** wire byte string +/// (header ‖ nonce ‖ ciphertext ‖ tag). This is the value the manifest commits to. +pub fn blob_ciphertext_hash(wire: &[u8]) -> Hash32 { + hash::hash_bytes(wire) +} + +/// The `crypto_suite_id` declared in a blob's header, without decrypting. +pub fn blob_suite_id(wire: &[u8]) -> Option { + (wire.len() >= SUITE_LEN).then(|| u16::from_be_bytes([wire[0], wire[1]])) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: [u8; 32] = [0x42; 32]; + + #[test] + fn seal_open_round_trip() { + let plaintext = b"deterministic CBOR sidecar bytes"; + let wire = seal_blob(&KEY, plaintext); + assert_eq!(open_blob(&KEY, &wire).unwrap(), plaintext); + } + + #[test] + fn wire_format_layout_is_exact() { + let wire = seal_blob(&KEY, b"abc"); + // suite(2) big-endian = 0x0001. + assert_eq!(&wire[0..2], &[0x00, 0x01]); + // total = 2 + 12 + 3 (ct) + 16 (tag). + assert_eq!(wire.len(), 2 + 12 + 3 + 16); + assert_eq!(blob_suite_id(&wire), Some(0x0001)); + } + + #[test] + fn empty_plaintext_is_well_formed() { + let wire = seal_blob(&KEY, b""); + assert_eq!(wire.len(), MIN_BLOB_LEN); + assert_eq!(open_blob(&KEY, &wire).unwrap(), b""); + } + + #[test] + fn ciphertext_hash_covers_entire_wire() { + let wire = seal_blob(&KEY, b"payload"); + assert_eq!(blob_ciphertext_hash(&wire), hash::hash_bytes(&wire)); + // Flipping any byte (even in the header) changes the content hash. + let mut t = wire.clone(); + t[0] ^= 0x01; + assert_ne!(blob_ciphertext_hash(&t), blob_ciphertext_hash(&wire)); + } + + #[test] + fn fresh_nonce_per_seal() { + let a = seal_blob(&KEY, b"same"); + let b = seal_blob(&KEY, b"same"); + assert_ne!(a, b, "each seal must draw a fresh nonce"); + // Nonce field differs. + assert_ne!(&a[2..14], &b[2..14]); + } + + #[test] + fn tamper_and_wrong_key_rejected() { + let wire = seal_blob(&KEY, b"secret"); + let mut t = wire.clone(); + let last = t.len() - 1; + t[last] ^= 0x01; + assert!(open_blob(&KEY, &t).is_err()); + assert!(open_blob(&[0u8; 32], &wire).is_err()); + } + + #[test] + fn unknown_suite_id_fails_closed_before_decrypt() { + let mut wire = seal_blob(&KEY, b"x"); + wire[0] = 0xff; + wire[1] = 0xff; + assert_eq!( + open_blob(&KEY, &wire), + Err(CryptoError::UnknownSuite(0xffff)) + ); + } + + #[test] + fn too_short_is_malformed() { + assert!(matches!( + open_blob(&KEY, &[0u8; 5]), + Err(CryptoError::Malformed(_)) + )); + } +} diff --git a/capsule-core/src/crypto/encryption/mod.rs b/capsule-core/src/crypto/encryption/mod.rs new file mode 100644 index 0000000..6842147 --- /dev/null +++ b/capsule-core/src/crypto/encryption/mod.rs @@ -0,0 +1,17 @@ +//! Asset and metadata encryption — the only place AES-256-GCM is invoked for user data. +//! +//! Two constructions (SSoT: [Cryptography — Encryption]): +//! - [`stream`] — AES-256-GCM STREAM for asset bytes (originals + derivatives), supporting +//! streaming, ranged reads, and per-chunk authentication. +//! - [`blob`] — standalone AES-256-GCM with a fixed wire format for small metadata blobs. +//! +//! [Cryptography — Encryption]: https://docs/design/cryptography/encryption/ + +pub mod blob; +pub mod stream; + +pub use blob::{blob_ciphertext_hash, open_blob, seal_blob}; +pub use stream::{ + AssetEncryption, StreamError, decrypt_asset, decrypt_chunk, encrypt_asset, + encrypt_asset_with_prefix, +}; diff --git a/capsule-core/src/crypto/encryption/stream.rs b/capsule-core/src/crypto/encryption/stream.rs new file mode 100644 index 0000000..94baf73 --- /dev/null +++ b/capsule-core/src/crypto/encryption/stream.rs @@ -0,0 +1,342 @@ +//! Authenticated asset encryption with the AES-256-GCM **STREAM** construction +//! (Hoang-Reyhanitabar-Rogaway-Vizár 2015), via RustCrypto `aead::stream`. +//! +//! Plaintext is split into 65,520-byte chunks, each sealed with AES-256-GCM under a +//! structured nonce `prefix(7) ‖ counter_be32(4) ‖ last_flag(1)`, producing 64 KiB +//! ciphertext chunks (16-byte tag each). STREAM detects truncation, reordering, and chunk +//! deletion. Because each chunk's nonce is derived deterministically from its index, any +//! chunk decrypts **independently** ([`decrypt_chunk`]) for ranged reads. +//! +//! A fresh per-file key (see [`crate::crypto::keys::Amk::derive_file_key`]) lets the +//! counter safely start at zero. SSoT: [Cryptography — Encryption § STREAM Construction]. +//! +//! [Cryptography — Encryption § STREAM Construction]: https://docs/design/cryptography/encryption/#stream-construction + +use std::io::{self, Read, Write}; + +use aes_gcm::aead::generic_array::GenericArray; +use aes_gcm::aead::stream::{DecryptorBE32, EncryptorBE32}; +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Key, Nonce}; +use thiserror::Error; + +use crate::crypto::hash::{Hash32, Sha256Hasher}; +use crate::crypto::rng; + +/// Plaintext bytes per STREAM chunk. +pub const PLAINTEXT_CHUNK: usize = 65_520; +/// Ciphertext bytes per full STREAM chunk (plaintext chunk + 16-byte GCM tag = 64 KiB). +pub const CIPHERTEXT_CHUNK: usize = PLAINTEXT_CHUNK + TAG_LEN; +/// STREAM nonce prefix length (12-byte nonce − 4-byte counter − 1-byte last flag). +pub const NONCE_PREFIX_LEN: usize = 7; +/// GCM authentication tag length. +pub const TAG_LEN: usize = 16; + +/// Result of encrypting an asset: everything the [signed manifest] commits to. +/// +/// [signed manifest]: crate::crypto::provenance +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AssetEncryption { + /// SHA-256 over the full produced ciphertext (the content address). + pub ciphertext_hash: Hash32, + /// Total plaintext byte length. + pub plaintext_size: u64, + /// Plaintext bytes per chunk (always [`PLAINTEXT_CHUNK`]). + pub chunk_size: u32, + /// The random STREAM nonce prefix for this file. + pub nonce_prefix: [u8; NONCE_PREFIX_LEN], +} + +/// Errors from streaming encrypt/decrypt. +#[derive(Debug, Error)] +pub enum StreamError { + /// Underlying reader/writer I/O failure. + #[error("stream io error: {0}")] + Io(#[from] io::Error), + /// A chunk failed AEAD authentication (tamper, wrong key, truncation, reorder). + #[error("stream authentication failed at chunk {0}")] + Auth(u32), +} + +fn read_chunk(reader: &mut R, n: usize) -> io::Result> { + let mut buf = vec![0u8; n]; + let mut filled = 0; + while filled < n { + let k = reader.read(&mut buf[filled..])?; + if k == 0 { + break; + } + filled += k; + } + buf.truncate(filled); + Ok(buf) +} + +/// Encrypt `reader` to `writer` under `file_key`, returning the content metadata. +/// Computes the ciphertext hash incrementally — the whole file is never buffered. +/// Draws a fresh random nonce prefix. +pub fn encrypt_asset( + file_key: &[u8; 32], + reader: R, + writer: W, +) -> Result { + encrypt_asset_with_prefix( + file_key, + rng::random_array::(), + reader, + writer, + ) +} + +/// As [`encrypt_asset`] but with an explicit nonce prefix, so a client can **regenerate the +/// exact ciphertext** from the plaintext + file key + the prefix recorded in the manifest +/// (the client stores plaintext locally; ciphertext is derived for upload/backup). +pub fn encrypt_asset_with_prefix( + file_key: &[u8; 32], + nonce_prefix: [u8; NONCE_PREFIX_LEN], + mut reader: R, + mut writer: W, +) -> Result { + let cipher = Aes256Gcm::new(Key::::from_slice(file_key)); + let mut enc = + EncryptorBE32::::from_aead(cipher, GenericArray::from_slice(&nonce_prefix)); + + let mut hasher = Sha256Hasher::new(); + let mut plaintext_size: u64 = 0; + let mut index: u32 = 0; + + // Read one chunk ahead so we know which chunk is the last (it gets the last-block flag). + let mut cur = read_chunk(&mut reader, PLAINTEXT_CHUNK)?; + loop { + let next = read_chunk(&mut reader, PLAINTEXT_CHUNK)?; + plaintext_size += cur.len() as u64; + if next.is_empty() { + let ct = enc + .encrypt_last(cur.as_slice()) + .map_err(|_| StreamError::Auth(index))?; + hasher.update(&ct); + writer.write_all(&ct)?; + break; + } + let ct = enc + .encrypt_next(cur.as_slice()) + .map_err(|_| StreamError::Auth(index))?; + hasher.update(&ct); + writer.write_all(&ct)?; + cur = next; + index += 1; + } + + Ok(AssetEncryption { + ciphertext_hash: hasher.finalize(), + plaintext_size, + chunk_size: PLAINTEXT_CHUNK as u32, + nonce_prefix, + }) +} + +/// Decrypt a STREAM ciphertext from `reader` to `writer`. Every chunk's tag is verified; +/// any tamper/truncation/reorder yields [`StreamError::Auth`]. +pub fn decrypt_asset( + file_key: &[u8; 32], + nonce_prefix: &[u8; NONCE_PREFIX_LEN], + mut reader: R, + mut writer: W, +) -> Result<(), StreamError> { + let cipher = Aes256Gcm::new(Key::::from_slice(file_key)); + let mut dec = + DecryptorBE32::::from_aead(cipher, GenericArray::from_slice(nonce_prefix)); + + let mut index: u32 = 0; + let mut cur = read_chunk(&mut reader, CIPHERTEXT_CHUNK)?; + loop { + let next = read_chunk(&mut reader, CIPHERTEXT_CHUNK)?; + if next.is_empty() { + let pt = dec + .decrypt_last(cur.as_slice()) + .map_err(|_| StreamError::Auth(index))?; + writer.write_all(&pt)?; + break; + } + let pt = dec + .decrypt_next(cur.as_slice()) + .map_err(|_| StreamError::Auth(index))?; + writer.write_all(&pt)?; + cur = next; + index += 1; + } + Ok(()) +} + +/// Decrypt a single ciphertext chunk in isolation (ranged read). `index` is the 0-based +/// chunk number; `is_last` must be true only for the final chunk of the file (it carries +/// the STREAM last-block flag). Returns [`crate::crypto::CryptoError::Auth`] on tamper. +pub fn decrypt_chunk( + file_key: &[u8; 32], + nonce_prefix: &[u8; NONCE_PREFIX_LEN], + index: u32, + is_last: bool, + ct_chunk: &[u8], +) -> Result, crate::crypto::CryptoError> { + let mut nonce = [0u8; 12]; + nonce[..NONCE_PREFIX_LEN].copy_from_slice(nonce_prefix); + nonce[NONCE_PREFIX_LEN..11].copy_from_slice(&index.to_be_bytes()); + nonce[11] = u8::from(is_last); + let cipher = Aes256Gcm::new(Key::::from_slice(file_key)); + cipher + .decrypt(Nonce::from_slice(&nonce), ct_chunk) + .map_err(|_| crate::crypto::CryptoError::Auth("STREAM chunk authentication failed")) +} + +/// Convenience: encrypt a whole slice in memory, returning (metadata, ciphertext). +pub fn encrypt_asset_vec(file_key: &[u8; 32], plaintext: &[u8]) -> AssetEncryption { + let mut out = Vec::new(); + // Slice reader + Vec writer are infallible, so the only error path is unreachable. + encrypt_asset(file_key, plaintext, &mut out).expect("in-memory encryption is infallible") +} + +/// Convenience: encrypt a whole slice in memory and also return the ciphertext bytes. +pub fn encrypt_asset_vec_full(file_key: &[u8; 32], plaintext: &[u8]) -> (AssetEncryption, Vec) { + let mut out = Vec::new(); + let meta = encrypt_asset(file_key, plaintext, &mut out).expect("in-memory encryption"); + (meta, out) +} + +/// Convenience: encrypt a whole slice in memory with an explicit prefix (deterministic). +pub fn encrypt_asset_vec_with_prefix( + file_key: &[u8; 32], + nonce_prefix: [u8; NONCE_PREFIX_LEN], + plaintext: &[u8], +) -> (AssetEncryption, Vec) { + let mut out = Vec::new(); + let meta = encrypt_asset_with_prefix(file_key, nonce_prefix, plaintext, &mut out) + .expect("in-memory encryption"); + (meta, out) +} + +/// Convenience: decrypt a whole ciphertext slice in memory. +pub fn decrypt_asset_vec( + file_key: &[u8; 32], + nonce_prefix: &[u8; NONCE_PREFIX_LEN], + ciphertext: &[u8], +) -> Result, StreamError> { + let mut out = Vec::new(); + decrypt_asset(file_key, nonce_prefix, ciphertext, &mut out)?; + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: [u8; 32] = [0x11; 32]; + + fn round_trip(plaintext: &[u8]) { + let (meta, ct) = encrypt_asset_vec_full(&KEY, plaintext); + assert_eq!(meta.plaintext_size, plaintext.len() as u64); + assert_eq!(meta.chunk_size, PLAINTEXT_CHUNK as u32); + let back = decrypt_asset_vec(&KEY, &meta.nonce_prefix, &ct).unwrap(); + assert_eq!(back, plaintext); + // Content hash matches a fresh hash of the ciphertext. + assert_eq!(meta.ciphertext_hash, crate::crypto::hash::hash_bytes(&ct)); + } + + #[test] + fn round_trip_various_sizes() { + round_trip(b""); // empty + round_trip(b"hello"); // < 1 chunk + round_trip(&[0xABu8; PLAINTEXT_CHUNK]); // exactly one chunk + round_trip(&[0xCDu8; PLAINTEXT_CHUNK + 1]); // one chunk + 1 + round_trip(&[0x77u8; PLAINTEXT_CHUNK * 3 + 123]); // multi-chunk, partial last + } + + #[test] + fn full_chunk_ciphertext_is_64_kib() { + let (_, ct) = encrypt_asset_vec_full(&KEY, &[0u8; PLAINTEXT_CHUNK * 2]); + // Two full plaintext chunks → two 64 KiB ciphertext chunks. + assert_eq!(ct.len(), CIPHERTEXT_CHUNK * 2); + } + + #[test] + fn ranged_read_matches_sequential() { + let plaintext: Vec = (0..(PLAINTEXT_CHUNK * 3 + 500)) + .map(|i| (i % 251) as u8) + .collect(); + let (meta, ct) = encrypt_asset_vec_full(&KEY, &plaintext); + let n_chunks = ct.len().div_ceil(CIPHERTEXT_CHUNK); + + for i in 0..n_chunks { + let start = i * CIPHERTEXT_CHUNK; + let end = (start + CIPHERTEXT_CHUNK).min(ct.len()); + let is_last = i == n_chunks - 1; + let pt = decrypt_chunk(&KEY, &meta.nonce_prefix, i as u32, is_last, &ct[start..end]) + .unwrap(); + + let p_start = i * PLAINTEXT_CHUNK; + let p_end = (p_start + PLAINTEXT_CHUNK).min(plaintext.len()); + assert_eq!(pt, &plaintext[p_start..p_end], "chunk {i} mismatch"); + } + } + + #[test] + fn tamper_in_each_chunk_is_detected() { + let plaintext = [0x5Au8; PLAINTEXT_CHUNK * 2 + 10]; + let (meta, ct) = encrypt_asset_vec_full(&KEY, &plaintext); + + // Bit-flip in the first chunk. + let mut t = ct.clone(); + t[10] ^= 0x01; + assert!(decrypt_asset_vec(&KEY, &meta.nonce_prefix, &t).is_err()); + + // Bit-flip in the last chunk. + let mut t = ct.clone(); + let last = t.len() - 1; + t[last] ^= 0x01; + assert!(decrypt_asset_vec(&KEY, &meta.nonce_prefix, &t).is_err()); + } + + #[test] + fn chunk_reorder_and_drop_are_detected() { + let plaintext = [0x33u8; PLAINTEXT_CHUNK * 2]; + let (meta, ct) = encrypt_asset_vec_full(&KEY, &plaintext); + + // Swap the two chunks (reorder) — STREAM counter mismatch. + let mut swapped = Vec::new(); + swapped.extend_from_slice(&ct[CIPHERTEXT_CHUNK..]); + swapped.extend_from_slice(&ct[..CIPHERTEXT_CHUNK]); + assert!(decrypt_asset_vec(&KEY, &meta.nonce_prefix, &swapped).is_err()); + + // Drop the final chunk — last-block flag never seen. + assert!(decrypt_asset_vec(&KEY, &meta.nonce_prefix, &ct[..CIPHERTEXT_CHUNK]).is_err()); + } + + #[test] + fn wrong_key_or_prefix_is_rejected() { + let (meta, ct) = encrypt_asset_vec_full(&KEY, b"secret bytes"); + assert!(decrypt_asset_vec(&[0x22; 32], &meta.nonce_prefix, &ct).is_err()); + let mut bad_prefix = meta.nonce_prefix; + bad_prefix[0] ^= 0xff; + assert!(decrypt_asset_vec(&KEY, &bad_prefix, &ct).is_err()); + } + + #[test] + fn distinct_files_get_distinct_prefixes() { + let a = encrypt_asset_vec(&KEY, b"x"); + let b = encrypt_asset_vec(&KEY, b"x"); + assert_ne!(a.nonce_prefix, b.nonce_prefix); + } + + #[test] + fn fixed_prefix_regenerates_identical_ciphertext() { + // The client stores plaintext; re-encrypting with the manifest's recorded prefix + // reproduces the exact ciphertext (and hash) for backup/upload. + let prefix = [9u8; NONCE_PREFIX_LEN]; + let plaintext = b"regenerable bytes"; + let (m1, c1) = encrypt_asset_vec_with_prefix(&KEY, prefix, plaintext); + let (m2, c2) = encrypt_asset_vec_with_prefix(&KEY, prefix, plaintext); + assert_eq!(c1, c2); + assert_eq!(m1.ciphertext_hash, m2.ciphertext_hash); + assert_eq!(m1.nonce_prefix, prefix); + assert_eq!(decrypt_asset_vec(&KEY, &prefix, &c1).unwrap(), plaintext); + } +} diff --git a/capsule-core/src/crypto/hash.rs b/capsule-core/src/crypto/hash.rs new file mode 100644 index 0000000..707ecaa --- /dev/null +++ b/capsule-core/src/crypto/hash.rs @@ -0,0 +1,234 @@ +//! SHA-256 content hashing — the one hash algorithm Capsule uses everywhere +//! (content addressing, integrity verification, provenance chaining). +//! +//! SSoT: [Cryptography — Primitives § Cryptographic Hash]. The same SHA-256 value is +//! reused across layers rather than recomputed — the content-addressing hash is the +//! value the signed manifest commits to and the upload protocol declares and verifies. +//! +//! Hashing is **streaming** (incremental over chunks): the ciphertext hash of a large +//! asset is computed as the STREAM chunks are produced, never by buffering the whole +//! file. [`Sha256Hasher`] is the incremental interface; [`hash_bytes`] / [`hash_reader`] +//! are the one-shot conveniences built on top of it. +//! +//! [Cryptography — Primitives § Cryptographic Hash]: https://docs/design/cryptography/primitives/#cryptographic-hash + +use std::io::{self, Read}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use sha2::{Digest, Sha256}; + +/// Length of a SHA-256 digest in bytes. Pinned by the `crypto_suite_id` inventory. +pub const SHA256_LEN: usize = 32; + +/// A 32-byte SHA-256 digest. +/// +/// Serializes as a CBOR **byte string** (major type 2), matching the `hash: bytes` +/// fields in the manifest and sidecar schemas — never as an array of integers — so +/// canonical encodings are byte-identical across implementations. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Hash32(pub [u8; SHA256_LEN]); + +impl Hash32 { + /// Wrap raw digest bytes. + #[inline] + pub const fn from_bytes(bytes: [u8; SHA256_LEN]) -> Self { + Self(bytes) + } + + /// Borrow the raw digest bytes. + #[inline] + pub const fn as_bytes(&self) -> &[u8; SHA256_LEN] { + &self.0 + } + + /// Lowercase hex encoding (64 chars). + pub fn to_hex(&self) -> String { + hex::encode(self.0) + } + + /// Parse a 64-char lowercase/uppercase hex string into a digest. + pub fn from_hex(s: &str) -> Result { + let v = hex::decode(s).map_err(|_| FromHexError)?; + let arr: [u8; SHA256_LEN] = v.as_slice().try_into().map_err(|_| FromHexError)?; + Ok(Self(arr)) + } +} + +/// The provided string was not a valid 32-byte hex digest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FromHexError; + +impl std::fmt::Display for FromHexError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("invalid 32-byte hex digest") + } +} +impl std::error::Error for FromHexError {} + +impl std::fmt::Debug for Hash32 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Hash32({})", self.to_hex()) + } +} + +impl std::fmt::Display for Hash32 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.to_hex()) + } +} + +impl Serialize for Hash32 { + fn serialize(&self, s: S) -> Result { + // CBOR byte string, not a sequence of integers. + s.serialize_bytes(&self.0) + } +} + +impl<'de> Deserialize<'de> for Hash32 { + fn deserialize>(d: D) -> Result { + struct V; + impl<'de> de::Visitor<'de> for V { + type Value = Hash32; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("a 32-byte SHA-256 digest") + } + fn visit_bytes(self, v: &[u8]) -> Result { + let arr: [u8; SHA256_LEN] = v + .try_into() + .map_err(|_| E::invalid_length(v.len(), &"32 bytes"))?; + Ok(Hash32(arr)) + } + fn visit_seq>(self, mut seq: A) -> Result { + // Tolerate decoders that surface a byte string as a sequence. + let mut arr = [0u8; SHA256_LEN]; + for (i, slot) in arr.iter_mut().enumerate() { + *slot = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(i, &"32 bytes"))?; + } + Ok(Hash32(arr)) + } + } + d.deserialize_bytes(V) + } +} + +/// Incremental SHA-256 hasher: feed bytes with [`update`](Self::update), then +/// [`finalize`](Self::finalize). Used to hash STREAM ciphertext as chunks are produced. +#[derive(Clone, Default)] +pub struct Sha256Hasher { + inner: Sha256, +} + +impl Sha256Hasher { + /// A fresh hasher over the empty input. + pub fn new() -> Self { + Self::default() + } + + /// Absorb a chunk of input. + #[inline] + pub fn update(&mut self, bytes: &[u8]) { + self.inner.update(bytes); + } + + /// Consume the hasher and produce the digest. + pub fn finalize(self) -> Hash32 { + Hash32(self.inner.finalize().into()) + } +} + +/// One-shot SHA-256 of a byte slice. +pub fn hash_bytes(bytes: &[u8]) -> Hash32 { + let mut h = Sha256Hasher::new(); + h.update(bytes); + h.finalize() +} + +/// Stream a reader to completion, hashing in 64 KiB blocks without buffering the whole input. +pub fn hash_reader(mut reader: R) -> io::Result { + let mut hasher = Sha256Hasher::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = reader.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // NIST FIPS 180-2 / RFC 6234 known-answer vectors. + const ABC: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + const EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + // SHA-256 of one million 'a' characters. + const MILLION_A: &str = "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"; + + #[test] + fn kat_abc() { + assert_eq!(hash_bytes(b"abc").to_hex(), ABC); + } + + #[test] + fn kat_empty() { + assert_eq!(hash_bytes(b"").to_hex(), EMPTY); + } + + #[test] + fn kat_million_a() { + let data = vec![b'a'; 1_000_000]; + assert_eq!(hash_bytes(&data).to_hex(), MILLION_A); + } + + #[test] + fn chunked_equals_one_shot() { + let data = vec![0xABu8; 65_520 * 3 + 17]; + let one_shot = hash_bytes(&data); + + // Feed in irregular pieces to prove order/chunking does not affect the digest. + let mut h = Sha256Hasher::new(); + for chunk in data.chunks(65_520) { + h.update(chunk); + } + assert_eq!(h.finalize(), one_shot); + + // And via the reader path. + assert_eq!(hash_reader(&data[..]).unwrap(), one_shot); + } + + #[test] + fn hex_round_trip() { + let h = hash_bytes(b"capsule"); + let parsed = Hash32::from_hex(&h.to_hex()).unwrap(); + assert_eq!(h, parsed); + assert_eq!(parsed.as_bytes(), h.as_bytes()); + } + + #[test] + fn from_hex_rejects_bad_length() { + assert_eq!(Hash32::from_hex("dead"), Err(FromHexError)); + assert_eq!( + Hash32::from_hex("zz".repeat(32).as_str()), + Err(FromHexError) + ); + } + + #[test] + fn serde_emits_cbor_byte_string() { + // Major type 2 (byte string) of length 32 has initial byte 0x58 0x20. + let h = hash_bytes(b"abc"); + let mut buf = Vec::new(); + ciborium::ser::into_writer(&h, &mut buf).unwrap(); + assert_eq!(buf[0], 0x58, "expected CBOR byte-string major type"); + assert_eq!(buf[1], 0x20, "expected length 32"); + assert_eq!(&buf[2..], h.as_bytes()); + + let decoded: Hash32 = ciborium::de::from_reader(buf.as_slice()).unwrap(); + assert_eq!(decoded, h); + } +} diff --git a/capsule-core/src/crypto/kdf.rs b/capsule-core/src/crypto/kdf.rs new file mode 100644 index 0000000..5775d06 --- /dev/null +++ b/capsule-core/src/crypto/kdf.rs @@ -0,0 +1,85 @@ +//! HKDF-SHA512 key derivation (SSoT: [Cryptography — Primitives § Key Derivation]). +//! +//! The wider SHA-512 keeps the post-quantum posture (a 256-bit hash falls to ~128-bit +//! under Grover; SHA-512 retains ~256-bit), and KDFs are off the hot path. Every +//! derivation includes a versioned `info` label (see [`super::primitives::info`]) and a +//! scope-unique salt (`album_id`, `file_id`, `blob_id`) — salts are never reused across +//! scopes. The 512-bit output is truncated to 32 bytes for AES-256 keys. +//! +//! [Cryptography — Primitives § Key Derivation]: https://docs/design/cryptography/primitives/#key-derivation + +use hkdf::Hkdf; +use sha2::Sha512; + +/// HKDF-SHA512 extract-then-expand, producing `out_len` bytes. +/// +/// `out_len` must be ≤ 255 × 64 (the HKDF-SHA512 ceiling), which every Capsule +/// derivation satisfies; an out-of-range request is a programming error and panics. +pub fn hkdf_sha512(ikm: &[u8], salt: &[u8], info: &[u8], out_len: usize) -> Vec { + let hk = Hkdf::::new(Some(salt), ikm); + let mut okm = vec![0u8; out_len]; + hk.expand(info, &mut okm) + .expect("HKDF-SHA512 output length within bounds"); + okm +} + +/// Derive a 32-byte (AES-256) key. The canonical derivation for file and metadata-blob keys. +pub fn derive_key32(ikm: &[u8], salt: &[u8], info: &[u8]) -> [u8; 32] { + let okm = hkdf_sha512(ikm, salt, info, 32); + let mut key = [0u8; 32]; + key.copy_from_slice(&okm); + key +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::primitives::info; + + #[test] + fn derivation_is_deterministic() { + let ikm = [7u8; 32]; + let a = derive_key32(&ikm, b"file-123", info::ASSET_FILE_V1); + let b = derive_key32(&ikm, b"file-123", info::ASSET_FILE_V1); + assert_eq!(a, b, "same (ikm, salt, info) must produce identical output"); + } + + #[test] + fn scope_uniqueness_by_salt_and_info() { + let ikm = [7u8; 32]; + let base = derive_key32(&ikm, b"file-123", info::ASSET_FILE_V1); + // Different salt (file_id) → different key. + assert_ne!(base, derive_key32(&ikm, b"file-124", info::ASSET_FILE_V1)); + // Different info label (domain separation) → different key. + assert_ne!( + base, + derive_key32(&ikm, b"file-123", info::METADATA_BLOB_V1) + ); + // Different ikm (AMK) → different key. + assert_ne!( + base, + derive_key32(&[8u8; 32], b"file-123", info::ASSET_FILE_V1) + ); + } + + #[test] + fn truncation_is_a_prefix_of_the_full_expand() { + // Deriving 32 bytes must equal the first 32 bytes of a 64-byte expansion, so the + // 512→256 truncation is well-defined and stable across platforms. + let ikm = [3u8; 32]; + let k32 = derive_key32(&ikm, b"album-1", info::ASSET_FILE_V1); + let k64 = hkdf_sha512(&ikm, b"album-1", info::ASSET_FILE_V1, 64); + assert_eq!(&k32[..], &k64[..32]); + } + + #[test] + fn golden_vector_pins_the_wiring() { + // Pins the exact HKDF-SHA512 construction (extract+expand, label, salt) so an + // accidental algorithm/label change is caught. Cross-checked once on first run. + let key = derive_key32(&[0u8; 32], b"salt", info::ASSET_FILE_V1); + assert_eq!( + hex::encode(key), + "cb54d8d1556baf00a1fb03103adaa63d7cd5fb108a4e418d76b5a5d724f75587" + ); + } +} diff --git a/capsule-core/src/crypto/keys/album.rs b/capsule-core/src/crypto/keys/album.rs new file mode 100644 index 0000000..8dda283 --- /dev/null +++ b/capsule-core/src/crypto/keys/album.rs @@ -0,0 +1,106 @@ +//! Album Master Keys (AMKs) and per-file/blob key derivation. +//! +//! Each album is an MLS group; its content key is a **random 32-byte AMK minted per +//! epoch** (`AMK_v{n}`) — never derived from MLS ratchet state. Per-file and per-blob +//! keys are derived from the AMK via HKDF-SHA512 with a scope-unique salt (the file/blob +//! UUID) and a versioned label (SSoT: [Cryptography — Keys § Album Master Keys] and +//! [Encryption § Asset Key Derivation]). +//! +//! [Cryptography — Keys § Album Master Keys]: https://docs/design/cryptography/keys/#album-master-keys-amks +//! [Encryption § Asset Key Derivation]: https://docs/design/cryptography/encryption/#asset-key-derivation + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::crypto::primitives::info; +use crate::crypto::{kdf, rng}; + +/// The monotonic epoch identifier for an AMK (`amk_version`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct AmkVersion(pub u32); + +impl AmkVersion { + /// The first epoch minted at album creation. + pub const FIRST: AmkVersion = AmkVersion(1); + + /// The next epoch after this one. + pub fn next(self) -> AmkVersion { + AmkVersion(self.0 + 1) + } +} + +/// A random 32-byte album content key for one epoch. Holding it lets you decrypt; not +/// holding it means you cannot (secrecy is enforced by encryption, authorization by +/// signatures — see [`super::hybrid_sig`] write-tier keys). +#[derive(Clone)] +pub struct Amk([u8; 32]); + +impl Amk { + /// Mint a fresh random AMK for a new epoch. + pub fn generate() -> Self { + Self(rng::random_array::<32>()) + } + + /// Wrap raw AMK bytes (e.g. from the escrowed ledger). + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Borrow the raw bytes (for escrow into the backup AMK ledger). + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + /// Derive the per-file AES-256 key: `HKDF(ikm=AMK, salt=file_id, info="asset-file/v1")`. + /// A fresh derived key per file lets the STREAM nonce counter safely start at zero. + pub fn derive_file_key(&self, file_id: &Uuid) -> [u8; 32] { + kdf::derive_key32(&self.0, file_id.as_bytes(), info::ASSET_FILE_V1) + } + + /// Derive the per-metadata-blob AES-256 key: + /// `HKDF(ikm=AMK, salt=blob_id, info="metadata-blob/v1")`. + pub fn derive_blob_key(&self, blob_id: &Uuid) -> [u8; 32] { + kdf::derive_key32(&self.0, blob_id.as_bytes(), info::METADATA_BLOB_V1) + } +} + +impl std::fmt::Debug for Amk { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Amk(****)") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn versions_advance_monotonically() { + assert_eq!(AmkVersion::FIRST, AmkVersion(1)); + assert_eq!(AmkVersion(3).next(), AmkVersion(4)); + assert!(AmkVersion(2) < AmkVersion(3)); + } + + #[test] + fn file_key_is_deterministic_per_file() { + let amk = Amk::from_bytes([7u8; 32]); + let f = Uuid::from_u128(0x1234); + assert_eq!(amk.derive_file_key(&f), amk.derive_file_key(&f)); + } + + #[test] + fn distinct_files_blobs_and_amks_yield_distinct_keys() { + let amk = Amk::from_bytes([7u8; 32]); + let f1 = Uuid::from_u128(1); + let f2 = Uuid::from_u128(2); + // Different file_id → different key. + assert_ne!(amk.derive_file_key(&f1), amk.derive_file_key(&f2)); + // File vs blob domain separation for the *same* id. + assert_ne!(amk.derive_file_key(&f1), amk.derive_blob_key(&f1)); + // Different AMK epoch → different key. + assert_ne!( + amk.derive_file_key(&f1), + Amk::from_bytes([8u8; 32]).derive_file_key(&f1) + ); + } +} diff --git a/capsule-core/src/crypto/keys/directory.rs b/capsule-core/src/crypto/keys/directory.rs new file mode 100644 index 0000000..2d27b47 --- /dev/null +++ b/capsule-core/src/crypto/keys/directory.rs @@ -0,0 +1,138 @@ +//! The signed device directory: how peers learn which device public keys to trust for a +//! user, and the anti-rollback `directory_version` (SSoT: [Cryptography — Keys § Device +//! Directory]). +//! +//! Each user publishes a directory listing their devices' hybrid signing public keys, +//! master-signed (here, by the user IK). `verify_asset` reads it to resolve the +//! `created_by_device` of a manifest and to enforce that a device's `added_at` precedes the +//! manifest timestamp. The monotonic `directory_version` lets readers refuse a rolled-back +//! directory (a server hiding a revocation). +//! +//! [Cryptography — Keys § Device Directory]: https://docs/design/cryptography/keys/#device-directory + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::hybrid_sig::{HybridSignature, HybridSigningKey, HybridVerifyingKey}; + +/// One device's published entry. A revoked device's entry is **retained** (marked with +/// `revoked_at`), never deleted, so manifests it signed before revocation stay verifiable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeviceEntry { + /// Stable device id. + pub device_id: Uuid, + /// The device signing key's hybrid public half. + pub dsk_public: HybridVerifyingKey, + /// RFC3339 time the device was added (must precede any manifest it signs). + pub added_at: String, + /// RFC3339 revocation time, if revoked. + pub revoked_at: Option, +} + +/// The unsigned core of a directory — exactly the bytes the master signature covers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectoryCore { + /// Account owner. + pub user_id: Uuid, + /// Monotonic; +1 on every change. Readers refuse a version below their high-water mark. + pub directory_version: u64, + /// RFC3339 last-update time. + pub updated_at: String, + /// The user's devices. + pub devices: Vec, +} + +/// A master/IK-signed device directory. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeviceDirectory { + /// The signed core. + pub core: DirectoryCore, + /// Hybrid signature by the user IK over the canonical core bytes. + pub signature: HybridSignature, +} + +impl DirectoryCore { + fn signing_bytes(&self) -> Vec { + crate::cbor::to_canonical_vec(self).expect("directory core serializes") + } + + /// Sign this core with the user IK, producing a [`DeviceDirectory`]. + pub fn sign(self, ik: &HybridSigningKey) -> DeviceDirectory { + let signature = ik.sign(&self.signing_bytes()); + DeviceDirectory { + core: self, + signature, + } + } +} + +impl DeviceDirectory { + /// Verify the directory's signature against the user IK public key. + pub fn verify(&self, ik_public: &HybridVerifyingKey) -> bool { + ik_public.verify(&self.core.signing_bytes(), &self.signature) + } + + /// Look up a device entry by id. + pub fn device(&self, device_id: &Uuid) -> Option<&DeviceEntry> { + self.core.devices.iter().find(|d| &d.device_id == device_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dir(version: u64, ik: &HybridSigningKey, device: &HybridSigningKey) -> DeviceDirectory { + DirectoryCore { + user_id: Uuid::from_u128(1), + directory_version: version, + updated_at: "2026-05-31T00:00:00Z".into(), + devices: vec![DeviceEntry { + device_id: Uuid::from_u128(0xD1), + dsk_public: device.verifying_key(), + added_at: "2026-05-30T00:00:00Z".into(), + revoked_at: None, + }], + } + .sign(ik) + } + + #[test] + fn sign_verify_and_lookup() { + let ik = HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]); + let dev = HybridSigningKey::from_seed_bytes(&[3; 32], &[4; 32]); + let d = dir(1, &ik, &dev); + + assert!(d.verify(&ik.verifying_key())); + // Wrong IK does not verify. + assert!(!d.verify(&HybridSigningKey::from_seed_bytes(&[9; 32], &[9; 32]).verifying_key())); + // Lookup. + assert_eq!( + d.device(&Uuid::from_u128(0xD1)).unwrap().dsk_public, + dev.verifying_key() + ); + assert!(d.device(&Uuid::from_u128(0xDEAD)).is_none()); + } + + #[test] + fn tampering_with_a_device_key_breaks_the_signature() { + let ik = HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]); + let dev = HybridSigningKey::from_seed_bytes(&[3; 32], &[4; 32]); + let mut d = dir(1, &ik, &dev); + // Swap in a different device key without re-signing. + d.core.devices[0].dsk_public = + HybridSigningKey::from_seed_bytes(&[7; 32], &[8; 32]).verifying_key(); + assert!(!d.verify(&ik.verifying_key())); + } + + #[test] + fn serializes_canonically() { + let ik = HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]); + let dev = HybridSigningKey::from_seed_bytes(&[3; 32], &[4; 32]); + let d = dir(7, &ik, &dev); + let bytes = crate::cbor::to_canonical_vec(&d).unwrap(); + let back: DeviceDirectory = crate::cbor::from_slice(&bytes).unwrap(); + assert_eq!(back, d); + assert!(back.verify(&ik.verifying_key())); + } +} diff --git a/capsule-core/src/crypto/keys/hybrid_sig.rs b/capsule-core/src/crypto/keys/hybrid_sig.rs new file mode 100644 index 0000000..7576020 --- /dev/null +++ b/capsule-core/src/crypto/keys/hybrid_sig.rs @@ -0,0 +1,344 @@ +//! Hybrid Ed25519 + ML-DSA-65 signatures — the long-lived identity signature used for +//! the user IK, device keys (DSK), asset manifests, write-tier keys, sidecars, the device +//! directory, and backup manifests (SSoT: [Cryptography — Primitives § Signature Scheme]). +//! +//! **Both halves must verify** for a signature to be accepted. Neither algorithm being +//! broken alone compromises authentication, and because both halves cover the same bytes +//! (including `crypto_suite_id`), the construction is downgrade-resistant even if one +//! algorithm is later broken. +//! +//! Keys are deterministic from 32-byte seeds (Ed25519 secret scalar / ML-DSA `ξ`), so a +//! signing key serializes as 64 seed bytes and can be wrapped and restored verbatim. +//! +//! [Cryptography — Primitives § Signature Scheme]: https://docs/design/cryptography/primitives/#signature-scheme + +use ed25519_dalek::{ + Signature as EdSignature, Signer as _, SigningKey as EdSigningKey, Verifier as _, + VerifyingKey as EdVerifyingKey, +}; +use ml_dsa::{ + B32, EncodedSignature, EncodedVerifyingKey, Keypair as _, MlDsa65, Signature as MlSignature, + Signer as _, SigningKey as MlSigningKey, Verifier as _, VerifyingKey as MlVerifyingKey, +}; +use serde::{Deserialize, Serialize}; + +use crate::crypto::CryptoError; +use crate::crypto::rng; + +/// Ed25519 secret/seed length and ML-DSA `ξ` seed length (both 32 bytes). +const SEED_LEN: usize = 32; +/// Ed25519 public key length. +const ED_PK_LEN: usize = 32; +/// Ed25519 signature length. +const ED_SIG_LEN: usize = 64; + +/// A hybrid signing keypair (private). Holds both algorithm halves. +#[derive(Clone)] +pub struct HybridSigningKey { + ed: EdSigningKey, + ml: MlSigningKey, +} + +/// A hybrid public verifying key. Published in the device directory. +#[derive(Clone)] +pub struct HybridVerifyingKey { + ed: EdVerifyingKey, + ml: MlVerifyingKey, +} + +/// A hybrid signature: an Ed25519 half and an ML-DSA-65 half over the same message. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HybridSignature { + ed: [u8; ED_SIG_LEN], + ml: Vec, +} + +fn to_ml_seed(bytes: &[u8; SEED_LEN]) -> B32 { + B32::try_from(&bytes[..]).expect("32-byte ML-DSA seed") +} + +impl HybridSigningKey { + /// Generate a fresh hybrid keypair from the OS CSPRNG. + pub fn generate() -> Self { + let seeds = rng::random_array::<{ 2 * SEED_LEN }>(); + let mut ed_seed = [0u8; SEED_LEN]; + let mut ml = [0u8; SEED_LEN]; + ed_seed.copy_from_slice(&seeds[..SEED_LEN]); + ml.copy_from_slice(&seeds[SEED_LEN..]); + Self::from_seed_bytes(&ed_seed, &ml) + } + + /// Reconstruct a keypair deterministically from its two 32-byte seeds. + pub fn from_seed_bytes(ed_seed: &[u8; SEED_LEN], ml_seed: &[u8; SEED_LEN]) -> Self { + Self { + ed: EdSigningKey::from_bytes(ed_seed), + ml: MlSigningKey::::from_seed(&to_ml_seed(ml_seed)), + } + } + + /// Export the two 32-byte seeds (Ed25519 secret ‖ ML-DSA ξ) for sealed storage. + pub fn to_seed_bytes(&self) -> [u8; 2 * SEED_LEN] { + let mut out = [0u8; 2 * SEED_LEN]; + out[..SEED_LEN].copy_from_slice(&self.ed.to_bytes()); + out[SEED_LEN..].copy_from_slice(self.ml.to_seed().as_slice()); + out + } + + /// Reconstruct from a 64-byte concatenation of the two seeds. + pub fn from_seed64(bytes: &[u8; 2 * SEED_LEN]) -> Self { + let mut ed_seed = [0u8; SEED_LEN]; + let mut ml = [0u8; SEED_LEN]; + ed_seed.copy_from_slice(&bytes[..SEED_LEN]); + ml.copy_from_slice(&bytes[SEED_LEN..]); + Self::from_seed_bytes(&ed_seed, &ml) + } + + /// The public verifying key. + pub fn verifying_key(&self) -> HybridVerifyingKey { + HybridVerifyingKey { + ed: self.ed.verifying_key(), + ml: self.ml.verifying_key(), + } + } + + /// Sign `msg`, producing both halves. ML-DSA uses the deterministic variant. + pub fn sign(&self, msg: &[u8]) -> HybridSignature { + let ed = self.ed.sign(msg).to_bytes(); + let ml = self.ml.sign(msg).encode().to_vec(); + HybridSignature { ed, ml } + } +} + +impl HybridVerifyingKey { + /// Verify `sig` over `msg`. Returns `true` only if **both** halves verify. + pub fn verify(&self, msg: &[u8], sig: &HybridSignature) -> bool { + let ed_ok = self + .ed + .verify(msg, &EdSignature::from_bytes(&sig.ed)) + .is_ok(); + // Short-circuit only matters for cost; correctness requires both. + let ml_ok = match EncodedSignature::::try_from(sig.ml.as_slice()) { + Ok(enc) => match MlSignature::::decode(&enc) { + Some(s) => self.ml.verify(msg, &s).is_ok(), + None => false, + }, + Err(_) => false, + }; + ed_ok && ml_ok + } + + /// Raw bytes: Ed25519 public key (32) followed by ML-DSA-65 public key (1952). + pub fn to_bytes(&self) -> Vec { + let mut out = Vec::with_capacity(ED_PK_LEN + 1952); + out.extend_from_slice(&self.ed.to_bytes()); + out.extend_from_slice(self.ml.encode().as_slice()); + out + } + + /// Reconstruct from the `ed (32) ‖ ml` byte layout produced by [`to_bytes`](Self::to_bytes). + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() <= ED_PK_LEN { + return Err(CryptoError::Malformed("hybrid verifying key too short")); + } + let (ed_b, ml_b) = bytes.split_at(ED_PK_LEN); + let ed_arr: [u8; ED_PK_LEN] = ed_b + .try_into() + .map_err(|_| CryptoError::Malformed("bad Ed25519 public key length"))?; + let ed = EdVerifyingKey::from_bytes(&ed_arr) + .map_err(|_| CryptoError::Key("invalid Ed25519 public key"))?; + let enc = EncodedVerifyingKey::::try_from(ml_b) + .map_err(|_| CryptoError::Malformed("bad ML-DSA public key length"))?; + let ml = MlVerifyingKey::::decode(&enc); + Ok(Self { ed, ml }) + } +} + +impl PartialEq for HybridVerifyingKey { + fn eq(&self, other: &Self) -> bool { + self.to_bytes() == other.to_bytes() + } +} +impl Eq for HybridVerifyingKey {} + +impl std::fmt::Debug for HybridVerifyingKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "HybridVerifyingKey(ed={})", + hex::encode(self.ed.to_bytes()) + ) + } +} + +// ── serde: signatures and verifying keys serialize as CBOR byte strings ───────── + +#[derive(Serialize, Deserialize)] +struct SigWire { + #[serde(with = "serde_bytes")] + ed: Vec, + #[serde(with = "serde_bytes")] + ml: Vec, +} + +impl Serialize for HybridSignature { + fn serialize(&self, s: S) -> Result { + SigWire { + ed: self.ed.to_vec(), + ml: self.ml.clone(), + } + .serialize(s) + } +} + +impl<'de> Deserialize<'de> for HybridSignature { + fn deserialize>(d: D) -> Result { + use serde::de::Error; + let w = SigWire::deserialize(d)?; + let ed: [u8; ED_SIG_LEN] = + w.ed.as_slice() + .try_into() + .map_err(|_| D::Error::custom("Ed25519 signature must be 64 bytes"))?; + Ok(HybridSignature { ed, ml: w.ml }) + } +} + +impl Serialize for HybridVerifyingKey { + fn serialize(&self, s: S) -> Result { + SigWire { + ed: self.ed.to_bytes().to_vec(), + ml: self.ml.encode().to_vec(), + } + .serialize(s) + } +} + +impl<'de> Deserialize<'de> for HybridVerifyingKey { + fn deserialize>(d: D) -> Result { + use serde::de::Error; + let w = SigWire::deserialize(d)?; + let mut bytes = w.ed; + bytes.extend_from_slice(&w.ml); + HybridVerifyingKey::from_bytes(&bytes).map_err(D::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixed_key() -> HybridSigningKey { + HybridSigningKey::from_seed_bytes(&[1u8; 32], &[2u8; 32]) + } + + #[test] + fn sign_verify_round_trip() { + let sk = HybridSigningKey::generate(); + let vk = sk.verifying_key(); + let msg = b"asset manifest bytes"; + let sig = sk.sign(msg); + assert!(vk.verify(msg, &sig)); + } + + #[test] + fn rejects_wrong_message() { + let sk = fixed_key(); + let sig = sk.sign(b"original"); + assert!(!sk.verifying_key().verify(b"tampered", &sig)); + } + + #[test] + fn rejects_wrong_key() { + let sig = fixed_key().sign(b"msg"); + let other = HybridSigningKey::from_seed_bytes(&[9u8; 32], &[9u8; 32]); + assert!(!other.verifying_key().verify(b"msg", &sig)); + } + + // ── Both halves are required (the load-bearing property) ───────────────────── + + #[test] + fn corrupting_only_the_ed25519_half_is_rejected() { + let sk = fixed_key(); + let mut sig = sk.sign(b"msg"); + sig.ed[0] ^= 0x01; // ML-DSA half still valid + assert!( + !sk.verifying_key().verify(b"msg", &sig), + "a valid ML-DSA half must not rescue a broken Ed25519 half" + ); + } + + #[test] + fn corrupting_only_the_mldsa_half_is_rejected() { + let sk = fixed_key(); + let mut sig = sk.sign(b"msg"); + let last = sig.ml.len() - 1; + sig.ml[last] ^= 0x01; // Ed25519 half still valid + assert!( + !sk.verifying_key().verify(b"msg", &sig), + "a valid Ed25519 half must not rescue a broken ML-DSA half" + ); + } + + #[test] + fn swapping_halves_between_two_signatures_is_rejected() { + let sk = fixed_key(); + let vk = sk.verifying_key(); + let sig_a = sk.sign(b"message A"); + let sig_b = sk.sign(b"message B"); + // Graft A's Ed25519 half onto B's ML-DSA half: neither message verifies. + let frankenstein = HybridSignature { + ed: sig_a.ed, + ml: sig_b.ml, + }; + assert!(!vk.verify(b"message A", &frankenstein)); + assert!(!vk.verify(b"message B", &frankenstein)); + } + + #[test] + fn truncated_mldsa_half_is_rejected_not_panicking() { + let sk = fixed_key(); + let mut sig = sk.sign(b"msg"); + sig.ml.truncate(10); + assert!(!sk.verifying_key().verify(b"msg", &sig)); + } + + // ── Determinism + serialization stability ──────────────────────────────────── + + #[test] + fn seeds_reconstruct_an_identical_key() { + let sk = fixed_key(); + let seeds = sk.to_seed_bytes(); + let sk2 = HybridSigningKey::from_seed64(&seeds); + assert_eq!(sk.verifying_key(), sk2.verifying_key()); + // And a signature from the reconstructed key verifies under the original's vk. + let sig = sk2.sign(b"x"); + assert!(sk.verifying_key().verify(b"x", &sig)); + } + + #[test] + fn verifying_key_byte_round_trip() { + let vk = fixed_key().verifying_key(); + let bytes = vk.to_bytes(); + assert_eq!(bytes.len(), 32 + 1952); + assert_eq!(HybridVerifyingKey::from_bytes(&bytes).unwrap(), vk); + } + + #[test] + fn signature_serde_uses_byte_strings_and_round_trips() { + let sk = fixed_key(); + let sig = sk.sign(b"msg"); + let bytes = crate::cbor::to_canonical_vec(&sig).unwrap(); + // Map with byte-string values: map(2) head 0xa2; first key "ed" (text) -> 0x62 6564. + assert_eq!(bytes[0], 0xa2); + let back: HybridSignature = crate::cbor::from_slice(&bytes).unwrap(); + assert_eq!(back, sig); + assert!(sk.verifying_key().verify(b"msg", &back)); + } + + #[test] + fn verifying_key_serde_round_trips() { + let vk = fixed_key().verifying_key(); + let bytes = crate::cbor::to_canonical_vec(&vk).unwrap(); + let back: HybridVerifyingKey = crate::cbor::from_slice(&bytes).unwrap(); + assert_eq!(back, vk); + } +} diff --git a/capsule-core/src/crypto/keys/kem.rs b/capsule-core/src/crypto/keys/kem.rs new file mode 100644 index 0000000..148c63d --- /dev/null +++ b/capsule-core/src/crypto/keys/kem.rs @@ -0,0 +1,142 @@ +//! The Device Encryption Key (DEK) — an ML-KEM-768 KEM keypair. +//! +//! The DEK is the device's key-encapsulation key: a sender encapsulates a shared secret +//! to the device's public key, and the device decapsulates it with its private key. In +//! Capsule it carries key wraps to a device and underlies MLS HPKE +//! (SSoT: [Cryptography — Keys § Device Keys], [Primitives § KEM]). +//! +//! Keys are deterministic from a 64-byte seed, so the DEK persists and restores verbatim. +//! +//! **Deferred:** the design's DEK is the *hybrid* X-Wing (X25519 + ML-KEM-768); the +//! X25519 half and the X-Wing combiner land with the OpenMLS integration (see +//! `DEFERRED.md`). This module implements the post-quantum ML-KEM-768 half, which is the +//! part exercised offline. The seam (`encapsulate`/`decapsulate` over byte strings) is +//! combiner-agnostic, so swapping in X-Wing later does not change callers. +//! +//! [Cryptography — Keys § Device Keys]: https://docs/design/cryptography/keys/#device-keys +//! [Primitives § KEM]: https://docs/design/cryptography/primitives/#kem + +use ml_kem::kem::Decapsulate; +use ml_kem::{B32, DecapsulationKey, KeyExport, MlKem768, Seed}; + +use crate::crypto::{CryptoError, rng}; + +/// Length of the ML-KEM-768 keypair seed (d ‖ z). +pub const DEK_SEED_LEN: usize = 64; + +/// A device encryption keypair (ML-KEM-768). Holds the private decapsulation key; the +/// public encapsulation key is reachable from it. +pub struct DekKeypair { + dk: DecapsulationKey, +} + +fn shared_to_32(bytes: &[u8]) -> [u8; 32] { + let mut out = [0u8; 32]; + out.copy_from_slice(bytes); + out +} + +impl DekKeypair { + /// Generate a fresh DEK from the OS CSPRNG. + pub fn generate() -> Self { + Self::from_seed(&rng::random_array::()) + } + + /// Reconstruct deterministically from a 64-byte seed. + pub fn from_seed(seed: &[u8; DEK_SEED_LEN]) -> Self { + let s = Seed::try_from(&seed[..]).expect("64-byte ML-KEM seed"); + Self { + dk: DecapsulationKey::::from_seed(s), + } + } + + /// Export the 64-byte seed for sealed storage in the keystore. + pub fn to_seed_bytes(&self) -> [u8; DEK_SEED_LEN] { + let seed = self + .dk + .to_seed() + .expect("a seed-derived DEK always retains its seed"); + let mut out = [0u8; DEK_SEED_LEN]; + out.copy_from_slice(seed.as_slice()); + out + } + + /// The public encapsulation-key bytes (for publishing in the device directory). + pub fn public_bytes(&self) -> Vec { + self.dk.encapsulation_key().to_bytes().to_vec() + } + + /// Encapsulate a fresh shared secret to this keypair's own public key, returning the + /// ciphertext and the 32-byte shared secret (the sender's side of a round trip). + pub fn encapsulate_to_self(&self) -> (Vec, [u8; 32]) { + // `encapsulate_deterministic` takes the encapsulation randomness explicitly; we + // draw it fresh from the OS CSPRNG, so this is a randomized encapsulation. + let m = B32::try_from(&rng::random_array::<32>()[..]).expect("32-byte m"); + let (ct, ss) = self.dk.encapsulation_key().encapsulate_deterministic(&m); + (ct.as_slice().to_vec(), shared_to_32(ss.as_slice())) + } + + /// Decapsulate a ciphertext, recovering the 32-byte shared secret (the receiver side). + pub fn decapsulate(&self, ciphertext: &[u8]) -> Result<[u8; 32], CryptoError> { + let ss = self + .dk + .decapsulate_slice(ciphertext) + .map_err(|_| CryptoError::Malformed("ML-KEM ciphertext wrong length"))?; + Ok(shared_to_32(ss.as_slice())) + } +} + +impl Clone for DekKeypair { + fn clone(&self) -> Self { + Self::from_seed(&self.to_seed_bytes()) + } +} + +impl std::fmt::Debug for DekKeypair { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("DekKeypair(****)") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encapsulate_decapsulate_round_trip() { + let dek = DekKeypair::generate(); + let (ct, k_send) = dek.encapsulate_to_self(); + let k_recv = dek.decapsulate(&ct).unwrap(); + assert_eq!( + k_send, k_recv, + "encapsulated and decapsulated secrets must match" + ); + } + + #[test] + fn seed_reconstructs_identical_keypair() { + let dek = DekKeypair::generate(); + let seed = dek.to_seed_bytes(); + let restored = DekKeypair::from_seed(&seed); + // A ciphertext sealed to the original decapsulates under the restored key. + let (ct, k) = dek.encapsulate_to_self(); + assert_eq!(restored.decapsulate(&ct).unwrap(), k); + assert_eq!(restored.public_bytes(), dek.public_bytes()); + } + + #[test] + fn wrong_key_recovers_a_different_secret() { + // ML-KEM uses implicit rejection: a foreign key decapsulates to a *different* + // pseudo-random secret rather than erroring — still cryptographically safe. + let alice = DekKeypair::generate(); + let bob = DekKeypair::generate(); + let (ct, k_for_alice) = alice.encapsulate_to_self(); + assert_ne!(bob.decapsulate(&ct).unwrap(), k_for_alice); + } + + #[test] + fn malformed_ciphertext_is_rejected() { + let dek = DekKeypair::generate(); + assert!(dek.decapsulate(b"too short").is_err()); + } +} diff --git a/capsule-core/src/crypto/keys/keystore.rs b/capsule-core/src/crypto/keys/keystore.rs new file mode 100644 index 0000000..88dce40 --- /dev/null +++ b/capsule-core/src/crypto/keys/keystore.rs @@ -0,0 +1,187 @@ +//! A software keystore: an [`Account`] (master key + user identity key + this device's +//! keys) and its encrypted-at-rest form [`AccountFile`]. +//! +//! This stands in for the hardware-bound keystores (Secure Enclave / StrongBox / TPM) the +//! design specifies per platform — those are deferred (see `DEFERRED.md`). Here the master +//! key is wrapped under a passphrase via [`pwkdf`], and the device identity private keys +//! are sealed under the master key (the design's "master key wraps device identity private +//! keys"). SSoT: [Cryptography — Keys]. +//! +//! [Cryptography — Keys]: https://docs/design/cryptography/keys/ + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::hybrid_sig::HybridSigningKey; +use super::kem::DekKeypair; +use super::master::MasterKey; +use crate::crypto::primitives::{Argon2Params, DeviceTier}; +use crate::crypto::{CryptoError, pwkdf}; + +/// This device's key material: a stable id, a hybrid Device Signing Key (DSK), and a +/// Device Encryption Key (DEK). +pub struct DeviceKeys { + /// Stable per-device identifier (UUIDv7), published in the device directory. + pub device_id: Uuid, + /// Hybrid device signing key — signs asset manifests (`device_sig`). + pub dsk: HybridSigningKey, + /// Device encryption key (KEM) — receives key wraps. + pub dek: DekKeypair, +} + +/// A fully unlocked account in memory. +pub struct Account { + /// The account owner's user id (UUIDv7). + pub user_id: Uuid, + /// The backed-up root key. + pub master: MasterKey, + /// The user identity key (root of signing trust). Signs the device directory. + pub user_ik: HybridSigningKey, + /// This device's keys. + pub device: DeviceKeys, +} + +/// The encrypted-at-rest account: the master key wrapped under a passphrase, and the +/// device/identity private keys sealed under the master key. Safe to persist to disk. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountFile { + /// Account owner id. + pub user_id: Uuid, + /// This device's id. + pub device_id: Uuid, + /// Master key wrapped under the passphrase (Argon2id + AES-256-GCM). + pub wrapped_master: pwkdf::WrappedSecret, + /// User IK seeds (64 bytes) sealed under the master key. + #[serde(with = "serde_bytes")] + pub sealed_ik: Vec, + /// Device DSK seeds (64 bytes) sealed under the master key. + #[serde(with = "serde_bytes")] + pub sealed_dsk: Vec, + /// Device DEK seed (64 bytes) sealed under the master key. + #[serde(with = "serde_bytes")] + pub sealed_dek: Vec, +} + +impl Account { + /// Create a brand-new account with a fresh master key, user IK, and first device. + pub fn create() -> Self { + Self { + user_id: Uuid::now_v7(), + master: MasterKey::generate(), + user_ik: HybridSigningKey::generate(), + device: DeviceKeys { + device_id: Uuid::now_v7(), + dsk: HybridSigningKey::generate(), + dek: DekKeypair::generate(), + }, + } + } + + /// Encrypt the account for persistence: master under `passphrase` (cost = `tier`), + /// identity/device private keys under the master key. + pub fn to_file(&self, passphrase: &[u8], tier: DeviceTier) -> Result { + self.to_file_with(passphrase, tier.params()) + } + + /// As [`to_file`](Self::to_file) but with explicit Argon2id parameters (used by tests + /// to avoid the multi-hundred-MiB production cost). + pub fn to_file_with( + &self, + passphrase: &[u8], + params: Argon2Params, + ) -> Result { + Ok(AccountFile { + user_id: self.user_id, + device_id: self.device.device_id, + wrapped_master: pwkdf::wrap_with(self.master.as_bytes(), passphrase, params)?, + sealed_ik: self.master.seal(&self.user_ik.to_seed_bytes()), + sealed_dsk: self.master.seal(&self.device.dsk.to_seed_bytes()), + sealed_dek: self.master.seal(&self.device.dek.to_seed_bytes()), + }) + } +} + +fn seed64(bytes: Vec) -> Result<[u8; 64], CryptoError> { + bytes + .as_slice() + .try_into() + .map_err(|_| CryptoError::Malformed("sealed key seed wrong length")) +} + +impl AccountFile { + /// Decrypt the account with `passphrase`. Returns [`CryptoError::Auth`] on a wrong + /// passphrase (master unwrap fails) or tampering. + pub fn unlock(&self, passphrase: &[u8]) -> Result { + let master_bytes: [u8; 32] = pwkdf::unwrap(&self.wrapped_master, passphrase)? + .as_slice() + .try_into() + .map_err(|_| CryptoError::Malformed("master key wrong length"))?; + let master = MasterKey::from_bytes(master_bytes); + + let user_ik = HybridSigningKey::from_seed64(&seed64(master.open(&self.sealed_ik)?)?); + let dsk = HybridSigningKey::from_seed64(&seed64(master.open(&self.sealed_dsk)?)?); + let dek = DekKeypair::from_seed(&seed64(master.open(&self.sealed_dek)?)?); + + Ok(Account { + user_id: self.user_id, + master, + user_ik, + device: DeviceKeys { + device_id: self.device_id, + dsk, + dek, + }, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Fast Argon2 params keep keystore tests quick; the production tier table is asserted + // in `primitives` without paying the 128–512 MiB hashing cost. + fn fast() -> Argon2Params { + Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, + } + } + + #[test] + fn account_create_save_unlock_round_trip() { + let acct = Account::create(); + let ik_vk = acct.user_ik.verifying_key(); + let dsk_vk = acct.device.dsk.verifying_key(); + let default_album = acct.master.derive_default_album_id(); + + let file = acct.to_file_with(b"passphrase", fast()).unwrap(); + let restored = file.unlock(b"passphrase").unwrap(); + + assert_eq!(restored.user_id, acct.user_id); + assert_eq!(restored.device.device_id, acct.device.device_id); + // Identity and device verifying keys survive the round trip. + assert_eq!(restored.user_ik.verifying_key(), ik_vk); + assert_eq!(restored.device.dsk.verifying_key(), dsk_vk); + // The master key still derives the same default album id. + assert_eq!(restored.master.derive_default_album_id(), default_album); + } + + #[test] + fn wrong_passphrase_fails_to_unlock() { + let acct = Account::create(); + let file = acct.to_file_with(b"right", fast()).unwrap(); + assert!(file.unlock(b"wrong").is_err()); + } + + #[test] + fn account_file_serializes_canonically() { + let acct = Account::create(); + let file = acct.to_file_with(b"pw", fast()).unwrap(); + let bytes = crate::cbor::to_canonical_vec(&file).unwrap(); + let back: AccountFile = crate::cbor::from_slice(&bytes).unwrap(); + assert_eq!(back.user_id, file.user_id); + assert_eq!(back.sealed_dek, file.sealed_dek); + } +} diff --git a/capsule-core/src/crypto/keys/master.rs b/capsule-core/src/crypto/keys/master.rs new file mode 100644 index 0000000..50c1d74 --- /dev/null +++ b/capsule-core/src/crypto/keys/master.rs @@ -0,0 +1,133 @@ +//! The account master key: the single backed-up root of the hierarchy. +//! +//! It does **not** encrypt assets directly. Its jobs are (1) to wrap the per-device +//! identity private keys and (2) to anchor the encrypted backup that escrows album keys. +//! It also derives one *identifier* — the [default album]'s `album_id` — via HKDF with a +//! dedicated label, so any device can recompute the default album from the master key +//! alone after recovery (SSoT: [Cryptography — Keys § Key Chain]). +//! +//! [default album]: https://docs/design/organization/#the-default-album +//! [Cryptography — Keys § Key Chain]: https://docs/design/cryptography/keys/#key-chain + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Key, Nonce}; +use uuid::Uuid; + +use crate::crypto::primitives::info; +use crate::crypto::{CryptoError, kdf, rng}; + +const NONCE_LEN: usize = 12; + +/// A 32-byte account master key. +#[derive(Clone)] +pub struct MasterKey([u8; 32]); + +impl MasterKey { + /// Generate a fresh master key from the OS CSPRNG (client-side at account creation). + pub fn generate() -> Self { + Self(rng::random_array::<32>()) + } + + /// Wrap raw key bytes (e.g. after unwrapping the escrow blob). + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Borrow the raw key bytes (for escrow wrapping). + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + /// Derive the default album's `album_id` deterministically. This derives an *ID*, + /// never a key — the default album has its own random per-epoch AMK like any album. + pub fn derive_default_album_id(&self) -> Uuid { + let okm = kdf::hkdf_sha512(&self.0, b"", info::DEFAULT_ALBUM_ID_V1, 16); + let mut b = [0u8; 16]; + b.copy_from_slice(&okm); + // A v8 (custom) UUID: deterministic, unguessable before creation, recomputable. + uuid::Builder::from_custom_bytes(b).into_uuid() + } + + /// Symmetrically seal `plaintext` under the master key (AES-256-GCM, random nonce). + /// Used to wrap device identity private keys. Output is `nonce(12) ‖ ciphertext+tag`. + pub fn seal(&self, plaintext: &[u8]) -> Vec { + let nonce = rng::random_array::(); + let cipher = Aes256Gcm::new(Key::::from_slice(&self.0)); + let ct = cipher + .encrypt(Nonce::from_slice(&nonce), plaintext) + .expect("AES-256-GCM seal is infallible for a valid key/nonce"); + let mut out = Vec::with_capacity(NONCE_LEN + ct.len()); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ct); + out + } + + /// Open a blob produced by [`seal`](Self::seal). Returns [`CryptoError::Auth`] on a + /// wrong key or tampered ciphertext. + pub fn open(&self, blob: &[u8]) -> Result, CryptoError> { + if blob.len() < NONCE_LEN { + return Err(CryptoError::Malformed("sealed blob shorter than nonce")); + } + let (nonce, ct) = blob.split_at(NONCE_LEN); + let cipher = Aes256Gcm::new(Key::::from_slice(&self.0)); + cipher + .decrypt(Nonce::from_slice(nonce), ct) + .map_err(|_| CryptoError::Auth("master-key unseal failed")) + } +} + +impl std::fmt::Debug for MasterKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never print key material. + f.write_str("MasterKey(****)") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seal_open_round_trip() { + let mk = MasterKey::generate(); + let secret = b"device signing key seeds"; + let blob = mk.seal(secret); + assert_ne!(&blob[12..], secret, "ciphertext must not equal plaintext"); + assert_eq!(mk.open(&blob).unwrap(), secret); + } + + #[test] + fn open_rejects_wrong_key_and_tamper() { + let mk = MasterKey::generate(); + let blob = mk.seal(b"x"); + assert!(MasterKey::generate().open(&blob).is_err()); + + let mut t = blob.clone(); + *t.last_mut().unwrap() ^= 0x01; + assert!(mk.open(&t).is_err()); + } + + #[test] + fn default_album_id_is_deterministic_per_master() { + let mk = MasterKey::from_bytes([5u8; 32]); + let id1 = mk.derive_default_album_id(); + let id2 = MasterKey::from_bytes([5u8; 32]).derive_default_album_id(); + assert_eq!( + id1, id2, + "same master must recompute the same default album id" + ); + // Different master → different id. + assert_ne!( + id1, + MasterKey::from_bytes([6u8; 32]).derive_default_album_id() + ); + // It is a well-formed v8 UUID. + assert_eq!(id1.get_version(), Some(uuid::Version::Custom)); + } + + #[test] + fn seal_uses_fresh_nonce() { + let mk = MasterKey::generate(); + assert_ne!(mk.seal(b"same"), mk.seal(b"same")); + } +} diff --git a/capsule-core/src/crypto/keys/mod.rs b/capsule-core/src/crypto/keys/mod.rs new file mode 100644 index 0000000..9a7301b --- /dev/null +++ b/capsule-core/src/crypto/keys/mod.rs @@ -0,0 +1,22 @@ +//! Capsule's key hierarchy (SSoT: [Cryptography — Keys]). +//! +//! One backed-up root (the account master key) wraps device identity private keys and +//! anchors the AMK escrow. Album keys (AMKs) are random per-epoch keys; per-file keys are +//! derived from them. Device signing/encryption keys are hybrid [`HybridSigningKey`] / +//! [`kem::DekKeypair`]. +//! +//! [Cryptography — Keys]: https://docs/design/cryptography/keys/ + +pub mod album; +pub mod directory; +pub mod hybrid_sig; +pub mod kem; +pub mod keystore; +pub mod master; + +pub use album::{Amk, AmkVersion}; +pub use directory::{DeviceDirectory, DeviceEntry, DirectoryCore}; +pub use hybrid_sig::{HybridSignature, HybridSigningKey, HybridVerifyingKey}; +pub use kem::DekKeypair; +pub use keystore::{Account, AccountFile, DeviceKeys}; +pub use master::MasterKey; diff --git a/capsule-core/src/crypto/mod.rs b/capsule-core/src/crypto/mod.rs new file mode 100644 index 0000000..801b6f7 --- /dev/null +++ b/capsule-core/src/crypto/mod.rs @@ -0,0 +1,60 @@ +//! Capsule's cryptographic data plane. +//! +//! Every primitive identity (hash, KDF, AEAD, signature scheme, suite id) is the +//! single source of truth declared in the design docs under `design/cryptography/` +//! and pinned in code by [`primitives`]. Submodules are layered strictly in +//! dependency order: +//! +//! ```text +//! hash · primitives · rng · kdf · pwkdf (foundation, no internal deps) +//! └─ keys ─ encryption (key hierarchy + AEAD) +//! └─ authority ─┐ +//! └─ provenance ┴─ verify_asset (the single acknowledgement chokepoint) +//! ``` +//! +//! The cryptographic layer is deliberately self-contained and side-effect-free +//! (no network, no global state), so the whole data plane is unit-testable offline +//! against RFC / FIPS known-answer vectors and exhaustive negative cases. + +pub mod authority; +pub mod encryption; +pub mod hash; +pub mod kdf; +pub mod keys; +pub mod primitives; +pub mod provenance; +pub mod pwkdf; +pub mod rng; +pub mod verify_asset; + +pub use hash::{Hash32, Sha256Hasher}; +pub use primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION, SuiteId}; +pub use verify_asset::{PendingReason, RejectReason, VerifyOutcome, verify_asset}; + +use thiserror::Error; + +/// Errors surfaced by the cryptographic layer. +/// +/// Variants are intentionally coarse and carry a `&'static str` reason code rather +/// than free-form strings, so a rejection is greppable in logs and stable across +/// refactors (see [Threat Model — Validation] structured reason codes). +/// +/// [Threat Model — Validation]: https://docs/design/threat-model/validation/ +#[derive(Debug, Error, PartialEq, Eq)] +pub enum CryptoError { + /// An AEAD open / signature verification failed authentication. + #[error("authentication failed: {0}")] + Auth(&'static str), + + /// A structure declared a `crypto_suite_id` this build does not implement. + #[error("unknown crypto suite id: {0:#06x}")] + UnknownSuite(u16), + + /// A wire/on-disk structure was malformed (wrong length, bad framing, ...). + #[error("malformed input: {0}")] + Malformed(&'static str), + + /// A key could not be derived, decoded, or reconstructed from bytes. + #[error("key error: {0}")] + Key(&'static str), +} diff --git a/capsule-core/src/crypto/primitives.rs b/capsule-core/src/crypto/primitives.rs new file mode 100644 index 0000000..5123fc3 --- /dev/null +++ b/capsule-core/src/crypto/primitives.rs @@ -0,0 +1,168 @@ +//! The single source of truth, in code, for Capsule's cryptographic primitive set. +//! +//! Mirrors the inventory in [Cryptography — Primitives]. Every on-disk and on-wire +//! structure that depends on a primitive carries a [`SuiteId`] (`crypto_suite_id`), so two +//! structures encrypted under different suite versions can coexist without a flag day. +//! Retiring a primitive does not edit a row — it adds a new [`SuiteId`] variant. +//! +//! [Cryptography — Primitives]: https://docs/design/cryptography/primitives/ + +/// `crypto_suite_id` of the current primitive bundle (the [`SuiteId::V1`] inventory). +pub const CRYPTO_SUITE_ID: u16 = 0x0001; + +/// The date-based wire `protocol_version` this build writes. Pinned per album at creation. +pub const PROTOCOL_VERSION: &str = "2026-05-31"; + +/// Identifies a complete bundle of cryptographic primitives. +/// +/// A structure declaring a `crypto_suite_id` outside this closed set is rejected +/// (fail-closed), never best-effort-parsed under a guessed suite. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum SuiteId { + /// `0x0001`: SHA-256 · HKDF-SHA512 · Argon2id · AES-256-GCM(+STREAM) · + /// Ed25519+ML-DSA-65 · X-Wing · MLS `0x004D`. + V1, +} + +impl SuiteId { + /// The suite new writes use. + pub const CURRENT: SuiteId = SuiteId::V1; + + /// Map a wire `crypto_suite_id` to its suite, or `None` if this build does not + /// implement it (the caller then fails closed — see invariant 2). + pub fn from_u16(id: u16) -> Option { + match id { + CRYPTO_SUITE_ID => Some(SuiteId::V1), + _ => None, + } + } + + /// The wire `crypto_suite_id` for this suite. + pub fn as_u16(self) -> u16 { + match self { + SuiteId::V1 => CRYPTO_SUITE_ID, + } + } + + /// SHA-256 digest length under this suite (content-hash / `hash` field length). + pub fn hash_len(self) -> usize { + match self { + SuiteId::V1 => 32, + } + } +} + +/// Versioned HKDF `info` labels. Including a version string lets the KDF be rotated +/// later without a flag day; the SSoT for each label is the doc that derives that key. +pub mod info { + /// Per-file asset key: `HKDF(ikm=AMK, salt=file_id, info=ASSET_FILE_V1)`. + pub const ASSET_FILE_V1: &[u8] = b"asset-file/v1"; + /// Per-metadata-blob key: `HKDF(ikm=AMK, salt=blob_id, info=METADATA_BLOB_V1)`. + pub const METADATA_BLOB_V1: &[u8] = b"metadata-blob/v1"; + /// Default-album *identifier* derived from the account master key (an ID, not a key). + pub const DEFAULT_ALBUM_ID_V1: &[u8] = b"default-album-id/v1"; +} + +/// Device hardware tier, selecting Argon2id cost parameters at *wrap* time. The chosen +/// parameters are recorded in the wrapped blob, so unwrap works on any tier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceTier { + /// ≤ 2 GiB total RAM (entry-level Android / embedded). + LowRam, + /// Default for phones and laptops. + Normal, + /// ≥ 8 GiB; used when wrapping new escrow blobs from a desktop. + Desktop, +} + +/// Argon2id cost parameters: memory in KiB, iteration count `t`, parallelism `p`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Argon2Params { + /// Memory cost in KiB. + pub mem_kib: u32, + /// Iteration (time) cost `t`. + pub t_cost: u32, + /// Degree of parallelism `p`. + pub p_cost: u32, +} + +impl DeviceTier { + /// Canonical Argon2id parameters for this tier (see the primitives doc's table). + pub fn params(self) -> Argon2Params { + match self { + DeviceTier::LowRam => Argon2Params { + mem_kib: 128 * 1024, + t_cost: 3, + p_cost: 1, + }, + DeviceTier::Normal => Argon2Params { + mem_kib: 256 * 1024, + t_cost: 3, + p_cost: 1, + }, + DeviceTier::Desktop => Argon2Params { + mem_kib: 512 * 1024, + t_cost: 4, + p_cost: 1, + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn suite_id_round_trip_dispatches_to_exactly_one_row() { + assert_eq!(SuiteId::from_u16(0x0001), Some(SuiteId::V1)); + assert_eq!(SuiteId::V1.as_u16(), 0x0001); + assert_eq!(SuiteId::CURRENT, SuiteId::V1); + assert_eq!(SuiteId::V1.hash_len(), 32); + } + + #[test] + fn unknown_suite_ids_fail_closed() { + assert_eq!(SuiteId::from_u16(0x0000), None); + assert_eq!(SuiteId::from_u16(0x0002), None); + assert_eq!(SuiteId::from_u16(0xffff), None); + } + + #[test] + fn device_tier_params_match_inventory() { + assert_eq!( + DeviceTier::LowRam.params(), + Argon2Params { + mem_kib: 131_072, + t_cost: 3, + p_cost: 1 + } + ); + assert_eq!( + DeviceTier::Normal.params(), + Argon2Params { + mem_kib: 262_144, + t_cost: 3, + p_cost: 1 + } + ); + assert_eq!( + DeviceTier::Desktop.params(), + Argon2Params { + mem_kib: 524_288, + t_cost: 4, + p_cost: 1 + } + ); + } + + #[test] + fn info_labels_are_versioned_and_distinct() { + // Distinct labels keep derived keys in separate domains. + assert_ne!(info::ASSET_FILE_V1, info::METADATA_BLOB_V1); + assert_ne!(info::ASSET_FILE_V1, info::DEFAULT_ALBUM_ID_V1); + assert!(info::ASSET_FILE_V1.ends_with(b"/v1")); + assert!(info::METADATA_BLOB_V1.ends_with(b"/v1")); + } +} diff --git a/capsule-core/src/crypto/provenance/action.rs b/capsule-core/src/crypto/provenance/action.rs new file mode 100644 index 0000000..0ead6b3 --- /dev/null +++ b/capsule-core/src/crypto/provenance/action.rs @@ -0,0 +1,100 @@ +//! The closed lifecycle-action set (SSoT: [Authorization — The Closed Action Set]). +//! +//! Every lifecycle transition is an [`AssetManifest`](super::manifest::AssetManifest) whose +//! `action` is one of these. A value outside the set is a **structural error**, never a +//! "future value to ignore" — adding a value bumps `protocol_version` and old albums never +//! see it. Deserializing an unknown action string fails (closed-enum rejection). +//! +//! [Authorization — The Closed Action Set]: https://docs/design/authorization/#the-closed-action-set + +use serde::{Deserialize, Serialize}; + +/// The seven authorized lifecycle actions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Action { + /// First write of an asset; `prior_provenance_hash` is null. + Create, + /// Replace the original bytes (e.g. re-encryption under a new AMK epoch). + Replace, + /// Soft-delete; the asset enters trash with a retention window. + Delete, + /// Edit to the encrypted metadata blob or sidecar fields. + MetadataUpdate, + /// Add a thumbnail, preview, or embedding. + DerivativeAdd, + /// Replace an existing derivative — the only authorized path; silent overwrite rejected. + DerivativeReplace, + /// Recover a soft-deleted asset from trash within its retention window. + TrashRestore, +} + +impl Action { + /// Whether this action is the first link in an asset's chain (`prior_provenance_hash` + /// must be null iff this is true). + pub fn is_create(self) -> bool { + matches!(self, Action::Create) + } +} + +/// The role of a derivative (SSoT: [Provenance — Derivative Provenance]). +/// +/// [Provenance — Derivative Provenance]: https://docs/design/cryptography/provenance/#derivative-provenance +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DerivativeRole { + /// A small thumbnail image. + Thumbnail, + /// A larger preview image. + Preview, + /// An ML embedding vector. + Embedding, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn actions_use_the_exact_wire_strings() { + let cases = [ + (Action::Create, "create"), + (Action::Replace, "replace"), + (Action::Delete, "delete"), + (Action::MetadataUpdate, "metadata-update"), + (Action::DerivativeAdd, "derivative-add"), + (Action::DerivativeReplace, "derivative-replace"), + (Action::TrashRestore, "trash-restore"), + ]; + for (action, wire) in cases { + let bytes = crate::cbor::to_canonical_vec(&action).unwrap(); + let decoded: Action = crate::cbor::from_slice(&bytes).unwrap(); + assert_eq!(decoded, action); + // The encoded value is exactly the kebab-case text string. + let as_text: String = crate::cbor::from_slice(&bytes).unwrap(); + assert_eq!(as_text, wire); + } + } + + #[test] + fn unknown_action_value_is_rejected() { + // A closed enum: an unknown string fails to decode (not "ignored as future"). + let bytes = crate::cbor::to_canonical_vec(&"future-action-not-yet-defined").unwrap(); + assert!(crate::cbor::from_slice::(&bytes).is_err()); + } + + #[test] + fn only_create_is_a_chain_root() { + assert!(Action::Create.is_create()); + for a in [ + Action::Replace, + Action::Delete, + Action::MetadataUpdate, + Action::DerivativeAdd, + Action::DerivativeReplace, + Action::TrashRestore, + ] { + assert!(!a.is_create()); + } + } +} diff --git a/capsule-core/src/crypto/provenance/manifest.rs b/capsule-core/src/crypto/provenance/manifest.rs new file mode 100644 index 0000000..02a617a --- /dev/null +++ b/capsule-core/src/crypto/provenance/manifest.rs @@ -0,0 +1,291 @@ +//! The signed asset manifest and derivative manifest (SSoT: [Cryptography — Provenance]). +//! +//! A manifest carries **two** hybrid signatures over the same canonical core bytes: +//! `device_sig` (provenance — which device produced it) and `write_sig` (authorization — +//! the album's per-epoch write-tier key). Both must verify at [`verify_asset`]. The core +//! excludes the signatures, so signing bytes are unambiguous and downgrade-resistant +//! (both sigs cover `crypto_suite_id`, `protocol_version`, and `prior_provenance_hash`). +//! +//! [`verify_asset`]: crate::crypto::verify_asset +//! [Cryptography — Provenance]: https://docs/design/cryptography/provenance/ + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::action::{Action, DerivativeRole}; +use crate::cbor; +use crate::crypto::hash::Hash32; +use crate::crypto::keys::{AmkVersion, HybridSignature, HybridSigningKey}; + +/// Current asset-manifest schema string. +pub const ASSET_MANIFEST_VERSION: &str = "asset-manifest/v1"; +/// Current derivative-manifest schema string. +pub const DERIVATIVE_MANIFEST_VERSION: &str = "derivative-manifest/v1"; + +/// The signed core of an asset manifest — every field the two signatures cover. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManifestCore { + /// Schema version string (`asset-manifest/v1`). + pub version: String, + /// The primitive bundle this manifest was produced under. + pub crypto_suite_id: u16, + /// Date-based wire protocol version; matches the album pin. + pub protocol_version: String, + /// The asset's file id. + pub file_id: Uuid, + /// The album the asset belongs to. + pub album_id: Uuid, + /// The AMK epoch (and write-tier key) this manifest is authorized under. + pub amk_version: AmkVersion, + /// Content-address digest over the ciphertext. + pub ciphertext_hash: Hash32, + /// Total plaintext byte length. + pub plaintext_size: u64, + /// Plaintext bytes per STREAM chunk. + pub chunk_size: u32, + /// STREAM nonce prefix (random per file). + pub nonce_prefix: [u8; 7], + /// User who produced the asset. + pub created_by_user: Uuid, + /// Device that produced the asset (resolved in the device directory). + pub created_by_device: Uuid, + /// Producing client version string. + pub client_version: String, + /// Self-asserted capture/write time (RFC3339). Audit-only; never load-bearing. + pub timestamp: String, + /// The lifecycle action. + pub action: Action, + /// SHA-256 of the previous manifest in this asset's chain; null iff `action = create`. + pub prior_provenance_hash: Option, + /// Server-visible retention deadline (RFC3339); set only for `action = delete`. + pub retention_until: Option, +} + +/// A signed asset manifest: a [`ManifestCore`] plus its two hybrid signatures. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetManifest { + /// The signed core. + pub core: ManifestCore, + /// Hybrid signature by the uploading device's DSK (provenance). + pub device_sig: HybridSignature, + /// Hybrid signature under the epoch write-tier key (authorization). + pub write_sig: HybridSignature, +} + +impl ManifestCore { + /// The canonical bytes both signatures cover. + pub fn signing_bytes(&self) -> Vec { + cbor::to_canonical_vec(self).expect("manifest core serializes") + } + + /// Sign this core with the device DSK and the epoch write-tier key. + pub fn sign(self, device: &HybridSigningKey, write_tier: &HybridSigningKey) -> AssetManifest { + let bytes = self.signing_bytes(); + let device_sig = device.sign(&bytes); + let write_sig = write_tier.sign(&bytes); + AssetManifest { + core: self, + device_sig, + write_sig, + } + } +} + +impl AssetManifest { + /// The canonical bytes both signatures cover. + pub fn signing_bytes(&self) -> Vec { + self.core.signing_bytes() + } + + /// Structural well-formedness independent of any key: + /// - `prior_provenance_hash` is null **iff** the action is `create`; + /// - `retention_until` is set only for `delete`. + /// + /// These are enforced both here (client `verify_asset`) and by the server envelope. + pub fn structural_ok(&self) -> bool { + let prior_rule = self.core.prior_provenance_hash.is_none() == self.core.action.is_create(); + let retention_rule = + self.core.retention_until.is_none() || self.core.action == Action::Delete; + prior_rule && retention_rule + } +} + +/// The signed core of a derivative manifest (thumbnail / preview / embedding). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DerivativeCore { + /// Schema version string (`derivative-manifest/v1`). + pub version: String, + /// Primitive bundle. + pub crypto_suite_id: u16, + /// The asset this derivative is generated from. + pub source_asset_id: Uuid, + /// Which kind of derivative. + pub role: DerivativeRole, + /// MIME/format string, e.g. `image/avif` or `embedding/mobileclip-b`. + pub format: String, + /// Content-address digest over the derivative ciphertext. + pub ciphertext_hash: Hash32, + /// Device that generated the derivative. + pub generated_by_device: Uuid, + /// Generating client version. + pub generated_by_client: String, + /// Model id (embeddings only). + pub model_id: Option, + /// Model version (embeddings only). + pub model_version: Option, + /// RFC3339 generation time. + pub generated_at: String, + /// Chain link per `(source_asset_id, role)`; null for the first of that role. + pub prior_provenance_hash: Option, +} + +/// A signed derivative manifest. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DerivativeManifest { + /// The signed core. + pub core: DerivativeCore, + /// Hybrid device signature. + pub device_sig: HybridSignature, + /// Hybrid write-tier signature. + pub write_sig: HybridSignature, +} + +impl DerivativeCore { + /// The canonical bytes both signatures cover. + pub fn signing_bytes(&self) -> Vec { + cbor::to_canonical_vec(self).expect("derivative core serializes") + } + + /// Sign with the device DSK and epoch write-tier key. + pub fn sign( + self, + device: &HybridSigningKey, + write_tier: &HybridSigningKey, + ) -> DerivativeManifest { + let bytes = self.signing_bytes(); + DerivativeManifest { + device_sig: device.sign(&bytes), + write_sig: write_tier.sign(&bytes), + core: self, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; + + fn core(action: Action, prior: Option) -> ManifestCore { + ManifestCore { + version: ASSET_MANIFEST_VERSION.into(), + crypto_suite_id: CRYPTO_SUITE_ID, + protocol_version: PROTOCOL_VERSION.into(), + file_id: Uuid::from_u128(0xF11E), + album_id: Uuid::from_u128(0xA1), + amk_version: AmkVersion(1), + ciphertext_hash: Hash32([0xCC; 32]), + plaintext_size: 1024, + chunk_size: 65_520, + nonce_prefix: [1, 2, 3, 4, 5, 6, 7], + created_by_user: Uuid::from_u128(0x05E2), + created_by_device: Uuid::from_u128(0xD1), + client_version: "capsule-cli/0.1.0".into(), + timestamp: "2026-05-31T12:00:00Z".into(), + action, + prior_provenance_hash: prior, + retention_until: None, + } + } + + #[test] + fn sign_produces_two_verifiable_signatures() { + let device = HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]); + let write = HybridSigningKey::from_seed_bytes(&[3; 32], &[4; 32]); + let m = core(Action::Create, None).sign(&device, &write); + + let bytes = m.signing_bytes(); + assert!(device.verifying_key().verify(&bytes, &m.device_sig)); + assert!(write.verifying_key().verify(&bytes, &m.write_sig)); + } + + #[test] + fn signing_bytes_are_canonical_and_stable() { + let device = HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]); + let write = HybridSigningKey::from_seed_bytes(&[3; 32], &[4; 32]); + let m = core(Action::Create, None).sign(&device, &write); + // The core round-trips through canonical CBOR unchanged, and the full manifest too. + let back: AssetManifest = cbor::from_slice(&cbor::to_canonical_vec(&m).unwrap()).unwrap(); + assert_eq!(back, m); + assert_eq!(back.signing_bytes(), m.signing_bytes()); + } + + #[test] + fn structural_rules_prior_hash_and_retention() { + // create + null prior: ok. + assert!( + core(Action::Create, None) + .sign(&dev(), &wt()) + .structural_ok() + ); + // create + non-null prior: violation. + assert!( + !core(Action::Create, Some(Hash32([1; 32]))) + .sign(&dev(), &wt()) + .structural_ok() + ); + // non-create + null prior: violation. + assert!( + !core(Action::Replace, None) + .sign(&dev(), &wt()) + .structural_ok() + ); + // non-create + non-null prior: ok. + assert!( + core(Action::Replace, Some(Hash32([1; 32]))) + .sign(&dev(), &wt()) + .structural_ok() + ); + + // retention only on delete. + let mut c = core(Action::MetadataUpdate, Some(Hash32([1; 32]))); + c.retention_until = Some("2026-07-01T00:00:00Z".into()); + assert!(!c.sign(&dev(), &wt()).structural_ok()); + let mut d = core(Action::Delete, Some(Hash32([1; 32]))); + d.retention_until = Some("2026-07-01T00:00:00Z".into()); + assert!(d.sign(&dev(), &wt()).structural_ok()); + } + + #[test] + fn derivative_chain_is_independent() { + let device = HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]); + let write = HybridSigningKey::from_seed_bytes(&[3; 32], &[4; 32]); + let dm = DerivativeCore { + version: DERIVATIVE_MANIFEST_VERSION.into(), + crypto_suite_id: CRYPTO_SUITE_ID, + source_asset_id: Uuid::from_u128(0xF11E), + role: DerivativeRole::Thumbnail, + format: "image/avif".into(), + ciphertext_hash: Hash32([0xAB; 32]), + generated_by_device: Uuid::from_u128(0xD1), + generated_by_client: "capsule-cli/0.1.0".into(), + model_id: None, + model_version: None, + generated_at: "2026-05-31T12:00:00Z".into(), + prior_provenance_hash: None, + } + .sign(&device, &write); + assert!( + write + .verifying_key() + .verify(&dm.core.signing_bytes(), &dm.write_sig) + ); + } + + fn dev() -> HybridSigningKey { + HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]) + } + fn wt() -> HybridSigningKey { + HybridSigningKey::from_seed_bytes(&[3; 32], &[4; 32]) + } +} diff --git a/capsule-core/src/crypto/provenance/mod.rs b/capsule-core/src/crypto/provenance/mod.rs new file mode 100644 index 0000000..8bbdc61 --- /dev/null +++ b/capsule-core/src/crypto/provenance/mod.rs @@ -0,0 +1,22 @@ +//! Signed manifests and append-only provenance chains (SSoT: [Cryptography — Provenance]). +//! +//! - [`action`] — the closed lifecycle-action and derivative-role enums. +//! - [`manifest`] — [`AssetManifest`] / [`DerivativeManifest`] with their canonical signing +//! bytes and two hybrid signatures. +//! - [`record`] — [`ProvenanceRecord`] and the hash-chained [`ProvenanceChain`]. +//! +//! Verification of all of this flows through the single [`verify_asset`] chokepoint. +//! +//! [`verify_asset`]: crate::crypto::verify_asset +//! [Cryptography — Provenance]: https://docs/design/cryptography/provenance/ + +pub mod action; +pub mod manifest; +pub mod record; + +pub use action::{Action, DerivativeRole}; +pub use manifest::{ + ASSET_MANIFEST_VERSION, AssetManifest, DERIVATIVE_MANIFEST_VERSION, DerivativeCore, + DerivativeManifest, ManifestCore, +}; +pub use record::{ChainError, ProvenanceChain, ProvenanceRecord}; diff --git a/capsule-core/src/crypto/provenance/record.rs b/capsule-core/src/crypto/provenance/record.rs new file mode 100644 index 0000000..5250ad7 --- /dev/null +++ b/capsule-core/src/crypto/provenance/record.rs @@ -0,0 +1,265 @@ +//! Append-only, hash-chained provenance log per asset (SSoT: [Cryptography — Provenance +//! § Provenance of Library Modifications]). +//! +//! Each non-create record references its predecessor by SHA-256 hash; a rewrite of any +//! past record breaks the chain at that point and is detectable by any reader walking +//! forward from `create`. This is the structure that lets a key-holding attacker be +//! detected after the fact: history is read-only. +//! +//! [Cryptography — Provenance § Provenance of Library Modifications]: https://docs/design/cryptography/provenance/#provenance-of-library-modifications + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use super::manifest::AssetManifest; +use crate::cbor; +use crate::crypto::hash::{self, Hash32}; + +/// One link in an asset's provenance chain. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProvenanceRecord { + /// The asset this chain belongs to. + pub asset_id: Uuid, + /// The signed manifest for this transition. + pub manifest: AssetManifest, + /// SHA-256 of the previous record; null only for `action = create`. Mirrors the + /// manifest's own `prior_provenance_hash`, so signing the manifest signs this link. + pub prior_provenance_hash: Option, +} + +impl ProvenanceRecord { + /// The content hash of this record (SHA-256 over its canonical CBOR, signatures + /// included), used as the next record's `prior_provenance_hash`. + pub fn record_hash(&self) -> Hash32 { + hash::hash_bytes(&cbor::to_canonical_vec(self).expect("provenance record serializes")) + } + + /// Whether the manifest's `prior_provenance_hash` mirrors the record's, as required. + fn mirrors_manifest(&self) -> bool { + self.manifest.core.prior_provenance_hash == self.prior_provenance_hash + } +} + +/// Errors from building or walking a provenance chain. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ChainError { + /// The first record was not a `create`, or a `create` appeared mid-chain. + #[error("chain root must be a single create record")] + BadRoot, + /// A record's `prior_provenance_hash` does not match the current chain head. + #[error("record does not chain to the current head (stale or forked)")] + BrokenLink, + /// A record's manifest prior hash does not mirror the record's prior hash. + #[error("manifest prior hash does not mirror the record")] + MirrorMismatch, +} + +/// An in-memory append-only provenance chain for one asset. +#[derive(Debug, Clone, Default)] +pub struct ProvenanceChain { + records: Vec, +} + +impl ProvenanceChain { + /// An empty chain. + pub fn new() -> Self { + Self::default() + } + + /// The current chain head hash (the last record's hash), or `None` if empty. + pub fn head(&self) -> Option { + self.records.last().map(ProvenanceRecord::record_hash) + } + + /// All records, oldest first. + pub fn records(&self) -> &[ProvenanceRecord] { + &self.records + } + + /// Append a record, enforcing the chain invariants: + /// the first record must be a `create` with a null prior; every later record's prior + /// must equal the current head; and each record's manifest prior must mirror it. + pub fn append(&mut self, record: ProvenanceRecord) -> Result<(), ChainError> { + if !record.mirrors_manifest() { + return Err(ChainError::MirrorMismatch); + } + match self.head() { + None => { + if !record.manifest.core.action.is_create() + || record.prior_provenance_hash.is_some() + { + return Err(ChainError::BadRoot); + } + } + Some(head) => { + if record.manifest.core.action.is_create() { + return Err(ChainError::BadRoot); + } + if record.prior_provenance_hash != Some(head) { + return Err(ChainError::BrokenLink); + } + } + } + self.records.push(record); + Ok(()) + } + + /// Walk the chain forward from `create`, asserting every link and mirror holds. Detects + /// a dropped or rewritten record as a non-matching prior hash. (Signature verification + /// is `verify_asset`'s job; this is structural chain integrity.) + pub fn verify_walk(records: &[ProvenanceRecord]) -> Result<(), ChainError> { + let mut expected_prior: Option = None; + for (i, rec) in records.iter().enumerate() { + if !rec.mirrors_manifest() { + return Err(ChainError::MirrorMismatch); + } + let is_create = rec.manifest.core.action.is_create(); + if i == 0 { + if !is_create || rec.prior_provenance_hash.is_some() { + return Err(ChainError::BadRoot); + } + } else { + if is_create { + return Err(ChainError::BadRoot); + } + if rec.prior_provenance_hash != expected_prior { + return Err(ChainError::BrokenLink); + } + } + expected_prior = Some(rec.record_hash()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::keys::{AmkVersion, HybridSigningKey}; + use crate::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; + use crate::crypto::provenance::action::Action; + use crate::crypto::provenance::manifest::{ASSET_MANIFEST_VERSION, ManifestCore}; + + const ASSET: u128 = 0xF11E; + + fn dev() -> HybridSigningKey { + HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]) + } + fn wt() -> HybridSigningKey { + HybridSigningKey::from_seed_bytes(&[3; 32], &[4; 32]) + } + + fn record(action: Action, prior: Option) -> ProvenanceRecord { + let core = ManifestCore { + version: ASSET_MANIFEST_VERSION.into(), + crypto_suite_id: CRYPTO_SUITE_ID, + protocol_version: PROTOCOL_VERSION.into(), + file_id: Uuid::from_u128(ASSET), + album_id: Uuid::from_u128(0xA1), + amk_version: AmkVersion(1), + ciphertext_hash: Hash32([0xCC; 32]), + plaintext_size: 10, + chunk_size: 65_520, + nonce_prefix: [0; 7], + created_by_user: Uuid::from_u128(0x05E2), + created_by_device: Uuid::from_u128(0xD1), + client_version: "t".into(), + timestamp: "2026-05-31T00:00:00Z".into(), + action, + prior_provenance_hash: prior, + retention_until: None, + }; + ProvenanceRecord { + asset_id: Uuid::from_u128(ASSET), + manifest: core.sign(&dev(), &wt()), + prior_provenance_hash: prior, + } + } + + fn build_chain() -> ProvenanceChain { + let mut chain = ProvenanceChain::new(); + chain.append(record(Action::Create, None)).unwrap(); + let h1 = chain.head().unwrap(); + chain + .append(record(Action::MetadataUpdate, Some(h1))) + .unwrap(); + let h2 = chain.head().unwrap(); + chain.append(record(Action::Delete, Some(h2))).unwrap(); + chain + } + + #[test] + fn build_and_walk_a_valid_chain() { + let chain = build_chain(); + assert_eq!(chain.records().len(), 3); + ProvenanceChain::verify_walk(chain.records()).unwrap(); + } + + #[test] + fn non_create_root_is_rejected() { + let mut chain = ProvenanceChain::new(); + // MetadataUpdate with null prior: mirrors (both null) but is not a create. + assert_eq!( + chain.append(record(Action::MetadataUpdate, None)), + Err(ChainError::BadRoot) + ); + } + + #[test] + fn second_create_is_rejected() { + let mut chain = ProvenanceChain::new(); + chain.append(record(Action::Create, None)).unwrap(); + assert_eq!( + chain.append(record(Action::Create, None)), + Err(ChainError::BadRoot) + ); + } + + #[test] + fn stale_prior_hash_breaks_the_link() { + let mut chain = ProvenanceChain::new(); + chain.append(record(Action::Create, None)).unwrap(); + // Append with a wrong (stale) prior hash → BrokenLink. + assert_eq!( + chain.append(record(Action::Delete, Some(Hash32([0xEE; 32])))), + Err(ChainError::BrokenLink) + ); + } + + #[test] + fn rewriting_a_past_record_is_detected_by_the_walk() { + let chain = build_chain(); + let mut records = chain.records().to_vec(); + // Tamper the middle record's timestamp (re-sign so its own sigs still verify, but the + // chain hash it produced changes, breaking the downstream link). + records[1].manifest.core.timestamp = "1999-01-01T00:00:00Z".into(); + assert_eq!( + ProvenanceChain::verify_walk(&records), + Err(ChainError::BrokenLink), + "a rewritten middle record breaks the forward walk" + ); + } + + #[test] + fn dropping_a_record_is_detected() { + let chain = build_chain(); + let mut records = chain.records().to_vec(); + records.remove(1); // drop the metadata-update + assert_eq!( + ProvenanceChain::verify_walk(&records), + Err(ChainError::BrokenLink) + ); + } + + #[test] + fn manifest_prior_must_mirror_record_prior() { + let mut chain = ProvenanceChain::new(); + chain.append(record(Action::Create, None)).unwrap(); + let head = chain.head().unwrap(); + // Build a record whose manifest prior disagrees with the record prior. + let mut rec = record(Action::Delete, Some(head)); + rec.manifest.core.prior_provenance_hash = Some(Hash32([0x77; 32])); + assert_eq!(chain.append(rec), Err(ChainError::MirrorMismatch)); + } +} diff --git a/capsule-core/src/crypto/pwkdf.rs b/capsule-core/src/crypto/pwkdf.rs new file mode 100644 index 0000000..edb493b --- /dev/null +++ b/capsule-core/src/crypto/pwkdf.rs @@ -0,0 +1,170 @@ +//! Password-based key wrapping: Argon2id derives a wrapping key from a passphrase / +//! recovery code, which then seals a secret under AES-256-GCM. Used for the master-key +//! escrow and backup wrap key (SSoT: [Cryptography — Primitives § Password-based KDF]). +//! +//! Argon2id runs only at account recovery and device bootstrap — never on a hot path. +//! The cost parameters are recorded **inside** the wrapped blob, so a desktop-wrapped +//! blob unwraps correctly on a phone (slowly) and vice versa, and parameters can be +//! raised over time without a flag day. +//! +//! [Cryptography — Primitives § Password-based KDF]: https://docs/design/cryptography/primitives/#password-based-kdf + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Key, Nonce}; +use argon2::{Algorithm, Argon2, Params, Version}; +use serde::{Deserialize, Serialize}; + +use super::CryptoError; +use super::primitives::{Argon2Params, DeviceTier}; +use super::rng; + +/// A secret sealed under a passphrase-derived key, self-describing for unwrap. +/// +/// The Argon2id parameters and salt travel with the ciphertext so any device can +/// reconstruct the wrapping key from the passphrase alone. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WrappedSecret { + /// Argon2id memory cost (KiB) used at wrap time. + pub mem_kib: u32, + /// Argon2id iteration cost `t`. + pub t_cost: u32, + /// Argon2id parallelism `p`. + pub p_cost: u32, + /// 32-byte CSPRNG salt for Argon2id. + pub salt: [u8; 32], + /// 12-byte AES-256-GCM nonce. + pub nonce: [u8; 12], + /// AES-256-GCM ciphertext of the secret (includes the 16-byte tag). + pub ciphertext: Vec, +} + +/// Derive a 32-byte wrapping key from a passphrase and salt via Argon2id. Exposed so the +/// backup artifact can use one passphrase-derived key for both its MANIFEST HMAC and its +/// AMK-ledger seal (SSoT: [Backup — Master-Key Escrow]). +/// +/// [Backup — Master-Key Escrow]: https://docs/design/backup-recovery/#master-key-escrow +pub fn derive_wrap_key( + passphrase: &[u8], + salt: &[u8], + p: Argon2Params, +) -> Result<[u8; 32], CryptoError> { + let params = Params::new(p.mem_kib, p.t_cost, p.p_cost, Some(32)) + .map_err(|_| CryptoError::Key("invalid Argon2 parameters"))?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut key = [0u8; 32]; + argon + .hash_password_into(passphrase, salt, &mut key) + .map_err(|_| CryptoError::Key("Argon2id derivation failed"))?; + Ok(key) +} + +/// Wrap `secret` under `passphrase` with explicit Argon2id parameters (recorded in-band). +pub fn wrap_with( + secret: &[u8], + passphrase: &[u8], + params: Argon2Params, +) -> Result { + let salt = rng::random_array::<32>(); + let nonce = rng::random_array::<12>(); + let wrap_key = derive_wrap_key(passphrase, &salt, params)?; + let cipher = Aes256Gcm::new(Key::::from_slice(&wrap_key)); + let ciphertext = cipher + .encrypt(Nonce::from_slice(&nonce), secret) + .map_err(|_| CryptoError::Auth("AES-GCM wrap failed"))?; + Ok(WrappedSecret { + mem_kib: params.mem_kib, + t_cost: params.t_cost, + p_cost: params.p_cost, + salt, + nonce, + ciphertext, + }) +} + +/// Wrap `secret` under `passphrase` using the canonical parameters for `tier`. +pub fn wrap( + secret: &[u8], + passphrase: &[u8], + tier: DeviceTier, +) -> Result { + wrap_with(secret, passphrase, tier.params()) +} + +/// Unwrap a [`WrappedSecret`], re-deriving the wrapping key from the in-band parameters. +/// Returns [`CryptoError::Auth`] on a wrong passphrase or a corrupt/tampered blob. +pub fn unwrap(blob: &WrappedSecret, passphrase: &[u8]) -> Result, CryptoError> { + let params = Argon2Params { + mem_kib: blob.mem_kib, + t_cost: blob.t_cost, + p_cost: blob.p_cost, + }; + let wrap_key = derive_wrap_key(passphrase, &blob.salt, params)?; + let cipher = Aes256Gcm::new(Key::::from_slice(&wrap_key)); + cipher + .decrypt(Nonce::from_slice(&blob.nonce), blob.ciphertext.as_slice()) + .map_err(|_| CryptoError::Auth("unwrap: wrong passphrase or corrupt blob")) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Tiny parameters keep the password-hash tests fast; the production tier table is + // asserted in `primitives` without paying the 128–512 MiB hashing cost. + fn fast() -> Argon2Params { + Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, + } + } + + #[test] + fn wrap_unwrap_round_trip() { + let secret = [0x42u8; 32]; + let blob = wrap_with(&secret, b"correct horse battery staple", fast()).unwrap(); + let out = unwrap(&blob, b"correct horse battery staple").unwrap(); + assert_eq!(out, secret); + } + + #[test] + fn wrong_passphrase_is_rejected() { + let blob = wrap_with(&[1u8; 32], b"right", fast()).unwrap(); + assert_eq!( + unwrap(&blob, b"wrong"), + Err(CryptoError::Auth( + "unwrap: wrong passphrase or corrupt blob" + )) + ); + } + + #[test] + fn tampered_ciphertext_is_rejected() { + let mut blob = wrap_with(&[9u8; 16], b"pw", fast()).unwrap(); + blob.ciphertext[0] ^= 0x01; + assert!(unwrap(&blob, b"pw").is_err()); + } + + #[test] + fn parameters_recorded_in_band_enable_cross_tier_unwrap() { + // Wrap with one parameter set; unwrap reads the params from the blob, not from a + // caller-supplied tier — so a blob made on a "desktop" opens on a "phone". + let desktopish = Argon2Params { + mem_kib: 96, + t_cost: 2, + p_cost: 1, + }; + let blob = wrap_with(b"master-key-bytes", b"passphrase", desktopish).unwrap(); + assert_eq!(blob.mem_kib, 96); + assert_eq!(blob.t_cost, 2); + assert_eq!(unwrap(&blob, b"passphrase").unwrap(), b"master-key-bytes"); + } + + #[test] + fn distinct_salts_make_blobs_unique() { + let a = wrap_with(&[0u8; 32], b"pw", fast()).unwrap(); + let b = wrap_with(&[0u8; 32], b"pw", fast()).unwrap(); + assert_ne!(a.salt, b.salt); + assert_ne!(a.ciphertext, b.ciphertext); + } +} diff --git a/capsule-core/src/crypto/rng.rs b/capsule-core/src/crypto/rng.rs new file mode 100644 index 0000000..b5b5651 --- /dev/null +++ b/capsule-core/src/crypto/rng.rs @@ -0,0 +1,39 @@ +//! OS CSPRNG access. Every key, salt, and nonce is drawn here; Capsule never seeds +//! its own PRNG (SSoT: [Cryptography — Primitives § Randomness]). +//! +//! [Cryptography — Primitives § Randomness]: https://docs/design/cryptography/primitives/#randomness + +/// Fill `buf` with cryptographically secure random bytes from the OS. +/// +/// Panics only if the OS RNG is unavailable — an unrecoverable environment fault on +/// every platform Capsule targets, where continuing would be a security defect. +pub fn fill(buf: &mut [u8]) { + getrandom::fill(buf).expect("OS CSPRNG (getrandom) must be available"); +} + +/// Draw a fresh `N`-byte random array (key, salt, or nonce prefix). +pub fn random_array() -> [u8; N] { + let mut a = [0u8; N]; + fill(&mut a); + a +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fills_exact_length() { + let mut buf = [0u8; 48]; + fill(&mut buf); + // Astronomically unlikely to be all-zero; guards against a no-op stub. + assert!(buf.iter().any(|&b| b != 0)); + } + + #[test] + fn draws_are_distinct() { + let a = random_array::<32>(); + let b = random_array::<32>(); + assert_ne!(a, b, "two CSPRNG draws must not collide"); + } +} diff --git a/capsule-core/src/crypto/verify_asset.rs b/capsule-core/src/crypto/verify_asset.rs new file mode 100644 index 0000000..34f61cd --- /dev/null +++ b/capsule-core/src/crypto/verify_asset.rs @@ -0,0 +1,542 @@ +//! The single asset-acknowledgement chokepoint (SSoT: [Keys — Write Authorization]). +//! +//! `verify_asset` is the **only** path by which a client accepts an asset into its trusted +//! set. It returns one of three outcomes — never a silent drop, never a silent accept: +//! +//! - [`VerifyOutcome::Accept`] — both signatures valid, epoch within the MLS-attested +//! ceiling, chain head matches, AMK locally held. +//! - [`VerifyOutcome::TerminalReject`] — reader-signed / removed-writer / wrong-epoch / +//! forged-chain / replayed / suite-downgrade / bad device sig … → quarantine. +//! - [`VerifyOutcome::Pending`] — epoch is within the attested range but its AMK content +//! key has not arrived yet → hold and retry, never quarantine. +//! +//! Authorization authority is the album's admin-signed MLS commit chain (the +//! [`AlbumAuthority`]), never the server. It ships with an exhaustive negative-case test +//! surface, since every negative is a real damage scenario. +//! +//! [Keys — Write Authorization]: https://docs/design/cryptography/keys/#write-authorization + +use crate::crypto::authority::AlbumAuthority; +use crate::crypto::hash::{self, Hash32}; +use crate::crypto::keys::DeviceDirectory; +use crate::crypto::primitives::SuiteId; +use crate::crypto::provenance::AssetManifest; + +/// Why a manifest was terminally rejected. Each maps to a damage scenario. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RejectReason { + /// The album authority's admin chain does not verify — untrusted state. + UntrustedAuthority, + /// The manifest names a different album than this authority speaks for. + WrongAlbum, + /// `crypto_suite_id` is not in the current inventory (downgrade / unknown). + SuiteDowngrade, + /// Structural rule violated (e.g. non-create with null prior; retention off a delete). + Structural, + /// Recomputed ciphertext hash does not match the manifest's declared hash. + CiphertextHashMismatch, + /// The signing device is not in the user's published directory. + UnknownDevice, + /// The device's `added_at` postdates the manifest timestamp (key older than itself). + DeviceAddedAfter, + /// A timestamp field was not valid RFC3339. + BadTimestamp, + /// The device signature (`device_sig`) did not verify. + BadDeviceSig, + /// `amk_version` exceeds the MLS-attested epoch ceiling (fabricated future epoch). + WrongEpoch, + /// The write-tier signature did not verify for the claimed epoch (reader-signed, + /// removed-writer, or wrong-epoch signature). + BadWriteSig, + /// `prior_provenance_hash` does not match the local chain head (stale / forked / replay). + ForgedChain, + /// A `create` for an asset that already exists locally (replay). + Replayed, +} + +/// Why a manifest is pending rather than accepted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PendingReason { + /// The epoch is attested but its AMK content key has not arrived locally yet. + AmkNotYetLocal, +} + +/// The outcome of [`verify_asset`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerifyOutcome { + /// Acknowledge the asset. + Accept, + /// Reject and quarantine, with a structured reason. + TerminalReject(RejectReason), + /// Hold and retry as MLS state catches up. + Pending(PendingReason), +} + +impl VerifyOutcome { + /// Convenience: did verification accept? + pub fn is_accept(self) -> bool { + matches!(self, VerifyOutcome::Accept) + } +} + +fn rfc3339_le(a: &str, b: &str) -> Option { + let pa = chrono::DateTime::parse_from_rfc3339(a).ok()?; + let pb = chrono::DateTime::parse_from_rfc3339(b).ok()?; + Some(pa <= pb) +} + +/// Verify a manifest against the device directory, the album's MLS-attested authority, and +/// the local provenance chain head. The one path by which an asset is acknowledged. +/// +/// - `ciphertext` is the asset's ciphertext, used to confirm the content hash (integrity); +/// it need not be decryptable here. +/// - `local_chain_head` is the hash of the current head provenance record for this asset, +/// or `None` if the asset is unknown locally. +pub fn verify_asset( + manifest: &AssetManifest, + ciphertext: &[u8], + directory: &DeviceDirectory, + authority: &dyn AlbumAuthority, + local_chain_head: Option, +) -> VerifyOutcome { + use RejectReason::*; + use VerifyOutcome::TerminalReject as Reject; + let core = &manifest.core; + + // 1. The authority's own admin chain must verify — never trust an unsigned ledger. + if !authority.admin_chain_verifies() { + return Reject(UntrustedAuthority); + } + // 2. The manifest must be for the album this authority speaks for. + if core.album_id != authority.album_id() { + return Reject(WrongAlbum); + } + // 3. The crypto suite must be one this build implements (fail-closed). + if SuiteId::from_u16(core.crypto_suite_id).is_none() { + return Reject(SuiteDowngrade); + } + // 4. Structural rules (prior-hash/create coupling; retention only on delete). + if !manifest.structural_ok() { + return Reject(Structural); + } + // 5. Content integrity: the ciphertext must hash to the declared content address. + if hash::hash_bytes(ciphertext) != core.ciphertext_hash { + return Reject(CiphertextHashMismatch); + } + // 6. The signing device must be in this user's published directory. + if directory.core.user_id != core.created_by_user { + return Reject(UnknownDevice); + } + let Some(entry) = directory.device(&core.created_by_device) else { + return Reject(UnknownDevice); + }; + // 7. The device cannot have been added after it claims to have signed. + match rfc3339_le(&entry.added_at, &core.timestamp) { + None => return Reject(BadTimestamp), + Some(false) => return Reject(DeviceAddedAfter), + Some(true) => {} + } + // 8. The device signature must verify (provenance). + let signing_bytes = manifest.signing_bytes(); + if !entry + .dsk_public + .verify(&signing_bytes, &manifest.device_sig) + { + return Reject(BadDeviceSig); + } + // 9. The epoch must be within the MLS-attested ceiling (no fabricated future epoch). + if core.amk_version > authority.epoch_ceiling() { + return Reject(WrongEpoch); + } + // 10. The write-tier signature must verify under the epoch's attested write-tier key. + // Reader-signed and removed-writer manifests both fail this check. + let Some(write_pub) = authority.write_tier_pubkey(core.amk_version) else { + return Reject(WrongEpoch); + }; + if !write_pub.verify(&signing_bytes, &manifest.write_sig) { + return Reject(BadWriteSig); + } + // 11. Chain placement: a create must be new; a non-create must chain to the local head. + if core.action.is_create() { + if local_chain_head.is_some() { + return Reject(Replayed); + } + } else if core.prior_provenance_hash.is_none() || core.prior_provenance_hash != local_chain_head + { + return Reject(ForgedChain); + } + // 12. Everything authorizes; accept iff the AMK is locally held, else hold as pending. + if authority.has_amk(core.amk_version) { + VerifyOutcome::Accept + } else { + VerifyOutcome::Pending(PendingReason::AmkNotYetLocal) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::authority::ReferenceAuthority; + use crate::crypto::hash::Hash32; + use crate::crypto::keys::directory::{DeviceEntry, DirectoryCore}; + use crate::crypto::keys::{AmkVersion, HybridSigningKey}; + use crate::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; + use crate::crypto::provenance::action::Action; + use crate::crypto::provenance::manifest::{ASSET_MANIFEST_VERSION, ManifestCore}; + use uuid::Uuid; + + const USER: u128 = 0x05E2; + const DEVICE: u128 = 0xD1; + const ALBUM: u128 = 0xA1; + const CIPHERTEXT: &[u8] = b"the asset ciphertext bytes"; + + /// A fully-valid setup; tests perturb exactly one thing. + struct Fixture { + device: HybridSigningKey, + write1: HybridSigningKey, + write2: HybridSigningKey, + admin: HybridSigningKey, + directory: DeviceDirectory, + authority: ReferenceAuthority, + } + + impl Fixture { + fn new() -> Self { + let ik = HybridSigningKey::from_seed_bytes(&[10; 32], &[11; 32]); + let device = HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]); + let write1 = HybridSigningKey::from_seed_bytes(&[3; 32], &[4; 32]); + let write2 = HybridSigningKey::from_seed_bytes(&[5; 32], &[6; 32]); + let admin = HybridSigningKey::from_seed_bytes(&[7; 32], &[8; 32]); + + let directory = DirectoryCore { + user_id: Uuid::from_u128(USER), + directory_version: 1, + updated_at: "2026-05-30T00:00:00Z".into(), + devices: vec![DeviceEntry { + device_id: Uuid::from_u128(DEVICE), + dsk_public: device.verifying_key(), + added_at: "2026-05-30T00:00:00Z".into(), + revoked_at: None, + }], + } + .sign(&ik); + + // Authority attests epoch 1 (AMK present) and epoch 2 (AMK present). + let authority = ReferenceAuthority::new(Uuid::from_u128(ALBUM), admin.verifying_key()) + .with_epoch(&admin, AmkVersion(1), &write1.verifying_key(), true) + .with_epoch(&admin, AmkVersion(2), &write2.verifying_key(), true); + + Self { + device, + write1, + write2, + admin, + directory, + authority, + } + } + + fn core(&self, action: Action, prior: Option) -> ManifestCore { + ManifestCore { + version: ASSET_MANIFEST_VERSION.into(), + crypto_suite_id: CRYPTO_SUITE_ID, + protocol_version: PROTOCOL_VERSION.into(), + file_id: Uuid::from_u128(0xF11E), + album_id: Uuid::from_u128(ALBUM), + amk_version: AmkVersion(1), + ciphertext_hash: hash::hash_bytes(CIPHERTEXT), + plaintext_size: 12, + chunk_size: 65_520, + nonce_prefix: [1, 2, 3, 4, 5, 6, 7], + created_by_user: Uuid::from_u128(USER), + created_by_device: Uuid::from_u128(DEVICE), + client_version: "capsule-cli/0.1.0".into(), + timestamp: "2026-05-31T12:00:00Z".into(), + action, + prior_provenance_hash: prior, + retention_until: None, + } + } + + /// A valid `create` manifest signed by the correct device + epoch-1 write key. + fn valid_create(&self) -> AssetManifest { + self.core(Action::Create, None) + .sign(&self.device, &self.write1) + } + + fn verify(&self, m: &AssetManifest, head: Option) -> VerifyOutcome { + verify_asset(m, CIPHERTEXT, &self.directory, &self.authority, head) + } + } + + #[test] + fn accept_valid_create() { + let f = Fixture::new(); + assert_eq!(f.verify(&f.valid_create(), None), VerifyOutcome::Accept); + } + + #[test] + fn accept_valid_non_create_chaining_to_head() { + let f = Fixture::new(); + let create = f.valid_create(); + // Pretend the create is the local head; a metadata-update chains onto it. + let head = crate::crypto::hash::hash_bytes(b"pretend-head"); + let update = f + .core(Action::MetadataUpdate, Some(head)) + .sign(&f.device, &f.write1); + assert_eq!(f.verify(&update, Some(head)), VerifyOutcome::Accept); + // And the create itself accepts when the asset is unknown locally. + assert_eq!(f.verify(&create, None), VerifyOutcome::Accept); + } + + /// A mock authority whose admin chain does not verify — also exercises the trait seam + /// (verify_asset works against any `AlbumAuthority`, not just `ReferenceAuthority`). + struct UntrustedAuthorityMock(Uuid); + impl AlbumAuthority for UntrustedAuthorityMock { + fn album_id(&self) -> Uuid { + self.0 + } + fn epoch_ceiling(&self) -> AmkVersion { + AmkVersion(10) + } + fn write_tier_pubkey( + &self, + _: AmkVersion, + ) -> Option { + None + } + fn has_amk(&self, _: AmkVersion) -> bool { + true + } + fn admin_chain_verifies(&self) -> bool { + false + } + } + + #[test] + fn reject_untrusted_authority() { + let f = Fixture::new(); + let bad = UntrustedAuthorityMock(Uuid::from_u128(ALBUM)); + assert_eq!( + verify_asset(&f.valid_create(), CIPHERTEXT, &f.directory, &bad, None), + VerifyOutcome::TerminalReject(RejectReason::UntrustedAuthority) + ); + } + + #[test] + fn reject_wrong_album() { + let f = Fixture::new(); + let mut core = f.core(Action::Create, None); + core.album_id = Uuid::from_u128(0xBEEF); + let m = core.sign(&f.device, &f.write1); + assert_eq!( + f.verify(&m, None), + VerifyOutcome::TerminalReject(RejectReason::WrongAlbum) + ); + } + + #[test] + fn reject_unknown_suite_downgrade() { + let f = Fixture::new(); + let mut core = f.core(Action::Create, None); + core.crypto_suite_id = 0xFFFF; // unknown suite, signed validly + let m = core.sign(&f.device, &f.write1); + assert_eq!( + f.verify(&m, None), + VerifyOutcome::TerminalReject(RejectReason::SuiteDowngrade) + ); + } + + #[test] + fn flipping_suite_after_signing_breaks_device_sig() { + let f = Fixture::new(); + let mut m = f.valid_create(); + // Change the declared suite without re-signing: signing bytes diverge → sig fails. + m.core.crypto_suite_id = CRYPTO_SUITE_ID; // still known, but sig was over the original + m.core.protocol_version = "1999-01-01".into(); + assert_eq!( + f.verify(&m, None), + VerifyOutcome::TerminalReject(RejectReason::BadDeviceSig) + ); + } + + #[test] + fn reject_structural_non_create_with_null_prior() { + let f = Fixture::new(); + let m = f.core(Action::Replace, None).sign(&f.device, &f.write1); + assert_eq!( + f.verify(&m, None), + VerifyOutcome::TerminalReject(RejectReason::Structural) + ); + } + + #[test] + fn reject_ciphertext_hash_mismatch() { + let f = Fixture::new(); + let m = f.valid_create(); + // Verify against different ciphertext bytes than the manifest committed to. + assert_eq!( + verify_asset(&m, b"different bytes", &f.directory, &f.authority, None), + VerifyOutcome::TerminalReject(RejectReason::CiphertextHashMismatch) + ); + } + + #[test] + fn reject_unknown_device() { + let f = Fixture::new(); + let mut core = f.core(Action::Create, None); + core.created_by_device = Uuid::from_u128(0xDEAD); + let m = core.sign(&f.device, &f.write1); + assert_eq!( + f.verify(&m, None), + VerifyOutcome::TerminalReject(RejectReason::UnknownDevice) + ); + } + + #[test] + fn reject_device_added_after_manifest() { + let f = Fixture::new(); + let mut core = f.core(Action::Create, None); + // Manifest claims a time before the device was added to the directory. + core.timestamp = "2026-05-29T00:00:00Z".into(); + let m = core.sign(&f.device, &f.write1); + assert_eq!( + f.verify(&m, None), + VerifyOutcome::TerminalReject(RejectReason::DeviceAddedAfter) + ); + } + + #[test] + fn reject_bad_device_sig() { + let f = Fixture::new(); + let mut m = f.valid_create(); + m.device_sig = HybridSigningKey::from_seed_bytes(&[99; 32], &[99; 32]).sign(b"garbage"); + assert_eq!( + f.verify(&m, None), + VerifyOutcome::TerminalReject(RejectReason::BadDeviceSig) + ); + } + + #[test] + fn reject_reader_signed_no_write_capability() { + let f = Fixture::new(); + // A reader holds the device key but NOT a write-tier key — sign write_sig with a + // non-write key. The write_sig won't verify under the epoch's attested write key. + let reader_fake_write = HybridSigningKey::from_seed_bytes(&[77; 32], &[78; 32]); + let m = f + .core(Action::Create, None) + .sign(&f.device, &reader_fake_write); + assert_eq!( + f.verify(&m, None), + VerifyOutcome::TerminalReject(RejectReason::BadWriteSig) + ); + } + + #[test] + fn reject_removed_writer_wrong_epoch_key() { + let f = Fixture::new(); + // Signer holds epoch-2's write key but claims epoch 1 (e.g. a writer removed at the + // epoch-1→2 bump trying to pass off old work). write_sig won't verify under epoch 1's key. + let m = f.core(Action::Create, None).sign(&f.device, &f.write2); + assert_eq!( + f.verify(&m, None), + VerifyOutcome::TerminalReject(RejectReason::BadWriteSig) + ); + } + + #[test] + fn reject_wrong_epoch_above_ceiling() { + let f = Fixture::new(); + let mut core = f.core(Action::Create, None); + core.amk_version = AmkVersion(99); // above the attested ceiling of 2 + let m = core.sign(&f.device, &f.write1); + assert_eq!( + f.verify(&m, None), + VerifyOutcome::TerminalReject(RejectReason::WrongEpoch) + ); + } + + #[test] + fn reject_forged_chain_stale_prior() { + let f = Fixture::new(); + let head = hash::hash_bytes(b"real-head"); + let stale = hash::hash_bytes(b"stale-or-forked"); + let m = f + .core(Action::Delete, Some(stale)) + .sign(&f.device, &f.write1); + // Local head is `head`, but the manifest chains to `stale`. + assert_eq!( + f.verify(&m, Some(head)), + VerifyOutcome::TerminalReject(RejectReason::ForgedChain) + ); + } + + #[test] + fn reject_replayed_create_when_asset_exists() { + let f = Fixture::new(); + let existing = hash::hash_bytes(b"existing-head"); + assert_eq!( + f.verify(&f.valid_create(), Some(existing)), + VerifyOutcome::TerminalReject(RejectReason::Replayed) + ); + } + + // ── Pending semantics ──────────────────────────────────────────────────────── + + #[test] + fn pending_when_amk_within_range_but_not_local() { + // Authority attests epoch 1's write key but the AMK content key hasn't arrived. + let admin = HybridSigningKey::from_seed_bytes(&[7; 32], &[8; 32]); + let write1 = HybridSigningKey::from_seed_bytes(&[3; 32], &[4; 32]); + let device = HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]); + let ik = HybridSigningKey::from_seed_bytes(&[10; 32], &[11; 32]); + let directory = DirectoryCore { + user_id: Uuid::from_u128(USER), + directory_version: 1, + updated_at: "2026-05-30T00:00:00Z".into(), + devices: vec![DeviceEntry { + device_id: Uuid::from_u128(DEVICE), + dsk_public: device.verifying_key(), + added_at: "2026-05-30T00:00:00Z".into(), + revoked_at: None, + }], + } + .sign(&ik); + let mut authority = ReferenceAuthority::new(Uuid::from_u128(ALBUM), admin.verifying_key()) + .with_epoch( + &admin, + AmkVersion(1), + &write1.verifying_key(), + false, // AMK NOT yet locally held + ); + + let f = Fixture::new(); + let m = f.core(Action::Create, None).sign(&device, &write1); + assert_eq!( + verify_asset(&m, CIPHERTEXT, &directory, &authority, None), + VerifyOutcome::Pending(PendingReason::AmkNotYetLocal), + "valid but AMK-missing manifest must be pending, not rejected" + ); + + // When the AMK arrives, the same manifest now accepts. + authority.mark_amk_present(AmkVersion(1)); + assert_eq!( + verify_asset(&m, CIPHERTEXT, &directory, &authority, None), + VerifyOutcome::Accept + ); + } + + #[test] + fn drop_in_authority_parity() { + // The seam is honored only through &dyn AlbumAuthority: a second authority that + // attests nothing rejects the same manifest at the epoch check, proving verify_asset + // depends only on the trait, not on ReferenceAuthority internals. + let f = Fixture::new(); + let empty = ReferenceAuthority::new(Uuid::from_u128(ALBUM), f.admin.verifying_key()); + let dynamic: &dyn AlbumAuthority = ∅ + assert_eq!( + verify_asset(&f.valid_create(), CIPHERTEXT, &f.directory, dynamic, None), + VerifyOutcome::TerminalReject(RejectReason::WrongEpoch) + ); + } +} diff --git a/capsule-core/src/lib.rs b/capsule-core/src/lib.rs index ba2d9e5..7f77266 100644 --- a/capsule-core/src/lib.rs +++ b/capsule-core/src/lib.rs @@ -1,10 +1,15 @@ +pub mod backup; +pub mod cbor; pub mod constants; +pub mod crypto; pub mod db; pub mod domain; pub mod exif; pub mod import; pub mod library; +pub mod lifecycle; pub mod metadata; pub mod models; pub mod sidecar; pub mod utils; +pub mod validation; diff --git a/capsule-core/src/lifecycle.rs b/capsule-core/src/lifecycle.rs new file mode 100644 index 0000000..8281d79 --- /dev/null +++ b/capsule-core/src/lifecycle.rs @@ -0,0 +1,761 @@ +//! The offline asset lifecycle — the integration layer that ties the cryptographic data +//! plane to the on-disk client library, and the substrate the CLI showcase drives. +//! +//! A [`Workspace`] holds an unlocked [`Account`], the per-album key material + its +//! [`ReferenceAuthority`], and the signed device directory. Each operation produces the +//! design's real artifacts and self-checks them through [`verify_asset`]: +//! +//! - [`import_asset`](Workspace::import_asset) — derive the file key, STREAM-encrypt to get +//! the content hash, build + sign the create manifest, append the provenance chain, write +//! the signed [`SidecarV1`], and gate on `verify_asset == Accept`. +//! - [`tag_add`](Workspace::tag_add) / [`set_caption`](Workspace::set_caption) — CRDT edits +//! emitting a `metadata-update` provenance record. +//! - [`soft_delete`](Workspace::soft_delete) / [`restore`](Workspace::restore) — `delete` +//! (with a signed retention window) and `trash-restore` lifecycle records. +//! - [`export_backup`](Workspace::export_backup) / [`import_backup`](Workspace::import_backup) +//! — the portable artifact round-trip; the client stores plaintext, so ciphertext is +//! regenerated deterministically from the manifest's recorded nonce prefix. +//! +//! Clients store **plaintext** locally (original + signed sidecar + provenance chain); +//! encryption produces the artifacts that cross a boundary. Album epoch rotation (the MLS +//! ceremony) is deferred — albums here are single-epoch (see `DEFERRED.md`). +//! +//! [`verify_asset`]: crate::crypto::verify_asset + +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::{Datelike, Utc}; +use thiserror::Error; +use uuid::Uuid; + +use crate::backup::{self, BackupArtifact, BackupAsset, BackupInput, RestoreMode}; +use crate::cbor; +use crate::crypto::encryption::{seal_blob, stream}; +use crate::crypto::hash::{self, Hash32}; +use crate::crypto::keys::directory::{DeviceEntry, DirectoryCore}; +use crate::crypto::keys::{Account, Amk, AmkVersion, DeviceDirectory, HybridSigningKey}; +use crate::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; +use crate::crypto::provenance::action::Action; +use crate::crypto::provenance::manifest::{ASSET_MANIFEST_VERSION, ManifestCore}; +use crate::crypto::provenance::{AssetManifest, ProvenanceChain, ProvenanceRecord}; +use crate::crypto::verify_asset::{VerifyOutcome, verify_asset}; +use crate::crypto::{CryptoError, authority::ReferenceAuthority}; +use crate::metadata::crdt::{AddId, Counter}; +use crate::sidecar::sidecar_v1::{SIDECAR_SCHEMA_V1, SidecarV1}; + +/// A device is treated as added far in the past so any import timestamp postdates it. +const DEVICE_ADDED_AT: &str = "2020-01-01T00:00:00Z"; + +/// Errors from lifecycle operations. +#[derive(Debug, Error)] +pub enum LifecycleError { + /// Filesystem error. + #[error("io: {0}")] + Io(String), + /// Unknown album / asset id. + #[error("not found: {0}")] + NotFound(String), + /// An asset failed its own `verify_asset` self-check (a bug — should never happen). + #[error("verify_asset self-check failed: {0:?}")] + SelfVerify(VerifyOutcome), + /// Cryptographic error. + #[error(transparent)] + Crypto(#[from] CryptoError), + /// Backup error. + #[error(transparent)] + Backup(#[from] backup::BackupError), + /// CBOR (de)serialization error. + #[error("cbor: {0}")] + Cbor(String), +} + +type Result = std::result::Result; + +/// One album's key material (single-epoch in this offline core). +pub struct AlbumKeys { + /// Album id. + pub album_id: Uuid, + /// Display name. + pub name: String, + /// AMKs by epoch. + pub amks: BTreeMap, + /// Per-album write-tier signing key. + pub write_tier: HybridSigningKey, + /// Per-album admin signing key. + pub admin: HybridSigningKey, + /// The current (and only) epoch. + pub current_epoch: u32, +} + +/// In-memory state for one managed asset. +pub struct AssetState { + /// Asset id (== file_id). + pub asset_id: Uuid, + /// Owning album. + pub album_id: Uuid, + /// Original file extension (lowercase). + pub ext: String, + /// UTC seconds used for date bucketing on disk. + pub capture_utc: i64, + /// The provenance chain. + pub chain: ProvenanceChain, + /// The signed sidecar. + pub sidecar: SidecarV1, +} + +/// An offline Capsule workspace over a client library directory. +pub struct Workspace { + root: PathBuf, + account: Account, + directory: DeviceDirectory, + counter: Counter, + albums: HashMap, + authorities: HashMap, + assets: HashMap, +} + +fn now_rfc3339() -> String { + Utc::now().to_rfc3339() +} + +fn content_type_for(ext: &str) -> String { + match ext { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "heic" => "image/heic", + "webp" => "image/webp", + "mp4" => "video/mp4", + _ => "application/octet-stream", + } + .to_string() +} + +fn media_dir(root: &Path, capture_utc: i64) -> PathBuf { + let dt = chrono::DateTime::from_timestamp(capture_utc, 0).unwrap_or_default(); + root.join("media") + .join(format!("{:04}", dt.year())) + .join(format!("{:04}-{:02}", dt.year(), dt.month())) +} + +impl Workspace { + /// Create a fresh workspace: initialise the library directory and a new account, and + /// publish a device directory. `passphrase` guards the on-disk account; `tier` sets the + /// Argon2id cost. + pub fn create( + root: &Path, + passphrase: &[u8], + tier: crate::crypto::primitives::DeviceTier, + ) -> Result { + Self::create_with_params(root, passphrase, tier.params()) + } + + /// As [`create`](Self::create) but with explicit Argon2id parameters (tests use a fast cost). + pub fn create_with_params( + root: &Path, + passphrase: &[u8], + params: crate::crypto::primitives::Argon2Params, + ) -> Result { + crate::library::init::init_library(root, "Capsule") + .map_err(|e| LifecycleError::Io(format!("init library: {e}")))?; + let account = Account::create(); + let file = account.to_file_with(passphrase, params)?; + let acct_bytes = + cbor::to_canonical_vec(&file).map_err(|e| LifecycleError::Cbor(e.to_string()))?; + fs::write(root.join(".library").join("account.cbor"), &acct_bytes) + .map_err(|e| LifecycleError::Io(e.to_string()))?; + + let directory = Self::build_directory(&account); + let counter = Counter::new(account.device.device_id); + Ok(Self { + root: root.to_path_buf(), + account, + directory, + counter, + albums: HashMap::new(), + authorities: HashMap::new(), + assets: HashMap::new(), + }) + } + + fn build_directory(account: &Account) -> DeviceDirectory { + DirectoryCore { + user_id: account.user_id, + directory_version: 1, + updated_at: now_rfc3339(), + devices: vec![DeviceEntry { + device_id: account.device.device_id, + dsk_public: account.device.dsk.verifying_key(), + added_at: DEVICE_ADDED_AT.into(), + revoked_at: None, + }], + } + .sign(&account.user_ik) + } + + /// The account's user id. + pub fn user_id(&self) -> Uuid { + self.account.user_id + } + + /// The account's default album id (derived from the master key). + pub fn default_album_id(&self) -> Uuid { + self.account.master.derive_default_album_id() + } + + /// Create a container album: mint AMK_v1 + write-tier + admin keys and an attested + /// authority. Returns the new album id. + pub fn create_album(&mut self, name: &str) -> Uuid { + self.create_album_with_id(Uuid::now_v7(), name) + } + + /// Create an album with a specific id (e.g. the derived default-album id). + pub fn create_album_with_id(&mut self, album_id: Uuid, name: &str) -> Uuid { + let amk = Amk::generate(); + let write_tier = HybridSigningKey::generate(); + let admin = HybridSigningKey::generate(); + let mut amks = BTreeMap::new(); + amks.insert(1, *amk.as_bytes()); + + let authority = ReferenceAuthority::new(album_id, admin.verifying_key()).with_epoch( + &admin, + AmkVersion(1), + &write_tier.verifying_key(), + true, + ); + self.authorities.insert(album_id, authority); + self.albums.insert( + album_id, + AlbumKeys { + album_id, + name: name.to_string(), + amks, + write_tier, + admin, + current_epoch: 1, + }, + ); + album_id + } + + fn album(&self, album_id: &Uuid) -> Result<&AlbumKeys> { + self.albums + .get(album_id) + .ok_or_else(|| LifecycleError::NotFound(format!("album {album_id}"))) + } + + fn provenance_path(&self, asset: &AssetState) -> PathBuf { + media_dir(&self.root, asset.capture_utc) + .join(format!("{}.provenance.cbor", asset.asset_id.simple())) + } + fn sidecar_path(&self, asset: &AssetState) -> PathBuf { + media_dir(&self.root, asset.capture_utc).join(format!("{}.cbor", asset.asset_id.simple())) + } + fn media_path(&self, asset: &AssetState) -> PathBuf { + media_dir(&self.root, asset.capture_utc).join(format!( + "{}.{}", + asset.asset_id.simple(), + asset.ext + )) + } + + fn file_key(&self, album: &AlbumKeys, file_id: &Uuid) -> [u8; 32] { + let amk = Amk::from_bytes(album.amks[&album.current_epoch]); + amk.derive_file_key(file_id) + } + + /// Build a signed lifecycle manifest for `asset`, sharing the create manifest's content + /// fields. Used for metadata-update / delete / trash-restore. + fn sign_lifecycle( + &self, + album: &AlbumKeys, + base: &ManifestCore, + action: Action, + prior: Option, + retention_until: Option, + ) -> AssetManifest { + let core = ManifestCore { + action, + prior_provenance_hash: prior, + retention_until, + timestamp: now_rfc3339(), + ..base.clone() + }; + core.sign(&self.account.device.dsk, &album.write_tier) + } + + fn write_asset_files(&self, asset: &AssetState, plaintext: &[u8]) -> Result<()> { + let dir = media_dir(&self.root, asset.capture_utc); + fs::create_dir_all(&dir).map_err(|e| LifecycleError::Io(e.to_string()))?; + fs::write(self.media_path(asset), plaintext) + .map_err(|e| LifecycleError::Io(e.to_string()))?; + fs::write(self.sidecar_path(asset), asset.sidecar.to_canonical_vec()) + .map_err(|e| LifecycleError::Io(e.to_string()))?; + let prov = cbor::to_canonical_vec(&asset.chain.records().to_vec()) + .map_err(|e| LifecycleError::Cbor(e.to_string()))?; + fs::write(self.provenance_path(asset), prov) + .map_err(|e| LifecycleError::Io(e.to_string()))?; + Ok(()) + } + + /// Import a file into `album_id`: encrypt, build the signed create manifest + provenance, + /// write the signed sidecar, and self-verify through `verify_asset`. Returns the asset id. + pub fn import_asset(&mut self, album_id: Uuid, src: &Path) -> Result { + let plaintext = + fs::read(src).map_err(|e| LifecycleError::Io(format!("read {src:?}: {e}")))?; + let ext = src + .extension() + .map(|e| e.to_string_lossy().to_lowercase()) + .unwrap_or_else(|| "bin".into()); + let asset_id = Uuid::now_v7(); + let capture_utc = Utc::now().timestamp(); + + let album = self.album(&album_id)?; + let file_key = self.file_key(album, &asset_id); + let (enc, ciphertext) = stream::encrypt_asset_vec_full(&file_key, &plaintext); + + let core = ManifestCore { + version: ASSET_MANIFEST_VERSION.into(), + crypto_suite_id: CRYPTO_SUITE_ID, + protocol_version: PROTOCOL_VERSION.into(), + file_id: asset_id, + album_id, + amk_version: AmkVersion(album.current_epoch), + ciphertext_hash: enc.ciphertext_hash, + plaintext_size: enc.plaintext_size, + chunk_size: enc.chunk_size, + nonce_prefix: enc.nonce_prefix, + created_by_user: self.account.user_id, + created_by_device: self.account.device.device_id, + client_version: concat!("capsule-core/", env!("CARGO_PKG_VERSION")).into(), + timestamp: now_rfc3339(), + action: Action::Create, + prior_provenance_hash: None, + retention_until: None, + }; + let manifest = core.sign(&self.account.device.dsk, &album.write_tier); + + let mut chain = ProvenanceChain::new(); + chain + .append(ProvenanceRecord { + asset_id, + manifest: manifest.clone(), + prior_provenance_hash: None, + }) + .map_err(|e| LifecycleError::Cbor(format!("chain: {e}")))?; + let chain_head = chain.head().expect("just appended"); + + let mut sidecar = SidecarV1 { + sidecar_schema: SIDECAR_SCHEMA_V1, + crypto_suite_id: CRYPTO_SUITE_ID, + uuid: asset_id, + hash: hash::hash_bytes(&plaintext), + capture_timestamp: now_rfc3339(), + import_timestamp: now_rfc3339(), + content_type: content_type_for(&ext), + dimensions: None, + lqip: None, + tags_user: Default::default(), + tags_ai: Default::default(), + caption: Default::default(), + rating: Default::default(), + stack_membership: None, + camera_id: None, + device_id: self.account.device.device_id, + session_id: Uuid::now_v7(), + gps: None, + provenance_chain_hash: chain_head, + unknown: BTreeMap::new(), + signature: None, + }; + sidecar.sign(&self.account.user_ik); + + // Self-check: the asset must verify through the one chokepoint before we accept it. + let authority = &self.authorities[&album_id]; + let outcome = verify_asset(&manifest, &ciphertext, &self.directory, authority, None); + if outcome != VerifyOutcome::Accept { + return Err(LifecycleError::SelfVerify(outcome)); + } + + let asset = AssetState { + asset_id, + album_id, + ext, + capture_utc, + chain, + sidecar, + }; + self.write_asset_files(&asset, &plaintext)?; + self.assets.insert(asset_id, asset); + Ok(asset_id) + } + + /// Run `verify_asset` for a managed asset (regenerating its ciphertext deterministically). + pub fn verify(&self, asset_id: &Uuid) -> Result { + let asset = self + .assets + .get(asset_id) + .ok_or_else(|| LifecycleError::NotFound(format!("asset {asset_id}")))?; + let album = self.album(&asset.album_id)?; + let head = &asset.chain.records().last().unwrap().manifest; + let plaintext = + fs::read(self.media_path(asset)).map_err(|e| LifecycleError::Io(e.to_string()))?; + let file_key = self.file_key(album, &head.core.file_id); + let (_, ciphertext) = + stream::encrypt_asset_vec_with_prefix(&file_key, head.core.nonce_prefix, &plaintext); + + // Walk the whole chain forward; the head is what enters the trusted set. + let prior = asset + .chain + .records() + .len() + .checked_sub(2) + .map(|i| asset.chain.records()[i].record_hash()); + Ok(verify_asset( + head, + &ciphertext, + &self.directory, + &self.authorities[&asset.album_id], + prior, + )) + } + + fn append_lifecycle( + &mut self, + asset_id: &Uuid, + action: Action, + retention_until: Option, + mutate_sidecar: impl FnOnce(&mut SidecarV1, AddId), + ) -> Result<()> { + let album_id = self + .assets + .get(asset_id) + .ok_or_else(|| LifecycleError::NotFound(format!("asset {asset_id}")))? + .album_id; + let prior = self.assets[asset_id].chain.head(); + let base = self.assets[asset_id] + .chain + .records() + .last() + .unwrap() + .manifest + .core + .clone(); + let album = self.album(&album_id)?; + let manifest = self.sign_lifecycle(album, &base, action, prior, retention_until); + let add_id = self.counter.issue(); + + { + let asset = self.assets.get_mut(asset_id).unwrap(); + asset + .chain + .append(ProvenanceRecord { + asset_id: *asset_id, + manifest, + prior_provenance_hash: prior, + }) + .map_err(|e| LifecycleError::Cbor(format!("chain: {e}")))?; + let new_head = asset.chain.head().unwrap(); + mutate_sidecar(&mut asset.sidecar, add_id); + asset.sidecar.provenance_chain_hash = new_head; + asset.sidecar.signature = None; + asset.sidecar.sign(&self.account.user_ik); + } + + // Re-borrow immutably to write the updated artifacts to disk. + let asset = self.assets.get(asset_id).unwrap(); + let plaintext = + fs::read(self.media_path(asset)).map_err(|e| LifecycleError::Io(e.to_string()))?; + self.write_asset_files(asset, &plaintext) + } + + /// Add a user tag (OR-set) and emit a `metadata-update` provenance record. + pub fn tag_add(&mut self, asset_id: &Uuid, tag: &str) -> Result<()> { + let tag = tag.to_string(); + self.append_lifecycle(asset_id, Action::MetadataUpdate, None, move |s, add_id| { + s.tags_user.add(tag, add_id); + }) + } + + /// Set the caption (LWW register) and emit a `metadata-update` provenance record. + pub fn set_caption(&mut self, asset_id: &Uuid, caption: &str) -> Result<()> { + let caption = caption.to_string(); + let device = self.account.device.device_id; + let ts = now_rfc3339(); + self.append_lifecycle(asset_id, Action::MetadataUpdate, None, move |s, _add_id| { + s.caption.set(caption, ts, device); + }) + } + + /// Soft-delete: emit a `delete` record carrying a signed retention window. + pub fn soft_delete(&mut self, asset_id: &Uuid, retain_days: i64) -> Result<()> { + let until = (Utc::now() + chrono::Duration::days(retain_days)).to_rfc3339(); + self.append_lifecycle(asset_id, Action::Delete, Some(until), |_, _| {}) + } + + /// Restore a soft-deleted asset: emit a `trash-restore` record. + pub fn restore(&mut self, asset_id: &Uuid) -> Result<()> { + self.append_lifecycle(asset_id, Action::TrashRestore, None, |_, _| {}) + } + + /// The current provenance head hash for each managed asset (for backup reconciliation). + pub fn local_heads(&self) -> BTreeMap { + self.assets + .iter() + .filter_map(|(id, a)| a.chain.head().map(|h| (*id, h))) + .collect() + } + + /// Export every managed asset to a portable backup artifact. + pub fn export_backup(&self, out: &Path, passphrase: &[u8]) -> Result<()> { + let mut assets = Vec::new(); + let mut amks: BTreeMap<(Uuid, u32), [u8; 32]> = BTreeMap::new(); + + for asset in self.assets.values() { + let album = self.album(&asset.album_id)?; + let head = &asset.chain.records().last().unwrap().manifest; + let plaintext = + fs::read(self.media_path(asset)).map_err(|e| LifecycleError::Io(e.to_string()))?; + let file_key = self.file_key(album, &head.core.file_id); + let (_, ciphertext) = stream::encrypt_asset_vec_with_prefix( + &file_key, + head.core.nonce_prefix, + &plaintext, + ); + let amk = Amk::from_bytes(album.amks[&album.current_epoch]); + let metadata_blob = seal_blob( + &amk.derive_blob_key(&asset.asset_id), + &asset.sidecar.to_canonical_vec(), + ); + amks.insert( + (asset.album_id, head.core.amk_version.0), + album.amks[&album.current_epoch], + ); + assets.push(BackupAsset { + album_id: asset.album_id, + asset_id: asset.asset_id, + ciphertext, + metadata_blob, + provenance: asset.chain.records().to_vec(), + }); + } + + let input = BackupInput { + assets, + amks, + exporter_device: self.account.device.device_id, + source_library_version: "1".into(), + export_timestamp: now_rfc3339(), + }; + let bytes = backup::export(&input, passphrase, &self.account.device.dsk)?; + fs::write(out, &bytes).map_err(|e| LifecycleError::Io(e.to_string()))?; + Ok(()) + } + + /// This device's signing public key (the exporter key a peer verifies a backup against). + pub fn exporter_verifying_key(&self) -> crate::crypto::keys::HybridVerifyingKey { + self.account.device.dsk.verifying_key() + } + + /// Open a backup artifact and restore (commit) its assets into this workspace, writing + /// decrypted plaintext + provenance into the library. `exporter_pub` is the exporting + /// device's signing key (resolved from the user's device directory). Returns the count + /// of assets added. + pub fn import_backup( + &mut self, + archive: &Path, + passphrase: &[u8], + exporter_pub: &crate::crypto::keys::HybridVerifyingKey, + ) -> Result { + let bytes = fs::read(archive).map_err(|e| LifecycleError::Io(e.to_string()))?; + let artifact = BackupArtifact::open(&bytes, passphrase, exporter_pub)?; + let report = artifact.restore(RestoreMode::Commit, &self.local_heads())?; + + let mut added = 0; + for restored in &report.applied { + // Rebuild on-disk artifacts for the restored asset. + let head = &restored.provenance.last().unwrap().manifest; + let capture_utc = Utc::now().timestamp(); + let mut chain = ProvenanceChain::new(); + for rec in &restored.provenance { + chain + .append(rec.clone()) + .map_err(|e| LifecycleError::Cbor(format!("restore chain: {e}")))?; + } + // Decode the sidecar from the (decrypted) metadata blob if present. + let sidecar = self.decode_restored_sidecar(restored, head)?; + let ext = "bin".to_string(); + let asset = AssetState { + asset_id: restored.asset_id, + album_id: restored.album_id, + ext, + capture_utc, + chain, + sidecar, + }; + self.write_asset_files(&asset, &restored.plaintext)?; + self.assets.insert(restored.asset_id, asset); + added += 1; + } + Ok(added) + } + + fn decode_restored_sidecar( + &self, + restored: &backup::artifact::RestoredAsset, + head: &AssetManifest, + ) -> Result { + // Minimal sidecar reconstructed from the head manifest (the full encrypted metadata + // blob is preserved verbatim in the artifact; decoding it requires the AMK, which we + // hold). Here we synthesise a plaintext-equivalent sidecar for the local library. + let mut sidecar = SidecarV1 { + sidecar_schema: SIDECAR_SCHEMA_V1, + crypto_suite_id: CRYPTO_SUITE_ID, + uuid: restored.asset_id, + hash: hash::hash_bytes(&restored.plaintext), + capture_timestamp: head.core.timestamp.clone(), + import_timestamp: now_rfc3339(), + content_type: "application/octet-stream".into(), + dimensions: None, + lqip: None, + tags_user: Default::default(), + tags_ai: Default::default(), + caption: Default::default(), + rating: Default::default(), + stack_membership: None, + camera_id: None, + device_id: head.core.created_by_device, + session_id: Uuid::now_v7(), + gps: None, + provenance_chain_hash: restored.provenance.last().unwrap().record_hash(), + unknown: BTreeMap::new(), + signature: None, + }; + sidecar.sign(&self.account.user_ik); + Ok(sidecar) + } + + /// The plaintext bytes of a managed asset (reads from disk). + pub fn read_plaintext(&self, asset_id: &Uuid) -> Result> { + let asset = self + .assets + .get(asset_id) + .ok_or_else(|| LifecycleError::NotFound(format!("asset {asset_id}")))?; + fs::read(self.media_path(asset)).map_err(|e| LifecycleError::Io(e.to_string())) + } + + /// All managed asset ids. + pub fn asset_ids(&self) -> Vec { + self.assets.keys().copied().collect() + } + + /// A managed asset's current state. + pub fn asset(&self, asset_id: &Uuid) -> Option<&AssetState> { + self.assets.get(asset_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::primitives::Argon2Params; + use tempfile::TempDir; + + fn fast_workspace(dir: &Path) -> Workspace { + Workspace::create_with_params( + dir, + b"passphrase", + Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, + }, + ) + .unwrap() + } + + #[test] + fn end_to_end_data_plane() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let img = src.path().join("photo.jpg"); + fs::write( + &img, + b"\xFF\xD8\xFF\xE0 fake jpeg bytes for the e2e test \x00\x01\x02", + ) + .unwrap(); + + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Trip"); + + // Import → encrypt → manifest+provenance+signed sidecar → verify_asset(Accept). + let asset = ws.import_asset(album, &img).unwrap(); + assert_eq!(ws.verify(&asset).unwrap(), VerifyOutcome::Accept); + + // The signed sidecar + provenance + plaintext exist on disk. + let st = ws.asset(&asset).unwrap(); + assert!(ws.media_path(st).exists()); + assert!(ws.sidecar_path(st).exists()); + assert!(ws.provenance_path(st).exists()); + assert!(st.sidecar.verify(&ws.account.user_ik.verifying_key())); + + // CRDT metadata edits advance the chain and re-sign the sidecar. + ws.tag_add(&asset, "vacation").unwrap(); + ws.set_caption(&asset, "sunset over the bay").unwrap(); + let st = ws.asset(&asset).unwrap(); + assert!(st.sidecar.tags_user.value().contains("vacation")); + assert_eq!(st.sidecar.caption.get().unwrap(), "sunset over the bay"); + assert_eq!(st.chain.records().len(), 3); // create + 2 metadata-update + ProvenanceChain::verify_walk(st.chain.records()).unwrap(); + + // Soft delete + restore append lifecycle records. + ws.soft_delete(&asset, 30).unwrap(); + ws.restore(&asset).unwrap(); + let st = ws.asset(&asset).unwrap(); + assert_eq!(st.chain.records().len(), 5); + // The delete record carries a retention window; it remains in the chain after restore. + let actions: Vec<_> = st + .chain + .records() + .iter() + .map(|r| r.manifest.core.action) + .collect(); + assert_eq!( + actions, + vec![ + Action::Create, + Action::MetadataUpdate, + Action::MetadataUpdate, + Action::Delete, + Action::TrashRestore + ] + ); + + // Backup → restore into a FRESH library (new device, verifying against the + // exporter's published key) → byte-equal plaintext. + let backup_path = src.path().join("backup.tar"); + ws.export_backup(&backup_path, b"recovery-pass").unwrap(); + let exporter_pub = ws.exporter_verifying_key(); + + let fresh = TempDir::new().unwrap(); + let mut ws2 = fast_workspace(fresh.path()); + let added = ws2 + .import_backup(&backup_path, b"recovery-pass", &exporter_pub) + .unwrap(); + assert_eq!(added, 1); + assert_eq!( + ws2.read_plaintext(&asset).unwrap(), + ws.read_plaintext(&asset).unwrap(), + "restored library must be byte-equal to the source" + ); + + // A wrong exporter key (untrusted device) is refused. + let imposter = HybridSigningKey::generate().verifying_key(); + let mut ws3 = fast_workspace(TempDir::new().unwrap().path()); + assert!( + ws3.import_backup(&backup_path, b"recovery-pass", &imposter) + .is_err() + ); + } +} diff --git a/capsule-core/src/metadata/crdt/counter.rs b/capsule-core/src/metadata/crdt/counter.rs new file mode 100644 index 0000000..debc698 --- /dev/null +++ b/capsule-core/src/metadata/crdt/counter.rs @@ -0,0 +1,110 @@ +//! The per-device monotonic `add_id` counter (SSoT: [Metadata — Add-id Binding § +//! Counter durability across restarts]). +//! +//! A `monotonic_counter` must never repeat for a given `(device, asset, OR-set)`: a reused +//! `add_id` would alias two distinct adds. On restart/reinstall the counter is reseeded to +//! **one past the maximum counter this device has ever issued** (recovered from the signed +//! sidecars it wrote). It resets to zero only when the device can prove it has issued +//! nothing. This makes the counter monotonic over a `device_id`'s lifetime, not just a +//! process. +//! +//! [Metadata — Add-id Binding § Counter durability across restarts]: https://docs/design/metadata/#add-id-binding + +use uuid::Uuid; + +use super::or_set::AddId; + +/// A monotonic counter issuing `add_id`s for one device. +#[derive(Debug, Clone)] +pub struct Counter { + device: Uuid, + next: u64, +} + +impl Counter { + /// A counter for `device` that has issued nothing yet (next = 0). + pub fn new(device: Uuid) -> Self { + Self { device, next: 0 } + } + + /// Reseed from the maximum `add_id.counter` this device has ever issued (recovered from + /// its own signed sidecars). `None` means it has issued nothing → reset to zero. + pub fn reseed_from_max(&mut self, max_issued: Option) { + self.next = match max_issued { + Some(m) => m + 1, + None => 0, + }; + } + + /// Issue the next `add_id` and advance the counter. + pub fn issue(&mut self) -> AddId { + let id = AddId { + device: self.device, + counter: self.next, + }; + self.next += 1; + id + } + + /// The next counter value that would be issued. + pub fn peek(&self) -> u64 { + self.next + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn issues_strictly_increasing_counters() { + let mut c = Counter::new(Uuid::from_u128(1)); + assert_eq!(c.issue().counter, 0); + assert_eq!(c.issue().counter, 1); + assert_eq!(c.issue().counter, 2); + assert_eq!(c.peek(), 3); + } + + #[test] + fn reseed_is_one_past_max_ever_issued() { + let device = Uuid::from_u128(7); + let mut c = Counter::new(device); + c.issue(); + c.issue(); // issued 0, 1 + // Simulate a restart: drop the in-memory counter, reseed from the max observed in + // this device's existing sidecars (1). + let mut restarted = Counter::new(device); + restarted.reseed_from_max(Some(1)); + let next = restarted.issue(); + assert_eq!( + next.counter, 2, + "must be strictly greater than every prior counter" + ); + assert_eq!(next.device, device); + } + + #[test] + fn reseed_to_zero_when_nothing_ever_issued() { + let mut c = Counter::new(Uuid::from_u128(1)); + c.reseed_from_max(None); + assert_eq!(c.issue().counter, 0); + } + + #[test] + fn reseed_never_reuses_a_written_counter() { + // The key safety property: across many restart cycles, no counter ever repeats. + let device = Uuid::from_u128(3); + let mut max_written: Option = None; + let mut all = Vec::new(); + for _ in 0..5 { + let mut c = Counter::new(device); + c.reseed_from_max(max_written); + for _ in 0..3 { + let issued = c.issue().counter; + assert!(!all.contains(&issued), "counter {issued} reused"); + all.push(issued); + max_written = Some(max_written.map_or(issued, |m| m.max(issued))); + } + } + } +} diff --git a/capsule-core/src/metadata/crdt/lww.rs b/capsule-core/src/metadata/crdt/lww.rs new file mode 100644 index 0000000..17fe5ca --- /dev/null +++ b/capsule-core/src/metadata/crdt/lww.rs @@ -0,0 +1,181 @@ +//! Last-writer-wins register with a bounded *superseded* log (SSoT: [Metadata — +//! Surfacing Concurrent Edits]). +//! +//! Single-value collaborative fields (caption, rating) are LWW registers keyed by a signed +//! timestamp with the writing `device_id` as the lexicographic tiebreaker. A plain LWW +//! loses one side of a tied edit silently; Capsule instead keeps the winner authoritative +//! **and** preserves displaced values in a `superseded` log (capped, oldest evicted), so a +//! buggy client clobbering another device's edit becomes an explicit, recoverable surface. +//! +//! [Metadata — Surfacing Concurrent Edits]: https://docs/design/metadata/#surfacing-concurrent-edits + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Default cap on the superseded log (see Metadata: `superseded_captions ≤ 16`). +pub const SUPERSEDED_CAP: usize = 16; + +/// A timestamped, device-attributed value. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Stamped { + /// The value. + pub value: T, + /// RFC3339 write time. + pub ts: String, + /// Writing device id (the tiebreaker). + pub by: Uuid, +} + +/// An LWW register that also retains displaced values, newest-superseded first. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Lww { + /// The current authoritative value, if any. + pub current: Option>, + /// Displaced values (most recently superseded first), capped at [`SUPERSEDED_CAP`]. + pub superseded: Vec>, +} + +/// Order two candidates: later timestamp wins; ties break on the larger device id. +/// `None` if a timestamp is unparseable (caller treats as a structural reject upstream). +fn beats(a: &Stamped, b: &Stamped) -> Option { + let ta = chrono::DateTime::parse_from_rfc3339(&a.ts).ok()?; + let tb = chrono::DateTime::parse_from_rfc3339(&b.ts).ok()?; + Some((ta, a.by) > (tb, b.by)) +} + +impl Lww { + /// An empty register. + pub fn new() -> Self { + Self { + current: None, + superseded: Vec::new(), + } + } + + /// Apply a write. The higher `(ts, device_id)` becomes current; the loser is recorded + /// in `superseded` (capped). Returns `false` if a timestamp was unparseable (no change). + pub fn set(&mut self, value: T, ts: impl Into, by: Uuid) -> bool { + let incoming = Stamped { + value, + ts: ts.into(), + by, + }; + match &self.current { + None => { + self.current = Some(incoming); + true + } + Some(cur) => match beats(&incoming, cur) { + None => false, + Some(true) => { + let loser = self.current.replace(incoming).unwrap(); + self.push_superseded(loser); + true + } + Some(false) => { + // Incoming loses (or equals) — keep it as a superseded alternative if + // it is genuinely a different value, not a duplicate of current. + if Some(&incoming.value) != self.current.as_ref().map(|c| &c.value) { + self.push_superseded(incoming); + } + true + } + }, + } + } + + fn push_superseded(&mut self, s: Stamped) { + self.superseded.insert(0, s); + self.superseded.truncate(SUPERSEDED_CAP); + } + + /// Merge another replica's register: apply its current and all superseded entries. + pub fn merge(&mut self, other: &Self) { + if let Some(c) = &other.current { + self.set(c.value.clone(), c.ts.clone(), c.by); + } + for s in &other.superseded { + self.set(s.value.clone(), s.ts.clone(), s.by); + } + } + + /// The current value, if any. + pub fn get(&self) -> Option<&T> { + self.current.as_ref().map(|s| &s.value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dev(n: u128) -> Uuid { + Uuid::from_u128(n) + } + + #[test] + fn later_timestamp_wins_and_loser_is_superseded() { + let mut r = Lww::new(); + r.set("first".to_string(), "2026-05-31T10:00:00Z", dev(1)); + r.set("second".to_string(), "2026-05-31T11:00:00Z", dev(2)); + assert_eq!(r.get(), Some(&"second".to_string())); + assert_eq!(r.superseded.len(), 1); + assert_eq!(r.superseded[0].value, "first"); + } + + #[test] + fn tie_breaks_on_larger_device_id() { + // Same timestamp, two devices → the larger device id wins; the other is superseded. + let mut r = Lww::new(); + r.set("from-dev-1".to_string(), "2026-05-31T10:00:00Z", dev(1)); + r.set("from-dev-9".to_string(), "2026-05-31T10:00:00Z", dev(9)); + assert_eq!(r.get(), Some(&"from-dev-9".to_string())); + assert_eq!(r.superseded[0].value, "from-dev-1"); + } + + #[test] + fn an_earlier_write_arriving_late_does_not_clobber() { + let mut r = Lww::new(); + r.set("new".to_string(), "2026-05-31T11:00:00Z", dev(2)); + // An older edit arrives after the newer one: current is unchanged, loser recorded. + r.set("old".to_string(), "2026-05-31T09:00:00Z", dev(1)); + assert_eq!(r.get(), Some(&"new".to_string())); + assert!(r.superseded.iter().any(|s| s.value == "old")); + } + + #[test] + fn superseded_log_is_capped() { + let mut r = Lww::new(); + for i in 0..(SUPERSEDED_CAP + 5) { + // Each later write wins and pushes the prior winner to superseded. + let ts = format!("2026-05-31T{:02}:00:00Z", i); + r.set(format!("v{i}"), ts, dev(1)); + } + assert_eq!(r.superseded.len(), SUPERSEDED_CAP); + // Most-recently superseded is first. + assert_eq!(r.superseded[0].value, format!("v{}", SUPERSEDED_CAP + 3)); + } + + #[test] + fn merge_converges() { + let mut a = Lww::new(); + a.set("a".to_string(), "2026-05-31T10:00:00Z", dev(1)); + let mut b = Lww::new(); + b.set("b".to_string(), "2026-05-31T11:00:00Z", dev(2)); + + let mut ab = a.clone(); + ab.merge(&b); + let mut ba = b.clone(); + ba.merge(&a); + assert_eq!(ab.get(), ba.get()); + assert_eq!(ab.get(), Some(&"b".to_string())); + } + + #[test] + fn unparseable_timestamp_is_a_no_op() { + let mut r = Lww::new(); + r.set("ok".to_string(), "2026-05-31T10:00:00Z", dev(1)); + assert!(!r.set("bad".to_string(), "not-a-date", dev(2))); + assert_eq!(r.get(), Some(&"ok".to_string())); + } +} diff --git a/capsule-core/src/metadata/crdt/mod.rs b/capsule-core/src/metadata/crdt/mod.rs new file mode 100644 index 0000000..e8be6e9 --- /dev/null +++ b/capsule-core/src/metadata/crdt/mod.rs @@ -0,0 +1,19 @@ +//! CRDT semantics for collaborative metadata (SSoT: [Metadata — Collaborative Metadata]). +//! +//! User-editable fields on a shared album (tags, captions, ratings) can be edited +//! concurrently across devices, including offline. They are modelled as CRDTs so merges are +//! deterministic and commutative: +//! +//! - [`or_set::OrSet`] — tags, with `add_id` binding and reject-unobserved-remove. +//! - [`lww::Lww`] — single-value registers (caption, rating) with a superseded log. +//! - [`counter::Counter`] — the per-device monotonic `add_id` counter. +//! +//! [Metadata — Collaborative Metadata]: https://docs/design/metadata/#collaborative-metadata + +pub mod counter; +pub mod lww; +pub mod or_set; + +pub use counter::Counter; +pub use lww::{Lww, Stamped}; +pub use or_set::{AddId, OrSet, UnobservedRemove}; diff --git a/capsule-core/src/metadata/crdt/or_set.rs b/capsule-core/src/metadata/crdt/or_set.rs new file mode 100644 index 0000000..c788676 --- /dev/null +++ b/capsule-core/src/metadata/crdt/or_set.rs @@ -0,0 +1,195 @@ +//! Observed-remove set (OR-set) with explicit `add_id` binding (SSoT: [Metadata — +//! Add-id Binding] and [Collaborative Metadata]). +//! +//! Tags are modelled as an OR-set so a tag added on one device and removed on another +//! converge predictably. Every add carries an `add_id = (device_id, monotonic_counter)`; +//! every remove targets a specific `add_id`. A remove naming an `add_id` the receiver has +//! never observed an add for is **rejected**, not silently no-op — defeating the "remove an +//! element you never added" attack. Merge is the union of adds and removes, so it is +//! commutative, associative, and idempotent. +//! +//! [Metadata — Add-id Binding]: https://docs/design/metadata/#add-id-binding +//! [Collaborative Metadata]: https://docs/design/metadata/#collaborative-metadata + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A per-device, per-(asset, OR-set) monotonic add identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct AddId { + /// The issuing device (UUIDv4). + pub device: Uuid, + /// Monotonic counter, unique per `(device, asset, OR-set)`. + pub counter: u64, +} + +/// A remove targeted an `add_id` that was never observed locally as an add. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnobservedRemove; + +/// An observed-remove set of `T`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OrSet { + /// Observed adds: `add_id -> element`. + adds: BTreeMap, + /// Tombstoned `add_id`s. + removes: BTreeSet, +} + +// Manual `Default` so it does not require `T: Default` (the maps are empty regardless). +impl Default for OrSet { + fn default() -> Self { + Self::new() + } +} + +impl OrSet { + /// An empty set. + pub fn new() -> Self { + Self { + adds: BTreeMap::new(), + removes: BTreeSet::new(), + } + } + + /// Record an add of `element` under `add_id`. + pub fn add(&mut self, element: T, add_id: AddId) { + self.adds.insert(add_id, element); + } + + /// Tombstone `add_id`. Returns [`UnobservedRemove`] if no add for it was ever observed + /// locally (a fabricated remove), rather than silently no-oping. + pub fn remove(&mut self, add_id: AddId) -> Result<(), UnobservedRemove> { + if !self.adds.contains_key(&add_id) { + return Err(UnobservedRemove); + } + self.removes.insert(add_id); + Ok(()) + } + + /// Merge another replica's state: the union of adds and of removes. Commutative, + /// associative, idempotent — order of arrival does not matter. + pub fn merge(&mut self, other: &Self) { + for (id, el) in &other.adds { + self.adds.insert(*id, el.clone()); + } + self.removes.extend(other.removes.iter().copied()); + } + + /// The current logical value: every element whose `add_id` is not tombstoned. + pub fn value(&self) -> BTreeSet { + self.adds + .iter() + .filter(|(id, _)| !self.removes.contains(id)) + .map(|(_, el)| el.clone()) + .collect() + } + + /// Whether `add_id` has been observed as an add. + pub fn observed(&self, add_id: &AddId) -> bool { + self.adds.contains_key(add_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dev(n: u128) -> Uuid { + Uuid::from_u128(n) + } + fn id(device: u128, counter: u64) -> AddId { + AddId { + device: dev(device), + counter, + } + } + + #[test] + fn add_then_value() { + let mut s = OrSet::new(); + s.add("vacation".to_string(), id(1, 0)); + s.add("2026".to_string(), id(1, 1)); + let v = s.value(); + assert!(v.contains("vacation")); + assert!(v.contains("2026")); + } + + #[test] + fn remove_of_unobserved_add_id_is_rejected() { + let mut s: OrSet = OrSet::new(); + assert_eq!(s.remove(id(9, 9)), Err(UnobservedRemove)); + // After observing the add, the remove succeeds. + s.add("x".into(), id(9, 9)); + assert_eq!(s.remove(id(9, 9)), Ok(())); + assert!(s.value().is_empty()); + } + + #[test] + fn merge_converges_regardless_of_order() { + // Replica A adds "a" then removes it; replica B adds "b". Merge both directions. + let mut a: OrSet = OrSet::new(); + a.add("a".into(), id(1, 0)); + a.remove(id(1, 0)).unwrap(); + a.add("shared".into(), id(1, 1)); + + let mut b: OrSet = OrSet::new(); + b.add("b".into(), id(2, 0)); + b.add("shared".into(), id(2, 1)); // same value, different add_id + + let mut ab = a.clone(); + ab.merge(&b); + let mut ba = b.clone(); + ba.merge(&a); + + assert_eq!(ab, ba, "merge is commutative"); + assert_eq!(ab.value(), ba.value()); + let v = ab.value(); + assert!( + !v.contains("a"), + "removed element stays removed after merge" + ); + assert!(v.contains("b")); + assert!(v.contains("shared")); + } + + #[test] + fn merge_is_idempotent_and_associative() { + let mut a: OrSet = OrSet::new(); + a.add("x".into(), id(1, 0)); + let mut b: OrSet = OrSet::new(); + b.add("y".into(), id(2, 0)); + let mut c: OrSet = OrSet::new(); + c.add("z".into(), id(3, 0)); + + // (a ∪ b) ∪ c + let mut left = a.clone(); + left.merge(&b); + left.merge(&c); + // a ∪ (b ∪ c) + let mut bc = b.clone(); + bc.merge(&c); + let mut right = a.clone(); + right.merge(&bc); + assert_eq!(left.value(), right.value()); + + // Idempotent: merging the same state twice changes nothing. + let before = left.clone(); + left.merge(&b); + assert_eq!(left, before); + } + + #[test] + fn add_remove_concurrent_on_different_add_ids() { + // "shared" added independently on two devices; removing one add_id keeps the other. + let mut s: OrSet = OrSet::new(); + s.add("shared".into(), id(1, 0)); + s.add("shared".into(), id(2, 0)); + s.remove(id(1, 0)).unwrap(); + assert!(s.value().contains("shared"), "the other add keeps it alive"); + s.remove(id(2, 0)).unwrap(); + assert!(!s.value().contains("shared")); + } +} diff --git a/capsule-core/src/metadata/export_policy.rs b/capsule-core/src/metadata/export_policy.rs new file mode 100644 index 0000000..45dd258 --- /dev/null +++ b/capsule-core/src/metadata/export_policy.rs @@ -0,0 +1,146 @@ +//! Privacy on export (SSoT: [Metadata — Privacy on Export]). +//! +//! Several sidecar fields are fingerprinting surface if they leave the user's trust +//! boundary unredacted (a camera serial links every photo to one device; precise GPS +//! reveals a home address). When an asset crosses a boundary (share link, external backup +//! handed off, federated peer), Capsule strips these by default and retains them only on +//! explicit, per-export opt-in. The **local** sidecar is never modified. +//! +//! [Metadata — Privacy on Export]: https://docs/design/metadata/#privacy-on-export + +use crate::sidecar::sidecar_v1::SidecarV1; + +/// Per-export opt-ins. Defaults strip everything (the safe default). +#[derive(Debug, Clone, Copy, Default)] +pub struct ExportOptions { + /// Retain the camera serial number. + pub retain_camera_serial: bool, + /// Retain the importing device id. + pub retain_device_id: bool, + /// Retain the importing session id. + pub retain_session_id: bool, + /// Retain full-precision GPS (otherwise rounded to ~1 km). + pub retain_full_gps: bool, +} + +/// Round a coordinate to 2 decimal places (~1 km), matching the export default. +fn round_2dp(x: f64) -> f64 { + (x * 100.0).round() / 100.0 +} + +/// Produce an export copy of `sidecar` with fingerprinting fields stripped per `opts`. The +/// returned sidecar is **unsigned** (the caller re-signs for the export context); the input +/// is left untouched. +pub fn strip_for_export(sidecar: &SidecarV1, opts: &ExportOptions) -> SidecarV1 { + let mut out = sidecar.clone(); + out.signature = None; + + if !opts.retain_camera_serial + && let Some(cam) = out.camera_id.as_mut() + { + // Keep the model (not identifying); drop the per-device serial. + cam.serial.clear(); + } + if !opts.retain_device_id { + out.device_id = uuid::Uuid::nil(); + } + if !opts.retain_session_id { + out.session_id = uuid::Uuid::nil(); + } + if !opts.retain_full_gps + && let Some(gps) = out.gps.as_mut() + { + gps.lat = round_2dp(gps.lat); + gps.lon = round_2dp(gps.lon); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::hash::Hash32; + use crate::sidecar::sidecar_v1::{CameraId, Gps, GpsSource, SIDECAR_SCHEMA_V1}; + use std::collections::BTreeMap; + use uuid::Uuid; + + fn sidecar() -> SidecarV1 { + SidecarV1 { + sidecar_schema: SIDECAR_SCHEMA_V1, + crypto_suite_id: crate::crypto::CRYPTO_SUITE_ID, + uuid: Uuid::from_u128(1), + hash: Hash32([0; 32]), + capture_timestamp: "2026-05-31T10:00:00Z".into(), + import_timestamp: "2026-05-31T11:00:00Z".into(), + content_type: "image/jpeg".into(), + dimensions: None, + lqip: None, + tags_user: Default::default(), + tags_ai: Default::default(), + caption: Default::default(), + rating: Default::default(), + stack_membership: None, + camera_id: Some(CameraId { + model: "iPhone 15 Pro".into(), + serial: "SECRET-SERIAL".into(), + }), + device_id: Uuid::from_u128(0xD1), + session_id: Uuid::from_u128(0x5E), + gps: Some(Gps { + lat: 40.712812, + lon: -74.006015, + source: GpsSource::Exif, + }), + provenance_chain_hash: Hash32([0; 32]), + unknown: BTreeMap::new(), + signature: None, + } + } + + #[test] + fn default_strips_all_fingerprinting_fields() { + let s = sidecar(); + let e = strip_for_export(&s, &ExportOptions::default()); + assert_eq!(e.camera_id.as_ref().unwrap().serial, ""); + assert_eq!(e.camera_id.as_ref().unwrap().model, "iPhone 15 Pro"); // model retained + assert_eq!(e.device_id, Uuid::nil()); + assert_eq!(e.session_id, Uuid::nil()); + let gps = e.gps.unwrap(); + assert_eq!(gps.lat, 40.71); // rounded to 2dp + assert_eq!(gps.lon, -74.01); + + // Local sidecar is untouched. + assert_eq!(s.camera_id.as_ref().unwrap().serial, "SECRET-SERIAL"); + assert_eq!(s.device_id, Uuid::from_u128(0xD1)); + assert_eq!(s.gps.as_ref().unwrap().lat, 40.712812); + } + + #[test] + fn opt_in_retains_each_field() { + let s = sidecar(); + let opts = ExportOptions { + retain_camera_serial: true, + retain_device_id: true, + retain_session_id: true, + retain_full_gps: true, + }; + let e = strip_for_export(&s, &opts); + assert_eq!(e.camera_id.as_ref().unwrap().serial, "SECRET-SERIAL"); + assert_eq!(e.device_id, Uuid::from_u128(0xD1)); + assert_eq!(e.session_id, Uuid::from_u128(0x5E)); + assert_eq!(e.gps.as_ref().unwrap().lat, 40.712812); + } + + #[test] + fn partial_opt_in() { + let s = sidecar(); + let opts = ExportOptions { + retain_full_gps: true, + ..Default::default() + }; + let e = strip_for_export(&s, &opts); + // GPS retained, but device id still stripped. + assert_eq!(e.gps.as_ref().unwrap().lat, 40.712812); + assert_eq!(e.device_id, Uuid::nil()); + } +} diff --git a/capsule-core/src/metadata/mod.rs b/capsule-core/src/metadata/mod.rs index b96c9f5..ba86a85 100644 --- a/capsule-core/src/metadata/mod.rs +++ b/capsule-core/src/metadata/mod.rs @@ -3,6 +3,8 @@ use std::path::Path; use crate::constants::SIDECAR_EXTENSIONS; +pub mod crdt; +pub mod export_policy; mod file; mod filter; mod types; diff --git a/capsule-core/src/sidecar/mod.rs b/capsule-core/src/sidecar/mod.rs index cda101b..5129334 100644 --- a/capsule-core/src/sidecar/mod.rs +++ b/capsule-core/src/sidecar/mod.rs @@ -2,6 +2,7 @@ pub mod asset_sidecar; pub mod io; pub mod library_config; pub mod library_version; +pub mod sidecar_v1; pub mod stack_hint; pub use asset_sidecar::AssetSidecar; @@ -11,4 +12,5 @@ pub use io::{ }; pub use library_config::LibraryConfigCbor; pub use library_version::LibraryVersionCbor; +pub use sidecar_v1::{SIDECAR_SCHEMA_V1, SidecarV1}; pub use stack_hint::StackHint; diff --git a/capsule-core/src/sidecar/sidecar_v1.rs b/capsule-core/src/sidecar/sidecar_v1.rs new file mode 100644 index 0000000..cbe8412 --- /dev/null +++ b/capsule-core/src/sidecar/sidecar_v1.rs @@ -0,0 +1,457 @@ +//! The CBOR sidecar schema v1 — the canonical, plaintext-local-only, **signed** metadata +//! record for an asset (SSoT: [Metadata — Sidecar Schema v1]). +//! +//! It is self-describing: `sidecar_schema` is **CBOR field 0** (an integer map key, which +//! sorts before every text key in canonical order), so a reader detects a schema it does +//! not implement before parsing the rest. The signature covers every byte including the +//! preserved `_unknown` map, so stripping unknown fields invalidates it. +//! +//! [Metadata — Sidecar Schema v1]: https://docs/design/metadata/#sidecar-schema-v1 + +use std::collections::BTreeMap; + +use ciborium::value::Value; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::cbor; +use crate::crypto::hash::Hash32; +use crate::crypto::keys::{HybridSignature, HybridSigningKey, HybridVerifyingKey}; +use crate::domain::StackType; +use crate::metadata::crdt::{Lww, OrSet}; + +/// The current sidecar schema version. +pub const SIDECAR_SCHEMA_V1: u16 = 1; + +/// Pixel dimensions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Dimensions { + /// Width in pixels. + pub width: u32, + /// Height in pixels. + pub height: u32, +} + +/// Low-quality image placeholder (image-derived; lives in the encrypted sidecar). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Lqip { + /// Chromahash bytes. + #[serde(with = "serde_bytes")] + pub chromahash: Vec, + /// LQIP format version. + pub format_version: u16, + /// Dominant color (RGB). + pub dominant_color: [u8; 3], +} + +/// Camera identifier (fingerprinting surface — stripped on export by default). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CameraId { + /// Camera model. + pub model: String, + /// Per-device serial number. + pub serial: String, +} + +/// The source of a GPS fix (closed enum per protocol version). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum GpsSource { + /// From the file's EXIF. + Exif, + /// User-entered. + Manual, + /// Derived (e.g. from a nearby asset). + Derived, +} + +/// WGS-84 geolocation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Gps { + /// Latitude (WGS-84). + pub lat: f64, + /// Longitude (WGS-84). + pub lon: f64, + /// Provenance of the fix. + pub source: GpsSource, +} + +/// An AI-suggested tag (kept in a structurally separate OR-set from user tags). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct AiTag { + /// The tag text. + pub tag: String, + /// The model that produced it. + pub model_id: String, + /// The model version. + pub model_version: String, +} + +/// Role of an asset within a stack. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum StackRole { + /// The stack's representative ("best photo"). + Primary, + /// An ordinary member. + Member, + /// A proxy/optimized variant. + Proxy, +} + +/// Stack grouping for this asset (metadata-only; never touches asset bytes). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StackMembership { + /// The stack id (UUIDv7). + pub stack_id: Uuid, + /// Closed stack-type enum. + pub stack_type: StackType, + /// This asset's role in the stack. + pub role: StackRole, + /// Ordering within the stack (burst sequence, chapter index). + pub member_index: Option, +} + +/// The signed CBOR sidecar v1. +#[derive(Debug, Clone, PartialEq)] +pub struct SidecarV1 { + /// Schema version — serialized as integer **field 0** (sorts first canonically). + pub sidecar_schema: u16, + /// Matches the asset's manifest. + pub crypto_suite_id: u16, + /// Asset id (UUIDv7). + pub uuid: Uuid, + /// Canonical plaintext digest. + pub hash: Hash32, + /// RFC3339 capture time. + pub capture_timestamp: String, + /// RFC3339 import time. + pub import_timestamp: String, + /// Closed content-type string per protocol version. + pub content_type: String, + /// Pixel dimensions, if known. + pub dimensions: Option, + /// Display placeholder. + pub lqip: Option, + /// User tags (OR-set). + pub tags_user: OrSet, + /// AI tags (separate OR-set; cannot overwrite user tags). + pub tags_ai: OrSet, + /// Caption LWW register (with superseded log). + pub caption: Lww, + /// Rating LWW register. + pub rating: Lww, + /// Stack membership, if any. + pub stack_membership: Option, + /// Camera identifier (export-stripped). + pub camera_id: Option, + /// Importing device id (UUIDv4; export-stripped). + pub device_id: Uuid, + /// Importing session id (UUIDv7; export-stripped). + pub session_id: Uuid, + /// Geolocation (export-rounded). + pub gps: Option, + /// Hash of the latest provenance record for this asset. + pub provenance_chain_hash: Hash32, + /// Unknown CBOR keys preserved verbatim (re-sorted canonically; covered by signature). + pub unknown: BTreeMap, + /// Hybrid signature over every byte above (the canonical map minus this field). + pub signature: Option, +} + +fn to_value(v: &T) -> Value { + let mut buf = Vec::new(); + ciborium::ser::into_writer(v, &mut buf).expect("sidecar field serializes"); + ciborium::de::from_reader(buf.as_slice()).expect("sidecar field re-reads") +} + +fn from_value Deserialize<'de>>(v: Value) -> Result { + let mut buf = Vec::new(); + ciborium::ser::into_writer(&v, &mut buf).map_err(|e| e.to_string())?; + ciborium::de::from_reader(buf.as_slice()).map_err(|e| e.to_string()) +} + +impl SidecarV1 { + /// Build the CBOR map (as ordered entries) with `sidecar_schema` at integer key 0 and + /// every other field under a text key. If `include_signature`, the `signature` entry is + /// appended (used for the full record; excluded for signing bytes). + fn to_entries(&self, include_signature: bool) -> Vec<(Value, Value)> { + let mut m: Vec<(Value, Value)> = Vec::new(); + // Field 0: schema version (integer key — sorts before all text keys). + m.push(( + Value::Integer(0u8.into()), + Value::Integer(self.sidecar_schema.into()), + )); + + macro_rules! put { + ($k:literal, $v:expr) => { + m.push((Value::Text($k.to_string()), to_value(&$v))); + }; + } + macro_rules! put_opt { + ($k:literal, $v:expr) => { + if let Some(inner) = &$v { + m.push((Value::Text($k.to_string()), to_value(inner))); + } + }; + } + put!("crypto_suite_id", self.crypto_suite_id); + put!("uuid", self.uuid); + put!("hash", self.hash); + put!("capture_timestamp", self.capture_timestamp); + put!("import_timestamp", self.import_timestamp); + put!("content_type", self.content_type); + put_opt!("dimensions", self.dimensions); + put_opt!("lqip", self.lqip); + put!("tags_user", self.tags_user); + put!("tags_ai", self.tags_ai); + put!("caption", self.caption); + put!("rating", self.rating); + put_opt!("stack_membership", self.stack_membership); + put_opt!("camera_id", self.camera_id); + put!("device_id", self.device_id); + put!("session_id", self.session_id); + put_opt!("gps", self.gps); + put!("provenance_chain_hash", self.provenance_chain_hash); + + // Merge preserved unknown fields (canonical encode re-sorts everything). + for (k, v) in &self.unknown { + m.push((Value::Text(k.clone()), v.clone())); + } + if include_signature && let Some(sig) = &self.signature { + m.push((Value::Text("signature".to_string()), to_value(sig))); + } + m + } + + /// The canonical bytes the signature covers (everything except `signature`). + pub fn signing_bytes(&self) -> Vec { + cbor::value_to_canonical_vec(&Value::Map(self.to_entries(false))) + } + + /// The full canonical CBOR encoding (including the signature, if present). + pub fn to_canonical_vec(&self) -> Vec { + cbor::value_to_canonical_vec(&Value::Map(self.to_entries(true))) + } + + /// Sign the sidecar with the user IK, setting `signature`. + pub fn sign(&mut self, ik: &HybridSigningKey) { + self.signature = Some(ik.sign(&self.signing_bytes())); + } + + /// Verify the sidecar's signature against the user IK public key. + pub fn verify(&self, ik_public: &HybridVerifyingKey) -> bool { + match &self.signature { + Some(sig) => ik_public.verify(&self.signing_bytes(), sig), + None => false, + } + } + + /// Decode a sidecar from canonical CBOR bytes, refusing a schema newer than `max_known` + /// ([Schema Versioning Rules]: an old client must not strip-and-resign a newer sidecar). + /// + /// [Schema Versioning Rules]: https://docs/design/metadata/#schema-versioning-rules + pub fn from_canonical_slice(bytes: &[u8], max_known: u16) -> Result { + let value: Value = + ciborium::de::from_reader(bytes).map_err(|e| format!("cbor decode: {e}"))?; + let entries = match value { + Value::Map(m) => m, + _ => return Err("sidecar must be a CBOR map".into()), + }; + + let mut schema: Option = None; + let mut text: BTreeMap = BTreeMap::new(); + for (k, v) in entries { + match k { + Value::Integer(i) if i128::from(i) == 0 => { + schema = Some(from_value::(v)?); + } + Value::Text(key) => { + text.insert(key, v); + } + _ => return Err("unexpected sidecar map key".into()), + } + } + let sidecar_schema = schema.ok_or("missing sidecar_schema (field 0)")?; + if sidecar_schema > max_known { + return Err(format!( + "sidecar_schema {sidecar_schema} newer than max known {max_known}; refusing" + )); + } + + macro_rules! req { + ($k:literal, $t:ty) => { + from_value::<$t>(text.remove($k).ok_or(concat!("missing field: ", $k))?)? + }; + } + macro_rules! opt { + ($k:literal, $t:ty) => { + match text.remove($k) { + None | Some(Value::Null) => None, + Some(v) => Some(from_value::<$t>(v)?), + } + }; + } + + let crypto_suite_id = req!("crypto_suite_id", u16); + let uuid = req!("uuid", Uuid); + let hash = req!("hash", Hash32); + let capture_timestamp = req!("capture_timestamp", String); + let import_timestamp = req!("import_timestamp", String); + let content_type = req!("content_type", String); + let dimensions = opt!("dimensions", Dimensions); + let lqip = opt!("lqip", Lqip); + let tags_user = req!("tags_user", OrSet); + let tags_ai = req!("tags_ai", OrSet); + let caption = req!("caption", Lww); + let rating = req!("rating", Lww); + let stack_membership = opt!("stack_membership", StackMembership); + let camera_id = opt!("camera_id", CameraId); + let device_id = req!("device_id", Uuid); + let session_id = req!("session_id", Uuid); + let gps = opt!("gps", Gps); + let provenance_chain_hash = req!("provenance_chain_hash", Hash32); + let signature = opt!("signature", HybridSignature); + + Ok(SidecarV1 { + sidecar_schema, + crypto_suite_id, + uuid, + hash, + capture_timestamp, + import_timestamp, + content_type, + dimensions, + lqip, + tags_user, + tags_ai, + caption, + rating, + stack_membership, + camera_id, + device_id, + session_id, + gps, + provenance_chain_hash, + unknown: text, // whatever remains is unknown — preserved verbatim + signature, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::primitives::CRYPTO_SUITE_ID; + + fn minimal() -> SidecarV1 { + SidecarV1 { + sidecar_schema: SIDECAR_SCHEMA_V1, + crypto_suite_id: CRYPTO_SUITE_ID, + uuid: Uuid::from_u128(0x7777), + hash: Hash32([0xAB; 32]), + capture_timestamp: "2026-05-31T10:00:00Z".into(), + import_timestamp: "2026-05-31T11:00:00Z".into(), + content_type: "image/jpeg".into(), + dimensions: Some(Dimensions { + width: 4032, + height: 3024, + }), + lqip: None, + tags_user: OrSet::new(), + tags_ai: OrSet::new(), + caption: Lww::new(), + rating: Lww::new(), + stack_membership: None, + camera_id: Some(CameraId { + model: "iPhone 15 Pro".into(), + serial: "ABC123".into(), + }), + device_id: Uuid::from_u128(0xD1), + session_id: Uuid::from_u128(0x5E), + gps: Some(Gps { + lat: 40.7128, + lon: -74.0060, + source: GpsSource::Exif, + }), + provenance_chain_hash: Hash32([0xCC; 32]), + unknown: BTreeMap::new(), + signature: None, + } + } + + #[test] + fn schema_is_field_zero_canonically_first() { + let s = minimal(); + let bytes = s.to_canonical_vec(); + // map head (0xAX for small maps) then the first key must be integer 0 (0x00), + // then its value (schema = 1 → 0x01). + assert!( + (0xa0..=0xbb).contains(&bytes[0]), + "expected a CBOR map head" + ); + assert_eq!(bytes[1], 0x00, "first map key must be integer 0"); + assert_eq!(bytes[2], 0x01, "schema value 1"); + } + + #[test] + fn sign_verify_round_trip_through_canonical_cbor() { + let ik = HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]); + let mut s = minimal(); + s.sign(&ik); + assert!(s.verify(&ik.verifying_key())); + + let bytes = s.to_canonical_vec(); + let back = SidecarV1::from_canonical_slice(&bytes, SIDECAR_SCHEMA_V1).unwrap(); + assert_eq!(back, s); + assert!(back.verify(&ik.verifying_key())); + } + + #[test] + fn tampering_after_signing_breaks_verification() { + let ik = HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]); + let mut s = minimal(); + s.sign(&ik); + s.content_type = "image/heic".into(); // change a field without re-signing + assert!(!s.verify(&ik.verifying_key())); + } + + #[test] + fn unknown_fields_are_preserved_and_signed() { + let ik = HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]); + let mut s = minimal(); + s.unknown + .insert("future_field".into(), Value::Text("future_value".into())); + s.sign(&ik); + + let bytes = s.to_canonical_vec(); + let back = SidecarV1::from_canonical_slice(&bytes, SIDECAR_SCHEMA_V1).unwrap(); + assert_eq!( + back.unknown.get("future_field"), + Some(&Value::Text("future_value".into())) + ); + // Signature still verifies (it covered the unknown field)... + assert!(back.verify(&ik.verifying_key())); + // ...and stripping the unknown field invalidates the signature. + let mut stripped = back.clone(); + stripped.unknown.clear(); + assert!(!stripped.verify(&ik.verifying_key())); + } + + #[test] + fn refuses_a_schema_newer_than_known() { + let mut s = minimal(); + s.sidecar_schema = 99; + let bytes = s.to_canonical_vec(); + // A client whose max known schema is 1 refuses to read schema 99. + assert!(SidecarV1::from_canonical_slice(&bytes, 1).is_err()); + // A future client that knows schema 99 reads it. + assert!(SidecarV1::from_canonical_slice(&bytes, 99).is_ok()); + } + + #[test] + fn canonical_encoding_is_deterministic() { + let s = minimal(); + assert_eq!(s.to_canonical_vec(), s.to_canonical_vec()); + } +} diff --git a/capsule-core/src/utils/hash.rs b/capsule-core/src/utils/hash.rs index b47e12f..29b7b22 100644 --- a/capsule-core/src/utils/hash.rs +++ b/capsule-core/src/utils/hash.rs @@ -1,15 +1,17 @@ -use std::{fs, io, path::Path}; +use std::{fs::File, io, path::Path}; -use sha2::{Digest, Sha256}; +use crate::crypto::hash::{hash_bytes as hash32_bytes, hash_reader}; /// SHA-256 hash of a byte slice as a 64-char lowercase hex string. pub fn hash_bytes(bytes: &[u8]) -> String { - hex::encode(Sha256::digest(bytes)) + hash32_bytes(bytes).to_hex() } -/// Get SHA-256 hash of a file as a 64-char lowercase hex string. -// TODO: switch to streaming version for large files +/// SHA-256 hash of a file as a 64-char lowercase hex string. +/// +/// Streams the file in fixed blocks via [`crate::crypto::hash`] rather than reading the +/// whole file into memory, so arbitrarily large originals hash with bounded memory. pub fn get_file_hash(path: &Path) -> io::Result { - let bytes = fs::read(path)?; - Ok(hash_bytes(&bytes)) + let file = File::open(path)?; + Ok(hash_reader(io::BufReader::new(file))?.to_hex()) } diff --git a/capsule-core/src/validation/idempotency.rs b/capsule-core/src/validation/idempotency.rs new file mode 100644 index 0000000..a230f71 --- /dev/null +++ b/capsule-core/src/validation/idempotency.rs @@ -0,0 +1,83 @@ +//! Idempotency keys for write surfaces (SSoT: [Threat Model — Idempotency Invariants]). +//! Every write surface has a single idempotency key: a duplicate (same key) is a no-op; a +//! conflict (same key, different content) is a corruption error. These constructors produce +//! a stable canonical key so a server can dedup deterministically. +//! +//! [Threat Model — Idempotency Invariants]: https://docs/design/threat-model/validation/#idempotency-invariants + +use uuid::Uuid; + +use crate::crypto::hash::Hash32; + +/// A stable idempotency key (a canonical string a server can index). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct IdempotencyKey(pub String); + +/// `(owner_id, hash, album_id)` — session creation (`POST /upload`) dedup. +pub fn session_key(owner_id: &Uuid, hash: &Hash32, album_id: &Uuid) -> IdempotencyKey { + IdempotencyKey(format!("session:{owner_id}:{}:{album_id}", hash.to_hex())) +} + +/// `(asset_id, prior_provenance_hash, manifest_hash)` — lifecycle manifest write. +pub fn lifecycle_key( + asset_id: &Uuid, + prior: Option, + manifest_hash: &Hash32, +) -> IdempotencyKey { + let prior = prior.map(|h| h.to_hex()).unwrap_or_else(|| "null".into()); + IdempotencyKey(format!( + "lifecycle:{asset_id}:{prior}:{}", + manifest_hash.to_hex() + )) +} + +/// `(upload_id, offset, chunk_hash)` — upload chunk (`PATCH /upload/{id}`). +pub fn chunk_key(upload_id: &Uuid, offset: u64, chunk_hash: &Hash32) -> IdempotencyKey { + IdempotencyKey(format!( + "chunk:{upload_id}:{offset}:{}", + chunk_hash.to_hex() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn same_inputs_produce_the_same_key() { + let owner = Uuid::from_u128(1); + let album = Uuid::from_u128(2); + let h = Hash32([7; 32]); + assert_eq!( + session_key(&owner, &h, &album), + session_key(&owner, &h, &album) + ); + } + + #[test] + fn different_content_produces_a_different_key() { + // Same (asset, prior) but a different manifest hash → a *conflict*, distinguishable + // by the key differing (server treats same-key/different-content as corruption). + let asset = Uuid::from_u128(1); + let prior = Some(Hash32([1; 32])); + let a = lifecycle_key(&asset, prior, &Hash32([2; 32])); + let b = lifecycle_key(&asset, prior, &Hash32([3; 32])); + assert_ne!(a, b); + } + + #[test] + fn null_prior_is_distinct_from_zero_hash() { + let asset = Uuid::from_u128(1); + let mh = Hash32([9; 32]); + let create = lifecycle_key(&asset, None, &mh); + let zero = lifecycle_key(&asset, Some(Hash32([0; 32])), &mh); + assert_ne!(create, zero); + } + + #[test] + fn chunk_key_varies_by_offset() { + let up = Uuid::from_u128(1); + let h = Hash32([5; 32]); + assert_ne!(chunk_key(&up, 0, &h), chunk_key(&up, 4096, &h)); + } +} diff --git a/capsule-core/src/validation/mod.rs b/capsule-core/src/validation/mod.rs new file mode 100644 index 0000000..9c1d40f --- /dev/null +++ b/capsule-core/src/validation/mod.rs @@ -0,0 +1,20 @@ +//! Refuse-by-default validation invariants — the operational core of the threat model +//! (SSoT: [Threat Model — Validation Invariants]). +//! +//! These are **pure, key-less** structural checks: the protocol/capability handshake +//! ([`protocol`]), the server-side manifest envelope ([`structural`]), and idempotency +//! keys ([`idempotency`]). They are reusable by the (deferred) server write paths and +//! mirror the client-side checks in [`verify_asset`](crate::crypto::verify_asset). +//! +//! Upload-transport-specific invariants (chunk offset/4 KiB alignment, cumulative size) +//! live with the deferred upload protocol; the chunk idempotency key is provided here. +//! +//! [Threat Model — Validation Invariants]: https://docs/design/threat-model/validation/ + +pub mod idempotency; +pub mod protocol; +pub mod structural; + +pub use idempotency::IdempotencyKey; +pub use protocol::{HandshakeReject, protocol_gate}; +pub use structural::{EnvelopeContext, EnvelopeReject, check_manifest_envelope}; diff --git a/capsule-core/src/validation/protocol.rs b/capsule-core/src/validation/protocol.rs new file mode 100644 index 0000000..0c2875f --- /dev/null +++ b/capsule-core/src/validation/protocol.rs @@ -0,0 +1,122 @@ +//! The universal, fail-closed protocol & capability handshake (SSoT: [Threat Model — +//! Protocol and Capability Negotiation]). Every versioned surface runs this one-shot +//! pre-flight before any state is written; a mismatch is a hard reject, never a degrade. +//! +//! [Threat Model — Protocol and Capability Negotiation]: https://docs/design/threat-model/validation/#protocol-and-capability-negotiation + +use crate::crypto::primitives::SuiteId; + +/// A handshake rejection. Each maps to a fail-closed rule and an HTTP status. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HandshakeReject { + /// `X-Capsule-Protocol` outside the server's `[Min, Max]` window → `426 Upgrade Required`. + ProtocolOutOfRange, + /// `X-Capsule-Protocol` not a `YYYY-MM-DD` date → `400`. + ProtocolMalformed, + /// `crypto_suite_id` not in the inventory → `400`. + UnknownSuite, + /// `sidecar_schema` above the receiver's max known → `400`. + SidecarSchemaTooNew, +} + +impl HandshakeReject { + /// The HTTP status code a server returns for this rejection. + pub fn http_status(self) -> u16 { + match self { + HandshakeReject::ProtocolOutOfRange => 426, + _ => 400, + } + } +} + +/// True if `v` is a well-formed `YYYY-MM-DD` date (the only grammar `protocol_version` +/// accepts). Lexicographic comparison of valid values equals chronological order. +fn is_date(v: &str) -> bool { + let b = v.as_bytes(); + b.len() == 10 + && b[4] == b'-' + && b[7] == b'-' + && b[..4].iter().all(u8::is_ascii_digit) + && b[5..7].iter().all(u8::is_ascii_digit) + && b[8..].iter().all(u8::is_ascii_digit) +} + +/// Gate a request's `protocol_version` against the server-advertised `[min, max]` window. +/// Reads succeed for any past version (callers skip this on read paths); this is the write +/// gate. +pub fn protocol_gate(client: &str, min: &str, max: &str) -> Result<(), HandshakeReject> { + if !is_date(client) { + return Err(HandshakeReject::ProtocolMalformed); + } + // For YYYY-MM-DD, bytewise/lexicographic order is chronological order. + if client < min || client > max { + return Err(HandshakeReject::ProtocolOutOfRange); + } + Ok(()) +} + +/// Reject a `crypto_suite_id` the receiver does not implement (invariant 2). +pub fn check_suite(suite_id: u16) -> Result<(), HandshakeReject> { + SuiteId::from_u16(suite_id) + .map(|_| ()) + .ok_or(HandshakeReject::UnknownSuite) +} + +/// Reject a `sidecar_schema` above the receiver's max known (Postel cross-version closure). +pub fn check_sidecar_schema(schema: u16, max_known: u16) -> Result<(), HandshakeReject> { + if schema > max_known { + Err(HandshakeReject::SidecarSchemaTooNew) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::CRYPTO_SUITE_ID; + + #[test] + fn protocol_in_range_accepts() { + assert!(protocol_gate("2026-05-31", "2026-01-01", "2026-12-31").is_ok()); + // Boundaries are inclusive. + assert!(protocol_gate("2026-01-01", "2026-01-01", "2026-12-31").is_ok()); + assert!(protocol_gate("2026-12-31", "2026-01-01", "2026-12-31").is_ok()); + } + + #[test] + fn protocol_out_of_range_is_426() { + let below = protocol_gate("2025-12-31", "2026-01-01", "2026-12-31"); + let above = protocol_gate("2027-01-01", "2026-01-01", "2026-12-31"); + assert_eq!(below, Err(HandshakeReject::ProtocolOutOfRange)); + assert_eq!(above, Err(HandshakeReject::ProtocolOutOfRange)); + assert_eq!(below.unwrap_err().http_status(), 426); + } + + #[test] + fn malformed_protocol_is_400() { + for bad in ["2026/05/31", "v1", "2026-5-31", "", "2026-05-31T00:00:00Z"] { + assert_eq!( + protocol_gate(bad, "2026-01-01", "2026-12-31"), + Err(HandshakeReject::ProtocolMalformed) + ); + } + } + + #[test] + fn suite_inventory_check() { + assert!(check_suite(CRYPTO_SUITE_ID).is_ok()); + assert_eq!(check_suite(0x9999), Err(HandshakeReject::UnknownSuite)); + assert_eq!(check_suite(0x9999).unwrap_err().http_status(), 400); + } + + #[test] + fn sidecar_schema_too_new_rejected() { + assert!(check_sidecar_schema(1, 1).is_ok()); + assert!(check_sidecar_schema(1, 2).is_ok()); + assert_eq!( + check_sidecar_schema(3, 2), + Err(HandshakeReject::SidecarSchemaTooNew) + ); + } +} diff --git a/capsule-core/src/validation/structural.rs b/capsule-core/src/validation/structural.rs new file mode 100644 index 0000000..7a1072c --- /dev/null +++ b/capsule-core/src/validation/structural.rs @@ -0,0 +1,301 @@ +//! The key-less structural envelope checks a server runs before persisting any write +//! (SSoT: [Threat Model — Server-Side Validation Invariants]). The server holds no keys, +//! so it cannot verify signatures — but it validates *structure* refuse-by-default. These +//! are pure predicates over the manifest core and server-known state; the client mirrors +//! them via [`verify_asset`](crate::crypto::verify_asset). +//! +//! [Threat Model — Server-Side Validation Invariants]: https://docs/design/threat-model/validation/#server-side-validation-invariants + +use crate::crypto::hash::Hash32; +use crate::crypto::primitives::SuiteId; +use crate::crypto::provenance::ManifestCore; + +/// Invariant 3: the declared content hash length matches the digest size for the suite. +/// (Type-level true for [`Hash32`], but checked explicitly for wire-decoded lengths.) +pub fn hash_length_ok(suite_id: u16, hash_len: usize) -> bool { + SuiteId::from_u16(suite_id).is_some_and(|s| s.hash_len() == hash_len) +} + +/// Invariant 4: declared size is in `(0, max_file_size]`. +pub fn size_in_bounds(size: u64, max_file_size: u64) -> bool { + size > 0 && size <= max_file_size +} + +/// Invariant 5: `content_type` is in the closed allow-list for this protocol version. +pub fn content_type_allowed(content_type: &str, allowed: &[&str]) -> bool { + allowed.contains(&content_type) +} + +/// Part of invariant 6: the album's pinned `protocol_version` equals the request's. +pub fn album_pin_matches(request_protocol: &str, album_pin: &str) -> bool { + request_protocol == album_pin +} + +/// Invariant 7 (time half): the device's directory `added_at` precedes the manifest +/// `timestamp`. `None` on an unparseable timestamp (the caller rejects with `400`). +pub fn device_added_before(added_at: &str, timestamp: &str) -> Option { + let a = chrono::DateTime::parse_from_rfc3339(added_at).ok()?; + let t = chrono::DateTime::parse_from_rfc3339(timestamp).ok()?; + Some(a <= t) +} + +/// Invariant 8: the self-asserted `timestamp` is within `±drift_days` of the server clock. +/// A non-security gross-drift guard. `None` on an unparseable timestamp. +pub fn timestamp_within_drift( + timestamp: &str, + server_clock: &str, + drift_days: i64, +) -> Option { + let t = chrono::DateTime::parse_from_rfc3339(timestamp).ok()?; + let now = chrono::DateTime::parse_from_rfc3339(server_clock).ok()?; + let delta = (t - now).num_days().abs(); + Some(delta <= drift_days) +} + +/// Invariant 17: `prior_provenance_hash` equals the last accepted manifest's hash for this +/// asset. `stored_head` is `None` for a never-seen asset (only a `create` is valid then). +pub fn prior_hash_matches( + prior: Option, + stored_head: Option, + is_create: bool, +) -> bool { + if is_create { + prior.is_none() && stored_head.is_none() + } else { + prior.is_some() && prior == stored_head + } +} + +/// Invariant 18: `amk_version` never regresses for an album (server's structural backstop; +/// MLS is the authority on the ceiling — see [`verify_asset`](crate::crypto::verify_asset)). +pub fn amk_version_monotonic(new: u32, stored: Option) -> bool { + match stored { + None => true, + Some(prev) => new >= prev, + } +} + +/// A keyless envelope decision over a manifest core plus the server-known context. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnvelopeReject { + /// `crypto_suite_id` unknown (invariant 2). + UnknownSuite, + /// Album pin mismatch (invariant 6). + AlbumPinMismatch, + /// Device `added_at` postdates the manifest timestamp (invariant 7). + DeviceAddedAfter, + /// Timestamp unparseable or beyond the drift bound (invariant 8). + TimestampUnsane, + /// `prior_provenance_hash` does not match the stored chain head (invariant 17). + StaleChain, + /// `amk_version` regressed (invariant 18). + AmkRegressed, +} + +/// Context a key-less server holds when validating a non-upload lifecycle manifest. +pub struct EnvelopeContext<'a> { + /// The album's immutable protocol pin. + pub album_pin: &'a str, + /// The device's `added_at` from the published directory. + pub device_added_at: &'a str, + /// The server's trusted clock (RFC3339). + pub server_clock: &'a str, + /// Allowed timestamp drift in days. + pub drift_days: i64, + /// The last accepted provenance head for this asset. + pub stored_chain_head: Option, + /// The last accepted `amk_version` for this album. + pub stored_amk_version: Option, +} + +/// Run the keyless envelope checks (2, 6, 7, 8, 17, 18) over a lifecycle manifest core. +/// Returns the first invariant violated, or `Ok(())`. +pub fn check_manifest_envelope( + core: &ManifestCore, + ctx: &EnvelopeContext<'_>, +) -> Result<(), EnvelopeReject> { + if SuiteId::from_u16(core.crypto_suite_id).is_none() { + return Err(EnvelopeReject::UnknownSuite); + } + if !album_pin_matches(&core.protocol_version, ctx.album_pin) { + return Err(EnvelopeReject::AlbumPinMismatch); + } + match device_added_before(ctx.device_added_at, &core.timestamp) { + None => return Err(EnvelopeReject::TimestampUnsane), + Some(false) => return Err(EnvelopeReject::DeviceAddedAfter), + Some(true) => {} + } + match timestamp_within_drift(&core.timestamp, ctx.server_clock, ctx.drift_days) { + None | Some(false) => return Err(EnvelopeReject::TimestampUnsane), + Some(true) => {} + } + if !prior_hash_matches( + core.prior_provenance_hash, + ctx.stored_chain_head, + core.action.is_create(), + ) { + return Err(EnvelopeReject::StaleChain); + } + if !amk_version_monotonic(core.amk_version.0, ctx.stored_amk_version) { + return Err(EnvelopeReject::AmkRegressed); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::CRYPTO_SUITE_ID; + use crate::crypto::keys::{AmkVersion, HybridSigningKey}; + use crate::crypto::primitives::PROTOCOL_VERSION; + use crate::crypto::provenance::action::Action; + use crate::crypto::provenance::manifest::{ASSET_MANIFEST_VERSION, ManifestCore}; + use uuid::Uuid; + + #[test] + fn hash_length_and_size_and_content_type() { + assert!(hash_length_ok(CRYPTO_SUITE_ID, 32)); + assert!(!hash_length_ok(CRYPTO_SUITE_ID, 31)); + assert!(!hash_length_ok(0x9999, 32)); + + assert!(size_in_bounds(1, 100)); + assert!(size_in_bounds(100, 100)); + assert!(!size_in_bounds(0, 100)); + assert!(!size_in_bounds(101, 100)); + + let allowed = ["image/jpeg", "image/heic"]; + assert!(content_type_allowed("image/jpeg", &allowed)); + assert!(!content_type_allowed("application/x-evil", &allowed)); + } + + #[test] + fn timestamp_and_added_at_rules() { + assert_eq!( + device_added_before("2026-05-30T00:00:00Z", "2026-05-31T00:00:00Z"), + Some(true) + ); + assert_eq!( + device_added_before("2026-06-01T00:00:00Z", "2026-05-31T00:00:00Z"), + Some(false) + ); + assert_eq!(device_added_before("bogus", "2026-05-31T00:00:00Z"), None); + + assert_eq!( + timestamp_within_drift("2026-05-31T00:00:00Z", "2026-05-31T00:00:00Z", 30), + Some(true) + ); + assert_eq!( + timestamp_within_drift("2026-01-01T00:00:00Z", "2026-05-31T00:00:00Z", 30), + Some(false) + ); + } + + #[test] + fn prior_hash_and_amk_monotonic() { + let h = Hash32([1; 32]); + // create: prior None, no stored head. + assert!(prior_hash_matches(None, None, true)); + assert!(!prior_hash_matches(Some(h), None, true)); + // non-create: prior must equal stored head. + assert!(prior_hash_matches(Some(h), Some(h), false)); + assert!(!prior_hash_matches(Some(h), Some(Hash32([2; 32])), false)); + assert!(!prior_hash_matches(None, Some(h), false)); + + assert!(amk_version_monotonic(1, None)); + assert!(amk_version_monotonic(3, Some(2))); + assert!(amk_version_monotonic(2, Some(2))); + assert!(!amk_version_monotonic(1, Some(2))); + } + + fn core(action: Action, prior: Option, amk: u32) -> ManifestCore { + let dev = HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]); + let wt = HybridSigningKey::from_seed_bytes(&[3; 32], &[4; 32]); + let c = ManifestCore { + version: ASSET_MANIFEST_VERSION.into(), + crypto_suite_id: CRYPTO_SUITE_ID, + protocol_version: PROTOCOL_VERSION.into(), + file_id: Uuid::from_u128(1), + album_id: Uuid::from_u128(2), + amk_version: AmkVersion(amk), + ciphertext_hash: Hash32([0; 32]), + plaintext_size: 10, + chunk_size: 65_520, + nonce_prefix: [0; 7], + created_by_user: Uuid::from_u128(3), + created_by_device: Uuid::from_u128(4), + client_version: "t".into(), + timestamp: "2026-05-31T00:00:00Z".into(), + action, + prior_provenance_hash: prior, + retention_until: None, + }; + let _ = c.clone().sign(&dev, &wt); // ensure it's a well-formed signable core + c + } + + fn ctx<'a>(head: Option, amk: Option) -> EnvelopeContext<'a> { + EnvelopeContext { + album_pin: PROTOCOL_VERSION, + device_added_at: "2026-05-30T00:00:00Z", + server_clock: "2026-05-31T01:00:00Z", + drift_days: 30, + stored_chain_head: head, + stored_amk_version: amk, + } + } + + #[test] + fn envelope_accepts_valid_create_and_update() { + let c = core(Action::Create, None, 1); + assert_eq!(check_manifest_envelope(&c, &ctx(None, None)), Ok(())); + + let head = Hash32([9; 32]); + let u = core(Action::MetadataUpdate, Some(head), 1); + assert_eq!( + check_manifest_envelope(&u, &ctx(Some(head), Some(1))), + Ok(()) + ); + } + + #[test] + fn envelope_rejects_each_invariant() { + // Album pin mismatch. + let mut c = core(Action::Create, None, 1); + c.protocol_version = "1999-01-01".into(); + assert_eq!( + check_manifest_envelope(&c, &ctx(None, None)), + Err(EnvelopeReject::AlbumPinMismatch) + ); + + // Unknown suite. + let mut c = core(Action::Create, None, 1); + c.crypto_suite_id = 0x9999; + assert_eq!( + check_manifest_envelope(&c, &ctx(None, None)), + Err(EnvelopeReject::UnknownSuite) + ); + + // Stale chain (update whose prior != stored head). + let u = core(Action::MetadataUpdate, Some(Hash32([1; 32])), 1); + assert_eq!( + check_manifest_envelope(&u, &ctx(Some(Hash32([2; 32])), Some(1))), + Err(EnvelopeReject::StaleChain) + ); + + // AMK regression. + let u = core(Action::MetadataUpdate, Some(Hash32([2; 32])), 1); + assert_eq!( + check_manifest_envelope(&u, &ctx(Some(Hash32([2; 32])), Some(5))), + Err(EnvelopeReject::AmkRegressed) + ); + + // Device added after the manifest timestamp. + let c = core(Action::Create, None, 1); + let mut bad = ctx(None, None); + bad.device_added_at = "2027-01-01T00:00:00Z"; + assert_eq!( + check_manifest_envelope(&c, &bad), + Err(EnvelopeReject::DeviceAddedAfter) + ); + } +}