intel/src/import.rs
//! The secure side's door: verify a carried intel bundle, hold it to the
//! anti-rollback floor, record it in the audit log, and only then make its
//! advisory set the live local database.
//!
//! The order is the one `holger-server bundle import` keeps (S0/B3), and for the
//! same reasons:
//!
//! 1. the bundle file is read ONCE; the bytes that verify are the bytes kept;
//! 2. the release signature is checked from the control members before any
//! payload is decompressed (`load_bundle_verified`);
//! 3. the full verification runs against the trusted clock and the floor;
//! 4. the floor decides: a lower sequence, a replay with other bytes, or an
//! absent floor without `first_import` is refused;
//! 5. the audit sink must be ready, and the audit record is written BEFORE the
//! floor moves and before the new set goes live. A record that cannot be
//! written undoes what was staged, and the database stays as it was.
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use chrono::{DateTime, Utc};
use holger_update_bundle::container::load_bundle_verified;
use holger_update_bundle::core::BundleCore;
use holger_update_bundle::digest::sha256_hex;
use holger_update_bundle::receipt::ImportReceipt;
use holger_update_bundle::rollback::{AcceptedEntry, AcceptedState, Admissibility, RollbackStore};
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::{atomic_write, TrustPolicy};
use serde::{Deserialize, Serialize};
use crate::db::{InstalledIntel, LocalIntelDb, INSTALLED_SCHEMA};
use crate::set::AdvisorySet;
use crate::{ADVISORIES_REL, INTEL_PRODUCT};
/// What to import, where, under which policy.
pub struct ImportRequest<'a> {
pub bundle: &'a Path,
pub policy: &'a TrustPolicy,
pub db: &'a LocalIntelDb,
/// Establish the anti-rollback floor with this bundle because there is
/// none. No effect when a floor exists.
pub first_import: bool,
}
/// The audit record of one import.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ImportRecord {
pub bundle_id: String,
pub sequence: u64,
pub supplier: String,
pub snapshot_at: DateTime<Utc>,
pub candidate_digest: String,
pub bundle_sha256: String,
pub advisories: u64,
/// `new` or `idempotent-reimport`.
pub outcome: String,
}
impl ImportRecord {
/// The detail line an audit event carries.
pub fn detail(&self) -> String {
format!(
"bundle_id={} seq={} snapshot={} digest={} advisories={} outcome={}",
self.bundle_id,
self.sequence,
self.snapshot_at.format("%Y-%m-%d"),
self.candidate_digest,
self.advisories,
self.outcome
)
}
}
/// The audit sink an import writes through. The server's audit log in
/// production; a test double in tests.
pub trait ImportAudit {
/// Refuse when the sink is off or could not be opened. Asked before anything
/// is written.
fn ready(&self) -> Result<()>;
/// Write and flush the record. An error refuses the import.
fn record(&self, record: &ImportRecord) -> Result<()>;
}
/// The result of an import.
#[derive(Debug, Clone)]
pub struct ImportOutcome {
pub record: ImportRecord,
pub installed: InstalledIntel,
pub stored_bundle: PathBuf,
pub receipt: PathBuf,
/// The verifier's checks, the time source first.
pub checks: Vec<String>,
}
/// An exclusive import lock: `create_new`, removed on drop.
struct Lock(PathBuf);
impl Lock {
fn acquire(path: PathBuf) -> Result<Self> {
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut f) => {
use std::io::Write;
let _ = writeln!(f, "pid={}", std::process::id());
Ok(Lock(path))
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => bail!(
"intel import refused: {} exists, so another import is running or one was \
killed. Check no import runs, then remove the lock.",
path.display()
),
Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
}
}
}
impl Drop for Lock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn safe_name(s: &str) -> String {
let out: String = s
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'_'
}
})
.collect();
if out.is_empty() || out == "." || out == ".." {
"_".into()
} else {
out
}
}
/// The schema of a [`Prepared`] import (the `import-intel` job's record).
pub const PREPARED_SCHEMA: &str = "holger.intel-prepared/v1";
/// **What verifying an intel bundle found** (lane cds4, plan row HOL-CDS-4): everything the
/// intel store needs to take the bundle, and nothing written. [`prepare`] makes it, inside
/// Holger's `import-intel` job; [`commit`] lands it, in the server, after the job's signed
/// result was believed. The advisory set and the bundle's bytes travel beside it, and
/// [`commit`] holds both to the digests named here.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Prepared {
pub schema: String,
/// The audit record (bundle id, sequence, supplier, snapshot, digests, outcome).
pub record: ImportRecord,
pub product: String,
pub release_version: String,
pub release_signer_fingerprint: String,
/// The SHA-256 of the advisory set (the bundle's own `advisory_snapshot_digest`).
pub advisories_sha256: String,
/// The verifier's checks, the time source first.
pub checks: Vec<String>,
}
/// A [`Prepared`] import with the bytes it names.
#[derive(Debug, Clone)]
pub struct PreparedImport {
pub prepared: Prepared,
/// The advisory set (`payload/advisories.json`), as the release signed it.
pub advisories: Vec<u8>,
/// The bundle's bytes, the ones that verified.
pub bundle: Vec<u8>,
}
/// Import one intel bundle. See the module note for the order. [`prepare`], then
/// [`commit`], under the database's import lock.
pub fn import(
req: &ImportRequest<'_>,
time: &dyn TrustedTime,
audit: &dyn ImportAudit,
) -> Result<ImportOutcome> {
let db = req.db;
std::fs::create_dir_all(db.root()).with_context(|| format!("mkdir {}", db.root().display()))?;
let _lock = Lock::acquire(db.lock_path())?;
let p = prepare(
req.bundle,
req.policy,
&db.state_dir(),
req.first_import,
time,
)?;
commit_locked(db, &p, time, audit)
}
/// **Verify an intel bundle against `policy` and the anti-rollback floor in `state_dir`**
/// (read only), writing nothing: steps 1 to 4 of the module note. The `import-intel` job runs
/// this over its read-only input; [`import`] runs it in-process.
pub fn prepare(
bundle: &Path,
policy: &TrustPolicy,
state_dir: &Path,
first_import: bool,
time: &dyn TrustedTime,
) -> Result<PreparedImport> {
let raw = std::fs::read(bundle).with_context(|| format!("read bundle {}", bundle.display()))?;
let members = load_bundle_verified(bundle, policy)
.map_err(|e| anyhow::anyhow!("intel bundle verification failed: {e}"))?;
// The bytes that verified are the bytes kept: the file did not change under the check.
let again =
std::fs::read(bundle).with_context(|| format!("read bundle {}", bundle.display()))?;
if sha256_hex(&again) != sha256_hex(&raw) {
bail!("intel import refused: the bundle changed while it was verified");
}
let core = BundleCore::from_json_bytes(
members
.get(CORE_JSON)
.ok_or_else(|| anyhow::anyhow!("intel bundle has no core.json"))?,
)?;
if core.product != INTEL_PRODUCT {
bail!(
"intel import refused: the bundle's product is {:?}, not {INTEL_PRODUCT:?}. Import \
update bundles with `bundle import`.",
core.product
);
}
let rollback = RollbackStore::open(state_dir);
let floor = rollback.floor(&core.supplier, &core.product)?;
let state = floor
.accepted()
.cloned()
.unwrap_or_else(AcceptedState::empty);
let target = TargetSpec {
product: Some(INTEL_PRODUCT.to_string()),
model: None,
revision: None,
};
let report = verify_member_map_with_time(&members, policy, &target, Some(&state), time)
.map_err(|e| anyhow::anyhow!("intel bundle verification failed: {e}"))?;
let admissibility = floor
.check_admissible(&core, &report.candidate_digest, first_import)
.map_err(|e| anyhow::anyhow!("intel import refused: {e}"))?;
let payload_path = format!("{PAYLOAD_PREFIX}{ADVISORIES_REL}");
let payload = members
.get(&payload_path)
.ok_or_else(|| anyhow::anyhow!("intel bundle has no {payload_path}"))?;
let payload_sha = sha256_hex(payload);
if core.policy_result.advisory_snapshot_digest.as_deref() != Some(payload_sha.as_str()) {
bail!("intel bundle's advisory_snapshot_digest does not name its own advisory set");
}
let set = AdvisorySet::from_json_bytes(payload)?;
let outcome = match admissibility {
Admissibility::New => "new",
Admissibility::IdempotentReimport => "idempotent-reimport",
};
Ok(PreparedImport {
prepared: Prepared {
schema: PREPARED_SCHEMA.to_string(),
record: ImportRecord {
bundle_id: core.bundle_id.clone(),
sequence: core.sequence,
supplier: core.supplier.clone(),
snapshot_at: core.created_at,
candidate_digest: report.candidate_digest.clone(),
bundle_sha256: sha256_hex(&raw),
advisories: set.advisories.len() as u64,
outcome: outcome.to_string(),
},
product: core.product.clone(),
release_version: core.release_version.clone(),
release_signer_fingerprint: report.release_signer_fingerprint.clone(),
advisories_sha256: payload_sha,
checks: report.checks,
},
advisories: payload.clone(),
bundle: raw,
})
}
/// **Land a [`PreparedImport`] in `db`**: step 5 of the module note. The audit sink must be
/// ready; the record is written BEFORE the floor moves and before the set goes live, and a
/// record that cannot be written undoes what was staged. The bytes are held to the digests
/// the record names, and the advisory set is not parsed here.
pub fn commit(
db: &LocalIntelDb,
p: &PreparedImport,
time: &dyn TrustedTime,
audit: &dyn ImportAudit,
) -> Result<ImportOutcome> {
std::fs::create_dir_all(db.root()).with_context(|| format!("mkdir {}", db.root().display()))?;
let _lock = Lock::acquire(db.lock_path())?;
commit_locked(db, p, time, audit)
}
fn commit_locked(
db: &LocalIntelDb,
p: &PreparedImport,
time: &dyn TrustedTime,
audit: &dyn ImportAudit,
) -> Result<ImportOutcome> {
let (prep, payload, raw) = (&p.prepared, &p.advisories, &p.bundle);
if prep.schema != PREPARED_SCHEMA {
bail!(
"intel import refused: the prepared import has schema {:?}, not {PREPARED_SCHEMA:?}",
prep.schema
);
}
if prep.product != INTEL_PRODUCT {
bail!(
"intel import refused: the prepared import is for {:?}, not {INTEL_PRODUCT:?}",
prep.product
);
}
if sha256_hex(payload) != prep.advisories_sha256 {
bail!("intel import refused: the advisory set is not the one the verified bundle names");
}
if sha256_hex(raw) != prep.record.bundle_sha256 {
bail!("intel import refused: the bundle's bytes are not the ones that verified");
}
let record = prep.record.clone();
let id = &record.bundle_id;
audit.ready().map_err(|e| {
anyhow::anyhow!(
"intel import refused: the audit log is off or could not be opened ({e:#}), and an \
import that leaves no audit record is not allowed"
)
})?;
let live_before = db.installed().ok().flatten();
let gen_file = LocalIntelDb::generation_file(record.sequence);
let gen_path = db.stage_generation(record.sequence, payload)?;
let stored = db
.bundles_dir()
.join(format!("{}.holger.znippy", safe_name(id)));
let bundle_was_there = stored.exists();
if !bundle_was_there {
atomic_write(&stored, raw).map_err(|e| anyhow::anyhow!("store intel bundle: {e}"))?;
}
let undo = || {
if live_before.as_ref().map(|m| m.advisories_file.as_str()) != Some(gen_file.as_str()) {
let _ = std::fs::remove_file(&gen_path);
}
if !bundle_was_there {
let _ = std::fs::remove_file(&stored);
}
};
if let Err(e) = audit.record(&record) {
undo();
bail!(
"intel import refused: its audit record could not be written ({e:#}). No audit record \
means no import; the advisory database and the anti-rollback floor are unchanged."
);
}
let now = time.now().unwrap_or_else(|_| Utc::now());
let rollback = RollbackStore::open(db.state_dir());
rollback.record(
&record.supplier,
&prep.product,
&AcceptedEntry {
sequence: record.sequence,
bundle_id: id.clone(),
core_digest: record.candidate_digest.clone(),
accepted_at: now,
},
)?;
let installed = InstalledIntel {
schema: INSTALLED_SCHEMA.to_string(),
bundle_id: id.clone(),
sequence: record.sequence,
supplier: record.supplier.clone(),
snapshot_at: record.snapshot_at,
imported_at: now,
candidate_digest: record.candidate_digest.clone(),
release_signer_fingerprint: prep.release_signer_fingerprint.clone(),
advisories_file: gen_file,
advisories_sha256: prep.advisories_sha256.clone(),
advisories: record.advisories,
};
db.activate(&installed)?;
let receipt = ImportReceipt {
bundle_id: id.clone(),
sequence: record.sequence,
supplier: record.supplier.clone(),
product: prep.product.clone(),
release_version: prep.release_version.clone(),
candidate_digest: record.candidate_digest.clone(),
release_signer_fingerprint: prep.release_signer_fingerprint.clone(),
imported_at: now,
outcome: record.outcome.clone(),
bundle_sha256: record.bundle_sha256.clone(),
};
std::fs::create_dir_all(db.receipts_dir()).ok();
let receipt_path = db.receipts_dir().join(format!("{}.json", safe_name(id)));
std::fs::write(&receipt_path, serde_json::to_vec_pretty(&receipt)?)?;
Ok(ImportOutcome {
record,
installed,
stored_bundle: stored,
receipt: receipt_path,
checks: prep.checks.clone(),
})
}