Commit Graph
28 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 HittandClaude Opus 4.8 f95cb1e6aa Keep a half-written message across a relaunch
Sealed in the vault, not in SharedPreferences — and that is the whole
point. A draft is plaintext the person has not decided to send, very
often the most sensitive text in the app because it is the thing they
are still weighing. SharedPreferences writes readable XML that survives
into backups; the vault seals it under the conversation's own key with
the record name bound into the AAD, and forgetEverything takes it on
sign-out. There is a test that asserts the bytes on disk do not contain
the text, because the wrong implementation would pass every other test
here.

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

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

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

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

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

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

Long press on a story bubble to hide or restore.

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

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

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

Refs paulhitt/flamenet-messenger-ios#26

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:47:39 -04:00
Paul Hitt 6ab4741f8c Merge branch 'test/ios-followup-fixes' into 'main'
Six harness defects the full sweep found, and one of mine

See merge request paulhitt/flamenet-messenger-ios!36
2026-08-25 23:10:53 +00:00
Paul HittandClaude Opus 5 e8d566b3f2 Record what was actually checked for TestFlight
Verified against the built product rather than the source tree, which is what
caught the one blocker: no PrivacyInfo.xcprivacy. Apple requires it for any app
touching a required-reason API and this one uses UserDefaults in five files, so
the upload would have come back ITMS-91053 before reaching TestFlight. The first
archive silently omitted the file because it was added after the project was
generated — hence the note to check the bundle, not the repo.

Also green: archive for generic/platform=iOS, 38 unit tests, launch screen,
usage strings, 1024 icon, ATS with no leftover dev exception, WebRTC's own
manifest, and no tracking SDKs (grepped, not assumed).

Two items left for a human: the collected-data declaration is a legal statement
that must match the ASC privacy label, and versionCode 1 needs bumping after the
first upload.

And a warning that cost an archive: plutil -extract writes back in place without
-o -, so using it to inspect a plist destroys it. Use plutil -p.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:16:09 -04:00
Paul Hitt 3ef8de63b9 Merge branch 'test/ios-two-simulator-and-surface' into 'main'
One sign-up helper, because five copies had the same bug

See merge request paulhitt/flamenet-messenger-ios!35
2026-08-25 21:39:35 +00:00
Paul HittandClaude Opus 5 4f15beb160 Retire the pristine session, not the one a failed decrypt mutated
The retained-session fix was necessary and did not work, and a live two-peer
reproduction is what showed it: both second-exchange messages still vanished
with delivered:1, failed:0.

Cause. `DoubleRatchet` is a final class on iOS and `E2ESession` is a class on
Android, so the failed decrypt that PROVES the peer is on another chain mutates
the instance it was handed — skipped keys consumed, chain stepped. Both fixes
then archived that same instance. Storing a part-advanced chain is
indistinguishable from storing nothing: it cannot open what the peer sends. The
vault copy was never handed to a decrypt, so re-read it before retiring it.

Second cause, Android only. Which slot the winner belongs in is not the same
question as which chain opened it. When the retired chain opens an envelope it
has to be written back to the RETIRED slot; promoting it swaps the two and
discards the chain we are sending on. Each branch now writes back deliberately
instead of sharing one `if (session !== existing)`.

Verified against relay1, two peer accounts, no simulators:

    before   p1/p2 poll -> {"messages":[]} / {"messages":[]}
    after    p1/p2 poll -> second-from-Q2 / second-from-Q1

The repro is in PARITY.md and takes about two minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:12:23 -04:00
Paul HittandClaude Opus 5 e62317b6f3 Reproduce the loss without simulators, and show the fix is not sufficient
Two :interop:peer accounts against relay1, both running the retired-session fix.
Neither polls before sending, which is the simultaneous-initiation case:

    p1 send / p2 send   -> delivered:1, failed:0   (each on its own chain)
    p1 poll / p2 poll   -> both first messages arrive
    p1 send / p2 send   -> delivered:1, failed:0
    p1 poll / p2 poll   -> {"messages":[]} / {"messages":[]}

The second exchange is lost in both directions, silently, with the relay
reporting success. Same failure as the two-simulator run with every iOS variable
removed, and it reproduces in about two minutes instead of forty.

So retaining the retired session is necessary and not sufficient. The receive
side can now open the old chain; the loop still does not close. The remaining
gap looks to be on the send side — after adopting the peer's chain each client
encrypts on the adopted one while the peer still reads the one it kept, so
neither is listening where the other is talking. Resolving this properly means
both sides converging on the SAME chain by a deterministic tie-break, not each
adopting the other's.

Recorded so the earlier commit is not read as having closed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:08:34 -04:00
Paul HittandClaude Opus 5 b6519faada Say what a two-simulator run actually costs
Roughly one second of test time per minute of wall clock, measured with the host
healthy — 38 runnable processes, load 27. The cost is XCUITest snapshotting the
whole accessibility tree per query against a home screen carrying a stories
strip, a buddy list, a groups section and a tab bar.

A DM run is 20-40 minutes per side and the group phase has never completed in
one sitting. Worth knowing before someone schedules this as a quick check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:42:34 -04:00
Paul HittandClaude Opus 5 fbe09d0b61 Disprove my own suspect, and narrow to true simultaneous initiation
The `existing != nil` theory was wrong, twice over. `ratchet` is nil at
E2EInbox.swift:995 only when the envelope is not type `prekey`, so an inbound
prekey never reaches that guard and it cannot drop a first message. And
measurement settles it: testARoundTripWithANonIOSClient passed in 54.2s, with
iOS sending FIRST — creating its own outgoing session — and then receiving and
rendering the peer's reply.

    peer add qrt90210  -> {"added":"qrt90210"}
    peer send          -> {"delivered":1,"failed":0,"to":404}
    iOS round trip     -> passed (54.234s)

Both directions now proven in isolation. What is left is the case neither
experiment covers: both sides sending before either has processed the other's
prekey, which is what two simulators racing through sign-up produces and what a
shell-driven peer never does.

The leading hypothesis moves to line 1006, which overwrites the session built
from an inbound prekey without archiving the one it replaces —
saveArchivedRatchet appears once in the file and only re-saves something already
archived. Recorded with what would confirm it, and with an explicit warning not
to patch on reasoning alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:38:27 -04:00
Paul HittandClaude Opus 5 18499ae746 Narrow the delivery failure to the receive side
The headless peer tool settles the direction. iOS sent to a real shell-driven
account and it arrived:

    testAMessageReachesANonIOSClient   passed (39.2s)
    peer poll -> "hello from iOS to the peer tool", from_user 403

So the send path and the relay are fine, and neither explains two iOS clients
failing one way. What differs there is that both sides initiate a session
simultaneously; the peer tool never initiates, which is why it works.

Suspect is E2EInbox.swift:995. `ingest` returning true means "advance the
cursor" — consume the envelope — and false means hold it for the next poll. The
retry branch is taken only when `existing == nil`. A recipient that already
created its own outgoing session has `existing != nil`, so an envelope it
cannot yet decrypt is skipped forever. That is exactly the simultaneous-
initiation case.

Recorded as a hypothesis with its evidence, not as a fix. It has not been
instrumented, and this file's history includes a previous advance-uncondition-
ally bug that lost messages silently, so the area deserves care rather than a
quick patch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:35:55 -04:00
Paul HittandClaude Opus 5 95659f4685 Record the two reproducible iOS pair symptoms in PARITY
Both reproduce on an idle machine, so neither is load. One-way delivery between
two fresh accounts, and a group create that throws while the sheet sits open.
The evidence that rules out the first wrong theory is written down with them, so
nobody re-derives it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:30:00 -04:00
Paul HittandClaude Opus 5 a9ec8e98bb One sign-up helper, because five copies had the same bug
Seven of 52 UI tests failed in one run with "sign-up did not complete". The
app was right and the tests were wrong, and the failure dump said so:

    signup.password  value: •                    ← one character
    signup.confirm   value: •••••••••••••••••    ← all seventeen
    signup.submit    Disabled

iOS puts "Use Strong Password?" over the first `.newPassword` field, and from
the first keystroke on it swallows what follows. Every sign-up helper in this
target called `dismissPasswordSuggestionIfPresent()` — at the end, after both
passwords had been typed, by which point there is nothing left to save. The app
then correctly refused a form whose two passwords disagreed, and the report
blamed sign-up for it.

It is non-deterministic, which is why it reads as flake: a fifth suite running
identical keystrokes passed in the same run. It is not flake and it is not
machine load.

There were five hand-rolled copies of that helper — four here, plus one I wrote
for the new two-simulator suite, which reproduced the bug immediately and is
what makes the point. Now there is one, `XCUIApplication.completeSignUp`, and it
dismisses the panel when the field takes focus, reads both fields back, repairs a
short one, and fails naming the cause instead of the app.

Two new suites:

  TwoSimulatorUITests — two simulators, two accounts, one relay. Both sides run
  the same test with the handles swapped, so neither has to start first; the
  peer lookup retries, which is the rendezvous. A direct message crosses in both
  directions and a group created on one device arrives on the other. This is
  weaker evidence than CrossPlatformUITests and says so in its own header: both
  halves are the same binary and will agree when both are wrong. What it proves
  that nothing else does is that a message crosses two devices, two Keychains
  and two ratchets and lands in the app's own UI.

  AppSurfaceUITests — the screens nothing was opening. Reactions, unsend,
  in-chat search, mute, the voice-note recorder, the stories strip, the groups
  section, the safety number and five of the seven settings screens had no test
  at all. Deliberately shallow and it says so: this catches a screen that
  crashes or fails to push, not one that is wrong.

Also here, each found while running the above:

- DEVELOPMENT_TEAM was "2W7DA3A8W2", the free personal team on the build Mac,
  while the App Store provisioning profile that already exists for
  net.flamenet.messenger belongs to M5923Y84P5 — the team that ships hittfitness
  and shepherdgiving, valid to 2027-06-04. A personal team cannot produce a
  distribution build, so every archive for TestFlight would have failed naming
  the certificate rather than the team ID that put it out of reach.

