M1-33: identity: devices, device keys and DPoP verification (ADR 0020)¶
Status: ready | Depends on: M1-28 | Milestone: M1
Goal¶
After this ticket the identity module stores devices with their two public device keys (dpop
and step_up), validates and fingerprints those keys (RFC 7638) and verifies DPoP proofs (RFC 9449)
with server nonces and a per-process jti replay store. People and owners can list and read
devices over the API. Every later authentication path (sessions M1-34, step-up M1-35, logins M1-37
to M1-43, API tokens M1-44, gateway M1-64) uses this one verifier, so a stolen token without the
non-exportable device key is worthless (08 "Goals" item 3, threat T-TK-01).
Read first¶
docs/spec/08-identity.md: "5.1 Registration", "5.2 Approval", "5.4 Management", "6.1 Two keys per native device", "7.1 Overview", "7.3 DPoP", "13. Login protection", "15. Tests" (ID-T14, ID-T27), "16. Decisions of this chapter" (R-ID-04), "18. Configuration values of this chapter".docs/spec/03-data-model.md: "1. Principles" (items 1, 2, 3, 9, 12, 13), "4.2 Identity" tablesdevicesanddevice_keys, "3. Retention classes".docs/spec/05-actions.md: "2.1 Column Permission", "2.5 Decision" (pending devices), "4.3 People, identity, devices, tokens" (device.list).docs/spec/07-permissions.md: "3. Principal context", "4.1 Decisions and reasons", "4.2 Flow", "4.3 Visibility".docs/spec/06-api.md: "3.1 DPoP", "5. Errors" (codesinvalid_dpop_proof,use_dpop_nonce,device_pending,not_found,cursor_invalid), "6. Pagination".docs/spec/02-architecture.md: "4.1 Rules", "4.5 Cross-cutting ports", "5. Layers within a module", "6. Code layout", "9. Request path", "14.1 Error kinds".docs/spec/19-security.md: "6.1 Input validation in the server" (SEC-IN-1, SEC-IN-2, SEC-IN-4), "9. Logs without secrets and without content", "10. Secrets".docs/spec/15-client-architecture.md: "9. Secure storage and device key" (clients use ES256; EdDSA stays allowed by the data model).docs/spec/04-sync-protocol.md: "6.3 Lifecycle" (the gateway reuses this verifier withhtm = GETand the nonce fromhello; not built here).- ADR 0020 (
docs/adr/0020-device-binding-dpop-step-up-for-admin-actions.md), ADR 0067 (docs/adr/0067-jose-profile-with-the-standard-library.md), ADR 0008, ADR 0017. docs/api/openapi.yaml: operationslistMyDevices,listUserDevices,getDevice; schemasDevice,DevicePage,DevicePlatform,DeviceRegistration,DeviceKeyRegistration,Problem; responsesUnauthorized,Forbidden,NotFound; headersWWW-Authenticate,DPoP-Nonce; security schemedpopAuth; parametersCursor,Limit,UserIdPath,DeviceIdPath.docs/security/threat-model.md: T-TK-01, T-ID-05.services/server/internal/identity/README.md,services/server/README.md,services/server/architecture/modules.yaml,docs/quality/gates.md,CONTRIBUTING.md,docs/PLAN.mdsection "3. Way of working per ticket" and the M1 row of this ticket.- The merged code of M1-07 (
internal/platform/config), M1-09 (internal/platform/secrets), M1-12 (internal/platform/pg,pgtest), M1-13 (internal/kernel/txn), M1-17 (services/server/api/operations.yaml, generator), M1-18 (internal/platform/httpserver,internal/kernel/actor,internal/kernel/errs, the fake authenticator of its tests), M1-26 (internal/identityprincipals and users, display-text validation) and M1-28 (internal/authz,kernel/authorize, the pending-device rule).
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), then code until green, then clean up.
- No en dash and no em dash in any text you write (code comments, docs, commit messages).
- White label: no company, person, host or domain names except example.com, example.org and the neutral names Alex, Sam, Robin.
- Configuration values only through named keys from
docs/operations/configuration.md; no literal limits or durations in code paths that the spec names. - Two keys per native device,
dpopwithout andstep_upwith user verification per use (08 "6.1"; R-ID-04; Ruling L10). At most one active key per device and purpose;jktis unique across all devices (03device_keys). - DPoP after RFC 9449 exactly as 08 "7.3":
typ = dpop+jwt,algonlyES256orEdDSA,jwkwithout private members, claimsjti,htm,htu(without query and fragment),iat,athwhen a token is presented,nonce;iatat mostauth.dpop_proof_max_ageold and at mostauth.clock_leewayin the future;jtionce perhtuwithin that window, stored as a hash, per process; nonces from theDPoP-Nonceheader, renewed afterauth.dpop_nonce_ttl, current and previous accepted, proofs withoutnoncerejected. - Errors:
401withWWW-Authenticate: DPoP error="invalid_dpop_proof"orerror="use_dpop_nonce"and a freshDPoP-Nonce(08 "7.3", 06 "3.1"). - A pending device has tokens but every action except
self.read,device.listfor its own device,auth.token.refreshandauth.logoutends with403 device_pending(05 "2.5"). - No view permission gives
404, never403(05 rule 2, ADR 0017);device.listisselforowner(05 "4.3"). - Display texts (device names) with control or bidi control characters are rejected (SEC-IN-4).
Scope¶
Work only in services/server and the docs named here.
- Migration
services/server/migrations/<next free number>_identity_devices.sql(goose, Up and Down): tablesdevicesanddevice_keysexactly as 03 "4.2" (all columns, defaults, CHECKs, FKs with theirON DELETE, the unique index onjkt, the partial unique index(device_id, purpose) WHERE revoked_at IS NULL, the indexes(user_id, state)and(state) WHERE state = 'pending'). A comment names the retention classsecurity. - sqlc queries
internal/identity/adapters/postgres/queries/devices.sqland the repository ininternal/identity/adapters/postgres/devices.go: insert device, insert key, get device by id, list devices of a user (keyset onid DESC), get active key by device and purpose, get key byjkt. No query on tables of other modules. - Port (root package
internal/identity, filesdevices.goanddpop.go), Go names fixed:- Types
KeyPurpose(dpop,step_up),DeviceState(pending,active,revoked),Platform(values of 03devices.platform),PublicKey{JWK json.RawMessage, Alg string, Attestation json.RawMessage},RegisterDevice{DeviceID, UserID, Name, Platform, AppVersion, DPoPKey PublicKey, StepUpKey *PublicKey, State DeviceState, ApprovedBy *ID},Device(all fields of the API schemaDeviceexceptpush_registrations),DeviceKey{ID, DeviceID, Purpose, JKT, Alg, PublicJWK, RevokedAt},DevicePage{Items []Device, NextCursor string}. IDs use the UUID type ofinternal/kernel/ids. type Devices interface:Register(ctx, RegisterDevice) (Device, error)joins the ambient unit of work and is called by the login tickets (M1-37 to M1-43) withStatependingoractive;Get(ctx, actor.Actor, deviceID) (Device, error);ListMine(ctx, actor.Actor, Page) (DevicePage, error);ListForUser(ctx, actor.Actor, userID, Page) (DevicePage, error);ActiveKey(ctx, deviceID, KeyPurpose) (DeviceKey, error)(error kindnot_foundwhen none).type ProofRequest struct{ Proofs []string; Method string; URL string; AccessToken string; ExpectedJKT string }whereProofsholds every value of theDPoPheader,URLis the absolute expectedhtu,AccessTokenis empty when no token is presented andExpectedJKTis the token'scnf.jktor empty.type VerifiedProof struct{ JKT string; Alg string; PublicJWK json.RawMessage; IssuedAt time.Time; NonceStale bool }(NonceStaleis true when the proof used the previous nonce, so the caller attaches the current one to its response).type DPoP interface{ CurrentNonce() string; Verify(ctx, ProofRequest) (VerifiedProof, error) }.type DPoPError struct{ Code string; Nonce string }withCodeexactlyinvalid_dpop_prooforuse_dpop_nonce; it maps to error kindunauthenticatedofkernel/errs.
- Types
- Kernel and domain, standard library only:
internal/kernel/jose(kernel-core, ADR 0067, 02 "4.5" and "7.2"):ParseJWK(raw) (JWK, error),JWK.Thumbprint() string(RFC 7638),ParseCompactJWS(s string, allowed []string, typ string) (JWS, error),JWS.Verify(JWK) errorforES256andEdDSA, andSignCompact(signer Signer, header Header, payload []byte) (string, error)withtype Signer interface{ Alg() string; Sign(signingInput []byte) ([]byte, error) }for the token issuers (M1-34 access tokens, M1-50 audit checkpoints, M2 invocation tokens). M1-41 addsRS256andPS256; nobody adds a JOSE library.internal/identity/domain/dpop:NormalizeHTU(string) (string, error), the claim checks of "Design notes", the nonce encode and check functions (pure, key and time passed in).internal/identity/domain/device: registration validation and the state machinepending -> active,pending -> revoked,active -> revoked(other transitions give409 invalid_state_transition). Approval and revocation use cases come in M1-37, M1-38 and M1-45; they use these transitions.
- App:
internal/identity/app/devices.go(Register, Get, ListMine, ListForUser, ActiveKey withauthorizeand the unit of work) andinternal/identity/app/dpop.go(verifier composing domain checks, the nonce key and the outgoing portReplayStore{ CheckAndRemember(key [32]byte, until time.Time) bool }). - Adapters:
internal/identity/adapters/replay(in-memoryReplayStore, concurrency safe, entries expire);internal/identity/adapters/httpapi/devices.go(handlers of the three operations) andinternal/identity/adapters/httpapi/dpop.gowithExpectedHTU(publicURL *url.URL, r *http.Request) string(origin ofserver.public_urlplus the escaped request path) for M1-34 and later login handlers. - Test helpers
internal/identity/adapters/identitytest(02 "6. Code layout"):NewDeviceKey(alg) (signer, PublicKey),SignDPoPProof(signer, ProofClaims) string,SignJWS(signer, header, payload) string. Later tickets (M1-34, M1-35, M1-64, M1-73) reuse them. Only_test.gofiles import this package; a test runsgo list -deps ./cmd/bothyand fails if it appears. - Wiring in
internal/wire(verifier, replay store, repository, handlers); the identityConfigstruct getsDPoPNonceTTL,DPoPProofMaxAge,ClockLeeway,TokenPepperRef,PublicURL,PageSizeDefault,PageSizeMaxfrom the keysauth.dpop_nonce_ttl,auth.dpop_proof_max_age,auth.clock_leeway,auth.token_pepper_ref,server.public_url,api.page_size_default,api.page_size_max. Register any of these keys that the schema of M1-07 lacks, with the standard values of the chapter tables, and regenerate the reference withmake config-reference. Ifbothy secrets init(M1-09) does not yet create the token pepper thatauth.token_pepper_refpoints to, add it there (random, 32 bytes, idempotent, mode 0600) with a test. - Enable
listMyDevices,listUserDevices,getDeviceinservices/server/api/operations.yamland regenerate with the generator command documented by M1-17 inservices/server/README.md. - If
kernel/errscannot carry response headers on an error, add an optional header list to its error type and render it inplatform/httpserver(the only change outsideidentitybesides wiring, config schema, secrets init and pagination below), with a unit test. - Pagination uses the cursor codec of
internal/platform/httpserver(M1-18, 06 "6. Pagination": opaque, bound to filter and sort,400 cursor_invalidon mismatch); no second codec. - Docs:
docs/spec/08-identity.md"7.3 DPoP" already states the nonce construction, thehturule and the replay store; ADR 0067 states the JWS profile. Change them only where the implementation proves them wrong (spec first).docs/DECISIONS.mdgets the rulings listed in "Design notes"; module README andservices/server/CHANGELOG.md(Unreleased). authz.CredentialSource, devices part (02 "Ports, events and sources per module", 07 "Flow"check_active, PLAN row M1-33): implementDeviceState(pending,active,revoked) of the request's device in the identity adapter of the source port that M1-28 declared, and replace the corresponding empty source ininternal/wire(fail closed until then). Test:TestCredentialSource_Devicesagainst PostgreSQL 18 (active, revoked, expired, foreign principal).
Out of scope¶
- Sessions, access tokens, the
Authenticator, refresh and logout (M1-34). This ticket does not authenticate requests; HTTP tests use the fake authenticator of M1-18. - Step-up challenges and the use of the
step_upkey (M1-35). - Creating devices from a login flow, enrollment codes, approval with verification code, pairing, expiry of pending devices (M1-37, M1-38).
- Device revocation,
DeviceRevoked, closing connections with4012(M1-45, M1-65). - Push registrations in
Device.push_registrations(M3); omit the member. - Gateway use of the verifier (M1-64), API tokens with
dpop_jkt(M1-44). - Automatic revocation after
auth.device_inactive_revoke_afterand device key rotation (08 "5.4"): no M1 ticket owns them yet; do not build them here.
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-33-identity-devices-dpop. - Conventional Commits with scope
identity(orserver,docswhere fitting), each signed off (git commit -s, DCO). - 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 the generated references named here. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs as a throwaway container on a random port through the M1-12 test helper; without a Docker-compatible runtime the test fails, it never skips. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets; tests generate their own keys and pepper.
- No network calls except package downloads for pinned tools. The RFCs are not fetched; this brief carries what you need.
- If the core contract (00, 03, 04, 05) lacks something this ticket needs, stop with
Verdict: BLOCKEDinstead of building a deviation (P-11).
Design notes¶
Device registration (Register):
DeviceIDmust be a UUIDv7 (kernel/ids); an existing id gives409 conflict.UserIDmust be a user principal (principals.kind = 'user'); otherwisenot_found.Nameis trimmed, must not be empty and must pass the display-text check of M1-26 (control and bidi characters rejected, SEC-IN-4); failures are400 validation_failedwith a field pointer.Platformone ofios,android,macos,windows,web,cli.DPoPKeyrequired,StepUpKeyoptional (08 "5.1": only native apps have one;DeviceRegistration.step_up_keyis optional). WithPlatform = cliaStepUpKeyis400 validation_failed(08 "6.4", ID-T29).- Each key:
AlgisES256orEdDSA. JWK rules: forES256kty = "EC",crv = "P-256",xandybase64url without padding, each exactly 32 bytes, and the point lies on the curve (validate throughcrypto/ecdhP256().NewPublicKeywith the uncompressed form); forEdDSAkty = "OKP",crv = "Ed25519",xexactly 32 bytes. Membersd,p,q,dp,dq,qi,k,othare rejected (private or symmetric material). A JWK memberalg, if present, must equalAlg. Unknown members are ignored for validation and excluded from the thumbprint. Duplicate JSON keys are rejected (SEC-IN-2). jktis the RFC 7638 thumbprint: SHA-256 over the UTF-8 bytes of the JSON object with only the required members in lexicographic order and no whitespace, base64url without padding ({"crv":"P-256","kty":"EC","x":"...","y":"..."},{"crv":"Ed25519","kty":"OKP","x":"..."}).- The
dpopandstep_upkeys of one device must differ (differentjkt), otherwise the step-up key would be usable without user verification:400 validation_failed. Ajktthat already exists on any device gives409 conflict. Stateispendingoractive;activerequiresApprovedBy(a principal id) and setsapproved_byandapproved_at;revokedis never a registration state.- Attestation JSON is stored unchanged in
device_keys.attestation; it is never required in M1 (auth.require_key_attestationstays unused here, 08 "6.1").
JWS profile (kernel/jose, ADR 0067, used by DPoP, access tokens in M1-34, step-up in M1-35 and
audit checkpoints in M1-50):
- Compact serialization only: exactly three segments, base64url without padding; any
=,+,/or whitespace is an error. - Header and payload are JSON objects; duplicate keys, invalid UTF-8 and nesting beyond the decoder limit of M1-18 are rejected.
algmust be in the caller's allowlist;noneand everyHS*value are always rejected. Acritmember is rejected.jku,x5uandx5care rejected (keys come only fromjwkor from the server).ES256signatures are exactly 64 bytesR || S(RFC 7518 section 3.4), verified withcrypto/ecdsaover SHA-256 of the signing input; DER or other lengths are rejected.EdDSAusescrypto/ed25519with a 64-byte signature.- No JOSE library (ADR 0067): the profile is a few algorithms and one serialization, and domain layers may not import third-party code (02 "5. Layers within a module").
DPoP proof checks (Verify), in this order; a failure stops and nothing is remembered:
- Exactly one
DPoPheader value; otherwiseinvalid_dpop_proof. - Parse as JWS with allowlist
ES256,EdDSA; headertypequalsdpop+jwt(ASCII case-insensitive); headerjwkpasses the JWK rules above and itsalgmatches; the signature verifies with thatjwk. - Payload:
jtinon-empty string,htmstring,htustring,iatinteger NumericDate;noncestring;athstring exactly whenAccessTokenis not empty, absent otherwise (06 "3.1" item 3). Missing or mistyped members:invalid_dpop_proof, except a missingnonce, which givesuse_dpop_nonce. htmequalsMethodexactly.NormalizeHTU(htu)equalsNormalizeHTU(URL): scheme and host lower case, default port removed (443forhttps,80forhttp), query and fragment dropped, empty path becomes/, percent-encodings normalized (upper-case hex, unreserved characters decoded). Path comparison is otherwise exact.iatnot older thanauth.dpop_proof_max_ageand not later than now plusauth.clock_leeway(kernel/clock).- Nonce: decode and check as below; unknown, malformed or too old gives
use_dpop_nonce. - With
AccessToken:athequals base64url(SHA-256(ASCII bytes of the token)); withExpectedJKT: the proof key's thumbprint equals it. Mismatch:invalid_dpop_proof. - Replay: key
SHA-256(normalized htu || 0x00 || jti);CheckAndRememberwith expiry now plusauth.dpop_proof_max_ageplusauth.clock_leeway. A second use inside that window givesinvalid_dpop_proof. Check and insert are one atomic step.
Nonce construction (stateless, so every process and every restart agrees; 08 says "across processes the nonce protects"):
- Key: HKDF-SHA256 (
crypto/hkdf) over the token pepper loaded fromauth.token_pepper_ref, empty salt, infodpop-nonce/v1, 32 bytes. Do not put the product name into the info string (02 "6. Code layout" lists the only places with the product name). - Window
w = floor(unix seconds / seconds of auth.dpop_nonce_ttl). - Nonce = base64url without padding of
uint64 big-endian(w) || first 16 bytes of HMAC-SHA256(key, uint64 big-endian(w)). - Accepted: decoded length 24, MAC equal (
crypto/hmac.Equal),wequals the current or the previous window.NonceStaleis true for the previous window. CurrentNonce()returns the nonce of the current window. 08 "7.3" states this construction.
Replay store: in-memory, sharded map plus expiry; no named size cap exists in the spec, so none
is invented; memory is bounded by the time window and the rate limits. Expired entries are
removed on insert and by a ticker owned by the lifecycle of internal/platform/lifecycle.
Error rendering: DPoPError maps to 401 with problem code invalid_dpop_proof or
use_dpop_nonce, header WWW-Authenticate: DPoP error="<code>", algs="ES256 EdDSA" and header
DPoP-Nonce: <CurrentNonce()> in both cases. The internal reason (for example htu_mismatch) goes
only to the debug log with the correlation id, never into the problem body (no oracle).
Authorization of the three operations (handlers call the app; the app calls authorize):
| Operation | Action | Resource passed to authorize |
Outcome |
|---|---|---|---|
listMyDevices |
device.list |
user = caller |
own devices; a pending device gets 403 device_pending |
listUserDevices |
device.list |
user = path user_id |
self or owner; other people and unknown ids 404 not_found |
getDevice |
device.list |
device with owner user_id |
self or owner; a pending device may read only its own device id; other ids 404; another own device from a pending device 403 device_pending |
Load the device before authorize and return the same 404 body for "missing" and "not
visible" (02 "9.2"). If the pending rule of M1-28 cannot distinguish "own device" from "another
own device", change that one rule in internal/authz with its test; that change is in scope.
Lists: order by id DESC (UUIDv7, newest first), all states including revoked, page size
api.page_size_default, larger limit capped at api.page_size_max, no totals. Response
members follow Device: key_thumbprint is the jkt of the active dpop key,
step_up_key_thumbprint of the active step_up key; absent when there is none; null columns are
absent members, never JSON null.
Tests (write them first)¶
Unit tests (go test):
TestJWKThumbprint_RFC9449Example: JWK{"kty":"EC","crv":"P-256","x":"l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs","y":"9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA"}gives0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I(RFC 9449 sections 4.1 and 6.1).TestJWKThumbprint_RFC8037Example: JWK{"kty":"OKP","crv":"Ed25519","x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}giveskPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k(RFC 8037 appendix A.3). Both vectors are quoted from the RFCs; if one fails, compare the canonical JSON byte by byte before changing the algorithm, and report it if the vector itself looks wrong.TestJWK_Rejects: private memberd,kty = RSA,kty = oct, wrong curve,xof 31 or 33 bytes, point not on the curve, padded base64, duplicate keys,algmember mismatch.TestJWS_Rejects: two or four segments, padding,algnone,HS256,RS256,crit,jku,x5u,x5c, DER-encoded ES256 signature, flipped signature bit.TestDPoP_IDT14_*(table test, one case each, expected code in brackets): wronghtm[invalid_dpop_proof], wronghtuhost, path and scheme [invalid_dpop_proof],htuwith query or fragment equal after normalization [accepted], wrongath[invalid_dpop_proof],athpresent without token [invalid_dpop_proof],iatolder thanauth.dpop_proof_max_ageand newer than leeway [invalid_dpop_proof], repeatedjti[invalid_dpop_proof], samejtiwith a differenthtu[accepted], missing nonce [use_dpop_nonce], nonce of two windows ago [use_dpop_nonce], forged nonce MAC [use_dpop_nonce], previous window [accepted withNonceStale], proof key differs fromExpectedJKT[invalid_dpop_proof], twoDPoPheaders [invalid_dpop_proof],typJWT[invalid_dpop_proof]. The Bearer case of ID-T14 belongs to M1-34.TestDPoP_NonceAcrossVerifiers: two verifier instances with the same pepper accept each other's nonces; a different pepper rejects them.TestDPoP_ReplayConcurrent: the same valid proof sent from many goroutines passes exactly once (-race).TestReplayStore_Expiry: an entry is forgotten after its expiry (steerable clock).TestDPoPError_Rendering:401, problem code,WWW-AuthenticateandDPoP-Nonceheaders present; no internal reason in the body.TestDevice_StateMachine: allowed and forbidden transitions.TestRegister_Validation: every rule of "Device registration", including equaldpopandstep_upkeys, aclidevice with astep_upkey (ID-T29) and control or bidi characters in the name (SEC-T07, part devices).- Fuzz targets with seed corpus in
testdata/fuzz/<Name>/(seeds run in everygo test):FuzzParseJWK,FuzzParseCompactJWS,FuzzVerifyDPoPProof. Property: no panic; an accepted input verifies again with the same result; seeds include valid ES256 and EdDSA proofs fromidentitytestand the malformed cases above.
Integration tests against PostgreSQL 18 (M1-12 helper):
TestDevicesMigration_Constraints: CHECKs onplatform,state,purpose,alg; uniquejkt; second active key of the same purpose fails, a revoked one plus a new one succeeds;ON DELETE CASCADEfromdevicestodevice_keys.TestRegister_Persists: both keys stored withjkt,alg, attestation;activesetsapproved_byandapproved_at; duplicate device id and duplicatejktgive409 conflict.- HTTP tests with the fake authenticator of M1-18 and real
authz:TestListMyDevices,TestListUserDevices_SelfOwnerOther(other person404, owner200, unknown user404),TestGetDevice_PendingOwnOnly,TestListMyDevices_PendingForbidden,TestDevices_Pagination(stable against new rows, foreign cursor400 cursor_invalid,limitaboveapi.page_size_maxcapped),TestDevice_NoNullMembers(response validation of the test build of M1-18). TestIdentitytest_NotInBinary.
Commands that must pass:
cd services/server && go test -race ./internal/identity/... ./internal/platform/httpserver/... ./internal/authz/...
make arch
make codegen-check
make migrations-lint
make config-reference && git diff --exit-code docs/operations/configuration.md
make check
Run each fuzz target once locally with go test -run=^$ -fuzz=<Name> -fuzztime=<duration of your
choice> and report duration and result; commit any crashing input as a seed after the fix.
Acceptance criteria¶
- Migration creates
devicesanddevice_keysexactly as 03 "4.2";make migrations-lintgreen. -
Registerenforces every rule of "Device registration"; one active key per purpose;jktunique;dpopandstep_upkeys differ. - Thumbprints match the RFC 9449 and RFC 8037 vectors.
-
Verifyimplements the checks and order of "DPoP proof checks"; ID-T14 cases without the Bearer case are green with the expected codes. - Nonces verify across verifier instances with the same pepper; current and previous window
accepted; proofs without nonce rejected with
use_dpop_nonce. - A proof is accepted at most once per
htuwithin the window, also under concurrency. - Fuzz targets with seed corpus run in every
go test. -
listMyDevices,listUserDevices,getDeviceare enabled, authorize withdevice.list, give404for invisible devices and403 device_pendingas specified. -
internal/kernel/josepasses the tests ADR 0067 names (RFC test vectors, negative cases, fuzz); 08 "7.3" and ADR 0067 match the implementation; DECISIONS.md has the rulings. -
make checkgreen.
Definition of done¶
Code, tests and docs are complete: the identity README (status, ports Devices and DPoP, tables
devices and device_keys, actions, config keys, test ids), a doc comment on every public symbol
(purpose, parameters, return value, errors, example where useful), comments explain why, an ADR
only if you take an architecture decision beyond ADR 0020, the CHANGELOG entry under Unreleased of
services/server/CHANGELOG.md, regenerated references. make check is green. Run an independent
review (make review and a code review over the diff) and the red team gate
(docs/quality/red-team-gate.md) over the diff; record both verdicts in the report. Merge locally
into the base branch only if the review is clean, the red team verdict is JA 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), the fuzzing runs, 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>.