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