//! Hybrid signatures: Ed25519 with ML-DSA-65 (`extensions/pq-signatures.md`).
//!
//! Every WAI signature is Ed25519 (SPEC §7 *Signatures*). A party that can run Shor's
//! algorithm at scale recovers an Ed25519 private key from its public key and signs
//! anything under it, so a receipt signed with Ed25519 alone stops proving who signed it on
//! the day such a computer exists. A **hybrid** signature adds a second, post-quantum
//! signature over the same bytes, and a record verifies only when both do, so it stands
//! while either algorithm holds.
//!
//! # The suite
//!
//! One suite: `id-MLDSA65-Ed25519-SHA512` of `draft-ietf-lamps-pq-composite-sigs-19` §6
//! (ML-DSA-65 of FIPS 204 beside Ed25519 of RFC 8032). Signing is that draft's §3.2 with an
//! empty application context, over the bytes the record's own rule signs (`M`):
//!
//! ```text
//! M'     = "CompositeAlgorithmSignatures2025" ‖ "COMPSIG-MLDSA65-Ed25519-SHA512" ‖ 0x00 ‖ SHA-512(M)
//! sig    = Ed25519(ed_key, M')                                  (RFC 8032, 64 bytes)
//! pq_sig = ML-DSA.Sign(pq_key, M', ctx = "COMPSIG-MLDSA65-Ed25519-SHA512")  (FIPS 204 Alg. 2, 3309 bytes)
//! ```
//!
//! `pq_sig ‖ sig` is the draft's composite signature value and `pq_key ‖ signer_pubkey` its
//! composite public key: a WAI hybrid record carries the two halves in two members.
//! Verification is the draft's §3.3, with WAI's strict Ed25519 rule ([`crate::sig`]) for
//! the classical half. The Ed25519 half signs `M'`, never `M`, so stripping the post-quantum
//! half leaves a signature that verifies over no classical record's bytes.
//!
//! # What this module gives the families
//!
//! - [`verify`]: the hybrid rule over one message, in the order every family reports it —
//!   the key, the policy ([`Policy::RequireHybrid`] refuses a classical-only item,
//!   `hybrid_required`), the Ed25519 half, the ML-DSA half (`pq_signature_invalid`).
//! - [`read_members`]: the three hybrid members of a JSON record (`pq_suite`, `pq_key`,
//!   `pq_sig`), all or none (`pq_malformed`), and the suite (`pq_suite_unknown`).
//! - [`verify_record`] and [`sign_record`]: the one helper for every record signed as
//!   `wai:<domain>\x01 ‖ JCS(record without its signatures)`, whatever its extension.
//! - [`HybridKey`]: a signing key pair from two 32-byte seeds, signing in FIPS 204's
//!   deterministic variant (§3.4), so the corpus is byte-stable.
//!
//! The group receipt (`moq-jwp:group-sign\x03`), the claim, and the key log's entries,
//! revisions and anchors use these from their own modules (`jwp_delivery`, `claim_receipt`,
//! `key_log`).

use crate::jcs::jcs;
use crate::sig::{self, PqHalf, SigError};
use ed25519_dalek::{Signer, SigningKey};
use ml_dsa::{EncodedSignature, EncodedVerifyingKey, ExpandedSigningKey, MlDsa65, Signature, VerifyingKey, B32};
use serde_json::{Map, Value};
use sha2::{Digest, Sha512};

pub mod corpus;

#[cfg(test)]
mod acvp;

/// The suite's identifier, as a record's `pq_suite` names it: the composite algorithm of
/// `draft-ietf-lamps-pq-composite-sigs-19` §6 (OID 1.3.6.1.5.5.7.6.48).
pub const SUITE: &str = "id-MLDSA65-Ed25519-SHA512";
/// The composite's signature label (draft §6), also the ML-DSA context string (draft §3.2).
pub const LABEL: &[u8] = b"COMPSIG-MLDSA65-Ed25519-SHA512";
/// The composite's prefix (draft §2.2).
pub const PREFIX: &[u8] = b"CompositeAlgorithmSignatures2025";
/// An ML-DSA-65 public key's length (FIPS 204 §4, Table 2). Crate-internal, so the C
/// header does not gain a generic integer name; [`pq_key_len`] is the public spelling.
pub(crate) const PQ_KEY_LEN: usize = 1952;
/// An ML-DSA-65 signature's length (FIPS 204 §4, Table 2); [`pq_sig_len`] is the public
/// spelling.
pub(crate) const PQ_SIG_LEN: usize = 3309;

