Commit graph halkyon/packages/halkyon_app/test/password/bloc
Author SHA1 Message Date
5067d11c18
fix(app): no state logs an authenticator's key, codes or the address
Equatable's own text lists every field, and a debug build's bloc observer
logs every state, so an authenticator's otpauth URI with its secret, the
recovery codes and the address went into the log. The password states
(signing in, getting back in, adding an authenticator, a step of the
account section) and AuthenticatorSetup now say only where they stand, as
the setup and new-phrase states did; Learn's observer takes a writer so
its test holds what it logs to that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:57 +02:00
cf69e62ad4
fix(app): no sheet is left busy by an error nothing expected
An instance's salt of another length made the device's cryptography throw
an ArgumentError, a wrapping's nonce a RangeError, and libsodium or the
keyring may throw their own; none was a refusal, so the step's error
escaped and the sheet stayed busy, which can't be closed. halkyon_keys
now checks the lengths of what comes from elsewhere before libsodium sees
it (KeyRefusal.malformed: a salt, a wrapping, a sealed key's box, a sealed
payload), the device's cryptography turns anything else into the general
failure, and so does every password cubit, the error logged by its type
alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:56 +02:00
9d0d037c86
fix(auth): a reset waits while its owner is held out, and devices show it
The full reading's H1: an owner could be kept from calling a reset off, and
the reset then went through. Whoever knew the password and had the mailbox
asked an authenticators' reset and kept the codes waiting, so the owner's
sign-ins were refused until it took effect; whoever had the mailbox filled
the address's proofs ceiling from three networks and completed an account
reset. Now every hold-out of the owner after a reset was asked (a wait
that begins, an attempt a wait refuses, a sign-in or proof refused by the
address's ceiling) moves the account's resets not carried out to take
effect no sooner than 72 hours after it ends (pushOutResets). A reset
carried out (the password alone signing in until an authenticator is
confirmed) is never pushed, nor a lapsed one brought back.

Signed-in devices are told too: SignInWays carries when a waiting reset
of each kind takes effect, the account section shows them with Call it
off, which takes the owner's proof (password and code; password.
callOffResets, forwarded by members) and calls every reset off, as no wait
holds a signed-in owner's proof back. Texts in English and German.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:52 +02:00
c95b3d55b3
feat(app): the warning that a session was ended for its proofs
A password sign-in whose account carries the warning (a session ended
for ten wrong proofs in a row) shows it before the sign-in is put in
place: when, what it means, and to change the password if it wasn't
the owner. Dismiss clears it on the instance through the held-back
session; Remind me later signs in and keeps it. The account section
shows it above the change of password, with Dismiss, while it stands.
Nothing identifies the session or its device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:51 +02:00
d7855162c7
fix(auth)!: the first authenticator takes the password, and a reset ends every session
The fourth reading's second finding: a stolen session outlived the reset
of the authenticators (carrying it out ended no session) and confirmed
the account's first authenticator on its own code alone, ahead of the
owner, so the owner's confirmation and sign-in were refused after.

- Carrying out a reset of the authenticators ends every session of the
  account, on the register at once and on every member by notice, as the
  other resets do; the sign-in that carries it out gets its session after
  that moment (PasswordProof.sessionsEndedBefore, issueSessionAfter).
- Confirming 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: a session of a lost phone, or of a
  setup never finished, claims no second factor.

The app sends the password with every confirmation: the sign-up's, a
reset's and the account section's (which asks for it), and a sign-in's
own unlock where the session is still held back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:50 +02:00
f0413531ff
fix(auth)!: a first password takes a fresh proof of the account's way in
The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).

- An account with an address proves it anew: a code mailed to that
  address for this (EmailCodePurpose.firstPassword, its own mail), never
  the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
  a GitHub sign-in (the session's method and creation time, where the
  session lives; members tell the register), refused signInAgain before
  any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
  they add.

