Skip to content

M1-39 Local accounts with password and TOTP

No Date Ruling Reason
M1-39/1 2026-10-14 Base int/m1-wave13 at 82693e5, branch feat/M1-39-identity-local-accounts in its own worktree; not merged locally and not pushed, the PR lane ports it. If M1-40 enters first, both entries of stepUpOnlyWithExistingCredential and both PrincipalSource methods stay. Instructions of the launcher, which override the brief's merge step (as M1-37/1).
M1-39/2 2026-10-14 TOTP is enrolled in two steps as the spec states (08 section 2.5, LocalCredentialSet with password, totp_enroll, totp_code): totp_enroll seals a new 20-byte secret, sets totp_enabled_at and totp_last_step to NULL and returns the otpauth URI once; a valid totp_code sets totp_enabled_at (if NULL) and the step. The members of one request apply in the order password, totp_enroll, totp_code, and any rejected member rolls the whole request back; a re-enrollment of an established path makes it pending again until confirmed. The URI is marked with idempotency.MarkOneTimeSecret. Brief "SetLocalCredential"; the spec counts the path as established only with a confirmed secret, so a new, unconfirmed secret cannot keep it established.
M1-39/3 2026-10-14 authz learns "credentials exist" from PrincipalSource.HasEstablishedLocalCredential(ctx, userID) (a row of an active identity of the person with totp_enabled_at IS NOT NULL). The map stepUpOnlyWithExistingCredential lives in the new file authz/app/stepup.go: step 8 itself is in the pure authz/domain/decide.go, which may not call a port, so gather asks the source for the person of the resource (its id for a user resource, else its owner) and sets Input.NoCredentialYet; decidePrincipalRules then skips only the freshness check of a conditional action, never the check "human with a session". passkey.register stays "yes" until M1-40 adds its entry. 07 section 4.2 step 8 and B7 (the rule stays in authz); the brief fixes the map name and signature for a clean merge with M1-40.
M1-39/4 2026-10-14 The otpauth issuer is the host of server.public_url; label issuer:login name, every part URL-escaped. Brief "TOTP": the display name lives in the instance settings (M1-46), which this ticket must not depend on.
M1-39/5 2026-10-14 AEAD scheme of the TOTP secret: AES-256-GCM, key = the 32 bytes behind auth.totp_key_ref as hex (the form bothy secrets init writes for totp_key, generator key), 12-byte random nonce, AAD "totp/v1" \|\| 0x00 \|\| identity_id, stored 0x01 \|\| nonce \|\| ciphertext and tag, the sealing ref in totp_key_ref. The start resolves the current key once in the identity step (wire.TOTPBox); a missing key or another length stops it. Open of a row with another ref resolves that ref through the secret provider (bounded by 5 seconds, its port has no context). Brief "Sealing", 19 secrets table: a new key applies to new rows while old rows stay readable; the identity in the AAD keeps a copied ciphertext from opening for another person.
M1-39/6 2026-10-14 Common passwords: SecLists 10k-most-common.txt at 3a5f62f (MIT), stored only as the first 16 hex characters of SHA-256(lowercase(NFC(password))), sorted and unique (adapter README). The NFC normalization lives in adapters/passwordpolicy (vendor group xtext now also allows golang.org/x/text/unicode/norm); domain.CheckPassword gets the NFC form, and app gets the extra outbound port PasswordNormalizer beside the three ports the brief fixes. The domain and app layers may not import vendors (02 "Layers within a module"); no word list in the repository (white-label scan, size).
M1-39/7 2026-10-14 Failure window: the column local_credentials.fail_window_started_at (03) starts with the first failure; a failure at or after its end starts a new window; reaching auth.login_fail_limit sets locked_until, clears count and window and writes a deny entry on the person with {lock: true, identity_id} in the same unit of work as the counter. A wrong TOTP code with the right password, a person that is not active and a path without confirmed TOTP count as failures of an existing row; a locked row counts nothing. Every rejection is one aggregated denial {cause, identity_id} (no identity for an unknown name), never the login name, the password or the code. 08 section 13 (failures per account: password and TOTP), brief step 6; the login name could be a mistyped password.
M1-39/8 2026-10-14 Exactly one Argon2id computation per rejected request: Verify for an existing row, also while locked, else VerifyDummy against a hash computed at start with the current parameters. Only a successful login with changed parameters computes a second hash (the rehash) after the verification. TestIDT21_UnknownNameTimingEqualsWrongPassword takes the samples in pairs back to back and compares the median of the pair differences, which cancels the drift of a loaded machine; without the race detector it measures the real Argon2id, under it (as in make check) Argon2id is instrumented to hundreds of milliseconds with a spread far above authz.not_found_timing_tolerance, so the test swaps in a hasher of constant cost and measures the database work, where an unknown name could still differ. TestDummyUsesConfiguredParameters, TestStaleParametersCostLikeUnknownNames and the case TestLockedAccountStillHashes hold the one computation of equal cost. The integration cases run as subtests of TestLocalAccounts on one world, as M1-44/13, and the timing test runs in parallel with the package. 08 section 13, ID-T21; a rehash only happens after the password was proven, so it reveals nothing about unknown names.
M1-39/9 2026-10-14 The Argon2id floors are the minimums of the configuration keys (auth.argon2id_memory_kib 19456, auth.argon2id_iterations 2, auth.argon2id_parallelism 1), so the start and bothy config check stop with a problem that names the key (ID-T24); the hasher refuses them too. Upper bounds keep a configuration from exhausting the server: memory 4194304 KiB, 1000 passes, parallelism 255 (the limit of the library), auth.password_min_length 1024. Brief "Argon2id"; the configuration loader already reports every key problem before the start.
M1-39/10 2026-10-14 wire.(*Identity).BindLocal(sealer, hasher) builds the local accounts after BindSessions in the start step sessions; nil builds the Argon2id hasher of the configuration, tests pass a counting one. bothy setup does not build them. The allow entry of local_credential.set carries {identity_id, password_changed, totp_enroll_started, totp_confirmed}. The login completion of BindSessions is a collaborator; the host commands never log in with a password.
M1-39/11 2026-10-14 The login verifies the password on a read of identity, person and row without locks and only then opens the unit of work, which reads them again with the row FOR UPDATE and the provider and identity share-locked, and decides there (lock, counter, TOTP step, login). A row whose hash changed in between is rejected without counting (cause credential_changed); an unknown name runs the same lookups in its unit of work. This deviates from step 3 of the brief, which locks the row before the hash. With the lock held during Argon2id, parallel attempts on one known name queued behind each other while those on an unknown name did not, so the time revealed accounts and an attacker could hold the row of a person; the counter stays exact because it is decided under the row lock (code review M1-39).
M1-39/12 2026-10-14 Every verification of passwordhash lasts at least the running average cost (weight 1/4) of the most expensive parameter set it has seen; the average of the configured parameters is learnt at construction. Code review M1-39 (Major): after a change of auth.argon2id_* the rows keep their old parameters until their next login rehashes them (08 section 2.5), so without padding a wrong password on such a row took another time than an unknown name, which verifies with the configured parameters.
M1-39/13 2026-10-15 Port onto main: code and tests are taken byte for byte from the integration squash with the integration fixes INT-W18/1 to INT-W18/7, apart from the points named here. The integration base also holds M2-39 and M2-40 (terminal), which are not on main, so nothing of them is ported: setHostState stays out of the enabled list of api/operations.yaml, the host route rows and the Hosts handler stay out of api/routes/real_test.go and wire/routes.go, and the host and terminal tables stay out of migrations/privileges_test.go. The migration is renumbered to follow 00027_chat_thread_tags.sql without a gap: 00029_identity_local_credentials.sql (INT-W18/3) becomes 00028_identity_local_credentials.sql, with the number in the changelog and the module README, and its checksum is recorded. The comments of the migration, its test, queries/local_credentials.sql, authz/sources.go, authz/app/rules_test.go and the new identity packages use the English chapter files and terms (03-data-model.md, 08-identity.md, 03 principle 9, the condition has_credentials of 07 section 4.2, the step-up value conditional), so the checksum of the migration differs from the integration file. The multi-byte cases of TestPasswordRules use Greek letters instead of German umlauts, because the language gate (D-W-94) rejects umlauts in tracked files; they still count twelve and eleven code points of more than one byte. the new keys of wire/config_test.go name the chapter 08-identity.md. The changelog entry, the module README (status line, port list and the new section on local accounts), the secrets stack README, the key rows and the generator paragraph of docs/operations/configuration.md, the new section of docs/REUSE.md and these rulings are written into the English text at the same places. Generated files are regenerated with the generators of the ported tree, not merged. main is English, holds M1-38 to M1-55 but not M2-39 or M2-40; the migration numbers of main must follow without a gap.
M1-39/14 2026-10-19 The field error code constants PasswordTooShort and PasswordCommon in identity/domain/password_rules.go carry NOSONAR. SonarQube Cloud reports the name PasswordTooShort as a possible hard-coded credential (Go rule S2068) and rates new code C for security; the values are the public error codes too_short and common_password of the API, not secrets.
M1-39/15 2026-10-19 Port onto main after M2-39: the migrations of this ticket and of the M1 tickets ported after it follow 00028_terminal_hosts.sql, each one number higher than on the integration line. M2-39 landed first with migration 00028.