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>
halkyon_app reads where the device's person stands with SimpleLogin
(nothing to show, linked, or missing) and shows it in SimpleLoginSection,
which each app places above the password part: Add SimpleLogin, once
added one more way in. GitHub is added to nobody where SimpleLogin is
offered, and Learn's Profile offers an anonymous person SimpleLogin
alone there (D23).
No cut-off day, reminder or banner (D24): with the developer the only
person on learn.halkyon.eu, the cut-off is the operator taking GitHub's
OAuth app out of the deploy's settings once that account has SimpleLogin.
ADR 0020 records it and drops step 5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Where the instance offers SimpleLogin, the start screen offers it first,
saying that the instance keeps only the SimpleLogin ID, and opens a sheet
that waits for SimpleLogin's page in the browser (Cancel ends the wait),
then: for someone new, the choice between creating an account (the
suggested handle or another, checked with the instance, the password
twice, the recovery phrase shown once and three of its words typed back,
nothing reaching the instance before) and linking theirs again with its
handle and recovery phrase; for an account, its password opening its
keys; for an account of before, the keys made with the handle it lacks.
SimpleLoginCubit holds the steps, each refusal shown where it is mended
(a handle's on the handle, a sign-in to begin again on the browser's),
and closing the sheet stops waiting on the loopback.
The suggested handle is never checked: the instance suggests none that
anybody has, and every answer about a handle counts against its limit
(D5). The fake's handle rule is now the module's.
Learn and Todo build it with SimpleLogin's browser (halkyon_oidc_flutter,
now a direct dependency of both, with their names on the loopback's
page), their schemes and their loopback ports (43823, 43824), as the
instance's forwarding page has them. Their tests offer no SimpleLogin;
halkyon_app's test the sheet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
SimpleLoginRepository signs in with SimpleLogin in the browser
(OidcBrowser: the app's scheme on Android, iOS and macOS, the app's port
on the loopback on Linux and Windows, D9), then makes the account's keys
and signs someone new up, opens an account's keys with the password, gives
an account of before its first keys, or links a new SimpleLogin account in
place of a lost one with the recovery phrase (D7), which opens the keys on
the device first: a phrase that doesn't fit sends no proof, and no
password is asked after. An anonymous person adding SimpleLogin keeps the
keys their device holds. Nothing is the device's until `enter`.
SimpleLoginGateway calls halkyon_base's `simpleLogin` and `keys`
endpoints (ServerpodSimpleLoginGateway), with a fake for tests, as does
FakeOidcBrowser. The calls made on clients of their own (signed out, or as
a session not yet the device's) move from the password gateway into
BaseCalls, which both share.
Fixes BaseProtocol, the client of halkyon_base alone, which didn't read
halkyon_oidc's classes: every refusal of SimpleLogin's sign-in on a
signed-out call would have reached the app as a failure. OidcBrowser keeps
its loopback private, so that a fake can implement it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
AccountCrypto makes the keys of an account that signs in with SimpleLogin
(newKeys): new key pairs or an anonymous person's device keys, wrapped
under the password and a new recovery phrase, with the public half of the
password's proof key in place of SRP's verifier. It proves the password to
open them (proveKeysPassword: one Argon2id for the proof and the unwrapping)
and the recovery phrase to link a new SimpleLogin account in place of a
lost one (signMendProof, bound to the attempt). The interop tests check all
three against the register's own checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
OidcBrowser opens the page of an attempt the server began (OidcStart),
waits until the browser comes back by an OidcReturn (the app's scheme,
a verified https address, or the loopback) and returns the provider's
code (OidcCode), its refusal (OidcDenied), or nothing when the person
closed the browser. It checks the returned state against the attempt's,
as the server never compares it; a wrong state, neither one code nor
one refusal, no browser or a failed way back are OidcCallbackExceptions.
halkyon_app's LoopbackWebAuth moves in with its tests, relicensed from
AGPL v3 to BSD-3-Clause by its author, the developer; GitHub's sign-in
keeps using it from there until it ends. The package is BSD-3-Clause,
publish_to: none, as the module (ADR 0023, D2, D3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
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>
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>
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>
- `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>
`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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The full reading's part in ADR 0017 records the device's medium 1 as done
(the identity's purpose and digest, the base URL in place of the host, an
instance under a path taking no password sign-in), and "The rules as they
stand" says what "password" binds; the module's and halkyon_app's READMEs
say the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The device's answer takes what it is made for and the digest of what it
sets, as the register now requires: the new password (made before the old
one's proof, which binds it), the new phrase's wrapping and proof key, the
authenticator confirmed (the first or another) or removed, new recovery
codes, calling resets off, deleting, and asking for an authenticator's
reset apart from signing in. proof_digests.dart maps the protocol's objects
onto the shared framing; the key proof's password digest uses it too.
The fake gateway refuses a proof made for anything but the call's own
change, as the register does, so every flow's tests hold the repository to
it; the fake's authenticators have UUIDs, as the register's do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, 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 its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.
The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The full reading's L1: the warning that ten wrong proofs ended a session is
there for the owner to learn of it, and any other session of the account
(another app on the same stolen phone) dismissed it. Now only a session
made after the warning dismisses it (signed in again, or the sign-in that
first shows it); an older one is refused (PasswordRefusal.signedInBefore,
its own text in English and German), and a new password clears it as
before. A member tells the register when its session asking was made.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The full reading's M1 and L6: once GitHub IDs were forgotten at
GITHUB_SIGN_IN_ENDS, an account that had signed in with GitHub alone
counted as anonymous, so a stolen session and any mailbox gave it a first
password and authenticator; and such accounts, with no way in left, were
never pruned. Now:
- at the end, the daily run that forgets the GitHub IDs first deletes the
accounts that sign in with GitHub alone (no address, so never a
password), with everything they have, members told (deletePerson); the
app tells them for the three months before that their account goes then
unless they set up a password (passwordSectionGitHubEndsDeletes, en/de);
- password.set adds an address without GitHub only for someone anonymous
where the session lives (anonymousHere, which a member tells the
register, as it tells freshGitHubSignIn); anyone else is refused before
any code is used.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
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>