- Scripts/generate.sh, because .gitignore excludes the whole .xcodeproj and
  SwiftPM keeps Package.resolved inside it. Since the WebRTC dependency landed a
  fresh checkout had no pins at all: resolve against the network and get a
  different build tomorrow, or disable resolution and fail outright. Both
  unacceptable for something going to testers. The pins live in Scripts/ now.

- rounds.sh ran an iOS *build* and called it a round. The 45 XCUITest cases
  never ran. It runs them now — and refuses to score a run that executed zero
  tests as a pass, which xcodebuild will happily report after a runner crashes
  and relaunches:

      Test Suite 'TwoSimulatorUITests' passed
           Executed 0 tests, with 0 failures

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:55:19 -04:00
Paul HittandClaude Opus 5 b4aadda162 Android: voice calls, peer to peer by default
The one third-party library in this app, and unavoidable: the alternative to
`io.github.webrtc-sdk:android` is hand-rolling ICE, DTLS-SRTP and an audio
pipeline. Worth naming rather than absorbing quietly, because everything else
here is deliberately auditable and this is a large native blob that is not.

🔴 **Peer to peer by default.** ICE tries a direct path first — same network,
then each side's address as seen through NAT — and only falls back to relaying
through TURN when both ends sit behind NAT that refuses one. `iceTransportsType`
is ALL rather than RELAY: forcing every call through the server is the opposite
of the default chosen here.

**Signalling rides the ratchet, not the relay.** The offer and answer are
ordinary content envelopes, so the SDP — codecs, and every candidate address —
is end-to-end encrypted like any other message. A relay that could read
signalling would learn both parties' addresses even on a call it never carries.

⚠️ **A direct call shows the other person your IP address.** That is what makes
it direct, and it is not something to find out afterwards. Explained before the
FIRST call, and it waits for an answer — once, not every time, because a prompt
people see repeatedly is one they stop reading. The acknowledgement lives in
ordinary preferences rather than the vault: it is not a secret, and storing it
encrypted would re-show the warning on every sign-out, which is how a one-time
prompt becomes one people click past.

⚠️ **No TURN server is deployed** — `FN_TURN_SECRET` and `FN_TURN_HOST` are
unset, so `/e2e/turn` answers 503. Handled rather than hidden: STUN alone still
connects through most NATs, and the banner says plainly that this server has no
relay so the call connects only if a direct path exists. "Calling did not
connect", with no reason, is the kind of failure people blame on the app for
ever.

Trickle ICE is off. Candidates are gathered before the offer is sent, so
signalling is one encrypted envelope each way rather than a stream of them —
a second or two slower to connect, and far simpler over a channel that is
polled rather than pushed.

⚠️ The microphone is held by the audio source, not the peer connection.
Closing one without disposing the other leaves the mic live with no call
attached, which is the worst failure this particular feature has available.

⚠️ **WebRTC's native libraries dominate the APK**: ~41 MB across four ABIs, of
which x86 and x86_64 (~24 MB) exist only for emulators. Release is restricted
to arm64-v8a and armeabi-v7a — 102 MB debug becomes a 22 MB release — while
debug keeps all four so it still runs on an emulator. Restricted rather than
split per-ABI on purpose: this app is handed to testers as one file, and
per-ABI outputs turn "send them the APK" into "send them the right APK".

A second offer while busy is declined rather than dropped, so the caller hears
something. Group calls are deliberately absent: different key handling, and an
enabled-looking button that cannot work is worse than none.

Tested: 108 engine, 67 client, none failing; debug and release APKs both build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:13:54 -04:00
Paul HittandClaude Opus 5 c1284aab5f Android: member lookup, and the group gaps
**Member lookup.** `MessengerApi.lookup` already existed and nothing called it.
Adding a contact now takes two steps: resolve the handle, see who it is, then
add — and the add button stays disabled until the lookup succeeds.

⚠️ One step is worse than it looks. Adding straight from a typed string means a
typo silently becomes a contact who is a stranger, and the first thing sent to
them is a message meant for somebody else. Seeing a display name first is what
catches that.

⚠️ A miss says "no account called that here" and nothing more. The relay
answers identically for an account that does not exist and one that has not
enrolled — deliberately, so it cannot be used to enumerate accounts — and this
client must not invent a distinction the relay refused to make. The result also
says plainly that it only proves the handle exists, and that identity is
checked by comparing safety numbers once there is a conversation.

This gate broke five existing instrumented call sites, which is the point of
having them: each now looks up before confirming, the way a person does.

**Group images.** Sealed and uploaded ONCE, then one envelope per member —
same reasoning as a story. Uploading per member would multiply the bytes by the
group size and hand the relay a set of blobs it could correlate.

🔴 The composer's photo button called `sendImage(chat.peerId!!, …)` regardless
of thread. In a group `peerId` is null, so sending a photo to a group did not
send to the wrong place — it crashed. Now routed on whether the thread is a
group.

