Commit Graph
215 Commits
Author SHA1 Message Date
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 Hitt 00c043918f Merge branch 'feat/drafts-that-survive' into 'main'
Keep a half-written message across a relaunch

See merge request paulhitt/flamenet-messenger-android!13
2026-09-02 19:50:14 +00: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 Hitt 1fc1a561b3 Merge branch 'fix/live-signup-guard-must-skip-not-pass' into 'main'
A skipped live sign-up must report skipped, not passed

See merge request paulhitt/flamenet-messenger-android!31
2026-09-02 19:48:25 +00:00
Paul Hitt 8c8f2c1b98 A skipped live sign-up must report skipped, not passed
The two live sign-up tests guarded themselves with a bare `return`, so
without `-e live true` they finished immediately and reported PASSED. A
test that did nothing and says it passed is worse than one that did not run:
a suite of those is indistinguishable from a suite that works, which is the
only reason to run it.

`assumeTrue` instead, with the message saying how to opt in. Assumed-out
shows as skipped, so the count reflects what was actually exercised.

The iOS side already had this reasoning written down; Android had the
comment's opposite.

Compiles: :app:compileDebugAndroidTestKotlin.
2026-09-02 15:48:12 -04:00
Paul Hitt 602ee82a1e Merge branch 'feat/mute-a-contacts-stories' into 'main'
Mute one contact's stories, without blocking them

See merge request paulhitt/flamenet-messenger-android!20
2026-09-02 19:48:08 +00: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 Hitt bf44c15266 Merge branch 'feat/edit-a-sent-message' into 'main'
Recognise an edit envelope so it is not drawn as a duplicate (§6.15)

See merge request paulhitt/flamenet-messenger-android!12
2026-09-02 19:47:52 +00: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 Hitt d1a438cdd2 Merge branch 'feat/signup-proof-of-work' into 'main'
Solve the sign-up puzzle when a relay asks for one

See merge request paulhitt/flamenet-messenger-android!11
2026-09-02 19:47:36 +00:00
Paul HittandClaude Opus 4.8 df81800783 Solve the sign-up puzzle when a relay asks for one
The relay can charge a few seconds of arithmetic before an account
exists — cheap for one person, expensive for a farm, and unlike a
CAPTCHA it hands nothing to a third party. This is the Android half,
matching the iPhone app.

Asked for only when the relay refuses without one, so a relay with the
feature off — every relay running today — still sees exactly one
request. Rethrown rather than swallowed when this app cannot answer: a
difficulty above the cap leaves it genuinely unable to sign up here, and
"update the app" is then the true sentence.

Two refusals get their own sentences. challenge_required means the app
is behind its server; challenge_failed is nearly always a challenge that
expired while somebody was typing. Sharing one message would send half
these people to the Play Store for nothing. Decided by the relay's code,
never the status, because a 400 is also a bad email.

The bit counting is pinned to exact vectors. A byte-counting
implementation still SATISFIES a 9-bit difficulty — it overshoots to 16
and does 230x the work — so an assertion about "at least 9 leading
zeros" cannot see the bug.

Refs flamenet-relay#39

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:47:22 -04:00
Paul Hitt 0271c15ab3 Merge branch 'feat/email-verification-ui' into 'main'
Tell somebody their address needs confirming, and let them do it

See merge request paulhitt/flamenet-messenger-android!10
2026-09-02 19:47:20 +00:00
Paul Hitt 90850aa654 Tell somebody their address needs confirming, and let them do it
Client half of flamenet-relay!13, which gates a relay token on a proved
email address.

🔴 **Without this the refusal is indistinguishable from a broken
password.** An unverified account arrives as a bare 403; `RelayAuth` logs
"minting failed" and falls back to Basic, which the e2e routes refuse. So
the person sees calls that do not work and an inbox that does nothing —
and nothing anywhere says the remedy is a code sitting in their inbox.

⚠️ **The status alone is not enough.** 403 is also "that device belongs
to another account". Telling somebody to check their email for that would
send them looking for a message that was never sent, so the relay's own
`code` decides, not the number. A 403 with no code, an unparseable body,
and the same code on a 500 all stay generic — three tests hold that.

