Compare commits
1 commit
main
...
feat/secur
| Author | SHA1 | Date | |
|---|---|---|---|
| 22fba16c0c |
5 changed files with 155 additions and 12 deletions
58
SECURITY-PAIRING.md
Normal file
58
SECURITY-PAIRING.md
Normal file
|
|
@ -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 <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import subprocess
|
import subprocess
|
||||||
from flask import Flask, request, jsonify
|
from flask import Flask, request, jsonify
|
||||||
from . import config, db, forwarder
|
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
|
from .routes import landmarks, gnss, status, frames, wigle
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -25,10 +25,23 @@ def create_app():
|
||||||
@app.route('/pair')
|
@app.route('/pair')
|
||||||
@app.route('/api/1/pair')
|
@app.route('/api/1/pair')
|
||||||
def 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()
|
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({
|
return jsonify({
|
||||||
'serial': serial,
|
'serial': serial,
|
||||||
|
'token': token,
|
||||||
'version': '1.0',
|
'version': '1.0',
|
||||||
'ap_ip': '10.77.0.1',
|
'ap_ip': '10.77.0.1',
|
||||||
'api_port': 5000
|
'api_port': 5000
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,74 @@
|
||||||
"""Authentication helpers for adacam-api."""
|
"""Authentication helpers for adacam-api.
|
||||||
import hashlib
|
|
||||||
|
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 functools import wraps
|
||||||
from flask import request, jsonify
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
TOKEN_PATH = '/data/adacam/api_token'
|
||||||
|
PAIRING_FLAG = '/data/adacam/pairing_open'
|
||||||
|
|
||||||
|
|
||||||
def get_device_serial():
|
def get_device_serial():
|
||||||
"""Get device serial from liberate.sh-generated file."""
|
"""Get device serial from the install-generated file (non-secret identifier)."""
|
||||||
try:
|
try:
|
||||||
return open('/data/adacam/device_serial').read().strip()
|
return open('/data/adacam/device_serial').read().strip()
|
||||||
except:
|
except Exception:
|
||||||
return 'unknown'
|
return 'unknown'
|
||||||
|
|
||||||
|
|
||||||
def get_api_token():
|
def get_api_token():
|
||||||
"""Derive API token from device serial (matches liberate.sh output)."""
|
"""Return the device's random API token, generating one on first use.
|
||||||
serial = get_device_serial()
|
|
||||||
return hashlib.sha256(f"adacam-api-{serial}-token".encode()).hexdigest()[:32]
|
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):
|
def require_auth(f):
|
||||||
"""Decorator: require valid Bearer token for protected endpoints."""
|
"""Decorator: require a valid Bearer token, compared in constant time."""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
auth = request.headers.get('Authorization', '')
|
auth = request.headers.get('Authorization', '')
|
||||||
token = auth.replace('Bearer ', '').strip()
|
token = auth[7:].strip() if auth.startswith('Bearer ') else auth.strip()
|
||||||
if token != get_api_token():
|
if not token or not hmac.compare_digest(token, get_api_token()):
|
||||||
return jsonify({'error': 'unauthorized'}), 401
|
return jsonify({'error': 'unauthorized'}), 401
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
return decorated
|
return decorated
|
||||||
|
|
|
||||||
16
bin/adacam-pair
Executable file
16
bin/adacam-pair
Executable file
|
|
@ -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"
|
||||||
12
install.sh
12
install.sh
|
|
@ -13,6 +13,9 @@ mkdir -p "$INSTALL_DIR" "$DATA_DIR"
|
||||||
# Copy files
|
# Copy files
|
||||||
cp -r adacam_api main.py requirements.txt "$INSTALL_DIR/"
|
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
|
# Install Python dependencies
|
||||||
pip3 install --no-cache-dir -r "$INSTALL_DIR/requirements.txt"
|
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"
|
echo "[*] Config will be generated on first API start"
|
||||||
fi
|
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..."
|
echo "[*] Starting adacam-api..."
|
||||||
systemctl restart adacam-api
|
systemctl restart adacam-api
|
||||||
systemctl status adacam-api --no-pager || true
|
systemctl status adacam-api --no-pager || true
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue