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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,11 @@ Code in `src/accounts/`, schema in `src/accounts/schema.ts` (Drizzle on D1).
- `POST /accounts/v1/account/register`: self-signed under the
`ww-account/v1` context; idempotent per key. Counts against the Buddies
register edge tier and the per-caller daily quota.
- `POST /accounts/v1/account/info`: the same self-signed envelope (op
`account/info`), read-only. Returns the key's own account (`accountId`,
`accountPub`, `status`, `createdAt`, `inboxIds`, and `statusChanges`
without their reasons) for the app's developer tools; an unregistered key
is 404 `unknown_account`. Counts against the Buddies read edge tier.
- `inbox/register` takes an optional `account: { accountId, signature }`,
the account key's signature over the inbox id and owner key. The link
(`account_inboxes`) is written only after the inbox accepted the
Expand Down
92 changes: 89 additions & 3 deletions src/accounts/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import {
type Harness,
} from '../test/buddies'
import { signedMessage } from '../buddies/envelope'
import { accountSignedMessage, inboxLinkMessage } from './contracts'
import {
type AccountsOp,
accountSignedMessage,
inboxLinkMessage,
} from './contracts'
import { createAccountsRoutes } from './route'
import { createAccountsAdminRoutes } from './admin'

Expand Down Expand Up @@ -71,12 +75,13 @@ const post = async (
const accountEnvelope = async (
payload: Record<string, unknown>,
key: SigningKey,
message = accountSignedMessage
message = accountSignedMessage,
op: AccountsOp = 'account/register'
) => {
const bytes = encoder.encode(JSON.stringify(payload))
return {
p: b64u(bytes),
s: b64u(await key.sign(message('account/register', bytes))),
s: b64u(await key.sign(message(op, bytes))),
}
}

Expand Down Expand Up @@ -213,6 +218,87 @@ describe('account/register', () => {
})
})

const accountInfo = async (
key: SigningKey,
payload: Record<string, unknown> = {},
op: AccountsOp = 'account/info'
) =>
post(
accountsApp as never,
'/account/info',
await accountEnvelope(
{ accountPub: key.publicKey, ts: Date.now(), nonce: randomId(), ...payload },
key,
accountSignedMessage,
op
)
)

describe('account/info', () => {
it('shows the account, its inboxes, and its status changes', async () => {
const { account, owner } = await linkedOwner()
vi.setSystemTime(START + 60_000)
await setStatus(account.accountId, 'suspended')
vi.setSystemTime(START + 120_000)
await setStatus(account.accountId, 'active', 'appeal')
expect(await accountInfo(account.key)).toEqual({
status: 200,
body: {
ok: true,
accountId: account.accountId,
accountPub: account.key.publicKey,
status: 'active',
createdAt: START,
inboxIds: [owner.inboxId],
statusChanges: [
{ status: 'suspended', createdAt: START + 60_000 },
{ status: 'active', createdAt: START + 120_000 },
],
},
})
})

it('never creates an account', async () => {
const key = await SigningKey.generate()
expect(await accountInfo(key)).toMatchObject({
status: 404,
body: { code: 'unknown_account' },
})
expect((await registerAccount(key)).status).toBe(200)
expect((await accountInfo(key)).body.inboxIds).toEqual([])
})

it('needs the account key’s signature for account/info', async () => {
const { key } = await newAccount()
const other = await SigningKey.generate()
const forged = await post(
accountsApp as never,
'/account/info',
await accountEnvelope(
{ accountPub: key.publicKey, ts: Date.now(), nonce: randomId() },
other,
accountSignedMessage,
'account/info'
)
)
expect(forged.body.code).toBe('bad_signature')
// A register signature can't be replayed as a read.
const crossOp = await accountInfo(key, {}, 'account/register')
expect(crossOp.body.code).toBe('bad_signature')
})

it('refuses a stale timestamp, and is off without the database', async () => {
const { key } = await newAccount()
const stale = await accountInfo(key, { ts: Date.now() - 10 * 60_000 })
expect(stale).toMatchObject({ status: 401, body: { code: 'stale' } })
h = await createHarness({ accountsDb: false })
expect(await accountInfo(key)).toMatchObject({
status: 503,
body: { code: 'disabled' },
})
})
})