`RelayAccounts.verifyEmail` spends the code. Unauthenticated on purpose,
exactly like reset confirm: the code IS the proof, and requiring a
session would mean somebody kept out *because* they are unverified could
never get in.

⚠️ **Kotlin nests block comments**, so a slash-star written inside a
KDoc — as part of a route glob — opens a comment that never closes and
takes the module down with ninety unrelated errors, none of them near the
real line. Cost twenty minutes here; there is now a note in the file.

Five tests, proven able to fail: removing the code check turns three red.
All three Android suites pass.
2026-09-02 15:47:07 -04:00
Paul Hitt 7958ebd8bb Merge branch 'feat/signup-refusal-messages' into 'main'
Read the relay's refusal code, not just the HTTP status

See merge request paulhitt/flamenet-messenger-android!9
2026-09-02 19:47:05 +00:00
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 fa925819d2 Merge branch 'fix/v3-responder' into 'main'
Android 0.8 — sign-in identity, v3 responder, media scrubbing

See merge request paulhitt/flamenet-messenger-android!8
2026-09-01 22:09:24 +00:00
Paul HittandClaude Opus 4.8 62a8206e5a Android 0.8 — published
versionCode 7 -> 8, versionName 0.7 -> 0.8, and the build carrying it is
live at /downloads/flamenet-messenger.apk on relay.flamenet.io.

🔴 **The bump is the point, not bookkeeping.** The published 0.7 and
today's build were different bytes under the same versionCode, and
Android refuses to install an APK whose versionCode is not higher — so
publishing it as 0.7 would have offered nothing to everyone who already
had 0.7 and reached only new installs. Every fix in this release would
have sat there invisible.

What is in it: sign-in loading the stored identity instead of minting a
new one (the cause of contacts being asked to accept keys, and of
messages and calls failing afterwards); protocol traffic marked silent so
the relay stops announcing it as mail; EXIF and container metadata
stripped from story photos and videos; a story lifetime setting; and the
two sign-out doors.

⚠️ Downloads had been PAUSED since 2026-09-01 00:04 — the owner pulled
them because of the bugs above. This release is what un-pauses them.

⚠️ Signing cert unchanged: 791fc826…, CN=Flamenet Messenger. A different
key would make this uninstallable over an existing copy rather than an
update.

⚠️ The 0.7 build was copied into apk-rollback/ before publishing. It had
never been kept as a rollback point — only 0.6 and v5 were there.

Verified as SERVED, not as copied: downloaded from the public URL and
hashed, and it matches the sha256 the manifest promises
(000a9618…). The manifest reads its version out of the APK rather than
being told, so the two cannot disagree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 17:43:57 -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 fc0dff18ca Sign-in loads the identity it already has
🔴 **This is why signing out and back in broke everything.** Sign-in only
ever consulted the in-memory `identity` field, and sign-out clears it —
so it minted a fresh `DeviceIdentity` and OVERWROTE the vault's, making
this phone a brand new device on every single sign-in.

Everything the owner hit follows from that one line:

  - contacts saw a safety-number change and had to accept new keys;
  - the account accumulated a registered device per sign-in, and fan-out
    is per device, so half of everything sent to this account went to a
    mailbox nobody reads — which is why the failures looked random and
    different each time;
  - the relay's public key log had not caught up with a key registered
    seconds earlier, so even after accepting, the next send failed on
    "the keys offered by this contact are not in the public log".

⚠️ It was harmless until it wasn't. Sign-out used to destroy the vault,
so there was never a stored identity to find and minting one was
correct. Keeping the device across a plain sign-out — which is what
makes signing out cheap — is what turned this into the bug it always
looked like. The two changes had to land together and only one did.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 15:28:17 -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 cb82d59b77 Answer a call only once the offer has actually been applied
Accepting a call failed intermittently, and when it did the caller heard
nothing and neither end said why:

    NOT SENDING the offer/answer: no localDescription after gathering
    (state=null). The other side will never see this call.

