Commit Graph
35 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5 e62317b6f3 Reproduce the loss without simulators, and show the fix is not sufficient
Two :interop:peer accounts against relay1, both running the retired-session fix.
Neither polls before sending, which is the simultaneous-initiation case:

    p1 send / p2 send   -> delivered:1, failed:0   (each on its own chain)
    p1 poll / p2 poll   -> both first messages arrive
    p1 send / p2 send   -> delivered:1, failed:0
    p1 poll / p2 poll   -> {"messages":[]} / {"messages":[]}

The second exchange is lost in both directions, silently, with the relay
reporting success. Same failure as the two-simulator run with every iOS variable
removed, and it reproduces in about two minutes instead of forty.

So retaining the retired session is necessary and not sufficient. The receive
side can now open the old chain; the loop still does not close. The remaining
gap looks to be on the send side — after adopting the peer's chain each client
encrypts on the adopted one while the peer still reads the one it kept, so
neither is listening where the other is talking. Resolving this properly means
both sides converging on the SAME chain by a deterministic tie-break, not each
adopting the other's.

Recorded so the earlier commit is not read as having closed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:08:34 -04:00
Paul HittandClaude Opus 5 999ab2bd26 Keep the chain a re-establishment replaces
When both sides open the same chat before either has processed the other's
prekey — two people tapping a conversation at the same moment — each runs X3DH
and each ends up adopting the other's chain to read what arrived. From then on
the peer sends on the chain being replaced. Overwriting it without a copy does
not lose one message; it makes that direction undecryptable for the rest of the
conversation, silently, because an envelope that will not open is skipped rather
than reported.

**iOS.** `E2EInbox` had the fallback already — `loadArchivedRatchet` is tried
before building from a prekey, and its comment describes exactly this situation.
It was unreachable: `saveArchivedRatchet` appeared once in the file, and that
call only re-saves a ratchet that was already archived. Nothing ever put a live
session there. It does now, immediately before the session it replaces is
overwritten.

**Android was worse, and had no fallback at all.** `Conversations.poll` read

    val session = existingSession(fromDevice) ?: E2ESession.respond(identity, envelope)

which looks like a fallback and is not one: with a stored session present that
was the ONLY thing tried, `decrypt` threw for a peer on another chain, and the
catch swallowed it. `respond` was never reached. Now three chains are tried in
order — the current one, the retired one, then a fresh one built from the prekey
block — and the replaced chain is kept.

⚠️ The watermark still advances on a total failure, deliberately. An envelope
none of the three chains could open will not open next poll either, and holding
the cursor there would wedge the inbox behind one bad message. What made this
lossy was never the watermark; it was that only one chain was ever tried.

`Vault` grows a retired-session slot under its own prefix — deliberately not
`SESSION_PREFIX`, or `knownPeers()` would list every retired chain as a peer —
and `forgetAllSessions` clears it too, since a sign-out that left chain keys
behind would keep exactly what it claims to erase.

Tests, and the one that matters is the storage-level one:

- `VaultTest.a retired session is kept and still opens its chain` — asserts the
  ADOPTED session cannot open the retired chain before asserting the retired one
  can, so it cannot pass by accident. **Verified load-bearing**: neutering
  `saveRetiredSession` fails it, restoring it passes.
- `VaultTest.forgetting sessions forgets the retired ones as well`.
- `E2ESessionTest.simultaneous initiation does not strand the conversation` —
  covers the engine primitives and asserts the SECOND exchange, the one that
  used to die. ⚠️ It holds both sessions in local variables, so it passes with
  or without the storage fix; it is not evidence for it.

111 engine tests, 0 failures. Both platforms compile.

⚠️ This is a real correctness defect and is fixed on its merits. Its
relationship to the one-way loss seen between two simulators is NOT established
— that symptom is the first message, and both first messages open fine either
way. Do not close that investigation on this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:53:19 -04:00
Paul HittandClaude Opus 5 b6519faada Say what a two-simulator run actually costs
Roughly one second of test time per minute of wall clock, measured with the host
healthy — 38 runnable processes, load 27. The cost is XCUITest snapshotting the
whole accessibility tree per query against a home screen carrying a stories
strip, a buddy list, a groups section and a tab bar.

