Commit Graph
77 Commits
Author SHA1 Message Date
Paul Hitt e5f4397b28 Read the relay's refusal, not just the HTTP status
🔴 **A 409 is not always a taken handle, and this said it was.** The relay
answers 409 for a taken handle, an already-registered email, AND — since
flamenet-relay!11 — a handle too close to an existing one. Three problems
with three different fixes, all reported as "that handle is already
taken".

Somebody whose EMAIL was already registered was told to pick a different
handle. Doing so cannot work, and nothing anywhere would have told them
why. That is a pre-existing bug, not something the new refusals
introduced; adding a third meaning to 409 is what made it visible.

400 was worse. Every format refusal — a short passphrase, an unusable
email address, a disallowed handle — became "the account service returned
400", which is not a sentence anybody can act on.

The status is the transport's opinion. The `code` in the body is the
relay's, and it has always been sent; nothing here ever read it.

⚠️ **Unknown codes still refuse.** A relay newer than the app is the
normal case, so an unrecognised code falls back to the status rather than
to success — an account the server never created would be a much worse
failure than a vague message.

⚠️ **An unparseable body cannot throw.** A proxy returning HTML on a 409
must still be a refusal, not a crash.

Five tests, proven able to fail: collapsing 409 back to a single meaning
turns four of them red. All three Android suites pass.

Pairs with flamenet-relay!11 and flamenet-messenger-ios!45.
2026-09-01 20:48:11 -04:00
Paul Hitt 1cda405072 Say which envelopes are worth interrupting somebody for
The relay announced every envelope as "New message" because it holds
ciphertext and cannot tell. It now takes `silent` from the sender.

Marked silent: call answers, call ends, delivery keys, key-log gossip,
reactions, avatars — everything that is a layer talking to a layer. A
call offer still announces itself, and so does anything a person would
see in the thread. `Content.isRenderable` already drew exactly this line
for the receive side; this is the same question asked on the way out.

⚠️ A plain string is a message: text from an older build carries no
envelope at all, and the safe reading of something unrecognised is that
a person meant to say it.
2026-09-01 15:59:07 -04:00
Paul HittandClaude Opus 4.8 fcc6692284 Stories stop carrying where they were taken, and can outlast a day
**Location.** Two story paths handed the picked file over byte for byte:

  - A story PHOTO was the one image path in the app that never went
    through `ImagePrep`, so it was uploaded with its EXIF block intact —
    including, for anything shot on the phone, the GPS coordinates. Chat
    photos have always been re-encoded and were safe by consequence
    (`Bitmap.compress` writes no EXIF); a story, which goes to every
    contact at once, was not.
  - A story VIDEO carried the capture location in the MP4 container.
    This file's own doc said "a clip that fits goes out exactly as
    picked", and that included where it was shot.

`MediaScrub` rebuilds the container with `MediaExtractor` +
`MediaMuxer`. A REMUX, not a transcode: the samples are copied across
untouched, so there is no quality loss and no codec work, and it is
cheap enough to run on every post — a scrub people wait for is a scrub
somebody eventually makes optional. Audio and video tracks only;
anything else a camera app decided to add is the kind of passenger this
exists to leave behind.

⚠️ Rotation IS carried over, deliberately. It says which way up the
phone was, not where it was, and dropping it plays every portrait story
on its side.

🔴 A scrub that fails REFUSES the post. "We could not remove the
location, so we sent it anyway" is the one outcome this must never
produce.

**Stories.** The lifetime was a constant. 1 hour / 6 hours / 24 hours /
3 days / 7 days, same set as iOS, defaulting to what it has always done.
`Conversations.postStory` already took `lifetimeSeconds` with a default,
so only the setting and the plumbing were missing — and
`Session.postStory` now REQUIRES it rather than defaulting, so a future
call site cannot silently ignore what the person chose.

⚠️ Clamped to the offered set on read: a value from a future build, or a
corrupted preference, must not post a story that expires before anybody
sees it.

Also fixes `RelayClientTest`, which had not compiled since `fetchKeys`
started returning the fnid alongside the bundles. Every other call site
in the tests was updated at the time; this one was missed, and I had not
run this suite since.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 14:58:20 -04:00
Paul HittandClaude Opus 4.8 ccdf82c00a Signing out no longer costs every contact a prompt
`signOut` destroyed the vault unconditionally, so signing back in was a
genuinely NEW device: every contact had the old key pinned, saw a
safety-number change, and could not message you until they accepted it.
For someone stepping away for an afternoon that is a strange price, and
it is why signing out and back in kept looking like the app breaking.

Two doors now, matching iOS. Plain sign-out ends the session and keeps
the device, so coming back is silent. "Sign out and erase this device"
is its own row, confirms in place like the delete control beside it, and
retires the device on the relay BEFORE destroying the keys that
authorise saying so — otherwise the account goes on publishing a mailbox
nobody reads, while the relay reports every copy delivered.

Also: changing your password erased the phone. `updatePassword` signed
out to drop the stale credential, which is right, and took the
destructive door to do it, which was not — a password change made you a
new device to everyone you had ever spoken to. It takes the plain one
now. Account deletion asks for the erasing door explicitly.

⚠️ Keeping the keys does not leave the mailbox reachable: a relay token
is minted against the account credential, which both doors clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 14:08:42 -04:00
Paul HittandClaude Opus 4.8 ee17d773b7 A session we ANSWER can now be v3, and receive failures say why
`E2ESession.respond` hard-coded `version = if (pq == null) 1 else 2`, so
a session this device answered could never be v3 — while `initiate`
twelve lines above correctly returns 3 whenever the post-quantum ratchet
is on. A conversation iOS started therefore ran as v3 on one side and v2
on the other, and every message this device sent into it was refused by
the peer as a downgrade (§13.4) and dropped on arrival. iOS to Android
worked; Android to iOS did not, for as long as the pairing existed.

Kotlin talking to Kotlin never noticed, because both ends drive the
ratchet from their own PQ state and ignore the label. Only a peer that
enforces the version sees it. `HybridSessionTest` asserted the wrong
value — `assertEquals(2, bobSide.version)`, two lines below requiring
the initiator to be 3 — so the suite passed while encoding the defect.
Corrected, with the reason recorded next to it.

The version now follows the initiator rather than assuming 3 whenever PQ
is present: a v2 peer sends a PQ block too, and answering that in v3
breaks the pairing in the other direction.

Receive-side failures are no longer swallowed. `catch (_: Exception) {}`
threw away the reason for every undecryptable envelope, which left this
half of every delivery investigation unreadable — the relay says
delivered, the app shows nothing, and the one place that knew why
discarded it. The envelope is still skipped; it now names the id, the
device and the exception. Sends report the protocol shape they emit, and
receives report which of the three chains opened them.

