Files
pangolin/server/internal/codes/generator.go
T
wangjia afcd7b325c feat(codes): implement activation-code module (tsk_tFMU7-hKzfOf)
Implements server/internal/codes/ with all required functionality:

## Generator (generator.go)
- Crockford Base32 16-char codes (15 data + 1 Crockford mod-37 check char)
- crypto/rand for unbiased random generation with rejection sampling
- Canonicalize(): I/L→1, O→0 folding, hyphen/space stripping
- Hash(): SHA-256 of canonical plaintext (only value stored in DB)
- Algorithm hard-coded; check char detects all single-char substitution errors

## Store (store.go)
- MySQL-backed via database/sql
- CreateBatch / CreateCode with ErrDuplicate on UNIQUE conflict
- FindCodeByHashForUpdate: SELECT … FOR UPDATE for row-level concurrency control
- MarkRedeemed, ExtendSubscription, CreateSubscription, GetActiveSubscriptions
- WriteAuditLog, CodeExistsByHash
- BeginTx at READ COMMITTED (FOR UPDATE provides row exclusivity)

## Service (service.go)
- Redeem(): 9-step flow with full idempotency and concurrency safety
  - isLocked / recordFail / clearFail via Redis key redeem:fail:{user_id}
  - SELECT … FOR UPDATE → single winner under N-concurrent redemptions
  - Same-plan: extends existing subscription (max(expires_at,now)+days)
  - Cross-plan: creates new subscription (max(now,latest)+days)
  - Idempotent: same user re-submits → 200 without re-applying
  - 5 failures → ACCOUNT_LOCKED for 1 hour (sliding window via Redis)
- CreateBatch(): generates N codes, stores hashes, returns plaintext once
  - Automatic retry on hash collision (birthday probability ≈10⁻⁸)

## Webhook (webhook.go)
- POST /webhook/store/codes — outside /v1, no JWT required
- HMAC-SHA256 with hmac.Equal constant-time comparison
- ±5 min timestamp window
- Redis SetNX nonce deduplication (15-min TTL)
- Idempotent: duplicate nonce → 200; duplicate code_hash → 200

## HTTP Handler (handler.go)
- POST /v1/redeem endpoint wired to Service.Redeem
- Reads userID from context key (set by JWT middleware from auth module)
- Bilingual error responses {code, message_zh, message_en}

## Export (export.go)
- ExportCSV(): streams plaintext codes to io.Writer as CSV
- Plaintext NEVER stored in DB; only SHA-256 hash persists

