use std::fmt;
use ml_kem::kem::{Decapsulate, Encapsulate, Generate, KeyExport, KeyInit};
use ml_kem::ml_kem_1024::{DecapsulationKey, EncapsulationKey};
use ml_kem::Seed;
use rand::rngs::{StdRng, SysRng};
use rand::{SeedableRng, TryRng};
use zeroize::{Zeroize, Zeroizing};
use crate::crypto::aead::{AEADCipher, AEADError, STENOXIDE_IDENTITY_AAD};
use crate::crypto::armor::{decode_labelled, encode_labelled, ArmorError};
use crate::crypto::expand::{
expand_master_key, expand_shared_secret, DerivedKeys, ExpandError, SharedSecret,
};
use crate::crypto::kdf::{KdfError, KeyDeriver};
pub const RECIPIENT_LABEL: &str = "stenoxide-recipient-v1";
pub const IDENTITY_LABEL: &str = "stenoxide-identity-v1";
pub const RECIPIENT_KEY_BYTES: usize = 1568;
pub const KEM_CIPHERTEXT_BYTES: usize = 1568;
const IDENTITY_SEED_BYTES: usize = 64;
const IDENTITY_SALT_BYTES: usize = 32;
const TAG_BYTES: usize = 16;
const IDENTITY_BLOB_BYTES: usize = IDENTITY_SALT_BYTES + IDENTITY_SEED_BYTES + TAG_BYTES;
const RNG_SEED_BYTES: usize = 32;
#[derive(Debug)]
pub enum KemError {
Entropy(String),
Armor(ArmorError),
MalformedKey,
WrongPassphrase,
Kdf(KdfError),
Expand(ExpandError),
Aead(AEADError),
}
impl fmt::Display for KemError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
KemError::Entropy(message) => write!(
f,
"could not read the system random number generator, and a key pair must not be \
generated without it: {message}"
),
KemError::Armor(err) => write!(f, "{err}"),
KemError::MalformedKey => write!(f, "the key material is damaged or incomplete"),
KemError::WrongPassphrase => {
write!(f, "the passphrase did not unlock this private key file")
}
KemError::Kdf(err) => write!(f, "{err}"),
KemError::Expand(err) => write!(f, "{err}"),
KemError::Aead(err) => write!(f, "{err}"),
}
}
}
impl std::error::Error for KemError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
KemError::Armor(err) => Some(err),
KemError::Kdf(err) => Some(err),
KemError::Expand(err) => Some(err),
KemError::Aead(err) => Some(err),
KemError::Entropy(_) | KemError::MalformedKey | KemError::WrongPassphrase => None,
}
}
}
impl From<ArmorError> for KemError {
fn from(err: ArmorError) -> Self {
KemError::Armor(err)
}
}
impl From<KdfError> for KemError {
fn from(err: KdfError) -> Self {
KemError::Kdf(err)
}
}
impl From<ExpandError> for KemError {
fn from(err: ExpandError) -> Self {
KemError::Expand(err)
}
}
impl From<AEADError> for KemError {
fn from(err: AEADError) -> Self {
KemError::Aead(err)
}
}
#[derive(Clone)]
pub struct RecipientKey(EncapsulationKey);
impl RecipientKey {
pub fn from_public_file(text: &str) -> Result<Self, KemError> {
let bytes = decode_labelled(RECIPIENT_LABEL, text)?;
let encoded = bytes
.as_slice()
.try_into()
.map_err(|_| KemError::MalformedKey)?;
EncapsulationKey::new(encoded)
.map(Self)
.map_err(|_| KemError::MalformedKey)
}
pub fn to_public_file(&self) -> String {
encode_labelled(RECIPIENT_LABEL, &self.0.to_bytes())
}
pub fn encapsulate(&self) -> Result<(Vec<u8>, DerivedKeys), KemError> {
let mut rng = system_rng()?;
let (ciphertext, mut shared) = self.0.encapsulate_with_rng(&mut rng);
let secret = SharedSecret::new(
shared
.as_slice()
.try_into()
.map_err(|_| KemError::MalformedKey)?,
);
shared.as_mut_slice().zeroize();
let keys = expand_shared_secret(&secret)?;
drop(secret);
Ok((ciphertext.to_vec(), keys))
}
}
pub struct Identity(DecapsulationKey);
impl Identity {
pub fn generate() -> Result<Self, KemError> {
DecapsulationKey::try_generate_from_rng(&mut SysRng)
.map(Self)
.map_err(|err| KemError::Entropy(err.to_string()))
}
pub fn recipient(&self) -> RecipientKey {
RecipientKey(self.0.encapsulation_key().clone())
}
pub fn seal(
&self,
passphrase: &[u8],
kdf: &dyn KeyDeriver,
cipher: &dyn AEADCipher,
) -> Result<String, KemError> {
let mut salt = [0u8; IDENTITY_SALT_BYTES];
SysRng
.try_fill_bytes(&mut salt)
.map_err(|err| KemError::Entropy(err.to_string()))?;
let keys = Self::file_keys(passphrase, &salt, kdf)?;
let Some(mut seed) = self.0.to_seed() else {
return Err(KemError::MalformedKey);
};
let sealed = cipher.encrypt(
keys.enc_key(),
keys.nonce(),
seed.as_slice(),
STENOXIDE_IDENTITY_AAD,
);
seed.as_mut_slice().zeroize();
drop(keys);
let mut blob = Vec::with_capacity(IDENTITY_BLOB_BYTES);
blob.extend_from_slice(&salt);
blob.extend_from_slice(&sealed?);
Ok(encode_labelled(IDENTITY_LABEL, &blob))
}
pub fn open(
text: &str,
passphrase: &[u8],
kdf: &dyn KeyDeriver,
cipher: &dyn AEADCipher,
) -> Result<Self, KemError> {
let blob = decode_labelled(IDENTITY_LABEL, text)?;
if blob.len() != IDENTITY_BLOB_BYTES {
return Err(KemError::MalformedKey);
}
let (salt, sealed) = blob.split_at(IDENTITY_SALT_BYTES);
let keys = Self::file_keys(passphrase, salt, kdf)?;
let seed = cipher
.decrypt(keys.enc_key(), keys.nonce(), sealed, STENOXIDE_IDENTITY_AAD)
.map_err(|_| KemError::WrongPassphrase)?;
drop(keys);
let seed: &[u8; IDENTITY_SEED_BYTES] = seed
.as_slice()
.try_into()
.map_err(|_| KemError::MalformedKey)?;
Ok(Self(DecapsulationKey::new(&Seed::from(*seed))))
}
pub fn decapsulate(&self, ciphertext: &[u8]) -> Result<DerivedKeys, KemError> {
let mut shared = self
.0
.decapsulate_slice(ciphertext)
.map_err(|_| KemError::MalformedKey)?;
let secret = SharedSecret::new(
shared
.as_slice()
.try_into()
.map_err(|_| KemError::MalformedKey)?,
);
shared.as_mut_slice().zeroize();
let keys = expand_shared_secret(&secret)?;
drop(secret);
Ok(keys)
}
fn file_keys(
passphrase: &[u8],
salt: &[u8],
kdf: &dyn KeyDeriver,
) -> Result<DerivedKeys, KemError> {
let master_key = kdf.derive_with_salt(passphrase, salt)?;
let keys = expand_master_key(&master_key)?;
drop(master_key);
Ok(keys)
}
}
fn system_rng() -> Result<StdRng, KemError> {
let mut seed = Zeroizing::new([0u8; RNG_SEED_BYTES]);
SysRng
.try_fill_bytes(seed.as_mut_slice())
.map_err(|err| KemError::Entropy(err.to_string()))?;
let rng = StdRng::from_seed(*seed);
drop(seed);
Ok(rng)
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
#![allow(clippy::panic)]
use super::*;
use crate::crypto::aead::XChaCha20Poly1305Cipher;
use crate::crypto::kdf::Argon2Kdf;
const PASSPHRASE: &[u8] = b"a local passphrase, not the message password";
fn cipher() -> XChaCha20Poly1305Cipher {
XChaCha20Poly1305Cipher::new()
}
fn kdf() -> Argon2Kdf {
Argon2Kdf::low_cost_for_tests()
}
#[test]
fn a_secret_established_by_one_side_is_recovered_by_the_other() {
let identity = Identity::generate().expect("the system generator must be readable");
let published = identity.recipient().to_public_file();
let recipient =
RecipientKey::from_public_file(&published).expect("our own public file must parse");
let (ciphertext, sent) = recipient.encapsulate().expect("encapsulation must succeed");
assert_eq!(ciphertext.len(), KEM_CIPHERTEXT_BYTES);
let received = identity
.decapsulate(&ciphertext)
.expect("decapsulation must succeed");
assert_eq!(sent.enc_key(), received.enc_key());
assert_eq!(sent.nonce(), received.nonce());
assert_eq!(sent.stc_seed(), received.stc_seed());
}
#[test]
fn two_encapsulations_to_one_recipient_share_nothing() {
let identity = Identity::generate().expect("the system generator must be readable");
let recipient = identity.recipient();
let (first_ct, first) = recipient.encapsulate().expect("encapsulation must succeed");
let (second_ct, second) = recipient.encapsulate().expect("encapsulation must succeed");
assert_ne!(first_ct, second_ct);
assert_ne!(first.enc_key(), second.enc_key());
assert_ne!(
first.nonce(),
second.nonce(),
"a repeated nonce is the one failure this mode exists to make impossible"
);
}
#[test]
fn a_wrong_identity_decapsulates_to_the_wrong_key_and_not_to_a_failure() {
let owner = Identity::generate().expect("the system generator must be readable");
let stranger = Identity::generate().expect("the system generator must be readable");
let (ciphertext, sent) = owner
.recipient()
.encapsulate()
.expect("encapsulation must succeed");
let recovered = stranger
.decapsulate(&ciphertext)
.expect("decapsulation must not report a failure, whatever the key");
assert_ne!(sent.enc_key(), recovered.enc_key());
}
#[test]
fn a_ciphertext_of_the_wrong_length_is_refused() {
let identity = Identity::generate().expect("the system generator must be readable");
for length in [0usize, KEM_CIPHERTEXT_BYTES - 1, KEM_CIPHERTEXT_BYTES + 1] {
let error = identity
.decapsulate(&vec![0u8; length])
.map(|_| ())
.expect_err("a ciphertext of the wrong length must be refused");
assert!(matches!(error, KemError::MalformedKey), "got: {error:?}");
}
}
#[test]
fn a_sealed_identity_is_the_identity_that_was_sealed() {
let identity = Identity::generate().expect("the system generator must be readable");
let file = identity
.seal(PASSPHRASE, &kdf(), &cipher())
.expect("sealing must succeed");
assert!(file.starts_with(IDENTITY_LABEL));
assert!(file.ends_with('\n'));
let reopened =
Identity::open(&file, PASSPHRASE, &kdf(), &cipher()).expect("unlocking must succeed");
assert_eq!(
reopened.recipient().to_public_file(),
identity.recipient().to_public_file()
);
let (ciphertext, sent) = identity
.recipient()
.encapsulate()
.expect("encapsulation must succeed");
let received = reopened
.decapsulate(&ciphertext)
.expect("decapsulation must succeed");
assert_eq!(sent.enc_key(), received.enc_key());
}
#[test]
fn two_seals_of_one_identity_are_not_the_same_file() {
let identity = Identity::generate().expect("the system generator must be readable");
let first = identity
.seal(PASSPHRASE, &kdf(), &cipher())
.expect("sealing must succeed");
let second = identity
.seal(PASSPHRASE, &kdf(), &cipher())
.expect("sealing must succeed");
assert_ne!(first, second, "the salt must be drawn per file");
for file in [&first, &second] {
assert!(Identity::open(file, PASSPHRASE, &kdf(), &cipher()).is_ok());
}
}
#[test]
fn a_private_key_file_that_will_not_open_says_why() {
let identity = Identity::generate().expect("the system generator must be readable");
let file = identity
.seal(PASSPHRASE, &kdf(), &cipher())
.expect("sealing must succeed");
let wrong = Identity::open(&file, b"not the passphrase", &kdf(), &cipher())
.map(|_| ())
.expect_err("a wrong passphrase must not unlock the file");
assert!(matches!(wrong, KemError::WrongPassphrase), "got: {wrong:?}");
let empty = Identity::open(&file, &[], &kdf(), &cipher())
.map(|_| ())
.expect_err("an empty passphrase must be refused by the deriver");
assert!(matches!(empty, KemError::Kdf(_)), "got: {empty:?}");
let mut blob = decode_labelled(IDENTITY_LABEL, &file).expect("our own file must decode");
blob[0] ^= 0x40;
let tampered = Identity::open(
&encode_labelled(IDENTITY_LABEL, &blob),
PASSPHRASE,
&kdf(),
&cipher(),
)
.map(|_| ())
.expect_err("a tampered file must not unlock");
assert!(
matches!(tampered, KemError::WrongPassphrase),
"got: {tampered:?}"
);
}
#[test]
fn the_two_files_are_not_interchangeable() {
let identity = Identity::generate().expect("the system generator must be readable");
let public = identity.recipient().to_public_file();
let private = identity
.seal(PASSPHRASE, &kdf(), &cipher())
.expect("sealing must succeed");
let as_identity = Identity::open(&public, PASSPHRASE, &kdf(), &cipher())
.map(|_| ())
.expect_err("a public key is not an identity");
assert!(
matches!(as_identity, KemError::Armor(ArmorError::WrongLabel { .. })),
"got: {as_identity:?}"
);
let as_recipient = RecipientKey::from_public_file(&private)
.map(|_| ())
.expect_err("an identity file is not a public key");
assert!(
matches!(as_recipient, KemError::Armor(ArmorError::WrongLabel { .. })),
"got: {as_recipient:?}"
);
let truncated_public = encode_labelled(RECIPIENT_LABEL, &[0u8; 16]);
assert!(matches!(
RecipientKey::from_public_file(&truncated_public).map(|_| ()),
Err(KemError::MalformedKey)
));
let truncated_private = encode_labelled(IDENTITY_LABEL, &[0u8; 16]);
assert!(matches!(
Identity::open(&truncated_private, PASSPHRASE, &kdf(), &cipher()).map(|_| ()),
Err(KemError::MalformedKey)
));
}
#[test]
fn a_public_key_file_is_one_line() {
let identity = Identity::generate().expect("the system generator must be readable");
let file = identity.recipient().to_public_file();
assert_eq!(file.matches('\n').count(), 1);
assert!(file.ends_with('\n'));
assert!(file.starts_with(RECIPIENT_LABEL));
assert_eq!(
decode_labelled(RECIPIENT_LABEL, &file)
.expect("our own file must decode")
.len(),
RECIPIENT_KEY_BYTES
);
}
#[test]
fn every_failure_explains_itself() {
let messages = [
KemError::Entropy("no device".to_owned()).to_string(),
KemError::MalformedKey.to_string(),
KemError::WrongPassphrase.to_string(),
KemError::from(ArmorError::Malformed).to_string(),
KemError::from(KdfError::EmptyPassword).to_string(),
KemError::from(ExpandError::HkdfError("too long".to_owned())).to_string(),
KemError::from(AEADError::AuthenticationFailed).to_string(),
];
for message in &messages {
assert!(!message.is_empty());
}
assert!(messages[0].contains("no device"));
assert!(messages[2].contains("passphrase"));
assert!(std::error::Error::source(&KemError::from(ArmorError::Malformed)).is_some());
assert!(std::error::Error::source(&KemError::MalformedKey).is_none());
assert!(std::error::Error::source(&KemError::WrongPassphrase).is_none());
assert!(std::error::Error::source(&KemError::Entropy("x".to_owned())).is_none());
}
#[test]
fn the_encapsulation_generator_is_seeded_from_the_system() {
let mut first = system_rng().expect("the system generator must be readable");
let mut second = system_rng().expect("the system generator must be readable");
assert_ne!(
rand::Rng::next_u64(&mut first),
rand::Rng::next_u64(&mut second)
);
}
}