A DM run is 20-40 minutes per side and the group phase has never completed in
one sitting. Worth knowing before someone schedules this as a quick check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:42:34 -04:00
Paul HittandClaude Opus 5 fbe09d0b61 Disprove my own suspect, and narrow to true simultaneous initiation
The `existing != nil` theory was wrong, twice over. `ratchet` is nil at
E2EInbox.swift:995 only when the envelope is not type `prekey`, so an inbound
prekey never reaches that guard and it cannot drop a first message. And
measurement settles it: testARoundTripWithANonIOSClient passed in 54.2s, with
iOS sending FIRST — creating its own outgoing session — and then receiving and
rendering the peer's reply.

    peer add qrt90210  -> {"added":"qrt90210"}
    peer send          -> {"delivered":1,"failed":0,"to":404}
    iOS round trip     -> passed (54.234s)

Both directions now proven in isolation. What is left is the case neither
experiment covers: both sides sending before either has processed the other's
prekey, which is what two simulators racing through sign-up produces and what a
shell-driven peer never does.

The leading hypothesis moves to line 1006, which overwrites the session built
from an inbound prekey without archiving the one it replaces —
saveArchivedRatchet appears once in the file and only re-saves something already
archived. Recorded with what would confirm it, and with an explicit warning not
to patch on reasoning alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:38:27 -04:00
Paul HittandClaude Opus 5 18499ae746 Narrow the delivery failure to the receive side
The headless peer tool settles the direction. iOS sent to a real shell-driven
account and it arrived:

    testAMessageReachesANonIOSClient   passed (39.2s)
    peer poll -> "hello from iOS to the peer tool", from_user 403

So the send path and the relay are fine, and neither explains two iOS clients
failing one way. What differs there is that both sides initiate a session
simultaneously; the peer tool never initiates, which is why it works.

Suspect is E2EInbox.swift:995. `ingest` returning true means "advance the
cursor" — consume the envelope — and false means hold it for the next poll. The
retry branch is taken only when `existing == nil`. A recipient that already
created its own outgoing session has `existing != nil`, so an envelope it
cannot yet decrypt is skipped forever. That is exactly the simultaneous-
initiation case.

Recorded as a hypothesis with its evidence, not as a fix. It has not been
instrumented, and this file's history includes a previous advance-uncondition-
ally bug that lost messages silently, so the area deserves care rather than a
quick patch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:35:55 -04:00
Paul HittandClaude Opus 5 95659f4685 Record the two reproducible iOS pair symptoms in PARITY
Both reproduce on an idle machine, so neither is load. One-way delivery between
two fresh accounts, and a group create that throws while the sheet sits open.
The evidence that rules out the first wrong theory is written down with them, so
nobody re-derives it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:30:00 -04:00
Paul HittandClaude Opus 5 a9ec8e98bb One sign-up helper, because five copies had the same bug
Seven of 52 UI tests failed in one run with "sign-up did not complete". The
app was right and the tests were wrong, and the failure dump said so:

    signup.password  value: •                    ← one character
    signup.confirm   value: •••••••••••••••••    ← all seventeen
    signup.submit    Disabled

iOS puts "Use Strong Password?" over the first `.newPassword` field, and from
the first keystroke on it swallows what follows. Every sign-up helper in this
target called `dismissPasswordSuggestionIfPresent()` — at the end, after both
passwords had been typed, by which point there is nothing left to save. The app
then correctly refused a form whose two passwords disagreed, and the report
blamed sign-up for it.

It is non-deterministic, which is why it reads as flake: a fifth suite running
identical keystrokes passed in the same run. It is not flake and it is not
machine load.

There were five hand-rolled copies of that helper — four here, plus one I wrote
for the new two-simulator suite, which reproduced the bug immediately and is
what makes the point. Now there is one, `XCUIApplication.completeSignUp`, and it
dismisses the panel when the field takes focus, reads both fields back, repairs a
short one, and fails naming the cause instead of the app.