/// An ML-DSA-65 public key's length in bytes (FIPS 204 §4, Table 2): 1952.
pub fn pq_key_len() -> usize {
    PQ_KEY_LEN
}

/// An ML-DSA-65 signature's length in bytes (FIPS 204 §4, Table 2): 3309.
pub fn pq_sig_len() -> usize {
    PQ_SIG_LEN
}
/// The hybrid members of a JSON record, in the order a reader checks them.
pub const MEMBERS: [&str; 3] = ["pq_suite", "pq_key", "pq_sig"];

/// Why a hybrid signature, or the lack of one, is refused (pq-signatures §8). The families
/// report these beside their own reasons.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Reason {
    /// The hybrid members are not all present or all absent, or one is not of its type,
    /// encoding or length.
    PqMalformed,
    /// `pq_suite` names no suite of §2.
    PqSuiteUnknown,
    /// The ML-DSA half does not verify over `M'`.
    PqSignatureInvalid,
    /// The verifier requires hybrid and the item carries a classical signature only.
    HybridRequired,
    /// The key log lists the item's key as hybrid and the item carries a classical
    /// signature only (§7 confirmation).
    HybridDowngrade,
    /// The item's ML-DSA key is not the one the key log lists with its Ed25519 key, or the
    /// log lists none (§7 confirmation).
    PqKeyMismatch,
}

impl Reason {
    /// The codes, in §8's order. A function rather than an associated constant, so the C
    /// header generator, which reads every public constant, does not reach this type.
    pub fn all() -> [Reason; 6] {
        [
            Reason::PqMalformed,
            Reason::PqSuiteUnknown,
            Reason::PqSignatureInvalid,
            Reason::HybridRequired,
            Reason::HybridDowngrade,
            Reason::PqKeyMismatch,
        ]
    }

    pub fn label(self) -> &'static str {
        match self {
            Reason::PqMalformed => "pq_malformed",
            Reason::PqSuiteUnknown => "pq_suite_unknown",
            Reason::PqSignatureInvalid => "pq_signature_invalid",
            Reason::HybridRequired => "hybrid_required",
            Reason::HybridDowngrade => "hybrid_downgrade",
            Reason::PqKeyMismatch => "pq_key_mismatch",
        }
    }

    pub fn from_label(s: &str) -> Option<Reason> {
        Reason::all().into_iter().find(|r| r.label() == s)
    }
}

impl std::fmt::Display for Reason {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.label())
    }
}

/// What a verifier accepts (§5).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Policy {
    /// Classical-only items verify as before; hybrid ones verify both halves.
    #[default]
    AcceptClassical,
    /// A classical-only item is refused (`hybrid_required`) before its signature is
    /// examined.
    RequireHybrid,
}

/// Why [`verify`] refused: the family maps the first two to its own reasons
/// (`bad_signer_key`, `signature_verify_failed`; `bad_key`, `bad_signature`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Failure {
    /// The Ed25519 key fails SPEC §7 *Signatures* steps 1–2.
    BadKey,
    /// The Ed25519 half fails steps 3–5 over the bytes it signs.
    BadSignature,
    /// This extension's reason.
    Pq(Reason),
}

impl From<SigError> for Failure {
    fn from(e: SigError) -> Failure {
        match e {
            SigError::BadKey => Failure::BadKey,
            SigError::BadSignature => Failure::BadSignature,
        }
    }
}

/// The composite's message representative with an empty context (draft §2.2, §3.2 step
/// 2): `Prefix ‖ Label ‖ len(ctx) ‖ ctx ‖ SHA-512(M)`.
pub fn representative(m: &[u8]) -> Vec<u8> {
    let mut out = Vec::with_capacity(PREFIX.len() + LABEL.len() + 1 + 64);
    out.extend_from_slice(PREFIX);
    out.extend_from_slice(LABEL);
    out.push(0);
    out.extend_from_slice(&Sha512::digest(m));
    out
}

