ADR-051 — Attested kiosk stations, paired from the student's phone, beside Safe Exam Browser¶
Status¶
Accepted (2026-09-30, settled with the product owner on the implementation plan). Amended 2026-10-09:
the session end is endKioskSessions (auth/session.ts), formerly named endConfinedSessions here;
§10's delivery plan is removed (delivery lives in docs/merge/PROGRESS.md and changes/), its step 0
stays as §10; §1 names the stale-submit refusal (423 kiosk_attestation_stale) the code already answers.
Amended 2026-10-10: proof B of §3 and §10 is recorded for evaluations, and production enforces the
Config Key check (see "Amendment 2026-10-10" below).
Scope: for evaluations only, the part of merge decision D21 (docs/merge/08-decisions.md) that
concerns how a SEB session is checked; the confinement of a session to an activity (a project) was
outside this decision. D21 was subsequently settled on 2026-10-01 for M6; see
ADR-027's Status for that scope and its
implementation boundary.
Relations: amends ADR-027 (§2 the Config Key, §3 the cookie and the lifetime of a confined session)
and the scope of docs/spec/00-cadre-et-perimetre.md §0.6. Amended by
ADR-089 (proposed, 2026-10-09): §1, §4 and §7 for a
kiosk session confined to an online_seb project, which frames the online workspace from a Quiz
page; the rules below for evaluations are unchanged.
Context¶
An exam that requires Safe Exam Browser (ADR-027) is sat on the student's
own laptop. When that laptop fails — a flat battery, a SEB that refuses to
start, an unsupported OS — the student has nowhere to go. The school owns
Chromebooks managed by its Google Workspace. Locked in a web kiosk on
/kiosk, one of them is a fallback station that needs no SEB. It is also a
device whose integrity Google can attest in hardware (Chrome Verified
Access): the platform can know that the request comes from one of its own
Chromebooks, in verified boot mode, and not from a copy of the page in a
laptop's browser.
A kiosk is nobody's machine. It cannot sign in with edu-ID, and must not: the second factor is on a phone. But the student's phone is already signed in to the portal, and nothing is being sat yet. The device authorization grant of RFC 8628 was made for this case: a device with no keyboard worth typing a password on shows a short code, and the user approves it on a device where they are signed in.
While this was planned, three things came out of ADR-027 that the request had assumed otherwise:
- the
.sebstart URL carries a one-time ticket, not a signed token, and it is already exchanged for a session cookie (ADR-027 §1–2); - no Browser Exam Key is checked (§2 rejected a BEK list);
- the Config Key is checked on the launch route only.
Decision¶
1. A trusted client is a session kind¶
The two ways of sitting a locked exam are two confined session kinds:
seb (ADR-027) and a new kiosk. Both are confined to one evaluation by
sessions.evaluation_id. Both reach only the routes that declare them: the
SITTING config gains kiosk, and every other route stays anonymous to them
(ADR-027 §3, default deny). There is no provider class. The difference
between them is one function per kind, trustRefusal(kind, …), called by the
session hook of auth/plugin.ts on every request of a confined session:
- a
sebsession: the Config Key header of this request (§3); - a
kiosksession: the request also carries the station's own cookie (quiz_kiosk, §5), whose hash is the credential of the session'sdevice_id; the device isactive; and its attestation is not refused or silent (§6). Without the station's cookie, the session cookie alone is worth nothing: it cannot be carried to another machine, the weakness §3 closes for SEB.
A refusal makes the request anonymous (the same answer as a missing
session), except the suspension of §6. That answers 423 kiosk_suspended
on unsafe methods only (writes). A GET and the event stream pass, so that the
page can still read the attempt, say why it is suspended, and hear the
resumption: EventSource stops for good on a non-200.
Amended 2026-10-09 (documents existing behaviour, flagged on 2026-10-09 for
the product owner's acknowledgement): a second refusal is not anonymous either. A submit of a kiosk
session without an attestation check less than two minutes old (§6) answers
423 kiosk_attestation_stale, which the page meets by re-attesting and
retrying once (§6). The code already does so: kioskAttestationRefusal and
trustRefused (auth/trust.ts), answered by the session hook of
auth/plugin.ts.
2. Which trusted clients an exam accepts¶
settings.kiosk: boolean joins settings.safeExamBrowser. Both are read
through ONE reader, trustedClientsOf(mode, settings) in @quiz/domain,
which returns ("seb" | "kiosk")[]. Like SEB, the kiosk is an exam's
setting, inert on every other mode. No stored row changes:
safeExamBrowser |
kiosk |
The exam is sat |
|---|---|---|
| off | off | in the portal (today's default, unchanged) |
| on | off | in SEB only (today's SEB exam, unchanged) |
| off | on | on a kiosk station only |
| on | on | in SEB or on a kiosk station |
sitRefusal (modules/guards.ts) is THE rule:
- a confined session sits its own evaluation, and only while that evaluation
still accepts its kind;
- a portal session sits an evaluation only when trustedClientsOf is empty;
- the IP allowlist applies to every kind, the kiosk included.
Both settings are frozen with the rest of settings once the evaluation
runs or has an attempt (configLock).
3. Safe Exam Browser, checked on every request¶
ADR-027 §2 checked the Config Key once, at launch. The confined session was then trusted for 6 h, including from any HTTP client holding its cookie.
- The Config Key is stored on the session (
sessions.seb_config_key), at launch. It cannot be recomputed later: it is a function of the start URL, whose ticket secret the server never keeps. On every request of asebsession, the hook checksX-SafeExamBrowser-ConfigKeyHash = SHA-256(absolute URL + Config Key). The absolute URL is the origin ofPUBLIC_URLfollowed by the request's path and query as received (req.raw.url, never re-encoded throughURL), since SEB hashes the URL it requested. The existing vector tests (Moodle, 201 keys) are joined by per-request vectors, one of them with a percent-encoded query. - Audit-only until proof B. Nobody has yet watched a real SEB send the
header on
fetchandEventSourcerequests (proof B ofdocs/merge/06-codespace-seb-infra.md§6.3). If one does not, enforcing would lock every SEB student out mid-exam.SEB_CONFIG_KEY_ENFORCE(default0) therefore only audits a mismatch (auth.seb_config_key_mismatch, once per session and route template, deduplicated in the process's memory: best effort, a restart may write one more) until proof B is recorded; after that it is set to1and the mismatch refuses. The launch route keeps refusing a bad header in both modes, as today. - Still no Browser Exam Key, for the reason of ADR-027 §2. The header remains what ADR-027 says it is: it stops the accidental and the lazy. The security boundary is the confined session. Noted 2026-10-11: the security audit of 2026-10-10 records this as finding M10 (the Config Key spoofable without a Browser Exam Key); unchanged, an accepted risk — a high-stakes exam sits on attested kiosk stations, not on SEB alone.
4. Confined sessions: one at a time, strict cookie¶
- A new confined session supersedes the old one. Opening a
seborkiosksession for a (user, evaluation) deletes every other confined session of the same pair, and, for akioskone, every session still holding thatdevice_id, expired rows included. Both deletions come before the insert, in one transaction, which is what lets the partial unique index ondevice_id(§7) hold. It writesauth.session_superseded(the kinds, and the device label when there was one) and a staff event on the dashboard. The portal sessions of the user are never touched: the phone that approves a pairing is one of them, and so is a teacher's portal beside their SEB rehearsal (ADR-018). - A confined session's cookies are
SameSite=Strict: the session cookie and the CSRF cookieopenSessionmints with it. Nothing cross-site ever needs to carry a SEB or kiosk session. A navigation that SEB or the kiosk shell starts is not cross-site, and a cookie set on the launch response survives its 303. The portal keepsLax, because the OIDC callback is a cross-site navigation (N-SEC-01, amended). - ADR-027's rules on lifetime are otherwise unchanged. A
sebsession lives 6 h and outlives the submit, so that SEB shows the closed attempt. Akiosksession is also fixed at 6 h, never sliding, but it does not outlive the attempt (§7).
5. The station registry and the attestation¶
The registry. kiosk_devices:
- google_device_id: unique; it is Verified Access's devicePermanentId;
- label: "Poste de secours n° 7";
- status: unnamed | active | retired;
- attested_at: the last attestation Google accepted;
- checked_at and attestation: ok | unavailable | refused, the last
attempt (§6);
- credential_hash: the SHA-256 of the station's cookie;
- watch: ok | unavailable | suspended, what the supervisor was last
told of the station (§6), so that each change is told once.
A device that attests successfully for the first time is created
unnamed. An admin names it (it becomes active) or retires it, from a
page of the admin panel. The registry is platform-wide and admin-only: a
station belongs to no course. An unnamed or retired station cannot
be paired. It shows "Station not recognised, call the supervisor". This
is also what keeps the registry from being a hole: the Workspace's customer
id admits every Chromebook the school manages, and only those an admin has
named are exam stations.
The attestation follows Google's current documentation (API v2; v1
is deprecated):
- POST /app/api/kiosk/attest/challenge calls
POST https://verifiedaccess.googleapis.com/v2/challenge:generate (an
empty body) and returns {challenge} in base64. A challenge is valid for
one minute.
- The page passes it to the companion extension
(extensions/kiosk-attestation/, Manifest V3, enterprise.platformKeys).
The page reaches the extension with chrome.runtime.sendMessage to the
configured extension id, allowed by the extension's
externally_connectable. The extension calls
chrome.enterprise.platformKeys.challengeKey({scope: "MACHINE", challenge})
and returns the response in base64.
- POST /app/api/kiosk/attest/verify calls
POST …/v2/challenge:verify with {challengeResponse, expectedIdentity},
where expectedIdentity is the enrollment domain. It then requires:
- customerId equal to KIOSK_GOOGLE_CUSTOMER_ID;
- keyTrustLevel equal to CHROME_OS_VERIFIED_MODE, never developer mode;
- a devicePermanentId.
On success it creates or updates the device and sets the quiz_kiosk
cookie (HttpOnly, Secure, SameSite=Strict, path /app/api, 12 h).
When the request's cookie already names that device, its credential is
KEPT and the same cookie re-set, which extends it by 12 h: a write of the
sitting sent while a re-attestation is in flight carries the cookie of
before, and must not turn anonymous. A new credential is drawn only when
the request holds no valid cookie for the device (a first attestation, a
lost or expired cookie, another device's); the previous one then stops
naming it at once. That cookie is the station's identity, never a user's;
§1 requires it beside a kiosk session.
The trade-off is accepted: a copied quiz_kiosk cookie is not invalidated
by the station's next re-attestation. It is HttpOnly on a locked kiosk,
worth nothing without the session cookie of a live kiosk session (which
ends with the sitting), and dies with the station's retirement or a new
credential. Rotating on every attestation was dropped: it answered the
in-flight writes 401 and could drop a station out of its attempt.
- The service account authenticates with the OAuth 2.0 JWT-bearer grant
and the scope https://www.googleapis.com/auth/verifiedaccess. Its key is
a file named by KIOSK_VA_KEY_FILE, outside the repository and the
database (ADR-010). The access token lives in memory only.
- KIOSK_ATTESTATION=google | mock | off (default off: the routes answer
404 and the setting of §2 is not offered). Under NODE_ENV=production,
config.ts refuses to start with mock, and with google whose key file
is unreadable or whose customer id, enrollment domain or extension id is
missing — exactly like the development login. mock accepts a response of
the form mock:<device id> and nothing else; mock:refuse and
mock:unavailable are test fixtures that never attest (they answer a
refusal and an unavailable Google).
What the attestation proves: a Chromebook of the school's Workspace, in
verified boot mode, holds the key. What it does not prove by itself: that
the station is in kiosk mode. That is the device policy of its
organizational unit (docs/development/kiosk.md), and the naming step of an admin.
Google's separate kiosk-mode check (a USER key, expectedIdentity:
"KIOSK_MODE") is not used in v1: it does not return the device id, and it
would double every attestation.
Staging has its own configuration (off by default), and may use its
own service account key. The extension lists both origins in
externally_connectable: https://quiz.chevallier.io/* and
https://quiz.dev.chevallier.io/*.
6. Re-attestation and suspension¶
While a kiosk session sits, the page re-attests every 10 minutes, and when the submit is refused as stale (below) it re-attests and retries it once. A station waiting to be paired re-attests before each new code, so it is never silent when it starts sitting. The server distinguishes what only it can know, because it is the one calling Google:
- Refused: Google says no, or the extension fails. The page reports
the extension's failure to
verifyin place of a response, and the server records it as refused at once. A page that reports nothing becomes silent (below).attestation = refused, and the session is suspended. Writes answer423 kiosk_suspended; answers already saved are kept. A staff eventdashboard.alertreaches the supervisor, andkiosk.suspendedgoes to the audit. The next accepted attestation lifts the suspension by itself (kiosk.resumed, and the alert is cleared). - Unavailable: Google cannot be reached, times out or answers 5xx.
attestation = unavailable. The session is NOT suspended, since suspending every station at once because of a Google outage is worse than the risk it covers. The supervisor sees an "attestation impossible" alert. - Silent: no attempt has been made for 12 minutes (the page stopped attesting). The session is treated as suspended until the next attestation.
The submit route additionally requires a check (ok or unavailable) less
than two minutes old. This is the one exception to N-SEC-10's "never
blocking" rule, and it holds for kiosk sessions only.
7. Pairing: RFC 8628, adapted¶
kiosk_pairings:
- the SHA-256 of device_code and of user_code;
- the device;
- state: pending → approved → consumed, or expired;
- the student and the evaluation, set on approval, and who approved;
- expires_at.
The pure rules live in @quiz/domain (kioskPairing.ts): code generation
and normalization, and the state machine.
- The station (the
quiz_kioskcookie, anactivedevice) callsPOST /app/api/kiosk/device_authorization. It receives: device_code: 256 bits;user_code: 8 characters asXXXX-XXXX, on an alphabet with no vowels, no0 O 1 I L;verification_uriandverification_uri_complete=<PUBLIC_URL>/pair?code=XXXX-XXXX;expires_in: 300andinterval: 2.
Issuing a pairing expires the station's previous pending one. The page shows
the station's label, the code and a QR code of the complete URI, and
renews both on expiry.
- The station polls POST /app/api/kiosk/token with the RFC's
grant_type and device_code. The answers are those of RFC 8628 §3.5:
authorization_pending, slow_down (the interval grows by 5 s when the
station polls too fast), expired_token and access_denied. Polling
rather than SSE, because the event stream serves user sessions and
presence (ADR-020), and a station is not a user; two seconds is well
within what one person waits at a desk. On approved, one conditional
UPDATE … RETURNING consumes the pairing. The station then gets a kiosk
session (the student, the evaluation, sessions.device_id) and goes to
/take/<evaluation>. A partial unique index on sessions.device_id
makes "one session per station" a constraint, not a convention. A new
session on a station deletes whatever it carried before.
- The phone (/pair, a portal session; signed out, it goes through the
sign-in with next). It calls GET /app/api/pair/:code, which returns
the station's label and the exams the student can start now: a claimed
seat, the evaluation in lobby, running or paused, and kiosk
accepted. A teacher's staff seat counts as a seat, so a teacher rehearses
on a station as with SEB (ADR-018, ADR-027 §4). The student checks the label against the screen in front of
them, picks the exam and confirms with POST /app/api/pair. That is a
mutation, so it is under the double-submit CSRF check like every other; a
delegated session (ADR-034) cannot approve.
Wrong codes are limited to 10 failures per 10 minutes per user. Each
failure is written to the audit as kiosk.pair_refused, and the limit
counts those rows under an advisory lock. A GET of the page never
approves anything.
- The supervisor's fallback: a student without a phone gives the code on
the station's screen to the supervisor. From the evaluation's dashboard,
the supervisor approves it for that student with
POST /app/api/evaluations/:id/kiosk-assign {userCode, userId}, the same
pairing. The teacher is recorded in kiosk_pairings.approved_by and in
kiosk.assigned, never in sessions.actor_user_id: a non-null actor
is an impersonation (delegated, ADR-034), read-only in production, and
the student must be able to answer. There is no second mechanism.
- The end. The station's session is deleted by the auth module's
endKioskSessions, which live calls wherever an attempt ends:
- the student's submit;
- the teacher's close;
- the ticker's expiry;
- the evaluation's close.
endKioskSessions also closes the event streams of that one session
(not of the user, whose phone keeps its portal); a stream is authorized
only when it connects, so a deleted session would otherwise keep one open.
ADR-027 kept the seb session after the submit so that the page could
still read the closed attempt; the kiosk page instead draws its closed
screen from what it already holds (the submit's response, or the
attempt.closed event), shows it for a few seconds, then returns to
/kiosk. A reload after the end lands on /kiosk directly.
- The navigation of a kiosk session is confined exactly like a SEB one:
the attempt page of its evaluation, nothing else (SebElsewhere,
generalized).
8. Audit and supervision¶
The audit union gains:
| Action | When |
|---|---|
kiosk.attested, kiosk.attest_failed |
an attestation succeeded or failed (reason refused or unavailable; the device id, never the challenge or the response). Amended 2026-10-11 (security audit L5, N-SEC-09): a failure is audited for a known station only; an unknown caller's is logged. |
kiosk.device_registered, kiosk.device_labeled, kiosk.device_retired, kiosk.device_reactivated |
the registry changed (a retired station put back in service is its own entry) |
kiosk.paired, kiosk.pair_refused, kiosk.assigned |
a pairing was approved, refused, or approved by the supervisor |
kiosk.suspended, kiosk.resumed |
a kiosk session was suspended or resumed |
auth.session_superseded |
a new confined session replaced an older one |
auth.seb_config_key_mismatch |
a per-request Config Key did not match |
No device_code, user_code, cookie or Google token is ever written; a
pairing is named by its row id. The request log masks /pair?code= and the
user_code in /app/api/pair/:code (redact.ts), as it masks the SEB
secret. It masks it inside the sign-in's next parameter too
(/app/auth/login?next=%2Fpair%3Fcode%3D…), which is where a signed-out
phone carries it.
Each row of the dashboard carries access: portal, seb, or kiosk
with the station's label, plus an alert when a station is suspended or
cannot attest.
9. Personal data¶
The station's Google device id is data about a machine, not a person. What
links a person to a station is the kiosk session and the audit entries of
the pairing. N-DATA-02 lists "the kiosk station an attempt was sat on".
10. Hardware checks before the switches¶
Step 0, done by hand on real hardware, gates the SEB hardening (§3–4) and the registry and attestation
(§5) — not their code, their switches:
- in a web kiosk, challengeKey with the MACHINE scope works and the page
reaches the extension;
- a real SEB sends the Config Key header on fetch and EventSource
(proof B).
Consequences¶
- The scope of
00§0.6 changes. The school's own attested stations are in scope, while "device lockdown" of a student's device stays out. N-SEC-10 gains its one blocking exception (§6). sessionsgainsdevice_idandseb_config_key. Two tables are added:kiosk_devicesandkiosk_pairings.- A dependency on Google exists for the kiosk path only. With
KIOSK_ATTESTATION=off, nothing of it runs, and a Google outage never suspends a station (§6). - A SEB session caught by the per-request check needs proof B first. Until then, the check only writes to the audit.
- D21's other part, a session confined to an activity (a project) and
packages/seb, was settled on 2026-10-01 for M6. When M6 implements it,trustRefusalis where the project's SEB check goes.
Amendment 2026-10-10: proof B recorded, the Config Key check enforced¶
Scope: the "Audit-only until proof B" rule of §3 and the second check of §10, for evaluations.
- Proof B done (product owner, 2026-10-10). A real Safe Exam Browser ran a full attempt on an
evaluation with a waiting room: the
EventSourcestream of the waiting room, the start, autosaved answers (fetch), a reload, the hand-in and the quit. The header was present on every request and noauth.seb_config_key_mismatchwas written. - Production enforces it:
SEB_CONFIG_KEY_ENFORCE=1(staging too). Asebsession whose hash does not match is anonymous on that request; the audit still records the mismatch. The default of the variable stays0for development. - Not covered: the project flow (cross-host 303 to the portal,
docs/merge/06-codespace-seb-infra.md§6.3 steps 5-8) still waits for its own run.
Alternatives considered¶
- A signed token (JWT) in the
.sebURL. Rejected by ADR-027 §1: no single use, no revocation. The ticket already does what the request asked of the JWT. - A Browser Exam Key list. Rejected by ADR-027 §2, still for the same reason.
- A provider class hierarchy. One function per session kind, called from the one session hook, does the same with less.
- The kiosk on the event stream (SSE) for the approval. The stream is built around user sessions and presence. Polling at 2 s is what the RFC describes and costs nothing at this scale.
- Any Chromebook of the Workspace may pair. The customer id alone admits staff machines; the naming step is the registry's whole point.
- Suspend on any attestation failure, Google outages included. One outage would stop every station in the room at once.
- Revoking every other session of the user on a confined login. It would sign out the very phone that approves the pairing.
Correspondence of old references¶
§1–§9 keep their numbers and text, except §1's dated note of 2026-10-09.
| Old reference | Now |
|---|---|
§10 Delivery, steps 1–8 (one pull request each): 1 this ADR and the spec; 2 the trusted-client setting and the generalized sitRefusal (no kiosk reachable yet); 3 SEB hardening (§3–4); 4 registry and attestation; 5 the extension; 6 pairing and the two pages; 7 re-attestation, suspension and the supervisor's view; 8 the end-to-end tests with the mock attestation, and docs/development/kiosk.md |
removed: delivery lives in docs/merge/PROGRESS.md and changes/ (apps/api/src/db/kiosk.ts cites steps 4 and 6) |
| §10, step 0 (hardware checks gating steps 3 and 4) | §10 |