uid: T-204 title: Appointed payers + step-up two-factor (TOTP) on every payment status: done area: bank-access created: 2026-08-28 updated: 2026-08-28 owner: girafeev1 assignee: Bank Access (Diagnosticcs) related: T-203, I-039, T-167
T-204 β Appointed payers + TOTP step-up¶
Why (owner, 2026-08-28)¶
"Regarding who can move money, I think it's not the matter of role, but the matter people who are specifically appointed, and that's only Jake and Jeff. I'm working on RBAC and access with another agent still, but if bank_accounts:write for bookkeeper and auditing_accountant can be easily blocked and that my RBAC agent could easily spot that, proceed with your fix now" β¦ "Proceed to TOTP"
T-203 gave payments a permission gate and an audit trail. This task completes the three-layer model the owner's security question implied:
| Layer | Question it answers | Shipped in |
|---|---|---|
| Appointment + permission | Are they allowed? | T-203 β narrowed here |
| Step-up TOTP | Is it really them, right now? | T-204 |
| Audit log | What happened, and who did it? | T-203 |
Part 1 β Appointment, not role¶
A role edit alone would not have met the requirement. Removing bank_accounts:write from
bookkeeper and auditing_accountant still leaves every admin able to pay β including Alison
(admin, currently suspended). "Only Jake and Jeff" needs an explicit appointment.
lib/payments/appointedPayers.tsβ the narrowing control: an explicit list of appointed payers, checked in addition to role + active status. Deliberately a code constant (changing who moves company money should take a reviewed change and a deploy, and it can never fail open because a config doc went missing) and deliberately outsidelib/rbac/so the in-flight RBAC workstream cannot silently widen it.canMoveMoney(email, role, status)now requires all three: appointed AND active ANDbank_accounts:write.bank_accounts:writeremoved from bookkeeper and auditing_accountant as the owner asked β in both places it is declared:ROLE_PERMISSIONSinlib/rbac/types.ts(the maproleHasPermissionactually reads) and the role catalogue inlib/rbac/roles.ts. Editing onlyroles.tswould have been cosmetic β worth knowing for the RBAC workstream. Bank read access is untouched, so reconciliation work is unaffected.
Part 2 β Step-up two-factor (TOTP)¶
RFC 6238 codes, implemented on node:crypto with no new dependencies (the algorithm is ~40
lines and every authenticator app implements the same RFC; a library would add supply-chain surface
to a security control). The QR code uses antd 5's built-in QRCode.
lib/security/totp.tsβ pure algorithm: base32, code derivation, constant-time verification with Β±1 step of clock drift,otpauth://URI, recovery-code generation.lib/security/totpStore.server.tsβ the security properties around it:- secrets encrypted at rest (AES-256-GCM,
TOTP_ENCRYPTION_KEY) - replay refused β a spent time step can never be reused, so an observed code is worthless
- brute force throttled β 5 wrong codes β 15-minute lockout (6 digits is only a million guesses)
- recovery codes β 8 single-use codes, stored only as SHA-256 hashes, shown once
- lives in its own
totpEnrollmentscollection, NOT on the user profile doc, so the concurrent RBAC work cannot drop an enrolment and a profile reset cannot silently disarm someone's MFA - re-enrolment over an ACTIVE enrolment is refused (a hijacked session can't swap in its own authenticator without an explicit reset)
/api/security/totpβ GET status, POSTbegin/activate/reset. Always acts on the caller's own enrolment; there is no user parameter, so one account can never touch another's./settings/security+components/settings/TwoFactorSetup.tsxβ self-service enrolment (QR + manual setup key β confirm a code β recovery codes). Auth-only, no permission gate.- Both payment endpoints require a valid code, and the drawer prompts for one on the confirm step, distinguishing "wrong code" (retry inline) from "not enrolled" (link to setup). Refusals are audited with the reason.
Verification¶
__tests__/lib/security/totp.test.tsβ 22 tests, all passing, including the published RFC 6238 Appendix B vectors, so codes interoperate with real authenticator apps. Also covers the32-bit counter (a 32-bit-only implementation silently emits wrong codes rather than throwing), drift acceptance/rejection, replay of another secret's code, and recovery-code normalization.
- Gate: fresh
npm ci+tsc --noEmit --incremental false, clean. The gate caught two implicit-anyhandlers and aRecord<AuditEntity, string>before they could reach a deploy. TOTP_ENCRYPTION_KEYgenerated and installed: Vercel production + GCP Secret Manager (TOTP_ENCRYPTION_KEYv1). Kept in two places deliberately β if the key is lost, enrolled secrets cannot be decrypted and everyone must re-enrol (no money is at risk; enrolment just has to be redone).
β οΈ Enrolment must happen before the next payment¶
This is a hard requirement, live from the deploy that carries it: an appointed payer with no
enrolment cannot send or queue a payment. The refusal is explicit and links to /settings/security,
and enrolment ships in the same deploy, so the sequence is: deploy β Jake and Jeff each enrol (about
two minutes) β payments work as before, plus a code.
Enforce-only-if-enrolled was rejected: it fails open, which is no second factor at all.
Follow-ups (owner-gated)¶
- Deploy (π· owner-commanded only), then both appointed payers enrol.
- Airwallex-side hardening (owner action, their dashboard): replace the admin API key with a scoped key (Transfers Write + needed reads β Airwallex explicitly discourages admin keys for new integrations) and rotate it. IP allowlisting is a real Airwallex feature but likely unavailable to us (Vercel serverless has no stable egress IPs outside Enterprise Secure Compute) β verify before relying on it.
- Adding or removing an appointed payer is a one-line change to
lib/payments/appointedPayers.ts.
Log¶
-
2026-08-29 pt2 β Authenticator label fix (owner-reported). The entry showed
Establishers+Operation+Platform: β¦βURLSearchParamsserializes form-encoded, where a space is+, but theotpauth://spec wants URI encoding (%20), so apps rendered the plus signs literally. Query params are now percent-encoded by hand, and the issuer shortened to EOP, so the entry readsEOP: me@jefferochan.com. Two regression tests added (24 total, all passing). Cosmetic only β existing enrolments keep working; re-enrol (or rename in the app) to pick up the new label. -
2026-08-28 β Shipped both parts from the owner's directive. Recorded that a role-only fix would not have delivered "only Jake and Jeff", and that
ROLE_PERMISSIONSintypes.tsβ notroles.tsβ is the map the permission check actually reads. Source: Bank Access (Diagnosticcs) Β· https://claude.ai/code/session_01S4ztAsDZfR97qXjhPdwLn4