Skip to content

feat: account handoff relay and encrypted backups - #45

Merged
leviFrosty merged 1 commit into
mainfrom
account-handoff
Oct 16, 2026
Merged

leviFrosty merged 1 commit into
mainfrom
account-handoff

Conversation

@leviFrosty

Copy link
Copy Markdown
Owner

Backend half of witness-work ADR 0026 (move, recover, and back up the server account), built to docs/accounts-protocol.md ("Handoff", "Backup", "Privacy").

What changed

Handoff relay (Set Up Another Device)

  • New SQLite Durable Object AccountHandoff (src/accounts/handoffDO.ts), one per channelId, binding ACCOUNT_HANDOFF, migration v7 (prod and env.dev).
  • POST /accounts/v1/handoff/{open,claim,status,send,receive,done,cancel} (src/accounts/handoff.ts).
    • Old-phone ops are self-signed under ww-account/v1 and need a registered account. An unknown key gets unknown_account.
    • New-phone ops are plain JSON. The DO compares b64u(SHA-256(claimToken)) with verifier in constant time.
  • The channel moves open → claimed → sent → done, or to cancelled from any state.
    • The first valid claim wins. An identical repeat gets ok; any other claim gets conflict.
    • Five wrong tokens burn the channel.
    • done and cancel clear the blobs at once. The alarm deletes everything 10 minutes after open.
    • Another account acting as the old phone gets forbidden.
  • Blob caps: hello ≤ 2,048 and key ≤ 1,024 b64u characters. Anything over is too_large.
  • Limits: open uses the register edge tier plus 20 opens per account per rolling day. That quota is BuddyRegistrationQuota under a hashed handoff-open name; admit now takes a limit. Every other op uses the read tier.

Backups

  • New R2 binding ACCOUNT_BACKUPS: ww-account-backups in prod, ww-account-backups-dev in dev. Each object is stored at v1/<accountId>/<slotId> with customMetadata.uploadedAt.
  • POST /accounts/v1/backup/{put,list,get,delete} (src/accounts/backup.ts).
    • put reads its envelope from x-ww-p/x-ww-s.
    • A declared Content-Length or signed size over 20 MiB is refused (too_large, 413) before the body is read.
    • The body is then checked against size and sha256.
    • Each account gets 8 slots (limit) and one put per slot per minute (rate_limited with Retry-After).
    • get streams application/octet-stream. delete without slotId deletes every slot.
    • Without the bucket or ACCOUNTS_DB, every op returns disabled. Suspended accounts can still back up.
  • scripts/r2-lifecycle.mjs now handles both bucket families. ww-account-backups* gets a rule that deletes v1/ objects 400 days after their last upload. deploy, deploy:dev, and the tag deploy run it for the backup bucket too.

Other

  • GET /h is the camera fallback page. It is localized with the same resolver and locale set as /c, runs no script, and reads nothing from the request except the language. The AASA now matches /h#1…. assetlinks.json already covers the whole host.
  • New shared error code forbidden (403). too_large, limit, and not_found already existed.
  • Sentry drops x-ww-p/x-ww-s.
  • OpenAPI docs for all 11 routes and /h.
  • AGENTS.md has a new "Handoff and backup" section.
  • New shared helpers for signed account ops (src/accounts/signed.ts). account/register and account/info now use them, with the same behavior and quota names.

Prod setup (not done here)

# 1. The bucket and its 400-day rule (the tag deploy also runs this before deploying)
node scripts/r2-lifecycle.mjs ww-account-backups
node scripts/r2-lifecycle.mjs ww-account-backups --check
# 2. Deploy: applies the v7 migration (AccountHandoff) and binds ACCOUNT_BACKUPS
pnpm run deploy        # or push a v* tag

The GitHub deploy's CLOUDFLARE_API_TOKEN already needs Workers R2 Storage: Edit for the Buddies bucket. iOS caches the AASA, so the /h entry only goes live with a prod deploy and should ship ahead of the app UI.

Dev: the ww-account-backups-dev bucket was created with its 400-day rule (node scripts/r2-lifecycle.mjs ww-account-backups-dev). The dev worker has not been deployed.

Verified

  • pnpm test: 771 passed. New suites: src/accounts/handoff.test.ts, src/accounts/backup.test.ts, src/accounts/handoffPage.test.ts, plus lifecycle and Sentry cases.
    • Covered: every status transition, conflict and idempotent claims, burning, expiry and the alarm, forbidden, the daily quota and edge tiers, put/list/get/delete, the size, sha, slot, and interval limits, too_large (declared, signed, and streamed), and disabled.
  • pnpm exec tsc --noEmit passes. This repo has no lint script.
  • wrangler deploy --dry-run and --env dev --dry-run both show ACCOUNT_HANDOFF and ACCOUNT_BACKUPS.
  • verify-ww-api on a local wrangler dev --env dev:
    • doctor ok.
    • pnpm test:e2e: 47 passed, 3 skipped. This includes new e2e for a full handoff, burning, a backup round trip through local R2, a sha mismatch, and /h.
    • A one-off exact 20 MiB put/get round trip passed.
    • A 20 MiB + 1 upload returned 413 too_large.
    • pnpm fuzz:buddies --runs 150 ok.

Not verified

  • On deployed Cloudflare: real R2 list({ include: ['customMetadata'] }), and a 20 MiB upload through the edge.
  • Against the app; its half isn't built yet.

Set Up Another Device and backups for the server account (witness-work
ADR 0026). POST /accounts/v1/handoff/{open,claim,status,send,receive,done,
cancel} relays a sealed hello and account key through one AccountHandoff
Durable Object per channel (10 minutes, first claim wins, five bad tokens
burn it). POST /accounts/v1/backup/{put,list,get,delete} keeps one
encrypted object per device slot in the new ACCOUNT_BACKUPS R2 bucket.
GET /h is the camera fallback page, matched by the AASA.
@leviFrosty
leviFrosty merged commit c539fcb into main Oct 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant