nordisk/heimdall
guests/verify-bundle/src/model.rs
// SPDX-License-Identifier: AGPL-3.0-only
//! The bundle's documents, as Holger writes them: `core.json`, `manifest.json`,
//! `signatures/release-manifest.json`, the approval statements, and the trust
//! policy. Field names, defaults and enum spellings are Holger's
//! (`update-bundle/src/{core,manifest,approval,policy}.rs`) so the same bytes
//! parse to the same values. These structs are only READ: every digest and
//! signature is checked over the exact stored bytes, never a re-serialization.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::digest::hex_decode;
use crate::limits::{BUNDLE_SCHEMA, POLICY_SCHEMA};
use crate::refusal::{Refusal, Result};
// ---------------------------------------------------------------- core.json
#[derive(Debug, Clone, Deserialize)]
pub struct BundleCore {
pub schema_version: String,
pub bundle_id: String,
pub sequence: u64,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub expires_at: Option<DateTime<Utc>>,
pub producer: String,
pub supplier: String,
pub product: String,
pub release_version: String,
#[serde(default)]
pub supported_hardware_models: Vec<String>,
#[serde(default)]
pub supported_hardware_revisions: Vec<String>,
pub source_provenance: SourceProvenance,
#[serde(default)]
pub payloads: Vec<PayloadEntry>,
#[serde(default)]
pub evidence: Vec<EvidenceEntry>,
pub policy_result: PolicyResult,
#[serde(default)]
pub supersedes: Vec<String>,
#[serde(default)]
pub withdraws: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SourceProvenance {
pub source_kind: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PayloadEntry {
pub relative_path: String,
pub media_type: String,
pub size_bytes: u64,
pub sha256: String,
pub blake3: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EvidenceKind {
SourceReceipt,
Sbom,
Vex,
Scan,
Advisory,
Exception,
Other,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EvidenceEntry {
pub relative_path: String,
pub media_type: String,
pub size_bytes: u64,
pub sha256: String,
pub blake3: String,
pub evidence_kind: EvidenceKind,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PolicyResult {
pub policy_digest: String,
pub verdict: String,
#[serde(default = "default_true")]
pub non_production: bool,
}
fn default_true() -> bool {
true
}
impl BundleCore {
pub const PASS_VERDICT: &'static str = "pass";
pub fn parse(bytes: &[u8]) -> Result<Self> {
Ok(serde_json::from_slice(bytes)?)
}
pub fn require_pass_verdict(&self) -> Result<()> {
if self.policy_result.verdict == Self::PASS_VERDICT {
Ok(())
} else {
Err(Refusal::VerdictNotPass(self.policy_result.verdict.clone()))
}
}
pub fn schema_ok(&self) -> bool {
self.schema_version == BUNDLE_SCHEMA
}
pub fn has_evidence_kind(&self, kind: EvidenceKind) -> bool {
self.evidence.iter().any(|e| e.evidence_kind == kind)
}
}
// ------------------------------------------------- release manifest, envelope
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct MemberDigest {
pub relative_path: String,
pub size_bytes: u64,
pub sha256: String,
pub blake3: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ReleaseManifest {
pub schema_version: String,
pub bundle_id: String,
pub candidate_digest: String,
pub members: Vec<MemberDigest>,
}
impl ReleaseManifest {
pub fn parse(bytes: &[u8]) -> Result<Self> {
Ok(serde_json::from_slice(bytes)?)
}
/// Index the members by path. A path listed twice is a refusal: Holger's
/// own index would silently keep the last one, and a manifest that says
/// two different things about one member says nothing.
pub fn index(&self) -> Result<std::collections::BTreeMap<&str, &MemberDigest>> {
let mut m = std::collections::BTreeMap::new();
for d in &self.members {
if m.insert(d.relative_path.as_str(), d).is_some() {
return Err(Refusal::Malformed(format!(
"release manifest lists {:?} twice",
d.relative_path
)));
}
}
Ok(m)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct EnvelopeManifest {
pub schema_version: String,
pub bundle_id: String,
pub sequence: u64,
pub supplier: String,
pub product: String,
pub release_version: String,
pub created_at: DateTime<Utc>,
pub member_count: usize,
pub core_digest: String,
}
// ---------------------------------------------------------------- approvals
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Decision {
Approve,
Reject,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ApprovalStatement {
pub candidate_digest: String,
pub approval_role: String,
pub signer_certificate_fingerprint: String,
pub signer_subject: String,
pub approved_at: DateTime<Utc>,
pub decision: Decision,
#[serde(default)]
pub optional_comment: Option<String>,
pub signature_algorithm: String,
pub signature_path: String,
}
impl ApprovalStatement {
pub fn parse(bytes: &[u8]) -> Result<Self> {
Ok(serde_json::from_slice(bytes)?)
}
pub fn is_approve(&self) -> bool {
self.decision == Decision::Approve
}
}
// ------------------------------------------------------------- trust policy
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TrustMode {
RootedChain,
PinnedSigners,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum CertificateValidity {
#[default]
Enforce,
IgnoreUnreliableClock,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExpiredPosture {
ReadOnly,
Refuse,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovedSigner {
pub fingerprint: String,
#[serde(default)]
pub subject: Option<String>,
pub roles: Vec<String>,
}
/// `holger.bundle-trust-policy/v1`, the same RON a Holger downstream reads.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrustPolicy {
pub schema_version: String,
#[serde(default)]
pub trust_mode: Option<TrustMode>,
#[serde(default)]
pub trusted_roots: Vec<String>,
#[serde(default)]
pub certificate_validity: CertificateValidity,
#[serde(default)]
pub approved_signers: Vec<ApprovedSigner>,
#[serde(default)]
pub required_approval_roles: Vec<String>,
#[serde(default = "default_min_approvers")]
pub minimum_distinct_approvers: usize,
#[serde(default)]
pub trusted_release_signers: Vec<String>,
#[serde(default = "default_true")]
pub release_signer_must_be_distinct: bool,
#[serde(default)]
pub maximum_bundle_age_secs: Option<u64>,
#[serde(default)]
pub require_expiry: bool,
#[serde(default)]
pub on_expired_metadata: Option<ExpiredPosture>,
#[serde(default)]
pub require_sbom: bool,
#[serde(default)]
pub require_vex: bool,
#[serde(default)]
pub require_scan: bool,
#[serde(default)]
pub allowed_products: Vec<String>,
#[serde(default)]
pub allowed_models: Vec<String>,
}
fn default_min_approvers() -> usize {
2
}
impl TrustPolicy {
/// Refuse a policy that is not `holger.bundle-trust-policy/v1`.
pub fn check_schema(&self) -> Result<()> {
if self.schema_version != POLICY_SCHEMA {
return Err(Refusal::UnsupportedPolicySchema {
found: self.schema_version.clone(),
expected: POLICY_SCHEMA,
});
}
Ok(())
}
/// The anchoring posture, or a refusal (Holger's three ways to be unusable).
pub fn trust_mode(&self) -> Result<TrustMode> {
match self.trust_mode {
None => Err(Refusal::TrustModeUnset),
Some(TrustMode::RootedChain) if self.trusted_roots.is_empty() => {
Err(Refusal::TrustRootsEmpty)
}
Some(TrustMode::PinnedSigners) if !self.trusted_roots.is_empty() => {
Err(Refusal::Policy(
"trust_mode is PinnedSigners but trusted_roots is not empty — under \
pinning no chain is checked, so those roots enforce nothing"
.into(),
))
}
Some(mode) => Ok(mode),
}
}
pub fn root_certs_der(&self) -> Result<Vec<Vec<u8>>> {
self.trusted_roots
.iter()
.enumerate()
.map(|(i, h)| {
hex_decode(h)
.ok_or_else(|| Refusal::Policy(format!("trusted_roots[{i}] is not valid hex")))
})
.collect()
}
pub fn authorizes_role(&self, fingerprint: &str, role: &str) -> bool {
self.approved_signers
.iter()
.find(|s| s.fingerprint.eq_ignore_ascii_case(fingerprint))
.map(|s| s.roles.iter().any(|r| r == role))
.unwrap_or(false)
}
pub fn is_release_signer(&self, fingerprint: &str) -> bool {
self.trusted_release_signers
.iter()
.any(|f| f.eq_ignore_ascii_case(fingerprint))
}
}
// ------------------------------------------------------------ anti-rollback
/// One accepted bundle's identity (Holger's `rollback::AcceptedEntry`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcceptedEntry {
pub sequence: u64,
pub bundle_id: String,
pub core_digest: String,
}
/// Holger's `AcceptedState::check_admissible`, over a list the host hands in.
pub fn check_admissible(
accepted: &[AcceptedEntry],
core: &BundleCore,
candidate_digest: &str,
) -> Result<()> {
for e in accepted {
if e.bundle_id == core.bundle_id && e.core_digest != candidate_digest {
return Err(Refusal::BundleIdReuse(core.bundle_id.clone()));
}
}
if let Some(hi) = accepted.iter().map(|e| e.sequence).max() {
if core.sequence < hi {
return Err(Refusal::SequenceRollback {
found: core.sequence,
accepted: hi,
});
}
}
if let Some(existing) = accepted.iter().find(|e| e.sequence == core.sequence) {
if existing.core_digest == candidate_digest && existing.bundle_id == core.bundle_id {
return Ok(()); // the idempotent re-import
}
return Err(Refusal::SequenceReplay(core.sequence));
}
Ok(())
}