M1-39: Local accounts with password and TOTP¶
Status: ready | Depends on: M1-37 | Milestone: M1
Goal¶
After this ticket a person with a local account can set a password and enroll TOTP, and can log in with login name, password and a TOTP code as a switchable fallback for instances without an identity provider and for native apps without passkeys. Passwords are stored as Argon2id, the TOTP secret only encrypted with a key from the secret provider, every TOTP code works once, failed attempts lock only the password path, and unknown names are indistinguishable from wrong passwords in answer and timing.
Read first¶
docs/spec/08-identity.md: "Local accounts", "Mapping an external identity", "Step-up flow", "DPoP", "Login protection", "Tests" (ID-T20, ID-T21, ID-T24, ID-T27), "Decisions of this chapter" (R-ID-09, R-ID-10), "Configuration values of this chapter".docs/spec/03-data-model.md: principle 9; tableslocal_credentials,user_identities,identity_providers,auth_sessions.docs/spec/05-actions.md: "Columns Owner, Step-up, Audit, Permissions"; actionsauth.login.complete,local_credential.set,identity.read.docs/spec/07-permissions.md: "Principal context", "Flow" step 8.docs/spec/06-api.md: "Login flows" point 3, "Errors" point 5 (404versus403 feature_disabled), "Endpoints" (Auth, Me).docs/spec/16-operations.md: "Secret provider", "Secrets in the Compose stack".docs/spec/19-security.md: the secrets table (row "Key for TOTP secrets").docs/operations/configuration.md: section "Secrets".docs/spec/02-architecture.md: "Layers within a module", "Module register as the source", "Configuration loading".- ADR 0011, ADR 0019.
docs/api/openapi.yaml:loginWithPassword,setMyLocalCredential,getMyCredentials; schemasPasswordLoginRequest,LocalCredentialSet,LocalCredentialResult,CredentialOverview,Discovery.local_password_enabled.- RFC 6238 (TOTP) and RFC 4226 (HOTP): https://www.rfc-editor.org/rfc/rfc6238.html, https://www.rfc-editor.org/rfc/rfc4226.html; OWASP Password Storage Cheat Sheet (link in 08).
- Code:
internal/identity(M1-36 providers and credential overview, M1-37CompleteLogin),internal/authz(PrincipalSource, authorize step 8),internal/platform/secretsandbothy secrets init(M1-09),internal/platform/config(M1-07).
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; example passwords containEXAMPLE. Configuration values only through named keys listed indocs/operations/configuration.md. - Password hash Argon2id in PHC format with parameters
auth.argon2id_*; values below the OWASP floor (19456 KiB, 2 iterations, parallelism 1) stop the start (08 "Local accounts", ID-T24). - Minimum length
auth.password_min_length, check against a local list of common passwords, no composition rules. - TOTP per RFC 6238 with 30-second step, at most one step tolerance, every code accepted only once; the server stores the last accepted step (ID-T20, ID-T27).
- The TOTP secret is stored only AEAD-encrypted; the key lives only in the secret provider and
the row keeps
totp_key_ref(03local_credentials, 19). - No recovery codes (R-ID-09).
auth.local_password_enabled = falsemakes the password login answer404(06 "Errors").- Failed attempts per local login name:
auth.login_fail_limitperauth.login_fail_window, then the password path only is locked forauth.login_lock_duration; passkey, pairing and OIDC stay open (R-ID-10). Unknown login name and wrong password give the same answer in the same time; the server computes an Argon2id hash for unknown names too (ID-T21). local_credential.setneeds step-up once credentials exist (05).
Scope¶
The specification is in place (planning, spec first): 03 local_credentials with
fail_window_started_at and its check; docs/api/openapi.yaml (LocalCredentialSet with
optional password, totp_enroll, totp_code; LocalCredentialResult.totp_enabled; responses
and descriptions of setMyLocalCredential and loginWithPassword); 08 "2.5" (login name, TOTP
enrollment with confirmation, AES-256-GCM and auth.totp_key_ref, otpauth issuer, when the
password path counts as established, rehash, common-password list with source in the adapter
README), "13" (failure window) and "18" (auth.totp_key_ref); 07 "4.2" step 8 (conditional
with has_credentials); docs/operations/configuration.md section "Secrets" (row totp_key).
Change them only where the implementation proves them wrong (spec first, make api-lint
api-checks green). docs/DECISIONS.md: rulings listed in Design notes.
Production code under services/server/:
migrations/<next sequence>_identity_local_credentials.sql: tablelocal_credentialsexactly as in 03, including the window column from the precondition.internal/identity/adapters/postgres/queries/local_credentials.sql; regenerated sqlc code.internal/identity/domain/totp.go:TOTPCode(secret []byte, step int64, digits int) string,TOTPStep(t time.Time) int64,VerifyTOTP(secret []byte, code string, now time.Time, lastStep *int64) (step int64, ok bool).internal/identity/domain/lockout.go: pure function for the failure window and lock.internal/identity/domain/password_rules.go: length rule on the NFC-normalized password counted in Unicode code points.internal/identity/app/local.go: use casesSetLocalCredentialandLoginWithPassword; outbound portsPasswordHasher,SecretSealer,CommonPasswords.internal/identity/adapters/passwordhash: Argon2id withgolang.org/x/crypto/argon2.internal/identity/adapters/secretbox: AES-256-GCM sealer using the secret provider.internal/identity/adapters/passwordpolicy: embedded listcommon_passwords.txt(truncated hashes, Design notes) and itsREADME.mdwith source, commit, license and checksum.internal/identity/adapters/httpapi: handlersLoginWithPassword,SetMyLocalCredential; the credential overview from M1-36 fillslocal_password_setandtotp_enrolled.internal/authz: conditional step-up forlocal_credential.set(Design notes).internal/identityadapter ofauthz.PrincipalSource: methodHasEstablishedLocalCredential.- Configuration schema (M1-07):
auth.totp_key_ref(type secret_ref); start validation of the Argon2id floors. - Secret catalogue used by
bothy secrets init(M1-09; find it withgrep -rn token_pepper services/server/internal/platform/secrets services/server/cmd): addtotp_key, 32 random bytes, serviceserver. architecture/modules.yaml: vendorxcrypto: [golang.org/x/crypto/argon2]allowed foridentityadapters; regenerate.go-arch-lint.yml.services/server/api/operations.yaml: enableloginWithPassword,setMyLocalCredential.internal/identity/README.md,services/server/CHANGELOG.md,docs/REUSE.mdattribution of the password list.
Outbound ports (in internal/identity/app, names fixed):
type PasswordHasher interface {
Hash(password string) (phc string, err error)
// Verify reports a match and whether the stored parameters differ from the configured ones.
Verify(phc, password string) (ok bool, needsRehash bool, err error)
// VerifyDummy spends the same work as Verify for an unknown login name.
VerifyDummy(password string)
}
type SecretSealer interface {
Seal(identityID ids.ID, plaintext []byte) (ciphertext []byte, keyRef string, err error)
Open(identityID ids.ID, ciphertext []byte, keyRef string) ([]byte, error)
}
type CommonPasswords interface { Contains(password string) bool }
Out of scope¶
- Passkeys and the conditional step-up rule for
passkey.register(M1-40), device approval of the pending devices this login creates (M1-38). - Step-up with password and TOTP (
step_up_method = 'password_totp'): its operation comes with the console session (M6-01), spec first (08 6.2). - Discovery field
local_password_enabled(M1-46). - Re-encrypting TOTP secrets after a key change (operations task, later).
- Revoking other sessions on password change (not specified).
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-39-identity-local-accounts. - 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.mdand 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 and the single download of the password list source named in Design notes.
- 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¶
Local identity and login name: a person has a local account when an active user_identities row
points to the enabled local provider (created by bootstrap-owner or createUser with
local_account, M1-37). The login name is that row's subject; input is trimmed and lowercased
before lookup. local_credentials.identity_id references this identity.
Argon2id (passwordhash): salt 16 bytes from crypto/rand, key length 32 bytes, PHC string
$argon2id$v=19$m=<kib>,t=<iterations>,p=<parallelism>$<salt b64>$<hash b64> (standard Base64
without padding). Parameters from auth.argon2id_memory_kib, auth.argon2id_iterations,
auth.argon2id_parallelism. Verification compares in constant time. needsRehash is true when
the stored parameters differ from the configuration; a successful login then stores a new hash in
the same unit of work. VerifyDummy verifies against a hash computed once at start with the
current parameters. Start validation (also bothy config check): memory below 19456, iterations
below 2 or parallelism below 1 stop the start with a message that names the key.
TOTP:
- Secret: 20 random bytes (RFC 4226 recommends 160 bits). HMAC-SHA-1, 6 digits, step 30 seconds, T0 = 0. These constants are held by tests (ID-T27).
- Verification accepts the steps
current - 1,current,current + 1; the matched step must be greater thantotp_last_step(so a code works once and older codes never again). Compare codes in constant time. - otpauth URI:
otpauth://totp/<issuer>:<login name>?secret=<Base32 without padding>&issuer= <issuer>&algorithm=SHA1&digits=6&period=30, issuer = host ofserver.public_url, all parts URL-escaped. Reason for the host instead of the product name: the display name lives in instance settings (M1-46), which this ticket must not depend on. - Sealing (
secretbox): AES-256-GCM, key = 32 bytes resolved fromauth.totp_key_refthrough the secret provider (a different length stops the start), nonce 12 random bytes, AAD ="totp/v1" || 0x00 || identity_id (16 bytes), stored layout0x01 || nonce || ciphertext and tag. The row stores the ref that sealed it intotp_key_ref;Openresolves that ref, so a new key applies to new rows while old rows stay readable (19).
Common passwords (passwordpolicy): source is the SecLists file
Passwords/Common-Credentials/10k-most-common.txt (github.com/danielmiessler/SecLists, MIT
license) at a commit you pin; download it once with curl into a mktemp -d folder, verify that
the repository's LICENSE at that commit is MIT (if not, stop with BLOCKED), and generate
common_passwords.txt with one line per entry: the first 16 hex characters of
SHA-256(lowercase(NFC(password))), sorted, unique. Commit only the generated file, not the
source. Reason for hashes: no word list in the repository (white-label scan, readability) and a
small file. Record source URL, commit, SHA-256 of the source file and the generation command in
the adapter README; a Go test checks the file is sorted, unique and well-formed. Contains
hashes the candidate the same way.
SetLocalCredential (setMyLocalCredential, action local_credential.set, self):
auth.local_password_enabled = false:403 feature_disabled(authenticated endpoint).- No active local identity on an enabled
localprovider:409 conflictwithreason: no_local_identity. - Body rules: at least one of
password,totp_enroll,totp_code; if no row exists,passwordis required. Violations:400 validation_failedwitherrors[]. password: shorter thanauth.password_min_lengthgives codetoo_short; listed givescommon_password; else new hash,password_changed_at = now.totp_enroll: true: new secret, sealed;totp_enabled_atandtotp_last_stepNULL (the enrollment is pending until confirmed); responsetotp_secret_urionce.totp_code: verified against the current secret; success setstotp_enabled_at = nowandtotp_last_step; failure gives400 validation_failedcodetotp_invalid.- Response
200LocalCredentialResultwithpassword_set,totp_enabled, optionaltotp_secret_uri. Audit per catalogue (yes), details without any secret. - The response with
totp_secret_uriis a one-time secret: mark it with the mechanism from M1-37 (idempotency.MarkOneTimeSecret) in case a client sendsIdempotency-Key.
Conditional step-up (identical shape in M1-40; keep names exactly so the merge is clean):
- "Credentials exist" for
local_credential.setmeans: alocal_credentialsrow of the person withtotp_enabled_at IS NOT NULL(an established password path). Reason: the first set-up and its confirmation must work without step-up, and a stolen session that adds a password only gains a login whose new devices staypending. - Add to
authz.PrincipalSource:HasEstablishedLocalCredential(ctx context.Context, userID <UUID type of PrincipalSource>) (bool, error); implement it in the identity adapter; update the authz fakes. - In the authz file that implements step 8 of 07 "Flow", add (or extend, if M1-40 already
created it)
var stepUpOnlyWithExistingCredential = map[authorize.Action]func(ctx context.Context, src PrincipalSource, userID <UUID type>) (bool, error){...}with the entry forlocal_credential.set. In step 8: if the action is in the map and the function returnsfalsefor the resource's person, step-up is not required; the check "human with a session" (elseforbidden,step_up_not_possible) stays. Permission logic stays insideauthz(B7).
LoginWithPassword (loginWithPassword, action auth.login.complete, rl.public_auth):
auth.local_password_enabled = falseor no enabledlocalprovider:404 not_found.- DPoP login proof without
ath(M1-33 verifier). - One unit of work: find the local identity by login name, the person (must be
active) andlocal_credentialswithSELECT ... FOR UPDATE. - Exactly one Argon2id computation per request:
Verifywhen a row exists, elseVerifyDummy; never skip it (also not when locked), so time does not reveal accounts or locks. - Reject with the same
401 unauthenticatedbody when: no row, person not active,locked_until > now,totp_enabled_at IS NULL, wrong password, or TOTP not accepted. - Counter (only when a row exists and the account is not locked): if
fail_window_started_atis NULL ornow >= fail_window_started_at + auth.login_fail_window, start a new window (fail_window_started_at = now,failed_count = 1), elsefailed_count + 1. Whenfailed_count >= auth.login_fail_limit:locked_until = now + auth.login_lock_duration,failed_count = 0,fail_window_started_at = NULL, audit entry deny with details{lock: true}. The counter update commits; every rejection is audited (aggregation by M1-24). - Success:
totp_last_step= matched step, reset counter fields andlocked_until, rehash if needed, thenCompleteLogin(M1-37) withTrustUnconfirmed,AuthMethod = "password_totp",IdentityID= the local identity. New devices arepending; a known active device with the same key stays active.201TokenResponse.
Configuration: new auth.totp_key_ref; used auth.local_password_enabled,
auth.password_min_length, auth.argon2id_memory_kib, auth.argon2id_iterations,
auth.argon2id_parallelism, auth.login_fail_limit, auth.login_fail_window,
auth.login_lock_duration, server.public_url, authz.not_found_timing_tolerance (tests).
Rulings to record in docs/DECISIONS.md: TOTP confirmation step and the API change; how authz
learns "credentials exist" (PrincipalSource; the rule itself is Step-up conditional in
05 2.4); issuer from server.public_url; AEAD scheme and AAD; password list source and hashed
storage; failure window column; one Argon2id computation per request.
Tests (write them first)¶
Tests use the Argon2id floor parameters (19456, 2, 1) to stay fast; one test uses the defaults.
Unit:
TestIDT27_TOTPVectorsRFC6238: the SHA-1 vectors of RFC 6238 Appendix B with 8 digits (secret: the ASCII string "1234567890" repeated twice, 20 bytes, as in the RFC; T = 59 gives94287082, 1111111109 gives07081804, 1111111111 gives14050471, 1234567890 gives89005924, 2000000000 gives69279037, 20000000000 gives65353130); step size 30 seconds and tolerance of exactly one step.TestIDT20_TOTPCodeOnlyOnce: same code twice in the same step, second rejected; a code of the previous step after a newer step was used, rejected.TestTOTPWindowEdges: steps -2 and +2 rejected, -1 and +1 accepted.TestLockoutWindow: table over window start, expiry, limit reached, lock expiry.TestPasswordRules: NFC normalization, code-point length, list hit.TestPHCFormatAndRehash: parse, parameter mismatch givesneedsRehash.TestSecretBoxBindsIdentity: a ciphertext opened with another identity ID fails; a wrong version byte fails; tampering fails.TestCommonPasswordsFile: sorted, unique, 16 hex characters per line; a known listed password (taken from the source file during generation and recorded in the test) is found.TestIDT24_Argon2BelowFloorStopsStart: each of the three keys below its floor makes config validation fail and names the key; floor values pass.
Integration (pgtest, controllable clock):
TestPasswordLoginHappyPath: set password, enroll and confirm TOTP, log in;201,auth_method: password_totp, new devicepending.TestPasswordLoginKnownDeviceStaysActive.TestIDT21_UnknownNameTimingEqualsWrongPassword: interleaved samples (at least 31 each) of unknown login name and known name with wrong password; equal status and body (exceptcorrelation_id); medians differ by less thanauthz.not_found_timing_tolerance.TestLockedAccountStillHashes: while locked, a correct password is rejected with the same body and the hasher was called once (counting fake).TestLockoutOnlyPasswordPath: after the limit the password login is rejected forauth.login_lock_duration; redeeming an enrollment code (M1-37) still works.TestPasswordDisabledGives404:auth.local_password_enabled = false: login404,setMyLocalCredential403 feature_disabled.TestTOTPSecretEncryptedAtRest:totp_secret_ciphertextdoes not contain the secret bytes;totp_key_refequals the configured ref.TestTOTPEnrolmentNeedsConfirmation: login before confirmation rejected; aftertotp_codeaccepted.TestLocalCredentialStepUpOnlyWhenEstablished: first set and confirm without step-up200; later change without fresh step-up401 step_up_required; with step-up200; personal token403.TestSetLocalCredentialValidation:too_short,common_password, empty body, first call without password, no local identity409.TestPasswordAndTOTPNeverLogged: captured logs and audit details contain neither password, TOTP code, secret nor otpauth URI.TestCredentialOverviewFlags:local_password_setandtotp_enrolledingetMyCredentials.
Commands that must pass:
make api-lint api-checks codegen-check arch migrations-lint config-doc-check docs licenses
cd services/server && go test -race ./internal/identity/... ./internal/authz/... ./internal/platform/config/...
make check
Acceptance criteria¶
- Migration matches 03 (including
fail_window_started_atand its check). - ID-T20, ID-T21, ID-T24 green and named after their IDs; the TOTP part of ID-T27 green.
- Password login answers
404when switched off; unknown names and wrong passwords are indistinguishable in body and timing. - Lock after
auth.login_fail_limitfailures withinauth.login_fail_windowaffects only the password path. - TOTP secrets are encrypted with a key from the secret provider;
bothy secrets initcreatestotp_key. - Step-up for
local_credential.setonly once a local credential is established, decided inauthz. - New dependency
golang.org/x/cryptopinned ingo.mod, license allowed (make licenses). -
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.
- READMEs (
internal/identity,adapters/passwordpolicy),services/server/CHANGELOG.mdentry under "Unreleased",docs/REUSE.mdattribution. - Specification, API and configuration changes 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: guessing, lockout abuse, timing, secrets at rest); 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. If M1-40 merged first, keep both entries ofstepUpOnlyWithExistingCredentialand bothPrincipalSourcemethods.
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, the pinned password-list commit and
checksum, what is not proven, and follow-ups that could not be done in scope with the reason. Then
write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.