Commit Graph
602 Commits
Author SHA1 Message Date
Paul Hitt 6ce2f59e0f Merge branch 'chore/take-the-e2e-poll-vectors' into 'main'
Take the e2e poll vectors and check them in CI

See merge request paulhitt/flamenet-messenger-android!151
2026-09-25 12:35:24 +00:00
Paul HittandClaude Opus 5 f1cd145c5c Export the poll vectors in vectors-head too
The pinned job got FLAMENET_POLL_VECTORS and this one did not, so
PollTallyVectorTest ran here with no vectors to read and failed three
times, including its own control. It only reached the board as a green
pipeline because the job carries allow_failure, and a check that always
fails is noise.

The sibling-checkout fallback covers a developer machine and nothing on
the runner, which clones e2e into .ci-vectors-head instead.

Reproduced the job locally against e2e at HEAD, which is the same commit
as the pin: 723 engine tests, no failures, the vector suite itself 3 of 3
with none skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 07:49:47 -04:00
Paul HittandClaude Opus 5 e48907077c Take the e2e poll vectors and check them in CI
The other half of a change that was split so F6 could land alone.

`PollTallyVectorTest` was written, passed locally, and failed in CI with
three AssertionErrors at line 40, which is "vectors not found". The
fixture lives in flamenet-e2e, CI clones that repo at the commit
`.e2e-ref` pins, and the file only existed on an unmerged branch. It
passed on a developer machine because there is a sibling checkout there
and the runner has none, which is exactly the gap the pin-and-clone
exists to close. Not a defect in the test; this repo's cross-repo
process working.

So now, in one commit as `.e2e-ref.md` asks: the pin moves to e2e main,
FLAMENET_POLL_VECTORS joins the export list and the existence check that
runs before the suite, and the test comes back.

That existence check is the reason the loop above it exists. A path that
silently does not exist puts the tests straight back where they were and
the failure reads as a protocol bug rather than a missing file.

Verified both ways it can be reached: with FLAMENET_POLL_VECTORS set, as
CI sets it, and with it unset so the sibling-checkout fallback is what
answers.

client 480, engine 723, app 652, no failures. The vector suite itself:
3 tests, 0 skipped, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 14:09:07 -04:00
Paul Hitt c0327c8fc0 Merge branch 'audit/call-log-parity' into 'main'
F6: a relay that answers and will not prove consistency is a fork

See merge request paulhitt/flamenet-messenger-android!150
2026-09-24 18:02:56 +00:00
Paul HittandClaude Opus 5 3689e99392 F6: a relay that answers and will not prove consistency is a fork
SPEC section 19.4a, on gossip: "A refusal and a bad proof are treated
identically." The reference engine throws KeyLogForkError. iOS sets
forkDetected. This client caught every exception and returned, with the
comment "A failed fetch is NOT a fork", so a hostile operator could
equivocate and then answer 503 on the consistency route, and Android
alone reported nothing. The check was optional from the attacker's side.

The audit register recorded this as a disagreement between two
defensible positions and said section 19.4 did not settle it. That was
wrong, and the correction is the reason this is a fix rather than a
question: 19.4a settles it in one sentence, and this was the one
implementation of three that disagreed with its own specification.

The distinction that makes failing closed safe here is the other catch:
no answer at all is not evidence, an answer that is not a proof is. The
scheduled sweep in verifyLogConsistencyIfDue() keeps the older, more
forgiving behaviour deliberately, because it asks "does this relay still
keep a log" rather than "prove these two heads are one history", and
19.4a's rule is about the second question.

Three tests had to change and all three had the same shape: a name and a
comment describing an unreachable relay, and a fixture feeding a 503. A
503 is a relay answering. Treating an answer we did not like as no
answer is the hole itself, and it hid behind the wording for as long as
it did because the tests read as though they covered the network being
down. They now cover both, separately: a 503 is a fork, and a server
that has been shut down is not.

Caught by a test, and worth recording: the first version of this fix
caught RelayException.Transport. RelayClient never throws it. Only
RelayAccounts and MessengerApi construct that type, so on this path a
dead network arrives as OkHttp's raw IOException and would have fallen
straight through to the fork branch, turning every flaky moment into a
security alarm. That is the exact cost the finding warned about,
introduced by the change meant to close it. The shut-down-server test
failed and said so.

Also worth recording: the filtered run was green. The full suite found a
fourth test in another file that the filter had never loaded.

client 475, engine 719, app 652, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 10:37:10 -04:00
Paul HittandClaude Opus 5 c5918445f3 Guard the call id a handoff re-offers under, on Android too
A call moved between devices has to leave the other person ONE entry in
their log, not two. The peer files by call id, so the whole of that
promise is that the device taking the call re-offers under the ORIGINAL
id rather than minting a fresh one, and that is one argument on one line
of takeHandoff.

Android does this correctly and nothing checked it. The iOS side gained
the same guard, and breaking the line there deliberately showed why it
is worth having: all eleven existing handoff tests still passed, because
they encode a signal with a fixed id and check it survives the round
trip, which tests the encoder rather than the manager's choice. Kotlin's
handoff tests have the same shape.