**Group membership** is editable after creation; `updateGroupMembers` existed
with no way to reach it. The copy states what membership actually does: adding
someone lets them read what is sent from now on, removing them stops future
messages, and neither is retroactive. A group cannot be un-read, and a UI
implying otherwise would be the dangerous kind of wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:02:00 -04:00
Paul HittandClaude Opus 5 acee4597d5 Android: stories
An image sealed and uploaded ONCE, then one ordinary story envelope down each
contact's existing ratchet. Uploading per recipient would multiply the bytes by
the size of the contact list for nothing, and would hand the relay a set of
distinct blobs it could correlate by size and timing.

⚠️ **There is no story feed on the relay, and there should not be.** A
server-side story needs the relay to know who may read it — which means giving
it your contact list for a feature that works without one. The cost is that
posting is a fan-out and partial delivery is normal: a contact with no
registered device cannot receive anything, story or not, so the count is
reported rather than the post failing.

⚠️ **A story bypasses the thread entirely.** It belongs to the strip, and
filing it as a message would drop every story somebody posted into a
conversation they did not have. One that arrives already expired — posted while
this device was offline — is dropped rather than shown late.

⚠️ **Expired stories are deleted on WRITE, not filtered on read.** Filtering on
read leaves a store that grows for ever and still holds every image anybody
posted, which is the opposite of what an expiring story promises. The bytes
actually leave the device.

🔴 **Expiry is a promise, not a guarantee, and the empty-state copy says so.**
This client deletes the bytes when the clock runs out because it chooses to.
Nothing stops a recipient's client keeping them. A UI implying otherwise would
tell people a story is safe to post when it is not, which is worse than not
having the feature.

Unseen is marked rather than styled away — a strip where read and unread look
alike is a strip nobody returns to. Stories are read back on every poll rather
than only when a thread was touched, because a story files itself outside the
thread map and would otherwise not appear until something else arrived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:58:39 -04:00
Paul HittandClaude Opus 5 4728928d98 Android: voice notes
The engine and the client already carried audio — `Attachment.AUDIO`, a
`duration` field, and a `sendAttachment` that takes both. What was missing was
entirely the phone: something to record with, something to play with, and two
controls. So this is app-layer only, and adds no dependency: `MediaRecorder`
and `MediaPlayer` are platform APIs, and a voice note is an ordinary encrypted
attachment the moment it is bytes.

AAC in an MP4 container, mono, 32 kbit/s. Speech rather than music: clearly
intelligible, and roughly a tenth the size of the stereo default nobody asked
for — which matters when every byte is uploaded encrypted and stored by
somebody paying for it.

⚠️ **Tap to start, tap to send — not hold-to-talk.** Hold-to-talk demonstrates
well and behaves badly on a real phone: the gesture competes with scrolling,
and letting go early silently discards what was said. Two deliberate taps
cannot be lost by accident.

⚠️ **`MediaRecorder.stop()` throws when it captured no frames** — a tap instead
of a hold, or a permission granted and then denied by policy. The exception is
a bare `RuntimeException` and it reads as "recording is broken" when the truth
is "there was nothing to record". Treated as an empty recording, and anything
under half a second is dropped rather than delivered: a note that plays silence
looks like a delivery bug, and the person who fumbled the button already knows
they did.

⚠️ **The microphone permission is requested when somebody asks to record**, not
at startup. A microphone prompt on first launch, before anything has been said,
is one people deny on reflex — and a denied permission is far harder to recover
from than one that was never asked for.

⚠️ Received notes are fetched and decrypted **on tap, never on render**. A
thread of twenty would otherwise download and decrypt all twenty on scroll:
bytes nobody asked for, and twenty decrypted files in the cache for a list
nobody has listened to. Playback stops on leaving the thread, because a
MediaPlayer that outlives its composable holds audio focus and a file handle.

Every temporary file — the recording, and the decrypted copy playback needs
because `MediaPlayer` cannot take a byte array — is deleted on both the success
and the failure path. Plaintext audio left in a cache directory is the exact
thing this product is not supposed to do.

`imageBytes` became `attachmentBytes`: photos and voice notes take the same
path, and a method called `imageBytes` that audio also calls is a name that has
quietly stopped being true.

Also retires buddy commands in the parity table — no longer a feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:37:31 -04:00
Paul HittandClaude Opus 5 a213ad8d5d Android: sealed sender, so the relay stops learning who talks to whom
Key transparency stopped the relay substituting keys. It did nothing about the
relay watching who writes to whom, which is the metadata most people actually
leak. §14 moves the sender's identity inside the ciphertext: the relay learns
who a message is for and nothing about who it is from.

Verified against relay1 rather than asserted. A sealed submission is accepted
unauthenticated, the stored row comes back with `from_user = 0` — the relay
saying it does not know — and the recipient still reads the message with the
sender recovered from inside the seal.

Cross-engine verified in BOTH directions against the normative web engine, and
that mattered: the seal is asymmetric in practice, since one side derives from
an ephemeral it generated and the other from one it was handed, so a byte-order
or AAD mistake can easily break only one direction and a single-direction test
picks the half that works. The interop harness gained `seal` and `open_sealed`
ops for it; 43 assertions, none failing.

