Files
Crussell/backend/handlers/payments/twofa_delivery_prod.go
T
popertots 2cdbad0cea feat: SCA-only saved-card charges — 2FA charge fallback removed (C6), versioned consent fields, token provenance
PSR 2017 reg 100 makes SCA mandatory and non-waivable for customer-initiated
stored-credential charges; a merchant-side 2FA check cannot legally substitute
for it (authorising a token-less charge via 2FA leaves the MERCHANT liable for
ECI 7 / SLI 210 chargebacks and reg 77(6) compensation regardless of consent).

- payments/twofa.go: the homegrown 2FA fallback for token-less saved-card
  charges is REMOVED ENTIRELY. requireTwoFactorForCardAccess is now SCA-only:
  a non-empty Square verification_token (charge surfaces, token forwarded to
  Square) skips the gate; anything else is refused 402 verification_required.
  enforceSCAFallbackConsent is a compile-compatible no-op (fallback never runs).
- New requireTwoFactorForCardAccessWithTokenValidation distinguishes surfaces
  where the token IS forwarded to Square (charge — Square validates it) from
  card-SAVE surfaces (token client-asserted, never forwarded: a non-empty token
  must NOT skip the save gate, auth-F1).
- SCA tokenize-result wire contract (C1): a saved card charged with a fresh
  one-time tokenize-result sends the token as the charge SOURCE (new_card_token
  -> source_id) alongside saved_card_id, never a separate verification_token.
  resolveChargeSource resolves the saved-card branch FIRST (customer from the
  card row, token as source) so combined token+card requests are SCA-clean.
- C6 consent fields (consent_version / consent_accepted) added to the booking/
  tip/till/gift-card charge requests, enforced server-side before any fallback
  charge could reach Square and recorded on the 2fa_fallback_charge audit row;
  logVerificationTokenProvenance traces minted tokens to their charge.
- user 2FA issuance gate refactored into pure build-agnostic functions
  (twoFAPepperConfigured / twoFADeliveryChannelConfigured /
  twoFAEnsureIssueAllowedStrict) shared with the payments re-issue path and
  exercised directly by the test,dev suite; TWO_FACTOR_FALLBACK switch and
  .env.example entry removed; startup posture notes updated.
- Test coverage: fail-closed 2FA production gates (pepper/delivery), token
  validation on save vs charge surfaces, completion idempotency, idempotency
  key determinism, refund-policy 72h/24h epsilon boundaries, VAT parity.
2026-08-22 00:34:50 +01:00

64 lines
3.3 KiB
Go

//go:build !dev && !test
package payments
import (
"log"
"os"
)
// twoFADeliveryAvailable reports whether a 2FA code delivery channel exists in
// this build. Production has no wired email/SMS transport (P6), so the ONLY
// channel is the operator's explicit opt-in to insecure log delivery
// (TWO_FACTOR_ALLOW_LOG_DELIVERY=true). Without a channel, codes can never
// reach the customer, so the 2FA BACKUP authorization (the saved-card gate
// when SCA is unavailable) cannot operate and a token-less saved-card charge is
// denied 503 (see requireTwoFactorForCardAccess). Delegates to the pure
// build-agnostic twoFADeliveryChannelConfigured (twofa.go); dev/test builds
// always deliver (twofa_delivery_dev.go).
func twoFADeliveryAvailable() bool {
return twoFADeliveryChannelConfigured()
}
// twoFAReissueIssueAllowed is the re-issue path's issuance gate
// (reissueTwoFACodeAfterFailedCharge, handlers.go), mirroring the user
// package's twoFAEnsureIssueAllowed (handlers/user/twofa_prod.go) build-tagged
// semantics: production requires BOTH a delivery channel and TWO_FACTOR_PEPPER.
// Without a channel the code could never reach the customer, and without the
// pepper every stored code would be an offline-brute-forceable unsalted digest
// — either way the re-issue refuses (fail-closed), exactly like the interactive
// mint paths. Delegates to the pure build-agnostic gate
// twoFAReissueIssueAllowedStrict (twofa.go), which the test,dev suite also
// exercises directly; dev/test builds always allow issuance
// (twofa_delivery_dev.go).
//
// The pepper check is the ONLY hard gate on the re-issue (plus the delivery
// channel). PEPPER-CHANGE HAZARD (Loop B finding 2): the pepper keys the
// HMAC-SHA256 of every stored pending-code hash, so CHANGING TWO_FACTOR_PEPPER
// invalidates ALL pending codes — a re-issued code under the new pepper can
// never match a customer's code minted under the old one. An operator who
// changes the pepper must re-mint every user's code (or the customer must
// re-run 2FA setup), or a fresh saved-card charge whose code was consumed at
// the gate will strand the customer with 400 ErrMissingOrExpired on retry.
func twoFAReissueIssueAllowed() error {
return twoFAReissueIssueAllowedStrict()
}
// twoFAReissueDeliverCode delivers a re-issued code after a failed fresh
// saved-card charge (the gate consumed the customer's code at verify time).
// Production's ONLY channel is the operator's explicit, insecure opt-in to log
// delivery (TWO_FACTOR_ALLOW_LOG_DELIVERY=true — anyone with backend log access
// could defeat the 2FA gate on saved-card charges). WITHOUT that flag the
// plaintext code is NEVER written to the log (issuance was already refused by
// twoFAReissueIssueAllowed, so this no-op is unreachable); with it, the code
// goes to the [2FA] log line for the operator to relay to the customer, exactly
// like the documented dev flow. MEDIUM-3b: the user id and the plaintext code
// go to SEPARATE log lines so a single record cannot trivially pair them.
func twoFAReissueDeliverCode(userID, code string) {
if os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true" {
log.Printf("[2FA] code delivery requested (user=%s, purpose=re-issue after failed saved-card charge)", userID)
log.Printf("[2FA] code: %s", code)
}
// Otherwise: deliberate no-op — never log the plaintext code by default.
}