// Package idgen generates application-level identifiers. // For most entities it wraps github.com/google/uuid (v7 time-ordered UUIDs). // For activation codes it produces 16-character Crockford Base32 strings // with a check digit, suitable for display and human entry. package idgen import ( "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "fmt" "strings" "github.com/google/uuid" ) // ───────────────────────────────────────────────────────────────────────────── // UUID v7 — time-ordered identifiers for database entities // ───────────────────────────────────────────────────────────────────────────── // New returns a new UUID v7 (time-ordered, random node bits). // UUID v7 is the recommended identifier format for database entities; its // time-ordered structure improves B-tree index locality compared with UUID v4. // Panics only if the system's crypto/rand source is unavailable (a fatal // misconfiguration; panic is appropriate). func New() uuid.UUID { id, err := uuid.NewV7() if err != nil { panic("idgen: crypto/rand unavailable: " + err.Error()) } return id } // NewString returns a new UUID v7 as a canonical lower-case string // (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). func NewString() string { return New().String() } // ───────────────────────────────────────────────────────────────────────────── // Crockford Base32 — human-friendly activation codes // ───────────────────────────────────────────────────────────────────────────── // crockfordAlphabet is the 32-symbol encoding alphabet (excludes I, L, O, U // to prevent visual confusion with 1, 1, 0, and V respectively). const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" // crockfordCheck is the extended 37-symbol check-character alphabet used for // the Crockford mod-37 check symbol. Symbols 0–31 match crockfordAlphabet; // symbols 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. // Normalisation rules per the Crockford spec are applied at init time: // // I, i, l, L → 1 // O, o → 0 var crockfordDecode [128]int8 func init() { for i := range crockfordDecode { crockfordDecode[i] = -1 } for i, ch := range crockfordAlphabet { crockfordDecode[ch] = int8(i) // Accept lower-case equivalents. if ch >= 'A' && ch <= 'Z' { crockfordDecode[ch-'A'+'a'] = int8(i) } } // Normalisation: 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'] } // computeCheckValue computes the Crockford mod-37 check value (Horner's method) // of the first 15 characters of s (which must already be in canonical form). // Returns -1 if any character is invalid. 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 canonical[15] is the correct Crockford // mod-37 check symbol for canonical[0:15]. func validateCheckChar(canonical string) error { if len(canonical) != 16 { return errors.New("idgen: invalid length for check validation") } expected := computeCheckValue(canonical) if expected < 0 { return errors.New("idgen: invalid data characters in code") } want := rune(crockfordCheck[expected]) got := rune(canonical[15]) if got != want { return fmt.Errorf("idgen: check character mismatch: want %c, got %c", want, got) } return nil } // CanonicalizeCode converts an activation-code string into its canonical form: // uppercase, with I/L→1 and O→0 substitutions applied, with the check // character validated. Hyphens and spaces are stripped before validation // (supports the common XXXX-XXXX-XXXX-XXXX display format). // // Returns an error if the string contains characters outside the normalised // Crockford alphabet, if the length (after stripping) is not exactly 16, or // if the check character is incorrect. func CanonicalizeCode(code string) (string, error) { code = strings.TrimSpace(code) code = strings.ReplaceAll(code, "-", "") code = strings.ReplaceAll(code, " ", "") if len(code) != 16 { return "", fmt.Errorf("idgen: 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("idgen: non-ASCII character at position %d", i) } v := crockfordDecode[ch] if v < 0 { if i < 15 { return "", fmt.Errorf("idgen: invalid character %q at position %d", ch, i) } // Position 15 is the check character; validate separately below. buf[i] = []byte(strings.ToUpper(string(ch)))[0] continue } buf[i] = crockfordAlphabet[v] } canonical := string(buf[:]) if err := validateCheckChar(canonical); err != nil { return "", err } return canonical, nil } // HashCode returns the hex-encoded SHA-256 digest of the canonical plaintext // activation code. This digest is the value stored in the database; the // plaintext code itself must never be persisted or logged. func HashCode(canonical string) string { sum := sha256.Sum256([]byte(canonical)) return hex.EncodeToString(sum[:]) } // GenerateCode generates a single random activation code in canonical // Crockford Base32 form: 15 random data characters followed by 1 mod-37 // check character (16 characters total). Uses crypto/rand for // cryptographically-secure randomness. // // To avoid modular bias, the function uses rejection sampling: random bytes // in [0, 224) are accepted (224 = 7×32 ensures a uniform distribution over // the 32-symbol alphabet), bytes ≥ 224 are discarded. func GenerateCode() (string, error) { const dataLen = 15 var buf [dataLen]byte i := 0 for i < dataLen { // Over-read to reduce crypto/rand syscall count. var tmp [dataLen * 2]byte if _, err := rand.Read(tmp[:]); err != nil { return "", fmt.Errorf("idgen: crypto/rand: %w", err) } for _, b := range tmp { if b < 224 { // accept range: 224 = 7×32 buf[i] = crockfordAlphabet[b%32] i++ if i == dataLen { break } } } } data := string(buf[:]) // Compute check value over the 15 data chars (the dummy 16th char is ignored). checkVal := computeCheckValue(data + "0") if checkVal < 0 { // Cannot happen: all buf[i] values come from crockfordAlphabet. return "", errors.New("idgen: internal check computation error") } return data + string(crockfordCheck[checkVal]), nil }