use x25519_dalek::{PublicKey, StaticSecret};
use zeroize::Zeroizing;
use crate::CryptoError;
use crate::crypto::aead::{
TAG_SIZE, WRAP_NONCE_SIZE, WRAPPED_FILE_KEY_SIZE, open_file_key, seal_file_key,
};
use crate::crypto::hkdf::hkdf_expand_sha3_256;
use crate::crypto::keys::{FileKey, random_bytes, random_secret};
use crate::crypto::mac::ct_eq_32;
use crate::error::FormatDefect;
pub(crate) const TYPE_NAME: &str = "x25519";
pub(crate) const PUBLIC_KEY_SIZE: usize = 32;
pub(crate) const PRIVATE_KEY_SIZE: usize = 32;
pub(crate) const BODY_LENGTH: usize = PUBLIC_KEY_SIZE + WRAP_NONCE_SIZE + WRAPPED_FILE_KEY_SIZE;
pub(crate) const HKDF_INFO_WRAP: &[u8] = b"ferrocrypt/v1/recipient/x25519/wrap";
const EPHEMERAL_PUBLIC_KEY_OFFSET: usize = 0;
const WRAP_NONCE_OFFSET: usize = EPHEMERAL_PUBLIC_KEY_OFFSET + PUBLIC_KEY_SIZE;
const WRAPPED_FILE_KEY_OFFSET: usize = WRAP_NONCE_OFFSET + WRAP_NONCE_SIZE;
pub(crate) fn is_zero_public_key(bytes: &[u8; PUBLIC_KEY_SIZE]) -> bool {
ct_eq_32(bytes, &[0u8; PUBLIC_KEY_SIZE])
}
pub(crate) const FIELD_PRIME_LE: [u8; PUBLIC_KEY_SIZE] = [
0xED, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7F,
];
pub(crate) fn is_canonical_public_key_encoding(bytes: &[u8; PUBLIC_KEY_SIZE]) -> bool {
let mut is_below = 0u8;
let mut all_equal_so_far = 1u8;
for (byte, prime_byte) in bytes.iter().zip(FIELD_PRIME_LE.iter()).rev() {
is_below |= all_equal_so_far & u8::from(byte < prime_byte);
all_equal_so_far &= u8::from(byte == prime_byte);
}
is_below == 1
}
pub(crate) fn validate_body_preflight(body: &[u8]) -> Result<(), CryptoError> {
let ephemeral: &[u8; PUBLIC_KEY_SIZE] = body
.get(EPHEMERAL_PUBLIC_KEY_OFFSET..EPHEMERAL_PUBLIC_KEY_OFFSET + PUBLIC_KEY_SIZE)
.and_then(|slice| slice.try_into().ok())
.ok_or(CryptoError::InvalidFormat(
FormatDefect::MalformedRecipientEntry,
))?;
if is_zero_public_key(ephemeral) || !is_canonical_public_key_encoding(ephemeral) {
return Err(CryptoError::InvalidFormat(
FormatDefect::MalformedRecipientEntry,
));
}
Ok(())
}
pub(crate) fn wrap(
file_key: &FileKey,
recipient_public_key_bytes: &[u8; PUBLIC_KEY_SIZE],
) -> Result<[u8; BODY_LENGTH], CryptoError> {
if !is_canonical_public_key_encoding(recipient_public_key_bytes) {
return Err(CryptoError::InvalidInput(
"Invalid recipient public key".to_string(),
));
}
let ephemeral_raw = random_secret::<PRIVATE_KEY_SIZE>()?;
let ephemeral_secret = StaticSecret::from(*ephemeral_raw);
let ephemeral_public_key = PublicKey::from(&ephemeral_secret);
let recipient_public_key = PublicKey::from(*recipient_public_key_bytes);
let shared = ephemeral_secret.diffie_hellman(&recipient_public_key);
if ct_eq_32(shared.as_bytes(), &[0u8; PUBLIC_KEY_SIZE]) {
return Err(CryptoError::InvalidInput(
"Invalid recipient public key".to_string(),
));
}
let wrap_key = derive_wrap_key(
ephemeral_public_key.as_bytes(),
recipient_public_key.as_bytes(),
shared.as_bytes(),
)?;
let wrap_nonce = random_bytes::<WRAP_NONCE_SIZE>()?;
let wrapped_file_key = seal_file_key(&wrap_key, &wrap_nonce, file_key)?;
let mut body = [0u8; BODY_LENGTH];
body[EPHEMERAL_PUBLIC_KEY_OFFSET..EPHEMERAL_PUBLIC_KEY_OFFSET + PUBLIC_KEY_SIZE]
.copy_from_slice(ephemeral_public_key.as_bytes());
body[WRAP_NONCE_OFFSET..WRAP_NONCE_OFFSET + WRAP_NONCE_SIZE].copy_from_slice(&wrap_nonce);
body[WRAPPED_FILE_KEY_OFFSET..].copy_from_slice(&wrapped_file_key);
Ok(body)
}
pub(crate) fn unwrap(
body: &[u8; BODY_LENGTH],
private_key_bytes: &[u8; PRIVATE_KEY_SIZE],
) -> Result<FileKey, CryptoError> {
validate_body_preflight(body)?;
let mut ephemeral_public_key_bytes = [0u8; PUBLIC_KEY_SIZE];
ephemeral_public_key_bytes.copy_from_slice(
&body[EPHEMERAL_PUBLIC_KEY_OFFSET..EPHEMERAL_PUBLIC_KEY_OFFSET + PUBLIC_KEY_SIZE],
);
let mut wrap_nonce = [0u8; WRAP_NONCE_SIZE];
wrap_nonce.copy_from_slice(&body[WRAP_NONCE_OFFSET..WRAP_NONCE_OFFSET + WRAP_NONCE_SIZE]);
let mut wrapped_file_key = [0u8; WRAPPED_FILE_KEY_SIZE];
wrapped_file_key.copy_from_slice(&body[WRAPPED_FILE_KEY_OFFSET..]);
let x25519_private_key = StaticSecret::from(*private_key_bytes);
let recipient_public_key = PublicKey::from(&x25519_private_key);
let ephemeral_public_key = PublicKey::from(ephemeral_public_key_bytes);
let shared = x25519_private_key.diffie_hellman(&ephemeral_public_key);
if ct_eq_32(shared.as_bytes(), &[0u8; PUBLIC_KEY_SIZE]) {
return Err(CryptoError::InvalidFormat(
FormatDefect::MalformedRecipientEntry,
));
}
let wrap_key = derive_wrap_key(
&ephemeral_public_key_bytes,
recipient_public_key.as_bytes(),
shared.as_bytes(),
)?;
open_file_key(&wrap_key, &wrap_nonce, &wrapped_file_key, || {
CryptoError::RecipientUnwrapFailed {
type_name: TYPE_NAME.to_string(),
}
})
}
fn derive_wrap_key(
ephemeral_public_key_bytes: &[u8; PUBLIC_KEY_SIZE],
recipient_public_key_bytes: &[u8; PUBLIC_KEY_SIZE],
shared_secret: &[u8; PUBLIC_KEY_SIZE],
) -> Result<Zeroizing<[u8; 32]>, CryptoError> {
let mut salt = [0u8; 2 * PUBLIC_KEY_SIZE];
salt[..PUBLIC_KEY_SIZE].copy_from_slice(ephemeral_public_key_bytes);
salt[PUBLIC_KEY_SIZE..].copy_from_slice(recipient_public_key_bytes);
hkdf_expand_sha3_256(Some(&salt), shared_secret, HKDF_INFO_WRAP)
}
pub(crate) struct X25519Recipient<'a> {
pub recipient_public_key_bytes: &'a [u8; PUBLIC_KEY_SIZE],
}
impl<'a> crate::protocol::RecipientScheme for X25519Recipient<'a> {
const TYPE_NAME: &'static str = TYPE_NAME;
const MIXING_RULE: crate::recipient::policy::NativeMixingRule =
crate::recipient::policy::NativeRecipientType::X25519.mixing_rule();
fn wrap_file_key(
&self,
file_key: &FileKey,
_on_event: &dyn Fn(&crate::ProgressEvent),
) -> Result<crate::recipient::entry::RecipientBody, CryptoError> {
let bytes = wrap(file_key, self.recipient_public_key_bytes)?;
Ok(crate::recipient::entry::RecipientBody {
type_name: TYPE_NAME,
bytes: bytes.to_vec(),
})
}
}
pub(crate) struct X25519Credential {
pub private_key_bytes: Zeroizing<[u8; PRIVATE_KEY_SIZE]>,
}
impl crate::protocol::DecryptionCredential for X25519Credential {
const TYPE_NAME: &'static str = TYPE_NAME;
const EXPECTED_MODE: crate::UnauthenticatedRecipientMode =
crate::UnauthenticatedRecipientMode::PublicKey;
fn unwrap_file_key(
&self,
body: &[u8],
_on_event: &dyn Fn(&crate::ProgressEvent),
) -> Result<Option<FileKey>, CryptoError> {
let body_array: &[u8; BODY_LENGTH] = body
.try_into()
.map_err(|_| CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry))?;
match unwrap(body_array, &self.private_key_bytes) {
Ok(file_key) => Ok(Some(file_key)),
Err(CryptoError::RecipientUnwrapFailed { .. }) => Ok(None),
Err(other) => Err(other),
}
}
}
pub(crate) fn generate_keypair()
-> Result<(Zeroizing<[u8; PRIVATE_KEY_SIZE]>, [u8; PUBLIC_KEY_SIZE]), CryptoError> {
let raw = random_secret::<PRIVATE_KEY_SIZE>()?;
let secret = StaticSecret::from(*raw);
let public = PublicKey::from(&secret);
let secret_material = Zeroizing::new(secret.to_bytes());
drop(secret);
let public_material = *public.as_bytes();
Ok((secret_material, public_material))
}
pub(crate) fn open_x25519_private_key(
path: &std::path::Path,
passphrase: &secrecy::SecretString,
kdf_limit: Option<&crate::crypto::kdf::KdfLimit>,
key_read_limits: crate::key::limits::KeyReadLimits,
on_event: &dyn Fn(&crate::ProgressEvent),
) -> Result<Zeroizing<[u8; PRIVATE_KEY_SIZE]>, CryptoError> {
use crate::error::FormatDefect;
use crate::key::files::KeyFileKind;
use crate::key::private::{open_private_key, read_private_key_file};
let bytes = read_private_key_file(path, key_read_limits.private_key_wrapped_secret_len())?;
if matches!(
KeyFileKind::classify(&bytes, key_read_limits),
KeyFileKind::Public
) {
return Err(CryptoError::InvalidFormat(FormatDefect::WrongKeyFileType));
}
let opened = open_private_key(
&bytes,
passphrase,
kdf_limit,
key_read_limits.private_key_wrapped_secret_len(),
on_event,
)?;
if opened.type_name != TYPE_NAME {
return Err(CryptoError::UnsupportedKeyType {
type_name: opened.type_name.clone(),
});
}
let public_material: [u8; PUBLIC_KEY_SIZE] = opened
.public_material
.as_slice()
.try_into()
.map_err(|_| CryptoError::InvalidFormat(FormatDefect::MalformedPrivateKey))?;
if opened.secret_material.len() != PRIVATE_KEY_SIZE {
return Err(CryptoError::InvalidFormat(
FormatDefect::MalformedPrivateKey,
));
}
let mut secret = Zeroizing::new([0u8; PRIVATE_KEY_SIZE]);
secret.copy_from_slice(&opened.secret_material);
let derived_public = PublicKey::from(&StaticSecret::from(*secret));
if derived_public.as_bytes() != &public_material {
return Err(CryptoError::InvalidFormat(
FormatDefect::MalformedPrivateKey,
));
}
Ok(secret)
}
pub fn validate_private_key_shape(data: &[u8]) -> Result<(), CryptoError> {
use crate::error::FormatDefect;
use crate::key::private::{PRIVATE_KEY_HEADER_FIXED_SIZE, PrivateKeyHeader};
let header_bytes =
data.first_chunk::<PRIVATE_KEY_HEADER_FIXED_SIZE>()
.ok_or(CryptoError::InvalidFormat(
FormatDefect::MalformedPrivateKey,
))?;
let header = PrivateKeyHeader::parse(header_bytes)?;
let type_name_start = PRIVATE_KEY_HEADER_FIXED_SIZE;
let type_name_end = type_name_start
.checked_add(header.type_name_len as usize)
.ok_or(CryptoError::InvalidFormat(
FormatDefect::MalformedPrivateKey,
))?;
if data.len() < type_name_end {
return Err(CryptoError::InvalidFormat(
FormatDefect::MalformedPrivateKey,
));
}
let type_name = std::str::from_utf8(&data[type_name_start..type_name_end])
.map_err(|_| CryptoError::InvalidFormat(FormatDefect::MalformedTypeName))?;
crate::recipient::name::validate_type_name_grammar(type_name)?;
if type_name != TYPE_NAME {
return Err(CryptoError::UnsupportedKeyType {
type_name: type_name.to_owned(),
});
}
if header.public_len != PUBLIC_KEY_SIZE as u32 {
return Err(CryptoError::InvalidFormat(
FormatDefect::MalformedPrivateKey,
));
}
if header.wrapped_secret_len != (PRIVATE_KEY_SIZE + TAG_SIZE) as u32 {
return Err(CryptoError::InvalidFormat(
FormatDefect::MalformedPrivateKey,
));
}
let expected_total = (PRIVATE_KEY_HEADER_FIXED_SIZE as u64)
.checked_add(header.type_name_len as u64)
.and_then(|v| v.checked_add(header.public_len as u64))
.and_then(|v| v.checked_add(header.ext_len as u64))
.and_then(|v| v.checked_add(header.wrapped_secret_len as u64))
.ok_or(CryptoError::InvalidFormat(
FormatDefect::MalformedPrivateKey,
))?;
if (data.len() as u64) != expected_total {
return Err(CryptoError::InvalidFormat(
FormatDefect::MalformedPrivateKey,
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CryptoError;
use crate::crypto::kdf::KdfParams;
use crate::crypto::keys::FILE_KEY_SIZE;
use crate::error::FormatDefect;
use crate::key::limits::KeyReadLimits;
use crate::key::private::seal_private_key;
use chacha20poly1305::aead::OsRng;
use secrecy::SecretString;
use std::fs;
#[test]
fn open_private_key_rejects_x25519_public_secret_mismatch() -> Result<(), CryptoError> {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("private.key");
let pass = SecretString::from("pw".to_string());
let secret = StaticSecret::random_from_rng(OsRng);
let secret_material = secret.to_bytes();
let other_secret = StaticSecret::random_from_rng(OsRng);
let wrong_public = PublicKey::from(&other_secret);
let bytes = seal_private_key(
&secret_material,
TYPE_NAME,
wrong_public.as_bytes(),
&[],
&pass,
&KdfParams::test_fast_default(),
)?;
fs::write(&path, bytes)?;
match open_x25519_private_key(&path, &pass, None, KeyReadLimits::default(), &|_| {})
.map(|_| ())
{
Err(CryptoError::InvalidFormat(FormatDefect::MalformedPrivateKey)) => Ok(()),
other => {
panic!("expected MalformedPrivateKey for public/secret mismatch, got {other:?}")
}
}
}
#[test]
fn open_private_key_rejects_x25519_public_len_mismatch() -> Result<(), CryptoError> {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("private.key");
let pass = SecretString::from("pw".to_string());
let secret = StaticSecret::random_from_rng(OsRng);
let secret_material = secret.to_bytes();
let malformed_public = [0u8; PUBLIC_KEY_SIZE - 1];
let bytes = seal_private_key(
&secret_material,
TYPE_NAME,
&malformed_public,
&[],
&pass,
&KdfParams::test_fast_default(),
)?;
fs::write(&path, bytes)?;
match open_x25519_private_key(&path, &pass, None, KeyReadLimits::default(), &|_| {})
.map(|_| ())
{
Err(CryptoError::InvalidFormat(FormatDefect::MalformedPrivateKey)) => Ok(()),
other => panic!("expected MalformedPrivateKey for public_len mismatch, got {other:?}"),
}
}
#[test]
fn open_private_key_rejects_unsupported_key_type() -> Result<(), CryptoError> {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("private.key");
let pass = SecretString::from("pw".to_string());
let events = std::cell::RefCell::new(Vec::new());
let sink = |event: &crate::ProgressEvent| events.borrow_mut().push(*event);
let (secret_material, public_material) = keypair();
let bytes = seal_private_key(
&secret_material,
"future",
&public_material,
&[],
&pass,
&KdfParams::test_fast_default(),
)?;
fs::write(&path, bytes)?;
match open_x25519_private_key(&path, &pass, None, KeyReadLimits::default(), &sink)
.map(|_| ())
{
Err(CryptoError::UnsupportedKeyType { type_name }) => {
assert_eq!(type_name, "future");
}
other => panic!("expected UnsupportedKeyType for a future key kind, got {other:?}"),
}
assert_eq!(
events.borrow().as_slice(),
&[crate::ProgressEvent::UnlockingPrivateKey],
"the type verdict requires an authenticated unlock"
);
Ok(())
}
#[test]
fn open_private_key_rejects_public_key_file_before_progress() -> Result<(), CryptoError> {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("public.key");
let pass = SecretString::from("pw".to_string());
let (_, public_material) = keypair();
let recipient = crate::key::public::encode_recipient_string(TYPE_NAME, &public_material)?;
fs::write(&path, format!("{recipient}\n"))?;
let events = std::cell::RefCell::new(Vec::new());
let sink = |event: &crate::ProgressEvent| events.borrow_mut().push(*event);
match open_x25519_private_key(&path, &pass, None, KeyReadLimits::default(), &sink)
.map(|_| ())
{
Err(CryptoError::InvalidFormat(FormatDefect::WrongKeyFileType)) => {}
other => panic!("expected WrongKeyFileType for public.key, got {other:?}"),
}
assert!(
events.borrow().is_empty(),
"a public/private crossing must reject before Argon2id"
);
Ok(())
}
#[test]
fn validate_private_key_shape_rejects_unsupported_key_type() -> Result<(), CryptoError> {
let (secret_material, public_material) = keypair();
let bytes = seal_private_key(
&secret_material,
"future",
&public_material,
&[],
&SecretString::from("pw".to_string()),
&KdfParams::test_fast_default(),
)?;
match validate_private_key_shape(&bytes) {
Err(CryptoError::UnsupportedKeyType { type_name }) => {
assert_eq!(type_name, "future");
Ok(())
}
other => panic!("expected UnsupportedKeyType from the shape validator, got {other:?}"),
}
}
fn keypair() -> ([u8; PRIVATE_KEY_SIZE], [u8; PUBLIC_KEY_SIZE]) {
let secret = StaticSecret::random_from_rng(OsRng);
let public = PublicKey::from(&secret);
(secret.to_bytes(), *public.as_bytes())
}
#[test]
fn body_length_matches_field_sum() {
assert_eq!(
BODY_LENGTH,
PUBLIC_KEY_SIZE + WRAP_NONCE_SIZE + WRAPPED_FILE_KEY_SIZE
);
assert_eq!(BODY_LENGTH, 104);
}
#[test]
fn type_name_is_canonical_lowercase() {
assert_eq!(TYPE_NAME, "x25519");
}
#[test]
fn hkdf_info_wrap_is_canonical() {
assert_eq!(HKDF_INFO_WRAP, b"ferrocrypt/v1/recipient/x25519/wrap");
}
#[test]
fn wrap_unwrap_round_trip() {
let file_key = FileKey::from_bytes_for_tests([0x42u8; FILE_KEY_SIZE]);
let (sk, pk) = keypair();
let body = wrap(&file_key, &pk).unwrap();
let recovered = unwrap(&body, &sk).unwrap();
assert_eq!(recovered.expose(), file_key.expose());
}
#[test]
fn unwrap_with_wrong_private_key_fails_with_recipient_unwrap_failed() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let (_alice_sk, alice_pk) = keypair();
let (bob_sk, _bob_pk) = keypair();
let body = wrap(&file_key, &alice_pk).unwrap();
match unwrap(&body, &bob_sk) {
Err(CryptoError::RecipientUnwrapFailed { type_name }) => {
assert_eq!(type_name, TYPE_NAME);
}
other => panic!("expected RecipientUnwrapFailed, got {other:?}"),
}
}
#[test]
fn unwrap_with_tampered_wrapped_file_key_fails_with_recipient_unwrap_failed() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let (sk, pk) = keypair();
let mut body = wrap(&file_key, &pk).unwrap();
body[WRAPPED_FILE_KEY_OFFSET] ^= 0x01;
match unwrap(&body, &sk) {
Err(CryptoError::RecipientUnwrapFailed { type_name }) => {
assert_eq!(type_name, TYPE_NAME);
}
other => panic!("expected RecipientUnwrapFailed, got {other:?}"),
}
}
#[test]
fn unwrap_with_tampered_ephemeral_public_key_fails_with_recipient_unwrap_failed() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let (sk, pk) = keypair();
let mut body = wrap(&file_key, &pk).unwrap();
body[EPHEMERAL_PUBLIC_KEY_OFFSET] ^= 0x01;
match unwrap(&body, &sk) {
Err(CryptoError::RecipientUnwrapFailed { type_name }) => {
assert_eq!(type_name, TYPE_NAME);
}
other => panic!("expected RecipientUnwrapFailed, got {other:?}"),
}
}
#[test]
fn unwrap_with_tampered_wrap_nonce_fails_with_recipient_unwrap_failed() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let (sk, pk) = keypair();
let mut body = wrap(&file_key, &pk).unwrap();
body[WRAP_NONCE_OFFSET] ^= 0x01;
match unwrap(&body, &sk) {
Err(CryptoError::RecipientUnwrapFailed { type_name }) => {
assert_eq!(type_name, TYPE_NAME);
}
other => panic!("expected RecipientUnwrapFailed, got {other:?}"),
}
}
#[test]
fn unwrap_rejects_all_zero_ephemeral_at_the_preflight() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let (sk, pk) = keypair();
let mut body = wrap(&file_key, &pk).unwrap();
body[EPHEMERAL_PUBLIC_KEY_OFFSET..EPHEMERAL_PUBLIC_KEY_OFFSET + PUBLIC_KEY_SIZE].fill(0);
match unwrap(&body, &sk) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry)) => {}
other => {
panic!("expected MalformedRecipientEntry for all-zero ephemeral, got {other:?}")
}
}
}
#[test]
fn unwrap_rejects_non_canonical_ephemeral() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let (sk, pk) = keypair();
let mut body = wrap(&file_key, &pk).unwrap();
body[EPHEMERAL_PUBLIC_KEY_OFFSET + PUBLIC_KEY_SIZE - 1] |= 0x80;
match unwrap(&body, &sk) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry)) => {}
other => {
panic!(
"expected MalformedRecipientEntry for non-canonical ephemeral, got {other:?}"
)
}
}
}
#[test]
fn credential_adapter_propagates_all_zero_shared_secret() {
use crate::protocol::DecryptionCredential;
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let (sk, pk) = keypair();
let mut body_bytes = wrap(&file_key, &pk).unwrap();
body_bytes[EPHEMERAL_PUBLIC_KEY_OFFSET..EPHEMERAL_PUBLIC_KEY_OFFSET + PUBLIC_KEY_SIZE]
.fill(0);
let credential = X25519Credential {
private_key_bytes: Zeroizing::new(sk),
};
match credential.unwrap_file_key(&body_bytes, &|_| {}) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry)) => {}
other => panic!(
"adapter must propagate all-zero shared as MalformedRecipientEntry, got {other:?}"
),
}
}
#[test]
fn wrap_rejects_all_zero_recipient_public_key() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let zero_pk = [0u8; PUBLIC_KEY_SIZE];
match wrap(&file_key, &zero_pk) {
Err(CryptoError::InvalidInput(msg)) => {
assert!(msg.contains("Invalid recipient"));
}
other => panic!("expected InvalidInput for all-zero public_key, got {other:?}"),
}
}
#[test]
fn wrap_rejects_non_canonical_recipient_public_key() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let (_, mut pk) = keypair();
pk[PUBLIC_KEY_SIZE - 1] |= 0x80;
match wrap(&file_key, &pk) {
Err(CryptoError::InvalidInput(msg)) => assert!(msg.contains("Invalid recipient")),
other => panic!("expected InvalidInput for non-canonical public_key, got {other:?}"),
}
}
#[test]
fn canonical_encoding_accepts_valid_and_boundary_values() {
let (_, pk) = keypair();
assert!(is_canonical_public_key_encoding(&pk));
let mut max_canonical = FIELD_PRIME_LE;
max_canonical[0] -= 1;
assert!(is_canonical_public_key_encoding(&max_canonical));
assert!(is_canonical_public_key_encoding(&[0u8; PUBLIC_KEY_SIZE]));
}
#[test]
fn canonical_encoding_rejects_prime_and_aliases() {
assert!(!is_canonical_public_key_encoding(&FIELD_PRIME_LE));
let mut prime_plus_one = FIELD_PRIME_LE;
prime_plus_one[0] += 1;
assert!(!is_canonical_public_key_encoding(&prime_plus_one));
let (_, mut pk) = keypair();
pk[PUBLIC_KEY_SIZE - 1] |= 0x80;
assert!(!is_canonical_public_key_encoding(&pk));
assert!(!is_canonical_public_key_encoding(
&[0xFFu8; PUBLIC_KEY_SIZE]
));
}
#[test]
fn preflight_rejects_zero_and_non_canonical_ephemeral() {
let file_key = FileKey::from_bytes_for_tests([0x22u8; FILE_KEY_SIZE]);
let (_, pk) = keypair();
let valid = wrap(&file_key, &pk).unwrap();
validate_body_preflight(&valid).expect("a wrapped body must pass the preflight");
let mut zero_ephemeral = valid;
zero_ephemeral[EPHEMERAL_PUBLIC_KEY_OFFSET..EPHEMERAL_PUBLIC_KEY_OFFSET + PUBLIC_KEY_SIZE]
.fill(0);
match validate_body_preflight(&zero_ephemeral) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry)) => {}
other => panic!("expected reject for all-zero ephemeral, got {other:?}"),
}
let mut alias_ephemeral = valid;
alias_ephemeral[PUBLIC_KEY_SIZE - 1] |= 0x80;
match validate_body_preflight(&alias_ephemeral) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry)) => {}
other => panic!("expected reject for non-canonical ephemeral, got {other:?}"),
}
}
#[test]
fn body_field_offsets_are_correct() {
let file_key = FileKey::from_bytes_for_tests([0x11u8; FILE_KEY_SIZE]);
let (_, pk) = keypair();
let body = wrap(&file_key, &pk).unwrap();
assert_eq!(EPHEMERAL_PUBLIC_KEY_OFFSET, 0);
assert_eq!(WRAP_NONCE_OFFSET, 32);
assert_eq!(WRAPPED_FILE_KEY_OFFSET, 56);
assert_eq!(body.len(), 104);
}
#[test]
fn validate_private_key_shape_rejects_wrong_wrapped_secret_len() {
use crate::crypto::kdf::ARGON2_SALT_SIZE;
use crate::key::private::PrivateKeyHeader;
let build = |wrapped_secret_len: u32| {
let header = PrivateKeyHeader {
key_flags: 0,
type_name_len: TYPE_NAME.len() as u16,
public_len: PUBLIC_KEY_SIZE as u32,
ext_len: 0,
wrapped_secret_len,
argon2_salt: [0u8; ARGON2_SALT_SIZE],
kdf_params: KdfParams::test_fast_default(),
wrap_nonce: [0u8; WRAP_NONCE_SIZE],
};
let mut data = header.to_bytes().to_vec();
data.extend_from_slice(TYPE_NAME.as_bytes());
data.extend_from_slice(&[0x07u8; PUBLIC_KEY_SIZE]);
data.extend_from_slice(&vec![0u8; wrapped_secret_len as usize]);
data
};
let expected = (PRIVATE_KEY_SIZE + TAG_SIZE) as u32;
validate_private_key_shape(&build(expected)).expect("canonical length must pass");
for wrong in [expected - 1, expected + 1] {
match validate_private_key_shape(&build(wrong)) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedPrivateKey)) => {}
other => {
panic!(
"expected MalformedPrivateKey for wrapped_secret_len {wrong}, got {other:?}"
)
}
}
}
}
#[test]
fn key_read_limits_raise_the_wrapped_secret_cap() -> Result<(), CryptoError> {
use crate::crypto::kdf::ARGON2_SALT_SIZE;
use crate::key::private::PrivateKeyHeader;
let oversized_len = KeyReadLimits::PRIVATE_KEY_WRAPPED_SECRET_LEN_DEFAULT + 1;
let header = PrivateKeyHeader {
key_flags: 0,
type_name_len: TYPE_NAME.len() as u16,
public_len: PUBLIC_KEY_SIZE as u32,
ext_len: 0,
wrapped_secret_len: oversized_len,
argon2_salt: [0u8; ARGON2_SALT_SIZE],
kdf_params: KdfParams::test_fast_default(),
wrap_nonce: [0u8; WRAP_NONCE_SIZE],
};
let mut data = header.to_bytes().to_vec();
data.extend_from_slice(TYPE_NAME.as_bytes());
data.extend_from_slice(&[0x07u8; PUBLIC_KEY_SIZE]);
data.extend_from_slice(&vec![0u8; oversized_len as usize]);
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("private.key");
fs::write(&path, &data)?;
let pass = SecretString::from("pw".to_string());
match open_x25519_private_key(&path, &pass, None, KeyReadLimits::default(), &|_| {}) {
Err(CryptoError::PrivateKeyWrappedSecretCapExceeded {
wrapped_secret_len,
local_cap,
}) => {
assert_eq!(wrapped_secret_len, oversized_len);
assert_eq!(
local_cap,
KeyReadLimits::PRIVATE_KEY_WRAPPED_SECRET_LEN_DEFAULT
);
}
other => panic!("expected the default cap to reject, got {other:?}"),
}
let raised = KeyReadLimits::default().max_private_key_wrapped_secret_len(oversized_len);
match open_x25519_private_key(&path, &pass, None, raised, &|_| {}) {
Err(CryptoError::KeyFileUnlockFailed) => Ok(()),
other => panic!("expected the raised cap to reach the unlock, got {other:?}"),
}
}
}