Also here:

  - proguard-rules.pro, which did not exist while build.gradle.kts
    referenced it. R8 accepts a missing entry in silence, so release
    builds ran on the platform defaults and answering a call aborted the
    process inside WebRTC's JNI_OnLoad — it resolves Java classes by
    name, and R8 had renamed them. The build now fails if the file is
    missing rather than shipping without it.
  - Server refusals read as sentences. `RelayException.Http` had no case
    in `describe()`, so choosing a taken handle showed "relay returned
    409:" and a JSON fragment, which says nothing about picking another
    name. Transport failures showed a Java networking string naming an
    address the person never typed.
  - Switching between signing in and creating an account clears the last
    error. It belonged to the other mode and stayed on screen, so taking
    the advice it gave looked like it had failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 09:12:28 -04:00
Paul Hitt 201e09aaa3 A live suite cannot be pointed at production any more
39 accounts were created in the production database in half an hour on
2026-08-31, and 55 of the 86 accounts on that relay are now test accounts. The
launch signup metric became unusable: a real person signing up was invisible
inside the burst.

Every one of those suites is correctly written. They are opt-in, and their own
headers say to point them at the disposable fleet and not at production. That
was the entire protection, and a comment cannot refuse anything.

`LiveTarget.refuseProduction` is checked by every live suite before it does
anything, and throws rather than skips. "This run was pointed at production" is
not an unmet precondition — it is a mistake that has already done damage once,
and a skip is how it goes unnoticed the next time.

🔴 The unit test found a real bug in the guard before it shipped. The
disposable fleet lives on the SAME DOMAIN as production, so denying
`flamenet.io` and its subdomains refused `relay1.flamenet.io` — the exact place
these suites are meant to point. The guard would have blocked every legitimate
run while appearing to block only production. The fleet is now named explicitly
and checked first, which keeps the bare domain in the deny list so a production
host added later is refused by default.

⚠️ A deny list rather than an allow list, deliberately. An allow list would also
refuse a developer's localhost or a self-hoster's own relay, which are normal
things to test against, and a guard that gets in the way is one somebody turns
off.

Proven both directions rather than read:

    FN_LIVE_FLEET=https://flamenetmessenger.com  -> IllegalStateException:
      refusing to run a live suite against flamenetmessenger.com
    FN_LIVE_FLEET unset                          -> tests=1 skipped=1 failures=0

The guard's own test runs in every pipeline, unlike the suites it protects —
those skip without FN_LIVE_FLEET, so a rule exercised only there would never be
checked at all.
2026-08-31 14:38:27 -04:00
Paul Hitt 5242a506b3 Eighteen tests reported PASSED while running nothing
`val url = base ?: return` from an unmet precondition makes a test that did
nothing report as a pass. FN_LIVE_FLEET is never set in CI, so eighteen tests
across five files did this on every pipeline: LiveKeyLogTest 7,
LiveConversationTest 6, LiveRelayTest 2, LiveSealedSenderTest 2, LiveSignUpTest 1.

🔴 This repository had already written the rule down. LiveShapesTest uses
`assumeTrue` and says why, in its own words: a suite of tests that did nothing is
indistinguishable from a suite that works. Two files followed that and five did
not, so this is a consistency fix rather than a new idea.

It nearly reached the specification. `LiveSealedSenderTest` was the evidence §15
would have cited for "Android sealed sender, wired end to end" — two tests, zero
skipped, zero failures, on a run that did nothing. Only reading the source shows
that, and a status table is exactly where nobody would.

⚠️ The suite now looks worse and is not. Client goes from "277 tests, 0 failures"
to "277 tests, 28 skipped" — the 28 were always doing nothing; the number was
wrong before, not now. 578 of 606 actually run.
2026-08-31 13:54:39 -04:00
Paul HittandClaude Opus 4.8 d56ae82c59 Sealed groups on Android: create, fetch, invitations
Mirrors the iOS side. Create mints a gid, group key, owner signing key and
one random tag per member, seals the roster and hands the relay a blob —
no member id leaves the device. Fetch asks by TAG, never "what groups am I
in", because a sealed relay cannot answer that.

Reachability moved here from the relay, which needed to know who the
members were in order to check it.

⚠️ An invitation is NOT subject to the roster check the group name goes
through, and cannot be: that check asks the relay who is in the group, and
for a group we have not joined yet we cannot fetch it to ask. What guards
it instead is that an invitation is accepted only for a group we hold no
secrets for — a stranger cannot overwrite a group we are really in, which
would make every later roster fail to open and silently empty the
conversation.

The base64 stays inside the engine: `Bytes` is internal there on purpose,
so `SealedRoster` grew `newGroupId`, `newOwnerKey`, `ownerPublic` and
`signRoster` rather than the app reaching past that boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 08:56:02 -04:00
Paul HittandClaude Opus 4.8 556580f4fb Test the roster gate and the own-account announcement directly
Two gaps left by the group-name work, both in the half that decides
whether a claim is believed.

The roster check that turns a group-name CLAIM into a stored name lives in
Session, and was covered only through Conversations — which asserts the
claim is NOT adopted there and says nothing about what happens next. iOS
had its equivalent tested directly; Android's was a layer removed. It now
has four tests: a member is adopted, a non-member is refused, a gid we are
not in is refused, and a refused claim is consumed rather than left to
retry against a roster that might later differ. Removing the membership
test fails two of them.

Reaching it needed a seam: `consumeControl` is internal to this module, on
purpose, and driving it through a real ratchet would mean making a real
sender claim a membership it does not have — which a real sender cannot be
made to do. `consumeControlForTesting` says that in its own comment.

The second gap: the announcement addresses our own account so a phone we
own is not told by nobody, and nothing checked that it did. Removing our
account from the list now fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 06:16:56 -04:00
Paul HittandClaude Opus 4.8 2162262159 An unproven device may not name a group
The announcement has always been refused from an unproven sender (audit
C2). An ordinary group envelope carrying `gname` was not — so the weaker
of the two routes decided what a group is called.

On an unproven device `fromUser` is a CLAIM: the binding says that device
belongs to somebody else. The roster check then asks "is the person they
are pretending to be in this group", which answers yes for any member the
attacker cares to name — and the group heading in someone's chat list is a
good place to put a lie, because the reader has no reason to doubt it.

`Received` now carries `senderProven` so a caller acting on the sender can
ask, and `Session.receive` gates the adoption on it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 21:38:28 -04:00
Paul HittandClaude Opus 4.8 fc12aaa591 Bound the group-name claim queue
The sender decides how many claims land in it: anyone who can reach us can
add one per envelope, and it is drained by a layer that may not run on
every path — a poll that never reaches Session.receive, or a path added
later that forgets. An unbounded list whose growth a stranger controls is
a slow leak with somebody else's hand on the tap.

Capped at 256, oldest dropped first, well above any real group count: a
claim is only ever acted on for a group we are actually in, so the cap
bounds the queue rather than rationing legitimate traffic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 21:32:54 -04:00
Paul HittandClaude Opus 4.8 41533baae6 Group names survive a reinstall, and reach our own other devices
Two gaps the group-name change left behind.

