docs(auth): what the second reading found, and what was done (ADR 0017)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-20 22:00:15 +02:00
commit 1908e4f362
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -113,7 +113,8 @@ through `AccountRegister`, the session being theirs.
wrong password, an unknown address, and a wrong, used or missing code;
`setupIncomplete` (no confirmed authenticator) and `notDue` (a reset waiting) only
after a right password. Begun sign-ins are limited to 10 an hour per address and 30
per network; challenges last two minutes and are taken once.
per network (per address and network since the second reading, below); challenges
last two minutes and are taken once.
- **Per-network limits count by the visitor's address** (`networkOf`): behind a
reverse proxy every connection is the proxy's, so for a connection from a proxy the
operator trusts (`TRUSTED_PROXIES`, Caddy's fixed address in `deploy/compose.yaml`) the
@ -227,6 +228,69 @@ released, stays at 1), after the second reading.
C compiler and make for sodium's build hook (Linux, Android's NDK, Xcode, Visual
Studio; the snap's build and CI's runner too).
## Second reading (20 Oct 2026)
An independent reading of everything above (`f6dfbbe..` the branch) found nothing
critical; what it found, and what was done (the developer decided M3, M4, M8 and
deletion):
- **H1, high: the phrase's proof was the account's own key.** A key proof was a
signature by the account's signing key, which every signed-in device keeps: a readable
keyring and one TOTP code set a new password, without the password or the phrase. The
phrase now proves itself with an Ed25519 key derived from its bits alone (halkyon_keys'
`recoveryProofKeysOf`, KDF context `hlkrprf1`), its public half sent with every setup,
new phrase and account reset and kept as `account_password.recoveryProofKey`
(nullable: an account without one proves no phrase until it sets a new one); the device
derives the secret half only while the phrase is typed.
- **M1: decoy keys were random bytes**, a real X25519 key one time in sixteen, so an
address with an account could be told apart; decoys' keys are now made from seeds.
- **M2: the password alone, once an authenticator reset took effect, left an account
reset waiting**, which the mailbox's holder could carry out after the owner was back;
every sign-in with the password now calls it off.
- **M3: anyone knowing an address could spend its hour.** Sign-ins begun count per
address and network (10), phrase proofs likewise (5), networks as before (30); failed
proofs count per address from every network, 30 an hour, past which nothing begins for
it (so many networks together make 30 guesses an hour, and holding an owner out takes
failing without end from at least three; longer windows would outlive the hourly
pruning of the limits' notes); changes begun, 10 an hour per account. Codes by mail:
3 an hour per address and network, 10 per address from every network, 10 per network,
and up to 3 valid at once, a fourth voiding the oldest.
- **M4: codes could be guessed forever.** Codes refused after a right password (or from
a signed-in device) count per account; after every tenth in a row no code is taken
(`codesWait`), an hour, then twice as long, a day at the most, the first wait mailed
to the address where the device sent it (sign-in and the phrase's reset; the instance
keeps none). At a day, ten guesses a day keep about a 1 % chance a year against one
authenticator: the mail is what tells its owner to change the password.
- **M5: IPv6 visitors.** Counted by their /64; and Caddy's network gets IPv6 (a unique
local /64, Caddy's address in it trusted too), as Docker's userland proxy showed every
IPv6 visitor as the gateway (reasoned, to be checked on the server: NEXT.md), in a
commit of its own, as it needs the network made anew.
- **M6: nothing bound a proof to the instance.** SRP's identity is now a label, the
challenge's ID and the host the device addressed (`passwordIdentity`), and the phrase's
proof signs that host, its purpose and the new password's digest (key proof v2); in
production the host must be the server's `apiServer.publicHost` (`otherInstance`
otherwise), any in development; members forward their own. A hostile instance relaying
a sign-in or a phrase's proof to the real one, or swapping in a password, gets nothing.
- **M7: data keys weren't authenticated.** A sealed data key is now signed by its sealer
and checked against the expected one on opening, so the register can't plant a key of
its own (before step 4 encrypts anything).
- **M8: passwords had no minimum.** New ones have at least 12 characters (code points,
once normalised), checked on the device; signing in takes any.
- **Deletion:** an account with a password deletes itself only with its proof and a code
(`DeletionProof`), as a change takes; others with their session, as before.
- **Lows:** a member waits at most 5 seconds for the register's moment after a reset
(`maxClockSkew`), refusing beyond (clocks must agree by NTP; a member's clock ahead by
less leaves sessions made in that gap); `instanceSecret` stops the server when shorter
than 32 characters; a change of password, a reset keeping the keys and a new phrase
write their own columns alone.
- **Left as they are:** the decoy salt never changes (an address's salt changes when it
gets an account or a new password, which polling `begin` could see, as any SRP's
does); SRP's x and the key-encryption key stay as plain bytes while a person types a
code; the key proof's first step still gives anyone the account's public keys and its
keys wrapped under the phrase (256 bits); an attacker failing from three networks or
more can still hold an owner's sign-ins out for an hour at a time, and three requests
void a code its owner is typing.
## Consequences
- People keep a password, an authenticator app and a recovery phrase: more to keep than a