PBKDF: implement argon2id
This commit is contained in:
1 parent
1fcf38db41
commit
b30b5d0add
13 files changed
+1291
-148
No files matched your search
+10
-11
@@ -2,21 +2,21 @@ use std::fs::File;
|
||||
use std::io::BufWriter;
|
||||
use std::path::Path;
|
||||
|
||||
use super::BUF_SIZE;
|
||||
use super::atomic::AtomicOutput;
|
||||
use super::error::FileCryptError;
|
||||
use super::stream::{decrypt_stream, encrypt_stream};
|
||||
use super::{BUF_SIZE, check_key};
|
||||
|
||||
/// Encrypt the file at `input` into `output`.
|
||||
///
|
||||
/// A random Argon2id salt, IV, and nonce are generated and stored in the encrypted file header.
|
||||
///
|
||||
/// `output` is only replaced if encryption succeeds.
|
||||
pub fn encrypt_file(
|
||||
input: impl AsRef<Path>,
|
||||
output: impl AsRef<Path>,
|
||||
key: &[u8],
|
||||
password: &str,
|
||||
) -> Result<(), FileCryptError> {
|
||||
check_key(key)?;
|
||||
|
||||
let input_file = File::open(input.as_ref())?;
|
||||
|
||||
let plaintext_len = input_file.metadata()?.len();
|
||||
@@ -25,7 +25,7 @@ pub fn encrypt_file(
|
||||
|
||||
let mut writer = BufWriter::with_capacity(BUF_SIZE, out_file);
|
||||
|
||||
encrypt_stream(input_file, &mut writer, key, plaintext_len)?;
|
||||
encrypt_stream(input_file, &mut writer, password, plaintext_len)?;
|
||||
|
||||
let out_file = writer
|
||||
.into_inner()
|
||||
@@ -41,22 +41,21 @@ pub fn encrypt_file(
|
||||
|
||||
/// Decrypt the file at `input` into `output`.
|
||||
///
|
||||
/// The destination is left untouched if authentication or any other part
|
||||
/// of decryption fails.
|
||||
/// The Argon2id salt is read from the encrypted file header.
|
||||
///
|
||||
/// The destination is left untouched if authentication or any other part of decryption fails.
|
||||
pub fn decrypt_file(
|
||||
input: impl AsRef<Path>,
|
||||
output: impl AsRef<Path>,
|
||||
key: &[u8],
|
||||
password: &str,
|
||||
) -> Result<(), FileCryptError> {
|
||||
check_key(key)?;
|
||||
|
||||
let input_file = File::open(input.as_ref())?;
|
||||
|
||||
let (guard, out_file) = AtomicOutput::create(output.as_ref())?;
|
||||
|
||||
let mut writer = BufWriter::with_capacity(BUF_SIZE, out_file);
|
||||
|
||||
decrypt_stream(input_file, &mut writer, key)?;
|
||||
decrypt_stream(input_file, &mut writer, password)?;
|
||||
|
||||
let out_file = writer
|
||||
.into_inner()
|
||||
|
||||
@@ -1,27 +1,53 @@
|
||||
use crate::kdf::{DERIVED_KEY_LEN, SALT_LEN};
|
||||
|
||||
use super::error::FileCryptError;
|
||||
|
||||
pub const IV_LEN: usize = 16;
|
||||
pub const NONCE_LEN: usize = 16;
|
||||
|
||||
/// The tag is as long as the key.
|
||||
pub const MIN_KEY_LEN: usize = 16;
|
||||
|
||||
/// Must match `CAPACITY_SIZE` in `keccak_aead.rs`.
|
||||
pub const MAX_KEY_LEN: usize = 24;
|
||||
pub const MAX_KEY_LEN: usize = 64;
|
||||
|
||||
pub const HEADER_LEN: usize = 48;
|
||||
pub const HEADER_LEN: usize = 64;
|
||||
|
||||
const MAGIC: [u8; 4] = *b"KAEF";
|
||||
const VERSION: u8 = 1;
|
||||
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,
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
getrandom::fill(&mut random).map_err(FileCryptError::Random)?;
|
||||
|
||||
let iv = random[0..IV_LEN].try_into().expect("IV length is constant");
|
||||
|
||||
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");
|
||||
|
||||
Ok(Self {
|
||||
key_len: DERIVED_KEY_LEN,
|
||||
iv,
|
||||
nonce,
|
||||
salt,
|
||||
plaintext_len,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn to_bytes(&self) -> [u8; HEADER_LEN] {
|
||||
let mut out = [0u8; HEADER_LEN];
|
||||
|
||||
@@ -33,7 +59,8 @@ impl Header {
|
||||
|
||||
out[8..24].copy_from_slice(&self.iv);
|
||||
out[24..40].copy_from_slice(&self.nonce);
|
||||
out[40..48].copy_from_slice(&self.plaintext_len.to_le_bytes());
|
||||
out[40..56].copy_from_slice(&self.salt);
|
||||
out[56..64].copy_from_slice(&self.plaintext_len.to_le_bytes());
|
||||
|
||||
out
|
||||
}
|
||||
@@ -55,9 +82,12 @@ impl Header {
|
||||
|
||||
Ok(Self {
|
||||
key_len,
|
||||
iv: bytes[8..24].try_into().unwrap(),
|
||||
nonce: bytes[24..40].try_into().unwrap(),
|
||||
plaintext_len: u64::from_le_bytes(bytes[40..48].try_into().unwrap()),
|
||||
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"),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ mod stream;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use crate::kdf::SALT_LEN;
|
||||
|
||||
pub use error::FileCryptError;
|
||||
pub use file::{decrypt_file, encrypt_file};
|
||||
pub use format::{HEADER_LEN, MAX_KEY_LEN, MIN_KEY_LEN};
|
||||
|
||||
+48
-22
@@ -2,12 +2,42 @@ use std::io::{self, Read, Write};
|
||||
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::kdf::{Argon2Params, derive_file_key_with_params};
|
||||
|
||||
#[cfg(not(test))]
|
||||
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};
|
||||
|
||||
#[cfg(test)]
|
||||
const TEST_ARGON2_PARAMS: Argon2Params = Argon2Params {
|
||||
memory_kib: 32,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
fn derive_stream_key(
|
||||
password: &str,
|
||||
salt: &[u8; crate::kdf::SALT_LEN],
|
||||
) -> [u8; crate::kdf::DERIVED_KEY_LEN] {
|
||||
derive_file_key_with_params(password, salt, TEST_ARGON2_PARAMS)
|
||||
.expect("test Argon2id parameters are valid")
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn derive_stream_key(
|
||||
password: &str,
|
||||
salt: &[u8; crate::kdf::SALT_LEN],
|
||||
) -> [u8; crate::kdf::DERIVED_KEY_LEN] {
|
||||
derive_file_key(password, salt)
|
||||
}
|
||||
|
||||
/// `read_exact`, but EOF becomes `on_eof` instead of an `io::Error`.
|
||||
fn read_exact_or<R: Read>(
|
||||
reader: &mut R,
|
||||
@@ -41,34 +71,26 @@ fn at_eof<R: Read>(reader: &mut R) -> io::Result<bool> {
|
||||
|
||||
/// Encrypt exactly `plaintext_len` bytes from `reader` into `writer`.
|
||||
///
|
||||
/// The length is authenticated as part of the header.
|
||||
/// 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.
|
||||
pub fn encrypt_stream<R: Read, W: Write>(
|
||||
mut reader: R,
|
||||
mut writer: W,
|
||||
key: &[u8],
|
||||
password: &str,
|
||||
plaintext_len: u64,
|
||||
) -> Result<(), FileCryptError> {
|
||||
check_key(key)?;
|
||||
let header = Header::new(plaintext_len)?;
|
||||
|
||||
let mut iv = [0u8; 16];
|
||||
let mut nonce = [0u8; 16];
|
||||
let key = derive_stream_key(password, &header.salt);
|
||||
|
||||
getrandom::fill(&mut iv).map_err(FileCryptError::Random)?;
|
||||
|
||||
getrandom::fill(&mut nonce).map_err(FileCryptError::Random)?;
|
||||
|
||||
let header = Header {
|
||||
key_len: key.len(),
|
||||
iv,
|
||||
nonce,
|
||||
plaintext_len,
|
||||
};
|
||||
check_key(&key)?;
|
||||
|
||||
let header_bytes = header.to_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);
|
||||
|
||||
aead.associated_data_processing(&header_bytes);
|
||||
|
||||
@@ -95,7 +117,7 @@ pub fn encrypt_stream<R: Read, W: Write>(
|
||||
return Err(FileCryptError::InputLengthChanged);
|
||||
}
|
||||
|
||||
let tag = aead.finalize(key);
|
||||
let tag = aead.finalize(&key);
|
||||
|
||||
writer.write_all(&tag)?;
|
||||
writer.flush()?;
|
||||
@@ -105,20 +127,24 @@ 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.
|
||||
///
|
||||
/// Plaintext is written before authentication succeeds. 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,
|
||||
key: &[u8],
|
||||
password: &str,
|
||||
) -> Result<(), FileCryptError> {
|
||||
check_key(key)?;
|
||||
|
||||
let mut header_bytes = [0u8; HEADER_LEN];
|
||||
|
||||
read_exact_or(&mut reader, &mut header_bytes, FileCryptError::Truncated)?;
|
||||
|
||||
let header = Header::from_bytes(&header_bytes)?;
|
||||
|
||||
let key = derive_stream_key(password, &header.salt);
|
||||
|
||||
check_key(&key)?;
|
||||
|
||||
if header.key_len != key.len() {
|
||||
return Err(FileCryptError::KeyLengthMismatch {
|
||||
header: header.key_len,
|
||||
@@ -126,7 +152,7 @@ pub fn decrypt_stream<R: Read, W: Write>(
|
||||
});
|
||||
}
|
||||
|
||||
let mut aead = KeccakAead::new(&header.iv, key, &header.nonce);
|
||||
let mut aead = KeccakAead::new(&header.iv, &key, &header.nonce);
|
||||
|
||||
aead.associated_data_processing(&header_bytes);
|
||||
|
||||
@@ -153,7 +179,7 @@ pub fn decrypt_stream<R: Read, W: Write>(
|
||||
return Err(FileCryptError::TrailingData);
|
||||
}
|
||||
|
||||
let computed_tag = aead.finalize(key);
|
||||
let computed_tag = aead.finalize(&key);
|
||||
|
||||
let tags_match: bool = computed_tag.as_slice().ct_eq(stored_tag.as_slice()).into();
|
||||
|
||||
|
||||
+183
-71
@@ -1,9 +1,18 @@
|
||||
use super::*;
|
||||
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;
|
||||
|
||||
const KEY: [u8; 16] = [0x42; 16];
|
||||
const TEST_ARGON2_PARAMS: Argon2Params = Argon2Params {
|
||||
memory_kib: 32,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
const PASSWORD: &str = "test password";
|
||||
|
||||
fn sample(n: usize) -> Vec<u8> {
|
||||
(0..n)
|
||||
@@ -11,13 +20,13 @@ fn sample(n: usize) -> Vec<u8> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn enc(key: &[u8], plaintext: &[u8]) -> Vec<u8> {
|
||||
fn enc(password: &str, plaintext: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
encrypt_stream(
|
||||
Cursor::new(plaintext),
|
||||
&mut out,
|
||||
key,
|
||||
password,
|
||||
plaintext.len() as u64,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -25,10 +34,10 @@ fn enc(key: &[u8], plaintext: &[u8]) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
fn dec(key: &[u8], data: &[u8]) -> Result<Vec<u8>, FileCryptError> {
|
||||
fn dec(password: &str, data: &[u8]) -> Result<Vec<u8>, FileCryptError> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
decrypt_stream(Cursor::new(data), &mut out, key)?;
|
||||
decrypt_stream(Cursor::new(data), &mut out, password)?;
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
@@ -53,74 +62,109 @@ fn boundary_sizes() -> Vec<usize> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::keccak_aead::KeccakAead;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_various_sizes() {
|
||||
for n in boundary_sizes() {
|
||||
let plaintext = sample(n);
|
||||
let encrypted = enc(&KEY, &plaintext);
|
||||
assert_eq!(encrypted.len(), HEADER_LEN + n + KEY.len(), "size {n}");
|
||||
assert_eq!(dec(&KEY, &encrypted).unwrap(), plaintext, "size {n}");
|
||||
let encrypted = enc(PASSWORD, &plaintext);
|
||||
|
||||
assert_eq!(
|
||||
encrypted.len(),
|
||||
HEADER_LEN + n + DERIVED_KEY_LEN,
|
||||
"size {n}"
|
||||
);
|
||||
|
||||
assert_eq!(dec(PASSWORD, &encrypted).unwrap(), plaintext, "size {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_all_key_lengths() {
|
||||
for key_len in MIN_KEY_LEN..=MAX_KEY_LEN {
|
||||
let key = vec![0xA5u8; key_len];
|
||||
let plaintext = sample(100);
|
||||
let encrypted = enc(&key, &plaintext);
|
||||
assert_eq!(encrypted[5] as usize, key_len);
|
||||
assert_eq!(encrypted.len(), HEADER_LEN + 100 + key_len);
|
||||
assert_eq!(dec(&key, &encrypted).unwrap(), plaintext);
|
||||
}
|
||||
fn header_contains_derived_key_length() {
|
||||
let encrypted = enc(PASSWORD, &sample(100));
|
||||
|
||||
assert_eq!(encrypted[5] as usize, DERIVED_KEY_LEN);
|
||||
|
||||
assert_eq!(encrypted.len(), HEADER_LEN + 100 + DERIVED_KEY_LEN);
|
||||
}
|
||||
|
||||
/// Chunked streaming must be byte-identical to the one-shot AEAD.
|
||||
#[test]
|
||||
fn matches_one_shot_aead() {
|
||||
for n in boundary_sizes() {
|
||||
let plaintext = sample(n);
|
||||
let encrypted = enc(&KEY, &plaintext);
|
||||
let encrypted = enc(PASSWORD, &plaintext);
|
||||
|
||||
let header = &encrypted[..HEADER_LEN];
|
||||
|
||||
let iv = &header[8..24];
|
||||
let nonce = &header[24..40];
|
||||
|
||||
let expected = KeccakAead::encrypt(&KEY, &plaintext, iv, header, nonce);
|
||||
let salt: [u8; SALT_LEN] = header[40..56].try_into().unwrap();
|
||||
|
||||
let key = derive_file_key_with_params(PASSWORD, &salt, TEST_ARGON2_PARAMS).unwrap();
|
||||
|
||||
let expected = KeccakAead::encrypt(&key, &plaintext, iv, header, nonce);
|
||||
|
||||
assert_eq!(
|
||||
&encrypted[HEADER_LEN..HEADER_LEN + n],
|
||||
&expected.cipher[..],
|
||||
"size {n}"
|
||||
);
|
||||
|
||||
assert_eq!(&encrypted[HEADER_LEN + n..], &expected.tag[..], "size {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iv_and_nonce_are_random() {
|
||||
let a = enc(&KEY, b"same plaintext");
|
||||
let b = enc(&KEY, b"same plaintext");
|
||||
fn iv_nonce_and_salt_are_random() {
|
||||
let a = enc(PASSWORD, b"same plaintext");
|
||||
let b = enc(PASSWORD, b"same plaintext");
|
||||
|
||||
assert_ne!(a[8..24], b[8..24], "IV repeated");
|
||||
|
||||
assert_ne!(a[24..40], b[24..40], "nonce repeated");
|
||||
|
||||
assert_ne!(a[40..56], b[40..56], "salt repeated");
|
||||
|
||||
assert_ne!(a[HEADER_LEN..], b[HEADER_LEN..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_salts_still_allow_decryption() {
|
||||
let plaintext = sample(100);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_single_bit_flip_is_rejected() {
|
||||
let plaintext = sample(100);
|
||||
let encrypted = enc(&KEY, &plaintext);
|
||||
let encrypted = enc(PASSWORD, &plaintext);
|
||||
|
||||
for pos in 0..encrypted.len() {
|
||||
for bit in [0x01u8, 0x80] {
|
||||
let mut tampered = encrypted.clone();
|
||||
tampered[pos] ^= bit;
|
||||
let result = dec(&KEY, &tampered);
|
||||
|
||||
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 the tag.
|
||||
/*
|
||||
* Everything after the header must fail specifically
|
||||
* on authentication because ciphertext/tag corruption
|
||||
* is detected by the AEAD.
|
||||
*/
|
||||
if pos >= HEADER_LEN {
|
||||
assert!(
|
||||
matches!(result, Err(FileCryptError::AuthenticationFailed)),
|
||||
@@ -131,32 +175,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The AEAD absorbs a partial final ciphertext block zero-padded and
|
||||
/// without a length, so `C || 00` yields the same tag as `C`. The
|
||||
/// authenticated length in the header must stop that.
|
||||
#[test]
|
||||
fn trailing_zero_byte_forgery_is_rejected() {
|
||||
let plaintext = sample(20); // final chunk has 4 bytes
|
||||
let encrypted = enc(&KEY, &plaintext);
|
||||
let plaintext = sample(20);
|
||||
let encrypted = enc(PASSWORD, &plaintext);
|
||||
|
||||
let mut forged = encrypted[..HEADER_LEN + 20].to_vec();
|
||||
|
||||
forged.push(0x00);
|
||||
|
||||
forged.extend_from_slice(&encrypted[HEADER_LEN + 20..]);
|
||||
|
||||
// Without touching the header: structural error.
|
||||
assert!(dec(&KEY, &forged).is_err());
|
||||
// Without modifying the authenticated length,
|
||||
// the file structure is inconsistent.
|
||||
assert!(dec(PASSWORD, &forged).is_err());
|
||||
|
||||
// Changing the authenticated length must also fail because
|
||||
// the header is associated data.
|
||||
forged[56..64].copy_from_slice(&21u64.to_le_bytes());
|
||||
|
||||
// Also bumping the length field: the header is associated data.
|
||||
forged[40..48].copy_from_slice(&21u64.to_le_bytes());
|
||||
assert!(matches!(
|
||||
dec(&KEY, &forged),
|
||||
dec(PASSWORD, &forged),
|
||||
Err(FileCryptError::AuthenticationFailed)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_and_trailing_data() {
|
||||
let encrypted = enc(&KEY, &sample(100));
|
||||
let encrypted = enc(PASSWORD, &sample(100));
|
||||
|
||||
for cut in [
|
||||
0,
|
||||
@@ -167,109 +213,167 @@ mod tests {
|
||||
encrypted.len() - 1,
|
||||
] {
|
||||
assert!(
|
||||
matches!(dec(&KEY, &encrypted[..cut]), Err(FileCryptError::Truncated)),
|
||||
matches!(
|
||||
dec(PASSWORD, &encrypted[..cut]),
|
||||
Err(FileCryptError::Truncated)
|
||||
),
|
||||
"cut at {cut}"
|
||||
);
|
||||
}
|
||||
|
||||
let mut extended = encrypted.clone();
|
||||
|
||||
extended.push(0);
|
||||
|
||||
assert!(matches!(
|
||||
dec(&KEY, &extended),
|
||||
dec(PASSWORD, &extended),
|
||||
Err(FileCryptError::TrailingData)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_and_key_length() {
|
||||
let encrypted = enc(&KEY, &sample(50));
|
||||
fn wrong_password() {
|
||||
let encrypted = enc(PASSWORD, &sample(50));
|
||||
|
||||
let mut other = KEY;
|
||||
other[0] ^= 1;
|
||||
assert!(matches!(
|
||||
dec(&other, &encrypted),
|
||||
dec("wrong password", &encrypted),
|
||||
Err(FileCryptError::AuthenticationFailed)
|
||||
));
|
||||
}
|
||||
|
||||
#[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!(
|
||||
dec(&[0x42u8; 24], &encrypted),
|
||||
dec(PASSWORD, &malformed),
|
||||
Err(FileCryptError::KeyLengthMismatch {
|
||||
header: 16,
|
||||
provided: 24
|
||||
provided: 64
|
||||
})
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
dec(&[0x42u8; 8], &encrypted),
|
||||
Err(FileCryptError::InvalidKeyLength(8))
|
||||
super::check_key(&[0u8; 15]),
|
||||
Err(FileCryptError::InvalidKeyLength(15))
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
encrypt_stream(Cursor::new(b"x"), Vec::new(), &[0u8; 25], 1),
|
||||
Err(FileCryptError::InvalidKeyLength(25))
|
||||
super::check_key(&[0u8; 65]),
|
||||
Err(FileCryptError::InvalidKeyLength(65))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_detects_length_change() {
|
||||
let too_short = encrypt_stream(Cursor::new(vec![0u8; 10]), Vec::new(), &KEY, 11);
|
||||
let too_short = encrypt_stream(Cursor::new(vec![0u8; 10]), Vec::new(), PASSWORD, 11);
|
||||
|
||||
assert!(matches!(too_short, Err(FileCryptError::InputLengthChanged)));
|
||||
|
||||
let too_long = encrypt_stream(Cursor::new(vec![0u8; 12]), Vec::new(), &KEY, 11);
|
||||
let too_long = encrypt_stream(Cursor::new(vec![0u8; 12]), Vec::new(), PASSWORD, 11);
|
||||
|
||||
assert!(matches!(too_long, Err(FileCryptError::InputLengthChanged)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_headers() {
|
||||
assert!(matches!(
|
||||
dec(&KEY, &[0u8; HEADER_LEN]),
|
||||
dec(PASSWORD, &[0u8; HEADER_LEN]),
|
||||
Err(FileCryptError::BadMagic)
|
||||
));
|
||||
|
||||
let mut bad_version = enc(&KEY, b"x");
|
||||
bad_version[4] = 2;
|
||||
let mut bad_version = enc(PASSWORD, b"x");
|
||||
|
||||
bad_version[4] = 3;
|
||||
|
||||
assert!(matches!(
|
||||
dec(&KEY, &bad_version),
|
||||
Err(FileCryptError::UnsupportedVersion(2))
|
||||
dec(PASSWORD, &bad_version),
|
||||
Err(FileCryptError::UnsupportedVersion(3))
|
||||
));
|
||||
|
||||
// Absurd declared length on a tiny file: must fail, not allocate.
|
||||
let mut huge = enc(&KEY, b"x");
|
||||
huge[40..48].copy_from_slice(&u64::MAX.to_le_bytes());
|
||||
assert!(matches!(dec(&KEY, &huge), Err(FileCryptError::Truncated)));
|
||||
/*
|
||||
* Absurd declared length on a tiny file:
|
||||
* decryption must fail without trying to allocate
|
||||
* based on that value.
|
||||
*/
|
||||
let mut huge = enc(PASSWORD, b"x");
|
||||
|
||||
huge[56..64].copy_from_slice(&u64::MAX.to_le_bytes());
|
||||
|
||||
assert!(matches!(
|
||||
dec(PASSWORD, &huge),
|
||||
Err(FileCryptError::Truncated)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salt_is_authenticated() {
|
||||
let encrypted = enc(PASSWORD, b"hello");
|
||||
|
||||
let mut tampered = encrypted.clone();
|
||||
|
||||
// Change one bit of the salt.
|
||||
tampered[40] ^= 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);
|
||||
|
||||
fs::write(&plain, &data).unwrap();
|
||||
|
||||
encrypt_file(&plain, &encrypted, &KEY).unwrap();
|
||||
decrypt_file(&encrypted, &decrypted, &KEY).unwrap();
|
||||
encrypt_file(&plain, &encrypted, PASSWORD).unwrap();
|
||||
|
||||
decrypt_file(&encrypted, &decrypted, PASSWORD).unwrap();
|
||||
|
||||
assert_eq!(fs::read(&decrypted).unwrap(), data);
|
||||
|
||||
// Corrupt one ciphertext byte.
|
||||
let mut bytes = fs::read(&encrypted).unwrap();
|
||||
|
||||
bytes[HEADER_LEN + 1000] ^= 1;
|
||||
|
||||
let corrupted = dir.path().join("corrupted.kaef");
|
||||
|
||||
fs::write(&corrupted, &bytes).unwrap();
|
||||
|
||||
// A failed decryption must not create the output
|
||||
// A failed decryption must not create
|
||||
// the output.
|
||||
let out_new = dir.path().join("out_new.bin");
|
||||
|
||||
assert!(matches!(
|
||||
decrypt_file(&corrupted, &out_new, &KEY),
|
||||
decrypt_file(&corrupted, &out_new, PASSWORD,),
|
||||
Err(FileCryptError::AuthenticationFailed)
|
||||
));
|
||||
|
||||
assert!(!out_new.exists());
|
||||
|
||||
// nor clobber an existing file.
|
||||
// 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, &KEY).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.
|
||||
@@ -278,23 +382,31 @@ mod tests {
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.ends_with(".tmp"))
|
||||
.collect();
|
||||
|
||||
assert!(leftovers.is_empty(), "leftover temp files: {leftovers:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_roundtrip() {
|
||||
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();
|
||||
|
||||
encrypt_file(&plain, &encrypted, &KEY).unwrap();
|
||||
encrypt_file(&plain, &encrypted, PASSWORD).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::metadata(&encrypted).unwrap().len() as usize,
|
||||
HEADER_LEN + KEY.len()
|
||||
HEADER_LEN + DERIVED_KEY_LEN
|
||||
);
|
||||
decrypt_file(&encrypted, &decrypted, &KEY).unwrap();
|
||||
|
||||
decrypt_file(&encrypted, &decrypted, PASSWORD).unwrap();
|
||||
|
||||
assert_eq!(fs::read(&decrypted).unwrap(), b"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
use super::compress::{Block, compress_block};
|
||||
use super::hash::{block_to_bytes, hash_long, initial_block, initial_hash};
|
||||
use super::kdf::ARGON2ID_TYPE;
|
||||
use super::{Argon2Params, KdfError};
|
||||
|
||||
/// Core Argon2id function.
|
||||
///
|
||||
/// This implements Argon2id v1.3 using BLAKE2b for H and H'.
|
||||
pub(super) fn argon2id(
|
||||
password: &[u8],
|
||||
salt: &[u8],
|
||||
params: Argon2Params,
|
||||
output_len: usize,
|
||||
) -> Result<Vec<u8>, KdfError> {
|
||||
if params.time_cost == 0 {
|
||||
return Err(KdfError::InvalidTimeCost);
|
||||
}
|
||||
|
||||
if params.parallelism == 0 {
|
||||
return Err(KdfError::InvalidParallelism);
|
||||
}
|
||||
|
||||
let parallelism = params.parallelism as usize;
|
||||
let requested_memory = params.memory_kib as usize;
|
||||
|
||||
/*
|
||||
* Argon2 requires at least 8 KiB per lane.
|
||||
*/
|
||||
if requested_memory < 8 * parallelism {
|
||||
return Err(KdfError::InvalidMemoryCost);
|
||||
}
|
||||
|
||||
/*
|
||||
* The actual memory size is rounded down to a multiple of 4 * parallelism.
|
||||
*/
|
||||
let memory_kib = 4 * parallelism * (requested_memory / (4 * parallelism));
|
||||
|
||||
/*
|
||||
* Number of blocks in each lane.
|
||||
*/
|
||||
let lane_length = memory_kib / parallelism;
|
||||
|
||||
/*
|
||||
* Four segments per lane.
|
||||
*/
|
||||
let segment_length = lane_length / 4;
|
||||
|
||||
/*
|
||||
* Initial hash H_0.
|
||||
*/
|
||||
let h0 = initial_hash(password, salt, params, memory_kib as u32, output_len);
|
||||
|
||||
/*
|
||||
* Allocate the complete Argon2 memory matrix.
|
||||
*
|
||||
* Layout:
|
||||
*
|
||||
* lane 0: [block][block][block]...
|
||||
* lane 1: [block][block][block]...
|
||||
* ...
|
||||
*/
|
||||
let mut memory = vec![Block::ZERO; memory_kib];
|
||||
|
||||
/*
|
||||
* Initialize the first two blocks of every lane:
|
||||
*
|
||||
* B[i][0] = H'(H_0 || LE32(0) || LE32(i))
|
||||
* B[i][1] = H'(H_0 || LE32(1) || LE32(i))
|
||||
*/
|
||||
for lane in 0..parallelism {
|
||||
memory[lane * lane_length] = initial_block(&h0, 0, lane);
|
||||
|
||||
memory[lane * lane_length + 1] = initial_block(&h0, 1, lane);
|
||||
}
|
||||
|
||||
/*
|
||||
* Fill the memory matrix.
|
||||
*
|
||||
* Argon2id:
|
||||
*
|
||||
* - pass 0, slices 0 and 1 use Argon2i style data independent addressing;
|
||||
* - everything else uses Argon2d style addressing.
|
||||
*/
|
||||
for pass in 0..params.time_cost as usize {
|
||||
for slice in 0..4 {
|
||||
for lane in 0..parallelism {
|
||||
process_segment(
|
||||
&mut memory,
|
||||
lane_length,
|
||||
segment_length,
|
||||
parallelism,
|
||||
pass,
|
||||
slice,
|
||||
lane,
|
||||
params.time_cost as usize,
|
||||
memory_kib as u64,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Final block:
|
||||
*
|
||||
* C = B[0][q-1] XOR B[1][q-1] XOR ... XOR B[p-1][q-1]
|
||||
*/
|
||||
let mut final_block = Block::ZERO;
|
||||
|
||||
for lane in 0..parallelism {
|
||||
let offset = lane * lane_length + lane_length - 1;
|
||||
|
||||
for (dst, src) in final_block.0.iter_mut().zip(memory[offset].0.iter()) {
|
||||
*dst ^= *src;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Final Argon2 output:
|
||||
*
|
||||
* H'(C, output_len)
|
||||
*/
|
||||
let final_bytes = block_to_bytes(&final_block);
|
||||
|
||||
Ok(hash_long(&final_bytes, output_len))
|
||||
}
|
||||
|
||||
/// Process one Argon2 segment.
|
||||
fn process_segment(
|
||||
memory: &mut [Block],
|
||||
lane_length: usize,
|
||||
segment_length: usize,
|
||||
parallelism: usize,
|
||||
pass: usize,
|
||||
slice: usize,
|
||||
lane: usize,
|
||||
total_time: usize,
|
||||
memory_kib: u64,
|
||||
) {
|
||||
/*
|
||||
* Argon2id uses data-independent addressing only during the first two slices of the first pass.
|
||||
*/
|
||||
let data_independent = pass == 0 && slice < 2;
|
||||
|
||||
let mut address_block = Block::ZERO;
|
||||
let mut input_block = Block::ZERO;
|
||||
|
||||
/*
|
||||
* Position within this segment.
|
||||
*/
|
||||
let mut index = 0usize;
|
||||
|
||||
if data_independent {
|
||||
input_block.0[0] = pass as u64;
|
||||
input_block.0[1] = lane as u64;
|
||||
input_block.0[2] = slice as u64;
|
||||
input_block.0[3] = memory_kib;
|
||||
input_block.0[4] = total_time as u64;
|
||||
input_block.0[5] = ARGON2ID_TYPE as u64;
|
||||
|
||||
/*
|
||||
* In the first segment, blocks 0 and 1 already exist, so generation starts at block 2.
|
||||
*/
|
||||
if pass == 0 && slice == 0 {
|
||||
input_block.0[6] += 1;
|
||||
|
||||
address_block = compress_block(&input_block, &Block::ZERO, None);
|
||||
|
||||
address_block = compress_block(&address_block, &Block::ZERO, None);
|
||||
|
||||
index = 2;
|
||||
}
|
||||
}
|
||||
|
||||
while index < segment_length {
|
||||
let current_offset = lane * lane_length + slice * segment_length + index;
|
||||
|
||||
/*
|
||||
* Previous block in this lane.
|
||||
*
|
||||
* At the beginning of a lane, the previous block is the final block of the same lane.
|
||||
*/
|
||||
let previous_offset = if slice == 0 && index == 0 {
|
||||
current_offset + lane_length - 1
|
||||
} else {
|
||||
current_offset - 1
|
||||
};
|
||||
|
||||
/*
|
||||
* Obtain J_1/J_2.
|
||||
*/
|
||||
let pseudo_random = if data_independent {
|
||||
/*
|
||||
* Generate a new address block every 128 positions.
|
||||
*/
|
||||
if index.is_multiple_of(128) {
|
||||
input_block.0[6] += 1;
|
||||
|
||||
address_block = compress_block(&input_block, &Block::ZERO, None);
|
||||
|
||||
address_block = compress_block(&address_block, &Block::ZERO, None);
|
||||
}
|
||||
|
||||
address_block.0[index % 128]
|
||||
} else {
|
||||
/*
|
||||
* Argon2d addressing uses the first word of the previous block.
|
||||
*/
|
||||
memory[previous_offset].0[0]
|
||||
};
|
||||
|
||||
/*
|
||||
* Select the reference block.
|
||||
*/
|
||||
let reference_offset = index_alpha(
|
||||
pseudo_random,
|
||||
lane_length,
|
||||
segment_length,
|
||||
parallelism,
|
||||
pass,
|
||||
slice,
|
||||
lane,
|
||||
index,
|
||||
);
|
||||
|
||||
/*
|
||||
* Later passes XOR the newly generated block with the block that was already present at this position.
|
||||
*/
|
||||
let previous_block = memory[previous_offset];
|
||||
|
||||
let reference_block = memory[reference_offset];
|
||||
|
||||
let old_block = if pass == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(&memory[current_offset])
|
||||
};
|
||||
|
||||
let new_block = compress_block(&previous_block, &reference_block, old_block);
|
||||
|
||||
memory[current_offset] = new_block;
|
||||
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the reference block for an Argon2 block.
|
||||
///
|
||||
/// This implements the Argon2 mapping from J_1/J_2 to the reference lane and reference position.
|
||||
fn index_alpha(
|
||||
random: u64,
|
||||
lane_length: usize,
|
||||
segment_length: usize,
|
||||
parallelism: usize,
|
||||
pass: usize,
|
||||
slice: usize,
|
||||
lane: usize,
|
||||
index: usize,
|
||||
) -> usize {
|
||||
/*
|
||||
* J_2 selects the reference lane.
|
||||
*/
|
||||
let mut reference_lane = ((random >> 32) as usize) % parallelism;
|
||||
|
||||
/*
|
||||
* During the first slice of the first pass, references must remain in the same lane.
|
||||
*/
|
||||
if pass == 0 && slice == 0 {
|
||||
reference_lane = lane;
|
||||
}
|
||||
|
||||
/*
|
||||
* Compute the size of the allowed reference area.
|
||||
*/
|
||||
let mut reference_area_size = 3 * segment_length;
|
||||
|
||||
let mut start_position = ((slice + 1) % 4) * segment_length;
|
||||
|
||||
if lane == reference_lane {
|
||||
reference_area_size += index;
|
||||
}
|
||||
|
||||
if pass == 0 {
|
||||
reference_area_size = slice * segment_length;
|
||||
|
||||
start_position = 0;
|
||||
|
||||
if slice == 0 || lane == reference_lane {
|
||||
reference_area_size += index;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The current block cannot reference itself.
|
||||
*/
|
||||
if index == 0 || lane == reference_lane {
|
||||
reference_area_size -= 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* Non-uniform mapping:
|
||||
*
|
||||
* x = J_1^2 / 2^32
|
||||
* y = |W| * x / 2^32
|
||||
*/
|
||||
let mut x = (random as u32) as u64;
|
||||
|
||||
x = (x * x) >> 32;
|
||||
|
||||
let y = (x * reference_area_size as u64) >> 32;
|
||||
|
||||
/*
|
||||
* Map into the selected lane.
|
||||
*/
|
||||
let relative_position = (start_position + reference_area_size - (y as usize + 1)) % lane_length;
|
||||
|
||||
reference_lane * lane_length + relative_position
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/// One Argon2 memory block is 1024 bytes = 128 × u64.
|
||||
pub(super) const BLOCK_WORDS: usize = 128;
|
||||
|
||||
/// One Argon2 memory block is 1024 bytes.
|
||||
pub(super) const BLOCK_SIZE: usize = 1024;
|
||||
|
||||
/// A single 1024-byte Argon2 memory block.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Block(pub(super) [u64; BLOCK_WORDS]);
|
||||
|
||||
impl Block {
|
||||
pub(super) const ZERO: Self = Self([0u64; BLOCK_WORDS]);
|
||||
}
|
||||
|
||||
/// Argon2 compression function G.
|
||||
///
|
||||
/// R = X XOR Y
|
||||
/// Z = P(R)
|
||||
/// output = Z XOR R
|
||||
///
|
||||
/// On passes after the first, the old contents of the destination block are also XORed into the result.
|
||||
pub(super) fn compress_block(previous: &Block, reference: &Block, old: Option<&Block>) -> Block {
|
||||
let mut r = [0u64; BLOCK_WORDS];
|
||||
|
||||
for (dst, (&a, &b)) in r.iter_mut().zip(previous.0.iter().zip(reference.0.iter())) {
|
||||
*dst = a ^ b;
|
||||
}
|
||||
|
||||
/*
|
||||
* P(R), first over rows and then over columns.
|
||||
*/
|
||||
let mut z = r;
|
||||
|
||||
/*
|
||||
* Row-wise permutation.
|
||||
*/
|
||||
for row in 0..8 {
|
||||
let start = row * 16;
|
||||
|
||||
let mut words = [0u64; 16];
|
||||
|
||||
words.copy_from_slice(&z[start..start + 16]);
|
||||
|
||||
round(&mut words);
|
||||
|
||||
z[start..start + 16].copy_from_slice(&words);
|
||||
}
|
||||
|
||||
/*
|
||||
* Column-wise permutation.
|
||||
*
|
||||
* Each permutation operates on two adjacent 64-bit
|
||||
* columns from each of the eight rows.
|
||||
*/
|
||||
for column_pair in 0..8 {
|
||||
let mut words = [0u64; 16];
|
||||
|
||||
for row in 0..8 {
|
||||
let source = row * 16 + column_pair * 2;
|
||||
|
||||
words[row * 2] = z[source];
|
||||
|
||||
words[row * 2 + 1] = z[source + 1];
|
||||
}
|
||||
|
||||
round(&mut words);
|
||||
|
||||
for row in 0..8 {
|
||||
let destination = row * 16 + column_pair * 2;
|
||||
|
||||
z[destination] = words[row * 2];
|
||||
|
||||
z[destination + 1] = words[row * 2 + 1];
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = [0u64; BLOCK_WORDS];
|
||||
|
||||
for i in 0..BLOCK_WORDS {
|
||||
output[i] = z[i] ^ r[i] ^ old.map_or(0, |block| block.0[i]);
|
||||
}
|
||||
|
||||
Block(output)
|
||||
}
|
||||
|
||||
/// The Argon2/BLAKE2b quarter-round.
|
||||
///
|
||||
/// This is the BlaMka operation used by Argon2.
|
||||
#[inline(always)]
|
||||
fn g(v: &mut [u64; 16], a: usize, b: usize, c: usize, d: usize) {
|
||||
v[a] = blamka(v[a], v[b]);
|
||||
|
||||
v[d] = (v[d] ^ v[a]).rotate_right(32);
|
||||
|
||||
v[c] = blamka(v[c], v[d]);
|
||||
|
||||
v[b] = (v[b] ^ v[c]).rotate_right(24);
|
||||
|
||||
v[a] = blamka(v[a], v[b]);
|
||||
|
||||
v[d] = (v[d] ^ v[a]).rotate_right(16);
|
||||
|
||||
v[c] = blamka(v[c], v[d]);
|
||||
|
||||
v[b] = (v[b] ^ v[c]).rotate_right(63);
|
||||
}
|
||||
|
||||
/// BlaMka addition:
|
||||
///
|
||||
/// a + b + 2 * low32(a) * low32(b) mod 2^64.
|
||||
#[inline(always)]
|
||||
fn blamka(a: u64, b: u64) -> u64 {
|
||||
let product = 2u64
|
||||
.wrapping_mul(a & 0xffff_ffff)
|
||||
.wrapping_mul(b & 0xffff_ffff);
|
||||
|
||||
a.wrapping_add(b).wrapping_add(product)
|
||||
}
|
||||
|
||||
/// One BLAKE2b style 16 word round used by Argon2.
|
||||
#[inline(always)]
|
||||
fn round(v: &mut [u64; 16]) {
|
||||
// Column step.
|
||||
g(v, 0, 4, 8, 12);
|
||||
g(v, 1, 5, 9, 13);
|
||||
g(v, 2, 6, 10, 14);
|
||||
g(v, 3, 7, 11, 15);
|
||||
|
||||
// Diagonal step.
|
||||
g(v, 0, 5, 10, 15);
|
||||
g(v, 1, 6, 11, 12);
|
||||
g(v, 2, 7, 8, 13);
|
||||
g(v, 3, 4, 9, 14);
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
use crate::blake2b::Blake2b;
|
||||
|
||||
use super::Argon2Params;
|
||||
use super::compress::{BLOCK_SIZE, BLOCK_WORDS, Block};
|
||||
use super::kdf::{ARGON2_VERSION, ARGON2ID_TYPE};
|
||||
|
||||
/// Compute the Argon2 initial hash H_0.
|
||||
///
|
||||
/// For our password KDF, the optional secret and associated-data
|
||||
/// inputs are both empty, but their zero lengths are still encoded
|
||||
/// because they are part of the Argon2 parameter structure.
|
||||
pub(super) fn initial_hash(
|
||||
password: &[u8],
|
||||
salt: &[u8],
|
||||
params: Argon2Params,
|
||||
memory_kib: u32,
|
||||
output_len: usize,
|
||||
) -> [u8; 64] {
|
||||
let mut input = Vec::with_capacity(6 * 4 + 4 + password.len() + 4 + salt.len() + 4 + 4);
|
||||
|
||||
// p
|
||||
append_u32(&mut input, params.parallelism);
|
||||
|
||||
// T
|
||||
append_u32(&mut input, output_len as u32);
|
||||
|
||||
// m'
|
||||
append_u32(&mut input, memory_kib);
|
||||
|
||||
// t
|
||||
append_u32(&mut input, params.time_cost);
|
||||
|
||||
// version
|
||||
append_u32(&mut input, ARGON2_VERSION);
|
||||
|
||||
// type = Argon2id
|
||||
append_u32(&mut input, ARGON2ID_TYPE);
|
||||
|
||||
// |P| and P
|
||||
append_u32(&mut input, password.len() as u32);
|
||||
input.extend_from_slice(password);
|
||||
|
||||
// |S| and S
|
||||
append_u32(&mut input, salt.len() as u32);
|
||||
input.extend_from_slice(salt);
|
||||
|
||||
// |K| = 0
|
||||
append_u32(&mut input, 0);
|
||||
|
||||
// |X| = 0
|
||||
append_u32(&mut input, 0);
|
||||
|
||||
let digest = Blake2b::hash(&input);
|
||||
|
||||
let mut output = [0u8; 64];
|
||||
output.copy_from_slice(&digest);
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// Compute one of the two initial memory blocks.
|
||||
pub(super) fn initial_block(h0: &[u8; 64], index: usize, lane: usize) -> Block {
|
||||
let mut input = Vec::with_capacity(72);
|
||||
|
||||
input.extend_from_slice(h0);
|
||||
|
||||
append_u32(&mut input, index as u32);
|
||||
|
||||
append_u32(&mut input, lane as u32);
|
||||
|
||||
hash_long_block(&input)
|
||||
}
|
||||
|
||||
/// Argon2 variable-length hash H'.
|
||||
///
|
||||
/// This is the H' construction specified by Argon2.
|
||||
/// It ultimately uses the project's BLAKE2b implementation.
|
||||
pub(super) fn hash_long(input: &[u8], output_len: usize) -> Vec<u8> {
|
||||
assert!(output_len >= 1);
|
||||
|
||||
let mut prefixed = Vec::with_capacity(4 + input.len());
|
||||
|
||||
append_u32(&mut prefixed, output_len as u32);
|
||||
|
||||
prefixed.extend_from_slice(input);
|
||||
|
||||
/*
|
||||
* For T <= 64:
|
||||
*
|
||||
* H'(T || A) = BLAKE2b-T(T || A)
|
||||
*/
|
||||
if output_len <= 64 {
|
||||
return Blake2b::hash_with_output_length(&prefixed, output_len)
|
||||
.expect("output length <= 64");
|
||||
}
|
||||
|
||||
/*
|
||||
* For T > 64:
|
||||
*
|
||||
* r = ceil(T / 32) - 2
|
||||
*
|
||||
* V_1 = H^64(T || A)
|
||||
* V_i = H^64(V_{i-1})
|
||||
* final = H^(T - 32*r)(V_r)
|
||||
*/
|
||||
let r = output_len.div_ceil(32) - 2;
|
||||
|
||||
let mut output = Vec::with_capacity(output_len);
|
||||
|
||||
let mut v = Blake2b::hash(&prefixed);
|
||||
|
||||
output.extend_from_slice(&v[..32]);
|
||||
|
||||
for _ in 1..r {
|
||||
v = Blake2b::hash(&v);
|
||||
output.extend_from_slice(&v[..32]);
|
||||
}
|
||||
|
||||
let final_len = output_len - 32 * r;
|
||||
|
||||
v = Blake2b::hash_with_output_length(&v, final_len).expect("final H' output length is valid");
|
||||
|
||||
output.extend_from_slice(&v);
|
||||
|
||||
debug_assert_eq!(output.len(), output_len);
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// Compute H'(input, 1024).
|
||||
fn hash_long_block(input: &[u8]) -> Block {
|
||||
let digest = hash_long(input, BLOCK_SIZE);
|
||||
|
||||
let mut words = [0u64; BLOCK_WORDS];
|
||||
|
||||
for (i, word) in words.iter_mut().enumerate() {
|
||||
let start = i * 8;
|
||||
|
||||
*word = u64::from_le_bytes(
|
||||
digest[start..start + 8]
|
||||
.try_into()
|
||||
.expect("Argon2 block is 1024 bytes"),
|
||||
);
|
||||
}
|
||||
|
||||
Block(words)
|
||||
}
|
||||
|
||||
/// Serialize an Argon2 block in little-endian order.
|
||||
pub(super) fn block_to_bytes(block: &Block) -> Vec<u8> {
|
||||
let mut output = Vec::with_capacity(BLOCK_SIZE);
|
||||
|
||||
for word in block.0 {
|
||||
output.extend_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// Append a little-endian u32.
|
||||
#[inline]
|
||||
fn append_u32(output: &mut Vec<u8>, value: u32) {
|
||||
output.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
+88
-13
@@ -1,24 +1,99 @@
|
||||
//! Password-to-key derivation.
|
||||
//! PBKDF using Argon2id.
|
||||
//!
|
||||
//! This currently uses SHA3-512 directly
|
||||
//! Argon2id version 1.3 using the project's BLAKE2b implementation.
|
||||
//!
|
||||
//! This is not a proper password-based KDF. There is currently no salt,
|
||||
//! work factor, memory hardness, or iteration count.
|
||||
//! The password is encoded as UTF-8 and combined with a random,
|
||||
//! per file salt. The salt is not secret and must be stored alongside
|
||||
//! the encrypted file so that the same key can be derived during
|
||||
//! decryption.
|
||||
//!
|
||||
//! Default parameters:
|
||||
//! - Memory: 64 MiB
|
||||
//! - Passes: 3
|
||||
//! - Parallelism: 4 lanes
|
||||
//! - Output: 64 bytes
|
||||
//!
|
||||
//! These defaults follow the memory-constrained recommendation from
|
||||
//! RFC 9106. The implementation itself is single-threaded; the
|
||||
//! `parallelism` parameter is still part of the Argon2 computation.
|
||||
|
||||
use crate::keccak::sha3_512;
|
||||
use super::argon2::argon2id;
|
||||
|
||||
/// Length of the key produced for `KeccakAead`.
|
||||
pub const DERIVED_KEY_LEN: usize = 64;
|
||||
|
||||
/// Derive a 64-byte encryption key from a password.
|
||||
/// Argon2 salt length.
|
||||
///
|
||||
/// The password is encoded as UTF-8 and hashed using the project's
|
||||
/// SHA3-512 implementation.
|
||||
pub fn derive_file_key(password: &str) -> [u8; DERIVED_KEY_LEN] {
|
||||
let digest = sha3_512(password.as_bytes());
|
||||
/// RFC 9106 recommends a 16-byte salt.
|
||||
pub const SALT_LEN: usize = 16;
|
||||
|
||||
let mut key = [0u8; DERIVED_KEY_LEN];
|
||||
key.copy_from_slice(&digest[..DERIVED_KEY_LEN]);
|
||||
/// Default Argon2id memory cost in KiB.
|
||||
///
|
||||
/// 64 MiB.
|
||||
pub const ARGON2_MEMORY_KIB: u32 = 64 * 1024;
|
||||
|
||||
key
|
||||
/// Default Argon2id time cost.
|
||||
pub const ARGON2_TIME_COST: u32 = 3;
|
||||
|
||||
/// Default Argon2id parallelism.
|
||||
pub const ARGON2_PARALLELISM: u32 = 4;
|
||||
|
||||
/// Argon2 version 1.3.
|
||||
pub(super) const ARGON2_VERSION: u32 = 0x13;
|
||||
|
||||
/// Argon2id type identifier.
|
||||
pub(super) const ARGON2ID_TYPE: u32 = 2;
|
||||
|
||||
/// Argon2 parameter set.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Argon2Params {
|
||||
/// Memory cost in KiB.
|
||||
pub memory_kib: u32,
|
||||
|
||||
/// Number of passes.
|
||||
pub time_cost: u32,
|
||||
|
||||
/// Number of lanes.
|
||||
pub parallelism: u32,
|
||||
}
|
||||
|
||||
impl Default for Argon2Params {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
memory_kib: ARGON2_MEMORY_KIB,
|
||||
time_cost: ARGON2_TIME_COST,
|
||||
parallelism: ARGON2_PARALLELISM,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KdfError {
|
||||
InvalidMemoryCost,
|
||||
InvalidTimeCost,
|
||||
InvalidParallelism,
|
||||
}
|
||||
|
||||
/// Derive a 64-byte encryption key from a password and a 16-byte salt.
|
||||
///
|
||||
/// The salt is randomly generated for each encrypted file and stored in its header. It is not secret.
|
||||
pub fn derive_file_key(password: &str, salt: &[u8; SALT_LEN]) -> [u8; DERIVED_KEY_LEN] {
|
||||
derive_file_key_with_params(password, salt, Argon2Params::default())
|
||||
.expect("default Argon2 parameters are valid")
|
||||
}
|
||||
|
||||
/// Derive a 64-byte key with explicit Argon2 parameters.
|
||||
///
|
||||
/// This is mainly useful for testing and benchmarking.
|
||||
pub fn derive_file_key_with_params(
|
||||
password: &str,
|
||||
salt: &[u8; SALT_LEN],
|
||||
params: Argon2Params,
|
||||
) -> Result<[u8; DERIVED_KEY_LEN], KdfError> {
|
||||
let digest = argon2id(password.as_bytes(), salt, params, DERIVED_KEY_LEN)?;
|
||||
|
||||
Ok(digest
|
||||
.try_into()
|
||||
.expect("Argon2 output length must be DERIVED_KEY_LEN"))
|
||||
}
|
||||
+7
-1
@@ -1,6 +1,12 @@
|
||||
mod argon2;
|
||||
mod compress;
|
||||
mod hash;
|
||||
mod kdf;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
|
||||
pub use kdf::derive_file_key;
|
||||
pub use kdf::{
|
||||
ARGON2_MEMORY_KIB, ARGON2_PARALLELISM, ARGON2_TIME_COST, Argon2Params, DERIVED_KEY_LEN,
|
||||
KdfError, SALT_LEN, derive_file_key, derive_file_key_with_params,
|
||||
};
|
||||
+296
-13
@@ -1,26 +1,309 @@
|
||||
use super::*;
|
||||
use crate::kdf::kdf::DERIVED_KEY_LEN;
|
||||
|
||||
#[test]
|
||||
fn key_has_expected_length() {
|
||||
assert_eq!(DERIVED_KEY_LEN, 64);
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
let key = derive_file_key("test password");
|
||||
assert_eq!(key.len(), DERIVED_KEY_LEN);
|
||||
fn salt() -> [u8; SALT_LEN] {
|
||||
*b"0123456789abcdef"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_password_same_key() {
|
||||
let a = derive_file_key("correct horse battery staple");
|
||||
let b = derive_file_key("correct horse battery staple");
|
||||
fn argon2id_known_vector() {
|
||||
/*
|
||||
* Password: "abc"
|
||||
* Salt: "0123456789abcdef"
|
||||
* Memory: 32 KiB
|
||||
* Passes: 3
|
||||
* Parallelism: 4
|
||||
* Output: 64 bytes
|
||||
* Version: 1.3
|
||||
* Type: Argon2id
|
||||
*/
|
||||
let key = derive_file_key_with_params(
|
||||
"abc",
|
||||
&salt(),
|
||||
Argon2Params {
|
||||
memory_kib: 32,
|
||||
time_cost: 3,
|
||||
parallelism: 4,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(
|
||||
hex(&key),
|
||||
"db750d0e8a56f66f4b70c0b24ee453c03f35c90edfa90715b203396b0be4bd4c\
|
||||
ed0884204bbb1e5221db12de6554cdb3b7624f9bbf3336b7797a90871be1f11a"
|
||||
.replace(' ', "")
|
||||
.replace('\n', "")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn argon2id_empty_password() {
|
||||
let key = derive_file_key_with_params(
|
||||
"",
|
||||
&salt(),
|
||||
Argon2Params {
|
||||
memory_kib: 32,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
hex(&key),
|
||||
"9a32bc5205d518e7692d11451a825efd8c6a6dcb5cfaa1d511c08f3961a701ff\
|
||||
a0e9bfc5163d4e90af944ec81a2fbc37afc2e6ed04807e96f66cd7737cb037c4"
|
||||
.replace(' ', "")
|
||||
.replace('\n', "")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_passwords_produce_different_keys() {
|
||||
let a = derive_file_key("password1");
|
||||
let b = derive_file_key("password2");
|
||||
let salt = salt();
|
||||
|
||||
assert_ne!(a, b);
|
||||
let key1 = derive_file_key_with_params(
|
||||
"password1",
|
||||
&salt,
|
||||
Argon2Params {
|
||||
memory_kib: 32,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let key2 = derive_file_key_with_params(
|
||||
"password2",
|
||||
&salt,
|
||||
Argon2Params {
|
||||
memory_kib: 32,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_salts_produce_different_keys() {
|
||||
let salt1 = [0x01u8; SALT_LEN];
|
||||
let salt2 = [0x02u8; SALT_LEN];
|
||||
|
||||
let key1 = derive_file_key_with_params(
|
||||
"password",
|
||||
&salt1,
|
||||
Argon2Params {
|
||||
memory_kib: 32,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let key2 = derive_file_key_with_params(
|
||||
"password",
|
||||
&salt2,
|
||||
Argon2Params {
|
||||
memory_kib: 32,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_password_and_salt_are_deterministic() {
|
||||
let salt = salt();
|
||||
|
||||
let params = Argon2Params {
|
||||
memory_kib: 32,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
let key1 = derive_file_key_with_params("password", &salt, params).unwrap();
|
||||
|
||||
let key2 = derive_file_key_with_params("password", &salt, params).unwrap();
|
||||
|
||||
assert_eq!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_password_is_supported() {
|
||||
let salt = salt();
|
||||
|
||||
let key = derive_file_key_with_params(
|
||||
"mot de passe 🔐",
|
||||
&salt,
|
||||
Argon2Params {
|
||||
memory_kib: 32,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(key.len(), DERIVED_KEY_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_always_64_bytes() {
|
||||
let salt = salt();
|
||||
|
||||
let key = derive_file_key_with_params(
|
||||
"password",
|
||||
&salt,
|
||||
Argon2Params {
|
||||
memory_kib: 32,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(key.len(), DERIVED_KEY_LEN);
|
||||
assert_eq!(DERIVED_KEY_LEN, 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_time_cost() {
|
||||
let result = derive_file_key_with_params(
|
||||
"password",
|
||||
&salt(),
|
||||
Argon2Params {
|
||||
memory_kib: 32,
|
||||
time_cost: 0,
|
||||
parallelism: 1,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(result.unwrap_err(), KdfError::InvalidTimeCost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_parallelism() {
|
||||
let result = derive_file_key_with_params(
|
||||
"password",
|
||||
&salt(),
|
||||
Argon2Params {
|
||||
memory_kib: 32,
|
||||
time_cost: 1,
|
||||
parallelism: 0,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(result.unwrap_err(), KdfError::InvalidParallelism);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_memory_cost() {
|
||||
let result = derive_file_key_with_params(
|
||||
"password",
|
||||
&salt(),
|
||||
Argon2Params {
|
||||
memory_kib: 31,
|
||||
time_cost: 1,
|
||||
parallelism: 4,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(result.unwrap_err(), KdfError::InvalidMemoryCost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_memory_cost() {
|
||||
/*
|
||||
* Argon2 requires at least 8 KiB per lane.
|
||||
*
|
||||
* With p = 1, 8 KiB is therefore the minimum valid
|
||||
* memory cost.
|
||||
*/
|
||||
let result = derive_file_key_with_params(
|
||||
"password",
|
||||
&salt(),
|
||||
Argon2Params {
|
||||
memory_kib: 8,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_cost_is_rounded_down() {
|
||||
/*
|
||||
* m' must be a multiple of 4*p.
|
||||
*
|
||||
* 33 KiB with p=1 is therefore rounded to 32 KiB.
|
||||
*/
|
||||
let rounded = derive_file_key_with_params(
|
||||
"password",
|
||||
&salt(),
|
||||
Argon2Params {
|
||||
memory_kib: 33,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let exact = derive_file_key_with_params(
|
||||
"password",
|
||||
&salt(),
|
||||
Argon2Params {
|
||||
memory_kib: 32,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rounded, exact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_parameters_produce_64_byte_key() {
|
||||
/*
|
||||
* This deliberately runs the real production parameters:
|
||||
*
|
||||
* 64 MiB, 3 passes, 4 lanes.
|
||||
*
|
||||
*/
|
||||
let key = derive_file_key("password", &salt());
|
||||
|
||||
assert_eq!(key.len(), DERIVED_KEY_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_parameters_match_reference() {
|
||||
/*
|
||||
* Production parameters:
|
||||
* m = 65536 KiB
|
||||
* t = 3
|
||||
* p = 4
|
||||
* T = 64
|
||||
*
|
||||
* This vector was independently generated with a reference Argon2id implementation.
|
||||
*/
|
||||
let key = derive_file_key("password", &salt());
|
||||
|
||||
assert_eq!(
|
||||
hex(&key),
|
||||
"ceb3d36b4076a38bd44d73139c58916ed33708a99e2941137c64ef51d38d3195\
|
||||
8e2dd94fcbbce2b62aeb049ab43781dc0351fcf3071e7b3cb3031fe0a71cffc9"
|
||||
.replace(' ', "")
|
||||
.replace('\n', "")
|
||||
);
|
||||
}
|
||||
+2
-7
@@ -10,7 +10,6 @@ mod keccak;
|
||||
mod keccak_aead;
|
||||
|
||||
use file_crypt::{decrypt_file, encrypt_file};
|
||||
use kdf::derive_file_key;
|
||||
|
||||
/// Encrypt and decrypt files using KeccakAead.
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -66,9 +65,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
output,
|
||||
password,
|
||||
} => {
|
||||
let key = derive_file_key(&password);
|
||||
|
||||
encrypt_file(&input, &output, &key)?;
|
||||
encrypt_file(&input, &output, &password)?;
|
||||
|
||||
println!("Encrypted '{}' -> '{}'", input.display(), output.display());
|
||||
}
|
||||
@@ -78,9 +75,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
output,
|
||||
password,
|
||||
} => {
|
||||
let key = derive_file_key(&password);
|
||||
|
||||
decrypt_file(&input, &output, &key)?;
|
||||
decrypt_file(&input, &output, &password)?;
|
||||
|
||||
println!("Decrypted '{}' -> '{}'", input.display(), output.display());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user