The mistake is also invisible from the side that makes it. The device
handing the call over sees a correct log, the caller keeps talking, and
only the peer ends up with two rows for one conversation.

Four checks, in the shape PollSharesGoToOneMemberTest already uses here:
a control that the source was read at all, the rule stated positively,
the rule stated again as "no identifier is minted here" so that renaming
a local cannot quietly retire it, and a check that placing an ordinary
call DOES mint one, so the third is a difference between two paths
rather than a fact about a file that never mints anything.

Falsified: replacing offer.callId with UUID.randomUUID() in takeHandoff
fails two of the four. Full app unit suite green, 338 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 09:22:10 -04:00
Paul Hitt a4feabd129 Merge branch 'fix/an-unproven-key-replacement-says-so' into 'main'
An unproven key replacement says so

See merge request paulhitt/flamenet-messenger-android!149
2026-09-22 19:20:49 +00:00
Paul HittandClaude Opus 5 b441256d92 An unproven key replacement says so
The relay separates a device key replacement the old key signed for,
`rotate`, from one that arrived with no signature at all,
`rotate_unproven`, and keeps them apart in the log on purpose.
`IdentityRotation.report` says why: "a client watching the log can tell
this apart from a rotation the previous key vouched for."

No client could. Both kinds fell to `else` here and read "Their key set
changed", the same sentence a routine change gets, so the distinction
the relay records reached nobody. The unproven case is the one that
matters: it is exactly what somebody who cannot sign with the old key
would produce.

Both kinds now name themselves, and the unproven one carries a warning
that says what to do about it rather than only that something happened.
The warning is a separate sentence after the date, so the clause naming
the day stays intact.

Five tests. One is the control: a test that only checked the unproven
wording would pass if `rotate` were given the identical warning, so the
two are asserted to differ. Another pins the sentence against the iOS
text, because two apps quietly drifting apart on a security warning is
the failure this whole check exists to prevent.

iOS carries the same change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:42:10 -04:00
Paul Hitt 8b50ffe765 Merge branch 'test/inclusion-refuses-a-malformed-proof' into 'main'
An inclusion proof that reconstructs nothing is refused

See merge request paulhitt/flamenet-messenger-android!148
2026-09-22 15:40:35 +00:00
Paul HittandClaude Opus 5 b11a41f09b An inclusion proof that reconstructs nothing is refused
Six refusals were disabled one at a time. Five died: the three §20
identity checks, downgrade, signature and substitution, and both key-log
fork checks. This one survived all 719 engine and 473 client tests.

`verifyInclusion` ends with `rootFromInclusion(...) ?: return false`.
That line is what turns "this proof reconstructs nothing" into a
refusal, and flipping it to `?: return true` broke nothing.

It hid because the suite does assert the null cases, on
`rootFromInclusion` directly:

    assertNull(KeyLog.rootFromInclusion(leaf, size, size, proof))
    assertNull(KeyLog.rootFromInclusion(leaf, -1, size, proof))

The primitive was tested and the wrapper everything calls was not. With
that line wrong, a proof whose shape is wrong, a bad index, a truncated
path, a spare node, would have verified, which is the §19.2 refusal
itself.

The new test drives `verifyInclusion` with all four shapes and asserts
the published proof still verifies first, so the refusals cannot be
satisfied by a verifier that always says no.

`scripts/mutate.py` ships the harness, as in the engine and the relay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 11:20:14 -04:00
Paul Hitt 55aa2d6f45 Merge branch 'chore/verify-the-gradle-distribution' into 'main'
Verify the Gradle distribution before running it

See merge request paulhitt/flamenet-messenger-android!147
2026-09-22 15:05:39 +00:00
Paul Hitt 183b6f528b Merge branch 'chore/remove-the-dead-composables' into 'main'
Remove the dead composables and the unread stat

See merge request paulhitt/flamenet-messenger-android!146
2026-09-22 14:49:43 +00:00
Paul HittandClaude Opus 5 56aa6f4a15 Verify the Gradle distribution before running it
The wrapper downloaded a 164 MB distribution from services.gradle.org
and executed it with no integrity check. Every dependency in this
repository is already pinned to an exact version, with no dynamic
versions anywhere, so the build tool itself was the unchecked part.

`distributionSha256Sum` is what Gradle provides for this.

Proven cold, because the first attempt proved nothing: setting the
checksum and running `./gradlew --version` succeeded with an all-zeros
value too, since Gradle verifies at download time and the distribution
was already extracted here. With the cache moved aside it refuses:

    Verification of Gradle distribution failed!
    Expected checksum: '0000...0000'
    Actual checksum:   'acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a'

That is both halves at once. The control is enforced, and Gradle's own
hash of the freshly downloaded artifact is the value pinned here, which
matches what services.gradle.org publishes. The correct checksum then
downloads and runs on the same cold cache.

