diff --git a/.claude/project_rules_decisions.md b/.claude/project_rules_decisions.md index c57332f..2081832 100644 --- a/.claude/project_rules_decisions.md +++ b/.claude/project_rules_decisions.md @@ -22,7 +22,7 @@ This project inherits the global security baseline: rules **R005–R016** in `~/ - **P011 — Tauri capabilities are append-only via review.** Never broaden `src-tauri/capabilities/*.json` (especially `plugin-fs`, `plugin-shell`, `plugin-opener`, `plugin-process`, `plugin-updater`) without explicit user approval and a written reason. Each capability scope = potential arbitrary file/shell access from JS. - **P012 — Sidecar binaries require provenance.** Any binary in `src-tauri/binaries/` must have a documented source URL + SHA-256 in `src-tauri/binaries/README.md`. Compromised sidecar = full RCE on every user machine. -- **P013 — Updater signing key never leaves CI secrets.** The Tauri updater private key (`TAURI_SIGNING_PRIVATE_KEY` / `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`) lives only in CI secret manager. Never in repo, never in `.env`, never in `tauri.conf.json`. A leaked signing key = ability to ship malicious updates to every installed user. +- **P013 — Updater signing key never leaves CI secrets.** The Tauri updater private key (`TAURI_SIGNING_PRIVATE_KEY` / `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`) lives only in CI secret manager. Never in repo, never in `.env`, never in `tauri.conf.json`. A leaked signing key = ability to ship malicious updates to every installed user. **Procedure (key generation, storage, rotation, incident response): see `.security/updater-signing-plan.md`.** Rotation cadence is annual + on-leak (documented exception to global R007's 90-day default, justified by Tauri's offline-verification model). - **P014 — Renderer untrust boundary.** Treat anything from the Vite/React renderer as user input when it crosses to Rust via Tauri commands. Validate paths (no `..`, no symlink escape), file types, and sizes inside the Rust handler — not in JS. - **P015 — Dependency audit gate.** `npm audit --omit=dev` MUST run in CI on every PR. High/critical advisories block merge. Pin direct deps; use lockfile. - **P016 — Secret scanning in CI.** `gitleaks detect --no-banner` MUST run in CI on every push. A failure blocks the build. diff --git a/.security/updater-signing-plan.md b/.security/updater-signing-plan.md new file mode 100644 index 0000000..7783afe --- /dev/null +++ b/.security/updater-signing-plan.md @@ -0,0 +1,228 @@ +# Updater Signing Plan + +**Status:** Draft. The Tauri auto-updater is not enabled. This document is the playbook to follow before flipping it on, written ahead of time so the security posture is decided before any key material exists. + +**Owner:** repo admin (founder) +**Authority:** P013 in `.claude/project_rules_decisions.md`; global rule R013 in `~/.claude/rules.md`. +**Last revised:** 2026-04-29 + +--- + +## 1. Why this exists + +`@tauri-apps/plugin-updater@2.10.0` is in `package.json` but the Rust dep is not in `Cargo.toml`, `tauri.conf.json` has `"plugins": {}`, and the release workflow never sees a signing key. The updater is dormant. + +The moment any of those flip, the Tauri private key becomes the most valuable secret in the project: a leaked key lets an attacker ship malicious updates to every installed Papercut user (full RCE on user machines). This document fixes the procedure for handling that key **before** it exists, so the rules are in place when the rollout begins. + +## 2. Threat model (what we're defending against) + +| Adversary | Capability | Mitigation | +|---|---|---| +| External attacker, no repo access | Can publish releases on a *different* repo, host typosquatted endpoints | Tauri's verifier checks signature against embedded `pubkey` — wrong-key bundle is rejected before install. Endpoint is HTTPS-only and pinned in `tauri.conf.json`. | +| External attacker, network-level (TLS strip / MITM) | Can serve modified `latest.json` or modified bundle to a user mid-update | Same: signature verification is the floor. TLS adds defence in depth but is not load-bearing. | +| Compromised CI runner | Reads secrets at build time, exfiltrates to attacker-controlled endpoint | Limit workflow triggers (tags only); branch protection on `main` forbids unreviewed workflow edits; rotate key on any suspected runner compromise. | +| Repo collaborator with write access | Could read GitHub Actions secrets via a malicious workflow | Solo project; if collaborators are added, secrets must be re-scoped before granting write. Audit log review on every grant. | +| Stolen developer machine | Could read `~/.tauri/papercut.key` if file is present | Private key file lives only on the founder's primary machine, full-disk-encrypted, and the file itself is encrypted with a strong password held in a password manager. Lost-machine drill: revoke + rotate immediately (§7). | + +## 3. Storage matrix + +| Item | Lives in | Read by | Written by | +|---|---|---|---| +| Private key (encrypted file) | `~/.tauri/papercut.key` (FDE'd dev machine, mode 600) | nobody at runtime | `tauri signer generate` (one-time) | +| Private key (base64 of file content) | GitHub Actions secret `TAURI_SIGNING_PRIVATE_KEY` | release workflow only | repo admin via Settings UI | +| Private-key password | GitHub Actions secret `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | release workflow only | repo admin via Settings UI | +| Private key & password (vault copy) | founder's password manager (1Password / Bitwarden) | nobody | founder, immediately after `tauri signer generate` | +| Public key | `tauri.conf.json` `plugins.updater.pubkey` | every installed app at update-check time | committed in PR | +| Public key file | `~/.tauri/papercut.key.pub` (gitignored) | reference copy only | `tauri signer generate` | +| `.sig` files | published as GitHub release assets | Tauri updater plugin (verifies) | `tauri-action` in CI | +| `latest.json` manifest | published as GitHub release asset | Tauri updater plugin (fetches) | `tauri-action` in CI | +| Rotation history | `.security/key-rotation-timeline.md` (created at first key-gen) | reviewers, auditors | founder during each rotation | + +**Hard rule:** the private key never appears in the repo, in `.env*`, in CI logs, or in chat transcripts. Existing global hooks already help: `pre-write.cjs` blocks PRIVATE-KEY blocks in writes; `pre-bash.cjs` blocks redirecting `*_KEY` env vars to disk. + +## 4. Key generation (one-time) + +Run on the founder's primary machine, with the screen unobserved: + +```bash +mkdir -p ~/.tauri && chmod 700 ~/.tauri +npx tauri signer generate -w ~/.tauri/papercut.key +# → prompts for password (≥ 24 chars, generated by password manager) +# → emits ~/.tauri/papercut.key (encrypted private) + ~/.tauri/papercut.key.pub +chmod 600 ~/.tauri/papercut.key ~/.tauri/papercut.key.pub +``` + +Immediately after generation: + +1. Copy the contents of `~/.tauri/papercut.key` (the entire file, including the leading `untrusted comment:` line) into the password manager as **"Papercut updater key — live"**, encrypted-file note. +2. Copy the password into the same vault entry's password field. +3. Run `base64 -i ~/.tauri/papercut.key | pbcopy` (macOS) — paste into the GitHub repo's Settings → Secrets and variables → Actions → New repository secret → name `TAURI_SIGNING_PRIVATE_KEY`. +4. Add `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` the same way (the password chosen in step 1's prompt). +5. Verify in the GitHub UI: both secrets are listed; values cannot be re-read. +6. `cat ~/.tauri/papercut.key.pub` — copy the public key string (single line of base64). This goes into `tauri.conf.json` in the next phase. +7. Do **not** push, email, AirDrop, copy to a second machine, or paste into chat the contents of `~/.tauri/papercut.key`. The file lives on exactly one disk. + +## 5. Rollout sequence (do not flip everything at once) + +### Phase 0 — Plan committed (this document) + +No code change. No key generated. Just this doc + tightened P013. + +### Phase 1 — Wire the plumbing, keep updater dormant + +One PR adds: + +- `src-tauri/Cargo.toml`: + ```toml + tauri-plugin-updater = "2" + ``` +- `src-tauri/src/lib.rs`, in `run_with_file()`: + ```rust + let builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); + ``` +- `src-tauri/tauri.conf.json`: + ```json + "plugins": { + "updater": { + "active": false, + "pubkey": "", + "endpoints": [ + "https://github.com/shyhunter/Papercut/releases/latest/download/latest.json" + ] + } + } + ``` +- `src-tauri/capabilities/default.json`: add `"updater:default"` to `permissions` (required for the plugin to be callable; otherwise the in-app check would fail). +- `.github/workflows/release.yml`, in the `build` job, on the `tauri-action@v0` step: + ```yaml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + ``` +- A new CI guard step in the `build` job, before `tauri-action`: + ```yaml + - name: Verify signing secrets are set + if: startsWith(github.ref, 'refs/tags/v') + run: | + [[ -n "${TAURI_SIGNING_PRIVATE_KEY}" ]] || { echo "FAIL: TAURI_SIGNING_PRIVATE_KEY is empty"; exit 1; } + [[ -n "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD}" ]] || { echo "FAIL: TAURI_SIGNING_PRIVATE_KEY_PASSWORD is empty"; exit 1; } + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + ``` + +The release workflow now produces signed bundles + `.sig` files + `latest.json` manifest on every tag, but **no installed app fetches anything yet** because `"active": false`. + +**Verification gate before Phase 2:** + +- Tag a test release (e.g. `v1.0.0-beta.9-rc.0`). Inspect the published draft release: `Papercut_*.dmg.sig`, `Papercut_*.exe.sig`, `Papercut_*.AppImage.sig`, and `latest.json` are present. +- Open `latest.json` — verify it has `version`, `notes`, `pub_date`, `platforms..signature`, `platforms..url` fields. +- Build a local installation from the .dmg. Open the app. No update prompt appears (because `active: false`). No errors in logs. +- **One full release cycle** (production tag, smoke tests pass, real installs in the wild) before flipping to active. + +### Phase 2 — Activate + +Single one-line PR: `"active": false` → `"active": true` in `tauri.conf.json`. Tag a release. The next time installed apps run, they fetch `latest.json`, verify the signature against their embedded pubkey, and surface an update prompt. + +### Phase 3 — Drill + +Within 14 days of Phase 2 going live, perform a key-rotation drill (see §6) on a throwaway version-tag to verify the bridge-release procedure works end-to-end. Document the drill in `.security/key-rotation-timeline.md`. + +## 6. Rotation + +**Cadence: annual** (on the anniversary of Phase 1 or the previous rotation), and out-of-cycle on any suspected leak. + +**Why this deviates from R007's 90-day default.** Tauri's offline signature model means every installed app verifies updates against the `pubkey` embedded in the binary it's running. Rotation cannot be a single-PR operation — it requires a **bridge release** signed with the OLD key whose embedded `tauri.conf.json` carries the NEW pubkey. Doing this every 90 days is high-friction and the marginal security gain over annual is small for a solo project where the same person controls both the dev machine and the GitHub secrets. R007's 90-day rule is preserved for **any other** secret class; this is a documented exception, scoped to the Tauri updater key only, ratified by P013. + +**Bridge-release rotation procedure** (target: ~30 minutes work, two release cycles ~1 week apart): + +1. Generate the NEW key: §4, with file name `papercut.key.next`. +2. Open release N (signed with OLD key, embeds NEW pubkey): + - Update `tauri.conf.json` `pubkey` to the NEW public key. + - Tag and ship as the next normal release. + - GitHub secrets still hold the OLD private key — `tauri-action` signs with the OLD key. + - All installed users update successfully (their embedded pubkey is still OLD, matches OLD signature). After this update, their on-disk `tauri.conf.json` has the NEW pubkey embedded. +3. Replace GitHub secrets: + - Update `TAURI_SIGNING_PRIVATE_KEY` to the NEW private key (base64 of `papercut.key.next`). + - Update `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` to its new password. +4. Open release N+1 (signed with NEW key): + - Tag and ship as the next normal release. + - All installed users at N now have NEW pubkey embedded → verify N+1's NEW signature → update successfully. +5. Decommission OLD key: + - Move the OLD password-manager entry to "Papercut updater key — retired YYYY-MM-DD". + - Delete `~/.tauri/papercut.key` (the OLD encrypted file). Verify the password manager has the OLD vault entry intact in case we need it for forensics within retention window. +6. Append a row to `.security/key-rotation-timeline.md`: rotation date, fingerprint of new pubkey, reason (annual / leak / drill), bridge-release tag pair. + +**What breaks if you skip the bridge release**: installed apps at version N reject N+1's signature (wrong pubkey), the in-app updater silently does nothing or shows a verification error, and users are stuck on N forever until they manually re-download. This is non-recoverable except by manual user action. + +## 7. Incident response (suspected leak) + +Trigger conditions: any of — + +- The encrypted private-key file leaves the founder's primary machine (uploaded, AirDropped, restored from backup to a second machine, copied to a USB stick, etc.) +- The password is exposed (typed into a wrong text field, pasted into chat, screen-recorded, sent in email, etc.) +- A GitHub audit-log entry shows secret access from an unexpected workflow run, branch, or actor +- A release is published outside the normal `release.yml` workflow path +- A user reports an update that didn't come from us + +Immediate actions (in order, do not skip): + +1. **STOP** all releases. Cancel any in-flight workflow run (`gh run cancel`). Convert any draft release to private. Freeze the `release.yml` workflow temporarily (`gh workflow disable release.yml`) until the rotation is complete. +2. **Revoke at the source.** Delete `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` from GitHub secrets. Mark the password-manager entry as compromised. Delete `~/.tauri/papercut.key` from disk. +3. **Rotate**, following §6's bridge-release procedure under emergency conditions: + - The bridge release MUST be signed with the leaked-but-not-yet-revoked OLD key. There is no way around this in the offline model. + - Mitigation: keep the bridge release window as short as possible. Tag and ship within hours of the leak being confirmed, not days. + - If the leak is confirmed and the OLD key has clearly been used to publish a malicious update, the bridge release option is gone — see "hard fork" below. +4. **Notify.** Add a banner to the next release notes. If the user base is small enough, email or DM directly. Be specific about what happened, what users should do (typically: nothing — the rotation is transparent), and what they should look out for. +5. **Investigate.** Pull the GitHub audit log for the past 30 days; review every workflow run that referenced `TAURI_SIGNING_*`; review every contributor-permission change; review every commit to `release.yml`. Document the timeline in `.security/key-rotation-timeline.md` with full incident detail. (Mirrors SuperCharge's incident-response convention.) +6. **Re-enable** `release.yml` and ship release N+1. + +**Hard-fork escape hatch** — if the OLD key has provably been used by an attacker to publish a release, the bridge-release option is poisoned: any update signed with the OLD key, including ours, would be indistinguishable from the attacker's. In that case: + +- Do NOT publish a bridge release. +- Publish release N+1 signed with the NEW key. All installed apps will reject it (wrong pubkey). Users will not auto-update. +- Communicate aggressively (release notes, banner, email): users must manually download Papercut from GitHub Releases. +- Track adoption in the rotation timeline; old installations sit on N indefinitely. + +This is a worst case. Avoiding it justifies all the §3-§5 hygiene above. + +## 8. Out of scope (separate work items) + +- **Apple Developer notarization.** The "Papercut is damaged" warning macOS users hit is from Gatekeeper, not the updater. Solving it requires an Apple Developer ID + notarization step in the release workflow; orthogonal to update integrity. Tracked separately. +- **Windows EV code-signing certificate.** Same idea — Windows SmartScreen warnings, not updater integrity. +- **Self-hosted update endpoint.** GitHub Releases is adequate for a solo project. If/when distribution outgrows it (high traffic, regional latency, takedown risk), revisit. +- **Delta updates / partial bundles.** Tauri supports them; Papercut doesn't need them at current binary size. Defer. + +## 9. Hard gates summary + +For a future reviewer to check this plan was followed: + +- [ ] `~/.tauri/papercut.key*` exists on **only one** machine, mode 600, full-disk-encrypted host +- [ ] `TAURI_SIGNING_PRIVATE_KEY` and `_PASSWORD` GitHub secrets are populated; secret-list audit shows no other actor created them +- [ ] `tauri.conf.json` `plugins.updater.pubkey` matches the contents of `~/.tauri/papercut.key.pub` +- [ ] No file matching `papercut.key*` is tracked by git: `git log --all -- '**/papercut.key*'` returns empty +- [ ] `release.yml` build step has the two `TAURI_SIGNING_*` env entries AND the "Verify signing secrets are set" guard step +- [ ] `e2e:build` (debug + e2e feature) workflow does NOT have `TAURI_SIGNING_*` exposed — release-path and e2e-path are mutually exclusive +- [ ] `.security/key-rotation-timeline.md` exists and has at least one entry (the genesis row) +- [ ] P013 in `.claude/project_rules_decisions.md` references this document + +--- + +## Appendix A — Genesis row template for `key-rotation-timeline.md` + +To be filled in at Phase 1: + +```markdown +# Key Rotation Timeline + +| Date | Event | Reason | OLD pubkey fingerprint | NEW pubkey fingerprint | Bridge release | Final release | +|---|---|---|---|---|---|---| +| YYYY-MM-DD | Genesis | Initial key generation, Phase 1 wire-up | (none) | | (n/a) | | +``` + +Each subsequent rotation appends a row. + +## Appendix B — Why minisign / not a CA + +Tauri uses [minisign](https://jedisct1.github.io/minisign/) signatures by design — Ed25519, no certificate chain, no CRL, no OCSP. This is the simplest secure design for offline binary signing: one keypair, one signature, no reliance on a third-party trust anchor. It also means the rotation problem above is a fundamental property of the model, not a Tauri bug. Trade-off accepted.