Skip to main content

holger_intel/
import.rs

1//! The secure side's door: verify a carried intel bundle, hold it to the
2//! anti-rollback floor, record it in the audit log, and only then make its
3//! advisory set the live local database.
4//!
5//! The order is the one `holger-server bundle import` keeps (S0/B3), and for the
6//! same reasons:
7//!
8//! 1. the bundle file is read ONCE; the bytes that verify are the bytes kept;
9//! 2. the release signature is checked from the control members before any
10//!    payload is decompressed (`load_bundle_verified`);
11//! 3. the full verification runs against the trusted clock and the floor;
12//! 4. the floor decides: a lower sequence, a replay with other bytes, or an
13//!    absent floor without `first_import` is refused;
14//! 5. the audit sink must be ready, and the audit record is written BEFORE the
15//!    floor moves and before the new set goes live. A record that cannot be
16//!    written undoes what was staged, and the database stays as it was.
17
18use std::path::{Path, PathBuf};
19
20use anyhow::{bail, Context, Result};
21use chrono::{DateTime, Utc};
22use holger_update_bundle::container::load_bundle_verified;
23use holger_update_bundle::core::BundleCore;
24use holger_update_bundle::digest::sha256_hex;
25use holger_update_bundle::receipt::ImportReceipt;
26use holger_update_bundle::rollback::{AcceptedEntry, AcceptedState, Admissibility, RollbackStore};
27use holger_update_bundle::schema::{CORE_JSON, PAYLOAD_PREFIX};
28use holger_update_bundle::time::TrustedTime;
29use holger_update_bundle::verify::{verify_member_map_with_time, TargetSpec};
30use holger_update_bundle::{atomic_write, TrustPolicy};
31use serde::{Deserialize, Serialize};
32
33use crate::db::{InstalledIntel, LocalIntelDb, INSTALLED_SCHEMA};
34use crate::set::AdvisorySet;
35use crate::{ADVISORIES_REL, INTEL_PRODUCT};
36
37/// What to import, where, under which policy.
38pub struct ImportRequest<'a> {
39    pub bundle: &'a Path,
40    pub policy: &'a TrustPolicy,
41    pub db: &'a LocalIntelDb,
42    /// Establish the anti-rollback floor with this bundle because there is
43    /// none. No effect when a floor exists.
44    pub first_import: bool,
45}
46
47/// The audit record of one import.
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct ImportRecord {
50    pub bundle_id: String,
51    pub sequence: u64,
52    pub supplier: String,
53    pub snapshot_at: DateTime<Utc>,
54    pub candidate_digest: String,
55    pub bundle_sha256: String,
56    pub advisories: u64,
57    /// `new` or `idempotent-reimport`.
58    pub outcome: String,
59}
60
61impl ImportRecord {
62    /// The detail line an audit event carries.
63    pub fn detail(&self) -> String {
64        format!(
65            "bundle_id={} seq={} snapshot={} digest={} advisories={} outcome={}",
66            self.bundle_id,
67            self.sequence,
68            self.snapshot_at.format("%Y-%m-%d"),
69            self.candidate_digest,
70            self.advisories,
71            self.outcome
72        )
73    }
74}
75
76/// The audit sink an import writes through. The server's audit log in
77/// production; a test double in tests.
78pub trait ImportAudit {
79    /// Refuse when the sink is off or could not be opened. Asked before anything
80    /// is written.
81    fn ready(&self) -> Result<()>;
82    /// Write and flush the record. An error refuses the import.
83    fn record(&self, record: &ImportRecord) -> Result<()>;
84}
85
86/// The result of an import.
87#[derive(Debug, Clone)]
88pub struct ImportOutcome {
89    pub record: ImportRecord,
90    pub installed: InstalledIntel,
91    pub stored_bundle: PathBuf,
92    pub receipt: PathBuf,
93    /// The verifier's checks, the time source first.
94    pub checks: Vec<String>,
95}
96
97/// An exclusive import lock: `create_new`, removed on drop.
98struct Lock(PathBuf);
99
100impl Lock {
101    fn acquire(path: PathBuf) -> Result<Self> {
102        match std::fs::OpenOptions::new()
103            .write(true)
104            .create_new(true)
105            .open(&path)
106        {
107            Ok(mut f) => {
108                use std::io::Write;
109                let _ = writeln!(f, "pid={}", std::process::id());
110                Ok(Lock(path))
111            }
112            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => bail!(
113                "intel import refused: {} exists, so another import is running or one was \
114                 killed. Check no import runs, then remove the lock.",
115                path.display()
116            ),
117            Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
118        }
119    }
120}
121
122impl Drop for Lock {
123    fn drop(&mut self) {
124        let _ = std::fs::remove_file(&self.0);
125    }
126}
127
128fn safe_name(s: &str) -> String {
129    let out: String = s
130        .chars()
131        .map(|c| {
132            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
133                c
134            } else {
135                '_'
136            }
137        })
138        .collect();
139    if out.is_empty() || out == "." || out == ".." {
140        "_".into()
141    } else {
142        out
143    }
144}
145
146/// The schema of a [`Prepared`] import (the `import-intel` job's record).
147pub const PREPARED_SCHEMA: &str = "holger.intel-prepared/v1";
148
149/// **What verifying an intel bundle found** (lane cds4, plan row HOL-CDS-4): everything the
150/// intel store needs to take the bundle, and nothing written. [`prepare`] makes it, inside
151/// Holger's `import-intel` job; [`commit`] lands it, in the server, after the job's signed
152/// result was believed. The advisory set and the bundle's bytes travel beside it, and
153/// [`commit`] holds both to the digests named here.
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156pub struct Prepared {
157    pub schema: String,
158    /// The audit record (bundle id, sequence, supplier, snapshot, digests, outcome).
159    pub record: ImportRecord,
160    pub product: String,
161    pub release_version: String,
162    pub release_signer_fingerprint: String,
163    /// The SHA-256 of the advisory set (the bundle's own `advisory_snapshot_digest`).
164    pub advisories_sha256: String,
165    /// The verifier's checks, the time source first.
166    pub checks: Vec<String>,
167}
168
169/// A [`Prepared`] import with the bytes it names.
170#[derive(Debug, Clone)]
171pub struct PreparedImport {
172    pub prepared: Prepared,
173    /// The advisory set (`payload/advisories.json`), as the release signed it.
174    pub advisories: Vec<u8>,
175    /// The bundle's bytes, the ones that verified.
176    pub bundle: Vec<u8>,
177}
178
179/// Import one intel bundle. See the module note for the order. [`prepare`], then
180/// [`commit`], under the database's import lock.
181pub fn import(
182    req: &ImportRequest<'_>,
183    time: &dyn TrustedTime,
184    audit: &dyn ImportAudit,
185) -> Result<ImportOutcome> {
186    let db = req.db;
187    std::fs::create_dir_all(db.root()).with_context(|| format!("mkdir {}", db.root().display()))?;
188    let _lock = Lock::acquire(db.lock_path())?;
189    let p = prepare(
190        req.bundle,
191        req.policy,
192        &db.state_dir(),
193        req.first_import,
194        time,
195    )?;
196    commit_locked(db, &p, time, audit)
197}
198
199/// **Verify an intel bundle against `policy` and the anti-rollback floor in `state_dir`**
200/// (read only), writing nothing: steps 1 to 4 of the module note. The `import-intel` job runs
201/// this over its read-only input; [`import`] runs it in-process.
202pub fn prepare(
203    bundle: &Path,
204    policy: &TrustPolicy,
205    state_dir: &Path,
206    first_import: bool,
207    time: &dyn TrustedTime,
208) -> Result<PreparedImport> {
209    let raw = std::fs::read(bundle).with_context(|| format!("read bundle {}", bundle.display()))?;
210    let members = load_bundle_verified(bundle, policy)
211        .map_err(|e| anyhow::anyhow!("intel bundle verification failed: {e}"))?;
212    // The bytes that verified are the bytes kept: the file did not change under the check.
213    let again =
214        std::fs::read(bundle).with_context(|| format!("read bundle {}", bundle.display()))?;
215    if sha256_hex(&again) != sha256_hex(&raw) {
216        bail!("intel import refused: the bundle changed while it was verified");
217    }
218
219    let core = BundleCore::from_json_bytes(
220        members
221            .get(CORE_JSON)
222            .ok_or_else(|| anyhow::anyhow!("intel bundle has no core.json"))?,
223    )?;
224    if core.product != INTEL_PRODUCT {
225        bail!(
226            "intel import refused: the bundle's product is {:?}, not {INTEL_PRODUCT:?}. Import \
227             update bundles with `bundle import`.",
228            core.product
229        );
230    }
231
232    let rollback = RollbackStore::open(state_dir);
233    let floor = rollback.floor(&core.supplier, &core.product)?;
234    let state = floor
235        .accepted()
236        .cloned()
237        .unwrap_or_else(AcceptedState::empty);
238    let target = TargetSpec {
239        product: Some(INTEL_PRODUCT.to_string()),
240        model: None,
241        revision: None,
242    };
243    let report = verify_member_map_with_time(&members, policy, &target, Some(&state), time)
244        .map_err(|e| anyhow::anyhow!("intel bundle verification failed: {e}"))?;
245    let admissibility = floor
246        .check_admissible(&core, &report.candidate_digest, first_import)
247        .map_err(|e| anyhow::anyhow!("intel import refused: {e}"))?;
248
249    let payload_path = format!("{PAYLOAD_PREFIX}{ADVISORIES_REL}");
250    let payload = members
251        .get(&payload_path)
252        .ok_or_else(|| anyhow::anyhow!("intel bundle has no {payload_path}"))?;
253    let payload_sha = sha256_hex(payload);
254    if core.policy_result.advisory_snapshot_digest.as_deref() != Some(payload_sha.as_str()) {
255        bail!("intel bundle's advisory_snapshot_digest does not name its own advisory set");
256    }
257    let set = AdvisorySet::from_json_bytes(payload)?;
258    let outcome = match admissibility {
259        Admissibility::New => "new",
260        Admissibility::IdempotentReimport => "idempotent-reimport",
261    };
262    Ok(PreparedImport {
263        prepared: Prepared {
264            schema: PREPARED_SCHEMA.to_string(),
265            record: ImportRecord {
266                bundle_id: core.bundle_id.clone(),
267                sequence: core.sequence,
268                supplier: core.supplier.clone(),
269                snapshot_at: core.created_at,
270                candidate_digest: report.candidate_digest.clone(),
271                bundle_sha256: sha256_hex(&raw),
272                advisories: set.advisories.len() as u64,
273                outcome: outcome.to_string(),
274            },
275            product: core.product.clone(),
276            release_version: core.release_version.clone(),
277            release_signer_fingerprint: report.release_signer_fingerprint.clone(),
278            advisories_sha256: payload_sha,
279            checks: report.checks,
280        },
281        advisories: payload.clone(),
282        bundle: raw,
283    })
284}
285
286/// **Land a [`PreparedImport`] in `db`**: step 5 of the module note. The audit sink must be
287/// ready; the record is written BEFORE the floor moves and before the set goes live, and a
288/// record that cannot be written undoes what was staged. The bytes are held to the digests
289/// the record names, and the advisory set is not parsed here.
290pub fn commit(
291    db: &LocalIntelDb,
292    p: &PreparedImport,
293    time: &dyn TrustedTime,
294    audit: &dyn ImportAudit,
295) -> Result<ImportOutcome> {
296    std::fs::create_dir_all(db.root()).with_context(|| format!("mkdir {}", db.root().display()))?;
297    let _lock = Lock::acquire(db.lock_path())?;
298    commit_locked(db, p, time, audit)
299}
300
301fn commit_locked(
302    db: &LocalIntelDb,
303    p: &PreparedImport,
304    time: &dyn TrustedTime,
305    audit: &dyn ImportAudit,
306) -> Result<ImportOutcome> {
307    let (prep, payload, raw) = (&p.prepared, &p.advisories, &p.bundle);
308    if prep.schema != PREPARED_SCHEMA {
309        bail!(
310            "intel import refused: the prepared import has schema {:?}, not {PREPARED_SCHEMA:?}",
311            prep.schema
312        );
313    }
314    if prep.product != INTEL_PRODUCT {
315        bail!(
316            "intel import refused: the prepared import is for {:?}, not {INTEL_PRODUCT:?}",
317            prep.product
318        );
319    }
320    if sha256_hex(payload) != prep.advisories_sha256 {
321        bail!("intel import refused: the advisory set is not the one the verified bundle names");
322    }
323    if sha256_hex(raw) != prep.record.bundle_sha256 {
324        bail!("intel import refused: the bundle's bytes are not the ones that verified");
325    }
326    let record = prep.record.clone();
327    let id = &record.bundle_id;
328
329    audit.ready().map_err(|e| {
330        anyhow::anyhow!(
331            "intel import refused: the audit log is off or could not be opened ({e:#}), and an \
332             import that leaves no audit record is not allowed"
333        )
334    })?;
335
336    let live_before = db.installed().ok().flatten();
337    let gen_file = LocalIntelDb::generation_file(record.sequence);
338    let gen_path = db.stage_generation(record.sequence, payload)?;
339    let stored = db
340        .bundles_dir()
341        .join(format!("{}.holger.znippy", safe_name(id)));
342    let bundle_was_there = stored.exists();
343    if !bundle_was_there {
344        atomic_write(&stored, raw).map_err(|e| anyhow::anyhow!("store intel bundle: {e}"))?;
345    }
346    let undo = || {
347        if live_before.as_ref().map(|m| m.advisories_file.as_str()) != Some(gen_file.as_str()) {
348            let _ = std::fs::remove_file(&gen_path);
349        }
350        if !bundle_was_there {
351            let _ = std::fs::remove_file(&stored);
352        }
353    };
354
355    if let Err(e) = audit.record(&record) {
356        undo();
357        bail!(
358            "intel import refused: its audit record could not be written ({e:#}). No audit record \
359             means no import; the advisory database and the anti-rollback floor are unchanged."
360        );
361    }
362
363    let now = time.now().unwrap_or_else(|_| Utc::now());
364    let rollback = RollbackStore::open(db.state_dir());
365    rollback.record(
366        &record.supplier,
367        &prep.product,
368        &AcceptedEntry {
369            sequence: record.sequence,
370            bundle_id: id.clone(),
371            core_digest: record.candidate_digest.clone(),
372            accepted_at: now,
373        },
374    )?;
375    let installed = InstalledIntel {
376        schema: INSTALLED_SCHEMA.to_string(),
377        bundle_id: id.clone(),
378        sequence: record.sequence,
379        supplier: record.supplier.clone(),
380        snapshot_at: record.snapshot_at,
381        imported_at: now,
382        candidate_digest: record.candidate_digest.clone(),
383        release_signer_fingerprint: prep.release_signer_fingerprint.clone(),
384        advisories_file: gen_file,
385        advisories_sha256: prep.advisories_sha256.clone(),
386        advisories: record.advisories,
387    };
388    db.activate(&installed)?;
389
390    let receipt = ImportReceipt {
391        bundle_id: id.clone(),
392        sequence: record.sequence,
393        supplier: record.supplier.clone(),
394        product: prep.product.clone(),
395        release_version: prep.release_version.clone(),
396        candidate_digest: record.candidate_digest.clone(),
397        release_signer_fingerprint: prep.release_signer_fingerprint.clone(),
398        imported_at: now,
399        outcome: record.outcome.clone(),
400        bundle_sha256: record.bundle_sha256.clone(),
401    };
402    std::fs::create_dir_all(db.receipts_dir()).ok();
403    let receipt_path = db.receipts_dir().join(format!("{}.json", safe_name(id)));
404    std::fs::write(&receipt_path, serde_json::to_vec_pretty(&receipt)?)?;
405
406    Ok(ImportOutcome {
407        record,
408        installed,
409        stored_bundle: stored,
410        receipt: receipt_path,
411        checks: prep.checks.clone(),
412    })
413}