ff975bf36d16b480eba571b9b36a51b5c850825a
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
ec03f469c7 |
Three clean rounds, and the rate limit that shapes them
Item 4. Three consecutive rounds of the Android on-device suite (18 tests each) and three of the iOS sign-in suite (7 each), all green, against a real relay. `Scripts/rounds.sh N` runs every layer that can run on a given machine and prints one line per layer per round -- because "three clean rounds" should be a command, not a ritual somebody performs from memory and reports on. ⚠️ **A skipped layer prints SKIP, never a pass.** A round that silently did nothing looks exactly like a round that worked; that is how a suite stops meaning anything. The shared-account rounds needed rethinking rather than retrying. The account service allows **three registrations per address per hour**, deliberately -- forty sign-in attempts is a person with a password manager and a bad afternoon, forty accounts is a flood. Three sign-UPS per shape consumes an hour's budget for one shape and fails the next, so a test written that way measures the rate limiter rather than the product. So sign-up is proven once per shape and the repeatable part is what a person actually repeats: sign in, register a device, be reachable -- three rounds, asserting every round lands on the SAME relay account. The relay keys on the product-scoped pseudonym rather than the handle, and if it keyed on the handle each round would quietly make a new account and nobody would notice until messages stopped arriving. Fresh-registration rounds stay on the local shape, which has no such limit because a self-hosted relay is not a shared service. ⚠️ The backoff no longer pretends it can outwait an hour. It fails with the actual constraint spelled out, because a correct defence dressed up as a flaky test is worse than either. |
||
|
|
ca626b3368 |
Public pages deployed, and one credential across two products
Item 7: the comparison, landing and donation pages were written on a branch and never deployed, so the live site described a product that stopped being accurate several changes ago. Cherry-picked and deployed; the comparison page now says relays federate, that an address is derived rather than issued, and why federating is a trade rather than a win. Donation flow verified end to end against production keys: out-of-range amounts are refused before Stripe is contacted at all, and a valid amount creates a real Checkout Session and redirects to it. No payment was made -- an unpaid session expires on its own. ⚠️ I could NOT verify with test keys: there is no Stripe test key on the box, and inventing one proves nothing. Item 9, which is a narrower claim than it looks and worth testing exactly: **hosting only Messenger leaves nothing missing.** Proven three ways -- a local relay names no account service at all, runs a full sign-up and sign-in cycle with nothing else involved, and never probes for Files. And when the shared service IS used, one handle and passphrase signs in to both Messenger and Files. The two sessions carry DIFFERENT pseudonyms, which is the point rather than a detail: same-pseudonym would let the two services correlate their users, which is exactly what a shared account service must not enable. A Files-scoped token is also refused by the Messenger relay -- the product scope is a boundary, not a label. ⚠️ The shared-account tests now back off and retry on the account service's rate limit, because a dozen registrations in a burst is something any sane service should refuse. They still fail if it never clears: a rate limit that does not lift is a real problem, and skipping on it would hide one. |
||
|
|
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> |
||
|
|
b1ba9cdc58 |
Kotlin: the relay transport
The engine can hold a conversation but had no way to reach a relay. This is that: the §8 routes, both authentication modes, and the transparency log. 13 tests against a real HTTP server rather than a hand-written fake. That distinction is the point -- a fake written alongside the client agrees with it about everything including the field name it got wrong, which is exactly how this protocol's one shipped bug survived until a live round trip. Two more tests run against the deployed relay, opt-in via FN_LIVE_RELAY and silent when unset. A test that reaches the network cannot sit in a hermetic suite; it fails on an aeroplane and a suite that is red for unrelated reasons stops being read. What they check is the thing MockWebServer structurally cannot: that the URL this client builds is one relay.flamenet.io actually serves. It does, on both mounts, and the Kotlin engine verified an inclusion proof from the production log against a head fetched separately -- not against the root the proof response carried, because a server that supplies both halves of a comparison has proven nothing. Details that are load-bearing: The per-request signature covers the LOGICAL section-8 path, not the URL's. The relay mounts these routes twice and signs against the former, so signing the REST-prefixed path yields a valid signature over the wrong string and a refusal with no hint as to why. Tested by asserting the other one does NOT verify. The nonce is not decoration. Two inbox polls in the same second with the same empty body would otherwise produce identical signatures, and the relay's replay cache cannot tell an honest repeat from a captured one -- so it refuses the honest client and the app quietly stops receiving mail. Token minting is lazy, and a deployment with no token route is asked exactly once. Laziness cost an hour here: the tests hung, because takeRequest() blocks forever and I was waiting for a mint that had not happened yet. They now use a bounded read, so that mistake fails instead of looking like a hung build. Application passwords are stripped of spaces. The account service prints them in readable groups and users paste what they were shown; sending the spaces is a 401 nobody can diagnose, because the password on screen looks right. Kotlin nests block comments, so `/e2e/` followed by a star inside a doc comment opens one that never closes and the compiler blames the last line of the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |