synta-certificate 0.3.3

X.509 certificate structures for synta ASN.1 library
Documentation
//! Composite ML-KEM algorithm spec table and backend-agnostic helpers.
//!
//! Implements draft-ietf-lamps-pq-composite-kem-18: all 12 composite
//! algorithms combining ML-KEM with a traditional KEM (RSA-OAEP, ECDH,
//! X25519, or X448) under the same `id-alg` OID arc (1.3.6.1.5.5.7.6) used
//! by Composite ML-DSA, sub-arcs 55-66 (contiguous).
//!
//! This module is feature-gated: `#[cfg(any(feature = "openssl", feature = "nss"))]`
//! because composite key operations always need a crypto backend.
//!
//! DER encoding reuses [`super::composite_mldsa::encode_composite_spki`],
//! [`super::composite_mldsa::encode_composite_pkcs8`],
//! [`super::composite_mldsa::extract_spki_bitstring_payload`],
//! [`super::composite_mldsa::pkcs8_private_key_content`], and
//! [`super::composite_mldsa::composite_oid_components`] — despite the
//! "mldsa" naming, those five helpers only concatenate raw byte slices and
//! wrap them in generic SPKI/PKCS#8 structures; they have no ML-DSA-specific
//! behavior, and the composite-KEM and composite-ML-DSA arcs share the same
//! OID prefix.  Only the private-key seed length differs (64 bytes for an
//! ML-KEM seed vs. 32 for ML-DSA), so this module provides its own
//! [`split_composite_kem_privkey`] rather than reusing
//! `composite_mldsa::split_composite_privkey`.

// ── Traditional component algorithm variants ──────────────────────────────────

/// The traditional (non-ML-KEM) component of a composite ML-KEM algorithm.
#[derive(Debug, Clone, Copy)]
pub enum TradKemAlg {
    /// RSA-OAEP key transport, promoted to a KEM per draft-ietf-lamps-pq-composite-kem §2.1.
    Rsa {
        /// RSA modulus size in bits (2048, 3072, or 4096).
        bits: u32,
    },
    /// ECDH on a named curve (e.g. `"P-256"`, `"P-384"`, `"P-521"`,
    /// `"brainpoolP256r1"`, `"brainpoolP384r1"`).
    Ec { curve: &'static str },
    /// X25519 (RFC 7748).
    X25519,
    /// X448 (RFC 7748).
    X448,
}

impl TradKemAlg {
    /// Raw public-key / ciphertext size in bytes for this traditional
    /// component.  EC points are uncompressed (`0x04` prefix included); the
    /// ciphertext (ephemeral public key) uses the same encoding and size as
    /// the static public key, per the composite-KEM draft.
    ///
    /// For [`TradKemAlg::Rsa`] this is the OAEP ciphertext size (the RSA
    /// modulus size, always fixed for a given key size).  It is *not*
    /// meaningful for the RSA public key, whose DER (`RSAPublicKey`)
    /// encoding varies slightly in size — draft-ietf-lamps-pq-composite-kem
    /// Appendix A marks all RSA composite sizes with an asterisk for this
    /// reason, unlike the fixed EC/X25519/X448 sizes below.
    pub fn raw_len(&self) -> usize {
        match self {
            TradKemAlg::Rsa { bits } => (*bits as usize) / 8,
            TradKemAlg::Ec { curve } => match *curve {
                "P-256" => 65,
                "P-384" => 97,
                "P-521" => 133,
                "brainpoolP256r1" => 65,
                "brainpoolP384r1" => 97,
                other => panic!("composite KEM: unsupported EC curve {other}"),
            },
            TradKemAlg::X25519 => 32,
            TradKemAlg::X448 => 56,
        }
    }
}

// ── Sub-arc constants ─────────────────────────────────────────────────────────
//
// Derived from the ASN.1-generated OID constants in `oids` (the single
// source of truth for the numeric values) rather than repeating 58/59/63 as
// magic numbers at each call site.

const fn sub_arc_of(oid: &[u32]) -> u32 {
    oid[oid.len() - 1]
}

/// Sub-arc of `id-MLKEM768-RSA2048-SHA3-256` (OID `1.3.6.1.5.5.7.6.55`).
pub const SUB_ARC_MLKEM768_RSA2048: u32 = sub_arc_of(crate::oids::MLKEM768_RSA2048_SHA3_256);
/// Sub-arc of `id-MLKEM768-RSA3072-SHA3-256` (OID `1.3.6.1.5.5.7.6.56`).
pub const SUB_ARC_MLKEM768_RSA3072: u32 = sub_arc_of(crate::oids::MLKEM768_RSA3072_SHA3_256);
/// Sub-arc of `id-MLKEM768-RSA4096-SHA3-256` (OID `1.3.6.1.5.5.7.6.57`).
pub const SUB_ARC_MLKEM768_RSA4096: u32 = sub_arc_of(crate::oids::MLKEM768_RSA4096_SHA3_256);
/// Sub-arc of `id-MLKEM768-X25519-SHA3-256` (OID `1.3.6.1.5.5.7.6.58`).
pub const SUB_ARC_MLKEM768_X25519: u32 = sub_arc_of(crate::oids::MLKEM768_X25519_SHA3_256);
/// Sub-arc of `id-MLKEM768-ECDH-P256-SHA3-256` (OID `1.3.6.1.5.5.7.6.59`).
pub const SUB_ARC_MLKEM768_ECDH_P256: u32 = sub_arc_of(crate::oids::MLKEM768_ECDH_P256_SHA3_256);
/// Sub-arc of `id-MLKEM768-ECDH-P384-SHA3-256` (OID `1.3.6.1.5.5.7.6.60`).
pub const SUB_ARC_MLKEM768_ECDH_P384: u32 = sub_arc_of(crate::oids::MLKEM768_ECDH_P384_SHA3_256);
/// Sub-arc of `id-MLKEM768-ECDH-brainpoolP256r1-SHA3-256` (OID `1.3.6.1.5.5.7.6.61`).
pub const SUB_ARC_MLKEM768_ECDH_BRAINPOOL_P256R1: u32 =
    sub_arc_of(crate::oids::MLKEM768_ECDH_BRAINPOOL_P256R1_SHA3_256);
/// Sub-arc of `id-MLKEM1024-RSA3072-SHA3-256` (OID `1.3.6.1.5.5.7.6.62`).
pub const SUB_ARC_MLKEM1024_RSA3072: u32 = sub_arc_of(crate::oids::MLKEM1024_RSA3072_SHA3_256);
/// Sub-arc of `id-MLKEM1024-ECDH-P384-SHA3-256` (OID `1.3.6.1.5.5.7.6.63`).
pub const SUB_ARC_MLKEM1024_ECDH_P384: u32 = sub_arc_of(crate::oids::MLKEM1024_ECDH_P384_SHA3_256);
/// Sub-arc of `id-MLKEM1024-ECDH-brainpoolP384r1-SHA3-256` (OID `1.3.6.1.5.5.7.6.64`).
pub const SUB_ARC_MLKEM1024_ECDH_BRAINPOOL_P384R1: u32 =
    sub_arc_of(crate::oids::MLKEM1024_ECDH_BRAINPOOL_P384R1_SHA3_256);
/// Sub-arc of `id-MLKEM1024-X448-SHA3-256` (OID `1.3.6.1.5.5.7.6.65`).
pub const SUB_ARC_MLKEM1024_X448: u32 = sub_arc_of(crate::oids::MLKEM1024_X448_SHA3_256);
/// Sub-arc of `id-MLKEM1024-ECDH-P521-SHA3-256` (OID `1.3.6.1.5.5.7.6.66`).
pub const SUB_ARC_MLKEM1024_ECDH_P521: u32 = sub_arc_of(crate::oids::MLKEM1024_ECDH_P521_SHA3_256);

// ── Spec struct ───────────────────────────────────────────────────────────────

/// Per-variant configuration for a composite ML-KEM algorithm.
#[derive(Debug)]
pub struct CompositeKemSpec {
    /// Sub-arc component (55-66) of the composite OID `1.3.6.1.5.5.7.6.<sub_arc>`.
    pub sub_arc: u32,
    /// ML-KEM variant string for key generation: `"ML-KEM-768"` or `"ML-KEM-1024"`.
    pub mlkem_variant: &'static str,
    /// Raw ML-KEM public key size in bytes (FIPS 203), for SPKI/ciphertext split.
    pub mlkem_pk_size: usize,
    /// Raw ML-KEM ciphertext size in bytes (FIPS 203), for ciphertext split.
    pub mlkem_ct_size: usize,
    /// The traditional KEM component algorithm.
    pub trad_alg: TradKemAlg,
    /// SHA3-256 combiner domain-separation label, appended last in the
    /// combiner input.  Raw bytes rather than `&str` because the
    /// MLKEM768-X25519 label (per the upstream draft) contains characters
    /// that don't round-trip cleanly through source text.
    pub label: &'static [u8],
}

// ── Static spec table ─────────────────────────────────────────────────────────

static COMPOSITE_SPECS: &[CompositeKemSpec] = &[
    CompositeKemSpec {
        sub_arc: SUB_ARC_MLKEM768_RSA2048,
        mlkem_variant: "ML-KEM-768",
        mlkem_pk_size: 1184,
        mlkem_ct_size: 1088,
        trad_alg: TradKemAlg::Rsa { bits: 2048 },
        label: b"MLKEM768-RSAOAEP2048",
    },
    CompositeKemSpec {
        sub_arc: SUB_ARC_MLKEM768_RSA3072,
        mlkem_variant: "ML-KEM-768",
        mlkem_pk_size: 1184,
        mlkem_ct_size: 1088,
        trad_alg: TradKemAlg::Rsa { bits: 3072 },
        label: b"MLKEM768-RSAOAEP3072",
    },
    CompositeKemSpec {
        sub_arc: SUB_ARC_MLKEM768_RSA4096,
        mlkem_variant: "ML-KEM-768",
        mlkem_pk_size: 1184,
        mlkem_ct_size: 1088,
        trad_alg: TradKemAlg::Rsa { bits: 4096 },
        label: b"MLKEM768-RSAOAEP4096",
    },
    CompositeKemSpec {
        sub_arc: SUB_ARC_MLKEM768_X25519,
        mlkem_variant: "ML-KEM-768",
        mlkem_pk_size: 1184,
        mlkem_ct_size: 1088,
        trad_alg: TradKemAlg::X25519,
        // draft-ietf-lamps-pq-composite-kem gives this label only in hex
        // (ASCII equivalent "\.//^\\") because of transcription hazards.
        label: &[0x5c, 0x2e, 0x2f, 0x2f, 0x5e, 0x5c],
    },
    CompositeKemSpec {
        sub_arc: SUB_ARC_MLKEM768_ECDH_P256,
        mlkem_variant: "ML-KEM-768",
        mlkem_pk_size: 1184,
        mlkem_ct_size: 1088,
        trad_alg: TradKemAlg::Ec { curve: "P-256" },
        label: b"MLKEM768-P256",
    },
    CompositeKemSpec {
        sub_arc: SUB_ARC_MLKEM768_ECDH_P384,
        mlkem_variant: "ML-KEM-768",
        mlkem_pk_size: 1184,
        mlkem_ct_size: 1088,
        trad_alg: TradKemAlg::Ec { curve: "P-384" },
        label: b"MLKEM768-P384",
    },
    CompositeKemSpec {
        sub_arc: SUB_ARC_MLKEM768_ECDH_BRAINPOOL_P256R1,
        mlkem_variant: "ML-KEM-768",
        mlkem_pk_size: 1184,
        mlkem_ct_size: 1088,
        trad_alg: TradKemAlg::Ec {
            curve: "brainpoolP256r1",
        },
        label: b"MLKEM768-BP256",
    },
    CompositeKemSpec {
        sub_arc: SUB_ARC_MLKEM1024_RSA3072,
        mlkem_variant: "ML-KEM-1024",
        mlkem_pk_size: 1568,
        mlkem_ct_size: 1568,
        trad_alg: TradKemAlg::Rsa { bits: 3072 },
        label: b"MLKEM1024-RSAOAEP3072",
    },
    CompositeKemSpec {
        sub_arc: SUB_ARC_MLKEM1024_ECDH_P384,
        mlkem_variant: "ML-KEM-1024",
        mlkem_pk_size: 1568,
        mlkem_ct_size: 1568,
        trad_alg: TradKemAlg::Ec { curve: "P-384" },
        label: b"MLKEM1024-P384",
    },
    CompositeKemSpec {
        sub_arc: SUB_ARC_MLKEM1024_ECDH_BRAINPOOL_P384R1,
        mlkem_variant: "ML-KEM-1024",
        mlkem_pk_size: 1568,
        mlkem_ct_size: 1568,
        trad_alg: TradKemAlg::Ec {
            curve: "brainpoolP384r1",
        },
        label: b"MLKEM1024-BP384",
    },
    CompositeKemSpec {
        sub_arc: SUB_ARC_MLKEM1024_X448,
        mlkem_variant: "ML-KEM-1024",
        mlkem_pk_size: 1568,
        mlkem_ct_size: 1568,
        trad_alg: TradKemAlg::X448,
        label: b"MLKEM1024-X448",
    },
    CompositeKemSpec {
        sub_arc: SUB_ARC_MLKEM1024_ECDH_P521,
        mlkem_variant: "ML-KEM-1024",
        mlkem_pk_size: 1568,
        mlkem_ct_size: 1568,
        trad_alg: TradKemAlg::Ec { curve: "P-521" },
        label: b"MLKEM1024-P521",
    },
];

/// Look up a composite ML-KEM spec by the sub-arc component (55-66).
///
/// Returns `None` if `sub_arc` is not a recognised composite ML-KEM variant.
pub fn composite_spec(sub_arc: u32) -> Option<&'static CompositeKemSpec> {
    COMPOSITE_SPECS.iter().find(|s| s.sub_arc == sub_arc)
}

