This commit is contained in:
37
pkg/authsupport/csrf.go
Normal file
37
pkg/authsupport/csrf.go
Normal 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
110
pkg/authsupport/password.go
Normal 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
|
||||
}
|
||||
87
pkg/authsupport/ratelimit.go
Normal file
87
pkg/authsupport/ratelimit.go
Normal 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
58
pkg/authsupport/token.go
Normal 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[:])
|
||||
}
|
||||
44
pkg/authsupport/token_test.go
Normal file
44
pkg/authsupport/token_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user