Commit Graph
61 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 4.8 088fd5f5a7 Key-log state is per relay, and two bugs the new tests found
Writing tests for the multi-mailbox and key-log work turned up three
things, two of them in code committed earlier today.

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

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

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

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

Verified against a live relay: 243 client tests.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Trust 73.9% -> 98.1% lines.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

engine 177, client 96, app 46.

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

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

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

engine 177, client 96, app 46.

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

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

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

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

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

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

engine 168, client 96, app 40.

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

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

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

client 96.

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

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

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

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

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

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

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

engine 155, client 94, app 32.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:14:08 -04:00
Paul HittandClaude Opus 4.8 7727cfa432 send path uses the device directory, not the prekey route — every send burned bundle quota and popped OPKs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 15:18:26 -04:00
Paul HittandClaude Opus 4.8 e7a3b60b32 checkpoint: calls/first-contact/stories fixes + Home/Messages redesign (unverified)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 14:58:43 -04:00
Paul HittandClaude Opus 5 85d6825fdf Stickers and emoji in the composer (Android), and a written queue
Stickers send, arrive, file into the right thread - group messages under the
group, not scattered across one thread per member - and draw bare. No bubble:
the kit draws none, and a glyph inside a grey rounded rectangle reads as a
broken image. The timestamp and avatar stay, because that is the line saying
who and when.

Two controls, not one, because they are two different things. An emoji goes
INTO the draft and is indistinguishable from any other character once sent -
nothing on the receiving end needs to understand it. A sticker is its own
message kind that is never rendered as text. Behind one button they would imply
they behave the same way. Hence also: the emoji panel stays open on tap, since
picking three in a row should put three characters in the box, while a sticker
sends and closes because it IS the message.

The emoji set is a short curated list, said plainly in the code: a full picker
is search, skin tones, categories and recents - a real feature the system
keyboard already ships. This is what a thumb can reach.

docs/ROADMAP.md now holds the queue, including the security and privacy work.
Written down because this project has already lost a finished feature to a
parked branch. Two items there are real and current: Android spends a one-time
prekey on EVERY message via fetchKeys, silently costing initial forward secrecy
once the pool drains - iOS fixed this and Android has the unused listDevices
sitting next to it - and a passphrase IS the account, with no second factor and
no recovery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:04:54 -04:00
Paul HittandClaude Opus 5 80b903e168 A message nobody can receive must not report success
Reported: a message sent from Android while the other end was signed out on
iOS was lost, and a group text vanished the same way.

Envelopes are addressed per device, and signing out deregisters the device.
sendPlaintext looped over an empty bundle list, made no request at all, and
returned Sent(delivered=[], failed={}) - which every caller reads as "it
went". The message was written into the sender's own thread and existed
nowhere else. Group sends call this once per member, so the same path lost
group text.

iOS never had this: transmit throws on an empty device list. The platforms
disagreed about a delivery guarantee for as long as both have existed, and
nothing caught it, because the tests that covered sending had both ends
signed in - and a happy-path test cannot tell a delivery from a no-op, since
both are silent.

Recorded in the failure map rather than thrown, so a group send still reaches
the members who ARE reachable. The banner now says the message was not queued
and has to be sent again, instead of "Delivered to 0 of 1 devices".

SendToNobodyTest pins it; verified failing with the guard removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:54:12 -04:00
Paul HittandClaude Opus 5 c9de1f26b8 Profile pictures, end to end, and the chat bar's second line
🔴 **An avatar is an attachment, not a field on the account.** The
obvious design — upload a JPEG and let the relay serve one per user —
hands the server everybody's face, on a product whose whole claim is
that it cannot see what you send. This rides the same encrypted blob
path as a photo message: the relay stores ciphertext, and the key
travels to each contact inside the ratchet exactly as the delivery key
already does.

⚠️ **The cost, stated on the screen rather than discovered.** It is per
contact. Somebody who has never messaged you cannot see your picture,
and changing it means telling every contact again. Sealed sender
already makes this bargain; a different answer for avatars would mean
two answers to the same question.

The pieces: a new `avatar` control kind, which is parsed WITH its
attachment ref rather than falling through the CONTROL early-return —
otherwise a profile picture arrives in the conversation as though
somebody had sent you their face as a photo. Encrypted at rest in the
vault like everything else, because a face on disk in the clear is the
one thing a stolen phone should not hand over about your contacts.
Sharing is keyed on the blob id, not a boolean: a flag would say "told"
for ever and a new picture would never reach anyone who had the old one.

⚠️ Contacts are told on OPENING a conversation, not when the picture is
chosen. Somebody added afterwards would otherwise never be told, would
see initials for ever, and nothing on any screen would explain why.

⚠️ Partial delivery is reported. "Sent, but 2 of 5 could not be told"
is the truth when two contacts have a changed key, and silence would
let somebody believe it had gone out.

Also: the chat bar gained the kit's second line — a presence dot and a
12sp fact, or a member count for a group, which shows no dot because a
group has many presences and one dot cannot speak for them. And the
reassuring identity states dropped to 12sp grey; they were as loud as
the title bar, which is the wrong weight for "nothing has happened".
The CHANGED state is untouched and stays loud.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:58:58 -04:00
Paul HittandClaude Opus 5 e46d51430a Android can show an address, and the Account screen looks like the app
Two gaps, one in each direction.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:36:58 -04:00
Paul HittandClaude Opus 5 cda64d8c2e A contact who reinstalled could never be messaged again
🔴 **The chat header said "keys unchanged since you met" while every send was
refused with "identity keys changed".** Found by driving two real apps: the
iOS side relaunched with -FNMSGResetState, which is a genuinely new device with
new keys, and Android correctly refused to send to it. Correct — and then
there was no way out.

`Trust.state()` could return UNKNOWN, VERIFIED or PINNED and nothing else.
CHANGED was computed inside `record()` while a send was in flight and thrown
away, because `Vault.Trust` had nowhere to remember it. So:

- the header kept reporting the OLD pinned keys as unchanged,
- the "Accept new keys" button, which only renders for CHANGED, never appeared,
- `acceptIdentityChange()` was unreachable from the UI,
- and the contact stayed unmessageable for ever, with the app insisting
  nothing was wrong.

That is precisely what a tester does on day one: reinstall, and discover their
friend can no longer reach them.

`Vault.Trust` now carries `changed`, `record()` persists the detection without
adopting the offered keys and drops the verified tick (whatever was compared in
person was compared against the old key), `state()` reports it, and accepting
clears it. Keys that match again also clear it, so a transient wrong answer
does not strand somebody behind a banner for ever.

Verified live on the emulator: the banner appears with its full text, Accept
new keys is reachable, and the header returns to "keys unchanged" afterwards.
Four tests in TrustTest; three of them fail against the old `state()`.

---

🔴 **An expired session told the user their passphrase was wrong.** Hit
immediately afterwards: a send failed with "That handle or passphrase isn't
right", ten hours after a successful sign-in, because AccountsClient mapped
EVERY 401 and 403 to BadCredentials. The natural reading is that the password
stopped working or was changed by someone else, and the natural response is to
change a password that was never wrong.

Only `sessions` and `accounts` actually present a passphrase, so only those can
report one as wrong. Everything else now raises SessionExpired, which the app
states as what it is. Two tests cover both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 07:09:08 -04:00
Paul HittandClaude Opus 5 b051814a6c Show the relay's sentence, not its JSON
Creating a group with a member who has never signed in put this on screen:

    relay returned 400: {"code":"bad_request","message":"No reachable
    device for user: 30.","data":{"status":400}}

The relay had written a perfectly readable sentence and the app showed the
envelope around it. Found by creating a group on an emulator, not by reading
code.

⚠️ The REFUSAL is correct — requireReachable declines a group whose member has
no device, because there is nothing to encrypt to. Only the presentation was
wrong.

Cause: MessengerApi.send and RelayAccounts already unwrap `message` from the
error body; RelayClient.checked did not. So which client a feature happened to
be built on decided whether its errors were legible, which is not a decision
anyone made. Now all three unwrap.

182 Android unit tests still green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:00:45 -04:00
Paul HittandClaude Opus 5 2855711bc5 Android could never find anybody: the relay answers with an array
Adding a contact was impossible on Android. Every lookup reported "No account
called X here" while the relay was returning the account.

RoutesAccount.lookup answers with an ARRAY — empty for a miss, one element for a
hit — deliberately, so a miss and a hit differ only in length and neither timing
nor shape is an oracle. MessengerApi.lookup sent that through send(), which does
JSONObject(text) and throws on an array, and then swallowed the throw with
runCatching { }.getOrNull(). A parse failure and a genuine miss became the same
thing.

Measured against relay.flamenet.io, before and after, in the running app:

    relay:  [{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]
    before: "No account called appreview2demo here."
    after:  the contact card, with Add contact enabled

⚠️ Three layers kept this quiet: the relay was correct, the failure was
swallowed, and a miss and a crash render identically. CrudSurfaceTest asserts
the lookup CONTROL exists — and it does. **A control being present says nothing
about the feature working**, which is the whole reason this was found by driving
the app rather than by reading the suite.

This is the ONLY array-returning route (grepped), so rawText() is deliberately
narrow: everything else keeps send()'s typed error mapping.

Guarded by MessengerApiLookupTest — hit, miss, and a 500 that must not be
mistaken for a miss. **Verified load-bearing**: restoring the old implementation
fails it. 182 Android unit tests, 0 failures.

Also adds FeatureUseTest, which USES voice notes, groups, stories and calls
rather than asserting their buttons exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:00:45 -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 999ab2bd26 Keep the chain a re-establishment replaces
When both sides open the same chat before either has processed the other's
prekey — two people tapping a conversation at the same moment — each runs X3DH
and each ends up adopting the other's chain to read what arrived. From then on
the peer sends on the chain being replaced. Overwriting it without a copy does
not lose one message; it makes that direction undecryptable for the rest of the
conversation, silently, because an envelope that will not open is skipped rather
than reported.

**iOS.** `E2EInbox` had the fallback already — `loadArchivedRatchet` is tried
before building from a prekey, and its comment describes exactly this situation.
It was unreachable: `saveArchivedRatchet` appeared once in the file, and that
call only re-saves a ratchet that was already archived. Nothing ever put a live
session there. It does now, immediately before the session it replaces is
overwritten.

**Android was worse, and had no fallback at all.** `Conversations.poll` read

    val session = existingSession(fromDevice) ?: E2ESession.respond(identity, envelope)

which looks like a fallback and is not one: with a stored session present that
was the ONLY thing tried, `decrypt` threw for a peer on another chain, and the
catch swallowed it. `respond` was never reached. Now three chains are tried in
order — the current one, the retired one, then a fresh one built from the prekey
block — and the replaced chain is kept.

⚠️ The watermark still advances on a total failure, deliberately. An envelope
none of the three chains could open will not open next poll either, and holding
the cursor there would wedge the inbox behind one bad message. What made this
lossy was never the watermark; it was that only one chain was ever tried.

`Vault` grows a retired-session slot under its own prefix — deliberately not
`SESSION_PREFIX`, or `knownPeers()` would list every retired chain as a peer —
and `forgetAllSessions` clears it too, since a sign-out that left chain keys
behind would keep exactly what it claims to erase.

Tests, and the one that matters is the storage-level one:

- `VaultTest.a retired session is kept and still opens its chain` — asserts the
  ADOPTED session cannot open the retired chain before asserting the retired one
  can, so it cannot pass by accident. **Verified load-bearing**: neutering
  `saveRetiredSession` fails it, restoring it passes.
- `VaultTest.forgetting sessions forgets the retired ones as well`.
- `E2ESessionTest.simultaneous initiation does not strand the conversation` —
  covers the engine primitives and asserts the SECOND exchange, the one that
  used to die. ⚠️ It holds both sessions in local variables, so it passes with
  or without the storage fix; it is not evidence for it.

111 engine tests, 0 failures. Both platforms compile.

⚠️ This is a real correctness defect and is fixed on its merits. Its
relationship to the one-way loss seen between two simulators is NOT established
— that symptom is the first message, and both first messages open fine either
way. Do not close that investigation on this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:53:19 -04:00
Paul HittandClaude Opus 5 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 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