# 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 |