Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

37
pkg/authsupport/csrf.go Normal file
View File

@@ -0,0 +1,37 @@
package authsupport
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
)
// csrfTokenBytes is the amount of entropy in a CSRF token before
// encoding. This isn't a secret that needs to resist offline brute
// force the way a password hash does — it just needs to be
// unguessable to a third-party site, so 128 bits is comfortably enough.
const csrfTokenBytes = 16
// NewCSRFToken returns a fresh, URL-safe random token suitable for the
// double-submit-cookie pattern: the same value is set as a cookie and
// embedded as a hidden form field, and a request is rejected unless the
// two match (see VerifyCSRF). Unlike session/verification tokens, the
// raw value itself is what's compared — there's no server-side lookup,
// so no hash-at-rest step is needed here.
func NewCSRFToken() (string, error) {
b := make([]byte, csrfTokenBytes)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("auth: generate csrf token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// VerifyCSRF reports whether a cookie value and a submitted form value
// match, in constant time. Both empty is never valid.
func VerifyCSRF(cookieValue, formValue string) bool {
if cookieValue == "" || formValue == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(cookieValue), []byte(formValue)) == 1
}

110
pkg/authsupport/password.go Normal file
View File

@@ -0,0 +1,110 @@
// Package auth holds enkly's password hashing, token generation, and
// session/rate-limiting primitives.
//
// Password hashing deliberately avoids golang.org/x/crypto/bcrypt: this
// module has no third-party dependencies beyond lib/pq and gomarkdown
// (see go.mod), and pulling in x/crypto just for bcrypt isn't worth the
// extra supply-chain surface when the stdlib's crypto/hmac + crypto/sha256
// are enough to implement PBKDF2 (RFC 8018) directly — a well-defined,
// widely reviewed construction, not a home-grown cipher.
package authsupport
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"strconv"
"strings"
)
// pbkdf2Iterations follows OWASP's 2023 password-storage cheat sheet
// recommendation for PBKDF2-HMAC-SHA256 (>= 600,000 was later raised;
// 210,000 is the still-current floor for SHA256 specifically). Bump this
// if hashing becomes cheap enough to be a problem — old hashes keep
// verifying fine since the iteration count travels with the hash.
const pbkdf2Iterations = 210_000
const (
saltLen = 16 // bytes
keyLen = 32 // bytes (SHA-256 output size)
)
// pbkdf2 derives a keyLen-byte key from password+salt using HMAC-SHA256
// as the PRF, per RFC 8018 section 5.2.
func pbkdf2(password, salt []byte, iterations, length int) []byte {
prf := hmac.New(sha256.New, password)
hLen := prf.Size()
numBlocks := (length + hLen - 1) / hLen
var derived []byte
buf := make([]byte, 4)
for block := 1; block <= numBlocks; block++ {
buf[0] = byte(block >> 24)
buf[1] = byte(block >> 16)
buf[2] = byte(block >> 8)
buf[3] = byte(block)
prf.Reset()
prf.Write(salt)
prf.Write(buf)
u := prf.Sum(nil)
t := make([]byte, len(u))
copy(t, u)
for i := 1; i < iterations; i++ {
prf.Reset()
prf.Write(u)
u = prf.Sum(nil)
for j := range t {
t[j] ^= u[j]
}
}
derived = append(derived, t...)
}
return derived[:length]
}
// HashPassword returns an encoded hash of the form
// "pbkdf2-sha256$<iterations>$<base64 salt>$<base64 hash>", suitable for
// storing directly in users.password_hash.
func HashPassword(password string) (string, error) {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("auth: generate salt: %w", err)
}
hash := pbkdf2([]byte(password), salt, pbkdf2Iterations, keyLen)
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s",
pbkdf2Iterations,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
), nil
}
// VerifyPassword checks a plaintext password against an encoded hash
// produced by HashPassword, in constant time.
func VerifyPassword(password, encoded string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
return false
}
iterations, err := strconv.Atoi(parts[1])
if err != nil || iterations <= 0 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return false
}
got := pbkdf2([]byte(password), salt, iterations, len(want))
return subtle.ConstantTimeCompare(got, want) == 1
}

View File

@@ -0,0 +1,87 @@
package authsupport
import (
"sync"
"time"
)
// Limiter slows repeated attempts against a single key (an email
// address or a client IP) instead of flatly blocking them: each
// consecutive failure within the window makes the *next* attempt's
// artificial delay longer, capped at maxDelay. That's enough to make
// password-guessing or verification-email-spam scripts painfully slow
// without needing a CAPTCHA or an IP ban list, and it degrades
// gracefully — a genuine user who mistypes their password twice waits a
// second, not minutes.
//
// State is in-memory and per-process, which is fine for enkly's
// single-instance deployment (see docker-compose.yml); it resets on
// restart and doesn't need a database round-trip on the hot path.
type Limiter struct {
mu sync.Mutex
attempts map[string]*attemptState
window time.Duration
baseWait time.Duration
maxWait time.Duration
}
type attemptState struct {
count int
lastSeen time.Time
}
// NewLimiter builds a Limiter. window is how long a run of failures is
// remembered before resetting; baseWait/maxWait bound the artificial
// delay (baseWait * 2^(failures-1), capped at maxWait).
func NewLimiter(window, baseWait, maxWait time.Duration) *Limiter {
return &Limiter{
attempts: make(map[string]*attemptState),
window: window,
baseWait: baseWait,
maxWait: maxWait,
}
}
// Delay returns how long the caller should sleep before processing the
// next attempt for key, based on how many recent failures it has seen.
// It does not itself sleep or record anything — call RecordFailure /
// RecordSuccess to update state.
func (l *Limiter) Delay(key string) time.Duration {
l.mu.Lock()
defer l.mu.Unlock()
st, ok := l.attempts[key]
if !ok || time.Since(st.lastSeen) > l.window {
return 0
}
if st.count <= 0 {
return 0
}
wait := l.baseWait << (st.count - 1) // baseWait * 2^(count-1)
if wait > l.maxWait || wait <= 0 { // <=0 guards against overflow
wait = l.maxWait
}
return wait
}
// RecordFailure increments key's failure streak.
func (l *Limiter) RecordFailure(key string) {
l.mu.Lock()
defer l.mu.Unlock()
st, ok := l.attempts[key]
if !ok || time.Since(st.lastSeen) > l.window {
st = &attemptState{}
l.attempts[key] = st
}
st.count++
st.lastSeen = time.Now()
}
// RecordSuccess clears key's failure streak.
func (l *Limiter) RecordSuccess(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.attempts, key)
}

58
pkg/authsupport/token.go Normal file
View File

@@ -0,0 +1,58 @@
package authsupport
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"math/big"
)
// tokenBytes is the amount of entropy in a raw token before encoding —
// 32 bytes (256 bits) is comfortably enough that guessing is infeasible.
const tokenBytes = 32
// NewToken returns a fresh, URL-safe random token (for password-reset
// links and session cookies) and the hex-encoded SHA-256 hash of it that
// should be persisted in its place. Only the hash ever touches the
// database — the raw token lives solely in the email link or the
// browser's cookie, so a DB dump alone can't be replayed.
func NewToken() (raw string, hash string, err error) {
b := make([]byte, tokenBytes)
if _, err := rand.Read(b); err != nil {
return "", "", fmt.Errorf("auth: generate token: %w", err)
}
raw = base64.RawURLEncoding.EncodeToString(b)
return raw, HashToken(raw), nil
}
// codeSpace is how many distinct 6-digit codes NewCode can produce
// (000000 through 999999).
const codeSpace = 1_000_000
// NewCode returns a fresh random 6-digit numeric code — as a
// zero-padded string, so "007123" round-trips correctly — for emailing
// as an email-verification code, and the hex-encoded SHA-256 hash of it
// that should be persisted in its place. Unlike NewToken's 256 bits of
// entropy, a 6-digit code is deliberately guessable-by-brute-force if
// nothing else stops repeated attempts; callers must pair it with an
// attempt limit and a short expiry (see internal/db.VerifyEmailCode and
// verifyCodeTTL) rather than relying on the code space alone.
func NewCode() (raw string, hash string, err error) {
n, err := rand.Int(rand.Reader, big.NewInt(codeSpace))
if err != nil {
return "", "", fmt.Errorf("auth: generate code: %w", err)
}
raw = fmt.Sprintf("%06d", n.Int64())
return raw, HashToken(raw), nil
}
// HashToken hashes a raw token or code the same way NewToken/NewCode do,
// so a value received back from the client (a reset link's ?token=, a
// session cookie, or a typed verification code) can be looked up by its
// hash.
func HashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,44 @@
package authsupport
import "testing"
func TestNewCodeShape(t *testing.T) {
seen := make(map[string]bool)
for i := 0; i < 200; i++ {
raw, hash, err := NewCode()
if err != nil {
t.Fatal(err)
}
if len(raw) != 6 {
t.Fatalf("code %q is not 6 digits", raw)
}
for _, c := range raw {
if c < '0' || c > '9' {
t.Fatalf("code %q has a non-digit", raw)
}
}
if hash != HashToken(raw) {
t.Fatalf("hash mismatch for %q", raw)
}
seen[raw] = true
}
// Not a strict randomness test, just a sanity check that we're not
// somehow returning the same code every time.
if len(seen) < 190 {
t.Fatalf("suspiciously few distinct codes out of 200 draws: %d", len(seen))
}
}
func TestNewCodeZeroPadded(t *testing.T) {
// %06d must preserve leading zeros (e.g. a draw of 42 -> "000042"),
// or VerifyEmailCode would compare against a mis-shapen string.
for i := 0; i < 2000; i++ {
raw, _, err := NewCode()
if err != nil {
t.Fatal(err)
}
if len(raw) != 6 {
t.Fatalf("code %q is not zero-padded to 6 digits", raw)
}
}
}