Files
Ekklesia/docs/architecture/decisions/0036-managed-device-identity-and-pairing.md
T
ekklesiaandClaude Opus 5.5 650932a170
CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 39s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m22s
CI / image-build (pull_request) Successful in 1m1s
CI / deploy-smoke (same-origin) (pull_request) Successful in 44s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m34s
fix(devices): keep an in-use display paired, renewing its credential instead of expiring it
A device credential was accepted for a fixed 180 days from pairing, so every
working screen dropped to a pairing code on the anniversary of its pairing,
and screens paired together failed together. Measure the 180 days from when
the device was last seen instead, and have the display descriptor - which
every screen requests on start, on reconnect, and periodically - re-issue the
cookie that authenticated it with a fresh lifetime. The value is not rotated,
so a renewal lost in transit cannot strand a screen. Revoked, expired, and
unknown credentials are never renewed, a person never receives a device
cookie, and an abandoned screen still stops being an identity 180 days after
it was last seen. No migration: lastSeenAt already slides with use.

ADR 0056 supersedes the session-lifetime section of ADR 0036.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:27:27 -05:00

194 lines
11 KiB
Markdown

# 0036: A display device is its own principal, paired by an administrator
- Status: Accepted
- Date: 2026-08-24
- Session lifetime superseded by: [0056](0056-device-credentials-last-while-in-use.md)
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
[0032](0032-native-module-realtime-transport.md),
[0033](0033-display-kiosk-host-contract.md),
[0034](0034-host-owned-browser-authentication.md),
[0035](0035-initial-platform-owner-bootstrap.md)
## Context
[ADR 0033](0033-display-kiosk-host-contract.md) gave Ekklesia a chrome-free display host, and
[ADR 0034](0034-host-owned-browser-authentication.md) gave it a login page. Put together, the only
way to light up a television in a foyer was to sign a human being into it and leave that session
running on a screen anyone can walk up to.
That is not a display model. It means a person's credentials live on a wall, a person's session is
what a screen depends on, and revoking that person — or their session simply expiring — takes the
signage down. The alternatives are worse: a public unauthenticated display, a hardcoded token, or a
credential in a URL.
A screen is not a person, and pretending otherwise is the problem.
## Decision
Ekklesia recognizes **three** principal classes: anonymous, authenticated human, and managed device.
A device is a first-class principal with its own identity, its own credential, and its own explicit
permissions.
```text
screen opens /display/announcements → no identity → shows a short code
administrator types the code in the Dashboard, names the screen, approves
screen collects a credential → device principal → display and realtime work
administrator revokes → the screen loses access and shows a code again
```
### Devices are not users
There is no device row in the identity tables and no foreign key from a device to a user. A device
has no email, no password, and no human session.
This matters beyond tidiness. Every "is a person signed in?" question in the platform is answered by
`isAuthenticatedPrincipal`, and a device that satisfied it would inherit whatever a signed-in human
may do. So a device satisfies the `authenticated` baseline **never** — not with one permission, not
with ten. The only thing that makes a device allowed is an explicit permission granted to that exact
device.
### Device permissions are their own table, and the evaluator is still the only judge
`ekklesia_core_grant.user_id` is a non-null foreign key into the user table. Generalizing it into a
polymorphic subject would rewrite the platform's central authorization record — its
relationship-derived grants, its audit semantics, its indexes — to serve one narrow case. Device
permissions therefore live in `ekklesia_core_device_permission`.
That is a schema decision, **not** a second decision point. `createAuthorizationEvaluator` gained one
branch, so a device is allowed or denied by the same function, with the same deny-by-default, as
anyone else. There is no `if (device) bypass`, no owner check a device could reach — a device is
never a platform owner — and no baseline that stands in for a grant. A deployment that composed no
device support at all simply denies every device.
### The pairing code is not the credential
Three secrets are involved and conflating any two of them would break the model:
| value | who sees it | what it does |
| --- | --- | --- |
| pairing code (6 digits) | **anyone who can see the screen** | identifies the request to an administrator |
| claim secret (256 bits) | only the requesting browser, HttpOnly | proves "I am the screen that asked" |
| device credential (256 bits) | only the paired browser, HttpOnly | is the device's identity |
The code is printed across a television in a public hallway. It is therefore treated as public: it
is stored in the clear, and knowing it is worth nothing on its own. Approving it requires an
administrator holding `core.devices.manage`, and **collecting** the resulting credential requires the
claim cookie, which no onlooker has. A flow that issued a credential to whoever presented the code
would hand a screen's identity to anyone who read it off the wall.
Six digits are therefore not carrying the security of this design. They are bounded anyway: ten
minutes, single-use, one live request per code enforced by a partial unique index, and an
administrator in the loop.
### The permission comes from the display, never from the request
A pairing request names only the module whose display the screen is sitting on, and even that is not
taken at its word — the module is looked up in the publication registry and the permission is read
from its validated display declaration. A browser cannot ask to be granted `core.devices.manage`,
another module's permission, or anything else. Approval grants exactly the one permission the host
derived, never a bundle.
### State machine
`pending → approved → consumed`, plus expiry derived from `expires_at`.
Expiry is deliberately *not* a fourth state: nothing has to run for a code to stop working, so there
is no sweeper whose failure would leave live codes lying around. Each transition is a conditional
`UPDATE` — approval matches only a `pending`, unexpired row, and consumption matches only an
`approved` one — so approving twice, approving something expired, and two screens racing to claim one
approval all affect zero rows rather than racing. Exactly one credential can ever come out of one
code.
### The credential is hashed, and revocation is server-side
The device receives an opaque 256-bit credential in an HttpOnly cookie; the server stores only
SHA-256 of it. A fast hash is correct here — unlike a password, there is nothing to brute-force in
CSPRNG output, and a slow KDF would only add latency to every request a display makes. The raw value
exists in exactly two places: the response that sets the cookie, and the device's own cookie jar. It
is never logged.
Revocation is `revoked_at` on the device row, checked on **every** principal resolution. It is not a
request that a remote browser discard anything: a revoked screen keeps its cookie and stops working.
### Cookies
Two host-owned cookies, named nothing like Better Auth's: `ekklesia_device_pairing` (short-lived,
cleared on use) and `ekklesia_device_session`. Both are `HttpOnly` — module display code runs in this
very page and must never be able to read the credential — `SameSite=Strict`, host-only, `Path=/`, and
`Secure` whenever the deployment's own base URL is https. Nothing about the credential is ever
returned in JSON, put in a query string, or written to `localStorage`.
**A human session always wins.** The device cookie is not even read when Better Auth resolves a
person, so an administrator who opens a display on their laptop stays themself.
### `/api/v1/principal` stays human-only
A device is reported as `anonymous` there, deliberately.
That endpoint is what the Dashboard shell uses to decide whether someone is signed in, and its
shape — "not anonymous means logged in" — was load-bearing in the shell before this pass. Returning a
device from it would have made a television a Dashboard user. The shell's test is now `=== "authenticated"`
as a second lock, but the first lock is that the endpoint never says "device" at all.
The display host instead reads its viewer from the **descriptor**, which is the display's actual
authorization boundary and already performed the check. So displays are descriptor-first while the
Dashboard remains principal-first, and neither has to infer an identity by elimination.
### The display page is served to anyone; the descriptor is not
An unpaired screen has no identity, so a permission gate on the page would leave a new television
with nowhere to render a code. The page is the same inert, module-agnostic bytes for every display —
no module id, no data, one already-public script URL — and every authorization decision stays in the
descriptor. What this does reveal to an anonymous caller is that a module with a display exists at
that path, which any pairing flow must reveal to the screen it is pairing.
### Session lifetime
Device credentials last 180 days; human sessions are unchanged. A screen mounted near a ceiling
should not need re-pairing on a fortnightly cycle. What makes a long horizon safe is that it is not
the security boundary — the server re-checks revocation on every request — and the expiry exists so
an abandoned, forgotten screen eventually stops being an identity at all. `lastSeenAt` is updated at
most every 15 minutes, in SQL as well as in the resolver, so a display polling realtime does not
become a write load.
## Consequences
### Revocation is prompt, but not instantaneous, and this ADR says so
A revoked device fails its next HTTP request immediately, and its next realtime *connection*
immediately. An **already open** SSE stream is not torn down at the moment of revocation: realtime
already forces a reconnect every five minutes (ADR 0032), so the worst case is a bounded five-minute
window during which a screen keeps receiving a channel it has just lost. Closing live streams on
revocation would mean reaching into the realtime registry by principal, which is a change to a
subsystem this pass has no other reason to touch. The bound is documented rather than described as
instant.
### Re-pairing creates a new identity
A revoked screen that is paired again becomes a **new** device row with a new credential. The old
credential is never reactivated, and the revoked row stays in the list as evidence. Reactivating the
original would mean a revoked credential could come back, which is exactly what revocation must
prevent.
### What was deliberately not built
QR codes (a future scanner can encode the same short code — the backend protocol does not change,
and a QR must never carry the credential, an admin token, or a session), per-device content
targeting, device groups, health monitoring, remote control, and mobile dashboards. The pairing API
is not desktop-specific, so a future mobile dashboard host can consume it unchanged.
### Ekklesia Lite has none of this
Lite is a minimal trusted-network module runtime: module loading, config, database, storage, HTTP,
realtime, and UI hosting. It deliberately provides no human authentication, no permission
enforcement, and **no managed device identity or pairing** — a Lite-hosted display simply runs on a
trusted local network. Device identity is a full-Ekklesia capability, and nothing here belongs in a
future Lite contract. No module code branches on which host is running it.
### Module UI is untouched
A module's display receives a principal and its permissions, exactly as before. The principal union
gained a `device` variant so a module can render honestly for a screen with nobody behind it, and
that is the whole change — there are no pairing controls in `ModuleUiContext`, and a module cannot
approve, list, or revoke anything.