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.

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

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

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

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

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

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

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

on a separate route, which is why enabling it does not widen the sign-in path at all.

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.