fix: loop-B adversarial (503c326 baseline) — IDEMPOTENCY_KEY_REUSED reclassified ambiguous, 2FA reissue fail-closed alerts, family-cache crash window, consolidation regression checks

Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul:

- CRITICAL-ish: IDEMPOTENCY_KEY_REUSED (409) no longer classified as a definitive
  402 in chargeFailureStatus — it means the ORIGINAL charge may have landed with
  a different body, so it is now AMBIGUOUS (503): the frontend keeps the same
  idempotency key, the pending row stays rescuable by the sweep (which already
  treated it as ambiguous), and the frontend no longer regenerates the key into
  a possible double charge. SCA verification-required codes remain definitive 402.
- HIGH: reissueTwoFACodeAfterFailedCharge now writes a CRITICAL admin notification
  (insertCriticalPaymentNotification) when issuance is refused (missing pepper /
  unavailable delivery) instead of silently stranding the customer; documented that
  a pepper CHANGE invalidates all pending codes.
- MEDIUM: family-alive cache invalidation crash window documented (invalidate-after-
  commit leaves up to 30s warm on a crash; the near-TTL DB re-check bounds it).
- Consolidation regression checks (8b2fe3b helpers): writeChargeSnapshot guard
  preserved at all sites, postChargeRecheck identical, squareRefundStatusToLocal
  mappings verified, reissue fresh-only semantics confirmed at all 5 call sites.

Verified: 26/26 dev packages, both vet tags, frontend tests + build, env-docs 42/42.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 36887167c6
commit dfe856b181
10 changed files with 219 additions and 38 deletions
+46 -6
View File
@@ -145,6 +145,18 @@ func IsJTIRevoked(ctx context.Context, jti string) bool {
// bound access tokens die immediately when theft is detected (HIGH 1).
const familyAliveCacheTTL = 30 * time.Second
// familyAliveRecheckGrace is how close a cached family-alive verdict must be to
// its TTL before verifyFamilyAlive re-validates it against the DB instead of
// trusting the cache (Loop B finding 3). The daily refresh-token cleanup
// captures the affected families, DELETEs them, commits, and then invalidates
// the in-memory cache — a crash between the DELETE commit and the
// invalidation leaves the cache warm for up to familyAliveCacheTTL, accepting a
// bound access token after its family was killed. Re-validating an ALIVE
// verdict within this grace of its expiry bounds that residual window to the
// grace itself; a small grace (5s of a 30s TTL) preserves the MEDIUM-1
// query-amplification win — only near-expiry lookups re-query.
const familyAliveRecheckGrace = 5 * time.Second
// familyAliveCacheMaxEntries bounds the in-memory map so a flood of distinct
// family ids cannot grow it without bound.
const familyAliveCacheMaxEntries = 10_000
@@ -172,17 +184,32 @@ func init() {
// familyAliveLookup returns a cached verdict for a family key and whether it is
// still fresh, evicting expired entries opportunistically.
func familyAliveLookup(key string) (alive bool, ok bool) {
e, ok := familyAliveLookupEntry(key)
if !ok {
return false, false
}
return e.alive, true
}
// familyAliveLookupEntry returns the cached verdict entry (with its expiry) for
// a family key and whether it is still fresh, evicting expired entries
// opportunistically. Unlike familyAliveLookup it hands the caller the entry so
// verifyFamilyAlive can re-validate a near-expiry ALIVE verdict against the DB
// (Loop B finding 3 — the residual crash window of the refresh-token cleanup's
// post-commit cache invalidation); familyAliveLookup stays as the simple
// (alive, ok) accessor used by the tests.
func familyAliveLookupEntry(key string) (familyAliveCacheEntry, bool) {
familyAliveCache.mu.Lock()
defer familyAliveCache.mu.Unlock()
e, ok := familyAliveCache.m[key]
if !ok {
return false, false
return familyAliveCacheEntry{}, false
}
if clock.Now().After(e.expires) {
delete(familyAliveCache.m, key)
return false, false
return familyAliveCacheEntry{}, false
}
return e.alive, true
return e, true
}
// familyAliveStore records a DB-confirmed verdict, evicting expired entries
@@ -398,6 +425,15 @@ func VerifyToken(tokenString string, ctx context.Context) (userID string, role s
// IsJTIRevoked — because genuine theft is already handled by the family kill in
// VerifyRefreshToken's reuse branch, and a transient DB error must not turn
// into a total 401 outage for every authenticated request.
//
// Loop B finding 3 (crash-safety residual window): an ALIVE verdict within
// familyAliveRecheckGrace of its TTL is re-validated against the DB. The daily
// refresh-token cleanup (handlers/scheduling/scheduled-cleanup.go
// CleanupExpiredRefreshTokens) invalidates the family-alive cache AFTER its
// DELETE commits, so a crash between the commit and the invalidation leaves the
// cache warm for the rest of the TTL — a family killed by that missed
// invalidation would otherwise keep admitting its bound access tokens. The
// near-expiry re-check closes the gap to the grace window.
func verifyFamilyAlive(ctx context.Context, token jwtClaimGetter, userID string) error {
var familyVal any
if err := token.Get(accessTokenFamilyClaim, &familyVal); err != nil {
@@ -411,11 +447,15 @@ func verifyFamilyAlive(ctx context.Context, token jwtClaimGetter, userID string)
return nil
}
key := familyID + "|" + userID
if alive, cached := familyAliveLookup(key); cached {
if !alive {
if e, cached := familyAliveLookupEntry(key); cached {
if !e.alive {
return fmt.Errorf("token revoked")
}
return nil
// A fresh verdict is trusted (the query-amplification win); only a
// near-expiry ALIVE verdict falls through to the DB re-check below.
if clock.Now().Before(e.expires.Add(-familyAliveRecheckGrace)) {
return nil
}
}
var exists bool
err := db.Conn.QueryRow(ctx,