use hpke::{
Deserializable, Kem as KemTrait, OpModeR, OpModeS, Serializable,
aead::ChaCha20Poly1305,
kdf::HkdfSha256,
kem::X25519HkdfSha256,
rand_core::{CryptoRng, RngCore},
single_shot_open, single_shot_seal,
};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use super::error::SealedTransferError;
const HPKE_INFO: &[u8] = b"vta-sealed-transfer/v1";
struct OsCsprng;
impl RngCore for OsCsprng {
fn next_u32(&mut self) -> u32 {
let mut buf = [0u8; 4];
getrandom::fill(&mut buf).expect("OS CSPRNG failed — see OsCsprng docs");
u32::from_le_bytes(buf)
}
fn next_u64(&mut self) -> u64 {
let mut buf = [0u8; 8];
getrandom::fill(&mut buf).expect("OS CSPRNG failed — see OsCsprng docs");
u64::from_le_bytes(buf)
}
fn fill_bytes(&mut self, dest: &mut [u8]) {
getrandom::fill(dest).expect("OS CSPRNG failed — see OsCsprng docs");
}
}
impl CryptoRng for OsCsprng {}
type Aead = ChaCha20Poly1305;
type Kdf = HkdfSha256;
type Kem = X25519HkdfSha256;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HpkeSealed {
pub kem_encap: [u8; 32],
pub aead_ciphertext: Vec<u8>,
}
pub fn seal(
recipient_pubkey: &[u8; 32],
plaintext: &[u8],
aad: &[u8],
) -> Result<HpkeSealed, SealedTransferError> {
let pk = <Kem as KemTrait>::PublicKey::from_bytes(recipient_pubkey)
.map_err(SealedTransferError::hpke)?;
let mut rng = OsCsprng;
let (encap, ciphertext) = single_shot_seal::<Aead, Kdf, Kem, _>(
&OpModeS::Base,
&pk,
HPKE_INFO,
plaintext,
aad,
&mut rng,
)
.map_err(SealedTransferError::hpke)?;
let encap_bytes = encap.to_bytes();
let kem_encap: [u8; 32] = encap_bytes
.as_slice()
.try_into()
.map_err(|_| SealedTransferError::Hpke("KEM encap not 32 bytes".into()))?;
Ok(HpkeSealed {
kem_encap,
aead_ciphertext: ciphertext,
})
}
pub fn open(
recipient_secret: &[u8; 32],
sealed: &HpkeSealed,
aad: &[u8],
) -> Result<Vec<u8>, SealedTransferError> {
let sk = <Kem as KemTrait>::PrivateKey::from_bytes(recipient_secret)
.map_err(SealedTransferError::hpke)?;
let encap = <Kem as KemTrait>::EncappedKey::from_bytes(&sealed.kem_encap)
.map_err(SealedTransferError::hpke)?;
single_shot_open::<Aead, Kdf, Kem>(
&OpModeR::Base,
&sk,
&encap,
HPKE_INFO,
&sealed.aead_ciphertext,
aad,
)
.map_err(SealedTransferError::hpke)
}
pub fn generate_keypair() -> (Zeroizing<[u8; 32]>, [u8; 32]) {
let mut rng = OsCsprng;
let (sk, pk) = <Kem as KemTrait>::gen_keypair(&mut rng);
let sk_bytes: [u8; 32] = sk
.to_bytes()
.as_slice()
.try_into()
.expect("X25519 secret is 32 bytes");
let pk_bytes: [u8; 32] = pk
.to_bytes()
.as_slice()
.try_into()
.expect("X25519 public is 32 bytes");
(Zeroizing::new(sk_bytes), pk_bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seal_open_round_trip() {
let (sk, pk) = generate_keypair();
let plaintext = b"the quick brown fox";
let aad = b"binding context";
let sealed = seal(&pk, plaintext, aad).expect("seal");
let opened = open(&sk, &sealed, aad).expect("open");
assert_eq!(opened, plaintext);
}
#[test]
fn open_with_wrong_recipient_secret_rejected() {
let (_sk_intended, pk_intended) = generate_keypair();
let (sk_attacker, _pk_attacker) = generate_keypair();
let sealed = seal(&pk_intended, b"secret content", b"aad").expect("seal");
let err = open(&sk_attacker, &sealed, b"aad")
.expect_err("opening with the wrong X25519 secret must fail");
assert!(
matches!(err, SealedTransferError::Hpke(_)),
"expected Hpke variant, got {err:?}"
);
}
#[test]
fn open_with_wrong_aad_rejected() {
let (sk, pk) = generate_keypair();
let sealed = seal(&pk, b"payload", b"correct aad").expect("seal");
let err = open(&sk, &sealed, b"different aad")
.expect_err("AAD mismatch must fail authentication");
assert!(matches!(err, SealedTransferError::Hpke(_)), "got {err:?}");
}
#[test]
fn open_with_tampered_kem_encap_rejected() {
let (sk, pk) = generate_keypair();
let mut sealed = seal(&pk, b"payload", b"aad").expect("seal");
sealed.kem_encap[0] ^= 0x01;
let err = open(&sk, &sealed, b"aad").expect_err("KEM-encap tampering must fail to open");
assert!(matches!(err, SealedTransferError::Hpke(_)), "got {err:?}");
}
}