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());
}
}