Gunnar

nordisk/heimdall

crates/heimdall-run/src/verify.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! The caller's side of `heimdall verify-result`.
//!
//! **Heimdall is a defensive sandbox**, and this is how a caller trusts its
//! verdict: the result file must decode, carry the pinned key id and boot id,
//! verify under the pinned Ed25519 key, optionally match the caller's job
//! ticket (job id + nonce), and every output it lists must match the files on
//! disk with nothing extra beside them.

use std::io::Read;
use std::path::{Path, PathBuf};

use heimdall_core::{
    check_output_files, JobTicket, OutputError, PinError, PinnedKey, SignedResult, Verifier,
    VerifyError, MAX_WIRE,
};

use crate::outdir::FILES_DIR;

/// A pinned-key file larger than this is refused.
pub const MAX_KEY_FILE_BYTES: u64 = 4096;

/// The prefix of a pinned key record (`PinnedKey::to_text`).
pub const PIN_PREFIX: &str = "heimdall-boot-key ";

/// Why verification did not succeed.
#[derive(Debug, thiserror::Error)]
pub enum VerifyFileError {
    /// The result or key file could not be read at all.
    #[error("cannot read {path}: {err}")]
    Read { path: PathBuf, err: String },
    /// The pinned key is not a valid record.
    #[error("pinned key: {0}")]
    Key(#[from] PinError),
    /// The result is not authentic or not for this job.
    #[error("result refused: {0}")]
    Verify(#[from] VerifyError),
    /// The outputs on disk do not match the result.
    #[error("outputs refused: {0}")]
    Outputs(#[from] OutputError),
}

impl VerifyFileError {
    /// True when the inputs were readable but the result is not trustworthy.
    pub fn is_rejection(&self) -> bool {
        matches!(
            self,
            VerifyFileError::Verify(_) | VerifyFileError::Outputs(_)
        )
    }
}

/// A result that verified.
#[derive(Debug, Clone)]
pub struct Verified {
    pub signed: SignedResult,
    /// The dir whose files were checked, if any.
    pub files_dir: Option<PathBuf>,
    /// How many output files were checked.
    pub files_checked: usize,
}

fn read_capped(path: &Path, cap: u64) -> Result<Vec<u8>, VerifyFileError> {
    let rerr = |e: String| VerifyFileError::Read {
        path: path.to_path_buf(),
        err: e,
    };
    let meta = std::fs::metadata(path).map_err(|e| rerr(e.to_string()))?;
    if !meta.is_file() {
        return Err(rerr("not a regular file".into()));
    }
    let f = std::fs::File::open(path).map_err(|e| rerr(e.to_string()))?;
    let mut buf = Vec::new();
    f.take(cap.saturating_add(1))
        .read_to_end(&mut buf)
        .map_err(|e| rerr(e.to_string()))?;
    if buf.len() as u64 > cap {
        return Err(rerr(format!("larger than {cap} bytes")));
    }
    Ok(buf)
}

/// The pinned key from `--key`: either the record itself
/// (`heimdall-boot-key v1 ...`) or the path of a file holding it.
pub fn load_pinned_key(arg: &str) -> Result<PinnedKey, VerifyFileError> {
    if arg.starts_with(PIN_PREFIX) {
        return Ok(PinnedKey::from_text(arg)?);
    }
    let path = Path::new(arg);
    let bytes = read_capped(path, MAX_KEY_FILE_BYTES)?;
    let text = std::str::from_utf8(&bytes).map_err(|_| PinError::BadFormat)?;
    Ok(PinnedKey::from_text(text)?)
}

/// Read a result file (a regular file, at most `MAX_WIRE` bytes).
pub fn read_result_file(path: &Path) -> Result<Vec<u8>, VerifyFileError> {
    read_capped(path, MAX_WIRE as u64)
}

/// The files dir to check by default: `files/` beside the result file.
pub fn default_files_dir(result_file: &Path) -> Option<PathBuf> {
    let dir = result_file.parent()?.join(FILES_DIR);
    std::fs::symlink_metadata(&dir)
        .ok()
        .filter(|m| m.is_dir())
        .map(|_| dir)
}

/// Verify result wire bytes against a pinned key, optionally bind them to a
/// job ticket, and optionally check the output files in `files_dir`.
pub fn verify_result(
    wire: &[u8],
    pinned: PinnedKey,
    ticket: Option<&JobTicket>,
    files_dir: Option<&Path>,
) -> Result<Verified, VerifyFileError> {
    let mut v = Verifier::new(pinned)?;
    let signed = match ticket {
        Some(t) => {
            v.accept(wire, t)?;
            SignedResult::from_bytes(wire).map_err(VerifyError::from)?
        }
        None => v.verify(wire)?,
    };
    let files_checked = match files_dir {
        Some(d) => check_output_files(&signed.result, d)?.len(),
        None => 0,
    };
    Ok(Verified {
        signed,
        files_dir: files_dir.map(Path::to_path_buf),
        files_checked,
    })
}