afcd7b325c
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>
179 lines
5.7 KiB
Go
179 lines
5.7 KiB
Go
// 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 0–31 are identical to crockfordAlphabet; 32–36 are *, ~, $, =, U.
|
||
const crockfordCheck = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U"
|
||
|
||
// crockfordDecode maps every printable ASCII character to its Crockford
|
||
// numeric value (0–31), 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")
|