lys-core 0.2.0

Domain-agnostic cryptographic trust primitives — certificate authority, Merkle transparency log, signed attestations, sealed envelopes, and Ed25519 key management.
Documentation
//! [`SealedEnvelope`] — the on-the-wire payload produced by [`seal`] and
//! consumed by [`open`].
//!
//! Encryption is X25519 ephemeral key agreement plus AES-256-GCM with a
//! HKDF-SHA256-derived key and nonce. Each [`seal`] generates a fresh
//! ephemeral X25519 keypair; the ephemeral public half travels in the
//! envelope, the secret half drops at end of scope (forward secrecy per
//! envelope). The recipient's long-term X25519 key is required to recompute
//! the shared secret and decrypt.
//!
//! HKDF expansion produces 44 bytes — 32 for the AES-256 key and 12 for the
//! GCM nonce — so the nonce is bound to the shared secret and never reused
//! by accident across distinct seals (fresh ephemeral key ⇒ fresh shared
//! secret ⇒ fresh nonce). The HKDF `info` input binds both public keys
//! (`SEAL_INFO || ephemeral_public_key || recipient_public_key`, the `NaCl`
//! `crypto_box_seal` pattern) so the derived key is committed to the exact
//! key pair that produced the shared secret — defense in depth against any
//! key-substitution confusion.
//!
//! **Contributory-behaviour check.** X25519 accepts low-order points and
//! silently produces an all-zero shared secret for them. Both [`seal`] and
//! [`open`] reject non-contributory Diffie-Hellman results: sealing to a
//! low-order recipient key fails with [`TrustError::Seal`], and opening an
//! envelope whose ephemeral point is low-order fails with
//! [`TrustError::UnsealFailed`]. An attacker can therefore never force a
//! predictable shared secret.
//!
//! **Single failure arbiter.** [`open`] performs no independent plausibility
//! checks on the envelope fields — AES-GCM tag verification is the sole
//! arbiter of decryption failure. A wrong recipient key yields a wrong
//! derived AES key; a tampered nonce or ephemeral point yields a key/nonce
//! pair that never encrypted the ciphertext; every case surfaces as the same
//! constant-shape GCM tag failure mapped to [`TrustError::UnsealFailed`].
//! This preserves the non-oracle property: the caller (and any observer of
//! timing) cannot learn *why* an unseal failed.
//!
//! The unsealed payload is returned as `Vec<u8>`. The trust crate does not
//! interpret it — consumers decide what the bytes mean (sealed API token,
//! credential bundle, etc.).

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};

/// Domain-separation tag for HKDF expansion. Bound to this crate's
/// sealed-envelope format so the same shared secret used in any other
/// protocol context (e.g. session-key derivation in a consumer) cannot
/// collide. The full HKDF `info` is this tag concatenated with the ephemeral
/// and recipient public keys — see [`derive_key_and_nonce`].
const SEAL_INFO: &[u8] = b"lys-sealed-envelope/v1";

/// HKDF output length: 32-byte AES-256 key + 12-byte AES-GCM nonce.
const HKDF_OUTPUT_LEN: usize = 44;

/// AES-256 key length in bytes.
const AES_KEY_LEN: usize = 32;

/// AES-GCM nonce length in bytes.
const NONCE_LEN: usize = 12;

/// Sealed payload bound to a single recipient X25519 public key.
///
/// Constructed by [`seal`] and consumed by [`open`]. The envelope is the
/// wire format — `serde` is derived so consumers can carry it through any
/// transport or storage layer.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SealedEnvelope {
    /// X25519 public half of the ephemeral keypair generated by [`seal`].
    /// The matching ephemeral secret is dropped at end of seal scope; the
    /// recipient combines this with their long-term X25519 secret to
    /// recompute the same shared secret.
    pub ephemeral_public_key: [u8; 32],

    /// AES-256-GCM ciphertext of the original payload, with the 16-byte
    /// authentication tag appended (per the `aes-gcm` crate convention).
    pub ciphertext: Vec<u8>,

    /// 12-byte AES-GCM nonce, derived from the same HKDF expansion that
    /// produces the AES key. Bound to the shared secret, never randomly
    /// generated, so distinct seals produce distinct nonces. [`open`] uses
    /// this field as the operative nonce; any tampering surfaces as an
    /// AES-GCM tag failure.
    pub nonce: [u8; NONCE_LEN],
}

impl SealedEnvelope {
    /// Canonical byte representation of the envelope, used by the
    /// authenticated composition ([`super::authenticated::sign_and_seal`])
    /// as the body of the attested message (prefixed there with a
    /// sealed-envelope context tag — see [`super::authenticated`]).
    ///
    /// The encoding is `ephemeral_public_key || ciphertext || nonce`, which
    /// covers every byte that travels with the envelope. Binding the sender
    /// signature to this exact byte sequence means an adversary cannot
    /// substitute a different ciphertext, ephemeral key, or nonce without
    /// invalidating the signature.
    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
    }
}

