package local import ( "crypto/rand" "encoding/base32" "fmt" "strings" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/entitlement" "galaxy/user/internal/domain/policy" ) var base32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding) // userNameSuffixAlphabet is the Crockford lowercase Base32 alphabet with // `i`, `l`, `o`, and `u` excluded to avoid visual confusables. The chosen // 32 characters also keep each byte pair aligned with a 5-bit group so the // 5-byte random source encodes into exactly eight suffix characters. const userNameSuffixAlphabet = "0123456789abcdefghjkmnpqrstvwxyz" const userNameSuffixLength = 8 // IDGenerator creates opaque stable user identifiers and generated initial // user names. type IDGenerator struct{} // NewUserID returns one newly generated opaque user identifier. func (IDGenerator) NewUserID() (common.UserID, error) { token, err := randomToken(10) if err != nil { return "", fmt.Errorf("generate user id: %w", err) } userID := common.UserID("user-" + token) if err := userID.Validate(); err != nil { return "", fmt.Errorf("generate user id: %w", err) } return userID, nil } // NewUserName returns one generated user name in the `player-` form. // The suffix is eight characters drawn from the Crockford lowercase Base32 // alphabet (confusable-free: `i`, `l`, `o`, `u` are excluded). func (IDGenerator) NewUserName() (common.UserName, error) { suffix, err := randomSuffix(userNameSuffixLength) if err != nil { return "", fmt.Errorf("generate user name: %w", err) } userName := common.UserName("player-" + suffix) if err := userName.Validate(); err != nil { return "", fmt.Errorf("generate user name: %w", err) } return userName, nil } // NewEntitlementRecordID returns one generated entitlement history record // identifier. func (IDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) { token, err := randomToken(10) if err != nil { return "", fmt.Errorf("generate entitlement record id: %w", err) } recordID := entitlement.EntitlementRecordID("entitlement-" + token) if err := recordID.Validate(); err != nil { return "", fmt.Errorf("generate entitlement record id: %w", err) } return recordID, nil } // NewSanctionRecordID returns one generated sanction history record // identifier. func (IDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) { token, err := randomToken(10) if err != nil { return "", fmt.Errorf("generate sanction record id: %w", err) } recordID := policy.SanctionRecordID("sanction-" + token) if err := recordID.Validate(); err != nil { return "", fmt.Errorf("generate sanction record id: %w", err) } return recordID, nil } // NewLimitRecordID returns one generated limit history record identifier. func (IDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) { token, err := randomToken(10) if err != nil { return "", fmt.Errorf("generate limit record id: %w", err) } recordID := policy.LimitRecordID("limit-" + token) if err := recordID.Validate(); err != nil { return "", fmt.Errorf("generate limit record id: %w", err) } return recordID, nil } func randomToken(size int) (string, error) { buffer := make([]byte, size) if _, err := rand.Read(buffer); err != nil { return "", err } return strings.ToLower(base32NoPadding.EncodeToString(buffer)), nil } // randomSuffix returns a length-character suffix encoded from crypto-random // bytes through the userNameSuffixAlphabet. Each character consumes five // random bits, so the caller receives `ceil(length * 5 / 8)` bytes of // entropy in the underlying buffer. func randomSuffix(length int) (string, error) { byteCount := (length*5 + 7) / 8 buffer := make([]byte, byteCount) if _, err := rand.Read(buffer); err != nil { return "", err } encoded := make([]byte, length) for index := range encoded { bitOffset := index * 5 byteIndex := bitOffset / 8 shift := bitOffset % 8 value := uint16(buffer[byteIndex]) << 8 if byteIndex+1 < len(buffer) { value |= uint16(buffer[byteIndex+1]) } encoded[index] = userNameSuffixAlphabet[(value>>(16-5-shift))&0x1F] } return string(encoded), nil }