From 14e57b0a1c3e1bbe861c4c00bbc9649256607abb Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Thu, 15 Oct 2026 01:59:21 -0400 Subject: [PATCH] feat: account handoff relay and encrypted backups 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. --- .github/workflows/deploy.yml | 7 + AGENTS.md | 68 ++++ package.json | 4 +- scripts/r2-lifecycle.mjs | 98 ++++-- scripts/r2-lifecycle.test.ts | 23 ++ src/accounts/backup.test.ts | 409 +++++++++++++++++++++++ src/accounts/backup.ts | 207 ++++++++++++ src/accounts/contracts.ts | 198 ++++++++++- src/accounts/handoff.test.ts | 510 +++++++++++++++++++++++++++++ src/accounts/handoff.ts | 188 +++++++++++ src/accounts/handoffDO.ts | 319 ++++++++++++++++++ src/accounts/handoffPage.test.ts | 81 +++++ src/accounts/handoffPage.ts | 141 ++++++++ src/accounts/handoffPageLocales.ts | 171 ++++++++++ src/accounts/openapi.ts | 416 +++++++++++++++++++++++ src/accounts/route.ts | 143 +++----- src/accounts/signed.ts | 191 +++++++++++ src/accounts/store.ts | 2 +- src/app.ts | 10 +- src/buddies/contracts.ts | 2 + src/buddies/envelope.ts | 15 +- src/buddies/invitePage.test.ts | 1 + src/buddies/registrationQuota.ts | 12 +- src/contactLink.test.ts | 1 + src/contactLink.ts | 2 + src/e2e/accounts.e2e.test.ts | 176 +++++++++- src/e2e/public.e2e.test.ts | 1 + src/index.ts | 1 + src/openapi/coreDocs.ts | 8 + src/sentry.test.ts | 19 ++ src/sentry.ts | 7 +- src/test/buddies.ts | 14 + src/test/memoryR2.ts | 26 +- src/types.ts | 16 + wrangler.toml | 38 ++- 35 files changed, 3362 insertions(+), 163 deletions(-) create mode 100644 src/accounts/backup.test.ts create mode 100644 src/accounts/backup.ts create mode 100644 src/accounts/handoff.test.ts create mode 100644 src/accounts/handoff.ts create mode 100644 src/accounts/handoffDO.ts create mode 100644 src/accounts/handoffPage.test.ts create mode 100644 src/accounts/handoffPage.ts create mode 100644 src/accounts/handoffPageLocales.ts create mode 100644 src/accounts/signed.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fdf906d..47fcd7a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -66,6 +66,13 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # The account backup bucket and its 400-day expiry, the same way. + - name: Ensure the account backup bucket + run: node scripts/r2-lifecycle.mjs ww-account-backups + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # Accounts schema (src/accounts/schema.ts) before the code that needs it. # Already-applied migrations are skipped. The API token needs D1: Edit. - name: Apply accounts database migrations diff --git a/AGENTS.md b/AGENTS.md index 8f4c9cd..f8c6fc1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -449,6 +449,74 @@ curl -sX POST "$BASE/admin/accounts/status" -H "x-ww-admin-token: $TOKEN" \ of the account's inboxes. If `inboxesFailed` is above 0, send the same request again. +### Handoff and backup + +Moving the account to another phone and backing it up (witness-work ADR 0026; +"Handoff", "Backup", and "Recovery key" in its `docs/accounts-protocol.md`). +Shared helpers for the self-signed ops are in `src/accounts/signed.ts`. + +- **Handoff** (Set Up Another Device): `POST /accounts/v1/handoff/open|claim| + status|send|receive|done|cancel`, code in `src/accounts/handoff.ts`. The old + phone's ops (`open`, `status`, `send`, and `cancel` with `p`/`s`) are signed + by the account key like `account/register` and need a registered account + (`unknown_account` otherwise; a suspended one may still hand off). The new + phone's (`claim`, `receive`, `done`, and `cancel` without `p`) are plain JSON + carrying `claimToken`. One `AccountHandoff` SQLite DO per `channelId` + (binding `ACCOUNT_HANDOFF`, migration `v7`, `src/accounts/handoffDO.ts`) + holds the opening account, `verifier = b64u(SHA-256(claimToken))`, the new + phone's temporary key, and the sealed hello (≤ 2,048 chars) and key + (≤ 1,024; over either is `too_large`). Status runs `open → claimed → sent → + done`, or `cancelled` from any of them. The first valid claim wins and an + identical repeat gets `ok`; five wrong tokens (on any new-phone op) burn the + channel to `cancelled`. `done` and `cancel` clear the blobs at once, and the + alarm deletes everything 10 minutes after `open`; expired and unknown + channels are `not_found`. Another account as the old phone is 403 + `forbidden`. A wrong token is 401 `bad_signature`, and an op out of order is + `conflict`. Limits: `open` uses the Buddies register edge tier plus 20 per + account a rolling day (`BUDDY_REGISTRATION_QUOTA` under a hashed + `handoff-open` name; `admit` takes the limit), and every other op the read + tier. +- **Backup**: `POST /accounts/v1/backup/put|list|get|delete`, code in + `src/accounts/backup.ts`. All four are signed; `put` sends its envelope in + `x-ww-p`/`x-ww-s` (Sentry drops both) and the blob as the raw body. R2 + bucket `ACCOUNT_BACKUPS` (`ww-account-backups` prod, + `ww-account-backups-dev` dev), one object per device slot at + `v1//` with `customMetadata.uploadedAt` (epoch ms). `put` + refuses a declared `Content-Length` or signed `size` over 20 MiB (413 + `too_large`) before reading the body, then reads at most `size` bytes and + checks length and SHA-256 (`bad_request`). 8 slots an account (429 `limit`) + and one `put` a slot a minute (429 `rate_limited` with `Retry-After`), both + read from an R2 prefix listing, so two racing puts can each pass. `put` + counts against the Buddies photo-upload edge tier, the rest the read tier. + `delete` without `slotId` deletes every slot. Suspended accounts can still + back up. Without the bucket or `ACCOUNTS_DB`, every op is 503 `disabled`. +- **`GET /h`** is the page a camera opens for a handoff QR code + (`/h#1<…>`): install or open WitnessWork on the new phone and scan from + inside the app. Localized like `/c` (`?lang=`, then `Accept-Language`; + strings in `src/accounts/handoffPageLocales.ts`), no script. The AASA + matches `/h#1…`; `assetlinks.json` already covers the whole host. +- **Privacy**: handoff and backup log only the op name. Never log or report + bodies, headers, keys, tokens, account ids, channel ids, or slot ids. + +The buckets need to exist **before the first deploy that carries the +`ACCOUNT_BACKUPS` binding**. Every deploy (`pnpm run deploy`, `deploy:dev`, +and the tag deploy) runs `scripts/r2-lifecycle.mjs` for both buckets first, +which creates a missing bucket and sets its rules: `v1/` objects deleted 400 +days after their last upload (each `put` rewrites the object, restarting its +age), plus R2's default multipart-abort rule. By hand: + +```bash +node scripts/r2-lifecycle.mjs ww-account-backups-dev # dev: create + 400-day rule +node scripts/r2-lifecycle.mjs ww-account-backups # prod +node scripts/r2-lifecycle.mjs ww-account-backups --check # read-only +# The same with wrangler alone: +wrangler r2 bucket create ww-account-backups +wrangler r2 bucket lifecycle add ww-account-backups account-backups-expiry v1/ --expire-days 400 +``` + +The `v7` migration (`AccountHandoff`) applies on the next deploy of each +worker. + ## App Store ratings (paywall social proof) `GET /app-store/ratings` returns `{averageRating, ratingCount, countryCount, diff --git a/package.json b/package.json index c642850..3a1643e 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "main": "src/index.ts", "scripts": { "dev": "wrangler dev", - "deploy": "node scripts/r2-lifecycle.mjs ww-buddy-blobs && wrangler deploy --outdir dist --var SENTRY_RELEASE:$(sentry-cli releases propose-version) && pnpm run sentry:sourcemaps", - "deploy:dev": "node scripts/r2-lifecycle.mjs ww-buddy-blobs-dev && wrangler deploy --env dev --outdir dist --var SENTRY_RELEASE:$(sentry-cli releases propose-version) && pnpm run sentry:sourcemaps", + "deploy": "node scripts/r2-lifecycle.mjs ww-buddy-blobs && node scripts/r2-lifecycle.mjs ww-account-backups && wrangler deploy --outdir dist --var SENTRY_RELEASE:$(sentry-cli releases propose-version) && pnpm run sentry:sourcemaps", + "deploy:dev": "node scripts/r2-lifecycle.mjs ww-buddy-blobs-dev && node scripts/r2-lifecycle.mjs ww-account-backups-dev && wrangler deploy --env dev --outdir dist --var SENTRY_RELEASE:$(sentry-cli releases propose-version) && pnpm run sentry:sourcemaps", "sentry:sourcemaps": "sentry-cli releases new $(sentry-cli releases propose-version) && sentry-cli sourcemaps upload --release=$(sentry-cli releases propose-version) --strip-prefix 'dist/..' dist", "test": "vitest run", "test:e2e": "vitest run --config vitest.e2e.config.ts", diff --git a/scripts/r2-lifecycle.mjs b/scripts/r2-lifecycle.mjs index b003a4c..5046986 100644 --- a/scripts/r2-lifecycle.mjs +++ b/scripts/r2-lifecycle.mjs @@ -1,15 +1,22 @@ #!/usr/bin/env node /** - * Makes sure a Buddies photo bucket exists and carries its lifecycle rules, - * so a deploy never depends on someone having run them by hand. + * Makes sure an R2 bucket exists and carries its lifecycle rules, so a deploy + * never depends on someone having run them by hand. * * node scripts/r2-lifecycle.mjs ww-buddy-blobs # create if missing, set the rules * node scripts/r2-lifecycle.mjs ww-buddy-blobs --check # read-only; exit 1 if anything is off + * node scripts/r2-lifecycle.mjs ww-account-backups # the same for account backups + * + * Buddies photo buckets (`ww-buddy-blobs*`): the backstop deletes any object a + * day past the longest a photo can live (`BUDDIES_LIMITS.maxBlobLifetimeMs`, + * 90 days). R2 counts an object's age from its last write, and every upload + * rewrites the object, so the rule never touches a live photo; it only catches + * objects the relay lost track of. + * + * Account backup buckets (`ww-account-backups*`): a slot not uploaded for 400 + * days is deleted (witness-work ADR 0026). Every `backup/put` rewrites the + * slot's object, which restarts its age. * - * The backstop deletes any object a day past the longest a photo can live - * (`BUDDIES_LIMITS.maxBlobLifetimeMs`, 90 days). R2 counts an object's age from - * its last write, and every upload rewrites the object, so the rule never - * touches a live photo; it only catches objects the relay lost track of. * `lifecycle set` replaces every rule on the bucket, so R2's default * multipart-abort rule is restated here too. * @@ -30,26 +37,44 @@ export const BACKSTOP_DAYS = 90 + 1 export const BACKSTOP_RULE_ID = 'buddy-blobs-backstop' export const BLOB_PREFIX = 'v1/' -export const lifecycleRules = () => ({ - rules: [ - { - id: BACKSTOP_RULE_ID, - enabled: true, - conditions: { prefix: BLOB_PREFIX }, - deleteObjectsTransition: { - condition: { type: 'Age', maxAge: BACKSTOP_DAYS * DAY_SECONDS }, +/** Account backup slots: deleted 400 days after their last upload. */ +export const BACKUP_EXPIRY_DAYS = 400 +export const BACKUP_RULE_ID = 'account-backups-expiry' + +/** Each bucket family's expiry rule, by bucket name prefix. */ +export const BUCKET_RULES = [ + { bucketPrefix: 'ww-buddy-blobs', id: BACKSTOP_RULE_ID, days: BACKSTOP_DAYS }, + { bucketPrefix: 'ww-account-backups', id: BACKUP_RULE_ID, days: BACKUP_EXPIRY_DAYS }, +] + +/** The expiry rule for `bucket`, or null for a bucket this script doesn't know. */ +export const ruleFor = (bucket = 'ww-buddy-blobs') => + BUCKET_RULES.find((rule) => bucket.startsWith(rule.bucketPrefix)) ?? null + +export const lifecycleRules = (bucket = 'ww-buddy-blobs') => { + const rule = ruleFor(bucket) + if (!rule) throw new Error(`no lifecycle rule for bucket ${bucket}`) + return { + rules: [ + { + id: rule.id, + enabled: true, + conditions: { prefix: BLOB_PREFIX }, + deleteObjectsTransition: { + condition: { type: 'Age', maxAge: rule.days * DAY_SECONDS }, + }, }, - }, - { - id: 'Default Multipart Abort Rule', - enabled: true, - conditions: { prefix: '' }, - abortMultipartUploadsTransition: { - condition: { type: 'Age', maxAge: 7 * DAY_SECONDS }, + { + id: 'Default Multipart Abort Rule', + enabled: true, + conditions: { prefix: '' }, + abortMultipartUploadsTransition: { + condition: { type: 'Age', maxAge: 7 * DAY_SECONDS }, + }, }, - }, - ], -}) + ], + } +} const root = join(dirname(fileURLToPath(import.meta.url)), '..') @@ -86,16 +111,16 @@ const bucketExists = (bucket) => { return parseBlocks(listed.output).some((fields) => fields.name === bucket) } -/** Whether the bucket's rules include the backstop at the right age. */ -const backstopSet = (bucket) => { +/** Whether the bucket's rules include its expiry rule at the right age. */ +const backstopSet = (bucket, expected) => { const listed = wrangler(['r2', 'bucket', 'lifecycle', 'list', bucket]) if (!listed.ok) throw new Error(`wrangler r2 bucket lifecycle list failed:\n${listed.output}`) return parseBlocks(listed.output).some( (rule) => - rule.name === BACKSTOP_RULE_ID && + rule.name === expected.id && rule.enabled === 'Yes' && rule.prefix === BLOB_PREFIX && - rule.action?.includes(`Expire objects after ${BACKSTOP_DAYS} days`) + rule.action?.includes(`Expire objects after ${expected.days} days`) ) } @@ -106,6 +131,11 @@ const main = () => { console.error('usage: node scripts/r2-lifecycle.mjs [--check]') process.exit(2) } + const expected = ruleFor(bucket) + if (!expected) { + console.error(`r2-lifecycle: no rule for ${bucket} (known: ${BUCKET_RULES.map((rule) => `${rule.bucketPrefix}*`).join(', ')})`) + process.exit(2) + } if (!bucketExists(bucket)) { if (check) { @@ -118,25 +148,25 @@ const main = () => { } if (check) { - if (!backstopSet(bucket)) { - console.error(`r2-lifecycle: ${bucket} lacks ${BACKSTOP_RULE_ID} (${BLOB_PREFIX}, ${BACKSTOP_DAYS} days)`) + if (!backstopSet(bucket, expected)) { + console.error(`r2-lifecycle: ${bucket} lacks ${expected.id} (${BLOB_PREFIX}, ${expected.days} days)`) process.exit(1) } - console.log(`r2-lifecycle: ${bucket} ok (${BACKSTOP_RULE_ID}, ${BACKSTOP_DAYS} days)`) + console.log(`r2-lifecycle: ${bucket} ok (${expected.id}, ${expected.days} days)`) return } const dir = mkdtempSync(join(tmpdir(), 'r2-lifecycle-')) try { const file = join(dir, 'lifecycle.json') - writeFileSync(file, JSON.stringify(lifecycleRules(), null, 2)) + writeFileSync(file, JSON.stringify(lifecycleRules(bucket), null, 2)) const set = wrangler(['r2', 'bucket', 'lifecycle', 'set', bucket, '--file', file, '--force']) if (!set.ok) throw new Error(`setting ${bucket}'s lifecycle failed:\n${set.output}`) } finally { rmSync(dir, { recursive: true, force: true }) } - if (!backstopSet(bucket)) throw new Error(`${bucket}'s lifecycle rules did not take`) - console.log(`r2-lifecycle: ${bucket} ok (${BACKSTOP_RULE_ID}, ${BACKSTOP_DAYS} days)`) + if (!backstopSet(bucket, expected)) throw new Error(`${bucket}'s lifecycle rules did not take`) + console.log(`r2-lifecycle: ${bucket} ok (${expected.id}, ${expected.days} days)`) } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/scripts/r2-lifecycle.test.ts b/scripts/r2-lifecycle.test.ts index 2a65075..fa417fd 100644 --- a/scripts/r2-lifecycle.test.ts +++ b/scripts/r2-lifecycle.test.ts @@ -1,11 +1,15 @@ import { expect, it } from 'vitest' import { BUDDIES_LIMITS, buddyBlobKey } from '../src/buddies/contracts' +import { backupObjectKey } from '../src/accounts/contracts' import { BACKSTOP_DAYS, BACKSTOP_RULE_ID, + BACKUP_EXPIRY_DAYS, + BACKUP_RULE_ID, BLOB_PREFIX, lifecycleRules, parseBlocks, + ruleFor, } from './r2-lifecycle.mjs' const DAY_MS = 24 * 60 * 60 * 1000 @@ -26,6 +30,25 @@ it('deletes objects a day past the longest a blob can live', () => { expect(buddyBlobKey('inbox', 'blob').startsWith(BLOB_PREFIX)).toBe(true) }) +it('deletes account backup slots 400 days after their last upload', () => { + expect(BACKUP_EXPIRY_DAYS).toBe(400) + for (const bucket of ['ww-account-backups', 'ww-account-backups-dev']) { + const [expiry, multipart] = lifecycleRules(bucket).rules + expect(expiry).toEqual({ + id: BACKUP_RULE_ID, + enabled: true, + conditions: { prefix: BLOB_PREFIX }, + deleteObjectsTransition: { + condition: { type: 'Age', maxAge: 400 * 24 * 60 * 60 }, + }, + }) + expect(multipart.id).toBe('Default Multipart Abort Rule') + } + expect(lifecycleRules('ww-buddy-blobs-dev').rules[0].id).toBe(BACKSTOP_RULE_ID) + expect(ruleFor('some-other-bucket')).toBeNull() + expect(backupObjectKey('account', 'slot').startsWith(BLOB_PREFIX)).toBe(true) +}) + it('reads wrangler’s labelled blocks', () => { const output = [ "Listing lifecycle rules for bucket 'ww-buddy-blobs'...", diff --git a/src/accounts/backup.test.ts b/src/accounts/backup.test.ts new file mode 100644 index 0000000..6403353 --- /dev/null +++ b/src/accounts/backup.test.ts @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + SigningKey, + b64u, + createHarness, + randomBytes, + randomId, + sha256B64u, + type Harness, +} from '../test/buddies' +import { type AccountsOp, accountSignedMessage } from './contracts' +import { createAccountsRoutes } from './route' + +vi.mock('cloudflare:workers', () => ({ + DurableObject: class { + ctx: DurableObjectState + env: unknown + constructor(ctx: DurableObjectState, env: unknown) { + this.ctx = ctx + this.env = env + } + }, +})) + +const START = Date.UTC(2026, 9, 12, 12) +const MINUTE = 60_000 +const MIB = 1_024 * 1_024 +const encoder = new TextEncoder() +const app = createAccountsRoutes() + +let h: Harness + +beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(START) + h = await createHarness() +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +let ipCounter = 0 +const freshIp = () => `198.18.${++ipCounter >> 8}.${ipCounter & 255}` + +const envelope = async ( + op: AccountsOp, + key: SigningKey, + fields: Record = {}, + signer = key +) => { + const bytes = encoder.encode( + JSON.stringify({ + accountPub: key.publicKey, + ts: Date.now(), + nonce: randomId(), + ...fields, + }) + ) + return { + p: b64u(bytes), + s: b64u(await signer.sign(accountSignedMessage(op, bytes))), + } +} + +const post = async (path: string, body: unknown, ip = freshIp()) => { + const response = await app.request( + path, + { + method: 'POST', + headers: { 'content-type': 'application/json', 'cf-connecting-ip': ip }, + body: JSON.stringify(body), + }, + h.env + ) + return response +} + +const json = async (response: Response) => ({ + status: response.status, + body: await response.json(), +}) + +const register = async () => { + const key = await SigningKey.generate() + const response = await json( + await post('/account/register', await envelope('account/register', key)) + ) + expect(response.status).toBe(200) + return { key, accountId: response.body.accountId as string } +} + +interface PutOptions { + slotId?: string + bytes?: Uint8Array + /** Overrides the signed fields. */ + fields?: Record + /** Overrides or removes request headers. */ + headers?: Record + body?: BodyInit + signer?: SigningKey + ip?: string +} + +const put = async (key: SigningKey, options: PutOptions = {}) => { + const bytes = options.bytes ?? randomBytes(4_096) + const { p, s } = await envelope( + 'backup/put', + key, + { + slotId: options.slotId ?? randomId(), + size: bytes.byteLength, + sha256: await sha256B64u(bytes), + ...options.fields, + }, + options.signer + ) + const headers: Record = { + 'content-type': 'application/octet-stream', + 'cf-connecting-ip': options.ip ?? freshIp(), + 'x-ww-p': p, + 'x-ww-s': s, + } + for (const [name, value] of Object.entries(options.headers ?? {})) { + if (value == null) delete headers[name] + else headers[name] = value + } + return json( + await app.request( + '/backup/put', + { + method: 'POST', + headers, + body: options.body ?? bytes, + // Node needs it for a streamed body. + duplex: 'half', + } as RequestInit, + h.env + ) + ) +} + +const list = async (key: SigningKey) => + json(await post('/backup/list', await envelope('backup/list', key))) + +const get = async (key: SigningKey, slotId: string) => + post('/backup/get', await envelope('backup/get', key, { slotId })) + +const remove = async (key: SigningKey, slotId?: string) => + json( + await post( + '/backup/delete', + await envelope('backup/delete', key, slotId ? { slotId } : {}) + ) + ) + +describe('backup', () => { + it('puts, lists, gets, and deletes a slot', async () => { + const { key, accountId } = await register() + const slotId = randomId() + const bytes = randomBytes(10_000) + expect(await put(key, { slotId, bytes })).toEqual({ + status: 200, + body: { ok: true, uploadedAt: START }, + }) + const object = h.backups.objects.get(`v1/${accountId}/${slotId}`) + expect(object?.bytes).toEqual(bytes) + expect(object?.customMetadata).toEqual({ uploadedAt: String(START) }) + + expect(await list(key)).toEqual({ + status: 200, + body: { + ok: true, + slots: [{ slotId, size: 10_000, uploadedAt: START }], + }, + }) + + const got = await get(key, slotId) + expect(got.status).toBe(200) + expect(got.headers.get('content-type')).toBe('application/octet-stream') + expect(got.headers.get('content-length')).toBe('10000') + expect(got.headers.get('cache-control')).toBe('no-store') + expect(new Uint8Array(await got.arrayBuffer())).toEqual(bytes) + + expect(await remove(key, slotId)).toEqual({ + status: 200, + body: { ok: true }, + }) + expect(h.backups.objects.size).toBe(0) + expect(await json(await get(key, slotId))).toMatchObject({ + status: 404, + body: { code: 'not_found' }, + }) + // Idempotent. + expect((await remove(key, slotId)).status).toBe(200) + expect((await list(key)).body.slots).toEqual([]) + }) + + it('keeps each account to its own slots', async () => { + const alice = await register() + const bob = await register() + const slotId = randomId() + await put(alice.key, { slotId }) + await put(bob.key) + expect((await list(bob.key)).body.slots).toHaveLength(1) + expect((await json(await get(bob.key, slotId))).body.code).toBe( + 'not_found' + ) + await remove(bob.key, slotId) + expect(h.backups.objects.has(`v1/${alice.accountId}/${slotId}`)).toBe( + true + ) + }) + + it('deletes every slot without a slotId', async () => { + const alice = await register() + const bob = await register() + for (let i = 0; i < 3; i++) await put(alice.key) + await put(bob.key) + expect(await remove(alice.key)).toEqual({ status: 200, body: { ok: true } }) + expect((await list(alice.key)).body.slots).toEqual([]) + expect((await list(bob.key)).body.slots).toHaveLength(1) + }) + + it('replaces a slot after a minute, but not sooner', async () => { + const { key } = await register() + const slotId = randomId() + await put(key, { slotId }) + vi.setSystemTime(START + 20_000) + const early = await put(key, { slotId }) + expect(early).toMatchObject({ + status: 429, + body: { code: 'rate_limited', retryAfter: 40 }, + }) + // Another slot isn't held up. + expect((await put(key)).status).toBe(200) + + vi.setSystemTime(START + MINUTE) + const bytes = randomBytes(123) + expect(await put(key, { slotId, bytes })).toEqual({ + status: 200, + body: { ok: true, uploadedAt: START + MINUTE }, + }) + const slots = (await list(key)).body.slots as { slotId: string }[] + expect(slots.find((slot) => slot.slotId === slotId)).toEqual({ + slotId, + size: 123, + uploadedAt: START + MINUTE, + }) + }) + + it('allows 8 slots an account', async () => { + const { key } = await register() + const slots = Array.from({ length: 8 }, randomId) + for (const slotId of slots) + expect((await put(key, { slotId })).status).toBe(200) + expect(await put(key)).toMatchObject({ + status: 429, + body: { code: 'limit' }, + }) + // An existing slot can still be replaced, and deleting one frees a spot. + vi.setSystemTime(START + MINUTE) + expect((await put(key, { slotId: slots[0] })).status).toBe(200) + await remove(key, slots[1]) + expect((await put(key)).status).toBe(200) + }) + + it('refuses a body that differs from its signed size or sha256', async () => { + const { key } = await register() + const bytes = randomBytes(1_000) + const bad = { status: 400, body: { code: 'bad_request' } } + expect( + await put(key, { bytes, fields: { sha256: await sha256B64u(randomBytes(1_000)) } }) + ).toMatchObject(bad) + expect(await put(key, { bytes, fields: { size: 999 } })).toMatchObject(bad) + expect(await put(key, { bytes, fields: { size: 1_001 } })).toMatchObject(bad) + expect( + await put(key, { bytes, headers: { 'content-length': '999' } }) + ).toMatchObject(bad) + expect(h.backups.objects.size).toBe(0) + }) + + it('refuses anything over 20 MiB as too_large', async () => { + const { key } = await register() + const tooLarge = { status: 413, body: { code: 'too_large' } } + // A declared Content-Length is refused before the body or signature is read. + expect( + await put(key, { + headers: { 'content-length': String(20 * MIB + 1), 'x-ww-s': 'nope' }, + }) + ).toMatchObject(tooLarge) + // A signed size over the cap. + expect( + await put(key, { fields: { size: 20 * MIB + 1 } }) + ).toMatchObject(tooLarge) + // A streamed body past the cap, without Content-Length. + const big = new Uint8Array(20 * MIB + 1) + const chunked = new ReadableStream({ + start(controller) { + controller.enqueue(big) + controller.close() + }, + }) + expect( + await put(key, { + fields: { size: 20 * MIB }, + body: chunked, + headers: { 'content-length': null }, + }) + ).toMatchObject(tooLarge) + expect(h.backups.objects.size).toBe(0) + }) + + it('stores exactly 20 MiB', async () => { + const { key } = await register() + const full = new Uint8Array(20 * MIB) + full.set(randomBytes(64)) + expect((await put(key, { bytes: full })).status).toBe(200) + }) + + it('needs a registered account and a valid signature', async () => { + const stranger = await SigningKey.generate() + expect(await put(stranger)).toMatchObject({ + status: 404, + body: { code: 'unknown_account' }, + }) + expect(await list(stranger)).toMatchObject({ + status: 404, + body: { code: 'unknown_account' }, + }) + const { key } = await register() + expect( + await put(key, { signer: await SigningKey.generate() }) + ).toMatchObject({ status: 401, body: { code: 'bad_signature' } }) + expect(await put(key, { headers: { 'x-ww-p': null } })).toMatchObject({ + status: 400, + body: { code: 'bad_request' }, + }) + // A list signature can't be replayed as a delete. + const crossOp = await json( + await post('/backup/delete', await envelope('backup/list', key)) + ) + expect(crossOp.body.code).toBe('bad_signature') + const stale = await json( + await post( + '/backup/list', + await envelope('backup/list', key, { ts: Date.now() - 10 * MINUTE }) + ) + ) + expect(stale).toMatchObject({ status: 401, body: { code: 'stale' } }) + }) + + it('lets a suspended account back up', async () => { + const { key, accountId } = await register() + h.accountsDb!.db.prepare( + "UPDATE accounts SET status = 'suspended' WHERE id = ?" + ).run(accountId) + expect((await put(key)).status).toBe(200) + expect((await list(key)).body.slots).toHaveLength(1) + }) + + it('is off without the bucket or the accounts database', async () => { + const { key } = await register() + h = await createHarness({ backupBucket: false }) + const off = { status: 503, body: { code: 'disabled' } } + expect(await put(key)).toMatchObject(off) + expect(await list(key)).toMatchObject(off) + expect(await remove(key)).toMatchObject(off) + h = await createHarness({ accountsDb: false }) + expect(await put(key)).toMatchObject(off) + expect(await json(await get(key, randomId()))).toMatchObject(off) + expect( + await put(key, { headers: { 'content-length': String(21 * MIB) } }) + ).toMatchObject(off) + }) + + it('counts put against the photo-upload edge tier', async () => { + const { key } = await register() + const ip = '203.0.113.20' + for (let i = 0; i < 60; i++) + expect((await put(key, { ip, bytes: randomBytes(8) })).status).not.toBe( + 500 + ) + expect(await put(key, { ip })).toMatchObject({ + status: 429, + body: { code: 'rate_limited', retryAfter: 60 }, + }) + }) + + it('logs nothing that names the account or a slot', async () => { + const log = vi.spyOn(console, 'log') + const warn = vi.spyOn(console, 'warn') + const error = vi.spyOn(console, 'error') + const { key, accountId } = await register() + const slotId = randomId() + await put(key, { slotId }) + h.backups.failNext('list') + expect((await list(key)).status).toBe(500) + const logged = JSON.stringify([ + log.mock.calls, + warn.mock.calls, + error.mock.calls, + ]) + expect(logged).toContain('backup/list') + for (const secret of [accountId, slotId, key.publicKey]) + expect(logged).not.toContain(secret) + }) +}) diff --git a/src/accounts/backup.ts b/src/accounts/backup.ts new file mode 100644 index 0000000..3ad442d --- /dev/null +++ b/src/accounts/backup.ts @@ -0,0 +1,207 @@ +import type { AppContext } from '../types' +import { bytesToBase64Url, sha256Bytes, timingSafeEqual } from '../crypto' +import { readBlobBody } from '../buddies/envelope' +import { buddiesErrorResponse } from '../buddies/errorResponse' +import { + ACCOUNT_LIMITS, + backupObjectKey, + backupPrefix, + type AccountsOp, +} from './contracts' +import { + accountForKey, + guarded, + refuseAtEdge, + verifiedBody, + verifiedHeaders, + type Verified, +} from './signed' +import type { AccountRecord } from './store' + +/** + * End-to-end encrypted backups (witness-work `docs/accounts-protocol.md`, + * "Backup"): one R2 object per device slot at `v1//`. The + * server sees ciphertext, its size, and when it was uploaded + * (`customMetadata.uploadedAt`, epoch ms). A suspended account can still + * back up. R2's lifecycle rule deletes a slot 400 days after its last upload + * (`scripts/r2-lifecycle.mjs`). + * + * `backup/put` counts against the Buddies photo-upload edge tier, the rest + * against the read tier. + * + * Privacy: nothing here logs or reports bodies, keys, account ids, or slot + * ids. + */ + +const { backupBytes, backupSlots, backupPutIntervalMs } = ACCOUNT_LIMITS + +interface Slot { + slotId: string + size: number + uploadedAt: number +} + +/** The account's slots, oldest key first. */ +const listSlots = async ( + bucket: R2Bucket, + accountId: string +): Promise => { + const prefix = backupPrefix(accountId) + const slots: Slot[] = [] + let cursor: string | undefined + for (;;) { + // `include` is honored from compatibility date 2022-08-04; the base + // workers-types this repo compiles against predate it. + const page = await bucket.list({ + prefix, + cursor, + include: ['customMetadata'], + } as R2ListOptions) + for (const object of page.objects) { + const stamped = Number(object.customMetadata?.uploadedAt) + slots.push({ + slotId: object.key.slice(prefix.length), + size: object.size, + uploadedAt: Number.isSafeInteger(stamped) + ? stamped + : object.uploaded.getTime(), + }) + } + if (!page.truncated) return slots + cursor = page.cursor + } +} + +/** + * The bucket, the verified call, and its registered account; or the refusal. + * Off (`disabled`) without the bucket or the accounts database. + */ +const authorized = async ( + c: AppContext, + edgeAs: 'blob/put' | 'inbox/sync', + verify: () => Promise> +): Promise< + | { + bucket: R2Bucket + verified: Exclude, { refusal: Response }> + account: AccountRecord + } + | { refusal: Response } +> => { + const bucket = c.env.ACCOUNT_BACKUPS + if (!bucket || !c.env.ACCOUNTS_DB) + return { refusal: buddiesErrorResponse('disabled') } + const limited = await refuseAtEdge(c, edgeAs) + if (limited) return { refusal: limited } + const verified = await verify() + if ('refusal' in verified) return verified + const found = await accountForKey(c.env, verified.payload.accountPub) + if ('refusal' in found) return found + return { bucket, verified, account: found.account } +} + +const hex = (bytes: Uint8Array): string => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') + +/** + * `backup/put`: the envelope in `x-ww-p`/`x-ww-s`, the blob as the raw body. + * A declared `Content-Length` over the cap is refused before anything is + * read; otherwise the body is read up to the signed `size` and checked + * against `size` and `sha256` before it replaces the slot. + */ +const handlePut = guarded('backup/put', async (c) => { + const declared = c.req.header('content-length') + if (declared != null && Number(declared) > backupBytes) + return c.env.ACCOUNT_BACKUPS && c.env.ACCOUNTS_DB + ? buddiesErrorResponse('too_large') + : buddiesErrorResponse('disabled') + const call = await authorized(c, 'blob/put', () => + verifiedHeaders(c, 'backup/put') + ) + if ('refusal' in call) return call.refusal + const { bucket, account } = call + const { slotId, size, sha256 } = call.verified.fields + if (size > backupBytes) return buddiesErrorResponse('too_large') + + const now = Date.now() + const slots = await listSlots(bucket, account.id) + const existing = slots.find((slot) => slot.slotId === slotId) + if (existing) { + const wait = existing.uploadedAt + backupPutIntervalMs - now + if (wait > 0) + return buddiesErrorResponse('rate_limited', { + retryAfterSeconds: Math.max(1, Math.ceil(wait / 1_000)), + }) + } else if (slots.length >= backupSlots) { + return buddiesErrorResponse('limit') + } + + const body = await readBlobBody(c.req.raw, size, backupBytes) + if (!body.ok) return buddiesErrorResponse(body.error) + const digest = await sha256Bytes(body.value) + if (!timingSafeEqual(bytesToBase64Url(digest), sha256)) + return buddiesErrorResponse('bad_request') + + await bucket.put(backupObjectKey(account.id, slotId), body.value, { + sha256: hex(digest), + httpMetadata: { contentType: 'application/octet-stream' }, + customMetadata: { uploadedAt: String(now) }, + }) + return c.json({ ok: true, uploadedAt: now }) +}) + +const handleList = guarded('backup/list', async (c) => { + const call = await authorized(c, 'inbox/sync', () => + verifiedBody(c, 'backup/list') + ) + if ('refusal' in call) return call.refusal + const slots = await listSlots(call.bucket, call.account.id) + return c.json({ ok: true, slots }) +}) + +/** Streams the slot's bytes back exactly as put. */ +const handleGet = guarded('backup/get', async (c) => { + const call = await authorized(c, 'inbox/sync', () => + verifiedBody(c, 'backup/get') + ) + if ('refusal' in call) return call.refusal + const object = await call.bucket.get( + backupObjectKey(call.account.id, call.verified.fields.slotId) + ) + if (!object) return buddiesErrorResponse('not_found') + return new Response(object.body, { + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(object.size), + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }, + }) +}) + +/** One slot, or every slot without `slotId`. Idempotent. */ +const handleDelete = guarded('backup/delete', async (c) => { + const call = await authorized(c, 'inbox/sync', () => + verifiedBody(c, 'backup/delete') + ) + if ('refusal' in call) return call.refusal + const { bucket, account } = call + const { slotId } = call.verified.fields + const keys = + slotId != null + ? [backupObjectKey(account.id, slotId)] + : (await listSlots(bucket, account.id)).map((slot) => + backupObjectKey(account.id, slot.slotId) + ) + // R2 deletes at most 1,000 keys a call. + for (let start = 0; start < keys.length; start += 1_000) + await bucket.delete(keys.slice(start, start + 1_000)) + return c.json({ ok: true }) +}) + +export const backupHandlers = { + put: handlePut, + list: handleList, + get: handleGet, + delete: handleDelete, +} diff --git a/src/accounts/contracts.ts b/src/accounts/contracts.ts index 3ce848d..5e40c60 100644 --- a/src/accounts/contracts.ts +++ b/src/accounts/contracts.ts @@ -15,9 +15,58 @@ import { * Buddies one or the other way round. */ -export const ACCOUNTS_OPS = ['account/register', 'account/info'] as const +/** Ops signed by the account key (the old phone's, for handoff). */ +export const ACCOUNTS_OPS = [ + 'account/register', + 'account/info', + 'handoff/open', + 'handoff/status', + 'handoff/send', + 'handoff/cancel', + 'backup/put', + 'backup/list', + 'backup/get', + 'backup/delete', +] as const export type AccountsOp = (typeof ACCOUNTS_OPS)[number] +/** `backup/put` sends its signed envelope in these headers; the body is the blob. */ +export const ACCOUNT_ENVELOPE_HEADERS = { + payload: 'x-ww-p', + signature: 'x-ww-s', +} as const + +const MINUTE_MS = 60_000 +const DAY_MS = 24 * 60 * MINUTE_MS +const MIB = 1_024 * 1_024 + +/** Handoff and backup limits from the contract's "Handoff" and "Backup". */ +export const ACCOUNT_LIMITS = { + /** A handoff channel lives this long from `handoff/open`. */ + handoffLifetimeMs: 10 * MINUTE_MS, + /** Wrong `claimToken`s before the channel is burned (`cancelled`). */ + handoffBadTokens: 5, + /** `handoff/open` per account per rolling day. */ + handoffOpensPerDay: 20, + handoffOpenWindowMs: DAY_MS, + /** The sealed hello, b64u characters. */ + handoffHelloChars: 2 * 1_024, + /** The sealed account key, b64u characters. */ + handoffKeyChars: 1_024, + /** One backup blob. */ + backupBytes: 20 * MIB, + /** Backup slots per account. */ + backupSlots: 8, + /** One `backup/put` per slot this often. */ + backupPutIntervalMs: MINUTE_MS, +} as const + +/** A slot's R2 object key; `v1//` lists an account's slots. */ +export const backupObjectKey = (accountId: string, slotId: string): string => + `${backupPrefix(accountId)}${slotId}` + +export const backupPrefix = (accountId: string): string => `v1/${accountId}/` + const SIGNING_CONTEXT = 'ww-account/v1\n' const encoder = new TextEncoder() @@ -59,11 +108,27 @@ export interface AccountKeyPayload { export const isAccountId = (value: unknown): value is string => typeof value === 'string' && BUDDIES_ID_PATTERN.test(value) -/** Canonical b64u of exactly 32 bytes, or null. */ -const publicKey = (value: unknown): string | null => { +/** Canonical b64u of exactly `size` bytes, or null. */ +const canonicalBytes = (value: unknown, size: number): string | null => { if (typeof value !== 'string') return null const bytes = decodeB64u(value) - return bytes && bytes.length === 32 ? bytesToBase64Url(bytes) : null + return bytes && bytes.length === size ? bytesToBase64Url(bytes) : null +} + +/** Canonical b64u of exactly 32 bytes, or null. */ +const publicKey = (value: unknown): string | null => canonicalBytes(value, 32) + +/** A 16-byte id (channel, slot, claim token) in its one canonical spelling. */ +const id16 = (value: unknown): string | null => { + const canonical = canonicalBytes(value, 16) + return canonical != null && canonical === value ? canonical : null +} + +/** A sealed blob as b64u: decodable, non-empty, at most `maxChars`. */ +const sealed = (value: unknown, maxChars: number): string | null => { + if (typeof value !== 'string' || value.length === 0) return null + if (value.length > maxChars) return null + return decodeB64u(value) ? value : null } /** @@ -82,3 +147,128 @@ export const parseAccountKeyPayload = ( if (!isAccountId(payload.nonce)) return null return { accountPub, ts: Math.floor(ts), nonce: payload.nonce } } + +// --- Handoff and backup payloads ------------------------------------------- + +export interface HandoffOpenFields { + channelId: string + /** b64u SHA-256 of the claim token, 32 bytes. */ + verifier: string +} + +export interface HandoffChannelFields { + channelId: string +} + +export interface HandoffSendFields { + channelId: string + key: string +} + +export interface BackupPutFields { + slotId: string + size: number + sha256: string +} + +export interface BackupSlotFields { + slotId: string +} + +export interface BackupDeleteFields { + slotId: string | null +} + +type Fields = Record + +/** + * The fields each signed op adds to `accountPub`, `ts`, and `nonce`. Null + * means `bad_request`. `too_large` is the route's call, so `backup/put` + * accepts any positive `size` here. + */ +export const ACCOUNT_FIELD_PARSERS = { + 'account/register': (): Fields => ({}), + 'account/info': (): Fields => ({}), + 'handoff/open': (p: Fields): HandoffOpenFields | null => { + const channelId = id16(p.channelId) + const verifier = canonicalBytes(p.verifier, 32) + return channelId && verifier ? { channelId, verifier } : null + }, + 'handoff/status': (p: Fields): HandoffChannelFields | null => { + const channelId = id16(p.channelId) + return channelId ? { channelId } : null + }, + 'handoff/send': (p: Fields): HandoffSendFields | null => { + const channelId = id16(p.channelId) + const key = sealed(p.key, ACCOUNT_LIMITS.handoffKeyChars) + return channelId && key ? { channelId, key } : null + }, + 'handoff/cancel': (p: Fields): HandoffChannelFields | null => { + const channelId = id16(p.channelId) + return channelId ? { channelId } : null + }, + 'backup/put': (p: Fields): BackupPutFields | null => { + const slotId = id16(p.slotId) + const sha256 = canonicalBytes(p.sha256, 32) + const size = p.size + if (!slotId || !sha256) return null + if (typeof size !== 'number' || !Number.isSafeInteger(size) || size < 1) + return null + return { slotId, size, sha256 } + }, + 'backup/list': (): Fields => ({}), + 'backup/get': (p: Fields): BackupSlotFields | null => { + const slotId = id16(p.slotId) + return slotId ? { slotId } : null + }, + 'backup/delete': (p: Fields): BackupDeleteFields | null => { + if (p.slotId === undefined || p.slotId === null) return { slotId: null } + const slotId = id16(p.slotId) + return slotId ? { slotId } : null + }, +} satisfies Record object | null> + +export type AccountFields = NonNullable< + ReturnType<(typeof ACCOUNT_FIELD_PARSERS)[K]> +> + +/** The new phone's requests: plain JSON, authorized by the claim token. */ +export interface HandoffTokenRequest { + channelId: string + claimToken: string +} + +export interface HandoffClaimRequest extends HandoffTokenRequest { + claimPub: string + hello: string +} + +export const parseHandoffTokenRequest = ( + body: unknown +): HandoffTokenRequest | null => { + if (!isPlainObject(body)) return null + const channelId = id16(body.channelId) + const claimToken = id16(body.claimToken) + return channelId && claimToken ? { channelId, claimToken } : null +} + +export const parseHandoffClaimRequest = ( + body: unknown +): HandoffClaimRequest | null => { + const base = parseHandoffTokenRequest(body) + if (!base || !isPlainObject(body)) return null + const claimPub = publicKey(body.claimPub) + const hello = sealed(body.hello, ACCOUNT_LIMITS.handoffHelloChars) + return claimPub && hello ? { ...base, claimPub, hello } : null +} + +/** A sealed blob's cap broken, for `too_large` instead of `bad_request`. */ +export const handoffBlobTooLarge = (body: unknown): boolean => { + if (!isPlainObject(body)) return false + const over = (value: unknown, max: number) => + typeof value === 'string' && value.length > max + return ( + over(body.hello, ACCOUNT_LIMITS.handoffHelloChars) || + over(body.key, ACCOUNT_LIMITS.handoffKeyChars) + ) +} diff --git a/src/accounts/handoff.test.ts b/src/accounts/handoff.test.ts new file mode 100644 index 0000000..005f2a3 --- /dev/null +++ b/src/accounts/handoff.test.ts @@ -0,0 +1,510 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + SigningKey, + b64u, + blobOfSize, + createHarness, + randomBytes, + randomId, + sha256B64u, + type Harness, +} from '../test/buddies' +import { type AccountsOp, accountSignedMessage } from './contracts' +import { createAccountsRoutes } from './route' + +vi.mock('cloudflare:workers', () => ({ + DurableObject: class { + ctx: DurableObjectState + env: unknown + constructor(ctx: DurableObjectState, env: unknown) { + this.ctx = ctx + this.env = env + } + }, +})) + +const START = Date.UTC(2026, 9, 12, 12) +const MINUTE = 60_000 +const encoder = new TextEncoder() +const app = createAccountsRoutes() + +let h: Harness + +beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(START) + h = await createHarness() +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +let ipCounter = 0 +const post = async (path: string, body: unknown, ip?: string) => { + const response = await app.request( + path, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + // A fresh caller per request unless a test is about the edge tier. + 'cf-connecting-ip': ip ?? `198.18.${++ipCounter >> 8}.${ipCounter & 255}`, + }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }, + h.env + ) + return { status: response.status, body: await response.json() } +} + +const signed = async ( + op: AccountsOp, + key: SigningKey, + fields: Record = {}, + signer = key +) => { + const bytes = encoder.encode( + JSON.stringify({ + accountPub: key.publicKey, + ts: Date.now(), + nonce: randomId(), + ...fields, + }) + ) + return { + p: b64u(bytes), + s: b64u(await signer.sign(accountSignedMessage(op, bytes))), + } +} + +const register = async () => { + const key = await SigningKey.generate() + const response = await post( + '/account/register', + await signed('account/register', key) + ) + expect(response.status).toBe(200) + return { key, accountId: response.body.accountId as string } +} + +/** What the QR code gives the new phone, and the server's verifier. */ +const newChannel = async () => { + const token = randomBytes(16) + return { + channelId: randomId(), + claimToken: b64u(token), + verifier: await sha256B64u(token), + } +} + +type Channel = Awaited> + +const oldOp = async ( + op: 'handoff/open' | 'handoff/status' | 'handoff/send' | 'handoff/cancel', + key: SigningKey, + fields: Record +) => post(`/${op}`, await signed(op, key, fields)) + +const open = (key: SigningKey, ch: Channel) => + oldOp('handoff/open', key, { + channelId: ch.channelId, + verifier: ch.verifier, + }) +const status = (key: SigningKey, ch: Channel) => + oldOp('handoff/status', key, { channelId: ch.channelId }) +const send = (key: SigningKey, ch: Channel, sealedKey = blobOfSize(80)) => + oldOp('handoff/send', key, { channelId: ch.channelId, key: sealedKey }) + +const claimBody = async (ch: Channel) => ({ + channelId: ch.channelId, + claimToken: ch.claimToken, + claimPub: (await SigningKey.generate()).publicKey, + hello: blobOfSize(200), +}) +const claim = (body: Record) => post('/handoff/claim', body) +const tokenOp = ( + op: 'receive' | 'done' | 'cancel', + ch: Channel, + claimToken = ch.claimToken +) => post(`/handoff/${op}`, { channelId: ch.channelId, claimToken }) + +describe('handoff', () => { + it('walks open → claimed → sent → done, deleting the blobs at done', async () => { + const { key } = await register() + const ch = await newChannel() + const expiresAt = START + 10 * MINUTE + expect(await open(key, ch)).toEqual({ + status: 200, + body: { ok: true, expiresAt }, + }) + expect((await status(key, ch)).body).toEqual({ + ok: true, + status: 'open', + expiresAt, + }) + expect((await tokenOp('receive', ch)).body).toEqual({ + ok: true, + status: 'open', + }) + + const body = await claimBody(ch) + expect(await claim(body)).toEqual({ + status: 200, + body: { ok: true, expiresAt }, + }) + expect((await status(key, ch)).body).toEqual({ + ok: true, + status: 'claimed', + expiresAt, + claimPub: body.claimPub, + hello: body.hello, + }) + expect((await tokenOp('receive', ch)).body).toEqual({ + ok: true, + status: 'claimed', + }) + // Done before the key arrives is out of order. + expect((await tokenOp('done', ch)).body.code).toBe('conflict') + + const sealedKey = blobOfSize(80) + expect(await send(key, ch, sealedKey)).toEqual({ + status: 200, + body: { ok: true }, + }) + expect((await status(key, ch)).body.status).toBe('sent') + expect((await tokenOp('receive', ch)).body).toEqual({ + ok: true, + status: 'sent', + key: sealedKey, + }) + // Only while claimed. + expect((await send(key, ch)).body.code).toBe('conflict') + + expect(await tokenOp('done', ch)).toEqual({ + status: 200, + body: { ok: true }, + }) + expect((await tokenOp('done', ch)).status).toBe(200) + expect((await status(key, ch)).body).toEqual({ + ok: true, + status: 'done', + expiresAt, + }) + expect((await tokenOp('receive', ch)).body).toEqual({ + ok: true, + status: 'done', + }) + const [row] = h.handoffs + .storage(ch.channelId) + .query('SELECT claim_pub, hello, key FROM channel') + expect(row).toEqual({ claim_pub: null, hello: null, key: null }) + // Cancelling a finished handoff leaves it done. + expect((await tokenOp('cancel', ch)).status).toBe(200) + expect((await status(key, ch)).body.status).toBe('done') + }) + + it('lets either phone cancel, deleting every blob, idempotently', async () => { + const { key } = await register() + const byOld = await newChannel() + await open(key, byOld) + await claim(await claimBody(byOld)) + expect( + await oldOp('handoff/cancel', key, { channelId: byOld.channelId }) + ).toEqual({ status: 200, body: { ok: true } }) + expect( + (await oldOp('handoff/cancel', key, { channelId: byOld.channelId })) + .status + ).toBe(200) + expect((await status(key, byOld)).body).toEqual({ + ok: true, + status: 'cancelled', + expiresAt: START + 10 * MINUTE, + }) + expect((await tokenOp('receive', byOld)).body.status).toBe('cancelled') + expect((await send(key, byOld)).body.code).toBe('conflict') + + const byNew = await newChannel() + await open(key, byNew) + await claim(await claimBody(byNew)) + await send(key, byNew) + expect(await tokenOp('cancel', byNew)).toEqual({ + status: 200, + body: { ok: true }, + }) + expect((await tokenOp('receive', byNew)).body).toEqual({ + ok: true, + status: 'cancelled', + }) + expect((await tokenOp('done', byNew)).body.code).toBe('conflict') + const [row] = h.handoffs + .storage(byNew.channelId) + .query('SELECT claim_pub, hello, key FROM channel') + expect(row).toEqual({ claim_pub: null, hello: null, key: null }) + }) + + it('lets the first claim win and repeats it, but no other', async () => { + const { key } = await register() + const ch = await newChannel() + await open(key, ch) + const first = await claimBody(ch) + expect((await claim(first)).status).toBe(200) + // A lost response: the same body again. + expect((await claim(first)).status).toBe(200) + // Someone else with the same QR code, or the same phone re-sealing. + expect(await claim(await claimBody(ch))).toMatchObject({ + status: 409, + body: { code: 'conflict' }, + }) + expect( + await claim({ ...first, hello: blobOfSize(200) }) + ).toMatchObject({ status: 409, body: { code: 'conflict' } }) + expect((await status(key, ch)).body.claimPub).toBe(first.claimPub) + // The winner's repeat still works after the key is sent, not once cancelled. + await send(key, ch) + expect((await claim(first)).status).toBe(200) + await tokenOp('cancel', ch) + expect((await claim(first)).body.code).toBe('conflict') + }) + + it('burns the channel after five wrong claim tokens', async () => { + const { key } = await register() + const ch = await newChannel() + await open(key, ch) + const body = await claimBody(ch) + for (let i = 0; i < 4; i++) { + const wrong = await claim({ ...body, claimToken: randomId() }) + expect(wrong).toMatchObject({ + status: 401, + body: { code: 'bad_signature' }, + }) + } + expect((await status(key, ch)).body.status).toBe('open') + // Wrong tokens count on every new-phone op. + expect((await tokenOp('receive', ch, randomId())).status).toBe(401) + expect((await status(key, ch)).body.status).toBe('cancelled') + expect((await claim(body)).body.code).toBe('conflict') + }) + + it('burns a claimed channel too, dropping its blobs', async () => { + const { key } = await register() + const ch = await newChannel() + await open(key, ch) + await claim(await claimBody(ch)) + for (let i = 0; i < 5; i++) + expect((await tokenOp('done', ch, randomId())).status).toBe(401) + expect((await status(key, ch)).body).toEqual({ + ok: true, + status: 'cancelled', + expiresAt: START + 10 * MINUTE, + }) + }) + + it('refuses another account as the old phone', async () => { + const owner = await register() + const other = await register() + const ch = await newChannel() + await open(owner.key, ch) + await claim(await claimBody(ch)) + const forbidden = { status: 403, body: { code: 'forbidden' } } + expect(await status(other.key, ch)).toMatchObject(forbidden) + expect(await send(other.key, ch)).toMatchObject(forbidden) + expect( + await oldOp('handoff/cancel', other.key, { channelId: ch.channelId }) + ).toMatchObject(forbidden) + expect((await status(owner.key, ch)).body.status).toBe('claimed') + // Opening an existing channel is a conflict, for anyone. + expect((await open(other.key, ch)).body.code).toBe('conflict') + expect((await open(owner.key, ch)).body.code).toBe('conflict') + }) + + it('needs a registered account and a valid signature for old-phone ops', async () => { + const stranger = await SigningKey.generate() + const ch = await newChannel() + expect(await open(stranger, ch)).toMatchObject({ + status: 404, + body: { code: 'unknown_account' }, + }) + const { key } = await register() + const forged = await post( + '/handoff/open', + await signed( + 'handoff/open', + key, + { channelId: ch.channelId, verifier: ch.verifier }, + await SigningKey.generate() + ) + ) + expect(forged).toMatchObject({ status: 401, body: { code: 'bad_signature' } }) + // A status signature can't open. + const crossOp = await post('/handoff/open', { + ...(await signed('handoff/status', key, { + channelId: ch.channelId, + verifier: ch.verifier, + })), + }) + expect(crossOp.body.code).toBe('bad_signature') + }) + + it('works for a suspended account', async () => { + const { key, accountId } = await register() + h.accountsDb!.db.prepare( + "UPDATE accounts SET status = 'suspended' WHERE id = ?" + ).run(accountId) + expect((await open(key, await newChannel())).status).toBe(200) + }) + + it('answers not_found for unknown and expired channels, and the alarm deletes them', async () => { + const { key } = await register() + const unknown = await newChannel() + expect((await status(key, unknown)).body.code).toBe('not_found') + expect((await claim(await claimBody(unknown))).body.code).toBe('not_found') + expect((await tokenOp('receive', unknown)).status).toBe(404) + + const ch = await newChannel() + await open(key, ch) + await claim(await claimBody(ch)) + const storage = h.handoffs.storage(ch.channelId) + expect(storage.alarm()).toBe(START + 10 * MINUTE) + + vi.setSystemTime(START + 10 * MINUTE) + expect((await status(key, ch)).body.code).toBe('not_found') + expect((await tokenOp('receive', ch)).body.code).toBe('not_found') + await h.handoffs.fireAlarm(ch.channelId) + expect(storage.tables()).toEqual([]) + expect(storage.alarm()).toBeNull() + + // The channel id can be opened again once its old channel is gone. + expect((await open(key, ch)).status).toBe(200) + }) + + it('reschedules an early alarm until expiry', async () => { + const { key } = await register() + const ch = await newChannel() + await open(key, ch) + vi.setSystemTime(START + 5 * MINUTE) + await h.handoffs.fireAlarm(ch.channelId) + const storage = h.handoffs.storage(ch.channelId) + expect(storage.alarm()).toBe(START + 10 * MINUTE) + expect(storage.tables()).toEqual(['channel']) + }) + + it('enforces the sealed blob caps', async () => { + const { key } = await register() + const ch = await newChannel() + await open(key, ch) + const body = await claimBody(ch) + expect( + await claim({ ...body, hello: 'A'.repeat(2_049) }) + ).toMatchObject({ status: 413, body: { code: 'too_large' } }) + expect((await claim({ ...body, hello: 'A'.repeat(2_048) })).status).toBe( + 200 + ) + expect(await send(key, ch, 'A'.repeat(1_025))).toMatchObject({ + status: 413, + body: { code: 'too_large' }, + }) + expect((await send(key, ch, 'A'.repeat(1_024))).status).toBe(200) + }) + + it('refuses malformed requests', async () => { + const { key } = await register() + const ch = await newChannel() + const bad = { status: 400, body: { code: 'bad_request' } } + expect( + await oldOp('handoff/open', key, { channelId: 'short', verifier: ch.verifier }) + ).toMatchObject(bad) + expect( + await oldOp('handoff/open', key, { channelId: ch.channelId }) + ).toMatchObject(bad) + await open(key, ch) + const body = await claimBody(ch) + expect(await claim({ ...body, claimPub: 'nope' })).toMatchObject(bad) + expect(await claim({ ...body, hello: '' })).toMatchObject(bad) + expect(await claim({ ...body, hello: '***' })).toMatchObject(bad) + expect(await post('/handoff/claim', '{not json')).toMatchObject(bad) + expect(await post('/handoff/receive', { channelId: ch.channelId })).toMatchObject(bad) + expect(await post('/handoff/cancel', { channelId: ch.channelId })).toMatchObject(bad) + // Malformed tokens don't count toward burning the channel. + expect((await status(key, ch)).body.status).toBe('open') + }) + + it('limits handoff/open to 20 per account a day', async () => { + const { key } = await register() + for (let i = 0; i < 20; i++) + expect((await open(key, await newChannel())).status).toBe(200) + const refused = await open(key, await newChannel()) + expect(refused).toMatchObject({ + status: 429, + body: { code: 'rate_limited' }, + }) + expect(refused.body.retryAfter).toBeGreaterThan(0) + // Another account has its own budget. + const other = await register() + expect((await open(other.key, await newChannel())).status).toBe(200) + // A day later it refills. + vi.setSystemTime(START + 24 * 60 * MINUTE) + expect((await open(key, await newChannel())).status).toBe(200) + }) + + it('counts open against the register edge tier and the rest against read', async () => { + const { key } = await register() + const ip = '203.0.113.9' + for (let i = 0; i < 60; i++) { + const response = await post( + '/handoff/open', + await signed('handoff/open', key, { + channelId: randomId(), + verifier: (await newChannel()).verifier, + }), + ip + ) + // The daily quota may refuse first; either way the edge counted it. + expect([200, 429]).toContain(response.status) + } + const edge = await post( + '/handoff/open', + await signed('handoff/open', key, { + channelId: randomId(), + verifier: (await newChannel()).verifier, + }), + ip + ) + expect(edge).toMatchObject({ status: 429, body: { code: 'rate_limited' } }) + expect(edge.body.retryAfter).toBe(60) + // The read tier is separate and larger. + const ch = await newChannel() + expect( + (await post('/handoff/receive', { channelId: ch.channelId, claimToken: ch.claimToken }, ip)) + .body.code + ).toBe('not_found') + }) + + it('is off without the accounts database', async () => { + h = await createHarness({ accountsDb: false }) + const ch = await newChannel() + const off = { status: 503, body: { code: 'disabled' } } + expect(await open(await SigningKey.generate(), ch)).toMatchObject(off) + expect(await claim(await claimBody(ch))).toMatchObject(off) + expect(await tokenOp('receive', ch)).toMatchObject(off) + }) + + it('logs nothing that names the channel, the token, or the account', async () => { + const log = vi.spyOn(console, 'log') + const warn = vi.spyOn(console, 'warn') + const error = vi.spyOn(console, 'error') + const { key, accountId } = await register() + const ch = await newChannel() + await open(key, ch) + await claim(await claimBody(ch)) + await tokenOp('receive', ch, randomId()) + const logged = JSON.stringify([ + log.mock.calls, + warn.mock.calls, + error.mock.calls, + ]) + for (const secret of [ch.channelId, ch.claimToken, accountId, key.publicKey]) + expect(logged).not.toContain(secret) + }) +}) diff --git a/src/accounts/handoff.ts b/src/accounts/handoff.ts new file mode 100644 index 0000000..d4c2f93 --- /dev/null +++ b/src/accounts/handoff.ts @@ -0,0 +1,188 @@ +import type { AppContext, Environment } from '../types' +import { isPlainObject } from '../buddies/contracts' +import { parseEnvelope, readEnvelopeText } from '../buddies/envelope' +import { buddiesErrorResponse } from '../buddies/errorResponse' +import { + ACCOUNT_LIMITS, + handoffBlobTooLarge, + parseHandoffClaimRequest, + parseHandoffTokenRequest, +} from './contracts' +import { + accountForKey, + guarded, + quotaName, + refuseAtEdge, + refuseOverDailyQuota, + replyWith, + verifiedBody, + verifyEnvelope, +} from './signed' + +/** + * Set Up Another Device (witness-work `docs/accounts-protocol.md`, "Handoff"): + * the old phone opens a channel and the new phone claims it with the token + * from the QR code; the server relays a sealed hello one way and the sealed + * account key the other, through one `AccountHandoff` Durable Object per + * channel. Old-phone ops are signed by the account key and need a registered + * account; new-phone ops are plain JSON authorized by `claimToken`. + * + * `handoff/open` counts against the register edge tier and 20 opens per + * account a rolling day; every other op against the read tier. + * + * Privacy: nothing here logs or reports bodies, keys, tokens, account ids, or + * channel ids. + */ + +const channel = (env: Environment, channelId: string) => + env.ACCOUNT_HANDOFF.get(env.ACCOUNT_HANDOFF.idFromName(channelId)) + +/** Accounts off (no database) answers `disabled` before anything else. */ +const precheck = async ( + c: AppContext, + tier: 'inbox/register' | 'inbox/sync' +): Promise => { + if (!c.env.ACCOUNTS_DB) return buddiesErrorResponse('disabled') + return refuseAtEdge(c, tier) +} + +/** The new phone's JSON body, or null for `bad_request`. */ +const readJson = async (c: AppContext): Promise => { + const text = await readEnvelopeText(c.req.raw) + if (text == null) return null + try { + return JSON.parse(text) + } catch { + // Never surface the parser message: V8 quotes the offending input. + return null + } +} + +const handleOpen = guarded('handoff/open', async (c) => { + const refused = await precheck(c, 'inbox/register') + if (refused) return refused + const verified = await verifiedBody(c, 'handoff/open') + if ('refusal' in verified) return verified.refusal + const found = await accountForKey(c.env, verified.payload.accountPub) + if ('refusal' in found) return found.refusal + const overQuota = await refuseOverDailyQuota( + c.env, + await quotaName('handoff-open', found.account.id), + ACCOUNT_LIMITS.handoffOpensPerDay, + 'handoff/open', + 'handoffOpensPerDay' + ) + if (overQuota) return overQuota + const { channelId, verifier } = verified.fields + return replyWith( + c, + await channel(c.env, channelId).open({ + channelId, + accountId: found.account.id, + verifier, + }) + ) +}) + +const handleStatus = guarded('handoff/status', async (c) => { + const refused = await precheck(c, 'inbox/sync') + if (refused) return refused + const verified = await verifiedBody(c, 'handoff/status') + if ('refusal' in verified) return verified.refusal + const found = await accountForKey(c.env, verified.payload.accountPub) + if ('refusal' in found) return found.refusal + const { channelId } = verified.fields + return replyWith( + c, + await channel(c.env, channelId).status(channelId, found.account.id) + ) +}) + +const handleSend = guarded('handoff/send', async (c) => { + const refused = await precheck(c, 'inbox/sync') + if (refused) return refused + const verified = await verifiedBody(c, 'handoff/send') + if ('refusal' in verified) return verified.refusal + const found = await accountForKey(c.env, verified.payload.accountPub) + if ('refusal' in found) return found.refusal + const { channelId, key } = verified.fields + return replyWith( + c, + await channel(c.env, channelId).send(channelId, found.account.id, key) + ) +}) + +const handleClaim = guarded('handoff/claim', async (c) => { + const refused = await precheck(c, 'inbox/sync') + if (refused) return refused + const body = await readJson(c) + if (handoffBlobTooLarge(body)) return buddiesErrorResponse('too_large') + const request = parseHandoffClaimRequest(body) + if (!request) return buddiesErrorResponse('bad_request') + return replyWith(c, await channel(c.env, request.channelId).claim(request)) +}) + +const handleReceive = guarded('handoff/receive', async (c) => { + const refused = await precheck(c, 'inbox/sync') + if (refused) return refused + const request = parseHandoffTokenRequest(await readJson(c)) + if (!request) return buddiesErrorResponse('bad_request') + return replyWith(c, await channel(c.env, request.channelId).receive(request)) +}) + +const handleDone = guarded('handoff/done', async (c) => { + const refused = await precheck(c, 'inbox/sync') + if (refused) return refused + const request = parseHandoffTokenRequest(await readJson(c)) + if (!request) return buddiesErrorResponse('bad_request') + return replyWith(c, await channel(c.env, request.channelId).done(request)) +}) + +/** + * Either phone: a signed envelope (`p`, `s`) is the old phone, anything else + * must be the new phone's `channelId` and `claimToken`. + */ +const handleCancel = guarded('handoff/cancel', async (c) => { + const refused = await precheck(c, 'inbox/sync') + if (refused) return refused + const text = await readEnvelopeText(c.req.raw) + let body: unknown = null + try { + body = text == null ? null : JSON.parse(text) + } catch { + body = null + } + if (isPlainObject(body) && 'p' in body) { + const verified = await verifyEnvelope( + 'handoff/cancel', + text == null ? null : parseEnvelope(text) + ) + if ('refusal' in verified) return verified.refusal + const found = await accountForKey(c.env, verified.payload.accountPub) + if ('refusal' in found) return found.refusal + const { channelId } = verified.fields + return replyWith( + c, + await channel(c.env, channelId).cancelByAccount( + channelId, + found.account.id + ) + ) + } + const request = parseHandoffTokenRequest(body) + if (!request) return buddiesErrorResponse('bad_request') + return replyWith( + c, + await channel(c.env, request.channelId).cancelByToken(request) + ) +}) + +export const handoffHandlers = { + open: handleOpen, + claim: handleClaim, + status: handleStatus, + send: handleSend, + receive: handleReceive, + done: handleDone, + cancel: handleCancel, +} diff --git a/src/accounts/handoffDO.ts b/src/accounts/handoffDO.ts new file mode 100644 index 0000000..54d8c0e --- /dev/null +++ b/src/accounts/handoffDO.ts @@ -0,0 +1,319 @@ +import { DurableObject } from 'cloudflare:workers' +import type { Environment } from '../types' +import { bytesToBase64Url, sha256Bytes, timingSafeEqual } from '../crypto' +import { + decodeB64u, + fail, + ok, + type BuddiesFailure, + type BuddiesResult, +} from '../buddies/contracts' +import { ACCOUNT_LIMITS } from './contracts' + +/** + * A channel's state (witness-work `docs/accounts-protocol.md`, "Handoff"): + * `open` → `claimed` (a new phone answered) → `sent` (the old phone sent the + * sealed key) → `done` (the new phone picked it up). `cancelled` from any of + * them, by either phone or by five wrong claim tokens. + */ +export type HandoffStatus = 'open' | 'claimed' | 'sent' | 'done' | 'cancelled' + +type Empty = Record + +export interface OpenHandoffArgs { + channelId: string + accountId: string + verifier: string +} + +export interface ClaimHandoffArgs { + channelId: string + claimToken: string + claimPub: string + hello: string +} + +export interface HandoffTokenArgs { + channelId: string + claimToken: string +} + +export interface HandoffStatusView { + status: HandoffStatus + expiresAt: number + claimPub?: string + hello?: string +} + +export interface HandoffReceiveView { + status: HandoffStatus + key?: string +} + +type ChannelRow = { + channelId: string + accountId: string + verifier: string + status: HandoffStatus + expiresAt: number + badTokens: number + claimPub: string | null + hello: string | null + claimHash: string | null + key: string | null +} + +const SCHEMA = `CREATE TABLE IF NOT EXISTS channel ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + channel_id TEXT NOT NULL, + account_id TEXT NOT NULL, + verifier TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + bad_tokens INTEGER NOT NULL, + claim_pub TEXT, + hello TEXT, + claim_hash TEXT, + key TEXT +)` + +/** + * One SQLite Durable Object per handoff `channelId`: the opening account, + * the claim token's verifier, the new phone's temporary public key, and two + * sealed blobs (hello and key), for at most ten minutes from `open`. The + * alarm at `expiresAt` deletes everything. `done` and `cancel` drop the + * blobs and the claim key at once and keep only the status until then. + * + * Single-threaded and nothing awaits between a read and its write, so the + * first valid claim wins. A repeat of the winning claim with the same body + * gets `ok` again (a lost response); any other claim is `conflict`. + * + * Privacy: nothing here logs ids, keys, tokens, or blobs. + */ +export class AccountHandoff extends DurableObject { + #schemaReady = false + + async open(args: OpenHandoffArgs): Promise> { + const now = Date.now() + const existing = this.#load() + if (existing && now < existing.expiresAt) return fail('conflict') + if (existing || this.#ready()) await this.#destroy() + const expiresAt = now + ACCOUNT_LIMITS.handoffLifetimeMs + this.ctx.storage.transactionSync(() => { + this.ctx.storage.sql.exec(SCHEMA) + this.#schemaReady = true + this.ctx.storage.sql.exec( + `INSERT INTO channel (singleton, channel_id, account_id, verifier, status, + created_at, expires_at, bad_tokens) + VALUES (1, ?, ?, ?, 'open', ?, ?, 0)`, + args.channelId, + args.accountId, + args.verifier, + now, + expiresAt + ) + }) + await this.ctx.storage.setAlarm(expiresAt) + return ok({ expiresAt }) + } + + /** The old phone's view; only the opening account may read it. */ + status( + channelId: string, + accountId: string + ): BuddiesResult { + const channel = this.#live(channelId, Date.now()) + if (!channel) return fail('not_found') + if (channel.accountId !== accountId) return fail('forbidden') + return ok({ + status: channel.status, + expiresAt: channel.expiresAt, + ...(channel.claimPub != null ? { claimPub: channel.claimPub } : {}), + ...(channel.hello != null ? { hello: channel.hello } : {}), + }) + } + + async claim( + args: ClaimHandoffArgs + ): Promise> { + // Hashed before reading the channel: from the read through marking it + // claimed, nothing may await, or two claims could both see `open`. + const [tokenHash, claimHash] = await Promise.all([ + tokenDigest(args.claimToken), + digest(`${args.claimPub}\n${args.hello}`), + ]) + const channel = this.#live(args.channelId, Date.now()) + if (!channel) return fail('not_found') + const refused = this.#checkToken(channel, tokenHash) + if (refused) return refused + + if (channel.status !== 'open') { + const repeat = + channel.claimHash != null && + channel.status !== 'cancelled' && + timingSafeEqual(claimHash, channel.claimHash) + return repeat ? ok({ expiresAt: channel.expiresAt }) : fail('conflict') + } + this.ctx.storage.sql.exec( + `UPDATE channel SET status = 'claimed', claim_pub = ?, hello = ?, claim_hash = ? + WHERE singleton = 1`, + args.claimPub, + args.hello, + claimHash + ) + return ok({ expiresAt: channel.expiresAt }) + } + + /** The old phone sends the sealed key, only while `claimed`. */ + send( + channelId: string, + accountId: string, + key: string + ): BuddiesResult { + const channel = this.#live(channelId, Date.now()) + if (!channel) return fail('not_found') + if (channel.accountId !== accountId) return fail('forbidden') + if (channel.status !== 'claimed') return fail('conflict') + this.ctx.storage.sql.exec( + "UPDATE channel SET status = 'sent', key = ? WHERE singleton = 1", + key + ) + return ok({}) + } + + /** The new phone polls for the key. */ + async receive( + args: HandoffTokenArgs + ): Promise> { + const tokenHash = await tokenDigest(args.claimToken) + const channel = this.#live(args.channelId, Date.now()) + if (!channel) return fail('not_found') + const refused = this.#checkToken(channel, tokenHash) + if (refused) return refused + return ok({ + status: channel.status, + ...(channel.status === 'sent' && channel.key != null + ? { key: channel.key } + : {}), + }) + } + + /** The new phone has the key: drop the blobs. Repeating it is `ok`. */ + async done(args: HandoffTokenArgs): Promise> { + const tokenHash = await tokenDigest(args.claimToken) + const channel = this.#live(args.channelId, Date.now()) + if (!channel) return fail('not_found') + const refused = this.#checkToken(channel, tokenHash) + if (refused) return refused + if (channel.status === 'done') return ok({}) + if (channel.status !== 'sent') return fail('conflict') + this.#finish('done') + return ok({}) + } + + /** Either phone ends the channel; idempotent, and a no-op once `done`. */ + cancelByAccount( + channelId: string, + accountId: string + ): BuddiesResult { + const channel = this.#live(channelId, Date.now()) + if (!channel) return fail('not_found') + if (channel.accountId !== accountId) return fail('forbidden') + if (channel.status !== 'done') this.#finish('cancelled') + return ok({}) + } + + async cancelByToken(args: HandoffTokenArgs): Promise> { + const tokenHash = await tokenDigest(args.claimToken) + const channel = this.#live(args.channelId, Date.now()) + if (!channel) return fail('not_found') + const refused = this.#checkToken(channel, tokenHash) + if (refused) return refused + if (channel.status !== 'done') this.#finish('cancelled') + return ok({}) + } + + /** Deletes the channel at `expiresAt`. */ + async alarm(): Promise { + const channel = this.#load() + if (!channel || Date.now() >= channel.expiresAt) { + await this.#destroy() + return + } + await this.ctx.storage.setAlarm(channel.expiresAt) + } + + /** + * Null when the token's hash matches the verifier. Otherwise counts it, and + * the fifth wrong one burns the channel (`cancelled`, blobs dropped). + */ + #checkToken(channel: ChannelRow, tokenHash: string): BuddiesFailure | null { + if (timingSafeEqual(tokenHash, channel.verifier)) return null + if (channel.status !== 'cancelled') { + if (channel.badTokens + 1 >= ACCOUNT_LIMITS.handoffBadTokens) { + this.#finish('cancelled') + } + this.ctx.storage.sql.exec( + 'UPDATE channel SET bad_tokens = bad_tokens + 1 WHERE singleton = 1' + ) + } + return fail('bad_signature') + } + + /** Ends the channel: keeps the status until the alarm, nothing else. */ + #finish(status: 'done' | 'cancelled'): void { + this.ctx.storage.sql.exec( + `UPDATE channel SET status = ?, claim_pub = NULL, hello = NULL, key = NULL + WHERE singleton = 1`, + status + ) + } + + #ready(): boolean { + if (this.#schemaReady) return true + this.#schemaReady = + this.ctx.storage.sql + .exec( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'channel'" + ) + .toArray().length > 0 + return this.#schemaReady + } + + #load(): ChannelRow | null { + if (!this.#ready()) return null + return ( + this.ctx.storage.sql + .exec( + `SELECT channel_id AS channelId, account_id AS accountId, verifier, status, + expires_at AS expiresAt, bad_tokens AS badTokens, + claim_pub AS claimPub, hello, claim_hash AS claimHash, key + FROM channel WHERE singleton = 1` + ) + .toArray()[0] ?? null + ) + } + + /** Expired and unknown channels are both `not_found`. */ + #live(channelId: string, now: number): ChannelRow | null { + const channel = this.#load() + return channel && channel.channelId === channelId && now < channel.expiresAt + ? channel + : null + } + + async #destroy(): Promise { + await this.ctx.storage.deleteAll() + // Before compatibility date 2026-02-24, deleteAll() keeps the alarm. + await this.ctx.storage.deleteAlarm() + this.#schemaReady = false + } +} + +const digest = async (text: string): Promise => + bytesToBase64Url(await sha256Bytes(new TextEncoder().encode(text))) + +/** `b64u(SHA-256(claimToken bytes))`, what `verifier` holds. */ +const tokenDigest = async (claimToken: string): Promise => + bytesToBase64Url(await sha256Bytes(decodeB64u(claimToken) ?? new Uint8Array())) diff --git a/src/accounts/handoffPage.test.ts b/src/accounts/handoffPage.test.ts new file mode 100644 index 0000000..d8a0285 --- /dev/null +++ b/src/accounts/handoffPage.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { Hono } from 'hono' +import type { Environment } from '../types' +import { contactLinkTranslations } from '../contactLinkLocales' +import { handleHandoffPage } from './handoffPage' +import { handoffPageTranslations } from './handoffPageLocales' + +const app = new Hono<{ Bindings: Environment }>() +app.get('/h', handleHandoffPage) + +const env = { APPLE_TEAM_ID: 'TEAMID1234' } as Environment + +const page = (path = '/h', headers: Record = {}) => + app.request(path, { headers }, env) + +describe('GET /h', () => { + it('tells someone to scan the code from inside the app, with store links', async () => { + const response = await page() + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/html') + expect(response.headers.get('x-robots-tag')).toBe('noindex, nofollow') + expect(response.headers.get('referrer-policy')).toBe('no-referrer') + expect(response.headers.get('content-language')).toBe('en-us') + const html = await response.text() + expect(html).toContain('Scan this code in WitnessWork') + expect(html).toContain('scan the code from inside the app') + expect(html).toContain( + 'href="https://apps.apple.com/us/app/jw-time/id6469723047"' + ) + expect(html).toContain( + 'href="https://play.google.com/store/apps/details?id=com.leviwilkerson.jwtime"' + ) + expect(html).toContain('') + }) + + it('runs no script and may load nothing but its own images', async () => { + const response = await page() + const policy = response.headers.get('content-security-policy') ?? '' + expect(policy).toContain("default-src 'none'") + expect(policy).not.toContain('script-src') + expect(policy).not.toContain('connect-src') + const html = await response.text() + expect(html).not.toContain(' { + const plain = await (await page()).text() + const noisy = await ( + await page('/h?channelId=QUJDREVGR0hJSktMTU5PUFFS&x=%3Cscript%3E') + ).text() + expect(noisy).toBe(plain) + expect(noisy).not.toContain('QUJDREVGR0hJSktMTU5PUFFS') + }) + + it('speaks the visitor’s language, like /c', async () => { + const german = await page('/h', { 'Accept-Language': 'de-DE,de;q=0.9' }) + expect(german.headers.get('content-language')).toBe('de-de') + expect(await german.text()).toContain('Scanne diesen Code in WitnessWork') + + const arabic = await page('/h?lang=ar') + const html = await arabic.text() + expect(html).toContain('') + + // Malformed input never reaches the page. + const odd = await page('/h?lang=%22%3E%3Cscript%3E') + expect(odd.headers.get('content-language')).toBe('en-us') + }) + + it('has every string in every locale /c has', () => { + expect(Object.keys(handoffPageTranslations).sort()).toEqual( + Object.keys(contactLinkTranslations).sort() + ) + for (const messages of Object.values(handoffPageTranslations)) { + for (const value of Object.values(messages)) { + expect(value.trim()).not.toBe('') + expect(value.toLowerCase()).not.toContain('magic') + } + } + }) +}) diff --git a/src/accounts/handoffPage.ts b/src/accounts/handoffPage.ts new file mode 100644 index 0000000..39d9533 --- /dev/null +++ b/src/accounts/handoffPage.ts @@ -0,0 +1,141 @@ +import type { AppContext } from '../types' +import { + contactLinkDirection, + resolveContactLinkLocale, + type ContactLinkLocale, +} from '../contactLinkLocales' +import { handoffPageTranslations } from './handoffPageLocales' + +/** + * `GET /h`: the page a phone's camera opens for a Set Up Another Device QR + * code (`https://ww-proxy.leviwilkerson.com/h#1`) + * when WitnessWork doesn't catch the link. It says to install or open the app + * on the new phone and scan the code from inside it. + * + * The handoff secret lives in the fragment, which browsers never send. The + * page reads nothing from the request but the language (`?lang=` or + * `Accept-Language`, as on `/c`), runs no script, and its CSP lets it load + * nothing but its own icon, so it can't send the fragment anywhere. + */ + +const SITE_ORIGIN = 'https://ww-proxy.leviwilkerson.com' +const APP_STORE_URL = 'https://apps.apple.com/us/app/jw-time/id6469723047' +const GOOGLE_PLAY_URL = + 'https://play.google.com/store/apps/details?id=com.leviwilkerson.jwtime' +const OG_IMAGE_URL = `${SITE_ORIGIN}/assets/og-image.png` +const ICON_PATH = '/assets/apple-touch-icon.png' + +const CONTENT_SECURITY_POLICY = [ + "default-src 'none'", + "img-src 'self'", + "style-src 'unsafe-inline'", + "base-uri 'none'", + "form-action 'none'", + "frame-ancestors 'none'", +].join('; ') + +const escapeHtml = (value: string): string => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + +const renderPage = (locale: ContactLinkLocale): string => { + const messages = handoffPageTranslations[locale] + const title = escapeHtml(messages.title) + const description = escapeHtml(messages.description) + const imageAlt = escapeHtml(messages.imageAlt) + const dir = contactLinkDirection(locale) === 'rtl' ? ' dir="rtl"' : '' + return ` + + + + + + + ${title} + + + + + + + + + + + + + + + + + + +
+ ${imageAlt} +