What this does not defend against: the checksum comes from the same
origin as the distribution, so a compromise of that server right now
would serve both. It does catch a corrupted mirror, a poisoned cache,
and a tampered re-upload later.

Still unverified: the dependency artifacts themselves. Their versions
are exact but there is no `verification-metadata.xml`, so nothing checks
what comes back from the repositories.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 10:38:39 -04:00
Paul HittandClaude Opus 5 e552d5ada0 Remove the dead composables and the unread stat
A dead-code pass across all four repositories, correctly scoped this
time: overrides and protocol conformances filtered, every `*/src/main`
covered rather than a hand-picked pair.

Relay 0 of 296 functions. Engine 0 of 93. iOS 0 of 957: the three
candidates are `forgetProtoPin`, which carries a comment saying not to
delete it for being unreferenced, and two
`QLPreviewControllerDataSource` methods the framework calls.

Android had four. Two go here: `PresenceDot`, whose own comment says its
colours are the previous brand's palette left behind when the accent
changed, and `BarAction`. Both are private composables nothing calls.
`Obfs4Stats.lastHandshakeAt` goes with the field written only for it;
its sibling `completedHandshakes` is read by NetworkSection and stays.

The fourth is `announceScreenshot`, which is the §6.27 gap the spec now
records. It stays until the receive path exists to pair with it.

The three stale inventories in the session scratchpad are marked as not
to be acted on. Their line numbers no longer matched the files and most
of their entries were framework callbacks.

Engine 719 and client 473 green, app and obfs4 compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 10:36:11 -04:00
Paul Hitt 381f9a46c1 Merge branch 'docs/no-em-dash-in-pqsign' into 'main'
No em dash in the PQSign header, and say what 0.29 closes

See merge request paulhitt/flamenet-messenger-android!145
2026-09-22 14:23:22 +00:00
Paul HittandClaude Opus 5 2c6f525e24 No em dash in the PQSign header, and say what 0.29 closes
The em dash was the last one in Kotlin outside a markdown table.

The paragraph also said "that is the gap this closes" without saying
which half. 0.29 does both: DeviceIdentity publishes an identity, and
PQIdentity.check runs from Conversations.sessionFor on the new-session
path, which is where §20.5 says the pin is consulted. Naming the call
site is what §20.6 asks a status claim to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 09:53:10 -04:00
Paul Hitt c75a27b5c5 Merge branch 'fix/off-the-record-drafts' into 'main'
Keep off-the-record drafts out of the vault

See merge request paulhitt/flamenet-messenger-android!144
2026-09-22 12:26:37 +00:00
Paul HittandClaude Opus 5 edfb8ea48a Keep off-the-record drafts out of the vault
`Vault.saveDraft` wrote the draft text under the thread id with no
off-the-record check, so a half-typed message in an off-the-record chat
sat on disk under a key naming the person it was for.

This is the iOS finding from 2026-09-17 on the other platform. That one
covered iOS drafts and pins and Android pins, edits and reactions;
Android drafts were the half nobody went back for.

Turning the mode on removes an existing draft rather than leaving it
behind, since the mode can be set after something was typed. iOS gets the
same result a different way: its filter runs at the save and the whole
map is rewritten each time.

The filter is on the write, with `saveOutbox` and `saveUnreadThreads`,
for the reason those give: a later caller cannot forget it.

Falsified: without it two of the four tests go red, report XML
`tests="4" skipped="0"`. Engine 719 and client 473 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:58:27 -04:00
Paul Hitt bafb44352c Merge branch 'fix/off-the-record-outbox' into 'main'
Keep off-the-record messages out of the outbox on disk

See merge request paulhitt/flamenet-messenger-android!143
2026-09-22 11:53:29 +00:00
Paul HittandClaude Opus 5 fd7841e002 Keep off-the-record messages out of the outbox on disk
An outbox entry carries `to` and `text`. That is the message, not a note
about it, and `saveOutbox` writes the whole queue to the vault on every
enqueue, so an off-the-record send queued for later was on disk in
plaintext the moment it was typed. §6.28 says the conversation lives in
memory while the app is open and is gone when it closes.

Two paths reach it: `Session.scheduleSend` for a send with a time on it,
and `Session.enqueue` for one the recipient could not be handed yet.
Neither checked, and neither had to: the filter belongs on the write, the
same place and for the same reason as `saveUnreadThreads`.

A group entry carries `thread` and goes to a member whose 1:1 thread may
not exist, and a group has no off-the-record setting, so it passes
through.

Dropping a queued send is the promise rather than the cost of it. The
thread is not kept either, so a message that survived would arrive into a
conversation the app no longer has, and it retries for as long as the app
is open, which is as long as the conversation exists.

Falsified: without the filter two of the four tests go red, report XML
`tests="4" skipped="0"`. Engine 711 and client 469 green, with a
flamenet-e2e checkout linked beside the worktree so the vector tests can
find the published vectors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:46:29 -04:00
Paul Hitt 934589cc06 Merge branch 'fix/off-the-record-unread-marks' into 'main'
Keep off-the-record unread marks out of the vault

