Gunnar

nordisk/heimdall

crates/heimdall-core/src/signed.rs

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB

//! The signed result (spec §7): the per-boot Ed25519 key, the wire format,
//! and the caller-side verifier that refuses forged, replayed, truncated,
//! bit-flipped, wrong-key and wrong-version results.
//!
//! Wire format v1:
//!
//! ```text
//! magic "HMDL-SR\0" | version u16 = 1 | key_id[16] | boot_id[16]
//!   | result_len u32 | result (canonical v1, see codec) | signature[64]
//! ```
//!
//! The signature is Ed25519 over `DOMAIN_V1 || <wire bytes between the magic
//! and the signature>`, so version, key id, boot id and the whole result are
//! all covered, and a signature made for anything else (another domain)
//! never verifies as a result.

use std::collections::HashSet;
use std::fmt;

use serde::{Deserialize, Serialize};

use crate::codec::{decode_result, encode_result, DecodeError, Reader, Writer};
use crate::types::*;

/// The wire magic.
pub const WIRE_MAGIC: &[u8; 8] = b"HMDL-SR\0";
/// The only format version this build signs and accepts.
pub const FORMAT_VERSION: u16 = 1;
/// Signing domain for version 1 results.
pub const DOMAIN_V1: &[u8] = b"heimdall.signed-result.v1\0";
/// Domain for deriving a key id from a public key.
pub const KEY_ID_DOMAIN: &[u8] = b"heimdall.key-id.v1\0";
/// Largest wire input the verifier reads.
pub const MAX_WIRE: usize = crate::codec::MAX_ENCODED_RESULT + 8 + 2 + 16 + 16 + 4 + 64;

/// A result plus Heimdall's signature over its canonical encoding.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedResult {
    pub version: u16,
    pub key_id: KeyId,
    pub boot_id: BootId,
    pub result: JobResult,
    pub sig: Ed25519Signature,
}

/// Why a result is not well-formed (checked when signing and when verifying).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
pub enum ResultError {
    #[error("output `{name}`: {err}")]
    OutputName { name: String, err: OutputNameError },
    #[error("output `{0}` listed twice")]
    DuplicateOutput(String),
    #[error("finished ({finished}) before started ({started})")]
    TimeReversed { started: i64, finished: i64 },
}

/// Why a signed result is refused.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum VerifyError {
    #[error("malformed signed result: {0}")]
    Decode(#[from] DecodeError),
    #[error("signed by key {got}, but the pinned key is {expected}")]
    WrongKey { expected: KeyId, got: KeyId },
    #[error("from boot {got}, but the pinned boot is {expected}")]
    WrongBoot { expected: BootId, got: BootId },
    #[error("signature does not verify")]
    BadSignature,
    #[error("invalid result: {0}")]
    Invalid(#[from] ResultError),
    #[error("result is for job {got}, expected job {expected}")]
    JobMismatch { expected: JobId, got: JobId },
    #[error("result nonce does not match the job's nonce")]
    NonceMismatch,
    #[error("result for job {0} was already accepted (replay)")]
    Replayed(JobId),
    #[error("the verifying key is a weak (small-order) key")]
    WeakKey,
    #[error("outputs: {0}")]
    Output(#[from] crate::outputs::OutputError),
}

/// Why a pinned public key record is refused.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum PinError {
    #[error("not a heimdall-boot-key v1 record")]
    BadFormat,
    #[error("public key is not a valid Ed25519 point or is a weak key")]
    BadPublicKey,
    #[error("key id does not match the public key")]
    KeyIdMismatch,
}

/// Key id of an Ed25519 public key.
pub fn key_id_of(public_key: &[u8; 32]) -> KeyId {
    use sha2::{Digest, Sha256};
    let mut h = Sha256::new();
    h.update(KEY_ID_DOMAIN);
    h.update(public_key);
    let full: [u8; 32] = h.finalize().into();
    KeyId(full[..16].try_into().expect("16 of 32"))
}

/// A usable verifying key: a valid point and not of small order.
fn verifying_key(pk: &Ed25519PublicKey) -> Result<ed25519_dalek::VerifyingKey, PinError> {
    let vk = ed25519_dalek::VerifyingKey::from_bytes(&pk.0).map_err(|_| PinError::BadPublicKey)?;
    if vk.is_weak() {
        return Err(PinError::BadPublicKey);
    }
    Ok(vk)
}

/// The per-boot signing key. Generated at boot, never written to disk;
/// its public half is handed to callers as a [`PinnedKey`].
pub struct BootKey {
    signing: ed25519_dalek::SigningKey,
    boot_id: BootId,
    key_id: KeyId,
}

impl fmt::Debug for BootKey {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("BootKey")
            .field("key_id", &self.key_id)
            .field("boot_id", &self.boot_id)
            .finish_non_exhaustive()
    }
}

