Gunnar

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