See merge request paulhitt/flamenet-messenger-android!142
2026-09-22 11:44:44 +00:00
Paul HittandClaude Opus 5 2cf20e761a Keep off-the-record unread marks out of the vault
`MessengerViewModel.update` persists the unread set through
`Vault.saveUnreadThreads` on every change, unfiltered. A thread id for a
user conversation is `user:<id>`, so an off-the-record conversation with
something unread put the peer's id on disk, and it came back at the next
launch. `saveOffTheRecord` says the setting is kept and only the
setting.

The filter goes in `saveUnreadThreads` rather than at the call site, for
the reason the call site gives for being a chokepoint: a later caller
cannot forget it. The vault already answers `offTheRecord(threadId)` for
exactly that key, so the check was available and unused.

Nothing is lost. An off-the-record thread holds no lines at rest, so a
restored mark would badge a conversation with nothing in it.

iOS had the same fault on `unreadPeers` and is fixed in ios!221. Both
are the September pins-and-drafts finding again, on the one dictionary
each side had left.

Falsified: dropping the filter turns two of the four tests red, and the
report XML says `tests="4" skipped="0"` in both directions, since gradle
will report success having run nothing.

Engine 711 and client 469, both green. The 55 engine failures a worktree
shows first are `vectors not found`: the vector tests resolve the
published vectors through a sibling `flamenet-e2e` checkout, which a
worktree has no path to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:27:49 -04:00
Paul Hitt 9103ede6bc Merge branch 'fix/deleting-an-account-tells-the-account-service' into 'main'
Tell the account service when an account is deleted

See merge request paulhitt/flamenet-messenger-android!141
2026-09-21 23:55:49 +00:00
Paul HittandClaude Opus 5 377ab65efe Drop the Flamenet Files half of the account delete
Files was folded into the messenger, so the account service issues
pseudonyms for one product and a delete can no longer half-succeed.

The rule this branch was built around, keep the shared account alive
while Flamenet Files still holds an identity, can never fire now. So
`deleteAccount` returns nothing instead of reporting which of two
outcomes happened, the screen no longer has a sentence explaining that
somebody's Flamenet account survived because Files was using it, and the
test for that case is gone. The endpoint half is in
flamenet-files!10.

What stays is what was right for other reasons: the passphrase is proven
before anything is deleted, the relay goes first, and the first tap asks
the relay where the account lives rather than guessing.

Copy: the sign-in screen said "One Flamenet account, shared with Flamenet
Files", which is now a promise about a product that does not exist. It
says the account works across relays, which is the thing that is
actually true and the only reason the shared service exists.

The signing comment in app/build.gradle.kts keeps its mention. Early
releases really did go out as `CN=Flamenet Files`, an Android signing key
is permanent, and that is history somebody needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 17:32:21 -04:00
Paul HittandClaude Opus 5 0cc14f92e7 Tell the account service when an account is deleted
Deleting an account removed the relay's copy and stopped there. On a
relay that uses the shared accounts, that is half the account: the row at
accounts.flamenet.io stayed, the handle stayed taken, and the person
stayed registered with a service they believed they had left. Found on
2026-09-21 by creating an account against production from the emulator
and deleting it: the relay row went, the account service row did not.

So the delete makes the second call. The endpoint is new, in
enclave!fix/deleting-an-account-tells-the-account-service, and that has to
be deployed before a build carrying this ships or the second call answers
404.

Three things decide the shape:

The relay goes first. Reversed, a failure in between leaves an account
that can no longer sign in, because a shared account signs in AT the
account service, and so an account nobody can ever finish deleting. This
way round strands a row, and a stranded row can still be cleared.

The passphrase is checked BEFORE anything is deleted, by signing in. A
typo is the likeliest thing to go wrong, and finding out afterwards would
mean the messenger account is already gone, the Flamenet account is not,
and the person has been signed out of the app that was offering to remove
either.

The first tap deletes nothing: it asks the relay where the account lives.
The vault records the relay and nothing about the account service, and
`site` tracks whatever was last typed into the relay box, which after a
restore can describe a different server. Guessing is how the account
service came to be skipped in the first place.

What the screen says now matches what happens, including the case where
the Flamenet account is kept because Files is using it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 16:15:18 -04:00
Paul HittandClaude Opus 5 2c22ffed75 Stop blaming the server for failures that never reached it
describeFailure had one sentence for everything it could not name: 'Something
went wrong reaching that server.' That is a claim about the network, and most
of what lands in the catch-all never touched the network.

Seen while testing the released 0.29 build on an emulator. Sign-up against a
relay that answered 200 to signup, token, device registration and identity put
that sentence on the screen. The account had been created. The person is told
to check a connection that is working, and the one action the sentence implies,
tapping again, is the one least likely to help. It is also what produced the
duplicate token request in the relay's access log.

Same shape as the relay-pin bug fixed in 80bb4c4: a specific failure arriving
as a shrug about the network, and the fix is the same. Say what is known.
RelayException.Transport now has its own branch and is the only place allowed
to blame the connection, because it is the only one that knows. The catch-all
says the failure was on this phone, and carries the cause's own sentence when
there is a readable one, refusing anything that looks like a class name.