Two new suites:

  TwoSimulatorUITests — two simulators, two accounts, one relay. Both sides run
  the same test with the handles swapped, so neither has to start first; the
  peer lookup retries, which is the rendezvous. A direct message crosses in both
  directions and a group created on one device arrives on the other. This is
  weaker evidence than CrossPlatformUITests and says so in its own header: both
  halves are the same binary and will agree when both are wrong. What it proves
  that nothing else does is that a message crosses two devices, two Keychains
  and two ratchets and lands in the app's own UI.

  AppSurfaceUITests — the screens nothing was opening. Reactions, unsend,
  in-chat search, mute, the voice-note recorder, the stories strip, the groups
  section, the safety number and five of the seven settings screens had no test
  at all. Deliberately shallow and it says so: this catches a screen that
  crashes or fails to push, not one that is wrong.

Also here, each found while running the above:

- DEVELOPMENT_TEAM was "2W7DA3A8W2", the free personal team on the build Mac,
  while the App Store provisioning profile that already exists for
  net.flamenet.messenger belongs to M5923Y84P5 — the team that ships hittfitness
  and shepherdgiving, valid to 2027-06-04. A personal team cannot produce a
  distribution build, so every archive for TestFlight would have failed naming
  the certificate rather than the team ID that put it out of reach.

- Scripts/generate.sh, because .gitignore excludes the whole .xcodeproj and
  SwiftPM keeps Package.resolved inside it. Since the WebRTC dependency landed a
  fresh checkout had no pins at all: resolve against the network and get a
  different build tomorrow, or disable resolution and fail outright. Both
  unacceptable for something going to testers. The pins live in Scripts/ now.

- rounds.sh ran an iOS *build* and called it a round. The 45 XCUITest cases
  never ran. It runs them now — and refuses to score a run that executed zero
  tests as a pass, which xcodebuild will happily report after a runner crashes
  and relaunches:

      Test Suite 'TwoSimulatorUITests' passed
           Executed 0 tests, with 0 failures

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:55:19 -04:00
Paul HittandClaude Opus 5 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>
2026-08-25 14:31:07 -04:00
Paul HittandClaude Opus 5 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>
2026-08-25 14:19:01 -04:00
Paul HittandClaude Opus 5 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>
2026-08-25 14:11:11 -04:00
Paul HittandClaude Opus 5 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>
2026-08-25 13:48:44 -04:00
Paul HittandClaude Opus 5 76d485fb55 Bring the parity table back in line with what has been run
Three rows had drifted from the code, in the direction that matters — claiming
less than exists, which sends people to build things that are already there.

`SiteConfig` on Android is not missing: `SiteConfig.kt` holds the three
deployment shapes, `MainActivity` has the relay field, and the screen shows an
`identity-mode` label naming which kind of server it is *before* a passphrase
is typed. Marked ⚪ regardless.

Sign-up on the managed and shared shapes now has a test rather than nothing —
`LiveSignUpFleetTest` — which compiles but has not been run, so the row stays
🟡. The distinction is worth keeping: written and unrun is not built and
unexercised, and neither is done.

Send/receive text was verified app-to-app this time, not app-to-harness, and
the evidence is recorded with the log lines it came from.

Also added the two environment traps that cost the most time today, because
neither is visible in a test log: XCUITest degrades into "Timed out while
synthesizing event" and 1700-second runs when macOS background daemons are
saturating the machine, and Spotlight indexes DerivedData unless the path ends
`.noindex`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:55:33 -04:00
Paul HittandClaude Opus 5 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>
2026-08-25 07:21:40 -04:00
Paul Hitt 824e822acf Merge main: relay hotfixes and the docs pages
Conflicts were comment wording only, from the WordPress purge. Kept this
branch's prose: main's side carries the rename's artifacts ('the the
previous platform'), and the code either side registers is identical.
Verified after resolving — one api index, no wp-json index, the
federation refusal path, HomeRelays, and FN_ACCOUNTS_URL all present.
2026-08-24 17:06:37 -04:00
Paul Hitt 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.
2026-08-24 15:39:29 -04:00
Paul Hitt 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.
2026-08-24 06:20:32 -04:00
Paul Hitt 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.
2026-08-24 05:40:01 -04:00
Paul HittandClaude Opus 5 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>
2026-08-24 05:28:15 -04:00
Paul HittandClaude Opus 5 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>
2026-08-23 20:46:54 -04:00
Paul HittandClaude Opus 5 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>
2026-08-23 19:32:02 -04:00
Paul HittandClaude Opus 5 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>
2026-08-23 18:47:45 -04:00
Paul HittandClaude Opus 5 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>
2026-08-23 17:55:49 -04:00
Paul HittandClaude Opus 5 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>
2026-08-23 17:02:16 -04:00
Paul HittandClaude Opus 5 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>
2026-08-23 15:37:59 -04:00
Paul HittandClaude Opus 5 8a3770f1d1 Kotlin: the session layer, verified across the wire
An app does not use X3DH and a ratchet, it uses "send this string to that
person". This is that layer: device identities with prekeys and rotation,
envelopes as the relay actually carries them, and sessions that survive being
put away and taken out again.

