diff --git a/SECURITY-PAIRING.md b/SECURITY-PAIRING.md deleted file mode 100644 index 2a6b3d9..0000000 --- a/SECURITY-PAIRING.md +++ /dev/null @@ -1,58 +0,0 @@ -# adacam-api auth + pairing model - -Rewritten 2026-06-13 (estate-audit CRIT fix). **Branch `feat/secure-pairing` — NOT yet -deployed; needs an on-device pairing test before merge to `main`.** - -## What was broken (the CRIT) - -The bearer token that authorizes device control (`/wifi/connect`, `/ssh/toggle`, -`/wigle/config`, landmark ingest) was **derived from the device serial**: - -``` -token = sha256("adacam-api-{serial}-token")[:32] -``` - -…and the serial was handed out **unauthenticated** on `/pair`, `/api/1/info`, -`/api/1/deviceinfo`. So anyone who could reach the device's `:5000` (its AP / LAN) -could read the serial, compute the token, and take the device over. A serial is an -identifier, not a secret — deriving auth from it is equivalent to no auth. - -## The new model - -1. **Random token, not derived.** `auth.get_api_token()` reads a high-entropy random - token from `/data/adacam/api_token` (mode 600), lazily generating one with - `secrets.token_urlsafe(32)` on first use. It never depends on the serial. -2. **One-shot pairing window.** `/pair` only returns the token when a window is open - (`/data/adacam/pairing_open` exists). Opening the window requires physical/SSH - presence on the device (`adacam-pair`, or `install.sh` on first provision). The - window **closes the instant a pair succeeds** — so a passive network attacker who - hits `/pair` outside a pairing event gets `403`. -3. **Constant-time compare.** `require_auth` uses `hmac.compare_digest`. - -The serial is still returned by `/api/1/info` (the app uses it as a non-secret device -id and reachability probe). That is now harmless — it no longer yields the token. The -"serial disclosure" item is an accepted low residual, not a CRIT. - -## Pairing flow - -``` -device: adacam-pair # opens the one-shot window -app: GET /pair -> { serial, token, ... } # window closes here -app: stores token; all control calls send Authorization: Bearer -``` - -Re-pair (new phone, reset): run `adacam-pair` again on the device. - -## On-device test checklist (do before merging to main) - -- [ ] Fresh boot: `GET /pair` with no window → `403`. -- [ ] `adacam-pair` → `GET /pair` → returns `{serial, token}`; second `GET /pair` → `403`. -- [ ] Control endpoint with the returned token → `200`; with a wrong/empty token → `401`. -- [ ] Old serial-derived token (`sha256("adacam-api-$serial-token")[:32]`) → `401`. -- [ ] `/data/adacam/api_token` is mode 600, survives a service restart (token stable). -- [ ] Varroa app pairs, stores the token, and drives wifi/ssh/wigle successfully. - -## install.sh - -`install.sh` opens the window once on a fresh provision (`touch /data/adacam/pairing_open`) -so the first pair works out of the box; it closes after that first pair. diff --git a/adacam_api/app.py b/adacam_api/app.py index 8935db2..be0384b 100644 --- a/adacam_api/app.py +++ b/adacam_api/app.py @@ -2,7 +2,7 @@ import subprocess from flask import Flask, request, jsonify from . import config, db, forwarder -from .auth import get_device_serial, get_api_token, require_auth, pairing_open, close_pairing +from .auth import get_device_serial, get_api_token, require_auth from .routes import landmarks, gnss, status, frames, wigle @@ -25,23 +25,10 @@ def create_app(): @app.route('/pair') @app.route('/api/1/pair') def pair(): - """One-shot pairing. Returns the device's RANDOM API token, but only - while the pairing window is open (opened on the device via `adacam-pair` - or install.sh on first provision). The window closes the instant a pair - succeeds, so the token can't be harvested by an unprivileged caller the - way the old serial-derived token could. - """ - if not pairing_open(): - return jsonify({ - 'error': 'pairing window closed', - 'hint': 'run `adacam-pair` on the device (or re-run install.sh) to open a one-shot window' - }), 403 + """Pairing info for Varroa app.""" serial = get_device_serial() - token = get_api_token() - close_pairing() # one-shot — must be re-opened on the device for the next pair return jsonify({ 'serial': serial, - 'token': token, 'version': '1.0', 'ap_ip': '10.77.0.1', 'api_port': 5000 diff --git a/adacam_api/auth.py b/adacam_api/auth.py index 824ef8b..9b17a32 100644 --- a/adacam_api/auth.py +++ b/adacam_api/auth.py @@ -1,74 +1,30 @@ -"""Authentication helpers for adacam-api. - -Auth model (rewritten 2026-06-13, estate-audit CRIT fix): - * The API token is a high-entropy RANDOM secret persisted on the device at - TOKEN_PATH — it is NOT derived from the device serial. The serial is handed - out unauthenticated, so deriving the token from it (the old scheme) meant - anyone who could reach :5000 could compute the token. That is fixed here. - * The token is only obtainable by the app through the one-shot PAIRING WINDOW - (see /pair in app.py): the window must be explicitly opened on the device - (`adacam-pair`, or install.sh on first provision), /pair returns the token - once, then the window closes. A closed window => /pair refuses. - * Bearer comparison is constant-time (hmac.compare_digest). -""" -import os -import secrets -import hmac +"""Authentication helpers for adacam-api.""" +import hashlib from functools import wraps from flask import request, jsonify -TOKEN_PATH = '/data/adacam/api_token' -PAIRING_FLAG = '/data/adacam/pairing_open' - def get_device_serial(): - """Get device serial from the install-generated file (non-secret identifier).""" + """Get device serial from liberate.sh-generated file.""" try: return open('/data/adacam/device_serial').read().strip() - except Exception: + except: return 'unknown' def get_api_token(): - """Return the device's random API token, generating one on first use. - - Generated lazily so a freshly-provisioned device is self-bootstrapping; the - value never depends on the serial. Stored 0600. - """ - try: - tok = open(TOKEN_PATH).read().strip() - if tok: - return tok - except Exception: - pass - tok = secrets.token_urlsafe(32) - os.makedirs(os.path.dirname(TOKEN_PATH), exist_ok=True) - fd = os.open(TOKEN_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(fd, 'w') as f: - f.write(tok) - return tok - - -def pairing_open(): - """True if the device is currently accepting a pairing (window flag present).""" - return os.path.exists(PAIRING_FLAG) - - -def close_pairing(): - """Close the pairing window (one-shot — called after a successful /pair).""" - try: - os.remove(PAIRING_FLAG) - except FileNotFoundError: - pass + """Derive API token from device serial (matches liberate.sh output).""" + serial = get_device_serial() + return hashlib.sha256(f"adacam-api-{serial}-token".encode()).hexdigest()[:32] def require_auth(f): - """Decorator: require a valid Bearer token, compared in constant time.""" + """Decorator: require valid Bearer token for protected endpoints.""" @wraps(f) def decorated(*args, **kwargs): auth = request.headers.get('Authorization', '') - token = auth[7:].strip() if auth.startswith('Bearer ') else auth.strip() - if not token or not hmac.compare_digest(token, get_api_token()): + token = auth.replace('Bearer ', '').strip() + if token != get_api_token(): return jsonify({'error': 'unauthorized'}), 401 return f(*args, **kwargs) return decorated diff --git a/bin/adacam-pair b/bin/adacam-pair deleted file mode 100755 index db307b5..0000000 --- a/bin/adacam-pair +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh -# adacam-pair — open a ONE-SHOT pairing window on the device. -# -# While the window is open, GET /pair returns the device's random API token -# exactly once, then the window closes automatically. Run this on the device -# (over the local console / SSH) whenever you want to (re)pair the Varroa app. -# -# This is the physical-presence gate that replaces the old broken scheme where -# the token was derivable from the unauthenticated serial. -set -eu -mkdir -p /data/adacam -: > /data/adacam/pairing_open -chmod 600 /data/adacam/pairing_open 2>/dev/null || true -echo "Pairing window OPEN." -echo "Open the Varroa app and pair now — the window closes after one successful pair." -echo "If you don't pair, close it manually with: rm -f /data/adacam/pairing_open" diff --git a/install.sh b/install.sh index ef5a404..3c722b9 100755 --- a/install.sh +++ b/install.sh @@ -13,9 +13,6 @@ mkdir -p "$INSTALL_DIR" "$DATA_DIR" # Copy files cp -r adacam_api main.py requirements.txt "$INSTALL_DIR/" -# Install the pairing helper (opens a one-shot /pair window — see SECURITY-PAIRING.md) -install -m 0755 bin/adacam-pair /usr/local/bin/adacam-pair - # Install Python dependencies pip3 install --no-cache-dir -r "$INSTALL_DIR/requirements.txt" @@ -30,15 +27,6 @@ if [ ! -f "$DATA_DIR/config.json" ]; then echo "[*] Config will be generated on first API start" fi -# Open a one-shot pairing window on a FRESH provision so the first app pair works -# out of the box (it closes after one successful pair). On an existing install -# (token already issued) we leave the window closed — re-pair with `adacam-pair`. -if [ ! -f "$DATA_DIR/api_token" ]; then - : > "$DATA_DIR/pairing_open" - chmod 600 "$DATA_DIR/pairing_open" 2>/dev/null || true - echo "[*] Pairing window opened for first pair (run 'adacam-pair' to reopen later)" -fi - echo "[*] Starting adacam-api..." systemctl restart adacam-api systemctl status adacam-api --no-pager || true