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, notnode: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 fromTOTP_ENCRYPTION_KEYwith 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.AirwallexUnlockGatewraps 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 asbad_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 falseclean.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-unlockpresent 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_6TdjHJiFChoHTxrj7PVvn27W5w6yatmain9d94e0a(READY, production). The first unlock after this is the live check. Source: Bank Access (Diagnosticcs) Β· https://claude.ai/code/session_01S4ztAsDZfR97qXjhPdwLn4