Go backend that drives kernel WireGuard over netlink (wireguard-go as the fallback), nftables NAT with MSS clamping, forwarding and buffer sysctls, SQLite for peers, users, sessions, traffic history and the audit log. React console: dashboard with live rates and usage history, peer management with QR codes and .conf downloads, disconnect, session reset, key rotation, expiry, client-supplied keys, settings, users with admin and viewer roles, two-factor authentication with recovery codes, audit log. Docker image on Alpine with compose files for bridged and host networking, CI and GHCR publish workflows, performance notes.
278 lines
7.5 KiB
Go
278 lines
7.5 KiB
Go
// Package auth holds the primitives behind the admin login: argon2id password
|
|
// hashing, opaque session tokens, RFC 6238 one-time passwords, recovery codes
|
|
// and a login rate limiter. None of it knows about HTTP.
|
|
package auth
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha1"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base32"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/crypto/argon2"
|
|
)
|
|
|
|
// Argon2id parameters: 64 MiB, 3 passes, 4 lanes. Roughly 100 ms on a
|
|
// modest server, which is the point.
|
|
const (
|
|
argonTime = 3
|
|
argonMemory = 64 * 1024
|
|
argonThreads = 4
|
|
argonKeyLen = 32
|
|
)
|
|
|
|
// MinPasswordLength is the shortest password accepted.
|
|
const MinPasswordLength = 12
|
|
|
|
// ValidatePassword enforces the password policy: length only. Composition
|
|
// rules produce worse passwords, not better ones.
|
|
func ValidatePassword(pw string) error {
|
|
if utf8.RuneCountInString(pw) < MinPasswordLength {
|
|
return fmt.Errorf("password must be at least %d characters", MinPasswordLength)
|
|
}
|
|
if len(pw) > 1024 {
|
|
return errors.New("password is too long")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// HashPassword returns a PHC-format argon2id string.
|
|
func HashPassword(pw string) (string, error) {
|
|
salt := make([]byte, 16)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return "", err
|
|
}
|
|
key := 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,
|
|
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
|
|
}
|
|
|
|
// VerifyPassword checks a password against a hash from HashPassword.
|
|
func VerifyPassword(hash, pw string) bool {
|
|
parts := strings.Split(hash, "$")
|
|
if len(parts) != 6 || parts[1] != "argon2id" {
|
|
return false
|
|
}
|
|
var m, t uint32
|
|
var p uint8
|
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
|
|
return false
|
|
}
|
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
|
|
return subtle.ConstantTimeCompare(got, want) == 1
|
|
}
|
|
|
|
// dummyHash is verified against when the user does not exist, so a login
|
|
// for an unknown name takes as long as one for a known name.
|
|
var dummyHash, _ = HashPassword("wgx-timing-equaliser-password")
|
|
|
|
// EqualiseTiming burns the cost of one hash verification.
|
|
func EqualiseTiming() { VerifyPassword(dummyHash, "not-the-password") }
|
|
|
|
// NewToken returns a random URL-safe token and its storage hash.
|
|
func NewToken() (token, hash string, err error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", "", err
|
|
}
|
|
token = base64.RawURLEncoding.EncodeToString(b)
|
|
return token, HashToken(token), nil
|
|
}
|
|
|
|
// HashToken is how tokens are stored: a leaked database is not a leaked login.
|
|
func HashToken(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// NewID returns a short random identifier for peers.
|
|
func NewID() (string, error) {
|
|
b := make([]byte, 10)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)), nil
|
|
}
|
|
|
|
// --- TOTP -----------------------------------------------------------------
|
|
|
|
// NewTOTPSecret returns a base32 secret for an authenticator app.
|
|
func NewTOTPSecret() (string, error) {
|
|
b := make([]byte, 20)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b), nil
|
|
}
|
|
|
|
// TOTPURI builds the otpauth:// URI an authenticator app scans.
|
|
func TOTPURI(issuer, account, secret string) string {
|
|
v := url.Values{}
|
|
v.Set("secret", secret)
|
|
v.Set("issuer", issuer)
|
|
v.Set("algorithm", "SHA1")
|
|
v.Set("digits", "6")
|
|
v.Set("period", "30")
|
|
return "otpauth://totp/" + url.PathEscape(issuer+":"+account) + "?" + v.Encode()
|
|
}
|
|
|
|
func totpCode(secret string, counter uint64) (string, error) {
|
|
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(strings.ReplaceAll(secret, " ", "")))
|
|
if err != nil {
|
|
return "", errors.New("bad secret")
|
|
}
|
|
var msg [8]byte
|
|
binary.BigEndian.PutUint64(msg[:], counter)
|
|
mac := hmac.New(sha1.New, key)
|
|
mac.Write(msg[:])
|
|
sum := mac.Sum(nil)
|
|
off := sum[len(sum)-1] & 0x0f
|
|
code := (binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff) % 1_000_000
|
|
return fmt.Sprintf("%06d", code), nil
|
|
}
|
|
|
|
// TOTPNow returns the current code, for the tests and the setup flow.
|
|
func TOTPNow(secret string, at time.Time) (string, error) {
|
|
return totpCode(secret, uint64(at.Unix()/30))
|
|
}
|
|
|
|
// VerifyTOTP accepts the current code and one step either side.
|
|
func VerifyTOTP(secret, code string, at time.Time) bool {
|
|
code = strings.TrimSpace(code)
|
|
if len(code) != 6 {
|
|
return false
|
|
}
|
|
if _, err := strconv.Atoi(code); err != nil {
|
|
return false
|
|
}
|
|
counter := uint64(at.Unix() / 30)
|
|
ok := false
|
|
for _, c := range []uint64{counter - 1, counter, counter + 1} {
|
|
want, err := totpCode(secret, c)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
|
|
ok = true
|
|
}
|
|
}
|
|
return ok
|
|
}
|
|
|
|
// NewRecoveryCodes returns n codes in the form xxxx-xxxx-xxxx and their hashes.
|
|
func NewRecoveryCodes(n int) (codes, hashes []string, err error) {
|
|
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
|
for i := 0; i < n; i++ {
|
|
b := make([]byte, 12)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var sb strings.Builder
|
|
for j, x := range b {
|
|
if j > 0 && j%4 == 0 {
|
|
sb.WriteByte('-')
|
|
}
|
|
sb.WriteByte(alphabet[int(x)%len(alphabet)])
|
|
}
|
|
codes = append(codes, sb.String())
|
|
hashes = append(hashes, HashToken(NormaliseRecoveryCode(sb.String())))
|
|
}
|
|
return codes, hashes, nil
|
|
}
|
|
|
|
// NormaliseRecoveryCode strips separators and case so typed codes match.
|
|
func NormaliseRecoveryCode(c string) string {
|
|
return strings.ToLower(strings.NewReplacer("-", "", " ", "").Replace(c))
|
|
}
|
|
|
|
// --- Rate limiting --------------------------------------------------------
|
|
|
|
// Limiter is a fixed-window failure counter keyed by string (an IP, a
|
|
// username). After max failures in the window the key is locked out until
|
|
// the window passes.
|
|
type Limiter struct {
|
|
mu sync.Mutex
|
|
max int
|
|
window time.Duration
|
|
hits map[string]*bucket
|
|
}
|
|
|
|
type bucket struct {
|
|
count int
|
|
start time.Time
|
|
}
|
|
|
|
// NewLimiter allows max failures per window.
|
|
func NewLimiter(max int, window time.Duration) *Limiter {
|
|
return &Limiter{max: max, window: window, hits: map[string]*bucket{}}
|
|
}
|
|
|
|
// Allowed reports whether the key may attempt again and how long to wait.
|
|
func (l *Limiter) Allowed(key string) (bool, time.Duration) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
b, ok := l.hits[key]
|
|
if !ok {
|
|
return true, 0
|
|
}
|
|
if time.Since(b.start) > l.window {
|
|
delete(l.hits, key)
|
|
return true, 0
|
|
}
|
|
if b.count >= l.max {
|
|
return false, l.window - time.Since(b.start)
|
|
}
|
|
return true, 0
|
|
}
|
|
|
|
// Fail records a failure.
|
|
func (l *Limiter) Fail(key string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
b, ok := l.hits[key]
|
|
if !ok || time.Since(b.start) > l.window {
|
|
l.hits[key] = &bucket{count: 1, start: time.Now()}
|
|
return
|
|
}
|
|
b.count++
|
|
}
|
|
|
|
// Reset clears a key after success.
|
|
func (l *Limiter) Reset(key string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
delete(l.hits, key)
|
|
}
|
|
|
|
// Sweep drops stale keys; call it now and then.
|
|
func (l *Limiter) Sweep() {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
for k, b := range l.hits {
|
|
if time.Since(b.start) > l.window {
|
|
delete(l.hits, k)
|
|
}
|
|
}
|
|
}
|