use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use hkdf::Hkdf;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret};
use crate::error::{AppError, AppResult};
pub const SEAL_ALG: &str = "x25519-hkdf-sha256-chacha20-poly1305";
pub const SEAL_V: u8 = 1;
const KDF_INFO: &[u8] = b"noetl-sealed-v1";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SealedEnvelope {
pub alg: String,
pub v: u8,
pub eph_pub: String,
pub ciphertext: String,
}
pub fn seal(recipient_pk: &PublicKey, plaintext: &[u8]) -> AppResult<SealedEnvelope> {
let eph_sk = EphemeralSecret::random_from_rng(rand_core::OsRng);
let eph_pk = PublicKey::from(&eph_sk);
let shared = eph_sk.diffie_hellman(recipient_pk);
let (key, nonce) = derive_key_nonce(shared.as_bytes())?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let ciphertext = cipher
.encrypt(
Nonce::from_slice(&nonce),
Payload {
msg: plaintext,
aad: associated_data().as_bytes(),
},
)
.map_err(|e| AppError::Internal(format!("sealed encrypt: {e}")))?;
Ok(SealedEnvelope {
alg: SEAL_ALG.to_string(),
v: SEAL_V,
eph_pub: B64.encode(eph_pk.as_bytes()),
ciphertext: B64.encode(&ciphertext),
})
}
pub fn open(recipient_sk: &StaticSecret, env: &SealedEnvelope) -> AppResult<Vec<u8>> {
if env.alg != SEAL_ALG {
return Err(AppError::BadRequest(format!(
"sealed open: unsupported alg '{}' (expected '{SEAL_ALG}')",
env.alg
)));
}
if env.v != SEAL_V {
return Err(AppError::BadRequest(format!(
"sealed open: unsupported version {} (expected {SEAL_V})",
env.v
)));
}
let eph_pub_bytes = B64
.decode(&env.eph_pub)
.map_err(|e| AppError::BadRequest(format!("sealed open: eph_pub base64: {e}")))?;
let eph_pub_array: [u8; 32] = eph_pub_bytes.as_slice().try_into().map_err(|_| {
AppError::BadRequest(format!(
"sealed open: eph_pub must be 32 bytes, got {}",
eph_pub_bytes.len()
))
})?;
let eph_pk = PublicKey::from(eph_pub_array);
let ciphertext = B64
.decode(&env.ciphertext)
.map_err(|e| AppError::BadRequest(format!("sealed open: ciphertext base64: {e}")))?;
let shared = recipient_sk.diffie_hellman(&eph_pk);
let (key, nonce) = derive_key_nonce(shared.as_bytes())?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
cipher
.decrypt(
Nonce::from_slice(&nonce),
Payload {
msg: &ciphertext,
aad: associated_data().as_bytes(),
},
)
.map_err(|e| AppError::BadRequest(format!("sealed open: AEAD verify/decrypt: {e}")))
}
fn derive_key_nonce(shared: &[u8; 32]) -> AppResult<([u8; 32], [u8; 12])> {
let hkdf = Hkdf::<Sha256>::new(None, shared);
let mut okm = [0u8; 32 + 12];
hkdf.expand(KDF_INFO, &mut okm)
.map_err(|e| AppError::Internal(format!("sealed kdf: {e}")))?;
let mut key = [0u8; 32];
let mut nonce = [0u8; 12];
key.copy_from_slice(&okm[..32]);
nonce.copy_from_slice(&okm[32..]);
Ok((key, nonce))
}
fn associated_data() -> String {
format!("{SEAL_ALG}|v={SEAL_V}")
}
#[cfg(test)]
mod tests {
use super::*;
fn recipient_keypair() -> (StaticSecret, PublicKey) {
let sk = StaticSecret::random_from_rng(rand_core::OsRng);
let pk = PublicKey::from(&sk);
(sk, pk)
}
#[test]
fn round_trip_short_payload() {
let (sk, pk) = recipient_keypair();
let env = seal(&pk, b"hello-secret").unwrap();
let opened = open(&sk, &env).unwrap();
assert_eq!(opened, b"hello-secret");
}
#[test]
fn round_trip_realistic_credential_payload() {
let (sk, pk) = recipient_keypair();
let plaintext =
br#"{"type":"bearer","data":{"token":"sk-test-AbCdEf123","expires_in":3600}}"#;
let env = seal(&pk, plaintext).unwrap();
let opened = open(&sk, &env).unwrap();
assert_eq!(&opened[..], plaintext);
}
#[test]
fn envelope_uses_documented_wire_constants() {
let (_, pk) = recipient_keypair();
let env = seal(&pk, b"x").unwrap();
assert_eq!(env.alg, SEAL_ALG);
assert_eq!(env.v, SEAL_V);
let eph = B64.decode(env.eph_pub).unwrap();
assert_eq!(eph.len(), 32, "eph_pub is 32 bytes (X25519)");
let ct = B64.decode(env.ciphertext).unwrap();
assert_eq!(ct.len(), 1 + 16);
}
#[test]
fn two_seals_to_same_recipient_use_distinct_eph_keys() {
let (_, pk) = recipient_keypair();
let a = seal(&pk, b"same-input").unwrap();
let b = seal(&pk, b"same-input").unwrap();
assert_ne!(a.eph_pub, b.eph_pub);
assert_ne!(a.ciphertext, b.ciphertext);
}
#[test]
fn open_rejects_tampered_ciphertext() {
let (sk, pk) = recipient_keypair();
let mut env = seal(&pk, b"important").unwrap();
let mut ct = B64.decode(&env.ciphertext).unwrap();
ct[0] ^= 0x01;
env.ciphertext = B64.encode(&ct);
let err = open(&sk, &env).unwrap_err();
assert!(format!("{err:?}").contains("AEAD verify/decrypt"));
}
#[test]
fn open_rejects_tampered_eph_pub() {
let (sk, pk) = recipient_keypair();
let mut env = seal(&pk, b"important").unwrap();
let (_, other_pk) = recipient_keypair();
env.eph_pub = B64.encode(other_pk.as_bytes());
let err = open(&sk, &env).unwrap_err();
assert!(format!("{err:?}").contains("AEAD verify/decrypt"));
}
#[test]
fn open_rejects_wrong_recipient() {
let (_alice_sk, alice_pk) = recipient_keypair();
let (bob_sk, _bob_pk) = recipient_keypair();
let env = seal(&alice_pk, b"for-alice").unwrap();
let err = open(&bob_sk, &env).unwrap_err();
assert!(format!("{err:?}").contains("AEAD verify/decrypt"));
}
#[test]
fn open_rejects_unknown_alg() {
let (sk, pk) = recipient_keypair();
let mut env = seal(&pk, b"x").unwrap();
env.alg = "x25519-hkdf-sha256-aes-gcm-v2".to_string();
let err = open(&sk, &env).unwrap_err();
assert!(format!("{err:?}").contains("unsupported alg"));
}
#[test]
fn open_rejects_wrong_version() {
let (sk, pk) = recipient_keypair();
let mut env = seal(&pk, b"x").unwrap();
env.v = SEAL_V + 7;
let err = open(&sk, &env).unwrap_err();
assert!(format!("{err:?}").contains("unsupported version"));
}
#[test]
fn open_rejects_short_eph_pub() {
let (sk, pk) = recipient_keypair();
let mut env = seal(&pk, b"x").unwrap();
env.eph_pub = B64.encode([0u8; 16]); let err = open(&sk, &env).unwrap_err();
assert!(format!("{err:?}").contains("eph_pub must be 32 bytes"));
}
#[test]
fn open_rejects_invalid_base64() {
let (sk, pk) = recipient_keypair();
let mut env = seal(&pk, b"x").unwrap();
env.ciphertext = "%not base64%".to_string();
let err = open(&sk, &env).unwrap_err();
assert!(format!("{err:?}").contains("ciphertext base64"));
}
#[test]
fn envelope_round_trips_through_json() {
let (sk, pk) = recipient_keypair();
let env = seal(&pk, b"json-stable").unwrap();
let json = serde_json::to_string(&env).unwrap();
let parsed: SealedEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(env, parsed);
let opened = open(&sk, &parsed).unwrap();
assert_eq!(opened, b"json-stable");
}
}