intel/src/sigs.rs
//! **Scanner signatures across the airgap** (lane HOL-NCSC, NCSC P2/P3): ClamAV's
//! signature databases (and YARA rules) carried as a signed `holger.update-bundle/v1` with
//! `product = holger.scanner-sigs/v1` ([`SIGS_PRODUCT`]), exactly as intel is.
//!
//! ```text
//! ONLINE │ AIRGAP │ SECURE
//! freshclam's main/daily/bytecode .cvd/.cld ─┐ │ │
//! a hotfix .ndb/.hdb/.ldb ───────────────────┼─ sign ─ USB ─ verify ─ floor ─ audit ─ install ─ clamd RELOAD
//! YARA .yar ─────────────────────────────────┘ (bundle) (`holger bundle import`, every door)
//! ```
//!
//! * **No new format, no unsigned path.** Each signature file is a payload
//! (`payload/clamav/<name>`, `payload/yara/<name>`); the release manifest hashes it, the
//! release signature covers the manifest, and the trust policy, two-person rule, trusted
//! clock and anti-rollback floor (`(supplier, holger.scanner-sigs/v1)`) are the bundle's.
//! * **Only signature files.** A member is a bare file name with a ClamAV database
//! extension ([`CLAMAV_EXTENSIONS`]) or a YARA one ([`YARA_EXTENSIONS`]); anything else
//! refuses the whole bundle ([`files_of`], at build and at import).
//! * **Overlay, by name.** An install keeps the files the last one left and writes the new
//! bundle's over them, so a **hotfix** is a bundle with a higher sequence carrying the
//! one urgent file (`hotfix-<id>.ndb`); a full refresh carries `main`, `daily` and
//! `bytecode` again. Each file lands by write-then-rename; `installed.json` is written
//! last and names every file and its SHA-256.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use chrono::{DateTime, Utc};
use holger_update_bundle::core::{PolicyResult, SourceProvenance};
use holger_update_bundle::digest::sha256_hex;
use holger_update_bundle::finalize::{add_approval, finalize};
use holger_update_bundle::prepare::{PayloadInput, Preparation};
use holger_update_bundle::schema::PAYLOAD_PREFIX;
use holger_update_bundle::{atomic_write, pack_bundle, TrustPolicy, VerifyReport};
use serde::{Deserialize, Serialize};
use crate::bundle::SignerFiles;
/// The `core.product` every scanner signature bundle carries: its own anti-rollback line.
pub const SIGS_PRODUCT: &str = "holger.scanner-sigs/v1";
/// The supplier a signature bundle names when the builder is not told another.
pub const DEFAULT_SIGS_SUPPLIER: &str = "holger-scanner-sigs";
/// ClamAV's database files (signed `.cvd`/`.cud`, freshclam's unpacked `.cld`, and the
/// plain signature formats a hotfix is written in).
pub const CLAMAV_EXTENSIONS: &[&str] = &[
"cvd", "cld", "cud", "cdb", "cfg", "crb", "fp", "sfp", "gdb", "hdb", "hsb", "hdu", "hsu",
"idb", "ign", "ign2", "info", "ldb", "ldu", "mdb", "mdu", "msb", "msu", "ndb", "ndu", "pdb",
"wdb", "ftm", "cbc", "pwdb", "imp",
];
/// YARA rule files.
pub const YARA_EXTENSIONS: &[&str] = &["yar", "yara"];
/// The install record's schema.
pub const INSTALLED_SIGS_SCHEMA: &str = "holger.scanner-sigs.installed/v1";
/// The longest file name a member may carry.
pub const MAX_NAME: usize = 128;
/// The engine a signature file is for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Engine {
Clamav,
Yara,
}
impl Engine {
/// The member directory (`clamav`, `yara`).
pub fn dir(self) -> &'static str {
match self {
Engine::Clamav => "clamav",
Engine::Yara => "yara",
}
}
fn extensions(self) -> &'static [&'static str] {
match self {
Engine::Clamav => CLAMAV_EXTENSIONS,
Engine::Yara => YARA_EXTENSIONS,
}
}
}
/// One signature file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SigFile {
pub engine: Engine,
pub name: String,
pub bytes: Vec<u8>,
}
impl SigFile {
/// Its path under `payload/` (`clamav/main.cvd`).
pub fn rel(&self) -> String {
format!("{}/{}", self.engine.dir(), self.name)
}
}
/// Pure: `name` is a file `engine` reads: a bare name (no path, no leading dot, printable
/// ASCII `[A-Za-z0-9._-]`, at most [`MAX_NAME`]) with one of its extensions.
pub fn check_name(engine: Engine, name: &str) -> Result<(), String> {
if name.is_empty()
|| name.len() > MAX_NAME
|| name.starts_with('.')
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
{
return Err(format!(
"{}/{name:?} is not a bare signature file name",
engine.dir()
));
}
let ext = name
.rsplit_once('.')
.map(|(_, e)| e.to_ascii_lowercase())
.unwrap_or_default();
if !engine.extensions().contains(&ext.as_str()) {
return Err(format!(
"{}/{name} is not a {} signature file (an extension of {})",
engine.dir(),
engine.dir(),
engine.extensions().join(", ")
));
}
Ok(())
}
/// Pure: a member path under `payload/` as `(engine, name)`, or why it is no signature file.
pub fn parse_rel(rel: &str) -> Result<(Engine, String), String> {
let (dir, name) = rel
.split_once('/')
.ok_or_else(|| format!("{rel:?} is not under clamav/ or yara/"))?;
let engine = match dir {
"clamav" => Engine::Clamav,
"yara" => Engine::Yara,
_ => return Err(format!("{rel:?} is not under clamav/ or yara/")),
};
check_name(engine, name)?;
Ok((engine, name.to_string()))
}
/// Every file of `clamav` and `yara` (each optional, not recursive), checked by name.
pub fn read_dirs(clamav: Option<&Path>, yara: Option<&Path>) -> Result<Vec<SigFile>> {
let mut out = Vec::new();
for (engine, dir) in [(Engine::Clamav, clamav), (Engine::Yara, yara)] {
let Some(dir) = dir else { continue };
let mut names: Vec<PathBuf> = std::fs::read_dir(dir)
.with_context(|| format!("read {}", dir.display()))?
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file())
.collect();
names.sort();
for p in names {
let name = p
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
check_name(engine, &name).map_err(|e| anyhow::anyhow!("{}: {e}", dir.display()))?;
out.push(SigFile {
engine,
name,
bytes: std::fs::read(&p).with_context(|| format!("read {}", p.display()))?,
});
}
}
if out.is_empty() {
bail!("no signature file to carry: name a --clamav-dir or --yara-dir that holds one");
}
Ok(out)
}
/// Pure: the signature files a verified bundle's `members` carry. Every payload must be one
/// ([`parse_rel`]); one that is not refuses them all.
pub fn files_of(members: &BTreeMap<String, Vec<u8>>) -> Result<Vec<SigFile>, String> {
let mut out = Vec::new();
for (path, bytes) in members
.iter()
.filter(|(p, _)| p.starts_with(PAYLOAD_PREFIX))
{
let rel = &path[PAYLOAD_PREFIX.len()..];
let (engine, name) = parse_rel(rel)?;
out.push(SigFile {
engine,
name,
bytes: bytes.clone(),
});
}
if out.is_empty() {
return Err("the bundle carries no signature file".to_string());
}
Ok(out)
}
/// What a signature bundle says about itself, and who signs it.
pub struct SigsBuild {
/// Monotonic per `(supplier, holger.scanner-sigs/v1)`; a hotfix takes the next one.
pub sequence: u64,
pub created_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
pub supplier: String,
pub producer: String,
/// The policy the secure side verifies with; `finalize` checks the bundle under it.
pub policy: TrustPolicy,
pub approvals: Vec<(String, SignerFiles)>,
pub release: SignerFiles,
pub out: PathBuf,
}
/// The written bundle.
pub struct BuiltSigs {
pub path: PathBuf,
pub bundle_id: String,
pub sequence: u64,
pub files: Vec<String>,
pub report: VerifyReport,
}
/// Sign `files` into a bundle at `opts.out`.
pub fn build_sigs_bundle(files: &[SigFile], opts: SigsBuild) -> Result<BuiltSigs> {
if files.is_empty() {
bail!("a signature bundle carries at least one file");
}
for f in files {
check_name(f.engine, &f.name).map_err(|e| anyhow::anyhow!("{e}"))?;
}
let date = opts.created_at.format("%Y-%m-%d");
let bundle_id = format!("holger-scanner-sigs-{}-{date}", opts.sequence);
let total: u64 = files.iter().map(|f| f.bytes.len() as u64).sum();
let index: Vec<String> = files
.iter()
.map(|f| format!("{} {}", sha256_hex(&f.bytes), f.rel()))
.collect();
let prep = Preparation {
bundle_id: bundle_id.clone(),
sequence: opts.sequence,
created_at: opts.created_at,
expires_at: opts.expires_at,
producer: opts.producer.clone(),
supplier: opts.supplier.clone(),
product: SIGS_PRODUCT.to_string(),
release_version: format!("{date}.{}", opts.sequence),
supported_hardware_models: vec![],
supported_hardware_revisions: vec![],
source_provenance: SourceProvenance {
source_kind: "scanner-signatures".into(),
source_uri: None,
source_repository: None,
source_asset_path: None,
fetched_at: None,
reported_size: Some(total),
vendor_checksum: None,
vendor_signature_status: None,
tls_origin: None,
transfer_receipt_digest: None,
},
policy_result: PolicyResult {
policy_digest: sha256_hex(opts.policy.to_ron()?.as_bytes()),
advisory_snapshot_digest: Some(sha256_hex(index.join("\n").as_bytes())),
verdict: "pass".into(),
non_production: false,
},
supersedes: vec![],
withdraws: vec![],
payloads: files
.iter()
.map(|f| PayloadInput {
relative_path: f.rel(),
bytes: f.bytes.clone(),
media_type: "application/octet-stream".into(),
})
.collect(),
evidence: vec![],
};
let candidate = prep
.build()
.map_err(|e| anyhow::anyhow!("prepare the signature bundle: {e}"))?;
let mut members = candidate.members;
let now = Utc::now();
for (role, s) in &opts.approvals {
add_approval(&mut members, role, &s.cert_der, &s.key_pkcs8, None, now)
.map_err(|e| anyhow::anyhow!("approve the signature bundle as {role}: {e}"))?;
}
let report = finalize(
&mut members,
&opts.policy,
&opts.release.cert_der,
&opts.release.key_pkcs8,
now,
)
.map_err(|e| anyhow::anyhow!("sign the signature bundle: {e}"))?;
if let Some(p) = opts.out.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(p).ok();
}
pack_bundle(&members, &opts.out)
.map_err(|e| anyhow::anyhow!("pack the signature bundle: {e}"))?;
Ok(BuiltSigs {
path: opts.out,
bundle_id,
sequence: opts.sequence,
files: files.iter().map(SigFile::rel).collect(),
report,
})
}
/// One file the database holds.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstalledFile {
/// `clamav/main.cvd`.
pub path: String,
pub sha256: String,
pub size: u64,
/// The bundle sequence that wrote it.
pub sequence: u64,
}
/// What the database holds now: the last bundle and every file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstalledSigs {
pub schema: String,
pub bundle_id: String,
pub sequence: u64,
pub supplier: String,
pub installed_at: DateTime<Utc>,
pub files: Vec<InstalledFile>,
}
/// **The signature database on the box**: `<root>/clamav/` (what the sandboxed clamd reads
/// as its DatabaseDirectory), `<root>/yara/`, and `<root>/installed.json`.
#[derive(Debug, Clone)]
pub struct SigsDb {
root: PathBuf,
}
impl SigsDb {
pub fn open(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn dir(&self, engine: Engine) -> PathBuf {
self.root.join(engine.dir())
}
pub fn installed_path(&self) -> PathBuf {
self.root.join("installed.json")
}
/// What is installed; `None` before the first bundle (the image's own database serves).
pub fn installed(&self) -> Result<Option<InstalledSigs>> {
match std::fs::read(self.installed_path()) {
Ok(b) => Ok(Some(serde_json::from_slice(&b).context("installed.json")?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).context("installed.json"),
}
}
/// **Install `files` over what is there** (the overlay): each file by write-then-rename,
/// then `installed.json` naming the whole set.
pub fn install(
&self,
bundle_id: &str,
sequence: u64,
supplier: &str,
files: &[SigFile],
now: DateTime<Utc>,
) -> Result<InstalledSigs> {
let mut set: BTreeMap<String, InstalledFile> = self
.installed()?
.map(|i| i.files.into_iter().map(|f| (f.path.clone(), f)).collect())
.unwrap_or_default();
for f in files {
check_name(f.engine, &f.name).map_err(|e| anyhow::anyhow!("{e}"))?;
let dir = self.dir(f.engine);
std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?;
let path = dir.join(&f.name);
atomic_write(&path, &f.bytes).map_err(|e| anyhow::anyhow!("write {}: {e}", f.rel()))?;
// World-readable, owner-writable: clamd reads it as its own (mapped) user, read-only.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
.with_context(|| format!("chmod {}", path.display()))?;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755))
.with_context(|| format!("chmod {}", dir.display()))?;
}
set.insert(
f.rel(),
InstalledFile {
path: f.rel(),
sha256: sha256_hex(&f.bytes),
size: f.bytes.len() as u64,
sequence,
},
);
}
let installed = InstalledSigs {
schema: INSTALLED_SIGS_SCHEMA.to_string(),
bundle_id: bundle_id.to_string(),
sequence,
supplier: supplier.to_string(),
installed_at: now,
files: set.into_values().collect(),
};
atomic_write(
&self.installed_path(),
&serde_json::to_vec_pretty(&installed)?,
)
.map_err(|e| anyhow::anyhow!("write installed.json: {e}"))?;
Ok(installed)
}
}