db5aa33280925ad736e422736f4fff595ac80adc
59
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
e7a3b60b32 |
checkpoint: calls/first-contact/stories fixes + Home/Messages redesign (unverified)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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 |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
ff975bf36d |
Trust the relay you typed, pin the relays that are ours
Correcting a rule I got wrong earlier today. I had the client accept a named
account service only if it was on a pinned list containing exactly
accounts.flamenet.io — which is safe and breaks the case the server already
supports: a self-hoster running Messenger and Files together, with their own
account service holding the one account across both. The file server has taken
ENCLAVE_ACCOUNTS_URL for exactly that since it shipped, and main's relay makes
FN_ACCOUNTS_URL configurable for the same reason. A client refusing the answer
would make "run both" mean "run them with two separate logins".
The rule is asymmetric, and the asymmetry is the point:
- For flamenet.io and its aliases the account service is PINNED in the client
and whatever those relays advertise is ignored. Compromising one of ours
must not let it redirect its users' passphrase-derived secrets.
- Every other relay is believed. The user typed that hostname themselves; a
server they chose naming its own account service is a choice they already
made, and it cannot redirect anybody else's users.
Two tests, one for each half: a pinned relay advertising an attacker's service
still authenticates against ours, and a self-hosted relay may name its own.
|