Skip to content

Fix the signing key's public key at start and hold each run's signer to it (#54) - #62

Merged
kikashy merged 4 commits into
mainfrom
fix/54-expected-signing-key
Oct 11, 2026
Merged

kikashy merged 4 commits into
mainfrom
fix/54-expected-signing-key

Conversation

@kikashy

@kikashy kikashy commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

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

  1. 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.

  2. Every operational evaluation gets both inputs: JPACK_SIGNING_KEY=<path>, as before, and JPACK_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).

  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 check verify-run --public-key makes (checkSignatures and a shared signatureStatus). The run then carries signing:

    • {"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.

  • Surfaces. signing sits on the stored run, beside auditSignatures, as interruptedAt and reason do. GET /v1/runs/{run} and run lists show it in these exact words, and openapi.json gives the reasons as an enum. It is not in verification exports, so no export version moves: version 5 carries the sidecar itself, and verify-run --require-signed refuses an unsigned or wrongly signed run as before. The journal is unchanged, because a new member there would raise its entryVersion (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). signed means 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 for signingOf, 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.
  • TestTheExpectedKeyOutlivesAReplacement and TestAnUnreadableSidecarFailsOnlyTheSigning (not private, a link, larger than kept: completed, decision kept, signing failed).
  • Extended: TestTheSigningKeyReachesOnlyTheOperationalEvaluation checks the expected key reaches only the operational evaluation. TestASignedRunKeepsItsSignature checks 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.
  • Real binaries (cmd/jpack-runner/expected_key_test.go):
    • TestASeedReplacedAfterStartSignsNothing (JPACK_TEST_BIN, 0.30.0): the first run is signed under the expected key and passes verify-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, and verify-run --public-key <expected> --require-signed refuses it as unsigned. 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-run under the expected key refuses it as signature-invalid, and under the replacement's key it is signed.
    • Each skips only when its variable is unset.
  • CI pins Runtime v0.30.0 (8270e99, moved from v0.27.0) and builds v0.27.1 (7a2fcfe) as JPACK_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, with GOOS=darwin too, and GOOS=windows go build ./... builds.
  • Focused tests, with JPACK_TEST_BIN set to Runtime 0.30.0 and JPACK_EARLIER_TEST_BIN set to 0.27.1, all pass:
    • in ./internal/runner, the tests above, plus -run 'Contract|Interop|Sign|Sidecar|Key|Version5|Journal|OpenAPI';
    • in ./cmd/jpack-runner, -run 'Coverage|Sign|Key|Seed|Unsigned'.
  • Mutation checks, run in a scratch copy; each is caught by the named tests:
    • Dropping JPACK_SIGNING_PUBLIC_KEY from the environment: TestTheSigningKeyReachesOnlyTheOperationalEvaluation, TestASeedReplacedAfterStartSignsNothing.
    • Dropping the run's signing check: TestASignedRunKeepsItsSignature, TestALiveKeyReplacementCannotPassDesksPublicKeyList, TestAnUnreadableSidecarFailsOnlyTheSigning, TestASeedReplacedAfterStartSignsNothing, TestAnEarlierRuntimeSigningWithAReplacedSeedIsNamed.
    • Trusting the sidecar's presence: TestARunsSigningIsHeldToTheExpectedKey, TestTheExpectedKeyOutlivesAReplacement, TestALiveKeyReplacementCannotPassDesksPublicKeyList, TestAnEarlierRuntimeSigningWithAReplacedSeedIsNamed.
    • Reading an unread sidecar as absent: TestARunsSigningIsHeldToTheExpectedKey, TestAnUnreadableSidecarFailsOnlyTheSigning.
    • Dropping the key's re-read: TestARunsSigningIsHeldToTheExpectedKey, TestASeedReplacedAfterStartSignsNothing.
  • CI on 906b886 found one drift from the pin move. Runtime 0.28.0 renamed audit verify's scope for one supplied chain from one-supplied-chain to committed-prefix (runtime #240), so TestTheRuntimesVerifierReadsTheChainOfRuns failed. 8998eec makes it accept either name, and it passes under 0.30.0 and 0.27.1 locally.
  • CI on 8998eec passes: 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.

  • HIGH (524f697): an in-place overwrite at start became the expected key. Before the fix, a same-user process could rewrite the seed in place after the key's checks and before its read. os.SameFile compares only device and inode, so the replacement's public key became the expected key for the service's lifetime.
    • One handle, rechecked after the read. Runner now reads the seed through the handle the checks were made on. It then reads that handle's attributes again, and refuses to start if the file's identity, size, links, mode, owner, modification time or change time differ (it changed while it was read, with no path and no bytes).
    • The boot line may name the expected key. The new optional signingPublicKey names 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 as JPACK_SIGNING_PUBLIC_KEY. Naming it without signingKey refuses the start.
    • Tests:
      • 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.
  • LOW (842cf9c): the scope assertion accepted either name. TestTheRuntimesVerifierReadsTheChainOfRuns now reads the Runtime's release from jpack version --format json. It expects exactly committed-prefix from 0.28.0 on and one-supplied-chain before. 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.
  • Mutation checks (scratch copy):
    • Re-check after the read dropped: TestAKeyChangedWhileItIsReadRefusesTheStart (4 subtests) and TestAConfiguredExpectedKeyIsHeldAtStart/the_seed_rewritten_before_the_read fail.
    • Seed read by path, not by the checked handle: TestTheSeedIsReadThroughTheHandleItWasCheckedOn fails.
    • Configured key ignored at start: TestAConfiguredExpectedKeyIsHeldAtStart (7 subtests) fails.
    • Either scope name accepted again: TestTheRuntimesVerifierReadsTheChainOfRuns fails.
    • Boot member dropped: TestTheBootLineNamesTheExpectedSigningKey fails.
  • Checks on 842cf9c:
    • gofmt and go vet ./... are clean, with GOOS=darwin too. GOOS=freebsd and GOOS=windows builds succeed.
    • The focused tests pass locally with 0.30.0 and 0.27.1, and TestTheRuntimesVerifierReadsTheChainOfRuns also passes with 0.27.1 as JPACK_TEST_BIN.
    • CI passes: 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

  • Key rotation (Desk #238).
  • Desk's Jobs record panel (Desk #345).
  • Any change to the runtime.
  • The journal entry for a run (see Design).
  • Runner's own verify-run --chain report still names its scope one-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

kikashy and others added 2 commits October 11, 2026 15:09
…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>
@kikashy

kikashy commented Oct 11, 2026

Copy link
Copy Markdown
Contributor Author

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.

  1. HIGH internal/runner/signing_key_unix.go:94-100: a same-user process can overwrite the seed file in place, same inode, after checkSigningKey passes and before the seed is read, so the replacement's public key becomes the lifetime expectation and a run signed under it enters the chain as signed. os.SameFile compares device and inode only, so an atomic rename is caught and an in-place overwrite is not. Confirmed end to end with the test signer. Fix: one handle for the checks and the read with a metadata re-check after reading, and an independently supplied expected public key from the owner's list that the seed must match at start.
  2. LOW internal/runner/run_chain_interop_test.go:267: the scope assertion accepts both committed-prefix and one-supplied-chain, so the pinned v0.30.0 answering the obsolete name would pass. Confirmed with a wrapper that changed only that output. Fix: choose the expected name from the runtime under test.

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).

kikashy and others added 2 commits October 11, 2026 15:42
…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>
@kikashy
kikashy merged commit a0de4d8 into main Oct 11, 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.

Replacing Runner's key at its path yields completed runs signed by a key absent from Desk's list

1 participant