libsoliton 0.1.3

Core cryptographic library for the LO protocol — hybrid post-quantum key exchange, signatures, ratchet, and storage encryption
Documentation
//! KEM-based authentication (§4).
//!
//! Proves possession of a LO identity private key via X-Wing encapsulation.
//! Server encapsulates, client decapsulates and proves knowledge of the shared secret.

use crate::constants;
use crate::error::Result;
use crate::identity::{self, IdentityPublicKey, IdentitySecretKey};
use crate::primitives::{hmac, xwing};
use zeroize::{Zeroize, Zeroizing};

/// Server-side: generate an authentication challenge.
///
/// Encapsulates to the client's identity key (X-Wing component) and computes
/// the expected proof token. Returns (ciphertext, expected_token).
///
/// The server sends `ciphertext` to the client and retains `expected_token`
/// for verification. The token is wrapped in `Zeroizing` to ensure it is
/// zeroized from memory when no longer needed.
///
/// # Security
///
/// The X-Wing shared secret is zeroized immediately after HMAC derivation.
/// The returned token is wrapped in `Zeroizing` and zeroized on drop.
///
/// # Caller Obligations
///
/// The proof token is computed as `HMAC(ss, "lo-auth-v1")` with a static label.
/// No server identity, session ID, or timestamp is bound into the HMAC. The
/// caller must ensure freshness and context binding externally:
/// - Use the ciphertext only once (single-use challenge).
/// - Bind the challenge to a specific session/connection at the application layer.
/// - Enforce a timeout on proof delivery to prevent delayed replay.
///
/// Without these measures, a valid proof is replayable across any server that
/// issues the same ciphertext (which requires the same public key).
#[must_use = "contains secret token material that must not be silently discarded"]
pub fn auth_challenge(
    client_pk: &IdentityPublicKey,
) -> Result<(xwing::Ciphertext, Zeroizing<[u8; 32]>)> {
    let (ct, mut ss) = identity::encapsulate(client_pk)?;

    // token = HMAC-SHA3-256(ss, "lo-auth-v1")
    let mut raw_token = hmac::hmac_sha3_256(ss.as_bytes(), constants::AUTH_HMAC_LABEL);
    let token = Zeroizing::new(raw_token);
    // [u8; 32] is Copy — Zeroizing::new() received a bitwise copy, so the
    // original stack value must be explicitly zeroized.
    raw_token.zeroize();

    // Shared secret used for token derivation — zeroize eagerly to minimize the
    // window during which it resides in memory; ZeroizeOnDrop fires again at drop.
    ss.0.zeroize();

    Ok((ct, token))
}

/// Client-side: respond to an authentication challenge.
///
/// Decapsulates the ciphertext using the identity secret key and computes
/// the proof. Returns the 32-byte proof to send back to the server, wrapped
/// in `Zeroizing` to ensure it is zeroized from memory after use.
///
/// # Security
///
/// The X-Wing shared secret is zeroized immediately after HMAC derivation.
/// The returned proof is wrapped in `Zeroizing` and zeroized on drop.
#[must_use = "contains secret proof material that must not be silently discarded"]
pub fn auth_respond(
    client_sk: &IdentitySecretKey,
    ct: &xwing::Ciphertext,
) -> Result<Zeroizing<[u8; 32]>> {
    let mut ss = identity::decapsulate(client_sk, ct)?;

    // proof = HMAC-SHA3-256(ss, "lo-auth-v1")
    let mut raw_proof = hmac::hmac_sha3_256(ss.as_bytes(), constants::AUTH_HMAC_LABEL);
    let proof = Zeroizing::new(raw_proof);
    // [u8; 32] is Copy — Zeroizing::new() received a bitwise copy, so the
    // original stack value must be explicitly zeroized.
    raw_proof.zeroize();

    // Shared secret used for proof derivation — zeroize eagerly to minimize the
    // window during which it resides in memory; ZeroizeOnDrop fires again at drop.
    ss.0.zeroize();

    Ok(proof)
}

