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
| Item | Shape |
|---|---|
Job | id: JobId, nonce: Nonce, kind: JobKind, input_dir: PathBuf, output_dir: PathBuf, params: serde_json::Value, policy: Policy |
JobKind | Native { image: ImageRef, argv: Vec<String> } | Wasm { module: GuestRef } |
ImageRef | name: String, digest: String (sha256: + 64 lower-case hex); ImageRef::sha256() parses it |
GuestRef | path: PathBuf, blake3: String |
Policy | read: Vec<PathBuf>, write: Vec<PathBuf>, net: Option<NetAllow>, limits: Limits |
NetAllow | host: String, port: u16 (exactly one peer) |
Limits | wall: 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() |
Verdict | Accepted | Refused { code: String, reason: String } |
Output | name: String, sha256: Sha256Digest, bytes: u64 |
ExitInfo | code: Option<i32>, signal: Option<i32>, killed: Option<KillReason> |
KillReason | Wall | Memory | Pids | Fuel |
JobResult | job_id, nonce, verdict, outputs: Vec<Output>, exit: ExitInfo, started_unix: i64, finished_unix: i64, runner: Features, events: Vec<BrokerEvent> |
SignedResult | version: u16, key_id: KeyId, boot_id: BootId, result: JobResult, sig: Ed25519Signature |
Features | runner: String, container, seccomp: Protection, landlock, broker, wasm, image_verified_each_start (bools) |
Protection | None | Default | Generated { profile_digest: String } |
trait JobRunner: Send + Sync | run(&self, Job) -> Result<SignedResult, RunError>, verifying_key(&self) -> VerifyingKey, features(&self) -> Features |
RunError | Unsupported { what }, InvalidJob { reason }, Image { reason }, Setup { reason }, Io { reason }, Sign(ResultError) — nothing was signed |
verify | fn verify(&SignedResult, &VerifyingKey) -> Result<&JobResult, VerifyError> |
check_outputs | fn check_outputs(&JobResult, &Path) -> Result<(), VerifyError> (VerifyError::Output(OutputError::…) names the failure) |
VerifyingKey | re-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)
| Code | Meaning |
|---|---|
heimdall-out-1 | output_dir holds an extra: a directory, symlink, fifo, device, bad name, or more files than max_output_files |
heimdall-out-2 | an 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-wall | killed at the wall-clock limit |
heimdall-limit-memory | killed at the memory limit |
heimdall-exit-1 | the job exited non-zero (its outputs are still listed) |
heimdall-exit-2 | the 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
- Namespaces: user (container root = the runner's uid, one id mapped), mount, pid, ipc, uts, cgroup, and network (empty: only a down loopback).
- Root: the pinned image, read-only, unpacked fresh for every job from a
local OCI image layout after checking the manifest, config and every layer
against their sha256 digests and sizes (so
image_verified_each_startis true)./tmpis a 64 MiB tmpfs. No/sys. input_dirat/inread-only,output_dirat/outread-write; extrapolicy.read/policy.writepaths at the same path, ro / rw.- All capabilities dropped, no-new-privs,
RLIMIT_CORE0,RLIMIT_NOFILE1024. - Seccomp
Default: youki ships no default profile, so heimdall-plain ships one fixed profile for every job: moby's default profile (moby/profiles 4b8214fc, Apache-2.0) reduced to x86_64 with no capabilities — deny by default with EPERM, nomount/unshare/setns/bpf/keyctl/module calls,clonewithout namespace flags,clone3ENOSYS. - cgroup v2:
memory.max=memory_byteswith no swap,pids.max=pids,cpu.maxfromcpus; the shim kills the whole job atwall. policy.net = Some(..)and wasm jobs:RunError::Unsupported.- After the job the runner lists every regular file in
output_dir(sorted, sha256, size); anything else there refuses the run withheimdall-out-1. - The per-boot Ed25519 key lives only in the runner process. Each job runs
through
heimdall-plain-shim, a separate process that drives libcontainer (the runner never forks itself) and never receives the key; the job's stdio is/dev/null.
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.