Skip to content

Commit 28ff812

Browse files
committed
encryption: zero-knowledge envelope crypto crate, native + wasm32
New workspace member (crates/* glob; NOT a default-member, so plain cargo build is untouched). Carries the forward crypto suite as one agnostic crate compiled from the same source for native servers and stock browsers via wasm32-unknown-unknown: - kdf: Argon2id (password -> 256-bit key), params carried in-band; DEFAULT (64 MiB/3/1) and INTERACTIVE (19 MiB/2/1, OWASP) presets - aead: XChaCha20-Poly1305 with explicit 24-byte random nonces - envelope: the sealed blob — versioned little-endian layout parsed by hand (no serde), header (magic|version|argon params|salt|nonce) is the AEAD associated data so cost-parameter tampering fails open() - sign: Ed25519 (generate/from_seed/sign/verify), RFC 8032 vector test - hash: SHA-384 + RFC 6962-style domain-separated merkle helpers - wasm: optional wasm-bindgen facade (--features wasm-bindings) No SIMD of its own: simd.rs remains the repo's single dispatch entry (simd_avx512/avx2/neon/simd_wasm); vectorized paths live inside the audited RustCrypto crates. Entropy is getrandom-only (js feature -> crypto.getRandomValues on wasm32). Keys zeroize on drop, no Debug on key types, field-free errors, #![forbid(unsafe_code)]. Verified: 23 unit tests green natively; clippy -D warnings clean (all features); cargo check green on wasm32-unknown-unknown with and without wasm-bindings; release cdylib builds a wasm module. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RbXniqYUYgwUsRrEnRRiid
1 parent 83d4895 commit 28ff812

9 files changed

Lines changed: 1193 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 336 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/encryption/Cargo.toml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
[package]
2+
name = "encryption"
3+
version = "0.1.0"
4+
edition = "2021"
5+
rust-version = "1.95"
6+
license = "MIT OR Apache-2.0"
7+
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."
8+
publish = false
9+
10+
[lib]
11+
# rlib for native consumers; cdylib so `wasm-pack` / `wasm-bindgen` can emit
12+
# a browser module from the same source.
13+
crate-type = ["rlib", "cdylib"]
14+
15+
[dependencies]
16+
# RustCrypto suite — all pure Rust, all compile to wasm32-unknown-unknown.
17+
argon2 = { version = "0.5", default-features = false, features = ["alloc"] }
18+
chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] }
19+
ed25519-dalek = { version = "2", default-features = false, features = ["alloc", "zeroize"] }
20+
sha2 = { version = "0.10", default-features = false }
21+
zeroize = { version = "1", features = ["derive"] }
22+
23+
# THE ONLY ENTROPY SOURCE. On wasm32 the `js` feature routes this to
24+
# `crypto.getRandomValues` (the browser CSPRNG). Never substitute a
25+
# non-cryptographic RNG (e.g. `rand::SmallRng`) anywhere in this crate.
26+
getrandom = "0.2"
27+
28+
# Optional browser bindings (enable with `--features wasm-bindings` when
29+
# building for wasm32; see src/wasm.rs).
30+
wasm-bindgen = { version = "0.2", optional = true }
31+
32+
[target.'cfg(target_arch = "wasm32")'.dependencies]
33+
getrandom = { version = "0.2", features = ["js"] }
34+
35+
[features]
36+
default = []
37+
wasm-bindings = ["dep:wasm-bindgen"]

crates/encryption/src/aead.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
//! XChaCha20-Poly1305 seal/open with explicit 24-byte nonces.
2+
//!
3+
//! The extended (192-bit) nonce is the whole point: it is safe to draw
4+
//! nonces at random from the CSPRNG for the lifetime of a key, so there
5+
//! is no counter state to persist or synchronise between a browser tab
6+
//! and a server. Callers normally use [`crate::envelope`], which manages
7+
//! nonce generation and layout; this module is the raw primitive.
8+
9+
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
10+
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
11+
12+
/// Byte length of an XChaCha20-Poly1305 nonce.
13+
pub const NONCE_LEN: usize = 24;
14+
/// Byte length of the Poly1305 authentication tag appended to ciphertext.
15+
pub const TAG_LEN: usize = 16;
16+
17+
/// AEAD failure. Deliberately carries nothing — a decryption failure
18+
/// must be indistinguishable whether the key, nonce, ciphertext, or AAD
19+
/// was wrong.
20+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21+
pub struct AeadError;
22+
23+
impl core::fmt::Display for AeadError {
24+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
25+
f.write_str("AEAD seal/open failed")
26+
}
27+
}
28+
29+
impl std::error::Error for AeadError {}
30+
31+
/// Encrypt `plaintext` under `key`/`nonce`, authenticating `aad`
32+
/// alongside it. Returns ciphertext with the 16-byte tag appended.
33+
pub fn seal_with_key(
34+
key: &[u8; 32], nonce: &[u8; NONCE_LEN], aad: &[u8], plaintext: &[u8],
35+
) -> Result<Vec<u8>, AeadError> {
36+
let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
37+
cipher
38+
.encrypt(XNonce::from_slice(nonce), Payload { msg: plaintext, aad })
39+
.map_err(|_| AeadError)
40+
}
41+
42+
/// Decrypt and authenticate. Fails if the key, nonce, AAD, or a single
43+
/// bit of ciphertext is wrong.
44+
pub fn open_with_key(
45+
key: &[u8; 32], nonce: &[u8; NONCE_LEN], aad: &[u8], ciphertext: &[u8],
46+
) -> Result<Vec<u8>, AeadError> {
47+
let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
48+
cipher
49+
.decrypt(XNonce::from_slice(nonce), Payload { msg: ciphertext, aad })
50+
.map_err(|_| AeadError)
51+
}
52+
53+
#[cfg(test)]
54+
mod tests {
55+
use super::*;
56+
57+
#[test]
58+
fn round_trip_with_aad() {
59+
let key = [0x42u8; 32];
60+
let nonce = [0x24u8; NONCE_LEN];
61+
let ct = seal_with_key(&key, &nonce, b"header", b"secret").unwrap();
62+
assert_eq!(ct.len(), b"secret".len() + TAG_LEN);
63+
let pt = open_with_key(&key, &nonce, b"header", &ct).unwrap();
64+
assert_eq!(pt, b"secret");
65+
}
66+
67+
#[test]
68+
fn wrong_key_fails() {
69+
let ct = seal_with_key(&[1u8; 32], &[0u8; NONCE_LEN], b"", b"x").unwrap();
70+
assert!(open_with_key(&[2u8; 32], &[0u8; NONCE_LEN], b"", &ct).is_err());
71+
}
72+
73+
#[test]
74+
fn wrong_aad_fails() {
75+
let key = [3u8; 32];
76+
let nonce = [4u8; NONCE_LEN];
77+
let ct = seal_with_key(&key, &nonce, b"aad-a", b"x").unwrap();
78+
assert!(open_with_key(&key, &nonce, b"aad-b", &ct).is_err());
79+
}
80+
81+
#[test]
82+
fn bit_flip_fails() {
83+
let key = [5u8; 32];
84+
let nonce = [6u8; NONCE_LEN];
85+
let mut ct = seal_with_key(&key, &nonce, b"", b"payload").unwrap();
86+
ct[0] ^= 0x01;
87+
assert!(open_with_key(&key, &nonce, b"", &ct).is_err());
88+
}
89+
}

crates/encryption/src/envelope.rs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
//! The sealed envelope — password-locked, self-describing, zero-knowledge.
2+
//!
3+
//! [`seal`] runs client-side (browser wasm or native): it derives a key
4+
//! from the password with Argon2id, draws a fresh salt + XChaCha20
5+
//! nonce from the CSPRNG, and returns one opaque blob. A server that
6+
//! stores the blob learns **nothing** — the password never leaves the
7+
//! client, and the blob authenticates its own header, so any tampering
8+
//! (including with the cost parameters) makes [`open`] fail.
9+
//!
10+
//! ## Byte layout (fixed, little-endian, parsed by hand — no serde)
11+
//!
12+
//! ```text
13+
//! offset len field
14+
//! 0 4 magic = b"ADAC"
15+
//! 4 1 version = 1
16+
//! 5 4 m_cost_kib (u32 LE) Argon2id memory cost
17+
//! 9 4 t_cost (u32 LE) Argon2id passes
18+
//! 13 4 p_cost (u32 LE) Argon2id lanes
19+
//! 17 16 salt fresh CSPRNG bytes per seal
20+
//! 33 24 nonce fresh CSPRNG bytes per seal
21+
//! 57 … ciphertext ‖ 16-byte Poly1305 tag
22+
//! ```
23+
//!
24+
//! The first 57 bytes (the header) are the AEAD's associated data, so
25+
//! they are integrity-bound to the ciphertext: an attacker cannot, for
26+
//! example, lower `m_cost_kib` to cheapen an offline guess and still
27+
//! have the blob open.
28+
29+
use crate::aead::{self, NONCE_LEN};
30+
pub use crate::kdf::KdfParams;
31+
use crate::kdf::{self, KdfError};
32+
33+
/// Envelope magic: "ADAC" (Ada crypto).
34+
pub const MAGIC: [u8; 4] = *b"ADAC";
35+
/// Current envelope layout version.
36+
pub const VERSION: u8 = 1;
37+
/// Salt length stored in the header.
38+
pub const SALT_LEN: usize = 16;
39+
/// Total header length preceding the ciphertext.
40+
pub const HEADER_LEN: usize = 4 + 1 + 4 + 4 + 4 + SALT_LEN + NONCE_LEN; // 57
41+
42+
/// Why an envelope could not be sealed or opened. Field-free — an
43+
/// `open` failure never says *which* part was wrong.
44+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45+
pub enum EnvelopeError {
46+
/// The platform CSPRNG was unavailable (seal only).
47+
Rng,
48+
/// The Argon2id parameters were rejected.
49+
Kdf(KdfError),
50+
/// The blob is not an envelope (bad magic, short buffer).
51+
Malformed,
52+
/// The blob's layout version is newer than this code understands.
53+
UnsupportedVersion,
54+
/// Wrong password, or the blob was tampered with. Indistinguishable
55+
/// by design.
56+
Decrypt,
57+
/// Encryption failed (should not happen with valid inputs).
58+
Encrypt,
59+
}
60+
61+
impl core::fmt::Display for EnvelopeError {
62+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
63+
match self {
64+
EnvelopeError::Rng => f.write_str("platform CSPRNG unavailable"),
65+
EnvelopeError::Kdf(e) => write!(f, "key derivation failed: {e}"),
66+
EnvelopeError::Malformed => f.write_str("not a sealed envelope"),
67+
EnvelopeError::UnsupportedVersion => f.write_str("unsupported envelope version"),
68+
EnvelopeError::Decrypt => f.write_str("wrong password or tampered envelope"),
69+
EnvelopeError::Encrypt => f.write_str("encryption failed"),
70+
}
71+
}
72+
}
73+
74+
impl std::error::Error for EnvelopeError {}
75+
76+
/// Seal `plaintext` under `password`. Draws salt + nonce from the
77+
/// CSPRNG, so sealing the same input twice yields different blobs.
78+
pub fn seal(password: &[u8], plaintext: &[u8], params: &KdfParams) -> Result<Vec<u8>, EnvelopeError> {
79+
let mut salt = [0u8; SALT_LEN];
80+
crate::fill_random(&mut salt).map_err(|_| EnvelopeError::Rng)?;
81+
let mut nonce = [0u8; NONCE_LEN];
82+
crate::fill_random(&mut nonce).map_err(|_| EnvelopeError::Rng)?;
83+
84+
let header = encode_header(params, &salt, &nonce);
85+
let key = kdf::derive_key(password, &salt, params).map_err(EnvelopeError::Kdf)?;
86+
let ciphertext =
87+
aead::seal_with_key(key.as_bytes(), &nonce, &header, plaintext).map_err(|_| EnvelopeError::Encrypt)?;
88+
89+
let mut blob = Vec::with_capacity(HEADER_LEN + ciphertext.len());
90+
blob.extend_from_slice(&header);
91+
blob.extend_from_slice(&ciphertext);
92+
Ok(blob)
93+
}
94+
95+
/// Open a sealed envelope with `password`. The KDF parameters are read
96+
/// from the (authenticated) header, so cost bumps never orphan old blobs.
97+
pub fn open(password: &[u8], blob: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
98+
let (params, salt, nonce) = decode_header(blob)?;
99+
let header = &blob[..HEADER_LEN];
100+
let ciphertext = &blob[HEADER_LEN..];
101+
102+
let key = kdf::derive_key(password, &salt, &params).map_err(EnvelopeError::Kdf)?;
103+
aead::open_with_key(key.as_bytes(), &nonce, header, ciphertext).map_err(|_| EnvelopeError::Decrypt)
104+
}
105+
106+
fn encode_header(params: &KdfParams, salt: &[u8; SALT_LEN], nonce: &[u8; NONCE_LEN]) -> [u8; HEADER_LEN] {
107+
let mut h = [0u8; HEADER_LEN];
108+
h[0..4].copy_from_slice(&MAGIC);
109+
h[4] = VERSION;
110+
h[5..9].copy_from_slice(&params.m_cost_kib.to_le_bytes());
111+
h[9..13].copy_from_slice(&params.t_cost.to_le_bytes());
112+
h[13..17].copy_from_slice(&params.p_cost.to_le_bytes());
113+
h[17..17 + SALT_LEN].copy_from_slice(salt);
114+
h[33..33 + NONCE_LEN].copy_from_slice(nonce);
115+
h
116+
}
117+
118+
fn decode_header(blob: &[u8]) -> Result<(KdfParams, [u8; SALT_LEN], [u8; NONCE_LEN]), EnvelopeError> {
119+
if blob.len() < HEADER_LEN || blob[0..4] != MAGIC {
120+
return Err(EnvelopeError::Malformed);
121+
}
122+
if blob[4] != VERSION {
123+
return Err(EnvelopeError::UnsupportedVersion);
124+
}
125+
let le_u32 = |at: usize| u32::from_le_bytes([blob[at], blob[at + 1], blob[at + 2], blob[at + 3]]);
126+
let params = KdfParams {
127+
m_cost_kib: le_u32(5),
128+
t_cost: le_u32(9),
129+
p_cost: le_u32(13),
130+
};
131+
let mut salt = [0u8; SALT_LEN];
132+
salt.copy_from_slice(&blob[17..17 + SALT_LEN]);
133+
let mut nonce = [0u8; NONCE_LEN];
134+
nonce.copy_from_slice(&blob[33..33 + NONCE_LEN]);
135+
Ok((params, salt, nonce))
136+
}
137+
138+
#[cfg(test)]
139+
mod tests {
140+
use super::*;
141+
142+
const FAST: KdfParams = KdfParams {
143+
m_cost_kib: 32,
144+
t_cost: 1,
145+
p_cost: 1,
146+
};
147+
148+
#[test]
149+
fn round_trip() {
150+
let blob = seal(b"hunter2", b"the secret", &FAST).unwrap();
151+
assert_eq!(open(b"hunter2", &blob).unwrap(), b"the secret");
152+
}
153+
154+
#[test]
155+
fn wrong_password_fails_opaquely() {
156+
let blob = seal(b"right", b"s", &FAST).unwrap();
157+
assert_eq!(open(b"wrong", &blob).unwrap_err(), EnvelopeError::Decrypt);
158+
}
159+
160+
#[test]
161+
fn same_input_two_seals_differ() {
162+
let a = seal(b"pw", b"s", &FAST).unwrap();
163+
let b = seal(b"pw", b"s", &FAST).unwrap();
164+
assert_ne!(a, b, "salt+nonce must be fresh per seal");
165+
}
166+
167+
#[test]
168+
fn header_tamper_fails() {
169+
let mut blob = seal(b"pw", b"s", &FAST).unwrap();
170+
// Attacker tries to cheapen the KDF cost in the header.
171+
blob[5] = 1; // m_cost_kib low byte
172+
assert!(open(b"pw", &blob).is_err());
173+
}
174+
175+
#[test]
176+
fn ciphertext_tamper_fails() {
177+
let mut blob = seal(b"pw", b"s", &FAST).unwrap();
178+
let last = blob.len() - 1;
179+
blob[last] ^= 0x80;
180+
assert_eq!(open(b"pw", &blob).unwrap_err(), EnvelopeError::Decrypt);
181+
}
182+
183+
#[test]
184+
fn malformed_inputs_rejected() {
185+
assert_eq!(open(b"pw", b"").unwrap_err(), EnvelopeError::Malformed);
186+
assert_eq!(open(b"pw", &[0u8; HEADER_LEN]).unwrap_err(), EnvelopeError::Malformed);
187+
let mut blob = seal(b"pw", b"s", &FAST).unwrap();
188+
blob[4] = 99;
189+
assert_eq!(open(b"pw", &blob).unwrap_err(), EnvelopeError::UnsupportedVersion);
190+
}
191+
192+
#[test]
193+
fn empty_plaintext_round_trips() {
194+
let blob = seal(b"pw", b"", &FAST).unwrap();
195+
assert_eq!(blob.len(), HEADER_LEN + crate::aead::TAG_LEN);
196+
assert_eq!(open(b"pw", &blob).unwrap(), b"");
197+
}
198+
}

0 commit comments

Comments
 (0)