docs(auth): proofs bound to the base URL and to what they approve (ADR 0017)

The full reading's part in ADR 0017 records the device's medium 1 as done
(the identity's purpose and digest, the base URL in place of the host, an
instance under a path taking no password sign-in), and "The rules as they
stand" says what "password" binds; the module's and halkyon_app's READMEs
say the same.

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-21 14:18:23 +02:00
commit 7bef70948d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 48 additions and 18 deletions

View file

@ -271,6 +271,7 @@ deletion):
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.
(Since the full reading, the instance's whole base URL in place of the host.)
- **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).
@ -471,18 +472,34 @@ refused but on this machine; the phrase is saved, never copied. Server side:
matters only once someone holding it made codes wait; L8, decoys answer today's costs
(`newPasswordCosts`), revisit when `PasswordKdf.current` rises; L9, a key proof's first
step gives out an address's public keys (step 4 shares keys: revisit then).
- **Not done yet, the device's M1:** the owner's proof is bound to the challenge and the
host, not to the operation it approves nor the instance's whole base URL; binding a
purpose label and the digest of what is set into SRP's identity on both sides, and
scheme, host, port and path in place of the host, is the next change. Until then a
`change` challenge's proof can be replayed for another signed-in change by whoever
reads and changes the traffic (plain `http://`, TLS intercepted), which the refusal of
`http://` instances (the device's part 2) narrows.
- **The device's medium 1, the owner's proof bound to the challenge and the host
alone.** Whoever read and changed the traffic (plain `http://`, TLS intercepted or
broken: what SRP is for) could keep the owner's proof and code and swap in a password,
a phrase or an authenticator of their own, or turn a proof made for new recovery codes
into a deletion; and instances on one host under another port, path or scheme could
relay each other's proofs (the device's low 4). SRP's identity is now a label, the
challenge's ID, the instance's base URL, what the proof is for and the digest of what
it sets (`passwordIdentity`, identity v2), and the phrase's proof signs the base URL
too (key proof v3). The base URL is written alike on both sides
(`canonicalInstanceUrl`: scheme and host in lower case, the port only where it isn't
the scheme's own, the path ending in `/`); the register takes only its own public
scheme, host and port at the root (`publicInstanceUrlOf`; Serverpod serves at the root,
so an instance under a path takes no password sign-in), any in development, a member's
as the member names it. The purposes are signing in, an authenticator's reset (each of
its own, so one never stands for the other), a new password (the digest of the
`NewPassword` sent), a new phrase (of its wrapping and proof key), the first or another
authenticator and removing one (of its ID), new recovery codes, calling resets off and
deleting (none); the register works out purpose and digest from the call that brings
the proof, never from the device. `OwnerProof`'s and `DeletionProof`'s `host`, and
every endpoint's, are `instanceUrl`. Each refusal has a test, which fails with the
binding taken out on both sides.
### The rules as they stand (21 Oct 2026)
Who changes what of an account, with which proofs. "Password" is SRP's proof bound to
the host the device addressed; "code" a TOTP code from a confirmed authenticator or a
the instance's base URL the device addressed, and to what it approves (the purpose and
the digest of what it sets: a proof made for one change counts for no other); "code" a
TOTP code from a confirmed authenticator or a
recovery code; "mailed code" one asked for that purpose, for the account's own address
unless said otherwise. Signed-in proofs count per session (ten wrong in a row end it);
sign-ins' codes per account and way, after the password or the phrase (waits from ten

View file

@ -71,7 +71,10 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
`showAccountRecoverySheet`, and `AccountSecuritySection` for an app's
account part. Every change of how the account is secured asks for the
password with the code, one answer per sheet (`OwnerAnswer`, the owner's
proof: a session alone gets no code checked); deleting by a code by mail
proof: a session alone gets no code checked), each answer bound to the
instance's base URL and to the change it approves (its purpose and the
digest of what it sets, `proof_digests.dart`), so nobody between the device
and the instance swaps anything in; deleting by a code by mail
follows `secondFactorWorks`, as the instance tells it, and asks for the
address only where the device doesn't know it. Anonymous people get no keys until something is encrypted
(step 4). Every build of an app needs a C compiler and make: sodium's build

