Skip to content

uid: T-205 title: TOTP gate on the whole Airwallex surface (page + every data endpoint) status: done area: bank-access created: 2026-08-29 updated: 2026-09-24 owner: girafeev1 assignee: Bank Access (Diagnosticcs) related: T-204, T-203, I-039


T-205 β€” Lock the Airwallex surface behind TOTP

Why (owner, 2026-08-29)

"I need the TOTP to safe guard the accessing to the Airwallex page, not just transfer making. Whenever someone tries to access Airwallex, they need to enter the TOTP."

T-204 put a code on the send action. The owner wants it on access: seeing balances, transactions, beneficiaries and transfer history is itself sensitive, and a session left open on an unlocked laptop exposed all of it.

The part that makes this real: gate the API, not the page

Hiding the React view would be a curtain β€” the data lives in /api/airwallex/*, and anyone with a session cookie could call those directly. So the enforcing gate is Edge middleware over the whole /api/airwallex/ namespace, default-deny.

Middleware rather than a per-route check on purpose: a per-route guard is something the next person adding /api/airwallex/whatever can forget, and a forgotten check on this namespace is a silent hole. Default-deny covers routes that don't exist yet.

Two deliberate exemptions, both reasoned rather than convenient: - webhook β€” called by Airwallex, not a browser. It has no cookie and authenticates itself by HMAC signature (AIRWALLEX_WEBHOOK_SECRET). Gating it would break status sync. - auth β€” returns only whether the integration is connected (no balances, no transactions); the UI needs it to render the locked state sensibly.

How it works

  • lib/security/unlockToken.ts β€” a short-lived HMAC-signed cookie (awx_unlock, 15 min) proving a TOTP check was passed. Built on Web Crypto, not node:crypto, so the identical verification runs in Edge middleware and in Node API routes. Carries no secret β€” only who it was issued to and when it dies β€” and is unforgeable without the signing key. Signing key is derived from TOTP_ENCRYPTION_KEY with domain separation (:airwallex-unlock:v1), so no new env var and an unlock token can never be confused with anything else encrypted under that key.
  • /api/security/totp/unlock β€” GET status Β· POST code β†’ sets the cookie Β· DELETE β†’ re-lock. Both outcomes are audited.
  • AirwallexUnlockGate wraps the Airwallex tab: a lock screen until a code is entered, and it re-locks itself when the unlock expires, so the screen never shows data the API would now refuse to refresh. Accepts a recovery code as well as a 6-digit code.
  • Payment step-up (T-204) stays. Access and money movement are separate gates.

One subtlety worth recording

Unlock verifies the code without consuming the time step; payments still consume it. Had both consumed, unlocking would spend the current code and the payment moments later would be refused as a replay β€” forcing a 30-second wait for no security gain. Replaying a code to view a page is not the risk being defended against; replaying one to move money is, and that protection is untouched.

Verification

  • 35 tests passing (unlockToken + totp), including forgery attempts: an edited payload (extended expiry) is rejected as bad_signature, a spliced signature is rejected, one user's token is rejected for another, expiry is asserted either side of the boundary, and a missing signing key fails closed rather than waving traffic through.
  • tsc --noEmit --incremental false clean.
  • next build β†’ βœ“ Compiled successfully (this is the check that matters here β€” a type check cannot catch an Edge-runtime-incompatible import in middleware). Verified the gate is genuinely in the compiled edge bundle (awx_unlock / airwallex-unlock present in .next/server/middleware.js). The build's later /v/[id] "Bucket name not specified" failure is the known credential-less-sandbox issue, unrelated to this diff.

Behaviour after deploy

Opening Bank Access β†’ Airwallex shows "Airwallex is locked" and asks for a code. After unlocking, the surface works normally for 15 minutes, then re-locks. Sending money still asks for its own code. Anyone not enrolled is sent to /settings/security first.

Log

  • 2026-08-29 β€” Built from the owner's directive. The design point worth remembering: the gate had to go on the API namespace, not the page, or it would have been decorative. Source: Bank Access (Diagnosticcs) Β· https://claude.ai/code/session_01S4ztAsDZfR97qXjhPdwLn4

  • 2026-09-24 β€” Follow-up: Airwallex connects by itself once unlocked. βœ… Read AGENTS.md Β· checked the board by scope (this extends T-205's own surface, not a new task). Source: Bank Access (Diagnosticcs) Β· https://claude.ai/code/session_01S4ztAsDZfR97qXjhPdwLn4

  • Owner, verbatim (2026-09-24): "Can you also make it so that Airwallex gets connected right away after the user authenticates themselves with the TOTP"
  • Why it wasn't already so: the unlock and the Airwallex "connection" were separate.
    • After unlocking, the page asked the server whether it held a live Airwallex session.
    • That session lives in the memory of whichever server instance answers, so the answer was often "no", and the page stopped on a Connect Airwallex button.
    • The data was never really cut off: every Airwallex data call already reconnects on its own. The button was a leftover step.
  • What it does now:
    • Once the code is accepted, the Airwallex page connects by itself and opens straight on balances and transactions. No Connect button, and no "Connected" pop-up.
    • The tab's green "connected" dot lights up too.
    • If connecting fails, the Connect screen appears with the reason, as before.
  • One exception, deliberately: right after someone chooses Disconnect from the tab menu, the page stays disconnected until they press Connect. A disconnect that undid itself a second later would be pointless.
  • Firestore: none touched. It uses the same server connect call the Connect button always used.
  • Not verified in a browser: the sandbox has no signed-in session or live Airwallex keys.
  • Commits (append-only): 6e7246d (#1000, the original gate) Β· this follow-up = the commit that adds this entry (#1047).
  • 2026-09-24 β€” Auto-connect deployed on the owner's one-off "Deploy": Vercel dpl_6TdjHJiFChoHTxrj7PVvn27W5w6y at main 9d94e0a (READY, production). The first unlock after this is the live check. Source: Bank Access (Diagnosticcs) Β· https://claude.ai/code/session_01S4ztAsDZfR97qXjhPdwLn4