Commit graph halkyon/packages/halkyon_app/test/password/widget
Author SHA1 Message Date
acb291afcb
feat(app): the recovery phrase's words typed back are suggested from BIP-39's list (ADR 0017)
Each of the three fields suggests the words of BIP-39's whole English
list beginning with what is typed, never the phrase's own, and refuses a
word not on it as its field is left, so that a typo is caught before the
words are compared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-30 15:53:10 +01:00
7ac715cd97
feat(app): a wait screen for accounts that wait for the operator (ADR 0021, step 1)
An account signed in or up to on an instance whose operator approves
new accounts, or one suspended, gets no session but a wait: the device
keeps its pass, words and handle in the keyring per instance
(SecureAccountWaitStore, InstanceStorage.waitOf), the auth status is
Waiting, and SessionGate shows WaitScreen in its own app, so the start
screen's sheets go with it. Every sign-in flow hands over to it: the
password's sign-in, sign-up, reset and account reset, SimpleLogin's
sign-in, sign-up and mending; their cubits show no problem then.

The wait screen shows the handle, the four words the operator reads out
for the person to match, and that Halkyon never asks anyone to read them
out (D33); it asks where the wait stands as it opens, every 30 seconds,
as the app comes back and when asked. A new account can be deleted with
its pass, a suspended one can't (ADR 0017). Approved, it leads to the
usual sign-in, once more as before (D31); a pass the instance knows no
more ends the wait. The start screen says where new accounts wait for
approval, and offers no Create an account where the operator takes none;
both refusals (closed, too many waiting) have their words, en and de.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-26 07:17:05 +01:00
4e33133733
feat(base,app)!: GitHub signs nobody in any more (ADR 0020, D36)
Every account signs in with SimpleLogin or a password now, so GitHub
goes from the module, the shared app package, Learn and Todo, ahead of
ADR 0017's GITHUB_SIGN_IN_ENDS.

- halkyon_base: no `gitHubSignIn` endpoint, GitHub identity provider or
  GitHub refusals; no `githubClientId`/`githubClientSecret`, no
  `GITHUB_SIGN_IN_ENDS` and no `forgetGitHubAccounts`; the register
  loses `account-with-github`. `SignInWays.gitHub`, the basics'
  `gitHubClientId` and `gitHubSignInEnds`, and `PasswordRefusal`'s
  `signInAgain` and `noAddress` go. A first password takes a code
  mailed to the person's own address; an account without one sets none
  (`wrongCredentials`).
- Learn's server: `instance.info()` without `gitHubClientId`, the
  pruning without GitHub, and `minimumAppBuild` 24, as builds up to 23
  (v0.20.0) require `SignInWays.gitHub`: the server deploys with the
  release of build 24.
- halkyon_app: no GitHub button or problems on the start screen, no
  password offered after a GitHub sign-in, and `ServerpodAuthGateway`
  without the browser half only GitHub used (`(client, base:)`). The
  account section offers a first password only to an account with an
  address. `LoopbackWebAuth` and the apps' schemes stay, SimpleLogin's
  ways back.
- Learn's Profile lists no GitHub; the texts go (English, German).
- Serverpod's `serverpod_auth_idp_github_account` stays, as its
  migrations make it; a row goes with its account, and ADR 0020's D36
  gives operators the SQL to delete those left.
- deploy/: compose.yaml and .env.example without GitHub. The READMEs,
  AGENTS.md files and ADR 0017 amended.

BREAKING CHANGE: builds up to 23 can't read the instance's answers
about an account's ways in; minimumAppBuild is 24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-26 07:16:39 +01:00
630965d920
feat(base)!: a handle at password sign-up (ADR 0020, D26, D28–D30)
Every new account gets a handle, and an account without one gets its
first as it sets a password. The handle is private until its person
goes public (D5).

- `passwordSignIn.signUp` takes a handle. `passwordSignIn.checkHandle`
  says whether a sign-up could take one, only while that sign-up's
  mailed code waits (`codeExpired` otherwise), counted per code request
  (30 an hour) and per network (60) (D29). Members ask the register.
- `password.set` takes the first handle of an account without one
  (D28); `SignInWays.handle` tells the app whether there is one.
- Each checks the handle before the code is used, so a refusal leaves
  the code for another try. An existing account at the address keeps
  its own handle where it has one, and the typed handle is still
  checked, so the answer says nothing about the address.
- `first_handle.dart` holds what SimpleLogin, the keys and passwords
  share: `refusalOfNewHandle`, `insertFirstProfile`, `hasHandle`.