The encrypted history export carried the messages but not the names. That
was harmless while the relay answered with them — a reinstalled device got
them back for free. It holds none now, so this device's copy is the only
copy, and restoring a backup would have produced a chat list of groups all
called "Group": exactly the loss the export exists to prevent. Names ride
the payload, and only fill gaps on import — a backup is older than the
device by definition, so a rename made since must not be undone.

And fan-out excludes our own account, so a second phone we own was told by
nobody: the relay has no name to serve it and the members only send to each
other. The announcement now addresses our own account too. The fan-out loop
already skipped our own device, so what goes out reaches our OTHER devices
and nothing else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 21:23:52 -04:00
Paul HittandClaude Opus 4.8 d128e8d56e Let people rename a group
Names used to be fixed at creation and served by the relay for ever, so
no client ever needed this. The relay has now dropped them — and that
makes every group created before the change read as the placeholder on
every phone, with nobody left to ask what it used to be called. The relay
deleted its copy and no client had ever kept one.

Without a rename, those groups are called "Group" permanently. This is
the repair, and it is the first time a group's name has been changeable
at all.

Stored first, announced second: the name is ours the moment we set it, so
a member who cannot be reached now does not stop us seeing the group
under the name we chose. They pick it up from the next message, which
carries it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 20:50:50 -04:00
Paul HittandClaude Opus 4.8 4a6be0ba6b Group names live on the devices, not the relay
The relay stopped storing them, so `createGroup` no longer sends one and
the `name` it returns is a placeholder. The real name is held in the
sealed vault and travels between members: on every group envelope, and as
an announcement riding the `delivery_key` control kind at creation and on
adding a member.

`delivery_key` rather than a new kind because a new one would arrive on
every phone running v5 as a bubble full of raw JSON.

An announced name is recorded as a CLAIM, not adopted. Conversations does
not know the rosters, and a name taken on the sender's word would let
anyone who can reach you write whatever they liked into your chat list
under a group heading you have no reason to doubt. Session checks it
against the relay's roster and only then stores it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 20:24:03 -04:00
Paul HittandClaude Opus 4.8 8061f54e96 Blocking somebody now cancels their sealed passes too
§21.4 gave a lever to cut off one contact and nothing pulled it. That is
the failure this project has been fixing all day in other places — a
capability built and left unreachable.

Blocking is where it belongs, and the reason is stronger than
convenience. The relay CANNOT enforce a block against a sealed sender:
the request carries no sender to compare against a block list, and the
send route's own comment says so. So blocking stopped the identified
path and nothing else, and a blocked person's sealed messages were
dropped by the app after decryption rather than refused at the door.
Revoking their capabilities is what closes that.

The copy changes with it, because "buddies you block can't send you
messages" was not true and is the kind of overclaim a safety screen must
not make. It now says what blocking does, and names the gap left: the
shared key everybody holds is not that person's alone, so it takes
replacing to close completely — with the cost of that stated in the same
breath rather than in a different screen.

The sealed-sender section changes for the same reason. It said replacing
the key is "the only way" to cut somebody off, which was true when it
was written and is not now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 19:36:59 -04:00
Paul HittandClaude Opus 4.8 79c2734f86 Issue, spend and revoke delivery capabilities on Android (§21.4)
The last client. A recipient mints a batch of random tokens per contact,
uploads only their hashes, and hands the tokens over inside the ratchet;
a sender spends one per message; revoking one contact is dropping one
batch, and nobody else notices.

The batch rides the existing `delivery_key` envelope rather than a new
control kind, because a receiver recognises control traffic by KIND
first and falls back to the `ctl` flag only for kinds it has never heard
of — and this engine reads that flag with `optBoolean`, which does not
coerce the number §6.10 calls for. A `delivery_tokens` kind would have
arrived on the phones already out there as a bubble full of raw JSON.

Replenishment is counted rather than requested, for the same reason: a
"running low" message would have been another new kind. The issuer can
count on its own, because every message it receives from a contact spent
one of their capabilities, and it mints afresh at half a batch.

A refused capability means the batch was revoked, so the rest is
discarded and the same envelopes are re-sent on the shared key — they
are addressed to the device, not to the authorisation. That is not a way
around revocation, and the code says so: revoking moves a sender back
onto §14.4's key, it does not silence them.

One bug the tests found. `consumeControl` read the peer's delivery key
first and bailed with `loadDelivery() ?: return`, so a device that had
not published a key of ITS own yet — every device before
`ensureDeliveryKey` runs — silently dropped everything in the envelope,
capabilities included. Capabilities have their own store and no such
dependency, so they are handled before that early return.

Verified against a locally-run relay carrying the §21 work as well as by
the unit suite: 126 capability hashes on the relay after the live run,
so the path is exercised rather than mocked past.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 19:25:22 -04:00
Paul HittandClaude Opus 4.8 088fd5f5a7 Key-log state is per relay, and two bugs the new tests found
Writing tests for the multi-mailbox and key-log work turned up three
things, two of them in code committed earlier today.

**The "this relay serves a log" flag was global.** So was the head pin.
Signing in to a second relay that keeps no log would have been read as
the FIRST one going quiet — a fork warning against an honest deployment,
and every contact on it unreachable. The pin was worse: a second relay's
head checked against the first relay's root cannot reconcile, so the
same outcome arrived by a different route. Both are now keyed by relay
host. Pin reads fall back to the old global entry, so a phone already
holding one keeps it rather than starting trust-on-first-use again.

**A dead home relay hid every other mailbox.** `poll` read the
signed-in relay first and let its failure propagate, which is precisely
the situation several mailboxes exist to survive, defeated by an
unguarded call. But swallowing it outright broke a property that was
already tested and matters: a failed poll must not be reported as an
empty inbox, because "no new messages" and "could not reach the server"
are different things the app says different words for. Both hold now —
every mailbox is read, and the failure is raised only if NOTHING could
be read. A home that is down while a backup answers is a partial success
whose mail is returned rather than thrown away with the error.

**A relay is named in the record and dialled by URL, and those are two
questions.** The hostname is inside the signature so it can carry no
scheme and no port; how this device reaches that host is separate and
now stored beside it. That is the production shape as well as what lets
these paths be tested at all.

New: 12 tests for the address and home record (including that a relay
serving a shortened or lengthened list is caught by arithmetic), 7 for
multi-mailbox reading, 10 for sending to an address, 10 for adding and
dropping a mailbox, 5 for the control flag. A fixture that let a mock
relay forget a record it had stored is fixed too — it made every publish
after it reuse serial 1, which a real relay refuses as a rollback.

Verified against a live relay: 243 client tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 17:29:53 -04:00
Paul HittandClaude Opus 4.8 366f5cbeab Live tests: read what a person sees, not what the wire carries
Six live conversation tests failed after key-log checking became
automatic, and the product was right and the tests were wrong.

`poll` returns control traffic as well as messages. Automatic
enforcement is what sets the transparency-log head pin — and §19.4
gossip has nothing to send without one, so until now the first send to a
contact never carried this device's view of the log. It does now. That
envelope is consumed by the client and never shown, exactly as the app
has always filtered it, but these tests asserted on the raw list and
counted it as a message.

