Commit Graph
60 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 4.8 688a519e0c Understand a file somebody sent, rather than showing a line of text
iOS can now send a document from Files. This build treated `file` as a kind
from a newer peer: legible, because a file envelope always carries a
caption naming the document, but only as text.

The engine now knows the kind, parses the additive `fn`/`cls`/`cmp` keys,
and can assemble them — so sending works in both directions, not one.

🔴 The interop test parses an envelope produced by the iOS build rather
than by this engine. Two implementations of one format that each only test
against themselves agree right up to the day somebody sends a real file,
and the symptom is "[unsupported attachment]" with no clue why. It also
pins the two rules that keep older peers working: a file envelope is never
captionless, and a photo envelope still carries none of the new keys.

⚠️ The bubble shows the classification as a MARKING. It says how the
sender had it filed; it does not mean this device was cleared for it, and
whoever can read the message already has the key.

⚠️ No "Save to Files" on this side yet — this build has no Files of its
own, so the button would be one that cannot work. Showing what arrived is
the honest half of parity while the other half is built.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 12:21:41 -04:00
Paul Hitt aebf41ed95 Put the app back to the last state that worked
Tonight's merges left sign-in, messages, calls and stories broken. Restores
app, engine and client to fa92581 — the tree that shipped as v8 and works.

Why they broke: those features are not independent. A message, a call
signal, a story and a story reply are the same content envelope with a
different kind, so they run through the same few functions in Content.kt.
Twenty-three MRs, each written against an older main and green on its own
branch, had never existed together until they were merged in one evening.

The conflicts that produced were resolved mechanically — union a parameter
list, merge a condition — and that produces code which compiles and passes
while being wrong: a field on the wrong struct, an argument in the wrong
order, a term in the wrong condition. Three were caught by the compiler.
The ones the compiler cannot see are why calls stopped connecting.

And the gate could not see any of it. 489 unit tests stayed green through
every break, because they test units: "assemble produces this JSON" is a
unit test, "Android shows what iOS sent" is not.

versionCode 10 so it installs over the broken v9.

Nothing is lost — every MR is still a merged commit with its branch on the
remote, and re-lands in small batches once there is a test that actually
sends a message between two devices.
2026-09-02 19:03:30 -04:00
Paul HittandClaude Opus 4.8 4c3f30e180 Count the sealed-to-unsealed fallback, and offer to refuse it
Android half of ios#16. `Sent.sealed` already reported each send's
outcome, but nothing accumulated, so nobody could answer "how often does
this happen to me".

🔴 The copy distinguishes sender from content, with tests asserting it.
An unsealed send is still end-to-end encrypted; what the relay learns is
who sent it. Somebody who reads "sealed" as "encrypted" makes the wrong
decision in both directions.

⚠️ Aggregate only. Per-contact counts would be more useful and would also
build a record of who this account talks to and how often — the precise
thing sealing exists to keep off a server.

Refusing is offered and off by default: it turns a metadata leak into an
undelivered message, which for most people is the worse outcome. It has
its own exception type so the refusal can explain itself.

⚠️ Refusing spends nothing. `sealed` is false exactly when neither a
capability nor a key was found, so `takePeerToken` returned null and
there is no token to hand back — my first cut had a line handing one
back, which was unreachable.

⚠️ The policy and the counter are injected as lambdas, since `client` and
the engine have no Android dependency, and both are wired in one helper.
Two sign-in paths build a session, and only one of them applying the
policy is how a setting silently stops working on half the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:57:00 -04:00
Paul HittandClaude Opus 4.8 b43be1b780 Post a story to a group
Android half of ios#55, spec §6.24. An ordinary story envelope carrying
`gid`, fanned out to the members and recorded in the story set.

🔴 `Vault.groupStoryReach` returns the overlap rather than a warning: who
is excluded by this account's story audience and who is not a contact at
all, counted separately. The roster decides either way — those are two
different lists making two different statements and they will disagree —
so a disclosure that cannot name the consequence is not one.

🔴 The group label is adopted only from an actual member. The roster
check that guards group messages runs after the story branch, so without
this a sender could attach the id of a group they are not in and have
their story shown as having come from it. Dropped to null rather than
refusing the story: the story is legitimately theirs, and only the claim
about where it was posted is unverified.

The story branch already sat before group routing, which is what keeps a
group story out of the conversation — filed there it would be a story
that is a message, and one that never expires out of the thread. That
ordering is now written down rather than incidental.

⚠️ `gid` is persisted, or a reload forgets which story went where.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:53:48 -04:00
Paul HittandClaude Opus 4.8 8b719032c2 React to a story with one tap
Android half of ios#52, spec §6.22.

🔴 Decoded before §6.9's reaction path, which requires `about`. A story
reaction carries `sre` instead — a story is not in the conversation and
has no `mid` there, so one carrying `re` would fold onto whatever message
happened to share that id, or onto nothing. Falling through would leave
it unhandled and drawn as an empty line in the thread. Tested both ways,
including that an ordinary thread reaction still decodes as before.

⚠️ Its own disclosure and its own stored key. A reaction reveals that you
watched exactly as a reply does; what differs is the deliberation. A
reply is typed. A reaction is one tap on a story already filling the
screen, so an account told about replies has been warned about the
considered case and not the careless one.

⚠️ The row does not share a gesture with anything that navigates: an
acknowledgement delivered by a mis-tap is a disclosure the person did not
make.

Branched from feat/story-replies, which carries the `sre` plumbing this
reuses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:52:32 -04:00
Paul HittandClaude Opus 4.8 d525ef3d69 Post several pictures as one story sequence
Android half of ios#53, spec §6.23. Each item is an ordinary story
envelope carrying a `set` naming the sequence, its position and its size.

🔴 One envelope per item rather than an array of attachments. The array
fails the single-`att` guard on any build predating §6.23 and renders the
whole set as a message in the conversation; separate envelopes degrade to
several stories in order, which every existing build already draws.

🔴 `setExpiry` takes the earliest stamp. Items are required to carry the
same one, but a sender can write different ones, and if the latest won
the difference would keep part of a sequence alive after the rest had
gone. A zero is skipped rather than read as "expires at the epoch",
which would kill the whole set.

⚠️ `groupedStories` renders what arrived: `n` is a claim, any item can
fail to arrive, and a missing one is indistinguishable from one never
sent. Stories with no set are keyed so two of them cannot collide.

⚠️ The set is persisted, or a relaunch scatters every sequence back into
loose stories.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:48:16 -04:00
Paul HittandClaude Opus 4.8 242391119b Keep your own stories in a private archive
Android half of ios#54. Expired stories were gone from the author's
device too, so there was no way to look back at what you posted.

🔴 This keeps something the interface told everybody would disappear, so
the shape of the feature is the argument for it being acceptable: off
unless switched on, labelled as what it is, and only ever holding stories
this account posted. `Vault.archive` refuses anything else — the caller
filters too, and this is the guard that survives the caller being
rewritten.

🔴 The archive is deliberately NOT written through `saveStories`.
Everything in it is expired by definition, and that path drops expired
stories on write — so reusing it would empty the archive on the next
pass. Separate record, and a test named for exactly that.

Archiving happens in `stories()`, before `saveStories` drops the expired
ones: the only moment they can be caught, since expired bytes leave on
write. That runs on every load, so the fold is idempotent.

⚠️ The setting is injected into `Session` as a lambda. The engine layer
has no Android dependency and must not read preferences, and an absent
value has to read as OFF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:47:44 -04:00
Paul HittandClaude Opus 4.8 819fab3161 Reply to a story, as a sealed direct message
Android half of ios#30, spec §6.20. A reply is an ordinary direct
message to the author carrying `sre.sid`, landing in the existing
conversation. Stories gain no comment thread and no viewer list.

The reference carries the id and nothing else. A story's author had the
story by definition, so the only way they no longer hold it is that their
own timer removed it — sending a copy back would resurrect exactly that,
at a moment they did not choose.

🔴 `Content.text` returns the raw body when it has nothing to wrap, so
`storyReplyId` had to join that condition. Left out, a reply with no
message id and no quote would have gone out as plain text and arrived
with the reference silently dropped — on exactly the path a story reply
normally takes. There is a test for it.

The disclosure is shown before the first reply this account sends, names
what is revealed, and says where the reply goes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:46:24 -04:00
Paul HittandClaude Opus 4.8 83d7803f9e Save a story you posted yourself, before it expires
Android half of ios#34. A story disappears from its author's own device
too, so there was no way to keep something you made.

🔴 Only ever your own, and the rule is `Vault.isSaveable` rather than a
condition on the menu item. Saving somebody else's story keeps a copy of
something they were told would disappear — the opposite of what the
feature promises them — and a guard living in a composable is one
refactor away from being lost. Tested from both directions.

