8a7f40fb13a0760ddf0587183b49639ab4eaa8f9
208
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
388e3001af |
The three fleet suites, and Android sign-up on the two shapes it never tried
Four files that were proving things nobody could re-run. Three of them existed
only as loose scripts in a scratchpad — they are the only tests of federation,
of the fleet's invariants, and of multi-homing that exist anywhere, and none of
them was in the repository. All three pass against the live fleet as committed:
federation 11, invariants 10, multi-homing 12.
What they cover, which is why they are worth keeping rather than rewriting:
federation.mjs relay1 → relay2 across the boundary, including that the
envelope carries no sender attribution once it crosses,
that a relay off the allowlist is refused, and that an
unknown address is reported back rather than dropped.
fleet-invariants the things one relay cannot show: a claim signed for
relay1 refused at relay2, a device token that does not
travel, and a handle resolving only within its own relay.
multi-homing one send, fanned out to both of Bob's homes though only
one was named, readable from either, sharing the id that
marks the copies as one message.
`LiveSignUpFleetTest` is the Android half of the two shapes the parity table
listed as built but never exercised: the managed relay, and a self-hosted relay
keeping its accounts in the shared service. iOS has had `LiveSignUpUITests` for
both; Android had only the local-accounts shape.
The assertion that carries the weight there is the ABSENCE of the email field.
A relay with its own account table asks for an address to send a reset code to;
the shared service never does. An email field still on screen means discovery
did not land and the passphrase is about to be stretched with the wrong salt —
which does not error. It derives a different account from the same passphrase,
and the person finds out on their next device.
Opt-in through an instrumentation argument, not an environment variable: env
vars do not reach the instrumentation process, so an env-gated skip stays
skipped however it is invoked and the suite reports success having run nothing.
The file also records the sign-up budget these share with the iOS suite — three
per hour from one address — because exceeding it looks like a broken sign-up.
Verified: `:app:compileDebugAndroidTestKotlin` clean. The instrumented test
itself is unrun; it needs a device and the hourly budget was spent proving the
iOS side.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ff975bf36d |
Trust the relay you typed, pin the relays that are ours
Correcting a rule I got wrong earlier today. I had the client accept a named
account service only if it was on a pinned list containing exactly
accounts.flamenet.io — which is safe and breaks the case the server already
supports: a self-hoster running Messenger and Files together, with their own
account service holding the one account across both. The file server has taken
ENCLAVE_ACCOUNTS_URL for exactly that since it shipped, and main's relay makes
FN_ACCOUNTS_URL configurable for the same reason. A client refusing the answer
would make "run both" mean "run them with two separate logins".
The rule is asymmetric, and the asymmetry is the point:
- For flamenet.io and its aliases the account service is PINNED in the client
and whatever those relays advertise is ignored. Compromising one of ours
must not let it redirect its users' passphrase-derived secrets.
- Every other relay is believed. The user typed that hostname themselves; a
server they chose naming its own account service is a choice they already
made, and it cannot redirect anybody else's users.
Two tests, one for each half: a pinned relay advertising an attacker's service
still authenticates against ours, and a self-hosted relay may name its own.
|
||
|
|
9ced7dc3f0 |
All three deployment shapes, and the one that was unreachable
"Self-hosted with shared identity" could not work. Both clients decided where accounts lived from a hardcoded list of relay hostnames, and a stranger's relay is never on that list -- so somebody self-hosting could have the shared FlameNet account service configured on their relay and the app would still authenticate against the relay itself. Mode 2 existed on the server and nowhere else. The relay already published the answer at `/.well-known/flamenet/relay`: `accounts: shared|local` and the service URL. Nothing read it. Now the client asks, and then checks the answer against a pinned list of account SERVICES rather than of relays. That keeps the property the hostname list was protecting -- an operator cannot point their users' passphrase-derived secrets at a host they control, because a service the client does not already trust is treated as local -- while letting any relay adopt the shared service. Unknown or unreachable descriptor also means local, which is the safe direction: the passphrase goes only to the server the user typed in. relay2 is now configured as that third shape, so all three exist to test: managed (relay.flamenet.io), self-hosted shared (relay2), self-hosted local (relay1, relay3). `LiveShapesTest` covers sign-up three rounds per shape, a conversation per shape, that each relay reports what it actually is, and that a relay naming an untrusted account service is refused. Fresh handles every round, so rounds are repeatable by construction rather than by cleanup. ⚠️ Two things worth keeping: Skips are announced. `return` from an unmet precondition makes a test that did nothing report PASSED -- I had four of those reporting green before switching to `assumeTrue`, which is the same silent-skip trap I had already criticised in the Swift interop harness. A suite of silent skips is indistinguishable from a suite that works. The handle travels beside the shared-session token, never inside it. The account service will not put a handle in a token, so a relay cannot correlate its users with another product's; the relay keys on the product-scoped pseudonym and uses the handle only the first time it sees one. Tested by signing in twice and asserting the same relay account comes back. Also deploys the discovery-document fix to relay.flamenet.io, with a database backup and a rollback image. The transparency-log root is byte-identical after the deploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e07660559c |
No more WordPress, and the bug that made iOS unusable
The iOS app could not sign up against a Swift relay at all. Sign-up died on a bare "Server returned HTTP 404." with nothing pointing at the cause. The cause: the relay served its route index under BOTH `api` and a legacy prefix, while serving the actual routes only under `api`. The app settles its REST prefix by probing for that index and keeping the first prefix that answers -- so it adopted the legacy prefix, and every call 404'd. ⚠️ This was supposed to have been fixed yesterday, and was not. `f5614ab` (Aug 21, "Serve the four routes the app was calling into a 404") is what INTRODUCED the two-prefix index loop; it was the cause, not the cure. The rule got written down and the code change never landed. My own notes asserted a fix that no branch contained. Believe grep. The rule, which is more general than this bug: **serve a discovery document only where the things it describes exist.** A document listing routes a prefix does not serve is worse than no document, because it is believed. Fixed in three places, because one was not enough: - the relay serves the index only under `api`; - the client probes only `api` -- there is one prefix, and probing for others only creates ways to be wrong; - the index no longer advertises routes that are not mounted. And the purge the owner asked for: no `WordPress`, `wp-json`, `wp/v2`, `wp-admin`, `wp-login`, `wp_` or `wp-cron` anywhere in the relay, the iOS app, the Android app or the tests. `/api/wp/v2/users/me` is now `/api/flamenet/v1/messenger/me`; the user lookup moves alongside it; the dead `ajaxURL` and the two error cases only a web login form could produce are gone. ⚠️ Recorded because it cost time: the deployed relay source on the box is NOT in any branch. It carries multi-homing `envelope_id` work that exists nowhere in git, so copying local files over it broke the build. Restored from the prod tree, then patched in place. That divergence needs closing. Adds CrossPlatformUITests (iOS) and CrossPlatformTest (Android) -- each driven by arguments so the two platforms can be interleaved from a shell, because two iOS accounts in one simulator prove only that the app agrees with itself. With this, item 5 is done: a message typed in the iOS app arrived and rendered in the Android app, through relay1, end-to-end encrypted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f2d74cff0c |
Android: groups, and the wire shape iOS got wrong
Groups were 100% broken on iOS -- not partly, entirely -- because the client
required a `name` on each member that the relay has never sent. Every group
decode failed, so the feature looked implemented and did nothing.
So this one was built by reading the relay's wire types first and asserting
them against live bytes second. `Member` is `{user_id}` and only that: the relay
has no user table and deliberately will not grow one, because a relay caching
display names is a relay that leaks them. Names are resolved client-side from
the contact list, and a member you have never added shows as an id, which is
honest.
Create, list, open, add and remove members, leave, and send -- all verified
against relay1, including that a group message carries its routing so it files
under the group rather than under whoever sent it.
Two things worth stating plainly:
The relay does no fan-out. A group is a membership list it keeps so clients
agree who is in it; the encryption is still pairwise, one ratchet per recipient
device. An n-person group is n sends. That is the honest cost of the relay not
being able to read anything, and the client is what knows it.
Thread history is now keyed by an opaque thread id -- `user:<id>` or
`group:<gid>` -- rather than by user. Filing a group message under its sender
scatters one conversation across as many threads as it has participants.
Leaving a group also clears its local thread. Keeping it would leave a
conversation nobody can add to and no way to remove it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e65d1d987b |
Android: photos, and a door for every capability
Owner feedback, and it was fair: capabilities kept landing in `:client` with a live test proving them and no way for a person to use them. Remove a contact. Block someone. Clear a thread. Change a password. Read a notification. All built, none reachable. So this is the CRUD pass, plus attachments. New in the UI: contact actions (block, unblock, clear chat, remove), a presence picker, a notifications screen with per-item and mark-all-read, an account screen with display name, password change and account deletion, password reset on the sign-in screen, bottom navigation, and photos -- sent through the system picker, rendered inline. `CrudSurfaceTest` walks the app and asserts the controls exist. It checks reachability rather than behaviour, because behaviour is what the live tests cover and the door is what was missing. It found two real gaps immediately: sign-out was reachable only from the contact list rather than from Account, where people look for it, and several controls sat below the fold. Underneath: the §6.6-§6.9 content envelope, which is one codec for attachments, replies, reactions, group routing and call signalling. 17 unit tests. Two things it has to get right, and both are one-line mistakes: `fnc` must serialise FIRST, because the marker is a prefix test on the bytes. A JSON library that sorts keys puts `att` first, every envelope it produces reads as plain text on the far side, and somebody's chat fills with raw JSON. The top level is assembled by hand. Control messages travel down the same channel as chat -- the web client sends `delivery_key`, `kt_head` and `sent` copies this way. A client that renders every envelope it does not recognise shows those to the user. They are now parsed, marked unrenderable, and handed to the caller with their payload. Attachments verified end to end against a real relay, including that the relay holds only ciphertext. That path was 401 on iOS for months because the attachment routes sent the account password, which the relay refuses on `/e2e/…`; this one uses the device-scoped token like everything else, and the test is what says so. Also: the instrumented suite passed one test at a time and failed as a set. The Compose rule gives each test a fresh Activity but the process and vault survive, so the app spends its first moments reading the vault behind a spinner -- and a reset that checks for a signed-in control immediately concludes "already signed out" while the session restores underneath it. 114 JVM tests, 37 cross-engine, 15 on-device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d30a77dd1e |
Android: contacts, conversations, and a message that made the round trip
The app now holds a conversation. A message typed into it reaches a different
program through the real relay, that program answers, and the answer appears in
the thread -- verified three times, end to end, encrypted the whole way.
96 JVM tests, 37 cross-engine, 11 on-device, plus live round trips against the
throwaway fleet.
What landed: a fan-out send that reaches every device a person has, inbox
polling, message history encrypted at rest, contacts, presence, and a session
that survives a restart. Plus `:interop:peer` -- a Flamenet account you can
drive from a shell, so the correspondent on the other end is not this app
talking to itself.
Three wire shapes were wrong, and only a real relay could say so:
- `prekeys`, not `opks`. The relay answered 200 and stored nothing, so the
device was published and could not be started with.
- The send body names the recipient once at the top, not per envelope.
- The buddy list is `id`/`presence`/`screen_name`, not `user_id`/`status`.
The second and third at least failed loudly. The first did not, which is why
the live tests exist.
Decisions worth stating:
A send returns partial success rather than throwing. One of someone's three
devices being unreachable must not stop the message reaching the other two, and
the UI says "delivered to 2 of 3" rather than "sent".
The inbox watermark advances per message, after it is decrypted AND stored --
never once for the batch, which would lose everything after a crash. An
undecryptable message advances it too: it is never going to decrypt, and
leaving it would re-fail on every poll for ever.
A sent line is recorded only once a device took it. Writing it first produces a
thread showing messages the recipient never got, which is worse than an error --
the sender believes they said something.
Opening a thread marks it read locally and tells the relay nothing. A read
receipt is a disclosure, and this product does not make one by default.
History is keyed by person, not device. Someone's phone and tablet are one
conversation, and filing by device would split it into threads each holding
half the story.
Also improves how instrumented failures report: the assertion now prints what
the screen was showing. That turned a bare 90-second timeout into "No value for
user_id" and then "Failed to connect to relay1" -- one a bug, one the emulator's
network, and previously indistinguishable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
5f983c1455 |
Android: the app, and the two things only a phone could tell us
There is now a Flamenet Messenger app that installs, launches, creates an account on a self-hosted relay, registers a device, publishes prekeys, and stores all of it in an Android Keystore-backed vault. Verified on an emulator through the actual screens, not by unit test. 92 JVM tests, 37 cross-engine, 9 on-device, 3 against live relays. Two findings, both of which the JVM suite was structurally incapable of producing. **Android has no ordinary Ed25519 provider.** `KeyPairGenerator.Ed25519` and `KeyFactory.Ed25519` come from `AndroidKeyStore` and from nowhere else -- probed on API 35 rather than assumed -- and Keystore only makes keys it keeps. Every Ed25519 key in this engine is one we must hold: a device identity in our own vault, an account key derived from a passphrase. So the JCA route could never have worked. It compiled, passed 81 tests on a laptop, and threw "Not initialized" on the first launch on a phone. Worse, it was LATENT: a wrong password failed before any key was minted, so the error path worked perfectly while the happy path was broken. Ed25519 now uses BouncyCastle's `math.ec.rfc8032` core directly -- the engine's one real dependency, present because the platform leaves a hole, and a strictly better answer than the non-constant-time keygen it replaces. ProviderProbeTest records the measurement so nobody guesses again. **The prekey field is `prekeys`, not `opks`.** I guessed. The relay accepted the registration, returned 200, and stored nothing -- so the device was published and could not be started with, a failure with no error anywhere. Caught by a live test against the throwaway fleet, which exists because the same path through the emulator takes four minutes to say "timed out" and nothing about where. Two testing traps, recorded where they bite: An indeterminate progress spinner animates for ever, so Compose's test clock never goes idle and `waitUntil` never evaluates its condition -- it fails on the full timeout, reading as "sign-up is broken" when sign-up works. Auto-advance is now disabled around the wait only; disabling it for the whole test is the next mistake, because the app opens on a spinner while it reads the vault. Wiping the Keystore in `@Before` deletes the key the already-launched activity is holding, and every later write fails against a key that no longer exists. The test signs out through the UI instead, which is both safe and what a person does. Failure messages now include what the screen was showing. A bare timeout sends you to the network, the relay and the crypto in turn while the app has been displaying the reason the whole time. Also corrects a note that said this Mac had no Android SDK. It does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |