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

nordisk/holger

provenance/src/material.rs

//! The shapes provenance arrives in, read into one [`Candidate`] each.
//!
//! * a bare DSSE envelope (`{payloadType, payload, signatures}`), key-signed;
//! * a JSON-lines file of those (`*.intoto.jsonl`, as the SLSA GitHub generator writes);
//! * a Sigstore bundle v0.1, v0.2 or v0.3 (`application/vnd.dev.sigstore.bundle…`),
//!   with a DSSE envelope or a message signature;
//! * a PyPI PEP 740 provenance object (`{version, attestation_bundles}`, the
//!   Integrity API's answer) or one PEP 740 attestation;
//! * npm's attestations answer (`{attestations: [{predicateType, bundle}]}`);
//! * npm's registry signatures (`holger.npm-registry-signatures/v1`): a version's
//!   `dist.signatures` over `<name>@<version>:<integrity>`, key-signed;
//! * cosign's image attachments (`holger.cosign-attachments/v1`): the `.sig` and
//!   `.att` layers with their annotations, as the online side fetched them.

use serde_json::Value;

use crate::crypto;
use crate::error::{ProvenanceError, Result};

/// The schema of a cosign attachments file.
pub const COSIGN_ATTACHMENTS_SCHEMA: &str = "holger.cosign-attachments/v1";

/// The schema of an npm registry signatures file: a version's `dist.signatures`
/// with the `name`, `version` and `dist.integrity` they sign, as the packument
/// carried them (`{schema, name, version, integrity, signatures: [{keyid, sig}]}`).
pub const NPM_REGISTRY_SIGNATURES_SCHEMA: &str = "holger.npm-registry-signatures/v1";

/// What was signed.
#[derive(Debug, Clone)]
pub enum Signed {
    /// A DSSE envelope.
    Dsse {
        payload_type: String,
        payload: Vec<u8>,
        signatures: Vec<(String, Vec<u8>)>,
    },
    /// A signature over the artifact's own bytes (a Sigstore message signature).
    /// `sha256` is the digest the bundle claims, when it claims one.
    Artifact {
        sha256: Option<String>,
        signature: Vec<u8>,
    },
    /// A cosign simple-signing payload, signed (an image signature).
    SimpleSigning {
        payload: Vec<u8>,
        signature: Vec<u8>,
    },
    /// An npm registry signature: ECDSA over `<name>@<version>:<integrity>`, where
    /// `integrity` is the tarball's `sha512-<base64>` (npm's `dist.signatures`).
    NpmRegistry {
        name: String,
        version: String,
        integrity: String,
        signatures: Vec<(String, Vec<u8>)>,
    },
}

/// Who is said to have signed.
#[derive(Debug, Clone)]
pub enum Signer {
    /// A configured key, by its hint (`keyid`), possibly empty.
    Key { hint: String },
    /// A certificate (Fulcio, for keyless), with whatever chain came along.
    Cert { leaf: Vec<u8>, chain: Vec<Vec<u8>> },
}

/// A Rekor inclusion proof with its checkpoint.
#[derive(Debug, Clone)]
pub struct InclusionProof {
    pub log_index: u64,
    pub tree_size: u64,
    pub root_hash: Vec<u8>,
    pub hashes: Vec<Vec<u8>>,
    pub checkpoint: String,
}

/// One transparency-log entry.
#[derive(Debug, Clone)]
pub struct TlogEntry {
    pub log_index: u64,
    pub log_id: Vec<u8>,
    pub integrated_time: i64,
    pub kind: String,
    pub version: String,
    /// The canonicalized body, decoded.
    pub body: Vec<u8>,
    pub set: Option<Vec<u8>>,
    pub proof: Option<InclusionProof>,
}

/// One piece of provenance to verify.
#[derive(Debug, Clone)]
pub struct Candidate {
    /// Where it came from, in words (`pep740`, `npm/<predicate>`, `cosign-att`, …).
    pub origin: String,
    pub signed: Signed,
    pub signer: Signer,
    pub tlog: Vec<TlogEntry>,
    /// The exact envelope bytes, when the log hashed them (cosign `.att` layers).
    pub envelope_bytes: Option<Vec<u8>>,
}

fn bad(w: impl Into<String>) -> ProvenanceError {
    ProvenanceError::Malformed(w.into())
}

fn s<'a>(v: &'a Value, ptr: &str) -> Option<&'a str> {
    v.pointer(ptr).and_then(Value::as_str)
}

fn int(v: &Value, ptr: &str) -> Option<i64> {
    let x = v.pointer(ptr)?;
    x.as_i64()
        .or_else(|| x.as_str().and_then(|s| s.parse().ok()))
}

fn dsse(v: &Value) -> Result<Signed> {
    let payload = crypto::b64(
        "dsse payload",
        s(v, "/payload").ok_or_else(|| bad("a DSSE envelope has no payload"))?,
    )?;
    let payload_type = s(v, "/payloadType").unwrap_or("").to_string();
    let mut signatures = Vec::new();
    for sig in v
        .get("signatures")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
    {
        let raw = sig
            .get("sig")
            .and_then(Value::as_str)
            .ok_or_else(|| bad("a DSSE signature has no sig"))?;
        signatures.push((
            sig.get("keyid")
                .and_then(Value::as_str)
                .unwrap_or("")
                .to_string(),
            crypto::b64("dsse sig", raw)?,
        ));
    }
    if signatures.is_empty() {
        return Err(bad("the DSSE envelope carries no signature"));
    }
    Ok(Signed::Dsse {
        payload_type,
        payload,
        signatures,
    })
}

/// A transparency-log entry in Sigstore's protobuf JSON (bundle `tlogEntries`,
/// PEP 740 `transparency_entries`).
fn tlog_entry(v: &Value) -> Result<TlogEntry> {
    let body = crypto::b64(
        "canonicalizedBody",
        s(v, "/canonicalizedBody").ok_or_else(|| bad("a log entry has no canonicalizedBody"))?,
    )?;
    let proof = match v.get("inclusionProof") {
        Some(p) => Some(InclusionProof {
            log_index: int(p, "/logIndex").unwrap_or(-1).max(0) as u64,
            tree_size: int(p, "/treeSize").unwrap_or(0).max(0) as u64,
            root_hash: crypto::b64("rootHash", s(p, "/rootHash").unwrap_or(""))?,
            hashes: p
                .get("hashes")
                .and_then(Value::as_array)
                .into_iter()
                .flatten()
                .map(|h| crypto::b64("inclusion hash", h.as_str().unwrap_or("")))
                .collect::<Result<_>>()?,
            checkpoint: s(p, "/checkpoint/envelope").unwrap_or("").to_string(),
        }),
        None => None,
    };
    Ok(TlogEntry {
        log_index: int(v, "/logIndex")
            .ok_or_else(|| bad("a log entry has no logIndex"))?
            .max(0) as u64,
        log_id: crypto::b64("logId.keyId", s(v, "/logId/keyId").unwrap_or(""))?,
        integrated_time: int(v, "/integratedTime").unwrap_or(0),
        kind: s(v, "/kindVersion/kind").unwrap_or("").to_string(),
        version: s(v, "/kindVersion/version").unwrap_or("").to_string(),
        body,
        set: s(v, "/inclusionPromise/signedEntryTimestamp")
            .map(|x| crypto::b64("signedEntryTimestamp", x))
            .transpose()?,
        proof,
    })
}

fn tlog_entries(list: Option<&Value>) -> Result<Vec<TlogEntry>> {
    list.and_then(Value::as_array)
        .into_iter()
        .flatten()
        .map(tlog_entry)
        .collect()
}

/// A Sigstore bundle (v0.1 to v0.3).
pub fn sigstore_bundle(origin: &str, v: &Value) -> Result<Candidate> {
    let vm = v
        .get("verificationMaterial")
        .ok_or_else(|| bad("a Sigstore bundle has no verificationMaterial"))?;
    let signer = if let Some(c) = s(vm, "/certificate/rawBytes") {
        Signer::Cert {
            leaf: crypto::b64("certificate", c)?,
            chain: Vec::new(),
        }
    } else if let Some(list) = vm
        .pointer("/x509CertificateChain/certificates")
        .and_then(Value::as_array)
    {
        let mut ders: Vec<Vec<u8>> = list
            .iter()
            .map(|c| {
                crypto::b64(
                    "certificate",
                    c.get("rawBytes").and_then(Value::as_str).unwrap_or(""),
                )
            })
            .collect::<Result<_>>()?;
        if ders.is_empty() {
            return Err(bad("the bundle's certificate chain is empty"));
        }
        let leaf = ders.remove(0);
        Signer::Cert { leaf, chain: ders }
    } else if let Some(h) = s(vm, "/publicKey/hint") {
        Signer::Key {
            hint: h.to_string(),
        }
    } else {
        return Err(bad("the bundle names no certificate and no key"));
    };
    let signed = if let Some(env) = v.get("dsseEnvelope") {
        dsse(env)?
    } else if let Some(ms) = v.get("messageSignature") {
        let sha256 = match (
            s(ms, "/messageDigest/algorithm"),
            s(ms, "/messageDigest/digest"),
        ) {
            (Some("SHA2_256"), Some(d)) => Some(hex::encode(crypto::b64("messageDigest", d)?)),
            _ => None,
        };
        Signed::Artifact {
            sha256,
            signature: crypto::b64("messageSignature", s(ms, "/signature").unwrap_or(""))?,
        }
    } else {
        return Err(bad(
            "the bundle holds neither a DSSE envelope nor a message signature",
        ));
    };
    Ok(Candidate {
        origin: origin.to_string(),
        signed,
        signer,
        tlog: tlog_entries(vm.get("tlogEntries"))?,
        envelope_bytes: None,
    })
}

/// One PEP 740 attestation object.
pub fn pep740_attestation(origin: &str, v: &Value) -> Result<Candidate> {
    let vm = v
        .get("verification_material")
        .ok_or_else(|| bad("a PEP 740 attestation has no verification_material"))?;
    let leaf = crypto::b64(
        "certificate",
        s(vm, "/certificate").ok_or_else(|| bad("a PEP 740 attestation has no certificate"))?,
    )?;
    let payload = crypto::b64(
        "statement",
        s(v, "/envelope/statement").ok_or_else(|| bad("a PEP 740 attestation has no statement"))?,
    )?;
    let sig = crypto::b64(
        "signature",
        s(v, "/envelope/signature").ok_or_else(|| bad("a PEP 740 attestation has no signature"))?,
    )?;
    Ok(Candidate {
        origin: origin.to_string(),
        signed: Signed::Dsse {
            payload_type: crate::statement::PAYLOAD_TYPE_INTOTO.to_string(),
            payload,
            signatures: vec![(String::new(), sig)],
        },
        signer: Signer::Cert {
            leaf,
            chain: Vec::new(),
        },
        tlog: tlog_entries(vm.get("transparency_entries"))?,
        envelope_bytes: None,
    })
}

/// A cosign legacy bundle annotation (`dev.sigstore.cosign/bundle`): a SET and its payload.
fn cosign_bundle(annotation: &str) -> Result<TlogEntry> {
    let v: Value = serde_json::from_str(annotation)
        .map_err(|e| bad(format!("cosign bundle annotation: {e}")))?;
    let body = crypto::b64(
        "cosign bundle body",
        s(&v, "/Payload/body").ok_or_else(|| bad("the cosign bundle has no body"))?,
    )?;
    let kind: Value =
        serde_json::from_slice(&body).map_err(|e| bad(format!("the cosign bundle body: {e}")))?;
    Ok(TlogEntry {
        log_index: int(&v, "/Payload/logIndex").unwrap_or(0).max(0) as u64,
        log_id: hex::decode(s(&v, "/Payload/logID").unwrap_or(""))
            .map_err(|e| bad(format!("logID: {e}")))?,
        integrated_time: int(&v, "/Payload/integratedTime").unwrap_or(0),
        kind: s(&kind, "/kind").unwrap_or("").to_string(),
        version: s(&kind, "/apiVersion").unwrap_or("").to_string(),
        body,
        set: s(&v, "/SignedEntryTimestamp")
            .map(|x| crypto::b64("SignedEntryTimestamp", x))
            .transpose()?,
        proof: None,
    })
}

/// cosign attachments: `{schema, image, digest, signatures: [layer], attestations: [layer]}`,
/// each layer `{mediaType, annotations, blob: <base64 of the layer bytes>}`.
pub fn cosign_attachments(v: &Value) -> Result<Vec<Candidate>> {
    let mut out = Vec::new();
    for (key, origin) in [("signatures", "cosign-sig"), ("attestations", "cosign-att")] {
        for layer in v.get(key).and_then(Value::as_array).into_iter().flatten() {
            let blob = crypto::b64(
                "layer blob",
                s(layer, "/blob").ok_or_else(|| bad("a cosign layer has no blob"))?,
            )?;
            let ann = layer.get("annotations").cloned().unwrap_or(Value::Null);
            let a = |k: &str| ann.get(k).and_then(Value::as_str);
            let certs = crypto::pem_certs(a("dev.sigstore.cosign/certificate").unwrap_or(""))?;
            let chain = crypto::pem_certs(a("dev.sigstore.cosign/chain").unwrap_or(""))?;
            let signer = match certs.into_iter().next() {
                Some(leaf) => Signer::Cert { leaf, chain },
                None => Signer::Key {
                    hint: String::new(),
                },
            };
            let tlog = match a("dev.sigstore.cosign/bundle") {
                Some(b) => vec![cosign_bundle(b)?],
                None => Vec::new(),
            };
            let (signed, envelope_bytes) = if key == "attestations" {
                let env: Value = serde_json::from_slice(&blob)
                    .map_err(|e| bad(format!("an .att layer is not a DSSE envelope: {e}")))?;
                (dsse(&env)?, Some(blob))
            } else {
                let sig = crypto::b64(
                    "cosign signature",
                    a("dev.cosignproject.cosign/signature").unwrap_or(""),
                )?;
                (
                    Signed::SimpleSigning {
                        payload: blob,
                        signature: sig,
                    },
                    None,
                )
            };
            out.push(Candidate {
                origin: origin.to_string(),
                signed,
                signer,
                tlog,
                envelope_bytes,
            });
        }
    }
    Ok(out)
}

/// An npm registry signatures file ([`NPM_REGISTRY_SIGNATURES_SCHEMA`]).
pub fn npm_registry_signatures(v: &Value) -> Result<Candidate> {
    let field = |k: &str| {
        s(v, &format!("/{k}"))
            .filter(|x| !x.is_empty())
            .ok_or_else(|| bad(format!("npm registry signatures name no {k}")))
    };
    let (name, version, integrity) = (
        field("name")?.to_string(),
        field("version")?.to_string(),
        field("integrity")?.to_string(),
    );
    let mut signatures = Vec::new();
    for sig in v
        .get("signatures")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
    {
        let raw = sig
            .get("sig")
            .and_then(Value::as_str)
            .ok_or_else(|| bad("an npm registry signature has no sig"))?;
        signatures.push((
            sig.get("keyid")
                .and_then(Value::as_str)
                .unwrap_or("")
                .to_string(),
            crypto::b64("npm registry sig", raw)?,
        ));
    }
    if signatures.is_empty() {
        return Err(bad("the npm registry signatures file carries no signature"));
    }
    Ok(Candidate {
        origin: "npm registry signature".into(),
        signed: Signed::NpmRegistry {
            name,
            version,
            integrity,
            signatures,
        },
        signer: Signer::Key {
            hint: String::new(),
        },
        tlog: Vec::new(),
        envelope_bytes: None,
    })
}

/// ★ Read one file of provenance material into its candidates.
pub fn parse(name: &str, bytes: &[u8]) -> Result<Vec<Candidate>> {
    let text = std::str::from_utf8(bytes).map_err(|e| bad(format!("{name}: not UTF-8: {e}")))?;
    let v: Value = match serde_json::from_str(text) {
        Ok(v) => v,
        Err(first) => {
            // JSON lines: one envelope or bundle per line.
            let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
            if lines.len() < 2 {
                return Err(bad(format!("{name}: not JSON: {first}")));
            }
            let mut out = Vec::new();
            for (i, l) in lines.iter().enumerate() {
                out.extend(parse(&format!("{name}#{}", i + 1), l.as_bytes())?);
            }
            return Ok(out);
        }
    };
    if v.get("schema").and_then(Value::as_str) == Some(COSIGN_ATTACHMENTS_SCHEMA) {
        return cosign_attachments(&v);
    }
    if v.get("schema").and_then(Value::as_str) == Some(NPM_REGISTRY_SIGNATURES_SCHEMA) {
        return Ok(vec![npm_registry_signatures(&v)?]);
    }
    if let Some(bundles) = v.get("attestation_bundles").and_then(Value::as_array) {
        let mut out = Vec::new();
        for b in bundles {
            for a in b
                .get("attestations")
                .and_then(Value::as_array)
                .into_iter()
                .flatten()
            {
                out.push(pep740_attestation("pep740", a)?);
            }
        }
        return Ok(out);
    }
    if v.get("verification_material").is_some() && v.get("envelope").is_some() {
        return Ok(vec![pep740_attestation("pep740", &v)?]);
    }
    if let Some(list) = v.get("attestations").and_then(Value::as_array) {
        let mut out = Vec::new();
        for a in list {
            let pt = a
                .get("predicateType")
                .and_then(Value::as_str)
                .unwrap_or("?");
            let b = a
                .get("bundle")
                .ok_or_else(|| bad("an npm attestation has no bundle"))?;
            out.push(sigstore_bundle(&format!("npm {pt}"), b)?);
        }
        return Ok(out);
    }
    if v.get("mediaType")
        .and_then(Value::as_str)
        .is_some_and(|m| m.starts_with("application/vnd.dev.sigstore.bundle"))
    {
        return Ok(vec![sigstore_bundle("sigstore-bundle", &v)?]);
    }
    if v.get("payloadType").is_some() && v.get("signatures").is_some() {
        return Ok(vec![Candidate {
            origin: "dsse".into(),
            signed: dsse(&v)?,
            signer: Signer::Key {
                hint: String::new(),
            },
            tlog: Vec::new(),
            envelope_bytes: Some(bytes.to_vec()),
        }]);
    }
    Err(bad(format!("{name}: not a DSSE envelope, Sigstore bundle, PEP 740 provenance, npm attestations or cosign attachments")))
}