5a648f33b5ea46eeffecd1148b86c4cfc6815ba8
106
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6cd7a0d5dc |
A settings screen for the bridge line, and a path for it to reach
Two gaps, and the second was the bigger one. obfs4 was reachable from nothing. The socket-factory seam landed in !77, but every client in the app takes `defaultHttpClient()`'s null default — `RelayClient`, `MessengerApi`, `AccountsClient`, `RelayAccounts`, `SignupProofOfWork`, `RelayDiscovery` and the background service, ten sites in all, several from default parameters evaluated at field-initialisation time. The transport had been built, tested and proven against real lyrebird in both directions, and no user could switch it on. Threading a bridge through ten constructors would have worked until an eleventh was added, and the eleventh would connect directly, successfully, with nothing to show that it had. A missed site does not fail; it silently does not protect. So the factory is installed unconditionally and consults `Obfs4Routing` per socket. With no bridge it delegates to the platform's own factory, which is what OkHttp would have used anyway — nothing changes for the overwhelming majority who never set one. Pooled connections are the trap underneath that. OkHttp keeps a connection alive across requests, so one opened through the old bridge serves requests for minutes after the user switches — or after they turn a bridge OFF, which is the dangerous direction. The pool is shared so `Obfs4Routing.use` can empty it. The other gap is the one flagged when the wiring merged: a malformed line parses to nothing, nothing means no bridge, and no bridge is a direct connection. So `BridgeLine.parse` reports which part is wrong, and `Obfs4Settings` stores only what it accepted. Java's decoder made that worse here than on iOS. `fromCert` appended `==`, which THROWS for most truncation lengths — 20, 29, 30, 50 and 51 bytes among them — so a short paste came back as unreadable gibberish while iOS, whose decoder is padding-lenient, called the same paste "cut short". That is the likeliest real mistake by a distance: bridge lines wrap in every chat app and terminal, and copying only the first line is ordinary. Decoding the bare string works at every length and lets the size check give the honest answer. Both forms produce identical bytes for a real 52-byte cert, so nothing that worked before changes. The messages are the same sentences iOS shows, pinned by a test. The two apps are handed the same lines by the same operators, and one support answer should cover both. None of them says obfs4, base64 or node id — whoever reaches this screen was handed a line and told to paste it, and an error in the protocol's vocabulary reads as "you are not qualified to fix this". `iat-mode` 1 and 2 are a caveat, not a rejection: they ask the client to space its writes and the bridge does not enforce them, so such a line connects and works. Refusing it would take a working bridge away from somebody who has no other one. The status line reads a handshake counter, never the stored preference. A saved bridge that is not there leaves the app connecting directly, and only a completed handshake can tell you otherwise — so a bridge that has carried nothing says exactly that, and the words "protected", "secure", "hidden" and "safe" are asserted absent from it. Live interop re-run against real lyrebird after the factory change: both directions still pass. 448 unit tests green, 145 in the app, and reverting the unconditional factory reddens the routing test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dd412b8680 |
obfs4 for Android, part 7: the OkHttp seam, off by default
RelayClient.defaultHttpClient(bridge) now carries every request inside obfs4 when a bridge is configured. The parameter defaults to null, which is the shipping configuration: nothing changes for anyone who does not ask for it. A real HTTP request now travels through obfs4 end to end via OkHttp. That test is deliberately Kotlin on both ends, and says so: the wire format is already pinned to real lyrebird in both directions by InteropTest. What is unproven until here is the plumbing -- that the handshake happens before any stream is handed out, that bytes arriving alongside the handshake are not dropped, and that a frame decoding to nothing does not read as end-of-stream. Each of those loses data without producing a wrong byte anywhere. The handshake runs inside connect(), not on first write. OkHttp starts writing a request the moment it has a connected socket, so a deferred handshake would interleave with request bytes and desynchronise the frame decoder on the first request of every connection. Obfs4Socket ignores the endpoint OkHttp asks for. That endpoint is the RELAY's address; the socket goes to the bridge, which proxies on. Connecting to the endpoint would defeat the entire transport while appearing to work. All five SocketFactory overloads are implemented. Leaving any of them returning a plain socket means some path silently bypasses the transport -- and the traffic still works, which is exactly why nobody would notice. A wrong bridge key fails as a refusal rather than hanging. obfs4 bridges stay silent for anyone they cannot verify, so without the timeout this is an app that never finishes a request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ead31053a4 |
A live test for the file round trip, and four published passwords
`LiveFileTransferTest` covers the one seam nothing else does: a document leaving one vault, crossing a real relay, and arriving in another's with its label intact. `FileShareWireTest` proves the envelope's shape and `LocalVaultTest` proves the vault at both ends; neither puts a byte on a wire. ⚠️ **Written, compiles, UNRUN** — the same state `LiveSignUpFleetTest` is in, and the same caveat applies: an unrun test proves nothing. It creates accounts, so it needs a throwaway relay and an explicit decision to point it at one. 🔴 **Writing it surfaced four published passwords and a guard that could not see them.** `TestSafetyTest` exists to stop a credential being written into a suite that creates real accounts. It matched `val X = "…"` only. So: - `LiveConversationTest` passed the literal straight as an ARGUMENT, twice — invisible to the check. - `LiveSignUpTest`, `LiveKeyLogTest` and `LiveSealedSenderTest` wrote `password = "…"` on its own line inside a multi-line argument list, the most ordinary Kotlin formatting there is — also invisible. I copied the same literal into the new suite before noticing, which is how I found it. **A guard that passes while the thing it guards against is present in the tree is worse than no guard, because it is read as evidence.** All five call sites now read `LiveTarget.passphrase()` from the environment with no fallback, and announce a SKIP when it is unset. The check itself took two wrong turns worth recording: - Skipping every named argument, to avoid flagging `product = "messenger"` — which let the three `password = "…"` suites through. Trading one blind spot for another is not a fix; a named argument is now judged by its NAME. - Widening the sign-up match to a few lines around the call — which flagged forty test tags, assertion messages and message bodies as credentials. That is how a check stops being read, so it is same-line only, and rule 1 covers the multi-line case properly. "secret" was dropped from the credential names for the same reason: test prose is full of it, and a marker string called `secret` is a fixture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0a4709dd0e |
Fix: promoting a link to the vault appeared to do nothing
🔴 `Node.equals` compared only the id. It was written that way to dodge a real trap — a data class compares `ByteArray` by identity, so the generated equals calls two identical rows different — and dodging it broke a whole feature. Promoting a link keeps the node's id and changes its `storage`. So the new row compared EQUAL to the one it replaced, the state object holding it compared equal too, `StateFlow` conflated the update away, and Compose never saw it. On screen the file vanished from Linked and did not appear in the vault until the app was restarted. That reads as losing somebody's file. Every field is compared now, with `contentEquals` for the four byte arrays — the identity trap handled rather than avoided. ⚠️ **This suite could not have caught it, and the new tests say why.** Everything here reads the vault, and the vault was right the whole time: the index on disk had both rows, and a fresh load rendered both. The failure was in what the vault handed a state holder that conflates on equality. So the tests assert that property directly — a promoted row must not equal its old self, an unchanged row read twice must, and every mutable field must be visible in equality. Found by watching it on an emulator. Confirmed by restoring the old equals and watching both go red, then re-verified on device: the promoted row now appears immediately with no restart, and its size corrects from the provider's reported 4 kB to the real 14 kB plaintext length — which is itself proof the row genuinely updated. Also verified on device while here: a persistable read grant survives a reboot, a full cold boot and an app update (`persistable=0x1 persisted=0x1`), promoting drops the bookmark from the index, and the person's own file is left untouched where they put it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
436ce240c7 |
Fix: the vault index failed to load on a real phone
🔴 **Android's bundled `org.json` coerces a JSON `null` to the four-character string `"null"`. The Maven `org.json` the tests run against returns `""`.** Every nullable field in the vault index therefore passed on a laptop and broke on a device: - `parentID` threw `Invalid UUID string: null`, so a vault holding any top-level file failed to load AT ALL. Adding the first file worked, because it was still in memory; reopening the app lost everything, with an error nobody could act on. - `wrappedContentKey` was worse, because it did NOT throw. `"null"` is valid base64, so the node came back with a plausible three-byte content key and simply refused to decrypt, for ever, with nothing to explain it. - `bookmark` produced a reference pointing at `"null"`, permanently missing. Absence is now decided by `JSONObject.isNull` and never by comparing a decoded string to a sentinel. The literal `"null"` is rejected as well: it is not a valid value for any of these fields, and tolerating it costs nothing next to reintroducing this. ⚠️ **The two regression tests write the literal string `"null"` into the index on purpose.** That is exactly what the decoder is handed on a device, so it is what makes a JVM run reproduce the device's behaviour. Feeding a real JSON `null` — which is what the vault actually writes — passes with the broken code and proves nothing. Both were confirmed by restoring the old decoding and watching them go red. Found by running the app (android!52). No JVM test could have caught it, which is an argument for the device run rather than against the tests. ⚠️ `optString` on a nullable field is wrong everywhere in this app, not only here, and it fails only on the platform it ships to. Worth a sweep. Verified on the emulator afterwards: the vault survives a cold start, and on disk the blob begins `ENCL\x01`, both wrapped keys carry version 0x11, the name 0x01, the filename is not in the clear, and the plaintext is absent from the whole app container. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6ec626cde2 |
Share a file into a chat, and save one back — with the label it carries
Both directions: pick a document from the vault inside a conversation, and save a received one into the vault at the classification it arrived with. 🔴 **This is the answer to android#15, and the codebase had already given it.** `Attachment.FILE` has carried this since before the lattice existed: *the label is a MARKING, not a permission — the content key is in the same envelope, so whoever can read the message can read the file.* That settles the ★-property question. The key travels with the file by design, because that is what makes sending work at all; there is no trusted computing base to enforce against, since the person holds every key on a device they control. A client-side refusal would be a suggestion, and **a suggestion dressed as a control is worse than none** — it is the one people plan around. So neither client implements the ★-property, `LabelTest` keeps asserting that absence, and PARITY.md now says plainly that Files is not mandatory access control. What it IS: cryptographic read separation, plus a classification that travels. The picker says so before the tap, in those words. Three things found on the way: 🔴 **A second copy of the vocabulary.** `classificationName` in ChatScreens listed the same four names as `FileLevels`, agreeing by coincidence with nothing making them agree. Rename a level for the Files screen and the chat bubble keeps showing the old word for the same number, in the same conversation, on the same phone. Collapsed to one. 🔴 **Attach and Camera did the same thing.** Both launched the gallery, and there was no way to send a document at all. Attach now opens a small panel — photo, or from Files — which keeps the kit's composer row at its four controls. 🔴 **iOS's picker lists the root only**, so a file inside a folder cannot be sent from a conversation, and nothing says so — it is simply absent, which reads as "it isn't there". `LocalVault.allFiles()` walks the whole tree. Filed as ios#68. ⚠️ The picker is the ONE place this vault hides a row it cannot read. Everywhere else it insists on showing them, because hiding one leaves somebody unable to see anything is there; a picker exists only for choosing, and a row that can never be chosen is furniture. ⚠️ A received file is always saved as an encrypted copy, never a link — there is no file on this phone to point at — and always at the label it arrived with. Re-labelling somebody else's document on receipt would launder its classification, and the number is the only thing that survived the trip. 9 new tests. Each guard proved by breaking the code: writing the label for every attachment kind fails the image test; dropping compartments fails the two that name them; removing the fallback caption fails the older-build test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3a2c10f7bc |
The local Files vault in Kotlin
The index, folders, both ways of holding a file, and the delete semantics — everything iOS's `LocalVault` does, minus the screen. 🔴 One deliberate divergence, and it is a fix rather than a port. `descendants(of:)` on iOS bounds a cyclic index with `guardRail < 10_000`. That terminates and then returns ten thousand copies of the same two ids — and the screen calling it is the one offering "delete this folder and everything in it", so a corrupt index turns that into an offer to delete 10,000 things. Bounding by what has already been visited terminates AND returns the right answer, with no magic number. Filed as ios#66. The test that found it writes a real cycle into a real index. It matters because the index is a file on disk: the input is not only ever our own writes. ⚠️ The index file is device-local and is NOT an interchange format. The node FIELDS match iOS's exactly so a courier transfer stays a copy rather than a translation; the JSON around them does not. iOS encodes dates as Swift's reference-date doubles, which is an accident of `JSONEncoder` rather than a decision, and pinning Kotlin to it would promote an accident to a format. Anybody building vault migration should move nodes through a declared wire shape, not by copying this file. 🔴 `ReferenceResolver` is required with no default. A no-op default would make every reference row read as "missing" in an app that simply forgot to wire it up — a vault that looks like it has lost half its contents, with nothing anywhere saying why. Two tests worth naming: - **Nothing on disk contains the plaintext.** A round trip passes just as happily if `add` wrote the file out unencrypted and read it straight back, so the bytes are searched for across the whole container, and the index is checked for the filename. - **Deleting a reference row does not reach the person's storage at all.** Asserting only that their file survived would be a test that cannot fail: the delete path holds no call to remove it, and the protection is structural — the resolver has no way to delete anything. Asserting zero resolver calls is the property that constrains future code, and it was verified by routing the delete path through the resolver and watching it go red. 29 tests. Each guard proved by breaking the code: the label key salted with the level alone, a node key wrapped to its parent's label, and a bulk delete that ignores unselected survivors each fail exactly the test that names them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b8a440fc4 |
Read a balance and a transaction history from a chain node (Android)
`ChainRpc` asks a node what a public address holds and what has happened to it. The node is trusted with nothing: it never sees a key and cannot move money. What it CAN do is lie about a balance or withhold a history, so nothing here treats an answer as authoritative beyond "this is what that node said". 🔴 A JSON-RPC error arrives with HTTP 200. The transport succeeded; the node said no. A client that checks only the status code carries on with a body that has no `result`, fails somewhere less obvious, and throws away the node's own explanation on the way — the difference between "insufficient funds for rent exemption" on screen and a parse error nobody can act on. Both cases are tested, including an error carrying no message. 🔴 A FAILED transaction still appears in an address's history, and is shown as failed. Not doing that is the worst kind of wrong: somebody would believe a payment went through. `err` is an OBJECT for one class of failure and an ARRAY for another, so the check is "not null" rather than a shape — testing for an object alone would pass one whole class of failure off as a success. Both shapes tested. ⚠️ A body with neither result nor error is malformed, never a balance of zero. Reading a missing result as zero would show somebody an empty wallet that is not empty. ⚠️ Lamports as a Long throughout, and to the edge of the UI. Five SOL already exceeds Int.MAX_VALUE, and converting to a fractional unit early means carrying rounding through code that compares and subtracts — a rounding error in a balance is money. ⚠️ A transaction the node has not dated yet reports no time rather than 1970. It has the transaction and has not assigned a block time; "just now" is the truth and the epoch is not. ⚠️ Which node somebody points at is a privacy decision the app has to state: asking a public RPC endpoint for an address's balance ties that address to this device's IP in somebody else's logs. Running your own node is the only answer that removes it; the default exists so the wallet works at all, not because it is private. 15 tests, against a MockWebServer. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6e4eca84e3 |
Refuse a home record older than one already seen (§20.4, Android half)
Both remaining ⚪ protocol rows turned out to be stale. `AccountKdf.localAccountSalt` is pinned to the published vector and wired into sign-in for a local relay; `MailboxHoming` and `AccountIdentity.publishHomes` have shipped the publish and read halves for a while. PARITY.md was describing a gap that had closed, which is its own kind of wrong — a definition of done that overstates the work left is one people stop reading. 🔴 What was genuinely missing was on the READING side, and on BOTH clients. An older home record is GENUINELY SIGNED — the account holder published it, before they added a second relay. So a relay that wants to be somebody's only mailbox does not have to forge anything: it serves the record it saw first, that record verifies perfectly, and the sender delivers to one relay believing it is delivering to all of them. That is the attack multi-homing exists to survive, and the serial is what was added to stop it. The relay refuses a lower serial on WRITE. Nothing refused one on READ: `verifiedRelays` here and `verifyHomes` on iOS both checked the signature and stopped. Fixed on both — iOS in ios!116, this is the Kotlin half. The highest serial seen per address is remembered in the vault and a lower one is refused. Equal is fine; only strictly lower is a rollback. 🔴 The floor rises only for a record that VERIFIED, via a separate `verifiedSerial`. Otherwise a hostile relay could hand over an unsigned record carrying Int.MAX_VALUE, have the client raise its floor to it, and lock out every later genuine record — turning the rollback defence into a denial of service against the account holder. Tested. ⚠️ Sealed in the vault and keyed by a hash of the address: which addresses this device has looked up is exactly the metadata the vault exists to keep off a stolen phone. ⚠️ The refusal does not claim an attack. A relay that has genuinely fallen behind serves an old record for honest reasons, and nothing in a client can tell that from the other case. 8 unit tests. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ab8ff658b2 |
Pin the relay's identity, not just its TLS (ios#19, Android half)
🔴 This was not a missing feature on Android. It was a wrong conclusion. `Trust.verifyHeadSignature` already pinned `issuerPublicKey` the first time a signed log head arrived. When the key later changed, the head signature simply stopped verifying — and the only conclusion available at that point was `KeyLogForkException`. So a relay key rotation somebody performed on purpose was reported to the user as the relay showing two different histories, with `forkDetected` set. Those need different responses. A fork means nothing the relay says can be trusted and the client should stop. A rotation means somebody ran a command, or somebody else is answering on that address, and only a person can tell which. Reporting the second as the first is alarming and unactionable — and an alarm people cannot act on is one they learn to dismiss, which costs the real fork detection its credibility as well. `RelayIdentityPin` now decides what the published key MEANS before it is used, with the same four verdicts as iOS, and a change raises `RelayIdentityChangedException` — deliberately NOT a `KeyLogForkException`, and without setting `forkDetected`, because the log may be perfectly consistent. ⚠️ A relay that publishes no key is a weaker deployment, not an attack, and an unparseable key is broken rather than hostile. Both read as "not published" rather than as a change: refusing to talk to an older self-hosted build would be a worse failure than the one this prevents. ⚠️ The warning names both possibilities and asserts neither, because nothing in the client can tell a rotation from an impostor. Two things found while writing the tests. `RelayClient.host` already existed, so the one I added was a duplicate declaration — removed rather than renamed. And the compiler folded `changed !is KeyLogForkException` to a constant when the value was typed concretely, which is the compiler proving the separation statically and also an assertion testing nothing; it is now held as the sealed base, which is the check a caller's `when` actually performs. 9 unit tests. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f362db0ee7 |
Handle a wrong clock (ios#45, Android half)
🔴 A wrong device clock breaks a promise made to somebody else. A story disappearing after an hour is a promise the SENDER made, and it is enforced on the READER's device — so a reader whose clock runs behind keeps a message the sender was told would be gone, and nobody involved can tell. That asymmetry decides the design, and it is the whole of this change. Expiry takes the LATER of the device's clock and the relay's, never either alone. A device set back cannot extend a message's life, and a hostile relay claiming an earlier time cannot either, because the other source overrules it. The remaining failure — expiring early on a clock set forward — costs the reader something they were about to lose anyway. The skew is learned free, from the `Date` header every HTTP response already carries. Observed in `RelayClient.checked`, which is the one funnel every relay call passes through, and BEFORE the success check: a phone whose clock is wrong enough to be refused is exactly the phone that needs to be told why. Story and disappearing-message expiry on the app side now reads `DeviceClock.expirySeconds()` rather than `System.currentTimeMillis()`, at all seven sites. ⚠️ Android needs synchronisation iOS does not. The Swift version is `nonisolated(unsafe) static var` because its writers sit on one actor; here the observation is written from whichever OkHttp thread finished a call and read from the UI thread. The two fields are `@Volatile` and updated together under a lock — a torn read of "skew from now, timestamp from an hour ago" would age out an observation just made, or trust one that had not been. ⚠️ `SimpleDateFormat` is built per call rather than shared, because it is not thread-safe, with a fixed `Locale.US` and GMT: a formatter taking its locale from the device parses nothing on a phone set to most of the world, and the failure is silent — the header is simply never understood. 15 unit tests. Proven: replacing the rule with "trust the server" turns the hostile-relay test and the never-earlier test red. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
775b6e62d0 |
A test run must not publish a credential (ios#62, Android half)
The Kotlin counterpart of the iOS fix, and Android's version of the bug was quieter rather than absent. Android was already ahead on the part iOS got wrong: every live suite is gated behind `assumeTrue`, and `LiveTarget.refuseProduction` refuses a production host outright. What it still had was the credential itself. `LiveTwoProductsTest` and `LiveShapesTest` both carried `const val PASSPHRASE = "a-long-enough-passphrase"` and used it to create accounts on accounts.flamenet.io whenever somebody set FN_LIVE_MANAGED=1. 🔴 The gate stops it running by accident. It does not stop the password being written down. Every account those runs ever created can be signed into by anybody who can read this repository, and "they were throwaway accounts" does not change that — they are real accounts on a real service and they persist until somebody deletes them. Both now read it from the environment through `LiveTarget.passphrase()` with no fallback, and both gates assume on it being non-empty, so opting in without configuring one reports SKIPPED rather than signing up with a published value. And a guard, matching the Swift one: TestSafetyTest fails the build if a suite that reaches a live relay carries a credential literal, or cannot announce a skip. Proven by putting the literal back and watching it go red. ⚠️ The guard had to be narrowed twice before it was worth keeping, and both narrowings are recorded in it. Keying on the `Live` name prefix flagged LiveTarget.kt (the helper) and LiveTargetTest.kt (a unit test of the helper). Keying on the string "http" then flagged four suites that sign up against a MockWebServer — which is exactly the correct way to test sign-up. The discriminator is MockWebServer: a file that stands up a mock cannot reach anybody. It now matches the nine real live suites and nothing else, and a test asserts the predicate matches at least five, so the checks cannot pass by guarding an empty set. PARITY.md gains a Test safety section. Full JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
eff8cdb493 |
A 403 is not an expired session
Signing in on a fresh install said "Your session expired. Sign out and back
in to keep going." — advice that cannot work, because signing in again hits
the same rule and says the same thing. There is no way out of that loop from
inside the app.
RelayAccounts mapped 401 and 403 to the same Unauthorized. They are not the
same: 401 means the token was refused and signing in again IS the fix; 403
means a rule refused this — too many devices, a retired device, an
unverified address — and the relay writes a sentence naming which one and
what to do about it. RelayClient already made this distinction; RelayAccounts
did not, so which client a path happened to use decided whether its refusals
were legible.
The 403 sentence now reaches the screen instead of "That isn't allowed on
this server", which replaced an answer somebody could act on with one they
could not. Only when it is really a sentence: a JSON fragment or a bare code
keeps the generic line rather than putting a payload on screen.
Predates tonight's merges — checked against
|
||
|
|
3f0e36fffc |
Count the sealed-to-unsealed fallback, and offer to refuse it (ios#16)
🔴 **Off by default, and that is the honest default rather than the lazy one.** An unsealed send is still end-to-end encrypted; what leaks is that this account sent something to that one. Refusing turns a metadata leak into an undelivered message, which for most people is the worse outcome — so the default degrades and says so, and the refusal is there for the people who would rather a message did not go than go attributable. The counters answer a question nobody could ask before. `Sent.sealed` says whether one send was sealed; the record says how often this happens to you, which is what somebody actually needs to decide whether the switch is worth throwing. ⚠️ Refusing costs nothing: `sealed` is false exactly when neither a capability nor a key was found, so there is no token to hand back. ⚠️ Both the policy and the counters are wired in ONE helper called from both sign-in paths. Two call sites building a `Session` and only one applying the policy is how a setting silently stops working on one of them. Taken by hand rather than cherry-picked — the original also rewrote a ChatScreens that has since changed underneath it. The settings switch for it is UI work still to come. |
||
|
|
a0bc8e68d2 |
Report a story to whoever runs the server (§6.25)
The reporting path, taken by hand — the original commit also rewrote 318 lines of ChatScreens against a story feed that no longer looks like that. The UI control comes next; this is the part underneath it. 🔴 **A report is the one thing in this app that leaves readable.** The relay cannot open a sealed story, so a report about one is only actionable if the reporter hands over what they saw. That is the point and the cost, and it means the consent has to name the consequence — `reportStory` does not ask, and must never be reached from a control that does not. ⚠️ It sends only what it is passed, and never reaches into the thread for surrounding context. §6.25 forbids including anything the reporter did not choose to include. ⚠️ The reasons are enumerated to match the relay. A reason the relay rejects is a report somebody believed they had filed. The message afterwards says what will and will not happen: whoever runs the server sees it, and you are not told what they do. Promising an outcome we cannot observe would be worse than saying nothing. |
||
|
|
32e2f6b5df |
Post a story to a group (§6.24)
Taken by hand, like the two before it — the original was cut from a tree whose text stories and story replies differ from this branch's. 🔴 The group's roster is the audience, and the story audience does NOT apply. Those are two different lists making two different statements — who may see this account's stories, and who is in a conversation — and they will disagree. The roster wins, because a story addressed to a group that silently dropped members would be a group message part of the group cannot see. `groupStoryReach` returns that overlap rather than a warning. A sender who believes their story audience still applies is being misled by the interface, and a disclosure that cannot name the consequence is not one. The two categories deliberately do not double-count: somebody who is not a contact was never on that list to be excluded from. 🔴 On receipt, a claimed group id is checked against our own roster. A story is caught before the group-message branch, so without that check a sender could attach the id of a group they are not in and have their story shown as having come from it. The claim is dropped to null rather than the story refused — the story is legitimately theirs; only the claim about where it was posted is unverified. ⚠️ `gid` names the audience, never a destination. Rendering a group story into the group's messages would make a story into a message, and one that never expires out of it. |
||
|
|
40da124b27 |
Solve the sign-up puzzle when a relay asks for one
The relay can charge a few seconds of arithmetic before an account exists — cheap for one person, expensive for a farm, and unlike a CAPTCHA it hands nothing to a third party. This is the Android half, matching the iPhone app. Asked for only when the relay refuses without one, so a relay with the feature off — every relay running today — still sees exactly one request. Rethrown rather than swallowed when this app cannot answer: a difficulty above the cap leaves it genuinely unable to sign up here, and "update the app" is then the true sentence. Two refusals get their own sentences. challenge_required means the app is behind its server; challenge_failed is nearly always a challenge that expired while somebody was typing. Sharing one message would send half these people to the Play Store for nothing. Decided by the relay's code, never the status, because a 400 is also a bad email. The bit counting is pinned to exact vectors. A byte-counting implementation still SATISFIES a 9-bit difficulty — it overshoots to 16 and does 230x the work — so an assertion about "at least 9 leading zeros" cannot see the bug. Refs flamenet-relay#39 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
1d834f67fc |
Tell somebody their address needs confirming, and let them do it
Client half of flamenet-relay!13, which gates a relay token on a proved email address. 🔴 **Without this the refusal is indistinguishable from a broken password.** An unverified account arrives as a bare 403; `RelayAuth` logs "minting failed" and falls back to Basic, which the e2e routes refuse. So the person sees calls that do not work and an inbox that does nothing — and nothing anywhere says the remedy is a code sitting in their inbox. ⚠️ **The status alone is not enough.** 403 is also "that device belongs to another account". Telling somebody to check their email for that would send them looking for a message that was never sent, so the relay's own `code` decides, not the number. A 403 with no code, an unparseable body, and the same code on a 500 all stay generic — three tests hold that. `RelayAccounts.verifyEmail` spends the code. Unauthenticated on purpose, exactly like reset confirm: the code IS the proof, and requiring a session would mean somebody kept out *because* they are unverified could never get in. ⚠️ **Kotlin nests block comments**, so a slash-star written inside a KDoc — as part of a route glob — opens a comment that never closes and takes the module down with ninety unrelated errors, none of them near the real line. Cost twenty minutes here; there is now a note in the file. Five tests, proven able to fail: removing the code check turns three red. All three Android suites pass. |
||
|
|
8a1a51073e |
Read the relay's refusal, not just the HTTP status
🔴 **A 409 is not always a taken handle, and this said it was.** The relay answers 409 for a taken handle, an already-registered email, AND — since flamenet-relay!11 — a handle too close to an existing one. Three problems with three different fixes, all reported as "that handle is already taken". Somebody whose EMAIL was already registered was told to pick a different handle. Doing so cannot work, and nothing anywhere would have told them why. That is a pre-existing bug, not something the new refusals introduced; adding a third meaning to 409 is what made it visible. 400 was worse. Every format refusal — a short passphrase, an unusable email address, a disallowed handle — became "the account service returned 400", which is not a sentence anybody can act on. The status is the transport's opinion. The `code` in the body is the relay's, and it has always been sent; nothing here ever read it. ⚠️ **Unknown codes still refuse.** A relay newer than the app is the normal case, so an unrecognised code falls back to the status rather than to success — an account the server never created would be a much worse failure than a vague message. ⚠️ **An unparseable body cannot throw.** A proxy returning HTML on a 409 must still be a refusal, not a crash. Five tests, proven able to fail: collapsing 409 back to a single meaning turns four of them red. All three Android suites pass. Pairs with flamenet-relay!11 and flamenet-messenger-ios!45. |
||
|
|
26b4f20a5f |
Give every message an id, and reactions something to land on
Reactions, edits and pins have all sat in the codec with no way to reach them, and the reason was the same in each case: they say "this one", and an Android message had no id to point at. 1:1 text went out as the raw body — cheaper on the wire, and it cost all three features. Every message now carries one. The sent copy, the copy written into our own transcript, and the copy queued for retry all share it: three constructions that disagreed about the id would be three different messages, and anything said about one would land on nothing. Inbound messages keep the SENDER's id rather than one we invent, because that is what they will use when they refer back to it. Old lines keep no id and must not be given one on read. Two devices would invent different ids for the same message, and a reaction would land on nothing — or on the wrong thing. Those messages simply cannot be reacted to, and the emoji row is absent on them rather than inert. Reacting is one long-press and one tap: the five emoji are the menu's first row, not behind a "React" item that opens a second menu. One emoji per person, so tapping another replaces it and tapping the same one takes it back. Applied locally before the send, because waiting on a round trip to see your own tap register makes it feel broken — and a failed send restores what was there before, not "nothing", which would be wrong for somebody replacing or removing one. Reactions render under the bubble, not inside it. They are what other people said about the message, not part of what was written. 648 unit tests green across app, engine and client; 8 new ones pin the id and the one-emoji-per-person rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
2179615263 |
Stories you can answer, and an @ that finds people
The story features existed in the codec and nowhere a person could reach them. + went straight to the file chooser, so a story of words was unreachable even though the engine could carry one; there was no way to react, reply, mute, or see what anybody had said back. + is a menu now. Reactions are a fixed row of five — a story is glanced at and scrolled past, and anything that takes two taps to say "nice" does not get used. Your own story shows the tally instead, because you cannot react to yourself and a row of dead buttons is worse than none. Mentions get an `@` picker in the group composer, matching iOS down to the rule that matters: the id is recorded when somebody is PICKED, never re-derived from the text at send time. Deriving it would mean typing a name reached that person whether or not they were chosen, and renaming a contact would silently unmention them. Only the ones still named in the body are sent, so picking somebody and then deleting their name doesn't ring them about a message that no longer mentions them. A group member who isn't in your contacts has no name to show, so they appear under their id. Un-mentionable would be worse than ugly — it would mean a group where some people cannot be reached. Mentions on Android are carried and rendered but override nothing: there is no per-conversation notification mute here to override yet. 640 unit tests green across app, engine and client. PARITY.md updated — mentions and the story features move to ✅, and Files and Wallet get rows saying plainly that Android has none of either. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
aebf41ed95 |
Put the app back to the last state that worked
Tonight's merges left sign-in, messages, calls and stories broken. Restores
app, engine and client to
|
||
|
|
2f46263aff |
A 403 is not an expired session
Signing in on a fresh install said "Your session expired. Sign out and back
in to keep going." — advice that cannot work, because signing in again hits
the same rule and says the same thing. There is no way out of that loop from
inside the app.
RelayAccounts mapped 401 and 403 to the same Unauthorized. They are not the
same: 401 means the token was refused and signing in again IS the fix; 403
means a rule refused this — too many devices, a retired device, an
unverified address — and the relay writes a sentence naming which one and
what to do about it. RelayClient already made this distinction; RelayAccounts
did not, so which client a path happened to use decided whether its refusals
were legible.
The 403 sentence now reaches the screen instead of "That isn't allowed on
this server", which replaced an answer somebody could act on with one they
could not. Only when it is really a sentence: a JSON fragment or a bare code
keeps the generic line rather than putting a payload on screen.
Predates tonight's merges — checked against
|
||
|
|
4c3f30e180 |
Count the sealed-to-unsealed fallback, and offer to refuse it
Android half of ios#16. `Sent.sealed` already reported each send's outcome, but nothing accumulated, so nobody could answer "how often does this happen to me". 🔴 The copy distinguishes sender from content, with tests asserting it. An unsealed send is still end-to-end encrypted; what the relay learns is who sent it. Somebody who reads "sealed" as "encrypted" makes the wrong decision in both directions. ⚠️ Aggregate only. Per-contact counts would be more useful and would also build a record of who this account talks to and how often — the precise thing sealing exists to keep off a server. Refusing is offered and off by default: it turns a metadata leak into an undelivered message, which for most people is the worse outcome. It has its own exception type so the refusal can explain itself. ⚠️ Refusing spends nothing. `sealed` is false exactly when neither a capability nor a key was found, so `takePeerToken` returned null and there is no token to hand back — my first cut had a line handing one back, which was unreachable. ⚠️ The policy and the counter are injected as lambdas, since `client` and the engine have no Android dependency, and both are wired in one helper. Two sign-in paths build a session, and only one of them applying the policy is how a setting silently stops working on half the app. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
6fe463d578 |
Report or block from a story
Android half of ios#56, spec §6.25. Needs the relay endpoint from flamenet-relay!24. 🔴 A report is the one thing this app sends that the server can read. Everything else is sealed, and the relay cannot open a story, so a report about one is only actionable if the reporter hands over what they saw. The dialog states that before anything is sent: what leaves the device, who can read it, and that it cannot be undone. Declining to include a copy is offered, and says plainly there may then be nothing to act on. ⚠️ `reportStory` sends only what it is passed. It does not reach into the thread for surrounding context — §6.25 forbids including anything the reporter did not choose to include. ⚠️ Blocking and reporting sit side by side and are never merged. Blocking is this person's own decision and takes effect now; a report is a request to somebody else. The copy says which is which, because a combined control would make one of the two invisible. The reasons are enumerated to match the relay, and a test asserts the two lists are equal — a reason the relay rejects is a report somebody believed they had filed. CSAM is its own reason rather than folded into abuse: it carries a different obligation, and an operator cannot triage what nobody can name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b43be1b780 |
Post a story to a group
Android half of ios#55, spec §6.24. An ordinary story envelope carrying `gid`, fanned out to the members and recorded in the story set. 🔴 `Vault.groupStoryReach` returns the overlap rather than a warning: who is excluded by this account's story audience and who is not a contact at all, counted separately. The roster decides either way — those are two different lists making two different statements and they will disagree — so a disclosure that cannot name the consequence is not one. 🔴 The group label is adopted only from an actual member. The roster check that guards group messages runs after the story branch, so without this a sender could attach the id of a group they are not in and have their story shown as having come from it. Dropped to null rather than refusing the story: the story is legitimately theirs, and only the claim about where it was posted is unverified. The story branch already sat before group routing, which is what keeps a group story out of the conversation — filed there it would be a story that is a message, and one that never expires out of the thread. That ordering is now written down rather than incidental. ⚠️ `gid` is persisted, or a reload forgets which story went where. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
3b7e69c0eb |
The transcript records a message id and a reply reference
🔴 The wire always carried both and Session.Line never stored them. Content.groupText has taken a messageId since §6.9 shipped and parse reads it back — the transcript was where it stopped. So nothing here could name an earlier message: no replies to resolve, no reactions to fold, no edits to apply. All three were "codec done" and none worked. A reply from the iPhone app arrived as a bare message with the quote silently removed, which is worse than the missing id: the reader had no way to know anything had been dropped. 🔴 Also fixes a real bug in sendGroup. It built the envelope twice — once to send, once for the outbox — so an id minted inside either would differ between them, and a member who was offline would eventually receive a DIFFERENT message from everybody else, which nothing could reconcile with a reply or a reaction. One id, minted by the caller, used by both. Both fields written only when present: a null on every message would grow the transcript for nothing and make "no id" indistinguishable from "an id of nothing". Refs paulhitt/flamenet-messenger-ios#58, #10 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
df81800783 |
Solve the sign-up puzzle when a relay asks for one
The relay can charge a few seconds of arithmetic before an account exists — cheap for one person, expensive for a farm, and unlike a CAPTCHA it hands nothing to a third party. This is the Android half, matching the iPhone app. Asked for only when the relay refuses without one, so a relay with the feature off — every relay running today — still sees exactly one request. Rethrown rather than swallowed when this app cannot answer: a difficulty above the cap leaves it genuinely unable to sign up here, and "update the app" is then the true sentence. Two refusals get their own sentences. challenge_required means the app is behind its server; challenge_failed is nearly always a challenge that expired while somebody was typing. Sharing one message would send half these people to the Play Store for nothing. Decided by the relay's code, never the status, because a 400 is also a bad email. The bit counting is pinned to exact vectors. A byte-counting implementation still SATISFIES a 9-bit difficulty — it overshoots to 16 and does 230x the work — so an assertion about "at least 9 leading zeros" cannot see the bug. Refs flamenet-relay#39 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
90850aa654 |
Tell somebody their address needs confirming, and let them do it
Client half of flamenet-relay!13, which gates a relay token on a proved email address. 🔴 **Without this the refusal is indistinguishable from a broken password.** An unverified account arrives as a bare 403; `RelayAuth` logs "minting failed" and falls back to Basic, which the e2e routes refuse. So the person sees calls that do not work and an inbox that does nothing — and nothing anywhere says the remedy is a code sitting in their inbox. ⚠️ **The status alone is not enough.** 403 is also "that device belongs to another account". Telling somebody to check their email for that would send them looking for a message that was never sent, so the relay's own `code` decides, not the number. A 403 with no code, an unparseable body, and the same code on a 500 all stay generic — three tests hold that. `RelayAccounts.verifyEmail` spends the code. Unauthenticated on purpose, exactly like reset confirm: the code IS the proof, and requiring a session would mean somebody kept out *because* they are unverified could never get in. ⚠️ **Kotlin nests block comments**, so a slash-star written inside a KDoc — as part of a route glob — opens a comment that never closes and takes the module down with ninety unrelated errors, none of them near the real line. Cost twenty minutes here; there is now a note in the file. Five tests, proven able to fail: removing the code check turns three red. All three Android suites pass. |
||
|
|
e5f4397b28 |
Read the relay's refusal, not just the HTTP status
🔴 **A 409 is not always a taken handle, and this said it was.** The relay answers 409 for a taken handle, an already-registered email, AND — since flamenet-relay!11 — a handle too close to an existing one. Three problems with three different fixes, all reported as "that handle is already taken". Somebody whose EMAIL was already registered was told to pick a different handle. Doing so cannot work, and nothing anywhere would have told them why. That is a pre-existing bug, not something the new refusals introduced; adding a third meaning to 409 is what made it visible. 400 was worse. Every format refusal — a short passphrase, an unusable email address, a disallowed handle — became "the account service returned 400", which is not a sentence anybody can act on. The status is the transport's opinion. The `code` in the body is the relay's, and it has always been sent; nothing here ever read it. ⚠️ **Unknown codes still refuse.** A relay newer than the app is the normal case, so an unrecognised code falls back to the status rather than to success — an account the server never created would be a much worse failure than a vague message. ⚠️ **An unparseable body cannot throw.** A proxy returning HTML on a 409 must still be a refusal, not a crash. Five tests, proven able to fail: collapsing 409 back to a single meaning turns four of them red. All three Android suites pass. Pairs with flamenet-relay!11 and flamenet-messenger-ios!45. |
||
|
|
1cda405072 |
Say which envelopes are worth interrupting somebody for
The relay announced every envelope as "New message" because it holds
ciphertext and cannot tell. It now takes `silent` from the sender.
Marked silent: call answers, call ends, delivery keys, key-log gossip,
reactions, avatars — everything that is a layer talking to a layer. A
call offer still announces itself, and so does anything a person would
see in the thread. `Content.isRenderable` already drew exactly this line
for the receive side; this is the same question asked on the way out.
⚠️ A plain string is a message: text from an older build carries no
envelope at all, and the safe reading of something unrecognised is that
a person meant to say it.
|
||
|
|
fcc6692284 |
Stories stop carrying where they were taken, and can outlast a day
**Location.** Two story paths handed the picked file over byte for byte:
- A story PHOTO was the one image path in the app that never went
through `ImagePrep`, so it was uploaded with its EXIF block intact —
including, for anything shot on the phone, the GPS coordinates. Chat
photos have always been re-encoded and were safe by consequence
(`Bitmap.compress` writes no EXIF); a story, which goes to every
contact at once, was not.
- A story VIDEO carried the capture location in the MP4 container.
This file's own doc said "a clip that fits goes out exactly as
picked", and that included where it was shot.
`MediaScrub` rebuilds the container with `MediaExtractor` +
`MediaMuxer`. A REMUX, not a transcode: the samples are copied across
untouched, so there is no quality loss and no codec work, and it is
cheap enough to run on every post — a scrub people wait for is a scrub
somebody eventually makes optional. Audio and video tracks only;
anything else a camera app decided to add is the kind of passenger this
exists to leave behind.
⚠️ Rotation IS carried over, deliberately. It says which way up the
phone was, not where it was, and dropping it plays every portrait story
on its side.
🔴 A scrub that fails REFUSES the post. "We could not remove the
location, so we sent it anyway" is the one outcome this must never
produce.
**Stories.** The lifetime was a constant. 1 hour / 6 hours / 24 hours /
3 days / 7 days, same set as iOS, defaulting to what it has always done.
`Conversations.postStory` already took `lifetimeSeconds` with a default,
so only the setting and the plumbing were missing — and
`Session.postStory` now REQUIRES it rather than defaulting, so a future
call site cannot silently ignore what the person chose.
⚠️ Clamped to the offered set on read: a value from a future build, or a
corrupted preference, must not post a story that expires before anybody
sees it.
Also fixes `RelayClientTest`, which had not compiled since `fetchKeys`
started returning the fnid alongside the bundles. Every other call site
in the tests was updated at the time; this one was missed, and I had not
run this suite since.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
ccdf82c00a |
Signing out no longer costs every contact a prompt
`signOut` destroyed the vault unconditionally, so signing back in was a genuinely NEW device: every contact had the old key pinned, saw a safety-number change, and could not message you until they accepted it. For someone stepping away for an afternoon that is a strange price, and it is why signing out and back in kept looking like the app breaking. Two doors now, matching iOS. Plain sign-out ends the session and keeps the device, so coming back is silent. "Sign out and erase this device" is its own row, confirms in place like the delete control beside it, and retires the device on the relay BEFORE destroying the keys that authorise saying so — otherwise the account goes on publishing a mailbox nobody reads, while the relay reports every copy delivered. Also: changing your password erased the phone. `updatePassword` signed out to drop the stale credential, which is right, and took the destructive door to do it, which was not — a password change made you a new device to everyone you had ever spoken to. It takes the plain one now. Account deletion asks for the erasing door explicitly. ⚠️ Keeping the keys does not leave the mailbox reachable: a relay token is minted against the account credential, which both doors clear. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ee17d773b7 |
A session we ANSWER can now be v3, and receive failures say why
`E2ESession.respond` hard-coded `version = if (pq == null) 1 else 2`, so
a session this device answered could never be v3 — while `initiate`
twelve lines above correctly returns 3 whenever the post-quantum ratchet
is on. A conversation iOS started therefore ran as v3 on one side and v2
on the other, and every message this device sent into it was refused by
the peer as a downgrade (§13.4) and dropped on arrival. iOS to Android
worked; Android to iOS did not, for as long as the pairing existed.
Kotlin talking to Kotlin never noticed, because both ends drive the
ratchet from their own PQ state and ignore the label. Only a peer that
enforces the version sees it. `HybridSessionTest` asserted the wrong
value — `assertEquals(2, bobSide.version)`, two lines below requiring
the initiator to be 3 — so the suite passed while encoding the defect.
Corrected, with the reason recorded next to it.
The version now follows the initiator rather than assuming 3 whenever PQ
is present: a v2 peer sends a PQ block too, and answering that in v3
breaks the pairing in the other direction.
Receive-side failures are no longer swallowed. `catch (_: Exception) {}`
threw away the reason for every undecryptable envelope, which left this
half of every delivery investigation unreadable — the relay says
delivered, the app shows nothing, and the one place that knew why
discarded it. The envelope is still skipped; it now names the id, the
device and the exception. Sends report the protocol shape they emit, and
receives report which of the three chains opened them.
Also here:
- proguard-rules.pro, which did not exist while build.gradle.kts
referenced it. R8 accepts a missing entry in silence, so release
builds ran on the platform defaults and answering a call aborted the
process inside WebRTC's JNI_OnLoad — it resolves Java classes by
name, and R8 had renamed them. The build now fails if the file is
missing rather than shipping without it.
- Server refusals read as sentences. `RelayException.Http` had no case
in `describe()`, so choosing a taken handle showed "relay returned
409:" and a JSON fragment, which says nothing about picking another
name. Transport failures showed a Java networking string naming an
address the person never typed.
- Switching between signing in and creating an account clears the last
error. It belonged to the other mode and stayed on screen, so taking
the advice it gave looked like it had failed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
201e09aaa3 |
A live suite cannot be pointed at production any more
39 accounts were created in the production database in half an hour on 2026-08-31, and 55 of the 86 accounts on that relay are now test accounts. The launch signup metric became unusable: a real person signing up was invisible inside the burst. Every one of those suites is correctly written. They are opt-in, and their own headers say to point them at the disposable fleet and not at production. That was the entire protection, and a comment cannot refuse anything. `LiveTarget.refuseProduction` is checked by every live suite before it does anything, and throws rather than skips. "This run was pointed at production" is not an unmet precondition — it is a mistake that has already done damage once, and a skip is how it goes unnoticed the next time. 🔴 The unit test found a real bug in the guard before it shipped. The disposable fleet lives on the SAME DOMAIN as production, so denying `flamenet.io` and its subdomains refused `relay1.flamenet.io` — the exact place these suites are meant to point. The guard would have blocked every legitimate run while appearing to block only production. The fleet is now named explicitly and checked first, which keeps the bare domain in the deny list so a production host added later is refused by default. ⚠️ A deny list rather than an allow list, deliberately. An allow list would also refuse a developer's localhost or a self-hoster's own relay, which are normal things to test against, and a guard that gets in the way is one somebody turns off. Proven both directions rather than read: FN_LIVE_FLEET=https://flamenetmessenger.com -> IllegalStateException: refusing to run a live suite against flamenetmessenger.com FN_LIVE_FLEET unset -> tests=1 skipped=1 failures=0 The guard's own test runs in every pipeline, unlike the suites it protects — those skip without FN_LIVE_FLEET, so a rule exercised only there would never be checked at all. |
||
|
|
5242a506b3 |
Eighteen tests reported PASSED while running nothing
`val url = base ?: return` from an unmet precondition makes a test that did nothing report as a pass. FN_LIVE_FLEET is never set in CI, so eighteen tests across five files did this on every pipeline: LiveKeyLogTest 7, LiveConversationTest 6, LiveRelayTest 2, LiveSealedSenderTest 2, LiveSignUpTest 1. 🔴 This repository had already written the rule down. LiveShapesTest uses `assumeTrue` and says why, in its own words: a suite of tests that did nothing is indistinguishable from a suite that works. Two files followed that and five did not, so this is a consistency fix rather than a new idea. It nearly reached the specification. `LiveSealedSenderTest` was the evidence §15 would have cited for "Android sealed sender, wired end to end" — two tests, zero skipped, zero failures, on a run that did nothing. Only reading the source shows that, and a status table is exactly where nobody would. ⚠️ The suite now looks worse and is not. Client goes from "277 tests, 0 failures" to "277 tests, 28 skipped" — the 28 were always doing nothing; the number was wrong before, not now. 578 of 606 actually run. |
||
|
|
d56ae82c59 |
Sealed groups on Android: create, fetch, invitations
Mirrors the iOS side. Create mints a gid, group key, owner signing key and one random tag per member, seals the roster and hands the relay a blob — no member id leaves the device. Fetch asks by TAG, never "what groups am I in", because a sealed relay cannot answer that. Reachability moved here from the relay, which needed to know who the members were in order to check it. ⚠️ An invitation is NOT subject to the roster check the group name goes through, and cannot be: that check asks the relay who is in the group, and for a group we have not joined yet we cannot fetch it to ask. What guards it instead is that an invitation is accepted only for a group we hold no secrets for — a stranger cannot overwrite a group we are really in, which would make every later roster fail to open and silently empty the conversation. The base64 stays inside the engine: `Bytes` is internal there on purpose, so `SealedRoster` grew `newGroupId`, `newOwnerKey`, `ownerPublic` and `signRoster` rather than the app reaching past that boundary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
556580f4fb |
Test the roster gate and the own-account announcement directly
Two gaps left by the group-name work, both in the half that decides whether a claim is believed. The roster check that turns a group-name CLAIM into a stored name lives in Session, and was covered only through Conversations — which asserts the claim is NOT adopted there and says nothing about what happens next. iOS had its equivalent tested directly; Android's was a layer removed. It now has four tests: a member is adopted, a non-member is refused, a gid we are not in is refused, and a refused claim is consumed rather than left to retry against a roster that might later differ. Removing the membership test fails two of them. Reaching it needed a seam: `consumeControl` is internal to this module, on purpose, and driving it through a real ratchet would mean making a real sender claim a membership it does not have — which a real sender cannot be made to do. `consumeControlForTesting` says that in its own comment. The second gap: the announcement addresses our own account so a phone we own is not told by nobody, and nothing checked that it did. Removing our account from the list now fails. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
2162262159 |
An unproven device may not name a group
The announcement has always been refused from an unproven sender (audit C2). An ordinary group envelope carrying `gname` was not — so the weaker of the two routes decided what a group is called. On an unproven device `fromUser` is a CLAIM: the binding says that device belongs to somebody else. The roster check then asks "is the person they are pretending to be in this group", which answers yes for any member the attacker cares to name — and the group heading in someone's chat list is a good place to put a lie, because the reader has no reason to doubt it. `Received` now carries `senderProven` so a caller acting on the sender can ask, and `Session.receive` gates the adoption on it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
fc12aaa591 |
Bound the group-name claim queue
The sender decides how many claims land in it: anyone who can reach us can add one per envelope, and it is drained by a layer that may not run on every path — a poll that never reaches Session.receive, or a path added later that forgets. An unbounded list whose growth a stranger controls is a slow leak with somebody else's hand on the tap. Capped at 256, oldest dropped first, well above any real group count: a claim is only ever acted on for a group we are actually in, so the cap bounds the queue rather than rationing legitimate traffic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
41533baae6 |
Group names survive a reinstall, and reach our own other devices
Two gaps the group-name change left behind. The encrypted history export carried the messages but not the names. That was harmless while the relay answered with them — a reinstalled device got them back for free. It holds none now, so this device's copy is the only copy, and restoring a backup would have produced a chat list of groups all called "Group": exactly the loss the export exists to prevent. Names ride the payload, and only fill gaps on import — a backup is older than the device by definition, so a rename made since must not be undone. And fan-out excludes our own account, so a second phone we own was told by nobody: the relay has no name to serve it and the members only send to each other. The announcement now addresses our own account too. The fan-out loop already skipped our own device, so what goes out reaches our OTHER devices and nothing else. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
d128e8d56e |
Let people rename a group
Names used to be fixed at creation and served by the relay for ever, so no client ever needed this. The relay has now dropped them — and that makes every group created before the change read as the placeholder on every phone, with nobody left to ask what it used to be called. The relay deleted its copy and no client had ever kept one. Without a rename, those groups are called "Group" permanently. This is the repair, and it is the first time a group's name has been changeable at all. Stored first, announced second: the name is ours the moment we set it, so a member who cannot be reached now does not stop us seeing the group under the name we chose. They pick it up from the next message, which carries it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
4a6be0ba6b |
Group names live on the devices, not the relay
The relay stopped storing them, so `createGroup` no longer sends one and the `name` it returns is a placeholder. The real name is held in the sealed vault and travels between members: on every group envelope, and as an announcement riding the `delivery_key` control kind at creation and on adding a member. `delivery_key` rather than a new kind because a new one would arrive on every phone running v5 as a bubble full of raw JSON. An announced name is recorded as a CLAIM, not adopted. Conversations does not know the rosters, and a name taken on the sender's word would let anyone who can reach you write whatever they liked into your chat list under a group heading you have no reason to doubt. Session checks it against the relay's roster and only then stores it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
8061f54e96 |
Blocking somebody now cancels their sealed passes too
§21.4 gave a lever to cut off one contact and nothing pulled it. That is the failure this project has been fixing all day in other places — a capability built and left unreachable. Blocking is where it belongs, and the reason is stronger than convenience. The relay CANNOT enforce a block against a sealed sender: the request carries no sender to compare against a block list, and the send route's own comment says so. So blocking stopped the identified path and nothing else, and a blocked person's sealed messages were dropped by the app after decryption rather than refused at the door. Revoking their capabilities is what closes that. The copy changes with it, because "buddies you block can't send you messages" was not true and is the kind of overclaim a safety screen must not make. It now says what blocking does, and names the gap left: the shared key everybody holds is not that person's alone, so it takes replacing to close completely — with the cost of that stated in the same breath rather than in a different screen. The sealed-sender section changes for the same reason. It said replacing the key is "the only way" to cut somebody off, which was true when it was written and is not now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
79c2734f86 |
Issue, spend and revoke delivery capabilities on Android (§21.4)
The last client. A recipient mints a batch of random tokens per contact, uploads only their hashes, and hands the tokens over inside the ratchet; a sender spends one per message; revoking one contact is dropping one batch, and nobody else notices. The batch rides the existing `delivery_key` envelope rather than a new control kind, because a receiver recognises control traffic by KIND first and falls back to the `ctl` flag only for kinds it has never heard of — and this engine reads that flag with `optBoolean`, which does not coerce the number §6.10 calls for. A `delivery_tokens` kind would have arrived on the phones already out there as a bubble full of raw JSON. Replenishment is counted rather than requested, for the same reason: a "running low" message would have been another new kind. The issuer can count on its own, because every message it receives from a contact spent one of their capabilities, and it mints afresh at half a batch. A refused capability means the batch was revoked, so the rest is discarded and the same envelopes are re-sent on the shared key — they are addressed to the device, not to the authorisation. That is not a way around revocation, and the code says so: revoking moves a sender back onto §14.4's key, it does not silence them. One bug the tests found. `consumeControl` read the peer's delivery key first and bailed with `loadDelivery() ?: return`, so a device that had not published a key of ITS own yet — every device before `ensureDeliveryKey` runs — silently dropped everything in the envelope, capabilities included. Capabilities have their own store and no such dependency, so they are handled before that early return. Verified against a locally-run relay carrying the §21 work as well as by the unit suite: 126 capability hashes on the relay after the live run, so the path is exercised rather than mocked past. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
088fd5f5a7 |
Key-log state is per relay, and two bugs the new tests found
Writing tests for the multi-mailbox and key-log work turned up three things, two of them in code committed earlier today. **The "this relay serves a log" flag was global.** So was the head pin. Signing in to a second relay that keeps no log would have been read as the FIRST one going quiet — a fork warning against an honest deployment, and every contact on it unreachable. The pin was worse: a second relay's head checked against the first relay's root cannot reconcile, so the same outcome arrived by a different route. Both are now keyed by relay host. Pin reads fall back to the old global entry, so a phone already holding one keeps it rather than starting trust-on-first-use again. **A dead home relay hid every other mailbox.** `poll` read the signed-in relay first and let its failure propagate, which is precisely the situation several mailboxes exist to survive, defeated by an unguarded call. But swallowing it outright broke a property that was already tested and matters: a failed poll must not be reported as an empty inbox, because "no new messages" and "could not reach the server" are different things the app says different words for. Both hold now — every mailbox is read, and the failure is raised only if NOTHING could be read. A home that is down while a backup answers is a partial success whose mail is returned rather than thrown away with the error. **A relay is named in the record and dialled by URL, and those are two questions.** The hostname is inside the signature so it can carry no scheme and no port; how this device reaches that host is separate and now stored beside it. That is the production shape as well as what lets these paths be tested at all. New: 12 tests for the address and home record (including that a relay serving a shortened or lengthened list is caught by arithmetic), 7 for multi-mailbox reading, 10 for sending to an address, 10 for adding and dropping a mailbox, 5 for the control flag. A fixture that let a mock relay forget a record it had stored is fixed too — it made every publish after it reuse serial 1, which a real relay refuses as a rollback. Verified against a live relay: 243 client tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
366f5cbeab |
Live tests: read what a person sees, not what the wire carries
Six live conversation tests failed after key-log checking became automatic, and the product was right and the tests were wrong. `poll` returns control traffic as well as messages. Automatic enforcement is what sets the transparency-log head pin — and §19.4 gossip has nothing to send without one, so until now the first send to a contact never carried this device's view of the log. It does now. That envelope is consumed by the client and never shown, exactly as the app has always filtered it, but these tests asserted on the raw list and counted it as a message. Worth stating plainly, because it is the more interesting half: gossip was written, called, and inert. Each client verified the relay was consistent with itself and never offered a head for anybody to compare against — which is the whole mechanism for catching a relay that serves two different logs. Turning enforcement on turned that on too. Verified against a live relay rather than a mock: 203 client tests including sign-up, device registration, prekey publication, two-party and group conversations, images, sealed sender and the deployed key log. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
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> |