11 unit tests, and the interop harness grows from 23 assertions to 37 -- the
new ones at the envelope level, which is where this protocol's one shipped bug
lived. A client and a relay disagreed about a field name and nothing caught it
until a live round trip; hand-assembled arguments to X3DH would not have.
Kotlin now builds a whole envelope, the web engine's own session layer parses
it, and both directions agree on the associated data.

Three decisions worth stating.

Retired signed prekeys are kept for longer than the relay keeps undelivered
mail. A sender fetches a bundle, encrypts against that prekey id, and the
envelope can then sit in a queue for the relay's full retention window.
Discarding the private half at rotation turns every one of those into an AEAD
failure -- which the receiving client cannot distinguish from an attack. A
prekey retired past retention is refused outright rather than guessed at, for
the same reason: guessing derives a garbage secret and reports it as tampering.

The handshake block rides every message until one is answered, not until one is
sent. Any message may be the first to arrive.

Sessions archive with their skipped message keys, and the harness proves it
against the peer rather than against our own bytes. Those keys are the messages
already in flight; an archive that drops them loses exactly the mail that was
in the air when the app was killed. The failure this whole path exists to
prevent is a client that loses session state, re-registers a device, and shows
every contact "security keys changed" -- the warning that is supposed to mean
an attack.

`org.json` is compileOnly: it is in the Android framework, so it costs nothing
on a phone and bundling a copy would shadow the platform's. Safe only because
the two encodings that must match byte-for-byte -- the ratchet header and the
canonical log leaf -- are built by hand; Android's implementation preserves key
order and the reference one does not.

Also adds PARITY.md: what the iOS app does, what Android does, what is left.
It is the definition of done, and a row moves only when something tests it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:12:14 -04:00
Paul HittandClaude Opus 5 fc93df24e6 Kotlin: Ed25519, and cross-engine interop against the web engine
The seventeen Kotlin tests that landed with X3DH and the ratchet prove the
engine agrees with itself. That is not the property anyone needs. Two engines
written by the same person agree about the same misreadings of the spec, and
every one of those shows up months later as "the message will not decrypt" on
someone else's phone. So this wires the Kotlin engine up against the web
engine, which is normative, in both directions.

23 assertions, all passing: v1 X3DH both ways, identical associated data,
alternating ratchet turns past the handshake, out-of-order and permanently
lost messages, a drained one-time prekey pool, tamper rejection, safety
numbers, and the canonical transparency-log leaf.

Three things worth recording.

Ed25519 was missing, and the engine could derive an account address but never
sign a claim for one. The JCA offers no route from a private EdDSA key to its
public half, so the RFC 8032 keygen is done here -- and verified against the
RFC 8032 test vectors, not against a round-trip through our own code. A
round-trip proves only that the file agrees with itself, and every wrong clamp,
endianness and sign convention agrees with itself perfectly. The scalar
multiplication uses BigInteger and is therefore not constant-time; it runs once
at sign-in, in the user's own process, and signing deliberately stays with the
platform provider.

The harness reports a build failure as a failure, never a skip. The Swift
harness beside it once printed "skipped" -- which reads as green -- while
silently building a different checkout. Only a missing JDK skips here, and the
resolved path is printed either way.

Each section is wrapped so a divergence is a named failure rather than a crash.
That was not theoretical: breaking the Kotlin message-key derivation on purpose
crashed the process with a stack trace and skipped every later section. It now
reports four failing sections and finishes the run. The negative control is how
this was checked at all -- a suite that passes on its first run and has never
been seen to fail is not evidence of anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:04:35 -04:00
Paul HittandClaude Opus 5 e0589eff14 Kotlin: the Double Ratchet
The second half of a protocol Android did not have. With X3DH it can now agree
a secret, and with this it can hold a conversation.

