HMAC SHA3 + authenticate file header with HMAC
This commit is contained in:
1 parent
b30b5d0add
commit
5e95b21fbd
6 files changed
+333
-78
No files matched your search
+101
-36
@@ -1,43 +1,50 @@
|
|||||||
|
use std::io;
|
||||||
|
|
||||||
use crate::kdf::{DERIVED_KEY_LEN, SALT_LEN};
|
use crate::kdf::{DERIVED_KEY_LEN, SALT_LEN};
|
||||||
|
use crate::keccak::hmac_sha3_256;
|
||||||
|
|
||||||
use super::error::FileCryptError;
|
use super::error::FileCryptError;
|
||||||
|
|
||||||
|
const MAGIC: [u8; 4] = *b"KAEF";
|
||||||
|
const VERSION: u8 = 2;
|
||||||
|
|
||||||
pub const IV_LEN: usize = 16;
|
pub const IV_LEN: usize = 16;
|
||||||
pub const NONCE_LEN: usize = 16;
|
pub const NONCE_LEN: usize = 16;
|
||||||
|
|
||||||
pub const MIN_KEY_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;
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Header {
|
||||||
const MAGIC: [u8; 4] = *b"KAEF";
|
pub(crate) key_len: usize,
|
||||||
const VERSION: u8 = 2;
|
pub(crate) iv: [u8; IV_LEN],
|
||||||
|
pub(crate) nonce: [u8; NONCE_LEN],
|
||||||
pub(super) struct Header {
|
pub(crate) salt: [u8; SALT_LEN],
|
||||||
pub(super) key_len: usize,
|
pub(crate) plaintext_len: u64,
|
||||||
pub(super) iv: [u8; IV_LEN],
|
|
||||||
pub(super) nonce: [u8; NONCE_LEN],
|
|
||||||
pub(super) salt: [u8; SALT_LEN],
|
|
||||||
pub(super) plaintext_len: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Header {
|
impl Header {
|
||||||
/// Create a new header with a fresh random IV, nonce, and KDF salt.
|
/// Create a new v2 header with a fixed-size Argon2-derived key.
|
||||||
pub(super) fn new(plaintext_len: u64) -> Result<Self, FileCryptError> {
|
pub fn new(plaintext_len: u64) -> Result<Self, FileCryptError> {
|
||||||
let mut random = [0u8; IV_LEN + NONCE_LEN + SALT_LEN];
|
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]
|
getrandom::fill(&mut salt).map_err(|e| {
|
||||||
.try_into()
|
FileCryptError::Io(io::Error::other(format!("failed to generate salt: {e}")))
|
||||||
.expect("nonce length is constant");
|
})?;
|
||||||
|
|
||||||
let salt = random[IV_LEN + NONCE_LEN..]
|
|
||||||
.try_into()
|
|
||||||
.expect("salt length is constant");
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
key_len: DERIVED_KEY_LEN,
|
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];
|
let mut out = [0u8; HEADER_LEN];
|
||||||
|
|
||||||
out[0..4].copy_from_slice(&MAGIC);
|
out[0..4].copy_from_slice(&MAGIC);
|
||||||
out[4] = VERSION;
|
out[4] = VERSION;
|
||||||
out[5] = self.key_len as u8;
|
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[8..24].copy_from_slice(&self.iv);
|
||||||
out[24..40].copy_from_slice(&self.nonce);
|
out[24..40].copy_from_slice(&self.nonce);
|
||||||
out[40..56].copy_from_slice(&self.salt);
|
out[40..56].copy_from_slice(&self.salt);
|
||||||
out[56..64].copy_from_slice(&self.plaintext_len.to_le_bytes());
|
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
|
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 {
|
if bytes[0..4] != MAGIC {
|
||||||
return Err(FileCryptError::BadMagic);
|
return Err(FileCryptError::BadMagic);
|
||||||
}
|
}
|
||||||
|
|
||||||
if bytes[4] != VERSION {
|
let version = bytes[4];
|
||||||
return Err(FileCryptError::UnsupportedVersion(bytes[4]));
|
|
||||||
|
if version != VERSION {
|
||||||
|
return Err(FileCryptError::UnsupportedVersion(version));
|
||||||
}
|
}
|
||||||
|
|
||||||
let key_len = bytes[5] as usize;
|
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);
|
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 {
|
Ok(Self {
|
||||||
key_len,
|
key_len,
|
||||||
iv: bytes[8..24].try_into().expect("IV length is constant"),
|
iv,
|
||||||
nonce: bytes[24..40].try_into().expect("nonce length is constant"),
|
nonce,
|
||||||
salt: bytes[40..56].try_into().expect("salt length is constant"),
|
salt,
|
||||||
plaintext_len: u64::from_le_bytes(
|
plaintext_len,
|
||||||
bytes[56..64].try_into().expect("length is constant"),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ use crate::kdf::derive_file_key;
|
|||||||
use crate::keccak_aead::KeccakAead;
|
use crate::keccak_aead::KeccakAead;
|
||||||
|
|
||||||
use super::error::FileCryptError;
|
use super::error::FileCryptError;
|
||||||
use super::format::Header;
|
use super::format::{HEADER_AUTH_LEN, HEADER_AUTH_OFFSET, Header, compute_header_auth};
|
||||||
use super::{BUF_SIZE, HEADER_LEN, check_key};
|
use super::{BUF_SIZE, HEADER_LEN};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
const TEST_ARGON2_PARAMS: Argon2Params = Argon2Params {
|
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.
|
/// 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.
|
/// 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>(
|
pub fn encrypt_stream<R: Read, W: Write>(
|
||||||
mut reader: R,
|
mut reader: R,
|
||||||
mut writer: W,
|
mut writer: W,
|
||||||
@@ -82,16 +84,23 @@ pub fn encrypt_stream<R: Read, W: Write>(
|
|||||||
) -> Result<(), FileCryptError> {
|
) -> Result<(), FileCryptError> {
|
||||||
let header = Header::new(plaintext_len)?;
|
let header = Header::new(plaintext_len)?;
|
||||||
|
|
||||||
|
// Argon2id always produces DERIVED_KEY_LEN bytes.
|
||||||
let key = derive_stream_key(password, &header.salt);
|
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)?;
|
writer.write_all(&header_bytes)?;
|
||||||
|
|
||||||
let mut aead = KeccakAead::new(&header.iv, &key, &header.nonce);
|
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);
|
aead.associated_data_processing(&header_bytes);
|
||||||
|
|
||||||
let mut buf = vec![0u8; BUF_SIZE];
|
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.
|
/// 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>(
|
pub fn decrypt_stream<R: Read, W: Write>(
|
||||||
mut reader: R,
|
mut reader: R,
|
||||||
mut writer: W,
|
mut writer: W,
|
||||||
@@ -143,8 +158,7 @@ pub fn decrypt_stream<R: Read, W: Write>(
|
|||||||
|
|
||||||
let key = derive_stream_key(password, &header.salt);
|
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() {
|
if header.key_len != key.len() {
|
||||||
return Err(FileCryptError::KeyLengthMismatch {
|
return Err(FileCryptError::KeyLengthMismatch {
|
||||||
header: header.key_len,
|
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);
|
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);
|
aead.associated_data_processing(&header_bytes);
|
||||||
|
|
||||||
let mut buf = vec![0u8; BUF_SIZE];
|
let mut buf = vec![0u8; BUF_SIZE];
|
||||||
|
|||||||
+82
-33
@@ -1,10 +1,12 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
use crate::file_crypt::format::{HEADER_AUTH_LEN, HEADER_AUTH_OFFSET};
|
||||||
use crate::file_crypt::stream::{decrypt_stream, encrypt_stream};
|
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::kdf::{Argon2Params, DERIVED_KEY_LEN, SALT_LEN, derive_file_key_with_params};
|
||||||
use crate::keccak_aead::KeccakAead;
|
use crate::keccak_aead::KeccakAead;
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Cursor;
|
use std::io::{self, Cursor, Read};
|
||||||
|
|
||||||
const TEST_ARGON2_PARAMS: Argon2Params = Argon2Params {
|
const TEST_ARGON2_PARAMS: Argon2Params = Argon2Params {
|
||||||
memory_kib: 32,
|
memory_kib: 32,
|
||||||
@@ -88,6 +90,16 @@ mod tests {
|
|||||||
assert_eq!(encrypted.len(), HEADER_LEN + 100 + DERIVED_KEY_LEN);
|
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]
|
#[test]
|
||||||
fn matches_one_shot_aead() {
|
fn matches_one_shot_aead() {
|
||||||
for n in boundary_sizes() {
|
for n in boundary_sizes() {
|
||||||
@@ -136,11 +148,8 @@ mod tests {
|
|||||||
let a = enc(PASSWORD, &plaintext);
|
let a = enc(PASSWORD, &plaintext);
|
||||||
let b = enc(PASSWORD, &plaintext);
|
let b = enc(PASSWORD, &plaintext);
|
||||||
|
|
||||||
// The salts must differ.
|
|
||||||
assert_ne!(a[40..56], b[40..56]);
|
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, &a).unwrap(), plaintext);
|
||||||
|
|
||||||
assert_eq!(dec(PASSWORD, &b).unwrap(), plaintext);
|
assert_eq!(dec(PASSWORD, &b).unwrap(), plaintext);
|
||||||
@@ -154,21 +163,20 @@ mod tests {
|
|||||||
for pos in 0..encrypted.len() {
|
for pos in 0..encrypted.len() {
|
||||||
for bit in [0x01u8, 0x80] {
|
for bit in [0x01u8, 0x80] {
|
||||||
let mut tampered = encrypted.clone();
|
let mut tampered = encrypted.clone();
|
||||||
|
|
||||||
tampered[pos] ^= bit;
|
tampered[pos] ^= bit;
|
||||||
|
|
||||||
let result = dec(PASSWORD, &tampered);
|
let result = dec(PASSWORD, &tampered);
|
||||||
|
|
||||||
assert!(result.is_err(), "flip at byte {pos} bit {bit:#x} accepted");
|
assert!(result.is_err(), "flip at byte {pos} bit {bit:#x} accepted");
|
||||||
|
|
||||||
/*
|
// Header structure errors are expected for some header
|
||||||
* Everything after the header must fail specifically
|
// fields. Once ciphertext/tag processing is reached, any
|
||||||
* on authentication because ciphertext/tag corruption
|
// modification must fail authentication.
|
||||||
* is detected by the AEAD.
|
|
||||||
*/
|
|
||||||
if pos >= HEADER_LEN {
|
if pos >= HEADER_LEN {
|
||||||
assert!(
|
assert!(
|
||||||
matches!(result, Err(FileCryptError::AuthenticationFailed)),
|
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..]);
|
forged.extend_from_slice(&encrypted[HEADER_LEN + 20..]);
|
||||||
|
|
||||||
// Without modifying the authenticated length,
|
assert!(matches!(
|
||||||
// the file structure is inconsistent.
|
dec(PASSWORD, &forged),
|
||||||
assert!(dec(PASSWORD, &forged).is_err());
|
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());
|
forged[56..64].copy_from_slice(&21u64.to_le_bytes());
|
||||||
|
|
||||||
assert!(matches!(
|
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]
|
#[test]
|
||||||
fn malformed_key_length() {
|
fn malformed_key_length() {
|
||||||
let encrypted = enc(PASSWORD, &sample(50));
|
let encrypted = enc(PASSWORD, &sample(50));
|
||||||
|
|
||||||
let mut malformed = encrypted.clone();
|
let mut malformed = encrypted.clone();
|
||||||
|
|
||||||
// 16 is structurally valid, but does not match
|
|
||||||
// the 64-byte key generated by Argon2id.
|
|
||||||
malformed[5] = 16;
|
malformed[5] = 16;
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -298,9 +345,7 @@ mod tests {
|
|||||||
));
|
));
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Absurd declared length on a tiny file:
|
* The plaintext length field is HMAC-authenticated, so changing it causes AuthenticationFailed before ciphertext processing.
|
||||||
* decryption must fail without trying to allocate
|
|
||||||
* based on that value.
|
|
||||||
*/
|
*/
|
||||||
let mut huge = enc(PASSWORD, b"x");
|
let mut huge = enc(PASSWORD, b"x");
|
||||||
|
|
||||||
@@ -308,7 +353,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
dec(PASSWORD, &huge),
|
dec(PASSWORD, &huge),
|
||||||
Err(FileCryptError::Truncated)
|
Err(FileCryptError::AuthenticationFailed)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +363,6 @@ mod tests {
|
|||||||
|
|
||||||
let mut tampered = encrypted.clone();
|
let mut tampered = encrypted.clone();
|
||||||
|
|
||||||
// Change one bit of the salt.
|
|
||||||
tampered[40] ^= 1;
|
tampered[40] ^= 1;
|
||||||
|
|
||||||
assert!(matches!(
|
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]
|
#[test]
|
||||||
fn file_roundtrip_and_atomic_failure() {
|
fn file_roundtrip_and_atomic_failure() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
let plain = dir.path().join("plain.bin");
|
let plain = dir.path().join("plain.bin");
|
||||||
|
|
||||||
let encrypted = dir.path().join("plain.bin.kaef");
|
let encrypted = dir.path().join("plain.bin.kaef");
|
||||||
|
|
||||||
let decrypted = dir.path().join("decrypted.bin");
|
let decrypted = dir.path().join("decrypted.bin");
|
||||||
|
|
||||||
let data = sample(3 * BUF_SIZE + 5);
|
let data = sample(3 * BUF_SIZE + 5);
|
||||||
@@ -347,7 +403,6 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(fs::read(&decrypted).unwrap(), data);
|
assert_eq!(fs::read(&decrypted).unwrap(), data);
|
||||||
|
|
||||||
// Corrupt one ciphertext byte.
|
|
||||||
let mut bytes = fs::read(&encrypted).unwrap();
|
let mut bytes = fs::read(&encrypted).unwrap();
|
||||||
|
|
||||||
bytes[HEADER_LEN + 1000] ^= 1;
|
bytes[HEADER_LEN + 1000] ^= 1;
|
||||||
@@ -356,27 +411,23 @@ mod tests {
|
|||||||
|
|
||||||
fs::write(&corrupted, &bytes).unwrap();
|
fs::write(&corrupted, &bytes).unwrap();
|
||||||
|
|
||||||
// A failed decryption must not create
|
|
||||||
// the output.
|
|
||||||
let out_new = dir.path().join("out_new.bin");
|
let out_new = dir.path().join("out_new.bin");
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
decrypt_file(&corrupted, &out_new, PASSWORD,),
|
decrypt_file(&corrupted, &out_new, PASSWORD),
|
||||||
Err(FileCryptError::AuthenticationFailed)
|
Err(FileCryptError::AuthenticationFailed)
|
||||||
));
|
));
|
||||||
|
|
||||||
assert!(!out_new.exists());
|
assert!(!out_new.exists());
|
||||||
|
|
||||||
// Nor clobber an existing file.
|
|
||||||
let out_existing = dir.path().join("out_existing.bin");
|
let out_existing = dir.path().join("out_existing.bin");
|
||||||
|
|
||||||
fs::write(&out_existing, b"precious").unwrap();
|
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");
|
assert_eq!(fs::read(&out_existing).unwrap(), b"precious");
|
||||||
|
|
||||||
// No temporary files are left behind.
|
|
||||||
let leftovers: Vec<_> = fs::read_dir(dir.path())
|
let leftovers: Vec<_> = fs::read_dir(dir.path())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||||
@@ -391,9 +442,7 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
let plain = dir.path().join("empty");
|
let plain = dir.path().join("empty");
|
||||||
|
|
||||||
let encrypted = dir.path().join("empty.kaef");
|
let encrypted = dir.path().join("empty.kaef");
|
||||||
|
|
||||||
let decrypted = dir.path().join("empty.out");
|
let decrypted = dir.path().join("empty.out");
|
||||||
|
|
||||||
fs::write(&plain, b"").unwrap();
|
fs::write(&plain, b"").unwrap();
|
||||||
|
|||||||
@@ -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
@@ -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;
|
mod keccak;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test;
|
mod test;
|
||||||
|
|
||||||
|
pub use hmac::{HMAC_SHA3_256_BLOCK_SIZE, HMAC_SHA3_256_OUTPUT_SIZE, hmac_sha3_256};
|
||||||
|
|
||||||
pub use keccak::{
|
pub use keccak::{
|
||||||
keccak, keccak_f1600, sha3_224, sha3_256, sha3_384, sha3_512, shake128, shake256,
|
keccak, keccak_f1600, sha3_224, sha3_256, sha3_384, sha3_512, shake128, shake256,
|
||||||
};
|
};
|
||||||
@@ -222,3 +222,54 @@ d5a15bef186a5386c75744c0527e1faa\
|
|||||||
9f8726e462a12a4feb06bd8801e751e4",
|
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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user