diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..43d439c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1263 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", +] + +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", + "zeroize", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer", + "crypto-common", + "inout", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", + "rand_core", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "crypto-common", + "ctutils", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "ctutils", + "typenum", + "zeroize", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common", + "rand_core", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[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", + "rand_core", + "sha3", + "zeroize", +] + +[[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", + "zeroize", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "phc", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common", + "ctutils", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "vault" +version = "0.6.0" +dependencies = [ + "aes", + "argon2", + "base64", + "chacha20poly1305", + "flate2", + "getrandom 0.4.3", + "hkdf", + "hmac", + "jiff", + "ml-kem", + "rustix", + "serde", + "serde_json", + "sha2", + "sha3", + "tar", + "ureq", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..11c2565 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "vault" +version = "0.6.0" +edition = "2024" +rust-version = "1.99" +license = "GPL-3.0-or-later" +description = "Post-quantum encrypted file vault with a decoy passphrase" +homepage = "https://dixon.cx/vault" +repository = "https://github.com/andydixon/vault" +publish = false + +[dependencies] +argon2 = { version = "0.6", default-features = false, features = ["alloc", "zeroize"] } +chacha20poly1305 = { version = "0.11", default-features = false, features = ["alloc"] } +hkdf = "0.13" +hmac = "0.13" +sha2 = { version = "0.11", default-features = false } +sha3 = { version = "0.11", default-features = false } +ml-kem = { version = "0.3.2", features = ["zeroize", "hazmat"] } +zeroize = { version = "1.9", features = ["derive"] } +getrandom = "0.4" +base64 = "0.23" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +jiff = "0.2" +tar = { version = "0.4.46", default-features = false } +flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] } +ureq = { version = "3.4", default-features = false, features = ["rustls", "platform-verifier", "gzip"] } + +[dev-dependencies] +aes = "0.9" + +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1.5", features = ["fs", "process", "mm", "termios", "event", "net", "stdio", "param"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_Memory", "Win32_Security"] } diff --git a/oracle/go/internal/pqcrypt/oracle_decrypt_test.go b/oracle/go/internal/pqcrypt/oracle_decrypt_test.go new file mode 100644 index 0000000..638dc9f --- /dev/null +++ b/oracle/go/internal/pqcrypt/oracle_decrypt_test.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Test-only: decrypts files another implementation wrote, for the Rust +// port's interoperability tests. Skips unless VAULT_ORACLE_DECRYPT is set. + +package pqcrypt + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// TestOracleDecrypt opens every DIR/pattern-SIZE.fury with the identity of +// master seed pattern(32, 1) and checks it holds pattern(SIZE, 0x5a), and +// that every DIR/bad-*.fury is rejected. +func TestOracleDecrypt(t *testing.T) { + dir := os.Getenv("VAULT_ORACLE_DECRYPT") + if dir == "" { + t.Skip("set VAULT_ORACLE_DECRYPT") + } + id, err := NewPQIdentityFromSeed(0x0001, pattern(32, 1)) + if err != nil { + t.Fatal(err) + } + files, _ := filepath.Glob(filepath.Join(dir, "*.fury")) + if len(files) == 0 { + t.Fatal("no files") + } + for _, f := range files { + data, _ := os.ReadFile(f) + r, err := Decrypt(bytes.NewReader(data), id) + var got []byte + if err == nil { + got, err = io.ReadAll(r) + } + base := filepath.Base(f) + if strings.HasPrefix(base, "bad-") { + if err == nil { + t.Errorf("%s: accepted", base) + } + continue + } + size, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(base, "pattern-"), ".fury")) + if err != nil || !bytes.Equal(got, pattern(size, 0x5a)) { + t.Errorf("%s: %v", base, err) + } + t.Logf("%s ok", base) + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..5a341ac --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.99.0" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..8449be0 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +max_width = 140 diff --git a/src/crypto/frodo.rs b/src/crypto/frodo.rs new file mode 100644 index 0000000..1cba7c2 --- /dev/null +++ b/src/crypto/frodo.rs @@ -0,0 +1,497 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! FrodoKEM-640-SHAKE exactly as CIRCL v1.6.4 implements it +//! (`kem/frodo/frodo640shake`): the round-3 FrodoKEM with a 48-byte key +//! seed, 16-byte encapsulation seed, 16-byte shared secret and 9720-byte +//! ciphertext. The RustCrypto `frodo-kem` crate implements the later ISO +//! variant (salted, 9752-byte ciphertexts, a different seed layout), which +//! cannot open existing vaults, so this module is a line-by-line port of +//! CIRCL's code, checked against vectors from the Go oracle and against the +//! NIST KAT digest CIRCL itself tests. +//! +//! All arithmetic is mod 2^16 with masking to 15 bits where CIRCL masks; +//! loops never branch on secret data, and the decapsulation re-encryption +//! check selects the rejection secret with a constant-time mask. + +use sha3::Shake128; +use sha3::digest::{ExtendableOutput, Update, XofReader}; +use zeroize::Zeroize; + +const N: usize = 640; +const NBAR: usize = 8; +const LOG_Q: u32 = 15; +const Q_MASK: u16 = (1 << LOG_Q) - 1; +const SEED_A: usize = 16; +const PK_HASH: usize = 16; +const EXTRACTED_BITS: u32 = 2; +const MESSAGE: usize = 16; +const BP_PACKED: usize = (LOG_Q as usize * N * NBAR) / 8; + +pub const SHARED_KEY_SIZE: usize = 16; +pub const KEY_SEED_SIZE: usize = SHARED_KEY_SIZE + SHARED_KEY_SIZE + 16; +pub const ENCAPSULATION_SEED_SIZE: usize = 16; +pub const CIPHERTEXT_SIZE: usize = 9720; +pub const PUBLIC_KEY_SIZE: usize = 9616; +pub const PRIVATE_KEY_SIZE: usize = 19888; + +const CDF: [u16; 13] = [ + 4643, 13363, 20579, 25843, 29227, 31145, 32103, 32525, 32689, 32745, 32762, 32766, 32767, +]; + +/// A packed public key (`seedA || pack(B)`), as CIRCL's `MarshalBinary`. +pub struct PublicKey { + seed_a: [u8; SEED_A], + b: Vec, // N x NBAR +} + +/// A private key. Its secret parts are cleared on drop. +pub struct PrivateKey { + s_reject: [u8; SHARED_KEY_SIZE], + pk: PublicKey, + s: Vec, // transpose(S), NBAR x N + hpk: [u8; PK_HASH], +} + +impl Drop for PrivateKey { + fn drop(&mut self) { + self.s_reject.zeroize(); + self.s.zeroize(); + } +} + +fn shake(parts: &[&[u8]], out: &mut [u8]) { + let mut h = Shake128::default(); + for p in parts { + h.update(p); + } + h.finalize_xof().read(out); +} + +fn le_u16s(bytes: &[u8], out: &mut [u16]) { + for (o, c) in out.iter_mut().zip(bytes.as_chunks::<2>().0) { + *o = u16::from_le_bytes([c[0], c[1]]); + } +} + +/// Inverse-transform sampling from the FrodoKEM error distribution. +fn sample(v: &mut [u16]) { + for x in v.iter_mut() { + let sign = *x & 1; + let unif = *x >> 1; + let mut g: u16 = 0; + for c in &CDF[..CDF.len() - 1] { + g = g.wrapping_add(c.wrapping_sub(unif) >> 15); + } + *x = (sign.wrapping_neg() ^ g).wrapping_add(sign); + } +} + +/// Row i of A is SHAKE128(le16(i) || seedA), as little-endian u16s. +fn expand_a(seed_a: &[u8; SEED_A]) -> Vec { + let mut a = vec![0u16; N * N]; + let mut input = [0u8; 2 + SEED_A]; + input[2..].copy_from_slice(seed_a); + let mut row = [0u8; N * 2]; + for i in 0..N { + input[0] = i as u8; + input[1] = (i >> 8) as u8; + shake(&[&input], &mut row); + le_u16s(&row, &mut a[i * N..(i + 1) * N]); + } + a +} + +/// out (N x NBAR) += A*S + E, with S stored transposed (NBAR x N). +fn mul_add_as_plus_e(out: &mut [u16], a: &[u16], s: &[u16], e: &[u16]) { + for i in 0..N { + let row = &a[i * N..(i + 1) * N]; + for k in 0..NBAR { + let sk = &s[k * N..(k + 1) * N]; + let mut sum = e[i * NBAR + k]; + for j in 0..N { + sum = sum.wrapping_add(row[j].wrapping_mul(sk[j])); + } + out[i * NBAR + k] = out[i * NBAR + k].wrapping_add(sum); + } + } +} + +/// out (NBAR x N) += S*A + E. CIRCL walks A by column; summing row by row +/// gives the same result mod 2^16 with far better locality. +fn mul_add_sa_plus_e(out: &mut [u16], s: &[u16], a: &[u16], e: &[u16]) { + let mut acc = e[..NBAR * N].to_vec(); + for j in 0..N { + let row = &a[j * N..(j + 1) * N]; + for k in 0..NBAR { + let skj = s[k * N + j]; + let dst = &mut acc[k * N..(k + 1) * N]; + for i in 0..N { + dst[i] = dst[i].wrapping_add(row[i].wrapping_mul(skj)); + } + } + } + for (o, v) in out.iter_mut().zip(&acc) { + *o = o.wrapping_add(*v); + } + acc.zeroize(); +} + +/// out (NBAR x NBAR) = S*B + E, masked. +fn mul_add_sb_plus_e(out: &mut [u16], s: &[u16], b: &[u16], e: &[u16]) { + for k in 0..NBAR { + for i in 0..NBAR { + let mut v = e[k * NBAR + i]; + for j in 0..N { + v = v.wrapping_add(s[k * N + j].wrapping_mul(b[j * NBAR + i])); + } + out[k * NBAR + i] = v & Q_MASK; + } + } +} + +/// out (NBAR x NBAR) = B'*S, with S transposed, masked. +fn mul_bs(out: &mut [u16], b: &[u16], s: &[u16]) { + for i in 0..NBAR { + for j in 0..NBAR { + let mut v: u16 = 0; + for k in 0..N { + v = v.wrapping_add(b[i * N + k].wrapping_mul(s[j * N + k])); + } + out[i * NBAR + j] = v & Q_MASK; + } + } +} + +fn pack(out: &mut [u8], v: &[u16]) { + for (o, x) in out.as_chunks_mut::<15>().0.iter_mut().zip(v.as_chunks::<8>().0) { + let m: [u16; 8] = core::array::from_fn(|i| x[i] & Q_MASK); + o[0] = (m[0] >> 7) as u8; + o[1] = (((m[0] & 0x7f) as u8) << 1) | (m[1] >> 14) as u8; + o[2] = (m[1] >> 6) as u8; + o[3] = (((m[1] & 0x3f) as u8) << 2) | (m[2] >> 13) as u8; + o[4] = (m[2] >> 5) as u8; + o[5] = (((m[2] & 0x1f) as u8) << 3) | (m[3] >> 12) as u8; + o[6] = (m[3] >> 4) as u8; + o[7] = (((m[3] & 0x0f) as u8) << 4) | (m[4] >> 11) as u8; + o[8] = (m[4] >> 3) as u8; + o[9] = (((m[4] & 0x07) as u8) << 5) | (m[5] >> 10) as u8; + o[10] = (m[5] >> 2) as u8; + o[11] = (((m[5] & 0x03) as u8) << 6) | (m[6] >> 9) as u8; + o[12] = (m[6] >> 1) as u8; + o[13] = (((m[6] & 0x01) as u8) << 7) | (m[7] >> 8) as u8; + o[14] = m[7] as u8; + } +} + +fn unpack(out: &mut [u16], b: &[u8]) { + for (o, i) in out.as_chunks_mut::<8>().0.iter_mut().zip(b.as_chunks::<15>().0) { + let w = |x: u8| x as u16; + o[0] = (w(i[0]) << 7) | (w(i[1] & 0xfe) >> 1); + o[1] = (w(i[1] & 0x01) << 14) | (w(i[2]) << 6) | (w(i[3] & 0xfc) >> 2); + o[2] = (w(i[3] & 0x03) << 13) | (w(i[4]) << 5) | (w(i[5] & 0xf8) >> 3); + o[3] = (w(i[5] & 0x07) << 12) | (w(i[6]) << 4) | (w(i[7] & 0xf0) >> 4); + o[4] = (w(i[7] & 0x0f) << 11) | (w(i[8]) << 3) | (w(i[9] & 0xe0) >> 5); + o[5] = (w(i[9] & 0x1f) << 10) | (w(i[10]) << 2) | (w(i[11] & 0xc0) >> 6); + o[6] = (w(i[11] & 0x3f) << 9) | (w(i[12]) << 1) | (w(i[13] & 0x80) >> 7); + o[7] = (w(i[13] & 0x7f) << 8) | w(i[14]); + } +} + +fn encode_message(out: &mut [u16], msg: &[u8; MESSAGE]) { + let mask = (1u16 << EXTRACTED_BITS) - 1; + let mut pos = 0; + for c in msg.as_chunks::<2>().0 { + let mut v = u16::from_le_bytes([c[0], c[1]]); + for _ in 0..(16 / EXTRACTED_BITS) { + out[pos] = (v & mask) << (LOG_Q - EXTRACTED_BITS); + pos += 1; + v >>= EXTRACTED_BITS; + } + } +} + +fn decode_message(out: &mut [u8; MESSAGE], m: &[u16]) { + let mask = (1u16 << EXTRACTED_BITS) - 1; + let mut pos = 0; + for o in out.iter_mut() { + for j in 0..(8 / EXTRACTED_BITS) { + let mut t = (m[pos] & Q_MASK) + (1 << (LOG_Q - EXTRACTED_BITS - 1)); + t >>= LOG_Q - EXTRACTED_BITS; + t &= mask; + *o |= (t as u8) << (j * EXTRACTED_BITS); + pos += 1; + } + } +} + +/// 0 if equal, 1 otherwise, without branching on the contents. +fn ct_differs(a: &[u16], b: &[u16]) -> u8 { + let mut v: u16 = 0; + for (x, y) in a.iter().zip(b) { + v |= x ^ y; + } + ((v | v.wrapping_neg()) >> 15) as u8 +} + +impl PublicKey { + pub fn to_bytes(&self) -> Vec { + let mut out = vec![0u8; PUBLIC_KEY_SIZE]; + out[..SEED_A].copy_from_slice(&self.seed_a); + pack(&mut out[SEED_A..], &self.b); + out + } + + /// Any 9616 bytes form a key, as in CIRCL; only the length is checked. + pub fn from_bytes(buf: &[u8]) -> Option { + if buf.len() != PUBLIC_KEY_SIZE { + return None; + } + let mut seed_a = [0u8; SEED_A]; + seed_a.copy_from_slice(&buf[..SEED_A]); + let mut b = vec![0u16; N * NBAR]; + unpack(&mut b, &buf[SEED_A..]); + Some(PublicKey { seed_a, b }) + } + + fn hash(&self) -> [u8; PK_HASH] { + let mut h = [0u8; PK_HASH]; + shake(&[&self.to_bytes()], &mut h); + h + } + + /// Encapsulates with the 16-byte message `mu` (CIRCL's + /// `EncapsulateTo` seed), returning the ciphertext and shared secret. + /// Callers must pass fresh randomness; reusing `mu` repeats the secret. + pub fn encapsulate(&self, mu: &[u8; ENCAPSULATION_SEED_SIZE]) -> (Vec, [u8; SHARED_KEY_SIZE]) { + let hpk = self.hash(); + let mut g2 = [0u8; 2 * SHARED_KEY_SIZE]; + shake(&[&hpk, mu], &mut g2); + let (sp, ep, epp, mut noise) = noise(&g2[..SHARED_KEY_SIZE]); + let a = expand_a(&self.seed_a); + let mut bp = vec![0u16; NBAR * N]; + mul_add_sa_plus_e(&mut bp, &noise[sp.clone()], &a, &noise[ep]); + let mut v = [0u16; NBAR * NBAR]; + mul_add_sb_plus_e(&mut v, &noise[sp], &self.b, &noise[epp]); + let mut c = [0u16; NBAR * NBAR]; + encode_message(&mut c, mu); + for (c, v) in c.iter_mut().zip(&v) { + *c = c.wrapping_add(*v) & Q_MASK; + } + let mut ct = vec![0u8; CIPHERTEXT_SIZE]; + pack(&mut ct[..BP_PACKED], &bp); + pack(&mut ct[BP_PACKED..], &c); + let mut ss = [0u8; SHARED_KEY_SIZE]; + shake(&[&ct, &g2[SHARED_KEY_SIZE..]], &mut ss); + noise.zeroize(); + g2.zeroize(); + v.zeroize(); + (ct, ss) + } +} + +type Span = core::ops::Range; + +/// SHAKE128(0x96 || seedSE) expanded and sampled into S', E', E''. +fn noise(seed_se: &[u8]) -> (Span, Span, Span, Vec) { + let total = 2 * N * NBAR + NBAR * NBAR; + let mut bytes = vec![0u8; 2 * total]; + shake(&[&[0x96], seed_se], &mut bytes); + let mut v = vec![0u16; total]; + le_u16s(&bytes, &mut v); + bytes.zeroize(); + sample(&mut v); + (0..N * NBAR, N * NBAR..2 * N * NBAR, 2 * N * NBAR..total, v) +} + +impl PrivateKey { + /// CIRCL's `newKeyFromSeed`: seed is s || seedSE || z. + pub fn derive(seed: &[u8; KEY_SEED_SIZE]) -> Self { + let mut seed_a = [0u8; SEED_A]; + shake(&[&seed[2 * SHARED_KEY_SIZE..]], &mut seed_a); + let mut bytes = vec![0u8; 2 * 2 * N * NBAR]; + shake(&[&[0x5f], &seed[SHARED_KEY_SIZE..2 * SHARED_KEY_SIZE]], &mut bytes); + let mut s = vec![0u16; N * NBAR]; + let mut e = vec![0u16; N * NBAR]; + le_u16s(&bytes[..2 * N * NBAR], &mut s); + le_u16s(&bytes[2 * N * NBAR..], &mut e); + bytes.zeroize(); + sample(&mut s); + sample(&mut e); + let a = expand_a(&seed_a); + let mut b = vec![0u16; N * NBAR]; + mul_add_as_plus_e(&mut b, &a, &s, &e); + e.zeroize(); + let pk = PublicKey { seed_a, b }; + let hpk = pk.hash(); + let mut s_reject = [0u8; SHARED_KEY_SIZE]; + s_reject.copy_from_slice(&seed[..SHARED_KEY_SIZE]); + PrivateKey { s_reject, pk, s, hpk } + } + + pub fn public(&self) -> &PublicKey { + &self.pk + } + + /// CIRCL's packed form: s || pk || S (little-endian) || H(pk). + pub fn to_bytes(&self) -> zeroize::Zeroizing> { + let mut out = zeroize::Zeroizing::new(Vec::with_capacity(PRIVATE_KEY_SIZE)); + out.extend_from_slice(&self.s_reject); + out.extend_from_slice(&self.pk.to_bytes()); + for x in &self.s { + out.extend_from_slice(&x.to_le_bytes()); + } + out.extend_from_slice(&self.hpk); + out + } + + /// Decapsulates with implicit rejection: a ciphertext that does not + /// re-encrypt identically yields SHAKE(ct || s) instead of an error. + pub fn decapsulate(&self, ct: &[u8]) -> Option<[u8; SHARED_KEY_SIZE]> { + if ct.len() != CIPHERTEXT_SIZE { + return None; + } + let mut bp = vec![0u16; NBAR * N]; + let mut c = [0u16; NBAR * NBAR]; + unpack(&mut bp, &ct[..BP_PACKED]); + unpack(&mut c, &ct[BP_PACKED..]); + let mut w = [0u16; NBAR * NBAR]; + mul_bs(&mut w, &bp, &self.s); + for (w, c) in w.iter_mut().zip(&c) { + *w = c.wrapping_sub(*w) & Q_MASK; + } + let mut mu = [0u8; MESSAGE]; + decode_message(&mut mu, &w); + + let mut g2 = [0u8; 2 * SHARED_KEY_SIZE]; + shake(&[&self.hpk, &mu], &mut g2); + let (sp, ep, epp, mut noise) = noise(&g2[..SHARED_KEY_SIZE]); + let a = expand_a(&self.pk.seed_a); + let mut bbp = vec![0u16; NBAR * N]; + mul_add_sa_plus_e(&mut bbp, &noise[sp.clone()], &a, &noise[ep]); + for x in bbp.iter_mut() { + *x &= Q_MASK; + } + mul_add_sb_plus_e(&mut w, &noise[sp], &self.pk.b, &noise[epp]); + let mut cc = [0u16; NBAR * NBAR]; + encode_message(&mut cc, &mu); + for (cc, w) in cc.iter_mut().zip(&w) { + *cc = cc.wrapping_add(*w) & Q_MASK; + } + let differs = ct_differs(&bp, &bbp) | ct_differs(&c, &cc); + let mask = differs.wrapping_neg(); // 0xff to reject, 0 to accept + let mut k = [0u8; SHARED_KEY_SIZE]; + for (i, k) in k.iter_mut().enumerate() { + *k = (g2[SHARED_KEY_SIZE + i] & !mask) | (self.s_reject[i] & mask); + } + let mut ss = [0u8; SHARED_KEY_SIZE]; + shake(&[ct, &k], &mut ss); + for z in [&mut mu[..], &mut g2[..], &mut k[..]] { + z.zeroize(); + } + noise.zeroize(); + w.zeroize(); + cc.zeroize(); + bbp.zeroize(); + Some(ss) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aes::cipher::{BlockCipherEncrypt, KeyInit}; + use sha2::Digest; + + /// NIST's AES-256 CTR_DRBG from the KAT generator's randombytes.c. + struct Drbg { + key: [u8; 32], + v: [u8; 16], + } + + impl Drbg { + fn new(entropy: &[u8; 48]) -> Self { + let mut d = Drbg { key: [0; 32], v: [0; 16] }; + d.update(Some(entropy)); + d + } + + fn block(&mut self) -> [u8; 16] { + for b in self.v.iter_mut().rev() { + *b = b.wrapping_add(1); + if *b != 0 { + break; + } + } + let c = aes::Aes256::new(&self.key.into()); + let mut blk = self.v.into(); + c.encrypt_block(&mut blk); + blk.into() + } + + fn update(&mut self, data: Option<&[u8; 48]>) { + let mut t = [0u8; 48]; + for i in 0..3 { + t[16 * i..16 * i + 16].copy_from_slice(&self.block()); + } + if let Some(d) = data { + for (t, d) in t.iter_mut().zip(d) { + *t ^= d; + } + } + self.key.copy_from_slice(&t[..32]); + self.v.copy_from_slice(&t[32..]); + } + + fn fill(&mut self, out: &mut [u8]) { + for c in out.chunks_mut(16) { + let b = self.block(); + c.copy_from_slice(&b[..c.len()]); + } + self.update(None); + } + } + + fn upper(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02X}")).collect() + } + + /// CIRCL's TestPQCgenKATKem: the SHA-256 of the KAT response file it + /// regenerates, which matches the FrodoKEM reference implementation's + /// PQCkemKAT_19888_shake.rsp. + #[test] + fn nist_kat_digest() { + let mut seed = [0u8; 48]; + for (i, s) in seed.iter_mut().enumerate() { + *s = i as u8; + } + let mut g = Drbg::new(&seed); + let mut f = sha2::Sha256::new(); + Digest::update(&mut f, b"# FrodoKEM-640-SHAKE\n\n"); + for i in 0..100 { + g.fill(&mut seed); + Digest::update(&mut f, format!("count = {i}\nseed = {}\n", upper(&seed)).as_bytes()); + let mut g2 = Drbg::new(&seed); + let mut kseed = [0u8; KEY_SEED_SIZE]; + g2.fill(&mut kseed); + let sk = PrivateKey::derive(&kseed); + let pk = sk.public().to_bytes(); + let mut eseed = [0u8; ENCAPSULATION_SEED_SIZE]; + g2.fill(&mut eseed); + let (ct, ss) = PublicKey::from_bytes(&pk).unwrap().encapsulate(&eseed); + assert_eq!(sk.decapsulate(&ct).unwrap(), ss); + Digest::update( + &mut f, + format!( + "pk = {}\nsk = {}\nct = {}\nss = {}\n\n", + upper(&pk), + upper(&sk.to_bytes()), + upper(&ct), + upper(&ss) + ) + .as_bytes(), + ); + } + let digest: String = f.finalize().iter().map(|x| format!("{x:02x}")).collect(); + assert_eq!(digest, "604a10cfc871dfaed9cb5b057c644ab03b16852cea7f39bc7f9831513b5b1cfa"); + } +} diff --git a/src/crypto/fury.rs b/src/crypto/fury.rs new file mode 100644 index 0000000..42ada9b --- /dev/null +++ b/src/crypto/fury.rs @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Whole-file Fury encryption: a header wrapping a fresh 32-byte file key, +//! a 16-byte nonce, then the STREAM payload. +//! +//! - header MAC: HMAC-SHA-256 keyed by HKDF-SHA-256(file key, salt none, +//! info "header"), over the header up to and including "---"; +//! - payload key: HKDF-SHA-256(file key, salt = nonce, info "payload"). + +use super::header::{self, Header, ParseError}; +use super::kem::{FILE_KEY_SIZE, Identity, Recipient, UnwrapError}; +use super::stream::{self, StreamReader, StreamWriter}; +use crate::rng::Rng; +use crate::secret::{SecretArray, SecretVec}; +use hkdf::Hkdf; +use hmac::{KeyInit, Mac}; +use sha2::Sha256; +use std::io::{self, Read, Write}; + +pub const NONCE_SIZE: usize = 16; + +#[derive(Debug)] +pub enum Error { + /// No stanza opens with this identity: the file is someone else's. + NoIdentityMatch, + Header(ParseError), + /// A stanza of our suite was malformed. + Stanza(String), + BadMac, + Io(io::Error), + TooLarge, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::NoIdentityMatch => f.write_str("no identity matched any of the recipients"), + Error::Header(e) => write!(f, "failed to read header: {e}"), + Error::Stanza(m) => f.write_str(m), + Error::BadMac => f.write_str("bad header MAC"), + Error::Io(e) => write!(f, "{e}"), + Error::TooLarge => f.write_str("encrypted payload is larger than expected"), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(e: io::Error) -> Self { + Error::Io(e) + } +} + +fn header_mac(file_key: &[u8; FILE_KEY_SIZE], h: &Header) -> hmac::Hmac { + let mut k = SecretArray::<32>::zero(); + Hkdf::::new(None, file_key) + .expand(b"header", k.expose_mut()) + .expect("32 bytes"); + let mut m = as KeyInit>::new_from_slice(k.expose()).expect("any key length"); + m.update(&h.marshal_without_mac()); + m +} + +pub fn payload_key(file_key: &[u8; FILE_KEY_SIZE], nonce: &[u8; NONCE_SIZE]) -> SecretArray<32> { + let mut k = SecretArray::<32>::zero(); + Hkdf::::new(Some(nonce), file_key) + .expand(b"payload", k.expose_mut()) + .expect("32 bytes"); + k +} + +/// Builds a header for `recipient` and returns it with the payload key and +/// nonce to use. All randomness is fresh from `rng`. +fn begin(recipient: &Recipient, rng: &mut Rng) -> (Header, [u8; NONCE_SIZE], SecretArray<32>) { + let mut file_key = SecretArray::::zero(); + rng.fill(file_key.expose_mut()); + let mut h = Header { + recipients: vec![recipient.wrap(file_key.expose(), rng)], + mac: [0; 32], + }; + h.mac = header_mac(file_key.expose(), &h).finalize().into_bytes().into(); + let nonce: [u8; NONCE_SIZE] = rng.array(); + let key = payload_key(file_key.expose(), &nonce); + (h, nonce, key) +} + +/// Encrypts a plaintext held in memory, appending the file to `out`. +pub fn encrypt_to(out: &mut Vec, recipient: &Recipient, plaintext: &[u8], rng: &mut Rng) { + let (h, nonce, key) = begin(recipient, rng); + out.extend_from_slice(&h.marshal()); + out.extend_from_slice(&nonce); + stream::seal_all(key.expose(), plaintext, out); +} + +/// Streaming encryption to `dst`; call `finish` on the result. +pub fn encrypt_writer(mut dst: W, recipient: &Recipient, rng: &mut Rng) -> io::Result> { + let (h, nonce, key) = begin(recipient, rng); + h.write_to(&mut dst)?; + dst.write_all(&nonce)?; + Ok(StreamWriter::new(key.expose(), dst)) +} + +/// Go's `decryptHdr`: unwrap with `id`, then check the header MAC in +/// constant time. +fn file_key(h: &Header, id: &Identity) -> Result, Error> { + let fk = match id.unwrap(&h.recipients) { + Ok(k) => k, + Err(UnwrapError::NotMine) => return Err(Error::NoIdentityMatch), + Err(UnwrapError::Malformed(m)) => return Err(Error::Stanza(m)), + }; + header_mac(fk.expose(), h).verify_slice(&h.mac).map_err(|_| Error::BadMac)?; + Ok(fk) +} + +/// Streaming decryption. Nothing read from the result is authentic until +/// it has returned end of input without error. +pub fn decrypt_reader(src: R, id: &Identity) -> Result, Error> { + let (h, mut payload) = header::parse(src).map_err(Error::Header)?; + let fk = file_key(&h, id)?; + let mut nonce = [0u8; NONCE_SIZE]; + payload + .read_exact(&mut nonce) + .map_err(|e| Error::Io(io::Error::new(e.kind(), format!("failed to read nonce: {e}"))))?; + let key = payload_key(fk.expose(), &nonce); + Ok(StreamReader::new(key.expose(), payload)) +} + +/// Decrypts a whole file whose plaintext is at most `max_plaintext` bytes, +/// reading no more than that much ciphertext. The result is fully +/// authenticated, final chunk and end of input included. +pub fn decrypt_bounded(src: R, id: &Identity, max_plaintext: usize) -> Result { + let (h, mut payload) = header::parse(src).map_err(Error::Header)?; + let fk = file_key(&h, id)?; + let mut nonce = [0u8; NONCE_SIZE]; + payload + .read_exact(&mut nonce) + .map_err(|e| Error::Io(io::Error::new(e.kind(), format!("failed to read nonce: {e}"))))?; + let key = payload_key(fk.expose(), &nonce); + let chunks = max_plaintext.div_ceil(stream::CHUNK_SIZE).max(1); + let max_ct = max_plaintext + chunks * stream::TAG_SIZE; + let mut buf = SecretVec::zeroed(max_ct + 1); + let n = stream::read_full(&mut payload, buf.expose_mut())?; + if n > max_ct { + return Err(Error::TooLarge); + } + let plain = stream::open_all(key.expose(), &mut buf.expose_mut()[..n]).map_err(|e| Error::Io(e.into()))?; + buf.resize_within_capacity(plain); + Ok(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::kem::{ + SUITE_ID, + tests::{unhex, vectors}, + }; + use crate::crypto::stream::tests::hex; + + fn pattern(n: usize, salt: u8) -> Vec { + (0..n).map(|i| (i.wrapping_mul(7) as u8) ^ salt).collect() + } + + #[test] + fn header_vectors_match_go() { + for v in vectors().wrap { + let raw = unhex(&v.header_hex); + let (h, _) = header::parse(&raw[..]).unwrap(); + assert_eq!(h.marshal(), raw, "canonical serialisation"); + let fk: [u8; 32] = unhex(&v.file_key).try_into().unwrap(); + assert_eq!(hex(&header_mac(&fk, &h).finalize().into_bytes()), v.mac); + let id = Identity::from_suite_seed(SUITE_ID, &unhex(&v.master_seed)).unwrap(); + assert_eq!(file_key(&h, &id).unwrap().expose(), &fk); + let nonce: [u8; 16] = unhex(&v.nonce).try_into().unwrap(); + assert_eq!(hex(payload_key(&fk, &nonce).expose()), v.payload_key); + // A one-bit change anywhere the MAC covers is caught. + let mut tampered = h.clone(); + tampered.recipients[0].body[0] ^= 1; + assert!(file_key(&tampered, &id).is_err()); + let mut wrong_mac = h.clone(); + wrong_mac.mac[31] ^= 1; + assert!(matches!(file_key(&wrong_mac, &id), Err(Error::BadMac))); + } + } + + #[test] + fn header_parser_agrees_with_go() { + for (i, c) in vectors().header_cases.into_iter().enumerate() { + let input = unhex(&c.input_hex); + let got = header::parse(&input[..]); + assert_eq!( + got.is_ok(), + c.accept, + "case {i}: {:?}", + String::from_utf8_lossy(&input[..input.len().min(80)]) + ); + if let Ok((_, mut rest)) = got { + let mut r = Vec::new(); + rest.read_to_end(&mut r).unwrap(); + assert_eq!(hex(&r), c.rest_hex.unwrap_or_default(), "case {i} payload"); + } + } + } + + #[test] + fn decrypts_go_files() { + let id = Identity::from_suite_seed(SUITE_ID, &pattern(32, 1)).unwrap(); + for size in [0usize, 1, 65535, 65536, 65537, 131072, 200000] { + let path = format!("{}/tests/vectors/fury/pattern-{size}.fury", env!("CARGO_MANIFEST_DIR")); + let data = std::fs::read(&path).unwrap(); + let mut out = Vec::new(); + decrypt_reader(&data[..], &id).unwrap().read_to_end(&mut out).unwrap(); + assert_eq!(out, pattern(size, 0x5a), "{path}"); + let b = decrypt_bounded(&data[..], &id, size).unwrap(); + assert_eq!(b.expose(), &pattern(size, 0x5a)[..]); + if size > 0 { + assert!(matches!( + decrypt_bounded(&data[..], &id, size - 1), + Err(Error::TooLarge) | Err(Error::Io(_)) + )); + } + let other = Identity::generate(&mut crate::rng::os()); + assert!(matches!(decrypt_reader(&data[..], &other), Err(Error::NoIdentityMatch))); + } + } + + #[test] + fn round_trip_and_tamper() { + let id = Identity::generate(&mut crate::rng::os()); + for size in [0usize, 1, 65536, 70000] { + let p = pattern(size, 3); + let mut f = Vec::new(); + encrypt_to(&mut f, &id.recipient(), &p, &mut crate::rng::os()); + let mut w = encrypt_writer(Vec::new(), &id.recipient(), &mut crate::rng::os()).unwrap(); + w.write_all(&p).unwrap(); + let f2 = w.finish().unwrap(); + for f in [&f, &f2] { + assert_eq!(decrypt_bounded(&f[..], &id, size).unwrap().expose(), &p[..]); + let mut g = f.clone(); + let last = g.len() - 1; + g[last] ^= 1; + assert!(decrypt_bounded(&g[..], &id, size).is_err()); + let mut out = Vec::new(); + assert!(decrypt_reader(&g[..], &id).unwrap().read_to_end(&mut out).is_err()); + } + } + } + + /// With identical synthetic randomness the in-memory and streaming + /// encryptors produce identical files. + #[test] + fn deterministic_under_test_entropy() { + let id = Identity::generate(&mut crate::rng::os()); + let e = crate::rng::Entropy::Seeded([9; 32]); + let p = pattern(200_000, 1); + let mut a = Vec::new(); + encrypt_to(&mut a, &id.recipient(), &p, &mut e.rng(b"x")); + let mut w = encrypt_writer(Vec::new(), &id.recipient(), &mut e.rng(b"x")).unwrap(); + w.write_all(&p).unwrap(); + assert_eq!(a, w.finish().unwrap()); + } +} diff --git a/src/crypto/header.rs b/src/crypto/header.rs new file mode 100644 index 0000000..a790313 --- /dev/null +++ b/src/crypto/header.rs @@ -0,0 +1,396 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The Fury header: serialisation and a strict parser matching the Go +//! `internal/format` package byte for byte. +//! +//! ```text +//! fury-encryption.org/v1\n +//! -> TYPE ARG...\n one stanza per recipient +//! BASE64 body, 64 columns per line, ending with a line shorter than 64 +//! --- BASE64(MAC)\n +//! ``` +//! +//! Base64 is the standard alphabet without padding, with non-zero trailing +//! bits rejected. A stanza's lines total at most 1 MiB and the header at +//! most 16 MiB. + +use base64::Engine; +use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}; +use std::io::{self, Read, Write}; + +pub const MAX_HEADER_SIZE: u64 = 16 << 20; +pub const MAX_STANZA_SIZE: u64 = 1 << 20; +pub const INTRO: &[u8] = b"fury-encryption.org/v1\n"; +const STANZA_PREFIX: &[u8] = b"->"; +const FOOTER_PREFIX: &[u8] = b"---"; +const COLUMNS: usize = 64; +const BYTES_PER_LINE: usize = COLUMNS / 4 * 3; + +pub const B64: GeneralPurpose = GeneralPurpose::new( + &base64::alphabet::STANDARD, + GeneralPurposeConfig::new() + .with_encode_padding(false) + .with_decode_padding_mode(DecodePaddingMode::RequireNone) + .with_decode_allow_trailing_bits(false), +); + +pub fn encode(b: &[u8]) -> String { + B64.encode(b) +} + +/// Strict unpadded base64; CR and LF are rejected outright, as in Go. +pub fn decode(s: &[u8]) -> Result, String> { + if s.iter().any(|&c| c == b'\n' || c == b'\r') { + return Err("unexpected newline character".into()); + } + B64.decode(s).map_err(|e| e.to_string().to_lowercase()) +} + +#[derive(Clone, PartialEq, Eq)] +pub struct Stanza { + pub typ: String, + pub args: Vec, + pub body: Vec, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct Header { + pub recipients: Vec, + pub mac: [u8; 32], +} + +impl Stanza { + pub fn marshal(&self, w: &mut Vec) { + w.extend_from_slice(STANZA_PREFIX); + for a in std::iter::once(&self.typ).chain(&self.args) { + w.push(b' '); + w.extend_from_slice(a.as_bytes()); + } + w.push(b'\n'); + let enc = encode(&self.body); + let mut chunks = enc.as_bytes().chunks(COLUMNS).peekable(); + let mut last_full = true; + while let Some(c) = chunks.next() { + w.extend_from_slice(c); + if c.len() == COLUMNS { + w.push(b'\n'); + } else { + last_full = false; + } + if chunks.peek().is_none() && c.len() < COLUMNS { + w.push(b'\n'); + } + } + // A body whose encoding fills its last line (or is empty) ends with + // an empty line, so every stanza ends with a short line. + if last_full { + w.push(b'\n'); + } + } +} + +impl Header { + /// Everything the MAC covers: intro, stanzas and the footer prefix. + pub fn marshal_without_mac(&self) -> Vec { + let mut w = INTRO.to_vec(); + for s in &self.recipients { + s.marshal(&mut w); + } + w.extend_from_slice(FOOTER_PREFIX); + w + } + + pub fn marshal(&self) -> Vec { + let mut w = self.marshal_without_mac(); + w.push(b' '); + w.extend_from_slice(encode(&self.mac).as_bytes()); + w.push(b'\n'); + w + } + + pub fn write_to(&self, w: &mut impl Write) -> io::Result<()> { + w.write_all(&self.marshal()) + } +} + +/// A reader with as much look-ahead as the parser needs. After parsing, +/// [`Peek::into_payload`] hands back the unread bytes followed by the rest +/// of the source. +/// The payload after a parsed header: read-ahead bytes, then the source. +pub type Payload = io::Chain>, R>; + +pub struct Peek { + inner: R, + buf: Vec, + pos: usize, + eof: bool, +} + +impl Peek { + pub fn new(inner: R) -> Self { + Peek { + inner, + buf: Vec::with_capacity(8192), + pos: 0, + eof: false, + } + } + + fn fill(&mut self) -> io::Result { + if self.eof { + return Ok(false); + } + if self.pos > 0 && self.pos == self.buf.len() { + self.buf.clear(); + self.pos = 0; + } + let start = self.buf.len(); + self.buf.resize(start + 8192, 0); + let n = loop { + match self.inner.read(&mut self.buf[start..]) { + Ok(n) => break n, + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => { + self.buf.truncate(start); + return Err(e); + } + } + }; + self.buf.truncate(start + n); + if n == 0 { + self.eof = true; + } + Ok(n > 0) + } + + /// Up to `n` bytes ahead, fewer only at end of input. + fn peek(&mut self, n: usize) -> io::Result<&[u8]> { + while self.buf.len() - self.pos < n && self.fill()? {} + let end = (self.pos + n).min(self.buf.len()); + Ok(&self.buf[self.pos..end]) + } + + /// Reads one line including its newline. Fails if the line, added to + /// `*count`, would exceed `limit`, or if input ends before a newline. + fn line(&mut self, count: &mut u64, limit: u64) -> Result, LineError> { + let mut line = Vec::new(); + loop { + if self.pos == self.buf.len() && !self.fill().map_err(LineError::Io)? { + return Err(LineError::Eof); + } + let avail = &self.buf[self.pos..]; + let (take, done) = match avail.iter().position(|&c| c == b'\n') { + Some(i) => (i + 1, true), + None => (avail.len(), false), + }; + if *count + take as u64 > limit { + return Err(LineError::TooLong); + } + *count += take as u64; + line.extend_from_slice(&avail[..take]); + self.pos += take; + if done { + return Ok(line); + } + } + } + + pub fn into_payload(self) -> Payload { + let mut rest = self.buf; + rest.drain(..self.pos); + io::Cursor::new(rest).chain(self.inner) + } +} + +enum LineError { + Io(io::Error), + Eof, + TooLong, +} + +#[derive(Debug)] +pub enum ParseError { + Io(io::Error), + Malformed(String), +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParseError::Io(e) => write!(f, "failed to read header: {e}"), + ParseError::Malformed(m) => write!(f, "parsing fury header: {m}"), + } + } +} + +fn bad(m: impl Into) -> Result { + Err(ParseError::Malformed(m.into())) +} + +fn split_args(line: &[u8]) -> (&[u8], Vec<&[u8]>) { + let l = line.strip_suffix(b"\n").unwrap_or(line); + let mut parts = l.split(|&c| c == b' '); + let first = parts.next().unwrap_or(b""); + (first, parts.collect()) +} + +fn valid_string(s: &[u8]) -> bool { + !s.is_empty() && s.iter().all(|&c| (33..=126).contains(&c)) +} + +fn read_stanza(p: &mut Peek) -> Result<(Stanza, u64), ParseError> { + let mut n = 0u64; + let line = match p.line(&mut n, MAX_STANZA_SIZE) { + Ok(l) => l, + Err(LineError::TooLong) => return bad(format!("stanza exceeds {MAX_STANZA_SIZE} byte limit")), + Err(LineError::Eof) => return bad("failed to read line: unexpected eof"), + Err(LineError::Io(e)) => return Err(ParseError::Io(e)), + }; + if !line.starts_with(STANZA_PREFIX) { + return bad("malformed stanza opening line"); + } + let (prefix, args) = split_args(&line); + if prefix != STANZA_PREFIX || args.is_empty() || !args.iter().all(|a| valid_string(a)) { + return bad("malformed stanza"); + } + // valid_string admits only printable ASCII, so these are UTF-8. + let text = |a: &[u8]| String::from_utf8(a.to_vec()).expect("ascii"); + let mut s = Stanza { + typ: text(args[0]), + args: args[1..].iter().map(|a| text(a)).collect(), + body: Vec::new(), + }; + loop { + let line = match p.line(&mut n, MAX_STANZA_SIZE) { + Ok(l) => l, + Err(LineError::TooLong) => return bad(format!("stanza exceeds {MAX_STANZA_SIZE} byte limit")), + Err(LineError::Eof) => return bad("failed to read line: unexpected eof"), + Err(LineError::Io(e)) => return Err(ParseError::Io(e)), + }; + let b = match decode(line.strip_suffix(b"\n").unwrap_or(&line)) { + Ok(b) => b, + Err(e) => { + if line.starts_with(FOOTER_PREFIX) || line.starts_with(STANZA_PREFIX) { + return bad("malformed body line: stanza ended without a short line"); + } + return bad(format!("malformed body line: {e}")); + } + }; + if b.len() > BYTES_PER_LINE { + return bad("malformed body line: too long"); + } + let short = b.len() < BYTES_PER_LINE; + s.body.extend_from_slice(&b); + if short { + return Ok((s, n)); + } + } +} + +/// Parses a header, returning it and the reader positioned at the payload. +pub fn parse(input: R) -> Result<(Header, Payload), ParseError> { + let mut p = Peek::new(input); + let mut total = 0u64; + match p.line(&mut total, MAX_HEADER_SIZE) { + Ok(l) if l == INTRO => {} + Ok(_) => return bad("unexpected intro"), + Err(LineError::TooLong) => return bad(format!("header exceeds {MAX_HEADER_SIZE} byte limit")), + Err(LineError::Eof) if total == 0 => return bad("file is empty"), + Err(LineError::Eof) => return bad("failed to read intro: unexpected eof"), + Err(LineError::Io(e)) => return Err(ParseError::Io(e)), + } + let mut recipients = Vec::new(); + loop { + let peek = p.peek(FOOTER_PREFIX.len()).map_err(ParseError::Io)?; + if peek.len() < FOOTER_PREFIX.len() { + return bad("failed to read header: unexpected eof"); + } + if peek == FOOTER_PREFIX { + let line = match p.line(&mut total, MAX_HEADER_SIZE) { + Ok(l) => l, + Err(LineError::TooLong) => return bad(format!("header exceeds {MAX_HEADER_SIZE} byte limit")), + Err(LineError::Eof) => return bad("failed to read header: unexpected eof"), + Err(LineError::Io(e)) => return Err(ParseError::Io(e)), + }; + let (prefix, args) = split_args(&line); + if prefix != FOOTER_PREFIX || args.len() != 1 { + return bad("malformed closing line"); + } + let mac = match decode(args[0]) { + Ok(m) if m.len() == 32 => m, + _ => return bad("malformed closing line"), + }; + let mut m = [0u8; 32]; + m.copy_from_slice(&mac); + return Ok((Header { recipients, mac: m }, p.into_payload())); + } + let (s, n) = read_stanza(&mut p)?; + total += n; + if total > MAX_HEADER_SIZE { + return bad(format!("header exceeds {MAX_HEADER_SIZE} byte limit")); + } + recipients.push(s); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stanza(body: usize) -> Stanza { + Stanza { + typ: "t".into(), + args: vec!["a".into()], + body: vec![7u8; body], + } + } + + #[test] + fn body_line_wrapping() { + let mut w = Vec::new(); + stanza(0).marshal(&mut w); + assert_eq!(w, b"-> t a\n\n"); + w.clear(); + stanza(48).marshal(&mut w); + let line = encode(&[7u8; 48]); + assert_eq!(line.len(), 64); + assert_eq!(w, format!("-> t a\n{line}\n\n").as_bytes()); + w.clear(); + stanza(50).marshal(&mut w); + let enc = encode(&[7u8; 50]); + assert_eq!(w, format!("-> t a\n{}\n{}\n", &enc[..64], &enc[64..]).as_bytes()); + } + + #[test] + fn round_trip_and_payload() { + let h = Header { + recipients: vec![stanza(48), stanza(3)], + mac: [9; 32], + }; + let mut b = h.marshal(); + b.extend_from_slice(b"payload"); + let (got, mut rest) = parse(&b[..]).unwrap(); + assert!(got == h); + let mut p = Vec::new(); + rest.read_to_end(&mut p).unwrap(); + assert_eq!(p, b"payload"); + } + + #[test] + fn oversized_stanza_and_header() { + let big = "A".repeat(MAX_STANZA_SIZE as usize); + let h = format!("fury-encryption.org/v1\n-> x {big}\n\n--- {}\n", encode(&[0; 32])); + assert!(parse(h.as_bytes()).is_err()); + // Just under the stanza limit is fine... + let ok = "A".repeat(MAX_STANZA_SIZE as usize - 10); + let h = format!("fury-encryption.org/v1\n-> x {ok}\n\n--- {}\n", encode(&[0; 32])); + assert!(parse(h.as_bytes()).is_ok()); + // ...but seventeen such stanzas pass 16 MiB. + let mut h = String::from("fury-encryption.org/v1\n"); + for _ in 0..17 { + h.push_str(&format!("-> x {ok}\n\n")); + } + h.push_str(&format!("--- {}\n", encode(&[0; 32]))); + assert!(matches!(parse(h.as_bytes()), Err(ParseError::Malformed(m)) if m.contains("header exceeds"))); + } +} diff --git a/src/crypto/kem.rs b/src/crypto/kem.rs new file mode 100644 index 0000000..d7e8493 --- /dev/null +++ b/src/crypto/kem.rs @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The hybrid FrodoKEM-640-SHAKE + ML-KEM-768 suite (ID 0x0001, stanza tag +//! `frodo640shake+mlkem768`), as the Go `pqcrypt` package defines it. +//! +//! - Keys: a 32-byte master seed is expanded with +//! HKDF-SHA-256(seed, salt = none, info = "fury/v1 seed-expand " + tag) +//! into a 48-byte FrodoKEM seed followed by a 64-byte ML-KEM seed (d||z). +//! - Wrap: both KEMs encapsulate with fresh randomness; the KEK is +//! HKDF-SHA-256(ss1 || ss2, salt = none, info = "fury/v1 kek " + tag || +//! ct1 || ct2), and the 32-byte file key is sealed under it with +//! ChaCha20-Poly1305 and an all-zero nonce. The zero nonce is safe only +//! because every KEK is fresh and used once; nothing else may use +//! [`seal_once`]. + +use super::frodo; +use super::header::{self, Stanza}; +use crate::rng::Rng; +use crate::secret::SecretArray; +use chacha20poly1305::aead::{AeadInOut, KeyInit}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce, Tag}; +use hkdf::Hkdf; +use ml_kem::kem::{Decapsulate, KeyExport}; +use ml_kem::{DecapsulationKey, EncapsulationKey, MlKem768}; +use sha2::Sha256; +use zeroize::{Zeroize, Zeroizing}; + +pub const SUITE_ID: u16 = 0x0001; +pub const TAG: &str = "frodo640shake+mlkem768"; +pub const SEED_SIZE: usize = 32; +pub const FILE_KEY_SIZE: usize = 32; +const MLKEM_PK: usize = 1184; +const MLKEM_CT: usize = 1088; +const MLKEM_SEED: usize = 64; +pub const PUBLIC_KEY_SIZE: usize = frodo::PUBLIC_KEY_SIZE + MLKEM_PK; + +/// Why a stanza could not be unwrapped. +#[derive(Debug, PartialEq, Eq)] +pub enum UnwrapError { + /// The stanza is not for this identity (wrong suite, or the file key + /// does not open): try the next stanza or identity. + NotMine, + /// The stanza claims this suite but is malformed; Go treats this as + /// fatal for the whole file. + Malformed(String), +} + +fn hkdf_expand(ikm: &[u8], salt: Option<&[u8]>, info: &[&[u8]], out: &mut [u8]) { + let h = Hkdf::::new(salt, ikm); + h.expand_multi_info(info, out).expect("hkdf output length within limits"); +} + +/// The recipient: FrodoKEM public key || ML-KEM encapsulation key. +pub struct Recipient { + bytes: Vec, + frodo: frodo::PublicKey, + mlkem: EncapsulationKey, +} + +impl Recipient { + pub fn from_bytes(pub_key: &[u8]) -> Result { + if pub_key.len() != PUBLIC_KEY_SIZE { + return Err(format!("invalid {TAG} public key size")); + } + let frodo = frodo::PublicKey::from_bytes(&pub_key[..frodo::PUBLIC_KEY_SIZE]).ok_or("invalid FrodoKEM public key")?; + let ek = ml_kem::kem::Key::>::try_from(&pub_key[frodo::PUBLIC_KEY_SIZE..]) + .map_err(|_| "invalid ML-KEM public key")?; + let mlkem = EncapsulationKey::::new(&ek).map_err(|_| "invalid ML-KEM public key")?; + Ok(Recipient { + bytes: pub_key.to_vec(), + frodo, + mlkem, + }) + } + + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + /// Wraps `file_key` with fresh encapsulations drawn from `rng`. + pub fn wrap(&self, file_key: &[u8; FILE_KEY_SIZE], rng: &mut Rng) -> Stanza { + let mut mu = Zeroizing::new([0u8; frodo::ENCAPSULATION_SEED_SIZE]); + rng.fill(&mut mu[..]); + let mut m = Zeroizing::new([0u8; 32]); + rng.fill(&mut m[..]); + self.wrap_with(file_key, &mu, &m) + } + + fn wrap_with(&self, file_key: &[u8; FILE_KEY_SIZE], mu: &[u8; 16], m: &[u8; 32]) -> Stanza { + let (ct1, mut ss1) = self.frodo.encapsulate(mu); + let (ct2, ss2) = self.mlkem.encapsulate_deterministic(&(*m).into()); + let mut ss2 = Zeroizing::new(ss2); + let kek = derive_kek(&ss1, &ss2[..], &ct1, &ct2[..]); + ss1.zeroize(); + ss2.zeroize(); + let body = seal_once(&kek, file_key); + Stanza { + typ: TAG.into(), + args: vec![header::encode(&ct1), header::encode(&ct2[..])], + body, + } + } +} + +/// HKDF over both shared secrets, binding both ciphertexts. +fn derive_kek(ss1: &[u8], ss2: &[u8], ct1: &[u8], ct2: &[u8]) -> SecretArray<32> { + let mut secret = Zeroizing::new([0u8; frodo::SHARED_KEY_SIZE + 32]); + secret[..frodo::SHARED_KEY_SIZE].copy_from_slice(ss1); + secret[frodo::SHARED_KEY_SIZE..].copy_from_slice(ss2); + let label = format!("fury/v1 kek {TAG}"); + let mut kek = SecretArray::<32>::zero(); + hkdf_expand(&secret[..], None, &[label.as_bytes(), ct1, ct2], kek.expose_mut()); + kek +} + +/// ChaCha20-Poly1305 under an all-zero nonce, for a key used exactly once. +fn seal_once(kek: &SecretArray<32>, file_key: &[u8; FILE_KEY_SIZE]) -> Vec { + let mut k = Key::from(*kek.expose()); + let aead = ChaCha20Poly1305::new(&k); + k.zeroize(); + let mut body = file_key.to_vec(); + let tag = aead + .encrypt_inout_detached(&Nonce::default(), &[], body.as_mut_slice().into()) + .expect("32 bytes fit"); + body.extend_from_slice(&tag); + body +} + +fn open_once(kek: &SecretArray<32>, body: &[u8]) -> Option> { + if body.len() != FILE_KEY_SIZE + 16 { + return None; + } + let mut k = Key::from(*kek.expose()); + let aead = ChaCha20Poly1305::new(&k); + k.zeroize(); + let mut out = SecretArray::::zero(); + out.expose_mut().copy_from_slice(&body[..FILE_KEY_SIZE]); + let tag = Tag::try_from(&body[FILE_KEY_SIZE..]).ok()?; + aead.decrypt_inout_detached(&Nonce::default(), &[], (&mut out.expose_mut()[..]).into(), &tag) + .ok()?; + Some(out) +} + +/// A private identity derived from a master seed. It owns the seed and +/// both expanded private keys; dropping it clears what this crate owns +/// (see `crate::secret` for the limits of that). There is no way to use an +/// identity after [`Identity::close`] or drop: +/// +/// ```compile_fail +/// let id = vault::crypto::kem::Identity::generate(&mut vault::rng::os()); +/// id.close(); +/// let _ = id.seed(); // use of a moved value +/// ``` +pub struct Identity { + seed: SecretArray, + frodo: frodo::PrivateKey, + mlkem: Box>, + recipient_bytes: Vec, +} + +impl Identity { + pub fn generate(rng: &mut Rng) -> Self { + let mut seed = SecretArray::::zero(); + rng.fill(seed.expose_mut()); + Self::from_seed(seed) + } + + /// Derives the identity of `suite` from its master seed. + pub fn from_suite_seed(suite: u16, seed: &[u8]) -> Result { + if suite != SUITE_ID { + return Err(format!("unknown suite id {suite:#06x}")); + } + let s = SecretArray::::from_slice(seed).ok_or_else(|| format!("invalid seed size {} for suite {TAG}", seed.len()))?; + Ok(Self::from_seed(s)) + } + + pub fn from_seed(seed: SecretArray) -> Self { + let mut expanded = Zeroizing::new([0u8; frodo::KEY_SEED_SIZE + MLKEM_SEED]); + let info = format!("fury/v1 seed-expand {TAG}"); + hkdf_expand(seed.expose(), None, &[info.as_bytes()], &mut expanded[..]); + let mut fseed = Zeroizing::new([0u8; frodo::KEY_SEED_SIZE]); + fseed.copy_from_slice(&expanded[..frodo::KEY_SEED_SIZE]); + let frodo = frodo::PrivateKey::derive(&fseed); + // ml-kem takes the seed by value (a stack copy we cannot clear) and + // keeps it, zeroizing it on drop. + let mut mseed = ml_kem::Seed::default(); + mseed.copy_from_slice(&expanded[frodo::KEY_SEED_SIZE..]); + let mlkem = Box::new(DecapsulationKey::::from_seed(mseed)); + mseed.zeroize(); + let mut recipient_bytes = frodo.public().to_bytes(); + recipient_bytes.extend_from_slice(&mlkem.encapsulation_key().to_bytes()); + Identity { + seed, + frodo, + mlkem, + recipient_bytes, + } + } + + pub fn suite_id(&self) -> u16 { + SUITE_ID + } + + /// The master seed: secret-equivalent to the whole identity. + pub fn seed(&self) -> &[u8; SEED_SIZE] { + self.seed.expose() + } + + pub fn recipient(&self) -> Recipient { + Recipient::from_bytes(&self.recipient_bytes).expect("derived key is valid") + } + + pub fn recipient_bytes(&self) -> &[u8] { + &self.recipient_bytes + } + + /// The private key bytes as Go serialises them (FrodoKEM || ML-KEM + /// expanded), for compatibility checks only. + #[allow(deprecated)] + pub fn private_key_bytes(&self) -> Zeroizing> { + use ml_kem::ExpandedKeyEncoding; + let mut v = self.frodo.to_bytes(); + let mut m = self.mlkem.to_expanded_bytes(); + v.extend_from_slice(&m); + m.zeroize(); + v + } + + pub fn close(self) {} + + /// Go's `unwrap` for one stanza. + pub fn unwrap_stanza(&self, s: &Stanza) -> Result, UnwrapError> { + if s.typ != TAG { + return Err(UnwrapError::NotMine); + } + if s.args.len() != 2 { + return Err(UnwrapError::Malformed(format!("invalid {TAG} stanza: expected 2 arguments"))); + } + let ct1 = header::decode(s.args[0].as_bytes()).map_err(|e| UnwrapError::Malformed(format!("invalid FrodoKEM ciphertext: {e}")))?; + let ct2 = header::decode(s.args[1].as_bytes()).map_err(|e| UnwrapError::Malformed(format!("invalid ML-KEM ciphertext: {e}")))?; + if ct1.len() != frodo::CIPHERTEXT_SIZE || ct2.len() != MLKEM_CT { + return Err(UnwrapError::Malformed("encrypted value has unexpected length".into())); + } + let mut ss1 = self.frodo.decapsulate(&ct1).expect("length checked"); + let ct2a = ml_kem::Ciphertext::::try_from(&ct2[..]).expect("length checked"); + let mut ss2 = self.mlkem.decapsulate(&ct2a); + // Both KEMs reject implicitly: a key mismatch shows up as a wrong + // KEK, which the AEAD below detects. + let kek = derive_kek(&ss1, &ss2[..], &ct1, &ct2); + ss1.zeroize(); + ss2.zeroize(); + open_once(&kek, &s.body).ok_or(UnwrapError::NotMine) + } + + /// Go's `multiUnwrap`: the first stanza that opens wins; a malformed + /// stanza of this suite stops the search. + pub fn unwrap(&self, stanzas: &[Stanza]) -> Result, UnwrapError> { + for s in stanzas { + match self.unwrap_stanza(s) { + Err(UnwrapError::NotMine) => continue, + r => return r, + } + } + Err(UnwrapError::NotMine) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::crypto::stream::tests::hex; + use serde::Deserialize; + use sha2::Digest; + + pub fn unhex(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() + } + + fn sha(b: &[u8]) -> String { + hex(&sha2::Sha256::digest(b)) + } + + #[derive(Deserialize)] + pub struct Crypto { + pub frodo640shake: Vec, + pub mlkem768: Vec, + pub hybrid: Vec, + pub wrap: Vec, + pub header_cases: Vec, + } + #[derive(Deserialize)] + pub struct KemV { + seed: String, + pub_sha256: String, + priv_sha256: String, + encap_seed: String, + ct_sha256: String, + ss: String, + reject_flip_index: usize, + reject_ss: String, + } + #[derive(Deserialize)] + pub struct HybridV { + master_seed: String, + pub_sha256: String, + priv_sha256: String, + pub_prefix: String, + } + #[derive(Deserialize)] + pub struct WrapV { + pub master_seed: String, + frodo_encap_seed: String, + mlkem_encap_seed: String, + pub file_key: String, + kek: String, + pub header_hex: String, + pub mac: String, + pub nonce: String, + pub payload_key: String, + stanza_arg0_sha256: String, + stanza_body: String, + } + #[derive(Deserialize)] + pub struct HeaderCase { + pub input_hex: String, + pub accept: bool, + pub rest_hex: Option, + } + + pub fn vectors() -> Crypto { + serde_json::from_str(include_str!("../../tests/vectors/crypto.json")).unwrap() + } + + #[test] + fn frodo_matches_circl() { + for v in vectors().frodo640shake { + let seed: [u8; frodo::KEY_SEED_SIZE] = unhex(&v.seed).try_into().unwrap(); + let sk = frodo::PrivateKey::derive(&seed); + let pk = sk.public().to_bytes(); + assert_eq!(sha(&pk), v.pub_sha256); + assert_eq!(sha(&sk.to_bytes()), v.priv_sha256); + let pk2 = frodo::PublicKey::from_bytes(&pk).unwrap(); + let mu: [u8; 16] = unhex(&v.encap_seed).try_into().unwrap(); + let (ct, ss) = pk2.encapsulate(&mu); + assert_eq!(sha(&ct), v.ct_sha256); + assert_eq!(hex(&ss), v.ss); + assert_eq!(hex(&sk.decapsulate(&ct).unwrap()), v.ss); + let mut bad = ct.clone(); + bad[v.reject_flip_index] ^= 1; + assert_eq!(hex(&sk.decapsulate(&bad).unwrap()), v.reject_ss, "implicit rejection"); + } + } + + #[test] + #[allow(deprecated)] + fn mlkem_matches_circl() { + use ml_kem::ExpandedKeyEncoding; + for v in vectors().mlkem768 { + let mut seed = ml_kem::Seed::default(); + seed.copy_from_slice(&unhex(&v.seed)); + let dk = DecapsulationKey::::from_seed(seed); + let ek = dk.encapsulation_key(); + assert_eq!(sha(&ek.to_bytes()), v.pub_sha256); + assert_eq!(sha(&dk.to_expanded_bytes()), v.priv_sha256); + let m: [u8; 32] = unhex(&v.encap_seed).try_into().unwrap(); + let (ct, ss) = ek.encapsulate_deterministic(&m.into()); + assert_eq!(sha(&ct), v.ct_sha256); + assert_eq!(hex(&ss), v.ss); + let mut bad = ct; + bad[v.reject_flip_index] ^= 1; + assert_eq!(hex(&dk.decapsulate(&bad)), v.reject_ss, "implicit rejection"); + } + } + + #[test] + fn hybrid_derivation_matches_go() { + for v in vectors().hybrid { + let id = Identity::from_suite_seed(SUITE_ID, &unhex(&v.master_seed)).unwrap(); + assert_eq!(sha(id.recipient_bytes()), v.pub_sha256); + assert_eq!(hex(&id.recipient_bytes()[..64]), v.pub_prefix); + assert_eq!(sha(&id.private_key_bytes()), v.priv_sha256); + } + assert!(Identity::from_suite_seed(2, &[0; 32]).is_err()); + assert!(Identity::from_suite_seed(SUITE_ID, &[0; 31]).is_err()); + } + + #[test] + fn wrap_matches_go_and_unwraps() { + for v in vectors().wrap { + let id = Identity::from_suite_seed(SUITE_ID, &unhex(&v.master_seed)).unwrap(); + let fk: [u8; 32] = unhex(&v.file_key).try_into().unwrap(); + let mu: [u8; 16] = unhex(&v.frodo_encap_seed).try_into().unwrap(); + let m: [u8; 32] = unhex(&v.mlkem_encap_seed).try_into().unwrap(); + let st = id.recipient().wrap_with(&fk, &mu, &m); + assert_eq!(sha(st.args[0].as_bytes()), v.stanza_arg0_sha256); + assert_eq!(hex(&st.body), v.stanza_body); + let ct1 = header::decode(st.args[0].as_bytes()).unwrap(); + let ct2 = header::decode(st.args[1].as_bytes()).unwrap(); + let ss1 = id.frodo.decapsulate(&ct1).unwrap(); + let ss2 = id.mlkem.decapsulate(&ml_kem::Ciphertext::::try_from(&ct2[..]).unwrap()); + assert_eq!(hex(derive_kek(&ss1, &ss2[..], &ct1, &ct2).expose()), v.kek); + assert_eq!(id.unwrap(&[st]).unwrap().expose(), &fk); + } + } + + #[test] + fn fresh_wraps_differ_and_foreign_keys_do_not_match() { + let a = Identity::generate(&mut crate::rng::os()); + let b = Identity::generate(&mut crate::rng::os()); + let fk = [7u8; 32]; + let s1 = a.recipient().wrap(&fk, &mut crate::rng::os()); + let s2 = a.recipient().wrap(&fk, &mut crate::rng::os()); + assert!(s1.args != s2.args && s1.body != s2.body, "encapsulations must be fresh"); + assert_eq!(b.unwrap(std::slice::from_ref(&s1)).err(), Some(UnwrapError::NotMine)); + let mut bad = s1.clone(); + bad.args.pop(); + assert!(matches!(a.unwrap(&[bad]), Err(UnwrapError::Malformed(_)))); + let mut other = s1.clone(); + other.typ = "x25519".into(); + assert_eq!(a.unwrap(&[other, s1]).unwrap().expose(), &fk); + } +} diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs new file mode 100644 index 0000000..278b9b9 --- /dev/null +++ b/src/crypto/mod.rs @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Fury file encryption, compatible with the Go `internal/pqcrypt`, +//! `internal/format` and `internal/stream` packages. + +pub mod frodo; +pub mod fury; +pub mod header; +pub mod kem; +pub mod stream; diff --git a/src/crypto/stream.rs b/src/crypto/stream.rs new file mode 100644 index 0000000..ef69c5f --- /dev/null +++ b/src/crypto/stream.rs @@ -0,0 +1,759 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The STREAM payload encryption of the Go `internal/stream` package. +//! +//! The plaintext is cut into 64 KiB chunks, each sealed with +//! ChaCha20-Poly1305 under a 12-byte nonce whose bytes 3..11 hold the +//! big-endian chunk index and whose last byte is 1 for the final chunk +//! only. Only the final chunk may be short; it may be empty only when it is +//! the sole chunk (an empty plaintext). A reader rejects truncation (no +//! final-flagged chunk), an empty non-first final chunk, and any byte after +//! the final chunk. +//! +//! Chunk nonces depend only on the index, so chunks may be sealed or opened +//! in any order and produce identical bytes; the readers here classify each +//! chunk as final or not from the shape of the stream (one chunk of +//! read-ahead) and open it exactly once, which is the same verdict Go's +//! sequential try-then-retry reader reaches. + +use chacha20poly1305::aead::{AeadInOut, KeyInit}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce, Tag}; +use std::io::{self, Read, Write}; +use zeroize::Zeroize; + +pub const CHUNK_SIZE: usize = 64 * 1024; +pub const TAG_SIZE: usize = 16; +pub const ENC_CHUNK_SIZE: usize = CHUNK_SIZE + TAG_SIZE; +const LAST_CHUNK_FLAG: u8 = 0x01; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StreamError { + /// The stream ended before a final chunk. + Truncated, + /// A chunk failed to authenticate. + Auth, + EmptyLastChunk, + TrailingData, + CounterOverflow, + /// The ciphertext length cannot be a STREAM encoding. + InvalidSize(u64), +} + +impl std::fmt::Display for StreamError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + StreamError::Truncated => "unexpected eof: encrypted payload is truncated", + StreamError::Auth => "failed to decrypt and authenticate payload chunk, file may be corrupted or tampered with", + StreamError::EmptyLastChunk => "last chunk is empty; encrypted payload is malformed", + StreamError::TrailingData => "trailing data after end of encrypted file", + StreamError::CounterOverflow => "stream: chunk counter would wrap around", + StreamError::InvalidSize(n) => return write!(f, "invalid encrypted payload size: {n}"), + }) + } +} + +impl std::error::Error for StreamError {} + +impl From for io::Error { + fn from(e: StreamError) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, e) + } +} + +pub fn nonce(index: u64, last: bool) -> Nonce { + let mut n = [0u8; 12]; + n[3..11].copy_from_slice(&index.to_be_bytes()); + if last { + n[11] = LAST_CHUNK_FLAG; + } + Nonce::from(n) +} + +/// A STREAM key. The cipher state holds a copy of the key that +/// `chacha20poly1305` does not expose for clearing; see SECURITY.md. +pub struct Cipher(ChaCha20Poly1305); + +impl Cipher { + pub fn new(key: &[u8; 32]) -> Self { + let mut k = Key::from(*key); + let c = ChaCha20Poly1305::new(&k); + k.zeroize(); + Cipher(c) + } + + /// Seals `buf` (at most CHUNK_SIZE bytes of plaintext followed by + /// TAG_SIZE bytes of room) in place. + pub fn seal(&self, index: u64, last: bool, buf: &mut [u8]) { + let n = buf.len() - TAG_SIZE; + debug_assert!(n <= CHUNK_SIZE); + let (msg, tag) = buf.split_at_mut(n); + let t = self + .0 + .encrypt_inout_detached(&nonce(index, last), &[], msg.into()) + .expect("chunk within aead limits"); + tag.copy_from_slice(&t); + } + + /// Opens a sealed chunk in place; on success the first `len - 16` bytes + /// are plaintext. On failure the buffer holds no plaintext. + pub fn open(&self, index: u64, last: bool, buf: &mut [u8]) -> Result { + if buf.len() < TAG_SIZE { + return Err(StreamError::Auth); + } + let n = buf.len() - TAG_SIZE; + let (msg, tag) = buf.split_at_mut(n); + let tag = Tag::try_from(&*tag).expect("tag size"); + self.0 + .decrypt_inout_detached(&nonce(index, last), &[], msg.into(), &tag) + .map_err(|_| StreamError::Auth)?; + Ok(n) + } +} + +fn next_index(i: u64) -> Result { + i.checked_add(1).ok_or(StreamError::CounterOverflow) +} + +/// Encrypting writer. Call [`StreamWriter::finish`] to seal the final +/// chunk; dropping without finishing leaves a truncated stream that every +/// reader rejects. +pub struct StreamWriter { + cipher: Cipher, + dst: Option, + buf: Vec, + index: u64, +} + +impl StreamWriter { + pub fn new(key: &[u8; 32], dst: W) -> Self { + StreamWriter { + cipher: Cipher::new(key), + dst: Some(dst), + buf: Vec::with_capacity(ENC_CHUNK_SIZE), + index: 0, + } + } + + #[cfg(test)] + fn starting_at(key: &[u8; 32], dst: W, index: u64) -> Self { + let mut w = Self::new(key, dst); + w.index = index; + w + } + + fn flush_chunk(&mut self, last: bool) -> io::Result<()> { + let n = self.buf.len(); + self.buf.resize(n + TAG_SIZE, 0); + self.cipher.seal(self.index, last, &mut self.buf); + self.dst.as_mut().expect("writer in use").write_all(&self.buf)?; + self.buf.clear(); + self.index = next_index(self.index)?; + Ok(()) + } + + pub fn finish(mut self) -> io::Result { + self.flush_chunk(true)?; + Ok(self.dst.take().expect("writer in use")) + } +} + +impl Write for StreamWriter { + fn write(&mut self, mut p: &[u8]) -> io::Result { + let total = p.len(); + while !p.is_empty() { + let take = p.len().min(CHUNK_SIZE - self.buf.len()); + self.buf.extend_from_slice(&p[..take]); + p = &p[take..]; + // Flush a full chunk only once more data proves it is not the + // last one. + if self.buf.len() == CHUNK_SIZE && !p.is_empty() { + self.flush_chunk(false)?; + } + } + Ok(total) + } + + fn flush(&mut self) -> io::Result<()> { + self.dst.as_mut().expect("writer in use").flush() + } +} + +impl Drop for StreamWriter { + fn drop(&mut self) { + self.buf.zeroize(); + } +} + +/// Reads until `buf` is full or the source ends; returns the count. +pub fn read_full(src: &mut impl Read, buf: &mut [u8]) -> io::Result { + let mut n = 0; + while n < buf.len() { + match src.read(&mut buf[n..]) { + Ok(0) => break, + Ok(k) => n += k, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(n) +} + +/// After a final chunk: the source must be at end of input. +fn expect_eof(src: &mut impl Read) -> io::Result<()> { + let mut b = [0u8; 1]; + loop { + match src.read(&mut b) { + Ok(0) => return Ok(()), + Ok(_) => return Err(StreamError::TrailingData.into()), + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } +} + +/// Decrypting reader with one chunk of read-ahead: a full-length chunk is +/// held until the next read shows whether anything follows it. +pub struct StreamReader { + cipher: Cipher, + src: R, + index: u64, + started: bool, + done: bool, + failed: bool, + pending: Option>, + /// Plaintext of a short final chunk, opened together with the chunk + /// before it and delivered after it. + queued: Option>, + out: Vec, + pos: usize, + spare: Vec>, +} + +impl StreamReader { + pub fn new(key: &[u8; 32], src: R) -> Self { + StreamReader { + cipher: Cipher::new(key), + src, + index: 0, + started: false, + done: false, + failed: false, + pending: None, + queued: None, + out: Vec::new(), + pos: 0, + spare: Vec::new(), + } + } + + /// Reads one raw chunk into a recycled buffer, truncated to its length. + fn read_raw(&mut self) -> io::Result> { + let mut b = self.spare.pop().unwrap_or_default(); + b.resize(ENC_CHUNK_SIZE, 0); + let n = read_full(&mut self.src, &mut b)?; + b.truncate(n); + Ok(b) + } + + /// Opens `raw` in place as chunk `self.index`, leaving its plaintext. + fn open(&mut self, mut raw: Vec, last: bool) -> io::Result> { + match self.cipher.open(self.index, last, &mut raw) { + Ok(n) => { + raw.truncate(n); + self.index = next_index(self.index)?; + Ok(raw) + } + Err(e) => { + raw.zeroize(); + Err(e.into()) + } + } + } + + fn deliver(&mut self, plain: Vec) { + let mut old = std::mem::replace(&mut self.out, plain); + old.zeroize(); + old.clear(); + self.spare.push(old); + self.pos = 0; + } + + fn advance(&mut self) -> io::Result<()> { + if !self.started { + self.started = true; + let raw = self.read_raw()?; + if raw.is_empty() { + return Err(StreamError::Truncated.into()); + } + if raw.len() < ENC_CHUNK_SIZE { + // A short first chunk is final; empty is the empty file. + let p = self.open(raw, true)?; + expect_eof(&mut self.src)?; + self.deliver(p); + self.done = true; + return Ok(()); + } + self.pending = Some(raw); + } + let pending = self.pending.take().expect("pending chunk"); + let next = self.read_raw()?; + if next.is_empty() { + // Nothing follows: a full-length final chunk, and end of input + // has just been seen. + let p = self.open(pending, true)?; + self.deliver(p); + self.done = true; + } else if next.len() < ENC_CHUNK_SIZE { + if next.len() == TAG_SIZE { + return Err(StreamError::EmptyLastChunk.into()); + } + let p = self.open(pending, false)?; + let q = self.open(next, true)?; + expect_eof(&mut self.src)?; + self.deliver(p); + self.queued = Some(q); + self.done = true; + } else { + let p = self.open(pending, false)?; + self.deliver(p); + self.pending = Some(next); + } + Ok(()) + } +} + +impl Read for StreamReader { + fn read(&mut self, p: &mut [u8]) -> io::Result { + if p.is_empty() { + return Ok(0); + } + loop { + if self.pos < self.out.len() { + let n = p.len().min(self.out.len() - self.pos); + p[..n].copy_from_slice(&self.out[self.pos..self.pos + n]); + self.pos += n; + return Ok(n); + } + if let Some(q) = self.queued.take() { + self.deliver(q); + continue; + } + if self.failed { + return Err(io::Error::new(io::ErrorKind::InvalidData, "encrypted stream already failed")); + } + if self.done { + return Ok(0); + } + if let Err(e) = self.advance() { + self.failed = true; + self.out.zeroize(); + self.out.clear(); + return Err(e); + } + } + } +} + +impl Drop for StreamReader { + fn drop(&mut self) { + self.out.zeroize(); + for b in self.pending.iter_mut().chain(self.queued.iter_mut()).chain(self.spare.iter_mut()) { + b.zeroize(); + } + } +} + +/// Seals a whole plaintext held in memory, chunk by chunk, appending to +/// `out`. Byte-identical to [`StreamWriter`]. +pub fn seal_all(key: &[u8; 32], plaintext: &[u8], out: &mut Vec) { + let c = Cipher::new(key); + let chunks = plaintext.len().div_ceil(CHUNK_SIZE).max(1); + out.reserve(plaintext.len() + chunks * TAG_SIZE); + for i in 0..chunks { + let start = i * CHUNK_SIZE; + let end = (start + CHUNK_SIZE).min(plaintext.len()); + let at = out.len(); + out.extend_from_slice(&plaintext[start..end]); + out.resize(out.len() + TAG_SIZE, 0); + c.seal(i as u64, i + 1 == chunks, &mut out[at..]); + } +} + +/// Opens a whole sealed payload in place. On success the plaintext is +/// `buf[..returned]`; on any failure the buffer is cleared. +pub fn open_all(key: &[u8; 32], buf: &mut [u8]) -> Result { + let r = open_all_inner(key, buf); + if r.is_err() { + buf.zeroize(); + } + r +} + +fn open_all_inner(key: &[u8; 32], buf: &mut [u8]) -> Result { + let size = buf.len() as u64; + let chunks = encrypted_chunk_count(size)?; + let c = Cipher::new(key); + let mut plain = 0usize; + for i in 0..chunks { + let start = i as usize * ENC_CHUNK_SIZE; + let end = (start + ENC_CHUNK_SIZE).min(buf.len()); + let n = c.open(i, i + 1 == chunks, &mut buf[start..end])?; + // Compact: plaintext of chunk i lands at i*CHUNK_SIZE <= start. + buf.copy_within(start..start + n, plain); + plain += n; + } + Ok(plain) +} + +/// The number of chunks in a payload of `size` bytes, or an error if no +/// STREAM encoding has that size (Go's `EncryptedChunkCount`). +pub fn encrypted_chunk_count(size: u64) -> Result { + let enc = ENC_CHUNK_SIZE as u64; + let chunks = size.div_ceil(enc); + let plain = size.checked_sub(chunks * TAG_SIZE as u64).ok_or(StreamError::InvalidSize(size))?; + let expected = if plain == 0 { 1 } else { plain.div_ceil(CHUNK_SIZE as u64) }; + if expected != chunks { + return Err(StreamError::InvalidSize(size)); + } + Ok(chunks) +} + +pub fn plaintext_size(encrypted: u64) -> Result { + Ok(encrypted - encrypted_chunk_count(encrypted)? * TAG_SIZE as u64) +} + +/// Positional reads, as `std::os::unix::fs::FileExt::read_at`. +pub trait ReadAt { + fn read_at(&self, buf: &mut [u8], offset: u64) -> io::Result; +} + +impl ReadAt for [u8] { + fn read_at(&self, buf: &mut [u8], offset: u64) -> io::Result { + let off = usize::try_from(offset).unwrap_or(usize::MAX).min(self.len()); + let n = buf.len().min(self.len() - off); + buf[..n].copy_from_slice(&self[off..off + n]); + Ok(n) + } +} + +impl ReadAt for std::fs::File { + fn read_at(&self, buf: &mut [u8], offset: u64) -> io::Result { + #[cfg(unix)] + { + std::os::unix::fs::FileExt::read_at(self, buf, offset) + } + #[cfg(windows)] + { + std::os::windows::fs::FileExt::seek_read(self, buf, offset) + } + #[cfg(not(any(unix, windows)))] + { + let _ = (buf, offset); + Err(io::Error::new(io::ErrorKind::Unsupported, "positional reads unsupported")) + } + } +} + +fn read_exact_at(src: &S, buf: &mut [u8], mut off: u64) -> io::Result<()> { + let mut done = 0; + while done < buf.len() { + match src.read_at(&mut buf[done..], off) { + Ok(0) => return Err(io::ErrorKind::UnexpectedEof.into()), + Ok(n) => { + done += n; + off += n as u64; + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(()) +} + +/// Random-access decryption (Go's `DecryptReaderAt`). Construction checks +/// the size is a valid encoding and authenticates the final chunk, so a +/// truncated or extended payload is rejected before any read. +pub struct StreamReaderAt<'a, S: ReadAt + ?Sized> { + cipher: Cipher, + src: &'a S, + size: u64, + chunks: u64, + cache: std::sync::Mutex)>>, +} + +impl<'a, S: ReadAt + ?Sized> StreamReaderAt<'a, S> { + pub fn new(key: &[u8; 32], src: &'a S, encrypted_size: u64) -> io::Result { + let cipher = Cipher::new(key); + let chunks = encrypted_chunk_count(encrypted_size)?; + let last = chunks - 1; + let off = last * ENC_CHUNK_SIZE as u64; + let mut buf = vec![0u8; (encrypted_size - off) as usize]; + read_exact_at(src, &mut buf, off)?; + let n = cipher.open(last, true, &mut buf).inspect_err(|_| buf.zeroize())?; + buf.truncate(n); + Ok(StreamReaderAt { + cipher, + src, + size: encrypted_size - chunks * TAG_SIZE as u64, + chunks, + cache: std::sync::Mutex::new(Some((last, buf))), + }) + } + + pub fn size(&self) -> u64 { + self.size + } + + /// Fills as much of `p` as lies before the end of the plaintext and + /// returns the count; reading at or past the end returns 0. + pub fn read_at(&self, p: &mut [u8], mut off: u64) -> io::Result { + if off > self.size { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("offset out of range [0:{}]: {off}", self.size), + )); + } + let mut n = 0; + let mut chunk = vec![0u8; ENC_CHUNK_SIZE]; + let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + while n < p.len() && off < self.size { + let idx = off / CHUNK_SIZE as u64; + let plain: &[u8] = match &*cache { + Some((i, data)) if *i == idx => data, + _ => { + let coff = idx * ENC_CHUNK_SIZE as u64; + let enc = (self.size + self.chunks * TAG_SIZE as u64 - coff).min(ENC_CHUNK_SIZE as u64) as usize; + read_exact_at(self.src, &mut chunk[..enc], coff)?; + let k = self.cipher.open(idx, idx == self.chunks - 1, &mut chunk[..enc])?; + if let Some((_, old)) = cache.as_mut() { + old.zeroize(); + } + *cache = Some((idx, chunk[..k].to_vec())); + &cache.as_ref().expect("just set").1 + } + }; + let within = (off - idx * CHUNK_SIZE as u64) as usize; + let take = (plain.len() - within).min(p.len() - n); + p[n..n + take].copy_from_slice(&plain[within..within + take]); + n += take; + off += take as u64; + } + chunk.zeroize(); + Ok(n) + } +} + +impl Drop for StreamReaderAt<'_, S> { + fn drop(&mut self) { + if let Some((_, d)) = self.cache.get_mut().unwrap_or_else(|e| e.into_inner()).as_mut() { + d.zeroize(); + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + fn pt(n: usize) -> Vec { + (0..n).map(|i| (i * 13 + 7) as u8).collect() + } + + fn seal(key: &[u8; 32], p: &[u8]) -> Vec { + let mut w = StreamWriter::new(key, Vec::new()); + w.write_all(p).unwrap(); + w.finish().unwrap() + } + + fn open_stream(key: &[u8; 32], c: &[u8]) -> io::Result> { + let mut out = Vec::new(); + StreamReader::new(key, c).read_to_end(&mut out)?; + Ok(out) + } + + #[derive(serde::Deserialize)] + struct Vectors { + key: String, + vectors: Vec, + } + #[derive(serde::Deserialize)] + struct V { + size: usize, + ct_len: usize, + ct_sha256: String, + ct_hex: Option, + } + + fn sha256(b: &[u8]) -> String { + use sha2::Digest; + hex(&sha2::Sha256::digest(b)) + } + + pub(crate) fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() + } + + #[test] + fn go_oracle_vectors() { + let v: Vectors = serde_json::from_str(include_str!("../../tests/vectors/stream.json")).unwrap(); + let key: [u8; 32] = (1..=32).collect::>().try_into().unwrap(); + assert_eq!(hex(&key), v.key); + for t in v.vectors { + let p = pt(t.size); + let c = seal(&key, &p); + assert_eq!(c.len(), t.ct_len, "size {}", t.size); + assert_eq!(sha256(&c), t.ct_sha256, "size {}", t.size); + if let Some(h) = t.ct_hex { + assert_eq!(hex(&c), h); + } + let mut all = Vec::new(); + seal_all(&key, &p, &mut all); + assert_eq!(all, c, "seal_all size {}", t.size); + assert_eq!(open_stream(&key, &c).unwrap(), p); + let n = open_all(&key, &mut all).unwrap(); + assert_eq!(&all[..n], &p[..]); + assert_eq!(plaintext_size(c.len() as u64).unwrap(), t.size as u64); + } + } + + #[test] + fn writes_in_any_steps_match() { + let key = [3u8; 32]; + let p = pt(3 * CHUNK_SIZE + 5); + let whole = seal(&key, &p); + for step in [1, 7, 4096, CHUNK_SIZE - 1, CHUNK_SIZE, CHUNK_SIZE + 1] { + let mut w = StreamWriter::new(&key, Vec::new()); + for c in p.chunks(step) { + w.write_all(c).unwrap(); + } + assert_eq!(w.finish().unwrap(), whole, "step {step}"); + } + } + + #[test] + fn reads_in_any_steps_match() { + let key = [4u8; 32]; + for size in [0, 1, CHUNK_SIZE, CHUNK_SIZE + 1, 2 * CHUNK_SIZE, 2 * CHUNK_SIZE + 17] { + let p = pt(size); + let c = seal(&key, &p); + for step in [1, 1000, CHUNK_SIZE, CHUNK_SIZE + 3] { + let mut r = StreamReader::new(&key, &c[..]); + let mut got = Vec::new(); + let mut b = vec![0u8; step]; + loop { + let n = r.read(&mut b).unwrap(); + if n == 0 { + break; + } + got.extend_from_slice(&b[..n]); + } + assert_eq!(got, p, "size {size} step {step}"); + } + } + } + + /// Every malformation is rejected by the streaming reader, the in-memory + /// opener and (where the size is valid) the random-access reader alike. + #[test] + fn malformed_streams_rejected_consistently() { + let key = [5u8; 32]; + let mut cases: Vec<(String, Vec)> = Vec::new(); + for size in [ + 0, + 1, + CHUNK_SIZE - 1, + CHUNK_SIZE, + CHUNK_SIZE + 1, + 2 * CHUNK_SIZE, + 2 * CHUNK_SIZE + 100, + ] { + let c = seal(&key, &pt(size)); + cases.push((format!("{size} truncated by one"), c[..c.len() - 1].to_vec())); + cases.push((format!("{size} trailing byte"), [&c[..], &[0]].concat())); + cases.push((format!("{size} trailing chunk"), [&c[..], &c[..]].concat())); + if c.len() > ENC_CHUNK_SIZE { + cases.push((format!("{size} final chunk dropped"), c[..ENC_CHUNK_SIZE].to_vec())); + let mut swapped = c.clone(); + swapped[..ENC_CHUNK_SIZE].copy_from_slice(&c[c.len() - ENC_CHUNK_SIZE..]); + if swapped != c { + cases.push((format!("{size} chunk swapped"), swapped)); + } + } + for at in [0, c.len() / 2, c.len() - 1] { + let mut f = c.clone(); + f[at] ^= 0x80; + cases.push((format!("{size} flipped at {at}"), f)); + } + } + cases.push(("empty".into(), Vec::new())); + cases.push(("tag only, second chunk".into(), { + // A full non-final chunk followed by an empty final chunk is + // never produced and must be refused. + let p = pt(CHUNK_SIZE); + let c = Cipher::new(&key); + let mut a = p.clone(); + a.resize(ENC_CHUNK_SIZE, 0); + c.seal(0, false, &mut a); + let mut b = vec![0u8; TAG_SIZE]; + c.seal(1, true, &mut b); + [a, b].concat() + })); + for (name, c) in cases { + assert!(open_stream(&key, &c).is_err(), "stream accepted: {name}"); + let mut b = c.clone(); + assert!(open_all(&key, &mut b).is_err(), "open_all accepted: {name}"); + assert!(b.iter().all(|&x| x == 0), "open_all left data behind: {name}"); + if let Ok(r) = StreamReaderAt::new(&key, &c[..], c.len() as u64) { + let mut out = vec![0u8; r.size() as usize]; + assert!(r.read_at(&mut out, 0).is_err(), "reader-at accepted: {name}"); + } + } + let wrong = seal(&[6u8; 32], b"x"); + assert!(open_stream(&key, &wrong).is_err()); + } + + #[test] + fn random_access() { + let key = [8u8; 32]; + for size in [0, 1, CHUNK_SIZE, CHUNK_SIZE + 1, 3 * CHUNK_SIZE - 5] { + let p = pt(size); + let c = seal(&key, &p); + let r = StreamReaderAt::new(&key, &c[..], c.len() as u64).unwrap(); + assert_eq!(r.size(), size as u64); + for (off, len) in [(0, size), (size / 2, size / 3 + 1), (size.saturating_sub(1), 10), (size, 4)] { + let mut b = vec![0u8; len]; + let n = r.read_at(&mut b, off as u64).unwrap(); + let want = &p[off.min(size)..(off + len).min(size)]; + assert_eq!(&b[..n], want, "size {size} off {off} len {len}"); + } + assert!(r.read_at(&mut [0u8; 1], size as u64 + 1).is_err()); + } + // Concurrent readers share the cache safely. + let p = pt(4 * CHUNK_SIZE); + let c = seal(&key, &p); + let r = StreamReaderAt::new(&key, &c[..], c.len() as u64).unwrap(); + std::thread::scope(|s| { + for t in 0..8u64 { + let (r, p) = (&r, &p); + s.spawn(move || { + for i in 0..200u64 { + let off = ((i * 7919 + t * 104729) % p.len() as u64) as usize; + let mut b = [0u8; 100]; + let n = r.read_at(&mut b, off as u64).unwrap(); + assert_eq!(&b[..n], &p[off..(off + 100).min(p.len())]); + } + }); + } + }); + assert!(StreamReaderAt::new(&key, &c[..], c.len() as u64 - 1).is_err()); + assert!(encrypted_chunk_count(15).is_err()); + assert!(encrypted_chunk_count(ENC_CHUNK_SIZE as u64 + 16).is_err()); + } + + #[test] + fn counter_overflow_is_refused() { + let mut w = StreamWriter::starting_at(&[1u8; 32], Vec::new(), u64::MAX); + w.write_all(&pt(CHUNK_SIZE + 1)).unwrap_err(); + assert_eq!(next_index(u64::MAX), Err(StreamError::CounterOverflow)); + } +} diff --git a/src/keyfile.rs b/src/keyfile.rs new file mode 100644 index 0000000..badf294 --- /dev/null +++ b/src/keyfile.rs @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The passphrase-protected key file, byte-compatible with the Go version. +//! +//! A PEM block of type `VAULT KEY` holds 198 bytes: an 18-byte header +//! (`VAULTKEY1`, Argon2id time 3 and memory 65536 KiB as big-endian u32s, +//! and 4 lanes as one byte) and two 90-byte slots. A slot is a 16-byte +//! salt, a 24-byte XChaCha20-Poly1305 nonce and the sealed big-endian suite +//! ID and 32-byte master seed, with the header as associated data and the +//! key Argon2id(passphrase, salt). The second slot is a decoy identity or +//! random bytes, and which slot is real is random, so the file says nothing +//! about whether a decoy exists. Unlocking always runs Argon2id for both +//! slots, so timing does not reveal which one matched. + +use crate::crypto::kem::{self, Identity}; +use crate::rng::Rng; +use crate::secret::{SecretArray, SecretVec}; +use argon2::{Algorithm, Argon2, Block, Params, Version}; +use base64::Engine; +use chacha20poly1305::aead::{AeadInOut, KeyInit}; +use chacha20poly1305::{Tag, XChaCha20Poly1305, XNonce}; +use std::io::Write; +use std::path::Path; +use zeroize::Zeroize; + +pub const PEM_TYPE: &str = "VAULT KEY"; +const MAGIC: &[u8] = b"VAULTKEY1"; +const ARGON_TIME: u32 = 3; +const ARGON_MEMORY: u32 = 64 * 1024; // KiB +const ARGON_THREADS: u8 = 4; +const SALT: usize = 16; +const XNONCE: usize = 24; +const SLOT_PLAIN: usize = 2 + kem::SEED_SIZE; +pub const SLOT_SIZE: usize = SALT + XNONCE + SLOT_PLAIN + 16; +pub const HEADER_SIZE: usize = 18; +pub const BODY_SIZE: usize = HEADER_SIZE + 2 * SLOT_SIZE; +/// Key files are a few hundred bytes; anything this large is not one. +const MAX_FILE: u64 = 64 << 10; + +pub fn header() -> [u8; HEADER_SIZE] { + let mut h = [0u8; HEADER_SIZE]; + h[..9].copy_from_slice(MAGIC); + h[9..13].copy_from_slice(&ARGON_TIME.to_be_bytes()); + h[13..17].copy_from_slice(&ARGON_MEMORY.to_be_bytes()); + h[17] = ARGON_THREADS; + h +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Error { + NotAKeyFile, + IncorrectPassphrase, + Io(String), + Invalid(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::NotAKeyFile => f.write_str("not a vault key file"), + Error::IncorrectPassphrase => f.write_str("incorrect passphrase"), + Error::Io(m) | Error::Invalid(m) => f.write_str(m), + } + } +} + +/// Argon2id(passphrase, salt) with the file's fixed parameters. The +/// passphrase bytes are used exactly as given: no normalisation. +pub fn slot_key(passphrase: &[u8], salt: &[u8]) -> SecretArray<32> { + let params = Params::new(ARGON_MEMORY, ARGON_TIME, ARGON_THREADS as u32, Some(32)).expect("valid parameters"); + let a = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut blocks = vec![Block::default(); a.params().block_count()]; + let mut key = SecretArray::<32>::zero(); + let r = a.hash_password_into_with_memory(passphrase, salt, key.expose_mut(), &mut blocks[..]); + // The working memory is derived from the passphrase; clear all 64 MiB. + for b in blocks.iter_mut() { + b.zeroize(); + } + r.expect("argon2 inputs within limits"); + key +} + +fn seal_slot(id: &Identity, passphrase: &[u8], rng: &mut Rng) -> Vec { + let mut slot = vec![0u8; SALT + XNONCE]; + rng.fill(&mut slot); + let key = slot_key(passphrase, &slot[..SALT]); + let aead = XChaCha20Poly1305::new(key.expose().into()); + let mut plain = SecretVec::with_capacity(SLOT_PLAIN); + plain.extend_within_capacity(&id.suite_id().to_be_bytes()); + plain.extend_within_capacity(id.seed()); + let nonce = XNonce::try_from(&slot[SALT..]).expect("nonce size"); + let tag = aead + .encrypt_inout_detached(&nonce, &header(), plain.expose_mut().into()) + .expect("small message"); + slot.extend_from_slice(plain.expose()); + slot.extend_from_slice(&tag); + slot +} + +/// Opens one slot, or None if the passphrase does not open it. +fn open_slot(slot: &[u8], passphrase: &[u8]) -> Option { + let key = slot_key(passphrase, &slot[..SALT]); + let aead = XChaCha20Poly1305::new(key.expose().into()); + let nonce = XNonce::try_from(&slot[SALT..SALT + XNONCE]).expect("nonce size"); + let ct = &slot[SALT + XNONCE..]; + let mut plain = SecretVec::zeroed(SLOT_PLAIN); + plain.expose_mut().copy_from_slice(&ct[..SLOT_PLAIN]); + let tag = Tag::try_from(&ct[SLOT_PLAIN..]).expect("tag size"); + aead.decrypt_inout_detached(&nonce, &header(), plain.expose_mut().into(), &tag) + .ok()?; + Some(plain) +} + +/// The PEM text for a key file body, exactly as Go's `pem.Encode` writes it. +pub fn encode_pem(body: &[u8]) -> String { + let b64 = base64::engine::general_purpose::STANDARD.encode(body); + let mut s = format!("-----BEGIN {PEM_TYPE}-----\n"); + for line in b64.as_bytes().chunks(64) { + s.push_str(std::str::from_utf8(line).expect("ascii")); + s.push('\n'); + } + s.push_str(&format!("-----END {PEM_TYPE}-----\n")); + s +} + +/// The block body if `data` is one `VAULT KEY` PEM block, optionally with +/// text before it (as Go's `pem.Decode` allows) and only whitespace after. +pub fn decode_pem(data: &[u8]) -> Option> { + let begin = format!("-----BEGIN {PEM_TYPE}-----"); + let end = format!("-----END {PEM_TYPE}-----"); + let start = if data.starts_with(b"-----BEGIN ") { + 0 + } else { + find(data, b"\n-----BEGIN ")? + 1 + }; + let mut lines = data[start..].split_inclusive(|&c| c == b'\n'); + let first = lines.next()?; + if trim_right(first) != begin.as_bytes() { + return None; + } + let mut b64 = Vec::new(); + let mut after = None; + let mut consumed = start + first.len(); + for line in lines.by_ref() { + consumed += line.len(); + let t = trim_right(line); + if t == end.as_bytes() { + after = Some(consumed); + break; + } + if t.contains(&b':') { + // A PEM header line; Go accepts and ignores them. + continue; + } + b64.extend(t.iter().filter(|c| !matches!(c, b' ' | b'\t'))); + } + let after = after?; + if !data[after..].iter().all(|c| c.is_ascii_whitespace()) { + return None; + } + base64::engine::general_purpose::STANDARD.decode(&b64).ok() +} + +fn trim_right(l: &[u8]) -> &[u8] { + let mut e = l.len(); + while e > 0 && matches!(l[e - 1], b' ' | b'\t' | b'\r' | b'\n') { + e -= 1; + } + &l[..e] +} + +fn find(h: &[u8], n: &[u8]) -> Option { + h.windows(n.len()).position(|w| w == n) +} + +/// Builds a key file body: one slot for `passphrase`, the other for +/// `decoy` or random bytes if it is empty, in random order. +pub fn new_body(passphrase: &[u8], decoy: &[u8], rng: &mut Rng) -> Vec { + let real_slot = (rng.array::<1>()[0] & 1) as usize; + let mut slots: [Vec; 2] = [Vec::new(), Vec::new()]; + for (i, pass) in [passphrase, decoy].into_iter().enumerate() { + let idx = real_slot ^ i; + if pass.is_empty() { + let mut r = vec![0u8; SLOT_SIZE]; + rng.fill(&mut r); + slots[idx] = r; + continue; + } + let id = Identity::generate(rng); + slots[idx] = seal_slot(&id, pass, rng); + } + let mut body = header().to_vec(); + body.extend_from_slice(&slots[0]); + body.extend_from_slice(&slots[1]); + body +} + +/// Creates a new key file at `path`, refusing to replace an existing one. +pub fn write(path: &Path, passphrase: &[u8], decoy: &[u8]) -> Result<(), Error> { + let body = new_body(passphrase, decoy, &mut crate::rng::os()); + let pem = encode_pem(&body); + let ioerr = |op: &str, e: std::io::Error| Error::Io(crate::util::io_msg(op, path, &e)); + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + crate::platform::fs::create_private_dir_all(parent).map_err(|e| Error::Io(crate::util::io_msg("mkdir", parent, &e)))?; + } + let mut f = crate::platform::fs::create_new_private(path).map_err(|e| ioerr("open", e))?; + if let Err(e) = f.write_all(pem.as_bytes()).and_then(|_| f.sync_all()) { + drop(f); + let _ = std::fs::remove_file(path); + return Err(ioerr("write", e)); + } + drop(f); + if let Some(parent) = path.parent() { + let _ = crate::platform::fs::sync_dir(if parent.as_os_str().is_empty() { Path::new(".") } else { parent }); + } + Ok(()) +} + +/// Checks a key file body's framing, returning its two slots. +pub fn parse_body(body: &[u8]) -> Result<[&[u8]; 2], Error> { + if body.len() != BODY_SIZE || body[..HEADER_SIZE] != header() { + return Err(Error::NotAKeyFile); + } + let s = &body[HEADER_SIZE..]; + Ok([&s[..SLOT_SIZE], &s[SLOT_SIZE..]]) +} + +/// The identity whose slot `passphrase` opens. Both slots are always tried. +pub fn unlock_body(body: &[u8], passphrase: &[u8]) -> Result { + let slots = parse_body(body)?; + // Both Argon2id runs happen whatever the outcome, concurrently, so the + // time taken does not depend on which slot (if any) matched. + let opened: Vec> = std::thread::scope(|s| { + let h: Vec<_> = slots.iter().map(|slot| s.spawn(move || open_slot(slot, passphrase))).collect(); + h.into_iter().map(|h| h.join().expect("argon2 thread")).collect() + }); + if let Some(plain) = opened.into_iter().flatten().next() { + let p = plain.expose(); + let suite = u16::from_be_bytes([p[0], p[1]]); + return Identity::from_suite_seed(suite, &p[2..]).map_err(Error::Invalid); + } + Err(Error::IncorrectPassphrase) +} + +pub fn read_body(path: &Path) -> Result, Error> { + use std::io::Read; + let f = std::fs::File::open(path).map_err(|e| Error::Io(crate::util::io_msg("open", path, &e)))?; + let mut data = Vec::new(); + f.take(MAX_FILE + 1) + .read_to_end(&mut data) + .map_err(|e| Error::Io(crate::util::io_msg("read", path, &e)))?; + if data.len() as u64 > MAX_FILE { + return Err(Error::NotAKeyFile); + } + let body = decode_pem(&data).ok_or(Error::NotAKeyFile)?; + parse_body(&body)?; + Ok(body) +} + +pub fn unlock(path: &Path, passphrase: &[u8]) -> Result { + unlock_body(&read_body(path)?, passphrase) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::kem::tests::unhex; + use crate::crypto::stream::tests::hex; + use serde_json::Value; + + fn store_vectors() -> Value { + serde_json::from_str(include_str!("../tests/vectors/store.json")).unwrap() + } + + #[test] + fn argon2id_matches_go() { + let v = store_vectors(); + for c in v["argon2id"].as_array().unwrap() { + let k = slot_key(&unhex(c["passphrase_hex"].as_str().unwrap()), &unhex(c["salt"].as_str().unwrap())); + assert_eq!(hex(k.expose()), c["key"].as_str().unwrap()); + } + assert_eq!(hex(&header()), v["key_header_hex"].as_str().unwrap()); + } + + #[test] + fn opens_go_key_files() { + let v = store_vectors(); + for (name, slots) in v["keys"].as_object().unwrap() { + let path = format!("{}/tests/vectors/{name}", env!("CARGO_MANIFEST_DIR")); + let body = read_body(Path::new(&path)).unwrap(); + assert_eq!(body.len(), 198); + for (_, s) in slots.as_object().unwrap() { + let id = unlock_body(&body, s["passphrase"].as_str().unwrap().as_bytes()).unwrap(); + assert_eq!(hex(id.seed()), s["seed"].as_str().unwrap()); + assert_eq!(id.suite_id() as u64, s["suite"].as_u64().unwrap()); + } + assert_eq!(unlock_body(&body, b"wrong").err(), Some(Error::IncorrectPassphrase)); + // Re-encoding reproduces Go's PEM text exactly. + assert_eq!(encode_pem(&body).as_bytes(), &std::fs::read(&path).unwrap()[..]); + } + } + + #[test] + fn new_key_files_round_trip() { + let body = new_body(b"real", b"decoy", &mut crate::rng::os()); + let a = unlock_body(&body, b"real").unwrap(); + let b = unlock_body(&body, b"decoy").unwrap(); + assert_ne!(a.seed(), b.seed()); + assert!(unlock_body(&body, b"other").is_err()); + let solo = new_body(b"real", b"", &mut crate::rng::os()); + assert_eq!(solo.len(), body.len()); + assert!(unlock_body(&solo, b"").is_err()); + // Passphrases are bytes: no normalisation of composed/decomposed é. + let nfc = "caf\u{e9}".as_bytes(); + let nfd = "cafe\u{301}".as_bytes(); + let k = new_body(nfc, b"", &mut crate::rng::os()); + assert!(unlock_body(&k, nfc).is_ok()); + assert!(unlock_body(&k, nfd).is_err()); + } + + #[test] + fn malformed_key_files() { + let body = new_body(b"p", b"", &mut crate::rng::os()); + let pem = encode_pem(&body); + assert_eq!(decode_pem(pem.as_bytes()).unwrap(), body); + assert_eq!(decode_pem(format!("junk\n{pem}\n \n").as_bytes()).unwrap(), body); + assert!(decode_pem(format!("{pem}x").as_bytes()).is_none()); + assert!(decode_pem(pem.replace("VAULT KEY", "VAULT KEX").as_bytes()).is_none()); + assert!(decode_pem(pem.replace("-----END", "-----FIN").as_bytes()).is_none()); + let mut short = body.clone(); + short.pop(); + assert_eq!(parse_body(&short).err(), Some(Error::NotAKeyFile)); + let mut wrong = body.clone(); + wrong[17] = 1; // a different Argon2 lane count + assert_eq!(parse_body(&wrong).err(), Some(Error::NotAKeyFile)); + let mut tampered = body.clone(); + tampered[HEADER_SIZE + SALT + XNONCE] ^= 1; + tampered[HEADER_SIZE + SLOT_SIZE + SALT + XNONCE] ^= 1; + assert_eq!(unlock_body(&tampered, b"p").err(), Some(Error::IncorrectPassphrase)); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8c54538 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! vault: a post-quantum encrypted file vault. The binary is a thin wrapper +//! around [`cli::main`]; the library exists so integration tests and +//! benchmarks can reach the same code. + +#![deny(unsafe_op_in_unsafe_fn)] + +pub mod crypto; +pub mod keyfile; +pub mod platform; +pub mod rng; +pub mod secret; +pub mod util; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/src/platform/fs.rs b/src/platform/fs.rs new file mode 100644 index 0000000..d904ec8 --- /dev/null +++ b/src/platform/fs.rs @@ -0,0 +1,488 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Filesystem primitives with the guarantees the store and extraction need. +//! +//! - [`publish`] moves a finished temporary file to its final name without +//! ever replacing anything there, symlinks (dangling or not) included: +//! `renameat2(RENAME_NOREPLACE)` on Linux, `renameatx_np(RENAME_EXCL)` on +//! macOS, `link`+`unlink` on other Unix systems and when a Linux +//! filesystem lacks RENAME_NOREPLACE, and `MoveFileExW` without +//! `MOVEFILE_REPLACE_EXISTING` on Windows. There is no check-then-rename +//! fallback: if no atomic primitive works, publishing fails. +//! - [`Dir`] creates and publishes files relative to an open directory. On +//! Unix every path component below the chosen destination is opened with +//! `O_NOFOLLOW|O_DIRECTORY`, so a symlink planted inside the destination +//! cannot redirect extraction elsewhere. Windows has no `openat`; there +//! each component is checked not to be a symlink or junction before use, +//! which narrows but does not close the race (see SECURITY.md). +//! - [`sync_dir`] makes a directory's entries durable where the OS allows +//! it (Unix). Windows offers no directory fsync through these APIs; NTFS +//! journals metadata, and that is the durability on offer there. + +use std::ffi::OsStr; +use std::fs::{File, OpenOptions}; +use std::io; +use std::path::{Path, PathBuf}; + +#[cfg(unix)] +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; + +pub fn create_private_dir_all(p: &Path) -> io::Result<()> { + let mut b = std::fs::DirBuilder::new(); + b.recursive(true); + #[cfg(unix)] + b.mode(0o700); + b.create(p) +} + +pub fn create_private_dir(p: &Path) -> io::Result<()> { + let mut b = std::fs::DirBuilder::new(); + #[cfg(unix)] + b.mode(0o700); + b.create(p) +} + +/// Creates a new file readable only by its owner; fails if `p` exists. +pub fn create_new_private(p: &Path) -> io::Result { + let mut o = OpenOptions::new(); + o.write(true).create_new(true); + #[cfg(unix)] + o.mode(0o600); + o.open(p) +} + +pub fn sync_dir(dir: &Path) -> io::Result<()> { + #[cfg(unix)] + { + File::open(dir)?.sync_all() + } + #[cfg(not(unix))] + { + let _ = dir; + Ok(()) + } +} + +/// Sets a path's access and modification times to the Unix epoch, +/// following symlinks, as Go's `os.Chtimes(p, epoch, epoch)`. +pub fn set_epoch(p: &Path) -> io::Result<()> { + #[cfg(unix)] + { + use rustix::fs::{AtFlags, CWD, Timespec, Timestamps, utimensat}; + let z = Timespec { tv_sec: 0, tv_nsec: 0 }; + utimensat( + CWD, + p, + &Timestamps { + last_access: z, + last_modification: z, + }, + AtFlags::empty(), + )?; + Ok(()) + } + #[cfg(not(unix))] + { + let f = open_for_times(p)?; + f.set_times( + std::fs::FileTimes::new() + .set_accessed(std::time::UNIX_EPOCH) + .set_modified(std::time::UNIX_EPOCH), + ) + } +} + +#[cfg(windows)] +fn open_for_times(p: &Path) -> io::Result { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, FILE_WRITE_ATTRIBUTES}; + OpenOptions::new() + .access_mode(FILE_WRITE_ATTRIBUTES) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(p) +} + +#[cfg(not(any(unix, windows)))] +fn open_for_times(p: &Path) -> io::Result { + OpenOptions::new().write(true).open(p) +} + +fn random_suffix() -> String { + let b: [u8; 8] = crate::rng::os().array(); + b.iter().map(|x| format!("{x:02x}")).collect() +} + +/// Creates `dir/` exclusively, mode 0600. +pub fn create_temp(dir: &Path, prefix: &str) -> io::Result<(File, PathBuf)> { + loop { + let p = dir.join(format!("{prefix}{}", random_suffix())); + match create_new_private(&p) { + Ok(f) => return Ok((f, p)), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } +} + +fn exists_error(dst: &Path) -> io::Error { + io::Error::new(io::ErrorKind::AlreadyExists, format!("{} already exists", dst.display())) +} + +/// Renames `tmp` to `dst` only if `dst` does not exist (in any form). On +/// failure `tmp` is left for the caller to remove. +pub fn publish(tmp: &Path, dst: &Path) -> io::Result<()> { + let r = publish_inner(tmp, dst); + match r { + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(exists_error(dst)), + r => r, + } +} + +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +fn publish_inner(tmp: &Path, dst: &Path) -> io::Result<()> { + use rustix::fs::{CWD, RenameFlags, renameat_with}; + match renameat_with(CWD, tmp, CWD, dst, RenameFlags::NOREPLACE) { + Ok(()) => Ok(()), + Err(e) if e == rustix::io::Errno::INVAL || e == rustix::io::Errno::NOSYS || e == rustix::io::Errno::NOTSUP => { + link_publish(tmp, dst) + } + Err(e) => Err(e.into()), + } +} + +#[cfg(all(unix, not(any(target_os = "linux", target_os = "android", target_vendor = "apple"))))] +fn publish_inner(tmp: &Path, dst: &Path) -> io::Result<()> { + link_publish(tmp, dst) +} + +#[cfg(not(any(unix, windows)))] +fn publish_inner(tmp: &Path, dst: &Path) -> io::Result<()> { + link_publish(tmp, dst) +} + +/// `link` never replaces an existing name, so link-then-unlink is an +/// atomic no-clobber publish wherever hard links work. +#[cfg(not(windows))] +fn link_publish(tmp: &Path, dst: &Path) -> io::Result<()> { + std::fs::hard_link(tmp, dst)?; + // The data is safely at dst; a failure to drop the temporary name is + // reported but dst stays. + std::fs::remove_file(tmp) +} + +#[cfg(windows)] +fn publish_inner(tmp: &Path, dst: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_WRITE_THROUGH, MoveFileExW}; + let w = |p: &Path| -> Vec { p.as_os_str().encode_wide().chain(Some(0)).collect() }; + let (a, b) = (w(tmp), w(dst)); + // SAFETY: both arguments are NUL-terminated UTF-16 strings that outlive + // the call; MoveFileExW does not retain them. + let ok = unsafe { MoveFileExW(a.as_ptr(), b.as_ptr(), MOVEFILE_WRITE_THROUGH) }; + if ok == 0 { Err(io::Error::last_os_error()) } else { Ok(()) } +} + +/// Sets permission bits as Go's `File.Chmod` does: all of `mode & 0o777` +/// on Unix, only the read-only attribute on Windows. +pub fn set_mode(f: &File, mode: u32) -> io::Result<()> { + #[cfg(unix)] + { + f.set_permissions(std::fs::Permissions::from_mode(mode & 0o777)) + } + #[cfg(not(unix))] + { + let mut p = f.metadata()?.permissions(); + p.set_readonly(mode & 0o200 == 0); + f.set_permissions(p) + } +} + +/// A destination directory that files are created and published inside +/// without following symlinks below it. +pub struct Dir { + path: PathBuf, + #[cfg(unix)] + fd: rustix::fd::OwnedFd, +} + +impl Dir { + /// Opens the chosen destination itself; a symlink here is followed, + /// because the user named it. + pub fn open(path: &Path) -> io::Result { + #[cfg(unix)] + { + use rustix::fs::{CWD, Mode, OFlags, openat}; + let fd = openat(CWD, path, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty())?; + Ok(Dir { + path: path.to_path_buf(), + fd, + }) + } + #[cfg(not(unix))] + { + if !std::fs::metadata(path)?.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + format!("{} is not a directory", path.display()), + )); + } + Ok(Dir { path: path.to_path_buf() }) + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Opens (creating with mode 0700 if missing) the subdirectory `name`, + /// refusing a symlink or anything that is not a directory. + pub fn subdir(&self, name: &OsStr) -> io::Result { + check_component(name)?; + let path = self.path.join(name); + #[cfg(unix)] + { + use rustix::fs::{Mode, OFlags, mkdirat, openat}; + let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; + let open = || openat(&self.fd, name, flags, Mode::empty()); + let fd = match open() { + Ok(fd) => fd, + Err(rustix::io::Errno::NOENT) => { + match mkdirat(&self.fd, name, Mode::from_raw_mode(0o700)) { + Ok(()) | Err(rustix::io::Errno::EXIST) => {} + Err(e) => return Err(e.into()), + } + open().map_err(|e| refuse(e, &path))? + } + Err(e) => return Err(refuse(e, &path)), + }; + Ok(Dir { path, fd }) + } + #[cfg(not(unix))] + { + match std::fs::symlink_metadata(&path) { + Ok(m) if m.is_dir() && !m.file_type().is_symlink() && !is_reparse(&m) => {} + Ok(_) => { + return Err(io::Error::other(format!("{} is not a directory, or is a link", path.display()))); + } + Err(e) if e.kind() == io::ErrorKind::NotFound => match create_private_dir(&path) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => return self.subdir(name), + Err(e) => return Err(e), + }, + Err(e) => return Err(e), + } + Ok(Dir { path }) + } + } + + /// Creates a private temporary file in this directory. + pub fn create_temp(&self, prefix: &str) -> io::Result<(File, std::ffi::OsString)> { + loop { + let name: std::ffi::OsString = format!("{prefix}{}", random_suffix()).into(); + #[cfg(unix)] + let r = { + use rustix::fs::{Mode, OFlags, openat}; + openat( + &self.fd, + &name, + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::from_raw_mode(0o600), + ) + .map(File::from) + .map_err(io::Error::from) + }; + #[cfg(not(unix))] + let r = create_new_private(&self.path.join(&name)); + match r { + Ok(f) => return Ok((f, name)), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + } + + /// Publishes `tmp` as `name` in this directory, never replacing + /// anything already there. + pub fn publish(&self, tmp: &OsStr, name: &OsStr) -> io::Result<()> { + check_component(name)?; + let dst = self.path.join(name); + #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] + { + use rustix::fs::{RenameFlags, renameat_with}; + match renameat_with(&self.fd, tmp, &self.fd, name, RenameFlags::NOREPLACE) { + Ok(()) => return Ok(()), + Err(rustix::io::Errno::EXIST) => return Err(exists_error(&dst)), + Err(e) if e == rustix::io::Errno::INVAL || e == rustix::io::Errno::NOSYS || e == rustix::io::Errno::NOTSUP => {} + Err(e) => return Err(e.into()), + } + } + #[cfg(unix)] + { + use rustix::fs::{AtFlags, linkat, unlinkat}; + match linkat(&self.fd, tmp, &self.fd, name, AtFlags::empty()) { + Ok(()) => {} + Err(rustix::io::Errno::EXIST) => return Err(exists_error(&dst)), + Err(e) => return Err(e.into()), + } + unlinkat(&self.fd, tmp, AtFlags::empty())?; + Ok(()) + } + #[cfg(not(unix))] + { + publish(&self.path.join(tmp), &dst) + } + } + + pub fn remove(&self, name: &OsStr) -> io::Result<()> { + #[cfg(unix)] + { + rustix::fs::unlinkat(&self.fd, name, rustix::fs::AtFlags::empty())?; + Ok(()) + } + #[cfg(not(unix))] + { + std::fs::remove_file(self.path.join(name)) + } + } + + pub fn sync(&self) -> io::Result<()> { + #[cfg(unix)] + { + rustix::fs::fsync(&self.fd)?; + } + Ok(()) + } +} + +#[cfg(unix)] +fn refuse(e: rustix::io::Errno, path: &Path) -> io::Error { + if e == rustix::io::Errno::LOOP || e == rustix::io::Errno::NOTDIR { + io::Error::other(format!( + "{} is not a directory, or is a symlink; refusing to extract through it", + path.display() + )) + } else { + e.into() + } +} + +#[cfg(windows)] +fn is_reparse(m: &std::fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + m.file_attributes() & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(any(unix, windows)))] +fn is_reparse(_: &std::fs::Metadata) -> bool { + false +} + +/// A single, plain path component: no separators, not "." or "..". +fn check_component(name: &OsStr) -> io::Result<()> { + let ok = !name.is_empty() + && name != "." + && name != ".." + && Path::new(name).components().count() == 1 + && matches!(Path::new(name).components().next(), Some(std::path::Component::Normal(_))); + if ok { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid path component {:?}", name), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmpdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("vault-fs-{tag}-{}-{}", std::process::id(), random_suffix())); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn publish_never_replaces() { + let d = tmpdir("pub"); + let (_, t1) = create_temp(&d, ".t-").unwrap(); + std::fs::write(&t1, b"one").unwrap(); + publish(&t1, &d.join("out")).unwrap(); + let (_, t2) = create_temp(&d, ".t-").unwrap(); + std::fs::write(&t2, b"two").unwrap(); + let e = publish(&t2, &d.join("out")).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read(d.join("out")).unwrap(), b"one"); + #[cfg(unix)] + { + std::os::unix::fs::symlink(d.join("nowhere"), d.join("dangling")).unwrap(); + assert_eq!(publish(&t2, &d.join("dangling")).unwrap_err().kind(), io::ErrorKind::AlreadyExists); + assert!(!d.join("nowhere").exists(), "followed a dangling symlink"); + } + std::fs::remove_dir_all(d).unwrap(); + } + + /// Many threads race to publish the same name: exactly one wins and + /// every loser's file is left for it to clean up. + #[test] + fn concurrent_publishers() { + let d = tmpdir("race"); + let wins = std::sync::atomic::AtomicUsize::new(0); + std::thread::scope(|s| { + for i in 0..16 { + let (d, wins) = (&d, &wins); + s.spawn(move || { + let dir = Dir::open(d).unwrap(); + let (mut f, t) = dir.create_temp(".t-").unwrap(); + std::io::Write::write_all(&mut f, format!("{i}").as_bytes()).unwrap(); + match dir.publish(&t, OsStr::new("same")) { + Ok(()) => { + wins.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + Err(e) => { + assert_eq!(e.kind(), io::ErrorKind::AlreadyExists); + dir.remove(&t).unwrap(); + } + } + }); + } + }); + assert_eq!(wins.into_inner(), 1); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1); + std::fs::remove_dir_all(d).unwrap(); + } + + #[cfg(unix)] + #[test] + fn subdir_refuses_symlinks() { + let d = tmpdir("conf"); + let outside = tmpdir("outside"); + std::os::unix::fs::symlink(&outside, d.join("evil")).unwrap(); + let dir = Dir::open(&d).unwrap(); + assert!(dir.subdir(OsStr::new("evil")).is_err()); + assert!(dir.subdir(OsStr::new("..")).is_err()); + assert!(dir.subdir(OsStr::new("a/b")).is_err()); + let sub = dir.subdir(OsStr::new("ok")).unwrap(); + assert!(sub.path().ends_with("ok")); + std::fs::write(d.join("file"), b"").unwrap(); + assert!(dir.subdir(OsStr::new("file")).is_err()); + assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 0); + std::fs::remove_dir_all(d).unwrap(); + std::fs::remove_dir_all(outside).unwrap(); + } + + #[test] + fn epoch_times() { + let d = tmpdir("epoch"); + let f = d.join("f"); + std::fs::write(&f, b"x").unwrap(); + set_epoch(&f).unwrap(); + set_epoch(&d).unwrap(); + for p in [&f, &d] { + assert_eq!(std::fs::metadata(p).unwrap().modified().unwrap(), std::time::UNIX_EPOCH); + } + std::fs::remove_dir_all(d).unwrap(); + } +} diff --git a/src/platform/lock.rs b/src/platform/lock.rs new file mode 100644 index 0000000..e9e201e --- /dev/null +++ b/src/platform/lock.rs @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The vault lock on `DIR/.lock`, interoperable with the Go version on the +//! same store: +//! +//! - Unix: classic POSIX record locks (`fcntl(F_SETLK/F_SETLKW)` over the +//! whole file), exactly what Go uses. Not `flock(2)`, which does not +//! exclude `fcntl` locks on Linux, and not OFD locks. The kernel drops +//! them when the process dies. POSIX record locks belong to the process, +//! so this process must hold the lock file open only once. +//! - Windows: `LockFileEx` on byte 0, as Go does; released on death. +//! - Elsewhere: an exclusive `O_EXCL` lock file. A crash leaves it behind; +//! after a minute of waiting the error names it for manual removal. +//! +//! Readers take the lock shared, writers exclusive. If it is held in a +//! conflicting mode, `waiting` is called once and the call blocks. + +use std::fs::File; +use std::io; +use std::path::Path; + +pub const LOCK_NAME: &str = ".lock"; + +/// A held lock; dropping it releases the lock. +pub struct Lock { + _file: Option, + #[cfg(not(any(unix, windows)))] + remove: Option, +} + +impl Drop for Lock { + fn drop(&mut self) { + #[cfg(not(any(unix, windows)))] + if let Some(p) = self.remove.take() { + let _ = std::fs::remove_file(p); + } + } +} + +#[cfg(unix)] +pub fn acquire(path: &Path, exclusive: bool, waiting: &mut dyn FnMut()) -> io::Result { + use rustix::fs::{FlockOperation, fcntl_lock}; + use std::os::unix::fs::OpenOptionsExt; + let f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(path)?; + let (try_op, wait_op) = if exclusive { + (FlockOperation::NonBlockingLockExclusive, FlockOperation::LockExclusive) + } else { + (FlockOperation::NonBlockingLockShared, FlockOperation::LockShared) + }; + match fcntl_lock(&f, try_op) { + Ok(()) => {} + Err(e) if e == rustix::io::Errno::AGAIN || e == rustix::io::Errno::ACCESS => { + waiting(); + loop { + match fcntl_lock(&f, wait_op) { + Ok(()) => break, + Err(rustix::io::Errno::INTR) => continue, + Err(e) => return Err(e.into()), + } + } + } + Err(e) => return Err(e.into()), + } + Ok(Lock { _file: Some(f) }) +} + +#[cfg(windows)] +pub fn acquire(path: &Path, exclusive: bool, waiting: &mut dyn FnMut()) -> io::Result { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION; + use windows_sys::Win32::Storage::FileSystem::{LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, LockFileEx}; + use windows_sys::Win32::System::IO::OVERLAPPED; + let f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path)?; + let flags = if exclusive { LOCKFILE_EXCLUSIVE_LOCK } else { 0 }; + let lock = |flags: u32| -> io::Result<()> { + // SAFETY: the handle is valid for the life of `f`; the OVERLAPPED is + // zeroed (offset 0) and outlives this synchronous call. + let mut ov: OVERLAPPED = unsafe { std::mem::zeroed() }; + let ok = unsafe { LockFileEx(f.as_raw_handle() as _, flags, 0, 1, 0, &mut ov) }; + if ok == 0 { Err(io::Error::last_os_error()) } else { Ok(()) } + }; + match lock(flags | LOCKFILE_FAIL_IMMEDIATELY) { + Ok(()) => {} + Err(e) if e.raw_os_error() == Some(ERROR_LOCK_VIOLATION as i32) => { + waiting(); + lock(flags)?; + } + Err(e) => return Err(e), + } + Ok(Lock { _file: Some(f) }) +} + +#[cfg(not(any(unix, windows)))] +pub fn acquire(path: &Path, _exclusive: bool, waiting: &mut dyn FnMut()) -> io::Result { + for i in 0.. { + match super::fs::create_new_private(path) { + Ok(_) => { + return Ok(Lock { + _file: None, + remove: Some(path.to_path_buf()), + }); + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e), + } + if i == 0 { + waiting(); + } + if i == 600 { + return Err(io::Error::other(format!( + "vault still locked after a minute; if no other vault process is running, remove {}", + path.display() + ))); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + unreachable!() +} + +/// Locks the vault at `root`. A shared (reader) lock on a read-only +/// filesystem, where nothing can be writing, proceeds without a lock; every +/// other failure is reported. +pub fn lock_vault(root: &Path, exclusive: bool) -> Result { + let path = root.join(LOCK_NAME); + let mut waiting = || eprintln!("waiting for another vault process to finish"); + match acquire(&path, exclusive, &mut waiting) { + Ok(l) => Ok(l), + Err(e) if !exclusive && e.kind() == io::ErrorKind::ReadOnlyFilesystem => Ok(Lock { + _file: None, + #[cfg(not(any(unix, windows)))] + remove: None, + }), + Err(e) => Err(format!("cannot lock the vault: {}", crate::util::io_msg("lock", &path, &e))), + } +} diff --git a/src/platform/memory.rs b/src/platform/memory.rs new file mode 100644 index 0000000..498c98f --- /dev/null +++ b/src/platform/memory.rs @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Locked, non-dumpable memory for the agent's secret. +//! +//! [`LockedBuf`] is an anonymous private mapping of whole pages: it never +//! moves or grows, it is `mlock`ed (kept out of swap) before any secret is +//! written into it and, on Linux, excluded from core dumps with +//! `MADV_DONTDUMP`. On drop it is cleared with volatile writes, unlocked and +//! unmapped, in that order. Every system call result is checked; a failure +//! is an error, never a silent downgrade. +//! +//! [`harden_process`] sets the core-file size limit to zero and, on Linux, +//! `PR_SET_DUMPABLE` to 0, which also stops same-user `ptrace` and +//! `/proc/PID/mem` access. Other Unix systems get the rlimit only. Windows +//! is not supported by the agent (see SECURITY.md). + +use std::io; + +#[cfg(unix)] +pub struct LockedBuf { + ptr: std::ptr::NonNull, + len: usize, + map_len: usize, +} + +// SAFETY: the buffer is uniquely owned; no aliasing pointers escape except +// through borrows of self. +#[cfg(unix)] +unsafe impl Send for LockedBuf {} + +#[cfg(unix)] +impl LockedBuf { + pub fn new(len: usize) -> io::Result { + use rustix::mm::{MapFlags, ProtFlags, mlock, mmap_anonymous, munmap}; + let page = rustix::param::page_size(); + let map_len = len.div_ceil(page).max(1) * page; + // SAFETY: a fresh anonymous private mapping at an address of the + // kernel's choosing aliases nothing. + let p = unsafe { mmap_anonymous(std::ptr::null_mut(), map_len, ProtFlags::READ | ProtFlags::WRITE, MapFlags::PRIVATE)? }; + let unmap = |p| { + // SAFETY: p/map_len is exactly the mapping created above. + let _ = unsafe { munmap(p, map_len) }; + }; + // SAFETY: the range is the mapping just created. + if let Err(e) = unsafe { mlock(p, map_len) } { + unmap(p); + return Err(io::Error::new(e.kind(), format!("cannot lock agent memory (mlock): {e}"))); + } + #[cfg(any(target_os = "linux", target_os = "android"))] + { + use rustix::mm::{Advice, madvise}; + // SAFETY: the range is the mapping just created. + if let Err(e) = unsafe { madvise(p, map_len, Advice::LinuxDontDump) } { + // SAFETY: as above. + let _ = unsafe { rustix::mm::munlock(p, map_len) }; + unmap(p); + return Err(io::Error::new( + e.kind(), + format!("cannot exclude agent memory from core dumps: {e}"), + )); + } + } + let ptr = std::ptr::NonNull::new(p.cast::()).expect("mmap returned null"); + Ok(LockedBuf { ptr, len, map_len }) + } + + pub fn as_slice(&self) -> &[u8] { + // SAFETY: ptr is valid for map_len >= len bytes for the life of self. + unsafe { std::slice::from_raw_parts(self.ptr.as_ptr(), self.len) } + } + + pub fn as_mut_slice(&mut self) -> &mut [u8] { + // SAFETY: as above, and &mut self guarantees exclusivity. + unsafe { std::slice::from_raw_parts_mut(self.ptr.as_ptr(), self.len) } + } + + pub fn clear(&mut self) { + // SAFETY: as above, over the whole mapping. + let all = unsafe { std::slice::from_raw_parts_mut(self.ptr.as_ptr(), self.map_len) }; + zeroize::Zeroize::zeroize(all); + } +} + +#[cfg(unix)] +impl Drop for LockedBuf { + fn drop(&mut self) { + self.clear(); + let p = self.ptr.as_ptr().cast(); + // SAFETY: p/map_len is the mapping owned by self, not used again. + unsafe { + let _ = rustix::mm::munlock(p, self.map_len); + let _ = rustix::mm::munmap(p, self.map_len); + } + } +} + +/// No core dumps; on Linux, not dumpable (and so not ptrace-able). +#[cfg(unix)] +pub fn harden_process() -> io::Result<()> { + use rustix::process::{Resource, Rlimit, setrlimit}; + setrlimit( + Resource::Core, + Rlimit { + current: Some(0), + maximum: Some(0), + }, + ) + .map_err(|e| io::Error::new(e.kind(), format!("cannot disable core dumps: {e}")))?; + #[cfg(any(target_os = "linux", target_os = "android"))] + { + use rustix::process::{DumpableBehavior, dumpable_behavior, set_dumpable_behavior}; + set_dumpable_behavior(DumpableBehavior::NotDumpable) + .map_err(|e| io::Error::new(e.kind(), format!("cannot make the agent undumpable: {e}")))?; + if dumpable_behavior()? != DumpableBehavior::NotDumpable { + return Err(io::Error::other("the agent is still dumpable")); + } + } + Ok(()) +} + +#[cfg(test)] +#[cfg(unix)] +mod tests { + use super::*; + + #[test] + fn locked_buf() { + let mut b = LockedBuf::new(34).unwrap(); + assert_eq!(b.as_slice().len(), 34); + b.as_mut_slice()[0] = 7; + b.clear(); + assert!(b.as_slice().iter().all(|&x| x == 0)); + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs new file mode 100644 index 0000000..8d9e8b5 --- /dev/null +++ b/src/platform/mod.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Operating-system specifics. All `unsafe` code outside third-party crates +//! lives in this module, in small documented blocks. + +pub mod fs; +pub mod lock; +pub mod memory; +pub mod term; diff --git a/src/platform/term.rs b/src/platform/term.rs new file mode 100644 index 0000000..c4c8468 --- /dev/null +++ b/src/platform/term.rs @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Terminal prompts, as the Go `internal/term` package and +//! `golang.org/x/term.ReadPassword` behave: the prompt and input use the +//! controlling terminal (`/dev/tty`, else a terminal on standard input; +//! `CONIN$`/`CONOUT$` on Windows), echo is off while a secret is typed, +//! the line is cleared afterwards, and the bytes typed are kept exactly +//! (a backspace byte deletes the previous byte; `\n` ends the line on Unix, +//! `\r` on Windows). + +use crate::secret::SecretVec; +use std::fs::File; +use std::io::{self, Read, Write}; + +/// Longest secret accepted from the terminal; the buffer never grows. +const MAX_SECRET: usize = 64 << 10; + +fn open_terminal() -> io::Result<(File, File)> { + #[cfg(windows)] + { + let i = std::fs::OpenOptions::new().read(true).write(true).open("CONIN$")?; + let o = std::fs::OpenOptions::new().write(true).open("CONOUT$")?; + Ok((i, o)) + } + #[cfg(not(windows))] + { + match std::fs::OpenOptions::new().read(true).write(true).open("/dev/tty") { + Ok(t) => { + let o = t.try_clone()?; + Ok((t, o)) + } + Err(e) => { + #[cfg(unix)] + if rustix::termios::isatty(rustix::stdio::stdin()) { + let i = File::from(rustix::io::dup(rustix::stdio::stdin())?); + let o = i.try_clone()?; + return Ok((i, o)); + } + Err(io::Error::new( + e.kind(), + format!( + "standard input is not a terminal, and /dev/tty is not available: {}", + crate::util::io_reason(&e) + ), + )) + } + } + } +} + +fn clear_line(out: &mut File) { + let _ = out.write_all(b"\r\n"); + #[cfg(windows)] + let ok = enable_vt(out); + #[cfg(not(windows))] + let ok = true; + if ok { + let _ = out.write_all(b"\x1b[F\x1b[K"); + } +} + +#[cfg(windows)] +fn enable_vt(out: &File) -> bool { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::System::Console::{ + ENABLE_PROCESSED_OUTPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING, GetConsoleMode, SetConsoleMode, + }; + let h = out.as_raw_handle() as _; + let mut mode = 0; + // SAFETY: h is a live console handle owned by `out`. + unsafe { + GetConsoleMode(h, &mut mode) != 0 && SetConsoleMode(h, mode | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0 + } +} + +/// x/term's readPasswordLine. +fn read_line_bytes(r: &mut impl Read) -> io::Result { + let mut ret = SecretVec::with_capacity(MAX_SECRET); + let mut b = [0u8; 1]; + loop { + match r.read(&mut b) { + Ok(0) => { + if !ret.is_empty() { + return Ok(ret); + } + return Err(io::ErrorKind::UnexpectedEof.into()); + } + Ok(_) => match b[0] { + 0x08 => { + let n = ret.len().saturating_sub(1); + ret.resize_within_capacity(n); + } + b'\n' if cfg!(not(windows)) => return Ok(ret), + b'\r' if cfg!(windows) => return Ok(ret), + b'\n' | b'\r' => {} + c => { + if ret.len() == MAX_SECRET { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "input too long")); + } + ret.extend_within_capacity(&[c]); + } + }, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } +} + +#[cfg(unix)] +fn without_echo(input: &mut File, f: impl FnOnce(&mut File) -> io::Result) -> io::Result { + use rustix::termios::{InputModes, LocalModes, OptionalActions, tcgetattr, tcsetattr}; + let old = tcgetattr(&*input)?; + let mut new = old.clone(); + new.local_modes.remove(LocalModes::ECHO); + new.local_modes.insert(LocalModes::ICANON | LocalModes::ISIG); + new.input_modes.insert(InputModes::ICRNL); + tcsetattr(&*input, OptionalActions::Now, &new)?; + let r = f(input); + tcsetattr(&*input, OptionalActions::Now, &old)?; + r +} + +#[cfg(windows)] +fn without_echo(input: &mut File, f: impl FnOnce(&mut File) -> io::Result) -> io::Result { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::System::Console::{ + ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, ENABLE_PROCESSED_OUTPUT, GetConsoleMode, SetConsoleMode, + }; + let h = input.as_raw_handle() as _; + let mut old = 0; + // SAFETY: h is a live console handle owned by `input`. + if unsafe { GetConsoleMode(h, &mut old) } == 0 { + return Err(io::Error::last_os_error()); + } + let new = (old & !ENABLE_ECHO_INPUT) | ENABLE_PROCESSED_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_OUTPUT; + // SAFETY: as above. + if unsafe { SetConsoleMode(h, new) } == 0 { + return Err(io::Error::last_os_error()); + } + let r = f(input); + // SAFETY: as above. + unsafe { SetConsoleMode(h, old) }; + r +} + +#[cfg(not(any(unix, windows)))] +fn without_echo(_: &mut File, _: impl FnOnce(&mut File) -> io::Result) -> io::Result { + Err(io::Error::new(io::ErrorKind::Unsupported, "no terminal support on this platform")) +} + +/// Reads a secret from the terminal with echo off. The prompt is erased. +pub fn read_secret(prompt: &str) -> io::Result { + let (mut input, mut out) = open_terminal()?; + write!(out, "{prompt} ")?; + let r = without_echo(&mut input, read_line_bytes); + clear_line(&mut out); + r +} + +/// Asks a yes/no question on the terminal; only "y" or "yes" is yes. +pub fn confirm(prompt: &str) -> io::Result { + let (mut input, mut out) = open_terminal()?; + write!(out, "{prompt} ")?; + let mut line = Vec::new(); + let mut b = [0u8; 1]; + loop { + match input.read(&mut b) { + Ok(0) => { + if line.is_empty() { + return Err(io::ErrorKind::UnexpectedEof.into()); + } + break; + } + Ok(_) => { + line.push(b[0]); + if b[0] == b'\n' || line.len() > 4096 { + break; + } + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + let answer = String::from_utf8_lossy(&line).trim().to_lowercase(); + Ok(answer == "y" || answer == "yes") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn password_line_editing() { + let got = read_line_bytes(&mut &b"ab\x08c\rd\nrest"[..]).unwrap(); + if cfg!(windows) { + assert_eq!(got.expose(), b"ac"); + } else { + assert_eq!(got.expose(), b"acd"); + } + assert_eq!(read_line_bytes(&mut &b"tail"[..]).unwrap().expose(), b"tail"); + assert!(read_line_bytes(&mut &b""[..]).is_err()); + } +} diff --git a/src/rng.rs b/src/rng.rs new file mode 100644 index 0000000..a9515fe --- /dev/null +++ b/src/rng.rs @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Randomness. +//! +//! Production code only ever uses the operating system's CSPRNG. Unit tests +//! can substitute a seeded generator so that sequential and parallel paths +//! can be compared byte for byte; that variant exists only under +//! `cfg(test)` and cannot be selected by a user or a build of the binary. + +/// Where a computation draws its randomness from. Each independent unit of +/// work asks for its own [`Rng`] by label, so seeded test runs stay +/// deterministic however work is scheduled across threads. +#[derive(Clone)] +pub enum Entropy { + Os, + #[cfg(test)] + Seeded([u8; 32]), +} + +pub enum Rng { + Os, + #[cfg(test)] + Seeded(Box), +} + +impl Entropy { + pub fn rng(&self, _label: &[u8]) -> Rng { + match self { + Entropy::Os => Rng::Os, + #[cfg(test)] + Entropy::Seeded(seed) => { + use sha3::digest::{ExtendableOutput, Update}; + let mut h = sha3::Shake256::default(); + h.update(b"vault test rng"); + h.update(seed); + h.update(&(_label.len() as u64).to_be_bytes()); + h.update(_label); + Rng::Seeded(Box::new(h.finalize_xof())) + } + } + } +} + +impl Rng { + pub fn fill(&mut self, out: &mut [u8]) { + match self { + // A failing system CSPRNG is not something to continue past, + // exactly as Go's crypto/rand treats it. + Rng::Os => getrandom::fill(out).expect("vault: the operating system's random number generator failed"), + #[cfg(test)] + Rng::Seeded(r) => r.read(out), + } + } + + pub fn array(&mut self) -> [u8; N] { + let mut b = [0u8; N]; + self.fill(&mut b); + b + } + + pub fn u64(&mut self) -> u64 { + u64::from_le_bytes(self.array()) + } + + /// A uniform float in [0, 1). + pub fn f64(&mut self) -> f64 { + (self.u64() >> 11) as f64 / (1u64 << 53) as f64 + } + + /// A uniform integer in [0, n), n > 0, without modulo bias. + pub fn below(&mut self, n: u64) -> u64 { + assert!(n > 0); + let zone = u64::MAX - (u64::MAX % n); + loop { + let v = self.u64(); + if v < zone { + return v % n; + } + } + } +} + +/// Shorthand for operating-system randomness. +pub fn os() -> Rng { + Rng::Os +} diff --git a/src/secret.rs b/src/secret.rs new file mode 100644 index 0000000..1d8c326 --- /dev/null +++ b/src/secret.rs @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Owned secret buffers. +//! +//! These types are deliberately neither `Clone`, `Copy`, `Debug` nor +//! serialisable, so a secret cannot be duplicated or printed by accident. +//! Each is heap-allocated once at its final size and cleared with `zeroize` +//! when dropped, including during unwinding. Clearing shortens the lifetime +//! of the bytes these types own; it cannot reach copies the compiler made in +//! registers or on the stack, copies held inside third-party crates' key +//! objects, or memory already swapped out, and an abort or kill skips it. + +use zeroize::Zeroize; + +/// A fixed-size secret, boxed so moving it never copies the bytes. +pub struct SecretArray(Box<[u8; N]>); + +impl SecretArray { + pub fn zero() -> Self { + SecretArray(Box::new([0u8; N])) + } + + pub fn from_slice(b: &[u8]) -> Option { + let mut s = Self::zero(); + if b.len() != N { + return None; + } + s.0.copy_from_slice(b); + Some(s) + } + + pub fn expose(&self) -> &[u8; N] { + &self.0 + } + + pub fn expose_mut(&mut self) -> &mut [u8; N] { + &mut self.0 + } +} + +impl Drop for SecretArray { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +/// A variable-length secret such as a passphrase or decrypted plaintext. +/// The capacity is fixed at creation; operations that could reallocate (and +/// leave an uncleared copy behind) are not offered. +pub struct SecretVec(Vec); + +impl SecretVec { + pub fn with_capacity(n: usize) -> Self { + SecretVec(Vec::with_capacity(n)) + } + + /// Takes ownership of `v`, which from now on is cleared on drop. + pub fn from_vec(v: Vec) -> Self { + SecretVec(v) + } + + pub fn zeroed(n: usize) -> Self { + SecretVec(vec![0u8; n]) + } + + pub fn expose(&self) -> &[u8] { + &self.0 + } + + pub fn expose_mut(&mut self) -> &mut [u8] { + &mut self.0 + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn capacity(&self) -> usize { + self.0.capacity() + } + + /// Appends without reallocating; panics if `b` does not fit. + pub fn extend_within_capacity(&mut self, b: &[u8]) { + assert!(self.0.len() + b.len() <= self.0.capacity(), "secret buffer would reallocate"); + self.0.extend_from_slice(b); + } + + /// Resizes within the existing capacity, zero-filling any growth; + /// panics rather than reallocate. + pub fn resize_within_capacity(&mut self, n: usize) { + assert!(n <= self.0.capacity(), "secret buffer would reallocate"); + if n < self.0.len() { + self.0[n..].zeroize(); + } + self.0.resize(n, 0); + } + + /// Clears the contents for reuse, keeping the allocation. + pub fn clear(&mut self) { + self.0.zeroize(); + } +} + +impl Drop for SecretVec { + fn drop(&mut self) { + // Zeroize the whole capacity, not just the live length. + self.0.zeroize(); + } +} + +impl PartialEq<[u8]> for SecretVec { + fn eq(&self, other: &[u8]) -> bool { + use subtle_eq::ct_eq; + ct_eq(&self.0, other) + } +} + +pub(crate) mod subtle_eq { + /// Constant-time equality for equal-length inputs; lengths are public. + pub fn ct_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut d = 0u8; + for (x, y) in a.iter().zip(b) { + d |= x ^ y; + } + core::hint::black_box(d) == 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn secret_vec_never_reallocates() { + let mut s = SecretVec::with_capacity(4); + s.extend_within_capacity(b"ab"); + s.resize_within_capacity(4); + assert_eq!(s.expose(), b"ab\0\0"); + assert!(std::panic::catch_unwind(move || s.extend_within_capacity(b"x")).is_err()); + } + + #[test] + fn secret_array_length_checked() { + assert!(SecretArray::<4>::from_slice(b"abc").is_none()); + assert_eq!(SecretArray::<3>::from_slice(b"abc").unwrap().expose(), b"abc"); + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..ea18057 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,539 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Small helpers that reproduce Go standard-library behaviour the CLI +//! exposes: size and duration parsing and printing, `path.Clean`, RFC 3339 +//! timestamps, and Go-style I/O error messages. + +use std::path::Path; +use std::time::Duration; + +/// `op path: reason`, with the OS reason lower-cased and without Rust's +/// "(os error N)" suffix, as Go prints a `*PathError`. +pub fn io_msg(op: &str, path: &Path, e: &std::io::Error) -> String { + format!("{op} {}: {}", path.display(), io_reason(e)) +} + +pub fn io_reason(e: &std::io::Error) -> String { + let s = e.to_string(); + let s = match s.find(" (os error ") { + Some(i) => s[..i].to_string(), + None => s, + }; + let mut c = s.chars(); + match c.next() { + Some(f) => f.to_lowercase().chain(c).collect(), + None => s, + } +} + +/// `humanSize`: bytes below 1 KiB, otherwise one decimal and a binary unit. +pub fn human_size(n: u64) -> String { + const UNIT: u64 = 1024; + if n < UNIT { + return format!("{n} B"); + } + let (mut div, mut exp) = (UNIT, 0usize); + let mut m = n / UNIT; + while m >= UNIT { + div *= UNIT; + exp += 1; + m /= UNIT; + } + format!("{:.1} {}iB", n as f64 / div as f64, &"KMGTPE"[exp..exp + 1]) +} + +/// `parseSize`: a byte count with optional K, M, G or T (powers of 1024) +/// and an optional trailing B, at most 1 TiB. +pub fn parse_size(s: &str, limit: u64) -> Result { + let err = || format!("invalid size {s:?}"); + let upper = s.to_uppercase(); + let mut num = upper.strip_suffix('B').unwrap_or(&upper).to_string(); + let mut shift = 0u32; + for (i, unit) in "KMGT".chars().enumerate() { + if let Some(rest) = num.strip_suffix(unit) { + num = rest.to_string(); + shift = 10 * (i as u32 + 1); + } + } + let n = go_parse_int(&num).ok_or_else(err)?; + if n < 0 || n as u64 > limit >> shift { + return Err(err()); + } + Ok((n as u64) << shift) +} + +/// Go's `strconv.ParseInt(s, 10, 64)`: optional sign, decimal digits. +fn go_parse_int(s: &str) -> Option { + let (neg, digits) = match s.as_bytes().first() { + Some(b'+') => (false, &s[1..]), + Some(b'-') => (true, &s[1..]), + _ => (false, s), + }; + if digits.is_empty() || !digits.bytes().all(|c| c.is_ascii_digit()) { + return None; + } + let v: i128 = digits.parse().ok()?; + let v = if neg { -v } else { v }; + i64::try_from(v).ok() +} + +/// Go's `time.ParseDuration`, in nanoseconds. +pub fn parse_duration(s: &str) -> Result { + let err = || format!("time: invalid duration {:?}", s); + let orig = s; + let mut s = s; + let mut neg = false; + if let Some(c @ ('-' | '+')) = s.chars().next() { + neg = c == '-'; + s = &s[1..]; + } + if s == "0" { + return Ok(0); + } + if s.is_empty() { + return Err(err()); + } + let mut total: u128 = 0; + while !s.is_empty() { + let int_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()); + let int_part = &s[..int_end]; + s = &s[int_end..]; + let mut frac_part = ""; + if let Some(rest) = s.strip_prefix('.') { + let fe = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len()); + frac_part = &rest[..fe]; + s = &rest[fe..]; + if int_part.is_empty() && frac_part.is_empty() { + return Err(err()); + } + } else if int_part.is_empty() { + return Err(err()); + } + let ue = s.find(|c: char| c == '.' || c.is_ascii_digit()).unwrap_or(s.len()); + let unit = &s[..ue]; + s = &s[ue..]; + let scale: u128 = match unit { + "ns" => 1, + "us" | "µs" | "μs" => 1_000, + "ms" => 1_000_000, + "s" => 1_000_000_000, + "m" => 60_000_000_000, + "h" => 3_600_000_000_000, + "" => return Err(format!("time: missing unit in duration {orig:?}")), + _ => return Err(format!("time: unknown unit {unit:?} in duration {orig:?}")), + }; + let v: u128 = if int_part.is_empty() { + 0 + } else { + int_part.parse().map_err(|_| err())? + }; + let mut ns = v.checked_mul(scale).ok_or_else(err)?; + if !frac_part.is_empty() { + // Go truncates the fraction to the unit's precision. + let mut f: u128 = 0; + let mut div: u128 = 1; + for c in frac_part.bytes() { + if div > 1_000_000_000_000_000_000 { + break; + } + f = f * 10 + (c - b'0') as u128; + div *= 10; + } + ns += f * scale / div; + } + total = total.checked_add(ns).ok_or_else(err)?; + if total > i64::MAX as u128 + neg as u128 { + return Err(err()); + } + } + Ok(if neg { (total as i128).wrapping_neg() as i64 } else { total as i64 }) +} + +/// Go's `time.Duration.String` for non-negative durations. +pub fn format_duration(d: Duration) -> String { + let ns = d.as_nanos(); + if ns == 0 { + return "0s".into(); + } + if ns < 1_000_000_000 { + let (unit, div, prec) = if ns < 1_000 { + ("ns", 1u128, 0) + } else if ns < 1_000_000 { + ("µs", 1_000, 3) + } else { + ("ms", 1_000_000, 6) + }; + return format!("{}{unit}", frac(ns, div, prec)); + } + let mut out = String::new(); + let h = ns / 3_600_000_000_000; + let m = (ns / 60_000_000_000) % 60; + let s_ns = ns % 60_000_000_000; + if h > 0 { + out.push_str(&format!("{h}h")); + } + if h > 0 || m > 0 { + out.push_str(&format!("{m}m")); + } + out.push_str(&format!("{}s", frac(s_ns, 1_000_000_000, 9))); + out +} + +fn frac(v: u128, div: u128, prec: usize) -> String { + let whole = v / div; + let f = v % div; + if f == 0 || prec == 0 { + return whole.to_string(); + } + let mut digits = format!("{f:0prec$}"); + while digits.ends_with('0') { + digits.pop(); + } + format!("{whole}.{digits}") +} + +/// Go's `path.Clean` for slash-separated paths. +pub fn clean(p: &str) -> String { + if p.is_empty() { + return ".".into(); + } + let rooted = p.starts_with('/'); + let mut parts: Vec<&str> = Vec::new(); + for seg in p.split('/') { + match seg { + "" | "." => {} + ".." => { + if parts.last().is_some_and(|l| *l != "..") { + parts.pop(); + } else if !rooted { + parts.push(".."); + } + } + s => parts.push(s), + } + } + let body = parts.join("/"); + match (rooted, body.is_empty()) { + (true, _) => format!("/{body}"), + (false, true) => ".".into(), + (false, false) => body, + } +} + +/// Go's `path.Base`. +pub fn base(p: &str) -> String { + if p.is_empty() { + return ".".into(); + } + let t = p.trim_end_matches('/'); + if t.is_empty() { + return "/".into(); + } + t.rsplit('/').next().unwrap_or(t).to_string() +} + +/// Go's `path.Join`: join non-empty elements with '/', then clean. +pub fn join(parts: &[&str]) -> String { + let nonempty: Vec<&str> = parts.iter().copied().filter(|p| !p.is_empty()).collect(); + if nonempty.is_empty() { + return String::new(); + } + clean(&nonempty.join("/")) +} + +/// A UTC instant with nanosecond precision, as stored in records. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub struct Instant { + pub secs: i64, + pub nanos: u32, +} + +impl Instant { + pub fn now() -> Self { + Self::from_system(std::time::SystemTime::now()) + } + + pub fn from_system(t: std::time::SystemTime) -> Self { + match t.duration_since(std::time::UNIX_EPOCH) { + Ok(d) => Instant { + secs: d.as_secs() as i64, + nanos: d.subsec_nanos(), + }, + Err(e) => { + let d = e.duration(); + let (s, n) = (d.as_secs() as i64, d.subsec_nanos()); + if n == 0 { + Instant { secs: -s, nanos: 0 } + } else { + Instant { + secs: -s - 1, + nanos: 1_000_000_000 - n, + } + } + } + } + } + + pub fn to_system(self) -> std::time::SystemTime { + if self.secs >= 0 { + std::time::UNIX_EPOCH + Duration::new(self.secs as u64, self.nanos) + } else { + std::time::UNIX_EPOCH - Duration::new(self.secs.unsigned_abs(), 0) + Duration::new(0, self.nanos) + } + } + + fn timestamp(self) -> Option { + // jiff wants both parts to share a sign. + if self.secs < 0 && self.nanos > 0 { + jiff::Timestamp::new(self.secs + 1, self.nanos as i32 - 1_000_000_000).ok() + } else { + jiff::Timestamp::new(self.secs, self.nanos as i32).ok() + } + } + + /// RFC 3339 with up to nine fractional digits, trailing zeros removed, + /// and "Z": Go's `time.RFC3339Nano` for a UTC time. None outside years + /// 0 to 9999, which Go's JSON encoding refuses too. + pub fn to_rfc3339(self) -> Option { + let days = self.secs.div_euclid(86_400); + let sod = self.secs.rem_euclid(86_400); + let (y, mo, d) = civil_from_days(days); + if !(0..=9999).contains(&y) { + return None; + } + let mut s = format!("{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}", sod / 3600, sod / 60 % 60, sod % 60); + if self.nanos != 0 { + let mut f = format!("{:09}", self.nanos); + while f.ends_with('0') { + f.pop(); + } + s.push('.'); + s.push_str(&f); + } + s.push('Z'); + Some(s) + } + + /// Strict RFC 3339 (`YYYY-MM-DDTHH:MM:SS[.frac](Z|+HH:MM|-HH:MM)`), as + /// Go's `time.Time.UnmarshalJSON` accepts it. + pub fn parse_rfc3339(s: &str) -> Option { + let b = s.as_bytes(); + let num = |r: std::ops::Range| -> Option { + let t = b.get(r)?; + if t.iter().all(u8::is_ascii_digit) { + std::str::from_utf8(t).ok()?.parse().ok() + } else { + None + } + }; + if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' { + return None; + } + let (y, mo, d, h, mi, se) = (num(0..4)?, num(5..7)?, num(8..10)?, num(11..13)?, num(14..16)?, num(17..19)?); + if !(1..=12).contains(&mo) || d < 1 || d > days_in_month(y, mo) || h > 23 || mi > 59 || se > 59 { + return None; + } + let mut i = 19; + let mut nanos = 0u32; + if b[i] == b'.' { + let start = i + 1; + i = start; + while i < b.len() && b[i].is_ascii_digit() { + i += 1; + } + let digits = &s[start..i]; + if digits.is_empty() || digits.len() > 9 { + return None; + } + nanos = format!("{digits:0<9}").parse().ok()?; + } + let offset = match &b[i..] { + b"Z" => 0, + [sign @ (b'+' | b'-'), _, _, b':', _, _] => { + let oh = num(i + 1..i + 3)?; + let om = num(i + 4..i + 6)?; + if oh > 23 || om > 59 { + return None; + } + let o = oh * 3600 + om * 60; + if *sign == b'-' { -o } else { o } + } + _ => return None, + }; + let secs = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + se - offset; + Some(Instant { secs, nanos }) + } + + /// "2006-01-02 15:04" in the local time zone, as `ls` and `list` show. + pub fn local_minute(self) -> String { + match self.timestamp() { + Some(ts) => { + let z = ts.to_zoned(jiff::tz::TimeZone::system()); + format!("{:04}-{:02}-{:02} {:02}:{:02}", z.year(), z.month(), z.day(), z.hour(), z.minute()) + } + None => "????-??-?? ??:??".into(), + } + } + + /// "15:04:05" local, for unlock's message. + pub fn local_clock(self) -> String { + match self.timestamp() { + Some(ts) => { + let z = ts.to_zoned(jiff::tz::TimeZone::system()); + format!("{:02}:{:02}:{:02}", z.hour(), z.minute(), z.second()) + } + None => "??:??:??".into(), + } + } +} + +fn days_in_month(y: i64, m: i64) -> i64 { + match m { + 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + } +} + +/// Days since 1970-01-01 of a proleptic Gregorian date (Hinnant). +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +fn civil_from_days(z: i64) -> (i64, i64, i64) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (yoe + era * 400 + (m <= 2) as i64, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + fn v() -> Value { + serde_json::from_str(include_str!("../tests/vectors/store.json")).unwrap() + } + + #[test] + fn sizes_match_go() { + let v = v(); + for (input, want) in v["parse_size"].as_object().unwrap() { + let got = parse_size(input, 1 << 40).ok(); + assert_eq!(got, want.as_u64(), "parseSize({input:?})"); + } + for (n, want) in v["human_size"].as_object().unwrap() { + assert_eq!(human_size(n.parse().unwrap()), want.as_str().unwrap(), "humanSize({n})"); + } + } + + #[test] + fn durations_match_go() { + for (input, want) in v()["durations"].as_object().unwrap() { + let got = parse_duration(input).ok(); + match want { + Value::Null => assert_eq!(got, None, "ParseDuration({input:?})"), + w => { + let ns = w["ns"].as_i64().unwrap(); + assert_eq!(got, Some(ns), "ParseDuration({input:?})"); + if ns >= 0 { + assert_eq!(format_duration(Duration::from_nanos(ns as u64)), w["string"].as_str().unwrap()); + } + } + } + } + } + + #[test] + fn clean_matches_go() { + for (input, want) in v()["path_clean"].as_object().unwrap() { + assert_eq!(clean(input), want.as_str().unwrap(), "Clean({input:?})"); + } + assert_eq!(base("a/b/"), "b"); + assert_eq!(base("/"), "/"); + assert_eq!(join(&["", "a", "b/../c"]), "a/c"); + } + + #[test] + fn rfc3339() { + for (s, n) in [ + (0, 0), + (1700000000, 123456789), + (-1, 500000000), + (253402300799, 999999999), + (1234567890, 120000000), + ] { + let t = Instant { secs: s, nanos: n }; + let text = t.to_rfc3339().unwrap(); + assert_eq!(Instant::parse_rfc3339(&text), Some(t), "{text}"); + } + assert_eq!( + Instant { + secs: 1234567890, + nanos: 120000000 + } + .to_rfc3339() + .unwrap(), + "2009-02-13T23:31:30.12Z" + ); + assert_eq!(Instant::parse_rfc3339("2009-02-13T23:31:30+01:00").unwrap().secs, 1234567890 - 3600); + for bad in [ + "2009-02-13 23:31:30Z", + "2009-02-13T23:31:30", + "2009-02-13T23:31:30.Z", + "2009-13-13T23:31:30Z", + "2009-02-13T23:31:30.1234567890Z", + ] { + assert_eq!(Instant::parse_rfc3339(bad), None, "{bad}"); + } + assert_eq!( + Instant { + secs: 253402300800, + nanos: 0 + } + .to_rfc3339(), + None + ); + assert_eq!( + Instant { + secs: -62167219200, + nanos: 0 + } + .to_rfc3339() + .unwrap(), + "0000-01-01T00:00:00Z" + ); + assert_eq!( + Instant { + secs: -62167219201, + nanos: 0 + } + .to_rfc3339(), + None + ); + assert_eq!(Instant::parse_rfc3339("2023-02-29T00:00:00Z"), None); + assert!(Instant::parse_rfc3339("2024-02-29T00:00:00Z").is_some()); + let neg = Instant::from_system(std::time::UNIX_EPOCH - Duration::new(0, 1)); + assert_eq!( + neg, + Instant { + secs: -1, + nanos: 999_999_999 + } + ); + assert_eq!(Instant::from_system(neg.to_system()), neg); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..b857a4a --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; + +pub fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +/// Whether the Go toolchain is available for oracle-backed tests. When it +/// is not, those tests print SKIPPED and return; CI installs Go so they +/// always run there (VAULT_REQUIRE_ORACLE=1 turns a skip into a failure). +pub fn have_go() -> bool { + let ok = Command::new("go").arg("version").output().is_ok_and(|o| o.status.success()); + if !ok && std::env::var_os("VAULT_REQUIRE_ORACLE").is_some() { + panic!("VAULT_REQUIRE_ORACLE is set but go is not available"); + } + ok +} + +pub fn skip(why: &str) { + eprintln!("SKIPPED: {why}"); +} + +/// Builds the Go oracle CLI once per test process ($VAULT_GO_ORACLE wins). +pub fn go_oracle() -> Option { + static BIN: OnceLock> = OnceLock::new(); + BIN.get_or_init(|| { + if let Some(p) = std::env::var_os("VAULT_GO_ORACLE") { + return Some(PathBuf::from(p)); + } + if !have_go() { + return None; + } + let out = root().join("target").join("vault-go-oracle"); + let st = Command::new("go") + .args(["build", "-o"]) + .arg(&out) + .arg(".") + .current_dir(root().join("oracle/go")) + .env("CGO_ENABLED", "0") + .status() + .expect("run go build"); + assert!(st.success(), "building the Go oracle failed"); + Some(out) + }) + .clone() +} + +pub fn go_test(pkg: &str, test: &str, env: &[(&str, &Path)]) -> std::process::Output { + let mut c = Command::new("go"); + c.args(["test", "-count=1", "-v", "-run", &format!("^{test}$"), pkg]) + .current_dir(root().join("oracle/go")); + for (k, v) in env { + c.env(k, v); + } + c.output().expect("run go test") +} diff --git a/tests/interop_crypto.rs b/tests/interop_crypto.rs new file mode 100644 index 0000000..794e189 --- /dev/null +++ b/tests/interop_crypto.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Rust-to-Go: files the Rust implementation encrypts must open in the +//! frozen Go oracle, and files it corrupts must not. + +mod common; + +use vault::crypto::{fury, kem}; + +fn pattern(n: usize, salt: u8) -> Vec { + (0..n).map(|i| (i.wrapping_mul(7) as u8) ^ salt).collect() +} + +#[test] +fn go_decrypts_rust_files() { + if !common::have_go() { + return common::skip("go not installed"); + } + let dir = tempfile_dir(); + let id = kem::Identity::from_suite_seed(kem::SUITE_ID, &pattern(32, 1)).unwrap(); + for size in [0usize, 1, 16, 65535, 65536, 65537, 131072, 300_001] { + let mut out = Vec::new(); + fury::encrypt_to(&mut out, &id.recipient(), &pattern(size, 0x5a), &mut vault::rng::os()); + std::fs::write(dir.join(format!("pattern-{size}.fury")), &out).unwrap(); + if size == 65537 { + let mut bad = out.clone(); + bad.truncate(bad.len() - 1); + std::fs::write(dir.join("bad-truncated.fury"), &bad).unwrap(); + let mut bad = out.clone(); + bad.push(0); + std::fs::write(dir.join("bad-trailing.fury"), &bad).unwrap(); + let mut bad = out; + let mid = bad.len() / 2; + bad[mid] ^= 4; + std::fs::write(dir.join("bad-flipped.fury"), &bad).unwrap(); + } + } + let o = common::go_test("./internal/pqcrypt", "TestOracleDecrypt", &[("VAULT_ORACLE_DECRYPT", &dir)]); + let log = String::from_utf8_lossy(&o.stdout); + assert!( + o.status.success(), + "go oracle rejected Rust output:\n{log}{}", + String::from_utf8_lossy(&o.stderr) + ); + assert_eq!(log.matches(".fury ok").count(), 8, "{log}"); + std::fs::remove_dir_all(dir).unwrap(); +} + +fn tempfile_dir() -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!( + "vault-interop-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&d).unwrap(); + d +}