Commit graph halkyon/packages
Author SHA1 Message Date
0fafa59f2c
fix(todo,app): Material's icon font where serverpod_auth_idp_flutter asks for it
serverpod_auth_idp_flutter sets `uses-material-design: true`, and Todo's
app and halkyon_app didn't: Flutter warned in every test run ("the
primary pubspec contains uses-material-design: false"), and Material's
icons would draw as boxes in Todo. Learn's app has it already. The lock
stays as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 17:48:31 +02:00
8d6785f5c6
feat(base): SimpleLogin's forwarding page and Learn's App Links statement (ADR 0020, step 1)
SimpleLogin takes only https redirect addresses, each registered exactly,
so the instance's SimpleLogin app registers one page on the register's
web server, /simplelogin-callback, which sends the browser on to the app
that began the sign-in: a 303 to the desktop's loopback (127.0.0.1, a port
per app) or the phone's scheme, chosen by the marker the state begins with
(`<app>-<platform>.<rest>`, the rest 1 to 512 characters of base64url),
from a fixed table, so it sends nobody anywhere else. It forwards the code
or SimpleLogin's error (`deny`) with the state and nothing else, refuses
anything malformed, repeated or not a GET with a page that repeats nothing
it was given, and keeps and logs nothing (no-store, no referrer). It is
built ahead of the sign-in itself so development can sign in through
learn.halkyon.eu's page, which the development app registers too.

Android's assetlinks.json for Halkyon Learn signed with the release key
is answered by the Caddyfile on learn.halkyon.eu alone: the one domain the
project's builds name, and the server knows no app's key. A root test
holds its fingerprint to tool/release/android-certificate.sha256. The
app's intent filter comes with the sign-in; iOS's association waits for
an Apple team ID.

The profile page's language choice and look move to common/web_page.dart,
shared by both pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 17:48:31 +02:00
ca24eb9cf9
feat(assistant)!: no hand-off and no connector; the field searches the web (ADR 0022)
The developer decided on 23 Oct to drop the hand-off to an AI assistant
outside the app, and Claude's connector with it, in v0.19.0, the app and
the server together: build 22 raises minimumAppBuild anyway, so no app
calling the connector's endpoints uses the instance after it. An instance
is to have one way to an assistant, its operator's (ADR 0021, proposed).

The app: the field at the foot of Home, and the button in the Library, on
a studyset and while studying, search the web alone, through the engine
chosen in Settings or Android's own search, with the parts of a card the
person picks; a studyset's name and description are never offered. Gone:
the assistants a question opened in (AiAssistant, AssistantHandOff), the
switch between them and the web, Settings' choice of assistant, Connect
Claude with its route, cubit, repository and gateway, and their texts. A
settings file from before keeps the old keys until the next change of a
setting writes it anew; nothing reads them.

The server: lib/src/assistant/ (MCP, its OAuth, the five tools, connect
codes and tokens), AssistantEndpoint, the daily forgetting of the
connector's secrets (its scheduled call cancelled at start), its lines in
the deletion hook, instance.info().assistantConnectorUrl (nullable, so an
older app reads it as none), and Caddy's forwarding of /mcp, /oauth/* and
/.well-known/oauth-*. The migration drops the connector's five tables,
children first:

  DROP TABLE "assistant_token" CASCADE;       -- tokens of a connection
  DROP TABLE "assistant_grant" CASCADE;       -- OAuth codes of a connection
  DROP TABLE "assistant_connection" CASCADE;  -- a person's connected client
  DROP TABLE "assistant_connect_code" CASCADE;
  DROP TABLE "assistant_client" CASCADE;      -- OAuth clients registered

No other table references them, so CASCADE drops only their own foreign
keys. For the minute between the maintenance run and the switch, the old
server answers the connector with errors and fails to delete an account
(its hook still deletes from these tables); ADR 0022 says so.

BREAKING CHANGE: a connected Claude gets no answer from the deploy on, and
the app no longer opens a question in an AI assistant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 17:48:30 +02:00
98586f1779
chore(client): regenerate the module's client for the endpoints' new comments
Some checks failed
CI / check (push) Successful in 29m20s
CI / deploy (push) Failing after 2m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:36:17 +02:00
88e79155bc
fix(privacy): privacy is a signed fact the instance can't take back (ADR 0019)
The reading of 22 Oct, against an instance that alters data. A data key's
ID now says what it is for (a studyset's key naming it, the person's key,
or a record that a studyset was made readable), and the ID is sealed with
the key and signed by the account, so the instance can't relabel a key or
make one up; the row's own columns are no longer trusted. A studyset is
private while the latest signed record about it says so, and the device
keeps the latest it saw in a ledger in the person's folder (privacy.json),
so a record withheld or a row replayed changes nothing; a tree's key is
the nearest studyset's with such a record, itself or above. An item keeps
the key it was last seen sealed with unless this device moves it or a
signed record makes its tree readable.

