Skip to main content

holger_intel/
sigs.rs

1//! **Scanner signatures across the airgap** (lane HOL-NCSC, NCSC P2/P3): ClamAV's
2//! signature databases (and YARA rules) carried as a signed `holger.update-bundle/v1` with
3//! `product = holger.scanner-sigs/v1` ([`SIGS_PRODUCT`]), exactly as intel is.
4//!
5//! ```text
6//!  ONLINE                                         │ AIRGAP │              SECURE
7//!  freshclam's main/daily/bytecode .cvd/.cld ─┐   │        │
8//!  a hotfix .ndb/.hdb/.ldb ───────────────────┼─ sign ─ USB ─ verify ─ floor ─ audit ─ install ─ clamd RELOAD
9//!  YARA .yar ─────────────────────────────────┘ (bundle)     (`holger bundle import`, every door)
10//! ```
11//!
12//! * **No new format, no unsigned path.** Each signature file is a payload
13//!   (`payload/clamav/<name>`, `payload/yara/<name>`); the release manifest hashes it, the
14//!   release signature covers the manifest, and the trust policy, two-person rule, trusted
15//!   clock and anti-rollback floor (`(supplier, holger.scanner-sigs/v1)`) are the bundle's.
16//! * **Only signature files.** A member is a bare file name with a ClamAV database
17//!   extension ([`CLAMAV_EXTENSIONS`]) or a YARA one ([`YARA_EXTENSIONS`]); anything else
18//!   refuses the whole bundle ([`files_of`], at build and at import).
19//! * **Overlay, by name.** An install keeps the files the last one left and writes the new
20//!   bundle's over them, so a **hotfix** is a bundle with a higher sequence carrying the
21//!   one urgent file (`hotfix-<id>.ndb`); a full refresh carries `main`, `daily` and
22//!   `bytecode` again. Each file lands by write-then-rename; `installed.json` is written
23//!   last and names every file and its SHA-256.
24
25use std::collections::BTreeMap;
26use std::path::{Path, PathBuf};
27
28use anyhow::{bail, Context, Result};
29use chrono::{DateTime, Utc};
30use holger_update_bundle::core::{PolicyResult, SourceProvenance};
31use holger_update_bundle::digest::sha256_hex;
32use holger_update_bundle::finalize::{add_approval, finalize};
33use holger_update_bundle::prepare::{PayloadInput, Preparation};
34use holger_update_bundle::schema::PAYLOAD_PREFIX;
35use holger_update_bundle::{atomic_write, pack_bundle, TrustPolicy, VerifyReport};
36use serde::{Deserialize, Serialize};
37
38use crate::bundle::SignerFiles;
39
40/// The `core.product` every scanner signature bundle carries: its own anti-rollback line.
41pub const SIGS_PRODUCT: &str = "holger.scanner-sigs/v1";
42/// The supplier a signature bundle names when the builder is not told another.
43pub const DEFAULT_SIGS_SUPPLIER: &str = "holger-scanner-sigs";
44/// ClamAV's database files (signed `.cvd`/`.cud`, freshclam's unpacked `.cld`, and the
45/// plain signature formats a hotfix is written in).
46pub const CLAMAV_EXTENSIONS: &[&str] = &[
47    "cvd", "cld", "cud", "cdb", "cfg", "crb", "fp", "sfp", "gdb", "hdb", "hsb", "hdu", "hsu",
48    "idb", "ign", "ign2", "info", "ldb", "ldu", "mdb", "mdu", "msb", "msu", "ndb", "ndu", "pdb",
49    "wdb", "ftm", "cbc", "pwdb", "imp",
50];
51/// YARA rule files.
52pub const YARA_EXTENSIONS: &[&str] = &["yar", "yara"];
53/// The install record's schema.
54pub const INSTALLED_SIGS_SCHEMA: &str = "holger.scanner-sigs.installed/v1";
55/// The longest file name a member may carry.
56pub const MAX_NAME: usize = 128;
57
58/// The engine a signature file is for.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
60#[serde(rename_all = "lowercase")]
61pub enum Engine {
62    Clamav,
63    Yara,
64}
65
66impl Engine {
67    /// The member directory (`clamav`, `yara`).
68    pub fn dir(self) -> &'static str {
69        match self {
70            Engine::Clamav => "clamav",
71            Engine::Yara => "yara",
72        }
73    }
74
75    fn extensions(self) -> &'static [&'static str] {
76        match self {
77            Engine::Clamav => CLAMAV_EXTENSIONS,
78            Engine::Yara => YARA_EXTENSIONS,
79        }
80    }
81}
82
83/// One signature file.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct SigFile {
86    pub engine: Engine,
87    pub name: String,
88    pub bytes: Vec<u8>,
89}
90
91impl SigFile {
92    /// Its path under `payload/` (`clamav/main.cvd`).
93    pub fn rel(&self) -> String {
94        format!("{}/{}", self.engine.dir(), self.name)
95    }
96}
97
98/// Pure: `name` is a file `engine` reads: a bare name (no path, no leading dot, printable
99/// ASCII `[A-Za-z0-9._-]`, at most [`MAX_NAME`]) with one of its extensions.
100pub fn check_name(engine: Engine, name: &str) -> Result<(), String> {
101    if name.is_empty()
102        || name.len() > MAX_NAME
103        || name.starts_with('.')
104        || !name
105            .chars()
106            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
107    {
108        return Err(format!(
109            "{}/{name:?} is not a bare signature file name",
110            engine.dir()
111        ));
112    }
113    let ext = name
114        .rsplit_once('.')
115        .map(|(_, e)| e.to_ascii_lowercase())
116        .unwrap_or_default();
117    if !engine.extensions().contains(&ext.as_str()) {
118        return Err(format!(
119            "{}/{name} is not a {} signature file (an extension of {})",
120            engine.dir(),
121            engine.dir(),
122            engine.extensions().join(", ")
123        ));
124    }
125    Ok(())
126}
127
128/// Pure: a member path under `payload/` as `(engine, name)`, or why it is no signature file.
129pub fn parse_rel(rel: &str) -> Result<(Engine, String), String> {
130    let (dir, name) = rel
131        .split_once('/')
132        .ok_or_else(|| format!("{rel:?} is not under clamav/ or yara/"))?;
133    let engine = match dir {
134        "clamav" => Engine::Clamav,
135        "yara" => Engine::Yara,
136        _ => return Err(format!("{rel:?} is not under clamav/ or yara/")),
137    };
138    check_name(engine, name)?;
139    Ok((engine, name.to_string()))
140}
141
142/// Every file of `clamav` and `yara` (each optional, not recursive), checked by name.
143pub fn read_dirs(clamav: Option<&Path>, yara: Option<&Path>) -> Result<Vec<SigFile>> {
144    let mut out = Vec::new();
145    for (engine, dir) in [(Engine::Clamav, clamav), (Engine::Yara, yara)] {
146        let Some(dir) = dir else { continue };
147        let mut names: Vec<PathBuf> = std::fs::read_dir(dir)
148            .with_context(|| format!("read {}", dir.display()))?
149            .flatten()
150            .map(|e| e.path())
151            .filter(|p| p.is_file())
152            .collect();
153        names.sort();
154        for p in names {
155            let name = p
156                .file_name()
157                .map(|n| n.to_string_lossy().into_owned())
158                .unwrap_or_default();
159            check_name(engine, &name).map_err(|e| anyhow::anyhow!("{}: {e}", dir.display()))?;
160            out.push(SigFile {
161                engine,
162                name,
163                bytes: std::fs::read(&p).with_context(|| format!("read {}", p.display()))?,
164            });
165        }
166    }
167    if out.is_empty() {
168        bail!("no signature file to carry: name a --clamav-dir or --yara-dir that holds one");
169    }
170    Ok(out)
171}
172
173/// Pure: the signature files a verified bundle's `members` carry. Every payload must be one
174/// ([`parse_rel`]); one that is not refuses them all.
175pub fn files_of(members: &BTreeMap<String, Vec<u8>>) -> Result<Vec<SigFile>, String> {
176    let mut out = Vec::new();
177    for (path, bytes) in members
178        .iter()
179        .filter(|(p, _)| p.starts_with(PAYLOAD_PREFIX))
180    {
181        let rel = &path[PAYLOAD_PREFIX.len()..];
182        let (engine, name) = parse_rel(rel)?;
183        out.push(SigFile {
184            engine,
185            name,
186            bytes: bytes.clone(),
187        });
188    }
189    if out.is_empty() {
190        return Err("the bundle carries no signature file".to_string());
191    }
192    Ok(out)
193}
194
195/// What a signature bundle says about itself, and who signs it.
196pub struct SigsBuild {
197    /// Monotonic per `(supplier, holger.scanner-sigs/v1)`; a hotfix takes the next one.
198    pub sequence: u64,
199    pub created_at: DateTime<Utc>,
200    pub expires_at: Option<DateTime<Utc>>,
201    pub supplier: String,
202    pub producer: String,
203    /// The policy the secure side verifies with; `finalize` checks the bundle under it.
204    pub policy: TrustPolicy,
205    pub approvals: Vec<(String, SignerFiles)>,
206    pub release: SignerFiles,
207    pub out: PathBuf,
208}
209
210/// The written bundle.
211pub struct BuiltSigs {
212    pub path: PathBuf,
213    pub bundle_id: String,
214    pub sequence: u64,
215    pub files: Vec<String>,
216    pub report: VerifyReport,
217}
218
219/// Sign `files` into a bundle at `opts.out`.
220pub fn build_sigs_bundle(files: &[SigFile], opts: SigsBuild) -> Result<BuiltSigs> {
221    if files.is_empty() {
222        bail!("a signature bundle carries at least one file");
223    }
224    for f in files {
225        check_name(f.engine, &f.name).map_err(|e| anyhow::anyhow!("{e}"))?;
226    }
227    let date = opts.created_at.format("%Y-%m-%d");
228    let bundle_id = format!("holger-scanner-sigs-{}-{date}", opts.sequence);
229    let total: u64 = files.iter().map(|f| f.bytes.len() as u64).sum();
230    let index: Vec<String> = files
231        .iter()
232        .map(|f| format!("{} {}", sha256_hex(&f.bytes), f.rel()))
233        .collect();
234    let prep = Preparation {
235        bundle_id: bundle_id.clone(),
236        sequence: opts.sequence,
237        created_at: opts.created_at,
238        expires_at: opts.expires_at,
239        producer: opts.producer.clone(),
240        supplier: opts.supplier.clone(),
241        product: SIGS_PRODUCT.to_string(),
242        release_version: format!("{date}.{}", opts.sequence),
243        supported_hardware_models: vec![],
244        supported_hardware_revisions: vec![],
245        source_provenance: SourceProvenance {
246            source_kind: "scanner-signatures".into(),
247            source_uri: None,
248            source_repository: None,
249            source_asset_path: None,
250            fetched_at: None,
251            reported_size: Some(total),
252            vendor_checksum: None,
253            vendor_signature_status: None,
254            tls_origin: None,
255            transfer_receipt_digest: None,
256        },
257        policy_result: PolicyResult {
258            policy_digest: sha256_hex(opts.policy.to_ron()?.as_bytes()),
259            advisory_snapshot_digest: Some(sha256_hex(index.join("\n").as_bytes())),
260            verdict: "pass".into(),
261            non_production: false,
262        },
263        supersedes: vec![],
264        withdraws: vec![],
265        payloads: files
266            .iter()
267            .map(|f| PayloadInput {
268                relative_path: f.rel(),
269                bytes: f.bytes.clone(),
270                media_type: "application/octet-stream".into(),
271            })
272            .collect(),
273        evidence: vec![],
274    };
275    let candidate = prep
276        .build()
277        .map_err(|e| anyhow::anyhow!("prepare the signature bundle: {e}"))?;
278    let mut members = candidate.members;
279    let now = Utc::now();
280    for (role, s) in &opts.approvals {
281        add_approval(&mut members, role, &s.cert_der, &s.key_pkcs8, None, now)
282            .map_err(|e| anyhow::anyhow!("approve the signature bundle as {role}: {e}"))?;
283    }
284    let report = finalize(
285        &mut members,
286        &opts.policy,
287        &opts.release.cert_der,
288        &opts.release.key_pkcs8,
289        now,
290    )
291    .map_err(|e| anyhow::anyhow!("sign the signature bundle: {e}"))?;
292    if let Some(p) = opts.out.parent().filter(|p| !p.as_os_str().is_empty()) {
293        std::fs::create_dir_all(p).ok();
294    }
295    pack_bundle(&members, &opts.out)
296        .map_err(|e| anyhow::anyhow!("pack the signature bundle: {e}"))?;
297    Ok(BuiltSigs {
298        path: opts.out,
299        bundle_id,
300        sequence: opts.sequence,
301        files: files.iter().map(SigFile::rel).collect(),
302        report,
303    })
304}
305
306/// One file the database holds.
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct InstalledFile {
309    /// `clamav/main.cvd`.
310    pub path: String,
311    pub sha256: String,
312    pub size: u64,
313    /// The bundle sequence that wrote it.
314    pub sequence: u64,
315}
316
317/// What the database holds now: the last bundle and every file.
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct InstalledSigs {
320    pub schema: String,
321    pub bundle_id: String,
322    pub sequence: u64,
323    pub supplier: String,
324    pub installed_at: DateTime<Utc>,
325    pub files: Vec<InstalledFile>,
326}
327
328/// **The signature database on the box**: `<root>/clamav/` (what the sandboxed clamd reads
329/// as its DatabaseDirectory), `<root>/yara/`, and `<root>/installed.json`.
330#[derive(Debug, Clone)]
331pub struct SigsDb {
332    root: PathBuf,
333}
334
335impl SigsDb {
336    pub fn open(root: impl Into<PathBuf>) -> Self {
337        Self { root: root.into() }
338    }
339
340    pub fn root(&self) -> &Path {
341        &self.root
342    }
343
344    pub fn dir(&self, engine: Engine) -> PathBuf {
345        self.root.join(engine.dir())
346    }
347
348    pub fn installed_path(&self) -> PathBuf {
349        self.root.join("installed.json")
350    }
351
352    /// What is installed; `None` before the first bundle (the image's own database serves).
353    pub fn installed(&self) -> Result<Option<InstalledSigs>> {
354        match std::fs::read(self.installed_path()) {
355            Ok(b) => Ok(Some(serde_json::from_slice(&b).context("installed.json")?)),
356            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
357            Err(e) => Err(e).context("installed.json"),
358        }
359    }
360
361    /// **Install `files` over what is there** (the overlay): each file by write-then-rename,
362    /// then `installed.json` naming the whole set.
363    pub fn install(
364        &self,
365        bundle_id: &str,
366        sequence: u64,
367        supplier: &str,
368        files: &[SigFile],
369        now: DateTime<Utc>,
370    ) -> Result<InstalledSigs> {
371        let mut set: BTreeMap<String, InstalledFile> = self
372            .installed()?
373            .map(|i| i.files.into_iter().map(|f| (f.path.clone(), f)).collect())
374            .unwrap_or_default();
375        for f in files {
376            check_name(f.engine, &f.name).map_err(|e| anyhow::anyhow!("{e}"))?;
377            let dir = self.dir(f.engine);
378            std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?;
379            let path = dir.join(&f.name);
380            atomic_write(&path, &f.bytes).map_err(|e| anyhow::anyhow!("write {}: {e}", f.rel()))?;
381            // World-readable, owner-writable: clamd reads it as its own (mapped) user, read-only.
382            #[cfg(unix)]
383            {
384                use std::os::unix::fs::PermissionsExt as _;
385                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
386                    .with_context(|| format!("chmod {}", path.display()))?;
387                std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755))
388                    .with_context(|| format!("chmod {}", dir.display()))?;
389            }
390            set.insert(
391                f.rel(),
392                InstalledFile {
393                    path: f.rel(),
394                    sha256: sha256_hex(&f.bytes),
395                    size: f.bytes.len() as u64,
396                    sequence,
397                },
398            );
399        }
400        let installed = InstalledSigs {
401            schema: INSTALLED_SIGS_SCHEMA.to_string(),
402            bundle_id: bundle_id.to_string(),
403            sequence,
404            supplier: supplier.to_string(),
405            installed_at: now,
406            files: set.into_values().collect(),
407        };
408        atomic_write(
409            &self.installed_path(),
410            &serde_json::to_vec_pretty(&installed)?,
411        )
412        .map_err(|e| anyhow::anyhow!("write installed.json: {e}"))?;
413        Ok(installed)
414    }
415}