Gunnar

nordisk/heimdall

guests/verify-bundle/src/params.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! What the caller hands the guest beside the bundle bytes. The guest has no
//! clock, no file and no network, so everything it decides against arrives here:
//! the instant, Holger's trust policy, the target, the anti-rollback state and
//! the pinned DSSE attestation signers.
//!
//! The format is one RON document:
//!
//! ```ron
//! VerifyParams(
//!     now: "2026-10-30T12:00:00Z",
//!     policy: ( schema_version: "holger.bundle-trust-policy/v1", trust_mode: Some(PinnedSigners), ... ),
//!     target: (product: Some("acme-fw")),
//!     accepted: Some([(sequence: 4, bundle_id: "b-4", core_digest: "…")]),
//!     dsse: (trusted_signers: ["<sha256 of the attestation signer certificate DER>"]),
//!     emit: Content,
//! )
//! ```
//!
//! `policy` is the body of a Holger trust-policy file, pasted as is.
//!
//! Heimdall's job parameters are string pairs, so the same document can also
//! arrive key by key ([`VerifyParams::from_pairs`]): `now` (RFC 3339),
//! `policy` (the trust-policy RON file, verbatim), `product`, `model`,
//! `revision`, `accepted` (RON list), `dsse` (RON), `emit` (`Content`, `All`
//! or `Report`). An unknown key is a refusal, never ignored.

use chrono::{DateTime, Utc};
use serde::Deserialize;

use crate::model::{AcceptedEntry, TrustPolicy};
use crate::refusal::{Refusal, Result};

/// The largest parameter document the guest reads.
pub const MAX_PARAMS_BYTES: usize = 1024 * 1024;

#[derive(Debug, Clone, Default, Deserialize)]
pub struct TargetSpec {
    #[serde(default)]
    pub product: Option<String>,
    #[serde(default)]
    pub model: Option<String>,
    #[serde(default)]
    pub revision: Option<String>,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub struct DsseParams {
    /// SHA-256 fingerprints (hex) of the certificates whose DSSE signatures are
    /// believed. A DSSE envelope signed by anyone else is refused.
    #[serde(default)]
    pub trusted_signers: Vec<String>,
    /// Pinned raw public keys (PEM `PUBLIC KEY` or hex SPKI DER); a signature
    /// whose keyid is sha256(SPKI DER) of one of them is checked against it.
    #[serde(default)]
    pub keys: Vec<String>,
    /// Extra signer certificates (hex DER) for keyids the bundle does not carry.
    #[serde(default)]
    pub certificates: Vec<String>,
    /// Refuse a bundle that carries no DSSE envelope at all.
    #[serde(default)]
    pub require_envelope: bool,
}

/// Which outputs an accepted bundle emits.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
pub enum Emit {
    /// The report plus every `payload/**` and `evidence/**` member.
    #[default]
    Content,
    /// The report plus every member, control members included.
    All,
    /// The report only.
    Report,
}

#[derive(Debug, Clone, Deserialize)]
pub struct VerifyParams {
    pub now: DateTime<Utc>,
    pub policy: TrustPolicy,
    #[serde(default)]
    pub target: TargetSpec,
    /// `None`: no anti-rollback check (the caller does it). `Some(list)`:
    /// Holger's `check_admissible` against that list.
    #[serde(default)]
    pub accepted: Option<Vec<AcceptedEntry>>,
    #[serde(default)]
    pub dsse: DsseParams,
    #[serde(default)]
    pub emit: Emit,
}

fn ron_field<T: serde::de::DeserializeOwned>(key: &str, v: &str) -> Result<T> {
    ron::from_str(v).map_err(|e| Refusal::Params(format!("{key}: {e}")))
}

impl VerifyParams {
    /// Build the parameters from string pairs (Heimdall's `Params`).
    pub fn from_pairs<'a>(pairs: impl IntoIterator<Item = (&'a str, &'a str)>) -> Result<Self> {
        let mut now = None;
        let mut policy = None;
        let mut target = TargetSpec::default();
        let mut accepted = None;
        let mut dsse = DsseParams::default();
        let mut emit = Emit::default();
        let mut total = 0usize;
        for (k, v) in pairs {
            total = total.saturating_add(k.len() + v.len());
            if total > MAX_PARAMS_BYTES {
                return Err(Refusal::Params(format!(
                    "over the {MAX_PARAMS_BYTES}-byte cap"
                )));
            }
            match k {
                "now" => {
                    now = Some(
                        v.parse::<DateTime<Utc>>()
                            .map_err(|e| Refusal::Params(format!("now: {e}")))?,
                    )
                }
                "policy" => policy = Some(ron_field::<TrustPolicy>(k, v)?),
                "product" => target.product = Some(v.to_string()),
                "model" => target.model = Some(v.to_string()),
                "revision" => target.revision = Some(v.to_string()),
                "accepted" => accepted = Some(ron_field::<Vec<AcceptedEntry>>(k, v)?),
                "dsse" => dsse = ron_field::<DsseParams>(k, v)?,
                "emit" => emit = ron_field::<Emit>(k, v)?,
                other => return Err(Refusal::Params(format!("unknown key {other:?}"))),
            }
        }
        let p = VerifyParams {
            now: now.ok_or_else(|| Refusal::Params("missing key \"now\"".into()))?,
            policy: policy.ok_or_else(|| Refusal::Params("missing key \"policy\"".into()))?,
            target,
            accepted,
            dsse,
            emit,
        };
        p.policy.check_schema()?;
        p.policy.trust_mode()?;
        Ok(p)
    }

    pub fn from_ron_bytes(bytes: &[u8]) -> Result<Self> {
        if bytes.len() > MAX_PARAMS_BYTES {
            return Err(Refusal::Params(format!(
                "{} bytes, over the {MAX_PARAMS_BYTES}-byte cap",
                bytes.len()
            )));
        }
        let text = core::str::from_utf8(bytes).map_err(|_| Refusal::Params("not UTF-8".into()))?;
        let p: VerifyParams = ron::from_str(text).map_err(|e| Refusal::Params(e.to_string()))?;
        p.policy.check_schema()?;
        p.policy.trust_mode()?;
        Ok(p)
    }
}