Repository navigation
Encrypt D1 backups before upload - #49
Conversation
The Backup D1 workflow uploaded a plaintext wrangler d1 export (users, sessions, OAuth tokens, flags) as an artifact on a public repo. Encrypt both artifacts to the public keys in the BACKUP_AGE_RECIPIENTS repo variable, fail before exporting if it is empty or invalid, and upload only the .age files. age v1.3.2 is installed from the upstream release and checked against a pinned SHA-256. docs/BACKUP.md covers keys, decryption, the local restore drill, Time Travel and key rotation.
wrangler d1 export --remote logs a one-hour presigned URL that serves the plaintext dump without auth. Run it with WRANGLER_LOG=warn and discard stdout. Also fail on an empty export, retry the age download, and document the plaintext cutover, mixed-recipient limit and the references a restore into a new database must move.
Commands run from wiki/ follow database_id; only the workflow resolves the database by name. The Brain snapshot descriptor is hash-pinned, so moving the id needs a new request version.
|
Review status: merged in Update 2026-10-14 (re-checked at
Original review (2026-10-14)Independent adversarial review of Verdict: right approach; the age encryption is implemented correctly for the pinned wrangler 4.120.0. Three small fixes before merge. Required before merge
Decisions for the maintainer
Merge order: release-neutral; merge alone once fixed, then set Fixes are in progress; this comment will be updated. Posted by the WikiLean PR swarm (lead); updated 2026-10-14. |
Status review (2026-10-14): ✅ ReadyThe Export step's real Before re-enabling the workflow
Nits
Order: merge before #58, which contains this branch. 🤖 Automated status pass by Claude Code over all 18 open PRs (#46–#63), posted for the repo owner. An independent verifier re-checked each finding. All 18 heads were also merged and tested together in a scratch tree; the only test failure that comes from combining them is #47 × #55 (three other pairs had mechanical text conflicts). Reviewed head: |
…uld skip; cutover covers old runs - Export: wrangler's stderr now passes through a URL redactor (stdout is still discarded, WRANGLER_LOG=warn), so a wrangler upgrade that logs the presigned URL as a warning or in an error can't publish it. pipefail keeps wrangler's exit status. - Recipient check: any output from `age -R` fails the job. age only warns about SSH key types it can't use and skips them, which would silently drop a spare key from every backup. - docs/BACKUP.md cutover: re-runs replay the original commit's workflow and dispatches use the chosen ref's file, so never re-run a plaintext-era run (delete them) and dispatch only from main. Manual-run commands now pass --ref main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…age-local-script Picks up #49's stderr URL redaction, its refusal of recipient lines age would only warn about and skip, and the cutover step about old runs and dispatching from main (cutover steps renumbered 1-4). Merged cleanly; this branch's local-script docs, fail-safe key-rotation loop and Time Machine/iCloud notes are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes pushed (2026-10-14)This addresses the PR swarm's three required items and the dropped-spare-key nit from the "Status review (2026-10-14)" comment. What changed
Verification
Not changed / still open
Merge notes
🤖 Posted by Claude Code for the repo owner. Each fix was checked by an independent verifier before it was pushed. Head: |
Brings in #49 and #51 as merged. #58 had merged #49's branch and an earlier main separately, which left two merge bases; GitHub's test merge reported a conflict even though git merges cleanly. With main as a direct ancestor the PR's diff is again only its own three files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he cron example The workflow on main (#49) fails when `age -R` prints anything, because age only warns about SSH key types it can't use and skips them. The local script now does the same, so a spare key can't silently drop out. The crontab example used a machine-specific checkout path; it is now a placeholder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…age-local-script Picks up #49's stderr URL redaction, its refusal of recipient lines age would only warn about and skip, and the cutover step about old runs and dispatching from main (cutover steps renumbered 1-4). Merged cleanly; this branch's local-script docs, fail-safe key-rotation loop and Time Machine/iCloud notes are unchanged.
Encrypt D1 backups before upload
…he cron example The workflow on main (#49) fails when `age -R` prints anything, because age only warns about SSH key types it can't use and skips them. The local script now does the same, so a spare key can't silently drop out. The crontab example used a machine-specific checkout path; it is now a placeholder.
Problem
Backup D1uploads a plaintext export of the production D1 database as a workflow artifact. The repository is public, so any signed-in GitHub account can download workflow artifacts (GitHub requires only read access), and anyone can list them without signing in..github/workflows/backup-d1.yml#L68-L80:wrangler d1 export wikilean --remotewith no table filter, thenupload-artifactof the.sqlfile withretention-days: 30. The job runs daily.users.email(0002_users.sql#L5-L14),sessions.token/ip_address/user_agentand the OAuthaccess_token/refresh_token/id_tokeninaccounts(0003_auth.sql#L4-L27), andflags.ip_hash(0005_events_flags_counts.sql#L35-L45). That contradicts the data notice inCONTRIBUTING.md#L151("never include emails or IP addresses").wrangler d1 export --remote(wrangler 4.120.0,exportRemotely) logsYou can also download your export from the following URL manually. This link will be valid for one hour: <presigned URL>at its default level. That URL serves the plaintext dump with no authentication, and Actions run logs on a public repo are public, so every run published an auth-free download link for its first hour. EachExport D1 (wikilean)step log on main shows the line, and those links have since expired.backup-d1.yml#L87-L109) republishes the output ofGET /api/research/export.jsonl, an endpoint gated behind the pipeline bearer token or an admin session. Publishing it as a public artifact undoes that gate.Change
.github/workflows/backup-d1.yml:sha256sum --check --strictchecks the tarball against a pinned SHA-256 before anything is extracted. Only theagebinary is installed (none of the bundled plugins).BACKUP_AGE_RECIPIENTSthroughvars, notsecrets, because it holds public keys only. If the variable is empty, or age rejects it as a recipient list, the job fails with an::error::annotation before checkout,npm cior any export. age is the validator, and it rejects malformed or typo'd keys (bech32 checksum), a pastedAGE-SECRET-KEY-…, a list where any line is invalid, and plugin recipients whose plugin is not installed. Any output from age also fails the check: age only warns about SSH key types it can't use (ECDSA, security keys, short RSA) and skips them, which would silently drop a spare key.wrangler d1 exportnow writes into$RUNNER_TEMP, outside the workspace, underumask 077.WRANGLER_LOG=warnand its stdout is discarded, so the presigned URL never reaches the log. Its stderr (warnings and errors) passes through asedfilter that replaces any URL with[URL redacted], so a future wrangler that logs the URL as a warning or in an error still can't publish it.pipefailkeeps wrangler's exit status.WRANGLER_WRITE_LOGS=falsealso skips wrangler's on-disk debug log, which records every message..agefile.age -Rencrypts the dump into the workspace and the plaintext is deleted immediately. AnEXITtrap also deletes it when the export or the encryption fails.age-encryption.org/v1header.wikilean-<ts>.sql.ageis uploaded. Artifact names, retention and ordering are unchanged./stats + research export + research plan, P2a) lists it as the nightly research-data deliverable: the pseudonymizedannotation_eventsstream, ready for analysis. The encrypted full dump also holds everyannotation_eventsrow, but raw and keyed by user id, so the export is not redundant with it. Leaving it public would contradict the bearer gate. Thecurlresponse is piped straight into age (curl … | age -R … -o research-export-<ts>.jsonl.age), so this plaintext never touches disk.pipefailmeans a failed fetch fails the step, and the upload is skipped as before (continue-on-erroris unchanged).contents: read, and no new action or secret is added.New
docs/BACKUP.md:age-keygen(the private key stays offline; an optional spare key)gh variable set BACKUP_AGE_RECIPIENTS --repo Deicyde/WikiLeanmain; delete the old runsBETTER_AUTH_SECRET, decide on notification)gh workflow enable backup-d1.yml, plus an end-to-end checkwrangler d1 execute --local --persist-to)articles.version)Local script:
wiki/scripts/backup-d1.shis left unchanged in this PR. Undersite/ops/brain_release_authority.py, any change underwiki/scripts/is release-affecting, and this fix should not wait on a Brain release.docs/BACKUP.mdstates that the script still writes plaintext to the gitignoredwiki/backups/and shows how to encrypt its output by hand. A follow-up PR makes the script encrypt the same way.Release check:
brain_release_authority.blocking_changes()overgit diff --name-only main...HEADreturns nothing, so this PR is release-neutral (.github/anddocs/only).How tested
The workflow only runs on
scheduleandworkflow_dispatch, so PR CI never executes it. Everything below ran locally on macOS.linux-amd64tarball's SHA-256 equals the release asset digest that GitHub reports (cbe24006…ac10). The tarball also verifies against the release's Sigsum proof (sigsum-verifyv0.13.1, policysigsum-generic-2025-1, the key from age'sSIGSUM.md), and a copy with one byte appended fails the proof.backup-d1.ymland runs the realrun:blocks in job order, underbash -eand GNUsha256sum. wrangler and curl are stubbed, the fake dump and research rows carry PII markers, and the keys are throwaway. All checks pass:.sql.ageand.jsonl.agedecrypt byte-for-byte to the originals, either of two recipients can decrypt, and a non-recipient key cannot.TODO, a key with a one-character typo, a valid key plus a garbage line, a pastedAGE-SECRET-KEY-…, or anage1yubikey1…recipient, the job stops at Check backup recipients.npm ci, wrangler and the research fetch never run, nothing is uploaded, and an::error::annotation is emitted. age's error text never echoes the rejected line.$RUNNER_TEMP. No marker appears in any step log on any path.PIPELINE_TOKEN, the endpoint is never called.wikilean-<ts>.sqlwith no age header.WRANGLER_LOG=warn: ad1 export --localprints nothing on stdout (its default level prints🌀 Exporting SQL…/Done!), the export bytes are unchanged, and an error still prints on stderr.users/sessions/accountsrows, thenwrangler d1 export --local,ageencrypt, delete the plaintext, decrypt (bytes identical), andwrangler d1 execute wikilean --local --persist-to <fresh dir> --file=…. All 16 tables have matching row counts. Thewrangler d1 time-travelandgh run download/gh variable setflags in the doc were checked against--help.bash -eo pipefailwith age v1.3.2 and a wrangler stub.age1…,age1pq1…, ssh-ed25519 and RSA-3072 lists pass. An ECDSA or security-key SSH line, short RSA, malformed keys and a pastedAGE-SECRET-KEYall fail. Before this change,age1…plus an ECDSA line passed with only a warning.actionlintv1.7.12 withshellcheckv0.11.0 reports nothing forbackup-d1.yml.Risks
--strip-components=1 age/agemember selection, which I checked with bsdtar, not GNU tar. If any step before the export misbehaves, the job fails before exporting, so the failure mode is "no backup that day", never a plaintext upload.logger.logto stdout, whichWRANGLER_LOG=warnand> /dev/nullboth drop. A future wrangler that printed it at warn or error level would go to stderr, where the URL redactor replaces it. A URL split across lines, or printed without a scheme, would still get through, so recheck the export step's log when bumping wrangler.BACKUP_AGE_RECIPIENTSis printed in the step's env block, which is fine for public keys. A private key pasted there by mistake is rejected but exposed, and the doc says to rotate.wrangler d1 exportcan only write to a file. It sits outside the workspace with mode 0600 and is deleted on every exit path.What Jack must do
Now, if not already done:
gh workflow disable backup-d1.yml --repo Deicyde/WikiLean). Then work through the cutover indocs/BACKUP.md: never re-run a pre-encryption run (re-runs replay the original commit's plaintext workflow) and delete those runs, delete the existingwikilean-d1-*/research-export-*artifacts, and decide on OAuth token revocation,BETTER_AUTH_SECRETrotation and user notification.Before merging (or before re-enabling):
age-keygen -o wikilean-backup.key && age-keygen -y wikilean-backup.key > backup-recipients.txt(optional: append a second, separately held spare key's
age1…line).gh variable set BACKUP_AGE_RECIPIENTS --repo Deicyde/WikiLean < backup-recipients.txtgh workflow enable backup-d1.yml --repo Deicyde/WikiLean && gh workflow run backup-d1.yml --repo Deicyde/WikiLean --ref main(Dispatch only from
main: a dispatch uses the workflow file on the chosen ref, and older branches still carry the plaintext version.)Then download that run's
wikilean-d1-<ts>artifact and confirm it decrypts (docs/BACKUP.md, "Download and decrypt").If the variable is missing, the re-enabled workflow fails every day without exporting anything. It never uploads plaintext.