Agent pairing: what it is, what it gives up, and what still holds
This document is referenced from eleven places in the code. If you are about to change
anything in packages/core/src/pairing.ts, apps/device-api/src/routes/pairing.ts, or the
enrollment_mode setting, read it first.
The problem it solves, and the one it creates
The Windows endpoint was built against a contract with a deliberate absence: `POST
/v1/auth/sms` has no destination field. A computer cannot say where a code should go.
EndpointTrust reads the destination from the organisation's own records, and there is exactly
one live destination per user, enforced by a partial unique index
(phone_numbers_one_active_per_user). That absence is the control. A computer with a
compromised agent, a stolen device credential, or a malicious administrator at the keyboard
cannot redirect anybody's second factor, because no request it can make has a field for
one.
The cost of that design is onboarding. Somebody has to type a thousand phone numbers, and
the person who knows a phone number is usually the person holding the phone.
Agent pairing is the relaxation: a computer may propose a number for an account that
has none. That is a real reduction in security, and this document exists so that nobody
adopts it without knowing exactly which property they gave up.
The three modes
Per tenant, on tenants.enrollment_mode. The default is the strongest.
admin_only (default)
Pairing is refused at the HTTP layer and again in the domain. The endpoint can never name a
destination. This is the mode the published contract describes, and the one to use unless
onboarding cost makes it impossible.
agent_pairing_with_approval
A computer may propose a number. A code is sent to the proposed number and must be typed
back. Then an administrator has to agree, in the dashboard, with their identity recorded
against the decision (pairings.decided_by_admin_id).
What an attacker gets: the ability to put a request in front of a human, with their own
phone number on it, claiming to be an account that has no destination yet. What stops them
is a person looking at the Pairing requests page and asking "is this the number I would
have registered for this person?"
agent_pairing
The same, without the human step. A correct code is enough.
What an attacker gets: **if they know a domain password for an account that has no
destination registered, and they can reach a computer in the organisation, they can bind
their own phone to that account.** From then on they hold both factors.
That is the honest statement. The mitigation is that the window is small — it only works on
accounts that have not been onboarded — and that every such number is labelled forever as
having come from a computer. It is not a mitigation that makes the mode equivalent to the
other two.
The seven bounds
Every one of these is enforced in code, and each has a test in tests/pairing.test.ts.
- Mode. Refused unless the tenant chose a pairing mode. Checked in
startPairing and again in the route, so a route added later cannot forget it.
- Only an account with no verified destination. An account that already has one cannot
be paired at all, in any mode. This is what makes pairing an onboarding path rather
than a redirection path: an existing second factor can never be moved by a computer.
- The proposal must be proven. A number is not stored as a destination until a code
sent to that number comes back. Until then it lives only on the pairings row.
phone_numbers.verified_at stays null.
- The computer must already be activated. Pairing uses the per-device bearer
credential, so a machine has to have completed activation with the tenant's enrollment
credential. An unknown machine cannot propose anything.
- Rate limits, at the tenant's own number. Per device per hour
(tenants.pairing_hourly_limit, default 10) and per tenant per hour. Pairing is the one
path where an endpoint influences where a code goes, so the ceiling is a setting rather
than an implicit constant.
- Optional human approval.
agent_pairing_with_approval. The response carries
requires_admin_approval so the endpoint can show the right message, and
verifyPairing returns awaiting_approval — which is a success for the user and not
an enrollment.
- The source is permanent.
phone_numbers.source = 'pairing'and
end_users.created_by = 'pairing', for the life of the account, visible on the user's
page and in the pairing history. An auditor can always tell which of an organisation's
second factors the organisation chose and which a computer proposed.
What is unchanged in every mode
POST /v1/auth/smsstill has no destination field. Pairing is a separate operation
on a separate route, which is why enabling it does not widen the sign-in path at all.
- A sign-in still reads the destination from EndpointTrust's records.
- There is still exactly one live destination per user.
- A code is still single-use, still verified by this service, and still bounded by the
attempt counters.
The relaxation is strictly about how a number comes to exist. It is not about how a code is
delivered or checked.
Why pairing is not part of the published contract
api/openapi-mvp.json in the endpoint repository describes five operations and no pairing.
That contract is normative for those five. Pairing is an extension, implemented as two
additional routes (POST /v1/user-pairings and POST /v1/user-pairings/verify), and it is
labelled as an extension everywhere it appears: in packages/contract/src/wire.ts, in the
route file, and here. If the endpoint repository and this service ever disagree about the
five, the endpoint repository is right and this service has the defect.
If you are deciding for a real deployment
Use admin_only if you can. Use agent_pairing_with_approval if onboarding cost makes
that impossible and you have somebody who will actually look at the queue. Use
agent_pairing only if you have accepted, in writing, that a domain password plus physical
access to a computer is enough to enroll a phone against an un-onboarded account.