Worth stating plainly, because it is the more interesting half: gossip
was written, called, and inert. Each client verified the relay was
consistent with itself and never offered a head for anybody to compare
against — which is the whole mechanism for catching a relay that serves
two different logs. Turning enforcement on turned that on too.

Verified against a live relay rather than a mock: 203 client tests
including sign-up, device registration, prekey publication, two-party
and group conversations, images, sealed sender and the deployed key log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 17:04:38 -04:00
Paul HittandClaude Opus 4.8 72314491fa Mailboxes at more than one relay
Your address already survives a relay disappearing — it comes from your
passphrase, so signing in elsewhere produces the same one. Your mailbox
does not. Mail sitting at a relay that goes away is gone with it, and a
contact holding only that relay's name has nowhere to write.

The relay has fanned out to a signed home record for a while and no
client could publish one, so the feature existed on exactly one side of
the wire. This is the other side, and both halves of it, because
publishing alone would not have worked: a copy sitting in a mailbox
nothing reads is storage, not redundancy. So the poll now reads every
home, with a cursor per relay — envelope ids come from each relay's own
database, and one shared cursor would let the first home's progress
silently skip the second home's mail.

Copies are collapsed by the id the sender stamps once per message.
Without that the second copy reaches a ratchet that has already deleted
its message key, so the symptom would not be a duplicate — it would be a
dropped envelope and a message that looks lost.

Adding a mailbox is several requests because homing somewhere means
having an account there: sign in, register this device so there is
somewhere to deliver, claim the same address so a sender can resolve it,
then publish the record at EVERY home rather than only the new one. A
home that does not know it is one still accepts mail, but a sender
asking it for the record gets a stale list and skips the others — so the
redundancy would quietly depend on which relay the sender happened to
ask. The new relay accepting is what decides the whole operation:
a record every old home took but the new one refused describes a mailbox
that does not exist.

Removal publishes the shorter list first and forgets the credential only
if that worked. The reverse order leaves mail arriving at a mailbox this
device no longer reads, which from the sender's side is indistinguishable
from delivery. The relay being dropped is not asked to accept the new
record — it is the one party with a reason to refuse.

No one-time prekeys are published at a second home. They are only usable
if this device kept the private halves, and publishing keys it would not
offer back makes every session opened with one undecryptable, which is
worse than offering none.

The passphrase is asked for on the screen that needs it and cleared the
moment the attempt ends. The account key seed lives in memory for the
run and is never written down: it signs the mailbox record, which is the
one thing a relay must never be able to do on the account's behalf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 16:06:11 -04:00
Paul HittandClaude Opus 4.8 444ebf0862 Post-quantum key agreement on Android
A session is post-quantum only when both ends are, so with every Android
device registering `proto: 1` the iPhone implementation protected
approximately no real conversation. The property was true of the code
and false of the product. This is the other half.

ML-KEM-768 over Bouncy Castle's low-level `pqc.crypto.mlkem`, the same
way Ed25519 already uses its `rfc8032` class: not the JCA provider,
which is awkward to register on Android and which R8 cannot shrink. The
platform offers nothing here — Conscrypt has no ML-KEM at any API level
— and hand-rolling a lattice KEM to avoid a dependency would be a far
worse trade than the one Ed25519 already made. No new dependency: the
jar was already in the build for Ed25519.

Keys are stored as the 64-byte FIPS 203 seed, never the expanded
decapsulation key — 64 bytes in the vault instead of 2400, and the same
representation iOS persists, which is what lets a history export cross
platforms. That is also the thing most likely to be silently wrong, so
it is checked against the published vector rather than against another
run of our own code: the seed in `pqx3dh-v2.json` derives the public key
the normative JavaScript engine derived, and the full responder side
reproduces the published session key and associated data byte for byte.
Everything else here would pass with an implementation that is
internally consistent and agrees with nobody.

Negotiation refuses in three places, and each is a substitution
somebody would otherwise get for free. A KEM prekey whose signature does
not verify is treated as ABSENT, so a relay handing out a key of its own
drops the session to v1 rather than to its key — the same substitution
`spk_sig` stops classically, and worth more here because it buys the
whole post-quantum half. A `proto` claim with no `caps_sig` reads as v1,
so a relay cannot advertise a v2 device as v1-only and strip the
post-quantum half off every new session. And the one-time id is signed
big-endian and inside the signature, so a renumbered key falls back to
the last-resort one instead of being spent.

Both pools are refilled together, and that is not tidiness. They drain
at the same rate — one key per new session — so a refill that topped up
only the classical pool would let the post-quantum one run dry and push
every later session onto the last-resort key, every one of them sharing
a single KEM key, with nothing anywhere reporting a downgrade.

Mixed fleets work in both directions and a spent prekey is not an error:
a duplicate delivery of a v2 prekey envelope names a one-time key this
device has already consumed, and throwing there would turn a
retransmission into a dead conversation.

Two test fixtures had to say what they are rather than answer by order.
Sending now asks for a transparency-log head on its own, so a queue
matched purely by position handed that request the device listing and
failed on a parse error nowhere near the cause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 15:53:34 -04:00
Paul HittandClaude Opus 4.8 91f58956cf Check every key the relay can prove, and reach other relays
Two things this app could describe and not do.

**Key transparency was a switch almost nobody found.** `requireKeyLog`
was off by default, so the log was published, verified in tests, and
checked for approximately no one. The reason it was opt-in — a relay
predating §19 keeps no log, and refusing every peer there is worse than
the attack — argues for skipping relays that CANNOT answer, never for
skipping the ones that can. `enforceKeyLogIfServed` draws that line: ask
the head route once, enforce fully if it answers, skip if it is absent.
The switch survives as the stricter question underneath — refuse a relay
that keeps no log at all — which is a real choice for somebody who knows
their server publishes one.

Two things make that safe rather than merely automatic. The head is
asked for SEPARATELY, because `enforceKeyLog` reads three routes and a
relay wanting to skip the check would otherwise only have to 404 the
middle one. And a relay that has answered before is remembered, so it
cannot publish a log until clients are talking and then stop — logs do
not disappear, and that is a fork rather than an absence. The flag is
its own vault entry rather than the pin, because the pin can arrive
from gossip and would make a logless relay look dishonest.

Both subject forms are now accepted for a leaf. A leaf names `fnid:`
once an account has claimed an address and `local:` before, and the hash
committed to whichever was true when it was written — so a contact who
registered a device and then claimed an address has older leaves under
one form and newer ones under the other. A verifier computing a single
subject would refuse half the network on the day claiming turned on.
Either candidate still requires the relay to have appended a leaf
binding the exact key it served.

**Federation was iOS-only.** The relay has spoken it for a while and so
has iPhone; Android could neither send to an address nor read one that
arrived. Now: the three remote routes, an always-sealed send that
carries our address as the return path, and a receive side that files a
federated message under that address rather than under `from_user`,
which their relay never set. Filing by user id would drop every message
from every other relay into thread 0 — or worse, into a local contact's,
if their row numbers happened to collide.

