Data foundation started, with updated guidelines and
golangci-lint config.
This commit is contained in:
2025-11-16 19:21:27 -08:00
parent 07a5e957af
commit c8a6830a80
9 changed files with 788 additions and 149 deletions

47
data/totp.go Normal file
View 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
View 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
View 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
View 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")
}
}