use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm,
};
use argon2::{Argon2, Params};
use zeroize::Zeroizing;
use crate::error::PqfileError;
use crate::secret::LockedSecret;
pub(crate) const ARGON2_M_COST: u32 = 65536; pub(crate) const ARGON2_T_COST: u32 = 3;
pub(crate) const ARGON2_P_COST: u32 = 4;
const ARGON2_P_COST_LEGACY: u32 = 1;
const SALT_LEN: usize = 16;
const NONCE_LEN: usize = 12;
const SEED_LEN: usize = 64;
const HYBRID_SEED_LEN: usize = 96;
pub const ENCRYPTED_BODY_LEN: usize = SALT_LEN + NONCE_LEN + SEED_LEN + 16;
pub const ENCRYPTED_HYBRID_BODY_LEN: usize = SALT_LEN + NONCE_LEN + HYBRID_SEED_LEN + 16;
fn encrypt_fixed_secret<const N: usize>(
secret: &[u8; N],
passphrase: &str,
body_len: usize,
) -> Result<Vec<u8>, PqfileError> {
let mut salt = [0u8; SALT_LEN];
getrandom::fill(&mut salt).map_err(|_| PqfileError::EncryptionFailure)?;
let key = derive_key(passphrase, &salt)?;
let cipher = Aes256Gcm::new(key.as_ref().try_into().expect("32-byte key"));
let mut nonce_bytes = [0u8; NONCE_LEN];
getrandom::fill(&mut nonce_bytes).map_err(|_| PqfileError::EncryptionFailure)?;
let nonce = nonce_bytes.as_slice().try_into().expect("12-byte nonce");
let ciphertext = cipher
.encrypt(nonce, secret.as_slice())
.map_err(|_| PqfileError::EncryptionFailure)?;
let mut out = Vec::with_capacity(body_len);
out.extend_from_slice(&salt);
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ciphertext);
Ok(out)
}
fn try_decrypt_fixed_secret<const N: usize>(
body: &[u8],
passphrase: &str,
p_cost: u32,
) -> Result<Zeroizing<[u8; N]>, PqfileError> {
let salt = &body[..SALT_LEN];
let nonce_bytes = &body[SALT_LEN..SALT_LEN + NONCE_LEN];
let ciphertext = &body[SALT_LEN + NONCE_LEN..];
let key = derive_key_with_pcost(passphrase, salt, p_cost)?;
let cipher = Aes256Gcm::new(key.as_ref().try_into().expect("32-byte key"));
let nonce = nonce_bytes.try_into().expect("12-byte nonce");
let plaintext = Zeroizing::new(
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| PqfileError::WrongPassphrase)?,
);
if plaintext.len() != N {
return Err(PqfileError::WrongPassphrase);
}
let mut secret = Zeroizing::new([0u8; N]);
secret.copy_from_slice(&plaintext);
Ok(secret)
}
fn decrypt_fixed_secret_with_legacy<const N: usize>(
body: &[u8],
passphrase: &str,
body_len: usize,
) -> Result<Zeroizing<[u8; N]>, PqfileError> {
if body.len() != body_len {
return Err(PqfileError::InvalidKeyLength {
expected: body_len,
got: body.len(),
});
}
if let Ok(secret) = try_decrypt_fixed_secret::<N>(body, passphrase, ARGON2_P_COST) {
return Ok(secret);
}
if try_decrypt_fixed_secret::<N>(body, passphrase, ARGON2_P_COST_LEGACY).is_ok() {
return Err(PqfileError::LegacyKeyFormat);
}
Err(PqfileError::WrongPassphrase)
}
fn decrypt_fixed_secret_legacy<const N: usize>(
body: &[u8],
passphrase: &str,
body_len: usize,
) -> Result<Zeroizing<[u8; N]>, PqfileError> {
if body.len() != body_len {
return Err(PqfileError::InvalidKeyLength {
expected: body_len,
got: body.len(),
});
}
try_decrypt_fixed_secret::<N>(body, passphrase, ARGON2_P_COST_LEGACY)
.map_err(|_| PqfileError::WrongPassphrase)
}
fn decrypt_fixed_secret_no_legacy<const N: usize>(
body: &[u8],
passphrase: &str,
body_len: usize,
) -> Result<Zeroizing<[u8; N]>, PqfileError> {
if body.len() != body_len {
return Err(PqfileError::InvalidKeyLength {
expected: body_len,
got: body.len(),
});
}
try_decrypt_fixed_secret::<N>(body, passphrase, ARGON2_P_COST)
}
pub fn encrypt_seed(seed: &[u8; SEED_LEN], passphrase: &str) -> Result<Vec<u8>, PqfileError> {
encrypt_fixed_secret(seed, passphrase, ENCRYPTED_BODY_LEN)
}
pub fn decrypt_seed(
body: &[u8],
passphrase: &str,
) -> Result<Zeroizing<[u8; SEED_LEN]>, PqfileError> {
decrypt_fixed_secret_with_legacy(body, passphrase, ENCRYPTED_BODY_LEN)
}
pub(crate) fn decrypt_seed_legacy(
body: &[u8],
passphrase: &str,
) -> Result<Zeroizing<[u8; SEED_LEN]>, PqfileError> {
decrypt_fixed_secret_legacy(body, passphrase, ENCRYPTED_BODY_LEN)
}
pub fn encrypt_hybrid_seed(
seed: &[u8; HYBRID_SEED_LEN],
passphrase: &str,
) -> Result<Vec<u8>, PqfileError> {
encrypt_fixed_secret(seed, passphrase, ENCRYPTED_HYBRID_BODY_LEN)
}
pub fn decrypt_hybrid_seed(
body: &[u8],
passphrase: &str,
) -> Result<Zeroizing<[u8; HYBRID_SEED_LEN]>, PqfileError> {
decrypt_fixed_secret_with_legacy(body, passphrase, ENCRYPTED_HYBRID_BODY_LEN)
}
pub(crate) fn decrypt_hybrid_seed_legacy(
body: &[u8],
passphrase: &str,
) -> Result<Zeroizing<[u8; HYBRID_SEED_LEN]>, PqfileError> {
decrypt_fixed_secret_legacy(body, passphrase, ENCRYPTED_HYBRID_BODY_LEN)
}
const SIGNING_SEED_LEN: usize = 32;
pub const ENCRYPTED_SIGNING_BODY_LEN: usize = SALT_LEN + NONCE_LEN + SIGNING_SEED_LEN + 16;
pub fn encrypt_signing_seed(
seed: &[u8; SIGNING_SEED_LEN],
passphrase: &str,
) -> Result<Vec<u8>, PqfileError> {
encrypt_fixed_secret(seed, passphrase, ENCRYPTED_SIGNING_BODY_LEN)
}
pub fn decrypt_signing_seed(
body: &[u8],
passphrase: &str,
) -> Result<Zeroizing<[u8; SIGNING_SEED_LEN]>, PqfileError> {
decrypt_fixed_secret_with_legacy(body, passphrase, ENCRYPTED_SIGNING_BODY_LEN)
}
pub(crate) fn decrypt_signing_seed_legacy(
body: &[u8],
passphrase: &str,
) -> Result<Zeroizing<[u8; SIGNING_SEED_LEN]>, PqfileError> {
decrypt_fixed_secret_legacy(body, passphrase, ENCRYPTED_SIGNING_BODY_LEN)
}
const SLH_SIGNING_SEED_LEN: usize = 72;
pub const ENCRYPTED_SLH_SIGNING_BODY_LEN: usize = SALT_LEN + NONCE_LEN + SLH_SIGNING_SEED_LEN + 16;
pub fn encrypt_slh_signing_seed(
seed: &[u8; SLH_SIGNING_SEED_LEN],
passphrase: &str,
) -> Result<Vec<u8>, PqfileError> {
encrypt_fixed_secret(seed, passphrase, ENCRYPTED_SLH_SIGNING_BODY_LEN)
}
pub fn decrypt_slh_signing_seed(
body: &[u8],
passphrase: &str,
) -> Result<Zeroizing<[u8; SLH_SIGNING_SEED_LEN]>, PqfileError> {
decrypt_fixed_secret_no_legacy(body, passphrase, ENCRYPTED_SLH_SIGNING_BODY_LEN)
}
const IDENTITY_SEED_LEN: usize = 32;
pub const ENCRYPTED_IDENTITY_BODY_LEN: usize = SALT_LEN + NONCE_LEN + IDENTITY_SEED_LEN + 16;
pub fn encrypt_identity_seed(
seed: &[u8; IDENTITY_SEED_LEN],
passphrase: &str,
) -> Result<Vec<u8>, PqfileError> {
encrypt_fixed_secret(seed, passphrase, ENCRYPTED_IDENTITY_BODY_LEN)
}
pub fn decrypt_identity_seed(
body: &[u8],
passphrase: &str,
) -> Result<Zeroizing<[u8; IDENTITY_SEED_LEN]>, PqfileError> {
decrypt_fixed_secret_no_legacy(body, passphrase, ENCRYPTED_IDENTITY_BODY_LEN)
}
fn derive_key(passphrase: &str, salt: &[u8]) -> Result<LockedSecret<32>, PqfileError> {
derive_key_with_pcost(passphrase, salt, ARGON2_P_COST)
}
fn derive_key_with_pcost(
passphrase: &str,
salt: &[u8],
p_cost: u32,
) -> Result<LockedSecret<32>, PqfileError> {
derive_key_with_params(passphrase, salt, ARGON2_M_COST, ARGON2_T_COST, p_cost)
}
pub(crate) fn derive_key_with_params(
passphrase: &str,
salt: &[u8],
m_kib: u32,
t_cost: u32,
p_cost: u32,
) -> Result<LockedSecret<32>, PqfileError> {
derive_key_with_params_and_secret(passphrase, None, salt, m_kib, t_cost, p_cost)
}
pub(crate) fn derive_key_with_params_and_secret(
passphrase: &str,
secret: Option<&[u8]>,
salt: &[u8],
m_kib: u32,
t_cost: u32,
p_cost: u32,
) -> Result<LockedSecret<32>, PqfileError> {
let params =
Params::new(m_kib, t_cost, p_cost, Some(32)).map_err(|_| PqfileError::EncryptionFailure)?;
let argon2 = match secret {
Some(s) => Argon2::new_with_secret(
s,
argon2::Algorithm::Argon2id,
argon2::Version::V0x13,
params,
)
.map_err(|_| PqfileError::EncryptionFailure)?,
None => Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params),
};
let mut key = LockedSecret::<32>::zeroed();
argon2
.hash_password_into(passphrase.as_bytes(), salt, key.as_mut())
.map_err(|_| PqfileError::EncryptionFailure)?;
Ok(key)
}
pub(crate) fn keyfile_secret(keyfile: &[u8]) -> LockedSecret<32> {
use sha3::{Digest, Sha3_256};
let mut hasher = Sha3_256::new();
hasher.update(b"pqfile-keyfile-v1");
hasher.update(keyfile);
let mut out = LockedSecret::<32>::zeroed();
out.copy_from_slice(&hasher.finalize());
out
}
pub(crate) fn fido2_secret(hmac_secret: &[u8; 32]) -> LockedSecret<32> {
use sha3::{Digest, Sha3_256};
let mut hasher = Sha3_256::new();
hasher.update(b"pqfile-fido2-v1");
hasher.update(hmac_secret);
let mut out = LockedSecret::<32>::zeroed();
out.copy_from_slice(&hasher.finalize());
out
}
pub(crate) fn webauthn_prf_secret(prf_output: &[u8; 32]) -> LockedSecret<32> {
use sha3::{Digest, Sha3_256};
let mut hasher = Sha3_256::new();
hasher.update(b"pqfile-webauthn-prf-v1");
hasher.update(prf_output);
let mut out = LockedSecret::<32>::zeroed();
out.copy_from_slice(&hasher.finalize());
out
}
#[cfg(not(target_arch = "wasm32"))]
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub struct CalibrationResult {
pub m_kib: u32,
pub t_cost: u32,
pub p_cost: u32,
pub measured_ms: u64,
pub default_ms: u64,
}
#[cfg(not(target_arch = "wasm32"))]
pub fn calibrate(target_ms: u64) -> Result<CalibrationResult, PqfileError> {
const MIN_M_KIB: u32 = ARGON2_M_COST; const MAX_M_KIB: u32 = 1024 * 1024; const MAX_T_COST: u32 = 16;
let target_ms = target_ms.max(1);
let probe = |m_kib: u32, t_cost: u32| -> Result<u64, PqfileError> {
let salt = [0x5au8; 16];
let start = std::time::Instant::now();
derive_key_with_params(
"pqfile-calibration-probe",
&salt,
m_kib,
t_cost,
ARGON2_P_COST,
)?;
Ok((start.elapsed().as_millis() as u64).max(1))
};
let round_mib = |m_kib: u64| -> u32 {
let clamped = m_kib.clamp(MIN_M_KIB as u64, MAX_M_KIB as u64) as u32;
(clamped / 1024) * 1024
};
let default_ms = probe(ARGON2_M_COST, ARGON2_T_COST)?;
let mut m_kib = round_mib((ARGON2_M_COST as u64 * target_ms) / default_ms);
let mut t_cost = ARGON2_T_COST;
let mut measured_ms = if m_kib == ARGON2_M_COST {
default_ms
} else {
let first = probe(m_kib, t_cost)?;
let corrected = round_mib((m_kib as u64 * target_ms) / first);
if corrected == m_kib {
first
} else {
m_kib = corrected;
probe(m_kib, t_cost)?
}
};
if m_kib == MAX_M_KIB && measured_ms < target_ms {
let scaled = (t_cost as u64 * target_ms) / measured_ms;
let candidate = (scaled as u32).clamp(ARGON2_T_COST, MAX_T_COST);
if candidate != t_cost {
t_cost = candidate;
measured_ms = probe(m_kib, t_cost)?;
}
}
Ok(CalibrationResult {
m_kib,
t_cost,
p_cost: ARGON2_P_COST,
measured_ms,
default_ms,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn encrypt_fixed_secret_legacy<const N: usize>(secret: &[u8; N], passphrase: &str) -> Vec<u8> {
let mut salt = [0u8; SALT_LEN];
getrandom::fill(&mut salt).unwrap();
let key = derive_key_with_pcost(passphrase, &salt, ARGON2_P_COST_LEGACY).unwrap();
let cipher = Aes256Gcm::new(key.as_ref().try_into().expect("32-byte key"));
let mut nonce_bytes = [0u8; NONCE_LEN];
getrandom::fill(&mut nonce_bytes).unwrap();
let ct = cipher
.encrypt(
nonce_bytes.as_slice().try_into().expect("12-byte nonce"),
secret.as_slice(),
)
.unwrap();
let mut out = Vec::new();
out.extend_from_slice(&salt);
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ct);
out
}
fn encrypt_seed_legacy(seed: &[u8; SEED_LEN], passphrase: &str) -> Vec<u8> {
encrypt_fixed_secret_legacy(seed, passphrase)
}
fn encrypt_signing_seed_legacy(seed: &[u8; SIGNING_SEED_LEN], passphrase: &str) -> Vec<u8> {
encrypt_fixed_secret_legacy(seed, passphrase)
}
fn encrypt_hybrid_seed_legacy(seed: &[u8; HYBRID_SEED_LEN], passphrase: &str) -> Vec<u8> {
encrypt_fixed_secret_legacy(seed, passphrase)
}
#[test]
fn roundtrip_correct_passphrase() {
let seed = [0x42u8; SEED_LEN];
let body = encrypt_seed(&seed, "hunter2").unwrap();
assert_eq!(body.len(), ENCRYPTED_BODY_LEN);
let recovered = decrypt_seed(&body, "hunter2").unwrap();
assert_eq!(*recovered, seed);
}
#[test]
fn wrong_passphrase_returns_error() {
let seed = [0x99u8; SEED_LEN];
let body = encrypt_seed(&seed, "correct").unwrap();
assert!(matches!(
decrypt_seed(&body, "wrong"),
Err(PqfileError::WrongPassphrase)
));
}
#[test]
fn different_encryptions_produce_different_bodies() {
let seed = [0x01u8; SEED_LEN];
let a = encrypt_seed(&seed, "pass").unwrap();
let b = encrypt_seed(&seed, "pass").unwrap();
assert_ne!(a, b);
}
#[test]
fn wrong_body_length_returns_error() {
assert!(matches!(
decrypt_seed(&[0u8; 10], "pass"),
Err(PqfileError::InvalidKeyLength { .. })
));
}
#[test]
fn legacy_key_returns_legacy_key_format_error() {
let seed = [0x11u8; SEED_LEN];
let legacy_body = encrypt_seed_legacy(&seed, "correct");
assert!(matches!(
decrypt_seed(&legacy_body, "correct"),
Err(PqfileError::LegacyKeyFormat)
));
}
#[test]
fn legacy_key_wrong_passphrase_returns_wrong_passphrase() {
let seed = [0x22u8; SEED_LEN];
let legacy_body = encrypt_seed_legacy(&seed, "correct");
assert!(matches!(
decrypt_seed(&legacy_body, "wrong"),
Err(PqfileError::WrongPassphrase)
));
}
#[test]
fn decrypt_seed_legacy_roundtrip() {
let seed = [0x33u8; SEED_LEN];
let legacy_body = encrypt_seed_legacy(&seed, "migrate-me");
let recovered = decrypt_seed_legacy(&legacy_body, "migrate-me").unwrap();
assert_eq!(*recovered, seed);
}
#[test]
fn decrypt_seed_legacy_wrong_passphrase() {
let seed = [0x44u8; SEED_LEN];
let legacy_body = encrypt_seed_legacy(&seed, "correct");
assert!(matches!(
decrypt_seed_legacy(&legacy_body, "wrong"),
Err(PqfileError::WrongPassphrase)
));
}
#[test]
fn hybrid_roundtrip_correct_passphrase() {
let seed = [0x77u8; HYBRID_SEED_LEN];
let body = encrypt_hybrid_seed(&seed, "hybrid-pass").unwrap();
assert_eq!(body.len(), ENCRYPTED_HYBRID_BODY_LEN);
let recovered = decrypt_hybrid_seed(&body, "hybrid-pass").unwrap();
assert_eq!(*recovered, seed);
}
#[test]
fn hybrid_wrong_passphrase_returns_error() {
let seed = [0xABu8; HYBRID_SEED_LEN];
let body = encrypt_hybrid_seed(&seed, "correct").unwrap();
assert!(matches!(
decrypt_hybrid_seed(&body, "wrong"),
Err(PqfileError::WrongPassphrase)
));
}
#[test]
fn hybrid_wrong_body_length_returns_error() {
assert!(matches!(
decrypt_hybrid_seed(&[0u8; 10], "pass"),
Err(PqfileError::InvalidKeyLength { .. })
));
}
#[test]
fn hybrid_different_encryptions_produce_different_bodies() {
let seed = [0x55u8; HYBRID_SEED_LEN];
let a = encrypt_hybrid_seed(&seed, "pass").unwrap();
let b = encrypt_hybrid_seed(&seed, "pass").unwrap();
assert_ne!(a, b);
}
#[test]
fn hybrid_legacy_key_returns_legacy_key_format_error() {
let seed = [0xCCu8; HYBRID_SEED_LEN];
let legacy_body = encrypt_hybrid_seed_legacy(&seed, "correct");
assert!(matches!(
decrypt_hybrid_seed(&legacy_body, "correct"),
Err(PqfileError::LegacyKeyFormat)
));
}
#[test]
fn decrypt_hybrid_seed_legacy_roundtrip() {
let seed = [0xDDu8; HYBRID_SEED_LEN];
let legacy_body = encrypt_hybrid_seed_legacy(&seed, "migrate-me");
let recovered = decrypt_hybrid_seed_legacy(&legacy_body, "migrate-me").unwrap();
assert_eq!(*recovered, seed);
}
#[test]
fn signing_roundtrip_correct_passphrase() {
let seed = [0x11u8; SIGNING_SEED_LEN];
let body = encrypt_signing_seed(&seed, "signpass").unwrap();
assert_eq!(body.len(), ENCRYPTED_SIGNING_BODY_LEN);
let recovered = decrypt_signing_seed(&body, "signpass").unwrap();
assert_eq!(*recovered, seed);
}
#[test]
fn signing_wrong_passphrase_returns_error() {
let seed = [0x22u8; SIGNING_SEED_LEN];
let body = encrypt_signing_seed(&seed, "correct").unwrap();
assert!(matches!(
decrypt_signing_seed(&body, "wrong"),
Err(PqfileError::WrongPassphrase)
));
}
#[test]
fn signing_wrong_body_length_returns_error() {
assert!(matches!(
decrypt_signing_seed(&[0u8; 10], "pass"),
Err(PqfileError::InvalidKeyLength { .. })
));
}
#[test]
fn signing_different_encryptions_produce_different_bodies() {
let seed = [0x33u8; SIGNING_SEED_LEN];
let a = encrypt_signing_seed(&seed, "pass").unwrap();
let b = encrypt_signing_seed(&seed, "pass").unwrap();
assert_ne!(a, b);
}
#[test]
fn signing_legacy_key_returns_legacy_key_format_error() {
let seed = [0x55u8; SIGNING_SEED_LEN];
let legacy_body = encrypt_signing_seed_legacy(&seed, "correct");
assert!(matches!(
decrypt_signing_seed(&legacy_body, "correct"),
Err(PqfileError::LegacyKeyFormat)
));
}
#[test]
fn decrypt_signing_seed_legacy_roundtrip() {
let seed = [0x66u8; SIGNING_SEED_LEN];
let legacy_body = encrypt_signing_seed_legacy(&seed, "migrate-me");
let recovered = decrypt_signing_seed_legacy(&legacy_body, "migrate-me").unwrap();
assert_eq!(*recovered, seed);
}
#[test]
fn identity_roundtrip_correct_passphrase() {
let seed = [0x77u8; IDENTITY_SEED_LEN];
let body = encrypt_identity_seed(&seed, "identitypass").unwrap();
assert_eq!(body.len(), ENCRYPTED_IDENTITY_BODY_LEN);
let recovered = decrypt_identity_seed(&body, "identitypass").unwrap();
assert_eq!(*recovered, seed);
}
#[test]
fn identity_wrong_passphrase_returns_error() {
let seed = [0x88u8; IDENTITY_SEED_LEN];
let body = encrypt_identity_seed(&seed, "correct").unwrap();
assert!(matches!(
decrypt_identity_seed(&body, "wrong"),
Err(PqfileError::WrongPassphrase)
));
}
#[test]
fn identity_wrong_body_length_returns_error() {
assert!(matches!(
decrypt_identity_seed(&[0u8; 10], "pass"),
Err(PqfileError::InvalidKeyLength { .. })
));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn calibrate_never_recommends_below_defaults() {
let r = calibrate(1).unwrap();
assert_eq!(r.m_kib, ARGON2_M_COST);
assert_eq!(r.t_cost, ARGON2_T_COST);
assert_eq!(r.p_cost, ARGON2_P_COST);
assert_eq!(r.measured_ms, r.default_ms);
assert!(r.measured_ms >= 1);
}
}