#![doc = include_str!("../README.md")]
use hpke::{
Deserializable, HpkeError, OpModeR, OpModeS, Serializable,
aead::ChaCha20Poly1305,
kdf::HkdfSha256,
kem::{Kem as KemTrait, XWing},
single_shot_open, single_shot_seal_with_rng,
};
mod keys;
mod rng;
pub use keys::{PublicKey, SecretKey};
pub(crate) type XKem = XWing;
pub(crate) type Aead = ChaCha20Poly1305;
pub(crate) type Kdf = HkdfSha256;
const VERSION: u8 = 0x01;
const KEM_ID: u16 = 0x647A;
const KDF_ID: u16 = 0x0001;
const AEAD_ID: u16 = 0x0003;
const HEADER: [u8; 7] = {
let kem = KEM_ID.to_be_bytes();
let kdf = KDF_ID.to_be_bytes();
let aead = AEAD_ID.to_be_bytes();
[VERSION, kem[0], kem[1], kdf[0], kdf[1], aead[0], aead[1]]
};
const HEADER_LEN: usize = HEADER.len();
const ENC_LEN: usize = 1120;
const MAX_INFO_LEN: usize = (1 << 16) - 1 - 5;
impl PublicKey {
pub fn seal(
recipient: &PublicKey,
plaintext: &[u8],
info: Option<&[u8]>,
) -> Result<Vec<u8>, Error> {
if info.unwrap_or_default().len() > MAX_INFO_LEN {
return Err(Error::InfoExceedsSize);
}
let (enc, ciphertext) = single_shot_seal_with_rng::<Aead, Kdf, XKem>(
&OpModeS::Base,
recipient.as_hpke(),
info.unwrap_or_default(),
plaintext,
&[],
&mut rng::os_csprng()?,
)?;
let enc = enc.to_bytes();
let mut out = Vec::with_capacity(HEADER_LEN + enc.len() + ciphertext.len());
out.extend_from_slice(&HEADER);
out.extend_from_slice(enc.as_slice());
out.extend_from_slice(&ciphertext);
Ok(out)
}
}
impl SecretKey {
pub fn unseal(
recipient: &SecretKey,
ciphertext: &[u8],
info: Option<&[u8]>,
) -> Result<Vec<u8>, Error> {
if info.unwrap_or_default().len() > MAX_INFO_LEN {
return Err(Error::InfoExceedsSize);
}
if ciphertext.is_empty() {
return Err(Error::EmptyCiphertext);
}
let Some((&[version, kem0, kem1, kdf0, kdf1, aead0, adead1], ciphertext)) =
ciphertext.split_first_chunk::<HEADER_LEN>()
else {
return Err(Error::Decode);
};
if version != VERSION {
return Err(Error::UnsupportedVersion(version));
}
let (kem_id, kdf_id, aead_id) = (
u16::from_be_bytes([kem0, kem1]),
u16::from_be_bytes([kdf0, kdf1]),
u16::from_be_bytes([aead0, adead1]),
);
if (kem_id, kdf_id, aead_id) != (KEM_ID, KDF_ID, AEAD_ID) {
return Err(Error::UnsupportedSuite);
}
let Some((enc_bytes, ciphertext)) = ciphertext.split_first_chunk::<ENC_LEN>() else {
return Err(Error::Decode);
};
let enc = <XKem as KemTrait>::EncappedKey::from_bytes(enc_bytes)?;
let plaintext = single_shot_open::<Aead, Kdf, XKem>(
&OpModeR::Base,
recipient.as_hpke(),
&enc,
info.unwrap_or_default(),
ciphertext,
&[],
)?;
Ok(plaintext)
}
}
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("provided key is malformed")]
KeyFormat,
#[error("sealed message is malformed")]
Decode,
#[error("decapsulation failed")]
Decap,
#[error("unable to unseal: AEAD authentication failed")]
Unseal,
#[error("seal unexpectedly failed")]
Seal,
#[error("operating system CSPRNG is unavailable")]
Rng,
#[error("internal critical bug")]
Internal,
#[error("unsupported version: {0}")]
UnsupportedVersion(u8),
#[error("unsupported suite")]
UnsupportedSuite,
#[error("info exceeds max size")]
InfoExceedsSize,
#[error("empty ciphertext")]
EmptyCiphertext,
}
impl From<HpkeError> for Error {
fn from(e: HpkeError) -> Self {
match e {
HpkeError::OpenError => Error::Unseal,
HpkeError::DecapError => Error::Decap,
HpkeError::EncapError | HpkeError::SealError => Error::Seal,
HpkeError::ValidationError | HpkeError::IncorrectInputLength(_, _) => Error::Decode,
HpkeError::MessageLimitReached
| HpkeError::KdfOutputTooLong
| HpkeError::InvalidPskBundle => Error::Internal,
}
}
}
#[expect(clippy::unwrap_used, reason = "clearer in tests")]
#[cfg(test)]
mod tests {
use super::{ENC_LEN, Error, HEADER, HEADER_LEN, MAX_INFO_LEN, PublicKey, SecretKey, VERSION};
use std::collections::HashSet;
const TAG_LEN: usize = 16;
fn keypair(seed: &[u8; 32]) -> (SecretKey, PublicKey) {
let sk = SecretKey::from_seed(seed);
let pk = sk.public_key();
(sk, pk)
}
#[test]
fn seal_unseal_roundtrip() {
let (sk, pk) = keypair(&[7u8; 32]);
let msg: &[u8] = b"execute order 66";
let sealed = PublicKey::seal(&pk, msg, None).unwrap();
let unsealed = SecretKey::unseal(&sk, &sealed, None).unwrap();
assert_eq!(unsealed, msg);
}
#[test]
fn roundtrips_empty_plaintext() {
let (sk, pk) = keypair(&[0u8; 32]);
let sealed = PublicKey::seal(&pk, &[], None).unwrap();
assert!(!sealed.is_empty());
let unsealed = SecretKey::unseal(&sk, &sealed, None).unwrap();
assert!(unsealed.is_empty());
}
#[test]
fn roundtrips_with_matching_info() {
let (sk, pk) = keypair(&[3u8; 32]);
let msg: &[u8] = b"never tell me the odds";
let info: &[u8] = b"com.example";
let sealed = PublicKey::seal(&pk, msg, Some(info)).unwrap();
let unsealed = SecretKey::unseal(&sk, &sealed, Some(info)).unwrap();
assert_eq!(unsealed, msg);
}
#[test]
fn unseal_fails_with_mismatched_info() {
let (sk, pk) = keypair(&[4u8; 32]);
let sealed = PublicKey::seal(&pk, b"it's a trap", Some(b"context-a")).unwrap();
assert_eq!(
SecretKey::unseal(&sk, &sealed, Some(b"context-b")),
Err(Error::Unseal)
);
}
#[test]
fn unseal_fails_with_wrong_recipient() {
let (_sk, pk) = keypair(&[1u8; 32]);
let (other_sk, _other_pk) = keypair(&[2u8; 32]);
let sealed = PublicKey::seal(&pk, b"for my eyes only", None).unwrap();
assert_eq!(
SecretKey::unseal(&other_sk, &sealed, None),
Err(Error::Unseal)
);
}
#[test]
fn unseal_fails_on_tampered_ciphertext() {
let (sk, pk) = keypair(&[9u8; 32]);
let sealed = PublicKey::seal(&pk, b"execute order 66", None).unwrap();
let mut tampered = sealed.clone();
let last = tampered.len() - 1;
tampered[last] ^= 0x01;
assert_eq!(SecretKey::unseal(&sk, &tampered, None), Err(Error::Unseal));
}
#[test]
fn unseal_rejects_empty_ciphertext() {
let (sk, _pk) = keypair(&[1u8; 32]);
assert_eq!(
SecretKey::unseal(&sk, b"", None),
Err(Error::EmptyCiphertext)
);
}
#[test]
fn unseal_rejects_ciphertext_shorter_than_header() {
let (sk, _pk) = keypair(&[1u8; 32]);
assert_eq!(SecretKey::unseal(&sk, b"short", None), Err(Error::Decode));
}
#[test]
fn unseal_rejects_truncated_encapsulated_key() {
let (sk, pk) = keypair(&[6u8; 32]);
let sealed = PublicKey::seal(&pk, b"this message will self-destruct", None).unwrap();
let truncated = &sealed[..HEADER_LEN + 10];
assert_eq!(SecretKey::unseal(&sk, truncated, None), Err(Error::Decode));
}
#[test]
fn unseal_rejects_unsupported_version() {
let (sk, pk) = keypair(&[1u8; 32]);
let sealed = PublicKey::seal(&pk, b"hello there", None).unwrap();
let mut bad = sealed.clone();
let bad_version = VERSION.wrapping_add(1);
bad[0] = bad_version;
assert_eq!(
SecretKey::unseal(&sk, &bad, None),
Err(Error::UnsupportedVersion(bad_version))
);
}
#[test]
fn unseal_rejects_unsupported_suite() {
let (sk, pk) = keypair(&[1u8; 32]);
let sealed = PublicKey::seal(&pk, b"hello there", None).unwrap();
let mut bad = sealed.clone();
bad[1] ^= 0xFF;
assert_eq!(
SecretKey::unseal(&sk, &bad, None),
Err(Error::UnsupportedSuite)
);
}
#[test]
fn seal_prepends_wire_header() {
let (_sk, pk) = keypair(&[1u8; 32]);
let sealed = PublicKey::seal(&pk, b"hello there", None).unwrap();
assert_eq!(&sealed[..HEADER_LEN], &HEADER);
}
#[test]
fn seal_output_has_expected_length() {
let (_sk, pk) = keypair(&[1u8; 32]);
let msg: &[u8] = b"hello there";
let sealed = PublicKey::seal(&pk, msg, None).unwrap();
assert_eq!(sealed.len(), HEADER_LEN + ENC_LEN + msg.len() + TAG_LEN);
}
#[test]
fn seal_is_non_deterministic() {
let (_sk, pk) = keypair(&[1u8; 32]);
let msg: &[u8] = b"same message";
let sealed = PublicKey::seal(&pk, msg, None).unwrap();
let sealed2 = PublicKey::seal(&pk, msg, None).unwrap();
assert_ne!(sealed, sealed2);
}
#[test]
fn seal_draws_fresh_encapsulation_randomness_each_call() {
let (_sk, pk) = keypair(&[1u8; 32]);
let msg: &[u8] = b"same message, same recipient, same info";
let info: &[u8] = b"com.example";
let mut enc_keys = HashSet::new();
for _ in 0..64 {
let sealed = PublicKey::seal(&pk, msg, Some(info)).unwrap();
let enc = sealed[HEADER_LEN..HEADER_LEN + ENC_LEN].to_vec();
assert!(
enc_keys.insert(enc),
"encapsulated key repeated: encapsulation randomness was reused"
);
}
assert_eq!(enc_keys.len(), 64);
}
#[test]
fn seal_and_unseal_accept_info_at_max_len() {
let (sk, pk) = keypair(&[1u8; 32]);
let msg: &[u8] = b"boundary";
let info = vec![0x2a; 2_usize.pow(16) - 5 - 1];
let sealed = PublicKey::seal(&pk, msg, Some(&info)).unwrap();
let unsealed = SecretKey::unseal(&sk, &sealed, Some(&info)).unwrap();
assert_eq!(unsealed, msg);
}
#[test]
fn seal_rejects_info_over_max_len() {
let (_sk, pk) = keypair(&[1u8; 32]);
let info = vec![0x2a; MAX_INFO_LEN + 1];
assert_eq!(
PublicKey::seal(&pk, b"nope", Some(&info)),
Err(Error::InfoExceedsSize)
);
}
#[test]
fn unseal_rejects_info_over_max_len() {
let (sk, pk) = keypair(&[1u8; 32]);
let sealed = PublicKey::seal(&pk, b"nope", None).unwrap();
let info = vec![0x2a; MAX_INFO_LEN + 1];
assert_eq!(
SecretKey::unseal(&sk, &sealed, Some(&info)),
Err(Error::InfoExceedsSize)
);
}
}