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:

is no record anywhere that a second copy exists.

device-to-device transfer, or malware with the app's own permissions.

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

AttackerAgainst a conventional authenticatorAgainst this
Reads the app's storageHolds the factor foreverFinds a public key and an id. Nothing.
Clones the handset's filesystemWorking cloneNothing: the private key is not in the filesystem
Restores a cloud backup to a new phoneWorking cloneNothing: the key does not leave the device
Photographs the QR code before the user scans itHolds the factor forever, silentlyCan enroll their own handset once, and the real user's scan then fails loudly
Photographs the QR code after the user scans itHolds the factorNothing: single-use
Shoulder-surfs a code being typedReplays it for up to 90 secondsNothing: single-use per time step
Dumps the databaseHolds every secretHolds 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.

  1. The tenant opted in. tenants.totp_enabled, default false.
  2. 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.

  1. 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.

  1. Completion is single-use. A conditional UPDATE ... WHERE state = 'pending'. Two

handsets racing the same QR code produce one authenticator and one loser.

  1. 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.

  1. 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.

  1. The service's half is destroyed. core_private_key_enc is set to null at

completion. A dump taken afterwards cannot re-derive an authenticator from the

enrollment.

  1. 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.

  1. 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:

payloadversionmodulespx/module at 128
full pairing URI, ~310 ch1369×691.66unscannable
ettrust://p/<8> lowercase225×253.88marginal
ETTRUST://P/<8> uppercase, no host121×214.41comfortable
ETTRUST://P/<8>/HOST — what is shipped2–325–293.5–3.9usable

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:

lowercase letter forces byte mode and pushes the same payload to version 2.

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:

  1. Three minutes. Not ten. The person is standing at the machine with their phone out;

longer is a code left on an unattended screen.

  1. 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.

  1. Five attempts per enrollment, debited before anything is returned.
  2. 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 codeYes
Generate correct codes on iOS and Android, offlineYes
Verify a code and sign an allow_mfa decisionYes, through POST /v1/auth/totp/verify
Have a Windows logon screen ask for that codeNo. 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.