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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user