Two ratchets: the symmetric one advances per message so a compromised key
reveals nothing earlier, the DH one advances on each direction change so a
conversation recovers from compromise rather than staying broken.

The tests run conversations rather than checking steps, because that is what
catches the bugs this code actually has. X3DH is a pure function — it agrees or
it does not. A ratchet can agree for six messages and diverge on the seventh
because one side counted a skipped message differently, and the symptom is only
that something will not decrypt. So: out-of-order delivery, a message lost
forever, a gap ACROSS a direction change (which is the whole reason "pn" is on
the wire), and four DH turns in sequence.

Three things that would each produce plausible-but-wrong bytes, so each is
tested rather than reasoned about:

- the sending chain advances BEFORE the key is used, so a caller that retries
  cannot encrypt twice under one key — two GCM encryptions under one key and
  nonce leak the XOR of both plaintexts
- the header's canonical JSON is hand-built, key order "dh, pn, n", no spaces,
  because it goes into the AAD and three implementations must reproduce it
  byte-for-byte
- the derived nonce is stripped from the wire rather than sent, so a receiver
  cannot be handed a nonce that disagrees with the one its key implies

MAX_SKIP caps derivation at 1000: a header claiming a huge counter costs the
sender nothing and the receiver everything.

13 tests. Still missing before an app: a session layer, a wire client, and the
app itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:51:30 -04:00
Paul HittandClaude Opus 5 c893d412d2 Kotlin: X3DH, and the account KDF beneath it
The Android engine had primitives and no protocol: no handshake, no ratchet, no
session. It could verify a safety number and could not start a conversation.
X3DH is the first half of fixing that.

The four agreements are mixed in the order the spec fixes, and DH4 is omitted
rather than zero-filled when the one-time prekey pool is drained. Both are
choices that produce 32 plausible bytes when wrong — the failure lands at the
far end as a message that will not decrypt, pointing nowhere near here, so the
tests check that the two sides AGREE rather than that either looks right.

Keys are JCA objects rather than raw bytes throughout, because Conscrypt will
not build an XDH key from raw bytes and wants a DER spec. Passing raw bytes
would work on this JVM and throw on the first tap on a phone, which this engine
has already been bitten by once.

Also here: AccountKdf, so Android can derive an account key and an address.
Checked against the vectors pinned from the enclave client — including the
non-ASCII and empty-passphrase cases, where a CharArray API and a UTF-8 wire
format quietly disagree.

⚠️ Still missing before an Android app can send a message: the Double Ratchet,
a session layer, a wire client, and the app itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:47:12 -04:00
Paul Hitt b7021e9a73 Merge remote-tracking branch 'origin/main' into feat/android-account-kdf
# Conflicts:
#	Sources/Views/SignInView.swift
2026-08-23 12:23:21 -04:00
Paul HittandClaude Opus 5 fdd1ab7ee7 Multi-homing: keep a mailbox at more than one relay
Federation let somebody at another relay write to you. It did not stop your
relay being the only place your mail lives — so relay1 going away took the
undelivered messages with it, and relay1 refusing to serve you locked you out
of your own inbox. That is the gap people actually mean by decentralised, and
it is what this closes.

An account registers the same devices, with the same identity keys, at several
relays and publishes a signed record naming them. A sender fans out to every
one. Any single relay can then be unreachable, hostile or gone and the message
still arrives, because it was never only there.

The record is signed by the ACCOUNT key, not asserted by a relay. A relay that
could edit the list would drop the others from it and become the sole delivery
target, which is precisely the position multi-homing exists to escape. A sending
relay verifies against the key the recipient's address decodes to — the address
IS the key — so it never has to trust what the relay it asked handed back.

The serial closes the half a signature does not. An OLD list is genuinely
signed: a relay could serve one from before a second home was added and it
would verify perfectly. Records may only move forward, and the test proves the
real attack rather than a forgery — publish serial 2, then replay the still
validly signed serial 1, and get a 409.

