Skip to content

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 outside lib/rbac/ so the in-flight RBAC workstream cannot silently widen it.
  • canMoveMoney(email, role, status) now requires all three: appointed AND active AND bank_accounts:write.
  • bank_accounts:write removed from bookkeeper and auditing_accountant as the owner asked β€” in both places it is declared: ROLE_PERMISSIONS in lib/rbac/types.ts (the map roleHasPermission actually reads) and the role catalogue in lib/rbac/roles.ts. Editing only roles.ts would 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 totpEnrollments collection, 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, POST begin / 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 the

    32-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-any handlers and a Record<AuditEntity, string> before they could reach a deploy.
  • TOTP_ENCRYPTION_KEY generated and installed: Vercel production + GCP Secret Manager (TOTP_ENCRYPTION_KEY v1). 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: … β€” URLSearchParams serializes form-encoded, where a space is +, but the otpauth:// 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 reads EOP: 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_PERMISSIONS in types.ts β€” not roles.ts β€” is the map the permission check actually reads. Source: Bank Access (Diagnosticcs) Β· https://claude.ai/code/session_01S4ztAsDZfR97qXjhPdwLn4