fix(security): remediate 2026-05-12 audit findings (Stages 2–5)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled

Remediation of the 2026-05-12/13 audits (78 findings + cluster gaps),
tracked in deploy-k3s/SECURITY.md, plus fixes from two independent
post-remediation reviews.

Auth & sessions:
- SHA-256 hashed auth-token storage (C1); prior-token cache eviction on
  re-login (MEDIUM-1)
- local Google JWKS verification, iss/aud/exp checks (C2/C3)
- constant-time login + generic errors (L1/LIVE-L11/LIVE-L13)
- per-account login lockout keyed on distinct source IPs (M5/MEDIUM-3)
- verified-email gating, login rate limiting (LIVE-L19, H1-H3)

IAP & webhooks:
- Apple/Google cross-account replay protection (C5/C6/C10/C13, H5/H6)
- migrations 000003-000006 (token hashing, IAP replay, audit_log +
  webhook_event_log table creation, append-only audit log)

Authorization & races:
- file-ownership owner-OR-member fix (C7), atomic share-code join
  (C9/H9), device-token reassignment (C8/LOW-3)

Secrets & deploy:
- secrets file-mounted at /etc/honeydue/secrets, not env (F8); Redis
  password out of the ConfigMap (HIGH-1); B2 keys reconciled
- digest-pinned images, admin ingress hardening, CSP/HSTS, /metrics
  lockdown; kubeconfig 0600, etcd secrets-encryption, fail2ban +
  unattended-upgrades at provision; secret-rotation runbook

