nordisk/heimdall
crates/heimdall-core/tests/signed_result.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! Signed results (spec §7, §11 Signatures): what verifies and every refusal,
//! each named by what it proves. Heimdall is a defensive sandbox.
mod common;
use common::*;
use heimdall_core::*;
// ---- accepted ----
#[test]
fn signed_result_verifies_with_the_pinned_key() {
let got = verifier_a().verify(&signed_wire()).unwrap();
assert_eq!(got.result, sample_result());
assert_eq!(got.key_id, key_a().key_id());
assert_eq!(got.boot_id, BOOT_A);
assert_eq!(got.version, FORMAT_VERSION);
}
#[test]
fn refused_verdict_is_signed_and_verifies_with_its_reason() {
let wire = key_a().sign(refused_result()).unwrap().to_bytes();
let got = verifier_a().verify(&wire).unwrap();
assert_eq!(
got.result.verdict,
Verdict::Refused {
code: "heimdall-wasm-1".into(),
reason: "guest trapped (out of fuel)".into()
}
);
}
#[test]
fn accept_with_the_right_ticket_returns_the_result() {
let mut v = verifier_a();
let r = v.accept(&signed_wire(), &ticket()).unwrap();
assert_eq!(r, sample_result());
}
#[test]
fn verify_is_stateless_and_can_be_repeated() {
let v = verifier_a();
let w = signed_wire();
v.verify(&w).unwrap();
v.verify(&w).unwrap();
}
#[test]
fn signing_the_same_result_twice_gives_identical_bytes() {
// Ed25519 is deterministic and so is the encoding.
let a = key_a().sign(sample_result()).unwrap().to_bytes();
let b = key_a().sign(sample_result()).unwrap().to_bytes();
assert_eq!(a, b);
}
#[test]
fn wire_bytes_round_trip_through_from_bytes() {
let s = key_a().sign(sample_result()).unwrap();
assert_eq!(SignedResult::from_bytes(&s.to_bytes()).unwrap(), s);
}
#[test]
fn json_form_round_trips_and_still_verifies() {
let s = key_a().sign(sample_result()).unwrap();
let j = serde_json::to_string(&s).unwrap();
let back: SignedResult = serde_json::from_str(&j).unwrap();
assert_eq!(back, s);
verifier_a().verify_signed(&back).unwrap();
}
#[test]
fn empty_result_with_no_outputs_events_or_features_verifies() {
let r = JobResult {
outputs: vec![],
events: vec![],
runner: min_features(),
exit: ExitInfo::default(),
..sample_result()
};
let wire = key_a().sign(r.clone()).unwrap().to_bytes();
assert_eq!(verifier_a().verify(&wire).unwrap().result, r);
}
#[test]
fn unicode_reason_and_output_names_survive_signing() {
let r = JobResult {
verdict: Verdict::Refused {
code: "guest-refused".into(),
reason: "signatur ogiltig: ÅÄÖ ✗".into(),
},
outputs: vec![Output::of("rapport-åäö.txt", b"x")],
..sample_result()
};
let wire = key_a().sign(r.clone()).unwrap().to_bytes();
assert_eq!(verifier_a().verify(&wire).unwrap().result, r);
}
#[test]
fn signed_message_starts_with_the_v1_domain() {
let s = key_a().sign(sample_result()).unwrap();
assert!(s.signed_message().starts_with(DOMAIN_V1));
}
#[test]
fn wire_starts_with_the_magic_and_version_one() {
let w = signed_wire();
assert_eq!(&w[..8], WIRE_MAGIC);
assert_eq!(&w[8..10], &1u16.to_be_bytes());
}
// ---- forged ----
#[test]
fn forged_result_with_changed_verdict_is_refused() {
let mut s = key_a().sign(refused_result()).unwrap();
s.result.verdict = Verdict::Accepted;
assert_eq!(
verifier_a().verify(&s.to_bytes()).unwrap_err(),
VerifyError::BadSignature
);
}
#[test]
fn forged_result_with_changed_output_hash_is_refused() {
let mut s = key_a().sign(sample_result()).unwrap();
s.result.outputs[0].sha256 = Sha256Digest::of(b"evil");
assert_eq!(
verifier_a().verify(&s.to_bytes()).unwrap_err(),
VerifyError::BadSignature
);
}
#[test]
fn forged_result_with_an_added_output_is_refused() {
let mut s = key_a().sign(sample_result()).unwrap();
s.result.outputs.push(Output::of("extra", b"x"));
assert_eq!(
verifier_a().verify(&s.to_bytes()).unwrap_err(),
VerifyError::BadSignature
);
}
#[test]
fn forged_result_with_a_dropped_event_is_refused() {
let mut s = key_a().sign(sample_result()).unwrap();
s.result.events.pop();
assert_eq!(
verifier_a().verify(&s.to_bytes()).unwrap_err(),
VerifyError::BadSignature
);
}
#[test]
fn forged_result_with_reordered_outputs_is_refused() {
let mut s = key_a().sign(sample_result()).unwrap();
s.result.outputs.reverse();
assert_eq!(
verifier_a().verify(&s.to_bytes()).unwrap_err(),
VerifyError::BadSignature
);
}
#[test]
fn forged_result_with_changed_timestamps_is_refused() {
let mut s = key_a().sign(sample_result()).unwrap();
s.result.finished_unix += 1;
assert_eq!(
verifier_a().verify(&s.to_bytes()).unwrap_err(),
VerifyError::BadSignature
);
}
#[test]
fn forged_result_with_changed_features_is_refused() {
let mut s = key_a().sign(sample_result()).unwrap();
s.result.runner.landlock = true;
assert_eq!(
verifier_a().verify(&s.to_bytes()).unwrap_err(),
VerifyError::BadSignature
);
}
#[test]
fn forged_zero_signature_is_refused() {
let mut s = key_a().sign(sample_result()).unwrap();
s.sig = Ed25519Signature([0; 64]);
assert_eq!(
verifier_a().verify(&s.to_bytes()).unwrap_err(),
VerifyError::BadSignature
);
}
#[test]
fn forged_all_ones_signature_is_refused() {
let mut s = key_a().sign(sample_result()).unwrap();
s.sig = Ed25519Signature([0xff; 64]);
assert_eq!(
verifier_a().verify(&s.to_bytes()).unwrap_err(),
VerifyError::BadSignature
);
}
#[test]
fn signature_from_another_result_is_refused() {
let good = key_a().sign(refused_result()).unwrap();
let mut s = key_a().sign(sample_result()).unwrap();
s.sig = good.sig;
assert_eq!(
verifier_a().verify(&s.to_bytes()).unwrap_err(),
VerifyError::BadSignature
);
}
#[test]
fn signature_over_the_bare_result_without_domain_is_refused() {
// Same key signs the canonical result bytes with no domain prefix: a
// signature made for another purpose must not verify as a result.
use ed25519_dalek::Signer;
let sk = ed25519_dalek::SigningKey::from_bytes(&SEED_A);
let mut s = key_a().sign(sample_result()).unwrap();
let msg = &s.signed_message()[DOMAIN_V1.len()..];
s.sig = Ed25519Signature(sk.sign(msg).to_bytes());
assert_eq!(
verifier_a().verify(&s.to_bytes()).unwrap_err(),
VerifyError::BadSignature
);
}
#[test]
fn signature_under_another_domain_is_refused() {
use ed25519_dalek::Signer;
let sk = ed25519_dalek::SigningKey::from_bytes(&SEED_A);
let mut s = key_a().sign(sample_result()).unwrap();
let mut msg = b"heimdall.something-else.v1\0".to_vec();
msg.extend_from_slice(&s.signed_message()[DOMAIN_V1.len()..]);
s.sig = Ed25519Signature(sk.sign(&msg).to_bytes());
assert_eq!(
verifier_a().verify(&s.to_bytes()).unwrap_err(),
VerifyError::BadSignature
);
}
#[test]
fn json_form_with_a_changed_verdict_is_refused() {
let s = key_a().sign(refused_result()).unwrap();
let j = serde_json::to_string(&s).unwrap().replace(
r#"{"Refused":{"code":"heimdall-wasm-1","reason":"guest trapped (out of fuel)"}}"#,
r#""Accepted""#,
);
let back: SignedResult = serde_json::from_str(&j).unwrap();
assert!(back.result.verdict.is_accepted());
assert_eq!(
verifier_a().verify_signed(&back).unwrap_err(),
VerifyError::BadSignature
);
}
// ---- wrong key / wrong boot ----
#[test]
fn result_signed_by_another_key_is_refused_as_wrong_key() {
let wire = key_b().sign(sample_result()).unwrap().to_bytes();
assert_eq!(
verifier_a().verify(&wire).unwrap_err(),
VerifyError::WrongKey {
expected: key_a().key_id(),
got: key_b().key_id()
}
);
}
#[test]
fn other_key_relabelled_with_the_pinned_key_id_is_refused_as_bad_signature() {
let mut s = key_b().sign(sample_result()).unwrap();
s.key_id = key_a().key_id();
s.boot_id = BOOT_A;
assert_eq!(
verifier_a().verify(&s.to_bytes()).unwrap_err(),
VerifyError::BadSignature
);
}
#[test]
fn same_key_from_another_boot_is_refused_as_wrong_boot() {
let other_boot = BootKey::from_seed(SEED_A, BOOT_B);
let wire = other_boot.sign(sample_result()).unwrap().to_bytes();
assert_eq!(
verifier_a().verify(&wire).unwrap_err(),
VerifyError::WrongBoot {
expected: BOOT_A,
got: BOOT_B
}
);
}
#[test]
fn boot_id_relabelled_after_signing_is_refused() {
let mut s = key_a().sign(sample_result()).unwrap();
s.boot_id = BOOT_B;
let pinned = PinnedKey {
boot_id: BOOT_B,
..key_a().pinned()
};
let v = Verifier::new(pinned).unwrap();
assert_eq!(
v.verify(&s.to_bytes()).unwrap_err(),
VerifyError::BadSignature
);
}
#[test]
fn two_generated_boot_keys_differ_in_key_and_boot_id() {
let a = BootKey::generate();
let b = BootKey::generate();
assert_ne!(a.key_id(), b.key_id());
assert_ne!(a.boot_id(), b.boot_id());
let wire = b.sign(sample_result()).unwrap().to_bytes();
let v = Verifier::new(a.pinned()).unwrap();
assert!(matches!(v.verify(&wire), Err(VerifyError::WrongKey { .. })));
}
#[test]
fn generated_boot_key_signs_results_its_own_pin_accepts() {
let k = BootKey::generate();
let v = Verifier::new(k.pinned()).unwrap();
v.verify(&k.sign(sample_result()).unwrap().to_bytes())
.unwrap();
}
// ---- wrong version / magic ----
#[test]
fn version_two_is_refused_as_unsupported_version() {
let mut w = signed_wire();
w[8..10].copy_from_slice(&2u16.to_be_bytes());
assert_eq!(
verifier_a().verify(&w).unwrap_err(),
VerifyError::Decode(DecodeError::UnsupportedVersion(2))
);
}
#[test]
fn version_zero_is_refused_as_unsupported_version() {
let mut w = signed_wire();
w[8..10].copy_from_slice(&0u16.to_be_bytes());
assert_eq!(
verifier_a().verify(&w).unwrap_err(),
VerifyError::Decode(DecodeError::UnsupportedVersion(0))
);
}
#[test]
fn json_form_with_version_two_is_refused() {
let mut s = key_a().sign(sample_result()).unwrap();
s.version = 2;
assert_eq!(
verifier_a().verify_signed(&s).unwrap_err(),
VerifyError::Decode(DecodeError::UnsupportedVersion(2))
);
}
#[test]
fn wrong_magic_is_refused() {
let mut w = signed_wire();
w[0] = b'X';
assert_eq!(
verifier_a().verify(&w).unwrap_err(),
VerifyError::Decode(DecodeError::BadMagic)
);
}
#[test]
fn empty_input_is_refused() {
assert!(matches!(
verifier_a().verify(&[]).unwrap_err(),
VerifyError::Decode(_)
));
}
#[test]
fn input_over_the_size_cap_is_refused_before_parsing() {
let w = vec![0u8; MAX_WIRE + 1];
assert!(matches!(
verifier_a().verify(&w).unwrap_err(),
VerifyError::Decode(DecodeError::TooLarge { .. })
));
}
// ---- truncated / extended ----
#[test]
fn every_truncation_of_the_wire_is_refused() {
let w = signed_wire();
let v = verifier_a();
for n in 0..w.len() {
assert!(v.verify(&w[..n]).is_err(), "prefix of {n} bytes verified");
}
}
#[test]
fn signature_cut_short_by_one_byte_is_refused_as_decode_error() {
let w = signed_wire();
assert!(matches!(
verifier_a().verify(&w[..w.len() - 1]).unwrap_err(),
VerifyError::Decode(_)
));
}
#[test]
fn trailing_byte_after_the_signature_is_refused() {
let mut w = signed_wire();
w.push(0);
assert!(matches!(
verifier_a().verify(&w).unwrap_err(),
VerifyError::Decode(DecodeError::TrailingBytes { .. })
));
}
#[test]
fn result_length_field_pointing_past_the_end_is_refused() {
let mut w = signed_wire();
// result_len sits after magic(8) + version(2) + key_id(16) + boot_id(16).
w[42..46].copy_from_slice(&u32::MAX.to_be_bytes());
assert!(matches!(
verifier_a().verify(&w).unwrap_err(),
VerifyError::Decode(_)
));
}
#[test]
fn result_length_field_one_short_is_refused() {
let mut w = signed_wire();
let n = u32::from_be_bytes(w[42..46].try_into().unwrap());
w[42..46].copy_from_slice(&(n - 1).to_be_bytes());
assert!(verifier_a().verify(&w).is_err());
}
// ---- bit flips ----
#[test]
fn every_single_bit_flip_of_the_wire_is_refused() {
let w = signed_wire();
let v = verifier_a();
for i in 0..w.len() {
for bit in 0..8 {
let mut f = w.clone();
f[i] ^= 1 << bit;
assert!(v.verify(&f).is_err(), "flip of byte {i} bit {bit} verified");
}
}
}
#[test]
fn bit_flip_in_the_signature_is_refused_as_bad_signature() {
let mut w = signed_wire();
let n = w.len();
w[n - 10] ^= 0x01;
assert_eq!(
verifier_a().verify(&w).unwrap_err(),
VerifyError::BadSignature
);
}
#[test]
fn bit_flip_in_an_output_hash_is_refused_as_bad_signature() {
let s = key_a().sign(sample_result()).unwrap();
let mut w = s.to_bytes();
let h = s.result.outputs[0].sha256.0;
let pos = w.windows(32).position(|x| x == h).unwrap();
w[pos] ^= 0x80;
assert_eq!(
verifier_a().verify(&w).unwrap_err(),
VerifyError::BadSignature
);
}
// ---- binding to the job and replay ----
#[test]
fn replayed_result_is_refused_on_second_accept() {
let mut v = verifier_a();
let w = signed_wire();
v.accept(&w, &ticket()).unwrap();
assert_eq!(
v.accept(&w, &ticket()).unwrap_err(),
VerifyError::Replayed(ticket().job_id)
);
}
#[test]
fn replay_with_reencoded_json_form_is_still_refused() {
let mut v = verifier_a();
let s = key_a().sign(sample_result()).unwrap();
v.accept(&s.to_bytes(), &ticket()).unwrap();
let back: SignedResult = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert!(matches!(
v.accept(&back.to_bytes(), &ticket()),
Err(VerifyError::Replayed(_))
));
}
#[test]
fn result_for_another_job_is_refused_as_job_mismatch() {
let mut v = verifier_a();
let other = JobTicket {
job_id: JobId([0x33; 16]),
..ticket()
};
assert_eq!(
v.accept(&signed_wire(), &other).unwrap_err(),
VerifyError::JobMismatch {
expected: other.job_id,
got: ticket().job_id
}
);
}
#[test]
fn result_with_an_old_nonce_is_refused_as_nonce_mismatch() {
let mut v = verifier_a();
let fresh = JobTicket {
nonce: Nonce([0x44; 32]),
..ticket()
};
assert_eq!(
v.accept(&signed_wire(), &fresh).unwrap_err(),
VerifyError::NonceMismatch
);
}
#[test]
fn refused_accept_does_not_burn_the_ticket() {
let mut v = verifier_a();
let mut bad = signed_wire();
let n = bad.len();
bad[n - 1] ^= 1;
assert!(v.accept(&bad, &ticket()).is_err());
v.accept(&signed_wire(), &ticket()).unwrap();
}
#[test]
fn job_mismatch_does_not_burn_the_ticket() {
let mut v = verifier_a();
let other = JobTicket {
job_id: JobId([0x33; 16]),
..ticket()
};
assert!(v.accept(&signed_wire(), &other).is_err());
v.accept(&signed_wire(), &ticket()).unwrap();
}
#[test]
fn two_different_jobs_are_both_accepted_once() {
let mut v = verifier_a();
let t2 = JobTicket {
job_id: JobId([0x55; 16]),
nonce: Nonce([0x66; 32]),
};
let r2 = JobResult {
job_id: t2.job_id,
nonce: t2.nonce,
..sample_result()
};
v.accept(&signed_wire(), &ticket()).unwrap();
v.accept(&key_a().sign(r2).unwrap().to_bytes(), &t2)
.unwrap();
}
#[test]
fn replay_guard_refuses_the_same_triple_and_counts_it_once() {
let mut g = ReplayGuard::new();
assert!(g.is_empty());
let s = key_a().sign(sample_result()).unwrap();
g.check_and_record(&s).unwrap();
assert_eq!(
g.check_and_record(&s).unwrap_err(),
VerifyError::Replayed(ticket().job_id)
);
assert_eq!(g.len(), 1);
}
#[test]
fn replay_guard_keeps_keys_apart() {
let mut g = ReplayGuard::new();
g.check_and_record(&key_a().sign(sample_result()).unwrap())
.unwrap();
g.check_and_record(&key_b().sign(sample_result()).unwrap())
.unwrap();
assert_eq!(g.len(), 2);
}
#[test]
fn job_ticket_from_a_job_matches_its_id_and_nonce() {
let job = Job::new(
JobKind::Native {
image: ImageRef {
name: "clamav".into(),
digest: format!("sha256:{}", "00".repeat(32)),
},
argv: vec!["clamscan".into()],
},
"/in".into(),
"/out".into(),
serde_json::Value::Null,
Policy::default(),
);
let t = job.ticket();
assert_eq!(t.job_id, job.id);
assert_eq!(t.nonce, job.nonce);
}
#[test]
fn two_new_jobs_get_different_ids_and_nonces() {
let mk = || {
Job::new(
JobKind::Wasm {
module: GuestRef {
path: "/w.wasm".into(),
blake3: "00".repeat(32),
},
},
"/in".into(),
"/out".into(),
serde_json::Value::Null,
Policy::default(),
)
};
let (a, b) = (mk(), mk());
assert_ne!(a.id, b.id);
assert_ne!(a.nonce, b.nonce);
}
// ---- well-formedness at sign and verify time ----
#[test]
fn signing_refuses_a_path_traversal_output_name() {
let mut r = sample_result();
r.outputs.push(Output::of("../etc/passwd", b"x"));
assert!(matches!(
key_a().sign(r).unwrap_err(),
ResultError::OutputName { .. }
));
}
#[test]
fn signing_refuses_a_duplicate_output_name() {
let mut r = sample_result();
r.outputs.push(Output::of("report.json", b"other"));
assert_eq!(
key_a().sign(r).unwrap_err(),
ResultError::DuplicateOutput("report.json".into())
);
}
#[test]
fn signing_refuses_finished_before_started() {
let mut r = sample_result();
r.finished_unix = r.started_unix - 1;
assert!(matches!(
key_a().sign(r).unwrap_err(),
ResultError::TimeReversed { .. }
));
}
#[test]
fn verify_refuses_a_correctly_signed_but_invalid_result() {
// A key holder that skips validation (raw signing) still cannot get a
// traversal name past the verifier.
use ed25519_dalek::Signer;
let sk = ed25519_dalek::SigningKey::from_bytes(&SEED_A);
let mut r = sample_result();
r.outputs.push(Output::of("../x", b"x"));
let mut s = key_a().sign(sample_result()).unwrap();
s.result = r;
s.sig = Ed25519Signature(sk.sign(&s.signed_message()).to_bytes());
assert!(matches!(
verifier_a().verify(&s.to_bytes()).unwrap_err(),
VerifyError::Invalid(ResultError::OutputName { .. })
));
}
#[test]
fn verify_refuses_a_correctly_signed_duplicate_output() {
use ed25519_dalek::Signer;
let sk = ed25519_dalek::SigningKey::from_bytes(&SEED_A);
let mut s = key_a().sign(sample_result()).unwrap();
s.result.outputs.push(Output::of("index.bin", b"z"));
s.sig = Ed25519Signature(sk.sign(&s.signed_message()).to_bytes());
assert!(matches!(
verifier_a().verify(&s.to_bytes()).unwrap_err(),
VerifyError::Invalid(ResultError::DuplicateOutput(_))
));
}
#[test]
fn boot_key_debug_does_not_print_the_secret_seed() {
let d = format!("{:?}", key_a());
assert!(!d.contains(&heimdall_core::hex::encode(&SEED_A)));
assert!(!d.contains("7, 7, 7"));
assert!(d.contains(&key_a().key_id().to_hex()));
}
#[test]
fn error_messages_name_the_refusal() {
assert_eq!(
VerifyError::BadSignature.to_string(),
"signature does not verify"
);
assert!(VerifyError::Replayed(JobId([0; 16]))
.to_string()
.contains("replay"));
assert!(VerifyError::Decode(DecodeError::UnsupportedVersion(9))
.to_string()
.contains("version 9"));
}