M1-38: Device approval and pairing¶
Status: ready | Depends on: M1-37 | Milestone: M1
Goal¶
After this ticket a new device that logged in without an enrollment code waits in state pending:
it has tokens but no access beyond reading itself, and it shows a verification code. The person
from one of their active devices, or an owner, approves it with step-up by typing that code; a
wrong code revokes the waiting device. As a second way, an active device pairs a new device by
entering the pairing code the new device shows. Waiting devices and unfinished pairings expire.
This closes the gap "someone else's device gets approved by a blind tap" (T-ID-05) and gives local
accounts without passkeys a way to add devices.
Read first¶
docs/spec/08-identity.md: "Local accounts", "Registration", "Approval", "Device pairing", "Management" (section 5), "Two keys per native device", "DPoP", "Login protection", "Approval of a new device" (flow 14.2), "Tests" (ID-T12, ID-T13), "Decisions of this chapter" (R-ID-08), "Configuration values of this chapter".docs/spec/03-data-model.md: tablesdevices,device_keys,device_pairings,auth_sessions,refresh_token_families,audit_events; principles 9 and 13.docs/spec/05-actions.md: "Decision" (pending device rule), actionsauth.login.begin,auth.login.complete,device.list,device.approve,self.read,auth.token.refresh,auth.logout; R-AZ-16.docs/spec/07-permissions.md: "Decisions and reasons" (device_pending,device_inactive,not_self), "Flow".docs/spec/06-api.md: "Login flows" point 4, "Errors", "Idempotency", "Endpoints" (Auth, Devices), "Other points" K13 and K16.docs/spec/02-architecture.md: "Layers within a module", "Transactions and Unit of Work", "Background jobs (River)" (job catalogue, rules for jobs).docs/PLAN.md: section 7 W5 and row M1-38.- ADR 0019, ADR 0020.
docs/api/openapi.yaml:approveDevice,getDevice,listMyDevices,listUserDevices,beginPairing,approvePairing,completePairing; schemasDevice,DeviceRegistration,DeviceKeyRegistration,PairingBeginRequest,PairingStarted,PairingApprove,PairingCompleteRequest,PairingStatus,TokenResponse; responseGone.docs/security/threat-model.md: T-ID-03, T-ID-05 and the device row in the STRIDE table.- Code:
internal/identity(M1-33 devices and DPoP, M1-34 sessions, M1-35 step-up, M1-37CompleteLoginand enrollment),internal/authz(M1-28 pending-device rule),internal/platform/jobs(M1-14).
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: every behavior change starts with a failing test (red), then code (green), then
cleanup. No en dash or em dash in any text. White label: examples use Alex, Sam, Robin and
example.com. Configuration values only through named keys listed indocs/operations/configuration.md. - A new device is
pendingunless it redeems an enrollment code or is confirmed by pairing. A pending device has tokens but every action exceptself.read,device.listfor itself,auth.token.refreshandauth.logoutends with403 device_pending(08 "Approval", 05 "Decision", R-AZ-16). Afterauth.device_pending_ttlwithout approval the device is revoked. - Approval (
device.approve, step-up) by the person from one of their active devices or by an owner, by typing the verification code ofauth.device_verification_code_digitsdigits that the pending device shows; a wrong code rejects (revokes) the pending device (08 "Approval", R-ID-08, ID-T13). - Pairing: the new device registers its public keys without login and with a DPoP proof, shows a
pairing code of
auth.pairing_code_charsCrockford characters validauth.pairing_code_ttl; an active device of the same person enters it with step-up; the new device collects its tokens with a DPoP proof of the registered key; one failed attempt invalidates the request; rate limits per code and per IP (08 "Device pairing", "Login protection").
Scope¶
The specification is in place (planning, spec first; W5 resolved there): approveDevice with the
body DeviceApprove, Device.verification_code and the descriptions of approveDevice,
approvePairing and completePairing in openapi.yaml; 08 "5.2" (derivation of the verification
code, where it is shown), "5.3" (device created at approval, collection window, expiry, wrong code
rule), "5.4" (revoke reasons), "13" and ID-T12; 03 devices.revoke_reason with its CHECK; 05
device.expire; the STRIDE row of the threat model; PLAN row M1-38. Change them only where the
implementation proves them wrong (spec first, make api-lint api-checks docs green).
docs/DECISIONS.md: rulings listed in Design notes.
Production code under services/server/:
migrations/<next sequence>_identity_device_pairings.sql: tabledevice_pairingsexactly as in 03 (checks, FKs, partial unique index onpairing_code_hash WHERE status = 'requested', index(expires_at)).internal/identity/adapters/postgres/queries/device_pairings.sql, queries for approval and expiry; regenerated sqlc code.internal/identity/domain/verification.go:DeriveVerificationCode,VerificationCodeEqual.internal/identity/domain/pairing.go: pairing code generation and normalization, state machinerequested -> approved -> completed,requested -> rejected | expired,approved -> expired(all other transitions are errors), JSON typeDeviceRegistrationV1fordevice_registration.internal/identity/app/approval.go: use caseApproveDevice; internal functionrevokeUnconfirmedDevice(ctx, deviceID, reason).internal/identity/app/pairing.go: use casesBeginPairing,ApprovePairing,CompletePairing.internal/identity/app/expiry.go:ExpirePendingDevices,ExpirePairingsfor the jobidentity.auth_cleanup(created by M1-35), audited as system actiondevice.expire.- Authentication: the actor builder from M1-34 treats a pending device older than
auth.device_pending_ttlas revoked (401 session_revoked, reasondevice_inactive). - Response mapping of
DeviceandTokenResponse.device: addverification_codeonly for the pending device itself. internal/identity/adapters/httpapi: handlersApproveDevice,BeginPairing,ApprovePairing,CompletePairing.- Both expiry functions added to the job
identity.auth_cleanup(Design notes). services/server/api/operations.yaml: enableapproveDevice,beginPairing,approvePairing,completePairing; regenerateapi/restandapi/routes.internal/identity/README.md,services/server/CHANGELOG.md.
Public interfaces (Go, names fixed):
// internal/identity/domain
// DeriveVerificationCode returns the decimal code the pending device shows (08 "Approval").
func DeriveVerificationCode(pepper []byte, deviceID ids.ID, dpopJKT string, digits int) string
func VerificationCodeEqual(expected, typed string) bool // constant time, ignores spaces
// internal/identity/app
func (s *Service) ApproveDevice(ctx context.Context, deviceID ids.ID, verificationCode string) (DeviceView, error)
func (s *Service) BeginPairing(ctx context.Context, reg DeviceRegistration, proofJKT string, clientIP netip.Addr) (PairingStarted, error)
func (s *Service) ApprovePairing(ctx context.Context, pairingCode string) (DeviceView, error)
func (s *Service) CompletePairing(ctx context.Context, pairingID ids.ID, proofJKT string, clientIP netip.Addr) (PairingOutcome, error) // tokens, or status requested
Out of scope¶
- Login flows that create pending devices: password (M1-39), passkey (M1-40), OIDC (M1-41),
trusted header (M1-43). Tests here create pending devices through
CompleteLoginwithTrustUnconfirmed(M1-37). - Revoking active devices with the full ordered sequence and push registrations (
device.revoke, M1-45); the gateway close code4016for pending devices (M1-64, M1-65). - Notifying the person that a device waits (push, M3), key rotation of a device (08 "Management",
none: 08 "5.4" has no key change at an existing device), the console approval queue (M6),
automatic revocation of active devices after
auth.device_inactive_revoke_after(M1-45, which owns the full revocation sequence).
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on branch
feat/M1-38-identity-device-approval. - Conventional Commits with module scope and DCO sign-off (
git commit -s). - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside Scope, except
docs/DECISIONS.md, thedocs/PLAN.mdrow M1-38 and CHANGELOG entries. - Follow the host rules supplied by the launcher. Tests bind only
127.0.0.1and port 0; PostgreSQL only throughpgtest. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools.
- If an element of the core contract (00, 03, 04, 05) named here is missing or different, stop with
Verdict: BLOCKED(P-11).
Design notes¶
Pending devices and the verification code:
- The rule "tokens, but
403 device_pending" is normative in 05 and 07 and already enforced byauthorize(M1-28); ID-T12, the threat model and the plan row say the same. Test the 05 behavior. - The code is derived, not stored, so no column is needed:
HMAC-SHA-256(key = pepper, msg = "device-verification/v1" || 0x00 || device_id (16 bytes) || 0x00 || dpop_jkt (ASCII)); take the first 8 bytes as big-endianuint64, reduce modulo10^digits, left-pad with zeros toauth.device_verification_code_digits. The pepper comes fromauth.token_pepper_ref. The code depends on the device and its DPoP key, so a second login of an attacker shows a different code. - Shown only to the pending device itself:
TokenResponse.device.verification_codeafter a login that leaves the device pending, andgetDeviceorlistMyDeviceswhen the caller's session device is that device and it is still pending. Owners and other devices never see it. ApproveDeviceorder: load the device (invisible:404),authorize(actor, device.approve, device)(owner or self; a pending caller gets403 device_pending; step-up required); stateactive:200with the device and no change (06 2 "No effect"); staterevoked:409 invalid_state_transition; pending older thanauth.device_pending_ttl: revoke it with reasonpending_expiredand answer409 invalid_state_transition.- Correct code: one unit of work with the permission-change option for the device's person:
state = 'active',approved_by= actor,approved_at; auditdevice.approveallow with details{device_id, result: "approved"};200with the device. - Wrong code: same unit of work shape:
revokeUnconfirmedDevice(deviceID, "verification_failed"); auditdevice.approveallow with details{device_id, result: "rejected_wrong_code"};200withstate: revoked. Reason for200: the request had a committed effect; an error status would claim nothing changed. revokeUnconfirmedDevice:devices.state = 'revoked',revoked_at,revoked_by(actor or NULL for system),revoke_reason;device_keys.revoked_atfor all keys; every session of the devicerevoked_atwithrevoke_reason = 'device_revoked'; their refresh familiesrevoke_reason = 'session_revoked'; onesession.revokedevent per session; in-tx eventDeviceRevoked. Use the session revocation primitive from M1-34. This subset is complete for unconfirmed devices because they never had push registrations or gateway connections; M1-45 owns revocation of active devices.- Device revoke reasons used here (CHECK of
devices.revoke_reasonin 03, 08 "5.4"):verification_failed,pending_expired,pairing_expired. Expiry by the job is audited as the system actiondevice.expirewith actor NULL.
Pairing:
- Code:
auth.pairing_code_charsCrockford Base32 characters fromcrypto/rand(default 8 characters, 40 bits); display in two groups joined by-; normalization as for enrollment codes (M1-37) without prefix. Stored asHMAC-SHA-256(pepper, "pairing-code/v1" || 0x00 || canonical). On a unique-index collision with another open pairing, generate a new code (bounded retry, named constant). BeginPairing(beginPairing, actionauth.login.begin,rl.public_authper IP): DPoP login proof withoutath; proofjktmust equal the thumbprint ofdevice.public_jwk(else401 invalid_dpop_proof); validate the registration with the M1-33 rules (UUIDv7device_idnot in use, platform,alginES256/EdDSA, public JWK without private members,step_up_keydifferent from the DPoP key). Insertdevice_pairingswithstatus = 'requested',device_registrationasDeviceRegistrationV1({"v":1, device_id, name, platform, app_version, dpop:{jwk, alg}, step_up?:{jwk, alg}, attestation?}),expires_at = now + auth.pairing_code_ttl.201withpairing_id,pairing_code,expires_at. The code is never logged.ApprovePairing(approvePairing,/me/devices/pairings, actiondevice.approve, step-up,rl.admin):authorizewith a device resource owned by the caller (the device is created for the caller). Malformed code:400 validation_failed, step-up untouched. Well-formed code that matches no pairing withstatus = 'requested'andexpires_at > now: in the same unit of work set the calling session'sstep_up_atandstep_up_methodto NULL, auditdevice.approvedeny (deny_reasonno_grant, details{result: "pairing_code_unknown"}), answer404 not_found. Reason: 08 asks that one failed attempt invalidates the request; the approving request is bound to the step-up, so every guess costs a fresh biometric confirmation, and 40 bits withinauth.pairing_code_ttlcannot be searched that way.- Match: in one unit of work with the permission-change option for the caller: create the device
for the caller (
state = 'active',approved_by= caller,approved_at) and its keys from the registration; set pairingstatus = 'approved',user_id,approved_by_device_id(the caller's device),device_id, andexpires_at = now + auth.pairing_code_ttl(a fresh window to collect); audit allow with details{pairing_id, device_id};201with the device. CompletePairing(completePairing, actionauth.login.complete,rl.public_authper IP): DPoP login proof required. Unknownpairing_id:410 gone. Proofjktnot equal to the registereddpopkey: setstatus = 'rejected'and answer401 invalid_dpop_proof. Statusrequestedand not expired:202withPairingStatus. Statusapprovedand not expired:CompleteLogin(M1-37) withTrustConfirmed,AuthMethod = "device_pairing", the created device (known device, same key, stays active), thenstatus = 'completed',completed_at;201withTokenResponse. Expired, rejected or completed:410 gone.- Expiry:
requestedpastexpires_atbecomesexpired;approvedpastexpires_atbecomesexpiredand its device is revoked withrevokeUnconfirmedDevice(deviceID, "pairing_expired")(it never got a session). Applied lazily on access and by the cleanup job.
Cleanup job: identity.auth_cleanup (02 "12.3", interval auth.cleanup_interval) exists since
M1-35; add ExpirePendingDevices and ExpirePairings to it.
Correctness never depends on the job: authentication treats an expired pending
device as revoked, and pairing endpoints check expires_at themselves (same principle as AZ-P10).
Job arguments carry no data; each run is idempotent.
Configuration used: auth.device_pending_ttl, auth.device_verification_code_digits,
auth.pairing_code_ttl, auth.pairing_code_chars, auth.token_pepper_ref,
auth.step_up_max_age, auth.cleanup_interval, ratelimit.rl.public_auth.*,
ratelimit.rl.admin.*.
Rulings to record in docs/DECISIONS.md (link 08 "5.2" to "5.4"): 200 with state: revoked on
a wrong code; pairing code collision handling.
Tests (write them first)¶
Integration tests use pgtest, the controllable clock, CompleteLogin with TrustUnconfirmed
to create pending devices, and the step-up helper from M1-35.
Unit:
TestDeriveVerificationCode: fixed vector (pepper, device ID, jkt) gives a fixed string; length equals the digits value; leading zeros kept; a differentjktor device ID gives a different code for the vector set.TestVerificationCodeEqual: spaces ignored, different lengths false.TestPairingCodeFormatAndNormalize.TestPairingStateMachine: every allowed and every forbidden transition.
Integration:
TestIDT12_PendingDeviceHasTokensButNoAccess: with the pending device's tokensGET /api/v1/me200;GET /api/v1/devices/{own}200withverification_code;GET /api/v1/usersandGET /api/v1/devices/{other device of the person}403 device_pending; refresh200; logout204.TestVerificationCodeOnlyForPendingDeviceItself: owner and another active device of the person read the device withoutverification_code.TestApproveDeviceBySelfFromActiveDeviceandTestApproveDeviceByOwner: correct code, deviceactive, audit entry, then the former pending device readsGET /api/v1/users200.TestIDT13_WrongCodeRevokesDevice: devicerevokedwithverification_failed, keys revoked, sessions revoked withdevice_revoked, families revoked, onesession.revokedevent per session, response200withstate: revoked; the pending device's next request401.TestApproveDeviceNeedsStepUp(401 step_up_required),TestApproveDeviceFromPendingDevice(403 device_pending),TestApproveForeignDeviceByNonOwner(404),TestApproveActiveDevice(200, no change),TestApproveRevokedDevice(409 invalid_state_transition).TestPendingDeviceExpires: clock pastauth.device_pending_ttl: requests401; approval409; after the cleanup run the row isrevokedwithpending_expired.TestPairingHappyPath: begin201, complete202, approve201, complete201withauth_method: device_pairingand deviceactive, pairingcompleted.TestPairingWrongCodeEndsStepUp: approve with an unknown code404; the next approve with the right code and no new step-up401 step_up_required; after a new step-up201.TestPairingCompleteWithOtherKeyRejects:401 invalid_dpop_proof, pairingrejected, then410with the right key.TestPairingExpiry: requested past TTL410; approved but not collected past the window: pairingexpired, device revoked withpairing_expired.TestPairingBeginRateLimitedPerIP: requests above therl.public_authburst from one IP429; another IP still201.TestPairingCodeNeverLogged: captured logs contain no pairing code and no verification code.- The route-count test (AZ-P1) passes with the four operations.
Commands that must pass:
make api-lint api-checks codegen-check arch migrations-lint config-doc-check docs
cd services/server && go test -race ./internal/identity/... ./internal/authz/...
make check
Acceptance criteria¶
- The implementation matches openapi.yaml, 03, 05 and 08 (changed only where proven wrong);
make api-lint api-checks docsgreen; the expiry runs inidentity.auth_cleanupand is audited asdevice.expire. - ID-T12 and ID-T13 green and named after their IDs.
- The verification code is shown only to the pending device and is never stored or logged.
- A wrong verification code revokes the pending device with all its sessions and families.
- Pending devices are revoked after
auth.device_pending_ttl, also with the job switched off (enforced at authentication). - Pairing works end to end; a wrong pairing code ends the caller's step-up; a proof with another key rejects the pairing; expired pairings leave no active device.
- Rate limits apply per IP on the public pairing endpoints and per principal on approval.
-
make checkgreen, coverage at or abovequality.coverage.go_min_percent.
Definition of done¶
- Code and tests as above; doc comments on every exported symbol; comments explain why.
internal/identity/README.mdupdated (tabledevice_pairings, use cases, operations, job, revoke reasons);services/server/CHANGELOG.mdentry under "Unreleased".- Specification and API changes from Scope in the same branch; generated files regenerated;
docs/DECISIONS.mdupdated. No ADR unless you take an architecture decision beyond this brief (then the next free number). make checkgreen.make reviewand an independent code review over the diff, then the red team perdocs/quality/red-team-gate.md(focus: approving a foreign device, guessing pairing codes, access of pending devices); verdicts in the report.- Merge locally into the base branch only if review is clean, the red team verdict is
JAandmake checkis green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits, the files with line counts, test results with counts, what is not proven, and follow-ups
that could not be done in scope with the reason (state which cleanup-job case was taken). Then
write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.