From 22fba16c0c637e6ac4208769c785cb74a5a50873 Mon Sep 17 00:00:00 2001 From: Cobb Date: Sat, 13 Jun 2026 09:48:29 -0700 Subject: [PATCH] security: random per-device API token + one-shot pairing window (CRIT auth-bypass fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bearer token was sha256(serial)[:32] and the serial is served unauthenticated, so anyone reaching :5000 could compute it and take the device over. Now: token is a random secrets.token_urlsafe(32) at /data/adacam/api_token (never derived from serial); /pair only returns it during a one-shot pairing window (/data/adacam/pairing_open, opened by adacam-pair or install.sh, closes after one pair); require_auth uses hmac.compare_digest. NEEDS ON-DEVICE PAIRING TEST before merge to main — see SECURITY-PAIRING.md. --- SECURITY-PAIRING.md | 58 ++++++++++++++++++++++++++++++++++++++++ adacam_api/app.py | 17 ++++++++++-- adacam_api/auth.py | 64 ++++++++++++++++++++++++++++++++++++++------- bin/adacam-pair | 16 ++++++++++++ install.sh | 12 +++++++++ 5 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 SECURITY-PAIRING.md create mode 100755 bin/adacam-pair diff --git a/SECURITY-PAIRING.md b/SECURITY-PAIRING.md new file mode 100644 index 0000000..2a6b3d9 --- /dev/null +++ b/SECURITY-PAIRING.md @@ -0,0 +1,58 @@ +# 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 be0384b..8935db2 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 +from .auth import get_device_serial, get_api_token, require_auth, pairing_open, close_pairing from .routes import landmarks, gnss, status, frames, wigle @@ -25,10 +25,23 @@ def create_app(): @app.route('/pair') @app.route('/api/1/pair') def pair(): - """Pairing info for Varroa app.""" + """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 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 9b17a32..824ef8b 100644 --- a/adacam_api/auth.py +++ b/adacam_api/auth.py @@ -1,30 +1,74 @@ -"""Authentication helpers for adacam-api.""" -import hashlib +"""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 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 liberate.sh-generated file.""" + """Get device serial from the install-generated file (non-secret identifier).""" try: return open('/data/adacam/device_serial').read().strip() - except: + except Exception: return 'unknown' def get_api_token(): - """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] + """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 def require_auth(f): - """Decorator: require valid Bearer token for protected endpoints.""" + """Decorator: require a valid Bearer token, compared in constant time.""" @wraps(f) def decorated(*args, **kwargs): auth = request.headers.get('Authorization', '') - token = auth.replace('Bearer ', '').strip() - if token != get_api_token(): + token = auth[7:].strip() if auth.startswith('Bearer ') else auth.strip() + if not token or not hmac.compare_digest(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 new file mode 100755 index 0000000..db307b5 --- /dev/null +++ b/bin/adacam-pair @@ -0,0 +1,16 @@ +#!/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 3c722b9..ef5a404 100755 --- a/install.sh +++ b/install.sh @@ -13,6 +13,9 @@ 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" @@ -27,6 +30,15 @@ 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