🔴 **The seal is confidentiality, never authentication.** The recipient's
`IK_dh` is published to the world, so anyone can produce a well-formed seal
claiming to be from anybody. The claimed sender picks a session and nothing
else; the ratchet opening it is what makes it a fact, and the identity pin is
what refuses an impostor. There is a test that forges a sender deliberately, so
that if somebody later renames these fields into something confident the reason
they are claims is still written down and still executing.

⚠️ **The first message to a new contact cannot be sealed.** Sealing needs the
recipient's delivery key and that key travels inside an E2E message, so a prior
exchange is required. `Sent.sealed` reports which happened rather than leaving
the UI free to imply anonymity that was not achieved — a silent fall back to an
unsealed send is exactly what §14.5 warns about.

⚠️ **Rotation is blunt, and the copy says so.** There is no sender to block, so
the only lever is replacing the delivery key, which cuts off every contact
until the next time they are written to. Reachable from Account, asserted by
`CrudSurfaceTest`, and live-tested: a submission presenting a retired key is
refused by the relay.

The relay stores only `SHA-256(key)`. The key itself never passes through the
relay from its owner, which is the only reason an unauthenticated sealed
submission can be authorised without naming who is submitting.

Tested: 108 engine, 67 client, 43 interop, none failing, live suites enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:31:07 -04:00
Paul HittandClaude Opus 5 cfeefa0616 Contacts carry the peer's address, so an honest key verifies
The relay files a key-log leaf under an account's fnid once it has one, and
under `local:<audience>/<user_id>` before that. The subject is inside the
hashed bytes, so a verifier that does not know the address cannot recompute the
leaf — and enforcement refused honest keys from any account that had claimed an
identity. A false positive on a real conversation is how a security setting
gets switched off and left off, so this is the half that decides whether the
feature is usable at all.

No relay change was needed. `GET /e2e/account/identity/{handle}` already
resolves a handle to an address, unauthenticated, and the buddy list already
carries the handle. The contact record now learns the address once and pins it
beside the identity keys, and `enforceKeyLog` recomputes against the pinned one
by default.

⚠️ **Pinned, not looked up per check**, and that is the entire point. Reading it
fresh each time would let a relay that wants to substitute a key simply also
change the name it files that key under, and every verification would agree
with the new answer. Recomputing against the address this contact has always
had is what makes the check about a person rather than about whatever the relay
says today.

First contact is still trust-on-first-use. Taking the relay's word once is not
a check; what it buys is that every later answer has to match it, and the
safety number is what covers the first sighting.

🔴 A CHANGED address is an identity change, for a stronger reason than a
changed key: an fnid is derived from the account key, so a new address is not a
new device — it is a different account key, and therefore a different person.
Accepting a change re-resolves rather than keeping the old address, because if
the address is what changed, keeping it would leave every later check
recomputing against a name the account no longer publishes under, and honest
keys would stay refused for ever.

⚠️ An unreachable relay pins NOTHING rather than pinning "no address".
`rememberIdentity` does nothing once an address is pinned, so a wrong answer
would be permanent — and the next successful lookup would then read as the
address changing, which is the alarm, for a network blip.

Proven against relay1 in three states, because only all three make it legible:
refused with nothing pinned, accepted once the contact carries the right
address, and refused again with a wrong one — so what makes it pass is the
address being right, not the check having been loosened. A changed address is
asserted to come back CHANGED against the real relay too.

Tested: 100 engine, 65 client, none failing, live suites enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:19:01 -04:00
Paul HittandClaude Opus 5 6a6bcc9d08 §19 against relay1, and the false positive it found
The enforcement landed last change was unit-tested against a MockWebServer
carrying responses this repository wrote — which proves the verifier agrees
with our idea of the relay, not with the relay. That distinction has already
cost this protocol three silent wire-shape bugs, every one invisible to a fake.

`LiveKeyLogTest` runs it against relay1's real log, opt-in via `FN_LIVE_FLEET`.
Six cases, all passing: a key the relay genuinely published verifies; a
substituted key is refused; a second check reconciles a pinned head with a
grown tree using proofs the relay generated; a root we never saw at our pinned
size is a fork; and the send path refuses on a bad pin while NOT refusing an
honestly published key with enforcement on.

**And it found a false positive on honest keys.**

An account that has claimed an fnid is logged under it, and under
`local:<audience>/<user_id>` before that. The subject is inside the hashed
bytes, so a verifier that does not know the fnid cannot recompute the leaf. The
key was published perfectly well; the check cannot see it. The test pins the
behaviour both ways — refused without the fnid, verifying with it — so this is
a fact in the suite rather than a comment somebody may or may not read.

This is the worst shape a check like this can fail in. Refusing real
conversations is how a security setting gets switched off and left off.

Nothing in the relay's contact or device routes returns a peer's fnid, so the
client cannot learn one today and this is not fixable here. What is fixed:

  - `KeyNotLoggedException.hadEntries` separates "this device has publications,
    none of which match" from "nothing was ever logged for this device". Both
    still refuse — refusing is the safe direction — but they no longer produce
    the same sentence, and only the second is the shape a substitution takes.
  - `enforceKeyLog`'s `fnid` parameter now documents that it is REQUIRED for any
    account that has claimed one, rather than reading like an optional extra.
  - `RelayClient.claimAccountIdentity` exists, because the test needed to claim
    an identity to prove any of this and Android had no way to.

The real fix is for contact records to carry the peer's fnid, which is stronger
than the current check anyway: it verifies the key was logged under *that
person*, rather than under whatever subject the relay chose to file it against.

Tested: 100 engine, 58 client, none failing, live suites enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:11:11 -04:00
Paul HittandClaude Opus 5 0330a7b84a Android: refuse a key we have reason to distrust
Android had the transparency log's verifier and nothing that called it. The
parity table listed trust state, verification, identity-change acceptance, and
key-log enforcement as absent, and they were — so the relay could hand out a
key it minted, the conversation would encrypt to it, and every byte on the wire
would be correctly encrypted to the wrong person. A log nothing checks records
the substitution faithfully and is never read.

Two independent checks, because neither is sufficient alone.

**Pinning, always on.** Every identity key a peer offers is compared against
what was pinned on first contact; a send to a changed identity is refused until
a person accepts it. The comparison is "every offered key is already pinned",
not "any" — a relay adding one key of its own ALONGSIDE the real ones is the
attack, and the fact that all the genuine devices still work is exactly what
makes it invisible. There is a test for that case specifically.

**The log, opt-in.** `require-key-log` on the Account screen makes a send
demand that the relay prove the recipient's key appears in the log it
publishes. Off by default for the same reason the web engine's is: a relay
predating the log has none, and refusing every peer on those deployments is a
worse failure than the one being prevented.

`KeyLog.verifyConsistency` did not exist in the Kotlin engine. Inclusion alone
is worth very little — a relay that rewrites history reissues perfectly valid
inclusion proofs against its new tree, honest proofs about a dishonest log.
Consistency against a head this device already pinned is the one thing it
cannot produce. Ported from `engine/src/keylog.js`, which is normative, rather
than re-derived: a re-derivation that accepts too much passes every honest test
and fails only against an attacker.

Head gossip rides along with messages and compares a peer's view against ours,
which is what turns a per-device check into a detectable fork — alone, each
client only knows the relay was consistent with itself.

Tested, 100 engine / 52 client, none failing. Consistency is checked over EVERY
pair of prefix sizes in the published vectors, because the traversal has a
special case when the old size is a power of two: an implementation that gets
that wrong still passes 3 -> 5 and fails 2 -> 5. The proof builder the test
uses is trustworthy because the tree it builds is the one whose roots already
matched the published vectors at every size.

The refusals are what carry the weight, so each has its own test: a head going
backwards, two roots at one size, a tampered proof node, a served key absent
from the log, an inclusion proof for somebody else's leaf, and a rewritten log
that cannot be reconciled with a pinned root. A trust check that accepts
everything passes every happy-path test ever written.

Reachable from the UI, per the standing rule. The thread shows identity state
and the safety number; the changed state offers accept and nothing else,
because an "ignore" affordance would be a way to silence the one signal in this
product that means interception. `CrudSurfaceTest` asserts the controls exist.

⚠️ Not yet exercised against a live relay — the tests drive a real HTTP server
with hand-built responses. Implemented and unit-tested, not proven in the
field. ⚠️ Sealed sender is still absent, so the relay still learns who talks to
whom; these protect different things.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:48:44 -04:00
Paul HittandClaude Opus 5 76d485fb55 Bring the parity table back in line with what has been run
Three rows had drifted from the code, in the direction that matters — claiming
less than exists, which sends people to build things that are already there.

`SiteConfig` on Android is not missing: `SiteConfig.kt` holds the three
deployment shapes, `MainActivity` has the relay field, and the screen shows an
`identity-mode` label naming which kind of server it is *before* a passphrase
is typed. Marked ⚪ regardless.

Sign-up on the managed and shared shapes now has a test rather than nothing —
`LiveSignUpFleetTest` — which compiles but has not been run, so the row stays
🟡. The distinction is worth keeping: written and unrun is not built and
unexercised, and neither is done.

Send/receive text was verified app-to-app this time, not app-to-harness, and
the evidence is recorded with the log lines it came from.

Also added the two environment traps that cost the most time today, because
neither is visible in a test log: XCUITest degrades into "Timed out while
synthesizing event" and 1700-second runs when macOS background daemons are
saturating the machine, and Spotlight indexes DerivedData unless the path ends
`.noindex`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:55:33 -04:00
Paul HittandClaude Opus 5 e65d1d987b Android: photos, and a door for every capability
Owner feedback, and it was fair: capabilities kept landing in `:client` with a
live test proving them and no way for a person to use them. Remove a contact.
Block someone. Clear a thread. Change a password. Read a notification. All
built, none reachable.

So this is the CRUD pass, plus attachments.

New in the UI: contact actions (block, unblock, clear chat, remove), a presence
picker, a notifications screen with per-item and mark-all-read, an account
screen with display name, password change and account deletion, password reset
on the sign-in screen, bottom navigation, and photos -- sent through the system
picker, rendered inline.

