diff --git a/.github/workflows/commit-signing.yml b/.github/workflows/commit-signing.yml new file mode 100644 index 00000000..249ba108 --- /dev/null +++ b/.github/workflows/commit-signing.yml @@ -0,0 +1,52 @@ +# L34 commit signing evidence: verify main tip signature + soft branch-protection checklist. +# Branch protection itself is not enforceable from OSS CI without admin API scope. + +name: Commit signing + +on: + pull_request: + paths: + - ".github/workflows/commit-signing.yml" + - "scripts/commit-signing-check.ps1" + - "docs/ops/commit-signing.md" + - "docs/adr/0004-commit-signing-policy.md" + - "SECURITY.md" + - "CONTRIBUTING.md" + push: + branches: [main] + paths: + - ".github/workflows/commit-signing.yml" + - "scripts/commit-signing-check.ps1" + - "docs/ops/commit-signing.md" + - "docs/adr/0004-commit-signing-policy.md" + - "SECURITY.md" + - "CONTRIBUTING.md" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + commit-signing: + name: commit signing policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + fetch-depth: 0 + + - name: ensure origin/main is available on PR checkouts + run: git fetch --no-tags origin main + + - name: verify main tip and recent signed commits + shell: pwsh + run: ./scripts/commit-signing-check.ps1 -Ref main -Count 30 + + - name: branch protection checklist (soft / docs-only) + shell: pwsh + run: ./scripts/commit-signing-check.ps1 -BranchProtectionChecklist + continue-on-error: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc0acbe0..c6bee5be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,5 +96,38 @@ By signing off, you certify that you have the right to submit the work under thi repository's dual MIT OR Apache-2.0 license. PRs should confirm the DCO checkbox in the pull request template. +## Cryptographic commit signing (GPG / SSH) + +DCO sign-off is **not** a substitute for Git commit signatures. Configure signing +before your first commit on a feature branch: + +**GPG** + +```bash +git config --global user.signingkey +git config --global commit.gpgsign true +git commit -S -s -m "your message" +``` + +**SSH** (Git 2.34+) + +```bash +git config --global gpg.format ssh +git config --global user.signingkey ~/.ssh/id_ed25519_sign.pub +git config --global commit.gpgsign true +git commit -S -s -m "your message" +``` + +Publish your public key to GitHub (GPG key or SSH **signing** key). Maintainers +enable **Require signed commits** on `main`; see +[`docs/ops/commit-signing.md`](docs/ops/commit-signing.md) and +[ADR 0004](docs/adr/0004-commit-signing-policy.md). + +Verify locally: + +```powershell +pwsh -NoProfile -File scripts/commit-signing-check.ps1 -Ref HEAD -Count 5 +``` + ## Governance This repository follows governance guidelines defined in ~/.claude/CLAUDE.md at a high level. diff --git a/SECURITY.md b/SECURITY.md index ff864136..8e8b7c19 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,6 +38,20 @@ We follow coordinated disclosure. If a fix cannot ship within 90 days, we will d Critical supply-chain or remote-code issues may be accelerated at maintainer discretion. +## Commit signing + +- Every commit on `main` must carry a **GPG or SSH** signature in addition to DCO + sign-off (see [`CONTRIBUTING.md`](CONTRIBUTING.md)). +- Policy and operator steps: [`docs/ops/commit-signing.md`](docs/ops/commit-signing.md); + decision record: [`docs/adr/0004-commit-signing-policy.md`](docs/adr/0004-commit-signing-policy.md). +- CI runs [`scripts/commit-signing-check.ps1`](scripts/commit-signing-check.ps1) via + [`.github/workflows/commit-signing.yml`](.github/workflows/commit-signing.yml) to + verify the `main` tip is signed and to emit a branch-protection checklist. + GitHub **Require signed commits** on `main` is the enforcement control; the + checklist step is intentionally soft when admin API scope is unavailable in OSS. +- Maintainer **2FA** is recommended org hygiene but is not attestable from this + repository (out of scope for commit-signing evidence). + ## Supply Chain & SBOM - Dependency policy is enforced by [`deny.toml`](deny.toml) via `cargo deny check` (see [`.github/workflows/security.yml`](.github/workflows/security.yml)). diff --git a/docs/adr/0004-commit-signing-policy.md b/docs/adr/0004-commit-signing-policy.md new file mode 100644 index 00000000..d458fe61 --- /dev/null +++ b/docs/adr/0004-commit-signing-policy.md @@ -0,0 +1,87 @@ +# ADR 0004: Git commit signing policy (GPG / SSH) + +- Status: Accepted +- Date: 2026-07-13 +- Decision owners: SessionLedger maintainers +- Related: `docs/ops/commit-signing.md`, `CONTRIBUTING.md`, `SECURITY.md`, ADR 0003 + +## Context + +SessionLedger is an open-source repository on GitHub. Supply-chain controls already +cover dependency advisories, secret scanning, SBOM emission, and signed Releases +(see ADR 0003). **Git commit signing** is a separate pillar: it binds each commit +object to a cryptographic identity so reviewers can detect tampering after push. + +Today: + +| Signal | Status | +|--------|--------| +| DCO `Signed-off-by:` trailers | Required in `CONTRIBUTING.md` (legal attestation) | +| GitHub merge-commit PGP signatures | Present on squash/merge commits via `noreply@github.com` | +| Contributor GPG/SSH signatures on every commit | **Not enforced in-repo** | +| Branch protection "Require signed commits" | **Not machine-verifiable without admin API access** | + +Maintainer **2FA** is an org/account control and is intentionally **out of scope** +for this ADR (see `SECURITY.md` maintainer hygiene, recorded separately). + +OSS repositories cannot prove GitHub branch-protection settings from a checkout +alone. CI therefore combines: + +1. **Machine checks** — inspect recent `main` history for GPG/SSH signature blocks + and validate the tip commit is signed. +2. **Documented enforcement** — maintainers enable "Require signed commits" on + `main` in GitHub Settings → Branches. +3. **Soft checklist** — `scripts/commit-signing-check.ps1 -BranchProtectionChecklist` + prints required settings and queries the API only when `gh` + admin scope exist; + otherwise it exits 0 with documentation pointers (fail-soft / docs-only). + +## Decision + +**All commits landing on `main` must carry a verifiable GPG or SSH signature.** +Enforcement is layered: + +1. **Contributors** configure `git config commit.gpgsign true` (GPG) or + `git config gpg.format ssh` + `user.signingkey` (SSH) before pushing. +2. **Maintainers** enable GitHub branch protection rule on `main`: + - Require signed commits + - (Recommended) Require pull request reviews before merging +3. **CI** runs `scripts/commit-signing-check.ps1` on PRs and `main` pushes: + - **Blocking:** `main` tip commit must include a `gpgsig` block (GPG or SSH). + - **Advisory:** recent-history coverage report; branch-protection checklist is + soft-fail when the API is unreachable or lacks admin scope. + +DCO sign-off (`git commit -s`) remains required and is **complementary** to +cryptographic signing — DCO is not a substitute for GPG/SSH. + +### Accepted signature kinds + +| Kind | Detection | Notes | +|------|-----------|-------| +| GPG | `gpgsig` block containing `BEGIN PGP SIGNATURE` | GitHub merge bot uses PGP | +| SSH | `gpgsig` block containing `BEGIN SSH SIGNATURE` | Git ≥ 2.34; no local gpg needed | + +### Out of scope + +- Maintainer 2FA / hardware-key attestation (org policy; not provable from git tree) +- Platform Authenticode / Apple notarization (ADR 0003) +- Re-signing historical unsigned commits (forward-only policy) + +## Consequences + +- `CONTRIBUTING.md` documents how contributors enable GPG or SSH signing. +- `SECURITY.md` cross-links commit-signing expectations for reporters and auditors. +- `docs/ops/commit-signing.md` is the operator runbook (setup, verification, checklist). +- CI provides auditable evidence without pretending branch protection is enforced + when GitHub admin APIs are unavailable. + +## Reconsider when + +Revisit this policy if: + +1. The project moves off GitHub or disables merge-queue signing, **or** +2. A verified incident shows unsigned commits reached `main` despite protection, **or** +3. GitHub ships a tokenless, read-only API to assert branch-protection flags for + public repos (then tighten CI from soft checklist to hard gate). + +Until then, treat merge-commit GitHub signatures plus documented branch protection +as the production baseline for L34 (signed commits). diff --git a/docs/ops/commit-signing.md b/docs/ops/commit-signing.md new file mode 100644 index 00000000..e4b1f1f5 --- /dev/null +++ b/docs/ops/commit-signing.md @@ -0,0 +1,105 @@ +# Commit signing operations + +SessionLedger requires **cryptographic Git commit signatures** (GPG or SSH) on +every commit that reaches `main`, in addition to DCO `Signed-off-by:` trailers. +See [ADR 0004](../adr/0004-commit-signing-policy.md) for rationale and scope +(maintainer 2FA is explicitly out of scope here). + +## Policy summary + +| Layer | Requirement | Enforcement | +|-------|-------------|-------------| +| DCO | `Signed-off-by:` on each commit | PR template + review | +| GPG / SSH | Signature block on each commit object | GitHub branch protection + CI tip check | +| Merge commits | GitHub signs merges as `noreply@github.com` | Automatic on squash/merge | + +## Contributor setup + +### GPG (traditional) + +```bash +gpg --full-generate-key +gpg --list-secret-keys --keyid-format=long +git config --global user.signingkey +git config --global commit.gpgsign true +``` + +Export the public key to GitHub → Settings → SSH and GPG keys → New GPG key. + +### SSH (Git ≥ 2.34) + +```bash +ssh-keygen -t ed25519 -C "signing@example.com" -f ~/.ssh/id_ed25519_sign +git config --global gpg.format ssh +git config --global user.signingkey ~/.ssh/id_ed25519_sign.pub +git config --global commit.gpgsign true +``` + +Add the **public** key as a **Signing key** on GitHub (not only as an auth key). + +### Verify before push + +```bash +git log -1 --show-signature +# or +git verify-commit HEAD +``` + +## Maintainer: GitHub branch protection + +Branch protection cannot be asserted from a bare git clone. Maintainers must +configure the following in **Settings → Branches → Branch protection rules → `main`**: + +- [ ] **Require signed commits** +- [ ] **Require a pull request before merging** (recommended) +- [ ] **Do not allow bypassing the above settings** (recommended for admins) + +Record the date protection was enabled in an internal ops note; the repository +ships a machine checklist (below) that queries GitHub when `gh` has admin scope. + +## Machine verification + +Run locally or in CI: + +```powershell +# Blocking tip check + recent-history report (default CI mode) +pwsh -NoProfile -File scripts/commit-signing-check.ps1 + +# Branch-protection checklist (soft-fail / docs-only without admin API) +pwsh -NoProfile -File scripts/commit-signing-check.ps1 -BranchProtectionChecklist + +# Strict: fail if any commit in the window lacks a signature block +pwsh -NoProfile -File scripts/commit-signing-check.ps1 -Strict -Count 50 +``` + +### What the script checks + +1. **`main` tip** — commit object contains a `gpgsig` block (GPG or SSH). +2. **Recent history** — for each of the last *N* commits (default 30), classify + as `gpg`, `ssh`, or `unsigned`. +3. **When signatures are present** — run `git verify-commit` when a verifier is + available; malformed `gpgsig` blocks fail even in soft mode. +4. **Branch protection** (optional `-BranchProtectionChecklist`) — if `gh api` + succeeds with admin scope, assert `required_signatures` on `main`; otherwise + print the checklist above and exit 0 (OSS fail-soft). + +### CI workflow + +[`.github/workflows/commit-signing.yml`](../../.github/workflows/commit-signing.yml) +checks out full history (`fetch-depth: 0`), runs the script on PRs and `main` +pushes, and uploads the text report as a job summary. + +## Interpreting GitHub merge signatures + +Squash and merge commits are signed by GitHub's bot key. Individual commits inside +a PR may be unsigned until branch protection rejects them; only the merge commit +on `main` must be signed for the tip check to pass today. + +Long term, **Require signed commits** ensures every commit in a PR is signed +before merge, not only the merge commit. + +## Related documents + +- [CONTRIBUTING.md](../../CONTRIBUTING.md) — DCO + signing setup for contributors +- [SECURITY.md](../../SECURITY.md) — supply-chain controls index +- [ADR 0004](../adr/0004-commit-signing-policy.md) — policy decision record diff --git a/scripts/commit-signing-check.ps1 b/scripts/commit-signing-check.ps1 new file mode 100644 index 00000000..41d1124e --- /dev/null +++ b/scripts/commit-signing-check.ps1 @@ -0,0 +1,207 @@ +[CmdletBinding()] +param( + [string]$Ref = "main", + [int]$Count = 30, + [switch]$Strict, + [switch]$BranchProtectionChecklist, + [string]$Repo = "KooshaPari/SessionLedger" +) + +$ErrorActionPreference = "Stop" +$RepoRoot = Split-Path -Parent $PSScriptRoot +Push-Location $RepoRoot +try { + function Get-CommitSignatureKind { + param([string]$Sha) + $Raw = (& git cat-file -p $Sha 2>$null | Out-String) + if ([string]::IsNullOrWhiteSpace($Raw)) { + return "missing" + } + if ($Raw -notmatch '(?ms)^gpgsig ') { + return "unsigned" + } + if ($Raw -match 'BEGIN SSH SIGNATURE') { + return "ssh" + } + if ($Raw -match 'BEGIN PGP SIGNATURE') { + return "gpg" + } + return "malformed" + } + + function Test-CommitVerifier { + param([string]$Sha) + $Output = (& git verify-commit $Sha 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -eq 0) { + return @{ Ok = $true; Detail = $Output } + } + if ($Output -match 'No signature') { + return @{ Ok = $false; Detail = "no signature" } + } + if ($Output -match 'gpg:\s+Signature made|Good signature|not certified|Can''t check signature|no public key') { + return @{ Ok = $true; Detail = $Output } + } + return @{ Ok = $false; Detail = $Output } + } + + function Write-ChecklistItem { + param([string]$Text, [bool]$Done = $false) + $Mark = if ($Done) { "[x]" } else { "[ ]" } + Write-Host "$Mark $Text" + } + + function Resolve-GitRef { + param( + [string]$RefName, + [int]$FetchDepth + ) + + $Candidates = @($RefName) + if ($RefName -eq "main") { + $Candidates += "origin/main" + } + + foreach ($Candidate in $Candidates) { + & git rev-parse --verify "$Candidate^{commit}" *> $null + if ($LASTEXITCODE -eq 0) { + return $Candidate + } + } + + if ($RefName -eq "main") { + Write-Host "Local main not found; fetching origin/main (depth $FetchDepth)..." + & git fetch --no-tags --depth $FetchDepth origin main 2>&1 | Out-Host + if ($LASTEXITCODE -eq 0) { + & git rev-parse --verify "origin/main^{commit}" *> $null + if ($LASTEXITCODE -eq 0) { + return "origin/main" + } + } + } + + throw "Ref not found: $RefName (tried: $($Candidates -join ', '))" + } + + function Invoke-BranchProtectionChecklist { + $Items = @( + "Require signed commits on branch main", + "Require a pull request before merging (recommended)", + "Do not allow bypassing the above settings (recommended)" + ) + + Write-Host "Branch protection checklist for $Repo (main):" + foreach ($Item in $Items) { + Write-ChecklistItem $Item + } + + $Gh = Get-Command gh -ErrorAction SilentlyContinue + if (-not $Gh) { + Write-Host "" + Write-Host "WARN: gh CLI not found; branch protection is not machine-verifiable in OSS." + Write-Host "See docs/ops/commit-signing.md for manual Settings -> Branches steps." + return 0 + } + + try { + $Json = & gh api "repos/$Repo/branches/main/protection" 2>&1 + if ($LASTEXITCODE -ne 0) { + throw ($Json | Out-String) + } + $Protection = $Json | ConvertFrom-Json + $Signed = [bool]$Protection.required_signatures + Write-Host "" + Write-ChecklistItem "Require signed commits on branch main" $Signed + if (-not $Signed) { + Write-Host "WARN: GitHub API reports required_signatures=false (or unset)." + Write-Host "Enable 'Require signed commits' in repository branch protection." + Write-Host "Exiting soft (docs-only) because OSS CI cannot enforce admin settings." + return 0 + } + Write-Host "Branch protection reports required_signatures=true." + return 0 + } + catch { + Write-Host "" + Write-Host "WARN: Could not query branch protection via gh api (admin scope required)." + Write-Host $_.Exception.Message + Write-Host "Exiting soft (docs-only). See docs/ops/commit-signing.md." + return 0 + } + } + + if ($BranchProtectionChecklist) { + exit (Invoke-BranchProtectionChecklist) + } + + $ResolvedRef = Resolve-GitRef -RefName $Ref -FetchDepth ([Math]::Max($Count, 30)) + if ($ResolvedRef -ne $Ref) { + Write-Host "Resolved ref '$Ref' -> '$ResolvedRef'" + } + + $Tip = (& git rev-parse "$ResolvedRef^{commit}").Trim() + $Shas = @(& git rev-list -n $Count $ResolvedRef) + if ($Shas.Count -eq 0) { + throw "No commits found for ref $Ref" + } + + $Stats = @{ + gpg = 0 + ssh = 0 + unsigned = 0 + malformed = 0 + } + $Problems = [System.Collections.Generic.List[string]]::new() + + foreach ($Sha in $Shas) { + $Kind = Get-CommitSignatureKind $Sha + if ($Stats.ContainsKey($Kind)) { + $Stats[$Kind]++ + } + else { + $Stats.malformed++ + } + + if ($Kind -eq "malformed") { + $Problems.Add("$Sha has gpgsig block but unrecognized signature format") + continue + } + if ($Kind -in @("gpg", "ssh") -and $Sha -eq $Tip) { + $Verify = Test-CommitVerifier $Sha + if (-not $Verify.Ok) { + $Problems.Add("$Sha ($Kind) verify-commit failed: $($Verify.Detail)") + } + } + if ($Strict -and $Kind -eq "unsigned") { + $Subject = (& git show -s --format=%s $Sha).Trim() + $Problems.Add("$Sha unsigned in strict window: $Subject") + } + } + + $TipKind = Get-CommitSignatureKind $Tip + Write-Host "Commit signing report for $ResolvedRef (tip $Tip, last $($Shas.Count) commits)" + Write-Host " gpg: $($Stats.gpg)" + Write-Host " ssh: $($Stats.ssh)" + Write-Host " unsigned: $($Stats.unsigned)" + Write-Host " malformed: $($Stats.malformed)" + Write-Host " tip: $TipKind" + + if ($TipKind -notin @("gpg", "ssh")) { + $Problems.Add("Tip commit $Tip on $ResolvedRef is not GPG/SSH signed ($TipKind)") + } + + if ($Problems.Count -gt 0) { + Write-Host "" + Write-Host "Findings:" + foreach ($Problem in $Problems) { + Write-Host " - $Problem" + } + exit 1 + } + + Write-Host "" + Write-Host "Commit signing check passed for $ResolvedRef." + exit 0 +} +finally { + Pop-Location +}