/// Look up a [`CompositeKemSpec`] from a full OID component slice.
///
/// Accepts OIDs in the composite arc (1.3.6.1.5.5.7.6.{55-66}).
/// Returns `None` for unrecognised or shorter OIDs.
pub fn composite_spec_from_oid(comps: &[u32]) -> Option<&'static CompositeKemSpec> {
    let arc = crate::oids::COMPOSITE_KEM_ARC;
    if comps.len() != arc.len() + 1 {
        return None;
    }
    if !comps[..arc.len()]
        .iter()
        .zip(arc.iter())
        .all(|(a, b)| a == b)
    {
        return None;
    }
    composite_spec(comps[arc.len()])
}

// ── Combiner input ────────────────────────────────────────────────────────────

/// Assemble the input to the SHA3-256 KEM combiner (draft-ietf-lamps-pq-composite-kem §3.4):
///
/// ```text
/// combiner_input = mlkemSS || tradSS || tradCT || tradPK || Label
/// ss = SHA3-256(combiner_input)
/// ```
///
/// The label is appended last (unlike X-Wing, which prepends it), per the
/// upstream draft's FIPS-compliance rationale.  The actual SHA3-256 hashing
/// happens in the OpenSSL backend (`openssl_backend::composite_kem::kem_combine`);
/// this function only assembles the pure-Rust input bytes.
pub fn combiner_input(
    mlkem_ss: &[u8],
    trad_ss: &[u8],
    trad_ct: &[u8],
    trad_pk: &[u8],
    label: &[u8],
) -> Vec<u8> {
    let mut buf = Vec::with_capacity(
        mlkem_ss.len() + trad_ss.len() + trad_ct.len() + trad_pk.len() + label.len(),
    );
    buf.extend_from_slice(mlkem_ss);
    buf.extend_from_slice(trad_ss);
    buf.extend_from_slice(trad_ct);
    buf.extend_from_slice(trad_pk);
    buf.extend_from_slice(label);
    buf
}