Envelopes carry a client-chosen id, identical in every copy, so a recipient
polling two relays sees one message rather than two. Nullable and not unique:
an envelope from an older client has none and must still be delivered, and two
rows legitimately share an id when one message goes to two devices.

`maxHomes` is 4 because a sender fans out to every entry, and a signed record
naming a thousand hosts would turn any sender's relay into an amplifier aimed
wherever the signer likes — with a signature making it look legitimate.

Verified on relay1/2/3.flamenet.io: 12 checks. Alice names only relay2, her
relay discovers relay3 from the signed record, and Bob reads the message from
either. Local suite 255.

⚠️ Delivery to an unreachable home is reported and NOT retried. A home that is
down when you send simply misses that message; closing that needs a queue and
this does not have one.

⚠️ Neither app calls any of this yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 12:15:01 -04:00
Paul HittandClaude Opus 5 6f83248e1c Give an account an identity no server issued
A relay's `user_id` is a row number it assigned itself, and the account
service's pseudonym is HMAC'd per product precisely so two services cannot
correlate their users. Neither survives a trip to a second relay, so nothing
addressed by either can cross a relay boundary — which is the first thing
federation needs, and the reason a deleted relay currently takes its users'
names with it.

So identity becomes a key the client derives and holds: Ed25519, stretched from
the passphrase under `flamenet/account/identity/v1`, alongside the per-product
keys that already work this way. The public half, Crockford base32, is the
address. Derived rather than generated, so the same passphrase reproduces the
same fnid on a device that never held it — re-register the same key at a new
relay and contacts who pinned it are still talking to the same person.

Relay: `users.account_key` / `users.fnid` (unique in the database, not just in
the handler), `POST /e2e/account/identity` to claim by proving possession, and
`GET /e2e/account/identity/:handle` to resolve. The claim signature binds the
audience, the local user id and the key itself; dropping any one of the three
lets a claim be replayed at another relay, lifted onto another account here, or
transplanted onto another key. Verification uses the very key being registered,
so the relay confirms possession and never acquires an opinion about whose key
it is. Rotation is refused rather than silently overwritten — a new address
strands every contact that pinned the old one, and that needs a migration
record they can follow.

Log leaves go v1 -> v2, binding a subject instead of a row number: `fnid:<b32>`
where an account has enrolled, `local:<audience>/<user_id>` where it has not.
The fallback is qualified rather than bare because a device can be registered
before its key is claimed, and the alternatives were worse in both directions —
skipping those devices leaves a hole in the one record that must not have
holes, and logging them unqualified produces leaves that mean different things
depending on origin with nothing in the leaf to say so.

This is not a server-side change. All three engines hash the leaf and two of
them verify it against the relay, and a published vector pins the exact bytes,
so the format moves in one commit or not at all. Existing v1 leaves keep their
hashes and are backfilled to a self-describing `legacy-v1/` subject: the log is
append-only, and a format change is not a licence to rewrite history. The
version prefix sits inside the hashed bytes so a v1 leaf recomputed under v2
rules fails rather than verifying against a different subject.

Green: engine conformance 80, relay §8 suite 235 (10 new), JS<->Swift interop
74, Kotlin published vectors.

Two things found on the way, neither addressed here:

- iOS never calls `enforceKeyLog`. It and `requireKeyLog` are both declared in
  E2EInbox and nothing reads either, so §19 is dead code on that client while
  the JS one wires it. The subject is threaded through ready for a caller.
- `engine/test/interop/build.sh` prefers ~/Code/flamenetmessenger over the
  checkout it is run from, so in a worktree it silently builds the wrong
  sources and reports a *skip* rather than a failure. Set FNMSG_APP_DIR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:10:28 -04:00
Paul HittandClaude Opus 5 85f6a29953 Kotlin: X25519, with the small-order check that is not optional here
Raw 32-byte keys on the wire, X.509 SubjectPublicKeyInfo in the JCA -- so
every crossing needs the 12-byte DER prefix added or stripped. Written out
as a constant rather than parsed: a DER parser here would be more code than
the thing it parses, and one more place to get a length wrong.

Refuses an all-zero shared secret. X25519 produces all zeros for a
small-order peer key, and a peer that sends one forces both sides to a
secret it already knows. RFC 7748 says implementations MAY check; when the
peer's key arrives over a relay nobody trusts, MAY is not the right reading.
All four small-order points from §6.1 are tested.

