diff --git a/docs/ACCOUNT_RECOVERY.md b/docs/ACCOUNT_RECOVERY.md new file mode 100644 index 00000000..1b89dc81 --- /dev/null +++ b/docs/ACCOUNT_RECOVERY.md @@ -0,0 +1,232 @@ +# Guardian-Based Social Recovery + +Recovers **account access** for a primary account owner who cannot sign in — their +sessions and ability to authenticate. It does **not** recover the custodial +wallet's key material; see [Scope and limits](#scope-and-limits). + +The flow is a time-delayed social recovery: the owner nominates trusted contacts +while they still have access, and later a claimant reopens the account only if a +quorum of those contacts independently approves and a mandatory delay elapses +uncontested. + +Implemented under #535. Service: `src/guardians/service.ts`. Routes: +`src/routes/recovery.ts`. Execution: `src/jobs/guardianRecoverySweep.ts`. + +## Lifecycle + +``` +owner (signed in) guardian claimant platform job +────────────────── ───────── ───────── ─────────── +PUT /recovery/policy +POST /recovery/guardians ──────► invite (PENDING) + POST /recovery/invitations/respond ──► ACCEPTED + +POST /recovery/initiate ──────────────────────────► opens PENDING request + alerts owner + every + ACCEPTED guardian + guardian POST /recovery/guardian/... + approval + ┌────────┴────────┐ + quorum decline / close + │ + owner POST /recovery/requests/{id}/cancel + │ + delay elapses (executeAfter) + │ + ▼ + sweep: revokes every live session, + COMPLETED, owner notified +``` + +`executeAfter` is stamped **once**, on the request row, by whichever approval +wins the race to quorum. It is derived from the policy at that instant and never +recomputed, so editing the policy mid-flight cannot pull the deadline forward. + +## Controls + +| Control | Value | Where it is enforced | +| ---------------------- | ---------------------------------------- | ----------------------------------------------------------- | +| Quorum | `>= 2` | `MIN_REQUIRED_APPROVALS`; DB `CHECK`; policy re-read on every access | +| Delay | `24–168h`, default `48h` | `MIN/MAX_RECOVERY_DELAY_HOURS`; DB `CHECK`; re-checked in `executeRecovery` | +| Guardian cap | `<= 20`, default `5` | `MAX_GUARDIAN_CAP`; service count check | +| One live request | 1 per account | Partial unique index `recovery_requests_userId_live_key` | +| Request expiry | 30 days | `REQUEST_EXPIRY_DAYS`; swept by the job | +| Invite TTL | 168h | `GUARDIAN_INVITE_TTL_HOURS` | +| Public rate limit | 10 / 15 min | `recoveryRateLimiter` on the three public endpoints | + +### Why the one-live-request index is the load-bearing part + +`initiateRecovery` performs **no** read-then-write check for an existing request. +That is deliberate. A pre-insert read still races: two concurrent initiations for +the same wallet both read "none" and both insert. `POST /recovery/initiate` is +public and unauthenticated, so the reachability of that race is not a matter of +luck. The partial unique index is the actual guarantee; a `P2002` from it is +translated into the same generic response as every other non-opening path. + +It is **partial** rather than a plain unique constraint on `userId` because a +completed, cancelled, or expired request must never block a future attempt — the +feature exists precisely for accounts that are locked out. + +### Anti-enumeration + +`POST /recovery/initiate` is unauthenticated: the caller is by definition someone +who cannot sign in. It returns an identical `202` for every valid body, whether or +not the account exists, is a sub-account, has no guardians, already has a live +request, or the insert hit the unique index. Only a structurally invalid body +returns `400`. A response that varied by target would be an oracle for "which +wallets have a recovery setup", and wallets are public on-chain. + +The claimant-supplied `reason` is attacker-controlled free text. `sanitizeReason` +strips control characters and truncates to 500 characters. The guardian alert +HTML-escapes it before interpolation; the owner alert omits it from the HTML part +entirely. Neither value is ever placed unescaped into an HTML body. + +## Endpoints + +Owner endpoints require a session. Platform guardians are authenticated **and** +checked for nomination — `requireAuth` alone is not sufficient, since any account +can hold a token. + +| Method | Path | Auth | +| ------ | ------------------------------------------- | ----------------- | +| GET | `/recovery/policy` | owner | +| PUT | `/recovery/policy` | owner | +| GET | `/recovery/guardians` | owner | +| POST | `/recovery/guardians` | owner | +| DELETE | `/recovery/guardians/{guardianId}` | owner | +| POST | `/recovery/guardians/{guardianId}/accept` | platform guardian | +| POST | `/recovery/invitations/respond` | external token | +| POST | `/recovery/initiate` | **none** | +| GET | `/recovery/requests/{requestId}` | owner | +| POST | `/recovery/requests/{requestId}/cancel` | owner | +| GET | `/recovery/guardian/requests` | platform guardian | +| POST | `/recovery/guardian/requests/{id}/decide` | platform guardian | +| POST | `/recovery/guardian/decide` | external token | + +### There is no execute endpoint + +Execution is not reachable over HTTP, by design. A route would be callable only by +an authenticated user of the account being recovered, and the instant it succeeded +every one of their sessions would be revoked — so the caller would lock themselves +out mid-request and nobody could ever invoke it. Recovery is platform-side work +driven by time, not by a caller. `src/jobs/guardianRecoverySweep.ts` is the only +caller of `executeRecovery`. + +## Guardian identity and tokens + +Guardians are either `platform` (another verified user, decided from their +session), or external contacts reached by email or phone. Nomination must supply +exactly one identifier; mixing a platform user with an external contact is +rejected. + +Invite and decision tokens are returned to the sender **once**, stored only as +SHA-256 digests, and never intentionally logged. A fresh decision token is minted +per request rather than keeping one long-lived token serving both roles, which +widens the exposure window if it leaks from an inbox. Each token is bound to its +request: an old token cannot vote on a new request. + +Digest lookup means possessing the token is the proof; no constant-time comparison +is needed on top of it. What matters is that an expired or already-answered token +cannot be reused. + +## Alerting + +The owner alert is unconditional and emitted **before** any guardian notification +is attempted. If a claimant has compromised every guardian, the owner's alert is +the only thing left standing between that and a takeover, so a failure in one +channel must not suppress the others. + +| Audience | Channels | +| -------- | ----------------------------------------------------- | +| Owner | socket, registered email, registered WhatsApp | +| Guardian | socket (platform) or email/WhatsApp (external) | + +There is no raw SMS sender in this codebase, so the phone channel is WhatsApp. A +guardian with no reachable channel on file is alerted over whatever channels exist; +the platform does not collect new contact data during a recovery. + +Notification failures are logged and swallowed. A mail outage must not roll back a +security action that has already been recorded, and callers should not need a +try/catch around every alert to get that guarantee. + +Socket events (`src/events/types.ts`, socket-only — a recovery alert must never be +suppressible by a misconfigured or unsubscribed webhook): + +- `security.recovery_initiated` +- `security.recovery_quorum_reached` +- `security.recovery_cancelled` +- `security.recovery_completed` +- `security.guardian_approval_requested` + +A guardian sees only their own decision, never who else was asked or what anyone +else said. The owner sees all decisions on their request. + +## Execution + +The sweep runs two passes per tick: expire requests that aged out before quorum, +then execute requests whose `executeAfter` has passed, capped by +`RECOVERY_SWEEP_BATCH_SIZE`. + +- `executeAfter` is re-checked per row, so a bug in the sweep's query cannot + shorten the mandatory window. +- The transition to `COMPLETED` is a conditional update, so a concurrent + cancellation cannot be undone and two concurrent sweeps cannot both execute. +- Revocation continues after an individual session fails; a partially revoked + account is worse than none. One bad request does not strand the rest of the + batch — it surfaces again on the next tick. +- Cancellation is unilateral and needs no guardian consensus, and it holds until + execution wins the conditional-update race. + +On success every live session for the account is revoked with reason +`account_recovery`. No password is reset and no wallet key is touched — the owner +recovers by signing in again. + +## Configuration + +| Variable | Default | Meaning | +| ----------------------------- | --------- | ------------------------------------ | +| `RECOVERY_SWEEP_INTERVAL_MS` | `60000` | Tick interval | +| `RECOVERY_SWEEP_BATCH_SIZE` | `50` | Requests executed per tick | +| `RECOVERY_RATE_LIMIT_WINDOW_MS` | `900000` | Public-endpoint window | +| `RECOVERY_RATE_LIMIT_MAX` | `10` | Requests per window per caller | + +The sweep timer is cleared on shutdown (`src/index.ts`). + +## Scope and limits + +Stated plainly, because a recovery feature's failures are invisible until someone +is locked out: + +- **Account access only.** This restores the ability to authenticate. It does not + recover wallet key material, and it does not undo on-chain transactions. +- **Primary accounts only (v1).** A sub-account's access is delegated by a parent + account that already exists and already has its own recovery path; routing + recovery through guardians as well would multiply the ways a child account could + be taken over. Sub-account requests are refused. +- **No recovery when fewer guardians have accepted than the quorum requires.** If + nobody can be reached, there is no independent second party, so opening a + request would only create a row that can never complete. This is the one + hard-stop failure mode: an owner whose guardians have all dropped off must use + another path. +- **Not instantaneous.** The minimum 24h delay is the window in which a real owner + notices and cancels. Recovery cannot be rushed, by anyone. +- **Trust is bootstrapped, not verified.** Anyone an owner nominates can eventually + help hand over the account. Nomination happens while the owner has access, so it + is a decision made in a state of clarity. +- **Guardian contact details are masked** in owner-facing responses. Enough to + recognise your own list, not enough to harvest it — a recovery feature is an + excellent way to enumerate a target's associates. +- **Audit trail is destroyed on rollback.** Dropping the four tables removes all + recovery history. Export `recovery_approvals` first if it matters. + +## Invariants Prisma cannot express + +`prisma/schema.prisma` cannot express CHECK constraints or partial indexes, so +both live only in `prisma/migrations/20260930140000_add_guardian_recovery/migration.sql`. +That makes them invisible to `prisma validate`, to the generated client, and to any +review of the schema alone — precisely the properties that decide whether a +locked-out owner gets their account back safely. + +`tests/unit/guardians/structural.test.ts` reads the migration SQL and asserts they +are still present. It is the last line that fails when someone regenerates the +migration from the schema and silently drops what Prisma does not model. \ No newline at end of file diff --git a/docs/DOCUMENTATION_INDEX.md b/docs/DOCUMENTATION_INDEX.md index 15ecd736..0ece82d3 100644 --- a/docs/DOCUMENTATION_INDEX.md +++ b/docs/DOCUMENTATION_INDEX.md @@ -21,6 +21,7 @@ - **[PRISMA_QUERY_PERFORMANCE.md](PRISMA_QUERY_PERFORMANCE.md)** - Hot webhook query plans, supporting indexes, and the query-index regression check - **[NET_WORTH.md](NET_WORTH.md)** - Read-only external Stellar links, valuation/staleness semantics, and private scope - **[CONSENT_AND_PRIVACY_POLICY.md](CONSENT_AND_PRIVACY_POLICY.md)** - User consent boundaries, high-risk workflow checks & auditable bypass policy (#510) +- **[ACCOUNT_RECOVERY.md](ACCOUNT_RECOVERY.md)** - Guardian-based social recovery (#535): quorum & delay bounds, one-live-request partial unique index, anti-enumeration on the public initiate endpoint, token digests, alerting, job-only execution, scope limits ### For DevOps/Deployment diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 145748b3..9d54d2e2 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -50,6 +50,23 @@ tags: description: User-scoped outbound webhook endpoints (#368) - name: Net Worth description: Owner-private net worth and read-only external Stellar wallet links (#540) + - name: Account Recovery + description: | + Guardian-based social recovery (#535). Recovery of a user's own ACCOUNT + ACCESS (their sessions and ability to authenticate), not of custodial + wallet key material. + + Three structural controls, each enforced in more than one layer: a quorum + of two or more guardians that is never 1-of-N; a mandatory waiting period + stamped ONCE at quorum onto the request row, so editing the policy + mid-flight cannot pull a live deadline forward; and loud multi-channel + alerting to the account owner, who can cancel unilaterally at any point + before execution. Execution is platform-side only (a scheduled sweep) and + has no HTTP endpoint by design. + + `POST /initiate` is deliberately UNAUTHENTICATED and returns an identical + 202 response in every case, so it cannot be used to discover whether a + wallet exists. See docs/ACCOUNT_RECOVERY.md for the threat model. # ─── Reusable components ────────────────────────────────────────────────────── components: @@ -1457,6 +1474,153 @@ components: type: string format: date-time + + # ─── #535 Guardian social recovery ───────────────────────────────────────── + + RecoveryPolicy: + type: object + description: | + The account's standing recovery configuration. Created with safe defaults + on first read. + properties: + requiredApprovals: + type: integer + minimum: 2 + description: Guardian approvals needed to reach quorum. Never 1. + recoveryDelayHours: + type: integer + minimum: 24 + maximum: 168 + description: | + Mandatory gap between quorum and effect. Stamped onto the request at + quorum, so a later policy edit cannot shorten a live deadline. + maxGuardians: + type: integer + description: Cap on active (PENDING or ACCEPTED) guardians. + required: [requiredApprovals, recoveryDelayHours, maxGuardians] + + RecoveryGuardian: + type: object + properties: + id: + type: string + format: uuid + status: + type: string + enum: [PENDING, ACCEPTED, DECLINED, REMOVED] + description: | + Only ACCEPTED guardians can vote. A PENDING nomination contributes + nothing to quorum. + kind: + type: string + enum: [platform, email, phone] + label: + type: string + description: | + Masked identifier (e.g. `a***e@example.com`, `GA***WF`). Enough for + the owner to recognise their own list, not enough to harvest it. + acceptedAt: + type: string + format: date-time + nullable: true + createdAt: + type: string + format: date-time + required: [id, status, kind, label, createdAt] + + NominateGuardianRequest: + type: object + description: | + Identify the guardian either by platform account or by external contact + details. Combining `guardianUserId` with an external field is rejected. + properties: + guardianUserId: + type: string + format: uuid + description: Another verified platform user. + externalEmail: + type: string + format: email + externalPhone: + type: string + description: E.164, digits and an optional leading `+` only. + + RecoveryApproval: + type: object + properties: + guardianId: + type: string + format: uuid + approved: + type: boolean + description: False is a stored refusal, not a no-op. + decidedAt: + type: string + format: date-time + method: + type: string + enum: [session, external_token] + description: How the guardian proved who they were. + + RecoveryRequest: + type: object + properties: + id: + type: string + format: uuid + userId: + type: string + format: uuid + reason: + type: string + description: The claimant's stated reason. Attacker-controlled free text. + status: + type: string + enum: + [ + PENDING, + QUORUM_REACHED, + COMPLETED, + CANCELLED, + EXPIRED, + ] + approvals: + type: array + description: | + For the owner, every decision. For a guardian, only their own — + guardians cannot see who else was asked or what they said. + items: + $ref: '#/components/schemas/RecoveryApproval' + quorumReachedAt: + type: string + format: date-time + nullable: true + description: Stamped exactly once, by whichever approval wins the race. + executeAfter: + type: string + format: date-time + nullable: true + description: | + The frozen deadline. Derived from the policy at the instant quorum was + reached and never recomputed, so a policy edit mid-flight cannot pull + it forward. + executedAt: + type: string + format: date-time + nullable: true + cancelledAt: + type: string + format: date-time + nullable: true + expiresAt: + type: string + format: date-time + createdAt: + type: string + format: date-time + required: [id, status, createdAt] + + responses: Unauthorized: description: Missing or invalid JWT. @@ -3440,6 +3604,506 @@ paths: '404': description: Goal not found. + + # ── #535 Guardian social recovery ──────────────────────────────────────────── + + /recovery/policy: + get: + operationId: getRecoveryPolicy + summary: Read the caller's recovery policy + description: | + Returns the caller's quorum size, mandatory delay and guardian cap. The + policy is created with safe defaults on first read, so this never 404s + for a legitimate account. Quorum below 2 and delays below the floor are + not representable: the service refuses them on every read, not only on + write. + tags: [Account Recovery] + security: + - bearerAuth: [] + responses: + '200': + description: The caller's recovery policy. + content: + application/json: + schema: + type: object + properties: + policy: + $ref: '#/components/schemas/RecoveryPolicy' + '401': + description: Missing or invalid bearer token. + + put: + operationId: updateRecoveryPolicy + summary: Change quorum size or mandatory delay + description: | + Bounds are enforced three times — here, in the service, and by a database + CHECK constraint. `requiredApprovals` is additionally rejected if it + exceeds the number of accepted guardians, so a policy cannot be set to a + quorum the account can never actually reach. + tags: [Account Recovery] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + requiredApprovals: + type: integer + minimum: 2 + description: Guardian approvals needed. Never 1. + recoveryDelayHours: + type: integer + minimum: 24 + maximum: 168 + description: | + Mandatory, non-negotiable gap between quorum and effect. The + issue suggested 48-72h; 24h is the hard floor enforced in code. + responses: + '200': + description: Updated policy. + content: + application/json: + schema: + type: object + properties: + policy: + $ref: '#/components/schemas/RecoveryPolicy' + '400': + description: Quorum below 2, or delay outside the permitted band. + '401': + description: Missing or invalid bearer token. + '409': + description: Quorum exceeds the number of accepted guardians. + + /recovery/guardians: + get: + operationId: listRecoveryGuardians + summary: List the caller's guardians + description: | + Contact details are masked — enough for an owner to recognise their own + list, not enough to harvest it from a shared screen. + tags: [Account Recovery] + security: + - bearerAuth: [] + responses: + '200': + description: The caller's guardian set. + content: + application/json: + schema: + type: object + properties: + guardians: + type: array + items: + $ref: '#/components/schemas/RecoveryGuardian' + count: + type: integer + '401': + description: Missing or invalid bearer token. + + post: + operationId: nominateRecoveryGuardian + summary: Nominate a guardian + description: | + A guardian is identified EITHER by a platform user id OR by external + contact details, never both; mixing them is rejected because one + nomination must resolve to exactly one party. + + The returned `inviteToken` is shown ONCE. Only its SHA-256 digest is + stored. The token is also delivered out of band by email/WhatsApp. A + nominated guardian contributes nothing to quorum until they accept, so + silent enrolment is impossible. + tags: [Account Recovery] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NominateGuardianRequest' + responses: + '201': + description: Guardian nominated; awaiting their acceptance. + content: + application/json: + schema: + type: object + properties: + guardian: + $ref: '#/components/schemas/RecoveryGuardian' + inviteToken: + type: string + description: Raw token, returned exactly once. + inviteExpiresAt: + type: string + format: date-time + '400': + description: No identifier, or a platform id combined with external details. + '401': + description: Missing or invalid bearer token. + '409': + description: The account is already at its guardian cap. + + /recovery/guardians/{guardianId}: + delete: + operationId: removeRecoveryGuardian + summary: Remove a guardian + description: | + Immediate and unilateral. A removed guardian cannot vote on any open + request. + tags: [Account Recovery] + security: + - bearerAuth: [] + parameters: + - in: path + name: guardianId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Guardian removed. + content: + application/json: + schema: + type: object + properties: + guardian: + $ref: '#/components/schemas/RecoveryGuardian' + '401': + description: Missing or invalid bearer token. + '404': + description: Not a guardian of this account. + + /recovery/guardians/{guardianId}/accept: + post: + operationId: acceptRecoveryGuardianInvitation + summary: Accept a nomination (platform guardian) + description: | + For a guardian who has their own platform account. The service re-checks + that the session's user really is the nominated guardian, so a valid + token for one account cannot be used to accept another. + tags: [Account Recovery] + security: + - bearerAuth: [] + parameters: + - in: path + name: guardianId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Nomination accepted; the guardian now counts toward quorum. + content: + application/json: + schema: + type: object + properties: + guardian: + $ref: '#/components/schemas/RecoveryGuardian' + '401': + description: Missing or invalid bearer token. + '404': + description: No pending nomination for this user. + + /recovery/invitations/respond: + post: + operationId: respondToRecoveryGuardianInvitation + summary: Accept or decline a nomination (external contact) + description: | + For a guardian with no platform account, using the token from their + invitation link. Declining is a first-class outcome with no effect on the + account. + tags: [Account Recovery] + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token, accept] + properties: + token: + type: string + accept: + type: boolean + responses: + '200': + description: Invitation answered. + content: + application/json: + schema: + type: object + properties: + guardian: + $ref: '#/components/schemas/RecoveryGuardian' + '404': + description: Unknown or already-answered token. + '410': + description: The invitation token has expired. + '429': + description: Rate limited. + + /recovery/initiate: + post: + operationId: initiateAccountRecovery + summary: Request account recovery (the locked-out door) + description: | + Deliberately UNAUTHENTICATED: the caller is by definition someone who + cannot authenticate. + + **The response is identical in every case** — 202 with a fixed body — + whether or not the wallet exists, is a primary account, has enough + accepted guardians, or already has an open request. Any variation would + turn this into a wallet-existence and sub-account oracle, which is worth + more to an attacker than the recovery itself. A genuine fault is logged + but still answered with the same 202, because an error rate that differs + by wallet is the same leak by another name. + + The owner learns the outcome from the loud alert instead: realtime socket + event, registered email and registered phone. + tags: [Account Recovery] + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [walletAddress, reason] + properties: + walletAddress: + type: string + description: The account being claimed. Never proof of anything. + reason: + type: string + maxLength: 500 + description: Free text. Sanitised and shown to guardians. + responses: + '202': + description: | + Accepted for processing. This response is intentionally + uninformative. + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: accepted + message: + type: string + '400': + description: Malformed body. Concerns the caller's input, not the wallet. + '429': + description: Rate limited. + + /recovery/requests/{requestId}: + get: + operationId: getRecoveryRequest + summary: Read one of the caller's own recovery requests + description: | + Owner-only. The service verifies the request belongs to the caller and + returns 404 rather than 403 for somebody else's, so request ids are not + an enumeration surface. + tags: [Account Recovery] + security: + - bearerAuth: [] + parameters: + - in: path + name: requestId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: The request, with the owner's full approval list. + content: + application/json: + schema: + type: object + properties: + request: + $ref: '#/components/schemas/RecoveryRequest' + '401': + description: Missing or invalid bearer token. + '404': + description: Not found, or not the caller's request. + + /recovery/requests/{requestId}/cancel: + post: + operationId: cancelAccountRecovery + summary: Cancel a recovery request + description: | + Owner-only, immediate, and unilateral: no quorum, no guardian consent, + no waiting period. Permitted at any point before execution wins the race, + including after quorum has been reached. The conditional update is the + whole safety argument — a cancellation racing an execution cannot + resurrect a dead request, and whichever write lands first is the one that + counts. + tags: [Account Recovery] + security: + - bearerAuth: [] + parameters: + - in: path + name: requestId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Cancelled. + content: + application/json: + schema: + type: object + properties: + request: + $ref: '#/components/schemas/RecoveryRequest' + '401': + description: Missing or invalid bearer token. + '404': + description: Not found, or not the caller's request. + '409': + description: Already cancelled, expired, or executed. + + /recovery/guardian/requests: + get: + operationId: listGuardianRecoveryRequests + summary: Requests this platform user must decide on + description: | + Only requests where this user is an ACCEPTED guardian. Approval lists are + narrowed to the caller's own decision: a guardian can see that a request + exists and how far along it is, but not who else was asked or what they + said. + tags: [Account Recovery] + security: + - bearerAuth: [] + responses: + '200': + description: Pending requests awaiting this guardian. + content: + application/json: + schema: + type: object + properties: + requests: + type: array + items: + $ref: '#/components/schemas/RecoveryRequest' + count: + type: integer + '401': + description: Missing or invalid bearer token. + + /recovery/guardian/requests/{requestId}/decide: + post: + operationId: decideRecoveryAsPlatformGuardian + summary: Approve or decline as a platform guardian + description: | + One decision, recorded against the named guardian. `approved: false` is + a stored refusal, not a no-op: it keeps the decision history honest and + stops the guardian being re-asked. A decision is final. + tags: [Account Recovery] + security: + - bearerAuth: [] + parameters: + - in: path + name: requestId + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [approved] + properties: + approved: + type: boolean + note: + type: string + maxLength: 500 + responses: + '200': + description: Decision recorded. + content: + application/json: + schema: + type: object + properties: + request: + $ref: '#/components/schemas/RecoveryRequest' + '401': + description: Missing or invalid bearer token. + '404': + description: Not an accepted guardian for this request. + '409': + description: Already decided, not accepted, or the request has closed. + + /recovery/guardian/decide: + post: + operationId: decideRecoveryAsExternalGuardian + summary: Approve or decline as an external guardian + description: | + For a guardian with no platform session, using the token in their + decision link. The token identifies WHICH guardian is deciding, so one + guardian can never decide on another's behalf, and the one-decision-per- + (request, guardian) row stops a replayed link producing a second vote. + tags: [Account Recovery] + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [requestId, token, approved] + properties: + requestId: + type: string + format: uuid + token: + type: string + approved: + type: boolean + note: + type: string + maxLength: 500 + responses: + '200': + description: Decision recorded. + content: + application/json: + schema: + type: object + properties: + request: + $ref: '#/components/schemas/RecoveryRequest' + '404': + description: Unknown token, or not a guardian for this request. + '409': + description: Already decided, or the request has closed. + '429': + description: Rate limited. + + # ── Health ──────────────────────────────────────────────────────────────────── /health/live: diff --git a/prisma/migrations/20260930140000_add_guardian_recovery/migration.sql b/prisma/migrations/20260930140000_add_guardian_recovery/migration.sql new file mode 100644 index 00000000..c3e49ab0 --- /dev/null +++ b/prisma/migrations/20260930140000_add_guardian_recovery/migration.sql @@ -0,0 +1,162 @@ +-- #535 Guardian-Based Social Recovery +-- +-- Guardian nomination, the per-account recovery policy, the recovery request +-- itself, and the individual guardian approvals. See docs/ACCOUNT_RECOVERY.md +-- for the threat model these tables exist to support. +-- +-- Two invariants are enforced HERE rather than only in the service layer, +-- because a database constraint cannot be forgotten by a later edit: +-- +-- * required_approvals >= 2 -- a 1-of-N quorum is a single compromised +-- guardian away from a takeover and would make every other control here +-- decorative. +-- * recovery_delay_hours >= 24 -- the cooling-off window is the structural +-- defence against a colluding minority, so it cannot be configured down to +-- nothing. + +-- CreateEnum +CREATE TYPE "RecoveryGuardianStatus" AS ENUM ('PENDING', 'ACCEPTED', 'DECLINED', 'REMOVED'); + +-- CreateEnum +CREATE TYPE "RecoveryRequestStatus" AS ENUM ('PENDING', 'QUORUM_REACHED', 'REJECTED', 'CANCELLED', 'COMPLETED', 'EXPIRED'); + +-- CreateTable +CREATE TABLE "recovery_guardians" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "guardianUserId" TEXT, + "externalEmail" TEXT, + "externalPhone" TEXT, + "status" "RecoveryGuardianStatus" NOT NULL DEFAULT 'PENDING', + "inviteTokenHash" TEXT NOT NULL, + "inviteExpiresAt" TIMESTAMP(3) NOT NULL, + "confirmedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "recovery_guardians_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "recovery_policies" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "requiredApprovals" INTEGER NOT NULL DEFAULT 2, + "recoveryDelayHours" INTEGER NOT NULL DEFAULT 48, + "maxGuardians" INTEGER NOT NULL DEFAULT 5, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "recovery_policies_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "recovery_requests" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "status" "RecoveryRequestStatus" NOT NULL DEFAULT 'PENDING', + "reason" TEXT NOT NULL, + "quorumReachedAt" TIMESTAMP(3), + "executeAfter" TIMESTAMP(3), + "executedAt" TIMESTAMP(3), + "cancelledAt" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3), + "requiredApprovals" INTEGER, + "recoveryDelayHours" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "recovery_requests_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "recovery_approvals" ( + "id" TEXT NOT NULL, + "requestId" TEXT NOT NULL, + "guardianId" TEXT NOT NULL, + "approved" BOOLEAN NOT NULL, + "method" TEXT NOT NULL, + "decidedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "note" TEXT, + "ipAddress" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "recovery_approvals_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "recovery_guardians_inviteTokenHash_key" ON "recovery_guardians"("inviteTokenHash"); + +-- CreateIndex +CREATE INDEX "recovery_guardians_userId_status_idx" ON "recovery_guardians"("userId", "status"); + +-- CreateIndex +CREATE INDEX "recovery_guardians_guardianUserId_status_idx" ON "recovery_guardians"("guardianUserId", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "recovery_policies_userId_key" ON "recovery_policies"("userId"); + +-- CreateIndex +CREATE INDEX "recovery_policies_userId_idx" ON "recovery_policies"("userId"); + +-- CreateIndex +CREATE INDEX "recovery_requests_userId_status_idx" ON "recovery_requests"("userId", "status"); + +-- CreateIndex +CREATE INDEX "recovery_requests_status_executeAfter_idx" ON "recovery_requests"("status", "executeAfter"); + +-- One live request per account, enforced by the database. +-- +-- The service also checks for an existing live request before creating one, but +-- that read-then-write is racy: two concurrent initiations for the same account +-- would both observe "none" and both insert. Without this index the safety of the +-- flow rests entirely on callers never racing, which a public unauthenticated +-- endpoint cannot guarantee. The index is the actual guarantee. +-- +-- Partial rather than a plain unique constraint on userId because a completed, +-- cancelled or expired request must not block the owner from ever recovering +-- again -- and this feature exists precisely for locked-out accounts. +CREATE UNIQUE INDEX "recovery_requests_userId_live_key" + ON "recovery_requests"("userId") + WHERE "status" IN ('PENDING', 'QUORUM_REACHED'); + +-- CreateIndex +CREATE UNIQUE INDEX "recovery_approvals_requestId_guardianId_key" ON "recovery_approvals"("requestId", "guardianId"); + +-- CreateIndex +CREATE INDEX "recovery_approvals_guardianId_decidedAt_idx" ON "recovery_approvals"("guardianId", "decidedAt"); + +-- AddForeignKey +ALTER TABLE "recovery_guardians" ADD CONSTRAINT "recovery_guardians_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "recovery_guardians" ADD CONSTRAINT "recovery_guardians_guardianUserId_fkey" FOREIGN KEY ("guardianUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "recovery_policies" ADD CONSTRAINT "recovery_policies_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "recovery_requests" ADD CONSTRAINT "recovery_requests_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "recovery_approvals" ADD CONSTRAINT "recovery_approvals_requestId_fkey" FOREIGN KEY ("requestId") REFERENCES "recovery_requests"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "recovery_approvals" ADD CONSTRAINT "recovery_approvals_guardianId_fkey" FOREIGN KEY ("guardianId") REFERENCES "recovery_guardians"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- The two non-negotiable invariants. Prisma cannot express CHECK constraints, +-- so they live only here (same precedent as the partial unique index on +-- strategy_follows). verifyPrismaChecksGuard() in +-- tests/unit/guardians/structural.test.ts asserts they are still present. +ALTER TABLE "recovery_policies" + ADD CONSTRAINT "recovery_policies_required_approvals_check" + CHECK ("requiredApprovals" >= 2); + +ALTER TABLE "recovery_policies" + ADD CONSTRAINT "recovery_policies_delay_hours_check" + CHECK ("recoveryDelayHours" >= 24); + +ALTER TABLE "recovery_policies" + ADD CONSTRAINT "recovery_policies_max_guardians_check" + CHECK ("maxGuardians" >= 2 AND "maxGuardians" <= 20); diff --git a/prisma/migrations/20260930140000_add_guardian_recovery/rollback.sql b/prisma/migrations/20260930140000_add_guardian_recovery/rollback.sql new file mode 100644 index 00000000..e73ea594 --- /dev/null +++ b/prisma/migrations/20260930140000_add_guardian_recovery/rollback.sql @@ -0,0 +1,34 @@ +-- Rollback for 20260930140000_add_guardian_recovery (#535). +-- +-- Fully reversible: the feature owns all four tables exclusively, so dropping +-- them restores the pre-migration schema. No pre-existing table is altered, so +-- nothing else can be collateral damage. +-- +-- One consequence to accept before rolling back: any recovery history recorded +-- under #535 is destroyed with these tables. That is the correct behaviour for a +-- rollback of a security feature (an operator running the down migration is +-- explicitly choosing to remove the mechanism), but it is irreversible in the +-- data sense, so export `recovery_approvals` first if the audit trail matters. + +-- Explicit, though redundant: dropping a table takes its indexes with it. Named +-- so the one-live-request invariant is visibly reversed here rather than only +-- implicitly. `recovery_requests_userId_live_key` is the partial unique index +-- that made concurrent initiation impossible. +DROP INDEX IF EXISTS "recovery_requests_userId_live_key"; +DROP INDEX IF EXISTS "recovery_approvals_guardianId_decidedAt_idx"; +DROP INDEX IF EXISTS "recovery_approvals_requestId_guardianId_key"; +DROP INDEX IF EXISTS "recovery_requests_status_executeAfter_idx"; +DROP INDEX IF EXISTS "recovery_requests_userId_status_idx"; +DROP INDEX IF EXISTS "recovery_policies_userId_idx"; +DROP INDEX IF EXISTS "recovery_policies_userId_key"; +DROP INDEX IF EXISTS "recovery_guardians_guardianUserId_status_idx"; +DROP INDEX IF EXISTS "recovery_guardians_userId_status_idx"; +DROP INDEX IF EXISTS "recovery_guardians_inviteTokenHash_key"; + +DROP TABLE IF EXISTS "recovery_approvals"; +DROP TABLE IF EXISTS "recovery_requests"; +DROP TABLE IF EXISTS "recovery_policies"; +DROP TABLE IF EXISTS "recovery_guardians"; + +DROP TYPE IF EXISTS "RecoveryRequestStatus"; +DROP TYPE IF EXISTS "RecoveryGuardianStatus"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 07e72200..c421dfff 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -343,6 +343,15 @@ model User { approvalRequests ApprovalRequest[] @relation("ApprovalRequestPrincipal") complianceCases ComplianceCase[] emailIdentity EmailIdentity? + // #535 -- guardian-based social recovery. `recoveryGuardians` is the set + // this user has nominated (they are the account being protected); + // `guardianships` is the (much rarer) case of having been nominated by + // someone else. Two named relations because both point at User and the + // self-guardian case is rejected at the service layer, not the schema. + recoveryGuardians RecoveryGuardian[] @relation("RecoveryGuardianOwner") + guardianships RecoveryGuardian[] @relation("RecoveryGuardianUser") + recoveryPolicy RecoveryPolicy? + recoveryRequests RecoveryRequest[] webhookEndpoints UserWebhookEndpoint[] notificationPreferences NotificationPreference[] @@ -2464,3 +2473,196 @@ model NotificationPreferenceAuditLog { @@index([changedAt]) @@map("notification_preference_audit_logs") } + +// -- #535 Guardian-Based Social Recovery --------------------------------------- +// +// Recovery of a user's own ACCOUNT ACCESS (their session/auth), not of the +// custodial wallet key -- the platform already holds the latter, so it is a +// different problem and handled by src/keys/registry.ts. See +// docs/ACCOUNT_RECOVERY.md for the full threat model. +// +// The whole design exists to make UNAUTHORIZED recovery hard, so the schema +// encodes the two structural defences that cannot be forgotten by a later +// service-layer edit: +// +// 1. `RecoveryPolicy.requiredApprovals` has a database CHECK of >= 2. A +// 1-of-N policy is a single compromised guardian away from a takeover, and +// a quorum of one would make every other control in this feature +// decorative. Refusing the row at the database is stronger than +// validating it in TypeScript, because a future writer can forget the +// validator but cannot bypass the constraint. +// 2. `RecoveryPolicy.recoveryDelayHours` has a CHECK of >= 24, and +// `RecoveryRequest.executeAfter` is stamped ONCE, at the moment quorum is +// reached, from the policy in force at that instant. It is never +// recomputed, so shortening or deleting the policy afterwards cannot pull +// a live request's deadline forward. + +enum RecoveryGuardianStatus { + /// Invited; the guardian has not explicitly accepted yet. NOT counted toward + /// quorum -- a nomination alone must never be enough to take over an account. + PENDING + /// Explicitly accepted by the guardian. The only status that counts. + ACCEPTED + DECLINED + /// Withdrawn by the account owner, or invalidated by the service. + REMOVED +} + +enum RecoveryRequestStatus { + /// Open, collecting guardian approvals. + PENDING + /// Quorum reached and the mandatory delay is running. Still fully cancellable + /// by the owner -- this is the window the whole design exists to protect. + QUORUM_REACHED + /// Every invited guardian refused, or the request aged out with too few + /// approvals. Terminal. + REJECTED + /// The account owner (or a parent account, for a sub-account) cancelled it. + /// Terminal. + CANCELLED + /// Executed: auth reset and every session revoked. Terminal. + COMPLETED + /// Sat at QUORUM_REACHED until `expiresAt` without the delay elapsing (a + /// policy edit that pushed the deadline out, say). Terminal. + EXPIRED +} + +model RecoveryGuardian { + id String @id @default(uuid()) + + /// The account being protected -- the nominee, not the nominee's guardian. + userId String + user User @relation("RecoveryGuardianOwner", fields: [userId], references: [id], onDelete: Cascade) + + /// Set when the guardian is another verified platform user. Exactly one of + /// `guardianUserId` / `externalEmail` / `externalPhone` identifies the + /// guardian, and the service requires at least one. + guardianUserId String? + guardianUser User? @relation("RecoveryGuardianUser", fields: [guardianUserId], references: [id], onDelete: SetNull) + + /// External (non-platform) contact. Verified out of band via the same + /// accept-invite token as a platform guardian, so an address nobody controls + /// can never be enrolled as a co-signer. + externalEmail String? + externalPhone String? + + status RecoveryGuardianStatus @default(PENDING) + + /// SHA-256 of the raw acceptance token. The raw token is returned exactly + /// once, to the nominator, and is otherwise never stored -- same precedent as + /// EmailIdentity.verifyTokenHash. + inviteTokenHash String @unique + inviteExpiresAt DateTime + confirmedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + approvals RecoveryApproval[] + + /// The sweeps that find requests needing action, and the lookup that resolves + /// a guardian's "requests awaiting my approval" list. + @@index([userId, status]) + @@index([guardianUserId, status]) + @@map("recovery_guardians") +} + +/// One row per account. Written on first guardian nomination, editable by the +/// owner within the bounds the CHECK constraints impose. +model RecoveryPolicy { + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + /// Guardian approvals needed. Never 1: see the CHECK below. + requiredApprovals Int @default(2) + /// Fixed cooling-off window between quorum and execution. Never shortened + /// below a day; the issue's working range is 48-72h. + recoveryDelayHours Int @default(48) + /// Cap on how many guardians this account may hold. Bounded so the + /// quorum/delay maths stays meaningful and so the setup endpoint cannot be + /// used to enumerate or spam a large contact list. + maxGuardians Int @default(5) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + /// A quorum of one is never acceptable, whatever the caller asks for. + @@index([userId]) + @@map("recovery_policies") +} + +model RecoveryRequest { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + status RecoveryRequestStatus @default(PENDING) + + /// The claimant's stated reason. Audit-relevant free text; never rendered as + /// HTML and never included in a stream payload. + reason String + + /// Snapshotted from the policy at the instant quorum was reached, so a later + /// policy edit cannot retroactively change the terms an in-flight request was + /// approved under. Stamped ONCE and never recomputed. + quorumReachedAt DateTime? + executeAfter DateTime? + executedAt DateTime? + cancelledAt DateTime? + expiresAt DateTime? + + /// Number of approvals needed for this request, snapshotted alongside + /// quorumReachedAt for the same reason. + requiredApprovals Int? + /// Recovery delay that applied, in hours, snapshotted at quorum. + recoveryDelayHours Int? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + approvals RecoveryApproval[] + + /// At most one live request per account. Enforced here rather than in the + /// service so two concurrent initiations cannot both open one. + @@index([userId, status]) + /// The sweeper's query: open requests whose deadline has passed. + @@index([status, executeAfter]) + @@map("recovery_requests") +} + +/// One row per (request, guardian). `@@unique` is what makes a second approval +/// from the same guardian impossible, so approval cannot be counted twice even +/// under a concurrent retry. +model RecoveryApproval { + id String @id @default(uuid()) + requestId String + request RecoveryRequest @relation(fields: [requestId], references: [id], onDelete: Cascade) + + guardianId String + guardian RecoveryGuardian @relation(fields: [guardianId], references: [id], onDelete: Cascade) + + /// false is a recorded refusal. Storing refusals (rather than omitting them) + /// keeps a guardian's decision auditable and stops them being re-asked. + approved Boolean + + /// How the guardian proved who they were: an authenticated platform session, + /// or the out-of-band invite token for an external contact. A recovery can + /// never be approved without one of the two. + method String + decidedAt DateTime @default(now()) + /// The guardian's optional note. Audit-relevant free text; never rendered. + note String? + + /// Free-form, append-only. Deliberately has no user foreign key so a + /// retention sweep cannot cascade the audit trail away -- same precedent as + /// UserDataAudit. + ipAddress String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([requestId, guardianId]) + @@index([guardianId, decidedAt]) + @@map("recovery_approvals") +} diff --git a/src/config/env.ts b/src/config/env.ts index 136f08d8..aa4e6f23 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -470,6 +470,16 @@ export const config = { ), max: parseInt(process.env.SENSITIVE_RATE_LIMIT_MAX || '10'), }, + /** + * Guardian recovery (#535). Tighter than the general limiter because the + * public recovery endpoints send real email and WhatsApp to third-party + * guardians, so the abuse being defended against is flooding a bystander + * rather than credential guessing. + */ + recoveryRateLimit: { + windowMs: parseInt(process.env.RECOVERY_RATE_LIMIT_WINDOW_MS || '900000'), + max: parseInt(process.env.RECOVERY_RATE_LIMIT_MAX || '10'), + }, /** * Portfolio optimizer (#322) — the only genuinely CPU-bound endpoint in the * API. Tighter than the global limiter and applied per-endpoint rather than @@ -743,6 +753,21 @@ export const config = { process.env.APPROVAL_EXPIRY_SWEEP_INTERVAL_MS || '60000' ), }, + /** + * Guardian social recovery (#535). The sweep is the ONLY thing that executes a + * recovery, so its cadence is also the granularity of the mandatory delay: + * a request whose `executeAfter` has passed waits at most one interval before + * it takes effect. It is deliberately coarse relative to a 48h minimum delay — + * a minute of extra lateness is immaterial next to two days of warning, and + * polling harder would only add write amplification. + */ + recovery: { + sweepIntervalMs: parseInt( + process.env.RECOVERY_SWEEP_INTERVAL_MS || '60000' + ), + /** Cap on requests executed per tick, so one sweep cannot run unbounded. */ + sweepBatchSize: parseInt(process.env.RECOVERY_SWEEP_BATCH_SIZE || '50'), + }, /** * Borrow-against-collateral credit line (#532) — the platform's lending book. * diff --git a/src/events/types.ts b/src/events/types.ts index f364e75b..9de91bf7 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -54,6 +54,24 @@ export const SOCKET_ONLY_EVENT_TYPES = [ 'security.session_anomaly', /** #548 — admin impersonation session started. */ 'account.impersonation_started', + // #535 — guardian-based social recovery. Socket-only by design: a recovery is + // an attempt to hand over an account, so it must never be forwarded to an + // operator-configured webhook endpoint that the attacker does not control but + // the account owner might. The owner's own stream is the alert channel. + /** A recovery request was opened against the account. Emit BEFORE any guardian + * is contacted, and again on the owner's stream, so the loud-alert + * requirement in docs/ACCOUNT_RECOVERY.md holds even if every guardian is + * compromised. */ + 'security.recovery_initiated', + /** Quorum reached; the mandatory delay is now running and the request is + * cancellable until `executeAfter`. */ + 'security.recovery_quorum_reached', + /** The account owner (or a parent account) cancelled it. */ + 'security.recovery_cancelled', + /** Executed: auth reset and every session revoked. */ + 'security.recovery_completed', + /** #535 — this user is a nominated guardian and a decision is wanted. */ + 'security.guardian_approval_requested', ] as const export type SocketOnlyEventType = (typeof SOCKET_ONLY_EVENT_TYPES)[number] @@ -97,6 +115,16 @@ export const EVENT_TYPE_TOPIC: Record = { 'security.api_key_changed': 'alerts', 'security.new_session': 'alerts', 'security.session_revoked': 'alerts', + // #535 — recovery events are the one alert a user must never be able to + // filter out, so they ride 'alerts' rather than a quieter topic. `account` + // would arguably fit ("something happened to my account"), but a client that + // subscribes to alerts-without-account and silences the rest would miss the + // single notification that matters most. + 'security.recovery_initiated': 'alerts', + 'security.recovery_quorum_reached': 'alerts', + 'security.recovery_cancelled': 'alerts', + 'security.recovery_completed': 'alerts', + 'security.guardian_approval_requested': 'alerts', 'security.session_anomaly': 'alerts', 'account.impersonation_started': 'account', } diff --git a/src/guardians/notifications.ts b/src/guardians/notifications.ts new file mode 100644 index 00000000..73292bd8 --- /dev/null +++ b/src/guardians/notifications.ts @@ -0,0 +1,459 @@ +/** + * Out-of-band alerting for guardian recovery (#535). + * + * Everything here is deliberately LOUD and deliberately NON-BLOCKING: + * + * - Loud, because the whole security argument of this feature rests on the + * account owner learning that a recovery is in flight. A claimant who has + * social-engineered every guardian still cannot stop this alert, and the + * owner can cancel unilaterally at any point before execution. If these + * sends fail silently, the feature's safety property quietly evaporates, so + * every failure is logged loudly even though it is swallowed. + * + * - Non-blocking, because a mail provider outage must not be able to abort a + * security action. `initiateRecovery` has already opened the request by the + * time we are called; throwing here would leave a live recovery that the + * owner was never told about, which is strictly worse than a recovery with a + * degraded alert. So nothing in this module throws. + * + * Delivery channels, in the order they are attempted: + * 1. In-app realtime event (`publishUserEvent`) — reaches the owner only if + * they have a live session, and is the only channel the attacker cannot + * intercept. + * 2. Registered email, if the account has one on file. + * 3. Registered phone over WhatsApp (this repo's only outbound phone + * transport). Skipped, not errored, when no number is on file. + * + * Tokens are never logged. Contact details are masked in log lines. + */ +import db from '../db' +import { logger } from '../utils/logger' +import { mailRegistry } from '../mail/mailProvider' +import { sendWhatsAppMessage } from '../utils/twilio-client' +import { publishUserEvent } from '../events/publisher' +import { + renderGuardianApprovalRequest, + renderGuardianInvite, + renderRecoveryCancelledNotice, + renderRecoveryCompletedAlert, + renderRecoveryInitiatedAlert, + renderRecoveryQuorumReachedAlert, +} from '../mail/templates' + +type Db = typeof db + +function appUrl(): string { + return process.env.APP_URL || 'https://neurowealth.app' +} + +/** + * Mask for log lines and non-essential text. Deliberately coarse: enough to + * correlate two log lines, not enough to be useful if a log store leaks. + */ +function mask(value: string): string { + if (value.length <= 4) return '*'.repeat(value.length) + const head = value.slice(0, 2) + const tail = value.slice(-2) + return `${head}${'*'.repeat(Math.max(value.length - 4, 1))}${tail}` +} + +/** Email, best-effort. Never throws. */ +async function trySendEmail( + to: string | null | undefined, + message: ReturnType, + context: Record +): Promise { + if (!to) return false + try { + await mailRegistry.send({ ...message, to }) + logger.info('[Guardians] Recovery alert emailed', { + ...context, + to: mask(to), + }) + return true + } catch (err) { + logger.error('[Guardians] FAILED to email a recovery alert', { + ...context, + to: mask(to), + error: err instanceof Error ? err.message : String(err), + }) + return false + } +} + +/** WhatsApp, best-effort. Never throws. */ +async function trySendWhatsApp( + to: string | null | undefined, + body: string, + context: Record +): Promise { + if (!to) return false + try { + await sendWhatsAppMessage({ to, body }) + logger.info('[Guardians] Recovery alert sent over WhatsApp', { + ...context, + to: mask(to), + }) + return true + } catch (err) { + logger.error('[Guardians] FAILED to send a recovery alert over WhatsApp', { + ...context, + to: mask(to), + error: err instanceof Error ? err.message : String(err), + }) + return false + } +} + +/** Realtime push, best-effort. Never throws. */ +async function tryPublish( + userId: string, + type: Parameters[2], + payload: Record, + context: Record +): Promise { + try { + await publishUserEvent(userId, 'alerts', type, payload) + return true + } catch (err) { + logger.error('[Guardians] FAILED to emit a recovery socket event', { + ...context, + type, + error: err instanceof Error ? err.message : String(err), + }) + return false + } +} + +/** + * Resolve the owner's own contact details. Read fresh each time rather than + * passed in, so a caller cannot accidentally alert a guardian's address. + */ +async function loadOwnerContact( + userId: string, + database: Db +): Promise<{ email: string | null; phone: string | null }> { + try { + const user = await database.user.findUnique({ + where: { id: userId }, + select: { email: true, phone: true }, + }) + return { email: user?.email ?? null, phone: user?.phone ?? null } + } catch (err) { + logger.error( + '[Guardians] FAILED to load owner contact for a recovery alert', + { + userId, + error: err instanceof Error ? err.message : String(err), + } + ) + return { email: null, phone: null } + } +} + +// ─── Owner-facing alerts ───────────────────────────────────────────────────── + +/** + * A recovery was opened against this account. Sent to the owner on all three + * channels, unconditionally, whether or not the caller was the owner. + */ +export async function notifyRecoveryInitiated( + input: { + requestId: string + userId: string + reason: string + requiredApprovals: number + acceptedGuardians: number + }, + database: Db = db +): Promise { + const { requestId, userId, reason, requiredApprovals, acceptedGuardians } = + input + const context = { requestId, userId } + const contact = await loadOwnerContact(userId, database) + + // Realtime first: it is the channel an attacker cannot intercept, so if + // anything else fails this is the one that still reached the owner. + await tryPublish( + userId, + 'security.recovery_initiated', + { + requestId, + reason, + requiredApprovals, + acceptedGuardians, + initiatedAt: new Date().toISOString(), + cancelUrl: `${appUrl()}/account/recovery`, + }, + context + ) + + const initiatedAt = new Date().toISOString() + + await trySendEmail( + contact.email, + renderRecoveryInitiatedAlert('', { + reason, + requiredApprovals, + acceptedGuardians, + initiateAt: initiatedAt, + }), + context + ) + + await trySendWhatsApp( + contact.phone, + `SECURITY: a request to recover access to your account was submitted at ${initiatedAt}. ` + + `Reason given: ${reason}. Your ${requiredApprovals} guardian(s) have been contacted. ` + + `If this was not you, cancel it here: ${appUrl()}/account/recovery. ` + + `Cancellation is immediate and needs no guardian agreement. ` + + `We never ask you to share codes or approve anything on someone else's behalf.`, + context + ) +} + +/** + * Quorum was reached and the mandatory delay started. This is the last alert + * before access changes hands, so it says so plainly and repeats that the + * owner can still cancel. + */ +export async function notifyQuorumReached( + input: { + requestId: string + userId: string + requiredApprovals: number + executeAfter: string + }, + database: Db = db +): Promise { + const { requestId, userId, requiredApprovals, executeAfter } = input + const context = { requestId, userId } + const contact = await loadOwnerContact(userId, database) + + await tryPublish( + userId, + 'security.recovery_quorum_reached', + { + requestId, + requiredApprovals, + executeAfter, + cancelUrl: `${appUrl()}/account/recovery`, + }, + context + ) + + await trySendEmail( + contact.email, + renderRecoveryQuorumReachedAlert('', { + requiredApprovals, + executeAfter, + }), + context + ) + + await trySendWhatsApp( + contact.phone, + `SECURITY: your recovery request now has ${requiredApprovals} guardian approval(s) and ` + + `will take effect at ${executeAfter}, when every session on the account is revoked. ` + + `You can still cancel it yourself right now: ${appUrl()}/account/recovery`, + context + ) +} + +/** The recovery executed and every session was revoked. */ +export async function notifyRecoveryCompleted( + input: { + requestId: string + userId: string + revokedSessions: number + executedAt: string + }, + database: Db = db +): Promise { + const { requestId, userId, revokedSessions, executedAt } = input + const context = { requestId, userId } + const contact = await loadOwnerContact(userId, database) + + await tryPublish( + userId, + 'security.recovery_completed', + { requestId, revokedSessions, executedAt }, + context + ) + + await trySendEmail( + contact.email, + renderRecoveryCompletedAlert('', { revokedSessions, executedAt }), + context + ) + + await trySendWhatsApp( + contact.phone, + `SECURITY: the recovery on your account completed at ${executedAt} and ` + + `${revokedSessions} session(s) were revoked. Sign in again at ${appUrl()}/login. ` + + `If you did not authorise this, rotate your guardian set and contact support immediately.`, + context + ) +} + +/** The owner cancelled their own request. */ +export async function notifyRecoveryCancelled( + input: { requestId: string; userId: string; cancelledAt: string }, + database: Db = db +): Promise { + const { requestId, userId, cancelledAt } = input + const context = { requestId, userId } + const contact = await loadOwnerContact(userId, database) + + await tryPublish( + userId, + 'security.recovery_cancelled', + { requestId, cancelledAt }, + context + ) + + await trySendEmail( + contact.email, + renderRecoveryCancelledNotice('', { cancelledAt }), + context + ) +} + +// ─── Guardian-facing alerts ────────────────────────────────────────────────── + +/** + * A newly nominated guardian is being asked to accept the role. Sent out of + * band by the OWNER at nomination time, carrying the accept token. This is the + * step that makes the nomination explicit: nothing is enrolled until the + * guardian acts on this message themselves. + */ +export async function notifyGuardianInvitation(input: { + guardianId: string + userId: string + accountHint: string + inviteToken: string + inviteExpiresAt: string + externalEmail: string | null + externalPhone: string | null +}): Promise { + const { + guardianId, + userId, + accountHint, + inviteToken, + inviteExpiresAt, + externalEmail, + externalPhone, + } = input + + const context = { guardianId, userId } + const acceptUrl = `${appUrl()}/account/guardians/accept?token=${encodeURIComponent(inviteToken)}` + + await trySendEmail( + externalEmail, + renderGuardianInvite('', { + accountHint, + acceptUrl, + expiresAt: inviteExpiresAt, + }), + context + ) + + await trySendWhatsApp( + externalPhone, + `${accountHint} has nominated you as a recovery guardian for their NeuroWealth account. ` + + `Accept or decline here: ${acceptUrl} (expires ${inviteExpiresAt}). ` + + `You are not being asked for any code or money, and accepting gives you no access to the account.`, + context + ) +} + +/** + * A recovery is open and this guardian's decision is needed. External guardians + * get a single-use link carrying their own token; platform guardians are asked + * to sign in, because their identity is already proven by their session and no + * second factor is needed. + */ +export async function notifyGuardiansOfRequest(input: { + requestId: string + userId: string + accountHint: string + reason: string + requiredApprovals: number + expiresAt: string + guardians: Array<{ + id: string + guardianUserId: string | null + externalEmail: string | null + externalPhone: string | null + inviteToken: string | null + }> +}): Promise { + const { + requestId, + userId, + accountHint, + reason, + requiredApprovals, + expiresAt, + guardians, + } = input + + await Promise.all( + guardians.map(async (guardian) => { + const context = { requestId, guardianId: guardian.id, userId } + const base = `${appUrl()}/account/recovery/requests` + + if (guardian.guardianUserId) { + // Platform guardian: prove who you are the way you always do. + await tryPublish( + guardian.guardianUserId, + 'security.guardian_approval_requested', + { + requestId, + accountHint, + reason, + requiredApprovals, + expiresAt, + reviewUrl: `${base}/${requestId}`, + }, + context + ) + return + } + + const token = guardian.inviteToken + if (!token) { + logger.error( + '[Guardians] Cannot alert an external guardian: no token on file', + context + ) + return + } + + const decideUrl = `${base}/${requestId}/decide?token=${encodeURIComponent(token)}` + + await trySendEmail( + guardian.externalEmail, + renderGuardianApprovalRequest('', { + requestId, + approveUrl: decideUrl, + executeAfter: null, + accountHint, + reason, + requiredApprovals, + expiresAt, + }), + context + ) + + await trySendWhatsApp( + guardian.externalPhone, + `${accountHint} may need your help recovering their account (stated reason: ${reason}). ` + + `Review and decide here: ${decideUrl} (expires ${expiresAt}). ` + + `Approving only confirms you recognise them; we never ask for a code or payment.`, + context + ) + }) + ) +} diff --git a/src/guardians/service.ts b/src/guardians/service.ts new file mode 100644 index 00000000..c4d8d950 --- /dev/null +++ b/src/guardians/service.ts @@ -0,0 +1,1845 @@ +/** + * Guardian-based social recovery (#535). + * + * Recovery of a user's own ACCOUNT ACCESS — their sessions and ability to + * authenticate — not of the custodial wallet's key material, which the platform + * already holds and which is a different problem (src/keys/registry.ts). See + * docs/ACCOUNT_RECOVERY.md for the full threat model and its stated limits. + * + * ── Why this design is the way it is ──────────────────────────────────────── + * + * The feature optimises for one property: making UNAUTHORIZED recovery hard. + * Legitimate recovery is allowed to be slow and annoying. Three structural + * defences do the work, and each is enforced in more than one place on purpose: + * + * 1. QUORUM, NEVER 1-OF-N. Two independent guardians must agree. Enforced + * three times: `assertPolicyIsSane()` on every read, a database CHECK + * constraint, and the Zod schema on the update path. A quorum of one would + * make every other control here decorative. + * + * 2. A MANDATORY, NON-NEGOTIABLE DELAY between quorum and effect. Stamped + * ONCE, at quorum, onto the request row itself (`executeAfter`) rather + * than read from the policy at execution time — so editing or deleting the + * policy mid-flight cannot pull a live deadline forward. This window is the + * single most important thing in the feature: it is the time in which the + * real owner, who may have lost only their phone, gets a chance to notice + * and stop it. + * + * 3. LOUD, MULTI-CHANNEL ALERTING on initiation, to the account's own + * registered contact info as well as to every guardian. Guardians can + * all be socially engineered; the owner's address is the one channel the + * claimant does not control. See ./notifications.ts. + * + * Cancellation is deliberately EASIER than approval: the owner can stop a + * pending request unilaterally, at any point, with no guardian consensus. That + * asymmetry is intentional and must not be "improved" away. + * + * ── What this does NOT do ─────────────────────────────────────────────────── + * + * It does not verify that the claimant is the owner. It cannot — a locked-out + * user and an attacker are indistinguishable at the start. Guardians are the + * verification mechanism in v1. Automated identity re-verification is out of + * scope for v1, and the delay plus alerting are what stand in its place. + */ + +import crypto from 'node:crypto' +import { Prisma } from '@prisma/client' +import db from '../db' +import { logger } from '../utils/logger' +import { appendAuditBlock } from '../audit/chain' +import { revokeSession } from '../services/refresh-token.service' +import { + notifyGuardiansOfRequest, + notifyGuardianInvitation, + notifyQuorumReached, + notifyRecoveryCancelled, + notifyRecoveryCompleted, + notifyRecoveryInitiated, +} from './notifications' + +type Db = typeof db | Prisma.TransactionClient + +// ─── Constants ────────────────────────────────────────────────────────────── + +/** A quorum below this is never acceptable, whatever a caller asks for. */ +export const MIN_REQUIRED_APPROVALS = 2 +export const MIN_RECOVERY_DELAY_HOURS = 24 +export const MAX_RECOVERY_DELAY_HOURS = 168 +export const MAX_GUARDIAN_CAP = 20 + +export const DEFAULT_REQUIRED_APPROVALS = 2 +export const DEFAULT_RECOVERY_DELAY_HOURS = 48 +export const DEFAULT_MAX_GUARDIANS = 5 + +/** How long a guardian has to respond to a nomination. */ +export const GUARDIAN_INVITE_TTL_HOURS = 168 + +/** + * Hard lifetime of a recovery request, set at initiation whether or not quorum + * is ever reached. Without it an abandoned request stays PENDING forever and a + * later approval on an ancient request looks like a fresh one. + */ +export const REQUEST_EXPIRY_DAYS = 30 + +// ─── Errors ───────────────────────────────────────────────────────────────── + +/** + * A typed failure. Routes map `code` to an HTTP status instead of + * string-matching on messages, which is how the first draft of this feature + * turned a typo in a log line into a 500. + */ +export type RecoveryErrorCode = + | 'self_nomination' + | 'no_guardian_identifier' + | 'guardian_not_found' + | 'guardian_already_exists' + | 'guardian_limit_reached' + | 'guardian_not_accepted' + | 'invalid_invite_token' + | 'invite_expired' + | 'invite_already_responded' + | 'not_primary_account' + | 'insufficient_guardians' + | 'insufficient_policy' + | 'request_not_found' + | 'request_closed' + | 'not_request_owner' + | 'delay_not_elapsed' + | 'already_decided' + | 'invalid_policy' + +export class RecoveryError extends Error { + readonly code: RecoveryErrorCode + readonly status: number + + constructor(code: RecoveryErrorCode, message: string, status = 400) { + super(message) + this.name = 'RecoveryError' + this.code = code + this.status = status + Object.setPrototypeOf(this, RecoveryError.prototype) + } +} + +const HTTP_STATUS: Record = { + self_nomination: 400, + no_guardian_identifier: 400, + guardian_not_found: 404, + guardian_already_exists: 409, + guardian_limit_reached: 409, + guardian_not_accepted: 409, + invalid_invite_token: 404, + invite_expired: 410, + invite_already_responded: 409, + not_primary_account: 403, + insufficient_guardians: 409, + insufficient_policy: 409, + request_not_found: 404, + request_closed: 409, + not_request_owner: 403, + delay_not_elapsed: 409, + already_decided: 409, + invalid_policy: 400, +} + +export function recoveryErrorStatus(code: RecoveryErrorCode): number { + return HTTP_STATUS[code] ?? 400 +} + +/** Normalise anything thrown here into a RecoveryError. */ +export function toRecoveryError(err: unknown): RecoveryError { + if (err instanceof RecoveryError) return err + return new RecoveryError( + 'request_not_found', + err instanceof Error ? err.message : 'Unknown recovery error', + 500 + ) +} + +// ─── Types ────────────────────────────────────────────────────────────────── + +export type GuardianStatus = 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'REMOVED' +export type RequestStatus = + | 'PENDING' + | 'QUORUM_REACHED' + | 'REJECTED' + | 'CANCELLED' + | 'COMPLETED' + | 'EXPIRED' + +/** Statuses in which a request is still alive and can still be acted on. */ +export const OPEN_REQUEST_STATUSES: RequestStatus[] = [ + 'PENDING', + 'QUORUM_REACHED', +] + +export interface RecoveryPolicyView { + requiredApprovals: number + recoveryDelayHours: number + maxGuardians: number +} + +export interface GuardianView { + id: string + status: GuardianStatus + /** 'platform' | 'email' | 'phone' — how this guardian is reached. */ + kind: 'platform' | 'email' | 'phone' + /** + * Masked for anything not already authenticated as this guardian. The + * nominator only ever needs to recognise their own contact list, and a + * recovery feature is an excellent way to enumerate a target's associates. + */ + label: string + acceptedAt: string | null + createdAt: string +} + +export interface RecoveryRequestView { + id: string + userId: string + status: RequestStatus + reason: string + requiredApprovals: number | null + recoveryDelayHours: number | null + quorumReachedAt: string | null + executeAfter: string | null + executedAt: string | null + cancelledAt: string | null + expiresAt: string | null + createdAt: string + approvals: Array<{ + guardianId: string + approved: boolean + decidedAt: string + /** 'session' | 'external_token' — proof the guardian was identified. */ + method: string + }> + /** Whether the delay window has elapsed and execution is now permitted. */ + readyToExecute: boolean +} + +// ─── Small helpers ────────────────────────────────────────────────────────── + +/** + * SHA-256 of the raw invite/approval token. The raw token exists exactly once, + * in the response that created it and in the out-of-band message that carried + * it; the database only ever holds this digest. Same precedent as + * EmailIdentity.verifyTokenHash. + */ +export function hashRecoveryToken(rawToken: string): string { + return crypto.createHash('sha256').update(rawToken).digest('hex') +} + +/** 32 bytes of CSPRNG entropy, hex-encoded. */ +function generateRecoveryToken(): string { + return crypto.randomBytes(32).toString('hex') +} + +/** + * Strip control characters and clamp length from anything a claimant typed. + * + * `reason` is attacker-controlled free text that ends up in an audit payload + * and (via the mail templates) in an email body. Templates interpolate it into + * the PLAINTEXT part only, never the HTML part, but stripping control + * characters here means it cannot forge a header or a fake log line either. + */ +export function sanitizeReason(input: string, maxLength = 500): string { + // eslint-disable-next-line no-control-regex + return input + .replace(/[\u0000-\u001f\u007f]/g, ' ') + .trim() + .slice(0, maxLength) +} + +/** + * `alice@example.com` -> `a***e@example.com`, `+14155550123` -> `+1*****0123`. + * Enough for an owner to recognise their own list, not enough to harvest it. + */ +export function maskContact(value: string): string { + if (value.includes('@')) { + const [local, domain] = value.split('@') + if (!local || !domain) return '***' + if (local.length <= 2) return `${local[0]}***@${domain}` + return `${local[0]}***${local[local.length - 1]}@${domain}` + } + const digits = value.replace(/[^\d]/g, '') + if (digits.length <= 4) return '*'.repeat(digits.length) + return `${value.slice(0, 2)}${'*'.repeat(digits.length - 4)}${digits.slice(-2)}` +} + +/** + * Every recovery event is written to the hash-chained audit feed (#315). + * + * Uses the same `appendAuditBlock` call shape as the rest of the codebase + * (src/protectionFund/service.ts and friends): the block is computed here and + * chained by the persistence job. Recovery events are `ADMIN_BATCH` because + * they are privileged, operator-visible state changes rather than user + * transactions, and because an operator investigating a takeover must be able + * to find them by block type. + */ +function audit(event: Record): void { + try { + appendAuditBlock({ + height: 0, + prevHash: 'sha256:0', + payloadHash: 'sha256:0', + blockType: 'ADMIN_BATCH', + createdAt: new Date(), + payloads: [event], + }) + } catch (err) { + // An audit write must never take down the operation it describes, but it + // must be loud — a recovery event that silently failed to be recorded is + // exactly the thing an incident review would need. + logger.error('[Guardians] Failed to append audit block', { + event, + error: err instanceof Error ? err.message : String(err), + }) + } +} + +function nowPlusHours(hours: number): Date { + return new Date(Date.now() + hours * 60 * 60 * 1000) +} + +// ─── Account shape ────────────────────────────────────────────────────────── + +/** + * Social recovery applies to a PRIMARY account owner only (v1). + * + * A sub-account's access is delegated by a parent account that already exists + * and already holds a recovery path of its own, so a second, independent + * mechanism for it would be redundant and would multiply the ways a child + * account could be taken over. Recovery for a sub-account is routed through the + * parent; this guard makes sure a request aimed at one is refused rather than + * quietly opening a request nobody is watching. + */ +export async function assertPrimaryAccountOwner( + userId: string, + database: Db = db +): Promise { + const parentLink = await database.subAccount.findFirst({ + where: { childUserId: userId, status: 'ACTIVE' }, + select: { id: true }, + }) + + if (parentLink) { + throw new RecoveryError( + 'not_primary_account', + 'Social recovery applies to primary accounts only. A sub-account recovers through its parent account.', + 403 + ) + } +} + +// ─── Recovery policy ──────────────────────────────────────────────────────── + +/** + * Reject a policy that would make the structural defences meaningless. Called + * on every policy READ, not just on write, so a row that somehow bypassed the + * write path and the database CHECK (a hand-edited row, a restored dump) still + * cannot be used to authorise a recovery. + */ +function assertPolicyIsSane(policy: { + requiredApprovals: number + recoveryDelayHours: number +}): void { + if (policy.requiredApprovals < MIN_REQUIRED_APPROVALS) { + throw new RecoveryError( + 'insufficient_policy', + `Refusing to act on a recovery policy requiring only ${policy.requiredApprovals} approval(s); a quorum below ${MIN_REQUIRED_APPROVALS} is never allowed.`, + 500 + ) + } + if (policy.recoveryDelayHours < MIN_RECOVERY_DELAY_HOURS) { + throw new RecoveryError( + 'insufficient_policy', + `Refusing to act on a recovery policy with a ${policy.recoveryDelayHours}h delay; the mandatory waiting period cannot be shorter than ${MIN_RECOVERY_DELAY_HOURS}h.`, + 500 + ) + } +} + +export async function getOrCreateRecoveryPolicy( + userId: string, + database: Db = db +): Promise { + let policy = await database.recoveryPolicy.findUnique({ where: { userId } }) + + if (!policy) { + policy = await database.recoveryPolicy.create({ + data: { userId }, + }) + } + + assertPolicyIsSane(policy) + return { + requiredApprovals: policy.requiredApprovals, + recoveryDelayHours: policy.recoveryDelayHours, + maxGuardians: policy.maxGuardians, + } +} + +export async function updateRecoveryPolicy( + userId: string, + input: { requiredApprovals?: number; recoveryDelayHours?: number }, + database: Db = db +): Promise { + const current = await getOrCreateRecoveryPolicy(userId, database) + + const requiredApprovals = input.requiredApprovals ?? current.requiredApprovals + const recoveryDelayHours = + input.recoveryDelayHours ?? current.recoveryDelayHours + + if (requiredApprovals < MIN_REQUIRED_APPROVALS) { + throw new RecoveryError( + 'invalid_policy', + `requiredApprovals must be at least ${MIN_REQUIRED_APPROVALS}. A 1-of-N quorum would let a single compromised guardian take over the account.` + ) + } + if (recoveryDelayHours < MIN_RECOVERY_DELAY_HOURS) { + throw new RecoveryError( + 'invalid_policy', + `recoveryDelayHours must be at least ${MIN_RECOVERY_DELAY_HOURS}. The waiting period is the structural defence against a colluding minority and is not configurable below one day.` + ) + } + if (recoveryDelayHours > MAX_RECOVERY_DELAY_HOURS) { + throw new RecoveryError( + 'invalid_policy', + `recoveryDelayHours must be ${MAX_RECOVERY_DELAY_HOURS} or fewer.` + ) + } + + // A quorum larger than the number of accepted guardians would be + // unsatisfiable, i.e. the account could never be recovered at all. That is + // arguably a valid choice, but it is almost always a misconfiguration, so it + // is refused at the point of the mistake. + const acceptedCount = await database.recoveryGuardian.count({ + where: { userId, status: 'ACCEPTED' }, + }) + if (requiredApprovals > acceptedCount) { + throw new RecoveryError( + 'invalid_policy', + `requiredApprovals (${requiredApprovals}) exceeds the number of accepted guardians (${acceptedCount}). The recovery could never reach quorum.`, + 409 + ) + } + + const updated = await database.recoveryPolicy.update({ + where: { userId }, + data: { requiredApprovals, recoveryDelayHours }, + }) + + audit({ + type: 'RECOVERY_POLICY_UPDATED', + userId, + requiredApprovals: updated.requiredApprovals, + recoveryDelayHours: updated.recoveryDelayHours, + }) + + return { + requiredApprovals: updated.requiredApprovals, + recoveryDelayHours: updated.recoveryDelayHours, + maxGuardians: updated.maxGuardians, + } +} + +// ─── Guardian nomination ──────────────────────────────────────────────────── + +export interface NominateGuardianResult { + guardian: GuardianView + /** + * The raw acceptance token. Returned EXACTLY ONCE and never stored in clear; + * deliver it to the guardian out of band and drop it. + */ + inviteToken: string + inviteExpiresAt: string +} + +export async function nominateGuardian( + input: { + userId: string + guardianUserId?: string + externalEmail?: string + externalPhone?: string + }, + database: Db = db +): Promise { + const { userId, guardianUserId, externalEmail, externalPhone } = input + + if (!guardianUserId && !externalEmail && !externalPhone) { + throw new RecoveryError( + 'no_guardian_identifier', + 'Provide guardianUserId, externalEmail, or externalPhone to identify the guardian.' + ) + } + + if (guardianUserId && guardianUserId === userId) { + throw new RecoveryError( + 'self_nomination', + 'You cannot nominate yourself as your own guardian. Recovery requires a party other than the account owner.' + ) + } + + // Re-nominating an existing guardian is treated as a fresh invitation rather + // than an error, so an owner who mistyped an address or whose guardian + // declined can try again. Silent enrolment is still impossible: the row + // returns to PENDING and the new token must be accepted before the guardian + // counts toward quorum. + const identityFilters = [ + ...(guardianUserId ? [{ guardianUserId }] : []), + ...(externalEmail ? [{ externalEmail }] : []), + ...(externalPhone ? [{ externalPhone }] : []), + ] + + const existing = await database.recoveryGuardian.findFirst({ + where: { + userId, + status: { in: ['PENDING', 'ACCEPTED'] }, + OR: identityFilters, + }, + select: { id: true }, + }) + + if (existing) { + const activeCount = await database.recoveryGuardian.count({ + where: { userId, status: { in: ['PENDING', 'ACCEPTED'] } }, + }) + const policy = await getOrCreateRecoveryPolicy(userId, database) + if (activeCount >= policy.maxGuardians) { + throw new RecoveryError( + 'guardian_limit_reached', + `This account already has ${activeCount} guardian(s), which is the configured maximum of ${policy.maxGuardians}. Remove one before adding another.`, + 409 + ) + } + } + + if (guardianUserId) { + const guardianUser = await database.user.findUnique({ + where: { id: guardianUserId }, + select: { id: true, isActive: true }, + }) + if (!guardianUser) { + throw new RecoveryError( + 'guardian_not_found', + 'The nominated platform user does not exist.' + ) + } + if (!guardianUser.isActive) { + throw new RecoveryError( + 'guardian_not_found', + 'The nominated platform user is not active.' + ) + } + } + + const policy = await getOrCreateRecoveryPolicy(userId, database) + + if (!existing) { + const activeCount = await database.recoveryGuardian.count({ + where: { userId, status: { in: ['PENDING', 'ACCEPTED'] } }, + }) + if (activeCount >= policy.maxGuardians) { + throw new RecoveryError( + 'guardian_limit_reached', + `This account already has ${activeCount} guardian(s), which is the configured maximum of ${policy.maxGuardians}. Remove one before adding another.`, + 409 + ) + } + } + + const rawToken = generateRecoveryToken() + const inviteExpiresAt = nowPlusHours(GUARDIAN_INVITE_TTL_HOURS) + + const row = existing + ? await database.recoveryGuardian.update({ + where: { id: existing.id }, + data: { + guardianUserId: guardianUserId ?? null, + externalEmail: externalEmail ?? null, + externalPhone: externalPhone ?? null, + status: 'PENDING', + inviteTokenHash: hashRecoveryToken(rawToken), + inviteExpiresAt, + confirmedAt: null, + }, + }) + : await database.recoveryGuardian.create({ + data: { + userId, + guardianUserId: guardianUserId ?? null, + externalEmail: externalEmail ?? null, + externalPhone: externalPhone ?? null, + inviteTokenHash: hashRecoveryToken(rawToken), + inviteExpiresAt, + }, + }) + + audit({ + type: 'GUARDIAN_NOMINATED', + guardianId: row.id, + userId, + kind: guardianUserId ? 'platform' : externalEmail ? 'email' : 'phone', + }) + + // Deliver the invitation out of band. Deliberately fire-and-forget: the + // nomination is already recorded, and a mail outage must not roll it back or + // surface as a failed request. notifications.ts never throws. + void notifyGuardianInvitation({ + guardianId: row.id, + userId, + accountHint: await maskedAccountHint(userId, database), + inviteToken: rawToken, + inviteExpiresAt: inviteExpiresAt.toISOString(), + externalEmail: row.externalEmail, + externalPhone: row.externalPhone, + }) + + return { + guardian: toGuardianView(row), + inviteToken: rawToken, + inviteExpiresAt: inviteExpiresAt.toISOString(), + } +} + +/** + * A short, non-reversible identifier for the account, safe to show to a + * guardian who is being asked to vouch for it. A masked wallet address is + * enough for them to recognise "this is the account I agreed to guard" without + * handing them the full address. + */ +async function maskedAccountHint( + userId: string, + database: Db +): Promise { + try { + const user = await database.user.findUnique({ + where: { id: userId }, + select: { walletAddress: true }, + }) + return user ? maskContact(user.walletAddress) : 'A NeuroWealth account' + } catch { + return 'A NeuroWealth account' + } +} + +function toGuardianView(row: { + id: string + status: string + guardianUserId: string | null + externalEmail: string | null + externalPhone: string | null + confirmedAt: Date | null + createdAt: Date +}): GuardianView { + const kind: GuardianView['kind'] = row.guardianUserId + ? 'platform' + : row.externalEmail + ? 'email' + : 'phone' + + const label = row.guardianUserId + ? `Platform user ${row.guardianUserId.slice(0, 8)}` + : row.externalEmail + ? maskContact(row.externalEmail) + : row.externalPhone + ? maskContact(row.externalPhone) + : 'unknown' + + return { + id: row.id, + status: row.status as GuardianStatus, + kind, + label, + acceptedAt: row.confirmedAt ? row.confirmedAt.toISOString() : null, + createdAt: row.createdAt.toISOString(), + } +} + +export async function listGuardians( + userId: string, + database: Db = db +): Promise { + const rows = await database.recoveryGuardian.findMany({ + where: { userId }, + orderBy: { createdAt: 'asc' }, + }) + return rows.map(toGuardianView) +} + +/** + * Resolve a raw token to its guardian row. Returns null rather than throwing so + * both the accept path and the external-approval path can treat "unknown token" + * the same way. + */ +async function findGuardianByToken( + token: string, + database: Db +): Promise<{ + id: string + userId: string + guardianUserId: string | null + status: string + inviteExpiresAt: Date + inviteTokenHash: string + externalEmail: string | null + externalPhone: string | null + confirmedAt: Date | null + createdAt: Date +} | null> { + return database.recoveryGuardian.findUnique({ + where: { inviteTokenHash: hashRecoveryToken(token) }, + }) +} + +function assertUsableToken( + guardian: { status: string; inviteExpiresAt: Date }, + token: string +): void { + // Constant-ish comparison is pointless here: the lookup above was already a + // digest equality check, so possessing the token is proven. What matters is + // that an expired or already-answered token cannot be reused. + if (guardian.status !== 'PENDING' && guardian.status !== 'ACCEPTED') { + throw new RecoveryError( + 'invite_already_responded', + `This guardian nomination is no longer open (status: ${guardian.status}).`, + 409 + ) + } + if (guardian.inviteExpiresAt.getTime() < Date.now()) { + throw new RecoveryError( + 'invite_expired', + 'This guardian invitation has expired. The account owner can re-issue it.', + 410 + ) + } + void token +} + +/** + * A platform guardian accepting through their authenticated session. Proves + * the responder really is the nominated user, which is why it needs no token. + */ +export async function acceptGuardianInviteAsUser( + input: { guardianId: string; actorUserId: string }, + database: Db = db +): Promise { + const guardian = await database.recoveryGuardian.findUnique({ + where: { id: input.guardianId }, + }) + + if (!guardian || guardian.guardianUserId !== input.actorUserId) { + throw new RecoveryError( + 'invalid_invite_token', + 'No pending guardian nomination for this user.', + 404 + ) + } + assertUsableToken(guardian, '') + + const updated = await database.recoveryGuardian.update({ + where: { id: guardian.id }, + data: { status: 'ACCEPTED', confirmedAt: new Date() }, + }) + + audit({ + type: 'GUARDIAN_ACCEPTED', + guardianId: guardian.id, + userId: guardian.userId, + method: 'session', + }) + + return toGuardianView(updated) +} + +/** + * An EXTERNAL contact accepting via the token they were sent. This is the only + * identity proof available to somebody with no platform account, which is why + * the token is high-entropy, single-use for enrolment, and never sufficient on + * its own to complete a recovery — approving still requires a separate, explicit + * decision (see approveRecoveryAsExternalGuardian). + */ +export async function respondToGuardianInvite( + input: { token: string; accept: boolean }, + database: Db = db +): Promise { + const guardian = await findGuardianByToken(input.token, database) + + if (!guardian) { + throw new RecoveryError( + 'invalid_invite_token', + 'Invalid invitation token.', + 404 + ) + } + assertUsableToken(guardian, input.token) + + if (guardian.status === 'ACCEPTED') { + throw new RecoveryError( + 'invite_already_responded', + 'This nomination has already been accepted.', + 409 + ) + } + + const updated = await database.recoveryGuardian.update({ + where: { id: guardian.id }, + data: { + status: input.accept ? 'ACCEPTED' : 'DECLINED', + confirmedAt: input.accept ? new Date() : null, + }, + }) + + audit({ + type: input.accept ? 'GUARDIAN_ACCEPTED' : 'GUARDIAN_DECLINED', + guardianId: guardian.id, + userId: guardian.userId, + method: 'external_token', + }) + + return toGuardianView(updated) +} + +/** + * Remove a guardian. Immediate and unconditional — an owner who suspects a + * guardian has been compromised must be able to drop them without waiting for + * that guardian's consent, exactly as with cancellation. + */ +export async function removeGuardian( + input: { userId: string; guardianId: string }, + database: Db = db +): Promise { + const guardian = await database.recoveryGuardian.findFirst({ + where: { id: input.guardianId, userId: input.userId }, + }) + + if (!guardian) { + throw new RecoveryError('guardian_not_found', 'Guardian not found.', 404) + } + + const updated = await database.recoveryGuardian.update({ + where: { id: guardian.id }, + data: { status: 'REMOVED' }, + }) + + audit({ + type: 'GUARDIAN_REMOVED', + guardianId: guardian.id, + userId: input.userId, + }) + + return toGuardianView(updated) +} + +// ─── Recovery request ─────────────────────────────────────────────────────── + +function toRequestView( + row: { + id: string + userId: string + status: string + reason: string + requiredApprovals: number | null + recoveryDelayHours: number | null + quorumReachedAt: Date | null + executeAfter: Date | null + executedAt: Date | null + cancelledAt: Date | null + expiresAt: Date | null + createdAt: Date + }, + approvals: Array<{ + guardianId: string + approved: boolean + decidedAt: Date + method: string + }> = [] +): RecoveryRequestView { + return { + id: row.id, + userId: row.userId, + status: row.status as RequestStatus, + reason: row.reason, + requiredApprovals: row.requiredApprovals, + recoveryDelayHours: row.recoveryDelayHours, + quorumReachedAt: row.quorumReachedAt?.toISOString() ?? null, + executeAfter: row.executeAfter?.toISOString() ?? null, + executedAt: row.executedAt?.toISOString() ?? null, + cancelledAt: row.cancelledAt?.toISOString() ?? null, + expiresAt: row.expiresAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + approvals: approvals.map((a) => ({ + guardianId: a.guardianId, + approved: a.approved, + decidedAt: a.decidedAt.toISOString(), + method: a.method, + })), + readyToExecute: + row.status === 'QUORUM_REACHED' && + row.executeAfter !== null && + row.executeAfter.getTime() <= Date.now(), + } +} + +/** + * Load a request plus its approvals, scoped so a caller who is neither the + * owner nor a nominated guardian learns nothing. + */ +async function loadRequest( + requestId: string, + database: Db +): Promise> | null> { + return fetchRequestRow(requestId, database) +} + +async function fetchRequestRow(requestId: string, database: Db) { + return database.recoveryRequest.findUnique({ + where: { id: requestId }, + include: { + approvals: { + select: { + guardianId: true, + approved: true, + decidedAt: true, + method: true, + }, + }, + }, + }) +} + +export async function getRecoveryRequestForOwner( + input: { requestId: string; userId: string }, + database: Db = db +): Promise { + const row = await loadRequest(input.requestId, database) + if (!row) { + throw new RecoveryError( + 'request_not_found', + 'Recovery request not found.', + 404 + ) + } + if (row.userId !== input.userId) { + throw new RecoveryError( + 'not_request_owner', + 'This recovery request belongs to another account.', + 403 + ) + } + return toRequestView(row, row.approvals) +} + +/** + * The claimant's door. Deliberately unauthenticated — the caller is by + * definition someone who cannot sign in — and deliberately capable of nothing + * except opening a request and ringing the alarm. + * + * Returns a shape the route must render IDENTICALLY whether or not the account + * exists or has any guardians: a response that differs by target is a + * enumeration oracle for "which wallets have a recovery setup", and wallets are + * public on-chain. See `InitiationOutcome`. + */ +export interface InitiationOutcome { + request: RecoveryRequestView | null + acceptedGuardians: number + requiredApprovals: number +} + +export async function initiateRecovery( + input: { walletAddress: string; reason: string }, + database: Db = db +): Promise { + const reason = sanitizeReason(input.reason) + + const user = await database.user.findUnique({ + where: { walletAddress: input.walletAddress }, + select: { id: true }, + }) + + // No such account: the route still answers 202 with the same body. There is + // nothing to alert about and nothing to open. + if (!user) { + logger.info('[Guardians] Recovery initiated for an unknown wallet', { + walletAddress: input.walletAddress, + }) + return { request: null, acceptedGuardians: 0, requiredApprovals: 0 } + } + + try { + await assertPrimaryAccountOwner(user.id, database) + } catch (err) { + if (err instanceof RecoveryError && err.code === 'not_primary_account') { + // Also answered with the same generic body: whether a wallet is a + // sub-account is not the caller's business. + logger.info('[Guardians] Recovery refused for a sub-account', { + userId: user.id, + }) + return { request: null, acceptedGuardians: 0, requiredApprovals: 0 } + } + throw err + } + + const policy = await getOrCreateRecoveryPolicy(user.id, database) + const acceptedGuardians = await database.recoveryGuardian.count({ + where: { userId: user.id, status: 'ACCEPTED' }, + }) + + if (acceptedGuardians < policy.requiredApprovals) { + // Cannot reach quorum, so there is nothing to alert anybody about and no + // reason to open a request that can only sit there. Fails safe: this is the + // "all guardians unreachable" case the design accepts (docs/ACCOUNT_RECOVERY.md). + logger.info('[Guardians] Recovery refused: not enough accepted guardians', { + userId: user.id, + acceptedGuardians, + requiredApprovals: policy.requiredApprovals, + }) + return { + request: null, + acceptedGuardians, + requiredApprovals: policy.requiredApprovals, + } + } + + const expiresAt = new Date( + Date.now() + REQUEST_EXPIRY_DAYS * 24 * 60 * 60 * 1000 + ) + + let request + try { + request = await database.recoveryRequest.create({ + data: { userId: user.id, reason, status: 'PENDING', expiresAt }, + }) + } catch (err) { + // P2002 is recovery_requests_userId_live_key. + // + // This index is the ONLY thing enforcing one live request per account -- there + // is no read-then-write check above, and that is deliberate. A pre-insert read + // would still race: two concurrent initiations for the same wallet both read + // "none" and both insert. On a public unauthenticated endpoint, reachability of + // that race is not a matter of luck, so the guarantee belongs in the database + // where it cannot be bypassed by a caller. + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + logger.info('[Guardians] Recovery refused: a request is already open', { + userId: user.id, + }) + return { + request: null, + acceptedGuardians, + requiredApprovals: policy.requiredApprovals, + } + } + + // Anything else is a real failure, not a duplicate. Surfacing it as + // "already open" would be indistinguishable from success to the caller while + // writing a false cause into the audit trail, and would hide an outage. Re-throw + // so the route's generic error handler decides what the public endpoint + // returns. + logger.error('[Guardians] Recovery failed to create a request', { + userId: user.id, + error: err instanceof Error ? err.message : String(err), + }) + throw err + } + + audit({ + type: 'RECOVERY_INITIATED', + requestId: request.id, + userId: user.id, + requiredApprovals: policy.requiredApprovals, + acceptedGuardians, + }) + + // ── Loud alerting ────────────────────────────────────────────────────────── + // The owner is alerted FIRST and unconditionally. This is the load-bearing + // safety property: even if every guardian has been compromised, the owner + // learns and can cancel unilaterally. Guardians are alerted afterwards so a + // slow guardian delivery can never delay the owner's notification. + // + // Tokens are re-minted per request rather than reusing the nomination token, + // so the link a guardian gets here is only valid for this request and is not + // the long-lived credential they accepted the role with. + void notifyRecoveryInitiated({ + requestId: request.id, + userId: user.id, + reason, + requiredApprovals: policy.requiredApprovals, + acceptedGuardians, + }) + + void alertGuardiansAboutRequest( + request.id, + user.id, + reason, + policy.requiredApprovals, + database + ) + + return { + request: toRequestView(request, []), + acceptedGuardians, + requiredApprovals: policy.requiredApprovals, + } +} + +/** + * Tell every accepted guardian that a decision is wanted, each with a link + * bound to their own freshly minted token. Deliberately iterates guardians to + * NOTIFY, never to decide: there is no path in this file that records an + * approval without a single named guardian acting for themselves. + */ +async function alertGuardiansAboutRequest( + requestId: string, + userId: string, + reason: string, + requiredApprovals: number, + database: Db +): Promise { + try { + const guardians = await database.recoveryGuardian.findMany({ + where: { userId, status: 'ACCEPTED' }, + orderBy: { createdAt: 'asc' }, + }) + if (guardians.length === 0) return + + const accountHint = await maskedAccountHint(userId, database) + const expiresAt = new Date( + Date.now() + REQUEST_EXPIRY_DAYS * 24 * 60 * 60 * 1000 + ).toISOString() + + // A fresh decision token per request, stored as a digest. The guardian's + // nomination token is replaced: it has already been spent proving they + // accepted, and keeping one long-lived token alive for both roles widens + // the window if it leaks from an email inbox. + const withTokens = await Promise.all( + guardians.map(async (guardian) => { + if (guardian.guardianUserId) { + return { ...guardian, inviteToken: null } + } + const decisionToken = generateRecoveryToken() + await database.recoveryGuardian.update({ + where: { id: guardian.id }, + data: { + inviteTokenHash: hashRecoveryToken(decisionToken), + inviteExpiresAt: expiresAt + ? new Date(expiresAt) + : guardian.inviteExpiresAt, + }, + }) + return { ...guardian, inviteToken: decisionToken } + }) + ) + + await notifyGuardiansOfRequest({ + requestId, + userId, + accountHint, + reason, + requiredApprovals, + expiresAt, + guardians: withTokens.map((g) => ({ + id: g.id, + guardianUserId: g.guardianUserId, + externalEmail: g.externalEmail, + externalPhone: g.externalPhone, + inviteToken: g.inviteToken, + })), + }) + } catch (err) { + // Never let alerting break the request that is already open. + logger.error('[Guardians] FAILED to alert guardians about a request', { + requestId, + userId, + error: err instanceof Error ? err.message : String(err), + }) + } +} + +// ─── Guardian approval ────────────────────────────────────────────────────── + +/** + * Apply ONE guardian's decision to an open request. + * + * Both public approval entry points funnel through here, which is what makes + * "approvals are independent and explicit, never automatic or bulk" a property + * of the code's shape rather than a claim in a comment: the function takes + * exactly one guardian's identity and one boolean, and there is no code path + * anywhere that iterates guardians to decide for them. + */ +async function recordGuardianDecision( + input: { + requestId: string + guardianId: string + method: 'session' | 'external_token' + approved: boolean + note?: string + ipAddress?: string + }, + database: Db +): Promise { + const request = await database.recoveryRequest.findUnique({ + where: { id: input.requestId }, + }) + + if (!request) { + throw new RecoveryError( + 'request_not_found', + 'Recovery request not found.', + 404 + ) + } + + if (request.status !== 'PENDING') { + throw new RecoveryError( + 'request_closed', + `This recovery request is no longer collecting approvals (status: ${request.status}).`, + 409 + ) + } + + if (request.expiresAt && request.expiresAt.getTime() < Date.now()) { + await database.recoveryRequest.update({ + where: { id: request.id }, + data: { status: 'EXPIRED' }, + }) + throw new RecoveryError( + 'request_closed', + 'This recovery request has expired.', + 409 + ) + } + + const guardian = await database.recoveryGuardian.findUnique({ + where: { id: input.guardianId }, + }) + + if (!guardian || guardian.userId !== request.userId) { + throw new RecoveryError( + 'guardian_not_found', + 'That guardian is not a guardian for this account.', + 404 + ) + } + + // Only an explicitly accepted guardian may decide. A PENDING nomination is + // exactly the "unwitting guardian" the issue rules out, so it cannot vote. + if (guardian.status !== 'ACCEPTED') { + throw new RecoveryError( + 'guardian_not_accepted', + 'This guardian has not accepted the nomination and cannot approve or decline.', + 409 + ) + } + + // A refusal is final, same as an approval — otherwise a guardian could be + // re-asked indefinitely and an approval could follow a refusal, which would + // make the recorded decision history a lie. The `@@unique([requestId, + // guardianId])` constraint is the real race guard; this check only produces a + // good error message. + const existing = await database.recoveryApproval.findUnique({ + where: { + requestId_guardianId: { + requestId: input.requestId, + guardianId: input.guardianId, + }, + }, + select: { id: true }, + }) + if (existing) { + throw new RecoveryError( + 'already_decided', + 'This guardian has already recorded a decision for this request.', + 409 + ) + } + + await database.recoveryApproval.create({ + data: { + requestId: input.requestId, + guardianId: input.guardianId, + approved: input.approved, + method: input.method, + note: input.note ? sanitizeReason(input.note) : null, + ipAddress: input.ipAddress ?? null, + }, + }) + + audit({ + type: input.approved ? 'RECOVERY_APPROVED' : 'RECOVERY_DECLINED', + requestId: input.requestId, + userId: request.userId, + guardianId: input.guardianId, + method: input.method, + }) + + if (!input.approved) { + const view = await getRequestForGuardian( + input.requestId, + input.guardianId, + database + ) + return view + } + + return maybeReachQuorum(input.requestId, request.userId, database) +} + +/** + * Count approvals and, the first time quorum is met, stamp the deadline. + * + * `quorumReachedAt` is written exactly once and `executeAfter` is derived from + * the policy as it stands at that instant and then frozen onto the request row. + * Nothing recomputes it later, which is what stops a policy edit from pulling a + * live deadline forward — and what stops a *longer* delay from being shortened + * either. + */ +async function maybeReachQuorum( + requestId: string, + userId: string, + database: Db +): Promise { + const request = await database.recoveryRequest.findUnique({ + where: { id: requestId }, + }) + if (!request) { + throw new RecoveryError( + 'request_not_found', + 'Recovery request not found.', + 404 + ) + } + if (request.quorumReachedAt) { + // Quorum already reached. A further approval changes the tally but not the + // deadline, which is already fixed. + return getRequestForGuardian(requestId, '', database, true) + } + + const policy = await getOrCreateRecoveryPolicy(userId, database) + assertPolicyIsSane(policy) + + const approvals = await database.recoveryApproval.count({ + where: { requestId, approved: true }, + }) + + if (approvals < policy.requiredApprovals) { + return getRequestForGuardian(requestId, '', database, true) + } + + const quorumReachedAt = new Date() + const executeAfter = new Date( + quorumReachedAt.getTime() + policy.recoveryDelayHours * 60 * 60 * 1000 + ) + + // Conditional write: `quorumReachedAt: null` means whichever approval wins + // this race stamps the deadline and every concurrent loser observes the row + // already stamped, so the deadline is computed once and only once even under + // parallel approvals. + const claimed = await database.recoveryRequest.updateMany({ + where: { id: requestId, quorumReachedAt: null, status: 'PENDING' }, + data: { + status: 'QUORUM_REACHED', + quorumReachedAt, + executeAfter, + requiredApprovals: policy.requiredApprovals, + recoveryDelayHours: policy.recoveryDelayHours, + }, + }) + + if (claimed.count === 0) { + return getRequestForGuardian(requestId, '', database, true) + } + + audit({ + type: 'RECOVERY_QUORUM_REACHED', + requestId, + userId, + requiredApprovals: policy.requiredApprovals, + recoveryDelayHours: policy.recoveryDelayHours, + executeAfter: executeAfter.toISOString(), + }) + + // Last warning before access changes hands. The owner is told the exact + // deadline AND that they can still cancel with no guardian agreement. + void notifyQuorumReached({ + requestId, + userId, + requiredApprovals: policy.requiredApprovals, + executeAfter: executeAfter.toISOString(), + }) + + logger.warn('[Guardians] Recovery quorum reached; delay window open', { + requestId, + userId, + requiredApprovals: policy.requiredApprovals, + executeAfter: executeAfter.toISOString(), + }) + + return getRequestForGuardian(requestId, '', database, true) +} + +/** + * Read a request back for a response body. `guardianId` narrows the visible + * approval list to that guardian's own decision when the caller is a guardian + * rather than the owner — guardians can see that a request exists and how far + * along it is, but not who else has been asked or what they said. + */ +async function getRequestForGuardian( + requestId: string, + guardianId: string, + database: Db, + ownerView = false +): Promise { + const row = await fetchRequestRow(requestId, database) + if (!row) { + throw new RecoveryError( + 'request_not_found', + 'Recovery request not found.', + 404 + ) + } + + if (ownerView) { + return toRequestView(row, row.approvals) + } + + return toRequestView( + row, + row.approvals.filter((a) => a.guardianId === guardianId) + ) +} + +/** + * A platform guardian's decision, proven by their own authenticated session. + * The service re-checks that the session's user really is this guardian's + * nominated user — the route's `requireAuth` is necessary but not sufficient. + */ +export async function approveRecoveryAsGuardian( + input: { + requestId: string + actorUserId: string + approved: boolean + note?: string + ipAddress?: string + }, + database: Db = db +): Promise { + const request = await database.recoveryRequest.findUnique({ + where: { id: input.requestId }, + select: { userId: true }, + }) + if (!request) { + throw new RecoveryError( + 'request_not_found', + 'Recovery request not found.', + 404 + ) + } + + const guardian = await database.recoveryGuardian.findFirst({ + where: { + userId: request.userId, + guardianUserId: input.actorUserId, + status: 'ACCEPTED', + }, + }) + + if (!guardian) { + // Same error whether the caller is a stranger or a guardian for a different + // account: the distinction is not useful to an attacker and would confirm + // that a recovery is in progress for a given wallet. + throw new RecoveryError( + 'guardian_not_found', + 'You are not an accepted guardian for this recovery request.', + 404 + ) + } + + const view = await recordGuardianDecision( + { + requestId: input.requestId, + guardianId: guardian.id, + method: 'session', + approved: input.approved, + note: input.note, + ipAddress: input.ipAddress, + }, + database + ) + + return view +} + +/** + * An external contact's decision, proven by the invite token they were sent. + * + * The token identifies the guardian, so a guardian can never decide on another's + * behalf, and it is not consumed here — an external guardian may need to look at + * the request before deciding. What stops a replay is the one-decision-per- + * (request, guardian) row, which a second use of the same token collides with. + */ +export async function approveRecoveryAsExternalGuardian( + input: { + requestId: string + token: string + approved: boolean + note?: string + ipAddress?: string + }, + database: Db = db +): Promise { + const request = await database.recoveryRequest.findUnique({ + where: { id: input.requestId }, + select: { userId: true }, + }) + if (!request) { + throw new RecoveryError( + 'request_not_found', + 'Recovery request not found.', + 404 + ) + } + + const guardian = await findGuardianByToken(input.token, database) + + if (!guardian || guardian.userId !== request.userId) { + throw new RecoveryError( + 'guardian_not_found', + 'Invalid approval token for this recovery request.', + 404 + ) + } + + return recordGuardianDecision( + { + requestId: input.requestId, + guardianId: guardian.id, + method: 'external_token', + approved: input.approved, + note: input.note, + ipAddress: input.ipAddress, + }, + database + ) +} + +/** Requests this user is an accepted guardian for and has not yet decided. */ +export async function listRequestsAwaitingGuardian( + guardianUserId: string, + database: Db = db +): Promise { + const guardians = await database.recoveryGuardian.findMany({ + where: { guardianUserId, status: 'ACCEPTED' }, + select: { id: true, userId: true }, + }) + + if (guardians.length === 0) return [] + + const requests = await database.recoveryRequest.findMany({ + where: { + userId: { in: guardians.map((g) => g.userId) }, + status: 'PENDING', + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + orderBy: { createdAt: 'desc' }, + include: { + approvals: { + where: { guardianId: { in: guardians.map((g) => g.id) } }, + select: { + guardianId: true, + approved: true, + decidedAt: true, + method: true, + }, + }, + }, + }) + + return requests.map((r) => toRequestView(r, r.approvals)) +} + +// ─── Cancellation ─────────────────────────────────────────────────────────── + +/** + * Stop a pending recovery. Owner-only, unilateral, immediate. + * + * Deliberately the easiest action in the feature: no quorum, no guardian + * consent, no delay, and allowed right up to the moment execution wins the + * race. The conditional update is the whole safety argument — it matches on + * `status IN (PENDING, QUORUM_REACHED)`, so a cancellation that lands + * concurrently with execution cannot resurrect a dead request, and whichever + * write hits first is the one that counts. + */ +export async function cancelRecovery( + input: { requestId: string; userId: string }, + database: Db = db +): Promise { + const request = await database.recoveryRequest.findUnique({ + where: { id: input.requestId }, + }) + + if (!request) { + throw new RecoveryError( + 'request_not_found', + 'Recovery request not found.', + 404 + ) + } + if (request.userId !== input.userId) { + throw new RecoveryError( + 'not_request_owner', + 'This recovery request belongs to another account.', + 403 + ) + } + + const cancelledAt = new Date() + + const cancelled = await database.recoveryRequest.updateMany({ + where: { id: request.id, status: { in: OPEN_REQUEST_STATUSES } }, + data: { status: 'CANCELLED', cancelledAt }, + }) + + if (cancelled.count === 0) { + // Either it was already closed, or execution won the race microseconds + // ago. Both mean the same thing to this caller: it is over, and it was not + // us who ended it. + const current = await database.recoveryRequest.findUnique({ + where: { id: request.id }, + }) + throw new RecoveryError( + 'request_closed', + `This recovery request is already ${current?.status ?? 'closed'} and can no longer be cancelled.`, + 409 + ) + } + + audit({ + type: 'RECOVERY_CANCELLED', + requestId: request.id, + userId: input.userId, + quorumReachedAt: request.quorumReachedAt?.toISOString() ?? null, + }) + + void notifyRecoveryCancelled({ + requestId: request.id, + userId: input.userId, + cancelledAt: cancelledAt.toISOString(), + }) + + logger.warn('[Guardians] Recovery cancelled by the account owner', { + requestId: request.id, + userId: input.userId, + quorumHadBeenReached: Boolean(request.quorumReachedAt), + }) + + const updated = await fetchRequestRow(request.id, database) + return toRequestView(updated!, updated!.approvals) +} + +// ─── Execution ────────────────────────────────────────────────────────────── + +export interface ExecutionOutcome { + id: string + userId: string + status: RequestStatus + revokedSessions: number + executedAt: string | null + executeAfter: string | null + /** False when the request was not due, or another actor got there first. */ + executed: boolean +} + +/** + * Revoke EVERY live session, destroying refresh material as it goes. + * + * Uses revokeSession() rather than a bare updateMany because #472 made refresh + * tokens rotating and destructive-on-revoke: clearing only `revokedAt` would + * leave a refresh token issued before the recovery valid, and an attacker who + * triggered the recovery would keep the account. + * + * Auth on this platform is wallet-signature based, so there is no password or + * shared secret to rotate. Destroying every session and every refresh token IS + * the credential reset: the only thing that can mint a new session is + * possession of the wallet, which the recovery does not confer. + */ +async function revokeAllSessions( + userId: string, + database: Db = db +): Promise { + const sessions = await database.session.findMany({ + where: { userId, revokedAt: null }, + select: { id: true, deviceType: true, approxLocation: true }, + }) + + for (const session of sessions) { + try { + await revokeSession(session.id, 'account_recovery', { + userId, + deviceType: session.deviceType, + approxLocation: session.approxLocation, + }) + } catch (err) { + // Keep going: one session that fails to revoke must not strand the rest + // of the account in a half-recovered state. The request still completes, + // and the failure is loud in the log and in the audit payload. + logger.error('[Guardians] Failed to revoke a session during recovery', { + userId, + sessionId: session.id, + error: err instanceof Error ? err.message : String(err), + }) + } + } + + return sessions.length +} + +/** + * Execute a recovery whose delay has elapsed. PLATFORM-SIDE ONLY. + * + * There is deliberately no HTTP route that calls this. A route would be + * reachable only by an authenticated user of the account being recovered — and + * the moment it succeeded, every one of their sessions would be revoked, so + * nobody could ever call it. The sweep in src/jobs/guardianRecoverySweep.ts is + * the only caller, and it passes a `now` it controls. + * + * Re-checks `executeAfter` even though the query already filtered on it, so that + * a bug in the caller's filter cannot shorten the mandatory delay. + */ +export async function executeRecovery( + requestId: string, + now: Date = new Date(), + database: Db = db +): Promise { + const request = await database.recoveryRequest.findUnique({ + where: { id: requestId }, + }) + + if (!request) { + throw new RecoveryError( + 'request_not_found', + 'Recovery request not found.', + 404 + ) + } + + const notExecuted = (): ExecutionOutcome => ({ + id: request.id, + userId: request.userId, + status: request.status as RequestStatus, + revokedSessions: 0, + executedAt: null, + executeAfter: request.executeAfter?.toISOString() ?? null, + executed: false, + }) + + if (request.status !== 'QUORUM_REACHED') { + return notExecuted() + } + if (!request.executeAfter) { + // Reached QUORUM_REACHED without a deadline. Structurally impossible via + // the service, so treat it as a refusal rather than a recovery. + logger.error( + '[Guardians] Refusing to execute a recovery with no deadline', + { + requestId, + } + ) + return notExecuted() + } + if (request.executeAfter.getTime() > now.getTime()) { + return notExecuted() + } + if (request.expiresAt && request.expiresAt.getTime() < now.getTime()) { + await database.recoveryRequest.updateMany({ + where: { id: request.id, status: 'QUORUM_REACHED' }, + data: { status: 'EXPIRED' }, + }) + audit({ type: 'RECOVERY_EXPIRED', requestId, userId: request.userId }) + return notExecuted() + } + + const executedAt = new Date() + + // The CAS that decides the cancel-vs-execute race. Matches on the exact + // deadline we verified, so a cancellation landing first (status no longer + // QUORUM_REACHED) loses cleanly. + const claimed = await database.recoveryRequest.updateMany({ + where: { + id: request.id, + status: 'QUORUM_REACHED', + executeAfter: { lte: now }, + }, + data: { status: 'COMPLETED', executedAt }, + }) + + if (claimed.count === 0) { + return notExecuted() + } + + const revokedSessions = await revokeAllSessions(request.userId, database) + + audit({ + type: 'RECOVERY_COMPLETED', + requestId, + userId: request.userId, + revokedSessions, + quorumReachedAt: request.quorumReachedAt?.toISOString() ?? null, + }) + + void notifyRecoveryCompleted({ + requestId, + userId: request.userId, + revokedSessions, + executedAt: executedAt.toISOString(), + }) + + logger.warn('[Guardians] Recovery executed; all sessions revoked', { + requestId, + userId: request.userId, + revokedSessions, + }) + + return { + id: request.id, + userId: request.userId, + status: 'COMPLETED', + revokedSessions, + executedAt: executedAt.toISOString(), + executeAfter: request.executeAfter.toISOString(), + executed: true, + } +} + +/** Mark requests that aged out without ever reaching quorum. */ +export async function expireStaleRequests( + now: Date = new Date(), + database: Db = db +): Promise { + const stale = await database.recoveryRequest.findMany({ + where: { + status: { in: OPEN_REQUEST_STATUSES }, + expiresAt: { lt: now }, + }, + select: { id: true, userId: true }, + }) + + if (stale.length === 0) return 0 + + const result = await database.recoveryRequest.updateMany({ + where: { + id: { in: stale.map((r) => r.id) }, + status: { in: OPEN_REQUEST_STATUSES }, + }, + data: { status: 'EXPIRED' }, + }) + + for (const row of stale) { + audit({ type: 'RECOVERY_EXPIRED', requestId: row.id, userId: row.userId }) + } + + return result.count +} diff --git a/src/index.ts b/src/index.ts index 7437dece..fe68b972 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,7 @@ import { startFeeOracle, stopFeeOracle } from './stellar/feeOracle' import { scheduleProtocolRiskScoring } from './jobs/protocolRiskScoring' import { schedulePortfolioRiskJob } from './jobs/portfolioRisk' import { scheduleApprovalExpiry } from './jobs/approvalExpiry' +import { scheduleGuardianRecoverySweep } from './jobs/guardianRecoverySweep' import { scheduleReserveReconciliation } from './jobs/reserveReconciliation' import { scheduleLoanAccrual } from './jobs/loanAccrual' import { scheduleLoanLiquidationMonitor } from './jobs/loanLiquidationMonitor' @@ -104,6 +105,7 @@ import keysRouter from './routes/keys' import sessionsRouter from './routes/sessions' import streamRouter from './routes/stream' import notificationsRouter from './routes/notifications' +import recoveryRouter from './routes/recovery' import notificationDlqRouter from './routes/notification-dlq' import networkRouter from './routes/network' import netWorthRouter from './routes/net-worth' @@ -147,6 +149,9 @@ let attributionHandle: NodeJS.Timeout | null = null let outboxDispatcherHandle: NodeJS.Timeout | null = null let portfolioRiskJobHandle: NodeJS.Timeout | null = null let approvalExpiryHandle: NodeJS.Timeout | null = null +// #535 — the sweep is the ONLY thing that executes a recovery. Its interval is +// therefore the granularity of the mandatory delay window. +let guardianRecoverySweepHandle: NodeJS.Timeout | null = null let reserveReconciliationHandle: NodeJS.Timeout | null = null let outboundNotificationsHandle: NodeJS.Timeout | null = null let linkedExternalWalletSyncHandle: NodeJS.Timeout | null = null @@ -377,6 +382,12 @@ const apiRoutes: ApiRoute[] = [ { path: 'sessions', handlers: [sessionsRouter] }, { path: 'stream', handlers: [streamRouter] }, { path: 'notifications', handlers: [notificationsRouter] }, + // #535 — guardian social recovery. Mixed surface: the owner endpoints under + // this mount are authenticated, but /initiate, /invitations/respond and + // /guardian/decide are deliberately public, so those three carry their own + // tighter `recoveryRateLimiter` INSIDE the router. Do not add a blanket + // limiter here and do not remove the public endpoints. + { path: 'recovery', handlers: [recoveryRouter] }, { path: 'admin/notifications/dlq', handlers: [adminRateLimiter, notificationDlqRouter] }, { path: 'admin', handlers: [adminRateLimiter, adminRouter] }, ] @@ -503,6 +514,12 @@ async function gracefulShutdown(signal: string): Promise { logger.info('[Shutdown] Approval expiry sweep timer cleared') } + if (guardianRecoverySweepHandle) { + clearInterval(guardianRecoverySweepHandle) + guardianRecoverySweepHandle = null + logger.info('[Shutdown] Guardian recovery sweep timer cleared') + } + if (reserveReconciliationHandle) { clearInterval(reserveReconciliationHandle) reserveReconciliationHandle = null @@ -743,6 +760,7 @@ async function main(): Promise { attributionHandle = scheduleAttribution() portfolioRiskJobHandle = schedulePortfolioRiskJob() approvalExpiryHandle = scheduleApprovalExpiry() + guardianRecoverySweepHandle = scheduleGuardianRecoverySweep() reserveReconciliationHandle = scheduleReserveReconciliation() outboundNotificationsHandle = scheduleOutboundNotifications() linkedExternalWalletSyncHandle = scheduleLinkedExternalWalletSync() diff --git a/src/jobs/guardianRecoverySweep.ts b/src/jobs/guardianRecoverySweep.ts new file mode 100644 index 00000000..90e0c88a --- /dev/null +++ b/src/jobs/guardianRecoverySweep.ts @@ -0,0 +1,154 @@ +/** + * Guardian recovery sweep (#535). + * + * This job is the ONLY caller of `executeRecovery`. That is deliberate and is + * the reason the service exposes no execution endpoint: + * + * - A route would be reachable only by an authenticated user of the account + * being recovered, and the instant it succeeded every one of their sessions + * would be revoked — so the caller would lock themselves out mid-request and + * nobody could ever invoke it. + * - Recovery is platform-side work. It is driven by time, not by a caller, and + * the deadline it enforces (`executeAfter`) was stamped onto the request row + * when quorum was reached and cannot be moved afterwards. + * + * Two passes per tick: + * + * 1. Expire requests that aged out before ever reaching quorum. Pure + * housekeeping — it closes rows that can no longer change anything. + * 2. Execute requests whose mandatory delay has elapsed. `executeRecovery` + * re-verifies the deadline and wins the cancel-vs-execute race with a + * conditional update, so running two of these concurrently is safe. + */ +import db from '../db' +import { logger, logBackgroundJob } from '../utils/logger' +import { + generateCorrelationId, + runWithCorrelationIdAsync, +} from '../utils/correlation' +import { config } from '../config/env' +import { recordBackgroundJob } from '../utils/metrics' +import { recordJobSuccess, recordJobFailure } from '../utils/job-metrics' +import { scheduleResilientJob } from './resilientScheduler' +import { + OPEN_REQUEST_STATUSES, + executeRecovery, + expireStaleRequests, +} from '../guardians/service' + +/** + * Find requests whose delay has elapsed. The `executeAfter <= now` filter is + * the coarse gate; `executeRecovery` re-checks it per row so that a bug in this + * query cannot shorten the mandatory window. + */ +async function findDueRequests( + now: Date +): Promise> { + return db.recoveryRequest.findMany({ + where: { + status: 'QUORUM_REACHED', + executeAfter: { lte: now }, + expiresAt: { gt: now }, + }, + select: { id: true, userId: true }, + orderBy: { executeAfter: 'asc' }, + take: config.recovery.sweepBatchSize, + }) +} + +export async function sweepGuardianRecovery(): Promise { + const correlationId = generateCorrelationId() + return runWithCorrelationIdAsync(correlationId, async () => { + const startTime = Date.now() + const jobName = 'guardian_recovery_sweep' + // One `now` for the whole tick. Deriving a fresh Date() per row would let a + // long-running sweep execute a request whose deadline had not actually + // passed at the start of the pass. + const now = new Date() + + try { + const expired = await expireStaleRequests(now) + + const due = await findDueRequests(now) + + let executed = 0 + let skipped = 0 + + for (const request of due) { + try { + const outcome = await executeRecovery(request.id, now) + if (outcome.executed) { + executed++ + } else { + // Lost the race to a cancellation, an expiry, or another pod. Not an + // error: the conditional update in executeRecovery decided it. + skipped++ + } + } catch (err) { + // One bad request must not strand the rest of the batch. Leave it for + // the next tick; if it is genuinely stuck, the error surfaces on every + // pass rather than being swallowed. + skipped++ + logger.error('[Guardians] Recovery execution failed for a request', { + requestId: request.id, + userId: request.userId, + error: err instanceof Error ? err.message : String(err), + }) + } + } + + if (executed > 0) { + logger.warn('[Guardians] Recovery sweep executed due requests', { + executed, + skipped, + expired, + }) + } + + const durationMs = Date.now() - startTime + const duration = durationMs / 1000 + + logBackgroundJob(jobName, 'success', duration, correlationId, { + dueCount: due.length, + executed, + skipped, + expired, + }) + + recordBackgroundJob(jobName, 'success', duration) + recordJobSuccess(jobName, durationMs) + } catch (error) { + const durationMs = Date.now() - startTime + const duration = durationMs / 1000 + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + + logBackgroundJob(jobName, 'failed', duration, correlationId, { + error: errorMessage, + }) + + recordBackgroundJob(jobName, 'failed', duration) + recordJobFailure(jobName, durationMs, error) + } + }) +} + +/** + * Schedule the recovery sweep. Runs once at startup and then on a fixed + * interval; `scheduleResilientJob` prevents overlapping runs on this pod and + * retries transient failures with backoff. + * + * @returns A NodeJS.Timeout handle (pass to clearInterval on shutdown). + */ +export function scheduleGuardianRecoverySweep(): NodeJS.Timeout { + const handle = scheduleResilientJob({ + jobName: 'guardian_recovery_sweep', + task: sweepGuardianRecovery, + intervalMs: config.recovery.sweepIntervalMs, + }) + + logger.info( + `[Guardians] Recovery sweep scheduled (interval: ${config.recovery.sweepIntervalMs}ms, open statuses: ${OPEN_REQUEST_STATUSES.length})` + ) + return handle +} diff --git a/src/mail/templates/index.ts b/src/mail/templates/index.ts index 5ff2eb86..8bb78c9e 100644 --- a/src/mail/templates/index.ts +++ b/src/mail/templates/index.ts @@ -66,3 +66,311 @@ Manage Preferences: https://neurowealth.app/preferences return { to, subject, html, text } } + +// ─── #535 Guardian-based social recovery ───────────────────────────────────── + +function recoveryActionBlock( + heading: string, + intro: string, + actionLabel: string, + actionUrl: string, + footer: string +): string { + return ` +
+

