package data import ( "crypto/rand" "crypto/subtle" "encoding/base64" "errors" "strconv" "time" "golang.org/x/crypto/scrypt" ) // AccountType distinguishes human users from system/service accounts. type AccountType string const ( AccountHuman AccountType = "human" AccountSystem AccountType = "system" ) // User represents an identity in the system. It supports password and optional TOTP. type User struct { ID string Username string Type AccountType Roles []string // Password hashing material pwdHash []byte pwdSalt []byte // Base32 encoded TOTP secret (RFC 3548). Empty means TOTP disabled. TOTPSecret string } // Scrypt parameters. Chosen for interactive logins; can be tuned later. const ( scryptN = 1 << 15 // 32768 scryptR = 8 scryptP = 1 keyLen = 32 saltLen = 16 ) // SetPassword hashes and stores the provided password using scrypt with a random salt. func (u *User) SetPassword(password string) error { if password == "" { return errors.New("password cannot be empty") } salt := make([]byte, saltLen) if _, err := rand.Read(salt); err != nil { return err } dk, err := scrypt.Key([]byte(password), salt, scryptN, scryptR, scryptP, keyLen) if err != nil { return err } u.pwdSalt = salt u.pwdHash = dk return nil } // CheckPassword verifies a plaintext password against the stored scrypt hash. func (u *User) CheckPassword(password string) bool { if len(u.pwdSalt) == 0 || len(u.pwdHash) == 0 { return false } dk, err := scrypt.Key([]byte(password), u.pwdSalt, scryptN, scryptR, scryptP, keyLen) if err != nil { return false } return subtle.ConstantTimeCompare(dk, u.pwdHash) == 1 } // PasswordHash returns a portable string representation of the scrypt hash and salt. // The format is: scrypt:N:r:p:base64(salt):base64(hash). func (u *User) PasswordHash() string { if len(u.pwdSalt) == 0 || len(u.pwdHash) == 0 { return "" } return "scrypt:" + strconv.Itoa(scryptN) + ":" + strconv.Itoa(scryptR) + ":" + strconv.Itoa(scryptP) + ":" + base64.RawStdEncoding.EncodeToString(u.pwdSalt) + ":" + base64.RawStdEncoding.EncodeToString(u.pwdHash) } const passwordHashParts = 5 // LoadPasswordHash parses the value produced by PasswordHash and loads it into the user. func (u *User) LoadPasswordHash(s string) error { if s == "" { u.pwdSalt = nil u.pwdHash = nil return nil } const prefix = "scrypt:" if len(s) < len(prefix) || s[:len(prefix)] != prefix { return errors.New("unsupported password hash format") } parts := splitN(s[len(prefix):], ':', passwordHashParts) if len(parts) != passwordHashParts { return errors.New("invalid password hash") } // We currently ignore parsed N,r,p and use compiled constants to avoid variable-time params. salt, err := base64.RawStdEncoding.DecodeString(parts[3]) if err != nil { return err } hash, err := base64.RawStdEncoding.DecodeString(parts[4]) if err != nil { return err } if len(hash) != keyLen { return errors.New("invalid hash length") } u.pwdSalt = salt u.pwdHash = hash return nil } // VerifyTOTP checks a TOTP code for the user's TOTP secret. If no secret is set, it fails. // The window parameter defines the allowed step skew (+/- window steps). func (u *User) VerifyTOTP(code string, at time.Time, window int) bool { if u.TOTPSecret == "" || code == "" { return false } return defaultTOTPValidator.Validate(u.TOTPSecret, code, at, window) } // TOTPValidator abstracts TOTP verification to allow swapping implementations. type TOTPValidator interface { Validate(secret, code string, at time.Time, window int) bool } var defaultTOTPValidator TOTPValidator = builtinTOTPValidator{} // splitN is like strings.SplitN but without importing another package here. func splitN(s string, sep rune, n int) []string { if n <= 0 { return nil } out := make([]string, 0, n) start := 0 count := 1 for i, r := range s { if r == sep { out = append(out, s[start:i]) start = i + 1 count++ if count == n { break } } } if start <= len(s) { out = append(out, s[start:]) } return out }