Skip to content

M1-35 Step-up with the device key

No Date Ruling Reason
M1-35/1 2026-10-13 Base int/m1-wave9 at 668ef20, branch feat/M1-35-identity-step-up in its own worktree; the branch is not merged locally and the worktree stays, the PR lane ports it. Text this ticket adds is English, also in German chapters and READMEs (their German parts stay). Instructions of the launcher, which override the brief's merge step and the brief's language (as M1-34/1).
M1-35/2 2026-10-13 StepUp is a port of its own in internal/identity/stepup.go, beside Auth; 02 section 4.3 lists step-up under Auth. The brief fixes the names and the file; M1-40 adds CompleteWithPasskey to the same port. The README's port list names both.
M1-35/3 2026-10-13 The migration 00012_identity_step_up.sql creates auth_challenges with every column, check, unique and index of 03 but defers the foreign key enrollment_code_id -> enrollment_codes(id) ON DELETE CASCADE: the migration that creates enrollment_codes (M1-37) adds it. bothy_app gets SELECT, INSERT, UPDATE, DELETE (the cleanup job deletes). enrollment_codes does not exist on the base; the column and its pairing check exist, so only the constraint is missing (as M1-34/10 for identity_id).
M1-35/4 2026-10-13 Answers: a wrong confirmation is 401 step_up_required (the one that reaches auth.step_up_fail_limit is 401 session_revoked); an unknown, foreign, other purpose, finished or expired challenge is 401 step_up_required and not counted; a caller who is no person, has no session, sits on a cli device or whose device has no active step_up key gets 403 forbidden with reason step_up_not_possible. Written into 08 section 6.2. Brief "Choice of codes": the client's correct reaction to a failed confirmation is a new step-up (06 section 5), not a refresh or a login; where a step-up is impossible a retry cannot help (08 section 13).
M1-35/5 2026-10-13 The anonymous principal is not refused with 403 forbidden (reason step_up_not_possible) by the actor check; it goes to authorize, which answers 401 unauthenticated. A request without credentials must be told to authenticate (06 section 3); the actor check is for principals that are authenticated but cannot give a step-up.
M1-35/6 2026-10-13 The deny entry of a counted failure is recorded inside the unit of work that commits the failed challenge and the revocation (Record), not after the commit; denials that roll back go to RecordDenial afterwards. Details: {cause, challenge_id, session_revoked}, never the value or the signature. The entry then exists exactly when the failure does (as the refresh reuse of M1-34/9); the brief's order "commit, then audit" would lose the entry when the process ends between the two.
M1-35/7 2026-10-13 Every confirmation that fails the check counts, also one that is not a JWS of the profile (invalid_jws, which includes crit, jku, x5u, x5c, zip refused by kernel/jose), one with a jwk header (forbidden_header) and one with another alg (alg_mismatch). The caller presented a valid open challenge of its own session, so it is a confirmation attempt; counting only good-looking JWS would give an attacker unlimited free attempts.
M1-35/8 2026-10-13 iat is compared in whole seconds: valid is iat >= floor(created_at - auth.clock_leeway) and iat <= now + auth.clock_leeway. iat is an integer NumericDate; without the cut a client that signs in the same second as the issue fails with auth.clock_leeway = 0.
M1-35/9 2026-10-13 The job identity.auth_cleanup deletes a challenge after expires_at, but a failed step-up challenge only once its finished_at has left auth.login_fail_window; 08 section 13 and the retention row of 20 say so. The failures are counted from these rows; deleting them after auth.step_up_challenge_ttl (2m) would reset the count inside the 15m window. 20 said "expired rows"; the implementation proved that incomplete (spec first, brief Scope 9). The job row of 02 stays a summary that points to 08.
M1-35/10 2026-10-13 The active step_up key is read inside the unit of work after the session rows are locked, before the challenge; the answer for a device without key is the same as the brief's step 2. A key revoked between the read and the lock cannot then confirm; the order of the answers stays the brief's (key before challenge).
M1-35/11 2026-10-13 Both operations audit their allow (step: challenge and step: confirm with method); a successful step-up does not reset the failure count. Times are cut to microseconds, so the answer equals the stored step_up_at. 05 marks auth.step_up "Audit ja" for the action, not one operation; 08 is silent on a reset, and a reset would let an attacker with the device interleave successes to keep guessing.
M1-35/12 2026-10-13 The integration tests run in the session world of M1-34 (sessions_env_test.go, now with the real authorizer and its fake structure source in reach). AZ-M36 builds the Marketing space and channel in that fake structure source instead of reusing the matrix world of authz/app, which lives in another test package. Test packages cannot import each other; principal, session and step-up stay real as the brief asks.
M1-35/13 2026-10-13 Port onto main: the migration keeps 00012_identity_step_up.sql with a checksum over the English comments, the details of the step-up check stand in 08 section 6.2 and the counting of failed step-ups in 08 section 13 of the English chapter, and the chapter references name the English files and section titles. The specification is English on main; no behaviour changes.