Seven tests, falsified: restoring the old sentence turns three of them red.
App unit suite 633 green.

Not fixed here: WHY that sign-up failed locally after a 200. The message was
hiding it, and now it will say.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 15:06:38 -04:00
Paul HittandClaude Opus 5 f8efd4d12c 0.29, versionCode 29
Post-quantum authentication, released. Android now publishes an ML-DSA-65
identity alongside its Ed25519 one, verifies a peer's, and refuses a relay
that strips or substitutes it. Thirteen commits since 0.28, also carrying
group and multi-picture stories, remote erase, invite by link, held refused
envelopes, and a server identity change that says which server.

Two things this release does NOT do, both deliberate. A device restored from
a backup older than this stays classical and is not given an identity behind
the user's back, so existing installs gain one only by re-registering. And
nothing in the app tells a user whether a given conversation is
post-quantum authenticated or classical; that needs surfacing and does not
exist yet.

The security page still says Android publishes no ik_pq. It is edited when
this is live in Play and not before, per the checklist in flamenet-e2e
docs/SECTION-20-ANDROID-RELEASE.md. A page describing a build nobody has yet
is wrong for however long the rollout takes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 14:30:46 -04:00
Paul HittandClaude Opus 5 26a14fb55a engine: Android verifies and pins a peer's section 20 identity
The receive half. PeerBundle now carries ik_pq and spk_pq_sig, PQIdentity
decides what to do with them, and Conversations enforces it where a session is
established.

The three refusals match the web engine and iOS: PQDowngradeException when a
device that was seen with an identity appears without one, PQVerificationException
when the signature does not verify, and PQIdentityChangedException when the
identity is not the pinned one.

The pin is the SHA-256 of ik_pq, not a boolean, because section 20.5 is
explicit about why: a relay that substituted its own ML-DSA key would satisfy
a boolean pin and the substitution would go unnoticed. Same fingerprint the
web engine stores, so it means the same thing on both.

Absent to present is allowed and present to absent is refused. That asymmetry
is section 20.2 and 20.5 together: a device that has never published an
identity is classical and may start later, and one that has may never stop.

Enforced on the new-session path only, because section 20.5 says the pin is
consulted when a session is established, which is the only moment a downgrade
can be attempted; an existing session never refetches a bundle. A bundle that
never opens a session therefore cannot move a pin.

PQIdentity.check is a pure function of the pin and the bundle, so every case
is testable without a vault or a relay. Thirteen cases, most of them a relay
misbehaving: stripping, substituting, a bent signature, an identity with no
signature, and a signature over a different device's prekey.

Falsified: disabling the two refusals turns the suite red on exactly the two
tests that cover them. Engine 707 green, client 469 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 14:26:12 -04:00
Paul HittandClaude Opus 5 54ce831897 engine: Android publishes a section 20 post-quantum identity
The DeviceIdentity half. A new device now mints a 32-byte ML-DSA-65 identity
seed, archives it, and publishes ik_pq and spk_pq_sig in the registration
bundle.

spk_pq_sig covers EXACTLY the bytes spk_sig covers, the raw X25519 signed
prekey, because section 20.1 requires a verifier to check one message under
two schemes rather than two messages under two schemes. Signing anything else
would have verified against ourselves and failed against the web engine and
iOS, which is the failure this whole section is designed to prevent.

Published before the v2 early return, not after, because section 20.2 makes a
post-quantum identity independent of post-quantum key agreement. Putting it
after would have tied the two together against the spec.

The archive key is the web engine's ikPQSeed, so an export crosses platforms.
An archive written before this restores as classical-only and is deliberately
NOT given an identity behind the user's back: the reference implementation
mints only on create, and a device that starts publishing an identity it did
not have is a decision to take on purpose.

Proven rather than assumed, both directions across implementations:
BouncyCastle and the web engine derive the same public key from the same seed,
each verifies a signature the other made, and each refuses the other's
tampered signature. Ten wiring tests, ten primitive tests, engine suite 694
green.

Still not done: nothing VERIFIES a peer's ik_pq yet, and there is no pinning,
so this publishes an identity that no Android device checks. That is the
receive half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 14:22:21 -04:00
Paul HittandClaude Opus 5 fd386259f6 engine: ML-DSA-65 for Android, the primitive and its vectors
Section 20 post-quantum authentication shipped on the web engine and iOS.
Android had nothing, so it published no ik_pq, and section 20.2 makes an
absent identity a supported state rather than an error. Every conversation
with an Android device therefore fell back to classical authentication
SILENTLY. This is the first half of closing that.

PQSign is ML-DSA-65 on BouncyCastle 1.79, which is already a dependency and
already carries the sibling mlkem package PQKEM uses. Seeded key generation
via MLDSAPrivateKeyParameters(params, seed): 32 bytes persisted, the signing
key rederived on demand, so a post-quantum identity lives in the existing
vault without a new storage format.