The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:50 +02:00
a0ae08f975
fix(app): every change of how an account is secured asks for the password
The app's side of the owner's proof (OwnerProof, the third reading): adding
an authenticator once one is confirmed, removing a confirmed one and new
recovery codes now ask for the current password beside the code, as
changing the password, a new phrase and deleting already did; one
challenge and one answer per sheet (OwnerAnswer). A wrong password or code
reads as the one refusal the instance gives.

Deleting chooses a code by mail from what the instance says
(secondFactorWorks), no longer from whether an authenticator was ever
confirmed: with the authenticators' reset taken effect but not carried
out, the app offered a code field the instance refused (the fourth
finding). Signed-in calls take no address any more, so a device that
doesn't know it changes the password as any other; deleting by mail asks
for the address only where the device doesn't know it, as the code goes
there (the fifth finding).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:49 +02:00
1d4320370b
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.

Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:48 +02:00
2a21672b43
fix(auth): every call with a code after the password takes the address
Whoever guessed codes decided whether the owner was mailed (the re-review's
fourth finding): the address a sign-in sent was optional, so a guesser left
it out and no mail went, and a wait begun without a mail used up the one
mail there was. Every call carrying a code after the password or the phrase
proved (signing in, the phrase's reset, a change of password, a new phrase,
deletion) now takes the account's address and the language of its mails,
and is refused (wrongCredentials, as anything wrong after the password)
before any code is checked when the address isn't the account's; every
wait mails the owner, not only the first. The app sends the address the
person typed, or for changes while signed in the one this device signed in
with, and says so where it doesn't know it (addressUnknown).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:47 +02:00
4a32f150db
fix(auth): codes wait after ten wrong in a row, and the owner is told
Whoever knew the password could guess codes at 10 an hour forever,
unnoticed, each guess checked against every authenticator (the second
reading's M4). Every code an account's second factor refuses after a
right password (sign-in, a phrase's reset, a change) or from a signed-in
device (removing an authenticator, new recovery codes, the code from one
confirmed before) now counts, per account, from every network; after
every tenth in a row no code is taken (codesWait, saying until when), an
hour, then twice as long each time, a day at the most (longestCodeWait,
second_factor_waits.dart): at a day, ten guesses a day still have about a
1 % chance a year against one authenticator, so the first wait mails the
owner that someone knows their password. The instance keeps no address,
so sign-in and the phrase's reset now send the address the device typed
(and its language), used only for that mail after its hash is checked
against the account's. A code accepted clears it all.

Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "second_factor_failures" ("id" bigserial PRIMARY KEY,
"authUserId" uuid NOT NULL, "failures" bigint NOT NULL, "waits"
bigint NOT NULL, "waitUntil" timestamp, "updatedAt" timestamp NOT
NULL) with a unique index on "authUserId": a new table, nothing
touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:44 +02:00
b33b99a50a
fix(keys): new passwords have at least 12 characters
The register can't check a password it never sees, and should its
database leak, the verifier and the keys wrapped under the password fall
to guessing at Argon2id's cost (the second reading's M8). Devices now
refuse a new password shorter than 12 characters, counted as code points
once normalised (halkyon_keys' checkNewPassword), at sign-up, a reset
keeping the keys and a change alike, before anything is derived; the new
password's field says so, and the refusal has its own text (en, de).
Signing in takes any length, so a password set before keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:43 +02:00
b51445a01a
test(app): the password cubits' states, every refusal included
Signing in (a code, a recovery code, an account never finished, a lost
authenticator by mail and by phrase, an impostor instance, no
connection), setting up (signing up, a password for someone signed in,
finishing an account's reset with new keys or the old), getting back in
(with the phrase, starting a reset without it) and the account section
(what keeps the account, its steps, adding an authenticator, a new
phrase), each as the states it emits.

They found that reading what keeps the account wrapped two refusals at
once in a ParallelWaitError, which no caller takes for one: offline, the
account section showed nothing. The first refusal now goes up as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:42 +02:00