Files
vault/src/crypto/header.rs
T
Andy DixonandClaude c324f6a306 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>
2026-10-08 10:12:53 +01:00

397 lines
13 KiB
Rust

// 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")));
}
}