Files
jcoffey-dev 02e7993c87 Rename the project to ihasvpn
WGX shares its name with several other WireGuard tools, so the project
becomes ihasvpn, alongside ihasmail.

- Module github.com/Coffey-Labs/ihasvpn, command cmd/ihasvpn, image
  ghcr.io/coffey-labs/ihasvpn.
- Environment variables move from WGX_* to IHASVPN_*. The default database
  is ihasvpn.db, the nftables table is `ihasvpn`, metrics are ihasvpn_*, and
  the session cookie and theme key are renamed, so existing sessions end.
- The mark is the ihasmail cat peeking over the edge of a shield, drawn as
  a vector. docs/brand/generate.py builds the mark, mono mark, wordmarks,
  social card, favicons and app icons from that one drawing.
- The console takes ihasmail's palette: the ihasmail.org teal-navy for dark,
  its contrast-checked light tiers with the site's light accent, received
  traffic in the cat's orange and sent in teal. The wordmark weight and
  font stack follow ihasmail.org.
- Detail values wrap at spaces before breaking inside an address, so an
  IPv6 tunnel address no longer splits mid-number.
- The README history note about the earlier WGX installer is gone with the
  name it explained. Screenshots retaken.
2026-09-12 23:48:36 -07:00

278 lines
7.6 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("ihasvpn-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)
}
}
}