diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8cf3f757..818c72f25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,6 +129,24 @@ jobs: echo "crates=false" >> "$GITHUB_OUTPUT" fi + signatures: + name: ๐Ÿ” Verified Signatures + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [security] + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Verify every introduced GitHub commit and release tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/signing/verify-github-signatures.mjs + # ---------------------------------------------------------- # 1. QUALITY GATE: Lint + Typecheck + Tests (parallel matrix) # ---------------------------------------------------------- @@ -417,20 +435,21 @@ jobs: # ---------------------------------------------------------- # 3. CI SUCCESS: single required-status aggregator - # (security + quality + changes + rust-tauri + core-rust + build + e2e + vrt) + # (security + signatures + quality + changes + rust-tauri + core-rust + build + e2e + vrt) # ---------------------------------------------------------- ci-success: name: โœ… CI Success runs-on: ubuntu-latest timeout-minutes: 5 # QNBS-v3: Every unconditional job is either required here or explicitly advisory at job level. - needs: [security, quality, changes, rust-tauri, core-rust, build, e2e, lighthouse, vrt] + needs: [security, signatures, quality, changes, rust-tauri, core-rust, build, e2e, lighthouse, vrt] if: always() steps: - name: Verify all required jobs succeeded run: | FAIL=0 [ "${{ needs.security.result }}" = "success" ] || FAIL=1 + [ "${{ needs.signatures.result }}" = "success" ] || FAIL=1 [ "${{ needs.quality.result }}" = "success" ] || FAIL=1 [ "${{ needs.changes.result }}" = "success" ] || FAIL=1 if [ "${{ needs.rust-tauri.result }}" != "success" ] && [ "${{ needs.rust-tauri.result }}" != "skipped" ]; then @@ -446,6 +465,7 @@ jobs: if [ "$FAIL" = "1" ]; then echo "One or more required jobs did not succeed:" echo " security: ${{ needs.security.result }}" + echo " signatures: ${{ needs.signatures.result }}" echo " quality: ${{ needs.quality.result }}" echo " changes: ${{ needs.changes.result }}" echo " rust-tauri: ${{ needs.rust-tauri.result }} (skipped = OK, src-tauri untouched)" diff --git a/AGENTS.md b/AGENTS.md index 39f51993f..eb431789d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -271,6 +271,23 @@ and invoke local binaries without pnpm's workspace-state preflight in the hook p The pre-commit hook is not a substitute for the complete pre-push gate; CI remains mandatory when hooks are not installed. +### Verified signing cutover + +`required_signatures` remains enabled on `main`. Before creating or pushing new history, run +`pnpm run signing:doctor` and install the hooks with `pnpm run hooks:install`. The pre-commit hook +fails closed when the effective signing configuration cannot create and Git-verify a signed commit; +the pre-push hook verifies every commit introduced by every ref update and verifies both annotated +release tags and their target commits. CI verifies GitHub's `commit.verification.verified` result +for the complete introduced range and includes that gate in `โœ… CI Success`. + +Local Git verification and GitHub Verified status are distinct: a local `git verify-commit` pass is +necessary but cannot establish GitHub account/key association. Never use `--no-gpg-sign`, `--no-verify`, +unsigned temporary commits, or unsigned release tags as recovery. Squash merges create a new signed +result and do not rewrite or retroactively verify legacy unsigned source commits. Worktree-local Git +configuration overrides repository and global configuration; the doctor reports unsafe environment +overrides. See [`docs/VERIFIED-SIGNING.md`](docs/VERIFIED-SIGNING.md) for the recovery and audit +procedure. + --- ## Testing Instructions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab55f31cf..f44c75f49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -511,3 +511,12 @@ The tool will automatically appear in `WriterView` if added to the tool list in ## License [MIT](LICENSE) +## Signed commits and tags + +New commits, outgoing pushes, and release tags must be signed. Run `pnpm run signing:doctor` +before installing hooks or when a signing operation fails, then install the repository hooks with +`pnpm run hooks:install`. The hooks reject missing or invalid local signatures; CI additionally +requires GitHub's `verification.verified` result for every introduced commit and for both an +annotated tag and its target commit. Do not bypass these checks with `--no-gpg-sign`, `--no-verify`, +or unsigned fallback objects. See [`docs/VERIFIED-SIGNING.md`](docs/VERIFIED-SIGNING.md) for +configuration precedence, recovery, and squash-history semantics. diff --git a/README.md b/README.md index 0410afb73..8c23e8a8e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales โ€” 2925 keys - 6937+ tests / 573 files + 6947+ tests / 574 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings โ†’ AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys ร— 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (6937+ tests / 573 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (6947+ tests / 574 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -549,7 +549,7 @@ WorldScript-Studio/ โ”‚ โ”œโ”€โ”€ sw.js # PWA Service Worker โ”‚ โ””โ”€โ”€ manifest.json # PWA Web App Manifest v3 โ”œโ”€โ”€ tests/ -โ”‚ โ”œโ”€โ”€ unit/ # Vitest unit tests (6937+ tests, 573 files) โ€” count spans tests/, components/, packages/*/tests/, not just this folder +โ”‚ โ”œโ”€โ”€ unit/ # Vitest unit tests (6947+ tests, 574 files) โ€” count spans tests/, components/, packages/*/tests/, not just this folder โ”‚ โ”‚ โ”œโ”€โ”€ ai/ # aiSmallModules, aiCoreFallbackPaths โ”‚ โ”‚ โ””โ”€โ”€ settings/ # WebLlmPanel, AiSections โ”‚ โ””โ”€โ”€ e2e/ # Playwright specs + helpers.ts @@ -711,7 +711,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard โ€” SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):** -- **6937+ unit tests** across **573 test files** โ€” CI is authoritative for pass/fail +- **6947+ unit tests** across **574 test files** โ€” CI is authoritative for pass/fail - Coverage thresholds: lines โ‰ฅ 80 ยท branches โ‰ฅ 66 ยท functions โ‰ฅ 72 ยท statements โ‰ฅ 78 โ€” enforced in CI (see Codecov badge for live metrics) - i18n: **2925 keys ร— 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/docs/CI.md b/docs/CI.md index eaaee91b9..b65d740bb 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -37,8 +37,8 @@ CI runs for the affected test path before removing a temporary quarantine. ### Gate authority -`โœ… CI Success` is the required branch-protection status and aggregates `security`, `quality`, -`changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, and `vrt`. `e2e-deep` and +`โœ… CI Success` is the required branch-protection status and aggregates `security`, `signatures`, +`quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, and `vrt`. `e2e-deep` and `storybook` are explicitly advisory at job level while their stability criteria are measured. The `deploy` job depends only on that aggregate and remains main-push-only. @@ -104,6 +104,7 @@ security โ”€โ”€โ–บ quality โ”€โ”€โ”ฌโ”€โ”€โ–บ build โ”€โ”€โ”ฌโ”€โ”€โ–บ lighthous โ””โ”€โ”€โ–บ storybook (advisory) security โ”€โ”ฌ +signatures โ”€โ”ค quality โ”€โ”€โ”ผโ”€โ”€โ–บ ci-success (required-status aggregator) changes โ”€โ”€โ”ค rust โ”€โ”€โ”€โ”€โ”ค @@ -135,7 +136,8 @@ registry gzip-decoding failure mode, while OSV failures remain blocking. | `lighthouse` | `build` | LHCI (mobile): **accessibility error gate** `minScore: 0.95`; **CLS error** โ‰ค 0.1; performance/SEO warn. Desktop run: `continue-on-error: true` until baselines stabilise. Timeout 25 min. | | `storybook` | `quality` | Cloud-first โ€” Storybook build + test-runner only run in CI (not locally); Playwright browser cache `v5`; `--maxWorkers=2 --junit` (non-blocking, `continue-on-error: true` โ€” see [exit criteria](#non-blocking-gates--exit-criteria-f-13)); artifacts uploaded always. Debug: manual `storybook-debug.yml` workflow. | | `vrt` | `build` | Visual regression against production `dist`; `toHaveScreenshot()` with committed PNG baselines (4 views ร— Chromium); artifacts uploaded always | -| `ci-success` | `security`, `quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, `vrt` | Required-status **aggregator** โ€” `if: always()`, fails if any required release-safety job does not resolve to `success`; Storybook and deep-E2E are explicitly advisory. Rust jobs are legitimately skipped when their paths are untouched. | +| `signatures` | `security` | Read-only GitHub API verification of every commit in the complete introduced range; pull-request commit pagination; and annotated release-tag plus target-commit verification. | +| `ci-success` | `security`, `signatures`, `quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, `vrt` | Required-status **aggregator** โ€” `if: always()`, fails if any required release-safety job does not resolve to `success`; signature verification is authoritative; Storybook and deep-E2E are explicitly advisory. Rust jobs are legitimately skipped when their paths are untouched. | | `deploy` | `ci-success` | **Only** `main` push (not PR), and only after the aggregate gate succeeds; the Pages artifact is resolved from the same workflow run. | > **Desktop:** On-demand / tag-driven Tauri bundles live in [`tauri-build.yml`](../.github/workflows/tauri-build.yml); **`v*` tags** additionally publish installers on a **GitHub Release**. See [`docs/TAURI-CI.md`](TAURI-CI.md). Desktop CI does not block the web deploy graph above. diff --git a/docs/VERIFIED-SIGNING.md b/docs/VERIFIED-SIGNING.md new file mode 100644 index 000000000..527f95995 --- /dev/null +++ b/docs/VERIFIED-SIGNING.md @@ -0,0 +1,71 @@ +# Verified signing policy + +WorldScript Studio requires signed commit objects at the local hook boundary and requires +GitHub's `commit.verification.verified == true` result at the CI boundary. These are related +but different checks: + +- `git verify-commit` proves that the local Git installation can validate the signature object + against its configured trust/key policy. It does not prove that GitHub will associate the + commit with a verified account. +- GitHub's `verification.verified` result is the release and merge gate. It covers GitHub's + signature parser, key association, and account identity rules. +- Annotated release tags have two objects to verify: the tag object and its target commit. + Lightweight release tags are rejected because they have no independently verifiable tag + object; a verified target commit alone is insufficient for a release tag. + +## Local setup and recovery + +Run `pnpm run signing:doctor` after configuring a signing key. The doctor performs a +plumbing-level `git commit-tree -S` probe in an isolated temporary repository and validates the +result with Git-native verification. It never invokes normal repository hooks, reads private key +material, or prints signatures. `pnpm run hooks:install` installs the fail-closed pre-commit and +pre-push wrappers. + +If a hook rejects a commit or push: + +1. Run the doctor and correct the reported effective configuration, identity, key availability, + or trust/allowed-signers configuration. +2. Re-run the exact failed operation. Never use `--no-gpg-sign`, `--no-verify`, an unsigned + temporary commit, or an unsigned tag as a recovery path. +3. Use `pnpm run signing:check-range -- before..after` to inspect an exact local range. +4. For a pull request, use `pnpm run signing:verify-remote -- / ` when a + GitHub token is available; the CI gate remains authoritative. + +The hook reads effective Git configuration, including repository and worktree configuration. +Repository-local and worktree-local values override global values, and environment-provided Git +configuration can override all of them. The doctor reports unsafe configuration overrides rather +than silently accepting them. Configure only a public signing-key reference where possible; +never copy, print, commit, or place private key material in the repository. + +## History and squash semantics + +The signing cutover applies to every new commit, push, release tag, and GitHub merge result. It +does not rewrite legacy history. A squash merge creates a new signed commit whose tree contains +the reviewed change; it does not make the unsigned source commits in the old branch signed or +erase their historical verification state. A historical audit is evidence for migration planning, +not a release waiver for new unsigned objects. + +CI verifies the complete `before..after` range for branch pushes and the complete paginated PR +commit list for pull requests. Public-fork pull requests use read-only GitHub API access. API +errors, missing pages, missing verification data, invalid signatures, unsigned commits, and +unverified tag objects fail closed. + +## Historical audit snapshot + +The audit below is generated against `main` with GitHub's verification result, not merely local +Git trust. Dates are UTC and the interval is inclusive of commits reachable from `main` whose +committer timestamp is within the stated trailing window. Keep the command output or API response +with the release evidence when refreshing these figures. + +| Window ending 2026-08-23 UTC | Verified | Unverified | Total | +| --- | ---: | ---: | ---: | +| 7 days | 89 | 0 | 89 | +| 14 days | 124 | 0 | 124 | +| 30 days | 178 | 0 | 178 | + +This snapshot intentionally distinguishes the signed squash result from the source history that +preceded it. Open and merged PR source histories should be audited separately when investigating +legacy unsigned commits. The counts were collected on 2026-08-23 with GitHub's REST commits API, +`sha=main`, at collection time `2026-08-23T00:15:24Z`, with cutoff +`2026-08-23T00:15:24Z` and trailing UTC windows, using the `commit.verification.verified` +boolean; they are not inferred from local Git trust. diff --git a/package.json b/package.json index e271365aa..708bb32d9 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ }, "scripts": { "hooks:install": "simple-git-hooks", + "signing:doctor": "node scripts/signing/doctor.mjs", + "signing:check-range": "node scripts/signing/check-range.mjs", + "signing:verify-remote": "node scripts/signing/verify-remote.mjs", "toolchain:check": "node scripts/check-pnpm-toolchain.mjs", "deps:verify": "node scripts/dependency-state.mjs verify", "deps:reconcile": "node scripts/dependency-state.mjs reconcile", @@ -203,7 +206,7 @@ }, "simple-git-hooks": { "pre-commit": "node scripts/hooks/pre-commit.mjs", - "pre-push": "node scripts/hooks/pre-push.mjs" + "pre-push": "node scripts/hooks/pre-push.mjs \"$@\"" }, "lint-staged": { "*.{ts,tsx,js,mjs,css,md,json}": "biome check --write --error-on-warnings --no-errors-on-unmatched --files-ignore-unknown=true", diff --git a/scripts/check-tauri-import-boundary.mjs b/scripts/check-tauri-import-boundary.mjs index 636e99f8b..8409d0f0a 100644 --- a/scripts/check-tauri-import-boundary.mjs +++ b/scripts/check-tauri-import-boundary.mjs @@ -32,6 +32,8 @@ const IGNORE_DIRS = new Set([ 'storybook-static', 'coverage', '.git', + // QNBS-v3: linked worktrees are separate checkout artifacts, not application source. + '.worktrees', 'reports', 'graphify-out', '.codegraph', diff --git a/scripts/hooks/pre-commit.mjs b/scripts/hooks/pre-commit.mjs index 8ce3f04e3..9a6ef9eec 100644 --- a/scripts/hooks/pre-commit.mjs +++ b/scripts/hooks/pre-commit.mjs @@ -1,5 +1,6 @@ import process from 'node:process'; -import { ensureDependencyState, runLocalBinary } from './shared.mjs'; +import { ensureDependencyState, runLocalBinary, runNodeScript } from './shared.mjs'; +if (runNodeScript('scripts/signing/doctor.mjs', ['--hook']) !== 0) process.exit(1); if (!ensureDependencyState()) process.exit(1); process.exit(runLocalBinary('lint-staged')); diff --git a/scripts/hooks/pre-push.mjs b/scripts/hooks/pre-push.mjs index 13582888e..5c8f3aa39 100644 --- a/scripts/hooks/pre-push.mjs +++ b/scripts/hooks/pre-push.mjs @@ -1,4 +1,6 @@ import process from 'node:process'; import { runNodeScript } from './shared.mjs'; +if (runNodeScript('scripts/signing/verify-outgoing.mjs', process.argv.slice(2)) !== 0) + process.exit(1); process.exit(runNodeScript('scripts/ci-prepush-lowend.mjs')); diff --git a/scripts/signing/check-range.mjs b/scripts/signing/check-range.mjs new file mode 100644 index 000000000..ffa620613 --- /dev/null +++ b/scripts/signing/check-range.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node +import { verifyCommitRange } from './signing-core.mjs'; + +const range = process.argv[2]; +if (!range) { + console.error('usage: pnpm run signing:check-range -- '); + process.exit(2); +} +try { + const reports = verifyCommitRange(range); + for (const report of reports) { + console.log( + `${report.sha.slice(0, 12)} ${report.verification.ok ? 'verified' : 'REJECTED'} ${report.verification.reason} ${report.subject}`, + ); + } + process.exit(reports.every((report) => report.verification.ok) ? 0 : 1); +} catch (error) { + console.error( + `signing range check failed: ${error instanceof Error ? error.message : 'invalid Git range'}`, + ); + process.exit(1); +} diff --git a/scripts/signing/doctor.mjs b/scripts/signing/doctor.mjs new file mode 100644 index 000000000..c2ce4d5f5 --- /dev/null +++ b/scripts/signing/doctor.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import { + getIdentity, + getSigningConfig, + getUnsafeOverrides, + isGitHubCompatibleEmail, + isSigningEnabled, + runSigningProbe, + safeConfigSummary, +} from './signing-core.mjs'; + +const jsonMode = process.argv.includes('--json'); +const cwd = process.cwd(); +const signing = getSigningConfig(cwd); +const identity = getIdentity(cwd); +const unsafeOverrides = getUnsafeOverrides(); +const probe = runSigningProbe(cwd); +const summary = { + ...safeConfigSummary(cwd), + probe: { ok: probe.ok, reason: probe.reason ?? 'signed probe verified' }, +}; + +if (jsonMode) { + process.stdout.write(`${JSON.stringify(summary)}\n`); +} else { + console.log(`signing format: ${signing.format}`); + console.log(`commit.gpgsign: ${isSigningEnabled(signing.config) ? 'enabled' : 'disabled'}`); + console.log(`signing key: ${signing.keyConfigured ? 'configured' : 'missing'}`); + console.log( + `identity: ${identity.name && identity.email ? 'configured' : 'missing'} (${isGitHubCompatibleEmail(identity.email) ? 'GitHub noreply-compatible' : 'GitHub identity requires account verification'})`, + ); + console.log( + `hooks: ${summary.hooks.pathConfigured ? 'custom path configured' : 'default path'} (${summary.hooks.hooksInstalled ? 'available' : 'not installed'})`, + ); + console.log( + `unsafe config overrides: ${unsafeOverrides.length ? unsafeOverrides.join(', ') : 'none detected'}`, + ); + console.log(`isolated signing probe: ${probe.ok ? 'passed' : `failed โ€” ${probe.reason}`}`); +} + +const hookFailure = + !signing.enabled || + !signing.keyConfigured || + !identity.name || + !identity.email || + unsafeOverrides.length > 0 || + !probe.ok; +process.exit(hookFailure ? 1 : 0); diff --git a/scripts/signing/signing-core.d.mts b/scripts/signing/signing-core.d.mts new file mode 100644 index 000000000..715a9d42c --- /dev/null +++ b/scripts/signing/signing-core.d.mts @@ -0,0 +1,122 @@ +export interface VerificationResult { + ok: boolean; + reason: string; +} + +export interface GitResult { + status: number; + stdout: string; + stderr: string; + error?: Error; +} + +export interface RefUpdate { + localRef: string; + localSha: string; + remoteRef: string; + remoteSha: string; +} + +export interface SigningConfig { + format: string; + keyConfigured: boolean; + gpgProgramConfigured: boolean; + allowedSignersConfigured: boolean; + enabled: boolean; + keyDisplay: string; + allowedSignersFile: string; + config: Record; +} + +export interface GitOptions { + cwd?: string; + input?: string; +} + +export interface OutgoingReport { + sha: string; + subject: string; + verification: VerificationResult; +} + +export interface OutgoingResult { + ok: boolean; + reports?: OutgoingReport[]; + reason: string; +} + +export function runGit(args: string[], options?: GitOptions): GitResult; +export function gitOutput(args: string[], options?: GitOptions): string; +export function isSha(value: unknown): boolean; +export function isZeroSha(value: unknown): boolean; +export function getRepositoryRoot(cwd?: string): string | null; +export function getGitDirectory(cwd?: string): string | null; +export function getConfig(cwd?: string): Record; +export function getIdentity(cwd?: string): { name: string; email: string }; +export function getUnsafeOverrides(env?: Record): string[]; +export function isSigningEnabled(config: Record): boolean; + +export function classifyCommitObject(input: { + objectType: string; + contents: string; + verificationStatus: number; +}): VerificationResult; +export function classifyTagVerification(input: { + objectType: string; + targetType: string; + tagVerificationStatus: number; + commitVerification: VerificationResult; +}): VerificationResult; +export function getSigningConfig(cwd?: string): SigningConfig; +export function hasCommitSignature(commitText: string): boolean; +export function isGitHubCompatibleEmail(email: string): boolean; +export function parseRefUpdate(line: string): RefUpdate | null; +export function selectIntroducedCommits(commits: string[], reachableFromBase: string[]): string[]; +export function runSigningProbe(cwd?: string): { ok: boolean; reason?: string; commit?: string }; +export function verifyCommitObject(sha: string, cwd?: string): VerificationResult; +export function commitSubject(sha: string, cwd?: string): string; +export function commitsInRange(range: string, cwd?: string): string[]; +export function verifyCommitRange( + range: string, + cwd?: string, +): Array<{ sha: string; subject: string; verification: VerificationResult }>; +export function pushEventRange(payload: { before?: string; after?: string }): { + before: string; + after: string; +}; +export function pushCommitShas( + payload: { before?: string; after?: string }, + cwd?: string, + rangeResolver?: (range: string, cwd?: string) => string[], +): string[]; +export function parseAnnotatedTag( + sha: string, + cwd?: string, +): { objectType: 'commit'; target: string } | { objectType: 'tag'; target: string; targetType: string } | null; +export function verifyTagObject(sha: string, cwd?: string): VerificationResult; +export function remoteTrackingBases(remote: string, remoteRef: string, cwd?: string): string[]; +export function outgoingBaseShas(update: RefUpdate, fallbackBases: string[]): string[]; +export function introducedCommits(update: RefUpdate, remote: string, cwd?: string): string[]; +export function safeConfigSummary(cwd?: string): { + signing: { + format: string; + enabled: boolean; + keyConfigured: boolean; + keyDisplay: string; + gpgProgramConfigured: boolean; + allowedSignersConfigured: boolean; + }; + identity: { nameConfigured: boolean; emailConfigured: boolean; githubCompatible: boolean }; + hooks: { pathConfigured: boolean; directory: string; hooksInstalled: boolean }; + unsafeOverrides: string[]; +}; +export function verifyOutgoingUpdates( + lines: string[], + remote: string, + cwd?: string, + dependencies?: { + verifyCommitObject?: (sha: string) => VerificationResult; + verifyTagObject?: (sha: string) => VerificationResult; + introducedCommits?: (update: RefUpdate) => string[]; + }, +): OutgoingResult; diff --git a/scripts/signing/signing-core.mjs b/scripts/signing/signing-core.mjs new file mode 100644 index 000000000..64b28b5b8 --- /dev/null +++ b/scripts/signing/signing-core.mjs @@ -0,0 +1,407 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; + +const SHA = /^[0-9a-f]{40}$/i; +const ZERO_SHA = /^0{40}$/; +const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; + +export function runGit(args, { cwd = process.cwd(), input = '' } = {}) { + const result = spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + SSH_ASKPASS: '/bin/false', + SSH_ASKPASS_REQUIRE: 'force', + }, + input, + timeout: 5000, + killSignal: 'SIGTERM', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { + status: result.status ?? 1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + error: result.error, + }; +} + +export function gitOutput(args, options = {}) { + const result = runGit(args, options); + return result.status === 0 ? result.stdout.trim() : ''; +} + +export function isSha(value) { + return SHA.test(value); +} + +export function isZeroSha(value) { + return ZERO_SHA.test(value); +} + +export function getRepositoryRoot(cwd = process.cwd()) { + const root = gitOutput(['rev-parse', '--show-toplevel'], { cwd }); + return root ? resolve(root) : null; +} + +export function getGitDirectory(cwd = process.cwd()) { + const gitDir = gitOutput(['rev-parse', '--git-dir'], { cwd }); + return gitDir ? resolve(cwd, gitDir) : null; +} + +export function getConfig(cwd = process.cwd()) { + const keys = [ + 'user.email', + 'user.name', + 'user.signingkey', + 'commit.gpgsign', + 'gpg.format', + 'gpg.program', + 'gpg.ssh.allowedSignersFile', + 'core.hooksPath', + ]; + const values = Object.fromEntries( + keys.map((key) => [key, gitOutput(['config', '--get', key], { cwd })]), + ); + return values; +} + +export function getIdentity(cwd = process.cwd()) { + const config = getConfig(cwd); + return { name: config['user.name'], email: config['user.email'] }; +} + +export function isGitHubCompatibleEmail(email) { + return /^\d+\+[^@\s]+@users\.noreply\.github\.com$/i.test(email); +} + +export function getUnsafeOverrides(env = process.env) { + const names = [ + 'GIT_CONFIG_NOSYSTEM', + 'GIT_CONFIG_SYSTEM', + 'GIT_CONFIG_GLOBAL', + 'GIT_CONFIG_COUNT', + 'GIT_CONFIG_PARAMETERS', + ]; + return names.filter((name) => env[name] !== undefined).map((name) => name); +} + +export function isSigningEnabled(config) { + return /^(true|yes|on|1)$/i.test(config['commit.gpgsign'] ?? ''); +} + +export function getSigningConfig(cwd = process.cwd()) { + const config = getConfig(cwd); + const allowedSignersFile = config['gpg.ssh.allowedSignersFile'] + ? gitOutput(['config', '--path', '--get', 'gpg.ssh.allowedSignersFile'], { cwd }) + : ''; + return { + format: config['gpg.format'] || 'openpgp', + keyConfigured: Boolean(config['user.signingkey']), + gpgProgramConfigured: Boolean(config['gpg.program']), + allowedSignersConfigured: Boolean(config['gpg.ssh.allowedSignersFile']), + enabled: isSigningEnabled(config), + keyDisplay: config['user.signingkey'] ? basename(config['user.signingkey']) : '', + allowedSignersFile, + config, + }; +} + +function configureProbeRepository(repo, signing, identity) { + const settings = [ + ['user.name', identity.name || 'WorldScript signing probe'], + ['user.email', identity.email || 'signing-probe@users.noreply.github.com'], + ['commit.gpgsign', 'true'], + ['core.hooksPath', join(repo, 'hooks-disabled')], + ]; + if (signing.config['gpg.format']) settings.push(['gpg.format', signing.config['gpg.format']]); + if (signing.config['user.signingkey']) + settings.push(['user.signingkey', signing.config['user.signingkey']]); + if (signing.config['gpg.program']) settings.push(['gpg.program', signing.config['gpg.program']]); + if (signing.allowedSignersFile) + settings.push(['gpg.ssh.allowedSignersFile', signing.allowedSignersFile]); + for (const [key, value] of settings) { + const result = runGit(['config', key, value], { cwd: repo }); + if (result.status !== 0) return false; + } + return true; +} + +function signingFailureReason(stderr) { + const message = stderr.toLowerCase(); + if (message.includes('passphrase')) return 'signing key requires an unavailable passphrase'; + if (message.includes('no such file') || message.includes('cannot open')) + return 'signing key or signer program is unavailable'; + return 'Git signing command failed'; +} + +export function runSigningProbe(cwd = process.cwd()) { + const signing = getSigningConfig(cwd); + const identity = getIdentity(cwd); + if (!signing.enabled) return { ok: false, reason: 'commit.gpgsign is not enabled' }; + if (!signing.keyConfigured) return { ok: false, reason: 'no signing key is configured' }; + if (!identity.name || !identity.email) + return { ok: false, reason: 'user.name and user.email are required' }; + + let probe; + try { + probe = mkdtempSync(join(tmpdir(), 'worldscript-signing-probe-')); + } catch (error) { + if (error?.code !== 'EROFS' && error?.code !== 'EACCES') throw error; + probe = mkdtempSync(join(cwd, '.worldscript-signing-probe-')); + } + try { + let result = runGit(['init', '--quiet', '--initial-branch=main', probe]); + if (result.status !== 0 || !configureProbeRepository(probe, signing, identity)) { + return { ok: false, reason: 'isolated probe repository could not be configured' }; + } + result = runGit( + ['commit-tree', '-S', '-m', 'WorldScript signing capability probe', EMPTY_TREE], + { + cwd: probe, + }, + ); + if (result.status !== 0 || !isSha(result.stdout.trim())) { + return { + ok: false, + reason: `plumbing-level signed commit could not be created: ${signingFailureReason(result.stderr)}`, + }; + } + const commit = result.stdout.trim(); + const verification = verifyCommitObject(commit, probe); + return verification.ok + ? { ok: true, commit } + : { ok: false, reason: `Git-native verification failed: ${verification.reason}` }; + } finally { + rmSync(probe, { recursive: true, force: true }); + } +} + +export function hasCommitSignature(commitText) { + return /(^|\n)gpgsig /.test(commitText); +} + +export function classifyCommitObject({ objectType, contents, verificationStatus }) { + if (objectType !== 'commit') return { ok: false, reason: 'object is not a commit' }; + if (!hasCommitSignature(contents)) return { ok: false, reason: 'commit has no signature object' }; + if (verificationStatus !== 0) + return { ok: false, reason: 'Git-native signature verification failed' }; + return { ok: true, reason: 'Git-native signature verified' }; +} + +export function verifyCommitObject(sha, cwd = process.cwd()) { + if (!isSha(sha)) return { ok: false, reason: 'invalid commit SHA' }; + const object = runGit(['cat-file', '-t', sha], { cwd }); + const contents = runGit(['cat-file', '-p', sha], { cwd }); + const verified = runGit(['verify-commit', '--raw', sha], { cwd }); + return classifyCommitObject({ + objectType: object.status === 0 ? object.stdout.trim() : '', + contents: contents.status === 0 ? contents.stdout : '', + verificationStatus: verified.status, + }); +} + +export function commitSubject(sha, cwd = process.cwd()) { + return gitOutput(['show', '-s', '--format=%s', sha], { cwd }) || '(subject unavailable)'; +} + +export function commitsInRange(range, cwd = process.cwd()) { + if (!range || /\s/.test(range)) throw new Error('range must be one Git revision range'); + const result = runGit(['rev-list', '--reverse', range], { cwd }); + if (result.status !== 0) throw new Error('requested Git range is not available'); + return result.stdout.split(/\r?\n/).filter(Boolean); +} + +export function verifyCommitRange(range, cwd = process.cwd()) { + return commitsInRange(range, cwd).map((sha) => ({ + sha, + subject: commitSubject(sha, cwd), + verification: verifyCommitObject(sha, cwd), + })); +} + +export function pushEventRange(payload) { + const before = payload?.before; + const after = payload?.after; + if (!isSha(before)) throw new Error('push event is missing an exact before SHA'); + if (!isSha(after) || isZeroSha(after)) + throw new Error('push event is missing an exact after SHA'); + return { before, after }; +} + +export function pushCommitShas(payload, cwd = process.cwd(), rangeResolver = commitsInRange) { + const { before, after } = pushEventRange(payload); + return isZeroSha(before) ? [after] : rangeResolver(`${before}..${after}`, cwd); +} + +// QNBS-v3: Git supplies both object IDs so malformed ref updates fail closed. +export function parseRefUpdate(line) { + const fields = line.trim().split(/\s+/); + if (fields.length !== 4 || !fields[0] || !fields[1] || !fields[2] || !fields[3]) return null; + return { localRef: fields[0], localSha: fields[1], remoteRef: fields[2], remoteSha: fields[3] }; +} + +function refSha(ref, cwd) { + const sha = gitOutput(['rev-parse', '--verify', `${ref}^{commit}`], { cwd }); + return isSha(sha) ? sha : null; +} + +export function remoteTrackingBases(remote, remoteRef, cwd = process.cwd()) { + const bases = []; + const add = (ref) => { + const sha = refSha(ref, cwd); + if (sha && !bases.includes(sha)) bases.push(sha); + }; + if (remoteRef.startsWith('refs/heads/')) { + const branch = remoteRef.slice('refs/heads/'.length); + add(`refs/remotes/${remote}/${branch}`); + const symbolic = gitOutput( + ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`], + { cwd }, + ); + if (symbolic) add(symbolic); + add(`refs/remotes/${remote}/main`); + add(`refs/remotes/${remote}/master`); + } + return bases; +} + +export function outgoingBaseShas(update, fallbackBases) { + if (!isZeroSha(update.remoteSha)) { + if (!isSha(update.remoteSha)) throw new Error('invalid remote SHA in pre-push update'); + return [update.remoteSha]; + } + return fallbackBases; +} + +export function introducedCommits(update, remote, cwd = process.cwd()) { + if (update.remoteRef.startsWith('refs/tags/')) return []; + if (!update.remoteRef.startsWith('refs/heads/')) { + throw new Error(`unsupported outgoing ref ${update.remoteRef}`); + } + const bases = outgoingBaseShas(update, remoteTrackingBases(remote, update.remoteRef, cwd)); + const args = ['rev-list', '--reverse', update.localSha, ...bases.map((base) => `^${base}`)]; + const result = runGit(args, { cwd }); + if (result.status !== 0) + throw new Error(`cannot enumerate outgoing commits for ${update.localRef}`); + return result.stdout.split(/\r?\n/).filter(Boolean); +} + +export function selectIntroducedCommits(commits, reachableFromBase) { + const excluded = new Set(reachableFromBase); + return commits.filter((sha) => !excluded.has(sha)); +} + +export function parseAnnotatedTag(sha, cwd = process.cwd()) { + const type = gitOutput(['cat-file', '-t', sha], { cwd }); + if (type === 'commit') return { objectType: 'commit', target: sha }; + if (type !== 'tag') return null; + const body = gitOutput(['cat-file', '-p', sha], { cwd }); + const target = body.match(/^object ([0-9a-f]{40})$/m)?.[1] ?? ''; + const targetType = target ? gitOutput(['cat-file', '-t', target], { cwd }) : ''; + return { objectType: 'tag', target, targetType }; +} + +export function verifyTagObject(sha, cwd = process.cwd()) { + const tag = parseAnnotatedTag(sha, cwd); + if (!tag) return { ok: false, reason: 'tag target is not a commit or annotated tag' }; + if (tag.objectType === 'commit') + return { ok: false, reason: 'release tag is not an annotated tag object' }; + if (!tag.target || tag.targetType !== 'commit') + return { ok: false, reason: 'annotated tag does not target a commit' }; + const verifiedTag = runGit(['verify-tag', '--raw', sha], { cwd }); + if (verifiedTag.status !== 0) + return { ok: false, reason: 'Git-native tag signature verification failed' }; + const verifiedCommit = verifyCommitObject(tag.target, cwd); + return verifiedCommit.ok + ? { ok: true, reason: 'tag and target commit verified' } + : verifiedCommit; +} + +export function classifyTagVerification({ + objectType, + targetType, + tagVerificationStatus, + commitVerification, +}) { + if (objectType === 'commit') + return { ok: false, reason: 'release tag is not an annotated tag object' }; + if (objectType !== 'tag' || targetType !== 'commit') + return { ok: false, reason: 'annotated tag does not target a commit' }; + if (tagVerificationStatus !== 0) + return { ok: false, reason: 'Git-native tag signature verification failed' }; + return commitVerification.ok + ? { ok: true, reason: 'tag and target commit verified' } + : commitVerification; +} + +export function verifyOutgoingUpdates(lines, remote, cwd = process.cwd(), dependencies = {}) { + const verifyCommit = dependencies.verifyCommitObject ?? ((sha) => verifyCommitObject(sha, cwd)); + const verifyTag = dependencies.verifyTagObject ?? ((sha) => verifyTagObject(sha, cwd)); + const getIntroducedCommits = + dependencies.introducedCommits ?? ((update) => introducedCommits(update, remote, cwd)); + const updates = lines.map(parseRefUpdate); + if (updates.some((update) => !update)) + return { ok: false, reason: 'invalid pre-push ref-update input' }; + const reports = []; + for (const update of updates) { + if (isZeroSha(update.localSha)) continue; + if (!isSha(update.localSha)) + return { ok: false, reason: `invalid outgoing SHA for ${update.remoteRef}` }; + if (update.remoteRef.startsWith('refs/tags/')) { + const verification = verifyTag(update.localSha); + reports.push({ sha: update.localSha, subject: update.remoteRef, verification }); + if (!verification.ok) + return { ok: false, reports, reason: `${update.remoteRef}: ${verification.reason}` }; + continue; + } + const commits = getIntroducedCommits(update); + for (const sha of commits) { + const verification = verifyCommit(sha); + const report = { sha, subject: commitSubject(sha, cwd), verification }; + reports.push(report); + if (!verification.ok) + return { ok: false, reports, reason: `${sha.slice(0, 12)}: ${verification.reason}` }; + } + } + return { ok: true, reports }; +} + +export function safeConfigSummary(cwd = process.cwd()) { + const signing = getSigningConfig(cwd); + const identity = getIdentity(cwd); + const gitDir = getGitDirectory(cwd); + const hookDir = signing.config['core.hooksPath'] + ? resolve(cwd, signing.config['core.hooksPath']) + : gitDir + ? join(gitDir, 'hooks') + : null; + const hookNames = ['pre-commit', 'pre-push']; + return { + signing: { + format: signing.format, + enabled: signing.enabled, + keyConfigured: signing.keyConfigured, + keyDisplay: signing.keyDisplay, + gpgProgramConfigured: signing.gpgProgramConfigured, + allowedSignersConfigured: signing.allowedSignersConfigured, + }, + identity: { + nameConfigured: Boolean(identity.name), + emailConfigured: Boolean(identity.email), + githubCompatible: isGitHubCompatibleEmail(identity.email), + }, + hooks: { + pathConfigured: Boolean(signing.config['core.hooksPath']), + directory: hookDir ? basename(hookDir) : '', + hooksInstalled: Boolean( + hookDir && hookNames.every((name) => existsSync(join(hookDir, name))), + ), + }, + unsafeOverrides: getUnsafeOverrides(), + }; +} diff --git a/scripts/signing/verify-github-signatures.mjs b/scripts/signing/verify-github-signatures.mjs new file mode 100644 index 000000000..019bfdf52 --- /dev/null +++ b/scripts/signing/verify-github-signatures.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import process from 'node:process'; +import { commitsInRange, pushCommitShas } from './signing-core.mjs'; +import { + hasCompleteCommitRange, + verifyRemoteCommitRange, + verifyRemotePullRequest, + verifyRemoteTag, +} from './verify-remote.mjs'; + +const [owner, repo] = String(process.env.GITHUB_REPOSITORY ?? '').split('/', 2); +const token = process.env.GITHUB_TOKEN ?? ''; +const fetchImpl = fetch; +const event = process.env.GITHUB_EVENT_NAME ?? ''; + +function fail(message) { + console.error(`signature gate failed: ${message}`); + process.exit(1); +} + +function readEventPayload() { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath) fail('GITHUB_EVENT_PATH is unavailable'); + try { + return JSON.parse(readFileSync(eventPath, 'utf8')); + } catch (error) { + fail( + `cannot read GitHub event payload: ${error instanceof Error ? error.message : 'invalid JSON'}`, + ); + } +} + +if (!owner || !repo) fail('GITHUB_REPOSITORY is unavailable'); + +try { + let result; + if (event === 'pull_request') { + const payload = readEventPayload(); + const number = payload.pull_request?.number; + const before = payload.pull_request?.base?.sha; + const after = payload.pull_request?.head?.sha; + if (!number || !before || !after) + fail('pull request payload is missing its exact before/after SHAs'); + const expected = new Set(commitsInRange(`${before}..${after}`)); + result = await verifyRemotePullRequest({ owner, repo, number, token, fetchImpl }); + if (!hasCompleteCommitRange([...expected], result.reports)) { + fail('GitHub PR pagination did not cover the complete before..after commit range'); + } + } else if (event === 'push' && process.env.GITHUB_REF_TYPE === 'tag') { + result = await verifyRemoteTag({ + owner, + repo, + tag: process.env.GITHUB_REF_NAME, + token, + fetchImpl, + }); + } else { + const shas = pushCommitShas(readEventPayload()); + result = await verifyRemoteCommitRange({ owner, repo, shas, token, fetchImpl }); + } + for (const report of result.reports) { + console.log( + `${report.sha} ${report.verified ? 'verified' : 'REJECTED'} ${report.reason} ${report.subject}`, + ); + } + if (!result.ok) fail(result.reason); +} catch (error) { + fail(error instanceof Error ? error.message : 'unexpected verification error'); +} diff --git a/scripts/signing/verify-outgoing.mjs b/scripts/signing/verify-outgoing.mjs new file mode 100644 index 000000000..a499b9eca --- /dev/null +++ b/scripts/signing/verify-outgoing.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import process from 'node:process'; +import { verifyOutgoingUpdates } from './signing-core.mjs'; + +const remote = process.argv[2]; +if (!remote) { + console.error( + 'pre-push signing check requires the remote name. Run "pnpm run hooks:install" to refresh the installed hook.', + ); + process.exit(1); +} +let input = ''; +process.stdin.setEncoding('utf8'); +for await (const chunk of process.stdin) input += chunk; +const lines = input.split(/\r?\n/).filter(Boolean); +try { + const result = verifyOutgoingUpdates(lines, remote); + if (!result.ok) { + console.error(`pre-push signing check rejected the update: ${result.reason}`); + process.exit(1); + } + console.log(`pre-push signing check verified ${result.reports.length} outgoing object(s)`); +} catch (error) { + console.error( + `pre-push signing check failed closed: ${error instanceof Error ? error.message : 'unknown error'}`, + ); + process.exit(1); +} diff --git a/scripts/signing/verify-remote.d.mts b/scripts/signing/verify-remote.d.mts new file mode 100644 index 000000000..9e7b00fbe --- /dev/null +++ b/scripts/signing/verify-remote.d.mts @@ -0,0 +1,55 @@ +export interface RemoteCommit { + sha: string; + commit?: { + message?: string; + verification?: { + verified?: boolean; + reason?: string; + }; + }; +} + +export interface RemoteReport { + sha: string; + subject: string; + verified: boolean; + reason: string; +} + +export interface RemoteResult { + ok: boolean; + reports: RemoteReport[]; + reason: string; +} + +export interface RemoteOptions { + owner: string; + repo: string; + number: number | string; + token?: string; + fetchImpl?: typeof fetch; +} + +export interface RemoteCommitRangeOptions { + owner: string; + repo: string; + shas: string[]; + token?: string; + fetchImpl?: typeof fetch; +} + +export function fetchJson(fetchImpl: typeof fetch, url: string, token: string): Promise; +export function fetchPullRequestCommits(options: RemoteOptions): Promise; +export function safeReport(commit: RemoteCommit): RemoteReport; + +export function verifyRemoteCommitReports(commits: RemoteCommit[]): RemoteResult; +export function hasCompleteCommitRange(expectedShas: string[], reports: RemoteReport[]): boolean; +export function verifyRemotePullRequest(options: RemoteOptions): Promise; +export function verifyRemoteCommitRange(options: RemoteCommitRangeOptions): Promise; +export function verifyRemoteTag(options: { + owner: string; + repo: string; + tag: string; + token?: string; + fetchImpl?: typeof fetch; +}): Promise; diff --git a/scripts/signing/verify-remote.mjs b/scripts/signing/verify-remote.mjs new file mode 100644 index 000000000..236620a78 --- /dev/null +++ b/scripts/signing/verify-remote.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +import process from 'node:process'; + +const API = 'https://api.github.com'; + +function safeText(value, limit = 200) { + return Array.from(String(value ?? ''), (character) => + character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127 ? character : ' ', + ) + .join('') + .slice(0, limit); +} + +function safeSha(value) { + return String(value ?? '').match(/^[0-9a-f]{12}/i)?.[0] ?? 'invalid-sha'; +} + +function safeSubject(message) { + return ( + safeText( + String(message ?? '') + .split(/\r?\n/, 1)[0] + .slice(0, 160), + ) || '(subject unavailable)' + ); +} + +function safeReport(commit) { + return { + sha: safeSha(commit.sha), + subject: safeSubject(commit.commit?.message), + verified: commit.commit?.verification?.verified === true, + reason: safeText(commit.commit?.verification?.reason ?? 'missing verification result'), + }; +} + +export async function fetchJson(fetchImpl, url, token) { + const response = await fetchImpl(url, { + headers: { + Accept: 'application/vnd.github+json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + if (!response.ok) throw new Error(`GitHub API request failed (${response.status})`); + return response.json(); +} + +export async function fetchPullRequestCommits({ + owner, + repo, + number, + token = '', + fetchImpl = fetch, +}) { + const commits = []; + for (let page = 1; ; page += 1) { + const batch = await fetchJson( + fetchImpl, + `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}/commits?per_page=100&page=${page}`, + token, + ); + if (!Array.isArray(batch)) throw new Error('GitHub PR commits response was not an array'); + commits.push(...batch); + if (batch.length < 100) return commits; + } +} + +export function verifyRemoteCommitReports(commits) { + const reports = commits.map(safeReport); + const invalid = reports.find((report) => !report.verified); + return { ok: !invalid, reports, reason: invalid ? `${invalid.sha}: ${invalid.reason}` : '' }; +} + +export function hasCompleteCommitRange(expectedShas, reports) { + if (expectedShas.length !== reports.length) return false; + return expectedShas.every((sha) => reports.some((report) => sha.startsWith(report.sha))); +} + +export async function verifyRemotePullRequest(options) { + const commits = await fetchPullRequestCommits(options); + return verifyRemoteCommitReports(commits); +} + +export async function verifyRemoteCommitRange({ + owner, + repo, + shas, + token = '', + fetchImpl = fetch, +}) { + const commits = []; + for (const sha of shas) { + commits.push( + await fetchJson( + fetchImpl, + `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${sha}`, + token, + ), + ); + } + return verifyRemoteCommitReports(commits); +} + +export async function verifyRemoteTag({ owner, repo, tag, token = '', fetchImpl = fetch }) { + const ref = await fetchJson( + fetchImpl, + `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/ref/tags/${encodeURIComponent(tag)}`, + token, + ); + const object = ref.object; + if (!object?.sha || !object.type) throw new Error('GitHub tag ref response was incomplete'); + if (object.type !== 'tag') { + const report = { + sha: safeSha(object.sha), + subject: safeText(`tag ${tag}`, 160), + verified: false, + reason: 'release tag is not an annotated tag object', + }; + return { ok: false, reports: [report], reason: `${report.sha}: ${report.reason}` }; + } + let tagReport = null; + let targetSha = object.sha; + const tagObject = await fetchJson( + fetchImpl, + `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/tags/${encodeURIComponent(object.sha)}`, + token, + ); + tagReport = { + sha: safeSha(object.sha), + subject: safeText(`tag ${tag}`, 160), + verified: tagObject.verification?.verified === true, + reason: safeText(tagObject.verification?.reason ?? 'missing tag verification result'), + }; + targetSha = tagObject.object?.sha; + if (tagObject.object?.type !== 'commit' || !targetSha) + throw new Error('annotated tag does not target a commit'); + const commit = await fetchJson( + fetchImpl, + `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${encodeURIComponent(targetSha)}`, + token, + ); + const commitReport = safeReport(commit); + const reports = tagReport ? [tagReport, commitReport] : [commitReport]; + const invalid = reports.find((report) => !report.verified); + return { ok: !invalid, reports, reason: invalid ? `${invalid.sha}: ${invalid.reason}` : '' }; +} + +export { safeReport }; + +if (process.argv[1]?.endsWith('/verify-remote.mjs')) { + const [repository, number] = process.argv.slice(2); + const [owner, repo] = String(repository ?? process.env.GITHUB_REPOSITORY ?? '').split('/', 2); + if (!owner || !repo || !number) { + console.error('usage: pnpm run signing:verify-remote -- / '); + process.exit(2); + } + try { + const result = await verifyRemotePullRequest({ + owner, + repo, + number, + token: process.env.GITHUB_TOKEN ?? '', + }); + for (const report of result.reports) + console.log( + `${report.sha} ${report.verified ? 'verified' : 'REJECTED'} ${report.reason} ${report.subject}`, + ); + process.exit(result.ok ? 0 : 1); + } catch (error) { + console.error( + `remote signature verification failed: ${error instanceof Error ? error.message : 'unexpected API error'}`, + ); + process.exit(1); + } +} diff --git a/tests/unit/signing.test.ts b/tests/unit/signing.test.ts new file mode 100644 index 000000000..c14f30c7c --- /dev/null +++ b/tests/unit/signing.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from 'vitest'; +import { + classifyCommitObject, + classifyTagVerification, + getSigningConfig, + hasCommitSignature, + isGitHubCompatibleEmail, + outgoingBaseShas, + parseRefUpdate, + pushCommitShas, + pushEventRange, + selectIntroducedCommits, + verifyOutgoingUpdates, +} from '../../scripts/signing/signing-core.mjs'; +import { + hasCompleteCommitRange, + verifyRemoteCommitReports, + verifyRemotePullRequest, + verifyRemoteTag, +} from '../../scripts/signing/verify-remote.mjs'; + +describe('local signing controls', () => { + it('parses every pre-push update shape, including deletion', () => { + const remoteSha = 'c'.repeat(40); + expect( + parseRefUpdate(`refs/heads/main ${'a'.repeat(40)} refs/heads/main ${remoteSha}`), + ).toEqual({ + localRef: 'refs/heads/main', + localSha: 'a'.repeat(40), + remoteRef: 'refs/heads/main', + remoteSha, + }); + expect( + parseRefUpdate(`refs/heads/deleted ${'0'.repeat(40)} refs/heads/deleted ${'d'.repeat(40)}`), + ).toEqual({ + localRef: 'refs/heads/deleted', + localSha: '0'.repeat(40), + remoteRef: 'refs/heads/deleted', + remoteSha: 'd'.repeat(40), + }); + expect(parseRefUpdate('refs/heads/main abc refs/heads/main')).toBeNull(); + }); + + it('checks only commits introduced beyond the remote tracking base', () => { + const base = 'a'.repeat(40); + const introduced = 'b'.repeat(40); + expect(selectIntroducedCommits([base, introduced], [base])).toEqual([introduced]); + }); + + it('uses the Git-advertised remote SHA before new-branch fallbacks', () => { + const remoteSha = 'c'.repeat(40); + const fallback = ['d'.repeat(40)]; + expect( + outgoingBaseShas( + { + localRef: 'refs/heads/main', + localSha: 'a'.repeat(40), + remoteRef: 'refs/heads/main', + remoteSha, + }, + fallback, + ), + ).toEqual([remoteSha]); + expect( + outgoingBaseShas( + { + localRef: 'refs/heads/new', + localSha: 'a'.repeat(40), + remoteRef: 'refs/heads/new', + remoteSha: '0'.repeat(40), + }, + fallback, + ), + ).toEqual(fallback); + }); + + it('covers deletion, malformed, tag, and branch pre-push routing', () => { + const zero = '0'.repeat(40); + const commit = 'a'.repeat(40); + const remote = 'b'.repeat(40); + expect( + verifyOutgoingUpdates( + [`refs/heads/deleted ${zero} refs/heads/deleted ${remote}`], + 'origin', + process.cwd(), + { + introducedCommits: () => { + throw new Error('deletions must be skipped'); + }, + }, + ), + ).toMatchObject({ ok: true, reports: [] }); + expect(verifyOutgoingUpdates(['malformed'], 'origin')).toMatchObject({ + ok: false, + reason: 'invalid pre-push ref-update input', + }); + expect( + verifyOutgoingUpdates( + [`refs/tags/v1.0.0 ${commit} refs/tags/v1.0.0 ${zero}`], + 'origin', + process.cwd(), + { + verifyTagObject: () => ({ ok: true, reason: 'tag and target commit verified' }), + introducedCommits: () => { + throw new Error('tags must not enumerate branch commits'); + }, + }, + ), + ).toMatchObject({ ok: true, reports: [{ sha: commit, subject: 'refs/tags/v1.0.0' }] }); + expect( + verifyOutgoingUpdates( + [`refs/heads/main ${commit} refs/heads/main ${remote}`], + 'origin', + process.cwd(), + { + introducedCommits: (update) => { + expect(update.remoteSha).toBe(remote); + return [commit]; + }, + verifyCommitObject: () => ({ ok: true, reason: 'Git-native signature verified' }), + }, + ), + ).toMatchObject({ ok: true, reports: [{ sha: commit }] }); + }); + + it('derives an exact push range and rejects an unverified earlier commit', () => { + const before = '0'.repeat(40); + const after = 'f'.repeat(40); + expect(pushEventRange({ before, after })).toEqual({ before, after }); + expect( + pushCommitShas({ before: '1'.repeat(40), after }, process.cwd(), () => [ + '1'.repeat(40), + 'e'.repeat(40), + after, + ]), + ).toEqual(['1'.repeat(40), 'e'.repeat(40), after]); + const result = verifyRemoteCommitReports([ + { + sha: '1'.repeat(40), + commit: { message: 'earlier', verification: { verified: false, reason: 'unsigned' } }, + }, + { + sha: after, + commit: { message: 'head', verification: { verified: true, reason: 'valid' } }, + }, + ]); + expect(result.ok).toBe(false); + expect(result.reason).toBe('111111111111: unsigned'); + }); + + it('requires both an annotated tag signature and its target commit signature', () => { + const unsignedCommit = { ok: false, reason: 'commit has no signature object' }; + expect( + classifyTagVerification({ + objectType: 'tag', + targetType: 'commit', + tagVerificationStatus: 0, + commitVerification: unsignedCommit, + }), + ).toEqual(unsignedCommit); + expect( + classifyTagVerification({ + objectType: 'tag', + targetType: 'commit', + tagVerificationStatus: 1, + commitVerification: { ok: true, reason: 'verified' }, + }), + ).toMatchObject({ ok: false }); + expect( + classifyTagVerification({ + objectType: 'commit', + targetType: 'commit', + tagVerificationStatus: 0, + commitVerification: { ok: true, reason: 'verified' }, + }), + ).toMatchObject({ ok: false, reason: 'release tag is not an annotated tag object' }); + }); + + it('distinguishes unsigned and malformed signed commit objects', () => { + expect( + classifyCommitObject({ objectType: 'commit', contents: '', verificationStatus: 0 }), + ).toMatchObject({ ok: false, reason: 'commit has no signature object' }); + expect(hasCommitSignature('\ngpgsig -----BEGIN SSH SIGNATURE-----\n')).toBe(true); + expect( + classifyCommitObject({ + objectType: 'commit', + contents: '\ngpgsig malformed\n', + verificationStatus: 1, + }), + ).toMatchObject({ ok: false, reason: 'Git-native signature verification failed' }); + }); + + it('reports configuration and GitHub-compatible identity without a key fallback', () => { + expect(getSigningConfig()).toHaveProperty('enabled'); + expect(isGitHubCompatibleEmail('155236708+qnbs@users.noreply.github.com')).toBe(true); + expect(isGitHubCompatibleEmail('writer@example.com')).toBe(false); + }); +}); + +describe('GitHub signature API controls', () => { + it('rejects any unverified commit while preserving safe report fields', () => { + const result = verifyRemoteCommitReports([ + { + sha: 'a'.repeat(40), + commit: { message: 'signed', verification: { verified: true, reason: 'valid' } }, + }, + { + sha: 'b'.repeat(40), + commit: { + message: 'unsigned\nbody', + verification: { verified: false, reason: 'unsigned' }, + }, + }, + ]); + expect(result.ok).toBe(false); + expect(result.reason).toBe('bbbbbbbbbbbb: unsigned'); + expect(result.reports[1]).toEqual({ + sha: 'bbbbbbbbbbbb', + subject: 'unsigned', + verified: false, + reason: 'unsigned', + }); + expect(hasCompleteCommitRange(['a'.repeat(40), 'b'.repeat(40)], result.reports)).toBe(true); + expect(hasCompleteCommitRange(['a'.repeat(40), 'c'.repeat(40)], result.reports)).toBe(false); + }); + + it('paginates PR commits and verifies annotated tags plus target commits', async () => { + const verifiedCommit = (sha: string) => ({ + sha, + commit: { message: 'commit', verification: { verified: true, reason: 'valid' } }, + }); + const pages = new Map([ + [ + 'page=1', + Array.from({ length: 100 }, (_, index) => verifiedCommit(String(index).padStart(40, '0'))), + ], + ['page=2', [verifiedCommit('f'.repeat(40))]], + ]); + const fetchImpl: typeof fetch = async (input) => + new Response(JSON.stringify(pages.get(String(input).split('&').pop() ?? '') ?? []), { + status: 200, + }); + const pullRequest = await verifyRemotePullRequest({ + owner: 'qnbs', + repo: 'WorldScript-Studio', + number: 1, + fetchImpl, + }); + expect(pullRequest.ok).toBe(true); + expect(pullRequest.reports).toHaveLength(101); + + const tagObjectSha = '1'.repeat(40); + const targetSha = '2'.repeat(40); + const tagFetch: typeof fetch = async (input) => { + const url = String(input); + if (url.includes('/git/ref/tags/')) + return new Response(JSON.stringify({ object: { sha: tagObjectSha, type: 'tag' } }), { + status: 200, + }); + if (url.includes('/git/tags/')) + return new Response( + JSON.stringify({ + object: { sha: targetSha, type: 'commit' }, + verification: { verified: true, reason: 'valid' }, + }), + { status: 200 }, + ); + return new Response(JSON.stringify(verifiedCommit(targetSha)), { status: 200 }); + }; + const tag = await verifyRemoteTag({ + owner: 'qnbs', + repo: 'WorldScript-Studio', + tag: 'v1.0.0', + fetchImpl: tagFetch, + }); + expect(tag.ok).toBe(true); + expect(tag.reports).toHaveLength(2); + + const lightweight = await verifyRemoteTag({ + owner: 'qnbs', + repo: 'WorldScript-Studio', + tag: 'v1.0.0-lightweight', + fetchImpl: async () => + new Response(JSON.stringify({ object: { sha: targetSha, type: 'commit' } }), { + status: 200, + }), + }); + expect(lightweight.ok).toBe(false); + expect(lightweight.reason).toContain('not an annotated tag object'); + }); +}); diff --git a/tests/unit/workflowPolicy.test.ts b/tests/unit/workflowPolicy.test.ts index 01bdb2010..4c7d9298b 100644 --- a/tests/unit/workflowPolicy.test.ts +++ b/tests/unit/workflowPolicy.test.ts @@ -131,6 +131,7 @@ describe('CI workflow policy', () => { ); expect(extractNeeds(workflowSource, 'ci-success')).toEqual([ 'security', + 'signatures', 'quality', 'changes', 'rust-tauri', @@ -140,6 +141,7 @@ describe('CI workflow policy', () => { 'lighthouse', 'vrt', ]); + expect(ciSuccessBlock).toMatch(/\$\{\{\s*needs\.signatures\.result\s*\}\}/); expect(ciSuccessBlock).toMatch(/\$\{\{\s*needs\.lighthouse\.result\s*\}\}/); for (const jobName of ['e2e-deep', 'storybook']) {