HMAC SHA3 + authenticate file header with HMAC

This commit is contained in:
sam.hadow committed 2026-10-02 18:11:33 +02:00
1 parent b30b5d0add
commit 5e95b21fbd
6 files changed
+333 -78

No files matched your search

+101 -36
View File
@@ -1,43 +1,50 @@
use std::io;
use crate::kdf::{DERIVED_KEY_LEN, SALT_LEN};
use crate::keccak::hmac_sha3_256;
use super::error::FileCryptError;
const MAGIC: [u8; 4] = *b"KAEF";
const VERSION: u8 = 2;
pub const IV_LEN: usize = 16;
pub const NONCE_LEN: usize = 16;
pub const MIN_KEY_LEN: usize = 16;
pub const MAX_KEY_LEN: usize = DERIVED_KEY_LEN;
pub const MAX_KEY_LEN: usize = 64;
pub const HEADER_AUTH_LEN: usize = 16;
pub const HEADER_AUTH_OFFSET: usize = 64;
pub const HEADER_LEN: usize = HEADER_AUTH_OFFSET + HEADER_AUTH_LEN;
pub const HEADER_LEN: usize = 64;
const MAGIC: [u8; 4] = *b"KAEF";
const VERSION: u8 = 2;
pub(super) struct Header {
pub(super) key_len: usize,
pub(super) iv: [u8; IV_LEN],
pub(super) nonce: [u8; NONCE_LEN],
pub(super) salt: [u8; SALT_LEN],
pub(super) plaintext_len: u64,
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Header {
pub(crate) key_len: usize,
pub(crate) iv: [u8; IV_LEN],
pub(crate) nonce: [u8; NONCE_LEN],
pub(crate) salt: [u8; SALT_LEN],
pub(crate) plaintext_len: u64,
}
impl Header {
/// Create a new header with a fresh random IV, nonce, and KDF salt.
pub(super) fn new(plaintext_len: u64) -> Result<Self, FileCryptError> {
let mut random = [0u8; IV_LEN + NONCE_LEN + SALT_LEN];
/// Create a new v2 header with a fixed-size Argon2-derived key.
pub fn new(plaintext_len: u64) -> Result<Self, FileCryptError> {
let mut iv = [0u8; IV_LEN];
let mut nonce = [0u8; NONCE_LEN];
let mut salt = [0u8; SALT_LEN];
getrandom::fill(&mut random).map_err(FileCryptError::Random)?;
getrandom::fill(&mut iv).map_err(|e| {
FileCryptError::Io(io::Error::other(format!("failed to generate IV: {e}")))
})?;
let iv = random[0..IV_LEN].try_into().expect("IV length is constant");
getrandom::fill(&mut nonce).map_err(|e| {
FileCryptError::Io(io::Error::other(format!("failed to generate nonce: {e}")))
})?;
let nonce = random[IV_LEN..IV_LEN + NONCE_LEN]
.try_into()
.expect("nonce length is constant");
let salt = random[IV_LEN + NONCE_LEN..]
.try_into()
.expect("salt length is constant");
getrandom::fill(&mut salt).map_err(|e| {
FileCryptError::Io(io::Error::other(format!("failed to generate salt: {e}")))
})?;
Ok(Self {
key_len: DERIVED_KEY_LEN,
@@ -48,46 +55,104 @@ impl Header {
})
}
pub(super) fn to_bytes(&self) -> [u8; HEADER_LEN] {
/// Serialize the header.
///
/// The authentication field at `64..80` is deliberately left zeroed.
/// `compute_header_auth()` computes the HMAC over this representation,
/// then the caller inserts the resulting authenticator.
pub fn to_bytes(&self) -> [u8; HEADER_LEN] {
let mut out = [0u8; HEADER_LEN];
out[0..4].copy_from_slice(&MAGIC);
out[4] = VERSION;
out[5] = self.key_len as u8;
// out[6..8] reserved, zero.
// 6..8: reserved, must remain zero.
out[8..24].copy_from_slice(&self.iv);
out[24..40].copy_from_slice(&self.nonce);
out[40..56].copy_from_slice(&self.salt);
out[56..64].copy_from_slice(&self.plaintext_len.to_le_bytes());
// 64..80: header authentication field.
// Left as zero while computing the HMAC.
out
}
pub(super) fn from_bytes(bytes: &[u8; HEADER_LEN]) -> Result<Self, FileCryptError> {
/// Parse and structurally validate a v2 header.
///
/// The HMAC field itself is not validated here. Authentication is
/// performed after deriving the password-based key.
pub fn from_bytes(bytes: &[u8; HEADER_LEN]) -> Result<Self, FileCryptError> {
if bytes[0..4] != MAGIC {
return Err(FileCryptError::BadMagic);
}
if bytes[4] != VERSION {
return Err(FileCryptError::UnsupportedVersion(bytes[4]));
let version = bytes[4];
if version != VERSION {
return Err(FileCryptError::UnsupportedVersion(version));
}
let key_len = bytes[5] as usize;
if !(MIN_KEY_LEN..=MAX_KEY_LEN).contains(&key_len) || bytes[6..8] != [0, 0] {
if !(MIN_KEY_LEN..=MAX_KEY_LEN).contains(&key_len) {
return Err(FileCryptError::MalformedHeader);
}
// Bytes 6..8 are reserved and must be zero.
if bytes[6] != 0 || bytes[7] != 0 {
return Err(FileCryptError::MalformedHeader);
}
let iv: [u8; IV_LEN] = bytes[8..24]
.try_into()
.map_err(|_| FileCryptError::MalformedHeader)?;
let nonce: [u8; NONCE_LEN] = bytes[24..40]
.try_into()
.map_err(|_| FileCryptError::MalformedHeader)?;
let salt: [u8; SALT_LEN] = bytes[40..56]
.try_into()
.map_err(|_| FileCryptError::MalformedHeader)?;
let plaintext_len_bytes: [u8; 8] = bytes[56..64]
.try_into()
.map_err(|_| FileCryptError::MalformedHeader)?;
let plaintext_len = u64::from_le_bytes(plaintext_len_bytes);
// 64..80 is the stored header HMAC.
// It must NOT be required to contain zeroes here.
Ok(Self {
key_len,
iv: bytes[8..24].try_into().expect("IV length is constant"),
nonce: bytes[24..40].try_into().expect("nonce length is constant"),
salt: bytes[40..56].try_into().expect("salt length is constant"),
plaintext_len: u64::from_le_bytes(
bytes[56..64].try_into().expect("length is constant"),
),
iv,
nonce,
salt,
plaintext_len,
})
}
}
/// Compute the truncated HMAC-SHA3-256 authenticator for a header.
///
/// The authentication field itself is replaced with zeroes before hashing.
/// Only the first `HEADER_AUTH_LEN` bytes of the 32-byte HMAC are stored.
pub fn compute_header_auth(
key: &[u8; DERIVED_KEY_LEN],
header: &[u8; HEADER_LEN],
) -> [u8; HEADER_AUTH_LEN] {
let mut authenticated_header = *header;
authenticated_header[HEADER_AUTH_OFFSET..HEADER_AUTH_OFFSET + HEADER_AUTH_LEN].fill(0);
let mac = hmac_sha3_256(key, &authenticated_header);
let mut auth = [0u8; HEADER_AUTH_LEN];
auth.copy_from_slice(&mac[..HEADER_AUTH_LEN]);
auth
}
+39 -8
View File
@@ -11,8 +11,8 @@ use crate::kdf::derive_file_key;
use crate::keccak_aead::KeccakAead;
use super::error::FileCryptError;
use super::format::Header;
use super::{BUF_SIZE, HEADER_LEN, check_key};
use super::format::{HEADER_AUTH_LEN, HEADER_AUTH_OFFSET, Header, compute_header_auth};
use super::{BUF_SIZE, HEADER_LEN};
#[cfg(test)]
const TEST_ARGON2_PARAMS: Argon2Params = Argon2Params {
@@ -74,6 +74,8 @@ fn at_eof<R: Read>(reader: &mut R) -> io::Result<bool> {
/// A new random IV, nonce, and Argon2id salt are generated for every encrypted file.
///
/// The password is used with the header salt to derive the 64-byte KeccakAead key.
///
/// A truncated HMAC-SHA3-256 authenticator is stored in the header. The HMAC covers the complete header except for the HMAC field itself.
pub fn encrypt_stream<R: Read, W: Write>(
mut reader: R,
mut writer: W,
@@ -82,16 +84,23 @@ pub fn encrypt_stream<R: Read, W: Write>(
) -> Result<(), FileCryptError> {
let header = Header::new(plaintext_len)?;
// Argon2id always produces DERIVED_KEY_LEN bytes.
let key = derive_stream_key(password, &header.salt);
check_key(&key)?;
// Header::to_bytes() leaves the authentication field zeroed.
let mut header_bytes = header.to_bytes();
let header_bytes = header.to_bytes();
// Authenticate the header before inserting the authenticator itself.
let header_auth = compute_header_auth(&key, &header_bytes);
header_bytes[HEADER_AUTH_OFFSET..HEADER_AUTH_OFFSET + HEADER_AUTH_LEN]
.copy_from_slice(&header_auth);
writer.write_all(&header_bytes)?;
let mut aead = KeccakAead::new(&header.iv, &key, &header.nonce);
// Authenticate the complete header, including its HMAC, as AEAD associated data.
aead.associated_data_processing(&header_bytes);
let mut buf = vec![0u8; BUF_SIZE];
@@ -127,9 +136,15 @@ pub fn encrypt_stream<R: Read, W: Write>(
/// Decrypt `reader` into `writer` and verify the authentication tag.
///
/// The Argon2id salt is read from the encrypted file's header and used together with `password` to derive the 64-byte AEAD key.
/// The Argon2id salt is read from the encrypted file's header and used
/// together with `password` to derive the 64-byte AEAD key.
///
/// Plaintext is written before authentication succeeds. The file-level API therefore uses an atomic temporary output file.
/// The header HMAC is verified immediately after key derivation and
/// BEFORE any ciphertext is read. Thus a wrong password can be rejected
/// without processing a potentially very large encrypted file.
///
/// Plaintext is written before the final AEAD tag has been checked.
/// The file-level API therefore uses an atomic temporary output file.
pub fn decrypt_stream<R: Read, W: Write>(
mut reader: R,
mut writer: W,
@@ -143,8 +158,7 @@ pub fn decrypt_stream<R: Read, W: Write>(
let key = derive_stream_key(password, &header.salt);
check_key(&key)?;
// The password KDF always produces a DERIVED_KEY_LEN-byte key.
if header.key_len != key.len() {
return Err(FileCryptError::KeyLengthMismatch {
header: header.key_len,
@@ -152,8 +166,25 @@ pub fn decrypt_stream<R: Read, W: Write>(
});
}
// Early authentication
let expected_header_auth = compute_header_auth(&key, &header_bytes);
let stored_header_auth =
&header_bytes[HEADER_AUTH_OFFSET..HEADER_AUTH_OFFSET + HEADER_AUTH_LEN];
let header_auth_matches: bool = expected_header_auth
.as_slice()
.ct_eq(stored_header_auth)
.into();
if !header_auth_matches {
return Err(FileCryptError::AuthenticationFailed);
}
// At this point the password and all HMAC-authenticated header fields are valid. Only now do we start reading the ciphertext.
let mut aead = KeccakAead::new(&header.iv, &key, &header.nonce);
// Authenticate the complete header, including the HMAC field, with AEAD.
aead.associated_data_processing(&header_bytes);
let mut buf = vec![0u8; BUF_SIZE];
+82 -33
View File
@@ -1,10 +1,12 @@
use super::*;
use crate::file_crypt::format::{HEADER_AUTH_LEN, HEADER_AUTH_OFFSET};
use crate::file_crypt::stream::{decrypt_stream, encrypt_stream};
use crate::kdf::{Argon2Params, DERIVED_KEY_LEN, SALT_LEN, derive_file_key_with_params};
use crate::keccak_aead::KeccakAead;
use std::fs;
use std::io::Cursor;
use std::io::{self, Cursor, Read};
const TEST_ARGON2_PARAMS: Argon2Params = Argon2Params {
memory_kib: 32,
@@ -88,6 +90,16 @@ mod tests {
assert_eq!(encrypted.len(), HEADER_LEN + 100 + DERIVED_KEY_LEN);
}
#[test]
fn header_contains_authentication_tag() {
let encrypted = enc(PASSWORD, b"hello");
let header_auth = &encrypted[HEADER_AUTH_OFFSET..HEADER_AUTH_OFFSET + HEADER_AUTH_LEN];
assert_eq!(header_auth.len(), HEADER_AUTH_LEN);
assert_ne!(header_auth, &[0u8; HEADER_AUTH_LEN]);
}
#[test]
fn matches_one_shot_aead() {
for n in boundary_sizes() {
@@ -136,11 +148,8 @@ mod tests {
let a = enc(PASSWORD, &plaintext);
let b = enc(PASSWORD, &plaintext);
// The salts must differ.
assert_ne!(a[40..56], b[40..56]);
// Both files must nevertheless decrypt correctly
// because their respective salts are stored in the header.
assert_eq!(dec(PASSWORD, &a).unwrap(), plaintext);
assert_eq!(dec(PASSWORD, &b).unwrap(), plaintext);
@@ -154,21 +163,20 @@ mod tests {
for pos in 0..encrypted.len() {
for bit in [0x01u8, 0x80] {
let mut tampered = encrypted.clone();
tampered[pos] ^= bit;
let result = dec(PASSWORD, &tampered);
assert!(result.is_err(), "flip at byte {pos} bit {bit:#x} accepted");
/*
* Everything after the header must fail specifically
* on authentication because ciphertext/tag corruption
* is detected by the AEAD.
*/
// Header structure errors are expected for some header
// fields. Once ciphertext/tag processing is reached, any
// modification must fail authentication.
if pos >= HEADER_LEN {
assert!(
matches!(result, Err(FileCryptError::AuthenticationFailed)),
"byte {pos}"
"byte {pos}, bit {bit:#x}: {result:?}"
);
}
}
@@ -186,12 +194,11 @@ mod tests {
forged.extend_from_slice(&encrypted[HEADER_LEN + 20..]);
// Without modifying the authenticated length,
// the file structure is inconsistent.
assert!(dec(PASSWORD, &forged).is_err());
assert!(matches!(
dec(PASSWORD, &forged),
Err(FileCryptError::TrailingData)
));
// Changing the authenticated length must also fail because
// the header is associated data.
forged[56..64].copy_from_slice(&21u64.to_le_bytes());
assert!(matches!(
@@ -241,14 +248,54 @@ mod tests {
));
}
#[test]
fn wrong_password_is_rejected_before_ciphertext_read() {
struct HeaderOnlyReader {
data: Vec<u8>,
position: usize,
}
impl Read for HeaderOnlyReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.position >= self.data.len() {
return Err(io::Error::other(
"ciphertext was read after header authentication",
));
}
let remaining = self.data.len() - self.position;
let n = buf.len().min(remaining);
buf[..n].copy_from_slice(&self.data[self.position..self.position + n]);
self.position += n;
Ok(n)
}
}
let encrypted = enc(PASSWORD, &sample(BUF_SIZE * 10));
let reader = HeaderOnlyReader {
data: encrypted[..HEADER_LEN].to_vec(),
position: 0,
};
let mut output = Vec::new();
let result = decrypt_stream(reader, &mut output, "wrong password");
assert!(matches!(result, Err(FileCryptError::AuthenticationFailed)));
assert!(output.is_empty());
}
#[test]
fn malformed_key_length() {
let encrypted = enc(PASSWORD, &sample(50));
let mut malformed = encrypted.clone();
// 16 is structurally valid, but does not match
// the 64-byte key generated by Argon2id.
malformed[5] = 16;
assert!(matches!(
@@ -298,9 +345,7 @@ mod tests {
));
/*
* Absurd declared length on a tiny file:
* decryption must fail without trying to allocate
* based on that value.
* The plaintext length field is HMAC-authenticated, so changing it causes AuthenticationFailed before ciphertext processing.
*/
let mut huge = enc(PASSWORD, b"x");
@@ -308,7 +353,7 @@ mod tests {
assert!(matches!(
dec(PASSWORD, &huge),
Err(FileCryptError::Truncated)
Err(FileCryptError::AuthenticationFailed)
));
}
@@ -318,7 +363,6 @@ mod tests {
let mut tampered = encrypted.clone();
// Change one bit of the salt.
tampered[40] ^= 1;
assert!(matches!(
@@ -327,14 +371,26 @@ mod tests {
));
}
#[test]
fn authentication_field_tampering_is_rejected() {
let encrypted = enc(PASSWORD, b"hello");
let mut tampered = encrypted.clone();
tampered[HEADER_AUTH_OFFSET] ^= 1;
assert!(matches!(
dec(PASSWORD, &tampered),
Err(FileCryptError::AuthenticationFailed)
));
}
#[test]
fn file_roundtrip_and_atomic_failure() {
let dir = tempfile::tempdir().unwrap();
let plain = dir.path().join("plain.bin");
let encrypted = dir.path().join("plain.bin.kaef");
let decrypted = dir.path().join("decrypted.bin");
let data = sample(3 * BUF_SIZE + 5);
@@ -347,7 +403,6 @@ mod tests {
assert_eq!(fs::read(&decrypted).unwrap(), data);
// Corrupt one ciphertext byte.
let mut bytes = fs::read(&encrypted).unwrap();
bytes[HEADER_LEN + 1000] ^= 1;
@@ -356,27 +411,23 @@ mod tests {
fs::write(&corrupted, &bytes).unwrap();
// A failed decryption must not create
// the output.
let out_new = dir.path().join("out_new.bin");
assert!(matches!(
decrypt_file(&corrupted, &out_new, PASSWORD,),
decrypt_file(&corrupted, &out_new, PASSWORD),
Err(FileCryptError::AuthenticationFailed)
));
assert!(!out_new.exists());
// Nor clobber an existing file.
let out_existing = dir.path().join("out_existing.bin");
fs::write(&out_existing, b"precious").unwrap();
assert!(decrypt_file(&corrupted, &out_existing, PASSWORD,).is_err());
assert!(decrypt_file(&corrupted, &out_existing, PASSWORD).is_err());
assert_eq!(fs::read(&out_existing).unwrap(), b"precious");
// No temporary files are left behind.
let leftovers: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
@@ -391,9 +442,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let plain = dir.path().join("empty");
let encrypted = dir.path().join("empty.kaef");
let decrypted = dir.path().join("empty.out");
fs::write(&plain, b"").unwrap();
+56
View File
@@ -0,0 +1,56 @@
//! HMAC-SHA3-256 implementation.
//!
//! HMAC construction:
//! H(K, m) = H((K' xor opad) || H((K' xor ipad) || m))
//!
//! SHA3-256 has a 136-byte rate, which is the block size used here.
use super::sha3_256;
/// SHA3-256 HMAC block size in bytes.
///
/// SHA3-256 has a rate of 1088 bits = 136 bytes.
pub const HMAC_SHA3_256_BLOCK_SIZE: usize = 136;
/// SHA3-256 HMAC output size in bytes.
pub const HMAC_SHA3_256_OUTPUT_SIZE: usize = 32;
/// HMAC-SHA3-256.
///
/// The key is normalized according to the HMAC construction:
/// - keys longer than the hash block size are hashed first;
/// - shorter keys are zero-padded to the block size.
pub fn hmac_sha3_256(key: &[u8], message: &[u8]) -> [u8; HMAC_SHA3_256_OUTPUT_SIZE] {
let mut key_block = [0u8; HMAC_SHA3_256_BLOCK_SIZE];
if key.len() > HMAC_SHA3_256_BLOCK_SIZE {
let hashed_key = sha3_256(key);
key_block[..hashed_key.len()].copy_from_slice(&hashed_key);
} else {
key_block[..key.len()].copy_from_slice(key);
}
let mut ipad = [0u8; HMAC_SHA3_256_BLOCK_SIZE];
let mut opad = [0u8; HMAC_SHA3_256_BLOCK_SIZE];
for ((ipad_byte, opad_byte), &key_byte) in
ipad.iter_mut().zip(opad.iter_mut()).zip(key_block.iter())
{
*ipad_byte = key_byte ^ 0x36;
*opad_byte = key_byte ^ 0x5c;
}
// inner = SHA3-256((K' xor ipad) || message)
let mut inner_input = Vec::with_capacity(HMAC_SHA3_256_BLOCK_SIZE + message.len());
inner_input.extend_from_slice(&ipad);
inner_input.extend_from_slice(message);
let inner_hash = sha3_256(&inner_input);
// outer = SHA3-256((K' xor opad) || inner)
let mut outer_input = Vec::with_capacity(HMAC_SHA3_256_BLOCK_SIZE + inner_hash.len());
outer_input.extend_from_slice(&opad);
outer_input.extend_from_slice(&inner_hash);
sha3_256(&outer_input)
}
+4 -1
View File
@@ -1,10 +1,13 @@
//! Keccak-f[1600], SHA-3, and SHAKE implementations.
//! Keccak-f[1600], SHA-3, SHAKE, and HMAC implementations.
mod hmac;
mod keccak;
#[cfg(test)]
mod test;
pub use hmac::{HMAC_SHA3_256_BLOCK_SIZE, HMAC_SHA3_256_OUTPUT_SIZE, hmac_sha3_256};
pub use keccak::{
keccak, keccak_f1600, sha3_224, sha3_256, sha3_384, sha3_512, shake128, shake256,
};
+51
View File
@@ -222,3 +222,54 @@ d5a15bef186a5386c75744c0527e1faa\
9f8726e462a12a4feb06bd8801e751e4",
);
}
#[test]
fn hmac_sha3_256_test_vectors() {
let vectors = [
(
vec![0x0b; 20],
b"Hi There".to_vec(),
"ba85192310dffa96e2a3a40e69774351140bb7185e1202cdcc917589f95e16bb",
),
(
b"Jefe".to_vec(),
b"what do ya want for nothing?".to_vec(),
"c7d4072e788877ae3596bbb0da73b887c9171f93095b294ae857fbe2645e1ba5",
),
(
vec![0xaa; 20],
vec![0xdd; 50],
"84ec79124a27107865cedd8bd82da9965e5ed8c37b0ac98005a7f39ed58a4207",
),
(
(1u8..=25).collect(),
vec![0xcd; 50],
"57366a45e2305321a4bc5aa5fe2ef8a921f6af8273d7fe7be6cfedb3f0aea6d7",
),
];
for (key, message, expected) in vectors {
let actual = hmac_sha3_256(&key, &message);
assert_hex(&actual, expected);
}
}
#[test]
fn hmac_sha3_256_empty_key_and_message() {
let actual = hmac_sha3_256(b"", b"");
assert_hex(
&actual,
"e841c164e5b4f10c9f3985587962af72fd607a951196fc92fb3a5251941784ea",
);
}
#[test]
fn long_key_is_hashed() {
let key = vec![0x42; HMAC_SHA3_256_BLOCK_SIZE + 1];
let message = b"test message";
let actual = hmac_sha3_256(&key, message);
assert_eq!(actual.len(), HMAC_SHA3_256_OUTPUT_SIZE);
}