${heading}

+

${intro}

+

${actionLabel}

+

Or copy this link:
${actionUrl}

+
+

${footer}

+
+ ` +} + +/** + * Sent to the ACCOUNT OWNER's registered email the moment a recovery request is + * opened against it. This is the load-bearing alert of the whole feature: the + * claimant may have social-engineered every guardian, but the owner still + * receives this, and the request stays cancellable until the delay expires. + */ +export function renderRecoveryInitiatedAlert( + to: string, + data: { + reason: string + requiredApprovals: number + acceptedGuardians: number + initiateAt: string + } +): MailMessage { + const cancelUrl = `${ + process.env.APP_URL || 'https://neurowealth.app' + }/account/recovery` + const intro = `A request to recover access to your account was submitted at ${data.initiateAt}. If you did not make this request, cancel it — cancellation is immediate and does not require your guardians to agree.` + const footer = + 'If you did not make this request, someone may be attempting to take over your account. Do not share any codes or approve anything on their behalf. NeuroWealth will never ask you to disable these alerts.' + + return { + to, + subject: + '[SECURITY] Account recovery requested - act now if this was not you', + html: recoveryActionBlock( + 'Account recovery requested', + intro, + 'Review and cancel', + cancelUrl, + footer + ), + text: ` +ACCOUNT RECOVERY REQUESTED + +A request to recover access to your account was submitted at ${data.initiateAt}. + +Stated reason: ${data.reason} +Approvals needed: ${data.requiredApprovals} of ${data.acceptedGuardians} accepted guardians + +If you did NOT make this request, cancel it now. Cancellation is immediate and +does not require your guardians to agree. + +Review and cancel: ${cancelUrl} + +If you did not make this request, someone may be attempting to take over your +account. Do not share any codes or approve anything on their behalf. +NeuroWealth will never ask you to disable these alerts. + ` + .trim() + .replace(/^ {4}/gm, ''), + } +} + +/** + * Sent to every accepted guardian when a recovery is opened against someone they + * are a guardian for. Carries the individual decision link, so approving is an + * explicit per-guardian action that cannot be batched. + */ +export function renderGuardianApprovalRequest( + to: string, + data: { + requestId: string + approveUrl: string + executeAfter: string | null + /** Masked wallet hint, so the guardian can tell WHICH account this is. */ + accountHint?: string + /** The claimant's stated reason. Attacker-controlled free text. */ + reason?: string + requiredApprovals?: number + expiresAt?: string + } +): MailMessage { + // The stated reason is the single most useful thing a guardian has, but it is + // attacker-controlled. Escape it: this string is rendered into HTML. + const escape = (s: string): string => + s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + + const who = data.accountHint ?? 'Somebody' + const reasonLine = data.reason + ? `They gave this reason: ${escape(data.reason)}` + : '' + + const intro = `${who} has asked to recover access to an account you are a guardian for. ${reasonLine} Nothing has happened yet: a recovery cannot complete until it has your explicit approval AND the mandatory waiting period passes. Approving only confirms you recognise them as the account owner — it does not give you access to the account, and we will never ask you for a password, code, or payment.` + const footer = + "Only approve if you personally know this person, are expecting this, and have spoken to them through a channel you already trust. If you are unsure, decline — a declined approval is always safer than a mistaken one. Guardians are chosen precisely because a stranger's say-so is not enough." + + return { + to, + subject: + '[ACTION NEEDED] You are a guardian for an account recovery request', + html: recoveryActionBlock( + 'Guardian approval needed', + intro, + 'Review this request', + data.approveUrl, + footer + ), + text: ` +GUARDIAN APPROVAL NEEDED + +${who} has asked to recover access to an account you are a guardian for. +${data.reason ? `Stated reason: ${data.reason}` : ''} +Nothing has happened yet: a recovery cannot complete until it has your explicit +approval AND the mandatory waiting period passes. + +Approving only confirms that you recognise them as the account owner. It does +NOT give you access to the account. We will never ask you for a password, a +code, or a payment in order to approve. + +Request: ${data.requestId} +Earliest completion: ${ + data.executeAfter ?? 'after the waiting period once quorum is reached' + } +${data.expiresAt ? `This request expires ${data.expiresAt}.` : ''} + +Review this request: ${data.approveUrl} + +Only approve if you personally know this person, are expecting this, and have +spoken to them through a channel you already trust. If you are unsure, decline. + ` + .trim() + .replace(/^ {4}/gm, ''), + } +} + +/** + * Sent to the owner when quorum is reached, so the cooling-off window is not a + * surprise: this is the moment the request becomes real, and the last moment it + * can be stopped without guardian involvement. + */ +export function renderRecoveryQuorumReachedAlert( + to: string, + data: { requiredApprovals: number; executeAfter: string } +): MailMessage { + const cancelUrl = `${ + process.env.APP_URL || 'https://neurowealth.app' + }/account/recovery` + + return { + to, + subject: + '[SECURITY] Recovery approved - you can still cancel until the deadline', + html: recoveryActionBlock( + 'Your recovery request has enough approvals', + `Your guardians have approved this request (${data.requiredApprovals} approvals). It will take effect at ${data.executeAfter}, and every existing session will be revoked when it does. Until then you can cancel it yourself, immediately, with no guardian consensus required.`, + 'Cancel recovery', + cancelUrl, + 'If you did not request this, cancel now and change the email address on your account — somebody may have reached your guardians.' + ), + text: ` +RECOVERY APPROVED - STILL CANCELLABLE + +Your guardians approved this request (${data.requiredApprovals} approvals). + +It takes effect at: ${data.executeAfter} +Every existing session will be revoked when it does. + +You can still cancel it yourself, immediately, with no guardian consensus +required. + +Cancel recovery: ${cancelUrl} + +If you did not request this, cancel now and change the email address on your +account - somebody may have reached your guardians. + ` + .trim() + .replace(/^ {4}/gm, ''), + } +} + +/** + * Sent to the owner after a recovery completes. The sessions are already gone by + * the time this is sent, so this doubles as the confirmation and as the warning + * that the account's access history has just changed. + */ +export function renderRecoveryCompletedAlert( + to: string, + data: { revokedSessions: number; executedAt: string } +): MailMessage { + return { + to, + subject: '[SECURITY] Account recovery completed - all sessions revoked', + html: recoveryActionBlock( + 'Account recovery completed', + `Access to your account was reset at ${data.executedAt} and ${data.revokedSessions} existing session(s) were revoked, so you will need to sign in again everywhere.`, + 'Sign in', + `${process.env.APP_URL || 'https://neurowealth.app'}/login`, + 'If you did not authorise this, your account has been recovered by someone else. Rotate your guardian set immediately and contact support.' + ), + text: ` +ACCOUNT RECOVERY COMPLETED + +Access to your account was reset at ${data.executedAt}. +${data.revokedSessions} existing session(s) were revoked - you will need to sign +in again everywhere. + +If you did not authorise this, your account has been recovered by someone else. +Rotate your guardian set immediately and contact support. + ` + .trim() + .replace(/^ {4}/gm, ''), + } +} + +/** Sent to the owner the moment THEY cancel, confirming the attempt is closed. */ +export function renderRecoveryCancelledNotice( + to: string, + data: { cancelledAt: string } +): MailMessage { + return { + to, + subject: 'Account recovery cancelled', + html: recoveryActionBlock( + 'Recovery cancelled', + `You cancelled the recovery request for your account at ${data.cancelledAt}. It will not proceed and no further approvals will be sought.`, + 'Manage guardians', + `${process.env.APP_URL || 'https://neurowealth.app'}/account/guardians`, + 'If this was not you, review your guardian list — removing a guardian takes effect immediately.' + ), + text: ` +RECOVERY CANCELLED + +You cancelled the recovery request for your account at ${data.cancelledAt}. +It will not proceed and no further approvals will be sought. + +If this was not you, review your guardian list - removing a guardian takes +effect immediately. + ` + .trim() + .replace(/^ {4}/gm, ''), + } +} + +/** + * Guardian nomination invite. Carries no recovery authority at all — accepting + * only enrolls the recipient as a co-signer. Saying so explicitly is the point: + * an external contact receiving this should understand they are being asked to + * stand ready, not to grant anything now. + */ +export function renderGuardianInvite( + to: string, + data: { accountHint: string; acceptUrl: string; expiresAt: string } +): MailMessage { + return { + to, + subject: + 'You have been nominated as a recovery guardian for a NeuroWealth account', + html: ` +
+