Build, vet, and the full test suite (incl. -race) pass; the goose
migration chain is verified against PostgreSQL 16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-05-16 22:28:33 -05:00
parent 2004f9c5b2
commit c77ff07ce9
59 changed files with 2819 additions and 1245 deletions
+2 -1
View File
@@ -252,7 +252,8 @@ func TestAuthToken_BeforeCreate_GeneratesKey(t *testing.T) {
require.NoError(t, err)
assert.NotEmpty(t, token.Key)
assert.Len(t, token.Key, 40) // 20 bytes = 40 hex chars
assert.Len(t, token.Key, 64) // SHA-256 hex hash (audit C1)
assert.Len(t, token.Plaintext, 40) // raw 20-byte token, returned to the client
assert.False(t, token.Created.IsZero())
}
+3
View File
@@ -43,6 +43,9 @@ type UserSubscription struct {
// In-App Purchase data (Apple / Google)
AppleReceiptData *string `gorm:"column:apple_receipt_data;type:text" json:"-"`
GooglePurchaseToken *string `gorm:"column:google_purchase_token;type:text" json:"-"`
// AppleOriginalTransactionID binds an Apple subscription to one account
// (audit C5/C13). A partial unique index enforces one-account-per-txn.
AppleOriginalTransactionID *string `gorm:"column:apple_original_transaction_id;type:text" json:"-"`
// Stripe data (web subscriptions)
StripeCustomerID *string `gorm:"column:stripe_customer_id;size:255" json:"-"`
+55 -20
View File
@@ -2,7 +2,10 @@ package models
import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
@@ -37,14 +40,16 @@ func (User) TableName() string {
return "auth_user"
}
// BcryptCost is the bcrypt work factor for password and code hashing.
// 12 (audit M2) is stronger than bcrypt.DefaultCost (10).
const BcryptCost = 12
// SetPassword hashes and sets the password
func (u *User) SetPassword(password string) error {
// Django uses PBKDF2_SHA256 by default, but we'll use bcrypt for Go
// Note: This means passwords set by Django won't work with Go's check
// For migration, you'd need to either:
// 1. Force password reset for all users
// 2. Implement Django's PBKDF2 hasher in Go
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
// Django uses PBKDF2_SHA256 by default, but we use bcrypt for Go.
// Passwords set by Django won't verify with Go's bcrypt check — those
// users must reset their password after migration.
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
if err != nil {
return err
}
@@ -69,12 +74,22 @@ func (u *User) GetFullName() string {
return u.Username
}
// AuthToken represents the user_authtoken table
// AuthToken represents the user_authtoken table.
//
// Audit C1: the Key column stores the SHA-256 hash of the token, never the
// token itself. The raw token is handed to the client exactly once, at
// creation, via the non-persisted Plaintext field — it is never stored or
// logged. A database compromise therefore yields no usable session tokens.
type AuthToken struct {
Key string `gorm:"column:key;primaryKey;size:40" json:"key"`
Key string `gorm:"column:key;primaryKey;size:64" json:"-"`
UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"`
Created time.Time `gorm:"column:created;autoCreateTime" json:"created"`
// Plaintext is the raw token value. It is never persisted (gorm:"-")
// and is only populated on a freshly-created token so the caller can
// return it to the client. On a token loaded from the DB it is "".
Plaintext string `gorm:"-" json:"-"`
// Relations
User User `gorm:"foreignKey:UserID" json:"-"`
}
@@ -84,10 +99,13 @@ func (AuthToken) TableName() string {
return "user_authtoken"
}
// BeforeCreate generates a token key if not provided
// BeforeCreate generates a token if one is not already set, storing only
// its hash in Key and the raw value in the non-persisted Plaintext field.
func (t *AuthToken) BeforeCreate(tx *gorm.DB) error {
if t.Key == "" {
t.Key = generateToken()
raw := generateToken()
t.Plaintext = raw
t.Key = HashToken(raw)
}
if t.Created.IsZero() {
t.Created = time.Now().UTC()
@@ -95,13 +113,23 @@ func (t *AuthToken) BeforeCreate(tx *gorm.DB) error {
return nil
}
// generateToken creates a random 40-character hex token
// generateToken creates a random 40-character hex token (the raw value).
func generateToken() string {
b := make([]byte, 20)
rand.Read(b)
return hex.EncodeToString(b)
}
// HashToken returns the at-rest representation of an auth token: the
// hex-encoded SHA-256 hash. Auth tokens are 160-bit random values, so a
// fast deterministic hash is appropriate — there is nothing to brute-force,
// and determinism preserves the single indexed-lookup query in the auth
// middleware. The raw token is never stored.
func HashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// GetOrCreate gets an existing token or creates a new one for the user
func GetOrCreateToken(tx *gorm.DB, userID uint) (*AuthToken, error) {
var token AuthToken
@@ -160,15 +188,22 @@ func (c *ConfirmationCode) IsValid() bool {
return !c.IsUsed && time.Now().UTC().Before(c.ExpiresAt)
}
// GenerateCode creates a random 6-digit code
// GenerateConfirmationCode creates a uniformly-random 6-digit code using
// rejection sampling on crypto/rand (audit H4 — removes the modulo bias of
// the previous implementation).
func GenerateConfirmationCode() string {
b := make([]byte, 3)
rand.Read(b)
// Convert to 6-digit number
num := int(b[0])<<16 | int(b[1])<<8 | int(b[2])
return string(rune('0'+num%10)) + string(rune('0'+(num/10)%10)) +
string(rune('0'+(num/100)%10)) + string(rune('0'+(num/1000)%10)) +
string(rune('0'+(num/10000)%10)) + string(rune('0'+(num/100000)%10))
for {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
continue
}
// 4294000000 is the largest multiple of 1e6 <= MaxUint32; rejecting
// the tail above it makes n % 1000000 perfectly uniform.
n := binary.BigEndian.Uint32(b[:])
if n < 4294000000 {
return fmt.Sprintf("%06d", n%1000000)
}
}
}
// PasswordResetCode represents the user_passwordresetcode table
@@ -193,7 +228,7 @@ func (PasswordResetCode) TableName() string {
// SetCode hashes and stores the reset code
func (p *PasswordResetCode) SetCode(code string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
hash, err := bcrypt.GenerateFromPassword([]byte(code), BcryptCost)
if err != nil {
return err
}