Repository navigation
Fix the signing key's public key at start and hold each run's signer to it (#54) - #62
Conversation
…to it (#54) Runner reads the signing key once at start, in the Runtime's seed form, and fixes its public key as the expected key. Each operational evaluation gets JPACK_SIGNING_PUBLIC_KEY beside JPACK_SIGNING_KEY, so Runtime 0.30.0 and later refuse to sign with a seed replaced at its path. Each completed run carries signing: signed under the expected key, by the check verify-run --public-key makes, or failed with a fixed reason; the decision is kept. With a key, an unreadable sidecar fails the signing, not the run. CI pins Runtime v0.30.0 and builds v0.27.1 for the detection test. Part of #57. Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runtime 0.28.0 renamed audit verify's scope for one supplied chain with no checkpoint from one-supplied-chain to committed-prefix (runtime #240). With CI's pin moved to v0.30.0, TestTheRuntimesVerifierReadsTheChainOfRuns accepts either name for the same check. Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Cross-vendor review round (OpenAI gpt-6-astra, reasoning effort high; read-only on a clone at head 8998eec; 24 focused tests offline under a memory cap, 20 passed and the four native-signing tests could not run in the reviewer's sandbox, whose root is owned by another user; scratch-copy probes with scheduling hooks; a scope-name mutation). Verdict: not mergeable until two fixes land; the fix pass follows on this branch, with no second round.
Held under probing: the decision completes and enters the chain whatever the signing outcome; the sidecar bytes are kept exactly; rewritten records, a matching key id with an invalid signature, and mixed-key sidecars are refused by the verifier; a scan of unsigned-run diagnostics found no seed or path; the routes, lists, exports, journal, OpenAPI statuses and the six reasons match the code. Not run: the whole suite, the race detector, macOS, native prevention under v0.30.0 and native replacement signing under v0.27.1 (CI covers the first three). |
…an expected key from the boot line (#54 review) Review of #62 (HIGH): a same-user process could overwrite the seed in place between the key's checks and its read, and the replacement became the lifetime expectation; os.SameFile compares device and inode only. Runner now reads the seed through the handle it checked and reads the handle's attributes again after the read: a change of identity, size, links, mode, owner, modification or change time refuses the start. The boot line's optional signingPublicKey names the expected key independently of the file, as Desk can from its public-key list (Desk #345): held to the key check, the key read at start must have it, or the start is refused, and it is the expectation for the service's lifetime. Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#54 review) Review of #62 (LOW): the interop test accepted either committed-prefix or one-supplied-chain, so Runtime 0.30.0 answering the obsolete name would pass. The test now reads the Runtime's release from its version answer and expects committed-prefix from 0.28.0 on and one-supplied-chain before; a Runtime naming no release fails the test. A wrapper that swaps the two names must not be taken for the Runtime. CI builds both pinned Runtimes naming their release, as their release builds do. Signed-off-by: kikashy <35789537+kikashy@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What changes
Runner now reads its signing key once, at start, and fixes that key's public key as the expected key for as long as it runs. It passes the expected key to each operational evaluation's Runtime. It also holds each completed run's record signature to that key and records the result on the run. Before this change, a seed replaced at its path after start signed later runs under a key that Desk's list does not name, and nothing said so (audit finding 3).
Design
The expected key is fixed at start. After the existing placement checks,
signingPublicKey(signing_key_unix.go) opens the file it checked without following a link. It holds the open file to the same checks, by device and inode, and reads at most 4096 bytes, the Runtime's bound. The file must be in the Runtime's seed form: 64 hex characters, either case, with nothing else but surrounding whitespace. Runner derives the public key and clears what it read. A key that cannot be opened, or is not a seed, refuses the start like the other checks do (the signing key is refused: ...), naming neither the path nor any byte.Every operational evaluation gets both inputs:
JPACK_SIGNING_KEY=<path>, as before, andJPACK_SIGNING_PUBLIC_KEY=<expected hex>. Rehearsals get neither. Runtime 0.30.0 and later refuse a replaced seed when they sign (prevention). Earlier Runtimes ignore the variable (detection only, part 3).Each completed run's signer is held to the expected key (
signingOf,signing.go). Runner checks the record's kept bytes and the kept sidecar with the checkverify-run --public-keymakes (checkSignaturesand a sharedsignatureStatus). The run then carriessigning:{"status":"signed","publicKey":"<expected hex>"}, or{"status":"failed","reason":"..."}with one of six fixed reasons: the sidecar could not be read; the sidecar names another key; the sidecar does not verify under the expected key; the Runtime signed nothing because the key at the path is not the expected key; the Runtime signed nothing and the key at the path could not be read as a key; no signature.Where no line signs the record, Runner reads the key at the path again, only to choose among the last three reasons. With a key, a sidecar that cannot be read now fails the signing, not the run. Without a key it still fails the run. The run always completes with its decision, its record and its chain entry.
signingsits on the stored run, besideauditSignatures, asinterruptedAtandreasondo.GET /v1/runs/{run}and run lists show it in these exact words, andopenapi.jsongives the reasons as an enum. It is not in verification exports, so no export version moves: version 5 carries the sidecar itself, andverify-run --require-signedrefuses an unsigned or wrongly signed run as before. The journal is unchanged, because a new member there would raise itsentryVersion(activity-journal design, section 5).What it establishes, and what it does not
The docs (README, MAPPING-V2 "The expected key" and "A run's signing", SECURITY) say: the expected key is fixed at start and passed to the Runtime, and a run signed under another key completes with an explicit signing failure. Rotating the key is a separate change (Desk #238).
signedmeans only that a valid signature under the expected key covers the stored record bytes. It says nothing about who held the key. "Another key" is read from the sidecar line's keyId, because Runner does not hold that key. Under an earlier Runtime this is detection, not prevention.Tests
TestTheExpectedKeyIsFixedAtStart: four accepted seed forms. Eight refusals (not a seed, one character short, one character more, two seeds, empty, 4097 bytes, non-hex, mode 0000), each with its exact text and with no path or key bytes in it.TestARunsSigningIsHeldToTheExpectedKey: 18 cases forsigningOf, including the guide's vector line, another key, a bad signature, a signature over other bytes, a torn line, no line the rule reads, an unread sidecar, and a key that was replaced, put back, removed, opened to others, or is not a seed.TestTheExpectedKeyOutlivesAReplacementandTestAnUnreadableSidecarFailsOnlyTheSigning(not private, a link, larger than kept: completed, decision kept, signing failed).TestTheSigningKeyReachesOnlyTheOperationalEvaluationchecks the expected key reaches only the operational evaluation.TestASignedRunKeepsItsSignaturechecks signed /publicKey, across a restart, in the run and the run list, and absent from v5.TestALiveKeyReplacementCannotPassDesksPublicKeyList, the audit's socket-free signer, checks the replaced run reads failed / another key, in the run and the list, with no path or seed.cmd/jpack-runner/expected_key_test.go):TestASeedReplacedAfterStartSignsNothing(JPACK_TEST_BIN, 0.30.0): the first run is signed under the expected key and passesverify-run --require-signed. After the seed is replaced, the run completes with the same decision and no sidecar, its signing reads "The Runtime signed nothing: ...", the export is v4, andverify-run --public-key <expected> --require-signedrefuses it asunsigned. It fails under 0.27.1 (checked).TestAnEarlierRuntimeSigningWithAReplacedSeedIsNamed(JPACK_EARLIER_TEST_BIN, 0.27.1): the run after the replacement completes with the decision and reads failed / another key.verify-rununder the expected key refuses it assignature-invalid, and under the replacement's key it is signed.8270e99, moved from v0.27.0) and builds v0.27.1 (7a2fcfe) asJPACK_EARLIER_TEST_BIN. The two real-binary tests are added to the must-run-and-pass step.Checks
Local runs, on Linux, in a plain clone:
gofmt -l .: clean.go vet ./...: clean, withGOOS=darwintoo, andGOOS=windows go build ./...builds.JPACK_TEST_BINset to Runtime 0.30.0 andJPACK_EARLIER_TEST_BINset to 0.27.1, all pass:./internal/runner, the tests above, plus-run 'Contract|Interop|Sign|Sidecar|Key|Version5|Journal|OpenAPI';./cmd/jpack-runner,-run 'Coverage|Sign|Key|Seed|Unsigned'.JPACK_SIGNING_PUBLIC_KEYfrom the environment:TestTheSigningKeyReachesOnlyTheOperationalEvaluation,TestASeedReplacedAfterStartSignsNothing.TestASignedRunKeepsItsSignature,TestALiveKeyReplacementCannotPassDesksPublicKeyList,TestAnUnreadableSidecarFailsOnlyTheSigning,TestASeedReplacedAfterStartSignsNothing,TestAnEarlierRuntimeSigningWithAReplacedSeedIsNamed.TestARunsSigningIsHeldToTheExpectedKey,TestTheExpectedKeyOutlivesAReplacement,TestALiveKeyReplacementCannotPassDesksPublicKeyList,TestAnEarlierRuntimeSigningWithAReplacedSeedIsNamed.TestARunsSigningIsHeldToTheExpectedKey,TestAnUnreadableSidecarFailsOnlyTheSigning.TestARunsSigningIsHeldToTheExpectedKey,TestASeedReplacedAfterStartSignsNothing.audit verify's scope for one supplied chain fromone-supplied-chaintocommitted-prefix(runtime #240), soTestTheRuntimesVerifierReadsTheChainOfRunsfailed. 8998eec makes it accept either name, and it passes under 0.30.0 and 0.27.1 locally.go test -race ./...on ubuntu-latest and macos-latest, Release contents, and the must-run step, where all five chain and signature tests ran and passed on both OSes.Review fixes
Cross-vendor round 1 (OpenAI gpt-6-astra, head 8998eec) found one HIGH and one LOW. Both are fixed as new commits, each with tests and a mutation check.
os.SameFilecompares only device and inode, so the replacement's public key became the expected key for the service's lifetime.it changed while it was read, with no path and no bytes).signingPublicKeynames the expected key independently of the file, as Desk can from its public-key list (Desk #345). Runner holds it to the key check before any seed is read. The key read at start must have it, or the start is refused (the signing key at its path is not the expected key). It is then the expected key for the service's lifetime, and what every operational evaluation gets asJPACK_SIGNING_PUBLIC_KEY. Naming it withoutsigningKeyrefuses the start.TestAKeyChangedWhileItIsReadRefusesTheStart: a test hook between the check and the read rewrites the file in place, adds a name, changes its mode or appends; a file left alone starts.TestTheSeedIsReadThroughTheHandleItWasCheckedOn: the key's directory is swapped after the check, and the expected key stays the checked file's.TestAConfiguredExpectedKeyIsHeldAtStart: a match starts. These are refused: another key, a seed replaced before start, a seed rewritten before the read, upper case, a trailing newline, a character short, a small-order key, and a key named without a signing key. With the key configured, a replacement after start is still held to it.TestTheBootLineNamesTheExpectedSigningKey.TestTheRuntimesVerifierReadsTheChainOfRunsnow reads the Runtime's release fromjpack version --format json. It expects exactlycommitted-prefixfrom 0.28.0 on andone-supplied-chainbefore. A Runtime that names no release (0.0.0-dev) fails the test, with instructions. A wrapper that swaps the two names must not pass. CI builds both pinned Runtimes with-ldflags -X …/internal/result.CLIVersion=<release>, as their release builds are.TestAKeyChangedWhileItIsReadRefusesTheStart(4 subtests) andTestAConfiguredExpectedKeyIsHeldAtStart/the_seed_rewritten_before_the_readfail.TestTheSeedIsReadThroughTheHandleItWasCheckedOnfails.TestAConfiguredExpectedKeyIsHeldAtStart(7 subtests) fails.TestTheRuntimesVerifierReadsTheChainOfRunsfails.TestTheBootLineNamesTheExpectedSigningKeyfails.gofmtandgo vet ./...are clean, withGOOS=darwintoo.GOOS=freebsdandGOOS=windowsbuilds succeed.TestTheRuntimesVerifierReadsTheChainOfRunsalso passes with 0.27.1 asJPACK_TEST_BIN.go test -race ./...on ubuntu-latest and macos-latest, Release contents, and the must-run step, where all five chain and signature tests ran and passed on both OSes.Not in this PR
verify-run --chainreport still names its scopeone-supplied-chain, the Runtime's name before 0.28.0. That is unchanged here.Review
Seam: a signing key. A cross-vendor round (OpenAI model) follows, run by the coordinator.
Closes #54
Part of #57
🤖 Generated with Claude Code