⚠️ Written through MediaStore, so no storage permission is asked for. An
app inserting its own entry needs no runtime grant on any version this
app supports; asking for WRITE_EXTERNAL_STORAGE in order to save one
picture would request read access to everything the person has, to
perform a write.

⚠️ The message says the copy has left the disappearing model and names
where it went. "Saved" alone would be true and useless — somebody not
told that may reasonably assume the expiry still covers it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:44:21 -04:00
Paul HittandClaude Opus 4.8 7c51bc2521 Post a story that is only words, or a link
Android half of ios#32, spec §6.21. `att` becomes optional and the
caption becomes the story.

🔴 The decode sits before the attachment branch, and that placement is
the feature. The branch requires `att` for a story; a text story failing
it falls through to the ordinary-message path and shows as a chat bubble
in the conversation that never expires — both of the things §6.6 says a
story must not be. There is a test named for it.

`Vault.Story.ref` is nullable rather than carrying a sentinel, so nothing
can read a blob id off a story that has none. That forced a `key` for
seen-marks and dedup, and an `expiresAt` that folds the two cases so no
caller has to know which kind it is holding.

⚠️ The stored record omits `ref` entirely rather than writing an empty
object — a reload would otherwise resurrect a blob that fails to fetch,
and `loadStories` had to stop treating an absent ref as malformed.

A "Write" entry on the Stories tab posts one, and says who enforces the
expiry: there is no photo to delete, so it relies on the receiving app
honouring `exp`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:43:43 -04:00
Paul HittandClaude Opus 4.8 3614665732 Recognise a pinned message (§6.19)
Codec only — the app does not pin yet — but without this branch an
inbound pin falls through parse to the attachment path and comes back
renderable, so every pin the iPhone app sets would draw an empty bubble
in the conversation.

isRenderable excludes it for the same reason it excludes a reaction, and
it is NOT in Kind.CONTROL: a build that swallowed it as generic control
would drop every pin while looking like it worked.

🔴 The envelope carries an id and never a copy of the message. A pin
carrying the text would keep a message readable after its own timer had
removed it, so pinning would quietly defeat disappearing messages.

Refs paulhitt/flamenet-messenger-ios#50

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 16:05:41 -04:00
Paul HittandClaude Opus 4.8 42b7d1732f Search inside messages, across every conversation
🔴 There is no index, and that is a decision rather than an omission. An
index derived from decrypted history is plaintext by another name: it
would have to be sealed as carefully as the vault, taken by
forgetEverything, and kept consistent with the threads — a second copy
of everything anybody ever wrote, with its own ways to go wrong. This
reads what already exists and throws the plaintext away.

⚠️ It costs more here than on iOS, and the code says so. The iPhone app
keeps the whole decrypted history in memory; Android decrypts each
thread on demand. Hence a two-character minimum, a bound, a 250ms
debounce, and Dispatchers.IO.

🔴 Folding is length-preserving on purpose: the snippet slices the
ORIGINAL string at an index found in the folded one, so a fold that
dropped characters would cut in the wrong place — and for an accented
character, mid-character. There is a test with accents before the match.

Accent-insensitive because somebody typing "cafe" means "café", and a
search that knows the difference quietly fails in every language with
accents — Spanish is next on the localisation list.

Refs paulhitt/flamenet-messenger-ios#25

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 16:03:09 -04:00
Paul HittandClaude Opus 4.8 ddb82b87b6 A timer on one message, not just the conversation (§6.17)
The rule that keeps this from making the product worse: the shorter
timer always wins. Without it a sender could defeat the recipient's
conversation timer by putting a long secs on every message — turning a
setting the recipient chose, on their own device, into something the
other party overrules.

🔴 pruneExpired returned early when the CONVERSATION had no timer, so a
timed message in an untimed thread would have been kept for ever — the
feature working on the sending phone and doing nothing on the receiving
one. Pruning is per message now.

⚠️ Content.text returns raw bytes with no envelope when it has nothing
to carry, so the timer had to join that decision or it would be dropped
and the message would live for ever.

The sender's own copy expires too: a timer that only cleared the
recipient's phone would leave the message with the person who set it.

Refs paulhitt/flamenet-messenger-ios#29

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:59:37 -04:00
Paul HittandClaude Opus 4.8 19f4247b71 Recognise mentions in a group message (§6.18)
Codec only — the app does not compose or act on them yet — but it must
already understand an envelope the iPhone app sends, and more
importantly must not be made to do work by one.

🔴 Bounded and deduplicated on receipt. The list comes off the wire, and
a hostile sender could name ten thousand ids in one envelope; the cost
of that lands on every recipient's device rather than theirs. Capped at
256, far above any real roster.

