Files
OSIT-AE-API-FastAPI/documentation/PROJECT__AE_Site_Passcode_Security.md
2026-04-10 11:56:44 -04:00

387 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (~50150ms), 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: <from $ae_api.headers['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}
<Loader size="1em" class="animate-spin text-gray-400" />
{/if}
{#if auth_error}
<span class="text-error-500 text-xs">{auth_error}</span>
{/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 |