ADR-0044: A phone proves the operator with a device key, so it never holds the password
Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page
Status: Accepted
Date: 2026-10-11
Decided: 2026-10-11, by the maintainer, Wessel Verheij: the companion mints integration keys only as claims, proved by a signature from a key pair held in the phone’s hardware and usable only after face or fingerprint, registered once with the operator password and the platform’s attestation; StrongBox and the Secure Enclave are preferred, a key in the trusted environment alone is accepted and shown as such, and a key in software is refused; on iOS, enclave records that the genuine app placed the key in the Secure Enclave, App Attest vouching for the app rather than the key; only the official NightWorksIO companion’s identities and builds the operator adds as a setting may register a device key; attestations are verified against roots shipped in the build, with no request leaving the machine and no revocation list fetched; a password change revokes every device key; the link is handed over as a QR code, as copyable text and through the system share sheet.
Amends: ADR-0037, whose rule that a web mint asks for the password again gains a second proof.
Context
ADR-0037 lets an operator session mint an integration key over the web API, and has the mint ask for the password again, so a session left open cannot mint a key and a forged request, which cannot know the password, mints nothing.
On a phone that rule costs the operator their password at every mint, typed on a small keyboard, or it tempts an app into keeping the password to type it for them. A password kept on a phone is a master credential in a device that is carried everywhere and lost now and then. Phones already hold something better: a key pair whose private half never leaves the secure hardware and is used only after the owner’s face or fingerprint.
Decision
A device key proves the operator for one mint. The phone makes a P-256 key pair in its
hardware, usable only after face or fingerprint: StrongBox or the Secure Enclave where it
has one, its trusted environment otherwise. The operator registers its public half with the
core once, from an operator session, giving the password in that one request with the
platform’s attestation of where the key lives
(C10-R23). The core verifies
the attestation, records the backing, strongbox, enclave or tee, and shows it in the
listing; a key the attestation places in software, or one with no attestation that
verifies, is refused.
What the attestation proves differs by platform, and the record says no more than it
proves. Android’s key attestation vouches for the key itself and the hardware holding it.
Apple’s App Attest vouches for a genuine instance of the app and for a hash of client data it
signed, not for any key the app made: the phone sends SHA-256 of the device key’s public half
as that client data, so on iOS enclave means the genuine app placed the key in the Secure
Enclave, not that the hardware proved it.
Only an app the core trusts may register a device key. Every attestation names the app:
its package and the digest of its signing certificate on Android, its Team ID and bundle ID
on iOS. The build ships the official NightWorksIO companion’s identities. An operator who
builds the companion themselves adds their build’s identity as a setting, at the command
line or over the web API, journaled and alerted (C10-R29, C10-R30). An attestation from
any other app is refused with a code of its own, so a program that is not the companion
cannot plant a key it controls.
Verifying an attestation sends nothing out. The core checks it against the platform
roots shipped in its build and makes no request to do so (C10-R31). It does not fetch
Google’s revocation list for attestation keys. That is the accepted trade-off: a phone whose
attestation key Google has revoked since the build was made is not caught by that list. The
alternative is a call to Google at every registration, which would tell a third party when
and from where the operator registers a phone, and would make registering depend on a large
company’s service being up. The registration still needs the operator password, and the
operator sees every registration in an alert.
From then on:
- The phone asks the core for a challenge, a fresh value good for one request and one
minute, bound to that session and that device key (
C10-R24). - The operator shows their face or fingerprint, and the phone signs the challenge together
with the mint it asks for: the name, the scope, the purpose and that it is a claim
(
C10-R25). - The core verifies the signature against the public half it holds and accepts it in place
of the password, for that mint alone (
C10-R28).
A device key is not a credential. It admits no request; it proves the operator to a session
that is already theirs. Every registration and revoke is journaled and alerted as a key’s
mint is, the operator lists and revokes device keys by name, and changing the password
revokes them all (C10-R26, C10-R27).
The phone mints claims and nothing else. A claim carries no secret (C10-R18), so the companion never receives one, and it never calls the door that collects a key (N29).
The link goes where the operator sends it. The screen shows the link as a QR code, as
text that can be copied, and offers the system share sheet. It is concealed from
screenshots, screen recording and the app switcher, says when the claim lapses, and is
gone once left. A link shared on is worth one collection within ten minutes, which the
operator is alerted to (C10-R21).
Consequences
- C10 gains the device key’s rows and amends
C10-R2; the web API gains the device-key routes and a mint body that carries a signature in place of the password (getting in). - The companion gains N29, and N28’s menu gains App keys under Access.
- A wrong signature counts against the limit wrong passwords meet, so a stolen registration name is no better a guess than the password.
Alternatives considered
Keeping the password on the phone, behind face or fingerprint. No change to the core. Rejected: the master credential would live on the phone, and a copy of the app’s storage taken from an unlocked phone would carry it.
Asking for the password at every mint. ADR-0037 as it stands. Rejected for the phone: it is the friction that pushes an app toward keeping the password.
Face or fingerprint checked by the app alone. Rejected: the core would be trusting the app’s word, which a forged request could give.
Fetching Google’s attestation revocation list. It would catch a phone whose attestation key has leaked. Rejected: it sends a request to Google for every registration and ties registering to Google’s service, against keeping the operator’s business on their own machine; the password and the alert stand behind every registration instead.
Any app’s attestation. Simpler, and any program on the phone able to pass attestation could register a key it controls. Rejected for that reason.
StrongBox or the Secure Enclave only. Rejected: many Android phones in use hold keys in the trusted environment and have no StrongBox, and they would be left with the password. A key there is still outside the operating system’s reach, and the listing says where each key lives so the operator can judge it.
Revisit when
- A browser surface wants the same proof. WebAuthn gives a browser the same kind of key, and the registration and challenge here are the shape it would take.