Skip to content

feat(release): sign an update manifest when a current key is provisioned - #528

Merged
BunsDev merged 3 commits into
mainfrom
feat/update-manifest-signing
Oct 3, 2026
Merged

BunsDev merged 3 commits into
mainfrom
feat/update-manifest-signing

Conversation

@BunsDev

@BunsDev BunsDev commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

Summary

This is the release side of the notify-only update channel (#477, plan item 3.2, first half). Each release can now publish a signed update-manifest.json. The desktop verifier and banner come in a later PR.

The feature ships inactive. release/update-manifest-keys.json starts with current: null, so releases behave exactly as they do today until the owner provisions a key.

Owner decisions this implements (2026-10-03)

  • Key custody: the private key is the UPDATE_MANIFEST_SIGNING_KEY secret in the existing protected release environment. Only the sign step's env sees it.
  • Notify only: the app will never install an update itself, and the Homebrew Cask is unchanged.
  • Signing tool: Node's built-in Ed25519 (crypto.sign(null, …)). No minisign and no new third-party tool or action.

Format

  • update-manifest.json: canonical JSON with sorted keys, no insignificant whitespace, JSON.stringify string escaping, integers only, UTF-8 with no BOM, and exactly one trailing LF. Fields:
    • schema: 1
    • version, tag, and the 40-character source_sha
    • artifacts.{aarch64,x86_64}.{file,sha256}, which must match SHA256SUMS
    • published_at, the signed tag's tagger time, so a retried run reproduces the same bytes
    • expires_at, at most 30 days after published_at
  • Signed bytes: the signature covers exactly the file bytes. A verifier rejects any file that does not re-serialize to identical bytes.
  • update-manifest.json.sig: a canonical JSON envelope, {"algorithm":"ed25519","key_id":"<16 hex>","schema":1,"signature":"<padded base64 of 64 bytes>"} plus a trailing LF.
  • Key id: the hex of the first 8 bytes of SHA-256 over the raw 32-byte public key.
  • Keys file: {schema:1, current:{keyId,publicKey}|null, next:…|null}, where publicKey is the base64 raw key. A next without a current, the same key in both slots, or a key id that does not match its key is treated as malformed.
  • verify: checks the signature before parsing anything. On failure it prints one bounded reason: keys_malformed, no_trusted_keys, signature_malformed, unknown_key_id, signature_invalid, manifest_malformed, manifest_not_canonical, manifest_not_yet_valid, manifest_expired or manifest_mismatch.

Differences from the design record

The dated design record is unchanged. Per the owner's 2026-10-03 decision, this PR differs from it in three ways, all recorded in docs/RELEASE.md:

  • Node's built-in Ed25519 replaces minisign.
  • The signature is the update-manifest.json.sig envelope, not a .minisig file.
  • The key is an unencrypted PKCS#8 PEM, held only in the protected release environment secret, instead of a password-protected key.

The future desktop verifier must expect exactly update-manifest.json and update-manifest.json.sig.

Activation and fail-closed rules (publish job)

  1. Resolve update manifest signing (always runs):
    • Legacy tag: a tag with no release/update-manifest-keys.json (such as v0.0.1 or v0.0.2, during a manual recovery run from main) predates the feature. It is inactive, posts a notice, and publishes the existing three assets.
    • Keys file present: the resolver runs. If the tag lacks the script, the step uses the copy at GITHUB_WORKFLOW_SHA, and fails closed if it can't read it. Later steps run the resolved $UPDATE_MANIFEST_SCRIPT.
    • With current: null, it writes active=false, emits a ::notice annotation, and publishes the existing three assets.
    • A malformed keys file fails the job.
  2. Build, Sign and Verify run only when active == 'true':
    • Build first checks that the local tag object (git rev-parse "$RELEASE_TAG^{tag}") equals the signed tag object the verify job checked, and fails closed on a mismatch or a missing value. Only then does it read the tagger date. It uses --expires-in-days 30.
    • Build refuses a published_at more than 300 seconds in the future with published_at_in_future. The docs cover tagger clock skew, which would otherwise fail self-verification with manifest_not_yet_valid, and how a tag created early shortens the 30-day window.
    • Sign is the only step that receives the secret. It reads the key by environment variable name only, and refuses any --key-env value that is not an environment variable name, so a PEM can never be passed on argv. It also refuses a key that is not the keys file's current key.
    • Verify checks against the keys file, the tag, the release commit and SHA256SUMS.
  3. When signing is active, a missing secret, a wrong key or a failed verification stops the job before the GitHub Release is created or changed.
  4. Exact asset sets: the four asset checks now use one literal RELEASE_ASSETS array. That is 3 names when inactive and 5 when active (adding update-manifest.json and update-manifest.json.sig), with no wildcard. An unresolved activation value fails closed.

The diff stays inside the publish job's checksum and asset-check area. It does not touch notify-homebrew (#527) or add a workflow (#526).

Owner steps to provision the key

The full procedure is in docs/RELEASE.md under "Update manifest signing".

  1. On a trusted machine, from a clean checkout of main:

    mkdir -p ~/.psyche-build-keys && chmod 700 ~/.psyche-build-keys
    node scripts/generate-update-signing-key.mjs --out ~/.psyche-build-keys/update-manifest-signing-key.pem

    The script writes the key with mode 0600, refuses to overwrite a file, and prints only the public entry and key id.

    It refuses to write inside a Git checkout, and that refusal fails closed:

    • Git runs with every GIT_* variable removed.
    • Only exit 128 with "not a git repository" counts as outside. A missing git, dubious ownership or any other result is refused.
    • A check that doesn't depend on Git also refuses any path with a .git segment, or any ancestor holding a .git file or directory.

    Writes are complete or leave nothing behind. The script loops over short writes and checks the final mode and size. It removes the file on any failure after creating it, including a failed close.

  2. Upload the private key. gh reads it from stdin:

    gh secret set UPDATE_MANIFEST_SIGNING_KEY --repo OpenCoven/psyche-build --env release < ~/.psyche-build-keys/update-manifest-signing-key.pem

    Then confirm the secret is listed with gh secret list --env release.

  3. Store an offline recovery copy, then delete the plaintext file.

  4. Open a PR that sets current in release/update-manifest-keys.json to the printed entry. Merge it only after step 2.

Rotation (stage the new key in next, then promote it and swap the secret) and revocation are documented in the same section.

Risk

R4. This touches signing, the release workflow, and a credential boundary.

  • No key is generated or committed. Tests create throwaway in-memory keys, or temp files outside the repository, and assert that no private key material is printed.
  • No secrets, environments, settings or workflow dispatches were touched.

Verification

All runs were on head 7a33a717, rebased on main with #525 and #526. It includes the fixes from the independent review and the review threads:

  • pnpm vitest --run __tests__/updateManifest.test.ts __tests__/releaseWorkflow.test.ts __tests__/releaseDocs.test.ts: all pass (34 + 48 + 27 = 109).
    • Added in this round: legacy and feature-era tag resolution, injected write failures, invocation through a symlinked path, and 2048-bit RSA plus Ed448 rejection (the 1024-bit key that CodeQL flagged is gone).
    • The new generator tests cover a redirecting GIT_DIR, GIT_CEILING_DIRECTORIES and GIT_WORK_TREE; no git on PATH; dubious ownership; a target inside .git/; and a lying git stopped by the .git check.
    • The new workflow test runs the tag-object check against a scratch repository with a matching, mismatched and empty value.
    • The new workflow tests run the real step scripts under /bin/bash, with a stateful fake gh.
    • They cover the exact 3-asset set when inactive and the exact 5-asset set when active.
    • They also cover missing manifest assets, a stray draft asset in either mode, an unresolved activation value, an empty secret, secret scoping (exactly one secrets.UPDATE_MANIFEST_SIGNING_KEY, used only in the sign step), and step order and gating.
  • bash ./scripts/agent-check fast (typecheck plus the repository contract) passed.
  • pnpm docs:focus:check passed.
  • actionlint .github/workflows/release.yml reports only the 3 SC2129 style notes already present on main, all in other jobs.
  • Full pnpm test (on the earlier head 32775b82; not re-run for the review fixes): 5777 passed. One timeout in recoveryHarness.test.ts under full-suite load; that file passes on its own (16/16).

Proof gap: no protected release run has exercised the active path. The first release after the key is provisioned is that evidence.

Rollback

Revert this PR. Until a key is provisioned, the inactive path already publishes exactly the previous three-asset set. After provisioning, setting current back to null turns signing off again without a code change.

Refs #477

🤖 Generated with Claude Code

Copilot AI balanced review requested due to automatic review settings October 3, 2026 02:26
@vercel

vercel Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
psyche-build-docs Ready Ready Preview Oct 3, 2026 3:59am UTC

Request Review

BunsDev added a commit that referenced this pull request Oct 3, 2026
Addresses the independent review of #528.

- generate-update-signing-key: the Git-checkout refusal no longer fails
  open. Git runs with every GIT_* variable stripped; only exit 128 with
  "not a git repository" counts as outside, and a missing git, dubious
  ownership or any other result refuses. A Git-independent backstop refuses
  any target with a .git path segment or an ancestor holding a .git file or
  directory. writeSync's byte count is checked, and a partial key file is
  removed.
- update-manifest build refuses a published_at more than the 300-second
  clock-skew allowance in the future (reason published_at_in_future).
- release.yml: the verify job exports the verified signed tag object SHA;
  the publish job's manifest build compares it with
  git rev-parse "$RELEASE_TAG^{tag}" before reading the tagger date, and
  fails closed on a mismatch or a missing value.
- docs/RELEASE.md records how the implementation differs from the design
  record (Node Ed25519, the .sig envelope, an unencrypted PKCS#8 PEM held
  only in the protected release environment) and the exact asset names, and
  documents tagger clock skew and early-tag window shortening.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread __tests__/updateManifest.test.ts Fixed
Comment thread __tests__/updateManifest.test.ts Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Legacy-tag recovery and key-generation failure handling currently violate the intended fail-closed behavior.

Review effort: Balanced
Findings: 2 High severity · 1 Medium severity

Open (3)
What changed in this PR

Adds an opt-in Ed25519-signed update manifest to the protected release pipeline.

Changes:

  • Adds canonical manifest generation, signing, verification, and key generation.
  • Integrates optional manifest assets into release publication.
  • Documents and tests key provisioning, rotation, verification, and asset handling.
File Description
scripts/​update-manifest.mjs Implements manifest lifecycle and CLI.
scripts/​update-manifest.d.mts Declares manifest APIs and types.
scripts/​generate-update-signing-key.mjs Generates owner-managed signing keys.
scripts/​generate-update-signing-key.d.mts Declares key-generation APIs.
release/​update-manifest-keys.json Adds inactive public-key configuration.
docs/​RELEASE.md Documents signing operations and recovery.
agent/​manifest.yaml Classifies signing files as R4.
.github/​workflows/​release.yml Adds conditional signing and publication.
__tests__/​updateManifest.test.ts Tests manifest and key behavior.
__tests__/​releaseWorkflow.test.ts Tests workflow gating and asset sets.
__tests__/​releaseDocs.test.ts Tests release documentation coverage.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/generate-update-signing-key.mjs Outdated
Comment thread scripts/generate-update-signing-key.mjs Outdated
Comment thread .github/workflows/release.yml Outdated
BunsDev and others added 2 commits October 2, 2026 22:53
Adds the release side of the notify-only update channel (#477, item 3.2).

- scripts/update-manifest.mjs: status/build/sign/verify with Node's built-in
  Ed25519. The manifest is canonical JSON (sorted keys, no whitespace,
  UTF-8, one trailing LF) and the signature covers exactly its bytes. The
  .sig envelope is canonical JSON carrying algorithm, key_id, schema and the
  base64 signature. Key id = first 8 bytes of SHA-256 over the raw public
  key. verify checks the signature before parsing and reports one bounded
  reason.
- scripts/generate-update-signing-key.mjs: owner-only key generation. Writes
  the private PEM with mode 0600, refuses overwrites and Git working trees,
  and never prints the private key.
- release/update-manifest-keys.json: current/next public key slots, empty.
- release.yml publish job: resolve activation, build, sign (the only step
  that sees UPDATE_MANIFEST_SIGNING_KEY), self-verify. With no current key
  it emits a notice and publishes the existing three assets; with one, a
  missing secret or failed verification blocks publication and the exact
  asset set becomes five.
- docs/RELEASE.md: format, activation rules, provisioning, rotation and
  revocation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses the independent review of #528.

- generate-update-signing-key: the Git-checkout refusal no longer fails
  open. Git runs with every GIT_* variable stripped; only exit 128 with
  "not a git repository" counts as outside, and a missing git, dubious
  ownership or any other result refuses. A Git-independent backstop refuses
  any target with a .git path segment or an ancestor holding a .git file or
  directory. writeSync's byte count is checked, and a partial key file is
  removed.
- update-manifest build refuses a published_at more than the 300-second
  clock-skew allowance in the future (reason published_at_in_future).
- release.yml: the verify job exports the verified signed tag object SHA;
  the publish job's manifest build compares it with
  git rev-parse "$RELEASE_TAG^{tag}" before reading the tagger date, and
  fails closed on a mismatch or a missing value.
- docs/RELEASE.md records how the implementation differs from the design
  record (Node Ed25519, the .sig envelope, an unencrypted PKCS#8 PEM held
  only in the protected release environment) and the exact asset names, and
  documents tagger clock skew and early-tag window shortening.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses the remaining #528 review threads.

- release.yml: manual recovery checks out the requested tag's source, and
  pre-feature tags (v0.0.1, v0.0.2) have neither the keys file nor the
  script. A tag without release/update-manifest-keys.json is now an
  explicit legacy tag: inactive, with a notice. A tag with the keys file
  runs the resolver, taking scripts/update-manifest.mjs from
  GITHUB_WORKFLOW_SHA when the tag lacks it, and fails closed if it cannot.
  Every later step runs the resolved $UPDATE_MANIFEST_SCRIPT.
- Both scripts now detect direct invocation through the entry's real path,
  so a symlinked location (e.g. /var -> /private/var) still runs main().
- generate-update-signing-key: derives the public entry before creating the
  file, loops over short writes, checks the final size and mode, and
  unlinks the target on every post-open failure, including a failed close.
  It reports EKEYCLEANUP if the removal itself fails.
- Tests: replaceAll for the CRLF case; the non-Ed25519 rejection uses
  2048-bit RSA and Ed448 instead of 1024-bit RSA; injected write-failure
  cases; legacy and feature-era tag resolution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@BunsDev
BunsDev merged commit e9956fc into main Oct 3, 2026
17 checks passed
@BunsDev
BunsDev deleted the feat/update-manifest-signing branch October 3, 2026 04:37

This branch was successfully deployed

1 active deployment
Preview — 7a33a717 Deployed Oct 3, 2026 by vercel[bot]
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.

3 participants