The address keyspace is kept apart from the local one throughout, and
that is the load-bearing decision. A remote sender is pinned, bound and
threaded by `fnid@relay`; they cannot write a local delivery key or
avatar, cannot post into a group, and cannot ring a phone, because all
four are addressed by a user id that means nothing across the boundary.

Wired end to end rather than left as a capability: the add-contact field
recognises an address and offers to message it — the QR scanner already
prefilled that field and had its address looked up as a handle — and
conversations on other relays get their own section, without which a
reply is unreachable short of retyping 52 characters of base32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 15:40:28 -04:00
Paul HittandClaude Opus 4.8 0a7c112b8b Test the inbound path with real ciphertext
`poll` was at 0%. The tests that cover it — the Live* suites — are opt-in
via FN_LIVE_FLEET and skip in an ordinary run, so the single most
important path in the client contributed nothing to a normal build. That
is worse than having no test, because the file listing says otherwise.

Nothing here is stubbed below the transport: the peer is a real
DeviceIdentity, the envelopes are real ciphertext from a real ratchet, and
only the relay's HTTP is a mock. A decrypt that succeeds here succeeded
for the same reason it does in the field.

The three-chain rule gets the test its comment asks for. This used to be
`existingSession(fromDevice) ?: respond(...)`, which reads as a fallback
and is not one: with a stored session it was the only thing tried, decrypt
threw for a peer on a different chain, and the catch swallowed it. That
happens whenever both sides run X3DH before either has processed the
other's prekey — two people opening the same chat at once, which is
ordinary — so the test stores one chain and then has the peer start a
fresh one.

The C1 receive-side gate is asserted from both sides: a handshake offering
an unpinned key while claiming a pinned contact is dropped, and the same
handshake from the pinned key is accepted. Without the gate, anyone could
hand us a session with their own key and every later message would render
as that contact.

Also that one unreadable envelope does not discard the rest of the page
AND that the watermark still passes it, or a single corrupt message is
retried for ever and the inbox never drains; and that a failed poll
surfaces rather than being reported as an empty inbox, since "no new
messages" and "we could not ask" are different states.

⚠️ One test needed two distinct user ids: two identities claiming one user
is exactly what C1 refuses, so writing it with the default sender makes
the second message vanish — correctly, and confusingly.

Conversations 25.4% -> 43.0%, client 63.2% -> 68.9% lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 11:51:44 -04:00
Paul HittandClaude Opus 4.8 4d8fc5b09c Test the account routes and their error mapping
RelayAccounts was at 0% — every route a person meets before they can send
a single message, sign-up through password reset, including the error
mapping that decides what they are told when it goes wrong.

The error mapping earns as much attention as the happy path. These are
the only failures somebody sees before they have an account, and a 409
shown as "something went wrong" instead of "that name is taken" is the
difference between fixing it in five seconds and giving up. So there are
tests for the taken username keeping the relay's own words, a wrong
password arriving as Unauthorized rather than a generic failure, 429
carrying the relay's Retry-After and still being throttling without one,
a non-JSON error body surviving as readable text, and an unreachable
relay being Transport rather than a rejected password.

Two of my assertions were wrong and the code was right, which is worth
recording. Unauthorized and Unavailable deliberately keep the relay's
text in `body` and give `message` a fixed string, because the type is a
decision rather than a diagnostic — Unauthorized triggers exactly one
retry, and a caller matching on a string to decide that eventually
matches wrong. For 451 the fixed sentence is a product decision too: the
download site is not geo-gated, so somebody can read the page, install
the app, choose a password, tap sign up and be shown "relay returned
451", which mentions nothing about their country and cannot be retried
past. The test now pins that wording.

Also pins the compatibility details: an email goes in the `username`
field because that field has always meant "username or email" and a
client that only sends usernames rejects half the people who typed what
they were asked for; a self-hosted relay's 404 on the shared-session
route is the correct answer rather than a misconfiguration; and the
handle travels beside the token rather than inside it, so a relay cannot
correlate its users with another product's.

RelayAccounts 0% -> 100%, client 57.7% -> 63.2% lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 09:47:10 -04:00
Paul HittandClaude Opus 4.8 7f7692318f Pin the roster, notification and account decode shapes
Three separate field-name bugs are recorded in comments in MessengerApi,
and all three shipped. The block list read `user_id` where the relay
sends `id`, so the Block button did nothing — and it survived because the
list it produced was always empty, so nothing ever disagreed with it.
Notifications read `kind`/`body`/`read`, names that exist nowhere, for
months: every alert decoded as a blank unread row and the screen looked
permanently empty. The roster route does not use the `user_id` shape the
§8 routes use, and guessing otherwise threw "No value for user_id" at the
exact moment a contact was added.

None of these are hard bugs. They are all one bug: a decoder and a
producer that disagree, where disagreement yields empty rather than an
error, and empty is indistinguishable from "nothing to show yet". Feeding
the relay's real response shape catches every one in a second, and no
such test existed.

Each decode test is therefore paired with a wrong-shape test asserting
the historical mistake yields nothing, so the pairing is load-bearing
rather than decorative. The mutating routes assert method and path too —
POST vs DELETE on blocks, `buddies/7` vs `buddies` for removing one
contact against the roster's one-way door.

Also pins two defaults that are compatibility decisions rather than
conveniences: `accepted` defaults true so a relay predating the field
does not relabel every contact a stranger, and `setStatus` returns what
the relay stored rather than what we asked for, because the relay once
accepted unknown values with 200 and stored nothing.

MessengerApi 35.2% -> 87.2%, client 51.0% -> 57.7% lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 09:45:32 -04:00
Paul HittandClaude Opus 4.8 6f5c2a7a24 Test the scheduled half of key transparency
`verifyLogConsistencyIfDue` and `consistent` were at 0%, and
`verifyHeadSignature` at 7%. These are what make transparency act rather
than merely record: checking the log only while establishing a session
leaves an established conversation unchecked indefinitely, which is
exactly the conversation worth attacking, so a relay that rewound or
forked between two bundle fetches would go unseen — possibly for ever.

Two failure directions matter equally here and pull against each other.
A check that never fires misses a real fork. A check that treats every
network blip as a fork cries wolf until the alarm means nothing, which is
worse than no alarm. So half of these twenty-five tests assert that
something is NOT reported: an unreachable relay, a failed consistency
fetch, an unsigned head from a self-hoster who cannot sign, a malformed
signature, and a peer who is merely further ahead than we are.

The other half are the refusals: a log that shrank, the same size under a
different root, growth whose consistency proof does not close the gap, a
head signed by a key other than the pinned issuer, and a peer on an
irreconcilable branch. Also that the alarm stands — a relay that forked
once does not become trustworthy because the next request looked fine.

Proofs are computed from a real eight-leaf tree rather than stubbed, so
"the proof verifies" means the same thing here as on the wire.

