use hpke::{
aead::ChaCha20Poly1305, kdf::HkdfSha256, kem::X25519HkdfSha256, Deserializable,
Kem as KemTrait, OpModeR, OpModeS, Serializable,
};
use thiserror::Error;
type Kem = X25519HkdfSha256;
type Kdf = HkdfSha256;
type Aead = ChaCha20Poly1305;
const HPKE_INFO: &[u8] = b"cloacina/fleet-secret-envelope/v1";
#[derive(Debug, Error)]
pub enum EnvelopeError {
#[error("invalid recipient public key")]
InvalidPublicKey,
#[error("invalid encapsulated key")]
InvalidEncappedKey,
#[error("envelope wrap failed: {0}")]
Wrap(String),
#[error("envelope unwrap failed: {0}")]
Unwrap(String),
}
pub struct EphemeralPrivateKey(<Kem as KemTrait>::PrivateKey);
impl std::fmt::Debug for EphemeralPrivateKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("EphemeralPrivateKey(<redacted>)")
}
}
#[derive(Debug)]
pub struct EphemeralKeypair {
pub private: EphemeralPrivateKey,
pub public_key_bytes: Vec<u8>,
}
pub fn generate_ephemeral_keypair() -> EphemeralKeypair {
let mut rng = rand::thread_rng();
let (private, public) = Kem::gen_keypair(&mut rng);
EphemeralKeypair {
private: EphemeralPrivateKey(private),
public_key_bytes: public.to_bytes().to_vec(),
}
}
pub fn wrap(
recipient_public_key: &[u8],
plaintext: &[u8],
aad: &[u8],
) -> Result<(Vec<u8>, Vec<u8>), EnvelopeError> {
let pk_recip = <Kem as KemTrait>::PublicKey::from_bytes(recipient_public_key)
.map_err(|_| EnvelopeError::InvalidPublicKey)?;
let mut rng = rand::thread_rng();
let (encapped, ciphertext) = hpke::single_shot_seal::<Aead, Kdf, Kem, _>(
&OpModeS::Base,
&pk_recip,
HPKE_INFO,
plaintext,
aad,
&mut rng,
)
.map_err(|e| EnvelopeError::Wrap(e.to_string()))?;
Ok((encapped.to_bytes().to_vec(), ciphertext))
}
pub fn unwrap(
recipient_private_key: &EphemeralPrivateKey,
enc: &[u8],
ciphertext: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, EnvelopeError> {
let encapped = <Kem as KemTrait>::EncappedKey::from_bytes(enc)
.map_err(|_| EnvelopeError::InvalidEncappedKey)?;
hpke::single_shot_open::<Aead, Kdf, Kem>(
&OpModeR::Base,
&recipient_private_key.0,
&encapped,
HPKE_INFO,
ciphertext,
aad,
)
.map_err(|e| EnvelopeError::Unwrap(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
const AAD: &[u8] = b"exec-1/db_prod";
#[test]
fn round_trip_recovers_plaintext() {
let kp = generate_ephemeral_keypair();
let plaintext = b"super-secret-password";
let (enc, ct) = wrap(&kp.public_key_bytes, plaintext, AAD).expect("wrap");
assert_ne!(ct.as_slice(), plaintext.as_slice());
let recovered = unwrap(&kp.private, &enc, &ct, AAD).expect("unwrap");
assert_eq!(recovered.as_slice(), plaintext.as_slice());
}
#[test]
fn blob_wrapped_to_a_does_not_unwrap_with_b() {
let a = generate_ephemeral_keypair();
let b = generate_ephemeral_keypair();
let plaintext = b"bound-to-A-only";
let (enc, ct) = wrap(&a.public_key_bytes, plaintext, AAD).expect("wrap to A");
let err = unwrap(&b.private, &enc, &ct, AAD).expect_err("must not unwrap with B");
assert!(matches!(err, EnvelopeError::Unwrap(_)));
}
#[test]
fn tampered_ciphertext_fails() {
let kp = generate_ephemeral_keypair();
let (enc, mut ct) = wrap(&kp.public_key_bytes, b"integrity", AAD).expect("wrap");
ct[0] ^= 0x01;
assert!(unwrap(&kp.private, &enc, &ct, AAD).is_err());
}
#[test]
fn tampered_encapped_key_fails() {
let kp = generate_ephemeral_keypair();
let (mut enc, ct) = wrap(&kp.public_key_bytes, b"integrity", AAD).expect("wrap");
enc[0] ^= 0x01;
assert!(unwrap(&kp.private, &enc, &ct, AAD).is_err());
}
#[test]
fn wrong_aad_fails() {
let kp = generate_ephemeral_keypair();
let (enc, ct) = wrap(&kp.public_key_bytes, b"aad-bound", AAD).expect("wrap");
let err =
unwrap(&kp.private, &enc, &ct, b"exec-2/db_prod").expect_err("aad mismatch must fail");
assert!(matches!(err, EnvelopeError::Unwrap(_)));
}
#[test]
fn public_key_is_x25519_sized() {
let kp = generate_ephemeral_keypair();
assert_eq!(kp.public_key_bytes.len(), 32);
}
#[test]
fn invalid_public_key_rejected() {
let err = wrap(&[0u8; 4], b"x", AAD).expect_err("bad pubkey");
assert!(matches!(err, EnvelopeError::InvalidPublicKey));
}
}