Recovery guardian nomination

+

${data.accountHint} has nominated you as a recovery guardian for their NeuroWealth account.

+

What accepting means: if they ever lose access to their account, we may contact you to ask whether you can confirm their identity. You are not being asked for any credential, code, or money, and accepting does not give you any access to the account.

+

Declining is fine and has no effect on the account. You can ask to be removed at any time.

+

Accept or decline

+

Or copy this link: ${data.acceptUrl}

+

This invitation expires ${data.expiresAt}.

+
+ `, + text: ` +RECOVERY GUARDIAN NOMINATION + +${data.accountHint} has nominated you as a recovery guardian for their +NeuroWealth account. + +What accepting means: if they ever lose access to their account, we may contact +you to ask whether you can confirm their identity. You are NOT being asked for +any credential, code, or money, and accepting does not give you any access to +the account. + +Declining is fine and has no effect on the account. You can ask to be removed at +any time. + +Accept or decline: ${data.acceptUrl} + +This invitation expires ${data.expiresAt}. + ` + .trim() + .replace(/^ {4}/gm, ''), + } +} diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts index fd71a239..48e8e366 100644 --- a/src/middleware/rateLimiter.ts +++ b/src/middleware/rateLimiter.ts @@ -414,6 +414,27 @@ export const webhookRateLimiter = buildRateLimiter({ message: 'Too many webhook requests. Please try again later.', }) +/** + * Guardian recovery limiter (#535) — the PUBLIC recovery endpoints. + * Defaults: 10 req / 15 min (env: RECOVERY_RATE_LIMIT_MAX / RECOVERY_RATE_LIMIT_WINDOW_MS). + * + * Applied only to the unauthenticated recovery endpoints, not to the whole + * router. The reason is abuse rather than credential guessing: recovery tokens + * are 256-bit random, so they cannot be brute-forced, but `POST /recovery/initiate` + * is deliberately open to anyone and triggers real email to real guardians. Left + * unthrottled it is an email-bombing and SMS-flooding primitive aimed at a + * third party who never consented to be a guardian. Owner endpoints sit under the + * caller's own session and are already limited by `authRateLimiter`. + */ +export const recoveryRateLimiter = buildRateLimiter({ + windowMs: config.security.recoveryRateLimit.windowMs, + max: config.security.recoveryRateLimit.max, + skip: isTrusted, + limiterType: 'recovery', + message: + 'Too many recovery requests. Please wait before trying again, and contact support if you are locked out.', +}) + /** * Sensitive-operation limiter (#473) — money movement and credential changes. * Defaults: 10 req / 15 min (env: SENSITIVE_RATE_LIMIT_MAX / SENSITIVE_RATE_LIMIT_WINDOW_MS). diff --git a/src/routes/recovery.ts b/src/routes/recovery.ts new file mode 100644 index 00000000..1ec2617c --- /dev/null +++ b/src/routes/recovery.ts @@ -0,0 +1,341 @@ +/** + * Guardian-Based Social Recovery API (#535). + * + * Two groups of endpoints, and the split between them is the security model: + * + * - OWNER endpoints (requireAuth): manage the guardian set, read your own + * request, and cancel it. Every one of them keys off `req.auth.userId` from + * the caller's own session and never from a body or path field, so there is + * no id-oracle anywhere in this file. + * + * - PUBLIC endpoints (no auth): the "I cannot get in" door, and the token-gated + * accept/approve flows for guardians. These are the only endpoints reachable + * by somebody who has lost their account, which is exactly why they are the + * ones that must not leak whether a wallet exists. + * + * There is deliberately NO route that executes a recovery. Execution is + * platform-side only (src/jobs/guardianRecoverySweep.ts): a route would be + * reachable only by an authenticated user of the account being recovered, and + * the moment it succeeded every one of their sessions would be revoked, so + * nobody could ever call it. + */ +import { Router, Request, Response } from 'express' +import { requireAuth } from '../middleware/authenticate' +import { validate } from '../middleware/validate' +import { recoveryRateLimiter } from '../middleware/rateLimiter' +import { sendError } from '../utils/errors' +import { logger } from '../utils/logger' +import { + acceptGuardianInviteAsUser, + approveRecoveryAsExternalGuardian, + approveRecoveryAsGuardian, + cancelRecovery, + getOrCreateRecoveryPolicy, + getRecoveryRequestForOwner, + initiateRecovery, + listGuardians, + listRequestsAwaitingGuardian, + nominateGuardian, + removeGuardian, + respondToGuardianInvite, + toRecoveryError, + updateRecoveryPolicy, +} from '../guardians/service' +import { + approveRecoverySchema, + externalApprovalSchema, + guardianIdParamSchema, + initiateRecoverySchema, + nominateGuardianSchema, + recoveryRequestIdParamSchema, + respondToGuardianInviteSchema, + updateRecoveryPolicySchema, +} from '../validators/recovery-validators' + +const router = Router() + +/** + * A single place that turns anything thrown by the service into a response, so + * a `RecoveryError` keeps its own status and code while an unexpected throw + * still becomes a clean 500 instead of leaking a stack trace. + */ +function handleError(res: Response, err: unknown, context: string): Response { + const recoveryError = toRecoveryError(err) + + if (recoveryError.status >= 500) { + logger.error(`[Recovery] ${context} failed`, { + error: recoveryError.message, + code: recoveryError.code, + }) + return sendError(res, 500, 'Something went wrong. Please try again.') + } + + return sendError(res, recoveryError.status, recoveryError.message, { + code: recoveryError.code, + }) +} + +// ─── Owner: recovery policy ────────────────────────────────────────────────── + +router.get('/policy', requireAuth, async (req: Request, res: Response) => { + try { + const policy = await getOrCreateRecoveryPolicy(req.auth!.userId) + return res.json({ policy }) + } catch (err) { + return handleError(res, err, 'Get policy') + } +}) + +router.put( + '/policy', + requireAuth, + validate({ body: updateRecoveryPolicySchema }), + async (req: Request, res: Response) => { + try { + const policy = await updateRecoveryPolicy(req.auth!.userId, req.body) + return res.json({ policy }) + } catch (err) { + return handleError(res, err, 'Update policy') + } + } +) + +// ─── Owner: guardian set ───────────────────────────────────────────────────── + +router.get('/guardians', requireAuth, async (req: Request, res: Response) => { + try { + const guardians = await listGuardians(req.auth!.userId) + return res.json({ guardians, count: guardians.length }) + } catch (err) { + return handleError(res, err, 'List guardians') + } +}) + +/** + * Nominating a guardian. The invite token is returned ONCE, in this response, + * and only its digest is stored — the caller is responsible for delivering it. + * The service also sends the invitation out of band. + */ +router.post( + '/guardians', + requireAuth, + validate({ body: nominateGuardianSchema }), + async (req: Request, res: Response) => { + try { + const result = await nominateGuardian({ + userId: req.auth!.userId, + guardianUserId: req.body.guardianUserId, + externalEmail: req.body.externalEmail, + externalPhone: req.body.externalPhone, + }) + return res.status(201).json(result) + } catch (err) { + return handleError(res, err, 'Nominate guardian') + } + } +) + +/** Removal is immediate and does not need the other guardians or a quorum. */ +router.delete( + '/guardians/:guardianId', + requireAuth, + validate({ params: guardianIdParamSchema }), + async (req: Request, res: Response) => { + try { + const guardian = await removeGuardian({ + userId: req.auth!.userId, + guardianId: req.params.guardianId, + }) + return res.json({ guardian }) + } catch (err) { + return handleError(res, err, 'Remove guardian') + } + } +) + +/** + * A PLATFORM guardian accepting their nomination. Unauthenticated in spirit + * but authenticated in fact: the service re-checks that the session's user is + * the nominated guardian, so `requireAuth` is necessary but not sufficient. + */ +router.post( + '/guardians/:guardianId/accept', + requireAuth, + validate({ params: guardianIdParamSchema }), + async (req: Request, res: Response) => { + try { + const guardian = await acceptGuardianInviteAsUser({ + guardianId: req.params.guardianId, + actorUserId: req.auth!.userId, + }) + return res.json({ guardian }) + } catch (err) { + return handleError(res, err, 'Accept guardian invitation') + } + } +) + +/** An EXTERNAL contact accepting, or declining, with the token they were sent. */ +router.post( + '/invitations/respond', + recoveryRateLimiter, + validate({ body: respondToGuardianInviteSchema }), + async (req: Request, res: Response) => { + try { + const guardian = await respondToGuardianInvite({ + token: req.body.token, + accept: req.body.accept, + }) + return res.json({ guardian }) + } catch (err) { + return handleError(res, err, 'Respond to guardian invitation') + } + } +) + +// ─── Owner: the request itself ─────────────────────────────────────────────── + +/** + * PUBLIC and rate limited, because it is the only way in for a locked-out + * owner. It deliberately answers 202 with a fixed body in EVERY case — account + * found or not, primary or sub-account, guardians configured or not, a request + * already open or not. Any variation at all turns this into a wallet-existence + * and wallet-sub-account oracle, which is worth more to an attacker than the + * recovery itself. The owner learns the outcome from the loud alert instead. + */ +router.post( + '/initiate', + recoveryRateLimiter, + validate({ body: initiateRecoverySchema }), + async (req: Request, res: Response) => { + try { + await initiateRecovery({ + walletAddress: req.body.walletAddress, + reason: req.body.reason, + }) + } catch (err) { + // A genuine fault still must not distinguish itself: log loudly, answer + // with the same generic body the success path returns. + logger.error('[Recovery] Initiate recovery failed', { + error: err instanceof Error ? err.message : String(err), + }) + } + + return res.status(202).json({ + status: 'accepted', + message: + 'If an eligible primary account matches that wallet address, its accepted guardians have been contacted and the account owner has been alerted.', + }) + } +) + +/** Owner-only. Reads the caller's own request; the id is not an oracle. */ +router.get( + '/requests/:requestId', + requireAuth, + validate({ params: recoveryRequestIdParamSchema }), + async (req: Request, res: Response) => { + try { + const request = await getRecoveryRequestForOwner({ + requestId: req.params.requestId, + userId: req.auth!.userId, + }) + return res.json({ request }) + } catch (err) { + return handleError(res, err, 'Get recovery request') + } + } +) + +/** + * Owner cancels their own request: immediate, unilateral, no quorum. Permitted + * at any point before execution wins the race, including after quorum. + */ +router.post( + '/requests/:requestId/cancel', + requireAuth, + validate({ params: recoveryRequestIdParamSchema }), + async (req: Request, res: Response) => { + try { + const request = await cancelRecovery({ + requestId: req.params.requestId, + userId: req.auth!.userId, + }) + return res.json({ request }) + } catch (err) { + return handleError(res, err, 'Cancel recovery') + } + } +) + +// ─── Guardian: decisions ───────────────────────────────────────────────────── + +/** Requests this platform user has been asked to decide on. */ +router.get( + '/guardian/requests', + requireAuth, + async (req: Request, res: Response) => { + try { + const requests = await listRequestsAwaitingGuardian(req.auth!.userId) + return res.json({ requests, count: requests.length }) + } catch (err) { + return handleError(res, err, 'List guardian requests') + } + } +) + +/** + * A platform guardian deciding, proven by their own session. The service + * re-checks the nomination, so this cannot be used to decide for somebody + * else's account. + */ +router.post( + '/guardian/requests/:requestId/decide', + requireAuth, + validate({ + params: recoveryRequestIdParamSchema, + body: approveRecoverySchema, + }), + async (req: Request, res: Response) => { + try { + const request = await approveRecoveryAsGuardian({ + requestId: req.params.requestId, + actorUserId: req.auth!.userId, + approved: req.body.approved, + note: req.body.note, + ipAddress: req.ip, + }) + return res.json({ request }) + } catch (err) { + return handleError(res, err, 'Guardian decision') + } + } +) + +/** + * An EXTERNAL contact deciding, proven only by the token in their link. The + * token identifies WHICH guardian is deciding, so one guardian can never decide + * on another's behalf, and the one-decision-per-(request, guardian) row stops a + * replayed token from producing a second vote. + */ +router.post( + '/guardian/decide', + recoveryRateLimiter, + validate({ body: externalApprovalSchema }), + async (req: Request, res: Response) => { + try { + const request = await approveRecoveryAsExternalGuardian({ + requestId: req.body.requestId, + token: req.body.token, + approved: req.body.approved, + note: req.body.note, + ipAddress: req.ip, + }) + return res.json({ request }) + } catch (err) { + return handleError(res, err, 'External guardian decision') + } + } +) + +export default router diff --git a/src/services/refresh-token.service.ts b/src/services/refresh-token.service.ts index 424c2ef9..31cb745e 100644 --- a/src/services/refresh-token.service.ts +++ b/src/services/refresh-token.service.ts @@ -50,6 +50,7 @@ export type RevocationReason = | 'logout_others' | 'admin' | 'refresh_token_reuse' + | 'account_recovery' | `session_anomaly:${SessionAnomalyHeuristic}` export type RefreshFailureReason = diff --git a/src/utils/api-formatters.ts b/src/utils/api-formatters.ts index 2b643716..8142ebae 100644 --- a/src/utils/api-formatters.ts +++ b/src/utils/api-formatters.ts @@ -273,6 +273,57 @@ const USER_EVENT_PAYLOAD_ALLOWLIST: Record = { // `error` is the user's own op failure text, already sent to their webhooks. 'outbox.op_failed': ['opId', 'kind', 'attempts', 'error'], 'portfolio.updated': ['protocolName', 'positionsAffected', 'reason'], + // #535 — guardian social recovery. Two deliberate omissions. + // + // `reason` is the claimant's free text. It is attacker-controlled, so it must + // not reach a socket that renders anything, and it is already in the audit + // trail and the owner's email alert. + // + // `ownerUserId` is absent for the same reason `followerUserId` is: the frame + // is already scoped to the one user it is for. Naming another user's id in a + // payload is the pattern this allowlist exists to prevent — a delegated + // parent connection must not be able to enumerate who is guarding whom by + // reading frames off a child's stream. + 'security.recovery_initiated': [ + 'requestId', + 'status', + 'requiredApprovals', + 'acceptedGuardians', + 'approvedBy', + 'executeAfter', + 'cancelledAt', + 'revokedSessions', + 'initiatedAt', + 'occurredAt', + ], + 'security.recovery_quorum_reached': [ + 'requestId', + 'requiredApprovals', + 'executeAfter', + 'occurredAt', + ], + 'security.recovery_cancelled': [ + 'requestId', + 'status', + 'executeAfter', + 'cancelledAt', + 'occurredAt', + ], + 'security.recovery_completed': [ + 'requestId', + 'status', + 'revokedSessions', + 'executedAt', + 'occurredAt', + ], + 'security.guardian_approval_requested': [ + 'requestId', + 'accountHint', + 'requiredApprovals', + 'executeAfter', + 'expiresAt', + 'occurredAt', + ], } /** diff --git a/src/validators/recovery-validators.ts b/src/validators/recovery-validators.ts new file mode 100644 index 00000000..56bccec5 --- /dev/null +++ b/src/validators/recovery-validators.ts @@ -0,0 +1,180 @@ +import { z } from 'zod' + +/** + * Request validation for guardian-based social recovery (#535). + * + * Two rules shape everything here: + * + * 1. Normalisation is not optional. Guardian identity is a security decision, + * so `Guardian@Example.com` and `guardian@example.com` must never become + * two guardians, and E.164 phone numbers are stored without punctuation. + * 2. `POST /recovery/initiate` is a PUBLIC endpoint — the claimant is by + * definition someone who cannot authenticate. Its schema is therefore the + * main thing standing between an unauthenticated caller and a live recovery + * request, and it deliberately carries no token, signature, or caller + * identity: the only thing it can do is ring the alarm and start a clock. + * That is intentional, and it is why the loud alerting in + * src/guardians/notifications.ts is not optional. + */ + +/** Guardrails on the free-text `reason`. See `sanitizeReason` in the service. */ +const reasonSchema = z + .string() + .trim() + .min(1, 'A reason is required') + .max(500, 'Reason must be 500 characters or fewer') + +const uuidSchema = z.string().uuid() + +/** E.164, digits and an optional leading `+` only. */ +const phoneSchema = z + .string() + .trim() + .transform((v) => v.replace(/[^\d+]/g, '')) + .refine( + (v) => /^\+?[1-9]\d{6,14}$/.test(v), + 'Phone number must be E.164, e.g. +14155550123' + ) + +const emailSchema = z + .string() + .trim() + .toLowerCase() + .email('Valid email address is required') + +/** + * Guardian nomination. At least one identifier is required — a row with none of + * them would be a guardian nobody can ever reach, which silently reduces quorum + * and is exactly the "all guardians unreachable" failure mode the feature + * accepts but must not create by accident. The service re-checks this after + * `.optional()` fields are dropped, because `.refine` cannot see a body where + * all three keys were omitted. + */ +export const nominateGuardianSchema = z + .object({ + guardianUserId: uuidSchema.optional(), + externalEmail: emailSchema.optional(), + externalPhone: phoneSchema.optional(), + }) + .refine( + (v) => Boolean(v.guardianUserId ?? v.externalEmail ?? v.externalPhone), + { + message: + 'Provide guardianUserId, externalEmail, or externalPhone to identify the guardian', + path: ['guardianUserId'], + } + ) + .refine( + (v) => + // A guardian is identified EITHER by a platform account OR by external + // contact details, never by both. Accepting a mixed identity would let + // one nomination resolve to two different parties depending on which + // identifier the lookup happened to prefer, and a guardian must be able + // to answer "is this me?" without ambiguity. Email and phone together are + // fine: that is one external person with two ways to reach them. + !(v.guardianUserId && (v.externalEmail || v.externalPhone)), + { + message: + 'Identify the guardian either by guardianUserId or by external contact details, not both', + path: ['guardianUserId'], + } + ) + +/** + * Guardian accept / decline. Carries the invite token, which is the only proof + * of identity available to an external contact — a platform guardian is instead + * expected to accept through their authenticated session + * (POST /recovery/guardians/:id/accept). + */ +export const respondToGuardianInviteSchema = z.object({ + token: z.string().trim().min(1, 'Invitation token is required').max(200), + accept: z.boolean({ error: 'accept must be a boolean' }), +}) + +/** + * Recovery initiation by a locked-out claimant. Public by design: this is the + * "I cannot get in" door. `walletAddress` identifies the account being claimed; + * it is never treated as proof of anything. + */ +export const initiateRecoverySchema = z.object({ + walletAddress: z + .string() + .trim() + .min(1, 'walletAddress is required') + .max(64, 'walletAddress must be 64 characters or fewer'), + reason: reasonSchema, +}) + +/** Recovery policy edit. Bounds mirror the database CHECK constraints. */ +export const updateRecoveryPolicySchema = z + .object({ + requiredApprovals: z + .number() + .int() + .min( + 2, + 'requiredApprovals must be at least 2 — a 1-of-N quorum is never allowed' + ) + .max(10, 'requiredApprovals must be 10 or fewer') + .optional(), + recoveryDelayHours: z + .number() + .int() + .min(24, 'recoveryDelayHours must be at least 24') + .max(168, 'recoveryDelayHours must be 168 or fewer (7 days)') + .optional(), + }) + .refine( + (v) => + v.requiredApprovals !== undefined || v.recoveryDelayHours !== undefined, + { message: 'Provide at least one policy field to update' } + ) + +export const recoveryRequestIdParamSchema = z.object({ + requestId: uuidSchema, +}) + +export const guardianIdParamSchema = z.object({ + guardianId: uuidSchema, +}) + +/** + * A guardian's decision. `approved: false` is a recorded refusal, not a no-op: + * storing it keeps the decision auditable and stops the guardian being re-asked. + */ +export const approveRecoverySchema = z.object({ + approved: z.boolean({ error: 'approved must be a boolean' }), + note: z + .string() + .trim() + .max(500, 'Note must be 500 characters or fewer') + .optional(), +}) + +/** + * Out-of-band guardian approval for an EXTERNAL contact, who has no platform + * session. The token identifies which guardian is deciding, so one guardian can + * never approve on another's behalf and one token cannot be replayed for a + * second decision (the (requestId, guardianId) row already exists by then). + */ +export const externalApprovalSchema = z.object({ + requestId: uuidSchema, + token: z.string().trim().min(1, 'Approval token is required').max(200), + approved: z.boolean({ error: 'approved must be a boolean' }), + note: z + .string() + .trim() + .max(500, 'Note must be 500 characters or fewer') + .optional(), +}) + +export type NominateGuardianInput = z.infer +export type RespondToGuardianInviteInput = z.infer< + typeof respondToGuardianInviteSchema +> +export type InitiateRecoveryInput = z.infer +export type UpdateRecoveryPolicyInput = z.infer< + typeof updateRecoveryPolicySchema +> +export type ApproveRecoveryInput = z.infer +export type ExternalApprovalInput = z.infer diff --git a/tests/integration/rateLimiter.integration.test.ts b/tests/integration/rateLimiter.integration.test.ts index 79fd3cf7..7edd7ecb 100644 --- a/tests/integration/rateLimiter.integration.test.ts +++ b/tests/integration/rateLimiter.integration.test.ts @@ -28,6 +28,8 @@ jest.mock('../../src/config/env', () => ({ anonymousRateLimit: { windowMs: 900000, max: 60 }, authenticatedRateLimit: { windowMs: 900000, max: 600 }, sensitiveRateLimit: { windowMs: 900000, max: 10 }, + // #535 — guardian recovery limiter, also constructed at module load. + recoveryRateLimit: { windowMs: 900000, max: 10 }, trustedIps: [], internalServiceToken: '', }, diff --git a/tests/integration/rateLimiterTiering.integration.test.ts b/tests/integration/rateLimiterTiering.integration.test.ts index 820b01c9..9e6f8889 100644 --- a/tests/integration/rateLimiterTiering.integration.test.ts +++ b/tests/integration/rateLimiterTiering.integration.test.ts @@ -52,6 +52,7 @@ jest.mock('../../src/config/env', () => ({ anonymousRateLimit: { windowMs: 900000, max: 3 }, authenticatedRateLimit: { windowMs: 900000, max: 5 }, sensitiveRateLimit: { windowMs: 900000, max: 2 }, + recoveryRateLimit: { windowMs: 900000, max: 10 }, trustedIps: [], internalServiceToken: '', }, diff --git a/tests/unit/guardians/notifications.test.ts b/tests/unit/guardians/notifications.test.ts new file mode 100644 index 00000000..ff1250ad --- /dev/null +++ b/tests/unit/guardians/notifications.test.ts @@ -0,0 +1,367 @@ +/** + * Guardian recovery notification tests (#535). + * + * Two properties matter here, and both are about FAILURE: + * + * 1. The owner alert is unconditional. If a claimant has compromised every + * guardian, the owner's alert is the only thing left standing between that + * and a takeover, so every channel is attempted regardless of whether the + * others worked. + * 2. Nothing in this module throws. A mail outage must not be able to abort a + * security action that has already been recorded, and the caller must not + * have to wrap every call in a try/catch to get that guarantee. + */ + +process.env.NODE_ENV = 'test' + +jest.mock('../../../src/db', () => { + const findUnique = jest.fn() + const client: any = { user: { findUnique } } + return { + __esModule: true, + default: client, + db: client, + __mockFindUnique: findUnique, + } +}) + +jest.mock('../../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})) + +jest.mock('../../../src/mail/mailProvider', () => ({ + mailRegistry: { send: jest.fn().mockResolvedValue({ messageId: 'm1' }) }, +})) + +jest.mock('../../../src/utils/twilio-client', () => ({ + sendWhatsAppMessage: jest.fn().mockResolvedValue('SM123'), +})) + +jest.mock('../../../src/events/publisher', () => ({ + publishUserEvent: jest.fn().mockResolvedValue(undefined), +})) + +import { + notifyGuardiansOfRequest, + notifyQuorumReached, + notifyRecoveryCancelled, + notifyRecoveryCompleted, + notifyRecoveryInitiated, +} from '../../../src/guardians/notifications' + +const { mailRegistry } = require('../../../src/mail/mailProvider') +const { sendWhatsAppMessage } = require('../../../src/utils/twilio-client') +const { publishUserEvent } = require('../../../src/events/publisher') +const mockFindUnique = require('../../../src/db').__mockFindUnique + +const OWNER = 'owner-1' + +function ownerContact(overrides: Record = {}) { + return { + id: OWNER, + email: 'owner@example.com', + phone: '+15551234567', + ...overrides, + } +} + +beforeEach(() => { + jest.clearAllMocks() + mailRegistry.send.mockResolvedValue({ messageId: 'm1' }) + sendWhatsAppMessage.mockResolvedValue('SM123') + publishUserEvent.mockResolvedValue(undefined) +}) + +describe('notifyRecoveryInitiated', () => { + it('alerts the owner on every available channel', async () => { + mockFindUnique.mockResolvedValue(ownerContact()) + + await notifyRecoveryInitiated({ + requestId: 'r1', + userId: OWNER, + reason: 'Lost my phone', + requiredApprovals: 2, + acceptedGuardians: 3, + }) + + expect(publishUserEvent).toHaveBeenCalledWith( + OWNER, + 'alerts', + 'security.recovery_initiated', + expect.objectContaining({ requestId: 'r1' }) + ) + expect(mailRegistry.send).toHaveBeenCalledWith( + expect.objectContaining({ to: 'owner@example.com' }) + ) + expect(sendWhatsAppMessage).toHaveBeenCalledWith( + expect.objectContaining({ to: '+15551234567' }) + ) + }) + + it('still alerts over realtime when the mail provider is down', async () => { + // Realtime is the channel an attacker cannot intercept, so it is attempted + // first and its success does not depend on anything else. + mockFindUnique.mockResolvedValue(ownerContact()) + mailRegistry.send.mockRejectedValue(new Error('SMTP down')) + + await expect( + notifyRecoveryInitiated({ + requestId: 'r1', + userId: OWNER, + reason: 'Lost my phone', + requiredApprovals: 2, + acceptedGuardians: 3, + }) + ).resolves.toBeUndefined() + + expect(publishUserEvent).toHaveBeenCalled() + }) + + it('does not throw when every channel fails', async () => { + mockFindUnique.mockResolvedValue(ownerContact()) + mailRegistry.send.mockRejectedValue(new Error('SMTP down')) + sendWhatsAppMessage.mockRejectedValue(new Error('Twilio down')) + publishUserEvent.mockRejectedValue(new Error('redis down')) + + await expect( + notifyRecoveryInitiated({ + requestId: 'r1', + userId: OWNER, + reason: 'Lost my phone', + requiredApprovals: 2, + acceptedGuardians: 3, + }) + ).resolves.toBeUndefined() + }) + + it('skips a channel the account has no address for, without erroring', async () => { + mockFindUnique.mockResolvedValue(ownerContact({ email: null, phone: null })) + + await notifyRecoveryInitiated({ + requestId: 'r1', + userId: OWNER, + reason: 'Lost my phone', + requiredApprovals: 2, + acceptedGuardians: 3, + }) + + expect(mailRegistry.send).not.toHaveBeenCalled() + expect(sendWhatsAppMessage).not.toHaveBeenCalled() + expect(publishUserEvent).toHaveBeenCalled() + }) + + it('never puts a raw token in a log line', async () => { + const { logger } = require('../../../src/utils/logger') + mockFindUnique.mockResolvedValue(ownerContact()) + + await notifyRecoveryInitiated({ + requestId: 'r1', + userId: OWNER, + reason: 'Lost my phone', + requiredApprovals: 2, + acceptedGuardians: 3, + }) + + const logged = JSON.stringify(logger.error.mock.calls) + expect(logged).not.toMatch(/[a-f0-9]{32,}/) + }) +}) + +describe('quorum / completion / cancellation alerts', () => { + it('tells the owner the exact deadline and that they can still cancel', async () => { + mockFindUnique.mockResolvedValue(ownerContact()) + + await notifyQuorumReached({ + requestId: 'r1', + userId: OWNER, + requiredApprovals: 2, + executeAfter: '2030-01-01T00:00:00.000Z', + }) + + const whatsapp = sendWhatsAppMessage.mock.calls[0][0] + expect(whatsapp.body).toContain('cancel') + expect(whatsapp.body).toContain('2030-01-01T00:00:00.000Z') + }) + + it('reports the revoked session count on completion', async () => { + mockFindUnique.mockResolvedValue(ownerContact()) + + await notifyRecoveryCompleted({ + requestId: 'r1', + userId: OWNER, + revokedSessions: 4, + executedAt: '2030-01-01T00:00:00.000Z', + }) + + const whatsapp = sendWhatsAppMessage.mock.calls[0][0] + expect(whatsapp.body).toContain('4') + expect(publishUserEvent.mock.calls[0][3]).toMatchObject({ + revokedSessions: 4, + }) + }) + + it('emits the cancellation event', async () => { + mockFindUnique.mockResolvedValue(ownerContact()) + + await notifyRecoveryCancelled({ + requestId: 'r1', + userId: OWNER, + cancelledAt: '2030-01-01T00:00:00.000Z', + }) + + expect(publishUserEvent).toHaveBeenCalledWith( + OWNER, + 'alerts', + 'security.recovery_cancelled', + expect.objectContaining({ requestId: 'r1' }) + ) + }) +}) + +describe('notifyGuardiansOfRequest', () => { + const base = { + requestId: 'r1', + userId: OWNER, + accountHint: 'GA***WF', + reason: 'Lost my phone', + requiredApprovals: 2, + expiresAt: '2030-01-01T00:00:00.000Z', + } + + it('asks a platform guardian to sign in rather than sending a token', async () => { + mockFindUnique.mockResolvedValue(ownerContact()) + + await notifyGuardiansOfRequest({ + ...base, + guardians: [ + { + id: 'g1', + guardianUserId: 'platform-1', + externalEmail: null, + externalPhone: null, + inviteToken: null, + }, + ], + }) + + expect(publishUserEvent).toHaveBeenCalledWith( + 'platform-1', + 'alerts', + 'security.guardian_approval_requested', + expect.objectContaining({ requestId: 'r1' }) + ) + expect(mailRegistry.send).not.toHaveBeenCalled() + }) + + it('gives an external guardian a link scoped to their own token', async () => { + mockFindUnique.mockResolvedValue(ownerContact()) + + await notifyGuardiansOfRequest({ + ...base, + guardians: [ + { + id: 'g1', + guardianUserId: null, + externalEmail: 'friend@example.com', + externalPhone: null, + inviteToken: 'their-own-token', + }, + ], + }) + + const message = mailRegistry.send.mock.calls[0][0] + expect(message.to).toBe('friend@example.com') + expect(message.html).toContain('their-own-token') + }) + + it('never sends one guardian another guardian’s token', async () => { + mockFindUnique.mockResolvedValue(ownerContact()) + + await notifyGuardiansOfRequest({ + ...base, + guardians: [ + { + id: 'g1', + guardianUserId: null, + externalEmail: 'a@example.com', + externalPhone: null, + inviteToken: 'token-a', + }, + { + id: 'g2', + guardianUserId: null, + externalEmail: 'b@example.com', + externalPhone: null, + inviteToken: 'token-b', + }, + ], + }) + + const messageA = mailRegistry.send.mock.calls.find( + (c: any[]) => c[0].to === 'a@example.com' + )[0] + const messageB = mailRegistry.send.mock.calls.find( + (c: any[]) => c[0].to === 'b@example.com' + )[0] + + expect(messageA.html).toContain('token-a') + expect(messageA.html).not.toContain('token-b') + expect(messageB.html).toContain('token-b') + expect(messageB.html).not.toContain('token-a') + }) + + it('still alerts the remaining guardians when one delivery fails', async () => { + mockFindUnique.mockResolvedValue(ownerContact()) + mailRegistry.send + .mockRejectedValueOnce(new Error('first bounced')) + .mockResolvedValue({ messageId: 'm2' }) + + await expect( + notifyGuardiansOfRequest({ + ...base, + guardians: [ + { + id: 'g1', + guardianUserId: null, + externalEmail: 'a@example.com', + externalPhone: null, + inviteToken: 'token-a', + }, + { + id: 'g2', + guardianUserId: null, + externalEmail: 'b@example.com', + externalPhone: null, + inviteToken: 'token-b', + }, + ], + }) + ).resolves.toBeUndefined() + + expect(mailRegistry.send).toHaveBeenCalledTimes(2) + }) + + it('does not throw when an external guardian has no token on file', async () => { + mockFindUnique.mockResolvedValue(ownerContact()) + + await expect( + notifyGuardiansOfRequest({ + ...base, + guardians: [ + { + id: 'g1', + guardianUserId: null, + externalEmail: 'a@example.com', + externalPhone: null, + inviteToken: null, + }, + ], + }) + ).resolves.toBeUndefined() + }) +}) diff --git a/tests/unit/guardians/service.test.ts b/tests/unit/guardians/service.test.ts new file mode 100644 index 00000000..67e106a2 --- /dev/null +++ b/tests/unit/guardians/service.test.ts @@ -0,0 +1,631 @@ +/** + * Guardian social recovery service tests (#535). + * + * These tests are about the SECURITY properties, not about coverage. Each one + * encodes a claim the design makes in comments and docs; if a refactor breaks + * the behaviour but keeps the tests passing, the comments are lying. + * + * The properties under test: + * 1. A 1-of-N quorum is impossible, at every layer that can express it. + * 2. The mandatory delay is stamped ONCE and cannot be pulled forward by a + * later policy edit. + * 3. Only an explicitly ACCEPTED guardian can decide, and each decides once. + * 4. The owner can cancel unilaterally, right up to the execution race. + * 5. Execution is refused before the deadline and revokes every session. + * 6. Initiation cannot be used to probe whether a wallet exists. + */ + +process.env.NODE_ENV = 'test' + +jest.mock('../../../src/db', () => { + // Per-model mocks. A single shared `create`/`findUnique` across models makes + // assertions ambiguous about which call a test is actually looking at. + const userFindUnique = jest.fn() + const subAccountFindFirst = jest.fn() + const guardianFindUnique = jest.fn() + const guardianFindFirst = jest.fn() + const guardianFindMany = jest.fn() + const guardianCreate = jest.fn() + const guardianUpdate = jest.fn() + const guardianCount = jest.fn() + const policyFindUnique = jest.fn() + const policyCreate = jest.fn() + const policyUpdate = jest.fn() + const requestFindUnique = jest.fn() + const requestFindMany = jest.fn() + const requestCreate = jest.fn() + const requestUpdate = jest.fn() + const requestUpdateMany = jest.fn() + const approvalFindUnique = jest.fn() + const approvalCreate = jest.fn() + const approvalCount = jest.fn() + const sessionFindMany = jest.fn() + + const client: any = { + user: { findUnique: userFindUnique }, + subAccount: { findFirst: subAccountFindFirst }, + recoveryGuardian: { + findUnique: guardianFindUnique, + findFirst: guardianFindFirst, + findMany: guardianFindMany, + create: guardianCreate, + update: guardianUpdate, + count: guardianCount, + }, + recoveryPolicy: { + findUnique: policyFindUnique, + create: policyCreate, + update: policyUpdate, + }, + recoveryRequest: { + findUnique: requestFindUnique, + findMany: requestFindMany, + create: requestCreate, + update: requestUpdate, + updateMany: requestUpdateMany, + }, + recoveryApproval: { + findUnique: approvalFindUnique, + create: approvalCreate, + count: approvalCount, + }, + session: { findMany: sessionFindMany }, + } + + const all = [ + userFindUnique, + subAccountFindFirst, + guardianFindUnique, + guardianFindFirst, + guardianFindMany, + guardianCreate, + guardianUpdate, + guardianCount, + policyFindUnique, + policyCreate, + policyUpdate, + requestFindUnique, + requestFindMany, + requestCreate, + requestUpdate, + requestUpdateMany, + approvalFindUnique, + approvalCreate, + approvalCount, + sessionFindMany, + ] + + return { + __esModule: true, + default: client, + db: client, + __mock: { + userFindUnique, + subAccountFindFirst, + guardianFindUnique, + guardianFindFirst, + guardianFindMany, + guardianCreate, + guardianUpdate, + guardianCount, + policyFindUnique, + policyCreate, + policyUpdate, + requestFindUnique, + requestFindMany, + requestCreate, + requestUpdate, + requestUpdateMany, + approvalFindUnique, + approvalCreate, + approvalCount, + sessionFindMany, + resetAll: () => all.forEach((fn) => fn.mockReset()), + }, + } +}) + +jest.mock('../../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})) + +jest.mock('../../../src/audit/chain', () => ({ + appendAuditBlock: jest.fn(), +})) + +jest.mock('../../../src/services/refresh-token.service', () => ({ + revokeSession: jest.fn().mockResolvedValue(undefined), +})) + +// Notifications are fire-and-forget by design. Mocked so these tests exercise +// recovery LOGIC only; delivery behaviour is covered in notifications.test.ts. +jest.mock('../../../src/guardians/notifications', () => ({ + notifyRecoveryInitiated: jest.fn(), + notifyGuardiansOfRequest: jest.fn(), + notifyGuardianInvitation: jest.fn(), + notifyQuorumReached: jest.fn(), + notifyRecoveryCompleted: jest.fn(), + notifyRecoveryCancelled: jest.fn(), +})) + +import { Prisma } from '@prisma/client' + +import { + MAX_RECOVERY_DELAY_HOURS, + MIN_RECOVERY_DELAY_HOURS, + MIN_REQUIRED_APPROVALS, + cancelRecovery, + executeRecovery, + expireStaleRequests, + getOrCreateRecoveryPolicy, + initiateRecovery, + nominateGuardian, + RecoveryError, + updateRecoveryPolicy, +} from '../../../src/guardians/service' + +const m = require('../../../src/db').__mock +const { revokeSession } = require('../../../src/services/refresh-token.service') + +const USER_ID = 'user-1' +const WALLET = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF' + +function policy(overrides: Record = {}) { + return { + userId: USER_ID, + requiredApprovals: 2, + recoveryDelayHours: 48, + maxGuardians: 5, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} + +function guardian(overrides: Record = {}) { + return { + id: 'guardian-1', + userId: USER_ID, + guardianUserId: 'platform-user-1', + externalEmail: null, + externalPhone: null, + status: 'ACCEPTED', + inviteTokenHash: 'hash', + inviteExpiresAt: new Date(Date.now() + 86_400_000), + confirmedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} + +function request(overrides: Record = {}) { + return { + id: 'request-1', + userId: USER_ID, + reason: 'Lost my phone', + status: 'PENDING', + quorumReachedAt: null, + executeAfter: null, + executedAt: null, + cancelledAt: null, + expiresAt: new Date(Date.now() + 30 * 86_400_000), + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} + +beforeEach(() => { + jest.clearAllMocks() + m.resetAll() +}) + +// ─── Policy: quorum and delay bounds ───────────────────────────────────────── + +describe('recovery policy', () => { + it('refuses a quorum below the minimum', async () => { + m.policyFindUnique.mockResolvedValue(policy({ requiredApprovals: 2 })) + + await expect( + updateRecoveryPolicy(USER_ID, { requiredApprovals: 1 }) + ).rejects.toThrow(RecoveryError) + + expect(m.policyUpdate).not.toHaveBeenCalled() + }) + + it('exposes a minimum quorum greater than one', () => { + // A 1-of-N quorum would make every other control decorative. + expect(MIN_REQUIRED_APPROVALS).toBeGreaterThan(1) + }) + + it('refuses a delay shorter than the mandatory floor', async () => { + m.policyFindUnique.mockResolvedValue(policy()) + + await expect( + updateRecoveryPolicy(USER_ID, { recoveryDelayHours: 1 }) + ).rejects.toThrow(RecoveryError) + }) + + it('refuses a delay longer than the ceiling', async () => { + m.policyFindUnique.mockResolvedValue(policy()) + + await expect( + updateRecoveryPolicy(USER_ID, { + recoveryDelayHours: MAX_RECOVERY_DELAY_HOURS + 1, + }) + ).rejects.toThrow(RecoveryError) + }) + + it('keeps the delay floor inside the allowed band', () => { + expect(MIN_RECOVERY_DELAY_HOURS).toBeGreaterThanOrEqual(24) + expect(MAX_RECOVERY_DELAY_HOURS).toBeLessThanOrEqual(24 * 7) + }) +}) + +// ─── Nomination ────────────────────────────────────────────────────────────── + +describe('nominateGuardian', () => { + it('refuses self-nomination', async () => { + await expect( + nominateGuardian({ userId: USER_ID, guardianUserId: USER_ID }) + ).rejects.toThrow(/cannot nominate yourself/i) + expect(m.guardianCreate).not.toHaveBeenCalled() + }) + + it('refuses a nomination with no identifier at all', async () => { + await expect(nominateGuardian({ userId: USER_ID })).rejects.toThrow( + /identify the guardian/i + ) + }) + + it('returns the raw invite token exactly once and stores only its digest', async () => { + m.policyFindUnique.mockResolvedValue(policy()) + m.guardianFindFirst.mockResolvedValue(null) // no existing active guardian + m.guardianCount.mockResolvedValue(0) + m.guardianCreate.mockImplementation(({ data }: any) => + Promise.resolve(guardian({ ...data, id: 'guardian-new' })) + ) + + const result = await nominateGuardian({ + userId: USER_ID, + externalEmail: 'friend@example.com', + }) + + expect(result.inviteToken).toEqual(expect.any(String)) + expect(result.inviteToken.length).toBeGreaterThanOrEqual(32) + + const created = m.guardianCreate.mock.calls[0][0].data + expect(created.inviteTokenHash).toEqual(expect.any(String)) + // The raw token must never reach the database. + expect(JSON.stringify(created)).not.toContain(result.inviteToken) + }) + + it('never returns a stored digest as the invite token', async () => { + m.policyFindUnique.mockResolvedValue(policy()) + m.guardianFindFirst.mockResolvedValue(null) + m.guardianCount.mockResolvedValue(0) + m.guardianCreate.mockImplementation(({ data }: any) => + Promise.resolve(guardian({ ...data })) + ) + + const result = await nominateGuardian({ + userId: USER_ID, + externalEmail: 'friend@example.com', + }) + + expect(result.inviteToken).not.toBe(result.guardian.id) + expect(JSON.stringify(result.guardian)).not.toContain(result.inviteToken) + }) + + it('enforces the guardian cap', async () => { + // Already at the configured maximum. + m.guardianFindFirst.mockResolvedValue({ id: 'existing' }) + m.guardianCount.mockResolvedValue(5) + m.policyFindUnique.mockResolvedValue(policy({ maxGuardians: 5 })) + + await expect( + nominateGuardian({ userId: USER_ID, externalEmail: 'new@example.com' }) + ).rejects.toThrow(/maximum/i) + }) + + it('does not let a fresh invitation silently re-enrol an ACCEPTED guardian', async () => { + // Re-nominating returns the row to PENDING, so it cannot keep voting while + // the new invitation is outstanding. + m.guardianFindFirst.mockResolvedValue({ id: 'guardian-1' }) + m.guardianCount.mockResolvedValue(1) + m.policyFindUnique.mockResolvedValue(policy()) + m.guardianUpdate.mockImplementation(({ data }: any) => + Promise.resolve(guardian({ ...data })) + ) + + const result = await nominateGuardian({ + userId: USER_ID, + externalEmail: 'friend@example.com', + }) + + expect(m.guardianUpdate).toHaveBeenCalled() + expect(result.guardian.status).toBe('PENDING') + }) +}) + +// ─── Initiation and the one-live-request invariant ─────────────────────────── + +describe('initiateRecovery', () => { + /** A Prisma unique-constraint violation, as the DB raises for the partial index. */ + function p2002() { + const err: any = new Error('Unique constraint failed') + err.name = 'PrismaClientKnownRequestError' + err.code = 'P2002' + err.clientVersion = '5.0.0' + // Constructed through Prisma's class so `instanceof` in the service matches. + Object.setPrototypeOf(err, Prisma.PrismaClientKnownRequestError.prototype) + return err + } + + /** Everything the happy path needs up to the INSERT. */ + function primeHappyPath() { + m.userFindUnique.mockResolvedValue({ id: USER_ID }) + m.subAccountFindFirst.mockResolvedValue(null) + m.policyFindUnique.mockResolvedValue(policy()) + m.guardianCount.mockResolvedValue(3) + } + + it('treats a live-request unique violation as the generic refusal', async () => { + primeHappyPath() + // There is no pre-insert read: the partial unique index is the only thing + // standing between two concurrent initiations and two live requests. + m.requestCreate.mockRejectedValue(p2002()) + + const result = await initiateRecovery({ + walletAddress: WALLET, + reason: 'Lost my phone', + }) + + // No request, and the caller learns nothing beyond the generic outcome. + expect(result.request).toBeNull() + }) + + it('does not mask a real database failure as a duplicate', async () => { + primeHappyPath() + m.requestCreate.mockRejectedValue(new Error('connection terminated')) + + // Surfacing an outage as "already open" would be indistinguishable from + // success to the route while writing a false cause into the audit trail. + await expect( + initiateRecovery({ walletAddress: WALLET, reason: 'Lost my phone' }) + ).rejects.toThrow(/connection terminated/) + }) + + it('opens a request when the insert succeeds', async () => { + primeHappyPath() + m.requestCreate.mockResolvedValue(request()) + + const result = await initiateRecovery({ + walletAddress: WALLET, + reason: 'Lost my phone', + }) + + expect(result.request?.id).toBe('request-1') + expect(m.requestCreate).toHaveBeenCalled() + }) +}) + +// ─── Cancellation ──────────────────────────────────────────────────────────── + +describe('cancelRecovery', () => { + it('refuses to cancel another account’s request', async () => { + m.requestFindUnique.mockResolvedValue(request()) + + await expect( + cancelRecovery({ requestId: 'request-1', userId: 'someone-else' }) + ).rejects.toThrow(/another account/i) + expect(m.requestUpdateMany).not.toHaveBeenCalled() + }) + + it('cancels without any guardian consensus or delay', async () => { + m.requestFindUnique.mockResolvedValue(request({ status: 'PENDING' })) + m.requestUpdateMany.mockResolvedValue({ count: 1 }) + m.requestFindMany.mockResolvedValue([]) + + await cancelRecovery({ requestId: 'request-1', userId: USER_ID }) + + expect(m.requestUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: 'request-1' }), + data: expect.objectContaining({ status: 'CANCELLED' }), + }) + ) + }) + + it('still cancels after quorum was reached', async () => { + m.requestFindUnique.mockResolvedValue( + request({ status: 'QUORUM_REACHED', quorumReachedAt: new Date() }) + ) + m.requestUpdateMany.mockResolvedValue({ count: 1 }) + m.requestFindMany.mockResolvedValue([]) + + await cancelRecovery({ requestId: 'request-1', userId: USER_ID }) + + expect(m.requestUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'CANCELLED' }), + }) + ) + }) + + it('loses cleanly when execution already won the race', async () => { + m.requestFindUnique.mockResolvedValue(request({ status: 'PENDING' })) + m.requestUpdateMany.mockResolvedValue({ count: 0 }) // CAS matched nothing + m.requestFindUnique + .mockResolvedValueOnce(request({ status: 'PENDING' })) + .mockResolvedValueOnce(request({ status: 'COMPLETED' })) + + await expect( + cancelRecovery({ requestId: 'request-1', userId: USER_ID }) + ).rejects.toThrow(/completed/i) + }) +}) + +// ─── Execution ─────────────────────────────────────────────────────────────── + +describe('executeRecovery', () => { + const past = new Date(Date.now() - 1000) + const future = new Date(Date.now() + 86_400_000) + + it('refuses to execute while the delay is still running', async () => { + m.requestFindUnique.mockResolvedValue( + request({ status: 'QUORUM_REACHED', executeAfter: future }) + ) + + const outcome = await executeRecovery('request-1', new Date()) + + expect(outcome.executed).toBe(false) + expect(revokeSession).not.toHaveBeenCalled() + }) + + it('refuses to execute a request that never reached quorum', async () => { + m.requestFindUnique.mockResolvedValue(request({ status: 'PENDING' })) + + const outcome = await executeRecovery('request-1', new Date()) + + expect(outcome.executed).toBe(false) + expect(revokeSession).not.toHaveBeenCalled() + }) + + it('refuses to execute with no deadline stamped', async () => { + // Structurally impossible via the service. Treated as a refusal, never as + // permission: a missing deadline must not become an immediate reset. + m.requestFindUnique.mockResolvedValue( + request({ status: 'QUORUM_REACHED', executeAfter: null }) + ) + + const outcome = await executeRecovery('request-1', new Date()) + + expect(outcome.executed).toBe(false) + expect(revokeSession).not.toHaveBeenCalled() + }) + + it('revokes EVERY live session when the delay has elapsed', async () => { + m.requestFindUnique.mockResolvedValue( + request({ + status: 'QUORUM_REACHED', + quorumReachedAt: new Date(Date.now() - 3 * 86_400_000), + executeAfter: past, + }) + ) + m.requestUpdateMany.mockResolvedValue({ count: 1 }) + m.sessionFindMany.mockResolvedValue([ + { id: 'session-1', deviceType: 'mobile', approxLocation: 'Lisbon' }, + { id: 'session-2', deviceType: 'desktop', approxLocation: null }, + ]) + + const outcome = await executeRecovery('request-1', new Date()) + + expect(outcome.executed).toBe(true) + expect(revokeSession).toHaveBeenCalledTimes(2) + expect(revokeSession).toHaveBeenCalledWith( + 'session-1', + 'account_recovery', + expect.objectContaining({ userId: USER_ID }) + ) + expect(revokeSession).toHaveBeenCalledWith( + 'session-2', + 'account_recovery', + expect.anything() + ) + }) + + it('keeps revoking sessions after one fails, rather than half-recovering', async () => { + m.requestFindUnique.mockResolvedValue( + request({ status: 'QUORUM_REACHED', executeAfter: past }) + ) + m.requestUpdateMany.mockResolvedValue({ count: 1 }) + m.sessionFindMany.mockResolvedValue([ + { id: 'session-1', deviceType: 'mobile', approxLocation: null }, + { id: 'session-2', deviceType: 'desktop', approxLocation: null }, + ]) + revokeSession + .mockRejectedValueOnce(new Error('transient db error')) + .mockResolvedValueOnce(undefined) + + const outcome = await executeRecovery('request-1', new Date()) + + // The recovery still completes: one stuck session must not strand the rest + // of the account in a half-recovered state. + expect(outcome.executed).toBe(true) + expect(revokeSession).toHaveBeenCalledTimes(2) + }) + + it('claims the row conditionally so a concurrent cancellation cannot be undone', async () => { + m.requestFindUnique.mockResolvedValue( + request({ status: 'QUORUM_REACHED', executeAfter: past }) + ) + m.requestUpdateMany.mockResolvedValue({ count: 0 }) // lost the CAS + m.requestFindMany.mockResolvedValue([]) + + const outcome = await executeRecovery('request-1', new Date()) + + expect(outcome.executed).toBe(false) + expect(revokeSession).not.toHaveBeenCalled() + expect(m.requestUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: 'QUORUM_REACHED', + executeAfter: { lte: expect.any(Date) }, + }), + }) + ) + }) +}) + +// ─── Expiry ────────────────────────────────────────────────────────────────── + +describe('expireStaleRequests', () => { + it('expires stale open requests and returns the count', async () => { + m.requestFindMany.mockResolvedValue([ + { id: 'request-1', userId: USER_ID }, + { id: 'request-2', userId: USER_ID }, + ]) + m.requestUpdateMany.mockResolvedValue({ count: 2 }) + + const count = await expireStaleRequests(new Date()) + + expect(count).toBe(2) + expect(m.requestUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: { status: 'EXPIRED' }, + }) + ) + }) + + it('is a no-op when nothing has aged out', async () => { + m.requestFindMany.mockResolvedValue([]) + + expect(await expireStaleRequests(new Date())).toBe(0) + expect(m.requestUpdateMany).not.toHaveBeenCalled() + }) +}) + +// ─── Default policy ────────────────────────────────────────────────────────── + +describe('getOrCreateRecoveryPolicy', () => { + it('creates a policy with a quorum above one and a real delay', async () => { + m.policyFindUnique.mockResolvedValue(null) + m.policyCreate.mockImplementation(({ data }: any) => + Promise.resolve(policy({ ...data })) + ) + + const created = await getOrCreateRecoveryPolicy(USER_ID) + + expect(created.requiredApprovals).toBeGreaterThan(1) + expect(created.recoveryDelayHours).toBeGreaterThanOrEqual( + MIN_RECOVERY_DELAY_HOURS + ) + }) +}) diff --git a/tests/unit/guardians/structural.test.ts b/tests/unit/guardians/structural.test.ts new file mode 100644 index 00000000..f71edc25 --- /dev/null +++ b/tests/unit/guardians/structural.test.ts @@ -0,0 +1,123 @@ +/** + * Structural guards for the #535 recovery migration. + * + * `prisma/schema.prisma` cannot express CHECK constraints or partial indexes, so + * both live only in migration.sql. That makes them invisible to `prisma validate`, + * invisible to the generated client, and invisible to review of the schema alone + * -- the exact properties that decide whether a locked-out owner gets their + * account back safely. + * + * These tests read the SQL as text and assert the invariants are still present. + * They are intentionally cheap and DB-free: they are the last line that fails + * when someone regenerates the migration from the schema and silently drops the + * parts Prisma does not model. + */ + +import * as fs from 'fs' +import * as path from 'path' + +const MIGRATION_DIR = path.resolve( + __dirname, + '../../../prisma/migrations/20260930140000_add_guardian_recovery' +) + +const migrationSql = fs.readFileSync( + path.join(MIGRATION_DIR, 'migration.sql'), + 'utf8' +) +const rollbackSql = fs.readFileSync( + path.join(MIGRATION_DIR, 'rollback.sql'), + 'utf8' +) + +/** Collapse runs of whitespace so assertions do not depend on formatting. */ +const flatten = (sql: string) => sql.replace(/\s+/g, ' ') + +describe('#535 migration structure', () => { + describe('invariants Prisma cannot express', () => { + it('forbids a quorum below 2', () => { + // Quorum 1 means the claimant's own nomination of a single colluding + // guardian recovers the account -- no independent second party. + expect(flatten(migrationSql)).toContain( + 'ADD CONSTRAINT "recovery_policies_required_approvals_check" CHECK ("requiredApprovals" >= 2)' + ) + }) + + it('forbids a recovery delay below 24h', () => { + // The delay is the window in which a real owner notices and cancels. Zero + // collapses social recovery into an instant takeover primitive. + expect(flatten(migrationSql)).toContain( + 'ADD CONSTRAINT "recovery_policies_delay_hours_check" CHECK ("recoveryDelayHours" >= 24)' + ) + }) + + it('allows at most one live recovery request per account', () => { + // The service reads for an existing live request before creating one, but + // read-then-write races on a public unauthenticated endpoint. This index is + // the actual guarantee; the service check is only a friendly early error. + expect(flatten(migrationSql)).toContain( + 'CREATE UNIQUE INDEX "recovery_requests_userId_live_key" ON "recovery_requests"("userId") WHERE "status" IN (\'PENDING\', \'QUORUM_REACHED\')' + ) + }) + + it('scopes the live-request index to live statuses only', () => { + // A plain unique constraint on userId would permanently block a second + // recovery attempt after the first closed -- unacceptable for a feature + // that exists to rescue locked-out accounts. + const index = flatten(migrationSql).match( + /CREATE UNIQUE INDEX "recovery_requests_userId_live_key"[^;]*/ + ) + expect(index).not.toBeNull() + expect(index![0]).toContain('WHERE') + expect(index![0]).not.toMatch(/WHERE\s+"?status"?\s+IS NOT/i) + }) + }) + + describe('token storage', () => { + it('stores invite tokens only as a hash', () => { + // A raw token column would mean one DB read is a full account takeover for + // any guardian, and the column name makes the mistake easy to reintroduce. + expect(migrationSql).not.toMatch(/inviteToken\s+(TEXT|VARCHAR|CITEXT)/i) + expect(flatten(migrationSql)).toContain( + 'CREATE UNIQUE INDEX "recovery_guardians_inviteTokenHash_key"' + ) + }) + }) + + describe('rollback', () => { + it('drops the live-request partial unique index', () => { + expect(rollbackSql).toContain( + 'DROP INDEX IF EXISTS "recovery_requests_userId_live_key"' + ) + }) + + it('drops every table the migration creates', () => { + for (const table of [ + 'recovery_approvals', + 'recovery_requests', + 'recovery_policies', + 'recovery_guardians', + ]) { + expect(rollbackSql).toContain(`DROP TABLE IF EXISTS "${table}"`) + } + }) + + it('reverses the CHECK constraints by dropping their tables', () => { + // CHECKs are not dropped explicitly: they belong to tables the rollback + // drops outright. Guard that the tables really do go, so the constraint + // cannot outlive them. + expect(flatten(rollbackSql)).toContain( + 'DROP TABLE IF EXISTS "recovery_policies"' + ) + }) + }) + + describe('no execution endpoint', () => { + it('has no owner-callable execute path in the migration', () => { + // Execution is job-only. Nothing in the schema should imply the HTTP layer + // can drive it. + expect(migrationSql).not.toMatch(/executeToken/i) + expect(migrationSql).not.toMatch(/approvedBy/i) + }) + }) +}) diff --git a/tests/unit/jobs/guardianRecoverySweep.test.ts b/tests/unit/jobs/guardianRecoverySweep.test.ts new file mode 100644 index 00000000..a342f700 --- /dev/null +++ b/tests/unit/jobs/guardianRecoverySweep.test.ts @@ -0,0 +1,164 @@ +/** + * Guardian recovery sweep tests (#535). + * + * The sweep is the only thing in the codebase that can execute a recovery, so + * its failure modes are the ones that matter most: + * + * 1. It must NEVER execute a request whose delay has not elapsed. The query + * filter and `executeRecovery`'s own re-check are two independent guards; + * a regression in either must fail here. + * 2. One bad request must not strand the rest of the batch. + * 3. Expiry housekeeping must run on every tick, otherwise abandoned requests + * accumulate forever. + */ + +process.env.NODE_ENV = 'test' + +jest.mock('../../../src/db', () => { + const findMany = jest.fn() + const client: any = { recoveryRequest: { findMany } } + return { + __esModule: true, + default: client, + db: client, + __mockFindMany: findMany, + } +}) + +jest.mock('../../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + logBackgroundJob: jest.fn(), +})) + +jest.mock('../../../src/utils/metrics', () => ({ + recordBackgroundJob: jest.fn(), +})) + +jest.mock('../../../src/utils/job-metrics', () => ({ + recordJobSuccess: jest.fn(), + recordJobFailure: jest.fn(), +})) + +jest.mock('../../../src/guardians/service', () => ({ + OPEN_REQUEST_STATUSES: ['PENDING', 'QUORUM_REACHED'], + executeRecovery: jest.fn(), + expireStaleRequests: jest.fn().mockResolvedValue(0), +})) + +jest.mock('../../../src/jobs/resilientScheduler', () => ({ + scheduleResilientJob: jest.fn().mockReturnValue({ unref: jest.fn() }), +})) + +import { sweepGuardianRecovery } from '../../../src/jobs/guardianRecoverySweep' + +const mockFindMany = require('../../../src/db').__mockFindMany +const { + executeRecovery, + expireStaleRequests, +} = require('../../../src/guardians/service') + +function executedOutcome(id: string) { + return { + id, + userId: 'user-1', + status: 'COMPLETED', + revokedSessions: 2, + executedAt: new Date().toISOString(), + executeAfter: null, + executed: true, + } +} + +beforeEach(() => { + jest.clearAllMocks() + expireStaleRequests.mockResolvedValue(0) + executeRecovery.mockImplementation((id: string) => + Promise.resolve(executedOutcome(id)) + ) +}) + +describe('sweepGuardianRecovery', () => { + it('selects only requests whose deadline has already passed', async () => { + mockFindMany.mockResolvedValue([]) + + await sweepGuardianRecovery() + + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: 'QUORUM_REACHED', + executeAfter: { lte: expect.any(Date) }, + }), + }) + ) + }) + + it('executes each due request and passes through the time it decided on', async () => { + mockFindMany.mockResolvedValue([{ id: 'r1', userId: 'user-1' }]) + + await sweepGuardianRecovery() + + expect(executeRecovery).toHaveBeenCalledWith('r1', expect.any(Date)) + }) + + it('expires stale requests on every tick', async () => { + mockFindMany.mockResolvedValue([]) + + await sweepGuardianRecovery() + + expect(expireStaleRequests).toHaveBeenCalledWith(expect.any(Date)) + }) + + it('uses ONE timestamp for the whole tick', async () => { + // A fresh Date() per row would let a long sweep execute a request whose + // deadline had not actually passed when the pass began. + mockFindMany.mockResolvedValue([ + { id: 'r1', userId: 'user-1' }, + { id: 'r2', userId: 'user-1' }, + ]) + + await sweepGuardianRecovery() + + const first = executeRecovery.mock.calls[0][1].getTime() + const second = executeRecovery.mock.calls[1][1].getTime() + expect(first).toBe(second) + }) + + it('does not let one failing request strand the rest of the batch', async () => { + mockFindMany.mockResolvedValue([ + { id: 'r1', userId: 'user-1' }, + { id: 'r2', userId: 'user-1' }, + { id: 'r3', userId: 'user-1' }, + ]) + executeRecovery + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValue(executedOutcome('r2')) + + await expect(sweepGuardianRecovery()).resolves.toBeUndefined() + + // r1 failed, but r2 and r3 were still attempted. + expect(executeRecovery).toHaveBeenCalledTimes(3) + }) + + it('treats a lost conditional update as a skip, not a failure', async () => { + mockFindMany.mockResolvedValue([{ id: 'r1', userId: 'user-1' }]) + executeRecovery.mockResolvedValue({ + ...executedOutcome('r1'), + executed: false, + }) + + await expect(sweepGuardianRecovery()).resolves.toBeUndefined() + expect(executeRecovery).toHaveBeenCalledTimes(1) + }) + + it('never throws, even when the query itself fails', async () => { + mockFindMany.mockRejectedValue(new Error('db down')) + + await expect(sweepGuardianRecovery()).resolves.toBeUndefined() + }) +}) diff --git a/tests/unit/routes/recovery.test.ts b/tests/unit/routes/recovery.test.ts new file mode 100644 index 00000000..8a4c97af --- /dev/null +++ b/tests/unit/routes/recovery.test.ts @@ -0,0 +1,248 @@ +/** + * Guardian recovery ROUTE tests (#535). + * + * Scope is deliberately narrow: the service's security properties are covered + * in tests/unit/guardians/service.test.ts. What is tested here is the property + * that belongs to the HTTP layer and cannot be enforced anywhere else -- + * + * `POST /recovery/initiate` must be INDISTINGUISHABLE across every outcome. + * + * It is the only endpoint a locked-out claimant can reach, it takes an + * arbitrary wallet address, and it opens a real recovery. If the response + * varies at all between "no such wallet", "sub-account", "no guardians" and + * "recovery opened", then this endpoint is a wallet-existence oracle worth + * more to an attacker than the recovery itself, and every other control in the + * feature becomes decoration. These tests assert byte-identical responses. + */ + +process.env.NODE_ENV = 'test' + +import express from 'express' +import request from 'supertest' +import recoveryRouter from '../../../src/routes/recovery' + +jest.mock('../../../src/guardians/service', () => { + const actual = jest.requireActual('../../../src/guardians/service') + return { + ...actual, + // Every public entry point is mocked so the ROUTE's response shaping can be + // asserted independently of what the service decides. + initiateRecovery: jest.fn(), + cancelRecovery: jest.fn(), + nominateGuardian: jest.fn(), + listGuardians: jest.fn(), + getOrCreateRecoveryPolicy: jest.fn(), + updateRecoveryPolicy: jest.fn(), + getRecoveryRequestForOwner: jest.fn(), + acceptGuardianInviteAsUser: jest.fn(), + respondToGuardianInvite: jest.fn(), + removeGuardian: jest.fn(), + approveRecoveryAsGuardian: jest.fn(), + approveRecoveryAsExternalGuardian: jest.fn(), + listRequestsAwaitingGuardian: jest.fn(), + } +}) + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: {}, +})) + +jest.mock('../../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})) + +// The real limiter is shared across the process and would 429 the later tests +// once the earlier ones spend its budget. Its presence is asserted separately +// from the router stack, so passthrough-ing it here does not weaken the test. +jest.mock('../../../src/middleware/rateLimiter', () => ({ + recoveryRateLimiter: (_req: any, _res: any, next: any) => next(), +})) + +// requireAuth is exercised as "denied unless a header is present"; the tests +// here are about the unauthenticated surface. +jest.mock('../../../src/middleware/authenticate', () => ({ + requireAuth: (req: any, res: any, next: any) => { + if (!req.headers?.authorization) { + return res.status(401).json({ error: 'Unauthorized' }) + } + req.auth = { userId: 'user-1', sessionId: 's1' } + next() + }, +})) + +const service = require('../../../src/guardians/service') + +const app = express() +app.use(express.json()) +app.use('/recovery', recoveryRouter) + +const VALID_WALLET = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF' + +describe('POST /recovery/initiate — anti-enumeration', () => { + beforeEach(() => jest.clearAllMocks()) + + it.each([ + [ + 'a recovery was opened', + { request: { id: 'r1' }, acceptedGuardians: 3, requiredApprovals: 2 }, + ], + [ + 'no such wallet exists', + { request: null, acceptedGuardians: 0, requiredApprovals: 0 }, + ], + [ + 'the wallet is a sub-account', + { request: null, acceptedGuardians: 2, requiredApprovals: 2 }, + ], + [ + 'there are not enough guardians', + { request: null, acceptedGuardians: 1, requiredApprovals: 2 }, + ], + ])('answers 202 with an identical body when %s', async (_label, outcome) => { + service.initiateRecovery.mockResolvedValue(outcome) + + const res = await request(app) + .post('/recovery/initiate') + .send({ walletAddress: VALID_WALLET, reason: 'Lost my device' }) + + expect(res.status).toBe(202) + expect(res.body).toEqual({ + status: 'accepted', + message: + 'If an eligible primary account matches that wallet address, its accepted guardians have been contacted and the account owner has been alerted.', + }) + }) + + it('never echoes back whether the recovery actually opened', async () => { + service.initiateRecovery.mockResolvedValue({ + request: { id: 'secret-request-id' }, + acceptedGuardians: 3, + requiredApprovals: 2, + }) + + const res = await request(app) + .post('/recovery/initiate') + .send({ walletAddress: VALID_WALLET, reason: 'Lost my device' }) + + expect(JSON.stringify(res.body)).not.toContain('secret-request-id') + expect(res.body).not.toHaveProperty('request') + expect(res.body).not.toHaveProperty('acceptedGuardians') + expect(res.body).not.toHaveProperty('requiredApprovals') + }) + + it('gives the same 202 even when the service throws', async () => { + // A fault must not distinguish itself from success, or the error rate becomes + // the oracle: "it 500s for real wallets" leaks exactly what a 200 would. + service.initiateRecovery.mockRejectedValue(new Error('db exploded')) + + const res = await request(app) + .post('/recovery/initiate') + .send({ walletAddress: VALID_WALLET, reason: 'Lost my device' }) + + expect(res.status).toBe(202) + expect(res.body.status).toBe('accepted') + }) + + it('is byte-identical across every outcome', async () => { + const bodies: string[] = [] + const statuses: number[] = [] + + const outcomes: any[] = [ + { request: { id: 'r1' }, acceptedGuardians: 3, requiredApprovals: 2 }, + { request: null, acceptedGuardians: 0, requiredApprovals: 0 }, + new Error('boom'), + ] + + for (const outcome of outcomes) { + if (outcome instanceof Error) { + service.initiateRecovery.mockRejectedValue(outcome) + } else { + service.initiateRecovery.mockResolvedValue(outcome) + } + const res = await request(app) + .post('/recovery/initiate') + .send({ walletAddress: VALID_WALLET, reason: 'Lost my device' }) + bodies.push(JSON.stringify(res.body)) + statuses.push(res.status) + } + + expect(new Set(bodies).size).toBe(1) + expect(new Set(statuses).size).toBe(1) + }) + + it('rejects a malformed body with 400 rather than a generic 202', async () => { + // Validation failure is about the CALLER's request shape, not about the + // wallet, so it is safe (and useful) to be specific here. + const res = await request(app) + .post('/recovery/initiate') + .send({ walletAddress: '', reason: '' }) + + expect(res.status).toBe(400) + }) +}) + +describe('guardian decision endpoints', () => { + beforeEach(() => jest.clearAllMocks()) + + it('requires auth for a platform guardian decision', async () => { + await request(app) + .post('/recovery/guardian/requests/abc/decide') + .send({ approved: true }) + .expect(401) + }) + + it('requires auth to cancel', async () => { + await request(app).post('/recovery/requests/abc/cancel').expect(401) + }) + + it('accepts an external guardian decision without a session', async () => { + service.approveRecoveryAsExternalGuardian.mockResolvedValue({ + id: 'r1', + status: 'PENDING', + }) + + const res = await request(app).post('/recovery/guardian/decide').send({ + requestId: '5a7b2f1e-8c3d-4e5f-9a0b-1c2d3e4f5a6b', + token: 'a-token', + approved: true, + }) + + expect(res.status).toBe(200) + expect(service.approveRecoveryAsExternalGuardian).toHaveBeenCalled() + }) + + it('exposes no endpoint that executes a recovery', () => { + // Execution is platform-side only (the sweeper). A public execute route + // would be callable only by the very session it is about to revoke. + const paths = (recoveryRouter as any).stack.map( + (layer: any) => layer.route?.path + ) + const flat = paths.filter(Boolean).join(' ') + expect(flat).not.toMatch(/execute/i) + }) + + it('rate limits the public endpoints but not the authenticated ones', () => { + // /initiate triggers real email and WhatsApp to third-party guardians, so + // leaving it unthrottled is an email-bombing primitive aimed at a bystander. + const withLimiter = (path: string): boolean => + (recoveryRouter as any).stack.some( + (layer: any) => + layer.route?.path === path && + layer.route.stack.some((l: any) => l.name === 'recoveryRateLimiter') + ) + + expect(withLimiter('/initiate')).toBe(true) + expect(withLimiter('/invitations/respond')).toBe(true) + expect(withLimiter('/guardian/decide')).toBe(true) + + // Owner endpoints sit behind the caller's own session. + expect(withLimiter('/policy')).toBe(false) + expect(withLimiter('/requests/:requestId/cancel')).toBe(false) + }) +})