Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/znippy

znippy-common/src/sign.rs

//! Detached, streaming CMS provenance signatures (feature `sign`).
//!
//! This module adds **provenance / authenticity** on top of znippy's existing
//! per-chunk BLAKE3 integrity. It never re-hashes file content: the per-artifact
//! and per-archive digests are a *merkle-fold of the chunk BLAKE3 hashes that the
//! compress path already produced* (`ChunkMeta.checksum`, ordered by `chunk_seq`,
//! Law 3). The signature is a **detached CMS `SignedData`** (RFC 5652) over that
//! digest — content bytes are never buffered or streamed through the signer.
//!
//! Everything here is pure Rust (RustCrypto: `cms`, `der`, `x509-cert`, `spki`,
//! `signature`, `ed25519-dalek`, `p256`/`ecdsa`, `sha2`). The whole module is
//! behind the off-by-default `sign` feature, so default builds, behaviour, and
//! the on-disk archive format are byte-for-byte unchanged.
//!
//! ## What gets signed
//! * Per-artifact: `file_digest(file)` — fold of one `FileMeta`'s chunk hashes.
//! * Per-archive: `archive_root(file_digests, footer)` — fold of every artifact
//!   digest plus the footer identity.
//!
//! The CMS message presented to the signer is the 32-byte digest; the
//! `messageDigest` signed attribute is `SHA-256(digest)` and `eContent` is
//! **absent** (detached, RFC 5652 §5.2). Verification recomputes the digest from
//! the index (the sunk-cost chunk hashes) and checks the CMS + cert chain.

use anyhow::{Result, anyhow, bail, ensure};

use cms::cert::{CertificateChoices, IssuerAndSerialNumber};
use cms::content_info::{CmsVersion, ContentInfo};
use cms::signed_data::{
    CertificateSet, EncapsulatedContentInfo, SignedData, SignerIdentifier, SignerInfo, SignerInfos,
};
use der::asn1::{Any, ObjectIdentifier, OctetString, SetOfVec};
use der::{Decode, Encode};
use sha2::{Digest, Sha256};
use spki::AlgorithmIdentifierOwned;
use x509_cert::Certificate;
use x509_cert::attr::Attribute;

use crate::meta::{ChunkMeta, FileMeta};

// ─────────────────────────── Object identifiers ────────────────────────────

/// `id-sha256` (NIST). Digest algorithm for the CMS message-digest attribute.
const OID_SHA256: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.1");
/// `ecdsa-with-SHA256`. Signature algorithm for the P-256 signer.
const OID_ECDSA_SHA256: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.2");
/// `ecdsa-with-SHA384` — what Sigstore's Fulcio CA signs its certificates with
/// (the root and the intermediate hold P-384 keys; a leaf is P-256, signed by the
/// intermediate with SHA-384).
const OID_ECDSA_SHA384: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.3");
/// The named curves an EC SPKI may carry: `secp256r1` and `secp384r1`.
const OID_SECP256R1: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.3.1.7");
const OID_SECP384R1: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.132.0.34");
/// The longest certificate path a signer may take to a trust anchor.
const MAX_CHAIN_DEPTH: usize = 8;
/// `id-Ed25519`. Signature algorithm for the Ed25519 signer.
const OID_ED25519: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.101.112");
/// `id-data` — the encapsulated content type (RFC 5652).
const OID_ID_DATA: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.7.1");
/// `id-signedData`.
const OID_ID_SIGNED_DATA: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.7.2");
/// `id-contentType` signed attribute.
const OID_ATTR_CONTENT_TYPE: ObjectIdentifier =
    ObjectIdentifier::new_unwrap("1.2.840.113549.1.9.3");
/// `id-messageDigest` signed attribute.
const OID_ATTR_MESSAGE_DIGEST: ObjectIdentifier =
    ObjectIdentifier::new_unwrap("1.2.840.113549.1.9.4");
/// `commonName` (CN) RDN.
const OID_CN: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.4.3");

/// Domain-separation tags so a per-artifact fold can never collide with a
/// per-archive fold (or with a raw chunk hash).
const ARTIFACT_TAG: &[u8] = b"znippy.artifact.v1\0";
const ARCHIVE_TAG: &[u8] = b"znippy.archive.v1\0";

// ─────────────────────────── Algorithm identity ────────────────────────────

/// Signature algorithm carried in the CMS `SignerInfo`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SigAlg {
    Ed25519,
    EcdsaP256,
}

impl SigAlg {
    /// Parse a CLI/config algorithm name (`p256`/`ecdsa` or `ed25519`).
    pub fn from_name(name: &str) -> Result<Self> {
        match name.trim().to_ascii_lowercase().as_str() {
            "p256" | "ecdsa" | "ecdsa-p256" => Ok(SigAlg::EcdsaP256),
            "ed25519" | "ed" => Ok(SigAlg::Ed25519),
            other => bail!("unknown signature algorithm '{other}' (expected p256|ed25519)"),
        }
    }

    fn signature_algorithm_id(self) -> AlgorithmIdentifierOwned {
        match self {
            // RFC 8419/8410: Ed25519 parameters are absent.
            SigAlg::Ed25519 => AlgorithmIdentifierOwned { oid: OID_ED25519, parameters: None },
            // RFC 5758: ecdsa-with-SHA256 parameters are absent.
            SigAlg::EcdsaP256 => {
                AlgorithmIdentifierOwned { oid: OID_ECDSA_SHA256, parameters: None }
            }
        }
    }
}

fn sha256_alg_id() -> AlgorithmIdentifierOwned {
    AlgorithmIdentifierOwned { oid: OID_SHA256, parameters: None }
}

// ───────────────────────────── Digest folds ────────────────────────────────

/// Per-artifact digest: a merkle-fold of one file's chunk BLAKE3 hashes, ordered
/// by `chunk_seq` (Law 3). Reuses the existing per-chunk hashes — content bytes
/// are never re-read. Binds the relative path so two files with identical bytes
/// still get distinct provenance digests.
pub fn file_digest(file: &FileMeta) -> [u8; 32] {
    let mut chunks: Vec<&ChunkMeta> = file.chunks.iter().collect();
    chunks.sort_by_key(|c| c.chunk_seq);
    file_digest_from_parts(
        &file.relative_path,
        chunks.iter().map(|c| (c.chunk_seq, &c.checksum)),
        chunks.len(),
    )
}

/// Same fold as [`file_digest`], but driven directly from `(chunk_seq, checksum)`
/// pairs (e.g. the on-disk index / lookup sub-index) so the verify path needs no
/// `FileMeta`. Caller passes the chunk count; pairs MUST already be sorted by
/// `chunk_seq`.
pub fn file_digest_from_parts<'a, I>(relative_path: &str, ordered_chunks: I, count: usize) -> [u8; 32]
where
    I: Iterator<Item = (u32, &'a [u8; 32])>,
{
    let mut h = blake3::Hasher::new();
    h.update(ARTIFACT_TAG);
    h.update(&(relative_path.len() as u64).to_le_bytes());
    h.update(relative_path.as_bytes());
    h.update(&(count as u64).to_le_bytes());
    for (seq, ck) in ordered_chunks {
        h.update(&seq.to_le_bytes());
        h.update(ck);
    }
    *h.finalize().as_bytes()
}

/// Per-archive root: a merkle-fold of every artifact digest (sorted by path for
/// determinism) plus the footer identity, so the archive signature commits to the
/// exact set of artifacts and to the index layout.
pub fn archive_root(file_digests: &[(String, [u8; 32])], footer: &crate::index::IndexFooter) -> [u8; 32] {
    let mut sorted: Vec<&(String, [u8; 32])> = file_digests.iter().collect();
    sorted.sort_by(|a, b| a.0.cmp(&b.0));

    let mut h = blake3::Hasher::new();
    h.update(ARCHIVE_TAG);
    // Bind the footer KIND (stable at write time), not the volatile manifest/index
    // offset — the signature sections are written *before* the manifest, so the
    // offset is not yet known when the archive root is signed.
    let footer_kind: u8 = match footer {
        crate::index::IndexFooter::Single { .. } => 0,
        crate::index::IndexFooter::Multi { .. } => 1,
    };
    h.update(&[footer_kind]);
    h.update(&(sorted.len() as u64).to_le_bytes());
    for (path, dig) in sorted {
        h.update(&(path.len() as u64).to_le_bytes());
        h.update(path.as_bytes());
        h.update(dig);
    }
    *h.finalize().as_bytes()
}

// ──────────────────────────── Signer abstraction ───────────────────────────

/// Produces a detached CMS `SignedData` (DER) over a 32-byte digest.
///
/// There is exactly ONE writer: [`ArchiveSigner::sign_digest_with_attrs`].
/// [`ArchiveSigner::sign_digest`] is a provided method that delegates to it with
/// no extra attributes, so the classic two-attribute seal (contentType +
/// messageDigest) is bit-for-bit what it always was.
pub trait ArchiveSigner {
    fn algorithm(&self) -> SigAlg;

    /// Returns a DETACHED CMS `SignedData` (DER) over `digest`, whose signed
    /// attributes are contentType + messageDigest **plus** `extra`.
    ///
    /// `extra` is signed, not merely adjacent: it is folded into the DER `SET OF`
    /// that the signature covers (RFC 5652 §5.4). znippy attaches no meaning to
    /// these attributes — a caller's claim (an approval role, a timestamp, …) is
    /// carried faithfully and returned faithfully by
    /// [`verify_digest`], and interpreted only by the caller.
    ///
    /// It is an error for `extra` to re-supply `contentType` or `messageDigest`:
    /// those are znippy's, and a duplicate would let a caller shadow the binding
    /// between the signature and the content.
    fn sign_digest_with_attrs(&self, digest: &[u8; 32], extra: &[Attribute])
    -> Result<Vec<u8>>;

    /// Returns a DETACHED CMS `SignedData` (DER) over `digest` with exactly the
    /// two mandatory signed attributes. Unchanged entry point.
    fn sign_digest(&self, digest: &[u8; 32]) -> Result<Vec<u8>> {
        self.sign_digest_with_attrs(digest, &[])
    }

    /// Sign `msg` RAW — the bare Ed25519 / ecdsa-with-SHA256 signature over the
    /// exact bytes, with no CMS wrapping. This is what a **DSSE** envelope needs
    /// (the message is the DSSE PAE); the CMS path never uses it. Returns 64-byte
    /// Ed25519, or a DER ECDSA-Sig-Value for P-256 — the shapes
    /// [`verify_signature`] already accepts.
    fn sign_raw(&self, msg: &[u8]) -> Result<Vec<u8>>;

    /// The signer's X.509 certificate (DER) — the same cert the CMS path embeds.
    /// A DSSE signer carries it alongside the envelope (the envelope itself holds
    /// only a `keyid`), so a verifier can recover the public key and identity.
    fn certificate_der(&self) -> &[u8];
}

/// Free-function form of [`ArchiveSigner::sign_digest_with_attrs`] — sign `root`
/// with caller-supplied signed attributes in addition to contentType +
/// messageDigest.
///
/// This is the entry point a policy layer (holger) calls to bind a claim to a
/// signature: the claim travels inside `signedAttrs`, so it cannot be stripped or
/// swapped without invalidating the signature. znippy never inspects it.
pub fn sign_digest_with_attrs(
    root: &[u8; 32],
    extra: &[Attribute],
    signer: &dyn ArchiveSigner,
) -> Result<Vec<u8>> {
    signer.sign_digest_with_attrs(root, extra)
}

/// Ed25519 signer (`ed25519-dalek`).
pub struct Ed25519Signer {
    key: ed25519_dalek::SigningKey,
    /// DER of the signer certificate (carries the public key + identity, chains
    /// to a CA the verifier trusts). Embedded in the CMS `certificates` field.
    cert_der: Vec<u8>,
}

impl Ed25519Signer {
    /// `cert_der` is the signer's X.509 certificate (DER) whose subject public
    /// key matches `key`.
    pub fn new(key: ed25519_dalek::SigningKey, cert_der: Vec<u8>) -> Self {
        Self { key, cert_der }
    }
}

impl ArchiveSigner for Ed25519Signer {
    fn algorithm(&self) -> SigAlg {
        SigAlg::Ed25519
    }
    fn sign_digest_with_attrs(
        &self,
        digest: &[u8; 32],
        extra: &[Attribute],
    ) -> Result<Vec<u8>> {
        use ed25519_dalek::Signer;
        let (attrs, attrs_der) = build_signed_attrs(digest, extra)?;
        let sig = self.key.sign(&attrs_der); // PureEdDSA over the signed attributes
        assemble_cms(attrs, sig.to_bytes().to_vec(), SigAlg::Ed25519, &self.cert_der)
    }
    fn sign_raw(&self, msg: &[u8]) -> Result<Vec<u8>> {
        use ed25519_dalek::Signer;
        Ok(self.key.sign(msg).to_bytes().to_vec()) // PureEdDSA over the DSSE PAE
    }
    fn certificate_der(&self) -> &[u8] {
        &self.cert_der
    }
}

/// ECDSA P-256 signer (`p256` / `ecdsa`).
pub struct EcdsaP256Signer {
    key: p256::ecdsa::SigningKey,
    cert_der: Vec<u8>,
}

impl EcdsaP256Signer {
    pub fn new(key: p256::ecdsa::SigningKey, cert_der: Vec<u8>) -> Self {
        Self { key, cert_der }
    }
}

impl ArchiveSigner for EcdsaP256Signer {
    fn algorithm(&self) -> SigAlg {
        SigAlg::EcdsaP256
    }
    fn sign_digest_with_attrs(
        &self,
        digest: &[u8; 32],
        extra: &[Attribute],
    ) -> Result<Vec<u8>> {
        use signature::Signer;
        let (attrs, attrs_der) = build_signed_attrs(digest, extra)?;
        // ecdsa-with-SHA256: SigningKey hashes the message with SHA-256 then signs;
        // DerSignature is the ASN.1 ECDSA-Sig-Value that X.509/CMS expects.
        let sig: p256::ecdsa::DerSignature = self.key.sign(&attrs_der);
        assemble_cms(attrs, sig.as_bytes().to_vec(), SigAlg::EcdsaP256, &self.cert_der)
    }
    fn sign_raw(&self, msg: &[u8]) -> Result<Vec<u8>> {
        use signature::Signer;
        // ecdsa-with-SHA256: hashes `msg` (the DSSE PAE) with SHA-256 then signs;
        // DerSignature is the ASN.1 ECDSA-Sig-Value the verify path expects.
        let sig: p256::ecdsa::DerSignature = self.key.sign(msg);
        Ok(sig.as_bytes().to_vec())
    }
    fn certificate_der(&self) -> &[u8] {
        &self.cert_der
    }
}

/// Build a boxed [`ArchiveSigner`] from a PKCS#8 (DER) private key and the DER of
/// the signer's X.509 certificate. The certificate's subject public key must match
/// `pkcs8_key`. This is the loader seam the CLI / a key-store calls — it keeps all
/// RustCrypto key types inside this crate so callers only handle bytes + a `SigAlg`.
pub fn signer_from_pkcs8(
    alg: SigAlg,
    pkcs8_key: &[u8],
    cert_der: &[u8],
) -> Result<Box<dyn ArchiveSigner + Send>> {
    match alg {
        SigAlg::Ed25519 => {
            use ed25519_dalek::pkcs8::DecodePrivateKey;
            let key = ed25519_dalek::SigningKey::from_pkcs8_der(pkcs8_key)
                .map_err(|e| anyhow!("load Ed25519 PKCS#8 key: {e}"))?;
            Ok(Box::new(Ed25519Signer::new(key, cert_der.to_vec())))
        }
        SigAlg::EcdsaP256 => {
            use p256::pkcs8::DecodePrivateKey;
            let secret = p256::SecretKey::from_pkcs8_der(pkcs8_key)
                .map_err(|e| anyhow!("load P-256 PKCS#8 key: {e}"))?;
            Ok(Box::new(EcdsaP256Signer::new(
                p256::ecdsa::SigningKey::from(secret),
                cert_der.to_vec(),
            )))
        }
    }
}

// ───────────────────────────── CMS construction ────────────────────────────

/// Build the CMS signed attributes (content-type = id-data, message-digest =
/// SHA-256(digest), **plus** any caller-supplied `extra`) and return them
/// together with their DER `SET OF` encoding — the exact bytes the signer signs
/// (RFC 5652 §5.4).
///
/// This is the ONE writer of znippy's signed-attribute set; both the classic
/// two-attribute seal (`extra = &[]`) and the attribute-carrying seal route
/// through it, so the mandatory bindings can never drift apart.
fn build_signed_attrs(
    digest: &[u8; 32],
    extra: &[Attribute],
) -> Result<(SetOfVec<Attribute>, Vec<u8>)> {
    let message_digest = Sha256::digest(digest); // SHA-256 of the 32-byte content

    let content_type_attr = Attribute {
        oid: OID_ATTR_CONTENT_TYPE,
        values: SetOfVec::try_from(vec![Any::encode_from(&OID_ID_DATA)?])
            .map_err(|e| anyhow!("content-type attr: {e}"))?,
    };
    let md_octets = OctetString::new(message_digest.as_slice())?;
    let message_digest_attr = Attribute {
        oid: OID_ATTR_MESSAGE_DIGEST,
        values: SetOfVec::try_from(vec![Any::encode_from(&md_octets)?])
            .map_err(|e| anyhow!("message-digest attr: {e}"))?,
    };

    let mut all = Vec::with_capacity(2 + extra.len());
    all.push(content_type_attr);
    all.push(message_digest_attr);
    for a in extra {
        // The two mandatory attributes are znippy's and are not overridable: a
        // caller-supplied duplicate would shadow the binding between the
        // signature and the content it commits to.
        ensure!(
            a.oid != OID_ATTR_CONTENT_TYPE && a.oid != OID_ATTR_MESSAGE_DIGEST,
            "signed attribute {} is reserved (contentType / messageDigest are set by znippy)",
            a.oid
        );
        all.push(a.clone());
    }

    // `SetOfVec::try_from` rejects duplicates, so two `extra` entries with the
    // same OID *and* the same values are a caller error, not a silent merge.
    let attrs = SetOfVec::try_from(all).map_err(|e| anyhow!("signed attrs: {e}"))?;
    // SignedAttributes are signed in their DER SET OF (tag 0x31) form.
    let attrs_der = attrs.to_der()?;
    Ok((attrs, attrs_der))
}

/// Assemble a detached CMS `SignedData` `ContentInfo` (DER) from pre-signed
/// attributes + the raw signature + the signer cert.
fn assemble_cms(
    signed_attrs: SetOfVec<Attribute>,
    signature: Vec<u8>,
    alg: SigAlg,
    cert_der: &[u8],
) -> Result<Vec<u8>> {
    let cert = Certificate::from_der(cert_der).map_err(|e| anyhow!("signer cert: {e}"))?;
    let iasn = IssuerAndSerialNumber {
        issuer: cert.tbs_certificate().issuer().clone(),
        serial_number: cert.tbs_certificate().serial_number().clone(),
    };

    let signer_info = SignerInfo {
        version: CmsVersion::V1,
        sid: SignerIdentifier::IssuerAndSerialNumber(iasn),
        digest_alg: sha256_alg_id(),
        signed_attrs: Some(signed_attrs),
        signature_algorithm: alg.signature_algorithm_id(),
        signature: OctetString::new(signature)?,
        unsigned_attrs: None,
    };

    let signed_data = SignedData {
        version: CmsVersion::V1,
        digest_algorithms: SetOfVec::try_from(vec![sha256_alg_id()])
            .map_err(|e| anyhow!("digest algs: {e}"))?,
        // Detached: eContentType present, eContent absent.
        encap_content_info: EncapsulatedContentInfo {
            econtent_type: OID_ID_DATA,
            econtent: None,
        },
        certificates: Some(
            CertificateSet::try_from(vec![CertificateChoices::Certificate(cert)])
                .map_err(|e| anyhow!("cert set: {e}"))?,
        ),
        crls: None,
        signer_infos: SignerInfos::try_from(vec![signer_info])
            .map_err(|e| anyhow!("signer infos: {e}"))?,
    };

    let content_info = ContentInfo {
        content_type: OID_ID_SIGNED_DATA,
        content: Any::encode_from(&signed_data)?,
    };
    Ok(content_info.to_der()?)
}

// ───────────────────────────── Verification ────────────────────────────────

/// A trusted set of root certificates (DER), used to chain a signer cert.
#[derive(Default, Clone)]
pub struct CertStore {
    roots: Vec<Certificate>,
}

impl CertStore {
    pub fn new() -> Self {
        Self::default()
    }
    /// Build from a list of DER-encoded root certificates.
    pub fn from_der_certs(ders: &[Vec<u8>]) -> Result<Self> {
        let mut s = Self::default();
        for d in ders {
            s.add_root_der(d)?;
        }
        Ok(s)
    }
    pub fn add_root_der(&mut self, der: &[u8]) -> Result<()> {
        self.roots
            .push(Certificate::from_der(der).map_err(|e| anyhow!("root cert: {e}"))?);
        Ok(())
    }
    pub fn is_empty(&self) -> bool {
        self.roots.is_empty()
    }
}

/// Identity recovered from a verified signer certificate.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignerId {
    /// SHA-256 over the signer certificate's DER — the certificate's fingerprint.
    ///
    /// This, not `subject`, is the key a distinctness rule must use: **two
    /// certificates can carry the same subject**, so subject equality does not
    /// identify a certificate. A policy layer maps fingerprints to people/roles;
    /// znippy only reports which certificates signed.
    pub fingerprint: [u8; 32],
    /// commonName (CN) of the signer cert subject, if present.
    pub common_name: String,
    /// Full RFC 4514 subject string.
    pub subject: String,
}

impl SignerId {
    /// Lowercase hex of [`SignerId::fingerprint`] — the form a human or a policy
    /// file writes down.
    pub fn fingerprint_hex(&self) -> String {
        hex::encode(self.fingerprint)
    }
}

/// A verified signer: the identity **plus the signed attributes that identity
/// actually signed**.
///
/// `verify_digest` used to validate `signedAttrs` and then drop them, so a caller
/// could never read a claim it was entitled to trust. Everything in `attrs` was
/// covered by the verified signature, including `contentType` and
/// `messageDigest`, which znippy has already checked.
///
/// znippy attaches **no meaning** to any other attribute. It does not know what a
/// `security-reviewer` is and must not learn; interpreting a claim is the
/// caller's policy.
#[derive(Debug, Clone)]
pub struct VerifiedSigner {
    /// Who signed (chained to a trusted root).
    pub id: SignerId,
    /// The complete, verified `signedAttrs` SET — returned verbatim.
    pub attrs: SetOfVec<Attribute>,
}

impl VerifiedSigner {
    /// The single signed attribute with this OID, if present. Returns the
    /// attribute verbatim; decoding its values is the caller's business.
    pub fn attribute(&self, oid: &ObjectIdentifier) -> Option<&Attribute> {
        self.attrs.iter().find(|a| &a.oid == oid)
    }

    /// Convenience: `self.id.common_name`.
    pub fn common_name(&self) -> &str {
        &self.id.common_name
    }
    /// Convenience: `self.id.subject`.
    pub fn subject(&self) -> &str {
        &self.id.subject
    }
    /// Convenience: `self.id.fingerprint`.
    pub fn fingerprint(&self) -> &[u8; 32] {
        &self.id.fingerprint
    }
}

/// Verify a detached CMS over `digest`, chain the signer cert to `roots`, and
/// return the signer identity **together with the attributes it signed**.
/// Algorithm-agnostic: the signature algorithm is read from the CMS `SignerInfo`,
/// so one call handles both Ed25519 and P-256.
///
/// Only `contentType` and `messageDigest` are *asserted*; every other signed
/// attribute is carried through untouched into [`VerifiedSigner::attrs`]. That is
/// what makes the attribute channel backward compatible in both directions: an
/// old verifier accepts a signature carrying new attributes, and a new verifier
/// accepts an old two-attribute signature (returning just those two).
pub fn verify_digest(
    digest: &[u8; 32],
    cms_der: &[u8],
    roots: &CertStore,
) -> Result<VerifiedSigner> {
    let ci = ContentInfo::from_der(cms_der).map_err(|e| anyhow!("parse ContentInfo: {e}"))?;
    ensure!(ci.content_type == OID_ID_SIGNED_DATA, "not a CMS SignedData");
    let sd = SignedData::from_der(&ci.content.to_der()?)
        .map_err(|e| anyhow!("parse SignedData: {e}"))?;

    let signer_info = sd
        .signer_infos
        .0
        .iter()
        .next()
        .ok_or_else(|| anyhow!("no SignerInfo"))?;

    let signed_attrs = signer_info
        .signed_attrs
        .as_ref()
        .ok_or_else(|| anyhow!("detached CMS requires signed attributes"))?;

    // 1. The signed message-digest attribute must equal SHA-256(recomputed digest).
    let expected_md = Sha256::digest(digest);
    let got_md = attr_octet_string(signed_attrs, &OID_ATTR_MESSAGE_DIGEST)?;
    ensure!(
        got_md.as_slice() == expected_md.as_slice(),
        "message-digest mismatch (content does not match signature)"
    );
    // 2. The content-type attribute must match the encapsulated content type.
    let got_ct = attr_oid(signed_attrs, &OID_ATTR_CONTENT_TYPE)?;
    ensure!(got_ct == OID_ID_DATA, "unexpected content-type attribute");

    // 3. Recover the signer certificate from the CMS.
    let signer_cert = signer_certificate(&sd)?;
    let spki_der = signer_cert.tbs_certificate().subject_public_key_info().to_der()?;

    // 4. Verify the signature over the DER SET OF signed attributes (RFC 5652 §5.4).
    let signed_bytes = signed_attrs.to_der()?;
    let sig_bytes = signer_info.signature.as_bytes();
    let alg = &signer_info.signature_algorithm.oid;
    verify_signature(alg, &spki_der, &signed_bytes, sig_bytes)?;

    // 5. Chain the signer cert to a trusted root.
    chain_to_roots(signer_cert, roots)?;

    // 6. Hand back WHAT WAS VERIFIED: the identity plus the whole signed-attribute
    //    SET, so a caller can act on a claim the signature actually covers.
    Ok(VerifiedSigner { id: signer_id_of(signer_cert)?, attrs: signed_attrs.clone() })
}

fn signer_certificate(sd: &SignedData) -> Result<&Certificate> {
    let set = sd
        .certificates
        .as_ref()
        .ok_or_else(|| anyhow!("CMS carries no signer certificate"))?;
    for choice in set.0.iter() {
        if let CertificateChoices::Certificate(c) = choice {
            return Ok(c);
        }
    }
    bail!("no X.509 certificate in CMS")
}

/// The named curve an EC SubjectPublicKeyInfo carries in its algorithm parameters.
fn ec_curve_of(spki_der: &[u8]) -> Result<ObjectIdentifier> {
    let spki = spki::SubjectPublicKeyInfoOwned::from_der(spki_der).map_err(|e| anyhow!("spki: {e}"))?;
    ensure!(spki.algorithm.oid == OID_EC_PUBLIC_KEY, "not an EC public key: {}", spki.algorithm.oid);
    spki.algorithm
        .parameters
        .as_ref()
        .and_then(|p| p.decode_as::<ObjectIdentifier>().ok())
        .ok_or_else(|| anyhow!("an EC public key names no curve"))
}

/// Verify a signature given the signer's SPKI DER, the signed bytes, and the
/// signature, dispatching on the signature-algorithm OID: Ed25519, and ECDSA with
/// SHA-256 or SHA-384 over P-256 or P-384 (the curve read from the SPKI). Fulcio's
/// chain is P-384 signed with SHA-384; its leaves P-256 signed with SHA-384.
fn verify_signature(
    alg: &ObjectIdentifier,
    spki_der: &[u8],
    signed_bytes: &[u8],
    sig_bytes: &[u8],
) -> Result<()> {
    if *alg == OID_ED25519 {
        use ed25519_dalek::pkcs8::DecodePublicKey;
        use ed25519_dalek::{Signature, Verifier, VerifyingKey};
        let vk =
            VerifyingKey::from_public_key_der(spki_der).map_err(|e| anyhow!("ed25519 spki: {e}"))?;
        let sig = Signature::from_slice(sig_bytes).map_err(|e| anyhow!("ed25519 sig: {e}"))?;
        vk.verify(signed_bytes, &sig)
            .map_err(|_| anyhow!("Ed25519 signature verification failed"))
    } else if *alg == OID_ECDSA_SHA256 || *alg == OID_ECDSA_SHA384 {
        use ecdsa::signature::DigestVerifier;
        use sha2::Sha384;
        let sha384 = *alg == OID_ECDSA_SHA384;
        let curve = ec_curve_of(spki_der)?;
        if curve == OID_SECP256R1 {
            use p256::ecdsa::signature::Verifier;
            use p256::ecdsa::{Signature, VerifyingKey};
            use p256::pkcs8::DecodePublicKey;
            let vk =
                VerifyingKey::from_public_key_der(spki_der).map_err(|e| anyhow!("p256 spki: {e}"))?;
            let sig = Signature::from_der(sig_bytes).map_err(|e| anyhow!("p256 sig: {e}"))?;
            let ok = if sha384 {
                vk.verify_digest(|d: &mut Sha384| { d.update(signed_bytes); Ok(()) }, &sig).is_ok()
            } else {
                vk.verify(signed_bytes, &sig).is_ok()
            };
            ensure!(ok, "ECDSA P-256 signature verification failed");
            Ok(())
        } else if curve == OID_SECP384R1 {
            use p384::ecdsa::signature::Verifier;
            use p384::ecdsa::{Signature, VerifyingKey};
            use p384::pkcs8::DecodePublicKey;
            let vk =
                VerifyingKey::from_public_key_der(spki_der).map_err(|e| anyhow!("p384 spki: {e}"))?;
            let sig = Signature::from_der(sig_bytes).map_err(|e| anyhow!("p384 sig: {e}"))?;
            let ok = if sha384 {
                vk.verify(signed_bytes, &sig).is_ok()
            } else {
                vk.verify_digest(|d: &mut Sha256| { d.update(signed_bytes); Ok(()) }, &sig).is_ok()
            };
            ensure!(ok, "ECDSA P-384 signature verification failed");
            Ok(())
        } else {
            bail!("unsupported EC curve OID: {curve}")
        }
    } else {
        bail!("unsupported signature algorithm OID: {alg}")
    }
}

/// Verify `leaf` chains to a trust anchor in `roots`: each link issued by the
/// next (issuer name and signature), through any intermediates the store holds
/// (a Fulcio leaf → the Sigstore intermediate → the Sigstore root), ending at an
/// anchor. An anchor is a store certificate that is self-signed, or that nothing
/// else in the store issued (a store of bare CAs behaves as before). The leaf
/// itself is never an anchor.
fn chain_to_roots(leaf: &Certificate, roots: &CertStore) -> Result<()> {
    ensure!(!roots.is_empty(), "no trusted roots configured");
    let issuer_of = |c: &Certificate| -> Option<&Certificate> {
        roots.roots.iter().find(|r| {
            c.tbs_certificate().issuer() == r.tbs_certificate().subject()
                && r.to_der().ok() != c.to_der().ok()
                && cert_signed_by(c, r).is_ok()
        })
    };
    let mut current = leaf;
    for _ in 0..MAX_CHAIN_DEPTH {
        let Some(parent) = issuer_of(current) else {
            bail!("signer certificate does not chain to any trusted root")
        };
        let self_signed = parent.tbs_certificate().issuer() == parent.tbs_certificate().subject();
        if self_signed || issuer_of(parent).is_none() {
            return Ok(());
        }
        current = parent;
    }
    bail!("signer certificate chain is longer than {MAX_CHAIN_DEPTH} links")
}

/// Verify `leaf`'s signature using `issuer`'s public key.
fn cert_signed_by(leaf: &Certificate, issuer: &Certificate) -> Result<()> {
    let tbs = leaf.tbs_certificate().to_der()?;
    let sig = leaf.signature().raw_bytes();
    let issuer_spki = issuer.tbs_certificate().subject_public_key_info().to_der()?;
    verify_signature(&leaf.signature_algorithm().oid, &issuer_spki, &tbs, sig)
}

fn signer_id_of(cert: &Certificate) -> Result<SignerId> {
    let subject = cert.tbs_certificate().subject().to_string();
    let common_name = extract_cn(cert).unwrap_or_default();
    Ok(SignerId { fingerprint: cert_fingerprint(cert)?, common_name, subject })
}

/// SHA-256 over a certificate's DER encoding — the standard X.509 fingerprint.
///
/// The certificate is re-encoded from its parsed form. DER is a canonical
/// encoding and `x509-cert` preserves the TBS bytes it parsed, so for any input
/// that was valid DER this reproduces the original bytes exactly — the same
/// fingerprint `openssl x509 -fingerprint -sha256` prints.
fn cert_fingerprint(cert: &Certificate) -> Result<[u8; 32]> {
    let der = cert.to_der().map_err(|e| anyhow!("re-encode signer cert: {e}"))?;
    Ok(Sha256::digest(&der).into())
}

/// Pull the commonName out of a cert subject, if present.
fn extract_cn(cert: &Certificate) -> Option<String> {
    // x509-cert 0.3: `Name::iter` walks every attribute of every RDN in order —
    // what the old two-level loop over `.0` did (2026-10-02, W3).
    {
        for atv in cert.tbs_certificate().subject().iter() {
            if atv.oid == OID_CN {
                if let Ok(s) = atv.value.decode_as::<der::asn1::Utf8StringRef<'_>>() {
                    return Some(s.as_str().to_string());
                }
                if let Ok(s) = atv.value.decode_as::<der::asn1::PrintableStringRef<'_>>() {
                    return Some(s.as_str().to_string());
                }
            }
        }
    }
    None
}

// ── signed-attribute extraction helpers ──

fn find_attr<'a>(attrs: &'a SetOfVec<Attribute>, oid: &ObjectIdentifier) -> Result<&'a Attribute> {
    attrs
        .iter()
        .find(|a| &a.oid == oid)
        .ok_or_else(|| anyhow!("missing signed attribute {oid}"))
}

fn attr_octet_string(attrs: &SetOfVec<Attribute>, oid: &ObjectIdentifier) -> Result<Vec<u8>> {
    let attr = find_attr(attrs, oid)?;
    let val = attr.values.iter().next().ok_or_else(|| anyhow!("empty attribute {oid}"))?;
    let os = OctetString::from_der(&val.to_der()?).map_err(|e| anyhow!("attr octet string: {e}"))?;
    Ok(os.as_bytes().to_vec())
}

fn attr_oid(attrs: &SetOfVec<Attribute>, oid: &ObjectIdentifier) -> Result<ObjectIdentifier> {
    let attr = find_attr(attrs, oid)?;
    let val = attr.values.iter().next().ok_or_else(|| anyhow!("empty attribute {oid}"))?;
    ObjectIdentifier::from_der(&val.to_der()?).map_err(|e| anyhow!("attr oid: {e}"))
}

// ───────────────────── Archive-level read + verify (Phase B/C) ──────────────

use std::collections::BTreeMap;
use std::path::Path;

/// The detached signatures recovered from a signed archive.
pub struct ArchiveSignatures {
    /// Per-archive detached CMS over the archive root digest.
    pub archive_cms: Vec<u8>,
    /// Per-artifact detached CMS, keyed by `relative_path`.
    pub artifacts: BTreeMap<String, Vec<u8>>,
}

/// Read the detached signature sections from an archive, if it was sealed with a
/// signer. Returns `None` for an unsigned archive (no signature sections).
pub fn read_archive_signatures(path: &Path) -> Result<Option<ArchiveSignatures>> {
    let archive_cms =
        match crate::index::read_reserved_section_bytes(path, crate::index::SIGN_ARCHIVE_MODULE)? {
            Some(b) => b,
            None => return Ok(None),
        };
    let artifacts = match crate::index::read_reserved_section_bytes(
        path,
        crate::index::SIGN_ARTIFACTS_MODULE,
    )? {
        Some(b) => deserialize_artifact_signatures(&b)?,
        None => BTreeMap::new(),
    };
    Ok(Some(ArchiveSignatures { archive_cms, artifacts }))
}

/// Decode the per-artifact signatures Arrow IPC stream into `path → cms`.
fn deserialize_artifact_signatures(bytes: &[u8]) -> Result<BTreeMap<String, Vec<u8>>> {
    use arrow::array::{BinaryArray, StringArray};

    let reader = crate::ipc_guard::stream_reader(bytes)?;
    let mut out = BTreeMap::new();
    for batch in reader {
        let batch = batch?;
        let paths = batch
            .column_by_name("relative_path")
            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
            .ok_or_else(|| anyhow!("artifact-sig: missing relative_path"))?;
        let cms = batch
            .column_by_name("cms")
            .and_then(|c| c.as_any().downcast_ref::<BinaryArray>())
            .ok_or_else(|| anyhow!("artifact-sig: missing cms"))?;
        for i in 0..batch.num_rows() {
            out.insert(paths.value(i).to_string(), cms.value(i).to_vec());
        }
    }
    Ok(out)
}

/// Recompute every artifact's digest from the archive index — i.e. from the
/// sunk-cost per-chunk BLAKE3 hashes — without reading any file bytes.
fn recompute_file_digests(path: &Path) -> Result<BTreeMap<String, [u8; 32]>> {
    use arrow::array::{FixedSizeBinaryArray, StringArray, UInt32Array};

    let (_schema, batches) = crate::index::read_znippy_index(path)?;
    let mut chunks: BTreeMap<String, Vec<(u32, [u8; 32])>> = BTreeMap::new();
    for batch in &batches {
        let paths = batch
            .column_by_name("relative_path")
            .and_then(|c| c.as_any().downcast_ref::<StringArray>())
            .ok_or_else(|| anyhow!("index: missing relative_path"))?;
        let seqs = batch
            .column_by_name("chunk_seq")
            .and_then(|c| c.as_any().downcast_ref::<UInt32Array>())
            .ok_or_else(|| anyhow!("index: missing chunk_seq"))?;
        let cks = batch
            .column_by_name("checksum")
            .and_then(|c| c.as_any().downcast_ref::<FixedSizeBinaryArray>())
            .ok_or_else(|| anyhow!("index: missing checksum"))?;
        for i in 0..batch.num_rows() {
            let mut ck = [0u8; 32];
            ck.copy_from_slice(cks.value(i));
            chunks.entry(paths.value(i).to_string()).or_default().push((seqs.value(i), ck));
        }
    }
    let mut out = BTreeMap::new();
    for (p, mut cs) in chunks {
        cs.sort_by_key(|(seq, _)| *seq);
        let n = cs.len();
        let dig = file_digest_from_parts(&p, cs.iter().map(|(s, c)| (*s, c)), n);
        out.insert(p, dig);
    }
    Ok(out)
}

/// Result of verifying an archive's provenance.
#[derive(Debug, Clone)]
pub struct ArchiveVerifyReport {
    /// The archive-level signer: identity + the attributes it signed.
    pub signer: VerifiedSigner,
    /// Number of artifacts whose per-artifact signature verified.
    pub artifacts_verified: usize,
}

/// Verify a served artifact: recompute its digest from `file` (the sunk-cost
/// chunk hashes) and verify the detached CMS against the trusted roots.
///
/// **This is the per-artifact entry point holger calls** when serving a file:
/// `verify_artifact(&file_meta, &stored_cms, &roots)`.
pub fn verify_artifact(
    file: &FileMeta,
    cms_der: &[u8],
    roots: &CertStore,
) -> Result<VerifiedSigner> {
    let digest = file_digest(file);
    verify_digest(&digest, cms_der, roots)
}

/// Verify a whole signed archive: every per-artifact signature against its
/// recomputed digest, and the per-archive signature against the recomputed root.
/// Recomputes all digests from the index — never reads file bytes.
pub fn verify_archive(path: &Path, roots: &CertStore) -> Result<ArchiveVerifyReport> {
    let sigs = read_archive_signatures(path)?
        .ok_or_else(|| anyhow!("archive carries no provenance signatures"))?;
    let digests = recompute_file_digests(path)?;

    // Every signed artifact must verify against its recomputed digest.
    let mut artifacts_verified = 0usize;
    for (rel, cms) in &sigs.artifacts {
        let digest = digests
            .get(rel)
            .ok_or_else(|| anyhow!("signed artifact {rel} not present in index"))?;
        verify_digest(digest, cms, roots)
            .map_err(|e| anyhow!("artifact {rel} signature: {e}"))?;
        artifacts_verified += 1;
    }

    // The per-archive signature commits to the exact set of artifacts.
    let footer = crate::index::IndexFooter::Multi { manifest_offset: 0 };
    let file_digests: Vec<(String, [u8; 32])> =
        digests.into_iter().collect();
    let root = archive_root(&file_digests, &footer);
    let signer = verify_digest(&root, &sigs.archive_cms, roots)
        .map_err(|e| anyhow!("archive signature: {e}"))?;

    Ok(ArchiveVerifyReport { signer, artifacts_verified })
}

/// Look up a single served artifact's stored CMS by path (for holger, which
/// verifies one artifact at a time).
pub fn artifact_signature_for(path: &Path, relative_path: &str) -> Result<Option<Vec<u8>>> {
    Ok(read_archive_signatures(path)?.and_then(|s| s.artifacts.get(relative_path).cloned()))
}

// ───────────── Variable multi-signer M-of-N threshold (M in 0..=N) ───────────
//
// The seal above puts ONE detached CMS over the merkle-fold root. This layer
// generalises that to **N detached signatures over the SAME root**, gated by a
// threshold **M**: a set is accepted iff at least M **distinct** valid
// signatures are present. It is deliberately artifact-agnostic — znippy knows
// only "signatures over a root" and "a threshold". What the signers *mean*
// (security-reviewer, system-owner, release-signer, …) lives entirely in the
// caller (holger, modgunn); znippy enforces M-of-N-distinct and nothing else.
//
//   * M = 0     → NO signature required — an explicit, loud opt-out (below).
//   * M = 1,N=1 → the classic single-signature seal (backward compatible).
//   * 1 ≤ M ≤ N → any M distinct valid signers satisfy the threshold.
//
// Distinctness is by verified signer CERTIFICATE — SHA-256 over its DER, the
// standard fingerprint. Two signatures from the same certificate count **once**,
// so an M-of-N genuinely needs M different certificates. It is deliberately NOT
// keyed on the subject: a subject is a label, and a CA can issue two certificates
// carrying the same one, so subject equality does not identify a certificate.
//
// Note the boundary this draws. znippy guarantees "M distinct certificates"; it
// cannot guarantee "M distinct humans", because the map from certificate to
// person is trust policy and lives in the caller. A caller that needs a genuine
// two-person rule must check the fingerprints in `ThresholdReport::signers`
// against its own roster — which is exactly why the fingerprint is returned.

/// Container magic for a serialized [`SignatureSet`]. A stored blob that does
/// NOT start with this magic is treated as a bare legacy CMS `ContentInfo`
/// (a DER `SEQUENCE`, tag `0x30`) and loaded as an M=1, N=1 set — this is what
/// makes archives sealed by the old single-signature path still verify.
const SIGSET_MAGIC: &[u8; 4] = b"ZSGS";
const SIGSET_VERSION: u8 = 1;

/// A set of detached CMS signatures over the **same** 32-byte root, plus the
/// M-of-N threshold required to accept them. Each entry is a detached CMS
/// `SignedData` (DER) and therefore already carries `{signer_id, cert, sig}`:
/// the signer's leaf certificate embeds the identity and public key, the CMS
/// carries the signature. The signer identity is recovered — and trusted —
/// only from the *verified* certificate at check time, never from an untrusted
/// side field.
///
/// Fields are private so the security-relevant `M = 0` opt-out can only be
/// produced through the loudly-named [`SignatureSet::opt_out_unsigned`]
/// constructor — never as a silent `Default` or struct literal.
#[derive(Clone, Debug)]
pub struct SignatureSet {
    threshold: u32,
    /// N detached CMS `SignedData` blobs (DER), one per signer.
    signatures: Vec<Vec<u8>>,
}

impl SignatureSet {
    /// Build a set that REQUIRES `threshold` (M) distinct valid signatures.
    ///
    /// `threshold` must be ≥ 1. A threshold of 0 means "no signature required"
    /// and can only be built through [`SignatureSet::opt_out_unsigned`]; that is
    /// intentional — M=0 is a real security decision and must be spelled out.
    /// `signatures` may hold fewer, equal, or more entries than the threshold;
    /// whether the threshold is actually met is decided against a root at verify
    /// time, not here.
    pub fn new(threshold: u32, signatures: Vec<Vec<u8>>) -> Result<Self> {
        ensure!(
            threshold >= 1,
            "SignatureSet::new requires threshold >= 1; M=0 (no signature required) \
             must be created explicitly via SignatureSet::opt_out_unsigned()"
        );
        Ok(Self { threshold, signatures })
    }

    /// The classic single-signature seal, expressed as an M=1, N=1 set. This is
    /// the backward-compatibility bridge: wrap an existing detached CMS and it
    /// verifies exactly as the old single-signature path did.
    pub fn single(cms_der: Vec<u8>) -> Self {
        Self { threshold: 1, signatures: vec![cms_der] }
    }

    /// EXPLICIT opt-out: a set whose threshold is 0 — it accepts a payload with
    /// NO valid signatures at all. This discards provenance entirely; use it
    /// only where signing is deliberately not required. It is a named
    /// constructor, never a default, and the verifier still forces the caller to
    /// acknowledge M=0 (see [`verify_threshold`] vs
    /// [`verify_threshold_allowing_unsigned`]).
    pub fn opt_out_unsigned() -> Self {
        Self { threshold: 0, signatures: Vec::new() }
    }

    /// The threshold M.
    pub fn threshold(&self) -> u32 {
        self.threshold
    }

    /// The N detached CMS blobs.
    pub fn signatures(&self) -> &[Vec<u8>] {
        &self.signatures
    }

    /// Number of attached signatures (N). NOT the number that verify against a
    /// root — that is decided in [`verify_threshold`].
    pub fn len(&self) -> usize {
        self.signatures.len()
    }

    /// True iff no signatures are attached (N = 0).
    pub fn is_empty(&self) -> bool {
        self.signatures.is_empty()
    }

    /// Serialize to a self-describing blob:
    /// `MAGIC(4) | version(1) | M(u32 LE) | N(u32 LE) | (len(u32 LE) | cms)*`.
    pub fn to_bytes(&self) -> Vec<u8> {
        let mut out = Vec::new();
        out.extend_from_slice(SIGSET_MAGIC);
        out.push(SIGSET_VERSION);
        out.extend_from_slice(&self.threshold.to_le_bytes());
        out.extend_from_slice(&(self.signatures.len() as u32).to_le_bytes());
        for cms in &self.signatures {
            out.extend_from_slice(&(cms.len() as u32).to_le_bytes());
            out.extend_from_slice(cms);
        }
        out
    }

    /// Parse a [`SignatureSet`] blob. **Backward compatible:** a blob that does
    /// not begin with [`SIGSET_MAGIC`] is assumed to be a legacy bare CMS
    /// `ContentInfo` (exactly what the old single-signature seal wrote) and is
    /// loaded as an M=1, N=1 set — so existing sealed archives still verify.
    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
        if bytes.len() < 4 || &bytes[..4] != SIGSET_MAGIC {
            // Legacy single-signature seal: the whole blob is one detached CMS.
            ensure!(!bytes.is_empty(), "empty signature blob");
            return Ok(Self::single(bytes.to_vec()));
        }
        let mut p = 4usize;
        let ver = *bytes.get(p).ok_or_else(|| anyhow!("sigset: truncated version"))?;
        p += 1;
        ensure!(ver == SIGSET_VERSION, "sigset: unsupported version {ver}");
        fn read_u32(b: &[u8], p: &mut usize) -> Result<u32> {
            let end = *p + 4;
            let s = b.get(*p..end).ok_or_else(|| anyhow!("sigset: truncated"))?;
            *p = end;
            Ok(u32::from_le_bytes(s.try_into().unwrap()))
        }
        let threshold = read_u32(bytes, &mut p)?;
        let n = read_u32(bytes, &mut p)? as usize;
        let mut signatures = Vec::with_capacity(n);
        for _ in 0..n {
            let len = read_u32(bytes, &mut p)? as usize;
            let end = p + len;
            let cms = bytes.get(p..end).ok_or_else(|| anyhow!("sigset: truncated cms"))?;
            signatures.push(cms.to_vec());
            p = end;
        }
        Ok(Self { threshold, signatures })
    }
}

/// Sign `root` with N signers and bundle them into a [`SignatureSet`] carrying
/// the given M-of-N `threshold`. Every signer produces a detached CMS over the
/// **same** root. Sequential (no thread pool): N is a handful of signers and a
/// signature is CPU-cheap, so there is nothing to fan out.
///
/// `threshold` may be `0..=N`. A threshold of 0 is the explicit unsigned opt-out
/// (M=0); any `1..=N` is a genuine M-of-N. `threshold > N` is rejected — you can
/// never require more distinct signatures than you attach.
pub fn sign_multi(
    root: &[u8; 32],
    threshold: u32,
    signers: &[&dyn ArchiveSigner],
) -> Result<SignatureSet> {
    ensure!(
        (threshold as usize) <= signers.len(),
        "threshold M={threshold} exceeds N={} signers (M-of-N requires M <= N)",
        signers.len()
    );
    let mut signatures = Vec::with_capacity(signers.len());
    for s in signers {
        signatures.push(s.sign_digest(root)?);
    }
    Ok(SignatureSet { threshold, signatures })
}

/// Outcome of verifying a [`SignatureSet`] against a root.
#[derive(Debug, Clone)]
pub struct ThresholdReport {
    /// The threshold M that was required.
    pub threshold: u32,
    /// Number of DISTINCT valid signers found (each signer CERTIFICATE counted
    /// once, keyed by its SHA-256 fingerprint).
    pub distinct_valid: usize,
    /// The distinct verified signers — identity **and** the attributes each one
    /// signed — deduplicated by certificate fingerprint and ordered by it.
    ///
    /// znippy stops here. Mapping a fingerprint to a person, and deciding which
    /// combination of claims is sufficient, is the caller's policy.
    pub signers: Vec<VerifiedSigner>,
    /// TRUE iff the threshold was 0 — i.e. NO provenance was required to accept
    /// this set. A caller MUST NOT treat such a payload as signed; the flag
    /// exists so the opt-out stays visible downstream instead of looking like a
    /// normal pass.
    pub accepted_without_signatures: bool,
}

/// Verify a [`SignatureSet`] against `root`: accept iff at least **M distinct**
/// valid signatures (by signer-certificate fingerprint) are present.
///
/// A zero threshold (M=0) is **rejected here** as a misconfiguration — accepting
/// an unsigned set is a deliberate act, so it is confined to the
/// separately-named [`verify_threshold_allowing_unsigned`]. That is what makes
/// the opt-out loud: an M=0 set can never slip through the ordinary verify path.
pub fn verify_threshold(
    root: &[u8; 32],
    set: &SignatureSet,
    roots: &CertStore,
) -> Result<ThresholdReport> {
    ensure!(
        set.threshold != 0,
        "signature set has threshold M=0 (no signature required); refusing to accept \
         an unsigned set through verify_threshold(). If you truly intend to accept a \
         payload with NO provenance, call verify_threshold_allowing_unsigned() and \
         take responsibility for the opt-out"
    );
    verify_threshold_inner(root, set, roots)
}

/// Like [`verify_threshold`], but explicitly permits the M=0 opt-out. Calling
/// this function IS the acknowledgement that an unsigned payload is acceptable;
/// the returned report's `accepted_without_signatures` flag is set so the
/// decision stays visible to everything downstream.
pub fn verify_threshold_allowing_unsigned(
    root: &[u8; 32],
    set: &SignatureSet,
    roots: &CertStore,
) -> Result<ThresholdReport> {
    verify_threshold_inner(root, set, roots)
}

fn verify_threshold_inner(
    root: &[u8; 32],
    set: &SignatureSet,
    roots: &CertStore,
) -> Result<ThresholdReport> {
    // Collect DISTINCT valid signers, keyed by the signer certificate's SHA-256
    // FINGERPRINT.
    //   * A signature over a DIFFERENT root fails `verify_digest` (message-digest
    //     mismatch) and is simply not counted — no cross-bundle replay.
    //   * Two signatures from the SAME certificate collapse to one entry.
    //   * Keying on the subject would be wrong: a subject is a label a CA may
    //     issue twice, so `CN=alice` on two different certificates is two
    //     certificates, and znippy must report two. Which fingerprints belong to
    //     the same *person* — the thing a two-person rule actually cares about —
    //     is a trust-policy question the caller answers, not znippy.
    let mut distinct: BTreeMap<[u8; 32], VerifiedSigner> = BTreeMap::new();
    for cms in &set.signatures {
        if let Ok(v) = verify_digest(root, cms, roots) {
            distinct.entry(v.id.fingerprint).or_insert(v);
        }
    }
    let distinct_valid = distinct.len();
    ensure!(
        distinct_valid as u32 >= set.threshold,
        "threshold not met: {distinct_valid} distinct valid signature(s) present, need M={}",
        set.threshold
    );
    Ok(ThresholdReport {
        threshold: set.threshold,
        distinct_valid,
        signers: distinct.into_values().collect(),
        accepted_without_signatures: set.threshold == 0,
    })
}

// ─────────────────────── DSSE / in-toto SLSA interop ────────────────────────
//
// The CMS seal above is znippy's own airgap format. This layer adds a SECOND,
// spec-standard signature shape so a build provenance can be verified by the
// mainstream SLSA toolchain (cosign / slsa-verifier), which speaks **DSSE**
// (github.com/secure-systems-lab/dsse) envelopes over in-toto statements.
//
// It reuses the EXACT SAME znippy signing keys (Ed25519 / ECDSA-P256) — a DSSE
// signature is the raw Ed25519/ecdsa-with-SHA256 signature over the DSSE PAE,
// not a CMS blob. Nothing here replaces or touches the CMS path; a caller picks
// one shape or the other (nornir's sealer exposes `--provenance cms|dsse`).
//
// The envelope is byte-conformant to the DSSE spec:
//   { "payload": base64(body), "payloadType": <type>,
//     "signatures": [ { "sig": base64(sig), "keyid": <hex-fingerprint> } ] }
// `keyid` is the signer certificate's SHA-256 fingerprint hex — the same value
// `SignerId::fingerprint_hex()` reports and the key modgunn reads out of a Rekor
// `content.envelope.signatures[].keyid`, so producer and verifier agree on it.

/// `id-ecPublicKey` — the SPKI algorithm OID of a P-256 signer certificate, used
/// to pick the signature-verify path for a DSSE envelope (which, unlike CMS,
/// carries no signature-algorithm OID of its own).
const OID_EC_PUBLIC_KEY: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.2.1");

/// The in-toto statement media type — the ONLY `payloadType` SLSA provenance
/// uses. cosign/slsa-verifier key on this exact string.
pub const DSSE_PAYLOAD_TYPE_INTOTO: &str = "application/vnd.in-toto+json";

/// DSSE **PAE** — Pre-Authentication Encoding (spec §"Signature Definition"):
///
/// ```text
/// PAE(type, body) = "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body
/// ```
///
/// where `SP` is a single 0x20 space, `LEN` is the ASCII-decimal BYTE length,
/// `type` is UTF-8 and `body` is raw bytes. This — never the bare payload — is
/// what gets signed, which is what stops a payload-type-confusion attack. Byte
/// exact; proven against the spec's own vector in the tests.
pub fn dsse_pae(payload_type: &str, payload: &[u8]) -> Vec<u8> {
    let mut out = Vec::with_capacity(payload.len() + payload_type.len() + 32);
    out.extend_from_slice(b"DSSEv1");
    out.push(b' ');
    out.extend_from_slice(payload_type.len().to_string().as_bytes());
    out.push(b' ');
    out.extend_from_slice(payload_type.as_bytes());
    out.push(b' ');
    out.extend_from_slice(payload.len().to_string().as_bytes());
    out.push(b' ');
    out.extend_from_slice(payload);
    out
}

/// Standard base64 encode (`A–Z a–z 0–9 + /`, `=` padding). A dozen lines of pure
/// Rust rather than a `base64` dependency — the DSSE envelope carries `payload`
/// and each `sig` this way, and modgunn's Rekor reader already hand-rolls the
/// matching decoder, so the whole path stays dependency-clean and airgap.
pub fn base64_encode(bytes: &[u8]) -> String {
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);
    for chunk in bytes.chunks(3) {
        let b0 = chunk[0] as u32;
        let b1 = *chunk.get(1).unwrap_or(&0) as u32;
        let b2 = *chunk.get(2).unwrap_or(&0) as u32;
        let n = (b0 << 16) | (b1 << 8) | b2;
        out.push(T[(n >> 18 & 0x3f) as usize] as char);
        out.push(T[(n >> 12 & 0x3f) as usize] as char);
        out.push(if chunk.len() > 1 { T[(n >> 6 & 0x3f) as usize] as char } else { '=' });
        out.push(if chunk.len() > 2 { T[(n & 0x3f) as usize] as char } else { '=' });
    }
    out
}

/// Standard base64 decode. Whitespace is ignored; any invalid character, data
/// after padding, or a truncated final group yields `None` (never a partial,
/// wrong decode). Mirror of modgunn's Rekor-body decoder.
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
    fn val(c: u8) -> Option<u8> {
        Some(match c {
            b'A'..=b'Z' => c - b'A',
            b'a'..=b'z' => c - b'a' + 26,
            b'0'..=b'9' => c - b'0' + 52,
            b'+' => 62,
            b'/' => 63,
            _ => return None,
        })
    }
    let (mut acc, mut bits, mut out, mut pad) = (0u32, 0u8, Vec::new(), 0usize);
    for c in s.bytes() {
        if c.is_ascii_whitespace() {
            continue;
        }
        if c == b'=' {
            pad += 1;
            continue;
        }
        if pad > 0 {
            return None;
        }
        acc = (acc << 6) | val(c)? as u32;
        bits += 6;
        if bits >= 8 {
            bits -= 8;
            out.push((acc >> bits) as u8);
        }
    }
    if bits >= 6 || (acc & ((1u32 << bits) - 1)) != 0 {
        return None;
    }
    Some(out)
}

/// One DSSE signature: the raw signature bytes plus the `keyid` hint (here the
/// signer cert's SHA-256 fingerprint hex).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DsseSignature {
    /// Unauthenticated key hint — the signer certificate's fingerprint hex.
    pub keyid: String,
    /// Raw signature over the PAE: 64-byte Ed25519, or a DER ECDSA-Sig-Value (P-256).
    pub sig: Vec<u8>,
}

/// A DSSE envelope (secure-systems-lab/dsse). `payload` is the RAW payload bytes;
/// the base64 lives only in the JSON form ([`Self::to_json`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DsseEnvelope {
    /// The in-toto statement bytes (raw). For SLSA this is the statement JSON.
    pub payload: Vec<u8>,
    /// The payload media type — [`DSSE_PAYLOAD_TYPE_INTOTO`] for in-toto/SLSA.
    pub payload_type: String,
    /// One or more detached signatures over `PAE(payload_type, payload)`.
    pub signatures: Vec<DsseSignature>,
}

impl DsseEnvelope {
    /// The exact bytes the signatures cover: `PAE(payloadType, payload)`.
    pub fn pae(&self) -> Vec<u8> {
        dsse_pae(&self.payload_type, &self.payload)
    }

    /// Serialize to the spec envelope JSON: `{payload, payloadType, signatures:[{sig, keyid}]}`
    /// with `payload`/`sig` base64-std. The field NAMES are exactly what cosign,
    /// slsa-verifier and Rekor read.
    pub fn to_json(&self) -> Result<String> {
        let sigs: Vec<serde_json::Value> = self
            .signatures
            .iter()
            .map(|s| serde_json::json!({ "keyid": s.keyid, "sig": base64_encode(&s.sig) }))
            .collect();
        let v = serde_json::json!({
            "payload": base64_encode(&self.payload),
            "payloadType": self.payload_type,
            "signatures": sigs,
        });
        serde_json::to_string(&v).map_err(|e| anyhow!("serialize DSSE envelope: {e}"))
    }

    /// Parse the spec envelope JSON. A `payload`/`sig` that is not valid base64 is
    /// a hard error (never a partial decode).
    pub fn from_json(text: &str) -> Result<Self> {
        let v: serde_json::Value =
            serde_json::from_str(text).map_err(|e| anyhow!("parse DSSE envelope: {e}"))?;
        let payload_type = v
            .get("payloadType")
            .and_then(|x| x.as_str())
            .ok_or_else(|| anyhow!("DSSE envelope: missing payloadType"))?
            .to_string();
        let payload_b64 = v
            .get("payload")
            .and_then(|x| x.as_str())
            .ok_or_else(|| anyhow!("DSSE envelope: missing payload"))?;
        let payload = base64_decode(payload_b64)
            .ok_or_else(|| anyhow!("DSSE envelope: payload is not valid base64"))?;
        let mut signatures = Vec::new();
        for s in v
            .get("signatures")
            .and_then(|x| x.as_array())
            .ok_or_else(|| anyhow!("DSSE envelope: missing signatures array"))?
        {
            let keyid = s.get("keyid").and_then(|x| x.as_str()).unwrap_or("").to_string();
            let sig_b64 = s
                .get("sig")
                .and_then(|x| x.as_str())
                .ok_or_else(|| anyhow!("DSSE envelope: signature missing sig"))?;
            let sig = base64_decode(sig_b64)
                .ok_or_else(|| anyhow!("DSSE envelope: sig is not valid base64"))?;
            signatures.push(DsseSignature { keyid, sig });
        }
        ensure!(!signatures.is_empty(), "DSSE envelope carries no signatures");
        Ok(DsseEnvelope { payload, payload_type, signatures })
    }
}

/// Sign a payload as a DSSE envelope with the SAME znippy keys the CMS path uses.
/// Each signer signs `PAE(payload_type, payload)`; `keyid` is that signer's cert
/// fingerprint hex. This is the DSSE counterpart of [`sign_multi`].
pub fn dsse_sign(
    payload_type: &str,
    payload: &[u8],
    signers: &[&dyn ArchiveSigner],
) -> Result<DsseEnvelope> {
    ensure!(!signers.is_empty(), "dsse_sign requires at least one signer");
    let pae = dsse_pae(payload_type, payload);
    let mut signatures = Vec::with_capacity(signers.len());
    for s in signers {
        let sig = s.sign_raw(&pae)?;
        let cert = Certificate::from_der(s.certificate_der())
            .map_err(|e| anyhow!("dsse_sign signer cert: {e}"))?;
        let keyid = hex::encode(cert_fingerprint(&cert)?);
        signatures.push(DsseSignature { keyid, sig });
    }
    Ok(DsseEnvelope { payload: payload.to_vec(), payload_type: payload_type.to_string(), signatures })
}

/// Verify a DSSE envelope against the `signer_certs_der` that produced it,
/// chaining each matched signer certificate to `roots`. Returns one
/// [`VerifiedSigner`] per envelope signature that verified.
///
/// DSSE carries no signature-algorithm OID, so the algorithm is read from the
/// signer certificate's SPKI (`id-Ed25519` → Ed25519, `id-ecPublicKey` →
/// ecdsa-with-SHA256) — the same `verify_signature` dispatcher the CMS path uses.
/// A signature whose `keyid` names a certificate is checked against THAT
/// certificate; an empty `keyid` is checked against every candidate. Every
/// envelope signature must verify and chain, else this is an error.
pub fn dsse_verify(
    env: &DsseEnvelope,
    signer_certs_der: &[Vec<u8>],
    roots: &CertStore,
) -> Result<Vec<VerifiedSigner>> {
    ensure!(!env.signatures.is_empty(), "DSSE envelope carries no signatures");
    let pae = env.pae();

    // Parse candidate certs once, keyed by fingerprint hex.
    let mut candidates: Vec<(String, Certificate)> = Vec::with_capacity(signer_certs_der.len());
    for cd in signer_certs_der {
        let cert = Certificate::from_der(cd).map_err(|e| anyhow!("dsse_verify signer cert: {e}"))?;
        let fp = hex::encode(cert_fingerprint(&cert)?);
        candidates.push((fp, cert));
    }

    let mut verified = Vec::with_capacity(env.signatures.len());
    for sig in &env.signatures {
        let mut ok = false;
        for (fp, cert) in &candidates {
            if !sig.keyid.is_empty() && &sig.keyid != fp {
                continue;
            }
            let alg = spki_sig_alg_oid(cert)?;
            let spki = cert
                .tbs_certificate()
                .subject_public_key_info()
                .to_der()
                .map_err(|e| anyhow!("dsse_verify spki: {e}"))?;
            if verify_signature(&alg, &spki, &pae, &sig.sig).is_ok() {
                chain_to_roots(cert, roots)?;
                verified.push(VerifiedSigner { id: signer_id_of(cert)?, attrs: SetOfVec::new() });
                ok = true;
                break;
            }
        }
        ensure!(
            ok,
            "DSSE signature (keyid {}) did not verify against any provided signer certificate",
            if sig.keyid.is_empty() { "<none>" } else { &sig.keyid }
        );
    }
    Ok(verified)
}

/// The signature-algorithm OID a DSSE verify should use for `cert`, read from the
/// certificate's SPKI algorithm (DSSE envelopes carry no algorithm OID).
fn spki_sig_alg_oid(cert: &Certificate) -> Result<ObjectIdentifier> {
    let spki_alg = cert.tbs_certificate().subject_public_key_info().algorithm.oid;
    if spki_alg == OID_ED25519 {
        Ok(OID_ED25519)
    } else if spki_alg == OID_EC_PUBLIC_KEY {
        Ok(OID_ECDSA_SHA256)
    } else {
        bail!("unsupported DSSE signer key algorithm OID: {spki_alg}")
    }
}

/// Test / dev / bootstrap helpers: mint an in-memory PKI (a P-256 CA mirroring
/// holger/mannequin) and build ready-to-use signers. Pure Rust (x509-cert
/// builder). Used by the unit tests and the seal-throughput bench; also handy for
/// dev/bootstrap where a real CA is not yet wired up.
pub mod dev {
    use super::*;
    use std::str::FromStr;
    use std::time::Duration;
    use x509_cert::builder::{profile::BuilderProfile, Builder, CertificateBuilder};
    use x509_cert::certificate::TbsCertificate;
    use x509_cert::ext::pkix::{AuthorityKeyIdentifier, BasicConstraints, KeyUsage, KeyUsages, SubjectKeyIdentifier};
    use x509_cert::ext::{Extension, ToExtension};
    use x509_cert::spki::SubjectPublicKeyInfoRef;
    use x509_cert::name::Name;
    use x509_cert::serial_number::SerialNumber;
    use x509_cert::spki::SubjectPublicKeyInfoOwned;
    use x509_cert::time::Validity;

    /// The two certificate shapes this module mints, as x509-cert 0.2's
    /// `Profile::Root` and `Profile::Leaf { include_subject_key_identifier: false,
    /// enable_key_agreement: false, enable_key_encipherment: false }` built them
    /// — the same extensions in the same order. x509-cert 0.3 replaced that enum
    /// with the `BuilderProfile` trait (2026-10-02, W3 crypto bump); its stock
    /// `cabf` profiles add/require other extensions, so these are spelled out.
    enum DevProfile {
        /// Self-signed CA: SKI, basicConstraints(ca), keyUsage(keyCertSign|cRLSign).
        Root(Name),
        /// End entity: AKI, basicConstraints(!ca), keyUsage(digitalSignature|nonRepudiation).
        /// No SKI — see `issue_leaf`.
        Leaf { issuer: Name, subject: Name },
    }

    impl BuilderProfile for DevProfile {
        fn get_issuer(&self, subject: &Name) -> Name {
            match self {
                DevProfile::Root(_) => subject.clone(),
                DevProfile::Leaf { issuer, .. } => issuer.clone(),
            }
        }
        fn get_subject(&self) -> Name {
            match self {
                DevProfile::Root(s) | DevProfile::Leaf { subject: s, .. } => s.clone(),
            }
        }
        fn build_extensions(
            &self,
            spk: SubjectPublicKeyInfoRef<'_>,
            issuer_spk: SubjectPublicKeyInfoRef<'_>,
            tbs: &TbsCertificate,
        ) -> x509_cert::builder::Result<Vec<Extension>> {
            let mut ext: Vec<Extension> = Vec::new();
            let subject = tbs.subject();
            match self {
                DevProfile::Root(_) => {
                    ext.push(SubjectKeyIdentifier::try_from(spk)?.to_extension(subject, &ext)?);
                    ext.push(BasicConstraints { ca: true, path_len_constraint: None }.to_extension(subject, &ext)?);
                    ext.push(KeyUsage(KeyUsages::KeyCertSign | KeyUsages::CRLSign).to_extension(subject, &ext)?);
                }
                DevProfile::Leaf { .. } => {
                    ext.push(AuthorityKeyIdentifier::try_from(issuer_spk)?.to_extension(subject, &ext)?);
                    ext.push(BasicConstraints { ca: false, path_len_constraint: None }.to_extension(subject, &ext)?);
                    ext.push(
                        KeyUsage(KeyUsages::DigitalSignature | KeyUsages::NonRepudiation).to_extension(subject, &ext)?,
                    );
                }
            }
            Ok(ext)
        }
    }

    /// Mint a self-signed P-256 CA. Returns `(signing key, cert DER)`.
    pub fn mint_ca(cn: &str) -> Result<(p256::ecdsa::SigningKey, Vec<u8>)> {
        let ca_key = <p256::ecdsa::SigningKey as p256::elliptic_curve::Generate>::generate_from_rng(&mut rand_core::UnwrapErr(getrandom::SysRng));
        let ca_vk = p256::ecdsa::VerifyingKey::from(&ca_key);
        let subject = Name::from_str(&format!("CN={cn}")).map_err(|e| anyhow!("name: {e}"))?;
        let spki = SubjectPublicKeyInfoOwned::from_key(&ca_vk).map_err(|e| anyhow!("spki: {e}"))?;
        let builder = CertificateBuilder::new(
            DevProfile::Root(subject),
            SerialNumber::from(1u32),
            Validity::from_now(Duration::from_secs(3600)).map_err(|e| anyhow!("validity: {e}"))?,
            spki,
        )
        .map_err(|e| anyhow!("ca builder: {e}"))?;
        let cert = builder.build::<_, p256::ecdsa::DerSignature>(&ca_key).map_err(|e| anyhow!("ca sign: {e}"))?;
        Ok((ca_key, cert.to_der()?))
    }

    /// Issue a leaf cert for an arbitrary subject public key, signed by the CA.
    pub fn issue_leaf(
        ca_key: &p256::ecdsa::SigningKey,
        ca_der: &[u8],
        cn: &str,
        leaf_spki: SubjectPublicKeyInfoOwned,
        serial: u32,
    ) -> Result<Vec<u8>> {
        let ca = Certificate::from_der(ca_der)?;
        let issuer = ca.tbs_certificate().subject().clone();
        let subject = Name::from_str(&format!("CN={cn}")).map_err(|e| anyhow!("name: {e}"))?;
        let builder = CertificateBuilder::new(
            // `include_subject_key_identifier: false` is MEASURED, not a default.
            // A CMS SignerIdentifier is either issuerAndSerialNumber or
            // subjectKeyIdentifier, and this crate builds the FIRST — see
            // `SignerIdentifier::IssuerAndSerialNumber` at the sign site above.
            // Nothing ever reads an SKI off this leaf, so emitting one would change
            // the certificate's bytes for no reader. RFC 5280 makes SKI mandatory
            // for a CA and optional for an end entity, and this is an end entity
            // with a one-hour validity. Flip it the day a verifier identifies the
            // signer by key id.
            //
            // x509-cert 0.3 (2026-10-02) has no such field: `DevProfile::Leaf`
            // above simply never emits the SKI.
            DevProfile::Leaf { issuer, subject },
            SerialNumber::from(serial),
            Validity::from_now(Duration::from_secs(3600)).map_err(|e| anyhow!("validity: {e}"))?,
            leaf_spki,
        )
        .map_err(|e| anyhow!("leaf builder: {e}"))?;
        let cert = builder.build::<_, p256::ecdsa::DerSignature>(ca_key).map_err(|e| anyhow!("leaf sign: {e}"))?;
        Ok(cert.to_der()?)
    }

    /// Build a P-256 signer with a fresh key + leaf cert issued by `ca`.
    pub fn new_p256_signer(
        ca_key: &p256::ecdsa::SigningKey,
        ca_der: &[u8],
        cn: &str,
    ) -> Result<EcdsaP256Signer> {
        let key = <p256::ecdsa::SigningKey as p256::elliptic_curve::Generate>::generate_from_rng(&mut rand_core::UnwrapErr(getrandom::SysRng));
        let vk = p256::ecdsa::VerifyingKey::from(&key);
        let spki = SubjectPublicKeyInfoOwned::from_key(&vk).map_err(|e| anyhow!("spki: {e}"))?;
        let leaf = issue_leaf(ca_key, ca_der, cn, spki, 10)?;
        Ok(EcdsaP256Signer::new(key, leaf))
    }

