libsoliton 0.1.3

Core cryptographic library for the LO protocol — hybrid post-quantum key exchange, signatures, ratchet, and storage encryption
Documentation
//! X25519 key agreement (RFC 7748).

use crate::error::{Error, Result};
use curve25519_dalek::MontgomeryPoint;
use subtle::ConstantTimeEq;
use zeroize::{Zeroize, ZeroizeOnDrop};

/// X25519 secret key (32 bytes).
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct SecretKey(pub(crate) [u8; 32]);

/// X25519 public key (32 bytes).
#[derive(Clone, PartialEq, Eq)]
pub struct PublicKey(pub(crate) [u8; 32]);

impl SecretKey {
    /// View the raw bytes.
    pub(crate) fn as_bytes(&self) -> &[u8; 32] {
        &self.0
    }

    /// Construct from raw bytes.
    ///
    /// # Security
    ///
    /// `[u8; 32]` is `Copy` — the caller's value remains on the stack after this
    /// call and must be explicitly zeroized by the caller.
    pub fn from_bytes(bytes: [u8; 32]) -> Self {
        Self(bytes)
    }
}

impl PublicKey {
    /// View the raw bytes.
    pub fn as_bytes(&self) -> &[u8; 32] {
        &self.0
    }

    /// Construct from raw bytes.
    pub fn from_bytes(bytes: [u8; 32]) -> Self {
        Self(bytes)
    }
}

/// Generate a random X25519 keypair.
///
/// # Security
///
/// The random seed is zeroized after copying into the `SecretKey`. The returned
/// `SecretKey` implements `ZeroizeOnDrop`.
#[must_use = "dropping the keypair loses secret key material without zeroization"]
pub fn keygen() -> (PublicKey, SecretKey) {
    let mut sk_bytes = [0u8; 32];
    super::random::random_bytes(&mut sk_bytes);

    let pk = MontgomeryPoint::mul_base_clamped(sk_bytes);

    // [u8; 32] is Copy — SecretKey(sk_bytes) copies the bytes into the struct.
    // sk_bytes.zeroize() zeroizes the stack copy; ZeroizeOnDrop on SecretKey
    // handles the field copy when the caller drops the return value.
    let sk = SecretKey(sk_bytes);
    sk_bytes.zeroize();
    (PublicKey(pk.to_bytes()), sk)
}

/// Derive X25519 public key from secret key.
#[must_use = "derived public key must not be discarded"]
pub fn public_from_secret(sk: &SecretKey) -> PublicKey {
    // Known limitation: mul_base_clamped takes [u8; 32] by value, creating
    // an unzeroized copy of the secret key on the stack. No fix possible
    // without upstream API changes to curve25519-dalek.
    PublicKey(MontgomeryPoint::mul_base_clamped(sk.0).to_bytes())
}

/// Compute X25519 Diffie-Hellman shared secret: result = sk * pk.
///
/// Returns an error if the result is a low-order point (degenerate shared secret).
/// This prevents silent degradation when given a maliciously-crafted public key.
///
/// # Security
///
/// Returns a plain `[u8; 32]` — the caller is responsible for zeroizing the
/// returned shared secret after use. Internal intermediates (`shared`,
/// `result`) are zeroized before returning.
pub fn dh(sk: &SecretKey, pk: &PublicKey) -> Result<[u8; 32]> {
    // Known limitation: mul_clamped takes [u8; 32] by value, creating an
    // unzeroized copy of the secret key on the stack. No fix possible
    // without upstream API changes to curve25519-dalek.
    let mut shared = MontgomeryPoint(pk.0).mul_clamped(sk.0);
    let mut result = shared.to_bytes();
    shared.zeroize();
    // Constant-time comparison: the result is secret material (DH output),
    // so a variable-time check could leak whether the result is all-zero
    // via timing.
    if bool::from(result.ct_eq(&[0u8; 32])) {
        result.zeroize();
        return Err(Error::DecapsulationFailed);
    }
    // [u8; 32] is Copy — `let output = result` copies the bytes; zeroize the
    // original binding to minimize stack residue of the DH secret.
    let output = result;
    result.zeroize();
    Ok(output)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::Error;
    use hex_literal::hex;

    #[test]
    fn rfc7748_vector() {
        // RFC 7748 §6.1: Alice's private key → public key (scalar × basepoint).
        let sk_bytes = hex!("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a");
        let expected_pk = hex!("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a");
        let sk = SecretKey::from_bytes(sk_bytes);
        let pk = public_from_secret(&sk);
        assert_eq!(pk.as_bytes(), &expected_pk);
    }

    #[test]
    fn rfc7748_dh_kat() {
        // RFC 7748 §6.1 — full DH key agreement with both parties' keys.
        let sk_a = SecretKey::from_bytes(hex!(
            "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"
        ));
        let sk_b = SecretKey::from_bytes(hex!(
            "5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb"
        ));
        let pk_b = public_from_secret(&sk_b);
        assert_eq!(
            pk_b.as_bytes(),
            &hex!("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f")
        );

        let ss_a = dh(&sk_a, &pk_b).unwrap();
        let ss_b = dh(&sk_b, &public_from_secret(&sk_a)).unwrap();
        let expected = hex!("4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742");
        assert_eq!(ss_a, expected);
        assert_eq!(ss_b, expected);
    }

    #[test]
    fn keygen_sizes() {
        let (pk, sk) = keygen();
        // Content checks — `&[u8; 32]` already enforces size at compile time.
        assert!(pk.as_bytes().iter().any(|&b| b != 0));
        assert!(sk.as_bytes().iter().any(|&b| b != 0));
    }

    #[test]
    fn public_from_secret_deterministic() {
        let (_, sk) = keygen();
        let pk1 = public_from_secret(&sk);
        let pk2 = public_from_secret(&sk);
        assert_eq!(pk1.as_bytes(), pk2.as_bytes());
    }

    #[test]
    fn public_from_secret_matches_keygen() {
        let (pk, sk) = keygen();
        let pk2 = public_from_secret(&sk);
        assert_eq!(pk.as_bytes(), pk2.as_bytes());
    }

    #[test]
    fn dh_agreement() {
        let (pk_a, sk_a) = keygen();
        let (pk_b, sk_b) = keygen();
        let ss_a = dh(&sk_a, &pk_b).unwrap();
        let ss_b = dh(&sk_b, &pk_a).unwrap();
        assert_eq!(ss_a, ss_b);
    }

    #[test]
    fn dh_low_order_rejected() {
        let (_, sk) = keygen();
        let zero_pk = PublicKey::from_bytes([0u8; 32]);
        assert!(matches!(dh(&sk, &zero_pk), Err(Error::DecapsulationFailed)));
    }

    #[test]
    fn dh_small_order_points() {
        let (_, sk) = keygen();
        // Small-order Montgomery u-coordinates that produce all-zeros DH output.
        let small_order_points: [[u8; 32]; 5] = [
            [0; 32], // u = 0 (zero point)
            {
                let mut p = [0u8; 32];
                p[0] = 1; // u = 1 (identity)
                p
            },
            {
                let mut p = [0xffu8; 32]; // u = p-1 = 2^255 - 20
                p[31] = 0x7f;
                p[0] = 0xec;
                p
            },
            {
                let mut p = [0xffu8; 32]; // u = p = 2^255 - 19 (≡ 0 mod p)
                p[31] = 0x7f;
                p[0] = 0xed;
                p
            },
            {
                let mut p = [0xffu8; 32]; // u = p+1 = 2^255 - 18 (≡ 1 mod p)
                p[31] = 0x7f;
                p[0] = 0xee;
                p
            },
        ];
        for point in &small_order_points {
            let pk = PublicKey::from_bytes(*point);
            assert!(
                matches!(dh(&sk, &pk), Err(Error::DecapsulationFailed)),
                "small-order point {:02x?} should be rejected",
                &point[..4]
            );
        }
    }
}