The load-bearing test derives the published seed's public key and compares it
byte for byte against protocol-v1.json. FIPS 204 keygen is deterministic in
the seed, so a device that derived a different key would publish a different
identity on restore and every contact would read it as a substitution.
Signatures are deliberately not pinned because ML-DSA signing is randomised;
verification is checked against the public key instead.

Falsified rather than assumed: flipping one bit of the derived key turns the
suite red on two assertions including that one. Engine suite is 684 tests
green. The test reads FLAMENET_VECTORS, which is the name CI exports; an
invented name would have passed here and found nothing there.

NOT yet wired: DeviceIdentity still mints no identity seed and no bundle
carries ik_pq or spk_pq_sig, so nothing changes on the wire yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 14:18:34 -04:00
Paul HittandClaude Opus 5 07f256c2ec Wire group stories and multi-picture stories (§6.24, §6.23)
Both were built and neither was reachable.

§6.24, group stories. The receive side has been complete for a long
time: an arriving story carries its gid and the gid is checked against
the group's roster before it is believed. `postGroupStory` had no
callers, so nothing could ever produce one. The composer offers the
groups this account is in as destinations now.

A group REPLACES the audience rather than filtering it. Running a story
audience over a group's roster would quietly drop the members somebody
excluded from their contact stories, which is not what posting to a
group means, and the branch runs before the contact check so a person
with no contacts at all can still post to a group they are in.

§6.23, a sequence posted as one story. Android was further behind: the
receive side stored `set` and the feed never read it, and
`Conversations.postStory` never passed one to the encoder, so this
phone could not make a sequence and drew an iPhone's three-picture set
as three separate stories from the same person, one after another.

The feed groups through `Vault.groupedStories`, which already existed
and had no callers outside its own test, and says how many pictures a
card stands for. The sender shares one set id across the items and puts
the caption on the first only: a sequence has one thing to say and
repeating it under every picture reads as a stutter. The poster's own
copies carry the same id, so they see the sequence their contacts will.

Capped at ten, matching iOS. Each item is sealed, uploaded and sent to
every recipient separately, so a set of forty is forty uploads and the
person waiting cannot tell it from a stuck app.

Falsifying this found a duplicate: I had added a second `groupedStories`
as an instance method beside the companion one that was already there.
It compiled, the feed resolved to the original, and mine was dead the
moment it was written. Removed, and the one case my test had that the
existing `StorySetTest` did not now lives in that file instead of a
parallel one.

engine 674, client 469, app 626, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:19:25 -04:00
Paul HittandClaude Opus 5 5b8177fba4 Wrap the erase dialog's handlers the way ktlint asks
Seven findings, all on lines the commit before this one added: two
semicolon-joined statements in onClick lambdas and a chained call that
wanted a newline. No behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:58:27 -04:00
Paul HittandClaude Opus 5 ef1a8138aa Remote erase on Android (§6.26)
Android had no part of this: no type, no route, no fetch. The relay's
half was complete and iOS had the crypto, so a lost Android phone was
the one that could not be erased at all.

DeviceWipe in the engine, signing and verifying the same canonical
string as the iOS half, `fnwipe:v1:<device>:<seconds>`. A vector in
the tests takes a signature the SWIFT implementation produced and
verifies it here, and the iOS suite takes one produced here. Not a
comparison of the two signatures: CryptoKit randomises Ed25519 signing
and this does not, so they never match and never need to. What interop
needs is that each side verifies what the other signed.

The poll asks once a session and stops there if it erased, rather than
carrying on into a vault it has just emptied. The account public key it
checks against comes out of the address this device already stored, not
out of anything the relay just said.

Issuing is on the Devices card and asks for the passphrase, which is
what somebody holding the phone does not have. A passphrase that does
not derive this account's published address is refused before anything
reaches the relay. The dialog shows DeviceWipe.HONEST_DESCRIPTION,
kept word for word with the iOS copy: two apps describing one guarantee
differently is how somebody ends up believing the stronger sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:56:01 -04:00
Paul HittandClaude Fable 5.1 9bc8cdcb23 Invite a friend, as a link
The relay has minted invite codes since relay#43; the app had no door to
them. Account settings now has "Invite a friend": it asks the relay for a
code, shows it large, and shares flamenetmessenger.com/join/<code>, the
page that shows who sent it, the store buttons and the code to type if
sign-up asks for one.

InviteLink.forCode repairs what a person did to a code on the way so the
link carries what the relay hashed. MessengerApi.mintInvite posts to
account/vouch on the relay root with the app credential. RateLimited now
carries the relay's own sentence when it gave one, so "You already have 5
unused invites" reaches the screen instead of "rate limited".

Five client tests; the whole client suite is green at 469.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 11:47:07 -04:00
Paul HittandClaude Opus 5 80bb4c4e58 Say which server changed its identity, instead of shrugging at the network
The relay pin exists to catch one thing: the server signing with an
issuer key this app never agreed to. It caught it and threw an
exception carrying a warning written for a person to read, naming the
host and giving both readings the app cannot tell apart -- a rotation
done on purpose, and something else answering in its place.

