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:
2026-03-11 20:33:04 -07:00
parent bf9002a31c
commit d42f51fc83
10 changed files with 1877 additions and 54 deletions

View File

@@ -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.