M1-35: identity: step-up with the device key¶
Status: ready | Depends on: M1-34 | Milestone: M1
Goal¶
After this ticket a person confirms administrative actions with the device's step_up key: the
app fetches a single-use challenge, the operating system asks for biometrics or the device PIN,
the step_up key signs the challenge, and the server sets auth_sessions.step_up_at, which
counts as fresh for auth.step_up_max_age. Too many failed attempts end the session. Every
action with "Step-up = yes" in 05 (owners, grants, providers, keys, agent policy) depends on this
(08 "Goals" item 4, ADR 0020, SR5).
Read first¶
docs/spec/03-data-model.mdauth_challenges(R-DM-31): single-use challenges for step-up and the WebAuthn ceremonies in one table (purposestep_up,passkey_login,passkey_enroll,passkey_register), stored as HMAC, withstatus,finished_atand the failure index; andstep_up_failedinauth_sessions.revoke_reasonandSessionRevokedPayload.reason. Reason for a table: a single-use challenge and a failure limit need state that every process sees (the code stays node-neutral from M1 on, plan section 20). This ticket creates the whole table (all purposes in the CHECK) and usesstep_up; M1-40 uses the passkey purposes.docs/spec/08-identity.md: "6.1 Two keys per native device", "6.2 Step-up flow", "7.4" (revocation helpers), "8. Session duration", "13. Login protection" (row Step-up), "14.3 Step-up" (sequence diagram), "15. Tests" (ID-T18, ID-T27), "18. Configuration values".docs/spec/03-data-model.md: "4.2" tablesauth_sessions(step_up_at,step_up_method, checks),device_keys, the challenge table above; "6.3 Lock order"; "9. Configuration values" (auth.step_up_max_age).docs/spec/05-actions.md: "2.4 Columns Owner, Step-up, Audit, Permissions", "2.5 Decision", "4.1" (auth.step_up).docs/spec/07-permissions.md: "3. Principal context", "4.1" (step_up_required,step_up_not_possible), "4.2 Flow" step 8, "SR5", "11.1 Test matrix" (AZ-M36).docs/spec/06-api.md: "3.3 Step-up", "5. Errors", "7. Idempotency" item 3.docs/spec/02-architecture.md: "4.3" (identityportAuth: step-up), "8. Transactions and Unit of Work", "9. Request path".- ADR 0020, ADR 0017, ADR 0021.
docs/api/openapi.yaml: operationscreateStepUpChallenge,completeStepUpWithDeviceKey(andcompleteStepUpWithPasskey, which M1-40 builds); schemasStepUpChallenge,StepUpDeviceKeyRequest,StepUpResult,Problem.docs/security/threat-model.md: T-TK-01, T-ID-05 (step-up when approving devices).- Merged code of M1-33 (
kernel/jose,identity.Devices.ActiveKey,identitytest), M1-34 (identity.Auth.RevokeSession, Authenticator, actor fieldsStepUpAt,StepUpMethod), M1-28 (authorizestep 8 and its matrix test world), M1-24 (audit recorder), M1-16 (event appender). services/server/internal/identity/README.md,docs/quality/gates.md,CONTRIBUTING.md,docs/PLAN.md"3. Way of working per ticket".
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 starts with a failing test (red output goes into the report).
- No en dash and no em dash in any text you write.
- White label: only example.com, example.org and the names Alex, Sam, Robin in examples.
- Configuration values only through named keys from
docs/operations/configuration.md. - Flow of 08 "6.2": challenge with a random value valid
auth.step_up_challenge_ttl; thestep_upkey signs a JWS over{challenge, sid, iat}; the server checks the signature against the activestep_upkey of the session's device, the challenge (single use) and the time; thenauth_sessions.step_up_at = now(), audit; fresh forauth.step_up_max_age. - Step-up only for people in a session, valid only for that session (08 "6.2", 07 SR5); tokens
without a session get
403 forbidden(06 "3.3", reasonstep_up_not_possible). - Missing or too old step-up on a step-up action gives
401 step_up_required(05 "2.4", 07 "4.2" step 8). auth.step_up_fail_limitfailures perauth.login_fail_windowrevoke the session (08 "13").createStepUpChallengeandcompleteStepUpWithDeviceKeyhave noIdempotency-Key(06 "7" item 3); actionauth.step_up, classrl.public_auth, audityes(05 "4.1").
Scope¶
- Migration
services/server/migrations/<next free number>_identity_step_up.sql: the tableauth_challengesexactly as 03 has it (columns, checks, FKs, unique and indexes). - sqlc queries
internal/identity/adapters/postgres/queries/stepup.sqland repository. - Port
internal/identity/stepup.go(names fixed):Challenge{ID, Value string, ExpiresAt},StepUpResult{StepUpAt, ValidUntil time.Time},type StepUp interface{ CreateChallenge(ctx, actor.Actor) (Challenge, error); CompleteWithDeviceKey(ctx, actor.Actor, challengeID ID, signature string) (StepUpResult, error) }. M1-40 addsCompleteWithPasskeyto the same port. - Domain
internal/identity/domain/stepup: pure check of the signed payload (see "Design notes") and the failure-limit decision. - App
internal/identity/app/stepup.go: use cases withauthorize, unit of work, audit and the call toAuth.RevokeSession(M1-34). - Adapter
internal/identity/adapters/httpapi/stepup.go: handlerscreateStepUpChallengeandcompleteStepUpWithDeviceKey; enable both inservices/server/api/operations.yamland regenerate (command fromservices/server/README.md). - Config keys in the identity
Config:auth.step_up_challenge_ttl,auth.step_up_max_age,auth.step_up_fail_limit,auth.login_fail_window,auth.clock_leeway,auth.token_pepper_ref; register missing ones in the schema,make config-reference. - Job
identity.auth_cleanup(02 "12.3", queuemaintenance, everyauth.cleanup_interval, roleworker): deletes rows ofauth_challengespastexpires_at; M1-38 adds pairings and device expiry, M1-41 addsauth_login_flows. Registerauth.cleanup_intervalin the identityConfig. Request handling never depends on the job (expiry is checked per request). - Docs:
docs/spec/08-identity.md"6.2" and "13" already state the JWS profile, the failure counting and the answers, andStepUpDeviceKeyRequestinopenapi.yamldescribes the signed payload; change them only where the implementation proves them wrong (spec first). Rulings indocs/DECISIONS.md; identity README;services/server/CHANGELOG.md.
Out of scope¶
- Step-up with a passkey (
completeStepUpWithPasskey,passkey_optionsin the challenge) (M1-40). - Step-up with password and TOTP and with OIDC re-login (08 "6.2"): the operations come with the console session (M6-01), spec first.
- Any administrative action itself; this ticket proves AZ-M36 through
authorize. - Device approval with step-up (M1-38), API tokens (M1-44; AZ-M35 with a real personal token is tested there).
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 the branch
feat/M1-35-identity-step-up. - Conventional Commits with scope
identity(orapi,docswhere fitting), each signed off (git commit -s). - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope above except
services/server/CHANGELOG.md,docs/DECISIONS.mdand generated references. - Follow the host rules supplied by the launcher.
- Tests bind only
127.0.0.1and port 0; PostgreSQL 18 as a throwaway container via the M1-12 helper; no Docker runtime means failure, never a skip. - Stop only processes you started, by PID; temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets; tests generate keys with
identitytest. - No network calls except package downloads for pinned tools.
- A gap in the core contract ends the ticket with
Verdict: BLOCKED(P-11).
Design notes¶
Create challenge (createStepUpChallenge):
- The actor must be a person with a session (
kind = user,SessionIDset) whose device is notplatform = 'cli'(08 "6.4"); otherwise403 forbidden, audit deny with reasonstep_up_not_possible. authorize(actor, auth.step_up, auth_session = actor.SessionID): permissionself; a pending device gets403 device_pending; a revoked session401 session_revoked.- Value: 32 bytes from
crypto/rand, base64url without padding. Storechallenge_hash= HMAC-SHA-256(token pepper, value),status = issued,expires_at = now + auth.step_up_challenge_ttl,id= new UUIDv7. - Response
200 StepUpChallengewithchallenge_id,challenge,expires_at;passkey_optionsabsent. The display text mentioned in 08 "6.2" is not in the schema; clients show their own localized text.
Complete with the device key (completeStepUpWithDeviceKey):
- Actor checks and
authorizeas above. - The device of the session needs an active
step_upkey (Devices.ActiveKey); otherwise403 forbidden, audit deny reasonstep_up_not_possible(details{"cause":"no_step_up_key"}). - Lock order (03 "6.3"):
SELECT ... FOR UPDATEon the session row, then on the challenge row. Concurrent completions of one challenge therefore serialize. - Challenge unknown, of another session or of another purpose:
401 step_up_required, not counted (the value cannot be signed without the challenge), audit deny. - Challenge
statusnotissuedorexpires_at<= now:401 step_up_required, not counted, audit deny. - Signature check, counted as a failure when it fails:
- Parse with
kernel/jose, allowlist exactly the key'salg; rejectcrit,jwk,jku,x5u,x5cin the header (the key comes only fromdevice_keys). - Verify with the active
step_upkey; a signature by thedpopkey or any other key fails (ID-T18 "wrong key"). - Payload:
challengestring whose HMAC equalschallenge_hash(hmac.Equal);sidequalsactor.SessionID;iatinteger NumericDate, not beforecreated_at - auth.clock_leewayand not after now +auth.clock_leeway. Unknown members are ignored.
- Parse with
- Failure: set the challenge
failed,finished_at = now; count failed challenges of this session withfinished_at > now - auth.login_fail_window; if the count reachesauth.step_up_fail_limit, callAuth.RevokeSession(session, step_up_failed, actor)in the same unit of work. Commit, write the audit deny (reasonstep_up_required, details with the cause, never the signature), then answer401 session_revokedif the session was revoked, else401 step_up_required. - Success: challenge
used,finished_at = now;auth_sessions.step_up_at = now,step_up_method = 'device_key'; audit allow;200 StepUpResultwithstep_up_atandvalid_until = step_up_at + auth.step_up_max_age.
The step-up time is read per request into the actor by the Authenticator of M1-34, so the next
request sees it in every process; no cache and no permission_version change. A child session
has its own step_up_at; a step-up in the parent does not count for a child and the reverse.
Choice of codes (spec silent, record as ruling in DECISIONS.md and 08 "6.2"): a failed
confirmation is 401 step_up_required because the client's correct reaction is a new step-up
(06 "5" table), not a token refresh or a re-login; 403 forbidden where step-up is impossible for
this caller or device.
Tests (write them first)¶
Unit tests:
TestStepUpPayload_Checktable: correct payload; wrongchallenge; wrongsid;iatbeforecreated_at - leeway;iatafter now + leeway; missing members;critandjwkheaders;algdiffering from the key.TestStepUpFailLimit_Decision: failures inside and outsideauth.login_fail_window(steerable clock), limit reached exactly atauth.step_up_fail_limit.TestChallengeValue_IDT27Like: 32 bytes fromcrypto/rand, distinct across draws.
Integration tests against PostgreSQL 18 with real Authenticator (M1-34), real authz, audit and
event appender:
TestStepUp_DeviceKeySuccess:step_up_at,step_up_method = device_key,valid_until, audit allow; the next request's actor carries the newstep_up_at.TestStepUp_IDT18_Missing: an owner session without step-up callsauthorizeforidentity_provider.manage(instance, owner, step-up):401 step_up_required.TestStepUp_AZM36_TooOld: owner, step-up older thanauth.step_up_max_age(steerable clock),authorize(grant.create)on the Marketing channel of the M1-28 matrix world (structure from its fakeStructureSource, principal, session and step-up real):step_up_required.TestStepUp_IDT18_AgentToken: an agent actor (fake, no session) gets403 forbiddenwith audit reasonstep_up_not_possibleon both operations.TestStepUp_IDT29_CliDevice: a session of aclidevice gets403 forbiddenwith auditstep_up_not_possiblefor the challenge and the completion; a step-up action from it stays401 step_up_required.TestStepUp_IDT18_WrongKey: signature by thedpopkey:401 step_up_required, counted.TestStepUp_ChallengeSingleUse: second completion with the same challenge401.TestStepUp_ChallengeExpired,TestStepUp_ForeignChallenge:401, not counted.TestStepUp_ConcurrentCompletion: two parallel completions of one challenge: exactly one success.TestStepUp_FailLimitRevokesSession: afterauth.step_up_fail_limitcounted failures the session is revoked with the reason of 03,session.revokedappended,401 session_revoked; failures older than the window do not count.TestStepUp_BoundToSession: step-up in one session does not satisfy another session of the same person, nor its child session.TestStepUp_PendingDevice:403 device_pending.TestStepUp_NoStepUpKey: device withoutstep_upkey:403 forbidden.TestStepUp_NoSecretsInLogs: challenge value and signature absent from logs and audit details.
Commands that must pass:
cd services/server && go test -race ./internal/identity/... ./internal/authz/...
make arch
make codegen-check
make migrations-lint
make api-lint
make config-reference && git diff --exit-code docs/operations/configuration.md
make check
Acceptance criteria¶
- Challenges are single use, valid
auth.step_up_challenge_ttl, stored only as HMAC. - Completion verifies against the active
step_upkey of the session's device only;sidandiatchecked; success setsstep_up_atandstep_up_method, audit allow. - ID-T18 (missing, too old, agent token, wrong key) and AZ-M36 green with the codes of "Design notes".
-
auth.step_up_fail_limitfailures withinauth.login_fail_windowrevoke the session withsession.revoked. - Concurrent completions of one challenge succeed at most once.
- The job
identity.auth_cleanupdeletes expired challenges (test with a controlled clock). - 08 "6.2" and "13" match the implementation; rulings in DECISIONS.md.
-
make checkgreen.
Definition of done¶
Code, tests and docs complete: identity README (status, port StepUp, challenge table, action
auth.step_up, config keys, test ids), a doc comment on every public symbol, comments explain why,
an ADR only for an architecture decision beyond ADR 0020, the CHANGELOG entry under Unreleased of
services/server/CHANGELOG.md, generated references current. make check green. Independent
review (make review plus a code review of the diff) and red team gate
(docs/quality/red-team-gate.md) over the diff with both verdicts in the report; merge locally
into the base branch only if both are clean and make check is green; then git worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits, the files with line counts, the test results with counts (including the red output of
the first failing tests), what is not proven, and follow-ups that could not be done in scope, each
with its reason. Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.