Skip to content

Authentication and pairing

Monika edited this page Aug 21, 2026 · 3 revisions

Authentication and pairing

Two credentials, never one: an enrolment key to begin pairing, and a per-device credential to actually use the API. This is the mechanism behind the pairing prompt in the panel and the app.

Breeze Core uses an RFC 8628-style device-pairing flow, so no long-lived shared password rides on every request:

  1. The api_key is an enrollment secret: on its own it only authorizes starting a pairing.
  2. A client POSTs /api/auth/enroll/start (with the key) and shows a short, single-use code (~60 s).
  3. An admin on the LAN approves that code (via tools/ac-approve.zsh or POST /api/auth/enroll/approve).
  4. The client polls /api/auth/enroll/poll and is bound to a per-device credential — individually named, revocable, and expiring.
  5. Control endpoints require the API key and a valid device credential. Approval and device management are admin-only and restricted to the local network.

Rotating the api_key doesn't log devices out (device credentials are independent); revoke a single lost device with ac-approve.zsh revoke <token_id>.

Auth versions (the device credential)

A device is pinned to the credential profile it enrolled with. The server advertises which it supports in GET /api/version (auth_versions, min_auth_version).

v2 — Ed25519 request signing (current, recommended). The client generates an Ed25519 keypair; the private key never leaves the device and the server stores only the public key — so a devices.json leak yields nothing an attacker can use. Each request is signed over a canonical string binding the method, path, timestamp, a single-use nonce, and a SHA3-512 digest of the body, sent as headers:

X-Breeze-Auth-Version: 2
X-Breeze-Key-Id:       <token_id>            # names the device (not secret)
X-Breeze-Timestamp:    <unix seconds>        # must be within ±60 s of the server
X-Breeze-Nonce:        <base64url, 16 bytes> # single-use within the window
X-Breeze-Signature:    <base64url Ed25519 sig>
#   signed message =
#   "breeze-auth-v2\n{METHOD}\n{path?query}\n{timestamp}\n{nonce}\n{sha3_512(body) hex}"

The timestamp + nonce give replay protection; the body digest gives tamper protection. At enrollment (auth_version: 2) the client sends its public_key in enroll/start; nothing secret is ever returned by poll.

v1 — bearer token (legacy). Authorization: Bearer <token>; the token is 256-bit random, shown once at enrollment, and stored only as a SHA-256 hash. Still fully supported.

Rollout and in-place upgrade

AC_MIN_AUTH_VERSION (default 1) is the clamp. At 1, both v1 and v2 devices work — a new device may still enrol as either (the web UI and the zsh/binary CLIs are v1-only, so this must stay open by default), and v1-authenticated responses carry an advisory X-Breeze-Upgrade: auth-version=2 header. Raise it to 2 once your clients are updated and v1 is fully closed:

  • v1 control requests are refused with 426 Upgrade Required + a human-readable message (which even an un-updated client surfaces).
  • v1 enrollment is refused too — enroll/start with auth_version below the floor returns 426, so no new legacy credential is ever minted (not even a dead one that would be clamped on its first call).

So a clamped server effectively runs v2-only: existing v1 devices must upgrade, and no new v1 device can be created. Migrate the web UI and CLIs before raising the clamp — they can't do v2 yet.

An enrolled v1 device upgrades to v2 in placePOST /api/auth/upgrade, authenticated by its existing credential, registers a freshly-generated public key and keeps the same token_id. No re-pairing and no admin re-approval (it re-keys a device that already proved possession, trusting nobody new). The Breeze app does this automatically on first launch after an update.

The bundled web UI and the diagnostic CLIs remain v1 clients for now; they keep working while AC_MIN_AUTH_VERSION=1 (the default). Migrate them before raising the clamp to 2.

Why an auth failure happened — the 401 body

Every rejection carries a machine-readable reason, because "401" alone can't tell a client whether to fix the request or throw its credential away. Getting that wrong is expensive: an app that re-pairs on any 401 will discard a perfectly good key the moment a phone's clock drifts, and then need an admin on the LAN to approve it again.

{"detail": {"error": "unauthorized", "detail": "Request timestamp outside the allowed window",
            "reason": "clock_skew", "retryable": true,
            "server_time": 1755284412.51, "client_time": 1755284100.0,
            "max_skew_seconds": 60}}
reason retryable What the client should do
no_credential no nothing was presented — enrol
unknown_key no the server has never heard of this key id (revoked, or a wiped devices.json) — re-enrol
expired no past AC_TOKEN_TTL_DAYS — re-enrol
bad_signature no the signature doesn't verify for this key — re-enrol
bad_api_key no the shared enrollment key is wrong — fix the key, don't touch the device credential
clock_skew yes the credential is fine; the timestamp isn't. server_time is included so the client can learn its offset and retry — the Breeze app does exactly this
replay yes this nonce was already used; sign a new request
incomplete_signature yes a required signing header was missing or malformed

Retryable reasons must not cost you your credential. Both retryable and definitive failures are logged server-side with the reason, the client IP, and a key hint (never the secret) — journalctl -u breeze-core -t meow-ac.auth.

Which clients sign

Client Credential
Breeze for Android Ed25519 since app 2.0 / server 3.0.0. Seed in the Android Keystore.
The web panel Ed25519 from 3.2.0. Non-extractable CryptoKey in IndexedDB — the page cannot read its own private key. Falls back to a bearer token on a browser without Ed25519.
breeze-core diag Bearer token, self-enrolled and cached.
Anything you write Either. AC_MIN_AUTH_VERSION=2 refuses v1.

Before the panel could sign, setting AC_MIN_AUTH_VERSION=2 locked the panel out along with everything else unsigned — so in practice the clamp was unusable for anyone who used the browser. It is usable now.

Whether a browser can do this at all is worth checking before relying on it: the package host serves a one-page capability probe.

Clone this wiki locally