View file

@ -210,13 +210,17 @@ Built on the three parts above, on the register (members forward every step thro
session. A setup never finished (never a confirmed authenticator) signs in with a code
mailed to the address it began with in its place (`request`, `code`), which calls off
a reset of the account waiting; any other account takes no code by mail there and
leaves it unused. SRP's identity I is `passwordIdentity`: the challenge's ID and the
host the device addressed (`host` on every proving call: in production it must be the
server's `apiServer.publicHost`, else `otherInstance`; in development any, as an
emulator reaches the machine as 10.0.2.2), so a hostile instance relaying a sign-in or
a phrase's proof here gets nothing taken; a member forwards its own host, and the
phrase's proof for a new password signs that password's digest
(`newPasswordDigest`). An address nobody has (or whose
leaves it unused. SRP's identity I is `passwordIdentity`: the challenge's ID, the
instance's base URL the device addressed (`instanceUrl` on every proving call, written
alike on both sides by `canonicalInstanceUrl`: in production it must be the server's
own public scheme, host and port at the root, `publicInstanceUrlOf`, else
`otherInstance`, so an instance served under a path takes no password sign-in; in
development any, as an emulator reaches the machine as 10.0.2.2), what the proof is for
and the digest of what it sets (`PasswordProofPurpose`; signing in and an
authenticator's reset each its own, with none), so a hostile instance relaying a
sign-in or a phrase's proof here gets nothing taken, nor one beside it on the same host;
a member forwards its own base URL, and the phrase's proof for a new password signs
that URL and that password's digest (`newPasswordDigest`). An address nobody has (or whose
account has no password) gets a salt and verifier from `signInDecoyKey` and the
address's hash, the same queries and arithmetic, and the same refusal: a wrong
password, an unknown address and a wrong, used or missing code are all
@ -238,7 +242,13 @@ Built on the three parts above, on the register (members forward every step thro
recovery codes, a new phrase, a new password, deleting) takes the password's SRP proof
for a challenge `password.beginChange` gave (30 begun an hour per session, holding
back no other) and then a TOTP or recovery code (`proveOwner`, `ownersCode`). A proof
given where none is taken is refused, so none passes unchecked. Each proof counts for
given where none is taken is refused, so none passes unchecked. The proof is bound to
what it approves: SRP's identity carries the purpose and the digest of what is set
(`newPasswordDigest` of the new password, `newPhraseDigest` of the phrase's wrapping
and proof key, `authenticatorDigest` of the authenticator confirmed or removed; none
for new recovery codes, calling resets off and deleting), which the register works out
from the call itself, so whoever stands between swaps nothing in and a proof made for
one change makes no other. Each proof counts for
its session alone (`owner_proof_tries.dart`, `asOwnerProofTry`, table
`session_code_failures`, kept where the session is, on a member too), before it is
checked; the tenth wrong in a row ends the session (`sessionEnded`), only a proof
@ -295,7 +305,7 @@ Built on the three parts above, on the register (members forward every step thro
neither, nor a session and a code.
- **Deleting an account with a password** (`account.delete(proof:)`, a `DeletionProof`)
takes the same: the password's proof for a `beginChange` challenge and a code, bound
to the host the device addressed (`requireOwnerToDelete`; a member passes the proof on
to the base URL the device addressed and made for deleting (`requireOwnerToDelete`; a member passes the proof on
and the register checks it), counted for the session as every owner's proof is. The
code is a TOTP or recovery code wherever an authenticator is confirmed; where no second
factor works (a setup never finished, or the authenticators' reset taken effect;