security: random per-device API token + one-shot pairing window (CRIT auth-bypass fix)

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.
This commit is contained in:
Sulkta 2026-06-13 09:48:29 -07:00
parent 6c27b75208
commit 185d490d58
5 changed files with 155 additions and 12 deletions

View file

@ -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 companion 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

View file

@ -1,30 +1,74 @@
"""Authentication helpers for adacam-api."""
import hashlib
"""Authentication helpers for adacam-api.
Auth model (rewritten 2026-06-13, security hardening):
* 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 the provisioning script-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 the provisioning script 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