Skip to content

Read the release manifest: verify, parse, decide, cache - #396

Merged
onel merged 2 commits into
devfrom
backend/395-release-manifest
Aug 12, 2026
Merged

Read the release manifest: verify, parse, decide, cache#396
onel merged 2 commits into
devfrom
backend/395-release-manifest

Conversation

@onel

@onel onel commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Closes #395.

The apply transaction (#385) and its trigger (#386) are both built, and nothing picks the target — POST /api/v1/system/update takes two image refs an admin typed. On an appliance the target comes from a signed release manifest (RELEASE_MANIFEST.md). This is the half of that with no network and no timer in it: verify, parse, decide, cache. The poll, the dashboard prompt and the three-strikes pin are later slices, and each is easier with this part pure and tested.

The verifier takes both minisign algorithm modes (Ed over the file, ED over its BLAKE2b-512) and checks both signatures in the file. The second one covers the signature plus the trusted comment — skipping it would let anyone rewrite that comment on a genuinely signed file, and the caller would get attacker-chosen text that looks authenticated.

Keys are a list from day one. RELEASE_MANIFEST.md # Signing builds rotation on it, and one constant would turn a lost key into a synchronized fleet update. Zero keys refuses everything — that is the shipping state today, since no signing key exists yet, so the appliance updater stays inert rather than acting on a file it cannot check.

Decide answers four states, not two. "Valid and current, but this box's host-agent is too old" is a healthy box waiting on its apt update, not a manifest to ignore, so it stays distinct from "not for you". The kill switch is checked before the host-agent gate: a box already running a retracted version should be offered the way back even when it is behind, because the gate exists to stop it moving forward. An unparseable version fails closed.

The cache stores the publisher's raw bytes, because a re-marshal drops the unknown fields Parse ignores and could never verify again, and Load re-verifies so the local file system is not a way around the signature.

Testing. Table tests over the failure modes the spec names, driven by a small minisign signer in the test file so the parser meets bytes in minisign's real layout. Four mutation checks, each done by editing the code and restoring it: drop the global-signature check (only the trusted-comment test fails), prehash the wrong mode, treat an unreadable version as new enough, skip the re-verify in Load.

Two things this does not fix, recorded in the progress entry rather than glossed over: no box has a signing key, so this is proven against test-generated keys only; and turning a version into an image digest is still undecided — the manifest names versions, the transaction pulls by digest, and the published ghcr packages are still private per BUILD.md # 6.

Progress entry: docs/progress/release-manifest-read.md. make test-nopam green; gofmt clean; no API surface touched.

The apply transaction and its trigger are built; nothing picks the target. On
an appliance the target comes from a signed release manifest. This is the half
of that with no network and no timer in it.

New internal/hostagent/relmanifest. The minisign verifier takes both algorithm
modes and checks both signatures in the file — the second one covers the
signature plus the trusted comment, so skipping it would let anyone rewrite
that comment on a genuinely signed file. Keys are a list from day one, because
rotation depends on it; zero keys refuses everything, which is the shipping
state today and keeps the appliance updater inert rather than credulous.

Decide answers four states. "Valid and current, but host-agent too old" is a
healthy box waiting on apt, not a manifest to ignore, so it stays distinct. The
kill switch is checked before the host-agent gate: a box on a retracted version
should be offered the way back even when it is behind, since the gate exists to
stop it moving forward.

The cache stores the publisher's raw bytes, because a re-marshal drops ignored
unknown fields and could never verify again, and Load re-verifies so the local
file system is not a way around the signature.

Closes #395

Claude-Session: https://claude.ai/code/session_01DpJrjCXiQygMgNsw2AqH25
@onel
onel force-pushed the backend/395-release-manifest branch from 18d09f5 to aa8b069 Compare August 12, 2026 09:41
@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
internal/hostagent/relmanifest/cache.go Stores the manifest and signature in one atomically replaced envelope, resolving the previously reported split-pair failure.
internal/hostagent/relmanifest/manifest.go Implements manifest validation and semver-aware update, rollback, hold, and no-op decisions.
internal/hostagent/relmanifest/minisign.go Implements minisign key parsing and verification of both primary and trusted-comment signatures.
internal/hostagent/relmanifest/relmanifest_test.go Covers signature failures, manifest decisions, cache verification, and whole-pair cache replacement.

Reviews (2): Last reviewed commit: "Review fixes: one cache file, and compar..." | Re-trigger Greptile

Comment thread internal/hostagent/relmanifest/cache.go Outdated
The cache wrote the manifest and its signature as two files, each write atomic
on its own. That cannot keep the promise RELEASE_MANIFEST.md # Failure modes
makes — "the previous valid manifest stays in effect". Two files means two
renames: a power cut between them leaves the new manifest beside the old
signature, which fails verification, and the previous good manifest is already
gone because the first rename overwrote it. The box would come back with no
usable cache at all.

Both now live in one file, so saving them is one rename and the box always
comes back to a complete pair. The publisher's exact bytes are still stored
verbatim, since the signature covers them.

Decide also compared the running pair as raw strings while the host-agent gate
compared versions. The two sides come from different code paths, so "v1.4.2"
and "1.4.2" for the same release would have offered the box an update to what
it already runs. Both comparisons now go through semver.

Claude-Session: https://claude.ai/code/session_01DpJrjCXiQygMgNsw2AqH25
@onel
onel merged commit 60ded25 into dev Aug 12, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Read the release manifest: minisign verify, parse, and cache

1 participant