Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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)
    }
}