// 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")