Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/project_rules_decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
228 changes: 228 additions & 0 deletions .security/updater-signing-plan.md
Original file line number Diff line number Diff line change
@@ -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": "<base64 of ~/.tauri/papercut.key.pub>",
"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.<os>.signature`, `platforms.<os>.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) | <fingerprint> | (n/a) | <tag> |
```

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