anubis-age 1.4.0

Post-quantum secure encryption library with hybrid X25519+ML-KEM-1024 mode (internal dependency for anubis-rage)
Documentation
//! Signed encryption combining ML-KEM-1024 encryption with ML-DSA-87 signatures.
//!
//! This module provides authenticated encryption with digital signatures,
//! combining:
//! - ML-KEM-1024 for quantum-resistant encryption (FIPS 203)
//! - ML-DSA-87 for quantum-resistant signatures (FIPS 204)
//!
//! The signed format includes:
//! 1. Standard age encrypted file
//! 2. ML-DSA-87 signature over the entire ciphertext
//! 3. Verification key identifier

use std::io::{self, Read, Write};

use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};

use super::mldsa::{Signature, SigningKey, VerificationKey, MLDSA87_SIGNATURE_BYTES};

/// Marker bytes identifying a signed age file.
const SIGNED_MAGIC: &[u8] = b"age-signature/v1\n";

/// Signed file footer structure:
/// ```text
/// age-signature/v1
/// -> ML-DSA-87 <verification-key-bech32>
/// <base64-signature-4595-bytes>
/// ```

/// Signs an encrypted age file with an ML-DSA-87 signature.
///
/// The signature covers the entire encrypted file contents, providing:
/// - Authenticity: Proves the file was created by the signing key owner
/// - Integrity: Detects any tampering with the ciphertext
/// - Non-repudiation: Cryptographic proof of file origin
///
/// # Example
///
/// ```no_run
/// use age::pqc::mldsa::SigningKey;
/// use age::pqc::signed::sign_encrypted_file;
///
/// let signing_key = SigningKey::generate();
/// let encrypted_data = b"encrypted age file...";
/// let mut signed_output = Vec::new();
///
/// sign_encrypted_file(encrypted_data, &signing_key, &mut signed_output)?;
/// # Ok::<(), std::io::Error>(())
/// ```
pub fn sign_encrypted_file<W: Write>(
    encrypted_data: &[u8],
    signing_key: &SigningKey,
    mut output: W,
) -> io::Result<()> {
    // Write the encrypted file
    output.write_all(encrypted_data)?;

    // Create signature over the encrypted data
    let signature = signing_key.sign(encrypted_data)?;

    // Write signature footer
    output.write_all(SIGNED_MAGIC)?;
    output.write_all(b"-> ML-DSA-87 ")?;
    output.write_all(signing_key.to_public().to_string().as_bytes())?;
    output.write_all(b"\n")?;

    // Write signature in base64
    let sig_b64 = BASE64_STANDARD_NO_PAD.encode(signature.as_bytes());
    output.write_all(sig_b64.as_bytes())?;
    output.write_all(b"\n")?;

    Ok(())
}

/// Verifies and extracts an encrypted age file from a signed file.
///
/// Returns the encrypted data if the signature is valid, or an error if:
/// - The file is not signed
/// - The signature is invalid
/// - The signature format is malformed
///
/// # Example
///
/// ```no_run
/// use age::pqc::mldsa::VerificationKey;
/// use age::pqc::signed::verify_and_extract;
///
/// let verification_key: VerificationKey = "age1mldsa87...".parse().unwrap();
/// let signed_data = std::fs::read("file.age.signed")?;
///
/// let encrypted_data = verify_and_extract(&signed_data, &verification_key)?;
/// // Now decrypt the encrypted_data with age
/// # Ok::<(), std::io::Error>(())
/// ```
pub fn verify_and_extract(
    signed_data: &[u8],
    expected_verification_key: &VerificationKey,
) -> io::Result<Vec<u8>> {
    // Find the signature footer
    let magic_pos = signed_data
        .windows(SIGNED_MAGIC.len())
        .rposition(|window| window == SIGNED_MAGIC)
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "not a signed age file"))?;

    // Split encrypted data and signature footer
    let encrypted_data = &signed_data[..magic_pos];
    let footer = &signed_data[magic_pos + SIGNED_MAGIC.len()..];

    // Parse footer
    let footer_str = std::str::from_utf8(footer)
        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid signature footer"))?;

    let mut lines = footer_str.lines();

    // Parse "-> ML-DSA-87 <verification-key>"
    let header_line = lines
        .next()
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing signature header"))?;

    if !header_line.starts_with("-> ML-DSA-87 ") {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "invalid signature header",
        ));
    }

    let verification_key_str = &header_line[13..]; // Skip "-> ML-DSA-87 "
    let verification_key: VerificationKey = verification_key_str.parse().map_err(|_| {
        io::Error::new(
            io::ErrorKind::InvalidData,
            "invalid verification key in signature",
        )
    })?;

    // Verify the verification key matches
    if &verification_key != expected_verification_key {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "verification key mismatch",
        ));
    }

    // Parse signature
    let sig_line = lines
        .next()
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing signature data"))?;

    let sig_bytes = BASE64_STANDARD_NO_PAD
        .decode(sig_line)
        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid signature encoding"))?;

    let signature = Signature::from_bytes(sig_bytes)
        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid signature length"))?;

    // Verify signature
    verification_key.verify(encrypted_data, &signature)?;

    // Signature valid, return encrypted data
    Ok(encrypted_data.to_vec())
}

