Skip to content

feat(verify): Refuse a boot asset whose digest differs from the bundle - #392

Merged
ananos merged 1 commit into
mainfrom
p0/234-digest
Sep 30, 2026
Merged

ananos merged 1 commit into
mainfrom
p0/234-digest

Conversation

@pmoust

@pmoust pmoust commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Summary

brig checked the signature on the boot bundle, but the kernel and initrd that booted were whatever sat in the asset directory, so a swapped or stale kernel booted under "boot assets verified". brig now reads the per-file sha256 from the registry manifest at the digest that verified (hull's provenance.json stands in when the registry does not answer) and compares both files before the boot. Stacked on #386.

Related issues

Fixes: #234
Refs: #168

Changes

  • internal/verify/bundle.go: fetch the bundle manifest by digest with net/http, hash it against that digest, read the layer digests. A registry that answers with other bytes, an index, or a plain-http token realm or redirect comes back as ManifestRefusedError and gets no provenance fallback.
  • internal/wrap/verify.go: hash the kernel and initrd and compare. BRIG_BOOT_ASSETS unset: a difference refuses under warn and require. Named directory: warn states it and boots, require refuses. The Linux runtime bundle note shows only for a named directory on nerdctl.
  • The summary names the boot assets only after both files match.
  • internal/runtime/boot.go: BootAssets.Named records that BRIG_BOOT_ASSETS chose the directory, which decides whether a difference refuses in every mode or only under require.
  • script/smoke.sh: five end-to-end cases for the digest check.
  • Docs: security.md, runtimes.md, troubleshooting.md.
  • Release note needed: a republished bundle tag refuses installs holding the older bundle until the two files are deleted.
  • Follow-up: a manifest whose bytes do not hash to the signed digest counts as "cannot check" (require refuses, warn states it and boots). Refusing it in every mode, as a bad signature is, is left for a separate decision.

Checklist

  • make all passes (vet, test, build)
  • script/smoke.sh passes
  • I have added or updated tests covering the change
  • I have run go test ./... -race, if the change touches concurrency, subprocesses or the daemon
  • I have exercised the change against a real runtime (brig run <agent>), if it touches the run, exec or credential path
  • I have updated the affected docs (see docs/README.md for the map)

Also ran gofmt -l . (empty), go vet ./..., script/check-tests-kept.sh origin/main and script/check-retired-spellings.sh.

Real runtime

macOS, hull v0.1.0-rc29, brig built from this branch, claude profile.

A named directory (copy of ~/.hull/store/assets with one byte changed in Image), BRIG_BOOT_ASSETS=<copy> BRIG_VERIFY=require brig sh claude echo booted-A, exit 5 before hull:

brig: refusing to boot: BRIG_BOOT_ASSETS names .../named-assets, and its boot assets are not the bundle that verified, ghcr.io/nofireai/hull-assets:darwin-arm64 (sha256:e82a8e4d...): Image is sha256:c7d4b9d2..., not the sha256:c5dd2578... it lists (BRIG_VERIFY=require). Set BRIG_VERIFY=warn to boot them

No Linux runtime bundle text on hull. Then BRIG_VERIFY=require brig sh claude echo booted-B with BRIG_BOOT_ASSETS unset, exit 0:

brig: image and boot assets verified
booted-B

brig rm claude removed the sandbox.

Rerun at 8e86624 with the same named copy and its provenance.json removed, so only the registry answer can supply the digests: exit 5 with the same refusal (Image is sha256:fd1a3fb2..., not the sha256:c5dd2578... it lists). The default directory under require printed brig: image and boot assets verified and booted-B, exit 0. brig rm claude-code removed the sandbox. The refused-registry-answer path cannot be triggered against ghcr and is covered by unit tests only. The nerdctl note was not run on Linux.

Credentials and the sandbox boundary

This moves promise 1: the kernel a sandbox boots is now bound to the digest whose signature verified, not only checked for existence. docs/security.md is updated in this change. Negative tests: a modified kernel refuses where brig chose the directory, a provenance record for another digest refuses, a refused registry answer is not replaced by a matching record, and a hull run does not name the Linux bundle. Credentials are untouched.

@pmoust pmoust added the P0 Blocks a public safety claim or a first run label Sep 28, 2026
@pmoust
pmoust added this pull request to stack #400 September 28, 2026 08:39
@pmoust
pmoust marked this pull request as ready for review September 28, 2026 08:39
@pmoust

pmoust commented Sep 28, 2026 •

Copy link
Copy Markdown
Member Author

Ready to merge from the review side: three nits, all fixed.

Findings:

  • nit, internal/verify/bundle.go:87: the comment said oras cannot print the manifest's layer list, which is not true of oras manifest fetch. Fixed in dd69794 with the real reasons (no subprocess on every verified boot, the size and time bounds, the plain-http refusals, hashing the exact bytes received).
  • nit, internal/verify/bundle.go:192: an https token realm was able to redirect the request to plain http, since the client followed any redirect. Fixed in 3746125: every registry request refuses a non-https redirect as a ManifestRefusedError. New negative test TestBundleDigestsRefusesARealmThatRedirectsToPlainHTTP failed before the fix and checks the plain-http host is never asked. docs/security.md names it in 8e86624.
  • nit, PR body: Changes left out internal/runtime/boot.go (BootAssets.Named) and script/smoke.sh. Body updated.

Checks run (at 8e86624):

  • gofmt -l .: empty
  • go vet ./...: passed
  • go test -race -p 2 ./...: all packages ok
  • script/smoke.sh: PASS
  • script/check-tests-kept.sh origin/main: every test present is still here
  • script/check-retired-spellings.sh: passed

Real runtime (macOS, hull v0.1.0-rc29, brig built at 8e86624):

  • Copy of ~/.hull/store/assets with one byte of Image changed and provenance.json removed, so only the registry answer supplies the digests. BRIG_BOOT_ASSETS=<copy> BRIG_VERIFY=require brig sh claude echo booted-A2, exit 5: brig: refusing to boot: BRIG_BOOT_ASSETS names .../named-assets, and its boot assets are not the bundle that verified, ghcr.io/nofireai/hull-assets:darwin-arm64 (sha256:e82a8e4d...): Image is sha256:fd1a3fb2..., not the sha256:c5dd2578... it lists (BRIG_VERIFY=require). The same with provenance.json in place refused the same way.
  • BRIG_VERIFY=require brig sh claude echo booted-B with BRIG_BOOT_ASSETS unset, exit 0: brig: image and boot assets verified, booted-B.
  • brig rm claude-code removed the sandbox. The redirect refusal cannot be triggered against ghcr and is covered by the unit test only. The nerdctl path was not run: no Linux host here.

brig checked the signature on the boot bundle, but the kernel and initrd
that booted were whatever sat in the asset directory. A kernel swapped
there, or left from an older bundle, booted under "boot assets verified".

brig now keeps the digest whose signature verified and reads the per-file
sha256 from the registry manifest at that digest. Every registry request
refuses a redirect to plain http or a redirect loop, as it refuses a
plain-http token realm. The two files brig hands the runtime are hashed
and compared before the boot. With BRIG_BOOT_ASSETS unset a difference
refuses under warn and require. A named directory states it under warn
and refuses under require, and on nerdctl the refusal says why the Linux
runtime bundle's directory lands there. Digests brig cannot read warn or
refuse by mode. The summary names the boot assets only after both files
match.

When the manifest cannot be read, hull's provenance.json is read instead,
and only a record of that digest counts. A registry that answers with a
manifest brig refuses gets no fallback. The record sits beside the files
it describes, so files that differ from it refuse as a difference, and
files that match it are "cannot check", never verified.

A relative BRIG_BOOT_ASSETS is made absolute: brig hashes the files from
its own working directory, and the runtime opens them from another.

Every republish of a bundle tag now refuses installs holding the older
bundle, until the two files are deleted and fetched again. This needs a
release note.

Fixes: #234
Fixes: #168
Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
Signed-off-by: Anastassios Nanos <ananos@nofire.ai>
@ananos

ananos commented Sep 30, 2026

Copy link
Copy Markdown
Member

thanks @pmoust! #392 is rebased onto main and approved at 4d258a4. I folded the three fixups and the review points into your commit, so it is one commit now, with your authorship and both sign-offs. What changed:

  • hull's provenance.json no longer verifies. When the registry manifest cannot be read, brig still compares the files with the record. A match is "cannot check", though: warn says so and boots, require refuses. The record sits beside the files it describes, so anything that can rewrite the kernel can rewrite the record to match.
  • A redirect loop is a refusal, like a redirect to plain http, so no record stands in for it.
  • "the registry did not answer" now reads "the registry manifest could not be read", which also holds for a 404 or a 401.
  • A relative BRIG_BOOT_ASSETS is made absolute. brig hashes the files from its own working directory, and urunc's shim opens them from another.
  • docs/security.md says what the fallback is worth, that hull's store counts as a directory brig fetches into wherever HULL_BOOT_ASSETS puts it, and who can swap a file between the check and the boot. A sandbox that mounts $HOME can.
  • Fixes: #168 next to Fixes: #234. The summary names the boot assets only after both files match, which is what "boot assets verified" can print over a kernel brig never examined #168 asks for.
  • script/smoke.sh expects image verified plus the record-only line, since smoke has no registry to read the manifest from.

A manifest brig refuses stays "cannot check": warn states it and boots, require refuses. We keep that, rather than refusing it in every mode.

The window between the check and the boot is tracked in #407.

#403 sits on top: the Linux runtime bundle's signed record, the fetch by the verified digest, the re-fetch of an older bundle, and the rc13 pin. @pmoust care to review it? The e2e canaries in #360 now run the boot-asset checks of both PRs on real hosts, and they pass over the stack.

@ananos
ananos merged commit 38e5c5c into main Sep 30, 2026
9 checks passed
@ananos
ananos deleted the p0/234-digest branch September 30, 2026 15:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P0 Blocks a public safety claim or a first run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bind the boot-asset digest to the kernel and initrd that boot

2 participants