- halkyon_app: one `HandleField` with its `HandleCheckCubit` (asked
  300 ms after typing pauses, only about the handle typed last; the
  instance's suggestion is free without asking) (D30). The password
  setup asks for the handle between the phrase check and the code, and
  a refusal at the code sends back to it. SimpleLogin's sign-up and
  keys steps use the same field and now check as it is typed.
- The module's README and ADR 0020 (D29, D30, the built state) amended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 19:21:39 +02:00
5b9df73756
feat(app)!: no anonymous accounts on the device (ADR 0020, D4, D25)
Every account has a way in, so the apps make no anonymous account any
more. The start screen shows whenever nobody is signed in, a first
start included, and offers SimpleLogin, a password and GitHub for those
who have it, with no start without an account. An instance out of reach
is said so, with Try again, and one offering no way to create an account
too. An anonymous account's session left from before is deleted as the
app starts (D25), falling back to signing out on the device.

What only anonymous accounts needed goes: signing in anonymously, the
signed-out mark, adding GitHub to an account and switching to a GitHub
account from the Profile tab (with its Cancel, the only one), an
anonymous device's own keys, the warning that signing out deletes the
account, and the texts about keeping progress. Settings says on which
instance the account is signed in. Learn's publishing, reports and
suggestions and its learner count no longer ask whether the person has
an identity: everyone signed in has one.

The server still offers anonymous sign-in until the next commit, so
older apps keep working until v0.20.0 raises the minimum build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 16:14:37 +02:00
1ca4e083bb
fix(app): a phrase field keeps its focus as the app goes and comes back
SecretScreen built its child alone, and a stack around it with the cover
while the app was away (iOS), so each switch of apps built the child anew:
a phrase field lost its focus, its caret and what the keyboard was
composing (the short reading's L4). It always builds the stack now, laid
out as the child alone (StackFit.passthrough), the cover in it only while
away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:04 +02:00
87529cd5c6
fix(app): the phrase typed back is pasted into its fields, never copied out
The fields the recovery phrase is typed back into offered the system's
Copy, Cut and Share, and Ctrl+C and Ctrl+X copied what was selected (the
short reading's L3), so the phrase could still reach clipboard histories,
cloud clipboards or another app. Their menu now offers Paste and Select
all alone, and the field's own copy and cut, overridden from above, do
nothing. The comment on the recovery codes no longer speaks of Copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:04 +02:00
b9bf9813c5
feat(app): secrets kept out of screenshots on macOS and Windows too
The reading of the full reading's fixes noted that macOS and Windows can
keep a window out of screen captures, which SecretScreen didn't ask.

PlatformScreenGuard now asks the app's window on Android, macOS and
Windows (asksOn), and both apps' runners answer app.halkyon/screen_guard,
counting what shows: macOS's MainFlutterWindow sets sharingType = .none,
Windows' FlutterWindow WDA_EXCLUDEFROMCAPTURE (Windows 10 2004 on;
before, WDA_MONITOR, black in captures). Neither runner was built here
(Linux): to be tried on each system.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:01 +02:00
fac9a000a1
fix(app): the phrase typed back is kept out of screenshots too
The reading of the full reading's fixes found that the recovery phrase
typed back (recovering, resetting an authenticator, finishing a reset
that keeps the keys) showed all 24 words in a plain field, in Android's
recent apps, screenshots and iOS's app switcher, while people switch
apps to read it; and the three words typed back at sign-up alike.
PhraseField and PhraseCheckFields now show inside a SecretScreen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:00 +02:00
999877d95c
fix(app): nothing secret is copied
The reading of the full reading's fixes found that copied recovery codes
and authenticator keys still reached clipboard histories (Windows, KDE's
Klipper, Gboard, Samsung's) and cloud clipboards, as no Flutter engine
marks a copy as sensitive, and that on Android an app in the background
can't empty the clipboard again, so the minute's clearing often never
happened.

As the developer decided: recovery codes are saved, like the phrase; an
authenticator's key is scanned as a QR code, opened in the authenticator
app, or typed from the screen. copySecret and its texts go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:00 +02:00
b0e1557153
fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member
whose clock ran ahead protected only the reset's own session: a session
made on that member in the gap, by whoever knew the old password, outlived
both the member's ending and the register's notice.

issueSessionAfter now ends every session of the account here made before
the register's moment or before now, whichever is later, then makes the
new one, neither waiting nor refusing; and the register sends its clock
with each notice (sentAt), so a member takes the moment by its own clock
(as long before now as it was before it was sent), ending the gap's
sessions and leaving those made since. The passwordChanged refusal, sent
only by the clock checks, goes with them (unreleased).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:59 +02:00
f3c3f7fb51
fix(app): secrets are kept out of screenshots and the recent apps
The recovery phrase, recovery codes and an authenticator's QR code and
key now show inside a SecretScreen: on Android it holds FLAG_SECURE while
it shows (through a channel each app's MainActivity answers, counting
holds, as several may show at once; ScreenGuard.kt), so screenshots,
screen recordings and the recents view don't get them; on iOS, which has
no such flag, it covers them while the app isn't in front, so the app
switcher's picture doesn't hold them (Dart-side, not checked on a device).
Desktops do neither: a window loses focus whenever its person turns to a
password manager. Todo's debug APK builds with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:58 +02:00
5270ce6775
fix(app): only an otpauth URI is shown as a QR code or opened
The instance sends the URI an authenticator is set up from; one that
isn't otpauth: (a web page, an Android intent) was drawn as a QR code and
handed to the system to open. Now it is neither; the key stays to type.

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
c9904de31d
fix(app): keyboards learn nothing of a password, a code or the phrase
The fields for a password, a code and the recovery phrase's words already
turned suggestions and corrections off; keyboards (Gboard, SwiftKey) still
learnt what was typed into them unless told not to. They now are
(enableIMEPersonalizedLearning: false), and the two sheets taking the
whole phrase share one PhraseField.

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
a0b7e2bf18
fix(app): the phrase is never copied, codes leave the clipboard a minute later
The recovery phrase opens the account's keys; clipboard histories and
cloud clipboard sync would keep it beyond the app's reach. It is saved as
a file or written down. Recovery codes and an authenticator's key may be
copied, and the clipboard is emptied 60 s later if it still holds them;
they are no longer selectable, as the system's own copy wouldn't empty it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:55 +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
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
09a627d1c7
fix(auth): a member behind the register's clock says the password changed
When a member's clock ran more than its allowance behind the register's,
the session after a reset was refused as unavailable (the re-review's tenth
finding), after the register had already reset the account and ended every
session, and the app read it as "this instance has no password sign-in".
It now has a refusal of its own (passwordChanged): the app says the password
was changed and to sign in again with the new one.

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
e8c4a3d472
fix(auth): a session's wrong codes end the session, not the account's codes
Wrong codes for removing an authenticator, new recovery codes or confirming
one counted toward the account's waits (the re-review's third finding): a
stolen session alone, without the password, could keep the owner's codes
waiting for up to a day at a time, renewed without end, holding them out
of signing in and of changing the password, the way to end that session,
and nobody was mailed. Those codes now count per session, where the session
is (the register's own, or a member's, which forwards the code): each try
counted before its code is checked (one statement, so tries sent at once
count each), a code accepted clearing the count, and the tenth wrong in a
row ending the session (sessionEnded; the device says it was signed out).
A try that checked no code (a refusal of something else) is taken back.
The account's waits now count only codes after a right password, and the
per-account ten tries an hour, which a session could fill as well, go.

Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "session_code_failures" ("id" bigserial PRIMARY KEY,
"sessionId" uuid NOT NULL, "authUserId" uuid NOT NULL, "failures"
bigint NOT NULL, "updatedAt" timestamp NOT NULL) with a unique index on
"sessionId": a new table, nothing touched. Its rows go with a code
accepted, with the person, and hourly once their session is gone.

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
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
85be0a9acc
test(app): the password screens' flows that had none
An impostor instance refused; a lost authenticator's reset by the
recovery phrase; the password alone signing in once a reset took
effect, an authenticator set up first; starting an account's reset
without the phrase, and finishing one with new keys and a new phrase;
in the account section, removing an authenticator (a code, never the
last), new recovery codes, and a change of password refused for a wrong
current one.

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
5fb28bd817
fix(auth): a new recovery phrase takes the password too
password.setRecoveryKeys took a session and a TOTP code alone, so a phone
signed in with its authenticator beside it could set a phrase of its
holder's own and, through the phrase's reset, a new password, never
knowing the old one. It now takes the old password's SRP proof (from
beginChange, the caller's own) and a TOTP or recovery code, as changing
the password does: one owner proof for both (_proveOwner), forwarded by
members, asked for in the account section's New recovery phrase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:41 +02:00
735dfa892a
test(app): the password screens, signed out and signed in
ADR 0017, step 3: widget tests driving the sheets as a person would,
on halkyon_app's fakes: signing in with a code (a wrong one refused
alike), an account never finished signing in with a code by mail and
setting up its authenticator (QR code, key, recovery codes saved), a
lost authenticator's reset, creating an account (the phrase shown and
three of its words checked before anything reaches the instance), the
recovery phrase getting back in, and the account section (a password
offered to someone anonymous; an authenticator added only with a code
from one before; the password changed).

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