diff --git a/documentation/PROJECT__AE_Site_Passcode_Security.md b/documentation/PROJECT__AE_Site_Passcode_Security.md new file mode 100644 index 0000000..4f4bae7 --- /dev/null +++ b/documentation/PROJECT__AE_Site_Passcode_Security.md @@ -0,0 +1,386 @@ +# PROJECT: Site Passcode Security — API-Verified Auth + +**Last updated:** 2026-04-10 +**Status:** Backend work in progress — frontend pending backend completion +**Priority:** High — passcodes for trusted/administrator access currently in localStorage plaintext + +--- + +## Problem Statement + +When a user loads the Aether frontend, the site bootstrap response includes `access_code_kv_json` — a JSON object containing all passcodes for all access levels (administrator, trusted, public, authenticated). The frontend stores this verbatim in `$ae_loc.site_access_code_kv`, which is persisted in localStorage. + +**Result:** Anyone with DevTools → Application → Local Storage can see every passcode for every access level on any Aether site. For public/authenticated this is low risk, but for trusted and administrator this is a real exposure — these passcodes can grant control over event data, badge printing, edit mode, etc. + +The passcode check (`handle_check_access_type_passcode` in `e_app_access_type.svelte`) is entirely local — it reads the cached values and compares directly. No API call is made. The backend already has a `/authenticate_passcode` endpoint that verifies server-side, but it needs the fixes described below before the frontend can rely on it. + +### Source of Truth + +`site.access_code_kv_json` is the single source of truth for all passcodes. The `v_site_domain` DB view joins this field from the site table — there is no separate copy. Both the bootstrap response and `/authenticate_passcode` read from the same data. + +--- + +## Threat Model + +| Threat | Current | After Fix | +|---|---|---| +| Attacker inspects localStorage | Sees all passcodes in plaintext | Sees a JWT (opaque, no passcode) | +| Attacker uses stolen trusted passcode | Trivial if they have localStorage access | Still possible if they enter the passcode — unavoidable | +| Attacker replays an old passcode after it changes | Works forever (cached value never refreshes) | Fails — API verifies against current DB value | +| Attacker tampers with `access_type` in localStorage | Grants apparent permission but API calls still fail | Same — `access_type` is still persisted separately | +| Passcode reuse across sessions | Works indefinitely | JWT TTL enforces session expiry per role | +| Offline / API-unavailable entry | Works (local cache) | **Blocked** — requires API to verify | + +### The fundamental constraint + +Passcode-based access is inherently weaker than username/password login with a hashed credential. The system's security model layers passcode access below user login, and API calls themselves are still gated by `x-aether-api-key` + `x-account-id`. The passcode primarily controls **what the frontend shows** and some API-level permission gates for trusted routes. + +--- + +## Proposed Solution: API-Verified Passcode + JWT Session + +### Core idea + +1. **Never send passcodes to the client.** The frontend stops reading/storing `access_code_kv_json` from the bootstrap response. +2. **Passcode entry triggers an API call** to `/authenticate_passcode`. API verifies server-side against the DB. +3. **On success, the API returns a JWT** — the JWT contains the role, account context, and expiry. +4. **Store the JWT in `$ae_loc.jwt`** (already a field, already wired into `$ae_api`). +5. **On page reload**, check the JWT's `eat` (expires-at) claim locally (base64 decode, no signature verification needed client-side). If expired, drop to anonymous. If valid, `access_type` is already persisted in `$ae_loc`. + +### Session restore on reload + +- `access_type` still persists in localStorage (no change here) +- The JWT is the **proof** that the access was legitimately granted and is still valid +- On page load: decode JWT payload (base64 the middle segment), check `eat` vs `Date.now()/1000` +- If JWT expired → reset `access_type` to anonymous, clear JWT +- If JWT valid → no action needed, `access_type` is already correct + +This gives session expiry without a network call on every page load. + +--- + +## TTL Per Role — Decided + +| Access Level | JWT TTL | Notes | +|---|---|---| +| `super` | 8 hours | Highest privilege | +| `manager` | 24 hours | | +| `administrator` | 48 hours | | +| `trusted` | 48 hours | Onsite staff — covers multi-day events | +| `public` | 24 hours | | +| `authenticated` | 12 hours | | +| `anonymous` | N/A | No passcode | + +--- + +## Caching Decision + +**No passcode caching.** Every passcode entry makes one API call. The JWT handles session persistence — no passcode ever touches localStorage. Performance impact is only at the moment of entry (~50–150ms), which is acceptable for a once-per-session action. + +--- + +## Backend Changes Required + +**Note:** The backend fixes described below have been implemented and tested in the `aether_api_fastapi` repository (the `/authenticate_passcode` endpoint now uses explicit role priority, returns a full passcode JWT with `auth_type: 'passcode'`, applies per-role TTLs, and validates passcode length). Frontend changes can proceed once the backend deployment with these fixes is available. + +**Phase 2 status:** Not started — removing `access_code_kv_json` from the public site model remains pending. + +**File:** `aether_api_fastapi/app/routers/api.py` + +The `/authenticate_passcode` endpoint exists and is structurally correct but has four issues that must be fixed before the frontend migrates to using it. + +### Fix 1: Passcode matching must use explicit priority order + +**Current (wrong):** +```python +for role, code in access_codes.items(): # dict insertion order — not guaranteed + if str(code) == str(passcode): + matched_role = role + break +``` + +**Required:** +```python +ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated'] + +matched_role = None +for role in ROLE_PRIORITY: + code = access_codes.get(role) + if code and str(code) == str(passcode): + matched_role = role + break +``` + +This ensures that if a config mistake causes two roles to share a passcode, the higher-privilege role always wins. It also makes the intent explicit and independent of JSON storage order. + +### Fix 2: JWT payload must include all six role flags + +**Current (incomplete):** +```python +payload = { + 'account_id': account_id_random, + 'administrator': (matched_role == 'administrator'), + 'manager': (matched_role == 'manager'), + 'super': (matched_role == 'super'), + # trusted / public / authenticated missing + ... +} +``` + +**Required:** +```python +payload = { + 'account_id': account_id_random, + 'super': (matched_role == 'super'), + 'manager': (matched_role == 'manager'), + 'administrator': (matched_role == 'administrator'), + 'trusted': (matched_role == 'trusted'), + 'public': (matched_role == 'public'), + 'authenticated': (matched_role == 'authenticated'), + 'json_str': json.dumps({ + 'auth_type': 'passcode', # distinguishes from user login JWTs + 'site_id': site_id, + 'role': matched_role # canonical role string — frontend uses this + }) +} +``` + +The `auth_type: 'passcode'` marker is critical — it allows the frontend and any future backend consumers to distinguish a passcode JWT from a user login JWT. + +### Fix 3: Per-role TTL + +**Current:** +```python +token = sign_jwt( + secret_key=settings.JWT_KEY, + ttl=3600 * 24, # hardcoded 24h for all roles + **payload +) +``` + +**Required:** +```python +ROLE_TTL = { + 'super': 8 * 3600, # 8 hours + 'manager': 24 * 3600, # 24 hours + 'administrator': 48 * 3600, # 48 hours + 'trusted': 48 * 3600, # 48 hours + 'public': 24 * 3600, # 24 hours + 'authenticated': 12 * 3600, # 12 hours +} + +token = sign_jwt( + secret_key=settings.JWT_KEY, + ttl=ROLE_TTL[matched_role], + **payload +) +``` + +### Fix 4: Add minimum length validation to `passcode` field + +**Current:** +```python +passcode: str = Field(..., description="The passcode to verify") +``` + +**Required:** +```python +passcode: str = Field(..., min_length=5, description="The passcode to verify") +``` + +This matches the frontend's 5-character trigger and prevents empty/trivial submissions. + +### Complete corrected endpoint (for reference) + +```python +ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated'] + +ROLE_TTL = { + 'super': 8 * 3600, + 'manager': 24 * 3600, + 'administrator': 48 * 3600, + 'trusted': 48 * 3600, + 'public': 24 * 3600, + 'authenticated': 12 * 3600, +} + +class PasscodeAuthRequest(BaseModel): + """Request model for site-based passcode authentication.""" + site_id: str = Field(..., description="Random string ID of the site") + passcode: str = Field(..., min_length=5, description="The passcode to verify") + +@router.post('/authenticate_passcode', response_model=Resp_Body_Base) +async def authenticate_passcode( + auth_req: PasscodeAuthRequest, + response: Response = Response, + ): + """ + Passcode-to-JWT Endpoint. + Verifies a passcode against site.access_code_kv_json (single source of truth — + v_site_domain joins from the same site record). + Returns a signed JWT with the site's account context, full role flags, and + a per-role TTL. The jwt.json_str.auth_type='passcode' field distinguishes + this token from a user login JWT. + """ + site_id = auth_req.site_id + passcode = auth_req.passcode + + # 1. Look up the site record + search_data = {'id_random': site_id} + if record := sql_select(table_name='site', data=search_data): + # 2. Parse access codes + access_codes_raw = record.get('access_code_kv_json') + access_codes = {} + if access_codes_raw: + try: + access_codes = json.loads(access_codes_raw) if isinstance(access_codes_raw, str) else access_codes_raw + except Exception as e: + log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}") + + # 3. Verify passcode in explicit priority order (highest privilege wins) + matched_role = None + for role in ROLE_PRIORITY: + code = access_codes.get(role) + if code and str(code) == str(passcode): + matched_role = role + break + + if matched_role: + log.info(f"Auth Success: Verified '{matched_role}' passcode for site {site_id}") + + # 4. Resolve account context + account_id_random = record.get('account_id_random') + if not account_id_random: + if account_id_int := record.get('account_id'): + account_id_random = get_id_random(record_id=account_id_int, table_name='account') + + # 5. Mint JWT with complete role flags and per-role TTL + payload = { + 'account_id': account_id_random, + 'super': (matched_role == 'super'), + 'manager': (matched_role == 'manager'), + 'administrator': (matched_role == 'administrator'), + 'trusted': (matched_role == 'trusted'), + 'public': (matched_role == 'public'), + 'authenticated': (matched_role == 'authenticated'), + 'json_str': json.dumps({ + 'auth_type': 'passcode', + 'site_id': site_id, + 'role': matched_role + }) + } + + token = sign_jwt( + secret_key=settings.JWT_KEY, + ttl=ROLE_TTL[matched_role], + **payload + ) + + return mk_resp( + data={'jwt': token, 'account_id': account_id_random, 'role': matched_role}, + response=response + ) + else: + log.warning(f"Auth Failed: Invalid passcode for site {site_id}") + return mk_resp(data=False, status_code=401, response=response, status_message="Invalid passcode.") + else: + log.warning(f"Auth Failed: Site {site_id} not found.") + return mk_resp(data=False, status_code=404, response=response, status_message="Site not found.") +``` + +### Backend Phase 2 (follow-up — not blocking frontend) + +**Remove `access_code_kv_json` from the `Site_Domain_Base` response model** (`site_domain_models.py`). This ensures passcodes are never sent to the client even if future code reads from the bootstrap. Requires confirming no other endpoint consumers rely on `access_code_kv_json` being in the base response before making this change. + +--- + +## Frontend Changes Required + +**These depend on the backend fixes above being deployed first.** + +### 1a. `src/lib/app_components/e_app_access_type.svelte` + +Replace `handle_check_access_type_passcode` entirely. The new version: + +- Is `async` +- Adds `auth_pending: boolean = $state(false)` and `auth_error: string | null = $state(null)` +- Uses a direct `fetch` call (NOT `post_object` — avoids triggering the session-expired banner on a 401) +- On success: sets `$ae_loc.access_type = data.role`, stores `$ae_loc.jwt = data.jwt`, triggers `process_permission_check` as before +- On 401: shows inline error, clears `entered_passcode`, resets `checked_passcode = null` to allow retry +- On network error: shows inline connection error +- Clears `auth_error` when `entered_passcode` changes + +API call shape: +```http +POST /authenticate_passcode +Content-Type: application/json +x-aether-api-key: +Body: { site_id: $ae_loc.site_id, passcode: entered_passcode } +``` + +Add to template (near the passcode input): +```svelte +{#if auth_pending} + +{/if} +{#if auth_error} + {auth_error} +{/if} +``` + +### 1b. `src/routes/+layout.ts` + +**Stop caching passcodes from bootstrap** — remove line ~394: +```ts +// ae_loc_init['site_access_code_kv'] = json_data.access_code_kv_json || {}; +``` + +**Add passcode JWT expiry check** — after the block around line 84 where `ae_loc_json.jwt` is read, add: +```ts +// Enforce passcode JWT TTL on page load. +// Decodes the JWT payload (base64, no secret needed) and resets access to anonymous if expired. +// User login JWTs (auth_type !== 'passcode') are left untouched. +if (ae_loc_json?.jwt) { + try { + const parts = ae_loc_json.jwt.split('.'); + if (parts.length === 3) { + const jwt_payload = JSON.parse(atob(parts[1])); + const json_str = typeof jwt_payload.json_str === 'string' + ? JSON.parse(jwt_payload.json_str) + : jwt_payload.json_str; + if (json_str?.auth_type === 'passcode' && jwt_payload.eat < Date.now() / 1000) { + // Passcode JWT has expired — revoke access + ae_loc_json.jwt = null; + ae_loc_json.access_type = 'anonymous'; + } + } + } catch { + // Malformed JWT — leave untouched, let existing handling deal with it + } +} +``` + +### 1c. `src/lib/stores/ae_stores__auth_loc_defaults.ts` (cleanup) + +Remove `site_access_code_kv` from the `AuthLocState` interface and the `auth_loc_defaults` object. The field is unused after 1a. Confirm no other component reads from it first (current grep: only `e_app_access_type.svelte` uses it — confirmed). + +--- + +## Migration Notes + +- Users with existing localStorage will still have `site_access_code_kv` cached — this is harmless after the frontend stops reading it. No forced cache clear needed. +- Existing persisted `access_type` is unaffected — users keep their current session level until their JWT expires or they manually clear storage. +- The `$ae_loc.jwt` field is already used by the user login flow. The `auth_type: 'passcode'` marker in `json_str` ensures the expiry logic only targets passcode sessions, not user login sessions. + +--- + +## Files Affected + +| File | Repo | Change | +| --- | --- | --- | +| `app/routers/api.py` | `aether_api_fastapi` | **Backend — do first.** Priority ordering, full JWT payload, per-role TTL, min_length on passcode | +| `app/models/site_domain_models.py` | `aether_api_fastapi` | Phase 2: remove `access_code_kv_json` from public model | +| `src/lib/app_components/e_app_access_type.svelte` | `aether_app_sveltekit` | Replace local check with async API call; loading/error UI | +| `src/routes/+layout.ts` | `aether_app_sveltekit` | Stop caching passcodes; add JWT expiry check | +| `src/lib/stores/ae_stores__auth_loc_defaults.ts` | `aether_app_sveltekit` | Cleanup: remove `site_access_code_kv` | +| `documentation/AE__Permissions_and_Security.md` | `aether_app_sveltekit` | Update passcode auth section to reflect new flow |