Gunnar

nordisk/heimdall

guests/verify-bundle/src/dsse.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! DSSE envelopes inside a bundle, verified BEFORE their payload is parsed.
//!
//! Holger's airgap export carries its attestations as
//! `…/attestations/<id>.dsse.json` (`application/vnd.dsse.envelope.v1+json`),
//! signed through znippy's `dsse_sign`: `keyid` = sha256 of the signer
//! certificate DER, signature over `PAE(payloadType, payload)`, Ed25519 or
//! ECDSA P-256 (DER), P-384 also read. SLSA release provenance travels as
//! `*.intoto.jsonl` (one envelope per line).
//!
//! The order here is the point:
//! 1. the envelope's own JSON (three fields, nothing nested to interpret);
//! 2. `payloadType` must be the in-toto type — it is bound into the signed
//!    bytes, so a different type is a confusion attempt, refused before decoding;
//! 3. every signature: a named keyid, pinned by the caller, its certificate in
//!    force, the signature valid over the PAE;
//! 4. only then is the payload parsed as an in-toto statement, and a subject
//!    must name a payload member of this bundle by sha256.

use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use chrono::{DateTime, Utc};
use serde::Deserialize;
use x509_parser::prelude::*;

use crate::digest::{hex_decode, sha256_hex};
use crate::model::CertificateValidity;
use crate::refusal::{Refusal, Result};

pub const PAYLOAD_TYPE_INTOTO: &str = "application/vnd.in-toto+json";
pub const MEDIA_TYPES: &[&str] = &[
    "application/vnd.dsse.envelope.v1+json",
    "application/vnd.dsse+json",
];
pub const STATEMENT_V1: &str = "https://in-toto.io/Statement/v1";
pub const STATEMENT_V01: &str = "https://in-toto.io/Statement/v0.1";

/// The most signatures one envelope may carry.
pub const MAX_SIGNATURES: usize = 16;
/// The most envelopes one member may carry (JSONL).
pub const MAX_ENVELOPES_PER_MEMBER: usize = 64;

const OID_ED25519: &str = "1.3.101.112";
const OID_EC_PUBLIC_KEY: &str = "1.2.840.10045.2.1";
const OID_P256: &str = "1.2.840.10045.3.1.7";
const OID_P384: &str = "1.3.132.0.34";

/// Is this member a DSSE envelope (by name or by its declared media type)?
pub fn is_envelope_member(path: &str, media_type: Option<&str>) -> bool {
    path.ends_with(".dsse.json")
        || path.ends_with(".dsse")
        || path.ends_with(".intoto.jsonl")
        || media_type.is_some_and(|m| MEDIA_TYPES.contains(&m))
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawEnvelope {
    #[serde(rename = "payloadType")]
    payload_type: String,
    payload: String,
    signatures: Vec<RawSig>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawSig {
    #[serde(default)]
    keyid: String,
    sig: String,
}

/// What a verified envelope proved.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedEnvelope {
    pub member: String,
    pub predicate_type: String,
    pub signers: Vec<String>,
    pub subjects_bound: Vec<String>,
}

/// The pinned signers and the certificates on offer for their keyids.
///
/// Two ways to pin, matching Holger's two DSSE producers:
/// * `trusted` — certificate fingerprints (keyid = sha256 of the certificate
///   DER, Holger's attestation store); the certificate must be on offer;
/// * `keys` — raw public keys (keyid = sha256 of the SPKI DER, Holger's SLSA
///   release provenance signed by `scripts/sign-release.sh`).
pub struct DsseTrust<'a> {
    pub trusted: &'a [String],
    /// (keyid, SPKI DER) of every pinned raw key.
    pub keys: Vec<(String, Vec<u8>)>,
    /// Candidate certificate DERs: every bundle member plus any the caller gave.
    pub certificates: Vec<&'a [u8]>,
    pub now: DateTime<Utc>,
    pub validity: CertificateValidity,
}

fn refuse(member: &str, why: impl core::fmt::Display) -> Refusal {
    Refusal::Dsse(format!("{member}: {why}"))
}

/// Verify every envelope in `bytes` (one JSON document, or JSONL) and bind
/// each verified statement to `payload_sha256s`.
pub fn verify_member(
    member: &str,
    bytes: &[u8],
    trust: &DsseTrust<'_>,
    payload_sha256s: &[&str],
) -> Result<Vec<VerifiedEnvelope>> {
    let text = core::str::from_utf8(bytes).map_err(|_| refuse(member, "not UTF-8"))?;
    let docs: Vec<&str> = if member.ends_with(".jsonl") {
        text.lines().filter(|l| !l.trim().is_empty()).collect()
    } else {
        vec![text]
    };
    if docs.is_empty() {
        return Err(refuse(member, "no envelope"));
    }
    if docs.len() > MAX_ENVELOPES_PER_MEMBER {
        return Err(refuse(
            member,
            format!("more than {MAX_ENVELOPES_PER_MEMBER} envelopes"),
        ));
    }
    docs.iter()
        .map(|d| verify_envelope(member, d.as_bytes(), trust, payload_sha256s))
        .collect()
}

/// The DSSE v1 pre-authentication encoding.
pub fn 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.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
}

pub fn verify_envelope(
    member: &str,
    json: &[u8],
    trust: &DsseTrust<'_>,
    payload_sha256s: &[&str],
) -> Result<VerifiedEnvelope> {
    // 1. the envelope itself
    let env: RawEnvelope =
        serde_json::from_slice(json).map_err(|e| refuse(member, format!("envelope JSON: {e}")))?;
    // 2. the type, before a byte of payload is decoded
    if env.payload_type != PAYLOAD_TYPE_INTOTO {
        return Err(refuse(
            member,
            format!(
                "payloadType {:?} is not {PAYLOAD_TYPE_INTOTO}; DSSE binds the type into the \
                 signed bytes, so a different type is refused",
                env.payload_type
            ),
        ));
    }
    if env.signatures.is_empty() {
        return Err(refuse(member, "the envelope carries no signature"));
    }
    if env.signatures.len() > MAX_SIGNATURES {
        return Err(refuse(
            member,
            format!("more than {MAX_SIGNATURES} signatures"),
        ));
    }
    let payload = B64
        .decode(env.payload.as_bytes())
        .map_err(|e| refuse(member, format!("payload is not base64: {e}")))?;
    let message = pae(&env.payload_type, &payload);

    // 3. every signature, by a pinned and present signer
    let mut signers = Vec::new();
    for s in &env.signatures {
        if s.keyid.is_empty() {
            return Err(refuse(
                member,
                "a signature carries no keyid; only a NAMED, pinned signer is believed",
            ));
        }
        let keyid = s.keyid.to_ascii_lowercase();
        let sig = B64
            .decode(s.sig.as_bytes())
            .map_err(|e| refuse(member, format!("signature is not base64: {e}")))?;
        let key = if let Some((_, spki)) = trust.keys.iter().find(|(id, _)| *id == keyid) {
            PublicKey::from_spki(spki)
                .map_err(|e| refuse(member, format!("pinned key {keyid}: {e}")))?
        } else if trust.trusted.iter().any(|t| t.eq_ignore_ascii_case(&keyid)) {
            let der = trust
                .certificates
                .iter()
                .find(|d| sha256_hex(d) == keyid)
                .ok_or_else(|| refuse(member, format!("no certificate for keyid {keyid}")))?;
            PublicKey::from_cert(der, trust.now, trust.validity)
                .map_err(|e| refuse(member, format!("signer {keyid}: {e}")))?
        } else {
            return Err(refuse(
                member,
                format!("keyid {keyid} is not a pinned attestation signer"),
            ));
        };
        if !key.verify(&message, &sig) {
            return Err(refuse(
                member,
                format!("the signature by {keyid} does not verify"),
            ));
        }
        signers.push(keyid);
    }

    // 4. only now is the payload interpreted
    let st: serde_json::Value = serde_json::from_slice(&payload)
        .map_err(|e| Refusal::DsseBinding(format!("{member}: statement is not JSON: {e}")))?;
    let t = st.get("_type").and_then(|v| v.as_str()).unwrap_or("");
    if t != STATEMENT_V1 && t != STATEMENT_V01 {
        return Err(Refusal::DsseBinding(format!(
            "{member}: _type {t:?} is not an in-toto Statement"
        )));
    }
    let predicate_type = st
        .get("predicateType")
        .and_then(|v| v.as_str())
        .ok_or_else(|| Refusal::DsseBinding(format!("{member}: no predicateType")))?
        .to_string();
    let subjects = st
        .get("subject")
        .and_then(|v| v.as_array())
        .filter(|a| !a.is_empty())
        .ok_or_else(|| Refusal::DsseBinding(format!("{member}: no subject")))?;
    let mut bound = Vec::new();
    for s in subjects {
        let Some(d) = s.pointer("/digest/sha256").and_then(|v| v.as_str()) else {
            continue;
        };
        let d = d.to_ascii_lowercase();
        if payload_sha256s.iter().any(|p| p.eq_ignore_ascii_case(&d)) {
            bound.push(d);
        }
    }
    if bound.is_empty() {
        return Err(Refusal::DsseBinding(format!(
            "{member}: no subject names a payload of this bundle by sha256"
        )));
    }
    Ok(VerifiedEnvelope {
        member: member.to_string(),
        predicate_type,
        signers,
        subjects_bound: bound,
    })
}

/// A signer key read from a certificate.
enum PublicKey {
    Ed25519([u8; 32]),
    P256(p256::ecdsa::VerifyingKey),
    P384(p384::ecdsa::VerifyingKey),
}

impl PublicKey {
    fn from_cert(der: &[u8], now: DateTime<Utc>, validity: CertificateValidity) -> Result<Self> {
        let (_, cert) = X509Certificate::from_der(der)
            .map_err(|_| Refusal::Certificate("cannot parse certificate DER".into()))?;
        if validity == CertificateValidity::Enforce {
            let t = now.timestamp();
            if t < cert.validity().not_before.timestamp()
                || t > cert.validity().not_after.timestamp()
            {
                return Err(Refusal::Certificate(
                    "certificate not in force at `now`".into(),
                ));
            }
        }
        Self::from_spki_parsed(cert.public_key())
    }

    fn from_spki(der: &[u8]) -> Result<Self> {
        let (rest, spki) = SubjectPublicKeyInfo::from_der(der)
            .map_err(|_| Refusal::Certificate("cannot parse SubjectPublicKeyInfo".into()))?;
        if !rest.is_empty() {
            return Err(Refusal::Certificate("trailing bytes after the SPKI".into()));
        }
        Self::from_spki_parsed(&spki)
    }

    fn from_spki_parsed(spki: &SubjectPublicKeyInfo<'_>) -> Result<Self> {
        let alg = spki.algorithm.algorithm.to_id_string();
        let raw = spki.subject_public_key.data.as_ref();
        if alg == OID_ED25519 {
            let k: [u8; 32] = raw
                .try_into()
                .map_err(|_| Refusal::Certificate("ed25519 key is not 32 bytes".into()))?;
            return Ok(PublicKey::Ed25519(k));
        }
        if alg == OID_EC_PUBLIC_KEY {
            let curve = spki
                .algorithm
                .parameters
                .as_ref()
                .and_then(|p| p.as_oid().ok())
                .map(|o| o.to_id_string())
                .unwrap_or_default();
            if curve == OID_P256 {
                return p256::ecdsa::VerifyingKey::from_sec1_bytes(raw)
                    .map(PublicKey::P256)
                    .map_err(|_| Refusal::Certificate("bad P-256 point".into()));
            }
            if curve == OID_P384 {
                return p384::ecdsa::VerifyingKey::from_sec1_bytes(raw)
                    .map(PublicKey::P384)
                    .map_err(|_| Refusal::Certificate("bad P-384 point".into()));
            }
            return Err(Refusal::Certificate(format!(
                "unsupported EC curve {curve}"
            )));
        }
        Err(Refusal::Certificate(format!(
            "unsupported key algorithm {alg}"
        )))
    }

    fn verify(&self, msg: &[u8], sig: &[u8]) -> bool {
        use p256::ecdsa::signature::Verifier as _;
        match self {
            PublicKey::Ed25519(k) => crate::cert::verify_ed25519(k, msg, sig).is_ok(),
            PublicKey::P256(vk) => {
                p256::ecdsa::Signature::from_der(sig).is_ok_and(|s| vk.verify(msg, &s).is_ok())
            }
            PublicKey::P384(vk) => {
                p384::ecdsa::Signature::from_der(sig).is_ok_and(|s| vk.verify(msg, &s).is_ok())
            }
        }
    }
}

/// Decode the caller's pinned raw keys: a PEM `PUBLIC KEY` block or hex SPKI
/// DER each. Returns (keyid = sha256 of the SPKI DER, SPKI DER).
pub fn decode_pinned_keys(texts: &[String]) -> Result<Vec<(String, Vec<u8>)>> {
    texts
        .iter()
        .enumerate()
        .map(|(i, t)| {
            let der = if t.contains("-----BEGIN") {
                let body: String = t
                    .lines()
                    .filter(|l| !l.starts_with("-----"))
                    .map(str::trim)
                    .collect();
                B64.decode(body.as_bytes()).ok()
            } else {
                hex_decode(t.trim())
            }
            .ok_or_else(|| Refusal::Params(format!("dsse.keys[{i}] is neither PEM nor hex")))?;
            PublicKey::from_spki(&der)
                .map_err(|e| Refusal::Params(format!("dsse.keys[{i}]: {e}")))?;
            Ok((sha256_hex(&der), der))
        })
        .collect()
}

/// Decode the caller's extra certificates (hex DER).
pub fn decode_extra_certs(hexes: &[String]) -> Result<Vec<Vec<u8>>> {
    hexes
        .iter()
        .enumerate()
        .map(|(i, h)| {
            hex_decode(h)
                .ok_or_else(|| Refusal::Params(format!("dsse.certificates[{i}] is not hex")))
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pae_matches_the_dsse_v1_specification_example() {
        // The DSSE spec's worked example.
        assert_eq!(
            pae("http://example.com/HelloWorld", b"hello world"),
            b"DSSEv1 29 http://example.com/HelloWorld 11 hello world".to_vec()
        );
    }

    #[test]
    fn envelope_members_are_recognised_by_name_and_by_media_type() {
        assert!(is_envelope_member(
            "evidence/a/attestations/0123.dsse.json",
            None
        ));
        assert!(is_envelope_member("evidence/rel.intoto.jsonl", None));
        assert!(is_envelope_member(
            "evidence/x.json",
            Some("application/vnd.dsse.envelope.v1+json")
        ));
        assert!(!is_envelope_member(
            "payload/fw.bin",
            Some("application/octet-stream")
        ));
    }
}