4728928d98d526602cd456bee07430a2ae8388b1
14
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a213ad8d5d |
Android: sealed sender, so the relay stops learning who talks to whom
Key transparency stopped the relay substituting keys. It did nothing about the relay watching who writes to whom, which is the metadata most people actually leak. §14 moves the sender's identity inside the ciphertext: the relay learns who a message is for and nothing about who it is from. Verified against relay1 rather than asserted. A sealed submission is accepted unauthenticated, the stored row comes back with `from_user = 0` — the relay saying it does not know — and the recipient still reads the message with the sender recovered from inside the seal. Cross-engine verified in BOTH directions against the normative web engine, and that mattered: the seal is asymmetric in practice, since one side derives from an ephemeral it generated and the other from one it was handed, so a byte-order or AAD mistake can easily break only one direction and a single-direction test picks the half that works. The interop harness gained `seal` and `open_sealed` ops for it; 43 assertions, none failing. 🔴 **The seal is confidentiality, never authentication.** The recipient's `IK_dh` is published to the world, so anyone can produce a well-formed seal claiming to be from anybody. The claimed sender picks a session and nothing else; the ratchet opening it is what makes it a fact, and the identity pin is what refuses an impostor. There is a test that forges a sender deliberately, so that if somebody later renames these fields into something confident the reason they are claims is still written down and still executing. ⚠️ **The first message to a new contact cannot be sealed.** Sealing needs the recipient's delivery key and that key travels inside an E2E message, so a prior exchange is required. `Sent.sealed` reports which happened rather than leaving the UI free to imply anonymity that was not achieved — a silent fall back to an unsealed send is exactly what §14.5 warns about. ⚠️ **Rotation is blunt, and the copy says so.** There is no sender to block, so the only lever is replacing the delivery key, which cuts off every contact until the next time they are written to. Reachable from Account, asserted by `CrudSurfaceTest`, and live-tested: a submission presenting a retired key is refused by the relay. The relay stores only `SHA-256(key)`. The key itself never passes through the relay from its owner, which is the only reason an unauthenticated sealed submission can be authorised without naming who is submitting. Tested: 108 engine, 67 client, 43 interop, none failing, live suites enabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cfeefa0616 |
Contacts carry the peer's address, so an honest key verifies
The relay files a key-log leaf under an account's fnid once it has one, and
under `local:<audience>/<user_id>` before that. The subject is inside the
hashed bytes, so a verifier that does not know the address cannot recompute the
leaf — and enforcement refused honest keys from any account that had claimed an
identity. A false positive on a real conversation is how a security setting
gets switched off and left off, so this is the half that decides whether the
feature is usable at all.
No relay change was needed. `GET /e2e/account/identity/{handle}` already
resolves a handle to an address, unauthenticated, and the buddy list already
carries the handle. The contact record now learns the address once and pins it
beside the identity keys, and `enforceKeyLog` recomputes against the pinned one
by default.
⚠️ **Pinned, not looked up per check**, and that is the entire point. Reading it
fresh each time would let a relay that wants to substitute a key simply also
change the name it files that key under, and every verification would agree
with the new answer. Recomputing against the address this contact has always
had is what makes the check about a person rather than about whatever the relay
says today.
First contact is still trust-on-first-use. Taking the relay's word once is not
a check; what it buys is that every later answer has to match it, and the
safety number is what covers the first sighting.
🔴 A CHANGED address is an identity change, for a stronger reason than a
changed key: an fnid is derived from the account key, so a new address is not a
new device — it is a different account key, and therefore a different person.
Accepting a change re-resolves rather than keeping the old address, because if
the address is what changed, keeping it would leave every later check
recomputing against a name the account no longer publishes under, and honest
keys would stay refused for ever.
⚠️ An unreachable relay pins NOTHING rather than pinning "no address".
`rememberIdentity` does nothing once an address is pinned, so a wrong answer
would be permanent — and the next successful lookup would then read as the
address changing, which is the alarm, for a network blip.
Proven against relay1 in three states, because only all three make it legible:
refused with nothing pinned, accepted once the contact carries the right
address, and refused again with a wrong one — so what makes it pass is the
address being right, not the check having been loosened. A changed address is
asserted to come back CHANGED against the real relay too.
Tested: 100 engine, 65 client, none failing, live suites enabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
6a6bcc9d08 |
§19 against relay1, and the false positive it found
The enforcement landed last change was unit-tested against a MockWebServer
carrying responses this repository wrote — which proves the verifier agrees
with our idea of the relay, not with the relay. That distinction has already
cost this protocol three silent wire-shape bugs, every one invisible to a fake.
`LiveKeyLogTest` runs it against relay1's real log, opt-in via `FN_LIVE_FLEET`.
Six cases, all passing: a key the relay genuinely published verifies; a
substituted key is refused; a second check reconciles a pinned head with a
grown tree using proofs the relay generated; a root we never saw at our pinned
size is a fork; and the send path refuses on a bad pin while NOT refusing an
honestly published key with enforcement on.
**And it found a false positive on honest keys.**
An account that has claimed an fnid is logged under it, and under
`local:<audience>/<user_id>` before that. The subject is inside the hashed
bytes, so a verifier that does not know the fnid cannot recompute the leaf. The
key was published perfectly well; the check cannot see it. The test pins the
behaviour both ways — refused without the fnid, verifying with it — so this is
a fact in the suite rather than a comment somebody may or may not read.
This is the worst shape a check like this can fail in. Refusing real
conversations is how a security setting gets switched off and left off.
Nothing in the relay's contact or device routes returns a peer's fnid, so the
client cannot learn one today and this is not fixable here. What is fixed:
- `KeyNotLoggedException.hadEntries` separates "this device has publications,
none of which match" from "nothing was ever logged for this device". Both
still refuse — refusing is the safe direction — but they no longer produce
the same sentence, and only the second is the shape a substitution takes.
- `enforceKeyLog`'s `fnid` parameter now documents that it is REQUIRED for any
account that has claimed one, rather than reading like an optional extra.
- `RelayClient.claimAccountIdentity` exists, because the test needed to claim
an identity to prove any of this and Android had no way to.
The real fix is for contact records to carry the peer's fnid, which is stronger
than the current check anyway: it verifies the key was logged under *that
person*, rather than under whatever subject the relay chose to file it against.
Tested: 100 engine, 58 client, none failing, live suites enabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
0330a7b84a |
Android: refuse a key we have reason to distrust
Android had the transparency log's verifier and nothing that called it. The parity table listed trust state, verification, identity-change acceptance, and key-log enforcement as absent, and they were — so the relay could hand out a key it minted, the conversation would encrypt to it, and every byte on the wire would be correctly encrypted to the wrong person. A log nothing checks records the substitution faithfully and is never read. Two independent checks, because neither is sufficient alone. **Pinning, always on.** Every identity key a peer offers is compared against what was pinned on first contact; a send to a changed identity is refused until a person accepts it. The comparison is "every offered key is already pinned", not "any" — a relay adding one key of its own ALONGSIDE the real ones is the attack, and the fact that all the genuine devices still work is exactly what makes it invisible. There is a test for that case specifically. **The log, opt-in.** `require-key-log` on the Account screen makes a send demand that the relay prove the recipient's key appears in the log it publishes. Off by default for the same reason the web engine's is: a relay predating the log has none, and refusing every peer on those deployments is a worse failure than the one being prevented. `KeyLog.verifyConsistency` did not exist in the Kotlin engine. Inclusion alone is worth very little — a relay that rewrites history reissues perfectly valid inclusion proofs against its new tree, honest proofs about a dishonest log. Consistency against a head this device already pinned is the one thing it cannot produce. Ported from `engine/src/keylog.js`, which is normative, rather than re-derived: a re-derivation that accepts too much passes every honest test and fails only against an attacker. Head gossip rides along with messages and compares a peer's view against ours, which is what turns a per-device check into a detectable fork — alone, each client only knows the relay was consistent with itself. Tested, 100 engine / 52 client, none failing. Consistency is checked over EVERY pair of prefix sizes in the published vectors, because the traversal has a special case when the old size is a power of two: an implementation that gets that wrong still passes 3 -> 5 and fails 2 -> 5. The proof builder the test uses is trustworthy because the tree it builds is the one whose roots already matched the published vectors at every size. The refusals are what carry the weight, so each has its own test: a head going backwards, two roots at one size, a tampered proof node, a served key absent from the log, an inclusion proof for somebody else's leaf, and a rewritten log that cannot be reconciled with a pinned root. A trust check that accepts everything passes every happy-path test ever written. Reachable from the UI, per the standing rule. The thread shows identity state and the safety number; the changed state offers accept and nothing else, because an "ignore" affordance would be a way to silence the one signal in this product that means interception. `CrudSurfaceTest` asserts the controls exist. ⚠️ Not yet exercised against a live relay — the tests drive a real HTTP server with hand-built responses. Implemented and unit-tested, not proven in the field. ⚠️ Sealed sender is still absent, so the relay still learns who talks to whom; these protect different things. 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.
|
||
|
|
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> |