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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user