use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use hkdf::Hkdf;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use x25519_dalek::{PublicKey, StaticSecret};
use zeroize::Zeroizing;
use crate::error::{TrustError, TrustResult};
const SEAL_INFO: &[u8] = b"lys-sealed-envelope/v1";
const HKDF_OUTPUT_LEN: usize = 44;
const AES_KEY_LEN: usize = 32;
const NONCE_LEN: usize = 12;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SealedEnvelope {
pub ephemeral_public_key: [u8; 32],
pub ciphertext: Vec<u8>,
pub nonce: [u8; NONCE_LEN],
}
impl SealedEnvelope {
pub fn attestation_bytes(&self) -> Vec<u8> {
let mut bytes = Vec::with_capacity(
self.ephemeral_public_key.len() + self.ciphertext.len() + self.nonce.len(),
);
bytes.extend_from_slice(&self.ephemeral_public_key);
bytes.extend_from_slice(&self.ciphertext);
bytes.extend_from_slice(&self.nonce);
bytes
}
}
pub fn seal(payload: &[u8], recipient_public_key: &[u8; 32]) -> TrustResult<SealedEnvelope> {
let mut ephemeral_seed = Zeroizing::new([0u8; 32]);
rand::rng().fill_bytes(ephemeral_seed.as_mut_slice());
let ephemeral_secret = StaticSecret::from(*ephemeral_seed);
let ephemeral_public = PublicKey::from(&ephemeral_secret);
let recipient_public = PublicKey::from(*recipient_public_key);
let shared = ephemeral_secret.diffie_hellman(&recipient_public);
if !shared.was_contributory() {
return Err(TrustError::Seal {
reason: "recipient public key is a low-order point".to_string(),
});
}
let (key_bytes, nonce_bytes) = derive_key_and_nonce(
shared.as_bytes(),
&ephemeral_public.to_bytes(),
recipient_public_key,
)?;
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&*key_bytes));
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(
nonce,
Payload {
msg: payload,
aad: &[],
},
)
.map_err(|e| TrustError::Seal {
reason: format!("AES-256-GCM encryption failed: {e}"),
})?;
Ok(SealedEnvelope {
ephemeral_public_key: ephemeral_public.to_bytes(),
ciphertext,
nonce: nonce_bytes,
})
}
pub fn open(
envelope: &SealedEnvelope,
recipient_private_key: &StaticSecret,
) -> TrustResult<Vec<u8>> {
let ephemeral_public = PublicKey::from(envelope.ephemeral_public_key);
let recipient_public = PublicKey::from(recipient_private_key);
let shared = recipient_private_key.diffie_hellman(&ephemeral_public);
if !shared.was_contributory() {
return Err(TrustError::UnsealFailed);
}
let (key_bytes, _derived_nonce) = derive_key_and_nonce(
shared.as_bytes(),
&envelope.ephemeral_public_key,
&recipient_public.to_bytes(),
)
.map_err(|_err| TrustError::UnsealFailed)?;
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&*key_bytes));
let nonce = Nonce::from_slice(&envelope.nonce);
cipher
.decrypt(
nonce,
Payload {
msg: &envelope.ciphertext,
aad: &[],
},
)
.map_err(|_err| TrustError::UnsealFailed)
}
fn derive_key_and_nonce(
shared_secret: &[u8],
ephemeral_public_key: &[u8; 32],
recipient_public_key: &[u8; 32],
) -> TrustResult<(Zeroizing<[u8; AES_KEY_LEN]>, [u8; NONCE_LEN])> {
let hk = Hkdf::<Sha256>::new(None, shared_secret);
let mut info = Vec::with_capacity(SEAL_INFO.len() + 64);
info.extend_from_slice(SEAL_INFO);
info.extend_from_slice(ephemeral_public_key);
info.extend_from_slice(recipient_public_key);
let mut okm = Zeroizing::new([0u8; HKDF_OUTPUT_LEN]);
hk.expand(&info, okm.as_mut_slice())
.map_err(|e| TrustError::Seal {
reason: format!("HKDF expand failed: {e}"),
})?;
let mut key = Zeroizing::new([0u8; AES_KEY_LEN]);
key.copy_from_slice(&okm[..AES_KEY_LEN]);
let mut nonce = [0u8; NONCE_LEN];
nonce.copy_from_slice(&okm[AES_KEY_LEN..]);
Ok((key, nonce))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::keys::identity::Ed25519Identity;
use tempfile::tempdir;
const LOW_ORDER_POINT_ORDER_8: [u8; 32] = [
0xe0, 0xeb, 0x7a, 0x7c, 0x3b, 0x41, 0xb8, 0xae, 0x16, 0x56, 0xe3, 0xfa, 0xf1, 0x9f, 0xc4,
0x6a, 0xda, 0x09, 0x8d, 0xeb, 0x9c, 0x32, 0xb1, 0xfd, 0x86, 0x62, 0x05, 0x16, 0x5f, 0x49,
0xb8, 0x00,
];
fn identity() -> Ed25519Identity {
let dir = tempdir().unwrap();
Ed25519Identity::load_or_generate(&dir.path().join("id.key")).unwrap()
}
#[test]
fn seal_returns_envelope_with_expected_field_shapes() {
let recipient = identity();
let payload = b"sealed credential bundle";
let env = seal(payload, &recipient.x25519_public_key()).unwrap();
assert_eq!(env.ephemeral_public_key.len(), 32);
assert_eq!(env.nonce.len(), 12);
assert_eq!(env.ciphertext.len(), payload.len() + 16);
}
#[test]
fn seal_open_roundtrip_returns_original_payload() {
let recipient = identity();
let payload = b"API token: super-secret";
let env = seal(payload, &recipient.x25519_public_key()).unwrap();
let opened = open(&env, &recipient.x25519_static_secret()).unwrap();
assert_eq!(opened.as_slice(), payload);
}
#[test]
fn open_with_wrong_private_key_returns_unseal_failed() {
let recipient = identity();
let wrong = identity();
let env = seal(b"payload", &recipient.x25519_public_key()).unwrap();
let err = open(&env, &wrong.x25519_static_secret()).unwrap_err();
assert!(matches!(err, TrustError::UnsealFailed));
}
#[test]
fn tampered_ciphertext_returns_unseal_failed() {
let recipient = identity();
let mut env = seal(b"payload", &recipient.x25519_public_key()).unwrap();
env.ciphertext[0] ^= 0x01;
let err = open(&env, &recipient.x25519_static_secret()).unwrap_err();
assert!(matches!(err, TrustError::UnsealFailed));
}
#[test]
fn tampered_nonce_returns_unseal_failed() {
let recipient = identity();
let mut env = seal(b"payload", &recipient.x25519_public_key()).unwrap();
env.nonce[0] ^= 0x01;
let err = open(&env, &recipient.x25519_static_secret()).unwrap_err();
assert!(matches!(err, TrustError::UnsealFailed));
}
#[test]
fn tampered_ephemeral_key_returns_unseal_failed() {
let recipient = identity();
let mut env = seal(b"payload", &recipient.x25519_public_key()).unwrap();
let other = identity();
env.ephemeral_public_key = other.x25519_public_key();
let err = open(&env, &recipient.x25519_static_secret()).unwrap_err();
assert!(matches!(err, TrustError::UnsealFailed));
}
#[test]
fn low_order_ephemeral_key_all_zero_returns_unseal_failed() {
let recipient = identity();
let mut env = seal(b"payload", &recipient.x25519_public_key()).unwrap();
env.ephemeral_public_key = [0u8; 32];
let err = open(&env, &recipient.x25519_static_secret()).unwrap_err();
assert!(matches!(err, TrustError::UnsealFailed));
}
#[test]
fn low_order_ephemeral_key_order_8_returns_unseal_failed() {
let recipient = identity();
let mut env = seal(b"payload", &recipient.x25519_public_key()).unwrap();
env.ephemeral_public_key = LOW_ORDER_POINT_ORDER_8;
let err = open(&env, &recipient.x25519_static_secret()).unwrap_err();
assert!(matches!(err, TrustError::UnsealFailed));
}
#[test]
fn sealing_to_low_order_recipient_key_returns_seal_error() {
for low_order in [[0u8; 32], LOW_ORDER_POINT_ORDER_8] {
let err = seal(b"payload", &low_order).unwrap_err();
match err {
TrustError::Seal { reason } => {
assert!(
reason.contains("low-order"),
"unexpected seal error reason: {reason}"
);
}
other => panic!("expected TrustError::Seal, got {other:?}"),
}
}
}
#[test]
fn two_seals_of_same_payload_produce_distinct_ciphertexts_and_keys() {
let recipient = identity();
let payload = b"identical payload";
let env_a = seal(payload, &recipient.x25519_public_key()).unwrap();
let env_b = seal(payload, &recipient.x25519_public_key()).unwrap();
assert_ne!(env_a.ephemeral_public_key, env_b.ephemeral_public_key);
assert_ne!(env_a.ciphertext, env_b.ciphertext);
assert_ne!(env_a.nonce, env_b.nonce);
}
#[test]
fn empty_payload_seals_and_opens() {
let recipient = identity();
let env = seal(&[], &recipient.x25519_public_key()).unwrap();
assert_eq!(env.ciphertext.len(), 16);
let opened = open(&env, &recipient.x25519_static_secret()).unwrap();
assert!(opened.is_empty());
}
#[test]
fn sealed_envelope_round_trips_through_postcard() {
let recipient = identity();
let env = seal(b"persisted", &recipient.x25519_public_key()).unwrap();
let bytes = postcard::to_allocvec(&env).unwrap();
let restored: SealedEnvelope = postcard::from_bytes(&bytes).unwrap();
assert_eq!(restored, env);
let opened = open(&restored, &recipient.x25519_static_secret()).unwrap();
assert_eq!(opened.as_slice(), b"persisted");
}
#[test]
fn attestation_bytes_concatenates_in_declared_order() {
let recipient = identity();
let env = seal(b"x", &recipient.x25519_public_key()).unwrap();
let bytes = env.attestation_bytes();
let expected_len = env.ephemeral_public_key.len() + env.ciphertext.len() + env.nonce.len();
assert_eq!(bytes.len(), expected_len);
assert_eq!(&bytes[..32], env.ephemeral_public_key.as_slice());
assert_eq!(
&bytes[32..32 + env.ciphertext.len()],
env.ciphertext.as_slice()
);
assert_eq!(&bytes[32 + env.ciphertext.len()..], env.nonce.as_slice());
}
}