Commit Graph
4 Commits
Author SHA1 Message Date
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 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