2026-10-20 11:17:31 +02:00
# ADR 0017: A password, TOTP and the account's keys
- **Status:** decided 20 Oct 2026, by the developer (D1– D5 below).
- **Decided before:** 19 Oct 2026, the developer: people sign in with a password that never
leaves the device (SRP) and a mandatory TOTP code; the password unlocks the account's keys;
recovery codes and a recovery phrase; Sign in with SimpleLogin later, as an extra way in.
- **Builds on:** ADR 0002 (the content envelope: `keyId` , `nonce` , `payload` ), ADR 0010
(email sign-in), ADR 0016 (the register).
2026-10-21 20:37:39 +02:00
- **Followed by:** ADR 0019 (private studysets), the first data encrypted with these keys;
ADR 0018 (the agenda).
2026-10-20 11:17:31 +02:00
## Context
Todo is to be the agenda of every Halkyon service, both ways (ADR 0016), and what people
plan there may need to be encrypted end to end later: readable on their devices, never by an
instance. Proton does this with one account key pair per person, unlocked on the device by a
key derived from the password, and random keys per store (a calendar, a vault) wrapped with
it; times a server must index stay readable but signed.
Today people sign in with a code by mail (ADR 0010), GitHub, or no account. No secret exists
that the instance doesn't also know, so nothing could be encrypted for the person alone.
## Proposal
1. **Signing in ** (identified accounts): the email address (kept as its keyed hash, as ADR
0010 keeps it), a password and a TOTP code. The password never leaves the device. It
replaces email codes for signing in outright (nobody signs in with them yet); a code by
mail serves only resets (5, 6).
2. **The protocol (D1). ** SRP-6a (RFC 5054, the 3072-bit group, SHA-256), as Proton runs it:
the device proves it knows the password without sending it or anything that would sign in
in its place; the register keeps a verifier. Its private value is derived from the
password with Argon2id, so guessing a leaked verifier costs Argon2id per guess.
Implemented in Dart (BigInt), held to RFC 5054's test vectors, and reviewed before release.
3. **The keys. ** At sign-up the device makes the account's key pairs (X25519 to encrypt,
Ed25519 to sign) and encrypts their private halves (XChaCha20-Poly1305) with a key derived
from the password (Argon2id, a salt per account, parameters a phone manages in about a
second; HKDF splits it from SRP's value). The register keeps the encrypted private keys,
the public ones and the salt; it can never use them.
4. **Data keys ** are random per store (Todo's agenda, later an app's space), sealed to the
account's public key, so every device that unlocks the account opens them; sharing with
someone later seals them to that person's key too, as Proton shares a calendar. Content
goes into ADR 0002's envelope (`keyId` , `nonce` , the encrypted `payload` ), signed by its
author; what a server must read (a reminder's time) stays readable and signed.
5. **TOTP ** (RFC 6238: SHA-1, 6 digits, 30 seconds, one step either way), mandatory for every
identified account; its secret kept by the register, encrypted with a key of the instance's
2026-10-20 12:28:56 +02:00
(a new password, `totpKey` ). Ten recovery codes, shown once, kept as HMAC-SHA256 under a key
of the instance (`recoveryCodeKey` , outside the database: a code's 40 bits leak nothing
from a database without it), each used once.
2026-10-20 11:17:31 +02:00
6. **Recovery. ** A recovery phrase (24 words, BIP-39's English list: 256 bits) also wraps the
private keys: a lost password with the phrase keeps everything. Without it, the address
(a code by mail) resets the account: new keys, and what was encrypted with the old ones
is lost, as Proton warns. A lost TOTP device: a recovery code (D3).
7. **Libraries. ** On devices libsodium through `sodium` and `sodium_libs` (Android, iOS,
Linux, Windows, macOS: Argon2id, X25519, XChaCha20-Poly1305, Ed25519); on servers only SRP's
arithmetic, SHA-256 and HMAC (`crypto` ): no server ever decrypts a key.
8. **Where it lives: ** with the register (ADR 0016); a member forwards SRP's two steps to it.
9. **Anonymous accounts ** stay without a password. Their device makes the key pairs itself
and keeps the private halves in its keyring, so their private studysets are encrypted as
anyone's; setting an address and password makes them identified, wrapping those keys with
the password and handing them to the register.
10. **Private or not, per studyset ** (D5): a studyset, with everything inside it, is private
(end to end encrypted with a data key of its own) or not, chosen when it is made and
changeable later (the device re-encrypts or decrypts it and syncs it anew). New ones are
private. A private studyset can't be published, read by the Claude connector, counted for
"learn this here" or checked by the instance's LanguageTool; asking an AI assistant from
the device (Lumo's hand-off) works as before. Other apps choose per store alike; Todo's
agenda is always private.
11. **GitHub sign-in is deprecated ** (D2): people who sign in with it add an address, a
password and TOTP at their next sign-in; three months after the release GitHub signs no
one in any more and the instance forgets the GitHub IDs.
## Decisions (20 Oct 2026, the developer's)
- **D1** SRP-6a, as decided on 19 Oct (over OPAQUE, which has no Dart implementation, and
Bitwarden's hash, which signs in in the password's place should TLS be broken).
- **D2** GitHub sign-in is deprecated: its people set an address, a password and TOTP at
their next sign-in; it ends three months after the release (11).
- **D3** A lost authenticator without recovery codes, as Proton: the password plus a code by
mail (or the recovery phrase) turns TOTP off, but only 72 hours later, with a mail at the
start, and called off if the person signs in meanwhile; several authenticators may be
linked.
- **D4** Nobody signs in with email codes yet, so the password replaces them outright (1).
- **D5** Privacy is the person's choice per studyset, private by default (10).
2026-10-20 12:28:56 +02:00
- **Also (20 Oct, at the crypto core's review):** recovery codes kept under the instance's
key (5); passwords taken as RFC 8265's OpaqueString (other spaces to a plain space, NFC,
control characters refused) before Argon2id, so a password unlocks alike on every system.
2026-10-20 14:27:40 +02:00
- **Also (20 Oct, before the apps' screens):** they come with build 22, after the second
reading, not build 21 (which ships with password sign-in off on learn.halkyon.eu, its
keys unset); sign-up shows the recovery phrase once and goes on once three of its words,
chosen at random, are typed back; the unlocked private keys stay in the system's keyring
after signing in, as sessions do, and go with signing out; changing the password while
signed in takes the old password (SRP) and a TOTP code.
2026-10-20 11:17:31 +02:00
2026-10-20 13:32:21 +02:00
## Settled in building the register's side (20 Oct 2026)
In `halkyon_base_server` (its README has the details); members forward every step
through `AccountRegister` , the session being theirs.
- **Sign-up** (`password.set` ) needs an address: the person's own, or one a mailed code
proves in the same call, which makes an anonymous person identified under their ID.
The password signs in only once an authenticator is confirmed; until then a new setup
2026-10-20 14:16:58 +02:00
replaces it, but never once one was, also while a reset leaves the account without
one (a reset's row stays until a new authenticator is confirmed): a session got
another way would skip the reset's 72 hours. The register checks what it can: salt 16 bytes, Argon2id `argon2id13` at
2026-10-20 13:32:21 +02:00
3– 16 passes and 64 MiB– 1 GiB, the verifier 1 < v < N padded to N, public keys 32 bytes,
each wrapping a 24-byte nonce and 112 bytes.
- **SRP's identity I is the sign-in's challenge ID**, not the account's: x comes from
Argon2id over the password and salt, so I only binds the proof to this sign-in, and
an account ID handed to whoever types an address would tell who has one.
- **Addresses nobody has** (or whose account has no password) get a salt and verifier
from a third instance key, `signInDecoyKey` , and the address's hash, the same queries
and modular arithmetic (both are computed for every sign-in), a challenge row of their
own, and in the second step the same refusal. One refusal, `wrongCredentials` , for a
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
2026-10-20 22:00:15 +02:00
per network (per address and network since the second reading, below); challenges
last two minutes and are taken once.
2026-10-20 14:16:58 +02:00
- **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
last address of `X-Forwarded-For` , the one the proxy saw itself; never the `Forwarded`
header, which Caddy passes on as sent. Serverpod's own limit on anonymous accounts
counts by the connection and is off; the anonymous endpoint counts them itself.
(Found reviewing this step: until then every per-network limit, anonymous sign-ups'
included, was the whole instance's.)
2026-10-20 13:32:21 +02:00
- **TOTP secrets** are sealed with XChaCha20-Poly1305 (pub.dev's `cryptography` , pure
Dart, pinned) under a key derived from `totpKey` , bound to the row's account and ID;
each step's code passes once (a conditional update of the last step). At most 10
authenticators; one not confirmed within a day is pruned; the last confirmed stays;
2026-10-20 14:16:58 +02:00
removing one, or new recovery codes, takes a code (10 tries an hour per account), and
so does adding one while a confirmed one exists (a code from that one too, decided by
the developer on 20 Oct): a session alone adds no second factor of its own.
2026-10-20 13:32:21 +02:00
- **Resets** (D3): an authenticator reset takes effect 72 hours after the password and
a mailed code or the recovery phrase ask, with a mail to the address at once (none
sent, none started); a sign-in with a code calls off every reset waiting. Once due,
the first sign-in with the password alone carries it out (authenticators and recovery
codes go), and the password alone signs in until a new authenticator is confirmed.
fix(auth)!: the recovery phrase is proved by a key of its own
A key proof was a signature by the account's signing key, which every
signed-in device keeps in its keyring: whoever could read one, with a
code from its authenticator, set a new password without the password or
the phrase (the second reading's H1). The phrase now proves itself with
an Ed25519 key derived from its bits alone (halkyon_keys'
recoveryProofKeysOf, KDF context hlkrprf1, a sibling of the recovery
key), whose public half every setup, new phrase and account reset sends
and the register keeps (account_password.recoveryProofKey); the device
derives the secret half only while the phrase is typed. An account
without one (none exist outside development) proves no phrase until it
sets a new one; decoys check against a decoy proof key alike.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_password" ADD COLUMN "recoveryProofKey" bytea;
nullable, so rows made before keep working and no data is touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:03:15 +02:00
The recovery phrase is proved by a signature over `halkyon key proof v1|` , the
challenge's ID and a nonce, by the phrase's own proof key (since the second reading:
see below). A password set with the
2026-10-20 13:32:21 +02:00
phrase, or an account reset, ends the account's sessions on the register's server.
- **The account reset without the phrase waits 72 hours too, with a mail at the start**
(a mailed code asks, a new one carries it out once due; a sign-in with the password
2026-10-20 14:16:58 +02:00
calls it off; confirmed by the developer on 20 Oct). Carried out: a new password and
new keys, no authenticators.
2026-10-20 13:32:21 +02:00
- **Keys:** without all three keys (or without email sign-in) the instance offers no
password sign-in (`passwordSignIn` in its basics), so an instance can take these
releases before its operator makes keys; a key set but shorter than 32 characters
stops the server.
2026-10-20 14:16:58 +02:00
- **GitHub** (D2): an account with a password that had a confirmed authenticator (one
now, or a reset standing: `SignInWays.passwordOnly` ) is refused (`usePassword` ), one
without signs in as before and the app leads to setting up an
2026-10-20 13:32:21 +02:00
address, a password and an authenticator (its ways in say what is missing): a refusal
there would lock out GitHub's people on a new device, as nothing else signs them in.
Where passwords are offered, no new GitHub account is linked (`deprecated` ). From
`GITHUB_SIGN_IN_ENDS` (a day the operator sets) GitHub signs nobody in (`ended` ), the
basics stop offering it, and the daily pruning forgets every GitHub ID.
- **Email codes** still sign in (until step 3), but not into an account with a password
2026-10-20 14:16:58 +02:00
that had a confirmed authenticator (`usePassword` ), which a code alone would bypass.
2026-10-20 13:32:21 +02:00
2026-10-20 19:34:02 +02:00
## Settled in building the apps' side (20 Oct 2026, step 3)
On branch `password-sign-in` , for build 22 (`minimumAppBuild` 22 in Learn; Todo, never
released, stays at 1), after the second reading.
- **Codes by mail sign nobody in** (D4): `emailCode.verifyCode` , `emailSignIn.signIn`
and the switch token went, with the register's operations for them. `emailCode.sendCode`
stays, without a session, as what proves an address: for `password.set` , a sign-up,
a setup never finished and the resets (`accountRecovery.sendCode` went).
- **Ways in for accounts without a working password:** `passwordSignIn.signUp` (a code
and a setup, no session) makes a new account at an address nobody has, or gives the
account with that address and no password (codes by mail before; no keys exist, so
nothing waits) its password, keeping its ID. A setup never finished signs in with its
password and a code mailed to the address the sign-in began with, until an
authenticator is confirmed, and that calls off an account reset waiting (a reset of
the account waiting no longer counts as having had an authenticator, so its owner
isn't locked out). Never for an account that had a confirmed authenticator.
- **Changing while signed in:** the password (`password.beginChange` , `password.change` :
the old password's SRP proof, a TOTP or recovery code, the same keys rewrapped on the
device; every other session ends) and the recovery phrase (`password.setRecoveryKeys` :
2026-10-20 20:01:07 +02:00
the keys wrapped under a new phrase, with the old password's proof and a code, as a
change of password takes). A session alone changes neither, nor a session with the
authenticator beside it (a phone signed in): its holder's own phrase would set a new
password through `resetPasswordWithPhrase` (found at step 3's review, 20 Oct).
2026-10-20 19:34:02 +02:00
- **Sessions end in every service:** a change of password ends every other session of
the account, a reset of the password or the account all of them, on the register and
on every member, by a notice kept until each confirms (`sessions_ended_notice` , a new
table; hourly with the deletions' notices). A notice names a moment, so sessions made
since stay: a member signing someone in after a reset makes the session once its clock
is past it.
- **A reset not due says when** it takes effect (`takesEffectAt` on the refusal), and the
basics say when GitHub sign-in ends (`gitHubSignInEnds` ), which apps show.
- **The apps** (halkyon_app, Learn and Todo): signed out, Sign in and Create an account
(and GitHub, its end date beneath); signing in asks the address and password (Argon2id
on the device, about a second), then a code from the authenticator or a recovery code;
M2 is checked and the keys opened before the session is put in place. An account
never finished is mailed a code; one whose authenticators' reset took effect signs in
without a code; both then set up an authenticator with the session held back, and put
it in place only after. Creating an account: address, password twice, the recovery
phrase shown once (copy, save as a file) and three random words typed back, then the
mailed code (nothing reaches the instance before), the authenticator (a QR code of the
`otpauth://` URI, drawn by the app with pub.dev's `qr` , BSD-3-Clause, pinned; its key
as text; on phones Open in authenticator app; considered: `qr_flutter` and
`pretty_qr_code` , Flutter widgets on the same encoder) and the ten recovery codes
(copy, save). Getting back in: with the phrase (a new password, the keys staying), a
reset of the account asked for by a code (72 hours), and finishing it (a new code; new
keys and a new phrase, or with the phrase the old keys rewrapped, as the account's
reset keeps them for someone who lost both password and authenticator). The account
part (`AccountSecuritySection` ): setting up a password (anonymous people, GitHub's, an
account with only an address), finishing the setup, the authenticators (adding one
takes a code from one before), new recovery codes, changing the password, a new
2026-10-20 20:04:37 +02:00
phrase (with the password and a code); GitHub's end date.
- **The device holds the costs to the register's bounds:** Argon2id's costs an instance
names (as it begins a sign-in or a change) are refused below the floor (3 passes,
64 MiB: guessing would be cheaper) and above the ceiling (16 passes, 1 GiB, the
register's own: a hostile instance could ask for any memory and crash the app), before
anything is derived or sent (`PasswordKdf.maximum` , `KeyRefusal.excessivePasswordKdf` ;
the app's `unsafeCosts` , found at step 3's review, 20 Oct). The two bounds move
together with the register's.
2026-10-20 19:34:02 +02:00
- **The keys on the device:** unlocked after signing in, kept in the system's keyring
per instance and person (flutter_secure_storage), gone once another person signs in or
nobody, and with the instance's removal from the device. Anonymous people's device keys
wait for step 4: nothing is encrypted before it.
- **libsodium on devices:** sodium's results (salts, public keys, cipher texts,
signatures) are views of native memory its finalizers free once the isolate that made
them ends; each step runs on an isolate of its own, so whatever crosses back is copied
first (a view read freed memory and crashed the process). Every build of an app needs a
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).
2026-10-20 13:32:21 +02:00
2026-10-20 22:00:15 +02:00
## 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.
2026-10-21 14:18:23 +02:00
(Since the full reading, the instance's whole base URL in place of the host.)
2026-10-20 22:00:15 +02:00
- **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.
2026-10-20 23:41:41 +02:00
### Re-reading the fixes (20 Oct 2026)
A second independent reading, of the fixes above (`7071933..1383640` ), found H1, M1, M2,
M5 (in the code), M6, M7, M8, deletion and the lows fixed, M3 and M4 incomplete, and
these; all but the ninth were fixed (the developer's choice):
1. **Codes sent at once all got checked ** before the tenth refused began a wait. Every
attempt at a code for an account is now counted before it is checked, one at a time
per account (a transaction holding an advisory lock on its key), the tenth in a row
beginning the wait as it is counted: of codes sent at once at most ten are checked.
2. **The ceiling of proofs per address was looked at only as a sign-in began, and
counted only wrong passwords**: sign-ins begun ahead got more checked, and a right
password could be told apart by whether the next one began. Every proof of a password
or of the phrase is now recorded before it is checked, whatever its outcome, 30 an
hour per address, refused past it; the check at begin stays as an early refusal.
3. **A stolen session alone could keep its owner's codes waiting ** , renewed without end,
holding them out of signing in and of changing the password, unmailed. Codes from a
signed-in session (removing an authenticator, new recovery codes, confirming one) now
count per session, where the session is (`session_code_failures` ), each counted
before it is checked; the tenth wrong in a row ends that session (`sessionEnded` ); a
try that checked no code is taken back. The account's waits count only codes after a
right password, and the per-account ten tries an hour, which a session could fill, go.
4. **Whoever guessed decided whether the owner was mailed ** , as the address was optional.
Every call carrying a code after the password or the phrase proved (sign-in, the
phrase's reset, a change, a new phrase, deletion) takes the account's address and is
refused before any code is checked when it isn't theirs; every wait mails the owner.
The app sends the address typed, or for changes while signed in the one the device
signed in with (`addressUnknown` where it doesn't know it).
5. **Strangers' wrong passwords held a signed-in owner out of changing the password, a
new phrase and deleting.** A change's challenge has its own purpose (`change` ),
counted per account (10 an hour), never toward the address; no sign-in takes it.
6. **Waits survived the resets ** : both now delete the codes refused before.
7. **Codes by mail served anything. ** A code is asked for a purpose (`signUp` ,
`finishSetup` , `authenticatorReset` , `accountReset` , `deletion` ; a nullable column of
`email_code_request` ), only that purpose takes it, and its mail says what it would do
and to type it only in the app on that instance: a hostile instance relaying a code
asked for one thing gets nothing else from it, and the person reads what they would
hand over.
8. **An account whose setup never finished, or whose authenticators' reset took effect,
couldn't delete itself.** It does with the password and a code by mail asked for
deleting.
9. * (Left as it is) * codes for one address, ten an hour from every network, can still be
used up from four networks, holding its owner's codes out for an hour at a time.
10. **A member behind the register's clock refused the session after a reset as
unavailable**, which the app read as "no password sign-in here": it has its own
refusal now (`passwordChanged` ), saying to sign in again with the new password.
2026-10-21 10:45:46 +02:00
### Third reading (21 Oct 2026)
A third independent reading, of the re-reading's fixes (`1383640..a45842f` ), found its
items 1, 2, 4, 6, 7 and 10 fixed and 5 and 8 holding but for the points below. The first
finding was a regression of item 3, so rather than count once more, the developer chose
Proton's rule (21 Oct 2026):
- **A session alone never gets a code checked.** Every signed-in change of how an
account is secured takes the owner's proof (`OwnerProof` ): the password's SRP proof
for a change's challenge (`password.beginChange` , the person checked) and then a TOTP
or recovery code. That is adding an authenticator once one is confirmed, removing a
confirmed one, new recovery codes, a new phrase, a new password and deleting. Only the
first authenticator (none confirmed, after a reset carried out too) is confirmed with
its own code alone, and removing one never confirmed takes nothing. A proof given where
none is taken is refused, and a confirmed authenticator's code is never checked again
for a session (confirming it again is refused as unknown), so no success passes a
proof unchecked. `wrongExistingCode` goes: a wrong code from another authenticator is
a wrong proof.
- **A session's proofs count for it alone** (`asOwnerProofTry` , table
`session_code_failures` , where the session is, on a member too): each counted before
it is checked, cleared only by one accepted, the tenth wrong in a row ending the
session. They never count toward the account's waits nor wait for them, and a new
password forgets those waits.
The findings, and what was done:
1. **A stolen session guessed codes without end ** (high): nine wrong codes for new
recovery codes, then removing an unconfirmed authenticator of its own, a success that
cleared the count, and again. Closed by the rule: no code is checked for a session
without the password first, and only a proof accepted clears the count.
2. **Whoever knew the password held a signed-in owner out of changing it ** : ten wrong
codes at sign-in made the account's codes wait, and the owner's change waited too.
Signed-in proofs no longer wait for the account's waits, and the change forgets them.
3. **A stolen session spent the ten changes an account begins an hour ** , holding its
owner out of changing the password, a new phrase and deleting. Beginning counts
nothing against the account: at most 30 an hour per session, a bound on the work one
session asks for that holds back no other; only failed proofs count, per session.
4. **Deleting, with the authenticators' reset taken effect but not carried out ** , was
offered a code field (the app read "an authenticator was confirmed") which the
instance refused (it read "no second factor works"). The instance takes a TOTP or
recovery code wherever an authenticator is confirmed, a code by mail where no second
factor works, and tells the app which (`SignInWays.secondFactorWorks` ).
5. **A device that didn't know the account's address couldn't change the password, set a
new phrase or delete** (`addressUnknown` ). Signed-in calls take no address any more:
nothing there mails, the account's waits being the sign-in's. Deleting by mail asks
for the address where the device doesn't know it, as the code goes there; the
instance takes the code only for the account's own address.
6. **The code mail's subject hid its purpose ** , which a notification shows alone. The
subject names it ("K7QX-M2PD: your Halkyon Learn code to delete your account"), and the
sign-up mail says it also gives a password to an account with that address and none.
2026-10-21 11:47:04 +02:00
### Fourth reading (21 Oct 2026)
A fourth independent reading, of the third's fixes (`a45842f..263c171` ), found its five
findings closed, and two older gaps in Proton's rule: the first password and the first
authenticator. What was done (`18ab2ee` , `f3c8706` , `403b88c` ):
1. **A stolen session made a GitHub-only account, or an address account without a
password, its own** (high): `password.set` took the session alone, so the thief set
their address (or, with the owner's address, their password) and their authenticator;
GitHub then refused the owner. A first password now takes a fresh proof of the
account's existing way in: a code mailed to the account's own address for it
(`EmailCodePurpose.firstPassword` ), or, for an account that signs in with GitHub
alone, a GitHub sign-in of the last 10 minutes (`gitHubSignInFreshFor` : the session's
method and creation time, where the session lives, a member telling the register),
refused `signInAgain` before any code is used. Anonymous people still become
identified with a code to the address they add. The app offers a password right after
a GitHub sign-in, before the person's app (D2's "at their next sign-in"), and asks
for a GitHub sign-in anew when the instance refuses an older one.
2. **A stolen session outlived a reset of the authenticators and claimed the first new
one** (medium-high): carrying the reset out ended no session, and the first
authenticator took its own code alone. Carrying it out now ends every session of the
account in every service (the sign-in that carries it out gets its session after
that moment), and the first authenticator takes the password's proof for a change's
challenge, without a code (`proveOwnersPassword` ), counted per session as the owner's
proofs are.
3. **Lows ** : a proof whose call to the register failed (`RegisterUnavailableException` )
stays counted, as the register may have checked it; the sign-up mail's subject says it
2026-10-21 12:11:11 +02:00
also gives an account its password.
4. **The owner is warned when ten wrong proofs end a session ** (`b096cb0` , `a177a8f` ):
someone with a session of theirs tried their password or codes. The register keeps
the moment with the account's password (`account_password.sessionEndedForProofsAt` ,
a nullable column), set where the session ends (a member tells the register; one out
of reach leaves the owner unwarned, the session ended all the same). The account's
sign-in ways and its next password sign-in carry it: the app shows it before the
sign-in is put in place (dismissed, or kept for later) and in the account section
above the change of password. It names when, nothing of the session or its device.
Dismissing takes the session alone (`password.dismissSessionEndedWarning` ): it hides
a warning and secures nothing. A new password clears it, as does deleting the person.
An owner who stays signed in sees it only in the account section.
2026-10-21 11:47:04 +02:00
docs(auth): the full reading, and the rules as they stand now (ADR 0017)
ADR 0017's "Full reading (21 Oct 2026)": each server finding and what was
done (H1, M1, M2, M3, L1, L4, L5, L6, L7, L10), those accepted with their
reasons (L2, L3, L8, L9), and the device's M1, the owner's proof bound to
its operation and the whole base URL, still to do. "The rules as they
stand" brought up to date; the module README and Learn's AGENTS.md too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:39:18 +02:00
### Full reading (21 Oct 2026)
The whole branch read again, the server and the device apart, against the rules below.
The developer decided (21 Oct): resets wait while their owner is held out and devices
show them; resets lapse; GitHub-only accounts go at GitHub's end; `http://` instances are
refused but on this machine; the phrase is saved, never copied. Server side:
- **H1, a reset pushed through while its owner was held out.** Whoever knew the password
and had the mailbox asked an authenticators' reset and kept the codes waiting until it
took effect; whoever had the mailbox filled the address's ceiling from three networks
until an account's reset did. Every hold-out after a reset was asked (a wait beginning,
an attempt a wait refuses, a sign-in or proof the ceiling refuses) now moves the
account's resets not carried out to 72 hours after it ends (`pushOutResets` ); a reset
carried out is never pushed, a lapsed one never brought back. `SignInWays` carries
when each waiting reset takes effect; the account section shows them with Call it off,
which takes the owner's proof (`password.callOffResets` ), no wait holding it back.
- **M1 and L6, GitHub's end.** At `GITHUB_SIGN_IN_ENDS` the daily run first deletes the
accounts that sign in with GitHub alone (no address, never a password), members told;
the app tells them so for the three months before. `password.set` adds an address
without GitHub only for someone anonymous where the session lives (`anonymousHere` ,
which a member tells), so a stolen session of a forgotten GitHub account claims
nothing.
- **M2, resets that never lapsed.** A reset that took effect and wasn't carried out lapses
a week later, as never asked (`resetLapsesAfter` ; the pruning forgets it); asked again,
it waits anew with a new mail. An authenticators' reset carried out never lapses.
- **M3, the phrase held back by the password's waits.** Codes count per account and way
(`SecondFactorPath` : after the password, after the phrase), each with its own waits; an
accepted code clears both.
- **L1.** Only a session made after the warning dismisses it (`signedInBefore` otherwise).
- **L4.** Replacing a setup's password ends the account's other sessions.
- **L5.** Members learn of ended sessions in the background, this account's notices
first, never inside the request.
- **L7.** A member asks the register whether the person is its account before deleting,
rather than trusting the session's scope.
- **L10.** A member clock more than five seconds ahead of the register's is refused as
one behind is.
- **Accepted:** L2, the phrase alone sets a password once an authenticators' reset took
effect (the phrase is 256 bits, and the reset's 72 hours were the owner's to call it
off); L3, a codes wait tells whoever guesses the password that it was right, which
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).
2026-10-21 14:18:23 +02:00
- **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.
docs(auth): the full reading, and the rules as they stand now (ADR 0017)
ADR 0017's "Full reading (21 Oct 2026)": each server finding and what was
done (H1, M1, M2, M3, L1, L4, L5, L6, L7, L10), those accepted with their
reasons (L2, L3, L8, L9), and the device's M1, the owner's proof bound to
its operation and the whole base URL, still to do. "The rules as they
stand" brought up to date; the module README and Learn's AGENTS.md too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:39:18 +02:00
2026-10-21 15:12:43 +02:00
The device side's rest (21 Oct 2026):
- **The device's medium 2, secrets on the clipboard.** The recovery phrase is saved as a
file or written down, never copied (clipboard histories and cloud clipboard sync would
keep it); recovery codes and an authenticator's key are copied and the clipboard
emptied 60 seconds later if it still holds them (`copySecret` ), and they are no longer
selectable, as the system's own copy wouldn't empty it.
- **`http://` instances**, refused but on this machine (`localhost` , `127.0.0.0/8` ,
`::1` ; `Instance.isSecure` ): an address over plain HTTP elsewhere is refused before
anything is sent there; one kept from before is never opened, the app saying why and
offering another instance or removing it, never dropping it silently
(`InstanceChoice.allows` , `leave` ). Development builds still use their own instance
over HTTP (an emulator reaches the developer's machine as 10.0.2.2); releases never.
- **Low 1, sheets left busy.** halkyon_keys checks the length of what comes from
elsewhere before libsodium sees it (`KeyRefusal.malformed` : a salt, a wrapping, a
sealed key's box, a sealed payload), and the device's cryptography and every password
cubit turn anything unexpected into the general failure, logged by its type alone, so
a sheet always closes again.
- **Low 2, what Save leaves.** On iOS the copy file_picker writes into the temporary
folder for the system's picker goes once it is done; on the desktop a saved file is the
own user's alone (0600, set before anything is written; Windows keeps its folder's
rights).
- **Low 3, secrets in debug logs.** The password states and `AuthenticatorSetup` say
only where they stand, never an authenticator's secret, the recovery codes or the
address; Learn's bloc observer's test holds what it logs to that.
- **Low 5, hardening.** Keyboards learn nothing typed into a password, a code or the
phrase (`enableIMEPersonalizedLearning: false` ); the phrase, recovery codes and an
authenticator's QR code and key show in a `SecretScreen` , kept out of screenshots, screen
recordings and recents on Android (`FLAG_SECURE` , each app's `ScreenGuard.kt` ) and
covered while the app isn't in front on iOS (not checked on a device); the account's
keys stay on this device on iOS and macOS (`first_unlock_this_device` , never iCloud
Keychain); only an `otpauth:` URI is shown as a QR code or opened.
- **Low 6, memory.** The account's and data keys go from guarded memory straight into
the buffer libsodium encrypts, no copy left on the way; halkyon_keys' README says
exactly what is wiped and what Dart can't wipe (x as a `BigInt` , `String` s, what
isolates copy, the keyring's base64).
- **Low 7, the device's real cryptography against the register's checks.**
`register_interop_test.dart` (halkyon_app) holds what libsodium makes on the device to
`isPasswordSetup` , `srpVerify` and M2, `isNewPassword` and the phrase's proof key, bound
to the instance and the purpose.
2026-10-21 16:49:57 +02:00
### Reading the fixes (21 Oct 2026)
A reading of the full reading's 26 fixes, server and device apart, by two reviewers who
hadn't seen them made. Nothing critical or high; the proof binding held byte for byte.
What it found, and what was done (the developer decided A1+A2, the clipboard and the
keychain on 21 Oct; the 30-day grace for GitHub-only accounts was proposed and kept):
- **A1, A2 (medium):** every refusal at the address's ceiling moved a reset to 72 hours
after it, so someone knowing only the address kept the owner's own reset from ever
taking effect with one burst every three days, and kept a forgotten one alive. Now a
hold-out that begins before a reset takes effect moves it on only by the time it holds
the owner out and no hold-out counted before did (`account_reset.heldOutUntil` , under
a row lock): the owner gets 72 hours outside hold-outs in total. And every reset not
carried out lapses 30 days after it was asked (`resetLivesAtMost` ), whatever moved it,
besides a week after it took effect.
- **C1 (low):** a member whose clock ran ahead kept sessions made in its gap. A member
now ends every session of the account made before the register's moment or before
now, whichever is later, and neither waits nor refuses; the register sends its clock
with each notice (`sentAt` ), so each member takes the moment by its own. The
`passwordChanged` refusal, sent only by the clock checks, went.
- **B1 (low):** a member asked the register whose account it is inside the proof's try,
so tries while the register was down ended the owner's session. Asked first now; while
the register is down, a person still anonymous on the member deletes there.
- **D1, D2 (low):** a member would have deleted everyone with a GitHub ID (it keeps no
addresses), and each deletion delivered every notice, one failure aborting the run.
The deletion runs on the register alone, 30 days after the end
(`gitHubAccountsKeptAfterEnd` : a date set wrong can be put right first), each account
on its own (one failing kept, with its GitHub ID, for the next day), the members told
once after all of them.
- **Nits:** the background delivery of session notices logs; a begin checks the
address's ceiling after the network's limits; the module's README lists every pruning
step.
- **The clipboard (medium):** copies reached clipboard histories and cloud clipboards,
and Android often couldn't empty it again. Nothing secret is copied any more: codes
are saved, like the phrase; an authenticator's key is scanned, opened or typed.
- **Saving (medium-low):** a saved file was made, then set to 0600, and written into
whatever the path held. Now a new file beside it, 0600 as it is made (`O_EXCL` ,
`O_NOFOLLOW` ), written through its descriptor and renamed over the path; a symbolic
link or another user's file is refused. Windows writes in place, with its folder's
rights.
- **The phrase typed back (medium-low):** shown in recents and screenshots; its fields
are inside a `SecretScreen` now.
- **The keychain (low-medium):** `first_unlock_this_device` left the keys readable on a
locked phone unlocked once since starting. Now `unlocked_this_device` ; keeping keys
deletes the entry first, which a delete finds whatever its class.
- **http:// and https:// on one host (low-medium):** one place on the device, so
removing one deleted the other's account. The places keep their names; such addresses
are one account there: choosing one takes the other's place, and removing one forgets
nothing the other uses (`InstanceStorage.sharesWith` ).
- **Lows:** no address in debug logs (`SignInWaysState` , `sign_in.json` 's errors); the
keyring that can't be read counts the keys as not here, and Delete goes on whatever
reading the account met; secrets kept out of captures on macOS (`sharingType = .none` )
and Windows (`WDA_EXCLUDEFROMCAPTURE` ) too, untried on either; the main isolate's x and
key-encryption key wiped once done with (`PasswordUnlock.forget` ).
- **Left as they are:** the per-address cap on codes by mail (the re-review's 9);
`sharingType = .none` doesn't hold against ScreenCaptureKit on macOS 15 and later.
2026-10-21 18:17:08 +02:00
### The short reading (21 Oct 2026)
A short reading of the 14 commits above, by a reviewer who hadn't seen them made.
Nothing critical; one high, three mediums. The developer decided the four questions on
21 Oct: resets are held back 14 days at most, macOS writes in place, a member deletes
nobody while the register is out of reach, and the end-to-end trial follows without
another reading.
- **H1 (high):** `open(2)` takes its mode among variable arguments, which Apple's Arm
passes on the stack; bound with fixed ones, a phrase or codes saved on an Apple
silicon Mac took their mode from whatever the stack held. Bound as variadic now, and
every file written is made 0600 through its descriptor (`fchmod` ), checked.
- **M1 (medium):** macOS's sandbox lets an app reach the file chosen and nothing beside
it, so the new file beside it was refused and nothing saved; Learn's entitlement was
read-only, so its exports never saved there either. On macOS the chosen file is
written in place now: opened without following a link, waiting on a pipe or
truncating, refused with nothing written unless `fstat` finds a file of the own
user's, then made 0600 and emptied. Both apps' entitlements grant reading and writing
chosen files. Whoever held the file open reads what is saved; Linux keeps the new file
renamed over it, and flushes the folder. Untried on a Mac.
- **M2 (medium):** a member took the register's notice of a reset by its own clock, so
a notice that took longer on its way than the reset's answer ended the very session
the reset had signed in. A member remembers that session, by the account and the
register's moment, and keeps it when the notice comes (in memory for a day; a member
restarted meanwhile ends it as before).
- **M3 (medium):** someone knowing only the address can fill its ceiling every hour, so
hold-outs kept any reset, the owner's own recovery too, from ever taking effect. A
reset now takes effect 14 days after it was asked at the latest, held out or not
(`resetWaitsAtMost` ): a stranger delays a recovery two weeks at most, and whoever
means to take the account over with the password and the mailbox (or the phrase) must
hold its owner out for all of them, every wait mailing the owner. It lapses a week
after it took effect, as before, so within 21 days; the 30-day bound stays as the
outer one.
- **L1 (low):** a refusal at the ceiling pushed a waiting reset inside the request, and a
write's commit waits for the log, so its time told that the address had an account
with a reset waiting. The push runs after the refusal now, on a session of its own.
- **L2 (low):** with the register out of reach, a member deleted a person still
anonymous there on the session alone, though the register might have linked them (a
becoming identified that failed). A member deletes nobody while it can't ask the
register; the app gives an anonymous account up all the same (signing out, or a
switch), leaving it to the pruning of anonymous accounts unused for 90 days.
- **L3 (low):** the fields the phrase is typed back into offered Copy, Cut and Share.
Paste and Select all alone now, and their copy and cut keys do nothing.
- **L4 (low):** on iOS the cover rebuilt what it covered, so a phrase field lost its
focus at each switch of apps. The cover sits in a stack always there.
- **L5 (low):** keys an earlier build kept with another keychain accessibility stayed
after signing out (an Apple keychain's read finds only its own accessibility's). They
are found with every accessibility a build used and deleted by name; a locked keyring
stops nothing, its error no longer unhandled.
- **L6 and nits:** the test of another user's file is skipped as root; a GitHub-only
account deleted whose step afterwards failed counts as deleted; the app says the real
day such an account goes (30 days after the end); `signInWays` has its comment back;
the file saver wipes its buffer whatever happens, calls again when interrupted, names
its new file apart from the one chosen (a name as long as the folder takes), and reads
types and owners on Linux with `statx` (`__lxstat` 's version was wrong on riscv64);
Windows chooses its capture affinity by its real version (`RtlGetVersion` : before 10
2004 `WDA_EXCLUDEFROMCAPTURE` acts as `WDA_MONITOR` without failing); Delete says
when the instance is out of reach rather than showing a spinner, then nothing;
`sharesStorage` is required; a forgotten `PasswordUnlock` is spent.
- **Left as they are:** the per-address cap on codes by mail (the re-review's 9); a
hot restart in a debug build leaves the native capture count held; nothing has run on
macOS or Windows.
### The rules as they stand (21 Oct 2026, after the short reading)
2026-10-21 11:47:04 +02:00
Who changes what of an account, with which proofs. "Password" is SRP's proof bound to
2026-10-21 14:18:23 +02:00
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
2026-10-21 11:47:04 +02:00
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);
docs(auth): the full reading, and the rules as they stand now (ADR 0017)
ADR 0017's "Full reading (21 Oct 2026)": each server finding and what was
done (H1, M1, M2, M3, L1, L4, L5, L6, L7, L10), those accepted with their
reasons (L2, L3, L8, L9), and the device's M1, the owner's proof bound to
its operation and the whole base URL, still to do. "The rules as they
stand" brought up to date; the module README and Learn's AGENTS.md too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:39:18 +02:00
sign-ins' codes per account and way, after the password or the phrase (waits from ten
wrong in a row, the owner mailed).
2026-10-21 11:47:04 +02:00
| What | Without a session | Signed in |
|---|---|---|
| Sign in | password + code; password alone once an authenticator reset took effect, until one is confirmed; password + mailed code (`finishSetup` ) while no authenticator was ever confirmed | — |
docs(auth): the full reading, and the rules as they stand now (ADR 0017)
ADR 0017's "Full reading (21 Oct 2026)": each server finding and what was
done (H1, M1, M2, M3, L1, L4, L5, L6, L7, L10), those accepted with their
reasons (L2, L3, L8, L9), and the device's M1, the owner's proof bound to
its operation and the whole base URL, still to do. "The rules as they
stand" brought up to date; the module README and Learn's AGENTS.md too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:39:18 +02:00
| Sign up / first password | mailed code (`signUp` ) to an address nobody has, or whose account has no password | anonymous where the session lives: mailed code (`signUp` ) to the address added; with an address: mailed code (`firstPassword` ); GitHub alone: GitHub sign-in of the last 10 minutes + mailed code (`signUp` ) to the address added. Never once an authenticator was confirmed |
2026-10-21 11:47:04 +02:00
| First authenticator | — | password (no code) + the new one's code |
| Another authenticator | — | password + code + the new one's code |
| Remove an authenticator | — | confirmed: password + code (never the last); unconfirmed: nothing |
| New recovery codes | — | password + code |
| New recovery phrase | — | password + code |
| New password | phrase's proof + code (keys kept) | password + code |
2026-10-21 18:17:08 +02:00
| Authenticator reset | password + (mailed code or phrase's proof); takes effect once 72 h outside hold-outs of the owner passed, 14 days after it was asked at the latest, owner mailed, called off by a sign-in with a code; lapses a week after taking effect unless carried out | password + code calls it off |
| Account reset (new keys) | mailed code (`accountReset` ) asks, a new one carries it out once 72 h outside hold-outs passed, 14 days after it was asked at the latest, owner mailed, called off by any sign-in with the password; lapses a week after taking effect | password + code calls it off |
2026-10-21 11:47:04 +02:00
| Delete the account | — | password + code; where no second factor works, password + mailed code (`deletion` ) |
docs(auth): the full reading, and the rules as they stand now (ADR 0017)
ADR 0017's "Full reading (21 Oct 2026)": each server finding and what was
done (H1, M1, M2, M3, L1, L4, L5, L6, L7, L10), those accepted with their
reasons (L2, L3, L8, L9), and the device's M1, the owner's proof bound to
its operation and the whole base URL, still to do. "The rules as they
stand" brought up to date; the module README and Learn's AGENTS.md too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 13:39:18 +02:00
Every reset carried out, and every new password (a setup's replaced too), ends the
account's sessions in every service (a change keeps the session asking). A session ended
for ten wrong proofs leaves its owner a warning; a session made since dismisses it, and a
2026-10-21 16:49:57 +02:00
new password clears it. 30 days after GitHub's end, accounts that sign in with GitHub
2026-10-21 19:22:12 +02:00
alone go, on the register. Where passwords are offered, GitHub is added to nobody, and
the apps offer it to nobody: an anonymous person's Sign in with GitHub reaches only the
account someone has (linking names it taken before refusing), and every refusal says
why (`usePassword` , `deprecated` , `ended` ; found in the end-to-end trial). A member ends the sessions an account had on it before the
2026-10-21 18:17:08 +02:00
register's moment by its own clock, but the one a reset signed in there, and deletes no
account while the register is out of reach. On the device nothing secret is copied
(the phrase typed back is pasted, never copied out), and a saved file is the own user's
alone (on macOS, the chosen file written in place). Every secret shown or typed back is
kept out of screenshots, screen recordings and recents on Android; out of captures on
Windows 10 2004 and later, before that shown black in them; on macOS out of
screenshots, but not out of ScreenCaptureKit's captures on macOS 15 and later; on iOS
covered in the app switcher's picture, screenshots and recordings not held back; on
Linux not at all.
2026-10-21 11:47:04 +02:00
2026-10-20 11:17:31 +02:00
## Consequences
- People keep a password, an authenticator app and a recovery phrase: more to keep than a
code by mail, and the reason the agenda can be encrypted.
- Every app gets the sign-up and sign-in screens (halkyon_app), the key store on the device
(the keyring, as sessions are), and the recovery screens.
- The instance can't read what is encrypted, can't recover it, and can't reset a password
without the data encrypted under it being lost.
- Learn's server-side features (publishing, the connector, the learner count, LanguageTool)
ask whether a studyset is private and refuse it; each says why, and how to open it up.
- SRP and the key handling are security-critical code of our own: test vectors, a second
reading of every step, and no release before both.