adacam-api/SECURITY-PAIRING.md
Cobb 22fba16c0c
All checks were successful
gitleaks / scan (push) Successful in 35s
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.
2026-06-13 09:48:29 -07:00

58 lines
2.7 KiB
Markdown

# 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.