`CrudSurfaceTest` walks the app and asserts the controls exist. It checks
reachability rather than behaviour, because behaviour is what the live tests
cover and the door is what was missing. It found two real gaps immediately:
sign-out was reachable only from the contact list rather than from Account,
where people look for it, and several controls sat below the fold.

Underneath: the §6.6-§6.9 content envelope, which is one codec for attachments,
replies, reactions, group routing and call signalling. 17 unit tests.

Two things it has to get right, and both are one-line mistakes:

`fnc` must serialise FIRST, because the marker is a prefix test on the bytes. A
JSON library that sorts keys puts `att` first, every envelope it produces reads
as plain text on the far side, and somebody's chat fills with raw JSON. The top
level is assembled by hand.

Control messages travel down the same channel as chat -- the web client sends
`delivery_key`, `kt_head` and `sent` copies this way. A client that renders
every envelope it does not recognise shows those to the user. They are now
parsed, marked unrenderable, and handed to the caller with their payload.

Attachments verified end to end against a real relay, including that the relay
holds only ciphertext. That path was 401 on iOS for months because the
attachment routes sent the account password, which the relay refuses on
`/e2e/…`; this one uses the device-scoped token like everything else, and the
test is what says so.

Also: the instrumented suite passed one test at a time and failed as a set. The
Compose rule gives each test a fresh Activity but the process and vault survive,
so the app spends its first moments reading the vault behind a spinner -- and a
reset that checks for a signed-in control immediately concludes "already signed
out" while the session restores underneath it.

114 JVM tests, 37 cross-engine, 15 on-device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:47:45 -04:00
Paul HittandClaude Opus 5 d30a77dd1e Android: contacts, conversations, and a message that made the round trip
The app now holds a conversation. A message typed into it reaches a different
program through the real relay, that program answers, and the answer appears in
the thread -- verified three times, end to end, encrypted the whole way.

96 JVM tests, 37 cross-engine, 11 on-device, plus live round trips against the
throwaway fleet.

What landed: a fan-out send that reaches every device a person has, inbox
polling, message history encrypted at rest, contacts, presence, and a session
that survives a restart. Plus `:interop:peer` -- a Flamenet account you can
drive from a shell, so the correspondent on the other end is not this app
talking to itself.

Three wire shapes were wrong, and only a real relay could say so:

  - `prekeys`, not `opks`. The relay answered 200 and stored nothing, so the
    device was published and could not be started with.
  - The send body names the recipient once at the top, not per envelope.
  - The buddy list is `id`/`presence`/`screen_name`, not `user_id`/`status`.

The second and third at least failed loudly. The first did not, which is why
the live tests exist.

Decisions worth stating:

A send returns partial success rather than throwing. One of someone's three
devices being unreachable must not stop the message reaching the other two, and
the UI says "delivered to 2 of 3" rather than "sent".

The inbox watermark advances per message, after it is decrypted AND stored --
never once for the batch, which would lose everything after a crash. An
undecryptable message advances it too: it is never going to decrypt, and
leaving it would re-fail on every poll for ever.

A sent line is recorded only once a device took it. Writing it first produces a
thread showing messages the recipient never got, which is worse than an error --
the sender believes they said something.

Opening a thread marks it read locally and tells the relay nothing. A read
receipt is a disclosure, and this product does not make one by default.

History is keyed by person, not device. Someone's phone and tablet are one
conversation, and filing by device would split it into threads each holding
half the story.

Also improves how instrumented failures report: the assertion now prints what
the screen was showing. That turned a bare 90-second timeout into "No value for
user_id" and then "Failed to connect to relay1" -- one a bug, one the emulator's
network, and previously indistinguishable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:55:49 -04:00
Paul HittandClaude Opus 5 5f983c1455 Android: the app, and the two things only a phone could tell us
There is now a Flamenet Messenger app that installs, launches, creates an
account on a self-hosted relay, registers a device, publishes prekeys, and
stores all of it in an Android Keystore-backed vault. Verified on an emulator
through the actual screens, not by unit test.

92 JVM tests, 37 cross-engine, 9 on-device, 3 against live relays.

Two findings, both of which the JVM suite was structurally incapable of
producing.

**Android has no ordinary Ed25519 provider.** `KeyPairGenerator.Ed25519` and
`KeyFactory.Ed25519` come from `AndroidKeyStore` and from nowhere else -- probed
on API 35 rather than assumed -- and Keystore only makes keys it keeps. Every
Ed25519 key in this engine is one we must hold: a device identity in our own
vault, an account key derived from a passphrase. So the JCA route could never
have worked. It compiled, passed 81 tests on a laptop, and threw "Not
initialized" on the first launch on a phone. Worse, it was LATENT: a wrong
password failed before any key was minted, so the error path worked perfectly
while the happy path was broken.

Ed25519 now uses BouncyCastle's `math.ec.rfc8032` core directly -- the engine's
one real dependency, present because the platform leaves a hole, and a strictly
better answer than the non-constant-time keygen it replaces. ProviderProbeTest
records the measurement so nobody guesses again.

