diff --git a/Cargo.lock b/Cargo.lock index 05525ca1..48855aa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -62,6 +72,18 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -113,6 +135,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "blake3" version = "1.8.4" @@ -124,7 +155,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.0", ] [[package]] @@ -172,6 +203,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc119b6761ce8b063102502af49043051f81a9bdf242ae06d12e9ea0d92b727a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -228,6 +268,30 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -255,6 +319,17 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.6.0" @@ -289,6 +364,12 @@ dependencies = [ "cc", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -311,6 +392,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -543,12 +633,59 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "defmac" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5592fca31e96d8a748d03080b58be78c5383617aa4bd89e69f30607d8769891" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "der" version = "0.8.0" @@ -559,6 +696,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "dirs" version = "6.0.0" @@ -580,12 +728,49 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encryption" +version = "0.1.0" +dependencies = [ + "argon2", + "chacha20poly1305", + "ed25519-dalek", + "getrandom 0.2.17", + "sha2", + "wasm-bindgen", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -614,6 +799,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.27" @@ -670,6 +861,16 @@ dependencies = [ "libm", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -677,8 +878,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -796,6 +999,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -1065,6 +1277,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openblas-build" version = "0.10.15" @@ -1149,6 +1367,17 @@ dependencies = [ "fractal", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -1170,6 +1399,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -1210,6 +1449,17 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1316,6 +1566,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -1491,6 +1747,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[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.4" @@ -1621,12 +1886,29 @@ dependencies = [ "serde_json", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -1639,12 +1921,28 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[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.117" @@ -1725,6 +2023,12 @@ dependencies = [ "serde_json", ] +[[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.24" @@ -1737,6 +2041,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "ureq" version = "3.3.0" @@ -1744,7 +2058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ "base64 0.22.1", - "der", + "der 0.8.0", "log", "native-tls", "percent-encoding", @@ -1778,6 +2092,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -2151,6 +2471,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zmij" diff --git a/crates/encryption/Cargo.toml b/crates/encryption/Cargo.toml new file mode 100644 index 00000000..b81c5d46 --- /dev/null +++ b/crates/encryption/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "encryption" +version = "0.1.0" +edition = "2021" +rust-version = "1.95" +license = "MIT OR Apache-2.0" +description = "Zero-knowledge envelope crypto for the Ada stack: Argon2id KDF, XChaCha20-Poly1305 AEAD, Ed25519 signatures, SHA-384 hashing. One codebase for native servers and wasm32 browsers." +publish = false + +[lib] +# rlib for native consumers; cdylib so `wasm-pack` / `wasm-bindgen` can emit +# a browser module from the same source. +crate-type = ["rlib", "cdylib"] + +[dependencies] +# RustCrypto suite — all pure Rust, all compile to wasm32-unknown-unknown. +argon2 = { version = "0.5", default-features = false, features = ["alloc"] } +chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] } +ed25519-dalek = { version = "2", default-features = false, features = ["alloc", "zeroize"] } +sha2 = { version = "0.10", default-features = false } +zeroize = { version = "1", features = ["derive"] } + +# THE ONLY ENTROPY SOURCE. On wasm32 the `js` feature routes this to +# `crypto.getRandomValues` (the browser CSPRNG). Never substitute a +# non-cryptographic RNG (e.g. `rand::SmallRng`) anywhere in this crate. +getrandom = "0.2" + +# Optional browser bindings (enable with `--features wasm-bindings` when +# building for wasm32; see src/wasm.rs). +wasm-bindgen = { version = "0.2", optional = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.2", features = ["js"] } + +[features] +default = [] +wasm-bindings = ["dep:wasm-bindgen"] diff --git a/crates/encryption/src/aead.rs b/crates/encryption/src/aead.rs new file mode 100644 index 00000000..25fbd452 --- /dev/null +++ b/crates/encryption/src/aead.rs @@ -0,0 +1,89 @@ +//! XChaCha20-Poly1305 seal/open with explicit 24-byte nonces. +//! +//! The extended (192-bit) nonce is the whole point: it is safe to draw +//! nonces at random from the CSPRNG for the lifetime of a key, so there +//! is no counter state to persist or synchronise between a browser tab +//! and a server. Callers normally use [`crate::envelope`], which manages +//! nonce generation and layout; this module is the raw primitive. + +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; + +/// Byte length of an XChaCha20-Poly1305 nonce. +pub const NONCE_LEN: usize = 24; +/// Byte length of the Poly1305 authentication tag appended to ciphertext. +pub const TAG_LEN: usize = 16; + +/// AEAD failure. Deliberately carries nothing — a decryption failure +/// must be indistinguishable whether the key, nonce, ciphertext, or AAD +/// was wrong. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AeadError; + +impl core::fmt::Display for AeadError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("AEAD seal/open failed") + } +} + +impl std::error::Error for AeadError {} + +/// Encrypt `plaintext` under `key`/`nonce`, authenticating `aad` +/// alongside it. Returns ciphertext with the 16-byte tag appended. +pub fn seal_with_key( + key: &[u8; 32], nonce: &[u8; NONCE_LEN], aad: &[u8], plaintext: &[u8], +) -> Result, AeadError> { + let cipher = XChaCha20Poly1305::new(Key::from_slice(key)); + cipher + .encrypt(XNonce::from_slice(nonce), Payload { msg: plaintext, aad }) + .map_err(|_| AeadError) +} + +/// Decrypt and authenticate. Fails if the key, nonce, AAD, or a single +/// bit of ciphertext is wrong. +pub fn open_with_key( + key: &[u8; 32], nonce: &[u8; NONCE_LEN], aad: &[u8], ciphertext: &[u8], +) -> Result, AeadError> { + let cipher = XChaCha20Poly1305::new(Key::from_slice(key)); + cipher + .decrypt(XNonce::from_slice(nonce), Payload { msg: ciphertext, aad }) + .map_err(|_| AeadError) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_with_aad() { + let key = [0x42u8; 32]; + let nonce = [0x24u8; NONCE_LEN]; + let ct = seal_with_key(&key, &nonce, b"header", b"secret").unwrap(); + assert_eq!(ct.len(), b"secret".len() + TAG_LEN); + let pt = open_with_key(&key, &nonce, b"header", &ct).unwrap(); + assert_eq!(pt, b"secret"); + } + + #[test] + fn wrong_key_fails() { + let ct = seal_with_key(&[1u8; 32], &[0u8; NONCE_LEN], b"", b"x").unwrap(); + assert!(open_with_key(&[2u8; 32], &[0u8; NONCE_LEN], b"", &ct).is_err()); + } + + #[test] + fn wrong_aad_fails() { + let key = [3u8; 32]; + let nonce = [4u8; NONCE_LEN]; + let ct = seal_with_key(&key, &nonce, b"aad-a", b"x").unwrap(); + assert!(open_with_key(&key, &nonce, b"aad-b", &ct).is_err()); + } + + #[test] + fn bit_flip_fails() { + let key = [5u8; 32]; + let nonce = [6u8; NONCE_LEN]; + let mut ct = seal_with_key(&key, &nonce, b"", b"payload").unwrap(); + ct[0] ^= 0x01; + assert!(open_with_key(&key, &nonce, b"", &ct).is_err()); + } +} diff --git a/crates/encryption/src/envelope.rs b/crates/encryption/src/envelope.rs new file mode 100644 index 00000000..b7e1003d --- /dev/null +++ b/crates/encryption/src/envelope.rs @@ -0,0 +1,198 @@ +//! The sealed envelope — password-locked, self-describing, zero-knowledge. +//! +//! [`seal`] runs client-side (browser wasm or native): it derives a key +//! from the password with Argon2id, draws a fresh salt + XChaCha20 +//! nonce from the CSPRNG, and returns one opaque blob. A server that +//! stores the blob learns **nothing** — the password never leaves the +//! client, and the blob authenticates its own header, so any tampering +//! (including with the cost parameters) makes [`open`] fail. +//! +//! ## Byte layout (fixed, little-endian, parsed by hand — no serde) +//! +//! ```text +//! offset len field +//! 0 4 magic = b"ADAC" +//! 4 1 version = 1 +//! 5 4 m_cost_kib (u32 LE) Argon2id memory cost +//! 9 4 t_cost (u32 LE) Argon2id passes +//! 13 4 p_cost (u32 LE) Argon2id lanes +//! 17 16 salt fresh CSPRNG bytes per seal +//! 33 24 nonce fresh CSPRNG bytes per seal +//! 57 … ciphertext ‖ 16-byte Poly1305 tag +//! ``` +//! +//! The first 57 bytes (the header) are the AEAD's associated data, so +//! they are integrity-bound to the ciphertext: an attacker cannot, for +//! example, lower `m_cost_kib` to cheapen an offline guess and still +//! have the blob open. + +use crate::aead::{self, NONCE_LEN}; +pub use crate::kdf::KdfParams; +use crate::kdf::{self, KdfError}; + +/// Envelope magic: "ADAC" (Ada crypto). +pub const MAGIC: [u8; 4] = *b"ADAC"; +/// Current envelope layout version. +pub const VERSION: u8 = 1; +/// Salt length stored in the header. +pub const SALT_LEN: usize = 16; +/// Total header length preceding the ciphertext. +pub const HEADER_LEN: usize = 4 + 1 + 4 + 4 + 4 + SALT_LEN + NONCE_LEN; // 57 + +/// Why an envelope could not be sealed or opened. Field-free — an +/// `open` failure never says *which* part was wrong. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnvelopeError { + /// The platform CSPRNG was unavailable (seal only). + Rng, + /// The Argon2id parameters were rejected. + Kdf(KdfError), + /// The blob is not an envelope (bad magic, short buffer). + Malformed, + /// The blob's layout version is newer than this code understands. + UnsupportedVersion, + /// Wrong password, or the blob was tampered with. Indistinguishable + /// by design. + Decrypt, + /// Encryption failed (should not happen with valid inputs). + Encrypt, +} + +impl core::fmt::Display for EnvelopeError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + EnvelopeError::Rng => f.write_str("platform CSPRNG unavailable"), + EnvelopeError::Kdf(e) => write!(f, "key derivation failed: {e}"), + EnvelopeError::Malformed => f.write_str("not a sealed envelope"), + EnvelopeError::UnsupportedVersion => f.write_str("unsupported envelope version"), + EnvelopeError::Decrypt => f.write_str("wrong password or tampered envelope"), + EnvelopeError::Encrypt => f.write_str("encryption failed"), + } + } +} + +impl std::error::Error for EnvelopeError {} + +/// Seal `plaintext` under `password`. Draws salt + nonce from the +/// CSPRNG, so sealing the same input twice yields different blobs. +pub fn seal(password: &[u8], plaintext: &[u8], params: &KdfParams) -> Result, EnvelopeError> { + let mut salt = [0u8; SALT_LEN]; + crate::fill_random(&mut salt).map_err(|_| EnvelopeError::Rng)?; + let mut nonce = [0u8; NONCE_LEN]; + crate::fill_random(&mut nonce).map_err(|_| EnvelopeError::Rng)?; + + let header = encode_header(params, &salt, &nonce); + let key = kdf::derive_key(password, &salt, params).map_err(EnvelopeError::Kdf)?; + let ciphertext = + aead::seal_with_key(key.as_bytes(), &nonce, &header, plaintext).map_err(|_| EnvelopeError::Encrypt)?; + + let mut blob = Vec::with_capacity(HEADER_LEN + ciphertext.len()); + blob.extend_from_slice(&header); + blob.extend_from_slice(&ciphertext); + Ok(blob) +} + +/// Open a sealed envelope with `password`. The KDF parameters are read +/// from the (authenticated) header, so cost bumps never orphan old blobs. +pub fn open(password: &[u8], blob: &[u8]) -> Result, EnvelopeError> { + let (params, salt, nonce) = decode_header(blob)?; + let header = &blob[..HEADER_LEN]; + let ciphertext = &blob[HEADER_LEN..]; + + let key = kdf::derive_key(password, &salt, ¶ms).map_err(EnvelopeError::Kdf)?; + aead::open_with_key(key.as_bytes(), &nonce, header, ciphertext).map_err(|_| EnvelopeError::Decrypt) +} + +fn encode_header(params: &KdfParams, salt: &[u8; SALT_LEN], nonce: &[u8; NONCE_LEN]) -> [u8; HEADER_LEN] { + let mut h = [0u8; HEADER_LEN]; + h[0..4].copy_from_slice(&MAGIC); + h[4] = VERSION; + h[5..9].copy_from_slice(¶ms.m_cost_kib.to_le_bytes()); + h[9..13].copy_from_slice(¶ms.t_cost.to_le_bytes()); + h[13..17].copy_from_slice(¶ms.p_cost.to_le_bytes()); + h[17..17 + SALT_LEN].copy_from_slice(salt); + h[33..33 + NONCE_LEN].copy_from_slice(nonce); + h +} + +fn decode_header(blob: &[u8]) -> Result<(KdfParams, [u8; SALT_LEN], [u8; NONCE_LEN]), EnvelopeError> { + if blob.len() < HEADER_LEN || blob[0..4] != MAGIC { + return Err(EnvelopeError::Malformed); + } + if blob[4] != VERSION { + return Err(EnvelopeError::UnsupportedVersion); + } + let le_u32 = |at: usize| u32::from_le_bytes([blob[at], blob[at + 1], blob[at + 2], blob[at + 3]]); + let params = KdfParams { + m_cost_kib: le_u32(5), + t_cost: le_u32(9), + p_cost: le_u32(13), + }; + let mut salt = [0u8; SALT_LEN]; + salt.copy_from_slice(&blob[17..17 + SALT_LEN]); + let mut nonce = [0u8; NONCE_LEN]; + nonce.copy_from_slice(&blob[33..33 + NONCE_LEN]); + Ok((params, salt, nonce)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FAST: KdfParams = KdfParams { + m_cost_kib: 32, + t_cost: 1, + p_cost: 1, + }; + + #[test] + fn round_trip() { + let blob = seal(b"hunter2", b"the secret", &FAST).unwrap(); + assert_eq!(open(b"hunter2", &blob).unwrap(), b"the secret"); + } + + #[test] + fn wrong_password_fails_opaquely() { + let blob = seal(b"right", b"s", &FAST).unwrap(); + assert_eq!(open(b"wrong", &blob).unwrap_err(), EnvelopeError::Decrypt); + } + + #[test] + fn same_input_two_seals_differ() { + let a = seal(b"pw", b"s", &FAST).unwrap(); + let b = seal(b"pw", b"s", &FAST).unwrap(); + assert_ne!(a, b, "salt+nonce must be fresh per seal"); + } + + #[test] + fn header_tamper_fails() { + let mut blob = seal(b"pw", b"s", &FAST).unwrap(); + // Attacker tries to cheapen the KDF cost in the header. + blob[5] = 1; // m_cost_kib low byte + assert!(open(b"pw", &blob).is_err()); + } + + #[test] + fn ciphertext_tamper_fails() { + let mut blob = seal(b"pw", b"s", &FAST).unwrap(); + let last = blob.len() - 1; + blob[last] ^= 0x80; + assert_eq!(open(b"pw", &blob).unwrap_err(), EnvelopeError::Decrypt); + } + + #[test] + fn malformed_inputs_rejected() { + assert_eq!(open(b"pw", b"").unwrap_err(), EnvelopeError::Malformed); + assert_eq!(open(b"pw", &[0u8; HEADER_LEN]).unwrap_err(), EnvelopeError::Malformed); + let mut blob = seal(b"pw", b"s", &FAST).unwrap(); + blob[4] = 99; + assert_eq!(open(b"pw", &blob).unwrap_err(), EnvelopeError::UnsupportedVersion); + } + + #[test] + fn empty_plaintext_round_trips() { + let blob = seal(b"pw", b"", &FAST).unwrap(); + assert_eq!(blob.len(), HEADER_LEN + crate::aead::TAG_LEN); + assert_eq!(open(b"pw", &blob).unwrap(), b""); + } +} diff --git a/crates/encryption/src/hash.rs b/crates/encryption/src/hash.rs new file mode 100644 index 00000000..5e4f65cc --- /dev/null +++ b/crates/encryption/src/hash.rs @@ -0,0 +1,78 @@ +//! SHA-384 hashing + domain-separated merkle helpers. +//! +//! SHA-384 is SHA-512 truncated to 48 bytes with a distinct IV: same +//! security family as SHA-256 but immune to length-extension, which is +//! why it is the fingerprint/merkle hash of choice here. + +use sha2::{Digest, Sha384}; + +/// Byte length of a SHA-384 digest. +pub const DIGEST_LEN: usize = 48; + +/// Domain-separation prefix for merkle leaves. +const LEAF_TAG: u8 = 0x00; +/// Domain-separation prefix for merkle interior nodes. +const NODE_TAG: u8 = 0x01; + +/// Plain SHA-384 of `data`. +pub fn sha384(data: &[u8]) -> [u8; DIGEST_LEN] { + let mut out = [0u8; DIGEST_LEN]; + out.copy_from_slice(&Sha384::digest(data)); + out +} + +/// Hash a merkle **leaf**: `SHA-384(0x00 ‖ data)`. The tag prevents a +/// leaf from ever colliding with an interior node (second-preimage +/// hardening, RFC 6962-style). +pub fn merkle_leaf(data: &[u8]) -> [u8; DIGEST_LEN] { + let mut h = Sha384::new(); + h.update([LEAF_TAG]); + h.update(data); + let mut out = [0u8; DIGEST_LEN]; + out.copy_from_slice(&h.finalize()); + out +} + +/// Hash a merkle **interior node**: `SHA-384(0x01 ‖ left ‖ right)`. +pub fn merkle_node(left: &[u8; DIGEST_LEN], right: &[u8; DIGEST_LEN]) -> [u8; DIGEST_LEN] { + let mut h = Sha384::new(); + h.update([NODE_TAG]); + h.update(left); + h.update(right); + let mut out = [0u8; DIGEST_LEN]; + out.copy_from_slice(&h.finalize()); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha384_abc_known_vector() { + // FIPS 180-4 example vector for SHA-384("abc"). + let expected = "cb00753f45a35e8bb5a03d699ac65007272c32ab0eded163\ + 1a8b605a43ff5bed8086072ba1e7cc2358baeca134c825a7"; + let got: String = sha384(b"abc").iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(got, expected); + } + + #[test] + fn leaf_and_node_domains_are_separated() { + // A node over (x, y) must differ from a leaf over the same bytes. + let x = merkle_leaf(b"x"); + let y = merkle_leaf(b"y"); + let node = merkle_node(&x, &y); + let mut concat = Vec::new(); + concat.extend_from_slice(&x); + concat.extend_from_slice(&y); + assert_ne!(node, merkle_leaf(&concat)); + assert_ne!(node[..], sha384(&concat)[..]); + } + + #[test] + fn deterministic() { + assert_eq!(sha384(b"same"), sha384(b"same")); + assert_ne!(sha384(b"a"), sha384(b"b")); + } +} diff --git a/crates/encryption/src/kdf.rs b/crates/encryption/src/kdf.rs new file mode 100644 index 00000000..5c881a6f --- /dev/null +++ b/crates/encryption/src/kdf.rs @@ -0,0 +1,138 @@ +//! Argon2id key derivation — password → 256-bit AEAD key. +//! +//! The parameters travel inside the sealed envelope header (see +//! [`crate::envelope`]) so old blobs stay openable after a cost bump. + +use argon2::{Algorithm, Argon2, Params, Version}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// Argon2id cost parameters. Stored verbatim (little-endian) in the +/// envelope header, so they are part of the authenticated data. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KdfParams { + /// Memory cost in KiB. + pub m_cost_kib: u32, + /// Iteration count (passes over memory). + pub t_cost: u32, + /// Parallelism (lanes). + pub p_cost: u32, +} + +impl KdfParams { + /// Server-grade default: 64 MiB, 3 passes, 1 lane. + pub const DEFAULT: KdfParams = KdfParams { + m_cost_kib: 64 * 1024, + t_cost: 3, + p_cost: 1, + }; + + /// Interactive / browser-grade: 19 MiB, 2 passes, 1 lane + /// (the OWASP first-recommended Argon2id configuration). + /// Use when the derivation runs on every login inside wasm. + pub const INTERACTIVE: KdfParams = KdfParams { + m_cost_kib: 19 * 1024, + t_cost: 2, + p_cost: 1, + }; +} + +impl Default for KdfParams { + fn default() -> Self { + Self::DEFAULT + } +} + +/// A derived 256-bit key. Wiped from memory on drop. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct DerivedKey(pub(crate) [u8; 32]); + +impl DerivedKey { + /// Borrow the raw key bytes (for handing to the AEAD). + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +/// Key-derivation failure. Field-free on purpose — no secret material, +/// no parameter echo. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KdfError { + /// The cost parameters are outside Argon2's accepted range. + InvalidParams, + /// The derivation itself failed (allocation, internal error). + DerivationFailed, +} + +impl core::fmt::Display for KdfError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + KdfError::InvalidParams => f.write_str("invalid Argon2id parameters"), + KdfError::DerivationFailed => f.write_str("Argon2id derivation failed"), + } + } +} + +impl std::error::Error for KdfError {} + +/// Derive a 256-bit key from `password` and a 16-byte `salt` with +/// Argon2id (v1.3). Deterministic: same inputs → same key. +pub fn derive_key(password: &[u8], salt: &[u8; 16], params: &KdfParams) -> Result { + let argon_params = + Params::new(params.m_cost_kib, params.t_cost, params.p_cost, Some(32)).map_err(|_| KdfError::InvalidParams)?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon_params); + let mut key = [0u8; 32]; + argon + .hash_password_into(password, salt, &mut key) + .map_err(|_| KdfError::DerivationFailed)?; + Ok(DerivedKey(key)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Small params so tests stay fast; correctness is parameter-independent. + const TEST_PARAMS: KdfParams = KdfParams { + m_cost_kib: 32, + t_cost: 1, + p_cost: 1, + }; + + #[test] + fn deterministic_for_same_inputs() { + let salt = [7u8; 16]; + let a = derive_key(b"correct horse", &salt, &TEST_PARAMS).unwrap(); + let b = derive_key(b"correct horse", &salt, &TEST_PARAMS).unwrap(); + assert_eq!(a.as_bytes(), b.as_bytes()); + } + + #[test] + fn different_salt_different_key() { + let a = derive_key(b"pw", &[1u8; 16], &TEST_PARAMS).unwrap(); + let b = derive_key(b"pw", &[2u8; 16], &TEST_PARAMS).unwrap(); + assert_ne!(a.as_bytes(), b.as_bytes()); + } + + #[test] + fn different_password_different_key() { + let salt = [9u8; 16]; + let a = derive_key(b"pw-a", &salt, &TEST_PARAMS).unwrap(); + let b = derive_key(b"pw-b", &salt, &TEST_PARAMS).unwrap(); + assert_ne!(a.as_bytes(), b.as_bytes()); + } + + #[test] + fn rejects_zero_memory() { + let bad = KdfParams { + m_cost_kib: 0, + t_cost: 1, + p_cost: 1, + }; + // No `unwrap_err()` here: DerivedKey deliberately has no Debug + // impl (a key must never be printable). + match derive_key(b"pw", &[0u8; 16], &bad) { + Err(e) => assert_eq!(e, KdfError::InvalidParams), + Ok(_) => panic!("zero-memory params must be rejected"), + } + } +} diff --git a/crates/encryption/src/lib.rs b/crates/encryption/src/lib.rs new file mode 100644 index 00000000..268ecda8 --- /dev/null +++ b/crates/encryption/src/lib.rs @@ -0,0 +1,95 @@ +//! # encryption — zero-knowledge envelope crypto, native + wasm32 +//! +//! One small, agnostic crate carrying the forward crypto suite of the Ada +//! stack, compiled from the same source for **native servers** and +//! **wasm32 browsers** (any stock Chrome/Firefox/Safari — no flags, no +//! extensions): +//! +//! | Role | Primitive | Why | +//! |---|---|---| +//! | Password hash / KDF | **Argon2id** | memory-hard, GPU/ASIC-resistant | +//! | Recoverable-secret AEAD | **XChaCha20-Poly1305** | 192-bit random nonce → no nonce-management footguns; authenticated; constant-time in software | +//! | Signatures (licences, audit) | **Ed25519** | small keys/sigs, deterministic, misuse-resistant | +//! | Hashing (merkle, fingerprints) | **SHA-384** | truncation-resistant SHA-2; no length-extension | +//! +//! The point of the wasm leg: a consumer application can run +//! [`envelope::seal`] **in the browser**, so secrets are encrypted +//! client-side before they ever reach a server. The server stores an +//! opaque [`envelope`] blob it cannot read — end-to-end zero knowledge — +//! and the *same* Rust functions open it again, in the browser or on the +//! server side of a self-hosted deployment. +//! +//! ## Iron rules of this crate +//! +//! - **Entropy comes from [`getrandom`] only** (on wasm32: the `js` +//! feature → `crypto.getRandomValues`). Never a userspace PRNG. +//! - **No serde, no runtime deserialization framework.** The sealed +//! envelope is a fixed, versioned little-endian byte layout parsed by +//! hand ([`envelope`]) — the whole layout is the documentation. +//! - **No secrets in errors.** Error types are field-free enums. +//! - **Derived keys zeroize on drop.** +//! - `#![forbid(unsafe_code)]` — this crate is pure safe Rust; the SIMD +//! fast paths live inside the RustCrypto crates it consumes. +//! +//! ## What this crate deliberately does NOT do +//! +//! - **TLS / transport.** Browsers speak TLS with ECDSA P-256/384 certs; +//! that stays in the web server / reverse proxy layer. +//! - **AES.** Browsers ship hardware-accelerated AES-GCM via WebCrypto; +//! if a consumer wants AES it should call WebCrypto, not a wasm +//! reimplementation. This crate exists for the primitives WebCrypto +//! does *not* offer (Argon2, XChaCha20-Poly1305, Ed25519). +//! - **SIMD.** ndarray's one and only SIMD entry point is the existing +//! `simd.rs` polyfill (compile-time dispatch → `simd_avx512.rs` / +//! `simd_avx2.rs` / `simd_neon.rs` / `simd_wasm.rs`). This crate adds +//! **no** SIMD code and **no** parallel dispatch — the vectorized fast +//! paths live inside the audited RustCrypto crates it consumes. If a +//! *batch* hash primitive (e.g. multi-buffer SHA-384 for merkle +//! throughput) is ever measured to be hot, it belongs in the root +//! crate behind the existing `simd.rs` dispatch under the W1a consumer +//! contract — never as a second dispatch here. +//! +//! ## wasm build +//! +//! ```text +//! cargo check -p encryption --target wasm32-unknown-unknown +//! cargo build -p encryption --target wasm32-unknown-unknown \ +//! --features wasm-bindings --release +//! ``` +//! +//! Note: wasm gives weaker constant-time *guarantees* than native code +//! (JIT tiers may re-time code). The chosen primitives are the most +//! timing-robust available (ChaCha/Poly1305 and Ed25519 avoid +//! data-dependent table lookups), which is exactly why this suite — and +//! not AES-CBC lookalikes — is the browser-side choice. + +#![forbid(unsafe_code)] + +pub mod aead; +pub mod envelope; +pub mod hash; +pub mod kdf; +pub mod sign; + +#[cfg(feature = "wasm-bindings")] +pub mod wasm; + +pub use envelope::{open, seal, EnvelopeError, KdfParams}; + +/// Fill `buf` from the platform CSPRNG (`getrandom`; on wasm32 this is +/// `crypto.getRandomValues`). The single entropy chokepoint of the crate. +pub(crate) fn fill_random(buf: &mut [u8]) -> Result<(), RngError> { + getrandom::getrandom(buf).map_err(|_| RngError) +} + +/// The platform CSPRNG was unavailable. Carries no detail on purpose. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RngError; + +impl core::fmt::Display for RngError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("platform CSPRNG unavailable") + } +} + +impl std::error::Error for RngError {} diff --git a/crates/encryption/src/sign.rs b/crates/encryption/src/sign.rs new file mode 100644 index 00000000..09d1d0ef --- /dev/null +++ b/crates/encryption/src/sign.rs @@ -0,0 +1,134 @@ +//! Ed25519 signatures — licence stamping, audit-witness signing. +//! +//! Ed25519 is deterministic (no per-signature randomness to get wrong), +//! uses SHA-512 internally as the scheme mandates, and produces 32-byte +//! public keys with 64-byte signatures. Seeds come from `getrandom` +//! only, or from a caller-supplied 32-byte seed (e.g. one derived by +//! [`crate::kdf`] from a passphrase, for a deterministic identity). + +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; + +/// Byte length of an Ed25519 public key. +pub const PUBLIC_KEY_LEN: usize = 32; +/// Byte length of an Ed25519 seed (private key). +pub const SEED_LEN: usize = 32; +/// Byte length of an Ed25519 signature. +pub const SIGNATURE_LEN: usize = 64; + +/// Signing failure (currently only: no entropy for key generation). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SignError; + +impl core::fmt::Display for SignError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("signing-key generation failed") + } +} + +impl std::error::Error for SignError {} + +/// An Ed25519 keypair. The seed zeroizes on drop (via `ed25519-dalek`'s +/// `zeroize` feature). +pub struct Keypair { + signing: SigningKey, +} + +impl Keypair { + /// Generate a fresh keypair from the platform CSPRNG. + pub fn generate() -> Result { + let mut seed = [0u8; SEED_LEN]; + crate::fill_random(&mut seed).map_err(|_| SignError)?; + Ok(Self::from_seed(&seed)) + } + + /// Deterministic keypair from a 32-byte seed the caller owns. + pub fn from_seed(seed: &[u8; SEED_LEN]) -> Self { + Keypair { + signing: SigningKey::from_bytes(seed), + } + } + + /// The 32-byte public half, safe to publish. + pub fn public_key(&self) -> [u8; PUBLIC_KEY_LEN] { + self.signing.verifying_key().to_bytes() + } + + /// Sign `message`; Ed25519 signatures are deterministic per + /// (seed, message). + pub fn sign(&self, message: &[u8]) -> [u8; SIGNATURE_LEN] { + self.signing.sign(message).to_bytes() + } +} + +/// Verify `signature` over `message` against a 32-byte public key. +/// Returns `false` for malformed keys/signatures as well as honest +/// mismatches — callers get one bit, nothing to oracle on. +pub fn verify(public_key: &[u8; PUBLIC_KEY_LEN], message: &[u8], signature: &[u8; SIGNATURE_LEN]) -> bool { + let Ok(vk) = VerifyingKey::from_bytes(public_key) else { + return false; + }; + let sig = Signature::from_bytes(signature); + vk.verify(message, &sig).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sign_verify_round_trip() { + let kp = Keypair::generate().unwrap(); + let sig = kp.sign(b"licence: 42 seats"); + assert!(verify(&kp.public_key(), b"licence: 42 seats", &sig)); + } + + #[test] + fn tampered_message_rejected() { + let kp = Keypair::generate().unwrap(); + let sig = kp.sign(b"licence: 42 seats"); + assert!(!verify(&kp.public_key(), b"licence: 43 seats", &sig)); + } + + #[test] + fn wrong_key_rejected() { + let a = Keypair::generate().unwrap(); + let b = Keypair::generate().unwrap(); + let sig = a.sign(b"msg"); + assert!(!verify(&b.public_key(), b"msg", &sig)); + } + + #[test] + fn deterministic_from_seed() { + let seed = [0x11u8; SEED_LEN]; + let a = Keypair::from_seed(&seed); + let b = Keypair::from_seed(&seed); + assert_eq!(a.public_key(), b.public_key()); + assert_eq!(a.sign(b"m"), b.sign(b"m")); + } + + #[test] + fn rfc8032_test_vector_1() { + // RFC 8032 §7.1 TEST 1: empty message. + let seed = hex(b"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"); + let expected_pk = hex(b"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"); + let kp = Keypair::from_seed(&seed); + assert_eq!(kp.public_key(), expected_pk); + let sig = kp.sign(b""); + assert!(verify(&kp.public_key(), b"", &sig)); + } + + fn hex(s: &[u8]) -> [u8; N] { + fn nibble(c: u8) -> u8 { + match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + _ => panic!("bad hex"), + } + } + let mut out = [0u8; N]; + for (i, pair) in s.chunks(2).enumerate() { + out[i] = (nibble(pair[0]) << 4) | nibble(pair[1]); + } + out + } +} diff --git a/crates/encryption/src/wasm.rs b/crates/encryption/src/wasm.rs new file mode 100644 index 00000000..e56f1310 --- /dev/null +++ b/crates/encryption/src/wasm.rs @@ -0,0 +1,88 @@ +//! Browser bindings (`--features wasm-bindings`, target wasm32). +//! +//! Thin `wasm-bindgen` façade over the crate's Rust API so a stock +//! browser can call it from JavaScript after a `wasm-pack build`: +//! +//! ```js +//! import init, { seal_envelope, open_envelope } from "./pkg/encryption.js"; +//! await init(); +//! const blob = seal_envelope(pwUtf8, secretBytes, true /* interactive */); +//! // ship `blob` to the server — it is unreadable there +//! const secret = open_envelope(pwUtf8, blob); +//! ``` +//! +//! Errors surface as thrown JS strings (the `Display` of the Rust +//! error) — deliberately message-only, never secret material. + +use wasm_bindgen::prelude::*; + +use crate::envelope::{self, KdfParams}; +use crate::sign::{Keypair, PUBLIC_KEY_LEN, SEED_LEN, SIGNATURE_LEN}; + +fn params(interactive: bool) -> KdfParams { + if interactive { + KdfParams::INTERACTIVE + } else { + KdfParams::DEFAULT + } +} + +/// Seal `plaintext` under `password` client-side. With +/// `interactive = true` the browser-grade Argon2id cost is used. +#[wasm_bindgen] +pub fn seal_envelope(password: &[u8], plaintext: &[u8], interactive: bool) -> Result, JsError> { + envelope::seal(password, plaintext, ¶ms(interactive)).map_err(|e| JsError::new(&e.to_string())) +} + +/// Open a sealed envelope. Throws on wrong password or tampering. +#[wasm_bindgen] +pub fn open_envelope(password: &[u8], blob: &[u8]) -> Result, JsError> { + envelope::open(password, blob).map_err(|e| JsError::new(&e.to_string())) +} + +/// Generate a fresh Ed25519 seed (32 bytes) from the browser CSPRNG. +/// The caller is responsible for storing it sealed (see +/// [`seal_envelope`]) — never in plaintext localStorage. +#[wasm_bindgen] +pub fn generate_signing_seed() -> Result, JsError> { + let mut seed = [0u8; SEED_LEN]; + crate::fill_random(&mut seed).map_err(|e| JsError::new(&e.to_string()))?; + Ok(seed.to_vec()) +} + +/// Derive the 32-byte public key for a seed. +#[wasm_bindgen] +pub fn public_key_of(seed: &[u8]) -> Result, JsError> { + let seed: [u8; SEED_LEN] = seed + .try_into() + .map_err(|_| JsError::new("seed must be 32 bytes"))?; + Ok(Keypair::from_seed(&seed).public_key().to_vec()) +} + +/// Sign `message` with `seed`; returns the 64-byte signature. +#[wasm_bindgen] +pub fn sign_message(seed: &[u8], message: &[u8]) -> Result, JsError> { + let seed: [u8; SEED_LEN] = seed + .try_into() + .map_err(|_| JsError::new("seed must be 32 bytes"))?; + Ok(Keypair::from_seed(&seed).sign(message).to_vec()) +} + +/// Verify a signature; returns a plain boolean, throws only on +/// malformed lengths. +#[wasm_bindgen] +pub fn verify_signature(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result { + let pk: [u8; PUBLIC_KEY_LEN] = public_key + .try_into() + .map_err(|_| JsError::new("public key must be 32 bytes"))?; + let sig: [u8; SIGNATURE_LEN] = signature + .try_into() + .map_err(|_| JsError::new("signature must be 64 bytes"))?; + Ok(crate::sign::verify(&pk, message, &sig)) +} + +/// SHA-384 of `data` (48 bytes). +#[wasm_bindgen] +pub fn sha384(data: &[u8]) -> Vec { + crate::hash::sha384(data).to_vec() +}