/// Verifies a signed file and returns both the encrypted data and verification key.
///
/// This variant extracts the verification key from the signed file, allowing
/// the caller to decide whether to trust it.
pub fn verify_and_extract_with_key(signed_data: &[u8]) -> io::Result<(Vec<u8>, VerificationKey)> {
    // Find the signature footer
    let magic_pos = signed_data
        .windows(SIGNED_MAGIC.len())
        .rposition(|window| window == SIGNED_MAGIC)
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "not a signed age file"))?;

    // Split encrypted data and signature footer
    let encrypted_data = &signed_data[..magic_pos];
    let footer = &signed_data[magic_pos + SIGNED_MAGIC.len()..];

    // Parse footer
    let footer_str = std::str::from_utf8(footer)
        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid signature footer"))?;

    let mut lines = footer_str.lines();

    // Parse "-> ML-DSA-87 <verification-key>"
    let header_line = lines
        .next()
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing signature header"))?;

    if !header_line.starts_with("-> ML-DSA-87 ") {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "invalid signature header",
        ));
    }

    let verification_key_str = &header_line[13..]; // Skip "-> ML-DSA-87 "
    let verification_key: VerificationKey = verification_key_str.parse().map_err(|_| {
        io::Error::new(
            io::ErrorKind::InvalidData,
            "invalid verification key in signature",
        )
    })?;

    // Parse signature
    let sig_line = lines
        .next()
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing signature data"))?;

    let sig_bytes = BASE64_STANDARD_NO_PAD
        .decode(sig_line)
        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid signature encoding"))?;

    let signature = Signature::from_bytes(sig_bytes)
        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid signature length"))?;

    // Verify signature
    verification_key.verify(encrypted_data, &signature)?;

    // Signature valid, return encrypted data and verification key
    Ok((encrypted_data.to_vec(), verification_key))
}

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

    #[test]
    fn sign_and_verify_round_trip() {
        let signing_key = SigningKey::generate();
        let verification_key = signing_key.to_public();
        let encrypted_data = b"fake encrypted age file data";

        // Sign
        let mut signed_output = Vec::new();
        sign_encrypted_file(encrypted_data, &signing_key, &mut signed_output).unwrap();

        // Verify
        let extracted = verify_and_extract(&signed_output, &verification_key).unwrap();
        assert_eq!(extracted, encrypted_data);
    }

    #[test]
    fn verify_with_wrong_key_fails() {
        let signing_key = SigningKey::generate();
        let wrong_key = SigningKey::generate().to_public();
        let encrypted_data = b"fake encrypted age file data";

        // Sign
        let mut signed_output = Vec::new();
        sign_encrypted_file(encrypted_data, &signing_key, &mut signed_output).unwrap();

        // Verify with wrong key should fail
        assert!(verify_and_extract(&signed_output, &wrong_key).is_err());
    }

    #[test]
    fn verify_tampered_data_fails() {
        let signing_key = SigningKey::generate();
        let verification_key = signing_key.to_public();
        let encrypted_data = b"fake encrypted age file data";

        // Sign
        let mut signed_output = Vec::new();
        sign_encrypted_file(encrypted_data, &signing_key, &mut signed_output).unwrap();

        // Tamper with the data
        signed_output[10] ^= 0x01;

        // Verification should fail
        assert!(verify_and_extract(&signed_output, &verification_key).is_err());
    }

    #[test]
    fn verify_and_extract_with_key_works() {
        let signing_key = SigningKey::generate();
        let verification_key = signing_key.to_public();
        let encrypted_data = b"fake encrypted age file data";

        // Sign
        let mut signed_output = Vec::new();
        sign_encrypted_file(encrypted_data, &signing_key, &mut signed_output).unwrap();

        // Verify and extract key
        let (extracted, extracted_key) = verify_and_extract_with_key(&signed_output).unwrap();
        assert_eq!(extracted, encrypted_data);
        assert_eq!(extracted_key, verification_key);
    }
}