Rust crypto core: FrodoKEM port, hybrid suite, Fury, STREAM, key files

FrodoKEM-640-SHAKE is ported from CIRCL v1.6.4 (the RustCrypto crate
implements the incompatible ISO variant) and reproduces the reference
NIST KAT digest. ML-KEM-768 comes from RustCrypto ml-kem. Derivation,
encapsulation, implicit rejection, KEK, header MAC, header parsing,
STREAM and Argon2id key files all match vectors from the Go oracle, Go's
files decrypt in Rust, and Go decrypts Rust's.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andy Dixon
2026-10-08 10:12:53 +01:00
co-authored by Claude
parent 69f4b90777
commit c324f6a306
24 changed files with 5944 additions and 0 deletions
+497
View File
@@ -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<u16>, // 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<u16>, // 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<u16> {
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<u8> {
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<Self> {
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>, [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<usize>;
/// SHAKE128(0x96 || seedSE) expanded and sampled into S', E', E''.
fn noise(seed_se: &[u8]) -> (Span, Span, Span, Vec<u16>) {
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<Vec<u8>> {
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");
}
}
+263
View File
@@ -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<io::Error> for Error {
fn from(e: io::Error) -> Self {
Error::Io(e)
}
}
fn header_mac(file_key: &[u8; FILE_KEY_SIZE], h: &Header) -> hmac::Hmac<Sha256> {
let mut k = SecretArray::<32>::zero();
Hkdf::<Sha256>::new(None, file_key)
.expand(b"header", k.expose_mut())
.expect("32 bytes");
let mut m = <hmac::Hmac<Sha256> 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::<Sha256>::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::<FILE_KEY_SIZE>::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<u8>, 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<W: Write>(mut dst: W, recipient: &Recipient, rng: &mut Rng) -> io::Result<StreamWriter<W>> {
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<SecretArray<FILE_KEY_SIZE>, 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<R: Read>(src: R, id: &Identity) -> Result<StreamReader<impl Read>, 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<R: Read>(src: R, id: &Identity, max_plaintext: usize) -> Result<SecretVec, 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);
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<u8> {
(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());
}
}
+396
View File
@@ -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<Vec<u8>, 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<String>,
pub body: Vec<u8>,
}
#[derive(Clone, PartialEq, Eq)]
pub struct Header {
pub recipients: Vec<Stanza>,
pub mac: [u8; 32],
}
impl Stanza {
pub fn marshal(&self, w: &mut Vec<u8>) {
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<u8> {
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<u8> {
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<R> = io::Chain<io::Cursor<Vec<u8>>, R>;
pub struct Peek<R> {
inner: R,
buf: Vec<u8>,
pos: usize,
eof: bool,
}
impl<R: Read> Peek<R> {
pub fn new(inner: R) -> Self {
Peek {
inner,
buf: Vec::with_capacity(8192),
pos: 0,
eof: false,
}
}
fn fill(&mut self) -> io::Result<bool> {
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<Vec<u8>, 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<R> {
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<T>(m: impl Into<String>) -> Result<T, ParseError> {
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<R: Read>(p: &mut Peek<R>) -> 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<R: Read>(input: R) -> Result<(Header, Payload<R>), 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")));
}
}
+425
View File
@@ -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::<Sha256>::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<u8>,
frodo: frodo::PublicKey,
mlkem: EncapsulationKey<MlKem768>,
}
impl Recipient {
pub fn from_bytes(pub_key: &[u8]) -> Result<Self, String> {
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::<EncapsulationKey<MlKem768>>::try_from(&pub_key[frodo::PUBLIC_KEY_SIZE..])
.map_err(|_| "invalid ML-KEM public key")?;
let mlkem = EncapsulationKey::<MlKem768>::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<u8> {
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<SecretArray<FILE_KEY_SIZE>> {
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::<FILE_KEY_SIZE>::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<SEED_SIZE>,
frodo: frodo::PrivateKey,
mlkem: Box<DecapsulationKey<MlKem768>>,
recipient_bytes: Vec<u8>,
}
impl Identity {
pub fn generate(rng: &mut Rng) -> Self {
let mut seed = SecretArray::<SEED_SIZE>::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<Self, String> {
if suite != SUITE_ID {
return Err(format!("unknown suite id {suite:#06x}"));
}
let s = SecretArray::<SEED_SIZE>::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<SEED_SIZE>) -> 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::<MlKem768>::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<Vec<u8>> {
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<SecretArray<FILE_KEY_SIZE>, 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::<MlKem768>::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<SecretArray<FILE_KEY_SIZE>, 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<u8> {
(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<KemV>,
pub mlkem768: Vec<KemV>,
pub hybrid: Vec<HybridV>,
pub wrap: Vec<WrapV>,
pub header_cases: Vec<HeaderCase>,
}
#[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<String>,
}
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::<MlKem768>::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::<MlKem768>::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);
}
}
+10
View File
@@ -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;
+759
View File
@@ -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<StreamError> 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<usize, StreamError> {
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<u64, StreamError> {
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<W: Write> {
cipher: Cipher,
dst: Option<W>,
buf: Vec<u8>,
index: u64,
}
impl<W: Write> StreamWriter<W> {
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<W> {
self.flush_chunk(true)?;
Ok(self.dst.take().expect("writer in use"))
}
}
impl<W: Write> Write for StreamWriter<W> {
fn write(&mut self, mut p: &[u8]) -> io::Result<usize> {
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<W: Write> Drop for StreamWriter<W> {
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<usize> {
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<R: Read> {
cipher: Cipher,
src: R,
index: u64,
started: bool,
done: bool,
failed: bool,
pending: Option<Vec<u8>>,
/// Plaintext of a short final chunk, opened together with the chunk
/// before it and delivered after it.
queued: Option<Vec<u8>>,
out: Vec<u8>,
pos: usize,
spare: Vec<Vec<u8>>,
}
impl<R: Read> StreamReader<R> {
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<Vec<u8>> {
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<u8>, last: bool) -> io::Result<Vec<u8>> {
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<u8>) {
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<R: Read> Read for StreamReader<R> {
fn read(&mut self, p: &mut [u8]) -> io::Result<usize> {
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<R: Read> Drop for StreamReader<R> {
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<u8>) {
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<usize, StreamError> {
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<usize, StreamError> {
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<u64, StreamError> {
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<u64, StreamError> {
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<usize>;
}
impl ReadAt for [u8] {
fn read_at(&self, buf: &mut [u8], offset: u64) -> io::Result<usize> {
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<usize> {
#[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<S: ReadAt + ?Sized>(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<Option<(u64, Vec<u8>)>>,
}
impl<'a, S: ReadAt + ?Sized> StreamReaderAt<'a, S> {
pub fn new(key: &[u8; 32], src: &'a S, encrypted_size: u64) -> io::Result<Self> {
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<usize> {
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<S: ReadAt + ?Sized> 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<u8> {
(0..n).map(|i| (i * 13 + 7) as u8).collect()
}
fn seal(key: &[u8; 32], p: &[u8]) -> Vec<u8> {
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<Vec<u8>> {
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<V>,
}
#[derive(serde::Deserialize)]
struct V {
size: usize,
ct_len: usize,
ct_sha256: String,
ct_hex: Option<String>,
}
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::<Vec<u8>>().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<u8>)> = 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));
}
}