anubis-age 1.4.0

Post-quantum secure encryption library with hybrid X25519+ML-KEM-1024 mode (internal dependency for anubis-rage)
Documentation
//! X25519 key exchange for classical (non-post-quantum) security.
//!
//! This module provides X25519 ECDH for use in hybrid mode, combining
//! classical proven security with post-quantum security from ML-KEM-1024.
//!
//! ## Security Properties
//!
//! - **Classical Security**: 128-bit (Discrete Log Problem)
//! - **Quantum Security**: ❌ Broken by Shor's algorithm
//! - **Use Case**: Hybrid mode only (combined with ML-KEM-1024)
//!
//! ## Example
//!
//! ```rust
//! use anubis_age::pqc::x25519;
//!
//! // Generate X25519 keypair
//! let identity = x25519::Identity::generate();
//! let recipient = identity.to_public();
//!
//! // In practice, use hybrid mode which combines this with ML-KEM-1024
//! ```

use rand::rngs::OsRng;
use std::fmt;
use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret};

use anubis_core::{
    format::{FileKey, Stanza, FILE_KEY_BYTES},
    primitives::{aead_decrypt, aead_encrypt, hkdf},
    secrecy::ExposeSecret,
};
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use bech32::{ToBase32, Variant};
use zeroize::{Zeroize, Zeroizing};

use crate::{
    error::{DecryptError, EncryptError},
    util::read::base64_arg,
};

const X25519_EPK_LABEL: &str = "X25519";
const X25519_RECIPIENT_TAG: &str = "X25519";

/// An X25519 secret key (for decryption).
///
/// This is the long-term identity that corresponds to an X25519 recipient.
pub struct Identity(StaticSecret);

impl Identity {
    /// Generates a new random X25519 identity.
    pub fn generate() -> Self {
        let mut rng = OsRng;
        Identity(StaticSecret::random_from_rng(&mut rng))
    }

    /// Returns the public key (recipient) corresponding to this identity.
    pub fn to_public(&self) -> Recipient {
        Recipient(PublicKey::from(&self.0))
    }

    /// Performs X25519 Diffie-Hellman with an ephemeral public key.
    pub(crate) fn diffie_hellman(&self, epk: &[u8; 32]) -> Result<[u8; 32], DecryptError> {
        let epk = PublicKey::from(*epk);
        let shared_secret = self.0.diffie_hellman(&epk);
        Ok(*shared_secret.as_bytes())
    }

    /// Attempts to unwrap the given X25519 stanza with this identity.
    fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, DecryptError>> {
        if stanza.tag != X25519_RECIPIENT_TAG {
            return None;
        }

        // Check stanza format: X25519 stanza has 1 arg (EPK) and body contains encrypted file key
        if stanza.args.len() != 1 {
            return Some(Err(DecryptError::InvalidHeader));
        }

        const ENCRYPTED_FILE_KEY_BYTES: usize = FILE_KEY_BYTES + 16;
        if stanza.body.len() != ENCRYPTED_FILE_KEY_BYTES {
            return Some(Err(DecryptError::InvalidHeader));
        }

        // Parse ephemeral public key
        let epk_bytes = match base64_arg::<_, 32, 64>(&stanza.args[0]) {
            Some(bytes) => bytes,
            None => return Some(Err(DecryptError::InvalidHeader)),
        };
        let epk = PublicKey::from(epk_bytes);

        // Perform X25519 ECDH
        let shared_secret = self.0.diffie_hellman(&epk);

        // Derive wrapping key with HKDF
        let mut salt = Vec::with_capacity(64);
        salt.extend_from_slice(PublicKey::from(&self.0).as_bytes());
        salt.extend_from_slice(&epk_bytes);

        let wrap_key = hkdf(
            &salt,
            b"age-encryption.org/v1/X25519",
            shared_secret.as_bytes(),
        );

        // Decrypt file key
        aead_decrypt(&Zeroizing::new(wrap_key), FILE_KEY_BYTES, &stanza.body)
            .ok()
            .map(|mut plaintext| {
                Ok(FileKey::init_with_mut(|file_key| {
                    file_key.copy_from_slice(&plaintext);
                    plaintext.zeroize();
                }))
            })
    }
}

impl crate::Identity for Identity {
    fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, DecryptError>> {
        Identity::unwrap_stanza(self, stanza)
    }
}

/// An X25519 public key (for encryption).
///
/// This is the recipient that files can be encrypted to.
#[derive(Clone)]
pub struct Recipient(PublicKey);

impl Recipient {
    /// Returns the underlying X25519 public key.
    pub(crate) fn public_key(&self) -> &PublicKey {
        &self.0
    }

    /// Wraps a file key to this X25519 recipient.
    fn wrap_file_key(&self, file_key: &FileKey) -> Result<Vec<Stanza>, EncryptError> {
        let mut rng = OsRng;

        // Generate ephemeral X25519 key pair
        let esk = EphemeralSecret::random_from_rng(&mut rng);
        let epk = PublicKey::from(&esk);

        // Perform X25519 ECDH
        let shared_secret = esk.diffie_hellman(&self.0);

        // Derive wrapping key with HKDF
        let mut salt = Vec::with_capacity(64);
        salt.extend_from_slice(self.0.as_bytes());
        salt.extend_from_slice(epk.as_bytes());

        let wrap_key = hkdf(
            &salt,
            b"age-encryption.org/v1/X25519",
            shared_secret.as_bytes(),
        );

        // Encrypt file key
        let encrypted_file_key = aead_encrypt(&Zeroizing::new(wrap_key), file_key.expose_secret());

        Ok(vec![Stanza {
            tag: X25519_RECIPIENT_TAG.to_string(),
            args: vec![BASE64_STANDARD_NO_PAD.encode(epk.as_bytes())],
            body: encrypted_file_key,
        }])
    }
}

impl crate::Recipient for Recipient {
    fn wrap_file_key(
        &self,
        file_key: &FileKey,
    ) -> Result<(Vec<Stanza>, std::collections::HashSet<String>), EncryptError> {
        // X25519 uses empty label set (can be combined with any recipient)
        Ok((self.wrap_file_key(file_key)?, std::collections::HashSet::new()))
    }
}

impl fmt::Display for Recipient {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(
            f,
            "age1{}",
            bech32::encode(
                "x25519",
                self.0.as_bytes().to_base32(),
                Variant::Bech32
            )
            .map_err(|_| fmt::Error)?
        )
    }
}

impl fmt::Display for Identity {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(
            f,
            "AGE-SECRET-KEY-1{}",
            bech32::encode(
                "",
                self.0.to_bytes().to_base32(),
                Variant::Bech32
            )
            .map_err(|_| fmt::Error)?
            .to_uppercase()
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn x25519_round_trip() {
        let identity = Identity::generate();
        let recipient = identity.to_public();

        let file_key = FileKey::new(Box::new([42; 16]));

        // Encrypt
        let stanzas = recipient.wrap_file_key(&file_key).unwrap();
        assert_eq!(stanzas.len(), 1);
        assert_eq!(stanzas[0].tag, X25519_RECIPIENT_TAG);

        // Decrypt
        let decrypted = identity.unwrap_stanza(&stanzas[0]).unwrap().unwrap();
        assert_eq!(decrypted.expose_secret(), file_key.expose_secret());
    }

    #[test]
    fn x25519_public_key_encoding() {
        let identity = Identity::generate();
        let recipient = identity.to_public();

        let encoded = recipient.to_string();
        assert!(encoded.starts_with("age1"));
        assert!(encoded.len() > 10);
    }
}