444ebf0862d0ebb0ce4a08c154abef34279d7fb9
48
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
1bbf8b460b |
Embedded live streams in stories, behind a disclosure (§6.13)
Every other media path here is a sealed blob — the relay stores ciphertext and nobody else is involved. A live stream is not that: it is continuous and hosted elsewhere, so there is nothing to seal, and watching one means this device connects to that host, which learns the viewer's IP address and that they watched. 🔴 **§6.12's answer does not transfer.** A link preview works because the sender fetches it once and puts the result inside the ciphertext. A stream cannot be fetched once. So this is disclosure rather than prevention. The URL rides inside the ciphertext, so the relay learns nothing about what is watched or by whom. Playback is an explicit tap with the host named and what it learns spelled out — a card that started playing on its own would make that disclosure after the fact, which is not a disclosure. ⚠️ The attachment is the POSTER, not the stream. Keeping a real blob means the strip, the unseen rings and the expiry sweep all work unchanged, and the viewer sees a still before deciding whether to connect. ⚠️ The host is derived from the URL, never the caption — a story captioned "youtube.com" pointing elsewhere is the phishing shape link previews already guard against. Non-http(s) URLs and ones with no host are refused: a `file:` URL arriving from another person is not something to hand a media player, and a destination nobody can name is one nobody can consent to. The wording lives in the engine rather than the UI so it can be asserted. 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> |
||
|
|
3f0dfd669c |
The triage pair: snooze, and "remind me if no reply"
Two halves of the same problem — a conversation you are not ready for, and one you are waiting on — and both are local to this device on purpose. "I am ignoring this person until Tuesday" and "remind me if they have not answered" are statements about a relationship, and the relay has no business holding either. The honest cost is that neither follows you to a new phone; the alternative is handing a server the list of people you are avoiding. 🔴 **A reply arriving cancels the reminder, and it happens in `Session.record`** — the one place every inbound message passes through. Hanging it off a screen or a poll handler means the one path that forgot to call it produces a reminder that fires after they answered, and that is the single thing that would teach people to ignore reminders. The service fires due reminders from its existing loop, deliberately AFTER `receive()`: a reply that has just landed cancels its own reminder before it can go off. Checking first would nudge about a conversation answered a moment ago. Cleared as it fires, too — a reminder is one-shot, and leaving the row would re-notify every half second for ever. ⚠️ Snoozes are recomputed, never cached as a flag. A snooze expires by the clock passing and nothing runs at that moment, so a stored boolean leaves a conversation muted past its own deadline. ⚠️ Same privacy rule as message notifications: a count, never a name. Who you are waiting on is as sensitive as what they said. engine 163, client 96, app 35. 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> |
||
|
|
c9e386ec0d |
Consume control kinds this build has never heard of (§6.10)
`isRenderable` asked `kind !in Kind.CONTROL` — an allowlist of the four control kinds that existed when it was written. A control kind from a newer peer is in no set here, so it fell through and rendered as an "[unsupported attachment]" bubble: a message the user can neither read nor act on, for something addressed to a layer rather than a person. Every control kind added from here would have shipped that to every older install. `ctl: 1` is the flag that stops it; `isControl` trusts the flag as well as the list, since the four that predate it do not carry it and never will. ⚠️ `needsNewerClient` is deliberately NOT part of `isRenderable`. Unknown *content* already parses to a placeholder and is shown, which is right — the first cut of this change suppressed it and an existing test caught that immediately. Dropping a real message silently is the one failure a messenger cannot make, so the flag decides rather than the unknown-ness. engine 144, client 90, app 32. 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> |
||
|
|
cbe2292d3c |
Audit H3: the safety number covers the post-quantum identity too
Mirror of iOS, byte-identical by construction: both keys folded under one label when both sides have a PQ identity, classical pairs unchanged, and the exact digits pinned against an independent implementation so the two platforms cannot drift into showing different numbers. 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> |
||
|
|
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> |
||
|
|
1799ada15b |
Delete individual messages via long-press; leave-group already shipped
Long-press any bubble for Copy and Delete for me (Unsend on your own), in 1:1 and group threads. Vault.deleteLine rewrites the thread minus the line, keyed by timestamp+side+text — messages carry no stable id here. Leaving a group was already wired. 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> |
||
|
|
6a0f642e0d |
D4: history the user can carry — encrypted export and import
Same design as iOS: the vault's history re-sealed under a passphrase- derived key (PBKDF2 600k rounds, AES-256-GCM), saved through SAF, merged back by content identity so the same backup twice adds nothing. Expired stories stay expired — a backup is not a way back in. The passphrase is stored nowhere, deliberately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
cf3f43fdbf |
video calls: capture/render/signal + video buttons; story card sizing; Alerts off the nav; avatar live refresh; sealed-key eviction
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> |
||
|
|
780dc50652 |
Story audiences (Android): all contacts, only chosen, or all except
Three modes, stored in the vault, applied at post time. 🔴 The audience is enforced by who is SENT the bytes, not by a flag. A story is sealed to each recipient's devices through the ratchet, so somebody outside the list is never sent it - there is no copy on the relay for them to be denied. Every mainstream product holds one server-side copy and decides who is shown it; this cannot work that way even if somebody wanted it to, and that is the feature rather than a limitation. Which also answers "make sure stories are only visible to friends": they already were, structurally. There is no public audience and there cannot be one - a story has to be encrypted to somebody in particular. ⚠️ The saved list is intersected with the live contact list in every branch, including "only chosen". An id left behind after that person was removed as a contact must not resurrect them as a recipient. The screen says what will actually happen - "your next story goes to 3 contacts" - resolved the same way, and refuses a post that would reach nobody rather than silently succeeding at nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3b195c730c |
Teach both engines the sticker wire
A sticker is a content envelope carrying an id, never an image: sending the picture would cost an upload and a download of bytes both ends already ship with, on every sticker. This is about sixty bytes. Recognised before the text path on both sides, because a sticker is renderable but is NOT text - it draws as a glyph with no bubble, and falling through would render it as an empty message. An unknown pack or id has to degrade to the pack's fallback rather than break, which is what the envelope format exists for. The pack is one JSON manifest read by both clients, so there is a single source of truth for what exists. docs/STICKERS.md says plainly what the first cut is: the glyphs are Unicode, because real sticker art is a design job, not a code one. The protocol, picker, rendering, fallback and tests are real and finished; swapping in artwork is a change to the manifest plus an image loader, not to any of that. It is not a claim to have drawn a sticker set. Emoji are deliberately NOT this. They go into the text of an ordinary message and are indistinguishable from any other character once sent - no new kind, no new field, nothing for the receiver to understand. That picker is a keyboard convenience, not a protocol feature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8ba0020785 |
Add a Calls tab, and the call log it needed
The kit's bottom bar is four icons and one of them is a phone; its "08 Calls-Listing" is a real screen. This app had a live call UI, a missed-call notification from the relay, and no history at all - so "who rang me yesterday" had no answer anywhere. So the log had to exist first. It lives in the vault with everything else: who somebody called and when is precisely the metadata this app exists to keep off a server, and encrypting the words while leaving the call log in the clear would be an odd place to stop. Bounded at 500, because the whole log is re-sealed on every write. Filed once, when a call ends. WebRTC walks DIALLING -> RINGING -> CONNECTING -> ACTIVE on the way, and recording each transition would turn one call into five rows; ENDED is usually followed by IDLE, hence the guard. Whether it ever connected is captured BEFORE the manager is torn down - asking afterwards always says no, which would file every answered call as missed. The row is measured from item/call/White: 48dp avatar, 16pt name, a 12dp direction icon before a 12pt line. Missed calls carry the kit's pink so the row somebody is actually looking for is the one that stands out, and the tab badges missed incoming calls only - an answered call is not news. No video icon, though the kit's row has one at x=255: there is no video path in either CallManager. relativeTime is shared with the chat list rather than reimplemented - two screens formatting times differently is drift nobody files a bug about and everybody notices. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
94337cbc07 |
Hold an undelivered message and send it when they come back
Asked: how does the sender know to try again when the other person has no registered device - especially since they are friends. They should not have to. Marking it undelivered and stopping there moves the problem onto the person who did nothing wrong: they would have to remember, notice their friend was back, and retype it. So the message goes into an outbox and is retried on every poll tick. Nothing expires and nothing is discarded - a contact who reinstalls in a month gets it in a month. Timing out would mean deleting something somebody wrote in order to tidy up a label. The queue has to live on the sender's phone, and that is forced by the encryption rather than chosen: every envelope is sealed to a particular device's keys, so somebody with no device has no keys to seal to, and the relay could only queue plaintext. The consequence is that it goes out the next time THIS app runs and polls, not the instant the recipient signs in - so the label says "Undelivered - will send" rather than implying it is in flight. Delivery is now a tri-state. PENDING is quiet and grey because it is the normal thing that happens when a friend is signed out and there is nothing to do about it; only FAILED is a warning, and the outbox never produces one. OutboxTest covers it end to end, including delivery against a real second identity rather than a hand-written bundle: queued while away, sent when they register, and the bubble restated - a version that delivered the message but left it reading "undelivered" would pass a test that only checked the queue. Engine, client and app suites green. 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> |
||
|
|
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 |
||
|
|
02e5c45cd4 |
A test that could actually have caught it
`a retired session is kept and still opens its chain` passed while the fix was broken, because it retires a PRISTINE session — which is not what the receive path holds. By the time that path decides to retire anything it has just proved the peer is on another chain by handing this session an envelope it could not open, and E2ESession is a class, so that attempt consumed skipped keys and stepped the chain. The new test reproduces that state and asserts both halves: the damaged instance must NOT still open its chain (or the test proves nothing), and the copy re-read from the vault must. It fails against the first version of the fix and passes against this one. 13 VaultTest cases, 112 engine tests, 0 failures. Both platforms compile. 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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
824e822acf |
Merge main: relay hotfixes and the docs pages
Conflicts were comment wording only, from the WordPress purge. Kept this
branch's prose: main's side carries the rename's artifacts ('the the
previous platform'), and the code either side registers is identical.
Verified after resolving — one api index, no wp-json index, the
federation refusal path, HomeRelays, and FN_ACCOUNTS_URL all present.
|
||
|
|
f2d74cff0c |
Android: groups, and the wire shape iOS got wrong
Groups were 100% broken on iOS -- not partly, entirely -- because the client
required a `name` on each member that the relay has never sent. Every group
decode failed, so the feature looked implemented and did nothing.
So this one was built by reading the relay's wire types first and asserting
them against live bytes second. `Member` is `{user_id}` and only that: the relay
has no user table and deliberately will not grow one, because a relay caching
display names is a relay that leaks them. Names are resolved client-side from
the contact list, and a member you have never added shows as an id, which is
honest.
Create, list, open, add and remove members, leave, and send -- all verified
against relay1, including that a group message carries its routing so it files
under the group rather than under whoever sent it.
Two things worth stating plainly:
The relay does no fan-out. A group is a membership list it keeps so clients
agree who is in it; the encryption is still pairwise, one ratchet per recipient
device. An n-person group is n sends. That is the honest cost of the relay not
being able to read anything, and the client is what knows it.
Thread history is now keyed by an opaque thread id -- `user:<id>` or
`group:<gid>` -- rather than by user. Filing a group message under its sender
scatters one conversation across as many threads as it has participants.
Leaving a group also clears its local thread. Keeping it would leave a
conversation nobody can add to and no way to remove it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e65d1d987b |
Android: photos, and a door for every capability
Owner feedback, and it was fair: capabilities kept landing in `:client` with a live test proving them and no way for a person to use them. Remove a contact. Block someone. Clear a thread. Change a password. Read a notification. All built, none reachable. So this is the CRUD pass, plus attachments. New in the UI: contact actions (block, unblock, clear chat, remove), a presence picker, a notifications screen with per-item and mark-all-read, an account screen with display name, password change and account deletion, password reset on the sign-in screen, bottom navigation, and photos -- sent through the system picker, rendered inline. `CrudSurfaceTest` walks the app and asserts the controls exist. It checks reachability rather than behaviour, because behaviour is what the live tests cover and the door is what was missing. It found two real gaps immediately: sign-out was reachable only from the contact list rather than from Account, where people look for it, and several controls sat below the fold. Underneath: the §6.6-§6.9 content envelope, which is one codec for attachments, replies, reactions, group routing and call signalling. 17 unit tests. Two things it has to get right, and both are one-line mistakes: `fnc` must serialise FIRST, because the marker is a prefix test on the bytes. A JSON library that sorts keys puts `att` first, every envelope it produces reads as plain text on the far side, and somebody's chat fills with raw JSON. The top level is assembled by hand. Control messages travel down the same channel as chat -- the web client sends `delivery_key`, `kt_head` and `sent` copies this way. A client that renders every envelope it does not recognise shows those to the user. They are now parsed, marked unrenderable, and handed to the caller with their payload. Attachments verified end to end against a real relay, including that the relay holds only ciphertext. That path was 401 on iOS for months because the attachment routes sent the account password, which the relay refuses on `/e2e/…`; this one uses the device-scoped token like everything else, and the test is what says so. Also: the instrumented suite passed one test at a time and failed as a set. The Compose rule gives each test a fresh Activity but the process and vault survive, so the app spends its first moments reading the vault behind a spinner -- and a reset that checks for a signed-in control immediately concludes "already signed out" while the session restores underneath it. 114 JVM tests, 37 cross-engine, 15 on-device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d30a77dd1e |
Android: contacts, conversations, and a message that made the round trip
The app now holds a conversation. A message typed into it reaches a different
program through the real relay, that program answers, and the answer appears in
the thread -- verified three times, end to end, encrypted the whole way.
96 JVM tests, 37 cross-engine, 11 on-device, plus live round trips against the
throwaway fleet.
What landed: a fan-out send that reaches every device a person has, inbox
polling, message history encrypted at rest, contacts, presence, and a session
that survives a restart. Plus `:interop:peer` -- a Flamenet account you can
drive from a shell, so the correspondent on the other end is not this app
talking to itself.
Three wire shapes were wrong, and only a real relay could say so:
- `prekeys`, not `opks`. The relay answered 200 and stored nothing, so the
device was published and could not be started with.
- The send body names the recipient once at the top, not per envelope.
- The buddy list is `id`/`presence`/`screen_name`, not `user_id`/`status`.
The second and third at least failed loudly. The first did not, which is why
the live tests exist.
Decisions worth stating:
A send returns partial success rather than throwing. One of someone's three
devices being unreachable must not stop the message reaching the other two, and
the UI says "delivered to 2 of 3" rather than "sent".
The inbox watermark advances per message, after it is decrypted AND stored --
never once for the batch, which would lose everything after a crash. An
undecryptable message advances it too: it is never going to decrypt, and
leaving it would re-fail on every poll for ever.
A sent line is recorded only once a device took it. Writing it first produces a
thread showing messages the recipient never got, which is worse than an error --
the sender believes they said something.
Opening a thread marks it read locally and tells the relay nothing. A read
receipt is a disclosure, and this product does not make one by default.
History is keyed by person, not device. Someone's phone and tablet are one
conversation, and filing by device would split it into threads each holding
half the story.
Also improves how instrumented failures report: the assertion now prints what
the screen was showing. That turned a bare 90-second timeout into "No value for
user_id" and then "Failed to connect to relay1" -- one a bug, one the emulator's
network, and previously indistinguishable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
5f983c1455 |
Android: the app, and the two things only a phone could tell us
There is now a Flamenet Messenger app that installs, launches, creates an account on a self-hosted relay, registers a device, publishes prekeys, and stores all of it in an Android Keystore-backed vault. Verified on an emulator through the actual screens, not by unit test. 92 JVM tests, 37 cross-engine, 9 on-device, 3 against live relays. Two findings, both of which the JVM suite was structurally incapable of producing. **Android has no ordinary Ed25519 provider.** `KeyPairGenerator.Ed25519` and `KeyFactory.Ed25519` come from `AndroidKeyStore` and from nowhere else -- probed on API 35 rather than assumed -- and Keystore only makes keys it keeps. Every Ed25519 key in this engine is one we must hold: a device identity in our own vault, an account key derived from a passphrase. So the JCA route could never have worked. It compiled, passed 81 tests on a laptop, and threw "Not initialized" on the first launch on a phone. Worse, it was LATENT: a wrong password failed before any key was minted, so the error path worked perfectly while the happy path was broken. Ed25519 now uses BouncyCastle's `math.ec.rfc8032` core directly -- the engine's one real dependency, present because the platform leaves a hole, and a strictly better answer than the non-constant-time keygen it replaces. ProviderProbeTest records the measurement so nobody guesses again. **The prekey field is `prekeys`, not `opks`.** I guessed. The relay accepted the registration, returned 200, and stored nothing -- so the device was published and could not be started with, a failure with no error anywhere. Caught by a live test against the throwaway fleet, which exists because the same path through the emulator takes four minutes to say "timed out" and nothing about where. Two testing traps, recorded where they bite: An indeterminate progress spinner animates for ever, so Compose's test clock never goes idle and `waitUntil` never evaluates its condition -- it fails on the full timeout, reading as "sign-up is broken" when sign-up works. Auto-advance is now disabled around the wait only; disabling it for the whole test is the next mistake, because the app opens on a spinner while it reads the vault. Wiping the Keystore in `@Before` deletes the key the already-launched activity is holding, and every later write fails against a key that no longer exists. The test signs out through the UI instead, which is both safe and what a person does. Failure messages now include what the screen was showing. A bare timeout sends you to the network, the relay and the crypto in turn while the app has been displaying the reason the whole time. Also corrects a note that said this Mac had no Android SDK. It does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8a3770f1d1 |
Kotlin: the session layer, verified across the wire
An app does not use X3DH and a ratchet, it uses "send this string to that person". This is that layer: device identities with prekeys and rotation, envelopes as the relay actually carries them, and sessions that survive being put away and taken out again. 11 unit tests, and the interop harness grows from 23 assertions to 37 -- the new ones at the envelope level, which is where this protocol's one shipped bug lived. A client and a relay disagreed about a field name and nothing caught it until a live round trip; hand-assembled arguments to X3DH would not have. Kotlin now builds a whole envelope, the web engine's own session layer parses it, and both directions agree on the associated data. Three decisions worth stating. Retired signed prekeys are kept for longer than the relay keeps undelivered mail. A sender fetches a bundle, encrypts against that prekey id, and the envelope can then sit in a queue for the relay's full retention window. Discarding the private half at rotation turns every one of those into an AEAD failure -- which the receiving client cannot distinguish from an attack. A prekey retired past retention is refused outright rather than guessed at, for the same reason: guessing derives a garbage secret and reports it as tampering. The handshake block rides every message until one is answered, not until one is sent. Any message may be the first to arrive. Sessions archive with their skipped message keys, and the harness proves it against the peer rather than against our own bytes. Those keys are the messages already in flight; an archive that drops them loses exactly the mail that was in the air when the app was killed. The failure this whole path exists to prevent is a client that loses session state, re-registers a device, and shows every contact "security keys changed" -- the warning that is supposed to mean an attack. `org.json` is compileOnly: it is in the Android framework, so it costs nothing on a phone and bundling a copy would shadow the platform's. Safe only because the two encodings that must match byte-for-byte -- the ratchet header and the canonical log leaf -- are built by hand; Android's implementation preserves key order and the reference one does not. Also adds PARITY.md: what the iOS app does, what Android does, what is left. It is the definition of done, and a row moves only when something tests it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fc93df24e6 |
Kotlin: Ed25519, and cross-engine interop against the web engine
The seventeen Kotlin tests that landed with X3DH and the ratchet prove the engine agrees with itself. That is not the property anyone needs. Two engines written by the same person agree about the same misreadings of the spec, and every one of those shows up months later as "the message will not decrypt" on someone else's phone. So this wires the Kotlin engine up against the web engine, which is normative, in both directions. 23 assertions, all passing: v1 X3DH both ways, identical associated data, alternating ratchet turns past the handshake, out-of-order and permanently lost messages, a drained one-time prekey pool, tamper rejection, safety numbers, and the canonical transparency-log leaf. Three things worth recording. Ed25519 was missing, and the engine could derive an account address but never sign a claim for one. The JCA offers no route from a private EdDSA key to its public half, so the RFC 8032 keygen is done here -- and verified against the RFC 8032 test vectors, not against a round-trip through our own code. A round-trip proves only that the file agrees with itself, and every wrong clamp, endianness and sign convention agrees with itself perfectly. The scalar multiplication uses BigInteger and is therefore not constant-time; it runs once at sign-in, in the user's own process, and signing deliberately stays with the platform provider. The harness reports a build failure as a failure, never a skip. The Swift harness beside it once printed "skipped" -- which reads as green -- while silently building a different checkout. Only a missing JDK skips here, and the resolved path is printed either way. Each section is wrapped so a divergence is a named failure rather than a crash. That was not theoretical: breaking the Kotlin message-key derivation on purpose crashed the process with a stack trace and skipped every later section. It now reports four failing sections and finishes the run. The negative control is how this was checked at all -- a suite that passes on its first run and has never been seen to fail is not evidence of anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e0589eff14 |
Kotlin: the Double Ratchet
The second half of a protocol Android did not have. With X3DH it can now agree a secret, and with this it can hold a conversation. Two ratchets: the symmetric one advances per message so a compromised key reveals nothing earlier, the DH one advances on each direction change so a conversation recovers from compromise rather than staying broken. The tests run conversations rather than checking steps, because that is what catches the bugs this code actually has. X3DH is a pure function — it agrees or it does not. A ratchet can agree for six messages and diverge on the seventh because one side counted a skipped message differently, and the symptom is only that something will not decrypt. So: out-of-order delivery, a message lost forever, a gap ACROSS a direction change (which is the whole reason "pn" is on the wire), and four DH turns in sequence. Three things that would each produce plausible-but-wrong bytes, so each is tested rather than reasoned about: - the sending chain advances BEFORE the key is used, so a caller that retries cannot encrypt twice under one key — two GCM encryptions under one key and nonce leak the XOR of both plaintexts - the header's canonical JSON is hand-built, key order "dh, pn, n", no spaces, because it goes into the AAD and three implementations must reproduce it byte-for-byte - the derived nonce is stripped from the wire rather than sent, so a receiver cannot be handed a nonce that disagrees with the one its key implies MAX_SKIP caps derivation at 1000: a header claiming a huge counter costs the sender nothing and the receiver everything. 13 tests. Still missing before an app: a session layer, a wire client, and the app itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c893d412d2 |
Kotlin: X3DH, and the account KDF beneath it
The Android engine had primitives and no protocol: no handshake, no ratchet, no session. It could verify a safety number and could not start a conversation. X3DH is the first half of fixing that. The four agreements are mixed in the order the spec fixes, and DH4 is omitted rather than zero-filled when the one-time prekey pool is drained. Both are choices that produce 32 plausible bytes when wrong — the failure lands at the far end as a message that will not decrypt, pointing nowhere near here, so the tests check that the two sides AGREE rather than that either looks right. Keys are JCA objects rather than raw bytes throughout, because Conscrypt will not build an XDH key from raw bytes and wants a DER spec. Passing raw bytes would work on this JVM and throw on the first tap on a phone, which this engine has already been bitten by once. Also here: AccountKdf, so Android can derive an account key and an address. Checked against the vectors pinned from the enclave client — including the non-ASCII and empty-passphrase cases, where a CharArray API and a UTF-8 wire format quietly disagree. ⚠️ Still missing before an Android app can send a message: the Double Ratchet, a session layer, a wire client, and the app itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b7021e9a73 |
Merge remote-tracking branch 'origin/main' into feat/android-account-kdf
# Conflicts: # Sources/Views/SignInView.swift |
||
|
|
fdd1ab7ee7 |
Multi-homing: keep a mailbox at more than one relay
Federation let somebody at another relay write to you. It did not stop your relay being the only place your mail lives — so relay1 going away took the undelivered messages with it, and relay1 refusing to serve you locked you out of your own inbox. That is the gap people actually mean by decentralised, and it is what this closes. An account registers the same devices, with the same identity keys, at several relays and publishes a signed record naming them. A sender fans out to every one. Any single relay can then be unreachable, hostile or gone and the message still arrives, because it was never only there. The record is signed by the ACCOUNT key, not asserted by a relay. A relay that could edit the list would drop the others from it and become the sole delivery target, which is precisely the position multi-homing exists to escape. A sending relay verifies against the key the recipient's address decodes to — the address IS the key — so it never has to trust what the relay it asked handed back. The serial closes the half a signature does not. An OLD list is genuinely signed: a relay could serve one from before a second home was added and it would verify perfectly. Records may only move forward, and the test proves the real attack rather than a forgery — publish serial 2, then replay the still validly signed serial 1, and get a 409. Envelopes carry a client-chosen id, identical in every copy, so a recipient polling two relays sees one message rather than two. Nullable and not unique: an envelope from an older client has none and must still be delivered, and two rows legitimately share an id when one message goes to two devices. `maxHomes` is 4 because a sender fans out to every entry, and a signed record naming a thousand hosts would turn any sender's relay into an amplifier aimed wherever the signer likes — with a signature making it look legitimate. Verified on relay1/2/3.flamenet.io: 12 checks. Alice names only relay2, her relay discovers relay3 from the signed record, and Bob reads the message from either. Local suite 255. ⚠️ Delivery to an unreachable home is reported and NOT retried. A home that is down when you send simply misses that message; closing that needs a queue and this does not have one. ⚠️ Neither app calls any of this yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6f83248e1c |
Give an account an identity no server issued
A relay's `user_id` is a row number it assigned itself, and the account service's pseudonym is HMAC'd per product precisely so two services cannot correlate their users. Neither survives a trip to a second relay, so nothing addressed by either can cross a relay boundary — which is the first thing federation needs, and the reason a deleted relay currently takes its users' names with it. So identity becomes a key the client derives and holds: Ed25519, stretched from the passphrase under `flamenet/account/identity/v1`, alongside the per-product keys that already work this way. The public half, Crockford base32, is the address. Derived rather than generated, so the same passphrase reproduces the same fnid on a device that never held it — re-register the same key at a new relay and contacts who pinned it are still talking to the same person. Relay: `users.account_key` / `users.fnid` (unique in the database, not just in the handler), `POST /e2e/account/identity` to claim by proving possession, and `GET /e2e/account/identity/:handle` to resolve. The claim signature binds the audience, the local user id and the key itself; dropping any one of the three lets a claim be replayed at another relay, lifted onto another account here, or transplanted onto another key. Verification uses the very key being registered, so the relay confirms possession and never acquires an opinion about whose key it is. Rotation is refused rather than silently overwritten — a new address strands every contact that pinned the old one, and that needs a migration record they can follow. Log leaves go v1 -> v2, binding a subject instead of a row number: `fnid:<b32>` where an account has enrolled, `local:<audience>/<user_id>` where it has not. The fallback is qualified rather than bare because a device can be registered before its key is claimed, and the alternatives were worse in both directions — skipping those devices leaves a hole in the one record that must not have holes, and logging them unqualified produces leaves that mean different things depending on origin with nothing in the leaf to say so. This is not a server-side change. All three engines hash the leaf and two of them verify it against the relay, and a published vector pins the exact bytes, so the format moves in one commit or not at all. Existing v1 leaves keep their hashes and are backfilled to a self-describing `legacy-v1/` subject: the log is append-only, and a format change is not a licence to rewrite history. The version prefix sits inside the hashed bytes so a v1 leaf recomputed under v2 rules fails rather than verifying against a different subject. Green: engine conformance 80, relay §8 suite 235 (10 new), JS<->Swift interop 74, Kotlin published vectors. Two things found on the way, neither addressed here: - iOS never calls `enforceKeyLog`. It and `requireKeyLog` are both declared in E2EInbox and nothing reads either, so §19 is dead code on that client while the JS one wires it. The subject is threaded through ready for a caller. - `engine/test/interop/build.sh` prefers ~/Code/flamenetmessenger over the checkout it is run from, so in a worktree it silently builds the wrong sources and reports a *skip* rather than a failure. Set FNMSG_APP_DIR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
85f6a29953 |
Kotlin: X25519, with the small-order check that is not optional here
Raw 32-byte keys on the wire, X.509 SubjectPublicKeyInfo in the JCA -- so every crossing needs the 12-byte DER prefix added or stripped. Written out as a constant rather than parsed: a DER parser here would be more code than the thing it parses, and one more place to get a length wrong. Refuses an all-zero shared secret. X25519 produces all zeros for a small-order peer key, and a peer that sends one forces both sides to a secret it already knows. RFC 7748 says implementations MAY check; when the peer's key arrives over a relay nobody trusts, MAY is not the right reading. All four small-order points from §6.1 are tested. ⚠️ XDH is API 33+ on Android. Older devices throw NoSuchAlgorithmException at the first call, and the app must either bundle a provider or refuse to run -- a decision nobody has made, so it is documented rather than papered over. ⚠️ These run on the JVM against SunEC. Android routes the same calls through Conscrypt. Passing here says the encoding is right and nothing about a device. Kotlin engine: 27 tests -> 33. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bff7a4bbdd |
Kotlin: HKDF-SHA256 and AES-256-GCM, against external vectors
Checked against RFC 5869 and NIST CAVP rather than against our JavaScript engine. An implementation validated only against our other implementation inherits our misunderstandings, and finding where the first two quietly agreed on something the spec does not say is the entire reason for writing a third. Three things pinned that a naive version gets wrong: - An empty HKDF salt means HashLen zeros, not "no HMAC key". SecretKeySpec throws on a zero-LENGTH key, so the naive version crashes on exactly the case the app uses -- §12 derives its per-context secrets with an empty salt. - 255 blocks is a hard ceiling. The counter is one byte; a naive loop wraps at 256 and silently returns key material it already returned. - The AEAD box is nonce || ciphertext || tag. CryptoKit's `combined` emits that layout and the JS engine assembles it by hand to match, so a box built any other way decrypts to garbage on the other engines rather than failing loudly. HKDF is hand-written on Mac rather than taken from a provider: Android's providers disagree about whether HKDF exists at all. Kotlin engine: 9 tests -> 27. ⚠️ Still JVM-only. These prove nothing about Android, where crypto goes through Conscrypt rather than the JVM's JCA, and nothing here has ever been compiled against the Android SDK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e7862c0d95 |
Point the site and the harnesses at the four repositories
The relay and the Kotlin engine are published on their own now, so the self-hosting guide told people to clone a directory that no longer exists and the source page said everything was in one repository. Both pages list all four now, with what each is for. The self-hosting guide still only needs the first one -- the rest are there so that self-hostable is something a reader can check rather than something they are told. The Android vector loader hardcoded ../../engine/test/vectors, which is only right inside this repository. It resolves from a candidate list now, honours FLAMENET_VECTORS, and still FAILS rather than skips when it finds nothing: the entire value of a third engine is that it is checked against something other than a reading of our own code. Nine tests pass against the JDK in Homebrew, which is not on the default java_home path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
06b97ca69b |
Start Android with the engine, held to the published vectors
Android does not exist, and the first thing it needs is not a screen. It needs the protocol implemented a third time — which is only worth anything if the third implementation is checked against the spec and the published vectors rather than against a reading of our own Swift. So `android/engine` is a plain Kotlin/JVM library with no Android in it: no SDK, no emulator, no device, and therefore gateable in CI on Linux like everything else. It reads engine/test/vectors/protocol-v1.json from its one location rather than carrying a copy, because a vector transcribed into the test that checks it is a vector that agrees with itself. Covered so far: safety numbers (§7) and key transparency verification (§19) — RFC 6962 leaf and node hashing, the unbalanced split, and bottom-up audit paths. Nine tests, all green against the same file the JavaScript and Swift engines answer to. The §19 tests are the ones worth having. A top-down reading of a bottom-up audit path yields the correct root for every left-spine index, so it passes any suite that only checks index 0 — the vectors pin a right-descending index, and the tests assert both that the published path verifies and that the same path reversed does not. android/README.md lists what is missing — X25519, X3DH, the ratchet, sealed sender, both post-quantum halves, the relay client, the app — as a table rather than as an omission. Android stays honestly marked "not started" on the site until that list is shorter. Bytecode targets 17 without demanding a JDK 17 be installed: a jvmToolchain(17) fails on any newer JDK, which is a poor trade for a library with no JDK 17-specific behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |