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