⚠️ XDH is API 33+ on Android. Older devices throw NoSuchAlgorithmException
at the first call, and the app must either bundle a provider or refuse to
run -- a decision nobody has made, so it is documented rather than papered
over.

⚠️ These run on the JVM against SunEC. Android routes the same calls through
Conscrypt. Passing here says the encoding is right and nothing about a
device.

Kotlin engine: 27 tests -> 33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:42:01 -04:00
Paul HittandClaude Opus 5 bff7a4bbdd Kotlin: HKDF-SHA256 and AES-256-GCM, against external vectors
Checked against RFC 5869 and NIST CAVP rather than against our JavaScript
engine. An implementation validated only against our other implementation
inherits our misunderstandings, and finding where the first two quietly
agreed on something the spec does not say is the entire reason for writing
a third.

Three things pinned that a naive version gets wrong:

- An empty HKDF salt means HashLen zeros, not "no HMAC key". SecretKeySpec
  throws on a zero-LENGTH key, so the naive version crashes on exactly the
  case the app uses -- §12 derives its per-context secrets with an empty
  salt.
- 255 blocks is a hard ceiling. The counter is one byte; a naive loop wraps
  at 256 and silently returns key material it already returned.
- The AEAD box is nonce || ciphertext || tag. CryptoKit's `combined` emits
  that layout and the JS engine assembles it by hand to match, so a box
  built any other way decrypts to garbage on the other engines rather than
  failing loudly.

HKDF is hand-written on Mac rather than taken from a provider: Android's
providers disagree about whether HKDF exists at all.

Kotlin engine: 9 tests -> 27.

⚠️ Still JVM-only. These prove nothing about Android, where crypto goes
through Conscrypt rather than the JVM's JCA, and nothing here has ever been
compiled against the Android SDK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:22:31 -04:00
Paul HittandClaude Opus 5 e7862c0d95 Point the site and the harnesses at the four repositories
The relay and the Kotlin engine are published on their own now, so the
self-hosting guide told people to clone a directory that no longer exists
and the source page said everything was in one repository.

Both pages list all four now, with what each is for. The self-hosting guide
still only needs the first one -- the rest are there so that self-hostable
is something a reader can check rather than something they are told.

The Android vector loader hardcoded ../../engine/test/vectors, which is only
right inside this repository. It resolves from a candidate list now, honours
FLAMENET_VECTORS, and still FAILS rather than skips when it finds nothing:
the entire value of a third engine is that it is checked against something
other than a reading of our own code. Nine tests pass against the JDK in
Homebrew, which is not on the default java_home path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:23:49 -04:00
Paul HittandClaude Opus 5 06b97ca69b Start Android with the engine, held to the published vectors
Android does not exist, and the first thing it needs is not a screen. It
needs the protocol implemented a third time — which is only worth
anything if the third implementation is checked against the spec and the
published vectors rather than against a reading of our own Swift.

So `android/engine` is a plain Kotlin/JVM library with no Android in it:
no SDK, no emulator, no device, and therefore gateable in CI on Linux
like everything else. It reads
engine/test/vectors/protocol-v1.json from its one location rather than
carrying a copy, because a vector transcribed into the test that checks
it is a vector that agrees with itself.

Covered so far: safety numbers (§7) and key transparency verification
(§19) — RFC 6962 leaf and node hashing, the unbalanced split, and
bottom-up audit paths. Nine tests, all green against the same file the
JavaScript and Swift engines answer to.

The §19 tests are the ones worth having. A top-down reading of a
bottom-up audit path yields the correct root for every left-spine index,
so it passes any suite that only checks index 0 — the vectors pin a
right-descending index, and the tests assert both that the published
path verifies and that the same path reversed does not.

android/README.md lists what is missing — X25519, X3DH, the ratchet,
sealed sender, both post-quantum halves, the relay client, the app — as
a table rather than as an omission. Android stays honestly marked "not
started" on the site until that list is shorter.

Bytecode targets 17 without demanding a JDK 17 be installed: a
jvmToolchain(17) fails on any newer JDK, which is a poor trade for a
library with no JDK 17-specific behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:00:13 -04:00