use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
use hkdf::Hkdf;
use rand::RngCore;
use sha2::Sha256;
#[cfg_attr(not(doc), allow(unused_imports))]
use x25519_dalek::SharedSecret;
use x25519_dalek::{PublicKey, StaticSecret};
use zeroize::Zeroizing;
pub const PUBLIC_KEY_LEN: usize = 32;
pub const PRIVATE_KEY_LEN: usize = 32;
pub const NONCE_LEN: usize = 12;
const AEAD_KEY_LEN: usize = 32;
const HKDF_LABEL: &[u8] = b"basil-x25519-seal-v1";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SealedEnvelope {
pub encapsulated_key: [u8; PUBLIC_KEY_LEN],
pub nonce: [u8; NONCE_LEN],
pub ciphertext: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum SealError {
#[error("invalid key length: expected {expected} bytes, got {actual}")]
BadKeyLength {
expected: usize,
actual: usize,
},
#[error("invalid nonce length: expected {expected} bytes, got {actual}")]
BadNonceLength {
expected: usize,
actual: usize,
},
#[error("key derivation failed")]
KdfFailed,
#[error("seal failed")]
SealFailed,
#[error("open failed")]
OpenFailed,
}
fn array32(bytes: &[u8]) -> Result<[u8; PUBLIC_KEY_LEN], SealError> {
bytes.try_into().map_err(|_| SealError::BadKeyLength {
expected: PUBLIC_KEY_LEN,
actual: bytes.len(),
})
}
#[must_use]
pub fn public_from_private(private: &Zeroizing<[u8; PRIVATE_KEY_LEN]>) -> [u8; PUBLIC_KEY_LEN] {
let secret = StaticSecret::from(**private);
PublicKey::from(&secret).to_bytes()
}
fn derive_aead_key(
shared: &Zeroizing<[u8; 32]>,
ephemeral_pub: &[u8; PUBLIC_KEY_LEN],
recipient_pub: &[u8; PUBLIC_KEY_LEN],
) -> Result<Zeroizing<[u8; AEAD_KEY_LEN]>, SealError> {
let mut info = Vec::with_capacity(HKDF_LABEL.len() + 2 * PUBLIC_KEY_LEN);
info.extend_from_slice(HKDF_LABEL);
info.extend_from_slice(ephemeral_pub);
info.extend_from_slice(recipient_pub);
let hk = Hkdf::<Sha256>::new(None, shared.as_slice());
let mut okm = Zeroizing::new([0u8; AEAD_KEY_LEN]);
hk.expand(&info, okm.as_mut_slice())
.map_err(|_| SealError::KdfFailed)?;
Ok(okm)
}
fn cipher_from_key(key: &Zeroizing<[u8; AEAD_KEY_LEN]>) -> Result<ChaCha20Poly1305, SealError> {
ChaCha20Poly1305::new_from_slice(key.as_slice()).map_err(|_| SealError::SealFailed)
}
pub fn seal(
recipient_pub: &[u8; PUBLIC_KEY_LEN],
plaintext: &[u8],
aad: &[u8],
) -> Result<SealedEnvelope, SealError> {
let mut eph_bytes = Zeroizing::new([0u8; PRIVATE_KEY_LEN]);
rand::thread_rng().fill_bytes(eph_bytes.as_mut_slice());
let mut nonce_bytes = [0u8; NONCE_LEN];
rand::thread_rng().fill_bytes(&mut nonce_bytes);
seal_with_parts(recipient_pub, plaintext, aad, &eph_bytes, nonce_bytes)
}
pub fn seal_with_parts(
recipient_pub: &[u8; PUBLIC_KEY_LEN],
plaintext: &[u8],
aad: &[u8],
ephemeral_private: &Zeroizing<[u8; PRIVATE_KEY_LEN]>,
nonce_bytes: [u8; NONCE_LEN],
) -> Result<SealedEnvelope, SealError> {
let ephemeral_secret = StaticSecret::from(**ephemeral_private);
let ephemeral_pub = PublicKey::from(&ephemeral_secret).to_bytes();
let recipient = PublicKey::from(*recipient_pub);
let shared_secret = ephemeral_secret.diffie_hellman(&recipient);
if !shared_secret.was_contributory() {
return Err(SealError::SealFailed);
}
let shared = Zeroizing::new(shared_secret.to_bytes());
let aead_key = derive_aead_key(&shared, &ephemeral_pub, recipient_pub)?;
let cipher = cipher_from_key(&aead_key)?;
let nonce = Nonce::from(nonce_bytes);
let ciphertext = cipher
.encrypt(
&nonce,
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| SealError::SealFailed)?;
Ok(SealedEnvelope {
encapsulated_key: ephemeral_pub,
nonce: nonce_bytes,
ciphertext,
})
}
pub fn open(
recipient_priv: &Zeroizing<[u8; PRIVATE_KEY_LEN]>,
env: &SealedEnvelope,
aad: &[u8],
) -> Result<Zeroizing<Vec<u8>>, SealError> {
let recipient_secret = StaticSecret::from(**recipient_priv);
let recipient_pub = PublicKey::from(&recipient_secret).to_bytes();
let ephemeral_pub = PublicKey::from(env.encapsulated_key);
let shared_secret = recipient_secret.diffie_hellman(&ephemeral_pub);
if !shared_secret.was_contributory() {
return Err(SealError::OpenFailed);
}
let shared = Zeroizing::new(shared_secret.to_bytes());
let aead_key = derive_aead_key(&shared, &env.encapsulated_key, &recipient_pub)?;
let cipher = cipher_from_key(&aead_key)?;
let nonce = Nonce::from(env.nonce);
let plaintext = cipher
.decrypt(
&nonce,
Payload {
msg: &env.ciphertext,
aad,
},
)
.map_err(|_| SealError::OpenFailed)?;
Ok(Zeroizing::new(plaintext))
}
pub fn private_from_slice(bytes: &[u8]) -> Result<Zeroizing<[u8; PRIVATE_KEY_LEN]>, SealError> {
Ok(Zeroizing::new(array32(bytes)?))
}
pub fn public_from_slice(bytes: &[u8]) -> Result<[u8; PUBLIC_KEY_LEN], SealError> {
array32(bytes)
}
pub fn envelope_from_parts(
encapsulated_key: &[u8],
nonce: &[u8],
ciphertext: &[u8],
) -> Result<SealedEnvelope, SealError> {
let encapsulated_key = array32(encapsulated_key)?;
let nonce: [u8; NONCE_LEN] = nonce.try_into().map_err(|_| SealError::BadNonceLength {
expected: NONCE_LEN,
actual: nonce.len(),
})?;
Ok(SealedEnvelope {
encapsulated_key,
nonce,
ciphertext: ciphertext.to_vec(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn recipient_keypair() -> (Zeroizing<[u8; 32]>, [u8; 32]) {
let priv_bytes = Zeroizing::new([7u8; 32]);
let public = public_from_private(&priv_bytes);
(priv_bytes, public)
}
#[test]
fn seal_open_round_trips() {
let (priv_bytes, public) = recipient_keypair();
let plaintext = b"enrollment-secret-payload";
let aad = b"enrollment-context";
let env = seal(&public, plaintext, aad).expect("seal");
let recovered = open(&priv_bytes, &env, aad).expect("open");
assert_eq!(recovered.as_slice(), plaintext);
}
#[test]
fn round_trips_with_empty_aad_and_empty_plaintext() {
let (priv_bytes, public) = recipient_keypair();
let env = seal(&public, b"", b"").expect("seal");
let recovered = open(&priv_bytes, &env, b"").expect("open");
assert!(recovered.is_empty());
}
#[test]
fn each_seal_uses_a_fresh_ephemeral_key_and_nonce() {
let (_priv, public) = recipient_keypair();
let a = seal(&public, b"same", b"").expect("seal a");
let b = seal(&public, b"same", b"").expect("seal b");
assert_ne!(a.encapsulated_key, b.encapsulated_key);
assert_ne!(a.nonce, b.nonce);
assert_ne!(a.ciphertext, b.ciphertext);
}
#[test]
fn tampered_ciphertext_fails_to_open() {
let (priv_bytes, public) = recipient_keypair();
let mut env = seal(&public, b"payload", b"aad").expect("seal");
if let Some(byte) = env.ciphertext.first_mut() {
*byte ^= 0xFF;
}
assert_eq!(open(&priv_bytes, &env, b"aad"), Err(SealError::OpenFailed));
}
#[test]
fn tampered_nonce_fails_to_open() {
let (priv_bytes, public) = recipient_keypair();
let mut env = seal(&public, b"payload", b"aad").expect("seal");
env.nonce[0] ^= 0xFF;
assert_eq!(open(&priv_bytes, &env, b"aad"), Err(SealError::OpenFailed));
}
#[test]
fn tampered_encapsulated_key_fails_to_open() {
let (priv_bytes, public) = recipient_keypair();
let mut env = seal(&public, b"payload", b"aad").expect("seal");
env.encapsulated_key[0] ^= 0xFF;
assert_eq!(open(&priv_bytes, &env, b"aad"), Err(SealError::OpenFailed));
}
#[test]
fn wrong_aad_fails_to_open() {
let (priv_bytes, public) = recipient_keypair();
let env = seal(&public, b"payload", b"right-aad").expect("seal");
assert_eq!(
open(&priv_bytes, &env, b"wrong-aad"),
Err(SealError::OpenFailed)
);
}
#[test]
fn wrong_recipient_key_fails_to_open() {
let (_priv, public) = recipient_keypair();
let env = seal(&public, b"payload", b"aad").expect("seal");
let other_priv = Zeroizing::new([42u8; 32]);
assert_eq!(open(&other_priv, &env, b"aad"), Err(SealError::OpenFailed));
}
#[test]
fn low_order_encapsulated_key_is_rejected_on_open() {
let (priv_bytes, _public) = recipient_keypair();
let env = SealedEnvelope {
encapsulated_key: [0u8; 32],
nonce: [0u8; 12],
ciphertext: vec![0u8; 16],
};
assert_eq!(open(&priv_bytes, &env, b"aad"), Err(SealError::OpenFailed));
}
#[test]
fn public_from_private_is_deterministic() {
let priv_bytes = Zeroizing::new([3u8; 32]);
assert_eq!(
public_from_private(&priv_bytes),
public_from_private(&priv_bytes)
);
}
#[test]
fn private_from_slice_rejects_wrong_length() {
assert!(private_from_slice(&[0u8; 31]).is_err());
assert!(private_from_slice(&[0u8; 33]).is_err());
assert!(private_from_slice(&[0u8; 32]).is_ok());
}
#[test]
fn public_from_slice_validates_length_and_round_trips() {
assert!(matches!(
public_from_slice(&[0u8; 31]),
Err(SealError::BadKeyLength { .. })
));
assert!(matches!(
public_from_slice(&[0u8; 33]),
Err(SealError::BadKeyLength { .. })
));
let (priv_bytes, public) = recipient_keypair();
let from_slice = public_from_slice(&public).expect("32-byte public");
assert_eq!(from_slice, public);
let env = seal(&from_slice, b"payload", b"ctx").expect("seal to slice-public");
assert_eq!(
open(&priv_bytes, &env, b"ctx").expect("open").as_slice(),
b"payload"
);
}
#[test]
fn envelope_from_parts_validates_lengths() {
let ek = [1u8; 32];
let nonce = [2u8; 12];
assert!(envelope_from_parts(&ek, &nonce, b"ct").is_ok());
assert!(matches!(
envelope_from_parts(&[1u8; 31], &nonce, b"ct"),
Err(SealError::BadKeyLength { .. })
));
assert!(matches!(
envelope_from_parts(&ek, &[2u8; 11], b"ct"),
Err(SealError::BadNonceLength { .. })
));
}
#[test]
fn envelope_round_trips_through_wire_parts() {
let (priv_bytes, public) = recipient_keypair();
let env = seal(&public, b"wire-payload", b"wire-aad").expect("seal");
let rebuilt = envelope_from_parts(&env.encapsulated_key, &env.nonce, &env.ciphertext)
.expect("rebuild");
assert_eq!(rebuilt, env);
let recovered = open(&priv_bytes, &rebuilt, b"wire-aad").expect("open");
assert_eq!(recovered.as_slice(), b"wire-payload");
}
}