impl BootKey {
    /// A fresh key and boot id from the OS CSPRNG.
    pub fn generate() -> Self {
        let mut seed = [0u8; 32];
        getrandom::getrandom(&mut seed).expect("operating system random source failed");
        let k = Self::from_seed(seed, BootId::random());
        // Best effort: do not leave the seed copy on the stack.
        seed.iter_mut().for_each(|b| *b = 0);
        std::hint::black_box(&seed);
        k
    }

    /// A key from a fixed seed (tests and reproducible fixtures only).
    pub fn from_seed(seed: [u8; 32], boot_id: BootId) -> Self {
        let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
        let key_id = key_id_of(signing.verifying_key().as_bytes());
        Self {
            signing,
            boot_id,
            key_id,
        }
    }

    pub fn key_id(&self) -> KeyId {
        self.key_id
    }

    pub fn boot_id(&self) -> BootId {
        self.boot_id
    }

    /// The per-boot verifying key (what [`crate::JobRunner::verifying_key`] returns).
    pub fn verifying_key(&self) -> ed25519_dalek::VerifyingKey {
        self.signing.verifying_key()
    }

    /// The public record a caller pins.
    pub fn pinned(&self) -> PinnedKey {
        PinnedKey {
            key_id: self.key_id,
            boot_id: self.boot_id,
            public_key: Ed25519PublicKey(self.signing.verifying_key().to_bytes()),
        }
    }

    /// Validate and sign `result`.
    pub fn sign(&self, result: JobResult) -> Result<SignedResult, ResultError> {
        use ed25519_dalek::Signer;
        result.validate()?;
        let mut s = SignedResult {
            version: FORMAT_VERSION,
            key_id: self.key_id,
            boot_id: self.boot_id,
            result,
            sig: Ed25519Signature([0; 64]),
        };
        s.sig = Ed25519Signature(self.signing.sign(&s.signed_message()).to_bytes());
        Ok(s)
    }
}

/// The public key a caller pins (out of band, at boot).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PinnedKey {
    pub key_id: KeyId,
    pub boot_id: BootId,
    pub public_key: Ed25519PublicKey,
}

impl PinnedKey {
    /// One line: `heimdall-boot-key v1 <key_id> <boot_id> <public_key>` (hex) + `\n`.
    pub fn to_text(&self) -> String {
        format!(
            "{PIN_LABEL} v1 {} {} {}\n",
            self.key_id, self.boot_id, self.public_key
        )
    }

    /// Strict parse of [`PinnedKey::to_text`]; checks the key id and the point.
    pub fn from_text(s: &str) -> Result<Self, PinError> {
        let line = s.strip_suffix('\n').unwrap_or(s);
        if line.contains('\n') || line.contains('\r') {
            return Err(PinError::BadFormat);
        }
        let parts: Vec<&str> = line.split(' ').collect();
        let [label, ver, kid, bid, pk] = parts[..] else {
            return Err(PinError::BadFormat);
        };
        if label != PIN_LABEL || ver != "v1" {
            return Err(PinError::BadFormat);
        }
        let p = PinnedKey {
            key_id: KeyId::from_hex(kid).ok_or(PinError::BadFormat)?,
            boot_id: BootId::from_hex(bid).ok_or(PinError::BadFormat)?,
            public_key: Ed25519PublicKey::from_hex(pk).ok_or(PinError::BadFormat)?,
        };
        p.check()?;
        Ok(p)
    }

