← Spørsmål og svar

Spørsmål og svar er skrevet på engelsk og nederlandsk; de vises på engelsk.

Spørsmål og svar

Phone app: pairing and confirming

FAQ › Profile server › Phone app · Nederlands

What is it?

The EUZOS app (Flutter, app/; Android and iOS) is the user's key. It signs in to the profile server with e-mail address and password, pairs the phone by creating a P-256 key pair in the phone's secure chip (Secure Enclave on iOS, Android Keystore/StrongBox on Android), and afterwards confirms logins with Face ID or a fingerprint: a PC login (QR code on the login screen) and a login on the admin page. The private key never leaves the chip. The app is built per environment (flavor lokaal, sandbox, productie) and only talks to the pinned server of its own environment.

How do I…?

How do I pair my phone?

Sign in in the app with your e-mail address and password (only an actief account). The app then creates the chip key, asks for Face ID / fingerprint, signs the pairing message and sends it with POST /api/app/v2/devices. The server answers with a device id and a device token (ezd_…), which the app keeps in the Keychain / encrypted with an Android Keystore key. Without (strong) biometrics the app refuses to pair; the fallback is then the web page.

How do I log in on a PC with my phone?

Scan the QR code on the PC's login screen with the app's scanner. The app fetches the request (GET /api/app/v2/session/<S>) and shows the PC's IP address, the pairing code in large type ("Is this code on the PC in front of you?"), a warning if the PC is on another network, and the choice Work or school / Private. Tap Sign in on this PC ("Inloggen op deze pc"); the chip asks for Face ID / fingerprint and signs the login message (POST /api/app/v2/session/<S>/confirm).

Then the app does not show the pairing code: you must type in the code shown on the PC in front of you, and only if it matches can you log in. "Did someone send you the link or the code: reject."

How do I reject a login request?

Tap Decline ("Weigeren"), or go back without choosing: the app sends POST /api/app/v2/session/<S>/reject. The PC shows a new QR code.

How do I see and log out my PCs?

In the app, the list of PC sessions (GET /api/app/v2/pc-sessions) shows every logged-in PC with IP, profile, device, since/expires and the method (telefoon, web, wachtwoord, …). A PC logged in with a password shows a warning. Log one out with POST /api/app/v2/pc-sessions/<ref>/logout; the PC notices at its next status poll.

How do I remove a phone?

In the app's device list (GET /api/app/v2/devices) revoke it (DELETE /api/app/v2/devices/<id>): with a password sign-in for any own device, with a device token only for that device itself. The admin uses device-revoke <device id> or the Phones page. Revoking also logs out the PCs that were logged in with that device.

Why does the app ask me to pair again?

The chip key becomes unusable when a new fingerprint or face is enrolled (by design: kSecAccessControlBiometryCurrentSet / setInvalidatedByBiometricEnrollment(true)). The bridge then reports ongeldig; pair again. The app only wipes its pairing after a 401 on the device token that is confirmed a second time, never on 5xx or a network error.

How do I confirm a login on the admin page?

Scan the QR code on https://admin.euzos.eu/beheer (link /b/<id>). The app shows "Log in to admin from <IP>" with the pairing code; Confirm signs the EUZOS-beheer-v1 message. See Admin page.

Details

Pairing (docs/PROFILE-PROTOCOL.md, "Pairing a phone"):

  1. POST /api/app/v2/sign-in → sign-in token eza_… (15 min, only in the server's memory) and a single-use pairing challenge (128 bits).
  2. The app creates the P-256 key in the chip (no fallback to the device passcode).
  3. It signs the pairing message (below) after biometrics.
  4. POST /api/app/v2/devices with name, platform (ios/android), public key (base64, SEC1 or SPKI DER), signature and optional attestation (stored, not yet checked).
  5. Response 201: device id (16 hex) and device token ezd_… (256 bits).

At most 10 active phones per account.

Confirming a PC login: the server rebuilds the login message from its own data and checks: device not revoked, session still waiting, profile exists, |time − now| ≤ 120 s, signature matches. The challenge belongs to this device and is renewed after every attempt (a signature is usable once). Without a prior GET session/<S>, confirm gives 409.

Pinning: the app pins the SHA-256 of the server key's SubjectPublicKeyInfo (with a backup pin); Caddy reuses its private key on certificate renewal (app/lib/pinning.dart, docs/PROFILESERVER-SETUP.md "Pinning").

Same network: same_network is true/false when PC and phone have the same address family (IPv4 per address, IPv6 per /64) and null otherwise; the app connects over IPv4 first so the comparison usually works.

Routes used by the app: the current app code calls the legacy /api/app/v1/… routes (app/lib/api.dart); the server serves the same handlers on /api/app/v2/… with English JSON field names. Both are listed below.

Reference

Functions and modules

Function / moduleRole
euzos_profileserver::app::sign_in, logoutpassword sign-in in the app (eza_ token)
euzos_profileserver::app::pair_challenge, pair, pair_messagepairing and the pairing message
euzos_profileserver::app::devices, revokelist and revoke phones
euzos_profileserver::app::session, confirm, reject, login_message, same_networkconfirming a PC login
euzos_profileserver::app::pc_sessions, pc_logoutlist and remotely log out PCs
euzos_profileserver::app::serverenvironment, domain, public key, flags
euzos_profileserver::admin_page::app::request, confirm, rejectconfirming an admin-page login
app/lib/chip.dart (ChannelChipKey)the Dart side of the chip bridge
app/lib/messages.dartbuilds the signed messages byte for byte
app/lib/pinning.dartpublic-key pinning
app/ios/Runner/ChipSleutel.swift, app/android/app/src/main/kotlin/eu/euzos/app/ChipSleutel.ktthe native chip key

API endpoints

All JSON, Authorization: Bearer <token>; errors {"error": …, "code": …}.

MethodPathLegacy Dutch pathTokenPurpose
GET/api/app/v2/server/api/app/v1/server—environment, server, key id, public key, web_fallback, registration_open
POST/api/app/v2/sign-in/api/app/v1/aanmelden—sign in → eza_ token + pairing challenge
POST/api/app/v2/logout/api/app/v1/afmeldeneza_end the sign-in
POST/api/app/v2/devices/challenge/api/app/v1/apparaten/uitdagingeza_new pairing challenge
POST/api/app/v2/devices/api/app/v1/apparateneza_pair → device id + ezd_ token
GET/api/app/v2/devices/api/app/v1/apparateneza_/ezd_list phones
DELETE/api/app/v2/devices/<id>/api/app/v1/apparaten/<id>eza_ (any own), ezd_ (itself)revoke a phone
GET/api/app/v2/session/<S>/api/app/v1/sessie/<S>ezd_fetch a PC login request (marks it "seen")
POST/api/app/v2/session/<S>/confirm/api/app/v1/sessie/<S>/bevestigezd_confirm with signature
POST/api/app/v2/session/<S>/reject/api/app/v1/sessie/<S>/weigerezd_reject
GET/api/app/v2/pc-sessions/api/app/v1/pcsessieseza_/ezd_logged-in PCs
POST/api/app/v2/pc-sessions/<ref>/logout/api/app/v1/pcsessies/<ref>/afmeldeneza_/ezd_log a PC out remotely
GET/api/app/v2/admin/<id>/api/app/v1/beheer/<id>ezd_fetch an admin-page login request
POST/api/app/v2/admin/<id>/confirm/api/app/v1/beheer/<id>/bevestigezd_confirm it
POST/api/app/v2/admin/<id>/reject/api/app/v1/beheer/<id>/weigerezd_reject it
GET/.well-known/apple-app-site-association——iOS universal links (/l/*, /v/*, /b/*)
GET/.well-known/assetlinks.json——Android app links

The legacy routes translate the JSON field names (for example device_token ↔ apparaat_token, challenge ↔ uitdaging, signature ↔ handtekening, pc_sessions ↔ pcsessies); the values are identical. Full list in API.

Config files

Key (server configuration)Meaning
app_duration (legacy app_duur)lifetime of an eza_ sign-in, default 900 s
confirm_duration (legacy bevestig_duur)how long a fetched session stays valid, default 120 s
apple_app_idTEAMID.eu.euzos.app for universal links; without it 404
android_package (legacy android_pakket), android_fingerprints (legacy android_vingerafdrukken)app links on Android

Protocol messages and formats

Every line ends in \n (also the last), UTF-8, no spaces around =. The keys stay Dutch (part of the signed format).

Pairing message (app::pair_message):

EUZOS-koppelen-v1
omgeving=<lokaal|sandbox|productie>
server=<domain>
gebruiker=<e-mail address, normalised>
uitdaging=<pairing challenge, 32 hex>
sleutel=<SHA-256 of the public key in SEC1 form, 64 hex>

Login message for a PC (app::login_message), ECDSA P-256 / SHA-256, DER or 64-byte r||s:

EUZOS-inloggen-v1
omgeving=<environment>
server=<domain>
sessie=<S>
pc=<SHA-256(pc secret) in hex, or ->
pcip=<IP of the PC>
profiel=<zakelijk|prive>
tijd=<Unix time of the phone>
uitdaging=<challenge U in hex>
apparaat=<device id>

Admin-page login message: EUZOS-beheer-v1 (see Admin page).

Pairing code shown on PC and phone: (first 4 bytes of SHA-256("EUZOS-koppelcode-v1\0" || S || SHA-256(G)) as a big-endian number) mod 1 000 000, six digits (crate::pairing_code).

Native channel eu.euzos.app/chip (MethodChannel; argument alias = the key name per environment). Method and argument names are Dutch wire values:

MethodArgumentsReturns
heeftSleutel—bool: is there a key
maakSleutel—public key (SEC1 or SPKI DER) of a new key pair
publiekeSleutel—the public key
ondertekenbericht (bytes), titel, redenDER signature after biometrics
verwijderSleutel—delete the key
biometrie—ok, geen_biometrie or geen_chip
toestelnaam—a device name for the list

Error codes of the bridge: geannuleerd (cancelled), ongeldig (key unusable: pair again), geen_sleutel, geen_biometrie, geen_chip, mislukt.

See also

  • docs/PROFILE-PROTOCOL.md, "Pairing a phone", "Logging in on a PC", "The API for the app (JSON)"
  • docs/PROFILESERVER-SETUP.md, "Pinning"
  • docs/ADMIN-PAGE.md, "The app"
  • PC login · Admin page · API