${title}

+

${escapeHtml(messages.instructions)}

+ +
+ +` +} + +export const handleHandoffPage = (context: AppContext): Response => { + const locale = resolveContactLinkLocale( + context.req.query('lang'), + context.req.header('Accept-Language') + ) + return context.html(renderPage(locale), 200, { + 'Cache-Control': 'public, max-age=3600', + 'Content-Language': locale, + Vary: 'Accept-Language', + 'Content-Security-Policy': CONTENT_SECURITY_POLICY, + 'Referrer-Policy': 'no-referrer', + 'X-Robots-Tag': 'noindex, nofollow', + }) as Response +} diff --git a/src/accounts/handoffPageLocales.ts b/src/accounts/handoffPageLocales.ts new file mode 100644 index 0000000..a14ecfa --- /dev/null +++ b/src/accounts/handoffPageLocales.ts @@ -0,0 +1,171 @@ +import type { ContactLinkLocale } from '../contactLinkLocales' + +type HandoffPageMessages = { + title: string + description: string + instructions: string + appStore: string + googlePlay: string + imageAlt: string +} + +/** + * The `/h` page's text, in every locale the shared contact page (`/c`) + * resolves to, so both pages pick a language the same way + * (`resolveContactLinkLocale`). Keep the keys in step with + * `contactLinkTranslations`. + */ +export const handoffPageTranslations = { + 'en-us': { + title: 'Scan this code in WitnessWork', + description: 'Set up WitnessWork on another phone.', + instructions: 'This code sets up WitnessWork on a new phone. Install or open WitnessWork on the new phone, then scan the code from inside the app.', + appStore: 'Get it on the App Store', + googlePlay: 'Get it on Google Play', + imageAlt: 'WitnessWork app icon', + }, + 'de-de': { + title: 'Scanne diesen Code in WitnessWork', + description: 'WitnessWork auf einem anderen Telefon einrichten.', + instructions: 'Mit diesem Code richtest du WitnessWork auf einem neuen Telefon ein. Installiere oder öffne WitnessWork auf dem neuen Telefon und scanne den Code dann in der App.', + appStore: 'Im App Store laden', + googlePlay: 'Bei Google Play herunterladen', + imageAlt: 'WitnessWork-App-Symbol', + }, + 'es-es': { + title: 'Escanea este código en WitnessWork', + description: 'Configura WitnessWork en otro teléfono.', + instructions: 'Este código configura WitnessWork en un teléfono nuevo. Instala o abre WitnessWork en el teléfono nuevo y escanea el código desde la aplicación.', + appStore: 'Descargar en el App Store', + googlePlay: 'Disponible en Google Play', + imageAlt: 'Icono de la aplicación WitnessWork', + }, + 'fr-fr': { + title: 'Scannez ce code dans WitnessWork', + description: 'Configurer WitnessWork sur un autre téléphone.', + instructions: 'Ce code configure WitnessWork sur un nouveau téléphone. Installez ou ouvrez WitnessWork sur le nouveau téléphone, puis scannez le code depuis l’application.', + appStore: 'Télécharger dans l’App Store', + googlePlay: 'Disponible sur Google Play', + imageAlt: 'Icône de l’application WitnessWork', + }, + 'it-it': { + title: 'Scansiona questo codice in WitnessWork', + description: 'Configura WitnessWork su un altro telefono.', + instructions: 'Questo codice configura WitnessWork su un nuovo telefono. Installa o apri WitnessWork sul nuovo telefono, poi scansiona il codice dall’app.', + appStore: 'Scarica su App Store', + googlePlay: 'Disponibile su Google Play', + imageAlt: 'Icona dell’app WitnessWork', + }, + 'ja-jp': { + title: 'このコードをWitnessWorkでスキャン', + description: '別の電話でWitnessWorkを設定します。', + instructions: 'このコードは新しい電話でWitnessWorkを設定するためのものです。新しい電話でWitnessWorkをインストールするか開いて、アプリ内からコードをスキャンしてください。', + appStore: 'App Storeでダウンロード', + googlePlay: 'Google Playで手に入れよう', + imageAlt: 'WitnessWorkのアプリアイコン', + }, + 'ko-kr': { + title: 'WitnessWork에서 이 코드를 스캔하세요', + description: '다른 휴대폰에서 WitnessWork를 설정합니다.', + instructions: '이 코드는 새 휴대폰에서 WitnessWork를 설정하는 데 사용됩니다. 새 휴대폰에서 WitnessWork를 설치하거나 연 다음, 앱 안에서 코드를 스캔하세요.', + appStore: 'App Store에서 다운로드', + googlePlay: 'Google Play에서 다운로드', + imageAlt: 'WitnessWork 앱 아이콘', + }, + 'nl-nl': { + title: 'Scan deze code in WitnessWork', + description: 'WitnessWork instellen op een andere telefoon.', + instructions: 'Met deze code stel je WitnessWork in op een nieuwe telefoon. Installeer of open WitnessWork op de nieuwe telefoon en scan de code daarna vanuit de app.', + appStore: 'Download in de App Store', + googlePlay: 'Ontdek het op Google Play', + imageAlt: 'App-pictogram van WitnessWork', + }, + 'pt-br': { + title: 'Escaneie este código no WitnessWork', + description: 'Configure o WitnessWork em outro celular.', + instructions: 'Este código configura o WitnessWork em um celular novo. Instale ou abra o WitnessWork no celular novo e escaneie o código dentro do app.', + appStore: 'Baixar na App Store', + googlePlay: 'Disponível no Google Play', + imageAlt: 'Ícone do app WitnessWork', + }, + 'pt-pt': { + title: 'Leia este código no WitnessWork', + description: 'Configure o WitnessWork noutro telemóvel.', + instructions: 'Este código configura o WitnessWork num telemóvel novo. Instale ou abra o WitnessWork no telemóvel novo e leia o código a partir da aplicação.', + appStore: 'Descarregar na App Store', + googlePlay: 'Disponível no Google Play', + imageAlt: 'Ícone da aplicação WitnessWork', + }, + 'ru-ru': { + title: 'Отсканируйте этот код в WitnessWork', + description: 'Настройка WitnessWork на другом телефоне.', + instructions: 'С помощью этого кода WitnessWork настраивается на новом телефоне. Установите или откройте WitnessWork на новом телефоне, а затем отсканируйте код в приложении.', + appStore: 'Загрузить в App Store', + googlePlay: 'Доступно в Google Play', + imageAlt: 'Значок приложения WitnessWork', + }, + 'vi-vn': { + title: 'Quét mã này trong WitnessWork', + description: 'Thiết lập WitnessWork trên một điện thoại khác.', + instructions: 'Mã này dùng để thiết lập WitnessWork trên điện thoại mới. Hãy cài đặt hoặc mở WitnessWork trên điện thoại mới, rồi quét mã từ trong ứng dụng.', + appStore: 'Tải về trên App Store', + googlePlay: 'Tải về trên Google Play', + imageAlt: 'Biểu tượng ứng dụng WitnessWork', + }, + 'zh-hant-tw': { + title: '請在 WitnessWork 中掃描這個條碼', + description: '在另一支手機上設定 WitnessWork。', + instructions: '這個條碼用來在新手機上設定 WitnessWork。請在新手機上安裝或開啟 WitnessWork,然後在 App 內掃描條碼。', + appStore: '在 App Store 下載', + googlePlay: '在 Google Play 下載', + imageAlt: 'WitnessWork App 圖示', + }, + 'zh-hans-cn': { + title: '请在 WitnessWork 中扫描此二维码', + description: '在另一部手机上设置 WitnessWork。', + instructions: '此二维码用于在新手机上设置 WitnessWork。请在新手机上安装或打开 WitnessWork,然后在应用内扫描二维码。', + appStore: '在 App Store 下载', + googlePlay: '在 Google Play 下载', + imageAlt: 'WitnessWork 应用图标', + }, + 'sw-ke': { + title: 'Changanua msimbo huu katika WitnessWork', + description: 'Weka WitnessWork kwenye simu nyingine.', + instructions: 'Msimbo huu unaweka WitnessWork kwenye simu mpya. Sakinisha au fungua WitnessWork kwenye simu mpya, kisha uchanganue msimbo ukiwa ndani ya programu.', + appStore: 'Pakua kwenye App Store', + googlePlay: 'Pakua kwenye Google Play', + imageAlt: 'Aikoni ya programu ya WitnessWork', + }, + 'uk-ua': { + title: 'Відскануйте цей код у WitnessWork', + description: 'Налаштування WitnessWork на іншому телефоні.', + instructions: 'За допомогою цього коду WitnessWork налаштовується на новому телефоні. Установіть або відкрийте WitnessWork на новому телефоні, а потім відскануйте код у додатку.', + appStore: 'Завантажити в App Store', + googlePlay: 'Завантажити в Google Play', + imageAlt: 'Значок додатка WitnessWork', + }, + 'bem-zm': { + title: 'Skanini iyi kodi muli WitnessWork', + description: 'Bikeni WitnessWork pa foni imbi.', + instructions: 'Iyi kodi ibika WitnessWork pa foni ipya. Bikeni nangu isuleni WitnessWork pa foni ipya, e lyo mu-skanine kodi ukufuma mu kati ka apu.', + appStore: 'Poseni pa App Store', + googlePlay: 'Poseni pa Google Play', + imageAlt: 'Icishibilo ca apu ya WitnessWork', + }, + 'rw-rw': { + title: 'Sikana iyi kode muri WitnessWork', + description: 'Shyira WitnessWork kuri telefoni indi.', + instructions: 'Iyi kode ishyira WitnessWork kuri telefoni nshya. Shyira cyangwa ufungure WitnessWork kuri telefoni nshya, hanyuma usikane kode uri muri porogaramu.', + appStore: 'Kuramo kuri App Store', + googlePlay: 'Kuramo kuri Google Play', + imageAlt: 'Agashushondanga ka porogaramu ya WitnessWork', + }, + 'ar': { + title: 'امسح هذا الرمز في WitnessWork', + description: 'إعداد WitnessWork على هاتف آخر.', + instructions: 'يُستخدم هذا الرمز لإعداد WitnessWork على هاتف جديد. ثبّت WitnessWork أو افتحه على الهاتف الجديد، ثم امسح الرمز من داخل التطبيق.', + appStore: 'تنزيل من App Store', + googlePlay: 'احصل عليه من Google Play', + imageAlt: 'أيقونة تطبيق WitnessWork', + }, +} satisfies Record diff --git a/src/accounts/openapi.ts b/src/accounts/openapi.ts index ae0e4c9..abf4992 100644 --- a/src/accounts/openapi.ts +++ b/src/accounts/openapi.ts @@ -5,9 +5,11 @@ import { b64u, buddiesId, errorResponse, + fieldTable, jsonBody, jsonResponse, signedBody, + signedEnvelopeSchema, } from '../openapi/schemas' import { ACCOUNT_STATUS_REASONS, ACCOUNT_STATUSES } from './schema' @@ -156,3 +158,417 @@ export const accountStatusDocs = describeRoute({ ...adminErrors, }, }) + +// --- Handoff (Set Up Another Device) --------------------------------------- + +const channelId = buddiesId('The channel id from the QR code') +const claimToken = buddiesId( + 'The claim token, `HKDF-SHA256(secret, "", "ww-account/v1/handoff/claim\\n" + channelId, 16)`' +) +const expiresAt = epochMs('When the channel and everything in it is deleted') +const handoffStatus = z.enum(['open', 'claimed', 'sent', 'done', 'cancelled']) + +/** A signed envelope whose payload adds `fields` to the key payload. */ +const keySignedWith = (op: string, fields: z.ZodRawShape) => + signedBody( + z.object({ + accountPub: b64u( + 'Ed25519 public key of the account signing key, 32 bytes' + ), + ts: z.number().int().meta({ + description: + 'Client time in epoch milliseconds; must be within 5 minutes of the server’s.', + }), + nonce: buddiesId('A fresh random nonce'), + ...fields, + }), + `A signed envelope; \`s\` is the account key’s signature over \`ww-account/v1\\n${op}\\n\` followed by \`p\`’s bytes.` + ) + +const okOnly = jsonResponse('Done.', z.object({ ok: z.literal(true) })) +const internal = { 500: errorResponse('Unexpected failure.', ['internal']) } +const accountsOff = { + 503: errorResponse('Accounts are off on this worker.', ['disabled']), +} +const readTier = 'Limits: the Buddies read tier (600 a minute per caller IP).' +const notFound = errorResponse( + 'No such channel, or it expired (10 minutes after `handoff/open`).', + ['not_found'] +) +const oldPhoneErrors = (extra: string) => ({ + ...signatureErrors, + 403: errorResponse('Another account opened this channel.', ['forbidden']), + 404: errorResponse( + `${extra} Also \`unknown_account\` when no account has the signing key.`, + ['not_found', 'unknown_account'] + ), +}) +const tokenErrors = { + 400: errorResponse('Malformed JSON or field.', ['bad_request']), + 401: errorResponse( + 'Wrong `claimToken`. The fifth wrong token on a channel cancels it.', + ['bad_signature'] + ), + 404: notFound, +} +const tokenBody = (extra: z.ZodRawShape = {}) => + jsonBody(z.object({ channelId, claimToken, ...extra })) + +export const handoffOpenDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Open a handoff channel (old phone)', + description: `The phone with the account opens a channel for Set Up Another Device and shows its QR code. The server stores the account, the claim token’s verifier, and nothing else until a new phone claims it. The channel and everything in it is deleted 10 minutes after opening. + +Limits: the Buddies register tier (60 a minute per caller IP) and 20 opens per account a rolling day.`, + requestBody: keySignedWith('handoff/open', { + channelId, + verifier: b64u('`SHA-256(claimToken bytes)`, 32 bytes'), + }), + responses: { + 200: jsonResponse( + 'Opened.', + z.object({ ok: z.literal(true), expiresAt }) + ), + ...signatureErrors, + 404: errorResponse('No account has this key; register first.', [ + 'unknown_account', + ]), + 409: errorResponse('The channel already exists.', ['conflict']), + 429: errorResponse('Over the per-caller rate or the daily opens.', [ + 'rate_limited', + ]), + ...internal, + ...accountsOff, + }, +}) + +export const handoffClaimDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Claim a handoff channel (new phone)', + description: `The new phone answers the QR code with its temporary X25519 public key and its sealed hello. The first valid claim wins; repeating it with the same body gets \`ok\` again (a lost response), and any other claim is \`conflict\`. Plain JSON, authorized by \`claimToken\`. + +${readTier}`, + requestBody: tokenBody({ + claimPub: b64u('The new phone’s temporary X25519 public key, 32 bytes'), + hello: b64u( + 'The hello, sealed with `helloKey` (`0x01 ‖ nonce[12] ‖ ChaCha20-Poly1305`), at most 2,048 characters' + ), + }), + responses: { + 200: jsonResponse( + 'Claimed (or the same claim again).', + z.object({ ok: z.literal(true), expiresAt }) + ), + ...tokenErrors, + 409: errorResponse('Another phone claimed it, or it was cancelled.', [ + 'conflict', + ]), + 413: errorResponse('`hello` is over 2,048 characters.', ['too_large']), + 429: errorResponse('Over the per-caller rate.', ['rate_limited']), + ...internal, + ...accountsOff, + }, +}) + +export const handoffStatusDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Read a handoff channel (old phone)', + description: `What the old phone polls (every 1.5 seconds) while it shows the code: the status and, once claimed, the new phone’s public key and sealed hello. Only the opening account can read it. + +${readTier}`, + requestBody: keySignedWith('handoff/status', { channelId }), + responses: { + 200: jsonResponse( + 'The channel.', + z.object({ + ok: z.literal(true), + status: handoffStatus, + expiresAt, + claimPub: b64u( + 'The new phone’s temporary public key, from `claimed` until `done` or `cancelled`' + ).optional(), + hello: b64u( + 'The sealed hello, from `claimed` until `done` or `cancelled`' + ).optional(), + }) + ), + ...oldPhoneErrors('No such channel, or it expired.'), + 429: errorResponse('Over the per-caller rate.', ['rate_limited']), + ...internal, + ...accountsOff, + }, +}) + +export const handoffSendDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Send the account key (old phone)', + description: `After the User approves the new phone, the old phone sends the account key sealed with \`keyKey\`. Only while \`claimed\`; the status becomes \`sent\`. + +${readTier}`, + requestBody: keySignedWith('handoff/send', { + channelId, + key: b64u( + 'The account key, sealed with `keyKey`, at most 1,024 characters' + ), + }), + responses: { + 200: okOnly, + ...oldPhoneErrors('No such channel, or it expired.'), + 409: errorResponse('The channel isn’t `claimed`.', ['conflict']), + 413: errorResponse('`key` is over 1,024 characters.', ['too_large']), + 429: errorResponse('Over the per-caller rate.', ['rate_limited']), + ...internal, + ...accountsOff, + }, +}) + +export const handoffReceiveDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Poll for the account key (new phone)', + description: `The new phone polls (every 1.5 seconds) until the status is \`sent\`, which carries the sealed key, or \`cancelled\`. + +${readTier}`, + requestBody: tokenBody(), + responses: { + 200: jsonResponse( + 'The status, and the key once sent.', + z.object({ + ok: z.literal(true), + status: handoffStatus, + key: b64u('The sealed account key, while `sent`').optional(), + }) + ), + ...tokenErrors, + 429: errorResponse('Over the per-caller rate.', ['rate_limited']), + ...internal, + ...accountsOff, + }, +}) + +export const handoffDoneDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Finish a handoff (new phone)', + description: `The new phone has the key: the server deletes the blobs at once and keeps only the status (\`done\`) until the channel expires. Repeating it is \`ok\`. + +${readTier}`, + requestBody: tokenBody(), + responses: { + 200: okOnly, + ...tokenErrors, + 409: errorResponse('The key wasn’t sent, or the channel was cancelled.', [ + 'conflict', + ]), + 429: errorResponse('Over the per-caller rate.', ['rate_limited']), + ...internal, + ...accountsOff, + }, +}) + +export const handoffCancelDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Cancel a handoff (either phone)', + description: `Either phone ends the channel: the server deletes every blob at once and keeps only the status (\`cancelled\`) until the channel expires. Idempotent; a \`done\` channel stays \`done\`. The old phone sends a signed envelope (op \`handoff/cancel\`, payload \`channelId\` plus the key payload) and must be the opening account; the new phone sends plain JSON with \`claimToken\`. + +${readTier}`, + requestBody: jsonBody( + z.union([signedEnvelopeSchema, z.object({ channelId, claimToken })]), + 'The old phone’s signed envelope, or the new phone’s `channelId` and `claimToken`.' + ), + responses: { + 200: okOnly, + 400: errorResponse('Malformed envelope, JSON, or field.', ['bad_request']), + 401: errorResponse( + 'Bad signature, stale `ts`, or a wrong `claimToken` (the fifth cancels the channel).', + ['bad_signature', 'stale'], + staleErrorSchema + ), + 403: errorResponse('Another account opened this channel.', ['forbidden']), + 404: errorResponse( + 'No such channel, it expired, or no account has the signing key.', + ['not_found', 'unknown_account'] + ), + 429: errorResponse('Over the per-caller rate.', ['rate_limited']), + ...internal, + ...accountsOff, + }, +}) + +// --- Backup ------------------------------------------------------------------ + +const slotId = buddiesId('The device’s backup slot') +const backupOff = { + 503: errorResponse( + 'Backups are off on this worker (no backup bucket or accounts database).', + ['disabled'] + ), +} +const backupNotes = + 'A suspended account can still back up. A slot is deleted 400 days after its last upload.' + +const binary = (description: string) => ({ + description, + content: { + 'application/octet-stream': { + schema: { type: 'string' as const, format: 'binary' }, + }, + }, +}) + +const keyPayloadFields = (op: string, fields: z.ZodRawShape) => + fieldTable( + z.object({ + accountPub: b64u( + 'Ed25519 public key of the account signing key, 32 bytes' + ), + ts: z.number().int().meta({ + description: + 'Client time in epoch milliseconds; must be within 5 minutes of the server’s.', + }), + nonce: buddiesId('A fresh random nonce'), + ...fields, + }) + ) + `\n\nSigned over \`ww-account/v1\\n${op}\\n\` followed by \`p\`’s bytes.` + +export const backupPutDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Upload a backup', + description: `Replaces this device’s slot with an end-to-end encrypted backup (\`0x01 ‖ nonce[24] ‖ XChaCha20-Poly1305\`). The body is the raw blob, at most 20 MiB; the signed envelope (op \`backup/put\`) rides in the \`x-ww-p\` and \`x-ww-s\` headers. + +**Decoded \`x-ww-p\`:** + +${keyPayloadFields('backup/put', { + slotId, + size: z.number().int().min(1).meta({ + description: + 'The body’s exact length, at most 20 MiB. `Content-Length`, when sent, must match.', + }), + sha256: b64u('`SHA-256(body)`, 32 bytes'), +})} + +Limits: 20 MiB a blob, 8 slots an account, one upload a slot a minute, and the Buddies photo-upload tier (60 a minute per caller IP). ${backupNotes}`, + parameters: [ + { + name: 'x-ww-p', + in: 'header' as const, + required: true, + schema: { type: 'string' as const }, + description: 'The payload JSON, UTF-8 encoded, base64url.', + }, + { + name: 'x-ww-s', + in: 'header' as const, + required: true, + schema: { type: 'string' as const }, + description: 'The account key’s Ed25519 signature, base64url.', + }, + ], + requestBody: { required: true, ...binary('The encrypted backup bytes.') }, + responses: { + 200: jsonResponse( + 'Stored.', + z.object({ + ok: z.literal(true), + uploadedAt: epochMs('When the slot was written'), + }) + ), + 400: errorResponse( + 'Missing or malformed headers or payload, or a body whose length isn’t `size` or whose SHA-256 isn’t `sha256`.', + ['bad_request'] + ), + 401: signatureErrors[401], + 404: errorResponse('No account has this key; register first.', [ + 'unknown_account', + ]), + 413: errorResponse( + '`size`, `Content-Length`, or the streamed body is over 20 MiB.', + ['too_large'] + ), + 429: errorResponse( + 'The account has 8 other slots (`limit`; delete one first), the slot was written less than a minute ago, or over the per-caller rate (`rate_limited`, with `Retry-After`).', + ['limit', 'rate_limited'] + ), + ...internal, + ...backupOff, + }, +}) + +export const backupListDocs = describeRoute({ + tags: ['Accounts'], + summary: 'List backups', + description: `Every slot of the signing key’s account, for a restore. + +${readTier} ${backupNotes}`, + requestBody: keySignedWith('backup/list', {}), + responses: { + 200: jsonResponse( + 'The account’s slots.', + z.object({ + ok: z.literal(true), + slots: z.array( + z.object({ + slotId, + size: z.number().int().meta({ description: 'Bytes.' }), + uploadedAt: epochMs('When the slot was last written'), + }) + ), + }) + ), + ...signatureErrors, + 404: errorResponse('No account has this key; register first.', [ + 'unknown_account', + ]), + 429: errorResponse('Over the per-caller rate.', ['rate_limited']), + ...internal, + ...backupOff, + }, +}) + +export const backupGetDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Download a backup', + description: `Streams one slot’s bytes exactly as uploaded. + +${readTier}`, + requestBody: keySignedWith('backup/get', { slotId }), + responses: { + 200: { + ...binary('The encrypted backup bytes.'), + headers: { + 'Cache-Control': { + description: '`no-store`.', + schema: { type: 'string' as const }, + }, + 'Content-Length': { schema: { type: 'integer' as const } }, + }, + }, + ...signatureErrors, + 404: errorResponse( + 'The account has no such slot (`not_found`), or no account has the key.', + ['not_found', 'unknown_account'] + ), + 429: errorResponse('Over the per-caller rate.', ['rate_limited']), + ...internal, + ...backupOff, + }, +}) + +export const backupDeleteDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Delete backups', + description: `Deletes one slot, or every slot of the account when \`slotId\` is left out (before Delete Buddies replaces the account key). Gone from storage at once; idempotent. + +${readTier}`, + requestBody: keySignedWith('backup/delete', { + slotId: slotId.optional(), + }), + responses: { + 200: okOnly, + ...signatureErrors, + 404: errorResponse('No account has this key; register first.', [ + 'unknown_account', + ]), + 429: errorResponse('Over the per-caller rate.', ['rate_limited']), + ...internal, + ...backupOff, + }, +}) diff --git a/src/accounts/route.ts b/src/accounts/route.ts index c732bac..139d2e8 100644 --- a/src/accounts/route.ts +++ b/src/accounts/route.ts @@ -1,95 +1,40 @@ import { Hono } from 'hono' import type { AppContext, Environment } from '../types' import { Sentry } from '../sentry' -import { bytesToBase64Url, sha256Bytes } from '../crypto' -import { BUDDIES_LIMITS, decodeSignature } from '../buddies/contracts' -import { - parseEnvelope, - readEnvelopeText, - verifyEd25519, -} from '../buddies/envelope' +import { bytesToBase64Url } from '../crypto' import { buddiesErrorResponse, buddiesInternalError, } from '../buddies/errorResponse' +import { buddiesCallerKey } from '../buddies/limits' +import { accountsDb, findAccountInfo, registerAccount } from './store' import { - allowBuddiesCaller, - buddiesCallerKey, - EDGE_RETRY_AFTER_SECONDS, - logLimitHit, -} from '../buddies/limits' + quotaName, + refuseAtEdge, + refuseOverDailyQuota, + verifiedBody, +} from './signed' +import { handoffHandlers } from './handoff' +import { backupHandlers } from './backup' import { - type AccountKeyPayload, - type AccountsOp, - accountSignedMessage, - parseAccountKeyPayload, -} from './contracts' -import { accountsDb, findAccountInfo, registerAccount } from './store' -import { accountInfoDocs, accountRegisterDocs } from './openapi' + accountInfoDocs, + accountRegisterDocs, + backupDeleteDocs, + backupGetDocs, + backupListDocs, + backupPutDocs, + handoffCancelDocs, + handoffClaimDocs, + handoffDoneDocs, + handoffOpenDocs, + handoffReceiveDocs, + handoffSendDocs, + handoffStatusDocs, +} from './openapi' const randomAccountId = (): string => bytesToBase64Url(crypto.getRandomValues(new Uint8Array(16))) -const quotaName = async (callerKey: string): Promise => - bytesToBase64Url( - await sha256Bytes( - new TextEncoder().encode(`ww-account/v1/register-caller\n${callerKey}`) - ) - ) - -/** - * The caller's rolling-day registration quota, shared in kind with Buddies' - * `inbox/register` (a DO per caller, under its own name). Null admits; a quota - * outage admits too. - */ -const refuseOverQuota = async ( - env: Environment, - callerKey: string -): Promise => { - try { - const quota = env.BUDDY_REGISTRATION_QUOTA - const admission = await quota - .get(quota.idFromName(await quotaName(callerKey))) - .admit(Date.now()) - if (admission.ok) return null - logLimitHit('account/register', 'registrationsPerDay') - return buddiesErrorResponse('rate_limited', { - retryAfterSeconds: admission.retryAfterSeconds, - }) - } catch (error) { - console.warn('accounts: registration quota unavailable; allowing') - Sentry.captureException(error) - return null - } -} - -/** - * The payload of a self-signed account op, after the envelope, its signature - * by the named key, and its timestamp check out; otherwise the refusal. - */ -const verifiedKeyPayload = async ( - c: AppContext, - op: AccountsOp -): Promise<{ payload: AccountKeyPayload } | { refusal: Response }> => { - const text = await readEnvelopeText(c.req.raw) - const envelope = text == null ? null : parseEnvelope(text) - const payload = envelope ? parseAccountKeyPayload(envelope.payload) : null - if (!envelope || !payload) - return { refusal: buddiesErrorResponse('bad_request') } - const signature = decodeSignature(envelope.signature) - if (!signature) return { refusal: buddiesErrorResponse('bad_signature') } - if (Math.abs(Date.now() - payload.ts) > BUDDIES_LIMITS.timestampSkewMs) - return { refusal: buddiesErrorResponse('stale') } - const valid = await verifyEd25519( - payload.accountPub, - accountSignedMessage(op, envelope.payloadBytes), - signature - ) - return valid - ? { payload } - : { refusal: buddiesErrorResponse('bad_signature') } -} - /** * `POST /accounts/v1/account/register`: the account for a signing key, created * on first use. Self-signed, like `inbox/register`, and idempotent: the same @@ -101,17 +46,23 @@ const verifiedKeyPayload = async ( const handleRegister = async (c: AppContext): Promise => { try { if (!c.env.ACCOUNTS_DB) return buddiesErrorResponse('disabled') - const callerKey = buddiesCallerKey(c.req.header('CF-Connecting-IP')) - if (!(await allowBuddiesCaller(c.env, 'inbox/register', callerKey))) - return buddiesErrorResponse('rate_limited', { - retryAfterSeconds: EDGE_RETRY_AFTER_SECONDS, - }) + const limited = await refuseAtEdge(c, 'inbox/register') + if (limited) return limited - const verified = await verifiedKeyPayload(c, 'account/register') + const verified = await verifiedBody(c, 'account/register') if ('refusal' in verified) return verified.refusal const { payload } = verified - const refused = await refuseOverQuota(c.env, callerKey) + // The caller's rolling-day registration quota, shared in kind with + // Buddies' `inbox/register` (a DO per caller, under its own name). + const callerKey = buddiesCallerKey(c.req.header('CF-Connecting-IP')) + const refused = await refuseOverDailyQuota( + c.env, + await quotaName('register-caller', callerKey), + undefined, + 'account/register', + 'registrationsPerDay' + ) if (refused) return refused const account = await registerAccount( @@ -138,13 +89,10 @@ const handleRegister = async (c: AppContext): Promise => { const handleInfo = async (c: AppContext): Promise => { try { if (!c.env.ACCOUNTS_DB) return buddiesErrorResponse('disabled') - const callerKey = buddiesCallerKey(c.req.header('CF-Connecting-IP')) - if (!(await allowBuddiesCaller(c.env, 'inbox/sync', callerKey))) - return buddiesErrorResponse('rate_limited', { - retryAfterSeconds: EDGE_RETRY_AFTER_SECONDS, - }) + const limited = await refuseAtEdge(c, 'inbox/sync') + if (limited) return limited - const verified = await verifiedKeyPayload(c, 'account/info') + const verified = await verifiedBody(c, 'account/info') if ('refusal' in verified) return verified.refusal const { payload } = verified @@ -167,5 +115,16 @@ export const createAccountsRoutes = () => { const routes = new Hono<{ Bindings: Environment }>() routes.post('/account/register', accountRegisterDocs, handleRegister) routes.post('/account/info', accountInfoDocs, handleInfo) + routes.post('/handoff/open', handoffOpenDocs, handoffHandlers.open) + routes.post('/handoff/claim', handoffClaimDocs, handoffHandlers.claim) + routes.post('/handoff/status', handoffStatusDocs, handoffHandlers.status) + routes.post('/handoff/send', handoffSendDocs, handoffHandlers.send) + routes.post('/handoff/receive', handoffReceiveDocs, handoffHandlers.receive) + routes.post('/handoff/done', handoffDoneDocs, handoffHandlers.done) + routes.post('/handoff/cancel', handoffCancelDocs, handoffHandlers.cancel) + routes.post('/backup/put', backupPutDocs, backupHandlers.put) + routes.post('/backup/list', backupListDocs, backupHandlers.list) + routes.post('/backup/get', backupGetDocs, backupHandlers.get) + routes.post('/backup/delete', backupDeleteDocs, backupHandlers.delete) return routes } diff --git a/src/accounts/signed.ts b/src/accounts/signed.ts new file mode 100644 index 0000000..176b51b --- /dev/null +++ b/src/accounts/signed.ts @@ -0,0 +1,191 @@ +import type { AppContext, Environment } from '../types' +import { Sentry } from '../sentry' +import { bytesToBase64Url, sha256Bytes } from '../crypto' +import { + BUDDIES_LIMITS, + decodeSignature, + type BuddiesResult, + type BuddiesRouteOp, +} from '../buddies/contracts' +import { + type Envelope, + envelopeFromParts, + parseEnvelope, + readEnvelopeText, + verifyEd25519, +} from '../buddies/envelope' +import { + buddiesErrorResponse, + buddiesInternalError, +} from '../buddies/errorResponse' +import { + allowBuddiesCaller, + buddiesCallerKey, + EDGE_RETRY_AFTER_SECONDS, + logLimitHit, +} from '../buddies/limits' +import { + ACCOUNT_ENVELOPE_HEADERS, + ACCOUNT_FIELD_PARSERS, + type AccountFields, + type AccountKeyPayload, + type AccountsOp, + accountSignedMessage, + handoffBlobTooLarge, + parseAccountKeyPayload, +} from './contracts' +import { accountsDb, findAccountByPub, type AccountRecord } from './store' + +/** A self-signed account op whose envelope, signature, and `ts` check out. */ +export interface VerifiedCall { + payload: AccountKeyPayload + fields: AccountFields +} + +export type Verified = + | VerifiedCall + | { refusal: Response } + +/** + * Checks a parsed envelope for `op`: the key payload and the op's own fields + * (`bad_request`, or `too_large` for `handoff/send`'s oversized key), the signature + * by the named key (`bad_signature`), and the timestamp (`stale`). + */ +export const verifyEnvelope = async ( + op: K, + envelope: Envelope | null +): Promise> => { + if (op === 'handoff/send' && envelope && handoffBlobTooLarge(envelope.payload)) + return { refusal: buddiesErrorResponse('too_large') } + const payload = envelope ? parseAccountKeyPayload(envelope.payload) : null + const fields = envelope + ? (ACCOUNT_FIELD_PARSERS[op](envelope.payload) as AccountFields | null) + : null + if (!envelope || !payload || !fields) + return { refusal: buddiesErrorResponse('bad_request') } + const signature = decodeSignature(envelope.signature) + if (!signature) return { refusal: buddiesErrorResponse('bad_signature') } + if (Math.abs(Date.now() - payload.ts) > BUDDIES_LIMITS.timestampSkewMs) + return { refusal: buddiesErrorResponse('stale') } + const valid = await verifyEd25519( + payload.accountPub, + accountSignedMessage(op, envelope.payloadBytes), + signature + ) + return valid + ? { payload, fields } + : { refusal: buddiesErrorResponse('bad_signature') } +} + +/** A self-signed op whose envelope is the JSON body. */ +export const verifiedBody = async ( + c: AppContext, + op: K +): Promise> => { + const text = await readEnvelopeText(c.req.raw) + return verifyEnvelope(op, text == null ? null : parseEnvelope(text)) +} + +/** A self-signed op whose envelope rides in `x-ww-p` and `x-ww-s`. */ +export const verifiedHeaders = ( + c: AppContext, + op: K +): Promise> => + verifyEnvelope( + op, + envelopeFromParts( + c.req.header(ACCOUNT_ENVELOPE_HEADERS.payload), + c.req.header(ACCOUNT_ENVELOPE_HEADERS.signature) + ) + ) + +/** The registered account for a verified key, or `unknown_account`. */ +export const accountForKey = async ( + env: Environment, + accountPub: string +): Promise<{ account: AccountRecord } | { refusal: Response }> => { + const db = env.ACCOUNTS_DB + if (!db) return { refusal: buddiesErrorResponse('disabled') } + const account = await findAccountByPub(accountsDb(db), accountPub) + return account + ? { account } + : { refusal: buddiesErrorResponse('unknown_account') } +} + +/** + * Counts the request against the Buddies edge tier `as` (named by a Buddies + * op of that tier). Null admits. + */ +export const refuseAtEdge = async ( + c: AppContext, + as: BuddiesRouteOp +): Promise => { + const callerKey = buddiesCallerKey(c.req.header('CF-Connecting-IP')) + return (await allowBuddiesCaller(c.env, as, callerKey)) + ? null + : buddiesErrorResponse('rate_limited', { + retryAfterSeconds: EDGE_RETRY_AFTER_SECONDS, + }) +} + +/** A hashed Durable Object name for a rolling-day quota, under `label`. */ +export const quotaName = async (label: string, key: string): Promise => + bytesToBase64Url( + await sha256Bytes(new TextEncoder().encode(`ww-account/v1/${label}\n${key}`)) + ) + +/** + * Counts one call against a rolling-day quota (a `BuddyRegistrationQuota` + * object under `name`). Null admits; a quota outage admits too. + */ +export const refuseOverDailyQuota = async ( + env: Environment, + name: string, + limit: number | undefined, + op: string, + limitName: string +): Promise => { + try { + const quota = env.BUDDY_REGISTRATION_QUOTA + const admission = await quota + .get(quota.idFromName(name)) + .admit(Date.now(), limit) + if (admission.ok) return null + logLimitHit(op, limitName) + return buddiesErrorResponse('rate_limited', { + retryAfterSeconds: admission.retryAfterSeconds, + }) + } catch (error) { + console.warn('accounts: quota unavailable; allowing', { op }) + Sentry.captureException(error) + return null + } +} + +/** A Durable Object's answer as JSON, `{ ok: true, ...value }` or the error. */ +export const replyWith = ( + c: AppContext, + result: BuddiesResult +): Response => + result.ok + ? c.json({ ok: true, ...result.value }) + : buddiesErrorResponse(result.error, { + retryAfterSeconds: result.retryAfterSeconds, + }) + +/** + * Runs an accounts handler, turning a throw into a bare 500. + * + * Privacy: the log names the op only, never the body, keys, or ids. + */ +export const guarded = + (op: string, handler: (c: AppContext) => Promise) => + async (c: AppContext): Promise => { + try { + return await handler(c) + } catch (error) { + console.error('accounts: request failed', { op }) + Sentry.captureException(error) + return buddiesInternalError() + } + } diff --git a/src/accounts/store.ts b/src/accounts/store.ts index 54b2a8b..5d3e565 100644 --- a/src/accounts/store.ts +++ b/src/accounts/store.ts @@ -33,7 +33,7 @@ export const findAccount = async ( .where(eq(accounts.id, accountId)) .get()) ?? null -const findAccountByPub = async ( +export const findAccountByPub = async ( db: AccountsDb, accountPub: string ): Promise => diff --git a/src/app.ts b/src/app.ts index 604dcf2..73d8c3f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -46,6 +46,7 @@ import { createBuddiesRoutes } from "./buddies/route"; import { createAccountsRoutes } from "./accounts/route"; import { createAccountsAdminRoutes } from "./accounts/admin"; import { handleBuddyInvitePage } from "./buddies/invitePage"; +import { handleHandoffPage } from "./accounts/handoffPage"; import { createRoutePlanningRoutes } from "./routePlanning/route"; import { mountApiDocs } from "./openapi/route"; import { @@ -53,6 +54,7 @@ import { appStoreRatingsDocs, autocompleteDocs, buddyInvitePageDocs, + handoffPageDocs, contactLinkDocs, geocodeDocs, healthDocs, @@ -188,11 +190,15 @@ app.get("/c/:payload", legacyContactLinkDocs, handleLegacyContactLinkRequest); // Buddies relay: POST /buddies/v1/{op}, ids only in bodies. Every op is // rate-limited per caller inside the handler (src/buddies/limits.ts). app.route("/buddies/v1", createBuddiesRoutes()); -// Anonymous accounts (witness-work ADR 0024): POST /accounts/v1/{op}, signed -// like Buddies ops and rate-limited per caller inside the handler. +// Anonymous accounts (witness-work ADR 0024, 0026): POST /accounts/v1/{op}, +// signed like Buddies ops (handoff's new-phone ops carry a claim token +// instead) and rate-limited per caller inside the handler. app.route("/accounts/v1", createAccountsRoutes()); // Buddy invite no-app fallback; the invite secret stays in the URL fragment. app.get("/b", buddyInvitePageDocs, handleBuddyInvitePage); +// Set Up Another Device QR codes opened by a camera instead of the app; the +// handoff secret stays in the URL fragment. +app.get("/h", handoffPageDocs, handleHandoffPage); // Supporter-only route ordering (HERE Waypoints Sequence); coordinates only, // with per-account limits in the RoutePlanningQuota DO. app.route("/route-planning", createRoutePlanningRoutes()); diff --git a/src/buddies/contracts.ts b/src/buddies/contracts.ts index 36c1047..2e4b1eb 100644 --- a/src/buddies/contracts.ts +++ b/src/buddies/contracts.ts @@ -139,6 +139,8 @@ export const BUDDIES_ERROR_STATUS = { suspended: 403, /** `inbox/register` named an account the accounts database doesn't have. */ unknown_account: 404, + /** A handoff channel another account opened. */ + forbidden: 403, } as const export type BuddiesErrorCode = keyof typeof BUDDIES_ERROR_STATUS diff --git a/src/buddies/envelope.ts b/src/buddies/envelope.ts index d9473d7..d53a6fe 100644 --- a/src/buddies/envelope.ts +++ b/src/buddies/envelope.ts @@ -89,7 +89,7 @@ export const parseEnvelope = (text: string): Envelope | null => { } /** Decodes `p` and its JSON; `s` passes through. Null means `bad_request`. */ -const envelopeFromParts = (p: unknown, s: unknown): Envelope | null => { +export const envelopeFromParts = (p: unknown, s: unknown): Envelope | null => { if (typeof p !== 'string' || !p) return null const payloadBytes = decodeB64u(p) if (!payloadBytes) return null @@ -163,16 +163,17 @@ export const readBlobPutCall = ( /** * Reads a `blob/put` body of exactly `expected` bytes (the signed `bytes`, - * at most `blobBytes`) without buffering past it: a declared - * `Content-Length` is checked first, and the stream is cancelled one chunk - * after it overruns. Anything past `blobBytes` is `too_large`; any other - * length mismatch is `bad_request`. + * at most `limit`, `blobBytes` by default) without buffering past it: a + * declared `Content-Length` is checked first, and the stream is cancelled one + * chunk after it overruns. Anything past `limit` is `too_large`; any other + * length mismatch is `bad_request`. Accounts' `backup/put` reads its blob + * the same way under its own limit. */ export const readBlobBody = async ( request: Request, - expected: number + expected: number, + limit: number = BUDDIES_LIMITS.blobBytes ): Promise> => { - const limit = BUDDIES_LIMITS.blobBytes const declared = request.headers.get('content-length') if (declared != null) { const length = Number(declared) diff --git a/src/buddies/invitePage.test.ts b/src/buddies/invitePage.test.ts index a908e49..a988f9f 100644 --- a/src/buddies/invitePage.test.ts +++ b/src/buddies/invitePage.test.ts @@ -87,6 +87,7 @@ describe('apple-app-site-association', () => { { '/': '/c', '#': '?*' }, { '/': '/c/*' }, { '/': '/b', '#': '1?*' }, + { '/': '/h', '#': '1?*' }, ]) }) }) diff --git a/src/buddies/registrationQuota.ts b/src/buddies/registrationQuota.ts index d2d860a..3500ade 100644 --- a/src/buddies/registrationQuota.ts +++ b/src/buddies/registrationQuota.ts @@ -22,22 +22,28 @@ type Bucket = { hour: number; n: number } * how many validly signed `inbox/register` calls it made in each hour of the * last day, and nothing else. Single-threaded, so the check and the count * can't race. It deletes itself once its newest hour leaves the window. + * + * Other rolling-day quotas reuse the class under their own hashed names with + * their own `limit` (accounts: `handoff/open` per account). */ export class BuddyRegistrationQuota extends DurableObject { #schemaReady = false - async admit(now: number): Promise { + async admit( + now: number, + limit: number = ABUSE.registrationsPerDay + ): Promise { const hour = Math.floor(now / HOUR_MS) const result = this.ctx.storage.transactionSync( (): RegistrationAdmission => { this.#ensureSchema() const buckets = this.#prune(hour) let counted = buckets.reduce((sum, bucket) => sum + bucket.n, 0) - if (counted >= ABUSE.registrationsPerDay) { + if (counted >= limit) { // Wait for enough of the oldest hours to leave the window. for (const bucket of buckets) { counted -= bucket.n - if (counted < ABUSE.registrationsPerDay) { + if (counted < limit) { const frees = (bucket.hour + WINDOW_HOURS) * HOUR_MS return { ok: false, diff --git a/src/contactLink.test.ts b/src/contactLink.test.ts index f3e1aa7..04446ef 100644 --- a/src/contactLink.test.ts +++ b/src/contactLink.test.ts @@ -70,6 +70,7 @@ describe('apple-app-site-association', () => { { '/': '/c', '#': '?*' }, { '/': '/c/*' }, { '/': '/b', '#': '1?*' }, + { '/': '/h', '#': '1?*' }, ], }, ], diff --git a/src/contactLink.ts b/src/contactLink.ts index f8229a9..7ecde94 100644 --- a/src/contactLink.ts +++ b/src/contactLink.ts @@ -100,6 +100,8 @@ function buildAasaPayload(teamId: string): object { { '/': `${LEGACY_CONTACT_LINK_PATH_PREFIX}*` }, // Buddy invite `/b#1`: the secret stays in the fragment. { '/': '/b', '#': '1?*' }, + // Set Up Another Device `/h#1`, likewise. + { '/': '/h', '#': '1?*' }, ], }, ], diff --git a/src/e2e/accounts.e2e.test.ts b/src/e2e/accounts.e2e.test.ts index 8935e8e..711cc9a 100644 --- a/src/e2e/accounts.e2e.test.ts +++ b/src/e2e/accounts.e2e.test.ts @@ -1,9 +1,18 @@ import { afterAll, describe, expect, it } from 'vitest' import { accountSignedMessage, type AccountsOp } from '../accounts/contracts' -import { SigningKey, b64u, randomId } from '../test/buddiesClient' -import { ADMIN_TOKEN, http, writeTranscript } from '../test/e2e' +import { + SigningKey, + b64u, + randomBytes, + randomId, + sha256B64u, +} from '../test/buddiesClient' +import { ADMIN_TOKEN, http, httpBytes, writeTranscript } from '../test/e2e' -/** Anonymous accounts: register, then read the account back with account/info. */ +/** + * Anonymous accounts: register, read the account back with account/info, move + * the key through a handoff channel, and back up to R2. + */ afterAll(() => { writeTranscript('accounts') @@ -11,16 +20,40 @@ afterAll(() => { const encoder = new TextEncoder() -const send = async (op: AccountsOp, key: SigningKey, signer = key) => { +const signedEnvelope = async ( + op: AccountsOp, + key: SigningKey, + fields: Record = {}, + signer = key +) => { const bytes = encoder.encode( - JSON.stringify({ accountPub: key.publicKey, ts: Date.now(), nonce: randomId() }) + JSON.stringify({ + accountPub: key.publicKey, + ts: Date.now(), + nonce: randomId(), + ...fields, + }) ) - return http('POST', `/accounts/v1/${op}`, { - body: { - p: b64u(bytes), - s: b64u(await signer.sign(accountSignedMessage(op, bytes))), - }, + return { + p: b64u(bytes), + s: b64u(await signer.sign(accountSignedMessage(op, bytes))), + } +} + +const send = async ( + op: AccountsOp, + key: SigningKey, + signer = key, + fields: Record = {} +) => + http('POST', `/accounts/v1/${op}`, { + body: await signedEnvelope(op, key, fields, signer), }) + +const registered = async () => { + const key = await SigningKey.generate() + expect((await send('account/register', key)).status).toBe(200) + return key } describe('POST /accounts/v1/account/info', () => { @@ -77,3 +110,126 @@ describe('POST /accounts/v1/account/info', () => { expect(JSON.stringify(res.body)).not.toContain('admin') }) }) + +describe('POST /accounts/v1/handoff/*', () => { + it('relays a sealed key from the old phone to the new one', async () => { + const key = await registered() + const token = randomBytes(16) + const channelId = randomId() + const claimToken = b64u(token) + const opened = await send('handoff/open', key, key, { + channelId, + verifier: await sha256B64u(token), + }) + expect(opened.status).toBe(200) + expect(opened.body).toEqual({ ok: true, expiresAt: expect.any(Number) }) + + const claimPub = (await SigningKey.generate()).publicKey + const hello = b64u(randomBytes(120)) + const claim = { channelId, claimToken, claimPub, hello } + expect((await http('POST', '/accounts/v1/handoff/claim', { body: claim })).status).toBe(200) + // A lost response, repeated. + expect((await http('POST', '/accounts/v1/handoff/claim', { body: claim })).status).toBe(200) + + const status = await send('handoff/status', key, key, { channelId }) + expect(status.body).toMatchObject({ ok: true, status: 'claimed', claimPub, hello }) + // Another account can't read it. + const stranger = await registered() + expect((await send('handoff/status', stranger, stranger, { channelId })).body.code).toBe('forbidden') + + const sealedKey = b64u(randomBytes(80)) + expect((await send('handoff/send', key, key, { channelId, key: sealedKey })).status).toBe(200) + const received = await http('POST', '/accounts/v1/handoff/receive', { + body: { channelId, claimToken }, + }) + expect(received.body).toEqual({ ok: true, status: 'sent', key: sealedKey }) + expect( + (await http('POST', '/accounts/v1/handoff/done', { body: { channelId, claimToken } })).status + ).toBe(200) + expect((await send('handoff/status', key, key, { channelId })).body).toEqual({ + ok: true, + status: 'done', + expiresAt: opened.body.expiresAt, + }) + }) + + it('burns a channel after five wrong tokens', async () => { + const key = await registered() + const token = randomBytes(16) + const channelId = randomId() + await send('handoff/open', key, key, { channelId, verifier: await sha256B64u(token) }) + for (let i = 0; i < 5; i++) { + const wrong = await http('POST', '/accounts/v1/handoff/receive', { + body: { channelId, claimToken: randomId() }, + }) + expect(wrong.body.code).toBe('bad_signature') + } + expect((await send('handoff/status', key, key, { channelId })).body.status).toBe('cancelled') + }) +}) + +describe('POST /accounts/v1/backup/*', () => { + const put = async (key: SigningKey, slotId: string, bytes: Uint8Array) => { + const { p, s } = await signedEnvelope('backup/put', key, { + slotId, + size: bytes.byteLength, + sha256: await sha256B64u(bytes), + }) + return httpBytes('/accounts/v1/backup/put', { + bytes, + headers: { 'x-ww-p': p, 'x-ww-s': s }, + }) + } + + it('round-trips a slot through R2 and deletes it', async () => { + const key = await registered() + const slotId = randomId() + const bytes = randomBytes(64 * 1_024) + const stored = await put(key, slotId, bytes) + expect(stored.status).toBe(200) + expect(stored.body).toEqual({ ok: true, uploadedAt: expect.any(Number) }) + // One put a slot a minute. + const again = await put(key, slotId, bytes) + expect(again.status).toBe(429) + expect(again.body.code).toBe('rate_limited') + + const listed = await send('backup/list', key) + expect(listed.body).toEqual({ + ok: true, + slots: [{ slotId, size: bytes.byteLength, uploadedAt: stored.body.uploadedAt }], + }) + + const got = await httpBytes('/accounts/v1/backup/get', { + json: await signedEnvelope('backup/get', key, { slotId }), + }) + expect(got.status).toBe(200) + expect(got.bytes).toEqual(bytes) + + expect((await send('backup/delete', key)).status).toBe(200) + expect((await send('backup/list', key)).body.slots).toEqual([]) + }) + + it('refuses a body whose sha256 differs', async () => { + const key = await registered() + const bytes = randomBytes(1_000) + const { p, s } = await signedEnvelope('backup/put', key, { + slotId: randomId(), + size: bytes.byteLength, + sha256: await sha256B64u(randomBytes(1_000)), + }) + const res = await httpBytes('/accounts/v1/backup/put', { + bytes, + headers: { 'x-ww-p': p, 'x-ww-s': s }, + }) + expect(res.status).toBe(400) + expect(res.body.code).toBe('bad_request') + }) +}) + +describe('GET /h', () => { + it('serves the scan-from-the-app page', async () => { + const res = await http('GET', '/h') + expect(res.status).toBe(200) + expect(res.text).toContain('Scan this code in WitnessWork') + }) +}) diff --git a/src/e2e/public.e2e.test.ts b/src/e2e/public.e2e.test.ts index f43590f..4d5dc7e 100644 --- a/src/e2e/public.e2e.test.ts +++ b/src/e2e/public.e2e.test.ts @@ -133,6 +133,7 @@ describe('universal links', () => { { '/': '/c', '#': '?*' }, { '/': '/c/*' }, { '/': '/b', '#': '1?*' }, + { '/': '/h', '#': '1?*' }, ]) }) diff --git a/src/index.ts b/src/index.ts index dcf7c30..8380e2e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ export { BuddyInbox } from "./buddies/inboxDO"; export { BuddyInvite } from "./buddies/inviteDO"; export { RoutePlanningQuota } from "./routePlanning/quotaDO"; export { BuddyRegistrationQuota } from "./buddies/registrationQuota"; +export { AccountHandoff } from "./accounts/handoffDO"; export default Sentry.withSentry( (environment: Environment) => createSentryConfig(environment), diff --git a/src/openapi/coreDocs.ts b/src/openapi/coreDocs.ts index 72779d3..8ece031 100644 --- a/src/openapi/coreDocs.ts +++ b/src/openapi/coreDocs.ts @@ -199,6 +199,14 @@ export const buddyInvitePageDocs = describeRoute({ responses: { 200: html('The invite landing page.') }, }) +export const handoffPageDocs = describeRoute({ + tags: ['Links'], + summary: 'Set Up Another Device fallback page', + description: + 'Shown when a phone’s camera opens a Set Up Another Device QR code (`/h#1<…>`) instead of WitnessWork: install or open the app on the new phone and scan the code from inside it, with App Store and Google Play buttons. Localized like `/c` (`?lang=`, then `Accept-Language`). The handoff secret stays in the fragment and never reaches the worker.', + responses: { 200: html('The handoff landing page.') }, +}) + export const notesImportAdminResetDocs = describeRoute({ tags: ['Admin'], summary: 'Reset a Notes Import meter', diff --git a/src/sentry.test.ts b/src/sentry.test.ts index 0dfc59b..d9fd1c1 100644 --- a/src/sentry.test.ts +++ b/src/sentry.test.ts @@ -144,6 +144,25 @@ describe('Sentry scrubbers', () => { ).toEqual({ upgrade: 'websocket' }) }) + it('drops the account backup envelope headers, and only those', () => { + const error: ErrorEvent = { + type: undefined, + request: { + url: 'https://ww-proxy.test/accounts/v1/backup/put', + headers: { + 'x-ww-p': 'eyJhY2NvdW50UHViIjoiQUFBQSJ9', + 'X-WW-S': 'c2lnbmF0dXJl', + 'x-ww-dev-bypass': 'kept', + 'content-type': 'application/octet-stream', + }, + }, + } + expect((config.beforeSend!(error, {}) as ErrorEvent).request?.headers).toEqual({ + 'x-ww-dev-bypass': 'kept', + 'content-type': 'application/octet-stream', + }) + }) + it('leaves events without contact links untouched', () => { const event: TransactionEvent = { type: 'transaction', diff --git a/src/sentry.ts b/src/sentry.ts index 45ec2d5..913a77f 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -47,10 +47,11 @@ function scrubStrings(value: unknown, seen = new WeakSet()): unknown { /** * The Buddies live socket sends its signed envelope (which holds the inbox - * id) in `x-buddies-*` request headers, and the SDK copies request headers - * into events. Drop them outright. + * id) in `x-buddies-*` request headers, and accounts' `backup/put` sends its + * envelope (account key and slot id) in `x-ww-p`/`x-ww-s`. The SDK copies + * request headers into events, so drop them outright. */ -const BUDDIES_HEADER = /^x-buddies-/i; +const BUDDIES_HEADER = /^(x-buddies-|x-ww-[ps]$)/i; function dropBuddiesHeaders(event: Event): void { const headers = event.request?.headers; diff --git a/src/test/buddies.ts b/src/test/buddies.ts index c707563..1b7e6f0 100644 --- a/src/test/buddies.ts +++ b/src/test/buddies.ts @@ -2,6 +2,7 @@ import { vi } from 'vitest' import { BuddyInbox } from '../buddies/inboxDO' import { BuddyInvite } from '../buddies/inviteDO' import { BuddyRegistrationQuota } from '../buddies/registrationQuota' +import { AccountHandoff } from '../accounts/handoffDO' import { BUDDIES_ABUSE_LIMITS } from '../buddies/limits' import { createBuddiesRoutes } from '../buddies/route' import { resetApnsState } from '../apns' @@ -111,6 +112,8 @@ export interface HarnessOptions { blobBucket?: boolean /** Bind the accounts database (default true). */ accountsDb?: boolean + /** Bind the account backup bucket (default true). */ + backupBucket?: boolean } export interface Harness { @@ -120,6 +123,9 @@ export interface Harness { r2: MemoryR2 /** The `ACCOUNTS_DB` database, when bound. */ accountsDb: MemoryD1 | null + /** The `ACCOUNT_BACKUPS` bucket (present even when unbound, for assertions). */ + backups: MemoryR2 + handoffs: FakeNamespace inboxes: FakeNamespace invites: FakeNamespace quotas: FakeNamespace @@ -195,6 +201,7 @@ export const createHarness = async ( await kv.put('buddies:enabled', options.kvEnabled) if (options.kvPhotos != null) await kv.put('buddies:photos', options.kvPhotos) const r2 = makeMemoryR2() + const backups = makeMemoryR2() const accountsDb = options.accountsDb === false ? null : makeMemoryD1() const apnsKeys = (await crypto.subtle.generateKey( @@ -231,6 +238,7 @@ export const createHarness = async ( BUDDIES_PHOTOS: options.photos ?? 'on', ...(options.blobBucket === false ? {} : { BUDDY_BLOBS: r2 }), ...(accountsDb ? { ACCOUNTS_DB: accountsDb } : {}), + ...(options.backupBucket === false ? {} : { ACCOUNT_BACKUPS: backups }), ...limiters, ...(options.apnsConfigured === false ? {} @@ -255,6 +263,10 @@ export const createHarness = async ( env.BUDDY_INVITE = invites.namespace as Environment['BUDDY_INVITE'] env.BUDDY_REGISTRATION_QUOTA = quotas.namespace as Environment['BUDDY_REGISTRATION_QUOTA'] + const handoffs = createNamespace( + (state) => new AccountHandoff(state as never, env as never) + ) + env.ACCOUNT_HANDOFF = handoffs.namespace as Environment['ACCOUNT_HANDOFF'] const pending: Promise[] = [] const executionCtx = { @@ -270,6 +282,8 @@ export const createHarness = async ( kv, r2, accountsDb, + backups, + handoffs, inboxes, invites, quotas, diff --git a/src/test/memoryR2.ts b/src/test/memoryR2.ts index 7772ac4..092fe85 100644 --- a/src/test/memoryR2.ts +++ b/src/test/memoryR2.ts @@ -7,6 +7,7 @@ export interface MemoryR2Object { bytes: Uint8Array uploaded: number + customMetadata?: Record } export interface MemoryR2 { @@ -21,7 +22,7 @@ export interface MemoryR2 { put( key: string, value: ArrayBuffer | ArrayBufferView, - options?: { sha256?: string } + options?: { sha256?: string; customMetadata?: Record } ): Promise<{ key: string; size: number }> get(key: string): Promise<{ body: ReadableStream; size: number } | null> head(key: string): Promise<{ key: string; size: number } | null> @@ -29,17 +30,25 @@ export interface MemoryR2 { /** Keys in order, `limit` (default 1,000) at a time; `cursor` is the last key. */ list(options?: { prefix?: string; cursor?: string; limit?: number }): Promise< | { - objects: { key: string; size: number; uploaded: Date }[] + objects: MemoryR2Listed[] truncated: true cursor: string } | { - objects: { key: string; size: number; uploaded: Date }[] + objects: MemoryR2Listed[] truncated: false } > } +/** A listed object; `customMetadata` as R2 returns it with `include`. */ +export interface MemoryR2Listed { + key: string + size: number + uploaded: Date + customMetadata?: Record +} + const hex = (bytes: Uint8Array): string => Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') @@ -76,7 +85,13 @@ export const makeMemoryR2 = (): MemoryR2 => { ) if (digest !== options.sha256) throw new Error('r2 sha256 mismatch') } - objects.set(key, { bytes, uploaded: Date.now() }) + objects.set(key, { + bytes, + uploaded: Date.now(), + ...(options.customMetadata + ? { customMetadata: { ...options.customMetadata } } + : {}), + }) return { key, size: bytes.byteLength } }, get: async (key) => { @@ -111,6 +126,9 @@ export const makeMemoryR2 = (): MemoryR2 => { key, size: object.bytes.byteLength, uploaded: new Date(object.uploaded), + ...(object.customMetadata + ? { customMetadata: { ...object.customMetadata } } + : {}), } }) return keys.length > limit diff --git a/src/types.ts b/src/types.ts index 2b897a7..8e65f4f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -242,6 +242,22 @@ export interface Environment { */ ACCOUNTS_DB?: D1Database; + /** + * One SQLite DO per handoff `channelId` (Set Up Another Device, witness-work + * ADR 0026): the opening account, the claim verifier, a temporary public + * key, and two sealed blobs, for at most ten minutes. + */ + ACCOUNT_HANDOFF: DurableObjectNamespace< + import('./accounts/handoffDO').AccountHandoff + >; + + /** + * End-to-end encrypted backups, one object per device slot at + * `v1//` (ciphertext only). Without it, `backup/*` + * answers `disabled`. + */ + ACCOUNT_BACKUPS?: R2Bucket; + // --- Buddies relay ------------------------------------------------------- /** diff --git a/wrangler.toml b/wrangler.toml index 4dd186d..8066da0 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -125,6 +125,10 @@ class_name = "RoutePlanningQuota" name = "BUDDY_REGISTRATION_QUOTA" class_name = "BuddyRegistrationQuota" +[[env.dev.durable_objects.bindings]] +name = "ACCOUNT_HANDOFF" +class_name = "AccountHandoff" + # Buddies note photos (encrypted blobs), separate from prod's bucket. Not # inherited, like every binding. `pnpm run deploy:dev` creates it and sets its # lifecycle rule (scripts/r2-lifecycle.mjs). @@ -132,6 +136,13 @@ class_name = "BuddyRegistrationQuota" binding = "BUDDY_BLOBS" bucket_name = "ww-buddy-blobs-dev" +# Account backups (witness-work ADR 0026), separate from prod's bucket. +# `pnpm run deploy:dev` creates it and sets its 400-day lifecycle rule +# (scripts/r2-lifecycle.mjs). +[[env.dev.r2_buckets]] +binding = "ACCOUNT_BACKUPS" +bucket_name = "ww-account-backups-dev" + # Anonymous accounts (witness-work ADR 0024): accounts, their status, and which # Buddies inboxes they own. Not inherited, like every binding. Migrations come # from `pnpm db:generate` (drizzle-kit); apply them before deploying code that @@ -166,6 +177,10 @@ new_sqlite_classes = ["RoutePlanningQuota"] tag = "v6" new_sqlite_classes = ["BuddyRegistrationQuota"] +[[env.dev.migrations]] +tag = "v7" +new_sqlite_classes = ["AccountHandoff"] + [[env.dev.ratelimits]] name = "RATE_LIMITER" namespace_id = "2001" # distinct from prod's 1001 @@ -447,6 +462,15 @@ migrations_dir = "migrations/accounts" binding = "BUDDY_BLOBS" bucket_name = "ww-buddy-blobs" +# Account backups (witness-work ADR 0026): one end-to-end encrypted object per +# device slot at v1//. Without the binding, backup/* answers +# `disabled`. Every deploy runs scripts/r2-lifecycle.mjs first: it creates the +# bucket if it's missing and deletes v1/ objects 400 days after their last +# upload. Repeated (with its own bucket) under [env.dev] above. +[[r2_buckets]] +binding = "ACCOUNT_BACKUPS" +bucket_name = "ww-account-backups" + # Notes Import streaming — per-import run DO + per-user cap/index DO, each # SQLite-backed (event log / active-import set). See # docs/notes-import-streaming-durable-objects.md. Repeated under [env.dev] above. @@ -486,11 +510,19 @@ class_name = "RoutePlanningQuota" # Buddies: signed inbox/register calls per caller (hashed client IP, IPv6 by # /64) per hour over the last day, nothing else; deletes itself a day after -# the newest. Repeated under [env.dev] above. +# the newest. Accounts reuse it under their own hashed names (account/register +# per caller, handoff/open per account). Repeated under [env.dev] above. [[durable_objects.bindings]] name = "BUDDY_REGISTRATION_QUOTA" class_name = "BuddyRegistrationQuota" +# Accounts: Set Up Another Device, one handoff channel per DO (a temporary +# public key and two sealed blobs, deleted within ten minutes of opening). +# Repeated under [env.dev] above. +[[durable_objects.bindings]] +name = "ACCOUNT_HANDOFF" +class_name = "AccountHandoff" + [[migrations]] tag = "v1" new_sqlite_classes = ["NotesImportRun", "NotesImportIndex"] @@ -517,6 +549,10 @@ new_sqlite_classes = ["RoutePlanningQuota"] tag = "v6" new_sqlite_classes = ["BuddyRegistrationQuota"] +[[migrations]] +tag = "v7" +new_sqlite_classes = ["AccountHandoff"] + [observability] enabled = true head_sampling_rate = 1 # optional. default = 1.