/// Server-side: verify a client's authentication proof.
///
/// Constant-time comparison of the proof against the expected token.
///
/// # Security
///
/// Uses `subtle::ConstantTimeEq` via `hmac_sha3_256_verify_raw` — execution time
/// does not depend on which bytes differ. The caller must zeroize
/// `expected_token` after verification (§4.4); `Zeroizing<[u8; 32]>` from
/// [`auth_challenge`] satisfies this automatically.
#[must_use]
pub fn auth_verify(expected_token: &[u8; 32], proof: &[u8; 32]) -> bool {
    hmac::hmac_sha3_256_verify_raw(expected_token, proof)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::identity::{GeneratedIdentity, generate_identity};

    #[test]
    fn challenge_response_verify() {
        let GeneratedIdentity {
            public_key: pk,
            secret_key: sk,
            ..
        } = generate_identity().unwrap();
        let (ct, token) = auth_challenge(&pk).unwrap();
        let proof = auth_respond(&sk, &ct).unwrap();
        assert!(auth_verify(&token, &proof));
    }

    #[test]
    fn verify_wrong_proof() {
        // Use a real challenge/response pair and flip one byte in the proof.
        // This exercises constant-time comparison on near-equal inputs rather
        // than fully independent random arrays.
        let GeneratedIdentity {
            public_key: pk,
            secret_key: sk,
            ..
        } = generate_identity().unwrap();
        let (ct, token) = auth_challenge(&pk).unwrap();
        let mut proof = auth_respond(&sk, &ct).unwrap();
        proof[0] ^= 0x01;
        assert!(!auth_verify(&token, &proof));
    }

    #[test]
    fn verify_different_client() {
        let GeneratedIdentity {
            public_key: pk_a, ..
        } = generate_identity().unwrap();
        let GeneratedIdentity {
            secret_key: sk_b, ..
        } = generate_identity().unwrap();
        let (ct, token) = auth_challenge(&pk_a).unwrap();
        // B responds to A's challenge — decapsulation produces a different SS.
        let proof = auth_respond(&sk_b, &ct).unwrap();
        assert!(!auth_verify(&token, &proof));
    }

    #[test]
    fn token_is_nonzero() {
        let GeneratedIdentity { public_key: pk, .. } = generate_identity().unwrap();
        let (_, token) = auth_challenge(&pk).unwrap();
        // Length enforced by type (`Zeroizing<[u8; 32]>`); this guards against all-zero HMAC output.
        assert!(token.iter().any(|&b| b != 0));
    }

    #[test]
    fn proof_is_nonzero() {
        let GeneratedIdentity {
            public_key: pk,
            secret_key: sk,
            ..
        } = generate_identity().unwrap();
        let (ct, _) = auth_challenge(&pk).unwrap();
        let proof = auth_respond(&sk, &ct).unwrap();
        // Length enforced by type (`Zeroizing<[u8; 32]>`); this guards against all-zero HMAC output.
        assert!(proof.iter().any(|&b| b != 0));
    }

    #[test]
    fn auth_respond_wrong_ciphertext_fails_verify() {
        let GeneratedIdentity {
            public_key: pk,
            secret_key: sk,
            ..
        } = generate_identity().unwrap();
        let (_, token) = auth_challenge(&pk).unwrap();
        // Create a second challenge to get a different ciphertext.
        let (ct2, _) = auth_challenge(&pk).unwrap();
        let proof = auth_respond(&sk, &ct2).unwrap();
        // Different ciphertext → different SS → different proof.
        assert!(!auth_verify(&token, &proof));
    }

    #[test]
    fn auth_token_is_hmac_of_shared_secret() {
        // Verify indirectly: challenge + respond for same identity produces
        // matching token and proof, confirming both derive from the same
        // HMAC(shared_secret, AUTH_HMAC_LABEL) computation.
        let GeneratedIdentity {
            public_key: pk,
            secret_key: sk,
            ..
        } = generate_identity().unwrap();
        let (ct, token) = auth_challenge(&pk).unwrap();
        let proof = auth_respond(&sk, &ct).unwrap();
        // Token and proof should be byte-identical (both = HMAC(ss, label)).
        assert_eq!(*token, *proof);
    }

    // === Unit-level tests for auth_verify with raw known inputs ===
    // These do not require PQ keygen; they exercise auth_verify's
    // constant-time comparison directly and are MIRI-safe.

    #[test]
    fn auth_verify_matching_raw_tokens_returns_true() {
        let token = [0x42u8; 32];
        assert!(auth_verify(&token, &token));
    }

    #[test]
    fn auth_verify_mismatched_raw_tokens_returns_false() {
        let token = [0x42u8; 32];
        let mut wrong = token;
        wrong[0] ^= 0x01;
        assert!(!auth_verify(&token, &wrong));
    }

    #[test]
    fn auth_verify_all_zero_tokens_returns_true() {
        // Exercises the all-zero edge case (degenerate but defined behavior).
        let token = [0u8; 32];
        assert!(auth_verify(&token, &token));
    }

    #[test]
    fn auth_verify_single_bit_flip_returns_false() {
        // Any single-bit difference must be detected.
        for bit in 0..256u32 {
            let byte = (bit / 8) as usize;
            let mask = 1u8 << (bit % 8);
            let token = [0xABu8; 32];
            let mut wrong = token;
            wrong[byte] ^= mask;
            assert!(!auth_verify(&token, &wrong), "bit {} not detected", bit);
        }
    }

    #[test]
    fn auth_challenges_produce_unique_ciphertexts() {
        // Two auth_challenge calls for the same key must produce different
        // ciphertexts — encapsulation is randomised, so replaying a captured
        // ciphertext from a previous challenge provides no proof of key possession.
        let GeneratedIdentity { public_key: pk, .. } = generate_identity().unwrap();
        let (ct1, _) = auth_challenge(&pk).unwrap();
        let (ct2, _) = auth_challenge(&pk).unwrap();
        assert_ne!(
            ct1.as_bytes(),
            ct2.as_bytes(),
            "consecutive auth_challenge calls must produce distinct ciphertexts"
        );
    }
}