describeFailure had a branch for a forked key log and one for a
contact's changed key, and none for this, so it fell through to
"Something went wrong reaching that server." The alarm arrived as a
shrug about the network, and the carefully written warning was
discarded on the way.

Found looking for the Android half of the iOS relay-identity banner
added earlier today. Android does raise it; nothing said so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 11:21:41 -04:00
Paul HittandClaude Opus 5 cc4f5d8844 Hold refused envelopes on Android too
The same data loss as the iOS side, in the other direction. When a
contact reinstalls, every message they send before this phone accepts
their new keys was destroyed: the poll advances its watermark whatever
happens, so the relay marked them delivered and forgot them, and the
catch in ingest swallowed the refusal and moved on. Tapping "Accept new
keys" brought nothing back. The sender saw delivered messages and this
side saw an empty thread under a security banner.

The identity gate is right and stays. The discard is what changes.

A refused envelope is now set aside as it arrived, still ciphertext, in
the sealed vault so it survives the app being closed -- which is the
ordinary way this happens. Bounded at a hundred per sender and thirty
days, expired on the way in so it needs no timer that a rarely-opened
app would never run. Blocking or removing a contact drops their hold
rather than keeping a removed contact's messages.

Two extractions were needed to make a replay possible, and both are
worth having on their own. The per-envelope work in the poll loop is
now `consume`, so a held envelope can go through exactly the path a
fresh one does; the replay calls it directly rather than through the
de-duplicating check at the top of the loop, which has already seen
every one of those envelope ids and would deliver nothing while
reporting success. And everything `receive` does with a batch of
decrypted messages is now `absorb`, because a replay that stopped at
the decrypt would write nothing and show nothing, which is this same
bug one layer further in.

The banner says how many are waiting, and accepting re-reads the thread
it just filled rather than only re-badging the trust state.

engine 663, client 464, app 582 across both variants, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 11:16:50 -04:00
Paul HittandClaude Opus 5 86da4cc8f4 Run the release variant in CI, and stop hand-listing what cannot run there
CI ran :app:testDebugUnitTest and nothing else, so the release variant
of the app suite was never exercised anywhere. Four tests in it have
been failing since the day they were written: they take an Activity
rule, ComponentActivity is declared by ui-test-manifest which is a
debugImplementation dependency, and the release variant's merged
manifest has no such activity. Green board, red suite.

The exclusion that covers this existed and was a hardcoded name. It
said ScreenshotTest, the next test to take an Activity rule arrived
months later, and nobody thinks about a list in a build file. It reads
the names off the sources now, so a test opts itself in by the rule it
declares.

Both variants run here now, 296 debug and 276 release, all green. The
gate's floors move with them: 663 / 453 / 75 / 572, and 1580 overall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:00:31 -04:00
Paul HittandClaude Opus 5 7f121ab456 Delete nine functions nothing calls
Each verified by bare-word grep across app, client and engine, not by a
counter: the twenty-five WebRTC, BiometricPrompt and capturer callbacks
that also show zero Kotlin-side references are invoked by the SDK and
are not dead.

  refreshGroups        Session calls relay.listGroups directly
  filesGoUp            the breadcrumb bar is how Files goes up here
  clearStoryArchive
  WalletChain.setEndpoint
  RelayAccounts.verifyEmail   a wrapper; the path is posted elsewhere
  Vault.visibleStories        Session filters mutes inline at 1665
  Vault.forgetWallet          KeystoreVault.destroy covers it
  Vault.forgetFilesIdentity   same

postGroupStory looked dead by the same measure and is not being
deleted: it is the only implementation of group stories on this side,
so removing it would drop a spec feature rather than tidy up after one.
Whether to wire it to a screen or take it out is a decision, not a
cleanup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 09:52:10 -04:00
Paul HittandClaude Opus 5 350b50c357 Putting the app away is not leaving the call, and erasing asks first
Two things a person could do by accident.

Swiping out of Recents hung up a live call. The `onTaskRemoved` that did
it was written when removing the task took the service with it, so the
call was over regardless and the envelope only saved the far end a few
seconds of talking to nobody. `stopWithTask="false"` is what keeps this
phone reachable, and it means the service now survives the swipe -- so
the call survived it too, and killing it there dropped a call for no
visible reason from a gesture nobody associates with hanging up. The
teardown that really is a teardown still tells the far end.

Erasing an unreadable vault was one tap on a screen the app can reach by
itself at launch, and it deletes every message this phone holds with no
way back. Keys that will not open here are not worthless: the device they
came from still opens them. Behind a confirmation now, as on iOS.

The test that pinned the old hang-up behaviour is rewritten to pin the
new one from both sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 09:45:39 -04:00
Paul HittandClaude Opus 5 c2f9889bd9 Revoke presence on block, keep the key pools alive, release SAF grants
Four things this client was not doing.

Presence: a per-contact token lets its holder ask the relay whether this
account is online, and the relay answers while the published digest set
carries it. Block and remove never touched that set. They do now, and the
publish no longer skips an empty set, which meant revoking the last
contact's token never reached the relay at all.