    /// The verifying key, after [`PinnedKey::check`].
    pub fn verifying_key(&self) -> Result<ed25519_dalek::VerifyingKey, PinError> {
        self.check()?;
        verifying_key(&self.public_key)
    }

    /// The point is usable and the key id matches it.
    pub fn check(&self) -> Result<(), PinError> {
        verifying_key(&self.public_key)?;
        if key_id_of(&self.public_key.0) != self.key_id {
            return Err(PinError::KeyIdMismatch);
        }
        Ok(())
    }
}

const PIN_LABEL: &str = "heimdall-boot-key";

/// Wire bytes between the magic and the signature (what the domain is prefixed to).
fn write_body(w: &mut Writer, s: &SignedResult) {
    w.u16(s.version);
    w.raw(&s.key_id.0);
    w.raw(&s.boot_id.0);
    let r = encode_result(&s.result);
    w.len(r.len());
    w.raw(&r);
}

impl SignedResult {
    /// The wire bytes.
    pub fn to_bytes(&self) -> Vec<u8> {
        let mut w = Writer::new();
        w.raw(WIRE_MAGIC);
        write_body(&mut w, self);
        w.raw(&self.sig.0);
        w.into_inner()
    }

    /// Structural decode of the wire bytes (no signature check).
    pub fn from_bytes(bytes: &[u8]) -> Result<Self, DecodeError> {
        if bytes.len() > MAX_WIRE {
            return Err(DecodeError::TooLarge { max: MAX_WIRE });
        }
        let mut rd = Reader::new(bytes);
        if rd.raw(WIRE_MAGIC.len(), "magic")? != WIRE_MAGIC {
            return Err(DecodeError::BadMagic);
        }
        let version = rd.u16("version")?;
        if version != FORMAT_VERSION {
            return Err(DecodeError::UnsupportedVersion(version));
        }
        let key_id = KeyId(rd.array("key_id")?);
        let boot_id = BootId(rd.array("boot_id")?);
        let n = rd.u32("result length")? as usize;
        let body = rd.raw(n, "result")?;
        let result = decode_result(body)?;
        let sig = Ed25519Signature(rd.array("signature")?);
        rd.finish()?;
        Ok(SignedResult {
            version,
            key_id,
            boot_id,
            result,
            sig,
        })
    }

    /// The exact message the signature covers.
    pub fn signed_message(&self) -> Vec<u8> {
        let mut w = Writer::new();
        w.raw(DOMAIN_V1);
        write_body(&mut w, self);
        w.into_inner()
    }
}

impl JobResult {
    /// Output names valid and unique, finished not before started.
    pub fn validate(&self) -> Result<(), ResultError> {
        let mut names = HashSet::with_capacity(self.outputs.len());
        for o in &self.outputs {
            validate_output_name(&o.name).map_err(|err| ResultError::OutputName {
                name: o.name.clone(),
                err,
            })?;
            if !names.insert(o.name.as_str()) {
                return Err(ResultError::DuplicateOutput(o.name.clone()));
            }
        }
        if self.finished_unix < self.started_unix {
            return Err(ResultError::TimeReversed {
                started: self.started_unix,
                finished: self.finished_unix,
            });
        }
        Ok(())
    }
}

/// Remembers which (key, job, nonce) triples were accepted, to refuse replays.
#[derive(Clone, Debug, Default)]
pub struct ReplayGuard {
    seen: HashSet<(KeyId, JobId, Nonce)>,
}

impl ReplayGuard {
    pub fn new() -> Self {
        Self::default()
    }

    /// Record a result; refuse it if this triple was seen before.
    pub fn check_and_record(&mut self, signed: &SignedResult) -> Result<(), VerifyError> {
        let k = (signed.key_id, signed.result.job_id, signed.result.nonce);
        if !self.seen.insert(k) {
            return Err(VerifyError::Replayed(signed.result.job_id));
        }
        Ok(())
    }

    pub fn len(&self) -> usize {
        self.seen.len()
    }

    pub fn is_empty(&self) -> bool {
        self.seen.is_empty()
    }
}

/// The caller side: verifies results against one pinned key.
#[derive(Debug)]
pub struct Verifier {
    pinned: PinnedKey,
    vk: ed25519_dalek::VerifyingKey,
    guard: ReplayGuard,
}

impl Verifier {
    pub fn new(pinned: PinnedKey) -> Result<Self, PinError> {
        pinned.check()?;
        let vk = verifying_key(&pinned.public_key)?;
        Ok(Self {
            pinned,
            vk,
            guard: ReplayGuard::new(),
        })
    }

    pub fn pinned(&self) -> &PinnedKey {
        &self.pinned
    }

    /// Stateless check of wire bytes: format, version, key, boot, signature,
    /// well-formedness. Does not bind to a job and does not detect replays.
    pub fn verify(&self, wire: &[u8]) -> Result<SignedResult, VerifyError> {
        let s = SignedResult::from_bytes(wire)?;
        self.verify_signed(&s)?;
        Ok(s)
    }

    /// Stateless check of an already-decoded result (e.g. from JSON).
    pub fn verify_signed(&self, signed: &SignedResult) -> Result<(), VerifyError> {
        if signed.version != FORMAT_VERSION {
            return Err(DecodeError::UnsupportedVersion(signed.version).into());
        }
        if signed.key_id != self.pinned.key_id {
            return Err(VerifyError::WrongKey {
                expected: self.pinned.key_id,
                got: signed.key_id,
            });
        }
        if signed.boot_id != self.pinned.boot_id {
            return Err(VerifyError::WrongBoot {
                expected: self.pinned.boot_id,
                got: signed.boot_id,
            });
        }
        let sig = ed25519_dalek::Signature::from_bytes(&signed.sig.0);
        self.vk
            .verify_strict(&signed.signed_message(), &sig)
            .map_err(|_| VerifyError::BadSignature)?;
        signed.result.validate()?;
        Ok(())
    }

    /// Full caller check: [`Verifier::verify`], bound to `ticket`, and not seen before.
    pub fn accept(&mut self, wire: &[u8], ticket: &JobTicket) -> Result<JobResult, VerifyError> {
        let s = self.verify(wire)?;
        if s.result.job_id != ticket.job_id {
            return Err(VerifyError::JobMismatch {
                expected: ticket.job_id,
                got: s.result.job_id,
            });
        }
        if s.result.nonce != ticket.nonce {
            return Err(VerifyError::NonceMismatch);
        }
        self.guard.check_and_record(&s)?;
        Ok(s.result)
    }
}

/// Check `signed` against the pinned per-boot `key`: format version, key id
/// (derived from `key`), Ed25519 strict signature over the canonical v1
/// bytes, and a well-formed result. Returns the result it vouches for.
///
/// This is stateless: it does not bind the result to a job or detect a
/// replay. Callers compare `job_id` (and `nonce`) with the job they ran, or
/// use [`Verifier::accept`].
pub fn verify<'a>(
    signed: &'a SignedResult,
    key: &ed25519_dalek::VerifyingKey,
) -> Result<&'a JobResult, VerifyError> {
    if signed.version != FORMAT_VERSION {
        return Err(DecodeError::UnsupportedVersion(signed.version).into());
    }
    if key.is_weak() {
        return Err(VerifyError::WeakKey);
    }
    let expected = key_id_of(key.as_bytes());
    if signed.key_id != expected {
        return Err(VerifyError::WrongKey {
            expected,
            got: signed.key_id,
        });
    }
    let sig = ed25519_dalek::Signature::from_bytes(&signed.sig.0);
    key.verify_strict(&signed.signed_message(), &sig)
        .map_err(|_| VerifyError::BadSignature)?;
    signed.result.validate()?;
    Ok(&signed.result)
}