/// ML-DSA.Verify (FIPS 204 Algorithm 3) of `sig` over `msg` under `key` with context `ctx`:
/// false for a key or signature of another length (§3.6.2) and for a signature whose hint
/// or response does not decode (Algorithm 27 returning ⊥, or ‖z‖∞ ≥ γ1 − β).
pub fn ml_dsa_verify(key: &[u8], msg: &[u8], ctx: &[u8], sig: &[u8]) -> bool {
    let (Ok(k), Ok(s)) = (EncodedVerifyingKey::<MlDsa65>::try_from(key), EncodedSignature::<MlDsa65>::try_from(sig)) else {
        return false;
    };
    let Some(s) = Signature::<MlDsa65>::decode(&s) else {
        return false;
    };
    VerifyingKey::<MlDsa65>::decode(&k).verify_with_context(msg, ctx, &s)
}

/// The hybrid rule over `msg`, the bytes the item's own rule signs (§4), in order:
///
/// 1. `key` is a canonical Ed25519 key of large order (SPEC §7 steps 1–2): [`Failure::BadKey`];
/// 2. under [`Policy::RequireHybrid`], an item with no post-quantum half is
///    `hybrid_required`, refused before its signature is examined;
/// 3. the Ed25519 half verifies strictly over `msg` for a classical item, over
///    [`representative`]`(msg)` for a hybrid one: [`Failure::BadSignature`];
/// 4. the post-quantum half has the suite's lengths (`pq_malformed`) and ML-DSA.Verify
///    holds over `M'` with the label as context (`pq_signature_invalid`).
pub fn verify(key: &[u8; 32], msg: &[u8], sig: &[u8; 64], pq: Option<&PqHalf>, policy: Policy) -> Result<(), Failure> {
    sig::strict_key(key)?;
    let Some(pq) = pq else {
        if policy == Policy::RequireHybrid {
            return Err(Failure::Pq(Reason::HybridRequired));
        }
        return sig::verify_strict(key, msg, sig).map_err(Failure::from);
    };
    let m_prime = representative(msg);
    sig::verify_strict(key, &m_prime, sig)?;
    if pq.key.len() != PQ_KEY_LEN || pq.sig.len() != PQ_SIG_LEN {
        return Err(Failure::Pq(Reason::PqMalformed));
    }
    if !ml_dsa_verify(&pq.key, &m_prime, LABEL, &pq.sig) {
        return Err(Failure::Pq(Reason::PqSignatureInvalid));
    }
    Ok(())
}

// ───────────────────────────── keys ─────────────────────────────

/// A hybrid signing key: an Ed25519 key and an ML-DSA-65 key, each from its own 32-byte
/// seed (RFC 8032 §5.1.5; FIPS 204 Algorithm 6, `ML-DSA.KeyGen_internal(ξ)`). The two halves
/// are generated apart and used only together (draft §3.1, §9.3).
#[derive(Clone)]
pub struct HybridKey {
    ed: SigningKey,
    pq: ExpandedSigningKey<MlDsa65>,
    pq_public: Vec<u8>,
}

impl std::fmt::Debug for HybridKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("HybridKey").field("public", &crate::claim_receipt::hex(&self.public())).finish_non_exhaustive()
    }
}

impl HybridKey {
    /// The key pair whose Ed25519 seed is `ed_seed` and whose ML-DSA-65 seed is `pq_seed`.
    pub fn from_seeds(ed_seed: [u8; 32], pq_seed: [u8; 32]) -> HybridKey {
        let pq = ExpandedSigningKey::<MlDsa65>::from_seed(&B32::from(pq_seed));
        let pq_public = pq.verifying_key().encode().to_vec();
        HybridKey { ed: SigningKey::from_bytes(&ed_seed), pq, pq_public }
    }

    /// The Ed25519 half, which signs a record's classical `sig`.
    pub fn ed25519(&self) -> &SigningKey {
        &self.ed
    }

    /// The Ed25519 public key: a record's `signer_pubkey`.
    pub fn public(&self) -> [u8; 32] {
        self.ed.verifying_key().to_bytes()
    }

    /// The ML-DSA-65 public key: a record's `pq_key`, 1952 bytes.
    pub fn pq_public(&self) -> &[u8] {
        &self.pq_public
    }