Trust 73.9% -> 98.1% lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 09:43:37 -04:00
Paul HittandClaude Opus 4.8 a40cc2d2d4 Measure Android coverage, and round-trip the vault
JaCoCo was not configured on either JVM module, so the Android numbers
were unknown rather than good. :engine and :client are plain Kotlin/JVM
precisely so they can be measured without an emulator; now they are.
Baseline: engine 84.9% lines, client 47.3%.

Vault was the outlier in engine at 53%, and its uncovered half was almost
entirely save/load pairs — credentials, trust pins, the delivery key, the
key-log pins, the roster, avatars, per-line deletion.

That is not an arbitrary gap. It is the exact shape of a bug that has
already shipped in this app twice. `requireKeyLog` was computed, held in
memory and never written down, so a security control the UI offered
silently reverted on every relaunch. `Trust.changed` says the same thing
in its own doc comment: returned by `record()`, persisted by nothing, so
`state()` could never answer CHANGED, the "Accept new keys" button could
never render, and a contact who reinstalled became permanently
unmessageable while the header insisted nothing had changed.

Neither was hard to write. Both were invisible because a save and a load
that disagree still compile and only differ after a restart, which no
test performed. Every test here writes with one Vault and reads with a
second built over the same bytes, because that is the only arrangement in
which the two halves are forced to agree.

Includes the AAD binding the class doc promises: a trust record copied
into another contact's slot must not open, or someone with file access
could attribute a stranger's keys to someone you trust.

Vault 53.0% -> 84.9% lines, engine 84.9% -> 92.3%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 09:13:26 -04:00
Paul HittandClaude Opus 4.8 93674d249c Blocking someone showed an error, and could be undone by their next message
Found by finally running the banner I shipped in 0.3 instead of trusting that
it compiled.

`blocked()` read `user_id` from rows the relay emits as `{id, name, slug}` —
the buddy-list shape, not the `user_id` shape the §8 routes use. It threw
`No value for user_id`. Tapping Block worked on the server and then showed the
user an error, which on a safety action is the worst outcome available: it
succeeded and the screen said otherwise.

The reason it survived is the reason it was dangerous. The block list is empty
until somebody blocks their first person, so the map never ran and every test
passed. The first block anybody performed broke it.

🔴 And it did not stop at a message. `adoptStrangers` read the block list with
`.getOrDefault(emptyList())`, so the throw became "nobody is blocked" — and the
next message from someone you had just blocked auto-added them back to your
contacts, defeating the exact property the code comment above it claims to
hold. That now fails closed: if the block list cannot be established, nothing
is adopted and the next poll tries again. A stranger appearing a few seconds
late is cosmetic; re-adding somebody who blocked their way out of a
conversation is not.

The new test uses a NON-EMPTY list, because that is the case that was missing —
an empty-list assertion looks like coverage and exercises no parsing. Confirmed
by reintroducing the bug: the populated case fails, the empty case still
passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 17:37:28 -04:00
Paul HittandClaude Opus 4.8 e345d93640 Show when a thread is with someone you never added
The app adopts anyone who messages you, because a first message from a
non-contact used to be invisible — no row, no name, just an unread badge over
"Nobody here yet". That fix stays: the message still arrives and is still
readable.

What it also did was decide something for the user. Somebody ends up in your
contacts because they wrote to you, and nothing on screen distinguished that
from a person you went and looked up.

So adoption now sends `auto: true` and leaves the acceptance to the person, and
the thread carries a banner naming what happened with the two useful actions in
it. Nothing is gated: delivery is unchanged, the difference is just visible.

`accepted` defaults to true wherever it is not answered — an older relay says
nothing, and reading silence as "stranger" would label every contact on every
older relay.

The copy states what happened rather than warning about it. Most first messages
are from someone you gave your address to, and opening with an alarm would be
wrong more often than right.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 15:47:26 -04:00
Paul HittandClaude Opus 4.8 31551f60f2 Tell a sideloaded app when a newer build exists
Nothing updates this app on its own. It is downloaded as an APK from a web
page, so there is no store to notice a release and no background updater to
install one — a person can run a build from months ago indefinitely, through
every fix that came after it, and never find out. That is the part of
sideloading that is genuinely worse than a store, and this is the only thing
that closes it.

The background check asks the RELAY the install is signed in to, not a fixed
address of ours. Pointing every install at flamenetmessenger.com would mean an
app whose whole claim is that it does not phone home checking in with us on a
timer, from every device, including the self-hosted ones that deliberately
never contact us at all. A relay that publishes no manifest simply produces no
prompt.

"Check for updates" in Account is the other half, and the reason the fallback
to our site exists: pressing it is a question the user asked, so answering
"your relay does not publish updates" when we plainly could is unhelpful. It
tries the relay first and names whichever host answered, so it is never a
silent phone-home. It also distinguishes "no newer build" from "could not
check" — reporting silence as good news is how somebody sits on a build with a
fix in it believing otherwise.

The download URL is built from the host that answered and never read from the
manifest. A `url` field would let any relay send its users to an APK on a host
of its choosing; Android's signature check is a backstop, not a reason to build
the hole in front of it. A test asserts a hostile manifest cannot move it, and
fails if the guard is removed.

versionCode is bumped and `scripts/release-manifest.sh` reads it back out of
the built APK, so the manifest and the artifact cannot drift.

⚠️ Nobody already installed sees this: every build before this one has no
check in it, so the first prompt anybody gets is for the build after this one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 15:02:59 -04:00
Paul HittandClaude Opus 4.8 0e89003cc0 The chat took half the screen, and the status control was unfindable
**Three panels became a menu.** Disappearing messages, snooze, remind-me and
send-later were four blocks of chips stacked above the messages — in a
conversation you had opened to read, they took half the screen. The controls are
behind ⋯ now.

🔴 **What stays on the thread is the STATE, in one line.** A timer both people
are deleting on, a snooze, a pending reminder, a queued message — those are facts
about the conversation, and a fact you must open a menu to discover is one you
will not rely on. Absent entirely when nothing is set, so an ordinary chat
carries no chrome at all.

⚠️ "Send later" only appears when the composer has something in it. Offered
against an empty box it is a control whose only possible effect is nothing.

**Status was at the bottom of Account, under the address card, as four
unlabelled chips.** It is directly under the profile picture now, with a heading
and a line saying what each state does to other people. The person who asked for
the feature could not find it, which is the same as it not being there.

🔴 **And it showed a guess.** The state defaulted to "online" and nothing ever
read the real value, so the screen showed the default rather than what contacts
could see. It loads from the relay now — an empty body on that route is a read,
so no new endpoint was needed.

engine 177, client 96, app 46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 14:22:19 -04:00
Paul HittandClaude Opus 4.8 4441400cd2 Actually let you attach a live stream to a story
Same gap as iOS: §6.13 shipped the wire format, the model, and the viewer's
consent gate, with no way to enter a URL. Adds the field to the composer, with
the disclosure said when attaching rather than only when watching.

⚠️ Post is disabled for a URL that could not be disclosed — a destination with
no host cannot be named, and a viewer cannot consent to one they cannot be
shown.