A malformed list does not break the message: it is an ordinary message
with an odd field, not a reason to drop what somebody wrote.

Refs paulhitt/flamenet-messenger-ios#49

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:56:26 -04:00
Paul HittandClaude Opus 4.8 a43f8a6feb Tell somebody when their disappearing message is screenshotted
Android half of ios#36, spec §6.27. `shot` joins the control kinds, so a
build that does not know it stays quiet rather than rendering JSON.

🔴 The care is in refusing to let the absence of a notice mean anything.
The device that took the screenshot reports it, so a modified build, a
second phone, or a screen recorder produce nothing. A notice means one
was probably taken; no notice means NOTHING, and nothing counts them.

⚠️ Android's half of the asymmetry is stated rather than hidden. There is
no screenshot callback at all before API 34, and the one added there does
not cover screen recording — so the disclaimer says that, in those words.
Claiming a symmetry with iOS that does not exist would be worse than the
gap, because somebody would rely on it.

⚠️ Only where a timer is set, and the timer check comes before the
rate-limit check so a suppression window cannot make an untimed
conversation start reporting.

⚠️ Last-notice times are in memory. Persisting them would store who
screenshotted whom on disk to enforce a five-second window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:50:52 -04:00
Paul HittandClaude Opus 4.8 3b7e69c0eb The transcript records a message id and a reply reference
🔴 The wire always carried both and Session.Line never stored them.
Content.groupText has taken a messageId since §6.9 shipped and parse
reads it back — the transcript was where it stopped. So nothing here
could name an earlier message: no replies to resolve, no reactions to
fold, no edits to apply. All three were "codec done" and none worked.

A reply from the iPhone app arrived as a bare message with the quote
silently removed, which is worse than the missing id: the reader had no
way to know anything had been dropped.

🔴 Also fixes a real bug in sendGroup. It built the envelope twice —
once to send, once for the outbox — so an id minted inside either would
differ between them, and a member who was offline would eventually
receive a DIFFERENT message from everybody else, which nothing could
reconcile with a reply or a reaction. One id, minted by the caller, used
by both.

Both fields written only when present: a null on every message would
grow the transcript for nothing and make "no id" indistinguishable from
"an id of nothing".

Refs paulhitt/flamenet-messenger-ios#58, #10

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:50:34 -04:00
Paul HittandClaude Opus 4.8 01e6d6fe6d Recognise a forwarded message (§6.16)
An inbound forward is shown as one rather than as the sender's own
words. Both flag shapes accepted, like ctl: org.json's optBoolean does
not coerce a number, so an engine reading one shape would silently miss
every forward the other two send.

⚠️ The subtle one: Content.text returns raw bytes with no envelope at
all when there is no id, reply or link to carry. A forward that was not
counted in that condition would lose its flag and arrive looking like
the sender's own composition — so  is part of the test for
whether an envelope is needed, not just a field inside it.

Codec only. Originating a forward needs the transcript work in #10.

Refs paulhitt/flamenet-messenger-ios#28

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:50:17 -04:00
Paul HittandClaude Opus 4.8 f95cb1e6aa Keep a half-written message across a relaunch
Sealed in the vault, not in SharedPreferences — and that is the whole
point. A draft is plaintext the person has not decided to send, very
often the most sensitive text in the app because it is the thing they
are still weighing. SharedPreferences writes readable XML that survives
into backups; the vault seals it under the conversation's own key with
the record name bound into the AAD, and forgetEverything takes it on
sign-out. There is a test that asserts the bytes on disk do not contain
the text, because the wrong implementation would pass every other test
here.

Keyed by the same threadFor* ids the transcript uses, so a group called
"7", the contact who is user 7, and the address "7" are three
different drafts.

Every write to the composer state goes through one setDraft: send clears
it by assigning directly in three places, and any one of those that
forgot to clear the stored copy would bring the message back next time
as a draft of something already sent.

Closes #47 on iOS side; Android half of the same feature

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:50:01 -04:00
Paul HittandClaude Opus 4.8 0c1af3e45f Mute one contact's stories, without blocking them
Android half of ios#33. Same shape as iOS: the muted set lives in the
vault beside the audience list, because who somebody has muted is a
statement about their social graph and shared preferences are readable
by anything that reaches the file.

The filter runs on the way out of `stories()`, after `saveStories` has
already written the live set back. A muted contact's stories are still
received and still stored — dropping them on receipt would be cheaper,
but unmuting would then show an empty strip until their next post, and
muting would quietly mean something other than what it says.

`Vault.visibleStories` never drops the signed-in user, even if their own
id is in the set. `setStoryMuted` refuses to put it there, but a restored
backup from a build without that guard could carry one, and hiding
somebody's own posts from them reads as the posts having failed to send.

Long press on a story bubble to hide or restore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:47:55 -04:00
Paul HittandClaude Opus 4.8 914112b032 Recognise an edit envelope so it is not drawn as a duplicate (§6.15)
Without this branch an edit falls through parse to the last case, which
returns the caption as text — so an iPhone correcting a typo appears on
Android as a SECOND bubble beside the original, with nothing to say
either replaced the other. That is the bug this fixes today, before any
of the applying half exists.

isRenderable excludes it for the same reason it excludes a reaction, and
it is NOT in Kind.CONTROL: a build that swallowed it as generic control
would drop every correction silently, which is worse than the duplicate
because nobody would notice.

⚠️ Codec only. Session.Line records no mid, so nothing in an Android
transcript can be named by an edit and the app cannot apply one. That is
the same gap reactions have been sitting behind (PARITY.md "codec done,
no UI") and one transcript change unlocks replies, reactions and edits
together — filed separately.

Refs paulhitt/flamenet-messenger-ios#26

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:47:39 -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 eaf860e794 Advertise v3, now that there is evidence for the claim
PROTOCOL_VERSION read 2 with a note saying 3 would be wrong until ML-KEM was in
every ratchet step, "a separate piece of work". That work is done and proven
against another engine, so the number moves in the same change as the evidence
rather than ahead of it.

Until now the ratchet was inert in the field: iOS advertises 3 and gates on the
peer doing the same, so an iOS-to-Android session negotiated v2 and no message
ever carried a ratchet step, whatever either engine implemented.

🔴 A version is a claim other devices act on. Peers pin the highest proven
version and the pin is monotonic (§13.4), so a build that advertises 3 and
cannot honour it does not degrade — it strands every contact that believed it.

Three tests moved with it, and one is new. `a v2 registration publishes the
whole block` asserted 2 and now asserts the constant. `a capability signature
does not transfer between versions` edited a bundle to `proto = 3` to prove the
signature would not follow — a hardcoded neighbour that stopped testing anything
the moment the engine published 3, so it uses `PROTOCOL_VERSION + 1`. The v2
gating test needed a peer that has genuinely proven 2, which this engine no
longer produces: `copy(proto = 2)` is not enough, because the capability
signature commits to the version, so `publishedV2` re-signs for it.

The new one checks the published bundle claims 3 AND proves it. Bumping the
constant without the signature following would produce a bundle every peer
silently reads as v1, with nothing reporting an error anywhere.

Also: `the sealed blob reveals nothing` was the same coin flip as its iOS twin —
sealing ids 41 and 42 and searching the BASE64 for "41" and "42", about a 14%
chance of a meaningless failure. It fired during this run. The iOS one was fixed
this morning and this was missed because only one engine was looked at.

606 tests, 0 failures. Cross-engine: 56 passed, 0 failed.
2026-08-31 13:00:22 -04:00
Paul HittandClaude Opus 4.8 1e96af0122 A v2 peer was being sent a post-quantum ratchet step
Proving the v3 ratchet across engines turned up a bug in shipping it, and this
one would have broken every existing Android conversation.

`E2ESession.initiate` turned the v3 ratchet on whenever the peer offered a PQ
prekey. Every v2 device offers one — offering a prekey is a claim to §12, not to
§13. So a build with this change would attach `pqdh`/`pqct` to messages for
0.6, the released build, whose ratchet has no post-quantum anything.

That fails twice over, which is why it would have looked like a network
problem. Their root step derives without `ss` and under the `_v1` info string
while ours used `_v3`, so the keys diverge; and their canonical header omits the
two fields, so the AAD does not match either. The first message never opens and
never would.

Gated on a PROVEN claim now, the way the iOS engine already does it:
`chosen != null && bundle.proto >= 3`. `bundle.proto` on its own is the relay's
word for it, but `chosen` is non-null only when the capability signature over
that same proto verified (§12.7), so the pair is a claim the peer actually made.

The test asserts on the absence of a header field, which is the only way to
catch this — every message still decrypts in a self-test, because both sides of
one engine agree with each other about the mistake.

The interop driver now publishes a real v3 bundle: `pqspk` under
`FlamenetE2E_PQSPK_v2`, and `caps_sig` over `FlamenetE2E_Caps_v2 ‖ uint8(3)`.
Both were needed before the web engine would negotiate anything but v1 — which
is why the harness had been quietly proving v1 and nothing else. It can still
publish a v1 bundle on request, because the fleet still holds devices that speak
it and that coverage must not disappear the day v3 lands.

605 tests, 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 12:55:44 -04:00
Paul HittandClaude Opus 4.8 98342dd608 Carry post-quantum through every ratchet step, not just the handshake
iOS mixed a fresh ML-KEM secret into every ratchet step; Android used it only in
X3DH. A conversation gets the property only if both ends do, so nobody had it —
which is what the comparison page now says, and this is what would let that row
change.

The gap is not academic. v2 already protects a whole session against
harvest-now-decrypt-later, because the root chain never recovers once an
adversary has missed the start. What v2 lacks is post-quantum HEALING: after a
compromise, recovery rests entirely on X25519 steps, which a quantum adversary
breaks. The session never heals against that attacker.

Implemented to §13 rather than by reading the Swift: PQs/PQr/PQpending on the
session, KDF_RK_v3 over `dh_out || ss` with its own info string, and the two
optional header fields.

🔴 Decapsulation happens BEFORE adopting the peer's new PQ key. They encapsulated
to the key we held going into the step, and reversing those two derives a
different root key with no error until an AEAD open fails somewhere later.

⚠️ The PQ block repeats on every message of a sending chain until the peer
answers. Sending it once would mean losing that one message strands the chain:
the receiver never gets the material to advance its root key and nothing later
in the chain can be opened. It is replaced by the next ratchet step, not cleared
when the peer replies — it belongs to the sending chain.

Seven tests. Six drive conversations; the seventh pins the canonical header as
bytes, because the header is the AAD and three engines must produce it
identically. A conversation test cannot catch a reordered field — both sides
would drift together. It also pins that half a PQ step falls back to the v2
shape rather than emitting a malformed header.

A peer with no PQ material still runs v2 and emits no PQ fields (§13.4), and an
archive written by an older build simply has no PQ state and stays on v2.

Closes #2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 11:17:04 -04:00
Paul HittandClaude Opus 4.8 fefd87e482 Sealed group membership changes, and secrets that survive a reinstall
This had been sitting uncommitted and unbuilt. It builds.

`Session.updateSealedGroupMembers` is owner-only and refuses locally rather than
sending a request the relay would reject: we hold the owner's signing key only
if we created the group. The version must increase, because a roster signature
stays valid for ever — a stale one could otherwise be replayed to re-add
somebody who was just removed, with a signature that genuinely verifies.
Existing members keep their tags; only newcomers get fresh ones, since reusing
a tag would hand somebody the token another member had held.

The vault carries the group secrets through export and import. Without them a
restored device loses its sealed groups for good: the relay cannot say what you
are a member of — that is the point — so the tag is the only way to fetch a
group and the key the only way to read it. A backup with the messages but not
these restores a device that can see old conversations and can never rejoin the
groups they came from. Import fills gaps only, the same rule the names use.

Verified: :engine:test passes, :app:assembleDebug succeeds.

Closes #1

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 11:07:03 -04:00
Paul HittandClaude Opus 4.8 58fb20246e Sealed roster primitives (§8.9b)
The group key, the member tag, and the blob. Mirrors the Swift engine —
same JSON shape, same canonical signing string, same nonce||ct||tag box
layout, so a roster sealed on either platform opens on the other. The
Swift suite pins that with a vector produced by this code.

Tags are random rather than derived from the account id: anything derived
is a pseudonym the relay can link across groups, which is the social graph
again wearing a hash.

The encoding writes its keys in a fixed order — `JSONObject` promises none,
and a roster whose bytes depend on map iteration order is a roster whose
signature depends on it too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 08:05:22 -04:00
Paul HittandClaude Opus 4.8 356c89e1cd Test the mailbox state whose keys a stranger chooses
Two of these keys are not ours. An envelope id comes from the sender, so
an unbounded list of them is storage anybody on the federation can grow;
a relay name comes from a signed record, so a per-relay entry can be
created by anyone who can get one published. Both are bounded or scoped
deliberately, which is worth asserting rather than trusting to a
comment — and the bound is now proven at 2,500 ids.

Also pinned: each relay's cursor is its own (ids come from different
databases, and one shared cursor would let the first mailbox's progress
skip the second's mail), the served flag and the head pin are per relay,
an existing global pin is still inherited so a phone already holding one
keeps it, and dropping a mailbox takes its cursor with it — otherwise a
re-added mailbox resumes mid-inbox and skips everything before.

One of these tests asserted that two objects were not the same object,
which is true of any two objects. It now writes with one vault and reads
with a second over the same storage, which is what a restart is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 17:54:28 -04:00
Paul HittandClaude Opus 4.8 ca49eedade Test address parsing, mirroring the Swift suite case for case
An address is parsed from input nobody authenticated: typed, pasted,
scanned from a code somebody else printed, and — worst — read out of a
sealed envelope written by a stranger on another relay. So the refusals
matter more than the successes.

Written to mirror the Swift cases one for one, because two
implementations that disagree about which strings are addresses open two
threads for one person on one side and none on the other, and nothing
about either engine's own tests would show it.

It found one on the first run, in the other direction: Swift's decoder
splits on the first `@` with `maxSplits: 1` and then refuses `/` and `:`
but not `@` — so `fn1abc@a@b.example.com` parsed as a relay of
`a@b.example.com`, a "hostname" no host is called. Fixed there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 17:51:12 -04:00
Paul HittandClaude Opus 4.8 db5aa33280 Write the control flag as the number the spec says
`Content.disappearing` emitted `"ctl":true` where §6.10 specifies
`"ctl":1`. That was not a cosmetic disagreement between engines.

iOS decodes the field as a number through a synthesised `Codable`, so a
boolean did not merely fail to set the flag — it threw out of the
initialiser and failed the WHOLE envelope's decode, dropping the message
onto the plain-text path. An Android user turning on disappearing
messages sent an iPhone a bubble containing raw JSON, and the timer was
never applied at that end.

Nothing caught it because each engine parsed what it had written itself.
The piece-level tests passed on both sides, the vectors do not cover
this envelope, and the two halves only meet on two real phones.

Both spellings are accepted on the way in. `org.json`'s `optBoolean`
does not coerce a number, so reading only that missed every `"ctl":1`
the other two engines send — and reading only the number would miss the
`true` this engine wrote until today, which is on phones now and cannot
be recalled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 16:23:03 -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 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 1bbf8b460b Embedded live streams in stories, behind a disclosure (§6.13)
Every other media path here is a sealed blob — the relay stores ciphertext and
nobody else is involved. A live stream is not that: it is continuous and hosted
elsewhere, so there is nothing to seal, and watching one means this device
connects to that host, which learns the viewer's IP address and that they
watched.

🔴 **§6.12's answer does not transfer.** A link preview works because the sender
fetches it once and puts the result inside the ciphertext. A stream cannot be
fetched once. So this is disclosure rather than prevention.

The URL rides inside the ciphertext, so the relay learns nothing about what is
watched or by whom. Playback is an explicit tap with the host named and what it
learns spelled out — a card that started playing on its own would make that
disclosure after the fact, which is not a disclosure.

⚠️ The attachment is the POSTER, not the stream. Keeping a real blob means the
strip, the unseen rings and the expiry sweep all work unchanged, and the viewer
sees a still before deciding whether to connect.

⚠️ The host is derived from the URL, never the caption — a story captioned
"youtube.com" pointing elsewhere is the phishing shape link previews already
guard against. Non-http(s) URLs and ones with no host are refused: a `file:` URL
arriving from another person is not something to hand a media player, and a
destination nobody can name is one nobody can consent to.

The wording lives in the engine rather than the UI so it can be asserted.

engine 177, client 96, app 46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 13:14:08 -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 3f0dfd669c The triage pair: snooze, and "remind me if no reply"
Two halves of the same problem — a conversation you are not ready for, and one
you are waiting on — and both are local to this device on purpose. "I am
ignoring this person until Tuesday" and "remind me if they have not answered"
are statements about a relationship, and the relay has no business holding
either. The honest cost is that neither follows you to a new phone; the
alternative is handing a server the list of people you are avoiding.

🔴 **A reply arriving cancels the reminder, and it happens in `Session.record`**
— the one place every inbound message passes through. Hanging it off a screen or
a poll handler means the one path that forgot to call it produces a reminder
that fires after they answered, and that is the single thing that would teach
people to ignore reminders.

The service fires due reminders from its existing loop, deliberately AFTER
`receive()`: a reply that has just landed cancels its own reminder before it can
go off. Checking first would nudge about a conversation answered a moment ago.
Cleared as it fires, too — a reminder is one-shot, and leaving the row would
re-notify every half second for ever.

⚠️ Snoozes are recomputed, never cached as a flag. A snooze expires by the clock
passing and nothing runs at that moment, so a stored boolean leaves a
conversation muted past its own deadline.

⚠️ Same privacy rule as message notifications: a count, never a name. Who you
are waiting on is as sensitive as what they said.

engine 163, client 96, app 35.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 12:18:21 -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 c9e386ec0d Consume control kinds this build has never heard of (§6.10)
`isRenderable` asked `kind !in Kind.CONTROL` — an allowlist of the four control
kinds that existed when it was written. A control kind from a newer peer is in
no set here, so it fell through and rendered as an "[unsupported attachment]"
bubble: a message the user can neither read nor act on, for something addressed
to a layer rather than a person.

Every control kind added from here would have shipped that to every older
install. `ctl: 1` is the flag that stops it; `isControl` trusts the flag as well
as the list, since the four that predate it do not carry it and never will.

⚠️ `needsNewerClient` is deliberately NOT part of `isRenderable`. Unknown
*content* already parses to a placeholder and is shown, which is right — the
first cut of this change suppressed it and an existing test caught that
immediately. Dropping a real message silently is the one failure a messenger
cannot make, so the flag decides rather than the unknown-ness.

engine 144, client 90, app 32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 11:07:32 -04:00
Paul HittandClaude Opus 4.8 cbe2292d3c Audit H3: the safety number covers the post-quantum identity too
Mirror of iOS, byte-identical by construction: both keys folded under
one label when both sides have a PQ identity, classical pairs unchanged,
and the exact digits pinned against an independent implementation so the
two platforms cannot drift into showing different numbers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 09:02:09 -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 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 6a0f642e0d D4: history the user can carry — encrypted export and import
Same design as iOS: the vault's history re-sealed under a passphrase-
derived key (PBKDF2 600k rounds, AES-256-GCM), saved through SAF, merged
back by content identity so the same backup twice adds nothing. Expired
stories stay expired — a backup is not a way back in. The passphrase is
stored nowhere, deliberately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 20:29:22 -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
Paul HittandClaude Opus 5 e46d51430a Android can show an address, and the Account screen looks like the app
Two gaps, one in each direction.

Android could ENCODE an address and had no way to DERIVE one, so it
could not show yours and nobody on another relay could be given it --
the cross-relay work was iOS-only in practice. The engine was missing
`localAccountSalt` and a way to get both sign-in secrets from one
stretch; asking for the account key separately re-runs 650,000 PBKDF2
iterations for a value already in hand. The salt is pinned against the
JavaScript engine, which is normative -- a Kotlin test asserting what
Kotlin computes would prove only that Kotlin is self-consistent, and
the failure to guard against is three engines disagreeing, which shows
up as one person having two addresses depending on which phone they
used.

The address is stored in the vault because it can only be derived at
sign-in: the passphrase exists for that one moment and is never kept,
so a restored session had nothing to recompute from. Caught by the
empty state saying so on the emulator rather than showing a blank.

A QR encoder, written rather than depended on. Android has no encoder
in the platform, and this is one fully specified algorithm for one
fixed job -- turning a short ASCII address into a bitmap. It is
verified by DECODING: thirteen codes, every version from 1 to 10 at
exact capacity, read back by CoreImage's detector, which shares no line
with it. That found the bug that mattered. The symbol was structurally
perfect -- finders, timing, alignment, data placement and mask all
confirmed correct against a reference symbol -- and nothing could read
it, because the fifteen format bits were written least-significant
first. It fails identically at every version with no clue where.

The Account screen was a flat stack of fields, buttons and paragraphs
with dividers between them, so a security setting carried the same
weight as a text box and "Delete account" the same as "Save display
name". Cards now, matching the Contacts screen that already had them,
with the destructive pair on their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:36:58 -04:00
Paul HittandClaude Opus 5 02e5c45cd4 A test that could actually have caught it
`a retired session is kept and still opens its chain` passed while the fix was
broken, because it retires a PRISTINE session — which is not what the receive
path holds. By the time that path decides to retire anything it has just proved
the peer is on another chain by handing this session an envelope it could not
open, and E2ESession is a class, so that attempt consumed skipped keys and
stepped the chain.

The new test reproduces that state and asserts both halves: the damaged instance
must NOT still open its chain (or the test proves nothing), and the copy re-read
from the vault must. It fails against the first version of the fix and passes
against this one.

13 VaultTest cases, 112 engine tests, 0 failures. Both platforms compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:13:59 -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 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 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 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 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 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 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