`accept` fired `setRemoteDescription` and `createAnswer` back to back.
`setRemoteDescription` is asynchronous and WebRTC cannot answer an offer
it has not finished reading, so when it lost that race `createAnswer`
failed, `setLocalDescription` never ran, and the send path found no local
description and gave up. The phone showed a call being accepted; the
caller rang on until it timed out.

⚠️ A race, which is what made it look intermittent — on a quiet device
the description usually lands first and the call connects, so the same
build both works and fails depending on load. That is also why it
survived every previous round of call testing.

`createAnswer` now runs from `setRemoteDescription`'s success callback.
The offer path needed no change: `createOffer` does not depend on a
remote description, and the caller applying the answer is terminal.

Found in a live log alongside the first end-to-end proof that calls
connect at all — the same run recorded `ice connection -> CONNECTED` and
then `COMPLETED` between this device and an iPhone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 11:19:06 -04:00
Paul HittandClaude Opus 4.8 32e4af06aa Interop driver: stand up a peer on an older protocol version
`session_device` takes a `proto` so the harness can build a peer that
advertises the previous version, which is the only way to keep the
standing compatibility rule honest — the current version and the one
before it must interoperate, and until now the suite could only test the
newest code against itself.

⚠️ The capability signature is remade over the number actually claimed.
A bundle that advertises one version and signs another is rejected by
`provenVersion` and falls back to v1, so leaving the original signature
would let a compatibility test pass while exercising a pairing nobody
ships.

The override lives in the driver rather than in `registrationWire`: the
engine should publish what it implements, not what a test wants.
`session_reload` gained `as_version` for the same reason — reproducing a
session stored by an older build, which is a state no amount of running
the new code can otherwise produce.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 10:46:17 -04:00
Paul HittandClaude Opus 4.8 2402f7a665 Interop driver: report the session version a responder settled on
`session_respond` returned no version at all, so the cross-engine test
could not assert the one thing that mattered — that a session this
device ANSWERS agrees with the initiator about which protocol it runs.
`session_initiate` and `session_encrypt` report it too, so a version
that changes across a store-and-reopen is visible rather than inferred.

Sealed groups: file the message instead of dropping it in silence.
`groupRoster` asked the relay for the membership of a group the relay
cannot know — §8.9b exists so it holds opaque tags and no member list —
so the sender was never "in the roster" and EVERY message in EVERY
sealed group was discarded on arrival by the check meant to protect it.
The sealed roster is the authoritative list and the only one that
exists. A refusal now says which group and which sender.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 09:21:19 -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 db6ad2a8ce Merge branch 'release-0.7' into 'main'
Release 0.7 — group member lists leave the server

See merge request paulhitt/flamenet-messenger-android!7
2026-08-31 21:13:55 +00:00
Paul Hitt 9787e2e6c5 Correct a driver comment that outlived what it described
newDevice's header said this engine has no ML-KEM and therefore always
publishes proto 1. It gained ML-KEM, the command gained a `pq` flag, and
the comment stayed — so it now reads as a statement of a limitation that
no longer exists, on the one file whose job is to be the honest account
of what the Kotlin engine can do.

Also names the distinction a reader of this file needs: these low-level
commands wire X3DH and the ratchet together by hand, and the session_*
commands go through E2ESession, which is the layer the app calls and the
layer that owns the negotiation decision.
2026-08-31 17:12:52 -04:00
Paul Hitt f80132a5a5 Release 0.7 — group member lists leave the server
versionCode 7. Never reused, and never backwards: it is the only thing that
tells an install a newer build exists, and Android refuses an APK numbered below
what is already on the phone.

What a person gets:

Making a group now seals its member list. The relay is handed a blob it has no
key for and one opaque token per member, instead of a readable `gid -> user_id`
row each. A group made before this can be sealed too, by whoever owns it, from
the group's info screen — everyone stays in and nobody else has to do anything.

⚠️ What that does NOT buy, and the app says so where the button is: it protects
the list if the database is stolen or handed over. It does not hide the group
from whoever runs the server, because messages are addressed to each device and
watching who receives a group's traffic still gives the group away.

Also in this build, and invisible: post-quantum key agreement now runs through
every ratchet step rather than only the handshake (§13), and this engine
advertises v3. A conversation only gets it when both ends do, so today that is
Android to Android on 0.7 — iPhone has it built and is still with Apple.

Verified before publishing: cert 791fc826… unchanged, so this installs over 0.6;
version read back out of the artifact as 7 / 0.7; 609 tests, 28 announced skips,
0 failures; cross-engine 56 passed.
2026-08-31 16:58:29 -04:00
Paul Hitt 7d823747df Merge branch '9-refuse-production-target' into 'main'
A live suite cannot be pointed at production any more

See merge request paulhitt/flamenet-messenger-android!6
2026-08-31 18:47:49 +00: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 73d5472fae Merge branch '4-announce-skips' into 'main'
Eighteen tests reported PASSED while running nothing

Closes #4

See merge request paulhitt/flamenet-messenger-android!5
2026-08-31 17:56:03 +00: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 Hitt cc51854fdd Merge branch '2-v3-cross-engine' into 'main'
A v2 peer was being sent a post-quantum ratchet step, and v3 was never advertised

See merge request paulhitt/flamenet-messenger-android!4
2026-08-31 17:01:58 +00: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 Hitt 0034a57389 Merge branch '3-sealed-groups-ui' into 'main'
The app never actually created a sealed group

Closes #3

See merge request paulhitt/flamenet-messenger-android!3
2026-08-31 15:24:58 +00:00
Paul HittandClaude Opus 4.8 0006d82de6 The app never actually created a sealed group
Everything underneath was built and tested and no screen called any of it, so
every group this app made still handed the relay one readable `gid -> user_id`
row per member. Production is four groups, four of them plaintext.

Making a group now seals it. The relay gets a blob it has no key for and one
opaque tag each.

Old groups get their own answer, because the migration is additive on purpose:
the relay keeps a plaintext roster until a client replaces it, so a group made
before sealing stays readable for ever unless something does it. The owner gets
a button. It keeps the gid — a new one would strand the thread, the history and
everyone else's copy — and reads the members out of the roster we already hold
rather than asking for them.

🔴 The group list now prefers the sealed roster, and that is the security-
relevant half. A sealed group's members come out of a blob the owner signed;
`GET /e2e/groups` carries no signature at all. Letting the relay's answer for
the same gid win — or merely shadow it — puts the server back in charge of
deciding who is in a group. Reverting just that merge fails the new test with
the relay's roster [1, 2, 99] replacing the blob's [1, 2].

Membership edits and leaving route on whether the group is sealed. Sending a
sealed group down the plaintext route asks the relay to edit a table it no
longer has: the call returns fine and the member is not there. One entry point,
so no screen has to branch to be correct.

The button says what it does and does not buy. It protects the list if the
database is stolen or handed over; it does not hide the group from whoever runs
the server, because messages are addressed to each device and who receives a
group's traffic still gives it away.

604 tests, 0 failures. assembleDebug succeeds.

Closes #3

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 11:23:20 -04:00
Paul Hitt 16ebf04d4c Merge branch '2-pq-ratchet' into 'main'
Carry post-quantum through every ratchet step, not just the handshake

Closes #2

See merge request paulhitt/flamenet-messenger-android!2
2026-08-31 15:18:47 +00: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 Hitt 9ae7b5473d Merge branch '1-sealed-rosters-android' into 'main'
Sealed group membership changes, and secrets that survive a reinstall

Closes #1

See merge request paulhitt/flamenet-messenger-android!1
2026-08-31 15:12:30 +00: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 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 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 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 2b75f0e41b Release 0.6 — group names leave the server
versionCode 6, versionName 0.6. Published APK sha256
136bbe7169363ac610c9472f489ceaa400c2f2e79df80590fefdef4e0c147d96.

Certificate unchanged and checked against the published v5 before
publishing — CN=Flamenet Messenger, 791fc826…. A key change makes a
release uninstallable for everyone already on it, so that comparison is
the one step of this that is not optional.

The APK went up before the manifest. A manifest naming a build the server
is not yet serving sends everyone to download the old one and wonder why
nothing changed.

v5 kept as a rollback copy outside the served directory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 05:11:18 -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