One-time prekeys: twenty were published at registration and nothing ever
topped the pool up. Once they were spent, a stranger's first message had
no prekey to take. Replenished from the poll, rate-gated to an hour.

Signed prekey: the rotation existed and nothing called it, so this device
kept one signed prekey for its whole life. Also driven from the poll, on
its own much longer clock.

SAF: deleting a linked file dropped the row and kept the persisted read
grant, so the app held access to files the user believed it had let go of.
The vault now hands back the bookmarks it dropped and the app releases them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 09:28:53 -04:00
Paul HittandClaude Opus 5 2ea825f5cc 0.28, versionCode 28
The rounded bubbles, released.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:37:01 -04:00
Paul HittandClaude Opus 5 c48c2ee5ba Round the bubbles, keeping the corner that points at the author
The conversation read as blocky, which it was: bubbleRadius was 2.dp,
so a bubble was a rectangle.

14.dp now, with bubbleTail left at 2, so one corner stays square and
points at whoever wrote the message: sent bubbles square at the bottom
right, received at the top left. A bubble rounded on all four corners
is the shape every other messenger draws, and the corner that does not
round is what keeps this one recognisable.

`bubbleShape` is the Compose counterpart of iOS `BubbleShape`, which
already drew the asymmetric corner and was also turned down to 2. Both
apps move together and to the same numbers; they are meant to be the
same conversation.

The rest of the app stays squared on purpose, and both files say so, so
this does not get read later as drift and "fixed" by rounding the lists
and bands to match.

1748 unit tests green. The 4 red in TheCrashListOpensAReportTest are
the release variant's Robolectric activity lookup and fail identically
with these two files stashed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:00:38 -04:00
Paul HittandClaude Opus 5 a336401d80 Keep built APKs and test screenshots out of the repo
Two 24MB rollback APKs and a screenshot reached a commit through a
broad `git add`. The rollback series is archived on the box at
/srv/prod/messenger/apk-rollback, which now holds the full run from 0.6
to 0.25.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:51:10 -04:00
Paul HittandClaude Opus 5 d871560324 No bold in code comments
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:44:35 -04:00
Paul HittandClaude Opus 5 9c5aec7f71 De-dash the README line
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:42:39 -04:00
Paul HittandClaude Opus 5 9a45d437e2 De-dash the manifest, proguard rules and resource comments
Comment prose in XML and .pro files, same rule as the Kotlin. PARITY.md
table rows keep theirs: a leading dash in a cell is typography, not a
prose splice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:42:25 -04:00
Paul HittandClaude Opus 5 c26368b493 Comments describe the code, not the edit that produced it
"This used to be drawn inside SignedIn", "nulling current first (as this
used to)", "this used to report chat.peerId". Each keeps the reason it
carried, written as the property rather than the change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:18:18 -04:00
Paul HittandClaude Opus 5 47ecffea39 Groups and recents sit on the surface, like everything beside them
On the Messages screen the CHATS rows were white cards with a rule under
each, and the GROUPS row and the RECENT strip sat straight on the page
with no surface behind them and no inset at the leading edge. Three
sections of one list, two of them looking unfinished next to the third.

The group row was a bare `Row` carrying only vertical padding, while a
chat row is a `PortalRow`, which supplies the surface, the horizontal
inset, the row height and the hairline. Its own comment already said the
two kinds of row are meant to read as one list; now they do, and the
hand-rolled `Divider()` under it goes, because PortalRow draws that.

The recents strip is a LazyRow with no background. It keeps its full
bleed content padding, so the tiles still scroll under the screen edge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:23:33 -04:00
Paul HittandClaude Opus 5 8f92f3c368 The Send button gets a name
Every other icon-only control in the composer row takes its label from
`IconAction(label =)`. Send is a bare Button whose Icon passed
`contentDescription = null`, so the single most important control in the
app had no name: TalkBack announced "button", and it was absent from an
accessibility dump altogether, which is also how it came to be reported
as a missing Send button while it sat there working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 09:31:58 -04:00
Paul HittandClaude Opus 5 223d40e88e Reactions and votes stop failing silently
`reactToStory` ran its work inside `runCatching` and threw the result
away. A reaction that never reached the relay looked exactly like one that
did: the strip redrew, nothing said otherwise, and the emoji was simply
not there afterwards. That is "I react to a story and the reaction doesn't
take", reported and until now unexplained.

`reactToMessage` and `votePoll` do the same thing, so they get the same
treatment. `replyToStory`, sitting immediately below the story reaction,
has always handled its failure properly; these three were the ones that
did not.

There are more of these in this file. They are not all bugs, plenty are
genuinely fire-and-forget, and a heuristic sweep gets it wrong in both
directions: mine first reported `addBuddy` as unhandled when its handler
is fifteen lines down, then missed `reactToStory` because it saw the
neighbouring reply's handler. These three were read rather than matched.

1474 unit tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 07:30:19 -04:00