**The prekey field is `prekeys`, not `opks`.** I guessed. The relay accepted the
registration, returned 200, and stored nothing -- so the device was published
and could not be started with, a failure with no error anywhere. Caught by a
live test against the throwaway fleet, which exists because the same path
through the emulator takes four minutes to say "timed out" and nothing about
where.

Two testing traps, recorded where they bite:

An indeterminate progress spinner animates for ever, so Compose's test clock
never goes idle and `waitUntil` never evaluates its condition -- it fails on the
full timeout, reading as "sign-up is broken" when sign-up works. Auto-advance is
now disabled around the wait only; disabling it for the whole test is the next
mistake, because the app opens on a spinner while it reads the vault.

Wiping the Keystore in `@Before` deletes the key the already-launched activity
is holding, and every later write fails against a key that no longer exists. The
test signs out through the UI instead, which is both safe and what a person does.

Failure messages now include what the screen was showing. A bare timeout sends
you to the network, the relay and the crypto in turn while the app has been
displaying the reason the whole time.

Also corrects a note that said this Mac had no Android SDK. It does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:02:16 -04:00
Paul HittandClaude Opus 5 b1ba9cdc58 Kotlin: the relay transport
The engine can hold a conversation but had no way to reach a relay. This is
that: the §8 routes, both authentication modes, and the transparency log.

13 tests against a real HTTP server rather than a hand-written fake. That
distinction is the point -- a fake written alongside the client agrees with it
about everything including the field name it got wrong, which is exactly how
this protocol's one shipped bug survived until a live round trip.

Two more tests run against the deployed relay, opt-in via FN_LIVE_RELAY and
silent when unset. A test that reaches the network cannot sit in a hermetic
suite; it fails on an aeroplane and a suite that is red for unrelated reasons
stops being read. What they check is the thing MockWebServer structurally
cannot: that the URL this client builds is one relay.flamenet.io actually
serves. It does, on both mounts, and the Kotlin engine verified an inclusion
proof from the production log against a head fetched separately -- not against
the root the proof response carried, because a server that supplies both halves
of a comparison has proven nothing.

Details that are load-bearing:

The per-request signature covers the LOGICAL section-8 path, not the URL's. The
relay mounts these routes twice and signs against the former, so signing the
REST-prefixed path yields a valid signature over the wrong string and a refusal
with no hint as to why. Tested by asserting the other one does NOT verify.

The nonce is not decoration. Two inbox polls in the same second with the same
empty body would otherwise produce identical signatures, and the relay's replay
cache cannot tell an honest repeat from a captured one -- so it refuses the
honest client and the app quietly stops receiving mail.

Token minting is lazy, and a deployment with no token route is asked exactly
once. Laziness cost an hour here: the tests hung, because takeRequest() blocks
forever and I was waiting for a mint that had not happened yet. They now use a
bounded read, so that mistake fails instead of looking like a hung build.

Application passwords are stripped of spaces. The account service prints them
in readable groups and users paste what they were shown; sending the spaces is
a 401 nobody can diagnose, because the password on screen looks right.

Kotlin nests block comments, so `/e2e/` followed by a star inside a doc comment
opens one that never closes and the compiler blames the last line of the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:37:59 -04:00
Paul HittandClaude Opus 5 8a3770f1d1 Kotlin: the session layer, verified across the wire
An app does not use X3DH and a ratchet, it uses "send this string to that
person". This is that layer: device identities with prekeys and rotation,
envelopes as the relay actually carries them, and sessions that survive being
put away and taken out again.

11 unit tests, and the interop harness grows from 23 assertions to 37 -- the
new ones at the envelope level, which is where this protocol's one shipped bug
lived. A client and a relay disagreed about a field name and nothing caught it
until a live round trip; hand-assembled arguments to X3DH would not have.
Kotlin now builds a whole envelope, the web engine's own session layer parses
it, and both directions agree on the associated data.

Three decisions worth stating.

Retired signed prekeys are kept for longer than the relay keeps undelivered
mail. A sender fetches a bundle, encrypts against that prekey id, and the
envelope can then sit in a queue for the relay's full retention window.
Discarding the private half at rotation turns every one of those into an AEAD
failure -- which the receiving client cannot distinguish from an attack. A
prekey retired past retention is refused outright rather than guessed at, for
the same reason: guessing derives a garbage secret and reports it as tampering.

The handshake block rides every message until one is answered, not until one is
sent. Any message may be the first to arrive.

Sessions archive with their skipped message keys, and the harness proves it
against the peer rather than against our own bytes. Those keys are the messages
already in flight; an archive that drops them loses exactly the mail that was
in the air when the app was killed. The failure this whole path exists to
prevent is a client that loses session state, re-registers a device, and shows
every contact "security keys changed" -- the warning that is supposed to mean
an attack.

`org.json` is compileOnly: it is in the Android framework, so it costs nothing
on a phone and bundling a copy would shadow the platform's. Safe only because
the two encodings that must match byte-for-byte -- the ratchet header and the
canonical log leaf -- are built by hand; Android's implementation preserves key
order and the reference one does not.

Also adds PARITY.md: what the iOS app does, what Android does, what is left.
It is the definition of done, and a row moves only when something tests it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:12:14 -04:00