Skip to content

Commit 3603c45

Browse files
feat(suidhelper): self-checksum stamping + build-identity embedding (#30)
1 parent f2cb58c commit 3603c45

17 files changed

Lines changed: 510 additions & 12 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,7 @@ docs/superpowers/
4444
# gRPC bindings are generated from proto/hyper/grpc/v0/hyper.proto before compile
4545
# -- see the :grpc_gen Mix compiler in mix.exs.
4646
/lib/hyper/grpc/v0/
47+
48+
# The expected suidhelper build identity (version + checksum) is captured from
49+
# the stamped binary's `version` output -- see the :suidhelper_stamp Mix compiler.
50+
/lib/hyper/suid_helper/expected.ex

config/config.exs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,7 @@ config :libcluster,
2424
config :hyper,
2525
cgroup_parent: "hyper",
2626
uid_gid_range: {900_000, 999_999},
27-
layer_dir: "/srv/hyper/layers",
28-
vmlinux: %{
29-
x86_64: "/srv/hyper/vmlinux/vmlinux-x86_64",
30-
aarch64: "/srv/hyper/vmlinux/vmlinux-aarch64"
31-
}
27+
layer_dir: "/srv/hyper/layers"
3228

3329
if config_env() == :test do
3430
config :opentelemetry, traces_exporter: :none

lib/hyper/config.ex

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ defmodule Hyper.Config do
2222
@skopeo_path Application.compile_env(:hyper, :skopeo_path, "skopeo")
2323
@umoci_path Application.compile_env(:hyper, :umoci_path, nil)
2424
@mke2fs_path Application.compile_env(:hyper, :mke2fs_path, "mke2fs")
25-
@vmlinux Application.compile_env(:hyper, :vmlinux, %{})
2625

2726
@doc """
2827
Root work directory for this node. All firecracker paths derive from it.
@@ -156,5 +155,7 @@ defmodule Hyper.Config do
156155
`Hyper.Node.Vmlinux` resolves and validates them per node.
157156
"""
158157
@spec vmlinux :: %{optional(Sys.Arch.t()) => Path.t()}
159-
def vmlinux, do: @vmlinux
158+
# Runtime read, not `compile_env`: an unset map would inline a literal `%{}`,
159+
# which the type checker proves makes every `Map.fetch/2` on it return `:error`.
160+
def vmlinux, do: Application.get_env(:hyper, :vmlinux, %{})
160161
end

lib/hyper/suid_helper.ex

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ defmodule Hyper.SuidHelper do
1616
self-test and reports the base path it was compiled against.
1717
"""
1818

19-
alias Hyper.SuidHelper.{Blockdev, Dmsetup, Losetup}
19+
alias Hyper.SuidHelper.{Blockdev, Dmsetup, Expected, Losetup}
2020

2121
use OpenTelemetryDecorator
2222

@@ -52,18 +52,43 @@ defmodule Hyper.SuidHelper do
5252

5353
@doc """
5454
Check that the setuid helper and every tool it execs are usable on this
55-
machine: the helper binary itself, then each tool submodule's own check.
55+
machine: the helper binary is present, is the build this release expects
56+
(`verify_version/0`), then each tool submodule's own check.
5657
"""
5758
@spec test_system() :: :ok | {:error, term()}
5859
@decorate with_span("Hyper.SuidHelper.test_system")
5960
def test_system do
6061
with :ok <- helper_present(),
62+
:ok <- verify_version(),
6163
:ok <- Losetup.test_system(),
6264
:ok <- Dmsetup.test_system() do
6365
Blockdev.test_system()
6466
end
6567
end
6668

69+
@doc """
70+
Check the deployed helper is the one this build produced: its `version` output
71+
must match `Hyper.SuidHelper.Expected` (the identity captured from the stamped
72+
binary at compile time). Catches a stale or wrong binary at the configured path.
73+
74+
This compares the helper's *self-reported* identity, so it is a build-provenance
75+
check, not an adversarial tamper proof -- a malicious binary could report any
76+
value.
77+
"""
78+
@spec verify_version() :: :ok | {:error, :version_mismatch | err()}
79+
@decorate with_span("Hyper.SuidHelper.verify_version")
80+
def verify_version do
81+
case exec(["version"]) do
82+
{:ok, %{"version" => v, "checksum_blake3" => c}} ->
83+
if v == Expected.version() and c == Expected.checksum_blake3(),
84+
do: :ok,
85+
else: {:error, :version_mismatch}
86+
87+
{:error, _} = err ->
88+
err
89+
end
90+
end
91+
6792
@spec helper_present() :: :ok | {:error, :suid_helper_not_found}
6893
defp helper_present do
6994
if System.find_executable(Hyper.Config.suid_helper()),

mix.exs

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ defmodule Hyper.MixProject do
1414
# A Mix compiler (not a `compile` alias) is used because Mix honors a
1515
# dependency's `:compilers` but NOT its aliases or `config/` -- so this is
1616
# the only hook that also fires when hyper is compiled AS A DEPENDENCY.
17-
compilers: [:firecracker_gen, :grpc_gen | Mix.compilers()],
17+
compilers: [:suidhelper_stamp, :firecracker_gen, :grpc_gen | Mix.compilers()],
1818
deps: deps(),
1919
test_coverage: [tool: ExCoveralls],
2020
docs: docs(),
@@ -195,7 +195,9 @@ defmodule Hyper.MixProject do
195195
# Force a regeneration of the Firecracker bindings (ignores staleness).
196196
"firecracker.gen": ["compile.firecracker_gen --force"],
197197
# Force a regeneration of the gRPC bindings (ignores staleness).
198-
"grpc.gen": ["compile.grpc_gen --force"]
198+
"grpc.gen": ["compile.grpc_gen --force"],
199+
# Rebuild + stamp the suidhelper and re-capture its expected identity.
200+
"suidhelper.stamp": ["compile.suidhelper_stamp --force"]
199201
]
200202
end
201203
end
@@ -325,3 +327,104 @@ defmodule Mix.Tasks.Compile.FirecrackerGen do
325327
not File.exists?(@out) or File.stat!(@spec_path).mtime > File.stat!(@out).mtime
326328
end
327329
end
330+
331+
defmodule Mix.Tasks.Compile.SuidhelperStamp do
332+
@moduledoc """
333+
Mix compiler that builds and stamps the Rust setuid helper, then captures the
334+
build identity it will report at runtime into a generated module,
335+
`Hyper.SuidHelper.Expected` (gitignored, like the other generated bindings).
336+
337+
Steps, run before the Elixir compiler so the generated module compiles:
338+
339+
1. `cargo xtask stamp` (in `native/suidhelper`) builds the release binary and
340+
writes its BLAKE3 self-checksum into the ELF `.note.sum` section.
341+
2. The stamped binary's `version` subcommand is invoked; its JSON
342+
(`{"version":..,"checksum_blake3":..}`) is the helper's self-reported
343+
build identity.
344+
3. Its version + checksum are baked into `lib/hyper/suid_helper/expected.ex`
345+
so the BEAM can compare a deployed helper against the one this build made.
346+
347+
Always runs (no staleness gate): `cargo` is incremental, so a no-op rebuild is
348+
cheap, and this keeps the embedded identity in lockstep with the binary. Like
349+
the protoc compiler, a missing toolchain is a hard failure -- `cargo` and the
350+
helper's nightly toolchain must be present wherever hyper is compiled.
351+
352+
Note: the checksum is *self-reported* by the binary, so the generated module is
353+
a build-provenance / version-skew check, not an adversarial tamper proof (a
354+
malicious binary could print any value). Real tamper detection would re-hash
355+
the on-disk ELF with `.note.sum` zeroed and compare -- the embedded checksum is
356+
the reference value that check would use.
357+
"""
358+
359+
use Mix.Task.Compiler
360+
361+
@helper_dir "native/suidhelper"
362+
@binary "native/suidhelper/target/release/hyper-suidhelper"
363+
@out "lib/hyper/suid_helper/expected.ex"
364+
365+
@impl Mix.Task.Compiler
366+
def run(_argv) do
367+
stamp!()
368+
json = capture_version!()
369+
generate(json)
370+
{:ok, []}
371+
end
372+
373+
defp stamp! do
374+
case System.cmd("cargo", ["xtask", "stamp"], cd: @helper_dir, stderr_to_stdout: true) do
375+
{_, 0} ->
376+
:ok
377+
378+
{output, code} ->
379+
Mix.raise("""
380+
`cargo xtask stamp` failed (exit #{code}) building the suidhelper:
381+
382+
#{output}
383+
Ensure `cargo` and the helper's toolchain (see #{@helper_dir}/rust-toolchain.toml)
384+
are installed.
385+
""")
386+
end
387+
end
388+
389+
defp capture_version! do
390+
case System.cmd(Path.expand(@binary), ["version"], stderr_to_stdout: true) do
391+
{out, 0} ->
392+
String.trim(out)
393+
394+
{out, code} ->
395+
Mix.raise("`hyper-suidhelper version` failed (exit #{code}): #{out}")
396+
end
397+
end
398+
399+
defp generate(json) do
400+
# Jason and the app's deps are available once loadpaths runs.
401+
Mix.Task.run("loadpaths")
402+
%{"version" => version, "checksum_blake3" => checksum} = Jason.decode!(json)
403+
404+
File.mkdir_p!(Path.dirname(@out))
405+
406+
source = """
407+
defmodule Hyper.SuidHelper.Expected do
408+
@moduledoc false
409+
# GENERATED by Mix.Tasks.Compile.SuidhelperStamp from the stamped
410+
# `hyper-suidhelper version` output. Do not edit; gitignored.
411+
412+
@version #{inspect(version)}
413+
@checksum_blake3 #{inspect(checksum)}
414+
415+
@doc "Expected helper version."
416+
@spec version() :: String.t()
417+
def version, do: @version
418+
419+
@doc "Expected BLAKE3 checksum (hex) of the stamped helper."
420+
@spec checksum_blake3() :: String.t()
421+
def checksum_blake3, do: @checksum_blake3
422+
end
423+
"""
424+
425+
# Format the generated source directly rather than via `Mix.Task.run("format",
426+
# ...)`: a Mix task runs once per session, so invoking it here would consume
427+
# the single run and leave the later `:grpc_gen` compiler's format a no-op.
428+
File.write!(@out, [Code.format_string!(source), "\n"])
429+
end
430+
end

native/suidhelper/.cargo/config.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@
44
# the host `cc`, so it's left at the default.
55
[target.aarch64-unknown-linux-musl]
66
linker = "rust-lld"
7+
8+
[alias]
9+
xtask = "run --package xtask --"

0 commit comments

Comments
 (0)