use x25519_dalek::{PublicKey, StaticSecret};
use zeroize::Zeroizing;
use crate::CryptoError;
use crate::crypto::aead::{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) fn wrap(
file_key: &FileKey,
recipient_public_key_bytes: &[u8; PUBLIC_KEY_SIZE],
) -> Result<[u8; BODY_LENGTH], CryptoError> {
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> {
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>,
on_event: &dyn Fn(&crate::ProgressEvent),
) -> Result<Zeroizing<[u8; PRIVATE_KEY_SIZE]>, CryptoError> {
use crate::crypto::tlv::validate_tlv;
use crate::error::FormatDefect;
use crate::fs::paths::read_file_capped;
use crate::key::files::KeyFileKind;
use crate::key::private::{
PRIVATE_KEY_FILE_READ_CAP_BYTES, PRIVATE_KEY_WRAPPED_SECRET_LOCAL_CAP_DEFAULT,
open_private_key,
};
let bytes = read_file_capped(path, PRIVATE_KEY_FILE_READ_CAP_BYTES, || {
CryptoError::InvalidFormat(FormatDefect::MalformedPrivateKey)
})?;
if matches!(KeyFileKind::classify(&bytes), KeyFileKind::Public) {
return Err(CryptoError::InvalidFormat(FormatDefect::WrongKeyFileType));
}
let opened = open_private_key(
&bytes,
passphrase,
kdf_limit,
PRIVATE_KEY_WRAPPED_SECRET_LOCAL_CAP_DEFAULT,
on_event,
)?;
if opened.type_name != TYPE_NAME {
return Err(CryptoError::InvalidFormat(FormatDefect::WrongKeyFileType));
}
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,
));
}
validate_tlv(&opened.ext_bytes)?;
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))?;
if type_name != TYPE_NAME {
return Err(CryptoError::InvalidFormat(FormatDefect::WrongKeyFileType));
}
if header.public_len != PUBLIC_KEY_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::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, &|_| {}).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, &|_| {}).map(|_| ()) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedPrivateKey)) => Ok(()),
other => panic!("expected MalformedPrivateKey for public_len mismatch, 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_small_order_ephemeral_via_all_zero_shared() {
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 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 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);
}
}