Files
pilens/internal/auth/auth.go
T
Andy Dixon 52da3c13f6 Core packages: config validation, network renderers, root helper, auth, SQLite store, capture engine
Capture engine streams dumpcap pcapng into verbatim, hashed segments with
per-segment tshark summaries; display filters, PDML dissection with verified
byte ranges, export and import. Tests run in a Debian trixie container.
2026-10-09 17:34:32 +01:00

174 lines
4.2 KiB
Go

// Package auth provides password hashing, TOTP, session tokens and login throttling.
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"time"
"golang.org/x/crypto/argon2"
)
// Argon2id parameters: ~64 MiB, sized for a Raspberry Pi 4/5.
const (
argonTime = 2
argonMemory = 64 * 1024
argonThreads = 2
argonKeyLen = 32
)
var b64 = base64.RawStdEncoding
func HashPassword(pw string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
k := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads, b64.EncodeToString(salt), b64.EncodeToString(k)), nil
}
func CheckPassword(pw, encoded string) bool {
p := strings.Split(encoded, "$")
if len(p) != 6 || p[1] != "argon2id" {
return false
}
var m uint32
var t uint32
var th uint8
if _, err := fmt.Sscanf(p[3], "m=%d,t=%d,p=%d", &m, &t, &th); err != nil || m > 1<<20 || t > 10 {
return false
}
salt, err1 := b64.DecodeString(p[4])
want, err2 := b64.DecodeString(p[5])
if err1 != nil || err2 != nil {
return false
}
got := argon2.IDKey([]byte(pw), salt, t, m, th, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
// PasswordPolicy: length is what matters; no composition rules.
func PasswordPolicy(pw string) error {
if len(pw) < 12 {
return errors.New("password must be at least 12 characters")
}
if len(pw) > 256 {
return errors.New("password too long")
}
return nil
}
// RandomToken returns n random bytes hex-encoded.
func RandomToken(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)
}
// TokenHash is what we store for session tokens.
func TokenHash(tok string) string {
h := sha256.Sum256([]byte(tok))
return hex.EncodeToString(h[:])
}
// --- TOTP (RFC 6238, SHA-1, 30 s, 6 digits) ---
func NewTOTPSecret() string {
b := make([]byte, 20)
rand.Read(b)
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
}
func totpAt(secret string, counter uint64) (string, error) {
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret))
if err != nil {
return "", err
}
var msg [8]byte
binary.BigEndian.PutUint64(msg[:], counter)
m := hmac.New(sha1.New, key)
m.Write(msg[:])
sum := m.Sum(nil)
off := sum[len(sum)-1] & 0xf
v := binary.BigEndian.Uint32(sum[off:]) & 0x7fffffff
return fmt.Sprintf("%06d", v%1_000_000), nil
}
// CheckTOTP accepts the current code and one step either side for clock skew.
func CheckTOTP(secret, code string, now time.Time) bool {
c := uint64(now.Unix() / 30)
for _, d := range []int64{0, -1, 1} {
want, err := totpAt(secret, uint64(int64(c)+d))
if err == nil && subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
return true
}
}
return false
}
func TOTPURI(issuer, user, secret string) string {
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30", issuer, user, secret, issuer)
}
// --- login throttling ---
// Limiter applies exponential lockout per key (IP and username tracked separately).
type Limiter struct {
mu sync.Mutex
m map[string]*entry
}
type entry struct {
fails int
until time.Time
}
func NewLimiter() *Limiter { return &Limiter{m: map[string]*entry{}} }
// Allowed reports whether key may attempt now, and the wait otherwise.
func (l *Limiter) Allowed(key string, now time.Time) (bool, time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
e := l.m[key]
if e == nil || now.After(e.until) {
return true, 0
}
return false, e.until.Sub(now)
}
func (l *Limiter) Fail(key string, now time.Time) {
l.mu.Lock()
defer l.mu.Unlock()
if len(l.m) > 10000 { // ponytail: crude bound on memory; LRU if this ever matters
l.m = map[string]*entry{}
}
e := l.m[key]
if e == nil {
e = &entry{}
l.m[key] = e
}
e.fails++
if e.fails >= 5 {
d := time.Duration(1<<min(e.fails-5, 10)) * time.Second
e.until = now.Add(d)
}
}
func (l *Limiter) Success(key string) {
l.mu.Lock()
delete(l.m, key)
l.mu.Unlock()
}