// ── DER split helpers ─────────────────────────────────────────────────────────

/// Split a composite SPKI BIT STRING payload into `(mlkem_pk, trad_pk)`.
pub fn split_composite_kem_spki_content<'a>(
    payload: &'a [u8],
    spec: &CompositeKemSpec,
) -> Result<(&'a [u8], &'a [u8]), String> {
    if payload.len() < spec.mlkem_pk_size {
        return Err(format!(
            "composite KEM SPKI payload too short for sub-arc {} (mlkem_pk_size={}): got {} bytes",
            spec.sub_arc,
            spec.mlkem_pk_size,
            payload.len()
        ));
    }
    Ok(payload.split_at(spec.mlkem_pk_size))
}

/// Split a composite KEM ciphertext into `(mlkem_ct, trad_ct)`.
pub fn split_composite_kem_ciphertext<'a>(
    ct: &'a [u8],
    spec: &CompositeKemSpec,
) -> Result<(&'a [u8], &'a [u8]), String> {
    if ct.len() < spec.mlkem_ct_size {
        return Err(format!(
            "composite KEM ciphertext too short for sub-arc {} (mlkem_ct_size={}): got {} bytes",
            spec.sub_arc,
            spec.mlkem_ct_size,
            ct.len()
        ));
    }
    Ok(ct.split_at(spec.mlkem_ct_size))
}

/// Split a composite KEM private key content into `(mlkem_seed, trad_sk)`.
///
/// The ML-KEM seed is always 64 bytes (FIPS 203 `d || z`); `trad_sk` is the
/// remainder.  Unlike composite ML-DSA (32-byte seed), this cannot reuse
/// `composite_mldsa::split_composite_privkey`.
pub fn split_composite_kem_privkey(privkey_content: &[u8]) -> Result<(&[u8], &[u8]), String> {
    const SEED_LEN: usize = 64;
    if privkey_content.len() <= SEED_LEN {
        return Err(format!(
            "composite KEM private key content too short: {} <= {} (no traditional key material)",
            privkey_content.len(),
            SEED_LEN
        ));
    }
    Ok(privkey_content.split_at(SEED_LEN))
}