// Package local provides small in-process runtime implementations for // authsession ports that do not require network dependencies. package local import ( "crypto/rand" "encoding/base64" "fmt" "math/big" "strings" "time" "galaxy/authsession/internal/domain/common" "galaxy/authsession/internal/ports" "golang.org/x/crypto/bcrypt" ) const ( challengeIDPrefix = "challenge-" deviceSessionIDPrefix = "device-session-" codeDigits = 6 ) // Clock implements ports.Clock using the local system clock in UTC. type Clock struct{} // Now returns the current system time normalized to UTC. func (Clock) Now() time.Time { return time.Now().UTC() } // IDGenerator implements ports.IDGenerator with cryptographically random // opaque identifiers. type IDGenerator struct{} // NewChallengeID returns a fresh random challenge identifier. func (IDGenerator) NewChallengeID() (common.ChallengeID, error) { value, err := newOpaqueIDString(challengeIDPrefix) if err != nil { return "", err } return common.ChallengeID(value), nil } // NewDeviceSessionID returns a fresh random device-session identifier. func (IDGenerator) NewDeviceSessionID() (common.DeviceSessionID, error) { value, err := newOpaqueIDString(deviceSessionIDPrefix) if err != nil { return "", err } return common.DeviceSessionID(value), nil } // CodeGenerator implements ports.CodeGenerator with random 6-digit decimal // confirmation codes. type CodeGenerator struct{} // Generate returns one fresh random 6-digit decimal code. func (CodeGenerator) Generate() (string, error) { var builder strings.Builder builder.Grow(codeDigits) for idx := 0; idx < codeDigits; idx++ { digit, err := rand.Int(rand.Reader, big.NewInt(10)) if err != nil { return "", fmt.Errorf("generate confirmation code: %w", err) } builder.WriteByte(byte('0' + digit.Int64())) } return builder.String(), nil } // CodeHasher implements ports.CodeHasher with bcrypt-backed hashes. type CodeHasher struct{} // Hash returns the bcrypt hash of code. func (CodeHasher) Hash(code string) ([]byte, error) { if err := validateCode(code); err != nil { return nil, err } hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost) if err != nil { return nil, fmt.Errorf("hash confirmation code: %w", err) } return hash, nil } // Compare reports whether hash matches code. func (CodeHasher) Compare(hash []byte, code string) (bool, error) { if err := validateCode(code); err != nil { return false, err } if len(hash) == 0 { return false, nil } err := bcrypt.CompareHashAndPassword(hash, []byte(code)) switch err { case nil: return true, nil case bcrypt.ErrMismatchedHashAndPassword: return false, nil default: return false, fmt.Errorf("compare confirmation code hash: %w", err) } } func newOpaqueIDString(prefix string) (string, error) { randomBytes := make([]byte, 16) if _, err := rand.Read(randomBytes); err != nil { return "", fmt.Errorf("generate opaque identifier: %w", err) } return prefix + base64.RawURLEncoding.EncodeToString(randomBytes), nil } func validateCode(code string) error { switch { case strings.TrimSpace(code) == "": return fmt.Errorf("code must not be empty") case strings.TrimSpace(code) != code: return fmt.Errorf("code must not contain surrounding whitespace") default: return nil } } var ( _ ports.Clock = Clock{} _ ports.IDGenerator = IDGenerator{} _ ports.CodeGenerator = CodeGenerator{} _ ports.CodeHasher = CodeHasher{} )