parley-core 0.2.0

Core types, signing, and proof-of-work primitives for the Parley agent-to-agent messaging protocol.
Documentation
//! Hierarchical key derivation from a single root seed.
//!
//! One 32-byte seed expands, via HKDF-SHA256 with distinct domain-
//! separation labels, into purpose-specific keys:
//!
//! - the Ed25519 **identity** key (byte-identical to a Solana address), and
//! - the ML-DSA-65 **auth** keypair (the post-quantum companion used to
//!   co-sign `Parley-Signature` headers).
//!
//! Keeping both under one seed means the user backs up a single secret
//! while each protocol domain gets an independent key — and a future PQ
//! key can be added under a new label without disturbing the address.

use ed25519_dalek::SigningKey;
use hkdf::Hkdf;
use libcrux_ml_dsa::ml_dsa_65::{self, MLDSA65KeyPair};
use sha2::Sha256;

// Re-export the ML-DSA key types so downstream crates (CLI, server) can
// hold a derived keypair without taking a direct libcrux dependency.
pub use libcrux_ml_dsa::ml_dsa_65::{MLDSA65KeyPair as AuthKeyPair, MLDSA65SigningKey};

/// Length of the root seed, in bytes.
pub const SEED_BYTES: usize = 32;

// Domain-separation labels (HKDF `info`). Changing a label rotates the
// derived key, so they are part of the on-disk identity contract.
const LABEL_IDENTITY_ED25519: &[u8] = b"parley/identity/ed25519/v1";
const LABEL_AUTH_MLDSA65: &[u8] = b"parley/auth/ml-dsa-65/v1";

/// Expand `seed` into `N` bytes with HKDF-SHA256 using `label` as `info`.
/// `N` is always small here (≤32), well under HKDF's 255·HashLen ceiling.
fn derive_bytes<const N: usize>(seed: &[u8; SEED_BYTES], label: &[u8]) -> [u8; N] {
    let hk = Hkdf::<Sha256>::new(None, seed);
    let mut out = [0u8; N];
    // `expand` only errors when the requested length exceeds 255·HashLen
    // (8160 bytes for SHA-256). All call sites here use N ≤ 32.
    let Ok(()) = hk.expand(label, &mut out) else {
        unreachable!("HKDF output length {N} is within the 255·HashLen ceiling");
    };
    out
}

/// Derive the Ed25519 identity signing key — the Solana-compatible address.
#[must_use]
pub fn derive_identity_ed25519(seed: &[u8; SEED_BYTES]) -> SigningKey {
    let sk: [u8; 32] = derive_bytes(seed, LABEL_IDENTITY_ED25519);
    SigningKey::from_bytes(&sk)
}

/// Derive the ML-DSA-65 auth keypair (post-quantum signature companion).
#[must_use]
pub fn derive_auth_mldsa(seed: &[u8; SEED_BYTES]) -> MLDSA65KeyPair {
    let randomness: [u8; 32] = derive_bytes(seed, LABEL_AUTH_MLDSA65);
    ml_dsa_65::generate_key_pair(randomness)
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use libcrux_ml_dsa::ml_dsa_65::{MLDSA65Signature, MLDSA65VerificationKey};

    #[test]
    fn derivation_is_deterministic() {
        let seed = [7u8; SEED_BYTES];
        let a = derive_identity_ed25519(&seed);
        let b = derive_identity_ed25519(&seed);
        assert_eq!(a.to_bytes(), b.to_bytes());

        let ka = derive_auth_mldsa(&seed);
        let kb = derive_auth_mldsa(&seed);
        assert_eq!(
            ka.verification_key.as_slice(),
            kb.verification_key.as_slice()
        );
    }

    #[test]
    fn domain_separation_yields_distinct_key_material() {
        // The identity Ed25519 seed bytes must differ from the ML-DSA
        // randomness derived from the same root, or the labels are not
        // actually separating.
        let seed = [9u8; SEED_BYTES];
        let id = derive_identity_ed25519(&seed);
        let auth = derive_auth_mldsa(&seed);
        // Compare the first 32 bytes of the ML-DSA verification key to the
        // Ed25519 public key — they must not coincide.
        assert_ne!(
            &id.verifying_key().to_bytes()[..],
            &auth.verification_key.as_slice()[..32]
        );
    }

    #[test]
    fn different_seeds_diverge() {
        let a = derive_identity_ed25519(&[1u8; SEED_BYTES]);
        let b = derive_identity_ed25519(&[2u8; SEED_BYTES]);
        assert_ne!(a.to_bytes(), b.to_bytes());
    }

    #[test]
    fn ml_dsa_65_sizes_match_fips204_constants() {
        // We hardcode these sizes in `signing.rs`; assert they match the
        // library's own notion so a future param change can't slip past.
        assert_eq!(MLDSA65VerificationKey::len(), 1952);
        assert_eq!(MLDSA65Signature::len(), 3309);
    }
}