All checks were successful
gitleaks / scan (push) Successful in 35s
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.
58 lines
2.7 KiB
Markdown
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.
|