⚠️ The poster's own copy carries the stream too, so the story does not look
different to the person who sent it than to everyone who receives it.

engine 177, client 96, app 46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 14:10:25 -04:00
Paul HittandClaude Opus 4.8 dc1be35816 Link previews the recipient never fetches (§6.12)
🔴 **The obvious implementation is the one that must not be built.** Having each
recipient fetch the URL to draw a card tells the site the reader's IP address,
that they received a link, and roughly when they opened it — once per member on
a group message. A messenger that hides the message and then has every reader
announce its contents to a third party has given away what it was protecting.

So the SENDER fetches, and the preview travels inside the ciphertext. The
receiver renders from those bytes and contacts nobody.

⚠️ Sender-side is a smaller leak, not no leak: the site learns somebody is about
to share that URL, from this address. The setting exists for that, and the copy
says who learns what rather than implying it is free.

🔴 **The host is shown separately and derived from the URL**, because title and
description come from a page the sender did not write — a card claiming one
destination while the link points at another is a phishing primitive. Both
fields are bounded on the way IN as well as line-limited at render, so the cap
does not depend on a future renderer remembering one.

⚠️ `Content.text` still returns the raw body byte-for-byte when there is no
preview, so an ordinary message is unchanged on the wire and older clients see
exactly what they saw before.

⚠️ The fetch is short-timeout and best-effort, and runs on the IO context. A
preview is a decoration and must never be able to delay a send — every failure
path ends as a message with a plain URL in it.

engine 168, client 96, app 40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 12:45:09 -04:00
Paul HittandClaude Opus 4.8 db7d05bd98 Say "not available in your country" instead of "relay returned 451"
The edge geo-gate serves US, PE and BO for the relay and accounts hosts, and
answers 451 for anywhere else — before the request reaches the relay. Nothing in
either client knew what 451 meant, so it fell through to the generic branch.

⚠️ **The download site is deliberately not gated**, which makes this worse than
a bad error string. Someone outside those countries can read the page, install
the app, choose a password, tap sign up, and be told "relay returned 451".
Nothing in that mentions their country, and it is not a failure they can retry
past, so the only sensible reading is that the app is broken.

451 is the right status — this is a legal/policy refusal, not a 403 — so the
only thing missing was somebody translating it.

client 96.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:39:37 -04:00
Paul HittandClaude Opus 4.8 f9f063a211 Disappearing messages, symmetric and from send time (§6.11)
The relay has had per-conversation lifetimes since the port and **no client
could set one** — its own comment says so. For E2E conversations the server
timer would not have helped anyway: it expires rows in the legacy plaintext
table, not the envelopes these apps actually exchange. So this is client-side,
which is also the only place it can be honest.

**Symmetric.** Either party sets the timer; the other is told inside the ratchet
and adopts it. A timer only the setter honoured would be worse than none — they
would believe their words were gone from a phone still holding them.

**Inside the ratchet**, so the relay never learns that a conversation is timed,
let alone for how long. It rides as ordinary control traffic carrying the `ctl`
flag from §6.10, which is what makes it safe to add at all: without that, this
envelope would surface in every older install as an "[unsupported attachment]"
bubble.

**Expiry runs on read**, in `Vault.history`. A sweep on a timer leaves expired
messages on disk between runs, and a disappearing message still sitting in the
vault is a claim rather than a feature. Reading is the one path every caller
shares, so nothing can see — or keep — an expired message.

⚠️ **Ages come from this device's own timestamp**, not one the peer supplies.
"From send time" in practice means when this device sent or received it, which
differs by seconds and cannot be manipulated: a peer who chose the clock could
backdate a message so it vanished from your phone immediately, or forward-date
one so it never expired.

⚠️ **A message with no timestamp is kept.** Those predate the field, and an
unknown age is not evidence of an old one — deleting somebody's history on a
guess cannot be undone.

The panel sits on the thread rather than in a menu, because "fewer taps to turn
it on" and "clearer per-conversation state" are the same problem: a timer nobody
can see is one nobody trusts. Presets only — arbitrary durations are the ones
nobody can reason about later.

engine 155, client 94, app 32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:23:04 -04:00
Paul HittandClaude Opus 4.8 cc8f4ca1d0 Show the presence the relay stored, not the one we asked for
`setPresence` did `it.copy(presence = status)` on success — the value it had
requested. The relay answered 200 for statuses it did not recognise and stored
nothing, and `busy` and `offline` were both in that set, so tapping either lit
the chip and changed nothing. Someone who chose "offline" stayed visible to
every contact with no way to find out.

The relay is fixed too, but a client that reports its own request back to itself
is wrong regardless of how the server behaves — so `setStatus` now returns what
the relay says the status is, and the screen renders that.

⚠️ The reply is not always the word sent: `offline` is stored as `invisible`,
the same choice under two names, so it is mapped back to this screen's
vocabulary or no chip would appear selected right after a successful change.

`PRESENCE_CHOICES` moves to the companion object so the chips and the test read
one list. It is a contract with another repository that no compiler checks, and
the test says so: every entry must be a value the relay's `ChosenStatus`
accepts.

engine 141, client 90, app 32 — all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:00:06 -04:00
Paul HittandClaude Opus 4.8 076e85d756 Actually send the key-log head we gossip about (§19.4)
`Trust.headPayload` built the gossip envelope and nothing called it. The
receiving half was fully wired — `consumeControl` handles `kt_head`, compares
against our pin, requests a consistency proof and sets `forkDetected` — so
every part of transparency reported working while the mechanism was inert.

That is the failure mode that looks like success. §19.4 detects a forked relay
only when two clients COMPARE heads, and a client that never offers one is a
client no fork can be detected against. Each side independently confirmed the
relay was consistent *with itself*, which is exactly what a relay serving two
self-consistent branches wants. Only the JS reference sent it, so gossip
worked web→app and in no other direction; iOS had the identical dead function
and is fixed in the same pass.

Sends the head once per contact per session as a second prelude alongside the
delivery key, in memory rather than in the vault to match the JS reference: a
device that pins a new head after a restart has to be able to mention it.
Marked before the send, like the delivery-key prelude and for the reason
documented there — a repeat costs one envelope, a crash between sending and
marking repeats it for ever.

GossipHeadSentTest drives the real send path against MockWebServer and asserts
the head goes, that it rides the ratchet rather than the envelope, and that it
is not attached to every message. Verified it fails when the wiring is removed
— three of its four tests go red — because a test written after the fact
otherwise proves only that it compiles.

⚠️ The "not posted in the clear" assertion initially passed against an EMPTY
list: the request-path filter was wrong, and a loop over nothing satisfies
every claim inside it. It now asserts the count first.

engine 141, client 90, app 28 — all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 09:59:46 -04:00
Paul HittandClaude Opus 4.8 251a9f9c4a Multi-device fan-out: merge the directory with fetched bundles, never choose
listDevices returns identity-only stubs — enough for a device we already
have a session with, useless for one we do not, because initiate()
verifies the signed prekey's signature and a stub carries empty bytes.
Choosing between the two lists meant that whenever ANY device already had
a session, every device got a stub, so a device still needing one could
drop out of the fan-out with no partial-delivery warning (the other
device succeeded, so  was non-empty).