    /// Build an Ed25519 signer with a fresh key + leaf cert issued by `ca`.
    pub fn new_ed25519_signer(
        ca_key: &p256::ecdsa::SigningKey,
        ca_der: &[u8],
        cn: &str,
    ) -> Result<Ed25519Signer> {
        let key = ed25519_dalek::SigningKey::generate(&mut rand_core::UnwrapErr(getrandom::SysRng));
        let spki =
            SubjectPublicKeyInfoOwned::from_key(&key.verifying_key()).map_err(|e| anyhow!("spki: {e}"))?;
        let leaf = issue_leaf(ca_key, ca_der, cn, spki, 11)?;
        Ok(Ed25519Signer::new(key, leaf))
    }
}

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

    fn mint_ca(cn: &str) -> (p256::ecdsa::SigningKey, Vec<u8>) {
        dev::mint_ca(cn).unwrap()
    }
    fn p256_signer(ca_key: &p256::ecdsa::SigningKey, ca_der: &[u8], cn: &str) -> EcdsaP256Signer {
        dev::new_p256_signer(ca_key, ca_der, cn).unwrap()
    }
    fn ed25519_signer(ca_key: &p256::ecdsa::SigningKey, ca_der: &[u8], cn: &str) -> Ed25519Signer {
        dev::new_ed25519_signer(ca_key, ca_der, cn).unwrap()
    }

    fn chunk(seq: u32, fill: u8) -> ChunkMeta {
        ChunkMeta {
            fdata_offset: 0,
            file_index: 0,
            chunk_seq: seq,
            checksum: [fill; 32],
            compressed: false,
            uncompressed_size: 100,
            compressed_size: 100,
        }
    }

    fn sample_file() -> FileMeta {
        FileMeta {
            relative_path: "pkg/artifact-1.0.0.jar".into(),
            compressed: false,
            uncompressed_size: 300,
            chunks: vec![chunk(0, 0xaa), chunk(1, 0xbb), chunk(2, 0xcc)],
        }
    }


    /// Known-answer test for every byte this module writes (2026-10-02, W3 crypto bump).
    ///
    /// The round-trip tests above prove sign→verify works on ONE version of the
    /// RustCrypto stack; they cannot see a byte change, because both ends move
    /// together. This pins the bytes: the fixtures in `testdata/sign-kat/` were
    /// written by the PRE-bump stack (der 0.7 / x509-cert 0.2 / cms 0.2 /
    /// ed25519-dalek 2 / p256 0.13) and every later stack must (a) verify them —
    /// archives already on disk stay valid — and (b) re-produce them bit for bit
    /// from the same keys: Ed25519 is deterministic (RFC 8032), RustCrypto ECDSA
    /// is deterministic (RFC 6979), and the signed attributes carry no time.
    ///
    /// Regenerate ONLY on purpose: `ZNIPPY_SIGN_KAT_REGEN=1 cargo test -p znippy-common kat`.
    mod kat {
        use super::*;
        use std::path::PathBuf;

        const PAYLOAD_TYPE: &str = "application/vnd.in-toto+json";
        const PAYLOAD: &[u8] = br#"{"_type":"https://in-toto.io/Statement/v1","subject":[{"name":"kat"}]}"#;

        fn dir() -> PathBuf {
            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/sign-kat")
        }
        fn get(name: &str) -> Vec<u8> {
            std::fs::read(dir().join(name)).unwrap_or_else(|e| panic!("fixture {name}: {e}"))
        }

        #[test]
        fn kat_regenerate_only_on_request() {
            if std::env::var_os("ZNIPPY_SIGN_KAT_REGEN").is_none() {
                return;
            }
            use ed25519_dalek::pkcs8::EncodePrivateKey as _;
            let (ca_key, ca_der) = mint_ca("Znippy KAT CA");
            let ed = ed25519_signer(&ca_key, &ca_der, "kat-ed25519");
            let p2 = p256_signer(&ca_key, &ca_der, "kat-p256");
            let ed_pk8 = ed.key.to_pkcs8_der().unwrap().as_bytes().to_vec();
            let p2_pk8 = {
                use p256::pkcs8::EncodePrivateKey as _;
                p256::SecretKey::from(p2.key.clone()).to_pkcs8_der().unwrap().as_bytes().to_vec()
            };
            let digest = file_digest(&sample_file());
            let d = dir();
            std::fs::create_dir_all(&d).unwrap();
            for (n, b) in [
                ("ca.der", ca_der.clone()),
                ("ed25519.pk8", ed_pk8),
                ("ed25519.cert.der", ed.certificate_der().to_vec()),
                ("ed25519.cms.der", ed.sign_digest(&digest).unwrap()),
                ("p256.pk8", p2_pk8),
                ("p256.cert.der", p2.certificate_der().to_vec()),
                ("p256.cms.der", p2.sign_digest(&digest).unwrap()),
                ("dsse.json", dsse_sign(PAYLOAD_TYPE, PAYLOAD, &[&ed, &p2]).unwrap().to_json().unwrap().into_bytes()),
            ] {
                std::fs::write(d.join(n), b).unwrap();
            }
        }

        /// Archives signed before the bump still verify after it.
        #[test]
        fn kat_old_signatures_verify() {
            let roots = CertStore::from_der_certs(&[get("ca.der")]).unwrap();
            let digest = file_digest(&sample_file());
            assert_eq!(verify_digest(&digest, &get("ed25519.cms.der"), &roots).unwrap().id.common_name, "kat-ed25519");
            assert_eq!(verify_digest(&digest, &get("p256.cms.der"), &roots).unwrap().id.common_name, "kat-p256");
            let env = DsseEnvelope::from_json(std::str::from_utf8(&get("dsse.json")).unwrap()).unwrap();
            let v = dsse_verify(&env, &[get("ed25519.cert.der"), get("p256.cert.der")], &roots).unwrap();
            assert_eq!(v.len(), 2);
        }

        /// The same keys produce the same bytes: CMS, DSSE and the PKCS#8 encodings.
        #[test]
        fn kat_signing_is_byte_identical() {
            let digest = file_digest(&sample_file());
            for (alg, pk8, cert, cms) in [
                (SigAlg::Ed25519, "ed25519.pk8", "ed25519.cert.der", "ed25519.cms.der"),
                (SigAlg::EcdsaP256, "p256.pk8", "p256.cert.der", "p256.cms.der"),
            ] {
                let s = signer_from_pkcs8(alg, &get(pk8), &get(cert)).unwrap();
                assert_eq!(s.sign_digest(&digest).unwrap(), get(cms), "{cms} changed");
            }
            let ed = signer_from_pkcs8(SigAlg::Ed25519, &get("ed25519.pk8"), &get("ed25519.cert.der")).unwrap();
            let p2 = signer_from_pkcs8(SigAlg::EcdsaP256, &get("p256.pk8"), &get("p256.cert.der")).unwrap();
            let env = dsse_sign(PAYLOAD_TYPE, PAYLOAD, &[ed.as_ref(), p2.as_ref()]).unwrap();
            assert_eq!(env.to_json().unwrap().into_bytes(), get("dsse.json"), "DSSE envelope changed");
        }

        /// Certificates and keys written by the old stack decode and re-encode unchanged.
        #[test]
        fn kat_der_round_trips_are_byte_identical() {
            for c in ["ca.der", "ed25519.cert.der", "p256.cert.der"] {
                let cert = Certificate::from_der(&get(c)).unwrap();
                assert_eq!(cert.to_der().unwrap(), get(c), "{c} re-encodes differently");
            }
            {
                use ed25519_dalek::pkcs8::{DecodePrivateKey as _, EncodePrivateKey as _};
                let k = ed25519_dalek::SigningKey::from_pkcs8_der(&get("ed25519.pk8")).unwrap();
                assert_eq!(k.to_pkcs8_der().unwrap().as_bytes(), get("ed25519.pk8").as_slice(), "Ed25519 PKCS#8 changed");
            }
            {
                use p256::pkcs8::{DecodePrivateKey as _, EncodePrivateKey as _};
                let k = p256::SecretKey::from_pkcs8_der(&get("p256.pk8")).unwrap();
                assert_eq!(k.to_pkcs8_der().unwrap().as_bytes(), get("p256.pk8").as_slice(), "P-256 PKCS#8 changed");
            }
        }
    }

    #[test]
    fn ed25519_round_trip() {
        let (ca_key, ca_der) = mint_ca("Znippy Test CA");
        let signer = ed25519_signer(&ca_key, &ca_der, "ed-signer");
        assert_eq!(signer.algorithm(), SigAlg::Ed25519);

        let digest = file_digest(&sample_file());
        let cms = signer.sign_digest(&digest).unwrap();
        let roots = CertStore::from_der_certs(&[ca_der]).unwrap();

        let v = verify_digest(&digest, &cms, &roots).unwrap();
        assert_eq!(v.id.common_name, "ed-signer");
    }

    #[test]
    fn p256_round_trip() {
        let (ca_key, ca_der) = mint_ca("Znippy Test CA");
        let signer = p256_signer(&ca_key, &ca_der, "p256-signer");
        assert_eq!(signer.algorithm(), SigAlg::EcdsaP256);

        let digest = file_digest(&sample_file());
        let cms = signer.sign_digest(&digest).unwrap();
        let roots = CertStore::from_der_certs(&[ca_der]).unwrap();

        let v = verify_digest(&digest, &cms, &roots).unwrap();
        assert_eq!(v.id.common_name, "p256-signer");
    }

    #[test]
    fn signer_from_pkcs8_round_trip_both_algs() {
        use ed25519_dalek::pkcs8::EncodePrivateKey as _;
        use x509_cert::spki::SubjectPublicKeyInfoOwned;

        let (ca_key, ca_der) = mint_ca("Znippy Loader CA");
        let roots = CertStore::from_der_certs(&[ca_der.clone()]).unwrap();
        let digest = file_digest(&sample_file());

        // ── P-256: mint a key, PKCS#8-encode it, issue a matching leaf, reload ──
        let p_key = <p256::ecdsa::SigningKey as p256::elliptic_curve::Generate>::generate_from_rng(&mut rand_core::UnwrapErr(getrandom::SysRng));
        let p_spki =
            SubjectPublicKeyInfoOwned::from_key(&p256::ecdsa::VerifyingKey::from(&p_key)).unwrap();
        let p_leaf = dev::issue_leaf(&ca_key, &ca_der, "loaded-p256", p_spki, 20).unwrap();
        let p_pkcs8 = p256::SecretKey::from(&p_key).to_pkcs8_der().unwrap();
        let p_signer = signer_from_pkcs8(SigAlg::EcdsaP256, p_pkcs8.as_bytes(), &p_leaf).unwrap();
        let p_cms = p_signer.sign_digest(&digest).unwrap();
        assert_eq!(
            verify_digest(&digest, &p_cms, &roots).unwrap().id.common_name,
            "loaded-p256"
        );

        // ── Ed25519: same flow through the loader ──
        let e_key = ed25519_dalek::SigningKey::generate(&mut rand_core::UnwrapErr(getrandom::SysRng));
        let e_spki = SubjectPublicKeyInfoOwned::from_key(&e_key.verifying_key()).unwrap();
        let e_leaf = dev::issue_leaf(&ca_key, &ca_der, "loaded-ed", e_spki, 21).unwrap();
        let e_pkcs8 = e_key.to_pkcs8_der().unwrap();
        let e_signer = signer_from_pkcs8(SigAlg::Ed25519, e_pkcs8.as_bytes(), &e_leaf).unwrap();
        let e_cms = e_signer.sign_digest(&digest).unwrap();
        assert_eq!(
            verify_digest(&digest, &e_cms, &roots).unwrap().id.common_name,
            "loaded-ed"
        );

        assert_eq!(SigAlg::from_name("p256").unwrap(), SigAlg::EcdsaP256);
        assert_eq!(SigAlg::from_name("ed25519").unwrap(), SigAlg::Ed25519);
        assert!(SigAlg::from_name("rsa").is_err());
    }

    #[test]
    fn tamper_chunk_hash_breaks_verification() {
        let (ca_key, ca_der) = mint_ca("Znippy Test CA");
        let signer = p256_signer(&ca_key, &ca_der, "p256-signer");
        let roots = CertStore::from_der_certs(&[ca_der]).unwrap();

        let file = sample_file();
        let digest = file_digest(&file);
        let cms = signer.sign_digest(&digest).unwrap();
        assert!(verify_digest(&digest, &cms, &roots).is_ok());

        // Flip one chunk's BLAKE3 → the recomputed digest changes → verify fails.
        let mut tampered = file;
        tampered.chunks[1].checksum[0] ^= 0x01;
        let bad_digest = file_digest(&tampered);
        assert_ne!(digest, bad_digest);
        assert!(verify_digest(&bad_digest, &cms, &roots).is_err());
    }

    #[test]
    fn wrong_ca_is_rejected() {
        let (ca_key, ca_der) = mint_ca("Znippy Test CA");
        let signer = ed25519_signer(&ca_key, &ca_der, "ed-signer");
        let digest = file_digest(&sample_file());
        let cms = signer.sign_digest(&digest).unwrap();

        // A different, untrusted CA.
        let (_other_key, other_ca_der) = mint_ca("Some Other CA");
        let roots = CertStore::from_der_certs(&[other_ca_der]).unwrap();
        let err = verify_digest(&digest, &cms, &roots).unwrap_err();
        assert!(err.to_string().contains("chain"), "unexpected error: {err}");
    }

    #[test]
    fn archive_root_changes_when_an_artifact_changes() {
        let footer = crate::index::IndexFooter::Multi { manifest_offset: 4096 };
        let d1 = file_digest(&sample_file());
        let mut other = sample_file();
        other.relative_path = "pkg/other-2.0.0.jar".into();
        let d2 = file_digest(&other);

        let root_a = archive_root(&[("a".into(), d1), ("b".into(), d2)], &footer);
        // Order-independent (sorted by path).
        let root_b = archive_root(&[("b".into(), d2), ("a".into(), d1)], &footer);
        assert_eq!(root_a, root_b);

        // Tamper one artifact digest → root changes.
        let mut d2_bad = d2;
        d2_bad[0] ^= 0xff;
        let root_c = archive_root(&[("a".into(), d1), ("b".into(), d2_bad)], &footer);
        assert_ne!(root_a, root_c);
    }

    #[test]
    fn archive_round_trip_signs_root() {
        let (ca_key, ca_der) = mint_ca("Znippy Test CA");
        let signer = p256_signer(&ca_key, &ca_der, "archive-signer");
        let roots = CertStore::from_der_certs(&[ca_der]).unwrap();
        let footer = crate::index::IndexFooter::Multi { manifest_offset: 8192 };

        let digests = vec![
            ("pkg/a.jar".to_string(), file_digest(&sample_file())),
            ("pkg/b.jar".to_string(), [0x42u8; 32]),
        ];
        let root = archive_root(&digests, &footer);
        let cms = signer.sign_digest(&root).unwrap();
        assert!(verify_digest(&root, &cms, &roots).is_ok());
    }

    // ── Variable multi-signer M-of-N threshold (the new primitive) ──

    /// A fixed 32-byte stand-in for an `archive_root` — the threshold layer is
    /// artifact-agnostic, so a raw root is all it needs.
    const MN_ROOT: [u8; 32] = [0x5au8; 32];

    #[test]
    fn threshold_two_of_three_needs_two_distinct_signers() {
        let (ck, cd) = mint_ca("Znippy Test CA");
        let s1 = p256_signer(&ck, &cd, "signer-1");
        let s2 = ed25519_signer(&ck, &cd, "signer-2");
        let s3 = p256_signer(&ck, &cd, "signer-3");
        let roots = CertStore::from_der_certs(&[cd.clone()]).unwrap();

        // N=3, M=2 with three DISTINCT signers → satisfied.
        let signers: Vec<&dyn ArchiveSigner> = vec![&s1, &s2, &s3];
        let set = sign_multi(&MN_ROOT, 2, &signers).unwrap();
        let rep = verify_threshold(&MN_ROOT, &set, &roots).unwrap();
        assert_eq!(rep.threshold, 2);
        assert_eq!(rep.distinct_valid, 3);
        assert!(!rep.accepted_without_signatures);

        // Exactly 2 distinct → verifies.
        let two: Vec<&dyn ArchiveSigner> = vec![&s1, &s2];
        let set2 = sign_multi(&MN_ROOT, 2, &two).unwrap();
        assert_eq!(verify_threshold(&MN_ROOT, &set2, &roots).unwrap().distinct_valid, 2);

        // Only 1 signature → FAILS the M=2 threshold.
        let set1 = SignatureSet::new(2, vec![s1.sign_digest(&MN_ROOT).unwrap()]).unwrap();
        let err = verify_threshold(&MN_ROOT, &set1, &roots).unwrap_err();
        assert!(err.to_string().contains("threshold not met"), "got: {err}");

        // 2 signatures from the SAME CERTIFICATE → count once → FAILS M=2.
        // Signing is deterministic here (RFC 6979 / PureEdDSA), so the two blobs
        // are also byte-identical; what matters is that the dedup collapses them.
        let dup = p256_signer(&ck, &cd, "same-cert");
        let dup_set =
            SignatureSet::new(2, vec![dup.sign_digest(&MN_ROOT).unwrap(); 2]).unwrap();
        let rep = verify_threshold(&MN_ROOT, &SignatureSet::new(1, dup_set.signatures().to_vec()).unwrap(), &roots).unwrap();
        assert_eq!(rep.distinct_valid, 1, "one certificate, presented twice, is one signer");
        let err = verify_threshold(&MN_ROOT, &dup_set, &roots).unwrap_err();
        assert!(err.to_string().contains("threshold not met"), "got: {err}");
    }

    // ── §8.1 — two certs sharing a SUBJECT are TWO distinct signers ──
    //
    // This is the behaviour change. `verify_threshold_inner` used to dedup on the
    // certificate subject, so a CA that issued `CN=alice` twice produced two
    // certificates that znippy reported as ONE signer. Subject equality does not
    // identify a certificate; the SHA-256 fingerprint does.
    //
    // Read the boundary honestly: this makes znippy report what it can actually
    // observe (distinct certificates), and it does NOT by itself give a
    // two-person rule. If the same human holds both certificates, znippy will
    // count 2 — and it is right to, because whether two fingerprints are the same
    // person is trust policy, which is why the fingerprint is now returned for the
    // caller to check against its roster.
    #[test]
    fn distinctness_is_by_fingerprint_not_subject() {
        let (ck, cd) = mint_ca("Znippy Test CA");
        let roots = CertStore::from_der_certs(&[cd.clone()]).unwrap();

        // Two certificates, same subject `CN=shared-subject`, different keys →
        // different DER → different fingerprints.
        let a = p256_signer(&ck, &cd, "shared-subject");
        let b = ed25519_signer(&ck, &cd, "shared-subject");

        let sa = a.sign_digest(&MN_ROOT).unwrap();
        let sb = b.sign_digest(&MN_ROOT).unwrap();

        let va = verify_digest(&MN_ROOT, &sa, &roots).unwrap();
        let vb = verify_digest(&MN_ROOT, &sb, &roots).unwrap();
        // The premise: subjects equal, fingerprints not.
        assert_eq!(va.id.subject, vb.id.subject, "premise: the subjects DO collide");
        assert_ne!(
            va.id.fingerprint, vb.id.fingerprint,
            "premise: two distinct certificates have distinct fingerprints"
        );
        // A fingerprint is SHA-256 over the cert DER — the same value
        // `openssl x509 -fingerprint -sha256` prints.
        assert_eq!(va.id.fingerprint_hex().len(), 64);

        // The claim: they count as TWO, so a 2-of-N is met.
        let set = SignatureSet::new(2, vec![sa, sb]).unwrap();
        let rep = verify_threshold(&MN_ROOT, &set, &roots).unwrap();
        assert_eq!(
            rep.distinct_valid, 2,
            "two certificates sharing a subject must count as two distinct signers"
        );
        assert_eq!(rep.signers.len(), 2);
        let mut fps: Vec<[u8; 32]> = rep.signers.iter().map(|s| s.id.fingerprint).collect();
        fps.sort();
        let mut want = vec![va.id.fingerprint, vb.id.fingerprint];
        want.sort();
        assert_eq!(fps, want, "the report must carry BOTH fingerprints");
    }

    #[test]
    fn threshold_zero_is_explicit_and_loud() {
        let (_ck, cd) = mint_ca("Znippy Test CA");
        let roots = CertStore::from_der_certs(&[cd]).unwrap();

        // You CANNOT create M=0 via the ordinary constructor.
        let err = SignatureSet::new(0, vec![]).unwrap_err();
        assert!(err.to_string().contains("opt_out_unsigned"), "got: {err}");

        // The ONLY way to M=0 is the loudly-named constructor.
        let optout = SignatureSet::opt_out_unsigned();
        assert_eq!(optout.threshold(), 0);
        assert!(optout.is_empty());

        // The ordinary verify entry point REFUSES an M=0 set — never a silent pass.
        let err = verify_threshold(&MN_ROOT, &optout, &roots).unwrap_err();
        assert!(err.to_string().contains("M=0"), "got: {err}");

        // Accepting it requires the separately-named, acknowledged function, and
        // the report loudly flags that NO provenance was checked.
        let rep = verify_threshold_allowing_unsigned(&MN_ROOT, &optout, &roots).unwrap();
        assert_eq!(rep.distinct_valid, 0);
        assert!(rep.accepted_without_signatures, "the opt-out must stay visible downstream");
    }

    #[test]
    fn threshold_tamper_flips_root_and_all_signatures_fail() {
        let (ck, cd) = mint_ca("Znippy Test CA");
        let s1 = p256_signer(&ck, &cd, "signer-1");
        let s2 = ed25519_signer(&ck, &cd, "signer-2");
        let roots = CertStore::from_der_certs(&[cd]).unwrap();

        let signers: Vec<&dyn ArchiveSigner> = vec![&s1, &s2];
        let set = sign_multi(&MN_ROOT, 2, &signers).unwrap();
        assert!(verify_threshold(&MN_ROOT, &set, &roots).is_ok());

        // Flip a byte in the merkle root (as a payload tamper would) → 0 valid.
        let mut tampered = MN_ROOT;
        tampered[0] ^= 0x01;
        let err = verify_threshold(&tampered, &set, &roots).unwrap_err();
        assert!(err.to_string().contains("threshold not met"), "got: {err}");
        // Even M=1 cannot be met against the tampered root.
        let one = SignatureSet::new(1, set.signatures().to_vec()).unwrap();
        assert!(verify_threshold(&tampered, &one, &roots).is_err());
    }

    #[test]
    fn threshold_legacy_single_signature_is_m1_and_still_verifies() {
        let (ck, cd) = mint_ca("Znippy Test CA");
        let s1 = p256_signer(&ck, &cd, "legacy-signer");
        let roots = CertStore::from_der_certs(&[cd]).unwrap();

        // The OLD path produced a bare detached CMS blob (no SignatureSet header).
        let legacy_cms: Vec<u8> = s1.sign_digest(&MN_ROOT).unwrap();

        // Loading that exact legacy blob yields an M=1, N=1 set...
        let set = SignatureSet::from_bytes(&legacy_cms).unwrap();
        assert_eq!(set.threshold(), 1);
        assert_eq!(set.len(), 1);
        // ...and it verifies through the threshold path exactly as before.
        let rep = verify_threshold(&MN_ROOT, &set, &roots).unwrap();
        assert_eq!(rep.distinct_valid, 1);
        assert_eq!(rep.signers[0].id.common_name, "legacy-signer");

        // SignatureSet::single is the same bridge, explicitly.
        let set2 = SignatureSet::single(legacy_cms);
        assert!(verify_threshold(&MN_ROOT, &set2, &roots).is_ok());
    }

    #[test]
    fn threshold_signature_over_a_different_root_is_rejected() {
        let (ck, cd) = mint_ca("Znippy Test CA");
        let s1 = p256_signer(&ck, &cd, "signer-1");
        let s2 = ed25519_signer(&ck, &cd, "signer-2");
        let roots = CertStore::from_der_certs(&[cd]).unwrap();

        let other_root = [0xa5u8; 32];
        assert_ne!(MN_ROOT, other_root);

        // s1 signs OUR root, s2 signs a DIFFERENT bundle's root.
        let sig_ours = s1.sign_digest(&MN_ROOT).unwrap();
        let sig_other = s2.sign_digest(&other_root).unwrap();

        // Present both, verify against OUR root, require M=2: the replayed
        // cross-bundle signature does not count → threshold fails.
        let set = SignatureSet::new(2, vec![sig_ours.clone(), sig_other.clone()]).unwrap();
        let err = verify_threshold(&MN_ROOT, &set, &roots).unwrap_err();
        assert!(err.to_string().contains("threshold not met"), "got: {err}");

        // Only the genuine signature over OUR root counts (M=1 met, distinct=1).
        let set1 = SignatureSet::new(1, vec![sig_ours, sig_other]).unwrap();
        let rep = verify_threshold(&MN_ROOT, &set1, &roots).unwrap();
        assert_eq!(rep.distinct_valid, 1);
        assert_eq!(rep.signers[0].id.common_name, "signer-1");
    }

    #[test]
    fn threshold_sign_multi_rejects_threshold_over_n() {
        let (ck, cd) = mint_ca("Znippy Test CA");
        let s1 = p256_signer(&ck, &cd, "signer-1");
        let signers: Vec<&dyn ArchiveSigner> = vec![&s1];
        let err = sign_multi(&MN_ROOT, 2, &signers).unwrap_err();
        assert!(err.to_string().contains("exceeds N"), "got: {err}");

        // M == N (unanimous) is allowed.
        assert!(sign_multi(&MN_ROOT, 1, &signers).is_ok());
        // M == 0 (opt-out choice at seal) is allowed with any N.
        let set = sign_multi(&MN_ROOT, 0, &signers).unwrap();
        assert_eq!(set.threshold(), 0);
    }

    #[test]
    fn threshold_signature_set_bytes_round_trip() {
        let (ck, cd) = mint_ca("Znippy Test CA");
        let s1 = p256_signer(&ck, &cd, "signer-1");
        let s2 = ed25519_signer(&ck, &cd, "signer-2");
        let s3 = p256_signer(&ck, &cd, "signer-3");
        let roots = CertStore::from_der_certs(&[cd]).unwrap();

        let signers: Vec<&dyn ArchiveSigner> = vec![&s1, &s2, &s3];
        let set = sign_multi(&MN_ROOT, 2, &signers).unwrap();

        let bytes = set.to_bytes();
        let back = SignatureSet::from_bytes(&bytes).unwrap();
        assert_eq!(back.threshold(), 2);
        assert_eq!(back.len(), 3);
        // The decoded set still verifies against the root.
        assert_eq!(verify_threshold(&MN_ROOT, &back, &roots).unwrap().distinct_valid, 3);
    }

    // ── §8.5 — caller-supplied signed attributes ──
    //
    // A private-arc OID standing in for a claim a policy layer would attach
    // (holger's approval role). znippy must carry it and return it and must NOT
    // interpret it — nothing in this module knows what "security-reviewer" means,
    // and this test is the only place the string appears.
    const OID_TEST_CLAIM: ObjectIdentifier =
        ObjectIdentifier::new_unwrap("1.3.6.1.4.1.99999.1.1");
    const OID_TEST_CLAIM_2: ObjectIdentifier =
        ObjectIdentifier::new_unwrap("1.3.6.1.4.1.99999.1.2");

    /// A UTF8String-valued signed attribute.
    fn utf8_attr(oid: ObjectIdentifier, value: &str) -> Attribute {
        let v = der::asn1::Utf8StringRef::new(value).unwrap();
        Attribute {
            oid,
            values: SetOfVec::try_from(vec![Any::encode_from(&v).unwrap()]).unwrap(),
        }
    }

    fn attr_utf8_value(a: &Attribute) -> String {
        let v = a.values.iter().next().unwrap();
        v.decode_as::<der::asn1::Utf8StringRef<'_>>().unwrap().as_str().to_string()
    }

    #[test]
    fn unknown_signed_attributes_survive_and_are_returned() {
        let (ck, cd) = mint_ca("Znippy Test CA");
        let s = p256_signer(&ck, &cd, "claim-signer");
        let roots = CertStore::from_der_certs(&[cd]).unwrap();

        let plain = s.sign_digest(&MN_ROOT).unwrap();
        let extra = [
            utf8_attr(OID_TEST_CLAIM, "some-opaque-claim"),
            utf8_attr(OID_TEST_CLAIM_2, "2026-07-31T00:00:00Z"),
        ];
        let with_attrs = sign_digest_with_attrs(&MN_ROOT, &extra, &s).unwrap();

        // The attributes really are IN the CMS (otherwise the rest is vacuous).
        assert_ne!(plain, with_attrs, "extra attributes must change the CMS bytes");
        assert!(
            with_attrs
                .windows(b"some-opaque-claim".len())
                .any(|w| w == b"some-opaque-claim"),
            "the claim must be carried inside the CMS"
        );

        // 1. Verification is NOT broken by attributes the verifier does not know.
        let v = verify_digest(&MN_ROOT, &with_attrs, &roots).unwrap();
        assert_eq!(v.id.common_name, "claim-signer");

        // 2. The new verifier RETURNS them, verbatim, alongside the identity.
        let got = v.attribute(&OID_TEST_CLAIM).expect("claim attribute must be returned");
        assert_eq!(attr_utf8_value(got), "some-opaque-claim");
        assert_eq!(
            attr_utf8_value(v.attribute(&OID_TEST_CLAIM_2).unwrap()),
            "2026-07-31T00:00:00Z"
        );
        // contentType + messageDigest + the two extras.
        assert_eq!(v.attrs.len(), 4);

        // 3. A two-attribute signature still verifies and returns just those two —
        //    old signatures keep verifying under the new verifier.
        let v_plain = verify_digest(&MN_ROOT, &plain, &roots).unwrap();
        assert_eq!(v_plain.attrs.len(), 2);
        assert!(v_plain.attribute(&OID_TEST_CLAIM).is_none());
        assert!(v_plain.attribute(&OID_ATTR_MESSAGE_DIGEST).is_some());

        // 4. The claim is SIGNED, not merely adjacent: flipping a byte of its
        //    value inside the CMS invalidates the signature.
        let needle = b"some-opaque-claim";
        let at = with_attrs.windows(needle.len()).position(|w| w == needle).unwrap();
        let mut tampered = with_attrs.clone();
        tampered[at] = b'S';
        assert_ne!(tampered, with_attrs);
        let err = verify_digest(&MN_ROOT, &tampered, &roots).unwrap_err();
        assert!(
            err.to_string().contains("verification failed"),
            "a tampered claim must break the signature, got: {err}"
        );

        // 5. A caller may not shadow znippy's own bindings.
        let md_octets = OctetString::new([0u8; 32].as_slice()).unwrap();
        let forged = Attribute {
            oid: OID_ATTR_MESSAGE_DIGEST,
            values: SetOfVec::try_from(vec![Any::encode_from(&md_octets).unwrap()]).unwrap(),
        };
        let err = sign_digest_with_attrs(&MN_ROOT, &[forged], &s).unwrap_err();
        assert!(err.to_string().contains("reserved"), "got: {err}");
    }

    // ── §8.7 — the non-vacuous anchor ──
    //
    // Without this the whole suite could pass while `verify_threshold` returned
    // Ok unconditionally. ONE valid approval, over the RIGHT root, from a signer
    // that chains to the RIGHT CA, against a 2-of-N policy, must FAIL.
    #[test]
    fn a_single_approval_fails_a_two_of_n_policy() {
        let (ck, cd) = mint_ca("Znippy Test CA");
        let reviewer = p256_signer(&ck, &cd, "reviewer");
        let owner = ed25519_signer(&ck, &cd, "owner");
        let roots = CertStore::from_der_certs(&[cd]).unwrap();

        let one = sign_digest_with_attrs(
            &MN_ROOT,
            &[utf8_attr(OID_TEST_CLAIM, "claim-a")],
            &reviewer,
        )
        .unwrap();

        // Positive control: the ONE approval is genuinely valid on its own.
        let v = verify_digest(&MN_ROOT, &one, &roots).unwrap();
        assert_eq!(v.id.common_name, "reviewer");
        assert_eq!(
            verify_threshold(&MN_ROOT, &SignatureSet::new(1, vec![one.clone()]).unwrap(), &roots)
                .unwrap()
                .distinct_valid,
            1
        );

        // THE ANCHOR: one valid approval, threshold 2 → REFUSED.
        let set = SignatureSet::new(2, vec![one.clone()]).unwrap();
        let err = verify_threshold(&MN_ROOT, &set, &roots).unwrap_err();
        assert!(err.to_string().contains("threshold not met"), "got: {err}");
        assert!(err.to_string().contains("need M=2"), "got: {err}");

        // ...and presenting the SAME approval twice does not conjure a second
        // signer: the fingerprint dedup collapses it.
        let doubled = SignatureSet::new(2, vec![one.clone(), one.clone()]).unwrap();
        assert!(verify_threshold(&MN_ROOT, &doubled, &roots).is_err());

        // A second, genuinely different approver is what satisfies it — and both
        // claims come back for the caller's policy to read.
        let two = sign_digest_with_attrs(
            &MN_ROOT,
            &[utf8_attr(OID_TEST_CLAIM, "claim-b")],
            &owner,
        )
        .unwrap();
        let rep =
            verify_threshold(&MN_ROOT, &SignatureSet::new(2, vec![one, two]).unwrap(), &roots)
                .unwrap();
        assert_eq!(rep.distinct_valid, 2);
        let mut claims: Vec<String> = rep
            .signers
            .iter()
            .map(|s| attr_utf8_value(s.attribute(&OID_TEST_CLAIM).unwrap()))
            .collect();
        claims.sort();
        assert_eq!(claims, vec!["claim-a".to_string(), "claim-b".to_string()]);
    }

    // ── DSSE / in-toto SLSA envelope (the second, spec-standard signature) ──

    #[test]
    fn dsse_pae_matches_spec_vector() {
        // The DSSE spec's own worked example (secure-systems-lab/dsse,
        // protocol.md): PAE("http://example.com/HelloWorld", "hello world").
        let pae = dsse_pae("http://example.com/HelloWorld", b"hello world");
        assert_eq!(
            pae,
            b"DSSEv1 29 http://example.com/HelloWorld 11 hello world".to_vec(),
            "PAE must be byte-exact per the DSSE spec"
        );
        // A different payloadType of the SAME length still changes the bytes —
        // the type is authenticated, not merely adjacent.
        assert_ne!(pae, dsse_pae("http://example.com/HelloXorld", b"hello world"));
        // Empty payload: the LEN(body) field is "0".
        assert_eq!(dsse_pae("t", b""), b"DSSEv1 1 t 0 ".to_vec());
    }

    #[test]
    fn base64_encode_decode_round_trip_and_known_vectors() {
        assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
        assert_eq!(base64_encode(b"hello!"), "aGVsbG8h");
        assert_eq!(base64_encode(b""), "");
        assert_eq!(base64_encode(b"f"), "Zg==");
        assert_eq!(base64_encode(b"fo"), "Zm8=");
        assert_eq!(base64_decode("aGVsbG8=").unwrap(), b"hello");
        // Round-trip over binary (a real 64-byte signature shape).
        let bin: Vec<u8> = (0u16..256).map(|b| (b % 251) as u8).collect();
        assert_eq!(base64_decode(&base64_encode(&bin)).unwrap(), bin);
        // The decoder is a trust boundary: malformed input is None, never partial.
        assert_eq!(base64_decode("a"), None);
        assert_eq!(base64_decode("aGVsbG8=X"), None);
    }

    #[test]
    fn dsse_sign_verify_round_trip_both_algs() {
        let (ck, cd) = mint_ca("Znippy DSSE CA");
        let roots = CertStore::from_der_certs(&[cd.clone()]).unwrap();
        let ed = ed25519_signer(&ck, &cd, "dsse-ed");
        let ec = p256_signer(&ck, &cd, "dsse-p256");
        let payload = br#"{"_type":"https://in-toto.io/Statement/v1"}"#;

        for signer in [&ed as &dyn ArchiveSigner, &ec as &dyn ArchiveSigner] {
            let env = dsse_sign(DSSE_PAYLOAD_TYPE_INTOTO, payload, &[signer]).unwrap();
            assert_eq!(env.payload_type, "application/vnd.in-toto+json");
            assert_eq!(env.payload, payload.to_vec());
            assert_eq!(env.signatures.len(), 1);
            // keyid is the cert fingerprint hex — the value modgunn reads.
            let cert = Certificate::from_der(signer.certificate_der()).unwrap();
            assert_eq!(env.signatures[0].keyid, hex::encode(cert_fingerprint(&cert).unwrap()));

            let certs = vec![signer.certificate_der().to_vec()];
            let v = dsse_verify(&env, &certs, &roots).unwrap();
            assert_eq!(v.len(), 1);
        }
    }

    #[test]
    fn dsse_tampered_payload_and_wrong_type_fail() {
        let (ck, cd) = mint_ca("Znippy DSSE CA");
        let roots = CertStore::from_der_certs(&[cd.clone()]).unwrap();
        let signer = p256_signer(&ck, &cd, "dsse-p256");
        let certs = vec![signer.certificate_der().to_vec()];
        let env =
            dsse_sign(DSSE_PAYLOAD_TYPE_INTOTO, br#"{"subject":"real"}"#, &[&signer]).unwrap();
        // Positive control.
        assert!(dsse_verify(&env, &certs, &roots).is_ok());

        // RED 1 — flip a payload byte: PAE changes → signature no longer covers it.
        let mut tampered = env.clone();
        tampered.payload[2] ^= 0x01;
        assert!(
            dsse_verify(&tampered, &certs, &roots).is_err(),
            "a tampered payload must fail DSSE verification"
        );

        // RED 2 — swap the payloadType: PAE changes → fails (payload-type confusion).
        let mut wrong_type = env.clone();
        wrong_type.payload_type = "application/vnd.evil+json".to_string();
        assert!(
            dsse_verify(&wrong_type, &certs, &roots).is_err(),
            "a wrong payloadType must fail DSSE verification"
        );

        // RED 3 — an untrusted root cannot chain the signer.
        let (_ok, other_ca) = mint_ca("Rogue DSSE CA");
        let bad_roots = CertStore::from_der_certs(&[other_ca]).unwrap();
        assert!(
            dsse_verify(&env, &certs, &bad_roots).is_err(),
            "a signer that does not chain to a trusted root must fail"
        );

        // RED 4 — the RIGHT root but the WRONG signer cert offered → no key matches.
        let stranger = ed25519_signer(&ck, &cd, "stranger");
        let wrong_certs = vec![stranger.certificate_der().to_vec()];
        assert!(
            dsse_verify(&env, &wrong_certs, &roots).is_err(),
            "the offered cert must actually be the one that signed"
        );
    }

    #[test]
    fn dsse_envelope_json_has_spec_field_names_and_round_trips() {
        let (ck, cd) = mint_ca("Znippy DSSE CA");
        let roots = CertStore::from_der_certs(&[cd.clone()]).unwrap();
        let signer = ed25519_signer(&ck, &cd, "dsse-ed");
        let certs = vec![signer.certificate_der().to_vec()];
        let env = dsse_sign(DSSE_PAYLOAD_TYPE_INTOTO, b"hello world", &[&signer]).unwrap();

        let json = env.to_json().unwrap();
        // The EXACT spec field names cosign / slsa-verifier / Rekor read.
        for needle in ["\"payload\"", "\"payloadType\"", "\"signatures\"", "\"sig\"", "\"keyid\""] {
            assert!(json.contains(needle), "envelope JSON missing {needle}: {json}");
        }
        // payload is base64 of the raw bytes.
        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(v["payload"].as_str().unwrap(), base64_encode(b"hello world"));
        assert_eq!(v["payloadType"].as_str().unwrap(), "application/vnd.in-toto+json");

        // Round-trip through JSON and still verify — this is the bytes a SLSA tool
        // would ingest.
        let back = DsseEnvelope::from_json(&json).unwrap();
        assert_eq!(back, env);
        assert!(dsse_verify(&back, &certs, &roots).is_ok());

        // Non-vacuity: corrupt the base64 payload in the JSON → verify fails.
        let bad = json.replace(
            v["payload"].as_str().unwrap(),
            &base64_encode(b"hello xorld"),
        );
        let bad_env = DsseEnvelope::from_json(&bad).unwrap();
        assert!(dsse_verify(&bad_env, &certs, &roots).is_err());
    }

    // ── Phase B: seal a real signed archive via ArrowIpcSink, then verify ──

    use crate::index::{
        lookup_schema, read_znippy_full_manifest, read_znippy_index, read_znippy_manifest,
    };
    use crate::meta_sink::{ArchiveMetaSink, ArrowIpcSink, GroupKey};
    use arrow::array::{
        BooleanBuilder, FixedSizeBinaryBuilder, RecordBatch, StringBuilder, UInt32Builder,
        UInt64Builder,
    };
    use std::fs::OpenOptions;
    use std::os::unix::fs::FileExt;
    use std::sync::Arc;

    /// Build a base-schema sub-index batch from `(path, [chunk_checksums])`.
    fn base_batch(files: &[(&str, Vec<[u8; 32]>)]) -> (Arc<arrow::datatypes::Schema>, RecordBatch) {
        let schema = lookup_schema();
        let mut path_b = StringBuilder::new();
        let mut seq_b = UInt32Builder::new();
        let mut fdata_b = UInt64Builder::new();
        let mut comp_b = BooleanBuilder::new();
        let mut usz_b = UInt64Builder::new();
        let mut boff_b = UInt64Builder::new();
        let mut bsz_b = UInt64Builder::new();
        let mut ck_b = FixedSizeBinaryBuilder::with_capacity(8, 32);
        let mut blob_off = 0u64;
        for (path, cks) in files {
            for (seq, ck) in cks.iter().enumerate() {
                path_b.append_value(path);
                seq_b.append_value(seq as u32);
                fdata_b.append_value(seq as u64 * 100);
                comp_b.append_value(false);
                usz_b.append_value(100);
                boff_b.append_value(blob_off);
                bsz_b.append_value(100);
                ck_b.append_value(ck).unwrap();
                blob_off += 100;
            }
        }
        let batch = RecordBatch::try_new(
            schema.clone(),
            vec![
                Arc::new(path_b.finish()),
                Arc::new(seq_b.finish()),
                Arc::new(fdata_b.finish()),
                Arc::new(comp_b.finish()),
                Arc::new(usz_b.finish()),
                Arc::new(boff_b.finish()),
                Arc::new(bsz_b.finish()),
                Arc::new(ck_b.finish()),
            ],
        )
        .unwrap();
        (schema, batch)
    }

    /// Seal a signed archive at `path` and return the data row count.
    fn seal_signed(path: &std::path::Path, signer: Box<dyn ArchiveSigner + Send>) -> usize {
        let files = vec![
            ("pkg/a-1.0.0.jar", vec![[0x11u8; 32], [0x12u8; 32]]),
            ("pkg/b-2.0.0.jar", vec![[0x21u8; 32]]),
            ("pkg/c-3.0.0.jar", vec![[0x31u8; 32], [0x32u8; 32], [0x33u8; 32]]),
        ];
        let row_count: usize = files.iter().map(|(_, c)| c.len()).sum();
        let (schema, batch) = base_batch(&files);

        let f = OpenOptions::new().create(true).read(true).write(true).truncate(true).open(path).unwrap();
        // 16 bytes of dummy "blob" region before the metadata layer.
        f.write_all_at(&[0u8; 16], 0).unwrap();
        let file = Arc::new(f);
        let mut sink = ArrowIpcSink::new(file, 16).with_signer(signer);
        sink.push_subindex(
            &schema,
            &[batch],
            GroupKey { pkg_type: 0, repo: "repo".into(), module_name: "data".into() },
        )
        .unwrap();
        Box::new(sink).finish().unwrap();
        row_count
    }

    #[test]
    fn seal_signed_archive_round_trip() {
        let (ca_key, ca_der) = mint_ca("Znippy Test CA");
        let signer = ed25519_signer(&ca_key, &ca_der, "archive-signer");
        let roots = CertStore::from_der_certs(&[ca_der]).unwrap();

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("signed.znippy");
        let rows = seal_signed(&path, Box::new(signer));

        // The default-path index reader ignores the (reserved) signature sections.
        let (_s, batches) = read_znippy_index(&path).unwrap();
        let got_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
        assert_eq!(got_rows, rows, "data rows unaffected by signature sections");
        // The data manifest also hides reserved entries.
        let manifest = read_znippy_manifest(&path).unwrap();
        assert!(manifest.iter().all(|e| !crate::index::is_reserved_module(&e.module_name)));

        // Whole-archive provenance verifies, and every artifact is signed.
        let report = verify_archive(&path, &roots).unwrap();
        assert_eq!(report.signer.id.common_name, "archive-signer");
        assert_eq!(report.artifacts_verified, 3);

        // Per-artifact entry point (what holger calls).
        let cms = artifact_signature_for(&path, "pkg/b-2.0.0.jar").unwrap().unwrap();
        let file = FileMeta {
            relative_path: "pkg/b-2.0.0.jar".into(),
            compressed: false,
            uncompressed_size: 100,
            chunks: vec![chunk(0, 0x21)],
        };
        let v = verify_artifact(&file, &cms, &roots).unwrap();
        assert_eq!(v.id.common_name, "archive-signer");
    }

    #[test]
    fn unsigned_seal_writes_no_signature_sections() {
        // Even with the `sign` feature compiled in, sealing WITHOUT a signer is a
        // no-op for the signature layer: no reserved sign sections are written, so
        // the archive is byte-for-byte the v0.7 format (additive guarantee).
        let files = vec![("pkg/a.jar", vec![[0x11u8; 32]])];
        let (schema, batch) = base_batch(&files);
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("unsigned.znippy");
        let f = OpenOptions::new().create(true).read(true).write(true).truncate(true).open(&path).unwrap();
        f.write_all_at(&[0u8; 16], 0).unwrap();
        let mut sink = ArrowIpcSink::new(Arc::new(f), 16); // no .with_signer(..)
        sink.push_subindex(
            &schema,
            &[batch],
            GroupKey { pkg_type: 0, repo: "repo".into(), module_name: "data".into() },
        )
        .unwrap();
        Box::new(sink).finish().unwrap();

        assert!(read_archive_signatures(&path).unwrap().is_none());
        let manifest = read_znippy_full_manifest(&path).unwrap().0;
        assert!(
            manifest.iter().all(|e| e.module_name != crate::index::SIGN_ARCHIVE_MODULE
                && e.module_name != crate::index::SIGN_ARTIFACTS_MODULE),
            "no signature sections in an unsigned seal"
        );
    }

    #[test]
    fn tampering_a_signed_archive_is_detected() {
        let (ca_key, ca_der) = mint_ca("Znippy Test CA");
        let signer = p256_signer(&ca_key, &ca_der, "archive-signer");
        let roots = CertStore::from_der_certs(&[ca_der]).unwrap();

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("signed.znippy");
        seal_signed(&path, Box::new(signer));
        assert!(verify_archive(&path, &roots).is_ok());

        // Tamper the served artifact CMS: verifying it against the (untouched)
        // recomputed digest must fail.
        let cms = artifact_signature_for(&path, "pkg/a-1.0.0.jar").unwrap().unwrap();
        let mut bad = cms.clone();
        let n = bad.len();
        bad[n - 1] ^= 0xff;
        let file = FileMeta {
            relative_path: "pkg/a-1.0.0.jar".into(),
            compressed: false,
            uncompressed_size: 200,
            chunks: vec![chunk(0, 0x11), chunk(1, 0x12)],
        };
        assert!(verify_artifact(&file, &bad, &roots).is_err());

        // Tamper the artifact's content (a chunk hash) → recomputed digest no
        // longer matches the genuine signature.
        let mut tampered = file;
        tampered.chunks[0].checksum[0] ^= 0x01;
        assert!(verify_artifact(&tampered, &cms, &roots).is_err());
    }

    // ── §8.3 + §8.4 — an appended approval ROW does not move `archive_root`,
    //    so a signature made BEFORE the append still verifies AFTER it ──
    //
    // `archive_root` folds the ARCHIVE_TAG, the footer *kind*, and the sorted
    // `(path, digest)` set. Nothing else. A section that carries no file rows is
    // therefore invisible to it **by construction** — no exclusion prefix, no
    // second "core root". That is the property the whole append-signing scheme
    // rests on, so it is worth a test that can actually fail.

    /// Recompute an archive's root exactly as [`verify_archive`] does.
    fn root_of(path: &std::path::Path) -> [u8; 32] {
        let digests = recompute_file_digests(path).unwrap();
        let footer = crate::index::IndexFooter::Multi { manifest_offset: 0 };
        let v: Vec<(String, [u8; 32])> = digests.into_iter().collect();
        archive_root(&v, &footer)
    }

    /// An approvals sub-index: `(bundle_role, cms)` ROWS — no `relative_path`, so
    /// these are not files.
    fn approval_batch(rows: &[(&str, &[u8])]) -> (Arc<arrow::datatypes::Schema>, RecordBatch) {
        use arrow::array::BinaryBuilder;
        use arrow::datatypes::{DataType, Field, Schema};
        let schema = Arc::new(Schema::new(vec![
            Field::new("bundle_role", DataType::Utf8, true),
            Field::new("cms", DataType::Binary, true),
        ]));
        let mut role_b = StringBuilder::new();
        let mut cms_b = BinaryBuilder::new();
        for (role, cms) in rows {
            role_b.append_value(role);
            cms_b.append_value(cms);
        }
        let batch = RecordBatch::try_new(
            schema.clone(),
            vec![Arc::new(role_b.finish()), Arc::new(cms_b.finish())],
        )
        .unwrap();
        (schema, batch)
    }

    /// Seal an archive holding `files`, optionally with an extra approvals
    /// sub-index appended as a NON-DATA section.
    fn seal_with_optional_approvals(
        path: &std::path::Path,
        files: &[(&str, Vec<[u8; 32]>)],
        approvals: Option<&[(&str, &[u8])]>,
    ) {
        let (schema, batch) = base_batch(files);
        let f = OpenOptions::new()
            .create(true)
            .read(true)
            .write(true)
            .truncate(true)
            .open(path)
            .unwrap();
        f.write_all_at(&[0u8; 16], 0).unwrap();
        let mut sink = ArrowIpcSink::new(Arc::new(f), 16);
        sink.push_subindex(
            &schema,
            &[batch],
            GroupKey { pkg_type: 0, repo: "repo".into(), module_name: "data".into() },
        )
        .unwrap();
        if let Some(rows) = approvals {
            let (asch, abatch) = approval_batch(rows);
            sink.push_subindex(&asch, &[abatch], GroupKey {
                // RESERVED: the manifest readers skip it, `accumulate_lookup` skips
                // it, so its rows never become `(path, digest)` pairs. This is the
                // carrier an approval must use.
                pkg_type: crate::index::RESERVED_PKG_TYPE,
                repo: String::new(),
                module_name: crate::index::META_MODULE.to_string(),
            })
            .unwrap();
        }
        Box::new(sink).finish().unwrap();
    }

    #[test]
    fn appended_approval_rows_leave_archive_root_byte_identical() {
        let (ck, cd) = mint_ca("Znippy Test CA");
        let approver = p256_signer(&ck, &cd, "approver-1");
        let roots = CertStore::from_der_certs(&[cd]).unwrap();
        let dir = tempfile::tempdir().unwrap();

        let files: Vec<(&str, Vec<[u8; 32]>)> = vec![
            ("pkg/a-1.0.0.jar", vec![[0x11u8; 32], [0x12u8; 32]]),
            ("pkg/b-2.0.0.jar", vec![[0x21u8; 32]]),
        ];

        // 1. Seal the release candidate. This root is fixed, forever.
        let before = dir.path().join("before.znippy");
        seal_with_optional_approvals(&before, &files, None);
        let root_before = root_of(&before);

        // 2. Approve it — a detached CMS over that root, carrying a claim.
        let approval = sign_digest_with_attrs(
            &root_before,
            &[utf8_attr(OID_TEST_CLAIM, "approval-1")],
            &approver,
        )
        .unwrap();
        assert!(verify_digest(&root_before, &approval, &roots).is_ok());

        // 3. Append the approval as a ROW and re-seal.
        let after = dir.path().join("after.znippy");
        seal_with_optional_approvals(
            &after,
            &files,
            Some(&[("security-reviewer", approval.as_slice())]),
        );

        // The two archives are NOT the same file — the append really happened.
        assert_ne!(
            std::fs::read(&before).unwrap(),
            std::fs::read(&after).unwrap(),
            "premise: appending the approval changed the archive bytes"
        );

        // §8.3 — yet the root is byte-identical.
        let root_after = root_of(&after);
        assert_eq!(root_before, root_after, "an appended ROW must not move archive_root");

        // §8.4 — so the signature made BEFORE the append still verifies AFTER it,
        // and the claim it carried comes back intact.
        let v = verify_digest(&root_after, &approval, &roots).unwrap();
        assert_eq!(v.id.common_name, "approver-1");
        assert_eq!(attr_utf8_value(v.attribute(&OID_TEST_CLAIM).unwrap()), "approval-1");

        // Non-vacuity: the root is not simply constant. Change the FILE SET and it
        // moves, and the earlier approval stops verifying — which is the whole
        // point of signing it.
        let changed = dir.path().join("changed.znippy");
        let mut other = files.clone();
        other.push(("pkg/c-3.0.0.jar", vec![[0x31u8; 32]]));
        seal_with_optional_approvals(&changed, &other, None);
        let root_changed = root_of(&changed);
        assert_ne!(root_before, root_changed, "adding a FILE must move the root");
        assert!(
            verify_digest(&root_changed, &approval, &roots).is_err(),
            "the approval must not carry over to a different file set"
        );

        // Non-vacuity of the content binding too: same paths, different chunk
        // hashes → different root.
        let tampered_path = dir.path().join("tampered.znippy");
        let mut tampered = files.clone();
        tampered[0].1[0][0] ^= 0x01;
        seal_with_optional_approvals(&tampered_path, &tampered, None);
        assert_ne!(root_before, root_of(&tampered_path), "a changed chunk hash must move the root");
    }

    // ── Phase C: holger-interop. Mint the CA+leaf with cert-helper (OpenSSL),
    //    exactly as holger/mannequin/src/pki does, and verify a single served
    //    artifact's signature through the pure-Rust sign path. ──
    #[cfg(feature = "sign-holger-test")]
    #[test]
    fn holger_verifies_a_served_artifact() {
        use cert_helper::certificate::{
            CertBuilder, HashAlg, KeyType, Usage, UseesBuilderFields, X509Parts,
        };
        use p256::pkcs8::DecodePrivateKey;
        use x509_cert::der::DecodePem;

        // 1. Mannequin-style P-256 CA.
        let ca = CertBuilder::new()
            .common_name("Holger Mannequin CA")
            .country_name("SE")
            .organization("Holger Test")
            .is_ca(true)
            .key_type(KeyType::P256)
            .signature_alg(HashAlg::SHA256)
            .key_usage([Usage::certsign, Usage::crlsign].into_iter().collect())
            .build_and_self_sign()
            .unwrap();

        // 2. CA issues a signer leaf cert (P-256), like issue_server_cert.
        let leaf = CertBuilder::new()
            .common_name("znippy-artifact-signer")
            .key_type(KeyType::P256)
            .signature_alg(HashAlg::SHA256)
            .key_usage([Usage::clientauth].into_iter().collect())
            .build_and_sign(&ca)
            .unwrap();

        let ca_pem = ca.get_pem().unwrap();
        let leaf_cert_pem = leaf.get_pem().unwrap();
        let leaf_key_pem = leaf.get_private_key().unwrap();

        // 3. Convert to the shapes the pure-Rust API consumes.
        let ca_der = x509_cert::Certificate::from_pem(&ca_pem).unwrap().to_der().unwrap();
        let leaf_der =
            x509_cert::Certificate::from_pem(&leaf_cert_pem).unwrap().to_der().unwrap();
        let key_pem_str = String::from_utf8(leaf_key_pem).unwrap();
        let secret = p256::SecretKey::from_pkcs8_pem(&key_pem_str)
            .or_else(|_| p256::SecretKey::from_sec1_pem(&key_pem_str))
            .expect("load leaf P-256 key");
        let signer = EcdsaP256Signer::new(p256::ecdsa::SigningKey::from(secret), leaf_der);

        // 4. holger's storage layer would seal a signed archive; here we sign one
        //    served artifact's digest directly, then verify it as holger would.
        let file = sample_file();
        let cms = signer.sign_digest(&file_digest(&file)).unwrap();

        let roots = CertStore::from_der_certs(&[ca_der]).unwrap();
        // verify_artifact is the exact call holger makes when serving a file.
        let v = verify_artifact(&file, &cms, &roots).unwrap();
        assert_eq!(v.id.common_name, "znippy-artifact-signer");

        // An untrusted CA is rejected (negative control).
        let other = CertBuilder::new()
            .common_name("Rogue CA")
            .is_ca(true)
            .key_type(KeyType::P256)
            .signature_alg(HashAlg::SHA256)
            .key_usage([Usage::certsign].into_iter().collect())
            .build_and_self_sign()
            .unwrap();
        let other_der =
            x509_cert::Certificate::from_pem(&other.get_pem().unwrap()).unwrap().to_der().unwrap();
        let bad_roots = CertStore::from_der_certs(&[other_der]).unwrap();
        assert!(verify_artifact(&file, &cms, &bad_roots).is_err());
    }
}