/// Encrypts `payload` for the holder of the X25519 secret matching
/// `recipient_public_key`, returning a [`SealedEnvelope`].
///
/// A fresh X25519 ephemeral keypair is generated per call. The ephemeral
/// secret is combined with the recipient's public key via Diffie-Hellman;
/// the shared secret is run through HKDF-SHA256 with an `info` input of
/// `SEAL_INFO || ephemeral_public_key || recipient_public_key` to produce a
/// 32-byte AES-256 key and a 12-byte AES-GCM nonce. The nonce is derived
/// from the same expansion as the key, so it is unique whenever the shared
/// secret is unique (which is always, since the ephemeral key is fresh).
/// Binding both public keys into the derivation commits the key material to
/// the exact key pair involved (`NaCl` `crypto_box_seal` pattern).
///
/// The Diffie-Hellman result is checked for contributory behaviour: a
/// recipient public key that is a low-order point would force an all-zero
/// shared secret regardless of the ephemeral secret, so it is rejected.
///
/// # Errors
///
/// Returns [`TrustError::Seal`] if `recipient_public_key` is a low-order
/// point (non-contributory Diffie-Hellman), if HKDF expansion fails (only
/// possible for output lengths above 255 × 32 bytes — never for the 44-byte
/// output produced here), or if AES-GCM encryption fails (only possible if
/// the payload length exceeds `2^39 - 256` bytes, the AES-GCM hard limit).
pub fn seal(payload: &[u8], recipient_public_key: &[u8; 32]) -> TrustResult<SealedEnvelope> {
    // `x25519-dalek 2`'s `EphemeralSecret::random_from_rng` requires an RNG
    // matching the rand_core version it was compiled against, which does
    // not line up with the pinned `rand 0.9`. Generating the 32-byte
    // ephemeral scalar from `rand` first and constructing a single-use
    // `StaticSecret` is the same workaround the sibling crypto crates
    // already use. The secret is dropped at end of scope, so forward
    // secrecy per envelope is preserved.
    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,
    })
}

/// Decrypts `envelope` using `recipient_private_key`.
///
/// Recomputes the X25519 shared secret from `envelope.ephemeral_public_key`
/// and `recipient_private_key` (rejecting low-order ephemeral points via the
/// contributory-behaviour check), re-derives the AES-256 key via the same
/// HKDF expansion used by [`seal`] — binding the envelope's ephemeral public
/// key and the recipient public key derived from `recipient_private_key`
/// into the `info` input — and decrypts the ciphertext using
/// `envelope.nonce` as the operative nonce.
///
/// AES-GCM tag verification is the single failure arbiter: a wrong recipient
/// key derives a wrong AES key, a tampered nonce or ephemeral point breaks
/// the key/nonce binding, and each case fails the tag check identically. No
/// independent pre-checks are performed, so failure timing does not reveal
/// *which* field was wrong. Every unseal failure — wrong key, tampered
/// ciphertext, tampered nonce, tampered or low-order ephemeral key, or the
/// practically unreachable HKDF expansion failure — collapses to
/// [`TrustError::UnsealFailed`]; the caller cannot distinguish them by error
/// variant, which is the desired non-oracle behaviour.
///
/// # Errors
///
/// Returns [`TrustError::UnsealFailed`] if the ephemeral public key is a
/// low-order point, if AES-GCM authentication or decryption fails, or in
/// the practically unreachable case that HKDF expansion fails.
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() {
        // A low-order ephemeral point forces an all-zero shared secret
        // regardless of the recipient's private key — an attacker-chosen,
        // predictable key. Reject before any key material is derived.
        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)?;

    // The envelope's own nonce is the operative nonce; the derived nonce is
    // intentionally not compared against it. If the envelope nonce was
    // tampered with, it differs from the nonce the ciphertext was encrypted
    // under and the GCM tag check below fails — AES-GCM stays the single
    // failure arbiter and no early-exit timing signal is introduced.
    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)
}

/// HKDF-SHA256 expansion of the X25519 shared secret into the AES key and
/// GCM nonce used by [`seal`] and [`open`].
///
/// The `info` input is `SEAL_INFO || ephemeral_public_key ||
/// recipient_public_key`, binding the derived key material to both halves of
/// the key agreement (the `NaCl` `crypto_box_seal` pattern). Both sides must
/// pass identical values: [`seal`] has both keys directly, [`open`] takes
/// the ephemeral key from the envelope and derives the recipient public key
/// from the recipient's private key.
///
/// The AES key is returned wrapped in [`Zeroizing`] so it is overwritten on
/// drop; the nonce is not a secret and ships in the envelope.
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)]
#[path = "sealed_envelope_tests.rs"]
mod tests;