The rule is union: full bundle where the key route returned one, stub
otherwise. Found while verifying a two-device pair by hand; the live
suite's 'a message reaches every device a person has' passes either way,
which is why it needed the hand test to surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 09:29:55 -04:00
Paul HittandClaude Opus 4.8 130edd7019 Audit LOW: empty offer, attachment id, QR binding, skipped-key order
Same empty-offer and attachment-id rules as iOS. The QR scanner now
binds a scanned code by its fnid rather than the handle even on the home
relay — the fnid certifies itself, a handle is a name the relay maps,
so resolving by handle handed the relay the choice of who you scanned.
And a skipped message key is consumed only after its tag verifies: it
was defused today by the caller discarding the session, but any future
in-memory ratchet cache would have turned it into real message loss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 09:13:25 -04:00
Paul HittandClaude Opus 4.8 b0c5a108b2 Audit M1: verify the signed log head against a pinned issuer key
The engine had no head-signature function at all and Trust.head() threw
the signature away, so a fork was an accusation rather than evidence.
KeyLog.verifyHead now checks the same string the relay signs, the issuer
key is pinned in the vault on first sight, and a head that fails under
the pin raises the standing fork alarm. Four tests pin the string and
the refusals — including a head signed by a different key, which is the
substitution the pin exists to stop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 08:57:44 -04:00
Paul HittandClaude Opus 4.8 e435cd710d Audit M5: cap the attachment download at its declared size
Header and read both bounded — contentLength is -1 on a chunked reply,
so trusting it alone would leave the hole open. Same reasoning as iOS:
the size came through the ratchet, so the relay cannot inflate it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 08:44:25 -04:00
Paul HittandClaude Opus 4.8 4b4126707f Audit C1+C2: gate inbound sessions on the pin, and bind device to user
C1 (Android-only, critical): building a session from a prekey envelope
accepted whatever identity key it offered — no pin check, where iOS has
always had one. Anyone could hand us a handshake with their own key
while claiming to be a pinned contact, and every later message from that
session rendered as that contact. The same gate the send path uses now
runs on receive.

C2 (both platforms): a device binds first-use-wins to its user, the
claim is checked against that binding on every decrypt, and control
traffic (delivery key, avatar) is refused from an unproven sender — a
poisoned delivery key forces the next send UNSEALED and deanonymizes it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 08:32:49 -04:00
Paul HittandClaude Opus 4.8 68329bcf0f S4: transparency that acts — schedule the check, name the change
Mirror of iOS: the poll loop runs a throttled, best-effort key-log
consistency sweep so a fork between bundle fetches is caught on a live
conversation; and KeyChangeDescription turns a log entry into a named,
dated sentence for the changed-keys warning. Both pure pieces are
unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 08:01:33 -04:00
Paul HittandClaude Opus 4.8 62356139dc P1: the contact list can live only on your devices
Session.roster() reads the vault once the list is off-server, mirrors
the relay's copy into the vault while one exists, and falls back to the
sealed copy when the network fails. The roster rides the history backup.
Settings gains the one-way door with the costs stated where the button
is: presence goes dark, and a new device restores contacts from the
encrypted backup rather than the server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 06:33:22 -04:00
Paul HittandClaude Opus 4.8 2a5970309e Badges, video stories, and parental controls
Launcher badges on the message notification (the service notice
explicitly never badges). Stories accept picked videos — validated
against a 30s/9MB cap rather than transcoded, said honestly when
refused — and play looping in the card via the framework player. The
wire's dur field travels for any attachment that has one.

Parental controls mirror iOS: a parent's PIN guards adding contacts,
stories, and calls; incoming calls answer busy; sign-up gains an
on-device age gate that stores nothing. The server learns nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 21:36:43 -04:00
Paul HittandClaude Opus 4.8 f1122777c1 A proxy's 404 is not the relay's answer: un-latch token minting on 401
Same fix as iOS: RelayAuth latched 'no token endpoint' off one 404, and
an outage's proxy-level 404s wedged the client into tokenless requests.
invalidate() now un-latches — the 401 is proof the conclusion was wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 18:41:56 -04:00
Paul HittandClaude Fable 5 b6557d6409 Re-tell the delivery key when a told peer writes unsealed
Mirror of the iOS change: the poll path un-marks a toldPeer whose
message arrived unsealed — their copy of our key is gone, and the next
send re-tells it. Same staleness class, and same cure, as the avatar
marker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 17:44:05 -04:00
Paul HittandClaude Fable 5 7885f55849 Registered devices card on Account — list, date, and remove
Same contract as iOS: the relay's roster for this account, oldest
first, the current install marked and not removable from itself (that
is Sign out), removal behind an in-place "Really remove" confirmation
per this screen's house style. RelayClient grows revokeDevice
(DELETE /e2e/devices/{id}); Session surfaces ownDevices()/revokeDevice
and parses the directory's new created_at.

The relay caps accounts at three devices, so this card is the only way
off the cap when registration refuses a fourth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 17:05:12 -04:00
Paul HittandClaude Fable 5 3b6bdb43a3 Group authors, avatars everywhere, QR scan/share, and iOS-parity Messages bar
- Session.Line carries `author` (record always persisted it; the read
  side never surfaced it) — group bubbles now wear the WRITER's name and
  picture instead of the group's initial for every message.
- Story cards use the author's real picture (your own from myAvatar).
- avatarShared marker stamps the peer's device set, not just the blob
  id: a bare marker outlived their reinstall and they saw initials for
  ever. Old markers mismatch once and self-heal with one resend.
- QR: AddressCard embeds ?u=<handle> so a same-relay scan resolves
  through the ordinary username lookup, and the Home + Messages bars
  gain a scanner — WebRTC's own Camera2 capture rendered to a
  SurfaceViewRenderer with each frame's Y plane offered to zxing:core
  (the one new dependency; decode only, no second camera framework).
  parseScannedAddress mirrors iOS ContactCard.decode's refusals.
- Messages screen matches iOS: bare title with scan + add-contact
  actions, New group beside the Groups heading (GroupsSection's own
  label, always drawn now, carries it), New chat beside CHATS with a
  contact picker, and the duplicated GROUPS label is gone.
- Story composer gains a per-story audience: Everyone / Only… /
  All except… chips with a member checklist, seeded from the account
  setting, never written back to it. postStory takes the override.
- AddContactForm accepts a prefill so a scan lands in the field —
  looked up, then added, never auto-added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:14:08 -04:00
Paul HittandClaude Opus 4.8 7727cfa432 send path uses the device directory, not the prekey route — every send burned bundle quota and popped OPKs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 15:18:26 -04:00
Paul HittandClaude Opus 4.8 e7a3b60b32 checkpoint: calls/first-contact/stories fixes + Home/Messages redesign (unverified)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 14:58:43 -04:00