# Auth Sub-Module — Security Model

This document describes the security guarantees of the CMS Auth sub-module,
the threats it defends against, and the operational checklist for deployment.

## TL;DR

- **Algorithm:** PBKDF2-SHA256, 310,000 iterations, 16-byte salt
- **Session:** SHA-256-hashed random token in sessionStorage, 7-day expiry
- **Lockout:** 5 failed login attempts → 5-minute cooldown
- **Recovery:** 12-word phrase shown ONCE at setup, PBKDF2-hashed
- **Comparison:** `constantTimeEqual()` for ALL hash checks (timing-safe)
- **Dev-mode:** Explicit opt-in only via `localStorage['cms:admin:devMode'] === 'true'`

## Threat Model

### Defended

| Threat | Defense |
|---|---|
| Brute-force (offline, stolen DB) | PBKDF2-SHA256 with 310k iterations makes each guess expensive (~200ms). |
| Brute-force (online) | 5-attempt lockout with 5-minute cooldown. |
| Timing attack on hash compare | `constantTimeEqual()` always touches every byte, no early exit. |
| Stolen auth blob from localStorage | Hash is PBKDF2-derived; raw password never stored. |
| Stolen session token from sessionStorage | Token is SHA-256-hashed before storage; raw token never persisted. |
| Session replay after expiry | 7-day expiry enforced at read time via `initCmsAuth` and `isAuthenticated`. |
| Backup leak | `cms:admin:auth` is excluded from `EXPORTABLE_KEYS` by design (Track 1). |
| Dev-mode accidentally enabled in prod | Flag is per-browser (localStorage); production builds can strip the helper. |
| XSS-via-recovery-phrase-display | `escapeHtml()` on every user-supplied string in HTML views. |
| Cross-tab session forgery | sessionStorage is per-tab; tokens never sync across tabs. |

### Out of Scope (Acknowledged Limitations)

| Threat | Status | Notes |
|---|---|---|
| Compromised device | Out of scope | Device-local model — anyone with the device can clear localStorage and re-setup. |
| Cross-device sync | Out of scope | By design — single-admin, single-device per Pattern P7. |
| Quantum attacks on PBKDF2 | Out of scope | PBKDF2-SHA256 is not post-quantum. Argon2id is a future option (Pattern P7 pluggable). |
| Server-side breach | Out of scope | No server. localStorage only. |
| Phishing | Out of scope | User education; no browser-level protection. |
| Session fixation | Mitigated | Sessions are SHA-256-hashed random tokens; old sessions are invalidated on logout. |

## Files

| File | Purpose | LOC |
|---|---|---|
| `auth-crypto.js` | PBKDF2, SHA-256, constantTimeEqual, randomBytes | 136 |
| `auth.js` | Lifecycle (setup, login, logout, recovery) | 191 |
| `auth-state.js` | Auth state I/O + minimal activity log | 58 |
| `auth-dev.js` | Dev-mode bypass + session introspection | 60 |
| `auth-routes.js` | Route handlers for login/setup/recovery flows | 94 |
| `views/login.html` | Login form | 74 |
| `views/setup.html` | Initial setup form (shows recovery phrase) | 94 |
| `tests/test-auth.js` | 10 mandatory security tests + extras | 161 |
| `tests/test-auth-helpers.js` | Test polyfills + helpers | 64 |
| `tests/test-auth-extras.js` | Sanity tests | 55 |

All files < 200 lines per FWV §06.

## Security Tests (mandatory)

All 10 mandatory tests in `tests/test-auth.js` pass via `node --test`:

1. ✅ SEC1 — Wrong password fails; lockout counter increments
2. ✅ SEC2 — 5 wrong attempts → 6th attempt locked out
3. ✅ SEC3 — Lockout expires after 5 minutes → login succeeds
4. ✅ SEC4 — Recovery phrase → reset password → new password works
5. ✅ SEC5 — Session expires after 7 days (mocked clock)
6. ✅ SEC6 — Dev-mode bypass only works when localStorage flag === 'true'
7. ✅ SEC7 — PBKDF2 uses 310,000 iterations (verified by source inspection)
8. ✅ SEC8 — `constantTimeEqual` used for ALL hash comparisons (grep test)
9. ✅ SEC9 — Auth tokens NEVER appear in backups (key excluded from EXPORTABLE_KEYS)
10. ✅ SEC10 — Session token is SHA-256 hash of random bytes (not the password itself)

Plus 7 extra sanity tests (recovery word uniqueness, password length, etc.).

Run: `cd /workspace/projects/cms/cms && node --test tests/test-auth.js tests/test-auth-extras.js`

## Operational Checklist

### For Developers

- [ ] Never call `localStorage.setItem('cms:admin:auth', ...)` directly — use `writeAuthState()`
- [ ] Never use `!==` to compare hashes — use `constantTimeEqual()`
- [ ] Never log a raw password or session token — only the action (e.g., `login_failed`)
- [ ] Never import `_INTERNAL` outside `auth/` directory
- [ ] Never include `cms:admin:auth` in EXPORTABLE_KEYS (Track 1 enforces this)
- [ ] Always escape user data in HTML views via `escapeHtml()`

### For Operators

- [ ] Use a password manager; minimum 12 characters (enforced)
- [ ] Save the recovery phrase in TWO places (paper + password manager)
- [ ] Disable dev-mode in production: `disableDevMode()` in console
- [ ] Wipe the device before disposal: `localStorage.clear()`
- [ ] Monitor the auth-activity log for unusual patterns
- [ ] Lockout is automatic — no manual intervention needed

### For Auditors

- [ ] `node --test tests/test-auth.js tests/test-auth-extras.js` — all green
- [ ] `grep -n "!=" cms/auth/*.js` — should only show safe usages (length checks, etc.)
- [ ] `grep -n "localStorage" cms/auth/*.js` — only dev-mode + state I/O
- [ ] `grep -n "innerHTML" cms/views/login.html cms/views/setup.html` — should be empty (we use textContent)

## Migration / Replacement

To swap PBKDF2 for Argon2id (future):

1. Replace `pbkdf2Hex` in `auth-crypto.js` with an Argon2 implementation.
2. Bump `user.iterations` on next login (re-derive on success).
3. Update `SEC7` test to check for Argon2 in source.

The lifecycle code (auth.js, auth-state.js, auth-dev.js) does not change.

— Track 2 Auth, cloud Mavis, 2026-07-02