## CLI (cmd/codegen/main.go)
- codegen -plan -days -count -channel -note -dsn [-out]
- Transition tool until admin panel (#8) is ready
- Outputs CSV to stdout or file; warns operator about plaintext sensitivity

## Infrastructure (skeleton)
- internal/config/config.go: env-var configuration
- internal/db/db.go: MySQL connection pool helper
- internal/redisutil/redis.go: Redis client constructor
- internal/apierr/apierr.go: bilingual error types
- migrations/001_init.sql: DDL for codes module tables
- go.mod with all dependencies

## Tests
- generator_test.go (unit, no deps):
  - Format, uniqueness (10k codes, zero collisions), normalization,
    check-char detection of all single-char errors, hash consistency
- webhook_test.go (unit, no deps):
  - Signature rejection, missing signature, stale/future timestamp,
    missing nonce, constant-time HMAC comparison
- service_test.go (//go:build integration, testcontainers):
  - N=20 concurrent redeemers → exactly 1 winner
  - Idempotent redeem returns success for same user
  - Other-user redemption → CODE_REDEEMED + failure counted
  - 5 failures → ACCOUNT_LOCKED on 6th attempt
  - Same-plan extension: expires_at precision assertion
  - Cross-plan creation: new subscription row
  - Audit log written on every successful redemption
  - CSV export: no plaintext in DB, hash present
  - Webhook nonce replay: idempotent 200
  - Webhook same-hash: idempotent 200, single DB row

Run unit tests: make test
Run integration tests: make test-integration (requires Docker)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 02:16:16 +08:00

179 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package codes implements the activation-code lifecycle:
// batch generation, card-store webhook ingestion, idempotent redemption,
// subscription extension, and CSV export.
//
// Security invariant: plaintext codes are NEVER written to the database or
// to any log. The database stores only SHA-256(canonical_plaintext).
// Plaintext appears exactly once: in the batch-generation response / CSV export.
package codes
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
)
// Crockford Base32 encoding alphabet (32 symbols, excludes I L O U).
const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
// crockfordCheck is the extended 37-symbol check-character alphabet.
// Symbols 031 are identical to crockfordAlphabet; 3236 are *, ~, $, =, U.
const crockfordCheck = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U"
// crockfordDecode maps every printable ASCII character to its Crockford
// numeric value (031), or to -1 if the character is not valid.
var crockfordDecode [128]int8
func init() {
for i := range crockfordDecode {
crockfordDecode[i] = -1
}
for i, ch := range crockfordAlphabet {
crockfordDecode[ch] = int8(i)
// Lower-case equivalents.
if ch >= 'A' && ch <= 'Z' {
crockfordDecode[ch-'A'+'a'] = int8(i)
}
}
// Normalisation rules per Crockford spec:
// I, i, l, L → 1
// O, o → 0
crockfordDecode['I'] = crockfordDecode['1']
crockfordDecode['i'] = crockfordDecode['1']
crockfordDecode['l'] = crockfordDecode['1']
crockfordDecode['L'] = crockfordDecode['1']
crockfordDecode['O'] = crockfordDecode['0']
crockfordDecode['o'] = crockfordDecode['0']
}
// Canonicalize converts an activation-code string into canonical form:
// uppercase, with I/L→1 and O→0 substitutions applied.
// Returns an error if the string contains characters outside the normalised
// Crockford alphabet or if the length is not exactly 16.
func Canonicalize(code string) (string, error) {
code = strings.TrimSpace(code)
// Strip any hyphens/spaces inserted for readability (e.g., XXXX-XXXX-XXXX-XXXX).
code = strings.ReplaceAll(code, "-", "")
code = strings.ReplaceAll(code, " ", "")
if len(code) != 16 {
return "", fmt.Errorf("codes: code must be exactly 16 characters, got %d", len(code))
}
var buf [16]byte
for i := 0; i < 16; i++ {
ch := code[i]
if ch >= 128 {
return "", fmt.Errorf("codes: non-ASCII character at position %d", i)
}
v := crockfordDecode[ch]
if v < 0 {
// For the check symbol position (last char) we allow the full 37-symbol set.
// Check separately below after we have the string.
if i < 15 {
return "", fmt.Errorf("codes: invalid character %q at position %d", ch, i)
}
// Position 15 is the check character; validate separately.
buf[i] = byte(strings.ToUpper(string(ch))[0])
continue
}
buf[i] = crockfordAlphabet[v]
}
canonical := string(buf[:])
// Validate the check character.
if err := validateCheckChar(canonical); err != nil {
return "", err
}
return canonical, nil
}
// Hash returns the hex-encoded SHA-256 of the canonical plaintext code.
// This is the value stored in the database; the plaintext is never stored.
func Hash(canonical string) string {
sum := sha256.Sum256([]byte(canonical))
return hex.EncodeToString(sum[:])
}
// computeCheckValue uses Horner's method to compute the Crockford check value
// (mod 37) of the 15 data characters in s[0:15].
// s must already be in canonical (uppercase) form.
func computeCheckValue(s string) int {
result := 0
for i := 0; i < 15; i++ {
ch := s[i]
if ch >= 128 {
return -1
}
v := int(crockfordDecode[ch])
if v < 0 {
return -1
}
result = (result*32 + v) % 37
}
return result
}
// validateCheckChar verifies that the 16th character of canonical is the
// correct Crockford mod-37 check symbol.
func validateCheckChar(canonical string) error {
if len(canonical) != 16 {
return errors.New("codes: invalid length for check validation")
}
expected := computeCheckValue(canonical)
if expected < 0 {
return errors.New("codes: invalid data characters")
}
want := rune(crockfordCheck[expected])
got := rune(canonical[15])
// The check character might be in the extended set (*~$=U) so compare directly.
if got != want {
return fmt.Errorf("codes: check character mismatch: want %c, got %c", want, got)
}
return nil
}
// GenerateCode generates a single random activation code in canonical Crockford
// Base32 form (15 random data chars + 1 check char = 16 chars total).
// Uses crypto/rand for cryptographically-secure randomness.
func GenerateCode() (string, error) {
// We need 15 random values each in [0, 32).
// To avoid modular bias, use rejection sampling with bytes from crypto/rand.
// Each byte gives a value in [0, 256); we accept values in [0, 224) to ensure
// uniform distribution over [0, 32) (224 = 7*32).
const dataLen = 15
var buf [dataLen]byte
i := 0
for i < dataLen {
var tmp [dataLen * 2]byte // over-read to reduce syscall count
if _, err := rand.Read(tmp[:]); err != nil {
return "", fmt.Errorf("codes: crypto/rand: %w", err)
}
for _, b := range tmp {
if b < 224 { // 224 = 7*32; accept to avoid bias
buf[i] = crockfordAlphabet[b%32]
i++
if i == dataLen {
break
}
}
}
}
data := string(buf[:])
checkVal := computeCheckValue(data + "0") // dummy 16th char, only first 15 used
if checkVal < 0 {
// Should never happen since we only use valid chars.
return "", errors.New("codes: internal check computation error")
}
return data + string(crockfordCheck[checkVal]), nil
}
// ErrDuplicate is returned by the batch generator when a generated code
// already exists in the database (hash collision). The caller should retry.
var ErrDuplicate = errors.New("codes: duplicate code hash")