2f16e735c3
The crypto engine was only reachable through the fcry binary; embedding
it in another Rust project meant shelling out to the CLI. Restructure
the crate so the binary sits on top of a proper library API.
- Add src/lib.rs exposing encrypt/decrypt/decrypt_range/derive_key, the
header and policy types, and the secret-handling primitives.
- Replace the positional-argument wrapper ladder
(encrypt_with_output_options, decrypt_with_argon_cap, ...) with
options structs: EncryptOptions, DecryptOptions, DecryptRangeOptions
and HeaderReadOptions. OutSinkOptions becomes the public
OutputOptions and no longer carries the input path; the input is now
an explicit parameter to OutSink::open_with_options so the
same-file-aliasing guard's inputs are visible at each call site.
- File parameters take Option<PathBuf>/&Path instead of AsRef<str>, so
non-UTF-8 paths work.
- FcryError implements Display and std::error::Error so it composes
with anyhow/thiserror-style error handling in downstream crates.
- Move read_key_file and normalize_passphrase from main.rs into
secrets.rs so library users get the same strict 32-byte key-file
parsing and NFC passphrase normalization. The world-readable
key-file warning stays in the CLI wrapper (read_key_file_cli).
- Drop now-unneeded #[allow(dead_code)] markers; ReadInfoChunk::Normal
loses its unused byte-count payload.
- Add rustfmt.toml (StdExternalCrate grouping, crate-granularity
imports) and reformat imports accordingly.
- Add tests/library_api.rs covering a file round-trip and a range
decrypt through the public API with a raw key.
User-visible change: CLI behavior is unchanged except error output,
which is now human-readable Display text ("Error: wrong key or
passphrase") instead of the Rust Debug representation.
Test plan: cargo clippy (default, --tests, --benches) is clean;
cargo +nightly fmt produces no diff; cargo test passes 43 tests
including the new library_api integration tests.
429 lines
14 KiB
Rust
429 lines
14 KiB
Rust
// SPDX-License-Identifier: MIT-0
|
|
|
|
//! On-disk file format for fcry.
|
|
//!
|
|
//! Layout:
|
|
//! ```text
|
|
//! magic "fcry" 4 bytes
|
|
//! version u8 1
|
|
//! alg_id u8 1
|
|
//! flags u8 1
|
|
//! reserved u8 1 (must be 0)
|
|
//! chunk_size u32 LE 4 (plaintext bytes per chunk)
|
|
//! kdf_id u8 1
|
|
//! kdf_params variable (depends on kdf_id)
|
|
//! nonce_prefix [u8; 19] 19 (STREAM nonce prefix)
|
|
//! plaintext_length u64 LE 8 (only if version >= 2 and flags & 0x01)
|
|
//! key_commitment [u8; 32] 32 (only if version >= 3 and flags & 0x02)
|
|
//! --- end of header ---
|
|
//! chunk[0..N] each chunk_size + 16 bytes,
|
|
//! last may be shorter
|
|
//! ```
|
|
//!
|
|
//! The full encoded header is fed as AAD to every chunk, so any tampering
|
|
//! with chunk_size, nonce_prefix, kdf params, plaintext_length, etc. causes
|
|
//! authentication failure on every chunk.
|
|
//!
|
|
//! Versions:
|
|
//! * v1 — no length committed, no flag bits used.
|
|
//! * v2 — adds `FLAG_LENGTH_COMMITTED` (bit 0); when set, the total plaintext
|
|
//! length is appended after `nonce_prefix`. This enables random-access
|
|
//! decryption without scanning predecessors.
|
|
//! * v3 — adds `FLAG_KEY_COMMITTED` (bit 1) and an authenticated key
|
|
//! commitment for fast wrong-key detection before chunk processing.
|
|
|
|
use std::io::Read;
|
|
|
|
use crate::{error::FcryError, policy};
|
|
|
|
const MAGIC: [u8; 4] = *b"fcry";
|
|
pub const VERSION_CURRENT: u8 = 3;
|
|
const VERSION_MIN: u8 = 1;
|
|
|
|
pub const NONCE_PREFIX_LEN: usize = 19;
|
|
pub const TAG_LEN: usize = 16;
|
|
|
|
/// Set in `flags` when the header carries an authenticated `plaintext_length`
|
|
/// field. Required for random-access decryption.
|
|
pub const FLAG_LENGTH_COMMITTED: u8 = 0x01;
|
|
pub const FLAG_KEY_COMMITTED: u8 = 0x02;
|
|
|
|
/// Mask of all flag bits this build understands. Unknown bits → reject.
|
|
const FLAG_KNOWN_MASK: u8 = FLAG_LENGTH_COMMITTED | FLAG_KEY_COMMITTED;
|
|
pub const KEY_COMMITMENT_LEN: usize = 32;
|
|
|
|
#[repr(u8)]
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
pub enum AlgId {
|
|
XChaCha20Poly1305 = 1,
|
|
}
|
|
|
|
impl AlgId {
|
|
fn from_u8(v: u8) -> Result<Self, FcryError> {
|
|
match v {
|
|
1 => Ok(Self::XChaCha20Poly1305),
|
|
_ => Err(FcryError::Format(format!("unknown alg id: {v}"))),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub const ARGON2_SALT_LEN: usize = 16;
|
|
|
|
/// Key-derivation parameters stored in the header.
|
|
///
|
|
/// `Raw` means the key was supplied directly (no KDF). `Argon2id` carries
|
|
/// the salt and cost parameters needed to redo derivation on decrypt.
|
|
#[derive(Clone, Debug)]
|
|
pub enum KdfParams {
|
|
Raw,
|
|
Argon2id {
|
|
salt: [u8; ARGON2_SALT_LEN],
|
|
m_cost: u32,
|
|
t_cost: u32,
|
|
p_cost: u32,
|
|
},
|
|
}
|
|
|
|
impl KdfParams {
|
|
fn id(&self) -> u8 {
|
|
match self {
|
|
Self::Raw => 0,
|
|
Self::Argon2id { .. } => 1,
|
|
}
|
|
}
|
|
|
|
fn write_into(&self, out: &mut Vec<u8>) {
|
|
match self {
|
|
Self::Raw => {}
|
|
Self::Argon2id {
|
|
salt,
|
|
m_cost,
|
|
t_cost,
|
|
p_cost,
|
|
} => {
|
|
out.extend_from_slice(salt);
|
|
out.extend_from_slice(&m_cost.to_le_bytes());
|
|
out.extend_from_slice(&t_cost.to_le_bytes());
|
|
out.extend_from_slice(&p_cost.to_le_bytes());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_from(id: u8, r: &mut impl Read) -> Result<Self, FcryError> {
|
|
match id {
|
|
0 => Ok(Self::Raw),
|
|
1 => {
|
|
let mut salt = [0u8; ARGON2_SALT_LEN];
|
|
r.read_exact(&mut salt)?;
|
|
let mut buf = [0u8; 4];
|
|
r.read_exact(&mut buf)?;
|
|
let m_cost = u32::from_le_bytes(buf);
|
|
r.read_exact(&mut buf)?;
|
|
let t_cost = u32::from_le_bytes(buf);
|
|
r.read_exact(&mut buf)?;
|
|
let p_cost = u32::from_le_bytes(buf);
|
|
Ok(Self::Argon2id {
|
|
salt,
|
|
m_cost,
|
|
t_cost,
|
|
p_cost,
|
|
})
|
|
}
|
|
_ => Err(FcryError::Format(format!("unknown kdf id: {id}"))),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Header {
|
|
/// On-disk format version. Set to `VERSION_CURRENT` for new encrypts;
|
|
/// preserved as-read for decrypt so the AAD recomputed on decode matches
|
|
/// the bytes that were authenticated when the file was written.
|
|
pub version: u8,
|
|
pub alg: AlgId,
|
|
pub flags: u8,
|
|
pub chunk_size: u32,
|
|
pub kdf: KdfParams,
|
|
pub nonce_prefix: [u8; NONCE_PREFIX_LEN],
|
|
/// Total plaintext byte count. `Some` iff `flags & FLAG_LENGTH_COMMITTED`.
|
|
pub plaintext_length: Option<u64>,
|
|
/// v3 key commitment. `Some` iff `flags & FLAG_KEY_COMMITTED`.
|
|
pub key_commitment: Option<[u8; KEY_COMMITMENT_LEN]>,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
pub struct HeaderReadOptions {
|
|
pub max_argon_memory_mib: u32,
|
|
}
|
|
|
|
impl Default for HeaderReadOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_argon_memory_mib: policy::default_argon_decrypt_cap_mib(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Header {
|
|
fn encode_without_commitment(&self) -> Vec<u8> {
|
|
let mut out = Vec::with_capacity(104);
|
|
out.extend_from_slice(&MAGIC);
|
|
out.push(self.version);
|
|
out.push(self.alg as u8);
|
|
out.push(self.flags);
|
|
out.push(0); // reserved
|
|
out.extend_from_slice(&self.chunk_size.to_le_bytes());
|
|
out.push(self.kdf.id());
|
|
self.kdf.write_into(&mut out);
|
|
out.extend_from_slice(&self.nonce_prefix);
|
|
if (self.flags & FLAG_LENGTH_COMMITTED) != 0 {
|
|
let len = self
|
|
.plaintext_length
|
|
.expect("FLAG_LENGTH_COMMITTED set but plaintext_length is None");
|
|
out.extend_from_slice(&len.to_le_bytes());
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn encode(&self) -> Vec<u8> {
|
|
let mut out = self.encode_without_commitment();
|
|
if (self.flags & FLAG_KEY_COMMITTED) != 0 {
|
|
let commitment = self
|
|
.key_commitment
|
|
.expect("FLAG_KEY_COMMITTED set but key_commitment is None");
|
|
out.extend_from_slice(&commitment);
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn commitment_input_encoding(&self) -> Vec<u8> {
|
|
self.encode_without_commitment()
|
|
}
|
|
|
|
pub fn read(r: &mut impl Read) -> Result<Self, FcryError> {
|
|
Self::read_with_options(r, HeaderReadOptions::default())
|
|
}
|
|
|
|
pub fn read_with_options(
|
|
r: &mut impl Read,
|
|
options: HeaderReadOptions,
|
|
) -> Result<Self, FcryError> {
|
|
let mut magic = [0u8; 4];
|
|
r.read_exact(&mut magic)?;
|
|
if magic != MAGIC {
|
|
return Err(FcryError::Format("not an fcry file (bad magic)".into()));
|
|
}
|
|
|
|
let mut fixed = [0u8; 4];
|
|
r.read_exact(&mut fixed)?;
|
|
let [version, alg_id, flags, reserved] = fixed;
|
|
if !(VERSION_MIN..=VERSION_CURRENT).contains(&version) {
|
|
return Err(FcryError::Format(format!("unsupported version: {version}")));
|
|
}
|
|
if reserved != 0 {
|
|
return Err(FcryError::Format("reserved byte must be zero".into()));
|
|
}
|
|
if (flags & !FLAG_KNOWN_MASK) != 0 {
|
|
return Err(FcryError::Format(format!(
|
|
"unknown flag bits: 0x{flags:02x}"
|
|
)));
|
|
}
|
|
if version < 2 && flags != 0 {
|
|
return Err(FcryError::Format("v1 header must have flags == 0".into()));
|
|
}
|
|
if version < 3 && (flags & FLAG_KEY_COMMITTED) != 0 {
|
|
return Err(FcryError::Format(
|
|
"key commitment flag requires v3 header".into(),
|
|
));
|
|
}
|
|
if version >= 3 && (flags & FLAG_KEY_COMMITTED) == 0 {
|
|
return Err(FcryError::Format("v3 header must commit the key".into()));
|
|
}
|
|
let alg = AlgId::from_u8(alg_id)?;
|
|
|
|
let mut chunk_size_bytes = [0u8; 4];
|
|
r.read_exact(&mut chunk_size_bytes)?;
|
|
let chunk_size = u32::from_le_bytes(chunk_size_bytes);
|
|
policy::validate_chunk_size(chunk_size)?;
|
|
|
|
let mut kdf_id = [0u8; 1];
|
|
r.read_exact(&mut kdf_id)?;
|
|
let kdf = KdfParams::read_from(kdf_id[0], r)?;
|
|
policy::validate_header_kdf(&kdf, options.max_argon_memory_mib)?;
|
|
|
|
let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN];
|
|
r.read_exact(&mut nonce_prefix)?;
|
|
|
|
let plaintext_length = if (flags & FLAG_LENGTH_COMMITTED) != 0 {
|
|
let mut b = [0u8; 8];
|
|
r.read_exact(&mut b)?;
|
|
Some(u64::from_le_bytes(b))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let key_commitment = if (flags & FLAG_KEY_COMMITTED) != 0 {
|
|
let mut b = [0u8; KEY_COMMITMENT_LEN];
|
|
r.read_exact(&mut b)?;
|
|
Some(b)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(Self {
|
|
version,
|
|
alg,
|
|
flags,
|
|
chunk_size,
|
|
kdf,
|
|
nonce_prefix,
|
|
plaintext_length,
|
|
key_commitment,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::io::Cursor;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn roundtrip() {
|
|
let h = Header {
|
|
version: VERSION_CURRENT,
|
|
alg: AlgId::XChaCha20Poly1305,
|
|
flags: FLAG_KEY_COMMITTED,
|
|
chunk_size: 1024 * 1024,
|
|
kdf: KdfParams::Raw,
|
|
nonce_prefix: [7u8; NONCE_PREFIX_LEN],
|
|
plaintext_length: None,
|
|
key_commitment: Some([1u8; KEY_COMMITMENT_LEN]),
|
|
};
|
|
let bytes = h.encode();
|
|
let mut cur = Cursor::new(&bytes);
|
|
let parsed = Header::read(&mut cur).unwrap();
|
|
assert_eq!(parsed.version, h.version);
|
|
assert_eq!(parsed.alg, h.alg);
|
|
assert_eq!(parsed.flags, h.flags);
|
|
assert_eq!(parsed.chunk_size, h.chunk_size);
|
|
assert_eq!(parsed.nonce_prefix, h.nonce_prefix);
|
|
assert_eq!(parsed.plaintext_length, None);
|
|
assert_eq!(parsed.key_commitment, h.key_commitment);
|
|
assert_eq!(cur.position() as usize, bytes.len());
|
|
}
|
|
|
|
#[test]
|
|
fn roundtrip_length_committed() {
|
|
let h = Header {
|
|
version: VERSION_CURRENT,
|
|
alg: AlgId::XChaCha20Poly1305,
|
|
flags: FLAG_LENGTH_COMMITTED | FLAG_KEY_COMMITTED,
|
|
chunk_size: 65536,
|
|
kdf: KdfParams::Raw,
|
|
nonce_prefix: [9u8; NONCE_PREFIX_LEN],
|
|
plaintext_length: Some(123_456_789),
|
|
key_commitment: Some([2u8; KEY_COMMITMENT_LEN]),
|
|
};
|
|
let bytes = h.encode();
|
|
let mut cur = Cursor::new(&bytes);
|
|
let parsed = Header::read(&mut cur).unwrap();
|
|
assert_eq!(parsed.flags, FLAG_LENGTH_COMMITTED | FLAG_KEY_COMMITTED);
|
|
assert_eq!(parsed.plaintext_length, Some(123_456_789));
|
|
assert_eq!(parsed.key_commitment, h.key_commitment);
|
|
assert_eq!(cur.position() as usize, bytes.len());
|
|
}
|
|
|
|
#[test]
|
|
fn v3_encoding_layout_stable() {
|
|
let h = Header {
|
|
version: VERSION_CURRENT,
|
|
alg: AlgId::XChaCha20Poly1305,
|
|
flags: FLAG_LENGTH_COMMITTED | FLAG_KEY_COMMITTED,
|
|
chunk_size: 0x0102_0304,
|
|
kdf: KdfParams::Raw,
|
|
nonce_prefix: [0x55u8; NONCE_PREFIX_LEN],
|
|
plaintext_length: Some(0x0807_0605_0403_0201),
|
|
key_commitment: Some([0xaau8; KEY_COMMITMENT_LEN]),
|
|
};
|
|
let commitment_input = h.commitment_input_encoding();
|
|
assert_eq!(commitment_input.len(), 40);
|
|
assert_eq!(&commitment_input[..4], b"fcry");
|
|
assert_eq!(commitment_input[4], 3);
|
|
assert_eq!(
|
|
&commitment_input[32..40],
|
|
&0x0807_0605_0403_0201u64.to_le_bytes()
|
|
);
|
|
|
|
let aad = h.encode();
|
|
assert_eq!(aad.len(), 72);
|
|
assert_eq!(&aad[..40], &commitment_input);
|
|
assert_eq!(&aad[40..], &[0xaau8; KEY_COMMITMENT_LEN]);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_bad_magic() {
|
|
let mut bytes = Header {
|
|
version: VERSION_CURRENT,
|
|
alg: AlgId::XChaCha20Poly1305,
|
|
flags: 0,
|
|
chunk_size: 4096,
|
|
kdf: KdfParams::Raw,
|
|
nonce_prefix: [0u8; NONCE_PREFIX_LEN],
|
|
plaintext_length: None,
|
|
key_commitment: Some([3u8; KEY_COMMITMENT_LEN]),
|
|
}
|
|
.encode();
|
|
bytes[0] ^= 1;
|
|
assert!(matches!(
|
|
Header::read(&mut Cursor::new(&bytes)),
|
|
Err(FcryError::Format(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unknown_flag_bits() {
|
|
let mut bytes = Header {
|
|
version: VERSION_CURRENT,
|
|
alg: AlgId::XChaCha20Poly1305,
|
|
flags: 0,
|
|
chunk_size: 4096,
|
|
kdf: KdfParams::Raw,
|
|
nonce_prefix: [0u8; NONCE_PREFIX_LEN],
|
|
plaintext_length: None,
|
|
key_commitment: Some([4u8; KEY_COMMITMENT_LEN]),
|
|
}
|
|
.encode();
|
|
// flags byte is at offset 6 (4 magic + version + alg)
|
|
bytes[6] = 0x80;
|
|
assert!(matches!(
|
|
Header::read(&mut Cursor::new(&bytes)),
|
|
Err(FcryError::Format(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn reads_v1_header() {
|
|
// hand-crafted v1 header (raw kdf, no length field)
|
|
let mut bytes = Vec::new();
|
|
bytes.extend_from_slice(b"fcry");
|
|
bytes.push(1); // version
|
|
bytes.push(1); // alg
|
|
bytes.push(0); // flags
|
|
bytes.push(0); // reserved
|
|
bytes.extend_from_slice(&1024u32.to_le_bytes());
|
|
bytes.push(0); // kdf id raw
|
|
bytes.extend_from_slice(&[3u8; NONCE_PREFIX_LEN]);
|
|
let parsed = Header::read(&mut Cursor::new(&bytes)).unwrap();
|
|
assert_eq!(parsed.version, 1);
|
|
assert_eq!(parsed.flags, 0);
|
|
assert_eq!(parsed.chunk_size, 1024);
|
|
assert_eq!(parsed.plaintext_length, None);
|
|
assert_eq!(parsed.key_commitment, None);
|
|
// Re-encoding must reproduce the original v1 bytes exactly so the
|
|
// recomputed AAD matches what the file was authenticated with.
|
|
assert_eq!(parsed.encode(), bytes);
|
|
}
|
|
}
|