fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs

Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes:
- CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back
- A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit)
- A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds
- A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows
- A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs
- A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point)
- A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface
- A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction
- M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test
- Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status)

All 25 backend packages pass; frontend 41/41; build + env-docs green.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 78e6d00dc5
commit 6d82535780
60 changed files with 6608 additions and 801 deletions
+123
View File
@@ -1,10 +1,20 @@
package payments
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
"crussell/db"
"crussell/internal/square"
@@ -186,3 +196,116 @@ func recheckBookingPayable(ctx context.Context, q db.Querier, bookingID string)
}
return status, bookingStatusAllowsCompletedPayment(status), nil
}
// snapshotEncMarker prefixes the at-rest encrypted form of a stored
// square_request_snapshot (PII: buyer email + ccof tokens) so decryptSnapshot
// can distinguish encrypted values from plaintext (dev/mock environments and
// legacy pre-encryption rows). The marker itself is not secret.
const snapshotEncMarker = "enc:v1:"
// snapshotEncKeyWarningOnce throttles the missing-key CRITICAL log to one line
// per process: a deployment without a usable SNAPSHOT_ENC_KEY falls back to
// plaintext (money-safety first — the replayable snapshot must not be lost),
// and the single loud warning makes the misconfiguration impossible to miss.
var snapshotEncKeyWarningOnce sync.Once
// snapshotEncKey parses the AES-256-GCM key from the SNAPSHOT_ENC_KEY
// environment variable (base64-encoded 32 bytes). It is read on every call so
// tests can flip the env; the parse is cheap and encryption happens once per
// payment.
func snapshotEncKey() ([]byte, error) {
raw := strings.TrimSpace(os.Getenv("SNAPSHOT_ENC_KEY"))
if raw == "" {
return nil, errors.New("SNAPSHOT_ENC_KEY is not set")
}
decoded, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("SNAPSHOT_ENC_KEY is not valid base64: %w", err)
}
if len(decoded) != 32 {
return nil, fmt.Errorf("SNAPSHOT_ENC_KEY must decode to 32 bytes for AES-256, got %d", len(decoded))
}
return decoded, nil
}
// encryptSnapshot returns the snapshot body ready for storage. In dev/mock
// environments it returns the body unchanged (no key required, tests keep
// passing); in non-mock environments (SQUARE_ENVIRONMENT production/sandbox —
// the same gate IsExplicitDevOrMockEnv drives) it AES-256-GCM-encrypts the
// body and returns "enc:v1:" + base64(nonce || ciphertext) so the PII at rest
// (buyer email, ccof card tokens) is encrypted. The transformation is
// lossless: decryptSnapshot recovers the ORIGINAL bytes exactly, which Square's
// identical-body idempotency replay depends on. A missing/unusable key in a
// non-mock deployment falls back to plaintext with a one-time CRITICAL log —
// breaking the replayable snapshot to protect PII would strand pending rows,
// so money-safety wins over best-effort hardening.
func encryptSnapshot(body []byte) ([]byte, error) {
if IsExplicitDevOrMockEnv() {
return body, nil
}
key, err := snapshotEncKey()
if err != nil {
snapshotEncKeyWarningOnce.Do(func() {
log.Printf("CRITICAL: %v — storing square_request_snapshot PLAINTEXT; set SNAPSHOT_ENC_KEY to a base64-encoded 32-byte key in non-mock deployments", err)
})
return body, nil
}
gcm, err := newSnapshotGCM(key)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("failed to read snapshot encryption nonce: %w", err)
}
sealed := gcm.Seal(nonce, nonce, body, nil)
out := append([]byte(snapshotEncMarker), []byte(base64.StdEncoding.EncodeToString(sealed))...)
return out, nil
}
// decryptSnapshot reverses encryptSnapshot for a stored square_request_snapshot.
// Marker-prefixed values are base64-decoded and AES-256-GCM-decrypted back to
// the byte-identical original request body (the sweep's by-key replay depends
// on this); values without the marker (dev/mock plaintext or legacy
// pre-encryption rows) are returned unchanged. Exporting it lets the sweep's
// stale-pending reconcile decrypt stored snapshots before replay.
func decryptSnapshot(data []byte) ([]byte, error) {
if !bytes.HasPrefix(data, []byte(snapshotEncMarker)) {
return data, nil
}
key, err := snapshotEncKey()
if err != nil {
return nil, fmt.Errorf("cannot decrypt stored square_request_snapshot: %w", err)
}
gcm, err := newSnapshotGCM(key)
if err != nil {
return nil, err
}
sealed, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(string(data), snapshotEncMarker))
if err != nil {
return nil, fmt.Errorf("stored square_request_snapshot is not valid base64: %w", err)
}
nonceSize := gcm.NonceSize()
if len(sealed) < nonceSize {
return nil, errors.New("stored square_request_snapshot ciphertext is too short")
}
nonce, ciphertext := sealed[:nonceSize], sealed[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("stored square_request_snapshot failed AES-GCM authentication: %w", err)
}
return plaintext, nil
}
// newSnapshotGCM builds the AES-256-GCM AEAD for the given 32-byte key.
func newSnapshotGCM(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to init snapshot AES cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to init snapshot AES-GCM: %w", err)
}
return gcm, nil
}