    /// Sign `msg` (§4): the Ed25519 half over `M'`, and the ML-DSA half over `M'` with the
    /// label as context, in FIPS 204's deterministic variant (§3.4: `rnd` = 0³²).
    pub fn sign(&self, msg: &[u8]) -> ([u8; 64], PqHalf) {
        let m_prime = representative(msg);
        let ed = self.ed.sign(&m_prime).to_bytes();
        let pq = self.pq.sign_deterministic(&m_prime, LABEL).expect("the label is under 256 bytes").encode().to_vec();
        (ed, PqHalf { key: self.pq_public.clone(), sig: pq })
    }

    /// An ML-DSA-65 signature over `m` with context `ctx`, alone: for the corpus's
    /// negative controls, which put a post-quantum half over other bytes.
    #[doc(hidden)]
    pub fn ml_dsa_sign(&self, m: &[u8], ctx: &[u8]) -> Vec<u8> {
        self.pq.sign_deterministic(m, ctx).expect("a context under 256 bytes").encode().to_vec()
    }
}

/// The ML-DSA-65 public key of the seed made of byte `b` repeated 32 times. Every
/// ML-DSA key WAI's corpora sign with has such a seed, as every Ed25519 one does
/// (jwp-receipts §7.8): published, so it attests nothing.
pub fn published_pq_key(b: u8) -> Vec<u8> {
    ExpandedSigningKey::<MlDsa65>::from_seed(&B32::from([b; 32])).verifying_key().encode().to_vec()
}

/// Whether `key` is the ML-DSA-65 public key of a published seed ([`published_pq_key`]).
pub fn is_published_test_pq_key(key: &[u8]) -> bool {
    static KEYS: std::sync::OnceLock<Vec<Vec<u8>>> = std::sync::OnceLock::new();
    key.len() == PQ_KEY_LEN && KEYS.get_or_init(|| (0..=255u8).map(published_pq_key).collect()).iter().any(|k| k == key)
}

// ───────────────────────────── JSON members ─────────────────────────────

/// Lowercase hex of exactly `len` bytes, or `None`.
fn hex_of(v: &Value, len: usize) -> Option<Vec<u8>> {
    let s = v.as_str()?;
    if s.len() != 2 * len || !crate::claim_receipt::is_lower_hex(s) {
        return None;
    }
    crate::claim_receipt::unhex(s)
}

/// The hybrid members of a JSON object (§3): none (`Ok(None)`), or all three, `pq_suite`
/// naming this suite and `pq_key` and `pq_sig` lowercase hex of the suite's lengths. Checked
/// in this order: all or none (`pq_malformed`); `pq_suite` a string (`pq_malformed`) naming a
/// suite of §2 (`pq_suite_unknown`); `pq_key`, then `pq_sig` (`pq_malformed`).
pub fn read_members(m: &Map<String, Value>) -> Result<Option<PqHalf>, Reason> {
    read_named(m, "pq_key")
}

/// [`read_members`] with the key member named `key_member` (a key-log revision names its
/// root's `root_pq_key`).
pub fn read_named(m: &Map<String, Value>, key_member: &str) -> Result<Option<PqHalf>, Reason> {
    let names = ["pq_suite", key_member, "pq_sig"];
    match names.iter().filter(|k| m.contains_key(**k)).count() {
        0 => return Ok(None),
        3 => {}
        _ => return Err(Reason::PqMalformed),
    }
    match m["pq_suite"].as_str() {
        None => return Err(Reason::PqMalformed),
        Some(s) if s != SUITE => return Err(Reason::PqSuiteUnknown),
        Some(_) => {}
    }
    let key = hex_of(&m[key_member], PQ_KEY_LEN).ok_or(Reason::PqMalformed)?;
    let sig = hex_of(&m["pq_sig"], PQ_SIG_LEN).ok_or(Reason::PqMalformed)?;
    Ok(Some(PqHalf { key, sig }))
}

/// A key member alone (a key-log entry's `pq_suite` and `pq_key`, §6.1): none, or both.
pub fn read_key_members(m: &Map<String, Value>) -> Result<Option<Vec<u8>>, Reason> {
    match ["pq_suite", "pq_key"].iter().filter(|k| m.contains_key(**k)).count() {
        0 => return Ok(None),
        2 => {}
        _ => return Err(Reason::PqMalformed),
    }
    match m["pq_suite"].as_str() {
        None => return Err(Reason::PqMalformed),
        Some(s) if s != SUITE => return Err(Reason::PqSuiteUnknown),
        Some(_) => {}
    }
    hex_of(&m["pq_key"], PQ_KEY_LEN).map(Some).ok_or(Reason::PqMalformed)
}

/// Write the hybrid members of `pq` into `m`, the key under `key_member`.
pub fn write_members(m: &mut Map<String, Value>, pq: &PqHalf, key_member: &str, with_sig: bool) {
    m.insert("pq_suite".into(), SUITE.into());
    m.insert(key_member.into(), crate::claim_receipt::hex(&pq.key).into());
    if with_sig {
        m.insert("pq_sig".into(), crate::claim_receipt::hex(&pq.sig).into());
    }
}

// ───────────────────────────── records of the pattern ─────────────────────────────

/// A record of the pattern verified by [`verify_record`]: its signer, and its ML-DSA key
/// where it is hybrid.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VerifiedRecord {
    pub signer_pubkey: [u8; 32],
    pub pq_key: Option<Vec<u8>>,
}

/// The bytes a record of the pattern signs (§3.4): `domain ‖ JCS(record without "sig" and
/// "pq_sig")`. For a classical record, which has no `pq_sig`, these are the bytes its
/// extension already signs.
pub fn record_signing_bytes(domain: &[u8], record: &Value) -> Vec<u8> {
    let mut r = record.clone();
    if let Some(o) = r.as_object_mut() {
        o.remove("sig");
        o.remove("pq_sig");
    }
    let mut m = domain.to_vec();
    m.extend_from_slice(jcs(&r).expect("a record in the canonical subset").as_bytes());
    m
}

/// The record without its hybrid members: what its extension's own member check reads.
pub fn without_members(record: &Value) -> Value {
    let mut r = record.clone();
    if let Some(o) = r.as_object_mut() {
        for k in MEMBERS {
            o.remove(k);
        }
    }
    r
}

/// The shared check of a record signed as `domain ‖ JCS(record without its signatures)`
/// (§3.4), whatever its extension: the hybrid members ([`read_members`]), then `signer_pubkey`
/// and `sig` as 32 and 64 bytes of lowercase hex (`malformed`, the extension's own reason,
/// returned as [`RecordFailure::Malformed`]), then [`verify`] under `policy`.
pub fn verify_record(domain: &[u8], record: &Value, policy: Policy) -> Result<VerifiedRecord, RecordFailure> {
    let m = record.as_object().ok_or(RecordFailure::Malformed)?;
    let pq = read_members(m).map_err(|r| RecordFailure::Signature(Failure::Pq(r)))?;
    let signer: [u8; 32] = m
        .get("signer_pubkey")
        .and_then(|v| hex_of(v, 32))
        .and_then(|b| b.try_into().ok())
        .ok_or(RecordFailure::Malformed)?;
    let sig: [u8; 64] = m.get("sig").and_then(|v| hex_of(v, 64)).and_then(|b| b.try_into().ok()).ok_or(RecordFailure::Malformed)?;
    verify(&signer, &record_signing_bytes(domain, record), &sig, pq.as_ref(), policy).map_err(RecordFailure::Signature)?;
    Ok(VerifiedRecord { signer_pubkey: signer, pq_key: pq.map(|p| p.key) })
}

/// Why [`verify_record`] refused.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RecordFailure {
    /// `signer_pubkey` or `sig` is absent or not of its encoding: the extension's own
    /// malformed reason.
    Malformed,
    /// The signature step.
    Signature(Failure),
}

