provenance/src/trust.rs
//! The pinned Sigstore trust root, and how it crosses the airgap.
//!
//! The online side takes Sigstore's `trusted_root.json` (the TUF target that
//! names the Fulcio certificate chains, the Rekor log keys and the CT log keys,
//! each with its validity window) and signs it into a `holger.update-bundle/v1`
//! with product [`TRUST_PRODUCT`], exactly as the intel bundle is signed. The
//! secure side verifies that bundle against its bundle trust policy and only then
//! reads the root. Nothing here dials TUF: the TUF chain stops on the online side,
//! and the bundle signature is what the secure side trusts.
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use holger_update_bundle::container::load_bundle_verified;
use holger_update_bundle::core::{BundleCore, EvidenceKind, PolicyResult, SourceProvenance};
use holger_update_bundle::digest::sha256_hex;
use holger_update_bundle::finalize::{add_approval, finalize};
use holger_update_bundle::prepare::{EvidenceInput, PayloadInput, Preparation};
use holger_update_bundle::schema::{CORE_JSON, PAYLOAD_PREFIX};
use holger_update_bundle::time::TrustedTime;
use holger_update_bundle::verify::{verify_member_map_with_time, TargetSpec};
use holger_update_bundle::{pack_bundle, TrustPolicy};
use serde_json::Value;
use crate::apk::ApkKey;
use crate::crypto::{self, PublicKey};
use crate::error::{ProvenanceError, Result};
use crate::pgp::PgpKey;
/// The bundle product a Sigstore trust root travels as.
pub const TRUST_PRODUCT: &str = "holger.sigstore-trust/v1";
/// The payload's path in the bundle.
pub const TRUSTED_ROOT_REL: &str = "trusted_root.json";
/// Where a trust bundle carries the pinned APK keys: `payload/apk-keys/<name>`,
/// `<name>` as the index's `.SIGN.*.<name>` names it (`wolfi-signing.rsa.pub`).
pub const APK_KEYS_PREFIX: &str = "apk-keys/";
/// Their media type.
pub const APK_KEY_MEDIA_TYPE: &str = "application/x-pem-file";
/// Where a trust bundle carries the pinned OpenPGP keys (Maven Central signers):
/// `payload/pgp-keys/<name>`, each an armored public key.
pub const PGP_KEYS_PREFIX: &str = "pgp-keys/";
/// Their media type.
pub const PGP_KEY_MEDIA_TYPE: &str = "application/pgp-keys";
/// Is `name` a key name the bundle may carry (one path segment, no surprises)?
pub fn apk_key_name_ok(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 200
&& !name.starts_with('.')
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-@+".contains(&b))
}
/// Its media type, as Sigstore names it.
pub const TRUSTED_ROOT_MEDIA_TYPE: &str =
"application/vnd.dev.sigstore.trustedroot+json;version=0.1";
/// A validity window, Unix seconds; `end: None` is open.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Window {
pub start: i64,
pub end: Option<i64>,
}
impl Window {
pub fn covers(&self, t: i64) -> bool {
t >= self.start && self.end.is_none_or(|e| t <= e)
}
}
/// A certificate authority: its chain, leafmost first, and when it issued.
#[derive(Debug, Clone)]
pub struct CertAuthority {
pub uri: String,
pub chain: Vec<Vec<u8>>,
pub valid: Window,
}
/// A transparency log or CT log key.
#[derive(Debug, Clone)]
pub struct LogKey {
pub base_url: String,
/// SHA-256 of the key's SPKI DER.
pub log_id: Vec<u8>,
pub key: PublicKey,
pub valid: Window,
}
/// The pinned Sigstore trust root.
#[derive(Debug, Clone, Default)]
pub struct TrustRoot {
pub cas: Vec<CertAuthority>,
pub tlogs: Vec<LogKey>,
pub ctlogs: Vec<LogKey>,
/// SHA-256 of the trusted_root.json the root was read from, for the record.
pub digest: String,
/// The pinned APK signing keys (Wolfi, Chainguard, Alpine), carried in the
/// same bundle as `apk-keys/<name>` payloads ([`APK_KEYS_PREFIX`]).
pub apk_keys: Vec<ApkKey>,
/// The pinned OpenPGP keys (Maven Central's `.asc` signers), carried in the
/// same bundle as `pgp-keys/<name>` payloads ([`PGP_KEYS_PREFIX`]).
pub pgp_keys: Vec<PgpKey>,
}
fn rfc3339(v: Option<&Value>) -> Result<Option<i64>> {
match v.and_then(Value::as_str) {
None => Ok(None),
Some(s) => DateTime::parse_from_rfc3339(s)
.map(|d| Some(d.timestamp()))
.map_err(|e| ProvenanceError::TrustRoot(format!("validFor time {s:?}: {e}"))),
}
}
fn window(v: Option<&Value>) -> Result<Window> {
let v = v.ok_or_else(|| ProvenanceError::TrustRoot("an entry has no validFor".into()))?;
Ok(Window {
start: rfc3339(v.get("start"))?.unwrap_or(0),
end: rfc3339(v.get("end"))?,
})
}
fn log_keys(list: Option<&Value>) -> Result<Vec<LogKey>> {
let mut out = Vec::new();
for t in list.and_then(Value::as_array).into_iter().flatten() {
let pk = t
.get("publicKey")
.ok_or_else(|| ProvenanceError::TrustRoot("a log has no publicKey".into()))?;
let raw = crypto::b64(
"publicKey.rawBytes",
pk.get("rawBytes").and_then(Value::as_str).unwrap_or(""),
)
.map_err(|e| ProvenanceError::TrustRoot(e.to_string()))?;
let key = PublicKey::from_spki_der(&raw)
.map_err(|e| ProvenanceError::TrustRoot(e.to_string()))?;
let log_id = match t.pointer("/logId/keyId").and_then(Value::as_str) {
Some(k) => crypto::b64("logId.keyId", k)
.map_err(|e| ProvenanceError::TrustRoot(e.to_string()))?,
None => crypto::key_id(&raw).to_vec(),
};
out.push(LogKey {
base_url: t
.get("baseUrl")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
log_id,
key,
valid: window(pk.get("validFor"))?,
});
}
Ok(out)
}
impl TrustRoot {
/// Read a Sigstore `trusted_root.json`. The caller vouches for these bytes;
/// on the secure side use [`TrustRoot::from_bundle`].
pub fn from_trusted_root_json(bytes: &[u8]) -> Result<Self> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| {
ProvenanceError::TrustRoot(format!("trusted_root.json is not JSON: {e}"))
})?;
let mt = v.get("mediaType").and_then(Value::as_str).unwrap_or("");
if !mt.starts_with("application/vnd.dev.sigstore.trustedroot") {
return Err(ProvenanceError::TrustRoot(format!(
"mediaType {mt:?} is not a Sigstore trusted root"
)));
}
let mut cas = Vec::new();
for ca in v
.get("certificateAuthorities")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let mut chain = Vec::new();
for c in ca
.pointer("/certChain/certificates")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
chain.push(
crypto::b64(
"certChain.rawBytes",
c.get("rawBytes").and_then(Value::as_str).unwrap_or(""),
)
.map_err(|e| ProvenanceError::TrustRoot(e.to_string()))?,
);
}
if chain.is_empty() {
return Err(ProvenanceError::TrustRoot(
"a certificate authority has no certificates".into(),
));
}
cas.push(CertAuthority {
uri: ca
.get("uri")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
chain,
valid: window(ca.get("validFor"))?,
});
}
let root = TrustRoot {
cas,
tlogs: log_keys(v.get("tlogs"))?,
ctlogs: log_keys(v.get("ctlogs"))?,
digest: crypto::sha256_hex(bytes),
apk_keys: Vec::new(),
pgp_keys: Vec::new(),
};
if root.cas.is_empty() || root.tlogs.is_empty() {
return Err(ProvenanceError::TrustRoot(
"the trust root names no certificate authority or no transparency log".into(),
));
}
Ok(root)
}
/// ★ Verify a `holger.sigstore-trust/v1` bundle against `policy` at `time`,
/// and read the root it carries. Any refusal is [`ProvenanceError::TrustRoot`].
pub fn from_bundle(path: &Path, policy: &TrustPolicy, time: &dyn TrustedTime) -> Result<Self> {
let v = Self::verified_bundle(path, policy, time)?;
Self::from_trusted_root_json(&v.trusted_root_json)?
.with_apk_keys(&v.apk_keys)?
.with_pgp_keys(&v.pgp_keys)
}
/// The same root with these OpenPGP keys pinned (`(name, armored key)`), each
/// read and its bindings checked.
pub fn with_pgp_keys(mut self, keys: &[(String, Vec<u8>)]) -> Result<Self> {
for (name, armored) in keys {
if !apk_key_name_ok(name) {
return Err(ProvenanceError::TrustRoot(format!(
"pgp key name {name:?} is not one path segment"
)));
}
self.pgp_keys.push(PgpKey::from_armored(name, armored)?);
}
Ok(self)
}
/// The same root with these APK keys pinned (`(name, PEM)`), each read and
/// checked as an RSA public key.
pub fn with_apk_keys(mut self, keys: &[(String, Vec<u8>)]) -> Result<Self> {
for (name, pem) in keys {
if !apk_key_name_ok(name) {
return Err(ProvenanceError::TrustRoot(format!(
"apk key name {name:?} is not one path segment"
)));
}
self.apk_keys.push(ApkKey::from_pem(name, pem)?);
}
Ok(self)
}
/// The `trusted_root.json` a `holger.sigstore-trust/v1` bundle carries, once
/// the bundle verifies against `policy` at `time` (the same checks as
/// [`TrustRoot::from_bundle`]). The scan sandbox hands these verified bytes
/// to the worker, which reads them with [`TrustRoot::from_trusted_root_json`].
pub fn verified_json_from_bundle(
path: &Path,
policy: &TrustPolicy,
time: &dyn TrustedTime,
) -> Result<Vec<u8>> {
Self::verified_from_bundle(path, policy, time).map(|(json, _)| json)
}
/// The `trusted_root.json` AND the pinned APK keys (`(name, PEM)`) a verified
/// `holger.sigstore-trust/v1` bundle carries. Every payload's digest is
/// covered by the bundle's signed core, so the keys are as trusted as the root.
pub fn verified_from_bundle(
path: &Path,
policy: &TrustPolicy,
time: &dyn TrustedTime,
) -> Result<(Vec<u8>, NamedKeys)> {
Self::verified_bundle(path, policy, time).map(|v| (v.trusted_root_json, v.apk_keys))
}
/// Everything a verified `holger.sigstore-trust/v1` bundle carries: the
/// `trusted_root.json`, the APK keys and the OpenPGP keys, each `(name, bytes)`.
pub fn verified_bundle(
path: &Path,
policy: &TrustPolicy,
time: &dyn TrustedTime,
) -> Result<VerifiedTrustBundle> {
let tr = |e: String| ProvenanceError::TrustRoot(e);
let members = load_bundle_verified(path, policy).map_err(|e| {
tr(format!(
"the trust bundle {} does not verify: {e}",
path.display()
))
})?;
let core = BundleCore::from_json_bytes(
members
.get(CORE_JSON)
.ok_or_else(|| tr("the bundle has no core.json".into()))?,
)
.map_err(|e| tr(format!("core.json: {e}")))?;
if core.product != TRUST_PRODUCT {
return Err(tr(format!(
"the bundle's product is {:?}, not {TRUST_PRODUCT:?}",
core.product
)));
}
let target = TargetSpec {
product: Some(TRUST_PRODUCT.to_string()),
model: None,
revision: None,
};
verify_member_map_with_time(&members, policy, &target, None, time)
.map_err(|e| tr(format!("the trust bundle does not verify: {e}")))?;
let payload = members
.get(&format!("{PAYLOAD_PREFIX}{TRUSTED_ROOT_REL}"))
.ok_or_else(|| tr(format!("the bundle carries no {TRUSTED_ROOT_REL}")))?;
if core.policy_result.advisory_snapshot_digest.as_deref()
!= Some(sha256_hex(payload).as_str())
{
return Err(tr(
"the bundle's recorded digest does not name its own trusted_root.json".into(),
));
}
let under = |p: &str| -> Vec<(String, Vec<u8>)> {
let prefix = format!("{PAYLOAD_PREFIX}{p}");
members
.iter()
.filter_map(|(k, v)| k.strip_prefix(&prefix).map(|n| (n.to_string(), v.clone())))
.collect()
};
Ok(VerifiedTrustBundle {
trusted_root_json: payload.clone(),
apk_keys: under(APK_KEYS_PREFIX),
pgp_keys: under(PGP_KEYS_PREFIX),
})
}
/// The transparency log with this id.
pub fn tlog(&self, log_id: &[u8]) -> Option<&LogKey> {
self.tlogs.iter().find(|l| l.log_id == log_id)
}
/// The CT log with this id.
pub fn ctlog(&self, log_id: &[u8]) -> Option<&LogKey> {
self.ctlogs.iter().find(|l| l.log_id == log_id)
}
}
/// Named keys as a bundle carries them: `(name, bytes)`.
pub type NamedKeys = Vec<(String, Vec<u8>)>;
/// What a verified trust bundle carries. Every payload's digest is covered by the
/// bundle's signed core, so the keys are as trusted as the root.
#[derive(Debug, Clone, Default)]
pub struct VerifiedTrustBundle {
pub trusted_root_json: Vec<u8>,
/// `(name, PEM)`.
pub apk_keys: Vec<(String, Vec<u8>)>,
/// `(name, armored OpenPGP public key)`.
pub pgp_keys: Vec<(String, Vec<u8>)>,
}
/// One signer's certificate (DER) and ed25519 key (PKCS#8 DER).
pub struct BundleSigner {
pub cert_der: Vec<u8>,
pub key_pkcs8: Vec<u8>,
}
/// How to sign a trust bundle (the online side).
pub struct TrustBundleOptions {
pub sequence: u64,
pub created_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
pub supplier: String,
pub producer: String,
/// Where the trusted root was fetched from, for the record.
pub source_uri: Option<String>,
pub policy: TrustPolicy,
pub approvals: Vec<(String, BundleSigner)>,
pub release: BundleSigner,
pub out: PathBuf,
/// The APK signing keys to pin (`(name, PEM)`), e.g. Wolfi's
/// `wolfi-signing.rsa.pub`; each becomes an `apk-keys/<name>` payload.
pub apk_keys: Vec<(String, Vec<u8>)>,
/// The OpenPGP keys to pin (`(name, armored public key)`), e.g. a Maven
/// Central signer's; each becomes a `pgp-keys/<name>` payload.
pub pgp_keys: Vec<(String, Vec<u8>)>,
}
/// ★ Sign a `trusted_root.json` into a trust bundle. The bytes are checked as a
/// trust root first, so a bundle that the secure side could not read is never made.
pub fn build_trust_bundle(
trusted_root_json: &[u8],
opts: TrustBundleOptions,
) -> anyhow::Result<PathBuf> {
TrustRoot::from_trusted_root_json(trusted_root_json)
.and_then(|r| r.with_apk_keys(&opts.apk_keys))
.and_then(|r| r.with_pgp_keys(&opts.pgp_keys))
.map_err(|e| anyhow::anyhow!("{}", e.tagged()))?;
let digest = sha256_hex(trusted_root_json);
let date = opts.created_at.format("%Y-%m-%d");
let record = serde_json::to_vec_pretty(&serde_json::json!({
"source_uri": opts.source_uri,
"sha256": digest,
"built_at": opts.created_at.to_rfc3339(),
}))?;
let prep = Preparation {
bundle_id: format!("holger-sigstore-trust-{}-{date}", opts.sequence),
sequence: opts.sequence,
created_at: opts.created_at,
expires_at: opts.expires_at,
producer: opts.producer.clone(),
supplier: opts.supplier.clone(),
product: TRUST_PRODUCT.to_string(),
release_version: format!("{date}.{}", opts.sequence),
supported_hardware_models: vec![],
supported_hardware_revisions: vec![],
source_provenance: SourceProvenance {
source_kind: "sigstore-tuf".into(),
source_uri: opts.source_uri.clone(),
source_repository: None,
source_asset_path: None,
fetched_at: Some(opts.created_at),
reported_size: Some(trusted_root_json.len() as u64),
vendor_checksum: None,
vendor_signature_status: None,
tls_origin: None,
transfer_receipt_digest: None,
},
policy_result: PolicyResult {
policy_digest: sha256_hex(opts.policy.to_ron()?.as_bytes()),
advisory_snapshot_digest: Some(digest),
verdict: "pass".into(),
non_production: false,
},
supersedes: vec![],
withdraws: vec![],
payloads: std::iter::once(PayloadInput {
relative_path: TRUSTED_ROOT_REL.into(),
bytes: trusted_root_json.to_vec(),
media_type: TRUSTED_ROOT_MEDIA_TYPE.into(),
})
.chain(opts.apk_keys.iter().map(|(n, pem)| PayloadInput {
relative_path: format!("{APK_KEYS_PREFIX}{n}"),
bytes: pem.clone(),
media_type: APK_KEY_MEDIA_TYPE.into(),
}))
.chain(opts.pgp_keys.iter().map(|(n, k)| PayloadInput {
relative_path: format!("{PGP_KEYS_PREFIX}{n}"),
bytes: k.clone(),
media_type: PGP_KEY_MEDIA_TYPE.into(),
}))
.collect(),
evidence: vec![EvidenceInput {
relative_path: "sigstore-trust-source.json".into(),
bytes: record,
media_type: "application/json".into(),
kind: EvidenceKind::SourceReceipt,
}],
};
let candidate = prep
.build()
.map_err(|e| anyhow::anyhow!("prepare the trust bundle: {e}"))?;
let mut members = candidate.members;
let now = Utc::now();
for (role, s) in &opts.approvals {
add_approval(&mut members, role, &s.cert_der, &s.key_pkcs8, None, now)
.map_err(|e| anyhow::anyhow!("approve the trust bundle as {role}: {e}"))?;
}
finalize(
&mut members,
&opts.policy,
&opts.release.cert_der,
&opts.release.key_pkcs8,
now,
)
.map_err(|e| anyhow::anyhow!("sign the trust bundle: {e}"))?;
if let Some(p) = opts.out.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(p).ok();
}
pack_bundle(&members, &opts.out).map_err(|e| anyhow::anyhow!("pack the trust bundle: {e}"))?;
Ok(opts.out)
}