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

2.7 KiB

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-pairGET /pair → returns {serial, token}; second GET /pair403.
  • 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.