Fix F-02: replace password-in-hidden-field with nonce
- ui/ui.go: add pendingLogin struct and pendingLogins sync.Map to UIServer; add issueTOTPNonce (generates 128-bit random nonce, stores accountID with 90s TTL) and consumeTOTPNonce (single-use, expiry-checked LoadAndDelete); add dummyHash() method - ui/handlers_auth.go: split handleLoginPost into step 1 (password verify → issue nonce) and step 2 (handleTOTPStep, consume nonce → validate TOTP) via a new finishLogin helper; password never transmitted or stored after step 1 - ui/ui_test.go: refactor newTestMux to reuse new newTestUIServer; add TestTOTPNonceIssuedAndConsumed, TestTOTPNonceUnknownRejected, TestTOTPNonceExpired, and TestLoginPostPasswordNotInTOTPForm; 11/11 tests pass - web/templates/fragments/totp_step.html: replace 'name=password' hidden field with 'name=totp_nonce' - db/accounts.go: add GetAccountByID for TOTP step lookup - AUDIT.md: mark F-02 as fixed Security: the plaintext password previously survived two HTTP round-trips and lived in the browser DOM during the TOTP step. The nonce approach means the password is verified once and immediately discarded; only an opaque random token tied to an account ID (never a credential) crosses the wire on step 2. Nonces are single-use and expire after 90 seconds to limit the window if one is captured.
This commit is contained in:
@@ -15,6 +15,8 @@ package ui
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
@@ -22,6 +24,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
@@ -33,18 +36,73 @@ import (
|
||||
const (
|
||||
sessionCookieName = "mcias_session"
|
||||
csrfCookieName = "mcias_csrf"
|
||||
totpNonceTTL = 90 * time.Second // single-use TOTP step nonce lifetime
|
||||
totpNonceBytes = 16 // 128 bits of entropy
|
||||
)
|
||||
|
||||
// pendingLogin is a short-lived record created after password verification
|
||||
// succeeds but before TOTP confirmation. It holds the account ID so the
|
||||
// TOTP step never needs to re-transmit the password.
|
||||
//
|
||||
// Security: the nonce is single-use (deleted on first lookup) and expires
|
||||
// after totpNonceTTL to bound the window of a stolen nonce.
|
||||
type pendingLogin struct {
|
||||
expiresAt time.Time
|
||||
accountID int64
|
||||
}
|
||||
|
||||
// UIServer serves the HTMX-based management UI.
|
||||
type UIServer struct {
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
logger *slog.Logger
|
||||
csrf *CSRFManager
|
||||
tmpls map[string]*template.Template // page name → template set
|
||||
pubKey ed25519.PublicKey
|
||||
privKey ed25519.PrivateKey
|
||||
masterKey []byte
|
||||
pendingLogins sync.Map // nonce (string) → *pendingLogin
|
||||
tmpls map[string]*template.Template // page name → template set
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
logger *slog.Logger
|
||||
csrf *CSRFManager
|
||||
pubKey ed25519.PublicKey
|
||||
privKey ed25519.PrivateKey
|
||||
masterKey []byte
|
||||
}
|
||||
|
||||
// issueTOTPNonce creates a random single-use nonce for the TOTP step and
|
||||
// stores the account ID it corresponds to. Returns the hex-encoded nonce.
|
||||
func (u *UIServer) issueTOTPNonce(accountID int64) (string, error) {
|
||||
raw := make([]byte, totpNonceBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("ui: generate TOTP nonce: %w", err)
|
||||
}
|
||||
nonce := hex.EncodeToString(raw)
|
||||
u.pendingLogins.Store(nonce, &pendingLogin{
|
||||
accountID: accountID,
|
||||
expiresAt: time.Now().Add(totpNonceTTL),
|
||||
})
|
||||
return nonce, nil
|
||||
}
|
||||
|
||||
// consumeTOTPNonce looks up and deletes the nonce, returning the associated
|
||||
// account ID. Returns (0, false) if the nonce is unknown or expired.
|
||||
func (u *UIServer) consumeTOTPNonce(nonce string) (int64, bool) {
|
||||
v, ok := u.pendingLogins.LoadAndDelete(nonce)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
pl, ok2 := v.(*pendingLogin)
|
||||
if !ok2 {
|
||||
return 0, false
|
||||
}
|
||||
if time.Now().After(pl.expiresAt) {
|
||||
return 0, false
|
||||
}
|
||||
return pl.accountID, true
|
||||
}
|
||||
|
||||
// dummyHash returns a hardcoded Argon2id PHC string used for constant-time
|
||||
// dummy password verification when the account is unknown or inactive.
|
||||
// Security: the dummy hash uses OWASP-recommended parameters (m=65536,t=3,p=4)
|
||||
// to match real verification timing. F-07 will replace this with a
|
||||
// sync.Once-computed real hash for exact parameter parity.
|
||||
func (u *UIServer) dummyHash() string {
|
||||
return "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g"
|
||||
}
|
||||
|
||||
// New constructs a UIServer, parses all templates, and returns it.
|
||||
@@ -423,7 +481,10 @@ type PageData struct {
|
||||
type LoginData struct {
|
||||
Error string
|
||||
Username string // pre-filled on TOTP step
|
||||
Password string // pre-filled on TOTP step (value attr, never logged)
|
||||
// Security (F-02): Password is no longer carried in the HTML form. Instead
|
||||
// a short-lived server-side nonce is issued after successful password
|
||||
// verification, and only the nonce is embedded in the TOTP step form.
|
||||
Nonce string // single-use server-side nonce replacing the password hidden field
|
||||
}
|
||||
|
||||
// DashboardData is the view model for the dashboard page.
|
||||
|
||||
Reference in New Issue
Block a user