fix(devices): keep an in-use display paired by renewing its credential (ADR 0056) #94

Open
ekklesia-bot wants to merge 1 commits from fix/device-credential-renewal into main
Member

Stage 0: a paired kiosk stays paired while it is in use, instead of dropping to a pairing code 180 days after pairing.

Problem

ADR 0036 gave a device credential a fixed 180 days from pairing (server check createdAt + 180d, browser cookie Max-Age 180d). Every working screen therefore fails on the anniversary of its pairing, and screens paired together fail together. Production kiosks paired around Signage 0.1.1 would all drop to pairing codes at about six months.

Decision (ADR 0056, superseding only ADR 0036's "Session lifetime")

  • Idle window, not lifetime. A credential is accepted while the device was last seen < 180 days ago (or paired < 180 days ago if never seen). lastSeenAt already slides on authenticated device requests (throttled to 15 min), so no migration.
  • Browser cookie renewed, not rotated. The display descriptor (GET /api/v1/module-displays/<module>) — requested by every display on start, on reconnect, and periodically (every 5 min with #92) — re-issues the cookie that authenticated it with a fresh 180-day Max-Age and the identical attributes (HttpOnly, SameSite=Strict, host-only, Path=/, Secure on https). Same value.
  • Only a request that resolved to a live, unrevoked device is renewed. Revoked, expired, and unknown credentials stay anonymous and are never renewed; a person viewing a display never receives a device cookie.

Security implications

Before (ADR 0036) After (ADR 0056)
Working, unrevoked screen Unpaired on day 180 Paired until revoked
Abandoned screen Dead 180 days after pairing Dead 180 days after it was last seen
Stolen credential Usable ≤ 180 days from pairing, or until revoked Usable as long as someone keeps using it, until revoked
Revocation Immediate, every request Unchanged
What a credential can do Display permissions it was paired for Unchanged
Storage / transport SHA-256 at rest; HttpOnly, SameSite=Strict, host-only, Secure Unchanged; renewal reuses the same writer

The one real loss: a stolen credential is no longer guaranteed to die on its own. That is accepted because revocation is immediate, the credential grants only read access to the displays it was paired for (never a person's access), and the operator sees each device's last-seen time. Rotation was considered and rejected for now: a lost renewal response would strand an unattended screen, which is the failure being fixed (see ADR 0056).

Compatibility

Existing credentials keep working with no migration. One that was paired under the fixed rule is renewed the next time its screen requests the descriptor, as long as the browser still holds the cookie. The device schema, the cookie name, and the pairing flow are all unchanged.

Tests

Added to apps/api/tests/device-pairing.integration.ts (real API and PostgreSQL; device ages set in the database):

  • An in-use screen paired 200 days ago is accepted, the cookie comes back with a full Max-Age and the same attributes, and last_seen_at moves forward. Fails on main.
  • A credential paired under the old rule keeps working past day 180. Fails on main.
  • An abandoned screen, last seen 181 days ago, is anonymous and is not renewed.
  • A revoked screen is never renewed.
  • A person viewing the display gets no device cookie.

Verification

  • pnpm ci:verify passes (core 1159, API 511, root 152).
  • pnpm test:migrations:integration passes: 401 tests, of which 2 fail with main's implementation.

#92's kiosk guide describes this renewal, so the two should merge together.

🤖 Generated with Claude Code

Stage 0: a paired kiosk stays paired while it is in use, instead of dropping to a pairing code 180 days after pairing. ## Problem ADR 0036 gave a device credential a **fixed** 180 days from pairing (server check `createdAt + 180d`, browser cookie `Max-Age` 180d). Every working screen therefore fails on the anniversary of its pairing, and screens paired together fail together. Production kiosks paired around Signage 0.1.1 would all drop to pairing codes at about six months. ## Decision (ADR 0056, superseding only ADR 0036's "Session lifetime") - **Idle window, not lifetime.** A credential is accepted while the device was last seen < 180 days ago (or paired < 180 days ago if never seen). `lastSeenAt` already slides on authenticated device requests (throttled to 15 min), so **no migration**. - **Browser cookie renewed, not rotated.** The display descriptor (`GET /api/v1/module-displays/<module>`) — requested by every display on start, on reconnect, and periodically (every 5 min with #92) — re-issues the cookie that authenticated it with a fresh 180-day `Max-Age` and the identical attributes (`HttpOnly`, `SameSite=Strict`, host-only, `Path=/`, `Secure` on https). Same value. - Only a request that resolved to a live, unrevoked device is renewed. Revoked, expired, and unknown credentials stay anonymous and are never renewed; a person viewing a display never receives a device cookie. ## Security implications | | Before (ADR 0036) | After (ADR 0056) | |---|---|---| | Working, unrevoked screen | Unpaired on day 180 | Paired until revoked | | Abandoned screen | Dead 180 days after pairing | Dead 180 days after it was last seen | | Stolen credential | Usable ≤ 180 days from pairing, or until revoked | Usable as long as someone keeps using it, until revoked | | Revocation | Immediate, every request | Unchanged | | What a credential can do | Display permissions it was paired for | Unchanged | | Storage / transport | SHA-256 at rest; HttpOnly, SameSite=Strict, host-only, Secure | Unchanged; renewal reuses the same writer | The one real loss: a stolen credential is no longer guaranteed to die on its own. That is accepted because revocation is immediate, the credential grants only read access to the displays it was paired for (never a person's access), and the operator sees each device's last-seen time. Rotation was considered and rejected for now: a lost renewal response would strand an unattended screen, which is the failure being fixed (see ADR 0056). ## Compatibility Existing credentials keep working with no migration. One that was paired under the fixed rule is renewed the next time its screen requests the descriptor, as long as the browser still holds the cookie. The device schema, the cookie name, and the pairing flow are all unchanged. ## Tests Added to `apps/api/tests/device-pairing.integration.ts` (real API and PostgreSQL; device ages set in the database): - An in-use screen paired 200 days ago is accepted, the cookie comes back with a full Max-Age and the same attributes, and `last_seen_at` moves forward. **Fails on `main`.** - A credential paired under the old rule keeps working past day 180. **Fails on `main`.** - An abandoned screen, last seen 181 days ago, is anonymous and is not renewed. - A revoked screen is never renewed. - A person viewing the display gets no device cookie. ## Verification - `pnpm ci:verify` passes (core 1159, API 511, root 152). - `pnpm test:migrations:integration` passes: 401 tests, of which 2 fail with `main`'s implementation. #92's kiosk guide describes this renewal, so the two should merge together. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
ekklesia-bot added 1 commit 2026-10-13 03:30:09 +00:00
fix(devices): keep an in-use display paired, renewing its credential instead of expiring it
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
650932a170
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]>
All checks were successful
CI / verify (pull_request) Successful in 1m7s
Required
Details
CI / infrastructure-smoke (pull_request) Successful in 39s
Required
Details
CI / package-release (pull_request) Successful in 50s
Required
Details
CI / migration-smoke (pull_request) Successful in 2m22s
Required
Details
CI / image-build (pull_request) Successful in 1m1s
Required
Details
CI / deploy-smoke (same-origin) (pull_request) Successful in 44s
Required
Details
CI / package-publication (pull_request) Successful in 58s
Required
Details
CI / deploy-smoke (split) (pull_request) Successful in 2m34s
Required
Details
This pull request doesn't have enough required approvals yet. 0 of 1 official approvals granted.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/device-credential-renewal:fix/device-credential-renewal
git checkout fix/device-credential-renewal
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Zayin/Ekklesia#94