Gunnar

nordisk/heimdall

docs/API.md

Heimdall job API (v0.1.0)

Heimdall is a defensive sandbox. It runs untrusted parsing, verification and scanning jobs so that a compromised job can reach only its own input and output. Its tests only exercise its own containment.

This is the stable job API every runner implements (milestone 0, Holger's demand). From v0.1.0 it changes by additions only (semver).

# Holger's Cargo.toml (until the crates are on crates.io)
heimdall-core  = { git = "ssh://git@git.gunnar.rs:2222/nordisk/heimdall.git", tag = "v0.1.0" }
heimdall-plain = { git = "ssh://git@git.gunnar.rs:2222/nordisk/heimdall.git", tag = "v0.1.0" }

The flow

use heimdall_core::*;
use heimdall_plain::{PlainConfig, PlainYoukiRunner};

let runner: Box<dyn JobRunner> =
    Box::new(PlainYoukiRunner::new(PlainConfig::new("/var/lib/holger/oci", "/var/lib/holger/heimdall")));
let pinned = runner.verifying_key();            // per boot; pin it
let job = Job::new(
    JobKind::Native {
        image: ImageRef { name: "docker.io/library/busybox".into(), digest: "sha256:…".into() },
        argv: vec!["/bin/cp".into(), "/in/a".into(), "/out/a".into()],
    },
    input_dir, output_dir,                       // output_dir: existing, empty
    serde_json::Value::Null,
    Policy::default(),
);
let signed = runner.run(job)?;                   // blocking; put it on a job queue
let result = verify(&signed, &pinned)?;          // signature, key id, well-formed
check_outputs(result, &output_dir)?;             // every listed file, hash checked, no extras
match &result.verdict {
    Verdict::Accepted => { /* use the outputs */ }
    Verdict::Refused { code, reason } => { /* code is stable, reason is for people */ }
}

Later, the protected Heimdall runner replaces PlainYoukiRunner::new(…); nothing else moves.

heimdall-core: the names

ItemShape
Jobid: JobId, nonce: Nonce, kind: JobKind, input_dir: PathBuf, output_dir: PathBuf, params: serde_json::Value, policy: Policy
JobKindNative { image: ImageRef, argv: Vec<String> } | Wasm { module: GuestRef }
ImageRefname: String, digest: String (sha256: + 64 lower-case hex); ImageRef::sha256() parses it
GuestRefpath: PathBuf, blake3: String
Policyread: Vec<PathBuf>, write: Vec<PathBuf>, net: Option<NetAllow>, limits: Limits
NetAllowhost: String, port: u16 (exactly one peer)
Limitswall: Duration, memory_bytes: u64, cpus: f32, pids: u32 + wasm/output limits fuel, table_elements, instances, tables, memories, stack_bytes, max_output_files, max_output_file_bytes, max_output_total_bytes; build with ..Limits::default()
VerdictAccepted | Refused { code: String, reason: String }
Outputname: String, sha256: Sha256Digest, bytes: u64
ExitInfocode: Option<i32>, signal: Option<i32>, killed: Option<KillReason>
KillReasonWall | Memory | Pids | Fuel
JobResultjob_id, nonce, verdict, outputs: Vec<Output>, exit: ExitInfo, started_unix: i64, finished_unix: i64, runner: Features, events: Vec<BrokerEvent>
SignedResultversion: u16, key_id: KeyId, boot_id: BootId, result: JobResult, sig: Ed25519Signature
Featuresrunner: String, container, seccomp: Protection, landlock, broker, wasm, image_verified_each_start (bools)
ProtectionNone | Default | Generated { profile_digest: String }
trait JobRunner: Send + Syncrun(&self, Job) -> Result<SignedResult, RunError>, verifying_key(&self) -> VerifyingKey, features(&self) -> Features
RunErrorUnsupported { what }, InvalidJob { reason }, Image { reason }, Setup { reason }, Io { reason }, Sign(ResultError) — nothing was signed
verifyfn verify(&SignedResult, &VerifyingKey) -> Result<&JobResult, VerifyError>
check_outputsfn check_outputs(&JobResult, &Path) -> Result<(), VerifyError> (VerifyError::Output(OutputError::…) names the failure)
VerifyingKeyre-export of ed25519_dalek::VerifyingKey (also heimdall_core::ed25519_dalek)

Everything public derives serde. heimdall-core is #![forbid(unsafe_code)] and depends on neither youki nor wasmtime.

Additions to the demand's shapes: Job.nonce and JobResult.nonce (bind a result to one request; Job::new fills it), JobResult.events (broker decisions, empty without a broker), SignedResult.version and boot_id, the KillReason and codes items, and Verifier/ReplayGuard/PinnedKey for callers that pin a key record and want replay detection. Output.sha256 is a typed Sha256Digest (its serde form is the 64-hex string), key_id a typed KeyId and sig an Ed25519Signature (64 bytes) rather than raw strings and arrays.

Refusal codes (heimdall_core::codes)

CodeMeaning
heimdall-out-1output_dir holds an extra: a directory, symlink, fifo, device, bad name, or more files than max_output_files
heimdall-out-2an output over max_output_file_bytes, or all over max_output_total_bytes
heimdall-out-3 / -4(wasm) an emitted file was refused / the output audit failed
heimdall-limit-wallkilled at the wall-clock limit
heimdall-limit-memorykilled at the memory limit
heimdall-exit-1the job exited non-zero (its outputs are still listed)
heimdall-exit-2the job died from a signal
heimdall-wasm-<n>a wasm guest trapped or hit a wasm limit (heimdall-wasm)

The signed bytes: canonical binary wire v1 (not JSON)

The demand asked for Ed25519 over canonical JSON. Heimdall signs its canonical binary encoding v1 instead: it is deterministic by construction (one value has exactly one encoding, the decoder accepts nothing else), domain-separated, length-prefixed, has no floats, and its decoder is fuzzed. Callers never touch the bytes: verify() re-encodes the result and checks the signature. JSON stays available for transport (SignedResult is serde) and still verifies after a JSON round trip.

wire      = "HMDL-SR\0" | version u16 = 1 | key_id[16] | boot_id[16]
            | result_len u32 | JobResult v1 | signature[64]
signature = Ed25519 over "heimdall.signed-result.v1\0" || (wire bytes between magic and signature)
key_id    = first 16 bytes of sha256("heimdall.key-id.v1\0" || public key)

JobResult v1 =
  job_id[16] nonce[32]
  verdict: u8 (0 Accepted | 1 Refused, then code: str, reason: str)
  outputs: u32 n × (name: str, sha256[32], bytes: u64)
  exit:    code: opt i32, signal: opt i32, killed: u8 (0 none, 1 Wall, 2 Memory, 3 Pids, 4 Fuel)
  started_unix: i64, finished_unix: i64
  runner:  runner: str, container: bool, seccomp: u8 (0 None, 1 Default, 2 Generated + profile_digest: str),
           landlock: bool, broker: bool, wasm: bool, image_verified_each_start: bool
  events:  u32 n × (at_ms: u64, call: str, args: u32 m × str, decision: u8)
str = u32 length + UTF-8; opt T = u8 0 | 1 + T; bool = u8 0 | 1; integers big-endian

Limits (and so cpus, the only float) is never part of a result.

heimdall-plain: PlainYoukiRunner (interim, NOT Heimdall)

An ordinary youki container per job (libcontainer pinned at =0.7.0), with no Heimdall protections. Its features(), and every result's runner:

runner "youki-plain", container true, seccomp Default, landlock false,
broker false, wasm false, image_verified_each_start true

Building: the shim needs the cargo feature youki (libseccomp-dev): cargo build -p heimdall-plain --features youki --bin heimdall-plain-shim. The runner finds it via HEIMDALL_PLAIN_SHIM, next to the current executable, or PlainConfig::shim. A host binary can also serve as the shim: call heimdall_plain::shim::run_if_shim() first in main and use ShimCommand::current_exe().

Host requirements: user namespaces, cgroup v2 with the memory, pids and cpu controllers delegated to the runner's cgroup, and a cgroup dir the runner can create heimdall/ in (PlainConfig::cgroup_root, cgroup_parent). scripts/plain-tests.sh shows a working setup in a privileged rootless podman container.

Wasm behind the trait

heimdall_run::WasmJobRunner runs JobKind::Wasm jobs through the same trait (signed guests verified and blake3-pinned on every job, no WASI); runner: "heimdall-wasm", wasm: true, container: false, seccomp: None.