Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
338 changes: 336 additions & 2 deletions Cargo.lock

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions crates/encryption/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"]
89 changes: 89 additions & 0 deletions crates/encryption/src/aead.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<u8>, 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<Vec<u8>, 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());
}
}
198 changes: 198 additions & 0 deletions crates/encryption/src/envelope.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<u8>, 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<Vec<u8>, 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, &params).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(&params.m_cost_kib.to_le_bytes());
h[9..13].copy_from_slice(&params.t_cost.to_le_bytes());
h[13..17].copy_from_slice(&params.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"");
}
}
Loading
Loading