A readable row a device holding the keys writes carries the account's
signature in place of a nonce; one nobody signed is locked wherever
privacy holds (in private trees, for items once sealed, and for tags and
settings once sealed), so the instance can't plant content, confirm a
guessed tag name by folding, or switch Count me on. Signed readable rows
from a device that hadn't heard are shown and sealed anew.

Rewrites are kept as under way until done, write the top studyset last,
hold writes into their tree meanwhile, and are finished as the data opens
and after each merge; a sweep seals what a stale device wrote. Cards moved
into a private tree are sealed with their files first; cards of a private
tree are never moved into a readable one. A private topic moved out of
its tree keeps its privacy with a key of its own. Settings carry a counter
inside their sealed payload, and a lower one than seen is refused. The
person's readable tags and settings are sealed once. A device without the
keys of an account that has them writes nothing that would be private and
reports nothing for the learner count; an anonymous person's keys kept but
unreadable are never replaced. Publishing sends only the names of the
tags the release carries. Key refusals are remembered per row.

Tests on the real gateways over a local database: a replayed readable top
row, a withheld key record, a moved parent, a planted revision, a signed
stale revision, card and topic moves, an interrupted rewrite, a settings
rollback, a device without keys, planted tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:11 +02:00
dcdc9c1542
docs(privacy): private studysets as built (ADR 0019)
Learn's AGENTS.md says how private studysets work (keys, the envelope,
locked rows, rewriting a tree) and what changed for the connector,
publishing, uploads and the learner count; ADR 0019 records what was built
otherwise than decided or not yet (a private studyset's files); the
halkyon_app README describes the content cipher and device keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:09 +02:00
64fdfa091d
feat(privacy): every synced payload sealed and opened through one envelope (ADR 0019)
- `ContentKeys` opens the person's data keys (`content_key`) with their
  account's keys on the device, an anonymous person's device keys made as
  their data first opens; it makes a private studyset's key and the
  personal key. Without the account's keys nothing opens and nothing new
  is private (D8).
- `PayloadEnvelope` is the one place payloads are sealed and opened, each
  for its table, row and column, so a payload moved to another row never
  opens; one that doesn't open is `Locked`, never half-read.
- `Privacy` gives each gateway the key of the row's tree (its top-level
  studyset's), and rewrites a tree under another key, or readable, in
  place (`rekeyTree`).
- The studyset, card, note, learning path, path answer and PDF gateways
  seal with their tree's key and open what they read: a new top-level
  studyset is private where the device holds the keys, a new one inside a
  tree takes its privacy, and one moved into another tree is rewritten
  under that tree's key. Tags and settings are sealed with the personal
  key; a tag or settings row that doesn't open here is left out, so
  nothing folds or deletes it. A studyset whose key doesn't open shows as
  locked (`Studyset.isLocked`); other rows that don't open read as blank.
- Every payload codec round-trips sealed, and refuses another row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:08 +02:00
905bb9abf2
feat(app): content sealed with data keys, and an anonymous person's device keys (ADR 0019)
`ContentCipher` makes a person's data keys, sealed to their account and
signed by it, opens them, and seals and opens a row's content with them for
the row it is stored in (`SodiumContentCipher`, on the isolate it is used
on, as rows are read and written one by one; `FakeContentCipher` for
tests). A sealed payload holds the signature, then the ciphertext.

`AccountCrypto.newDeviceKeys` makes the key pairs an anonymous person's
device encrypts their private studysets with (ADR 0017, point 9, not built
before); setting a password keeps them as the account's
(`keepingKeys` with a new phrase), so nothing they made private is lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:08 +02:00
cccb692ff0
docs(app): what signing out and deleting the account show meanwhile, and on failure
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:08 +02:00
f5d8a3ca1f
fix(app): a sign-out or deletion that failed says so, its tile showing it under way
The account's tiles show a spinner on Sign out or Delete account while it
runs, start neither again meanwhile, and say once, in a snack bar, why one
failed ("Couldn't delete your account: the instance wasn't reached. Try
again."; "Couldn't sign out on this device. Try again."), in English and
German. The session gate no longer builds the person's app anew for that:
only the tiles follow it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:07 +02:00
662c30b117
fix(app): signing out or deleting the account keeps the person's app until it is done
Both went through the launch screen (SessionStateLoading), which closed the
person's app before asking the instance; a failure then opened it again
with no word of why. The person's state stays now, saying what is under
way (`busy`), and a failure keeps them signed in with why (`problem`). An
instance out of reach still signs out on the device (Serverpod's
signOutDevice), as signing out of a borrowed device must work offline, so
only the device's own storage fails a sign-out; a deletion fails whenever
the instance isn't reached or refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:07 +02:00
3994257044
docs(auth): GitHub is offered to nobody where passwords are (ADR 0017)
ADR 0017's rules as they stand, halkyon_app's README and Learn's
AGENTS.md: where an instance offers passwords, the apps offer no Add GitHub
and no hint on adding it, an anonymous person's Sign in with GitHub reaches
only the account someone has, and every refusal says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:07 +02:00
19aabb0266
fix(app): GitHub's refusals say why, and no hint adds GitHub where passwords are offered
Where an instance offers password sign-in, GitHub is added to nobody (ADR
0017, D2), yet the start screen still said "then add GitHub" after a GitHub
account nobody has, and every refusal but an unknown account read
"Couldn't sign in with GitHub." (the end-to-end test of 21 Oct).

- The gateway tells from one read of the basics whether GitHub signs in and
  whether it is added to people (`gitHubOffer`, in place of
  `gitHubClientId`); the GitHub bloc keeps it (`Ready.linking`) for the
  profile, with why an attempt failed (`Ready.problem`).
- The instance's `usePassword`, `deprecated` and `ended` become problems of
  their own (`gitHubSignInProblemOf`), worded by `gitHubProblemText` and on
  the start screen; an anonymous person refused a link is told the GitHub
  account is nobody's and offered the password instead.
- Where passwords are offered, the start screen offers an account to create
  in place of the app's hint on adding GitHub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:06 +02:00
562d334e6e
fix(auth): a GitHub account someone has is theirs to switch to where passwords are offered too
Linking refused every GitHub account with `deprecated` where people sign in
with a password, before asking whether the account was someone else's. An
anonymous person tapping Sign in with GitHub, whose app links first and
switches to the account's person when it is taken, so never got to the
switch: the end-to-end test of 21 Oct found it. Nothing is linked either
way; the account someone has is said to be theirs first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:06 +02:00
a3b70175f6
docs(auth): the short reading of the fixes (ADR 0017)
ADR 0017 gets "The short reading": its findings, the developer's four
decisions of 21 Oct, and what changed. "The rules as they stand" says a
reset takes effect 14 days after it was asked at the latest, that a member
keeps the session a reset signed in and deletes nobody while the register
is out of reach, and no longer claims every secret is kept out of every
capture: Android holds them back, Windows from 10 2004, macOS from
screenshots but not ScreenCaptureKit on macOS 15 and later, iOS covers
the app switcher's picture alone, Linux nothing. The module's and
halkyon_app's READMEs and Learn's AGENTS.md follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:06 +02:00
012f2375a5
fix(app): the short reading's nits on the device
- Deleting an identified account whose security can't be read (the
  instance out of reach) says so and deletes nothing; it went on to a
  deletion by the session alone, refused for an account with a password,
  and the person saw a spinner, then nothing. A refusal still leaves the
  session's own deletion.
- InstanceChoice's sharesStorage is required, so no app leaves it out and
  removes a folder another instance uses.
- A PasswordUnlock forgotten is spent: its secrets read again fail
  (StateError) rather than make a proof or a wrapping of zeros.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:05 +02:00
efc1c7ffd6
fix(app): keys an earlier build kept go too, and a locked keyring stops nothing
On Apple's systems a keychain read finds only the entries of its own
accessibility, so forgetting everyone's keys but one person's missed those
builds before had kept with the default or first_unlock_this_device: they
stayed after signing out, a switch of person or removing the instance (the
short reading's L5). forgetAllBut now also reads the keyring with every
accessibility a build used and deletes each entry found by name, which a
delete finds whatever its accessibility; one that can't be read now holds
back none of the others. The repository's forgetting, run as the person
changes and not awaited, catches a keyring that can't be read (a locked
device), where its error went unhandled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:05 +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
290f584329
fix(app): a saved secret is 0600 on Apple's Arm too, and macOS saves at all
The short reading's H1: open(2) takes its mode among variable arguments,
which Apple's Arm passes on the stack, so bound with three fixed ones it
read the mode from whatever the stack held: a phrase or recovery codes
saved on an Apple silicon Mac could be readable by everyone. It is bound
as variadic now (VarArgs), and every file written is made 0600 through its
descriptor (fchmod), checked.

M1: macOS's sandbox lets an app reach the file the person chose and
nothing beside it, so the file made beside it was refused and nothing
could be saved; Learn's sandbox allowed reading chosen files alone, so its
exports never saved on macOS either. There, writePrivately now writes the
chosen file itself (decided by the developer on 21 Oct): opened without
following a link or waiting on a pipe and without 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 the
files the person chooses. Linux keeps the new file renamed over it.

The reading's nits: the buffer is wiped whatever happens, an interrupted
call is made again (EINTR), the file made beside a chosen one is named
apart from it so a name as long as a folder takes still saves, the folder
is flushed after the rename, and Linux reads a file's type and owner with
statx, alike on every architecture, where __lxstat's version differed
(riscv64's was wrong). The test of another user's file is skipped as root.
The in-place path is tested on Linux; none of it has run on a Mac.

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
a9bbda7017
fix(auth): GitHub's end deletes what it says, when it says
An account deleted at GitHub's end whose host step after the deletion's
transaction failed counts as deleted, its GitHub ID gone with it, not as
one to try again (the short reading). The app's text for an account that
signs in with GitHub alone named the end as the day it would be deleted;
it is deleted 30 days later (gitHubAccountsKeptAfterEnd), which the text
now says, 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:13:03 +02:00
634f2756d9
fix(auth): a member deletes nobody while the register is out of reach
A person still anonymous on a member was deleted there on the session
alone while the register was out of reach (the reading of the fixes' B1).
But the register may have linked them while their becoming identified on
the member failed, so with both failing, an account of the register's went
without its proof (the short reading's L2). A member now refuses every
deletion while it can't ask the register (decided by the developer on
21 Oct).

In the app, signing out of an anonymous account, which deletes it, signs
the device out all the same when the deletion fails, as a switch already
leaves one behind: the account is left to the instance's pruning of
anonymous accounts unused for 90 days. signInWays gets its doc comment
back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:03 +02:00
b83fa04594
fix(auth): a member keeps the session a reset signed in when its notice comes late
A member remembers the session issueSessionAfter made, by the account and
the register's moment, and keeps it when the register's notice of that
moment arrives (the short reading's M2). The notice's moment, taken by the
member's clock, is later by its way there; when that took longer than the
reset's answer took to come back and the session to be made, the notice
ended the very session the reset had signed in, and the device had to sign
in again.

Kept in memory for a day, 10,000 at most: notices go out at once and
hourly until confirmed. A member restarted in between ends that session as
before. endSessionsHere takes a set of sessions to keep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:03 +02:00
372ed60eb5
fix(auth): a refusal past an address's ceiling answers before its resets move
The push of a waiting reset runs after the refusal, on a session of its
own, as the members' notices are delivered (the short reading's L1). In
the request, the same statements for every address still differed in
time: an account with a reset waiting wrote it, and a write's commit waits
for the database's log to reach the disk, so how long a refusal took told
whether the address had an account with a reset waiting. Running the same
work for every address would not have closed that; leaving it out of the
request does. Tests wait for it (holdOutsCounted).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:02 +02:00
6dfa989eb3
fix(auth): a reset takes effect 14 days after it was asked at the latest
Hold-outs move a waiting reset on by the time they hold its owner out, but
never past 14 days after it was asked (resetWaitsAtMost): whoever knows
only an address can fill its ceiling of proofs every hour, and kept up, no
reset, the owner's own recovery included, would ever take effect (the
short reading, M3; the cap decided by the developer on 21 Oct). Whoever
means to take an account over with its password and mailbox, or its
phrase, has to hold the owner out for all 14 days, each wait mailing them.

The 7-day lapse after a reset takes effect and the 30-day bound stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:02 +02:00
8d6c54ef22
docs(auth): reading the full reading's fixes (ADR 0017)
ADR 0017 gets "Reading the fixes (21 Oct 2026)": each finding and what
was done, and "The rules as they stand" brought up to date (resets take
effect once 72 hours outside hold-outs passed and lapse 30 days after
they were asked; GitHub-only accounts go 30 days after the end; a member
takes the register's moment by its own clock; nothing secret copied on
the device). The module README and Learn's AGENTS.md say the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:02 +02:00
0c9bc3e007
fix(app): what a password unlocked is wiped once done with
The reading of the full reading's fixes noted that the main isolate's
copies of SRP's x and the key-encryption key, Dart's own bytes, stayed
for as long as the sign-in sheet's cubit lived, and after every change's
proof.

PasswordUnlock.forget zeroes them: the sign-in cubit wipes what the
password unlocked once signed in, once another password replaced it, and
as it closes; a change's or deletion's proof wipes what it unlocked once
its answer is made. The fake crypto's proof copies its bytes, as a real
one is new bytes. halkyon_keys' README says what is wiped and what isn't.

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
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
28fb6c0309
fix(app): no address in debug logs, and deleting goes on whatever reading the account met
The reading of the full reading's fixes found that SignInWaysState kept
Equatable's default text, which debug builds log with the address, and
that an unreadable sign_in.json logged its FormatException, whose message
holds the file's text; and that the account section's Delete caught only
refusals, so a keyring that couldn't be read escaped and the tap did
nothing.

The state says only whether an address is kept; the file's error logs its
type alone. Reading what keeps the account counts the keys as not here
where the keyring can't be read, and Delete goes on whatever the reading
met, failing as the session bloc says if it must.

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
16ad925095
fix(app): http:// and https:// on one host are one account on the device
The reading of the full reading's fixes found that the device keeps
http://host/ and https://host/ in one place (folder, session key, keyring
prefix): someone with a stored http://host/ who added https://host/ and
then removed the first deleted the second's folder, session and keys while
it was in use, losing unsynced changes, and an anonymous account for good.

The places keep the names installs made (InstanceStorage.sharesWith says
which addresses share one): choosing an address the device keeps where a
known one is puts it in that one's place in the list, and removing an
address forgets nothing another known one uses. The build's own instance
keeps its place apart. Learn and Todo pass the storage's rule.

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
9348504163
fix(app): an Apple device's keychain gives the keys only while it is unlocked
The reading of the full reading's fixes found that first_unlock_this_device
left the account's private keys readable on a locked phone once it had
been unlocked after starting, which is what forensic tools extract from a
stolen one; and that, the accessibility being part of every keychain
lookup, an entry kept with another class stayed unseen and made the next
write fail as a duplicate, so that person couldn't finish signing in on
that device.

As the developer decided, the keys are kept unlocked_this_device (still
never in a backup nor in iCloud Keychain); nothing decrypts with them
while the device is locked. Keeping keys deletes the entry first, which a
delete finds whatever its class.

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
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
801d0c6679
fix(app): a saved file is the own user's from the start, and replaces the one chosen whole
The reading of the full reading's fixes found that writePrivately made
the file, then set it to 0600: another local user could open it in that
window and keep reading what was written after; and that where the path
was a link or another user's file, chmod failed and the secret was
written into it anyway.

On Linux and macOS it now makes a new file beside the one chosen, 0600
as it is created (O_EXCL, O_NOFOLLOW), writes through the descriptor that
made it, and renames it over the chosen path, so the old file is never
written into; a symbolic link or another user's file is refused, with
nothing written (lstat through FFI, by each system's layout). Windows
writes in place, with its folder's rights.

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
8c910f8cfc
fix(auth): the reading of the fixes' nits
- The background delivery of session notices logs (its failure warning
  was never written: its session had logging off).
- A sign-in's and a phrase proof's begin check the address's ceiling
  after the network's limits, which hold a flood back before it costs
  the ceiling's queries (one an UPDATE, pushing resets).
- The module's README lists every pruning step a host schedules
  (forgetLapsedResets, deliverSessionNotices) and which mode each is for.

(The check of a member's clock ahead, which counted the register's
processing time as skew, went with C1's fix.)

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
f9880e6989
fix(auth): GitHub-only accounts go 30 days after GitHub's end, on the register alone, one failure holding back none
The reading of the full reading's fixes found that a member, which keeps
no addresses, would delete everyone it holds with a GitHub ID as if
GitHub were their only way in (D1); that each deletion delivered every
pending notice, so with a member hanging the daily run grew quadratic,
and one failing person aborted it, forgetting no GitHub ID (D2); and that
a GITHUB_SIGN_IN_ENDS set wrong deleted data at the next daily run.

forgetGitHubAccounts now does nothing on a member and waits 30 days after
the end (gitHubAccountsKeptAfterEnd), keeping the accounts and IDs while a
wrong date can be put right; deletes each GitHub-only account on its own,
a failure logged by type, that person's GitHub ID kept for the next run;
and tells the members once, after all of them (deletePerson's tellMembers).

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
9c2b1cd6e0
fix(auth): a member asks the register whose account it is before a deletion's proof counts
The reading of the full reading's fixes found (B1) that a member asked
the register knownAccounts inside the owner-proof try, so ten tries to
delete while the register was out of reach ended the owner's session,
though no proof ever left the member; and that a person anonymous on a
member could no longer delete their account while the register was down.

The member now asks first, outside the try, which wraps only what checks
the proof. While the register is out of reach, a person still anonymous
here (becoming identified ends that) is none of its and is deleted here;
anyone else waits for the register.

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
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
274b796faa
fix(auth): a hold-out moves a reset on only by its own time, and a reset lives 30 days at most
The reading of the full reading's fixes found that every refusal at the
address's ceiling moved a reset to 72 hours after it (A1): someone who
knows 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 (A2).

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), so 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.

Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_reset" ADD COLUMN "heldOutUntil" timestamp without
time zone; nullable, existing rows counted from now on.

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
a72b96d628
docs(auth): the device side's rest of the full reading (ADR 0017)
ADR 0017's full-reading part says what the device's fixes did: the
clipboard, plain HTTP refused but on this machine, sheets never left busy,
what Save leaves, secrets out of debug logs, keyboards, screenshots and
backups, memory, and the real cryptography against the register's checks.
halkyon_app's README says what an app must add (its activity's screen
guard), Learn's AGENTS.md the HTTPS rule for instances.

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
a253700f8b
test(app): the device's real cryptography meets the register's checks
The register's tests make the device's side from made-up bytes, and the
app's from fakes; nothing held what the device's real cryptography
(libsodium, on isolates) makes to what the register takes. Now
register_interop_test.dart does: a new account's setup passes
isPasswordSetup, the device's SRP answer checks with the register's
srpVerify and its M2 with the device, bound to the instance and the
purpose (another instance's or a change's identity refused), the keys
open as made, a new password passes isNewPassword, and the phrase's proof
for it checks against the phrase's own key, never the account's, with
the device's framing of the message the register's. halkyon_base_server
exports the checks it uses; halkyon_app takes it as a dev dependency.

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
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
3ca01915e2
fix(keys): no copy of a key left on the way, and the README says what is wiped
Wrapping the account's keys and sealing a data key spread extractBytes()
copies into the buffer libsodium encrypts: the buffer was wiped, the
copies on the way weren't. The keys now go from guarded memory straight
into it (runUnlockedSync), and the seed derived to check a signing key is
disposed. The README said key bytes copied to the Dart heap were wiped
afterwards; it now says exactly which are, and which Dart can't wipe (x as
a BigInt, Strings, what isolates copy, the keyring's base64).

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
67b26e0ea0
fix(app): on Apple's systems the account's keys stay on this device
flutter_secure_storage's default accessibility on iOS and macOS
(unlocked) lets keychain items into encrypted backups, which restore on
another device. The account's unlocked keys are now kept
first_unlock_this_device (macOS through its data protection keychain, the
package's default there), and never synchronised to iCloud Keychain.

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
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
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
001ab37d09
fix(app): saved files are the own user's alone, and iOS's copy goes
On the desktop a saved file (a recovery phrase, recovery codes, an
archive with progress) is made the own user's alone (0600, chmod(2)
through the C library already in the process) on the emptied file before
anything is written, so no other user of the machine reads it; Windows'
files keep their folder's rights. On iOS, file_picker (darwin v2.1.2)
writes a copy into the app's temporary folder for the system's picker and
never removes it, saved or cancelled: it goes once the picker is done.

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
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
4914df348e
fix(app)!: instances over plain HTTP are refused, but on this machine
What the app sends an instance (the proofs that sign in, the keys'
wrappings, every request a session carries) must not be read or changed on
the way, so an instance is reached over HTTPS, or over plain HTTP only on
this machine (localhost, 127.0.0.0/8, ::1), as Jellyfin servers are. An
address over plain HTTP elsewhere is refused before anything is sent there.
One kept from before is never opened: the app says why, and offers another
instance or removing it from the device, never dropping it silently.
Development builds still use their own instance over HTTP (an emulator
reaches the developer's machine as 10.0.2.2); releases never.

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