describe('linking an inbox', () => {
it('links on inbox/register with a proof, and says so later without one', async () => {
const { account, owner } = await linkedOwner()
Expand Down
14 changes: 9 additions & 5 deletions src/accounts/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
* Buddies one or the other way round.
*/

export const ACCOUNTS_OPS = ['account/register'] as const
export const ACCOUNTS_OPS = ['account/register', 'account/info'] as const
export type AccountsOp = (typeof ACCOUNTS_OPS)[number]

const SIGNING_CONTEXT = 'ww-account/v1\n'
Expand Down Expand Up @@ -49,7 +49,8 @@ export const inboxLinkMessage = (
`${SIGNING_CONTEXT}inbox/link\n${accountId}\n${inboxId}\n${ownerPub}`
)

export interface AccountRegisterPayload {
/** `account/register` and `account/info` both name the signing key. */
export interface AccountKeyPayload {
accountPub: string
ts: number
nonce: string
Expand All @@ -65,10 +66,13 @@ const publicKey = (value: unknown): string | null => {
return bytes && bytes.length === 32 ? bytesToBase64Url(bytes) : null
}

/** Validates `account/register`'s payload; null means `bad_request`. */
export const parseAccountRegister = (
/**
* Validates `account/register`'s or `account/info`'s payload; null means
* `bad_request`.
*/
export const parseAccountKeyPayload = (
payload: unknown
): AccountRegisterPayload | null => {
): AccountKeyPayload | null => {
if (!isPlainObject(payload)) return null
const accountPub = publicKey(payload.accountPub)
const ts = payload.ts
Expand Down
70 changes: 56 additions & 14 deletions src/accounts/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,8 @@ import { ACCOUNT_STATUS_REASONS, ACCOUNT_STATUSES } from './schema'
const accountId = buddiesId('The account id')
const status = z.enum(ACCOUNT_STATUSES)

export const accountRegisterDocs = describeRoute({
tags: ['Accounts'],
summary: 'Register an account',
description: `Returns the account for a signing key, creating it on first use. Self-signed by the account key under the \`ww-account/v1\` context, and idempotent: the same key always gets the same \`accountId\`.

Limits: the Buddies register tier (60 a minute per caller IP) and 2,000 registrations per caller per rolling day, shared in kind with \`inbox/register\`. Answers \`disabled\` on a worker without the accounts database.`,
requestBody: signedBody(
const keySignedBody = (op: string) =>
signedBody(
z.object({
accountPub: b64u(
'Ed25519 public key of the account signing key, 32 bytes'
Expand All @@ -31,19 +26,31 @@ Limits: the Buddies register tier (60 a minute per caller IP) and 2,000 registra
}),
nonce: buddiesId('A fresh random nonce'),
}),
'A signed envelope; `s` is the account key’s signature over `ww-account/v1\\naccount/register\\n` followed by `p`’s bytes.'
`A signed envelope; \`s\` is the account key’s signature over \`ww-account/v1\\n${op}\\n\` followed by \`p\`’s bytes.`
)

const signatureErrors = {
400: errorResponse('Malformed envelope or payload.', ['bad_request']),
401: errorResponse(
'Bad signature, or `ts` too far from the server clock. `stale` carries `serverTime` (epoch ms) and a `Date` header.',
['bad_signature', 'stale'],
staleErrorSchema
),
}

export const accountRegisterDocs = describeRoute({
tags: ['Accounts'],
summary: 'Register an account',
description: `Returns the account for a signing key, creating it on first use. Self-signed by the account key under the \`ww-account/v1\` context, and idempotent: the same key always gets the same \`accountId\`.

Limits: the Buddies register tier (60 a minute per caller IP) and 2,000 registrations per caller per rolling day, shared in kind with \`inbox/register\`. Answers \`disabled\` on a worker without the accounts database.`,
requestBody: keySignedBody('account/register'),
responses: {
200: jsonResponse(
'The account for this key.',
z.object({ ok: z.literal(true), accountId, status })
),
400: errorResponse('Malformed envelope or payload.', ['bad_request']),
401: errorResponse(
'Bad signature, or `ts` too far from the server clock. `stale` carries `serverTime` (epoch ms) and a `Date` header.',
['bad_signature', 'stale'],
staleErrorSchema
),
...signatureErrors,
429: errorResponse('Over the per-caller rate or daily quota.', [
'rate_limited',
]),
Expand All @@ -52,6 +59,41 @@ Limits: the Buddies register tier (60 a minute per caller IP) and 2,000 registra
},
})

const epochMs = (description: string) =>
z.number().int().meta({ description: `${description}, epoch milliseconds.` })

export const accountInfoDocs = describeRoute({
tags: ['Accounts'],
summary: 'Read your account',
description: `What the accounts database holds for the signing key’s own account, for the app’s developer tools: its id, status, creation time, linked inboxes, and status changes (without their reasons). Self-signed by the account key under the \`ww-account/v1\` context, so only the key’s holder can read it. Read-only: it never creates an account.

Limits: the Buddies read tier (600 a minute per caller IP). Answers \`disabled\` on a worker without the accounts database.`,
requestBody: keySignedBody('account/info'),
responses: {
200: jsonResponse(
'The account for this key.',
z.object({
ok: z.literal(true),
accountId,
accountPub: b64u('The account signing key’s public key'),
status,
createdAt: epochMs('When the account was registered'),
inboxIds: z.array(buddiesId('An inbox linked to the account')),
statusChanges: z.array(
z.object({ status, createdAt: epochMs('When it changed') })
),
})
),
...signatureErrors,
404: errorResponse('No account has this key; register first.', [
'unknown_account',
]),
429: errorResponse('Over the per-caller rate.', ['rate_limited']),
500: errorResponse('Unexpected failure.', ['internal']),
503: errorResponse('Accounts are off on this worker.', ['disabled']),
},
})

const adminErrors = {
400: errorResponse('Invalid JSON or field.', ['bad_request']),
404: errorResponse(
Expand Down
91 changes: 74 additions & 17 deletions src/accounts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@ import {
EDGE_RETRY_AFTER_SECONDS,
logLimitHit,
} from '../buddies/limits'
import { accountSignedMessage, parseAccountRegister } from './contracts'
import { accountsDb, registerAccount } from './store'
import { accountRegisterDocs } from './openapi'
import {
type AccountKeyPayload,
type AccountsOp,
accountSignedMessage,
parseAccountKeyPayload,
} from './contracts'
import { accountsDb, findAccountInfo, registerAccount } from './store'
import { accountInfoDocs, accountRegisterDocs } from './openapi'

const randomAccountId = (): string =>
bytesToBase64Url(crypto.getRandomValues(new Uint8Array(16)))
Expand Down Expand Up @@ -58,6 +63,33 @@ const refuseOverQuota = async (
}
}

/**
* 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
Expand All @@ -75,20 +107,9 @@ const handleRegister = async (c: AppContext): Promise<Response> => {
retryAfterSeconds: EDGE_RETRY_AFTER_SECONDS,
})

const text = await readEnvelopeText(c.req.raw)
const envelope = text == null ? null : parseEnvelope(text)
const payload = envelope ? parseAccountRegister(envelope.payload) : null
if (!envelope || !payload) return buddiesErrorResponse('bad_request')
const signature = decodeSignature(envelope.signature)
if (!signature) return buddiesErrorResponse('bad_signature')
if (Math.abs(Date.now() - payload.ts) > BUDDIES_LIMITS.timestampSkewMs)
return buddiesErrorResponse('stale')
const valid = await verifyEd25519(
payload.accountPub,
accountSignedMessage('account/register', envelope.payloadBytes),
signature
)
if (!valid) return buddiesErrorResponse('bad_signature')
const verified = await verifiedKeyPayload(c, 'account/register')
if ('refusal' in verified) return verified.refusal
const { payload } = verified

const refused = await refuseOverQuota(c.env, callerKey)
if (refused) return refused
Expand All @@ -106,9 +127,45 @@ const handleRegister = async (c: AppContext): Promise<Response> => {
}
}

/**
* `POST /accounts/v1/account/info`: what the database holds for the signing
* key's own account, for the app's developer tools. Read-only and self-signed,
* so only the account key's holder can read it; it never creates an account
* (an unknown key is 404 `unknown_account`). Counts against the read edge tier.
*
* Privacy: nothing here logs or reports the body, the key, or the account id.
*/
const handleInfo = async (c: AppContext): Promise<Response> => {
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 verified = await verifiedKeyPayload(c, 'account/info')
if ('refusal' in verified) return verified.refusal
const { payload } = verified

const info = await findAccountInfo(
accountsDb(c.env.ACCOUNTS_DB),
payload.accountPub
)
if (!info) return buddiesErrorResponse('unknown_account')
const { id, ...rest } = info
return c.json({ ok: true, accountId: id, ...rest })
} catch (error) {
console.error('accounts: request failed', { op: 'account/info' })
Sentry.captureException(error)
return buddiesInternalError()
}
}

/** Mounted at `/accounts/v1` by the Worker entry point. */
export const createAccountsRoutes = () => {
const routes = new Hono<{ Bindings: Environment }>()
routes.post('/account/register', accountRegisterDocs, handleRegister)
routes.post('/account/info', accountInfoDocs, handleInfo)
return routes
}
Loading
Loading