Device-bound TOTP: what it is, what it is worth, and what it is not
This document is referenced from packages/core/src/totp.ts,
packages/core/src/authenticators.ts, packages/db/src/schema.ts,
apps/device-api/src/routes/totp.ts, apps/dashboard/src/routes/enrollment.ts and the
mobile repository. If you are about to change any of them, read it first.
It is the sibling of pairing-security.md, and it is written the
same way: the parts that are weaker than they sound are named rather than implied.
The one-sentence version
A handset generates a P-256 key pair inside its secure element, agrees a secret with this
service over ECDH, and displays RFC 6238 codes derived from that agreement — so the TOTP
secret is never transmitted, never stored on the handset, and cannot be copied off it.
Why not the ordinary thing
A conventional authenticator enrollment puts the TOTP secret in the QR code. Three
consequences follow, and all three are real rather than theoretical:
- Whoever photographs the screen holds the factor. Permanently, and invisibly: there
is no record anywhere that a second copy exists.
- Whoever reads the app's storage holds the factor. A rooted handset, a backup, a
device-to-device transfer, or malware with the app's own permissions.
- It is indistinguishable from a clone. Two handsets with the same secret produce the
same codes, and no verifier can tell them apart.
The scheme here removes the second and third of those. It does not remove the first, and
the section on the honest limits says so.
The protocol
Writing it out once, because the comments in the code assume you have seen it.
ISSUE (administrator, dashboard)
core_priv, core_pub <- fresh P-256 pair, for this enrollment only
salt <- 32 random bytes
secret <- 32 random bytes, one-time bearer
QR encodes: ettrust://pair/v1?e=<id>&s=<secret>&k=<core_pub>&n=<salt>&a=<origin>&...
stored: sha256(secret), enc(core_priv), core_pub, salt, expires_at = now + 10 min
PAIR (handset)
dev_priv, dev_pub <- P-256 pair generated INSIDE the Secure Enclave / StrongBox,
non-exportable, agreement-only
Z <- ECDH(dev_priv, core_pub) 32 bytes, the bare x
K <- HKDF-SHA256(ikm=Z, salt=salt,
info="endpointtrust/totp/v1" 0x00 <id> 0x00 dev_pub, L=32)
proof <- HMAC-SHA256(K, "enroll" 0x00 <id> 0x00 dev_pub)
POST {id, secret, dev_pub, proof} -> dashboard
COMPLETE (service)
Z' <- ECDH(core_priv, dev_pub) same 32 bytes
K' <- same HKDF
check proof against K'; on success store enc(K'), DESTROY core_priv
CODE (handset, offline, for every code)
Z <- ECDH(dev_priv, core_pub) in the secure element, biometric-gated
K <- same HKDF
display HOTP-SHA256(K, floor(now/30)) mod 10^6 then discard K
VERIFY (service, Windows sign-in path)
check submitted code against K for steps [t-1, t, t+1]
refuse if step <= last_accepted_step
INSERT (authenticator_id, step) into totp_consumptions -- primary key is the control
The handset re-derives for every code and stores nothing. That is the single line on
which the whole scheme rests. If a future change caches K in application storage
"for performance", this becomes an ordinary authenticator with an extractable secret and
the hardware binding stops meaning anything. One ECDH in a secure element is a few
milliseconds; there is nothing to optimise.
What it is worth
| Attacker | Against a conventional authenticator | Against this |
|---|---|---|
| Reads the app's storage | Holds the factor forever | Finds a public key and an id. Nothing. |
| Clones the handset's filesystem | Working clone | Nothing: the private key is not in the filesystem |
| Restores a cloud backup to a new phone | Working clone | Nothing: the key does not leave the device |
| Photographs the QR code before the user scans it | Holds the factor forever, silently | Can enroll their own handset once, and the real user's scan then fails loudly |
| Photographs the QR code after the user scans it | Holds the factor | Nothing: single-use |
| Shoulder-surfs a code being typed | Replays it for up to 90 seconds | Nothing: single-use per time step |
| Dumps the database | Holds every secret | Holds every secret — see below |
The honest limits
This service still holds a symmetric secret. A six-digit code typed at a logon screen
can only be checked against one, so authenticators.secret_enc is a value that produces
codes. It is AES-256-GCM under the master key, which is not in the database — the same
protection a tenant's signing key gets — but a database dump plus the master key is a
compromise of every authenticator in it. That is a property of TOTP and not of this
scheme. Nothing short of a challenge-response factor removes it, and a challenge-response
factor cannot be six digits a person reads off a screen.
It is not phishing-resistant. A user who can be talked into reading a code aloud can
be talked into reading this one. The code is not bound to a channel, an origin or a
session. This is better than SMS — there is no SS7, no SIM swap, no gateway, and no cost
per message — and it is not WebAuthn.
hardware_backed is a claim, not a measurement. The column is named
hardware_backed_claim for that reason. Proving it needs App Attest on iOS or Play
Integrity on Android, neither of which this product does. The handset asserts it, the
administrator sees it, and nothing gates on it. A fleet showing software-backed keys
is worth investigating; a single one is not evidence of anything.
The QR code is a bearer token for ten minutes. Whoever holds it can bind their key.
What stops that being silent is that it is single-use: the legitimate user's scan then
fails, visibly, with a message telling them to ask for a new code. Issue it on a screen the
user is standing at, not over email or chat.
Android below 12 gets no hardware key at all. PURPOSE_AGREE_KEY arrived in API 31.
Below that the module reports itself unsupported and the app falls back to a software key
in the Keystore-encrypted store — and says so on the account, in those words.
The bounds
Each is enforced in code and has a test in tests/totp.test.ts.
- The tenant opted in.
tenants.totp_enabled, default false. - One live authenticator per user.
authenticators_one_active_per_user, a partial
unique index. Mirrors phone_numbers_one_active_per_user, for the same reason: if a
second can be added without taking the first away, a successful attack is invisible to
the victim. Re-enrolling is an explicit revocation first.
- One live QR code per user.
authenticator_enrollments_one_live_per_user. Issuing a
new one cancels the old in the same transaction, so an old photograph stops working the
moment an administrator reissues.
- Completion is single-use. A conditional
UPDATE ... WHERE state = 'pending'. Two
handsets racing the same QR code produce one authenticator and one loser.
- The agreement is proven. The handset's MAC is checked before anything is stored.
Without it, a handset that derived wrongly would enroll an authenticator that can never
produce a correct code — a lockout found at a logon screen rather than at the pairing
screen.
- Codes are single-use.
totp_consumptions, primary key `(authenticator_id,
time_step)`, inserted in the same transaction as the acceptance. Plus
last_accepted_step, which refuses a step at or below the highest ever accepted — the
consumption table alone would let the step before the last accepted one through,
because that row does not exist.
- The service's half is destroyed.
core_private_key_encis set to null at
completion. A dump taken afterwards cannot re-derive an authenticator from the
enrollment.
- Guesses are the attempt's own. The TOTP path increments the same
attempt_counters.verifications_used the SMS path does, so the two factors cannot be
combined into more guesses than either allows alone.
- Off-curve points are refused at import. An invalid-curve agreement leaks private key
bits. importDevicePublicKey is the only place a point from outside this service
enters, which is why it is the only place that check has to exist.
Pairing at the logon screen
The console can issue a pairing code, but somebody who already signs in every morning
should not have to ask an administrator to upgrade from SMS to the app. So the Windows
tile can paint one itself — under one rule, which is the whole of the security argument:
A pairing code is minted only on an attempt whose verification already succeeded.
Why that rule, and why nothing weaker
A credential provider never checks a password. It packs one into a
KERB_INTERACTIVE_UNLOCK_LOGON blob and hands it to LSA, or it does not. So at the
moment a tile could paint a QR code, *nothing has been proven about the person standing
there*.
A tile that offered a pairing code as soon as an un-paired username was typed would mean
that walking up to any unattended logon screen, typing a colleague's name, and scanning
with your own phone binds your handset as their second factor. That is weaker
than the agent_pairing mode pairing-security.md already calls the
most consequential setting in the product — because it would not even need the password.
Requiring the existing factor first turns the operation from an enrolment into an
upgrade: the person proved the factor they have, and is binding a better one to the same
account in the same session. Someone with only a username reaches nothing. Someone with
the password but not the phone reaches nothing, because they never reach a verified
attempt.
The consequence is that this path cannot onboard anybody who has no second factor yet,
and that is deliberate. Those people are onboarded from the console, where an
administrator's session is the authorisation.
Why the tile shows eight characters instead of the real pairing URI
A credential provider's tile image renders at roughly 128×128 pixels. The pairing URI is
about 310 characters, which is a **version 13 QR code: 69 modules across, 1.66 pixels per
module** inside that tile. No phone camera decodes that off a monitor. This is a
measurement, not a preference:
| payload | version | modules | px/module at 128 | |
|---|---|---|---|---|
| full pairing URI, ~310 ch | 13 | 69×69 | 1.66 | unscannable |
ettrust://p/<8> lowercase | 2 | 25×25 | 3.88 | marginal |
ETTRUST://P/<8> uppercase, no host | 1 | 21×21 | 4.41 | comfortable |
ETTRUST://P/<8>/HOST — what is shipped | 2–3 | 25–29 | 3.5–3.9 | usable |
The host costs a version, and it is worth it: a handset pairing for the first time has
no idea which server to redeem against. There is no directory to ask and nothing in the
app yet to remember, so a code only a previously-paired phone could use would not do the
job this flow exists for. The scheme is omitted — it is always https, restored in exactly
one place, so no pairing code can route a redemption over plaintext — and the port only
appears when it is not 443.
Eight bare characters still work when the app already holds an account, which is the
typed-in path for a camera that will not focus.
Two details carry that result and are easy to undo by accident:
- Uppercase only. QR alphanumeric mode covers
0-9 A-Zand a little punctuation. One
lowercase letter forces byte mode and pushes the same payload to version 2.
- No ambiguous letters. The code is printed as text beside the QR, because a camera
that will not focus still leaves somebody able to type it. I, L, O and U are
out — Crockford's alphabet — and normaliseShortCode maps them onto the digits they
are misread as, so nobody is told a correctly-read code is wrong.
The tile therefore shows a reference, and the handset exchanges it for the key material
over HTTPS.
Measure this on a real machine before trusting it. 128×128 is Microsoft's
documented guidance for CPFT_TILE_IMAGE; what LogonUI actually allots can differ by
Windows version and DPI. The design degrades safely either way — the printed characters
work when the QR does not — but the version numbers above are worth re-deriving against
a real tile rather than a specification.
The flow
Windows Core Handset
password + SMS code ───────> verify, sign allow_mfa
POST /v1/totp-enrollments ──> gate: is there a verification-stage
{attempt_id, nonce} allow_mfa for THIS attempt?
<─────── short_code + ETTRUST://P/<code>/<HOST>
paint QR + the 8 characters
<──── scan or type
POST /api/v1/totp-enrollments/redeem
<─────────────────────── {short_code}
──────> core_public_key, salt, enrollment_id
ECDH + HKDF in the
secure element
POST /api/v1/totp-enrollments/complete
<─────────────────────── {device_public_key, proof}
From the redemption onward it is the ordinary derivation in this document; the only
difference is that the material arrived over HTTPS instead of inside the QR.
What holds the 40-bit code up
Eight symbols of Crockford base32 is 2^40, which is not enough on its own and is not
asked to be. Four things carry it, and removing any one matters:
- Three minutes. Not ten. The person is standing at the machine with their phone out;
longer is a code left on an unattended screen.
- Single use. A conditional
UPDATE ... WHERE redeemed_at IS NULL, so two handsets
racing a code photographed off a screen produce one redemption and one loser — and the
loser is told the code was already used, not that it was invalid, so the real person
knows to ask for another.
- Five attempts per enrollment, debited before anything is returned.
- Ten redemptions per source address per hour. The per-enrollment counter cannot see
somebody guessing codes that belong to no enrollment, which is exactly what walking a
40-bit space looks like, so that ceiling has to live on the route.
What is recorded
authenticator_enrollments.origin is logon_screen, permanently, and
authorising_attempt_id names the sign-in that stood behind it. The two origins have
different things authorising them — an administrator's session, or a person's own
verified sign-in — and an auditor asking "who agreed to this handset" needs the answer
years later.
Why the enrollment route is not in the device API
The device API is on the Windows sign-in path: if it is unavailable, people's sign-ins
degrade to whatever their grace policy allows. That is the reason the two applications
deploy separately at all.
Completing an enrollment is onboarding. It happens once per handset, it is driven by a
phone on the public internet, and nothing about a sign-in waits for it. Putting that
traffic — and the attack surface of a route whose only authorisation is a secret printed on
a screen — onto the sign-in path would spend the product's central architectural decision
on a convenience. So POST /api/v1/totp-enrollments/complete is in the dashboard
application, and verification, which is a sign-in, is in the device API.
The part that is not finished, and will break sign-ins if you turn it on
**tenants.totp_decision_enabled must stay false until the Windows agent understands
require_totp.**
api/openapi-mvp.json in the endpoint repository is normative and describes four decision
outcomes. A deployed credential provider that has never heard of a fifth will reject the
response, and every sign-in for that user fails. The enum value exists in this service, the
verification route is implemented and tested, and no shipped agent emits or accepts it.
So the feature as it stands today is:
| Enroll a handset, end to end, with a real QR code | Yes |
| Generate correct codes on iOS and Android, offline | Yes |
Verify a code and sign an allow_mfa decision | Yes, through POST /v1/auth/totp/verify |
| Have a Windows logon screen ask for that code | No. Needs the endpoint repository. |
The remaining work is in the endpoint repository: a require_totp branch in the attempt
state machine, a tile that prompts for an authenticator code rather than sending an SMS,
the fifth operation in the OpenAPI document, and a capability flag at activation so this
service only ever answers require_totp to an agent that advertised it. Until then this is
a complete and tested service-side factor with no caller.
If you are deciding for a real deployment
Turn on totp_enabled and enroll a pilot group while sign-ins still use SMS. Watch
authenticators.last_used_at and the hardware_backed_claim distribution. Only consider
totp_decision_enabled once there is an agent build that names require_totp in its
capabilities, and roll it per tenant rather than globally.