Restart.
Data foundation started, with updated guidelines and golangci-lint config.
This commit is contained in:
47
data/totp.go
Normal file
47
data/totp.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
twofactor "git.wntrmute.dev/kyle/goutils/twofactor"
|
||||
)
|
||||
|
||||
const totpPeriod = 30
|
||||
|
||||
// builtinTOTPValidator delegates TOTP validation to goutils/twofactor.
|
||||
type builtinTOTPValidator struct{}
|
||||
|
||||
func (builtinTOTPValidator) Validate(secret, code string, at time.Time, window int) bool {
|
||||
if secret == "" || code == "" {
|
||||
return false
|
||||
}
|
||||
// Normalize secret similar to common authenticator apps: remove spaces and uppercase.
|
||||
norm := strings.ToUpper(strings.ReplaceAll(secret, " ", ""))
|
||||
norm = twofactor.Pad(norm)
|
||||
otp, err := twofactor.NewGoogleTOTP(norm)
|
||||
if err != nil || otp == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compute the base counter for the provided time (period 30s, start 0).
|
||||
base := uint64(at.Unix()&unsignedMask64) / totpPeriod // #nosec G115 - masked off overflow
|
||||
// Check +/- window steps.
|
||||
for i := -window; i <= window; i++ {
|
||||
var ctr uint64
|
||||
if i < 0 {
|
||||
// Guard underflow.
|
||||
offs := uint64(-i)
|
||||
if offs > base {
|
||||
continue
|
||||
}
|
||||
ctr = base - offs
|
||||
} else {
|
||||
ctr = base + uint64(i)
|
||||
}
|
||||
if otp.OATH.OTP(ctr) == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
14
data/types.go
Normal file
14
data/types.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package data
|
||||
|
||||
const (
|
||||
unsignedMask64 = 0x7FFFFFFF
|
||||
)
|
||||
|
||||
// DBCredentials holds generated database access parameters for a service account.
|
||||
type DBCredentials struct {
|
||||
Username string
|
||||
Password string
|
||||
Database string
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
160
data/user.go
Normal file
160
data/user.go
Normal file
@@ -0,0 +1,160 @@
|
||||
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
|
||||
}
|
||||
49
data/user_test.go
Normal file
49
data/user_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package data_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/data"
|
||||
)
|
||||
|
||||
func TestPasswordSetAndCheck(t *testing.T) {
|
||||
var u data.User
|
||||
if err := u.SetPassword("s3cret!"); err != nil {
|
||||
t.Fatalf("SetPassword error: %v", err)
|
||||
}
|
||||
if !u.CheckPassword("s3cret!") {
|
||||
t.Fatal("expected password to verify")
|
||||
}
|
||||
if u.CheckPassword("wrong") {
|
||||
t.Fatal("expected wrong password to fail")
|
||||
}
|
||||
// Round-trip hash string
|
||||
hs := u.PasswordHash()
|
||||
if hs == "" {
|
||||
t.Fatal("expected non-empty password hash string")
|
||||
}
|
||||
var u2 data.User
|
||||
if err := u2.LoadPasswordHash(hs); err != nil {
|
||||
t.Fatalf("LoadPasswordHash error: %v", err)
|
||||
}
|
||||
if !u2.CheckPassword("s3cret!") {
|
||||
t.Fatal("expected password to verify after LoadPasswordHash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPValidationKnownVector(t *testing.T) {
|
||||
// From RFC 6238 test secret (base32): "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|
||||
// Using T0=0, step=30. For SHA1, at 59s, code should be 94287082 -> 6-digit 287082.
|
||||
u := data.User{TOTPSecret: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"}
|
||||
ts := time.Unix(59, 0)
|
||||
if !u.VerifyTOTP("287082", ts, 0) {
|
||||
t.Fatal("expected TOTP code to verify for known vector")
|
||||
}
|
||||
if u.VerifyTOTP("287082", ts.Add(30*time.Second), 0) {
|
||||
t.Fatal("expected code to fail outside time step with zero window")
|
||||
}
|
||||
if !u.VerifyTOTP("287082", ts.Add(30*time.Second), 1) {
|
||||
t.Fatal("expected code to verify within window=1")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user