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

nordisk/holger

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