/// Sign `body` as a hybrid record of the pattern under `domain` (§3.4): adds
/// `signer_pubkey`, the hybrid members and `sig`.
pub fn sign_record(domain: &[u8], mut body: Value, key: &HybridKey) -> Value {
    let o = body.as_object_mut().expect("a record is an object");
    o.insert("signer_pubkey".into(), crate::claim_receipt::hex(&key.public()).into());
    o.insert("pq_suite".into(), SUITE.into());
    o.insert("pq_key".into(), crate::claim_receipt::hex(key.pq_public()).into());
    o.remove("sig");
    o.remove("pq_sig");
    let (ed, pq) = key.sign(&record_signing_bytes(domain, &body));
    let o = body.as_object_mut().expect("an object");
    o.insert("pq_sig".into(), crate::claim_receipt::hex(&pq.sig).into());
    o.insert("sig".into(), crate::claim_receipt::hex(&ed).into());
    body
}

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

    #[test]
    fn the_representative_is_the_drafts() {
        // draft-ietf-lamps-pq-composite-sigs-19 Appendix D builds M' for another label over
        // M = 00..09; the same construction with this suite's label.
        let m: Vec<u8> = (0u8..10).collect();
        let r = representative(&m);
        assert_eq!(&r[..32], b"CompositeAlgorithmSignatures2025");
        assert_eq!(&r[32..62], LABEL);
        assert_eq!(r[62], 0);
        assert_eq!(&r[63..], Sha512::digest(&m).as_slice());
        // Appendix D's PH(M), the SHA-512 of 00..09.
        assert_eq!(
            crate::claim_receipt::hex(&r[63..]),
            "0f89ee1fcb7b0a4f7809d1267a029719004c5a5e5ec323a7c3523a20974f9a3f202f56fadba4cd9e8d654ab9f2e96dc5c795ea176fa20ede8d854c342f903533"
        );
    }

    #[test]
    fn a_hybrid_signature_verifies_and_its_halves_do_not_separate() {
        let k = HybridKey::from_seeds([0x71; 32], [0x72; 32]);
        let msg = b"wai:test\x01{}";
        let (ed, pq) = k.sign(msg);
        assert_eq!(pq.key.len(), PQ_KEY_LEN);
        assert_eq!(pq.sig.len(), PQ_SIG_LEN);
        assert_eq!(verify(&k.public(), msg, &ed, Some(&pq), Policy::RequireHybrid), Ok(()));
        // The Ed25519 half alone is over M', not M: it is no classical signature of M.
        assert_eq!(verify(&k.public(), msg, &ed, None, Policy::AcceptClassical), Err(Failure::BadSignature));
        // Under the policy, a classical item is refused before its signature is examined.
        let classical = k.ed25519().sign(msg).to_bytes();
        assert_eq!(verify(&k.public(), msg, &classical, None, Policy::AcceptClassical), Ok(()));
        assert_eq!(verify(&k.public(), msg, &classical, None, Policy::RequireHybrid), Err(Failure::Pq(Reason::HybridRequired)));
        // The ML-DSA half over other bytes.
        let other = PqHalf { sig: k.ml_dsa_sign(&representative(b"other"), LABEL), ..pq.clone() };
        assert_eq!(verify(&k.public(), msg, &ed, Some(&other), Policy::AcceptClassical), Err(Failure::Pq(Reason::PqSignatureInvalid)));
        // The ML-DSA half over M' with no context: not the composite's.
        let no_ctx = PqHalf { sig: k.ml_dsa_sign(&representative(msg), b""), ..pq.clone() };
        assert_eq!(verify(&k.public(), msg, &ed, Some(&no_ctx), Policy::AcceptClassical), Err(Failure::Pq(Reason::PqSignatureInvalid)));
        // Another ML-DSA key.
        let k2 = HybridKey::from_seeds([0x71; 32], [0x73; 32]);
        let swapped = PqHalf { key: k2.pq_public().to_vec(), ..pq.clone() };
        assert_eq!(verify(&k.public(), msg, &ed, Some(&swapped), Policy::AcceptClassical), Err(Failure::Pq(Reason::PqSignatureInvalid)));
        // A flipped bit anywhere in the ML-DSA half.
        for at in [0, 47, 48, 3000, PQ_SIG_LEN - 56, PQ_SIG_LEN - 1] {
            let mut bad = pq.clone();
            bad.sig[at] ^= 1;
            assert_eq!(verify(&k.public(), msg, &ed, Some(&bad), Policy::AcceptClassical), Err(Failure::Pq(Reason::PqSignatureInvalid)), "{at}");
        }
        // Lengths of another parameter set.
        let short = PqHalf { key: pq.key[..1312].to_vec(), sig: pq.sig[..2420].to_vec() };
        assert_eq!(verify(&k.public(), msg, &ed, Some(&short), Policy::AcceptClassical), Err(Failure::Pq(Reason::PqMalformed)));
    }

    #[test]
    fn the_members_read_all_or_none() {
        let k = HybridKey::from_seeds([0x71; 32], [0x72; 32]);
        let rec = sign_record(b"wai:test\x01", serde_json::json!({"kind": "t", "n": 1}), &k);
        let m = rec.as_object().unwrap();
        assert!(read_members(m).unwrap().is_some());
        assert_eq!(verify_record(b"wai:test\x01", &rec, Policy::RequireHybrid).unwrap().pq_key.as_deref(), Some(k.pq_public()));
        // Another domain.
        assert_eq!(
            verify_record(b"wai:other\x01", &rec, Policy::AcceptClassical),
            Err(RecordFailure::Signature(Failure::BadSignature))
        );
        for drop in MEMBERS {
            let mut r = rec.clone();
            r.as_object_mut().unwrap().remove(drop);
            assert_eq!(read_members(r.as_object().unwrap()), Err(Reason::PqMalformed), "{drop}");
        }
        let mut r = rec.clone();
        r["pq_suite"] = "id-MLDSA44-Ed25519-SHA512".into();
        assert_eq!(read_members(r.as_object().unwrap()), Err(Reason::PqSuiteUnknown));
        r["pq_suite"] = 1.into();
        assert_eq!(read_members(r.as_object().unwrap()), Err(Reason::PqMalformed));
        let mut r = rec.clone();
        r["pq_key"] = r["pq_key"].as_str().unwrap().to_uppercase().into();
        assert_eq!(read_members(r.as_object().unwrap()), Err(Reason::PqMalformed));
        // Stripped of all three: the Ed25519 half is over M', so no classical record verifies.
        let stripped = without_members(&rec);
        assert_eq!(
            verify_record(b"wai:test\x01", &stripped, Policy::AcceptClassical),
            Err(RecordFailure::Signature(Failure::BadSignature))
        );
    }

    #[test]
    fn the_published_seeds_give_published_keys() {
        let k = HybridKey::from_seeds([0x71; 32], [0x72; 32]);
        assert_eq!(k.pq_public(), published_pq_key(0x72).as_slice());
        assert!(is_published_test_pq_key(k.pq_public()));
        let mut other = k.pq_public().to_vec();
        other[0] ^= 1;
        assert!(!is_published_test_pq_key(&other));
    }

    /// jwp-receipts §7.8 with §6.1: a log that lists a published ML-DSA key is reported, even
    /// where every Ed25519 key in it is unpublished.
    #[test]
    fn a_log_listing_a_published_ml_dsa_key_is_reported() {
        use crate::key_log::{commitment_hybrid, lists_published_test_key, KeyEntry, Revision};
        let seed = |b: u8| -> [u8; 32] { Sha512::digest([b"pq_sig test seed ".as_slice(), &[b]].concat())[..32].try_into().unwrap() };
        let root = HybridKey::from_seeds(seed(1), seed(2));
        let next = HybridKey::from_seeds(seed(3), seed(4));
        let rev = |pq: &[u8]| {
            let entry = KeyEntry::active_hybrid("k", HybridKey::from_seeds(seed(5), seed(6)).public(), pq, &["receipt"]);
            let unsigned = Revision {
                origin: "https://pub.example".into(),
                seq: 0,
                prev: None,
                issued_at: "2026-10-22T00:00:00Z".into(),
                root: [0; 32],
                next_root: commitment_hybrid(&next.public(), next.pq_public()),
                keys: vec![entry],
                revoked_modules: vec![],
                grant_max_s: 3_600,
                status_max_age_s: 600,
                sig: [0; 64],
                hybrid: None,
            };
            Revision::seal_hybrid(&root, unsigned).expect("rule 1")
        };
        let unpublished = HybridKey::from_seeds(seed(7), seed(8));
        assert!(!lists_published_test_key(&rev(unpublished.pq_public())));
        assert!(lists_published_test_key(&rev(&published_pq_key(0x72))));
    }

    #[test]
    fn the_codes_are_distinct() {
        let all = Reason::all();
        for (i, r) in all.iter().enumerate() {
            assert_eq!(Reason::from_label(r.label()), Some(*r));
            assert!(!all[..i].contains(r));
        }
    }
}
