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")))
}