Second pass with the avoid-ai-writing detector

The skill's detector scored the README, PARITY.md and the extracted
comment prose. Fixed what it flagged: hollow intensifiers, "in order
to", "additionally", "the whole point", bold sentences in docs, and
bold that had wrapped across comment lines. Comment prose scores a
maximum of 4 out of 100 across the repo.

One sweep rule mangled four comments ("which is the entire point of X"
lost its clause); repaired, and the rule now keeps the clause.

Comments and strings only. 1208 tests pass, counted from the XML.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-08 19:20:07 -04:00
co-authored by Claude Fable 5.1
parent 4dd544083c
commit 8a7f40fb13
131 changed files with 521 additions and 525 deletions
+2 -2
View File
@@ -13,13 +13,13 @@ the pipeline for the branch that made it*. See `.e2e-ref.md` there for the full
account; the conclusion was that a pin makes that situation impossible rather account; the conclusion was that a pin makes that situation impossible rather
than survivable. than survivable.
**A pin alone is worse than HEAD, though.** The argument for HEAD is real: a A pin alone is worse than HEAD, though. The argument for HEAD is real: a
client tested against a pinned old harness is tested against a pinned old spec, client tested against a pinned old harness is tested against a pinned old spec,
and if the vectors move and nothing says so, this suite quietly stops checking and if the vectors move and nothing says so, this suite quietly stops checking
the protocol anybody is actually running. That is why `vectors-head` exists and the protocol anybody is actually running. That is why `vectors-head` exists and
is `allow_failure: true`. is `allow_failure: true`.
**Being behind is LOUD and NON-BLOCKING. Being broken is a hard stop.** A red Being behind is LOUD and NON-BLOCKING. Being broken is a hard stop. A red
`vectors-head` means an update is waiting, not that anything is wrong. `vectors-head` means an update is waiting, not that anything is wrong.
## Taking an update ## Taking an update
+1 -1
View File
@@ -23,7 +23,7 @@ export ANDROID_HOME=$HOME/Library/Android/sdk
Those three suites are the merge gate. Those three suites are the merge gate.
**JVM crypto tests prove nothing about Android.** The JVM's JCA and Android's JVM crypto tests prove nothing about Android. The JVM's JCA and Android's
Conscrypt are different providers with different opinions. X25519/X448 need DER Conscrypt are different providers with different opinions. X25519/X448 need DER
key specs and API 33 on device, and code that passes `:engine:test` on the JVM key specs and API 33 on device, and code that passes `:engine:test` on the JVM
can throw on a handset. Anything touching key agreement or signatures needs an can throw on a handset. Anything touching key agreement or signatures needs an
+68 -70
View File
@@ -4,7 +4,7 @@ What the iOS app does, what Android does, and what is left. Read from the
sources listed, not from memory, every row names the iOS file it came from so sources listed, not from memory, every row names the iOS file it came from so
it can be re-checked rather than trusted. it can be re-checked rather than trusted.
**Standing rule: this file is the definition of done for the Android app.** A Standing rule: this file is the definition of done for the Android app. A
row moves to when the behaviour exists *and* something tests it, not when the row moves to when the behaviour exists *and* something tests it, not when the
screen renders. screen renders.
@@ -19,7 +19,7 @@ screen renders.
| Tests | XCUITest + JS interop | 114 unit + 37 cross-engine + 15 on-device + live round trips | | Tests | XCUITest + JS interop | 114 unit + 37 cross-engine + 15 on-device + live round trips |
| Ships to | TestFlight, once a distribution cert is minted (see below) | a debug APK that installs and runs; the release keystore is configured | | Ships to | TestFlight, once a distribution cert is minted (see below) | a debug APK that installs and runs; the release keystore is configured |
Android's engine is **verified against the web engine in both directions** Android's engine is verified against the web engine in both directions
(`engine/test/interop-kotlin.mjs`), which is a stronger claim than the iOS app (`engine/test/interop-kotlin.mjs`), which is a stronger claim than the iOS app
could make for most of its history. What Android has no version of at all is could make for most of its history. What Android has no version of at all is
everything above the protocol. everything above the protocol.
@@ -179,7 +179,7 @@ the ratchet, so the SDP, codecs and every candidate address, is end-to-end
encrypted; a relay that could read it would learn both parties' addresses even encrypted; a relay that could read it would learn both parties' addresses even
on a call it never carries. on a call it never carries.
**A direct call shows the other person your IP address.** That is what makes A direct call shows the other person your IP address. That is what makes
it direct. The app says so before the FIRST call and waits for an answer, it direct. The app says so before the FIRST call and waits for an answer,
once, not every time, because a prompt people see repeatedly is one they stop once, not every time, because a prompt people see repeatedly is one they stop
reading. reading.
@@ -209,13 +209,13 @@ being the one that leaks.
| Zero key material after use | | | ios#39, done, but the JVM guarantee is **weaker than iOS**; see below | | Zero key material after use | | | ios#39, done, but the JVM guarantee is **weaker than iOS**; see below |
| Pin the relay's identity | | | ios#19. Android already pinned the key and drew the **wrong conclusion** from a change; see below | | Pin the relay's identity | | | ios#19. Android already pinned the key and drew the **wrong conclusion** from a change; see below |
**The back-off is not a defence of the data.** It slows a person holding an The back-off is not a defence of the data. It slows a person holding an
unlocked phone. The vault and the ratchet keys are protected by the Android unlocked phone. The vault and the ratchet keys are protected by the Android
keystore, and no amount of back-off changes that, the same wording `AppLock` keystore, and no amount of back-off changes that, the same wording `AppLock`
itself uses, kept identical on both platforms so neither can be read as itself uses, kept identical on both platforms so neither can be read as
promising more. promising more.
**The clock rule is an asymmetry, not a correction.** A story disappearing The clock rule is an asymmetry, not a correction. A story disappearing
after an hour is a promise the SENDER made, enforced on the READER's device, so after an hour is a promise the SENDER made, enforced on the READER's device, so
a reader whose clock runs behind keeps a message the sender was told would be a reader whose clock runs behind keeps a message the sender was told would be
gone, and nobody involved can tell. Expiry therefore takes the LATER of the gone, and nobody involved can tell. Expiry therefore takes the LATER of the
@@ -255,7 +255,7 @@ rather than guaranteed. There is no way to pin a Java array, so this is a
ceiling on what any implementation here can offer, not a gap to close later. ceiling on what any implementation here can offer, not a gap to close later.
The claim Android supports is therefore narrower and is written that way in The claim Android supports is therefore narrower and is written that way in
`SecureBytes`: **a derived key is not left sitting in the heap after use.** Not `SecureBytes`: a derived key is not left sitting in the heap after use. Not
"the key is gone". "the key is gone".
One divergence from iOS, and it is a bug the Swift version cannot have. iOS One divergence from iOS, and it is a bug the Swift version cannot have. iOS
@@ -275,8 +275,8 @@ pinned to the published vector and wired into sign-in for a local relay, and
`MailboxHoming` / `AccountIdentity.publishHomes` have shipped the publish and `MailboxHoming` / `AccountIdentity.publishHomes` have shipped the publish and
read halves for a while. The document was describing a gap that had closed. read halves for a while. The document was describing a gap that had closed.
**What was genuinely missing was on the READING side, on BOTH clients.** An What was missing was on the READING side, on BOTH clients. An
older home record is *genuinely signed*, the account holder published it, older home record is *signed*, the account holder published it,
before they added a second relay, so a relay that wants to be somebody's only 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 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 record verifies perfectly, and the sender delivers to one relay believing it is
@@ -292,15 +292,14 @@ could raise it with a forged `Int.MAX_VALUE` and lock out every later genuine
record, turning the rollback defence into a denial of service against the record, turning the rollback defence into a denial of service against the
account holder. account holder.
Neither client claims an attack when it refuses. A relay that has genuinely Neither client claims an attack when it refuses. A relay that has fallen behind serves an old record for honest reasons, and nothing in a client
fallen behind serves an old record for honest reasons, and nothing in a client
can tell that from the other case. can tell that from the other case.
--- ---
## Inbound control envelopes ## Inbound control envelopes
**The app never asked the engine whether an envelope belonged in a thread.** The app never asked the engine whether an envelope belonged in a thread.
`Content.isRenderable` has existed since §6.15 and `Session`'s inbound loop did `Content.isRenderable` has existed since §6.15 and `Session`'s inbound loop did
not consult it, so three things arrived from the iPhone app and were filed as not consult it, so three things arrived from the iPhone app and were filed as
ordinary bubbles: ordinary bubbles:
@@ -332,7 +331,7 @@ their own, because the words would carry somebody else's name.
| No credential written into the source | | | both fixed together, ios#62 | | No credential written into the source | | | both fixed together, ios#62 |
| A guard that fails the build if either regresses | | | `Tests/UITestSafetyTests.swift`, `client/.../TestSafetyTest.kt` | | A guard that fails the build if either regresses | | | `Tests/UITestSafetyTests.swift`, `client/.../TestSafetyTest.kt` |
**iOS was creating real accounts on production on every plain test run**, with iOS was creating real accounts on production on every plain test run, with
a literal password from a public repository; Android's equivalent suites were a literal password from a public repository; Android's equivalent suites were
properly gated but still carried properly gated but still carried
`const val PASSPHRASE = "a-long-enough-passphrase"` and used it to sign up on `const val PASSPHRASE = "a-long-enough-passphrase"` and used it to sign up on
@@ -392,9 +391,9 @@ the messenger rather than staying separate apps.
| Wallet engine: address derivation and signing | BIP-44 path, Base58 address, and the engine's own RFC 8032 signs, no API-33 floor | | Wallet engine: address derivation and signing | BIP-44 path, Base58 address, and the engine's own RFC 8032 signs, no API-33 floor |
| Wallet: BIP-39 seed, create and restore | create, import, restore, sealed at rest, with the phrase shown once and explained | | Wallet: BIP-39 seed, create and restore | create, import, restore, sealed at rest, with the phrase shown once and explained |
| Wallet: fresh receive address per receive | counter in the vault; the screen says why the address changes | | Wallet: fresh receive address per receive | counter in the vault; the screen says why the address changes |
| Wallet: balance and transactions from a node | both, with **unread drawn as an em dash and never as zero** | | Wallet: balance and transactions from a node | both, with unread drawn as an em dash and never as zero |
**The device run found a bug no JVM test could have.** Android's bundled The device run found a bug no JVM test could have. Android's bundled
`org.json` coerces a JSON `null` to the four-character string `"null"`; the Maven `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 `org.json` the tests run against returns `""`. Every nullable field in the vault
index therefore passed on a laptop and broke on a phone: index therefore passed on a laptop and broke on a phone:
@@ -512,11 +511,11 @@ Two Kotlin-specific decisions, both money-relevant:
is worse than a failed sign-in: a phrase containing anything outside ASCII is worse than a failed sign-in: a phrase containing anything outside ASCII
would stretch differently under a different provider and derive a different would stretch differently under a different provider and derive a different
empty wallet with no error anywhere. empty wallet with no error anywhere.
- **Base58 is a digit loop, not `BigInteger`.** The obvious Kotlin version is - Base58 is a digit loop, not `BigInteger`. The obvious Kotlin version is
`BigInteger(1, bytes).toString(58)` remapped, and it silently drops leading `BigInteger(1, bytes).toString(58)` remapped, and it silently drops leading
zero bytes, encoding two different keys to one address. zero bytes, encoding two different keys to one address.
**Android can SIGN, and iOS's constraint does not apply here.** `AccountKdf` Android can SIGN, and iOS's constraint does not apply here. `AccountKdf`
notes that a raw Ed25519 seed needs `EdECPrivateKeySpec`, which Android exposes notes that a raw Ed25519 seed needs `EdECPrivateKeySpec`, which Android exposes
only from API 33, but the engine carries its own RFC 8032 (`Rfc8032`), so only from API 33, but the engine carries its own RFC 8032 (`Rfc8032`), so
`HdAccount.signingSeed` produces a key that signs on any supported device. The `HdAccount.signingSeed` produces a key that signs on any supported device. The
@@ -529,19 +528,19 @@ convenient, would need no backup at all, and would tie every payment somebody
ever received to their handle on a public ledger for ever. The chain does not ever received to their handle on a public ledger for ever. The chain does not
forget, so there is no undoing it later. forget, so there is no undoing it later.
**The record stores the ENTROPY, never the seed.** The seed is a one-way The record stores the ENTROPY, never the seed. The seed is a one-way
function of the phrase, so a record holding it would open the wallet and be function of the phrase, so a record holding it would open the wallet and be
unable to produce the words, somebody who lost their phrase but still had their unable to produce the words, somebody who lost their phrase but still had their
account could spend, and could never write it down again or move elsewhere. account could spend, and could never write it down again or move elsewhere.
Self-custody in name only. Self-custody in name only.
**Erasing the device destroys the record; the phrase is then the only copy.** Erasing the device destroys the record; the phrase is then the only copy.
Plain sign-out keeps it, and so does a password change (which signs out Plain sign-out keeps it, and so does a password change (which signs out
plainly), `signOut(erasingDevice = true)` and `deleteAccount` do not. That is plainly), `signOut(erasingDevice = true)` and `deleteAccount` do not. That is
what a recovery phrase is for, and it has to be said at the moment the phrase is what a recovery phrase is for, and it has to be said at the moment the phrase is
shown rather than discovered afterwards. shown rather than discovered afterwards.
**The node is somebody else's server and is trusted with nothing.** It is The node is somebody else's server and is trusted with nothing. It is
asked what a public address holds and handed transactions that are already asked what a public address holds and handed transactions that are already
signed; it never sees a key. What it can do is lie about a balance or withhold a signed; it never sees a key. What it can do is lie about a balance or withhold a
history, so no answer is treated as authoritative beyond "this is what that node history, so no answer is treated as authoritative beyond "this is what that node
@@ -549,7 +548,7 @@ said". And which node somebody points at is a privacy decision the app has to
state: asking a public endpoint for an address's balance ties that address to state: asking a public endpoint for an address's balance ties that address to
this device's IP in somebody's logs. this device's IP in somebody's logs.
**An unread balance is drawn as an em dash, never as 0.00.** Null means this An unread balance is drawn as an em dash, never as 0.00. Null means this
app could not read the balance; zero means the account is empty. Drawing the app could not read the balance; zero means the account is empty. Drawing the
first as the second tells somebody their money is gone, on every flaky first as the second tells somebody their money is gone, on every flaky
connection, which is exactly when they are most likely to believe it. Same rule connection, which is exactly when they are most likely to believe it. Same rule
@@ -559,7 +558,7 @@ Reached from Account, not the tab bar. The bar is four icons by design and
Alerts was taken OUT of it in August for being a fifth; a wallet people open Alerts was taken OUT of it in August for being a fifth; a wallet people open
occasionally does not earn the slot that decision refused. occasionally does not earn the slot that decision refused.
**A JSON-RPC refusal arrives with HTTP 200.** A client checking only the A JSON-RPC refusal arrives with HTTP 200. A client checking only the
status code carries on with a body that has no result, fails somewhere less status code carries on with a body that has no result, fails somewhere less
obvious, and throws the node's own explanation away on the route, the obvious, and throws the node's own explanation away on the route, the
difference between "insufficient funds for rent exemption" on screen and a parse difference between "insufficient funds for rent exemption" on screen and a parse
@@ -618,7 +617,7 @@ measurement so nobody has to guess again.
**XDH is fine**: Conscrypt serves it from API 33, which is why `minSdk` is 33. **XDH is fine**: Conscrypt serves it from API 33, which is why `minSdk` is 33.
**The wire shapes were wrong three times, and each was silent.** `prekeys` not The wire shapes were wrong three times, and each was silent. `prekeys` not
`opks` (relay answered 200 and stored nothing); the send body names the `opks` (relay answered 200 and stored nothing); the send body names the
recipient once at the top, not per envelope (400, at least loud); the buddy recipient once at the top, not per envelope (400, at least loud); the buddy
list uses `id`/`presence`/`screen_name`, not `user_id`/`status`. None of these list uses `id`/`presence`/`screen_name`, not `user_id`/`status`. None of these
@@ -640,7 +639,7 @@ contact list rather than from Account, and several controls sat below the fold.
## Cross-platform, 2026-08-25 ## Cross-platform, 2026-08-25
**iOS → Android is proven, through both UIs.** The iOS app signed up on iOS → Android is proven, through both UIs. The iOS app signed up on
`relay1`, added the Android account as a contact, and sent; the Android `relay1`, added the Android account as a contact, and sent; the Android
instrumentation logged the exact text back: instrumentation logged the exact text back:
@@ -674,13 +673,13 @@ gossip now exist and are tested. What each one is worth:
what was pinned the first time, and a send to a changed identity is refused what was pinned the first time, and a send to a changed identity is refused
until a person accepts it. The comparison is `all offered keys are pinned`, until a person accepts it. The comparison is `all offered keys are pinned`,
not `any`, a relay adding one key of its own alongside the real ones is the not `any`, a relay adding one key of its own alongside the real ones is the
attack, and every genuine device still working is what makes it invisible. attack, and every device still working is what makes it invisible.
**Key-log enforcement is opt-in** (`require-key-log` on the Account screen), **Key-log enforcement is opt-in** (`require-key-log` on the Account screen),
for the same reason the web engine's is: a relay older than the log has none, for the same reason the web engine's is: a relay older than the log has none,
and refusing every peer there is a worse failure than the one being prevented. and refusing every peer there is a worse failure than the one being prevented.
**`verifyConsistency` did not exist in the Kotlin engine and now does.** The `verifyConsistency` did not exist in the Kotlin engine and now does. The
engine had inclusion only, which on its own is worth very little: a relay that engine had inclusion only, which on its own is worth very little: a relay that
rewrites history reissues perfectly valid inclusion proofs against its new rewrites history reissues perfectly valid inclusion proofs against its new
tree. Consistency against a pinned head is the check it cannot satisfy. tree. Consistency against a pinned head is the check it cannot satisfy.
@@ -694,13 +693,12 @@ 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. and a rewritten log that cannot be reconciled with a pinned root.
**Exercised against `relay1`**, 2026-08-25, `LiveKeyLogTest`, opt-in via **Exercised against `relay1`**, 2026-08-25, `LiveKeyLogTest`, opt-in via
`FN_LIVE_FLEET`. Against the real 231-leaf log: a key the relay genuinely `FN_LIVE_FLEET`. Against the real 231-leaf log: a key the relay published verifies; a substituted key is refused; a second check reconciles the
published verifies; a substituted key is refused; a second check reconciles the
pinned head with a grown tree using proofs the relay generated; a root we never pinned head with a grown tree using proofs the relay generated; a root we never
saw at our pinned size is a fork; and the send path itself refuses on a bad pin saw at our pinned size is a fork; and the send path itself refuses on a bad pin
while NOT refusing an honestly published key with enforcement on. while NOT refusing an honestly published key with enforcement on.
**Contacts carry the peer's global address, and enforcement uses it.** Contacts carry the peer's global address, and enforcement uses it.
The relay files a leaf under an account's **fnid** once it has one, and under The relay files a leaf under an account's **fnid** once it has one, and under
`local:<audience>/<user_id>` before that. The subject is inside the hashed `local:<audience>/<user_id>` before that. The subject is inside the hashed
@@ -721,7 +719,7 @@ 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. The safety number covers the buys is that every later answer has to match. The safety number covers the
first sighting and nothing here replaces it. first sighting and nothing here replaces it.
**A changed address is an identity change**, and for a stronger reason than a A changed address is an identity change, and for a stronger reason than a
changed key: an fnid is derived from the account key, so a new address is not 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, which is to say a different person. new device, it is a different account key, which is to say a different person.
@@ -737,7 +735,7 @@ would then read as the address changing, the alarm, for a network blip.
## Sealed sender, 2026-08-25 ## Sealed sender, 2026-08-25
**The relay no longer learns who sent a message.** Verified against relay1: The relay no longer learns who sent a message. Verified against relay1:
a sealed submission is accepted unauthenticated, the stored row carries a sealed submission is accepted unauthenticated, the stored row carries
`from_user = 0`, and the recipient still reads it with the sender recovered `from_user = 0`, and the recipient still reads it with the sender recovered
from inside the seal. from inside the seal.
@@ -754,13 +752,13 @@ more; the ratchet opening it is what makes it a fact. There is a test that
forges a sender on purpose so that nobody later "tidies" the naming into forges a sender on purpose so that nobody later "tidies" the naming into
something that looks authoritative. something that looks authoritative.
**The first message to a new contact cannot be sealed**, and `Sent.sealed` The first message to a new contact cannot be sealed, and `Sent.sealed`
reports which it was rather than letting the UI imply otherwise. Sealing needs reports which it was rather than letting the UI imply otherwise. Sealing needs
the recipient's delivery key, and that key travels inside an E2E message, so the recipient's delivery key, and that key travels inside an E2E message, so
there has to be a prior exchange. Claiming anonymity for that first message there has to be a prior exchange. Claiming anonymity for that first message
would be lying about the one thing the feature is for. would be lying about the one thing the feature is for.
**Rotation is the only lever against a sealed spammer, and it is blunt.** Rotation is the only lever against a sealed spammer, and it is blunt.
There is no sender to block, so the recipient replaces the delivery key and There is no sender to block, so the recipient replaces the delivery key and
every contact loses sealed access until the next time they are written to. every contact loses sealed access until the next time they are written to.
Reachable from Account; the copy says so. Reachable from Account; the copy says so.
@@ -776,14 +774,14 @@ connect" and "Software caused connection abort" on sign-up, with the relay
reachable from the host at the same moment. A rerun succeeds. Worth knowing reachable from the host at the same moment. A rerun succeeds. Worth knowing
before treating a red instrumented run as a regression. before treating a red instrumented run as a regression.
**Instrumented tests share a process and a vault.** The Compose rule gives Instrumented tests share a process and a vault. The Compose rule gives
each test a fresh Activity, but the app spends its first moments reading the each test a fresh Activity, but the app spends its first moments reading the
vault behind a spinner. A reset that checks for a signed-in control immediately vault behind a spinner. A reset that checks for a signed-in control immediately
finds nothing, concludes "already signed out", and then the session restores finds nothing, concludes "already signed out", and then the session restores
underneath it, so the suite passes one test at a time and fails as a set. Wait underneath it, so the suite passes one test at a time and fails as a set. Wait
for the app to settle before deciding anything. for the app to settle before deciding anything.
**Two peer processes on one account race for the inbox.** The one that polls Two peer processes on one account race for the inbox. The one that polls
first takes the message and the other reports zero, which reads as "nothing was first takes the message and the other reports zero, which reads as "nothing was
sent". Run a single `autoreply` at a time. sent". Run a single `autoreply` at a time.
@@ -816,7 +814,7 @@ it has nothing to do with which app is more finished.
Found running `TwoSimulatorUITests` against `relay1`. **Both reproduce with the Found running `TwoSimulatorUITests` against `relay1`. **Both reproduce with the
machine idle**, so neither is the load artifact they were first taken for. machine idle**, so neither is the load artifact they were first taken for.
**1. One-way delivery between two fresh accounts.** Alpha receives bravo's 1. One-way delivery between two fresh accounts. Alpha receives bravo's
message, test1 passes on alpha's side, 113s. Bravo never shows alpha's, across message, test1 passes on alpha's side, 113s. Bravo never shows alpha's, across
a full three-minute wait, on two separate runs. The direction is stable. a full three-minute wait, on two separate runs. The direction is stable.
@@ -828,7 +826,7 @@ therefore either never fetched them or could not open them. The first reading
of this, "the app records messages it could not deliver", was **wrong**, and of this, "the app records messages it could not deliver", was **wrong**, and
reading `E2EInbox` is what disproved it. Do not act on that theory. reading `E2EInbox` is what disproved it. Do not act on that theory.
**2. Creating a group does not produce a group.** The sheet stayed open past two 2. Creating a group does not produce a group. The sheet stayed open past two
minutes; the tree captured at failure shows Home, the buddy row present, and the minutes; the tree captured at failure shows Home, the buddy row present, and the
Groups section reading "No groups yet. Start one with your buddies." Groups section reading "No groups yet. Start one with your buddies."
`createGroup` appends to `groups` on success and `groups` is `@Published`, so a `createGroup` appends to `groups` on success and `groups` is `@Published`, so a
@@ -861,12 +859,12 @@ exactly why it works.
### Disproved, and narrowed further (same day) ### Disproved, and narrowed further (same day)
**The `existing != nil` theory is WRONG.** The suspect was The `existing != nil` theory is WRONG. The suspect was
`E2EInbox.swift:995`, where `ingest` returns `true`, consume the envelope, `E2EInbox.swift:995`, where `ingest` returns `true`, consume the envelope,
rather than holding the cursor, whenever the recipient already has its own rather than holding the cursor, whenever the recipient already has its own
session. Two things kill it: session. Two things kill it:
1. `ratchet` is nil there **only when the envelope is not type `prekey`**. An 1. `ratchet` is nil there only when the envelope is not type `prekey`. An
inbound prekey envelope always builds a session and never reaches that guard, inbound prekey envelope always builds a session and never reaches that guard,
so it cannot drop a first message. so it cannot drop a first message.
2. Measured. `testARoundTripWithANonIOSClient` **passed in 54.2s**: iOS signed 2. Measured. `testARoundTripWithANonIOSClient` **passed in 54.2s**: iOS signed
@@ -882,10 +880,10 @@ iOS round trip → passed (54.234s)
So **non-iOS → iOS works even when the iOS client already holds an outgoing So **non-iOS → iOS works even when the iOS client already holds an outgoing
session to that device.** Both directions are proven in isolation. session to that device.** Both directions are proven in isolation.
**What is left is genuine simultaneous initiation.** In the round trip the peer What is left is simultaneous initiation. In the round trip the peer
received iOS's prekey and established its session *before* replying, so both received iOS's prekey and established its session *before* replying, so both
sides ended up on one chain. In the two-simulator case both sign up, both open sides ended up on one chain. In the two-simulator case both sign up, both open
the chat, and both send **before either has processed the other's prekey**: so the chat, and both send before either has processed the other's prekey: so
each creates its own session and then meets a message on a different chain. each creates its own session and then meets a message on a different chain.
**Leading hypothesis, not confirmed:** `E2EInbox.swift:1006` calls **Leading hypothesis, not confirmed:** `E2EInbox.swift:1006` calls
@@ -934,11 +932,11 @@ p1/p2 poll {"messages":[]} {"messages":[]} ❌ both lost
p1/p2 poll second-from-Q2 / second-from-Q1 ✅ both arrive p1/p2 poll second-from-Q2 / second-from-Q1 ✅ both arrive
``` ```
**What the first attempt got wrong, and it is worth remembering.** Retaining What the first attempt got wrong, and it is worth remembering. Retaining
the retired session was necessary; storing the WRONG OBJECT made it useless. the retired session was necessary; storing the WRONG OBJECT made it useless.
`DoubleRatchet` is a `final class` on iOS and `E2ESession` is a class on `DoubleRatchet` is a `final class` on iOS and `E2ESession` is a class on
Android, so the failed `decrypt` attempt that proves the peer is on another Android, so the failed `decrypt` attempt that proves the peer is on another
chain **mutates the very instance it was handed**: skipped keys consumed, chain chain mutates the very instance it was handed: skipped keys consumed, chain
stepped. Archiving that instance stores a chain that can no longer open stepped. Archiving that instance stores a chain that can no longer open
anything, which is indistinguishable from not archiving at all. The copy in the anything, which is indistinguishable from not archiving at all. The copy in the
vault was never handed to a decrypt, so it is the pristine one: **re-read it vault was never handed to a decrypt, so it is the pristine one: **re-read it
@@ -979,7 +977,7 @@ and caught one thing that would have bounced the upload.
| Third-party SDK manifest | WebRTC ships its own (no ITMS-91061) | | Third-party SDK manifest | WebRTC ships its own (no ITMS-91061) |
| Trackers | none, grepped for firebase/analytics/crashlytics/sentry/etc. | | Trackers | none, grepped for firebase/analytics/crashlytics/sentry/etc. |
**The privacy manifest was a hard blocker.** Apple requires one for any app The privacy manifest was a hard blocker. Apple requires one for any app
touching a "required reason" API, and this app uses `UserDefaults` in five files touching a "required reason" API, and this app uses `UserDefaults` in five files
mute and pin flags, the push toggle, the chosen relay. Without it the upload is mute and pin flags, the push toggle, the chosen relay. Without it the upload is
rejected **ITMS-91053 before it ever reaches TestFlight**. The first archive rejected **ITMS-91053 before it ever reaches TestFlight**. The first archive
@@ -1004,12 +1002,12 @@ be the first to exercise them.
Both platforms hide one contact's stories without blocking them. Local on each Both platforms hide one contact's stories without blocking them. Local on each
device: no envelope, no relay call, and the author is never told. device: no envelope, no relay call, and the author is never told.
**Stored in the vault, not shared preferences.** Who somebody has muted is a Stored in the vault, not shared preferences. Who somebody has muted is a
statement about their social graph, and the point of the vault is that such a statement about their social graph, and the point of the vault is that such a
statement is not readable by anything that reaches the file. iOS puts it in the statement is not readable by anything that reaches the file. iOS puts it in the
sealed history snapshot for the same reason. sealed history snapshot for the same reason.
**A muted contact's stories are still received and still stored.** The filter A muted contact's stories are still received and still stored. The filter
runs on the way out to the strip, `Vault.visibleStories`, and `saveStories` runs on the way out to the strip, `Vault.visibleStories`, and `saveStories`
runs before it. Dropping them on receipt would make unmuting show an empty strip runs before it. Dropping them on receipt would make unmuting show an empty strip
until their next post, so muting would quietly mean something other than it says. until their next post, so muting would quietly mean something other than it says.
@@ -1020,14 +1018,14 @@ having failed to send.
## Screenshot notices (§6.27, ios#36) ## Screenshot notices (§6.27, ios#36)
A disappearing-message timer promises the message goes away; a screenshot breaks A disappearing-message timer promises the message goes away; a screenshot breaks
that silently. It cannot be prevented on either platform, so it is reported. that silently. It cannot be prevented on either platform, so it is reported.
**Both platforms refuse to let the absence of a notice mean anything.** The Both platforms refuse to let the absence of a notice mean anything. The
device that took the screenshot is the device that reports it, so a modified device that took the screenshot is the device that reports it, so a modified
build, a second phone photographing the screen, or the platform's own recorder build, a second phone photographing the screen, or the platform's own recorder
produce nothing at all. A notice means one was probably taken; **no notice means produce nothing at all. A notice means one was probably taken; **no notice means
nothing**. Neither platform counts notices, a running total would be read as a nothing**. Neither platform counts notices, a running total would be read as a
measurement, and its zero as "nobody screenshotted", which is exactly the measurement, and its zero as "nobody screenshotted", which is exactly the
inference §6.27 forbids. inference §6.27 forbids.
**The platforms genuinely differ, and the copy says so rather than hiding **The platforms differ, and the copy says so rather than hiding
it.** iOS reports a screenshot to the foreground app reliably. Android has no it.** iOS reports a screenshot to the foreground app reliably. Android has no
callback at all before API 34, and the one added there does not cover screen callback at all before API 34, and the one added there does not cover screen
recording. So the disclaimer says "you are told when their app can tell", and recording. So the disclaimer says "you are told when their app can tell", and
@@ -1037,17 +1035,17 @@ does not exist would be worse than the gap, because somebody would rely on it.
general surveillance feature rather than the narrow one it is. The timer check general surveillance feature rather than the narrow one it is. The timer check
runs BEFORE the rate-limit check on both, so a suppression window cannot make an runs BEFORE the rate-limit check on both, so a suppression window cannot make an
untimed conversation start reporting. untimed conversation start reporting.
**Last-notice times are in memory on both.** Persisting them would store who Last-notice times are in memory on both. Persisting them would store who
screenshotted whom, on disk, to enforce a five-second window. screenshotted whom, on disk, to enforce a five-second window.
## Text and link stories (§6.21, ios#32) ## Text and link stories (§6.21, ios#32)
A story may carry no attachment; the caption is then the whole story. Both A story may carry no attachment; the caption is then the whole story. Both
platforms encode, decode, store, render and post one. platforms encode, decode, store, render and post one.
**The decode sits BEFORE the attachment branch on both.** That branch The decode sits BEFORE the attachment branch on both. That branch
requires `att` for `kind: "story"`; a text story failing it falls through to requires `att` for `kind: "story"`; a text story failing it falls through to
the ordinary-message path and appears as a chat bubble in the conversation that the ordinary-message path and appears as a chat bubble in the conversation that
never expires, both of the things §6.6 says a story must not be. Each engine never expires, both of the things §6.6 says a story must not be. Each engine
has a test named for exactly that. has a test named for exactly that.
**`ref` is nullable/optional rather than a sentinel**, so nothing can read a `ref` is nullable/optional rather than a sentinel, so nothing can read a
blob id off a story that has no blob. Both platforms therefore need a `key` for blob id off a story that has no blob. Both platforms therefore need a `key` for
seen-marks and de-duplication: with no blob id, a text story is named by what seen-marks and de-duplication: with no blob id, a text story is named by what
cannot repeat for one author, when it was posted and what it said. cannot repeat for one author, when it was posted and what it said.
@@ -1060,14 +1058,14 @@ and both composers say so instead of implying more.
## Saving your own story (ios#34) ## Saving your own story (ios#34)
Both platforms can write a story you posted yourself out to the device gallery Both platforms can write a story you posted yourself out to the device gallery
before it expires. Local only; no protocol change. before it expires. Local only; no protocol change.
**Only ever your own, and the rule lives in the engine**: `Vault.isSaveable` Only ever your own, and the rule lives in the engine: `Vault.isSaveable`
on Android, `E2EInbox.isSaveable` on iOS, rather than in the button. Saving on Android, `E2EInbox.isSaveable` on iOS, rather than in the button. Saving
somebody else's story keeps a copy of something they were told would disappear, somebody else's story keeps a copy of something they were told would disappear,
and a guard living in a view is one refactor away from being lost. Both are and a guard living in a view is one refactor away from being lost. Both are
tested from both directions. tested from both directions.
**Neither asks for broad storage access.** Android writes through **Neither asks for broad storage access.** Android writes through
`MediaStore`, which needs no runtime grant; iOS asks for `.addOnly` `MediaStore`, which needs no runtime grant; iOS asks for `.addOnly`
authorisation. Requesting read access in order to perform a write would be a authorisation. Requesting read access to perform a write would be a
much larger ask than the feature. much larger ask than the feature.
**iOS needs `NSPhotoLibraryAddUsageDescription`**: without it the app is **iOS needs `NSPhotoLibraryAddUsageDescription`**: without it the app is
terminated on first save rather than warned. Android has no equivalent trap. terminated on first save rather than warned. Android has no equivalent trap.
@@ -1076,32 +1074,32 @@ it went. "Saved" alone would let somebody assume the expiry still covered it.
## Story replies (§6.20, ios#30) ## Story replies (§6.20, ios#30)
A reply to a story is an ordinary direct message to its author carrying A reply to a story is an ordinary direct message to its author carrying
`sre.sid`. Stories gain no comment thread and no viewer list. `sre.sid`. Stories gain no comment thread and no viewer list.
**`sre` carries the id and nothing else**: no caption, no thumbnail, no `sre` carries the id and nothing else: no caption, no thumbnail, no
blob. This diverges from §6.9's `re` on purpose: a quoted message may be one blob. This diverges from §6.9's `re` on purpose: a quoted message may be one
the recipient never had, but a story's author had it by definition, so the only the recipient never had, but a story's author had it by definition, so the only
way they no longer hold it is that their own timer removed it. Sending a copy way they no longer hold it is that their own timer removed it. Sending a copy
back would resurrect it at a moment they did not choose. back would resurrect it at a moment they did not choose.
**`Content.text` returns raw bytes when it has nothing to carry**, so `Content.text` returns raw bytes when it has nothing to carry, so
`storyReplyId` had to join that condition. Left out, a reply with no message id `storyReplyId` had to join that condition. Left out, a reply with no message id
and no quote would go out as plain text and arrive with the reference silently and no quote would go out as plain text and arrive with the reference silently
dropped, on exactly the path a story reply normally takes. dropped, on exactly the path a story reply normally takes.
**The disclosure comes before the first reply**, on both platforms. The disclosure comes before the first reply, on both platforms.
Replying tells the author you watched, which stories otherwise never reveal. Replying tells the author you watched, which stories otherwise never reveal.
Neither platform sends anything automatic when a story is merely opened. Neither platform sends anything automatic when a story is merely opened.
## Private story archive (ios#54) ## Private story archive (ios#54)
Your own stories, kept past their expiry. Local only; no protocol change. Your own stories, kept past their expiry. Local only; no protocol change.
**This keeps something the interface told everybody would disappear**, so This keeps something the interface told everybody would disappear, so
the shape is the argument for it being acceptable: off unless switched on, the shape is the argument for it being acceptable: off unless switched on,
labelled as what it is rather than as a feature, and **only ever your own**. labelled as what it is rather than as a feature, and **only ever your own**.
`Vault.archive` / `E2EInbox.archive` refuse anything not the author's own, the `Vault.archive` / `E2EInbox.archive` refuse anything not the author's own, the
callers filter too, but these are the guards that survive a caller being callers filter too, but these are the guards that survive a caller being
rewritten. Both are tested from both directions. rewritten. Both are tested from both directions.
**Archiving happens at the moment expiry is applied**, which is the only Archiving happens at the moment expiry is applied, which is the only
moment it can. iOS catches it on restore (a story past its expiry is never moment it can. iOS catches it on restore (a story past its expiry is never
loaded into `stories`); Android catches it in `Session.stories()` before loaded into `stories`); Android catches it in `Session.stories()` before
`saveStories` drops it. Both run repeatedly, so the fold is idempotent, `saveStories` drops it. Both run repeatedly, so the fold is idempotent,
otherwise one long-expired story would be appended on every pass. otherwise one long-expired story would be appended on every pass.
**The archive is not swept by the expiry rule.** Everything in it is expired The archive is not swept by the expiry rule. Everything in it is expired
by definition, so reusing `saveStories` would empty it on the next write. by definition, so reusing `saveStories` would empty it on the next write.
Android has a separate record and a test named for exactly that. Android has a separate record and a test named for exactly that.
Android injects the setting into `Session` as a lambda rather than reading Android injects the setting into `Session` as a lambda rather than reading
@@ -1110,12 +1108,12 @@ value reads as OFF on both platforms.
## Multi-item stories (§6.23, ios#53) ## Multi-item stories (§6.23, ios#53)
Several pictures as one sequence. Each item is an ordinary story envelope Several pictures as one sequence. Each item is an ordinary story envelope
carrying a `set` that names the sequence, its position and its size. carrying a `set` that names the sequence, its position and its size.
**One envelope per item, not one carrying an array.** The array is the One envelope per item, not one carrying an array. The array is the
obvious encoding and degrades badly: a client predating §6.23 requires a single obvious encoding and degrades badly: a client predating §6.23 requires a single
`att`, fails that guard, and renders the whole set as a message in the `att`, fails that guard, and renders the whole set as a message in the
conversation. Separate envelopes degrade to what they actually are, several conversation. Separate envelopes degrade to what they actually are, several
stories from one person, in order, which every existing build already draws. stories from one person, in order, which every existing build already draws.
**Expiry is per set, and the earliest stamp wins.** Items are required to Expiry is per set, and the earliest stamp wins. Items are required to
carry the same `exp`, but a sender can write different ones; if the latest won, carry the same `exp`, but a sender can write different ones; if the latest won,
the difference would keep part of a sequence alive after the rest had gone. That the difference would keep part of a sequence alive after the rest had gone. That
decay is confusing to look at *and* a disclosure, the gaps reveal the order the decay is confusing to look at *and* a disclosure, the gaps reveal the order the
@@ -1128,13 +1126,13 @@ no set are keyed so two of them cannot collide into one group.
## Story reactions (§6.22, ios#52) ## Story reactions (§6.22, ios#52)
One tap, sealed to the author like a story reply. One tap, sealed to the author like a story reply.
**Decoded before §6.9's reaction path on both platforms.** That path requires Decoded before §6.9's reaction path on both platforms. That path requires
`re`; a story reaction carries `sre` instead. A story is not in the conversation `re`; a story reaction carries `sre` instead. A story is not in the conversation
and has no `mid` there, so one carrying `re` would fold onto whatever message and has no `mid` there, so one carrying `re` would fold onto whatever message
happened to share that id, or onto nothing. Falling through would leave it happened to share that id, or onto nothing. Falling through would leave it
unhandled and drawn as an empty line in the thread. Both engines test it in both unhandled and drawn as an empty line in the thread. Both engines test it in both
directions, including that an ordinary thread reaction is untouched. directions, including that an ordinary thread reaction is untouched.
**Its own disclosure and its own stored key** on both, `reactDisclosureSeen`, Its own disclosure and its own stored key on both, `reactDisclosureSeen`,
not the reply one. A reaction discloses exactly what a reply discloses; what not the reply one. A reaction discloses exactly what a reply discloses; what
differs is deliberation. A reply is typed; a reaction is one tap on something differs is deliberation. A reply is typed; a reaction is one tap on something
already filling the screen, so an account told about replies has been warned already filling the screen, so an account told about replies has been warned
@@ -1153,10 +1151,10 @@ message part of the group cannot see. Both platforms expose `groupStoryReach`,
which returns *the overlap* rather than a warning, who is excluded by the which returns *the overlap* rather than a warning, who is excluded by the
story audience, and who is not a contact at all, counted separately, since a story audience, and who is not a contact at all, counted separately, since a
non-contact was never on that list. non-contact was never on that list.
**The story branch runs BEFORE group routing on both platforms.** Falling The story branch runs BEFORE group routing on both platforms. Falling
through would render a story as a message in the group's conversation: a story through would render a story as a message in the group's conversation: a story
that is a message, and one that never expires out of the thread. that is a message, and one that never expires out of the thread.
**Android adopts the group label only from an actual member.** Its roster Android adopts the group label only from an actual member. Its roster
check guards group *messages* and runs after the story branch, so without this a check guards group *messages* and 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 sender could attach the id of a group they are not in and have their story shown
as having come from it. The label is dropped to null rather than the story as having come from it. The label is dropped to null rather than the story
@@ -1168,21 +1166,21 @@ extend it.
Blocking existed in a conversation and nowhere near a story, the surface a Blocking existed in a conversation and nowhere near a story, the surface a
stranger is most likely to reach somebody through. Reporting did not exist at stranger is most likely to reach somebody through. Reporting did not exist at
all, on either platform or in the relay. all, on either platform or in the relay.
**A report is the one thing either app sends that the server can read.** A report is the one thing either app sends that the server can read.
Everything else is sealed, and the relay cannot open a story, so a report about 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. Both platforms one is only actionable if the reporter hands over what they saw. Both platforms
state that before sending, in those words: what leaves the device, who can read state that before sending, in those words: what leaves the device, who can read
it, and that it cannot be undone. Declining to include a copy is offered on it, and that it cannot be undone. Declining to include a copy is offered on
both, and says plainly there may then be nothing to act on. both, and says plainly there may then be nothing to act on.
**Both send only what they were passed.** Neither reaches into the thread for Both send only what they were passed. Neither reaches into the thread for
surrounding context, §6.25 forbids including anything the reporter did not surrounding context, §6.25 forbids including anything the reporter did not
choose to include, and a helpful "we also attached the conversation" would be choose to include, and a helpful "we also attached the conversation" would be
exactly the wrong kind of help. exactly the wrong kind of help.
**Blocking and reporting are offered together and never merged**, on both. Blocking and reporting are offered together and never merged, on both.
Blocking is the reporter's own decision and takes effect now; a report is a Blocking is the reporter's own decision and takes effect now; a report is a
request to somebody else and nothing may come of it. A combined control would request to somebody else and nothing may come of it. A combined control would
make one of the two invisible. make one of the two invisible.
**The reasons are enumerated to match the relay**, with a test on each The reasons are enumerated to match the relay, with a test on each
platform asserting the lists are equal, a reason the relay rejects is a report platform asserting the lists are equal, a reason the relay rejects is a report
somebody believed they had filed. CSAM is its own reason on both rather than somebody believed they had filed. CSAM is its own reason on both rather than
folded into abuse: it carries a different obligation, and an operator cannot folded into abuse: it carries a different obligation, and an operator cannot
@@ -1200,8 +1198,8 @@ message the server could never read anyway.
**Aggregate counts only.** Per-contact counts would be more useful and would **Aggregate counts 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 also build a record of who this account talks to and how often, the precise
thing sealing exists to keep off a server, kept instead on the device. thing sealing exists to keep off a server, kept instead on the device.
**Zero sends reports as "nothing sent yet"**, never a reassuring 0%. Zero sends reports as "nothing sent yet", never a reassuring 0%.
**Refusing costs nothing that was already spent.** On both platforms the Refusing costs nothing that was already spent. On both platforms the
gate sits where `sealed` is false exactly when no capability was taken, so gate sits where `sealed` is false exactly when no capability was taken, so
refusing cannot burn a token per attempt and drain the contact's batch. refusing cannot burn a token per attempt and drain the contact's batch.
Android injects the policy and the counter into `Conversations`/`Session` as Android injects the policy and the counter into `Conversations`/`Session` as
+1 -1
View File
@@ -35,7 +35,7 @@ attachments, voice notes, stories, calls, sealed sender, key transparency and
the encrypted vault are built and tested; a cross-platform round trip against the encrypted vault are built and tested; a cross-platform round trip against
iOS was verified on 2026-08-26. iOS was verified on 2026-08-26.
**This file said "Nothing here is an app yet" until 2026-08-28**, above a This file said "Nothing here is an app yet" until 2026-08-28, above a
tree containing the finished app, the README described the repository as it tree containing the finished app, the README described the repository as it
was when the engine landed and nothing updated it as the app grew around it. If was when the engine landed and nothing updated it as the app grew around it. If
a claim here ever disagrees with PARITY.md or with the tree, believe those, and a claim here ever disagrees with PARITY.md or with the tree, believe those, and
+5 -5
View File
@@ -100,8 +100,8 @@ android {
buildTypes { buildTypes {
release { release {
// **WebRTC ships native libraries for four ABIs and they // WebRTC ships native libraries for four ABIs and they
// dominate the APK**, about 41 MB uncompressed, of which x86 and // dominate the APK, about 41 MB uncompressed, of which x86 and
// x86_64 are ~24 MB and exist only for emulators. Real phones are // x86_64 are ~24 MB and exist only for emulators. Real phones are
// arm64-v8a, with armeabi-v7a for old ones. // arm64-v8a, with armeabi-v7a for old ones.
// //
@@ -148,8 +148,8 @@ android {
all { all {
it.systemProperty("robolectric.graphicsMode", "NATIVE") it.systemProperty("robolectric.graphicsMode", "NATIVE")
// **The screenshot tests are debug-variant tests, and // The screenshot tests are debug-variant tests, and
// `./gradlew test` runs BOTH variants.** // `./gradlew test` runs BOTH variants.
// //
// They launch a `ComponentActivity`, which is declared by // They launch a `ComponentActivity`, which is declared by
// `androidx.compose.ui:ui-test-manifest`, a `debugImplementation` // `androidx.compose.ui:ui-test-manifest`, a `debugImplementation`
@@ -180,7 +180,7 @@ dependencies {
implementation(project(":client")) implementation(project(":client"))
// A direct dependency rather than leaning on `:client` to re-export it. // A direct dependency rather than leaning on `:client` to re-export it.
// The settings screen reads bridge lines and the handshake counter itself, // The settings screen reads bridge lines and the handshake counter itself,
// so the app genuinely uses these types; borrowing them through another // so the app uses these types; borrowing them through another
// module's classpath would make `:client` unable to change its own // module's classpath would make `:client` unable to change its own
// dependencies without breaking a screen that never mentioned it. // dependencies without breaking a screen that never mentioned it.
implementation(project(":obfs4")) implementation(project(":obfs4"))
@@ -29,7 +29,7 @@ import org.junit.runner.RunWith
* *
* Run with `adb shell am instrument`, not `connectedAndroidTest`, which * Run with `adb shell am instrument`, not `connectedAndroidTest`, which
* uninstalls the app afterwards and takes the vault, and therefore the account * uninstalls the app afterwards and takes the vault, and therefore the account
* with it. The whole point is that the account survives between steps. * with it. The point is that the account survives between steps.
*/ */
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class CrossPlatformTest { class CrossPlatformTest {
@@ -31,8 +31,8 @@ import androidx.compose.ui.unit.sp
* platforms disagreeing about a security guarantee is how one of them ends up * platforms disagreeing about a security guarantee is how one of them ends up
* being the one that leaks. * being the one that leaks.
* *
* **What this defends against, stated plainly, because the alternative is * What this defends against, stated plainly, because the alternative is
* implying more:** somebody who picks up your unlocked phone. That is the * implying more: somebody who picks up your unlocked phone. That is the
* threat people actually have, and it is worth something. * threat people actually have, and it is worth something.
* *
* What it does not defend against: anyone with the device and the tools to * What it does not defend against: anyone with the device and the tools to
@@ -168,8 +168,8 @@ class AppLock(private val context: Context) {
/** /**
* When the next attempt is allowed, or null if one is allowed now. * When the next attempt is allowed, or null if one is allowed now.
* *
* **Wall-clock, and somebody who can change the device clock can skip * Wall-clock, and somebody who can change the device clock can skip
* the wait.** Said rather than hidden: this slows a person holding the * the wait. Said rather than hidden: this slows a person holding the
* phone, and it is not a defence against somebody with the device and the * phone, and it is not a defence against somebody with the device and the
* patience to work at it. What actually protects the messages is the * patience to work at it. What actually protects the messages is the
* keystore, exactly as the note at the top of this file says. * keystore, exactly as the note at the top of this file says.
@@ -186,7 +186,7 @@ class AppLock(private val context: Context) {
} }
/** /**
* Only a genuine wrong answer counts. Dismissing the prompt is not an * Only a wrong answer counts. Dismissing the prompt is not an
* attempt, counting a cancel would let the owner back themselves into a * attempt, counting a cancel would let the owner back themselves into a
* wait by changing their mind twice. See the error handling in * wait by changing their mind twice. See the error handling in
* [authenticate], which is where that distinction is actually made. * [authenticate], which is where that distinction is actually made.
@@ -238,8 +238,8 @@ class CallManager(
} }
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) = Unit override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) = Unit
override fun onSignalingChange(state: PeerConnection.SignalingState?) = Unit override fun onSignalingChange(state: PeerConnection.SignalingState?) = Unit
// **Nothing may throw out of here, and nothing may call back // Nothing may throw out of here, and nothing may call back
// into WebRTC from here.** This runs on WebRTC's signaling thread, // into WebRTC from here. This runs on WebRTC's signaling thread,
// and its JNI asserts RTC_CHECK(!env->ExceptionCheck()) on the way // and its JNI asserts RTC_CHECK(!env->ExceptionCheck()) on the way
// out, so ANY escaping Kotlin exception aborts the process: // out, so ANY escaping Kotlin exception aborts the process:
// SIGABRT, no AndroidRuntime trace, nothing to catch. // SIGABRT, no AndroidRuntime trace, nothing to catch.
@@ -649,8 +649,8 @@ fun ColumnScope.NotificationsScreen(
return return
} }
// **Rows on the kit's 72dp pitch with an avatar, like every other list // Rows on the kit's 72dp pitch with an avatar, like every other list
// in the app.** This was a lazy list of two stacked text lines with an 8dp // in the app. This was a lazy list of two stacked text lines with an 8dp
// dot beside the unread ones, a different rhythm, a different type scale // dot beside the unread ones, a different rhythm, a different type scale
// and a different unread marker from the contact list two taps away. One // and a different unread marker from the contact list two taps away. One
// app, one row grammar. // app, one row grammar.
@@ -2008,8 +2008,8 @@ fun ColumnScope.ChatScreen(
) )
} }
DropdownMenuItem( DropdownMenuItem(
// **Said "Unsend" on your own messages and did // Said "Unsend" on your own messages and did
// nothing of the kind (android#18).** `deleteMessage` // nothing of the kind (android#18). `deleteMessage`
// reaches `vault.deleteLine` and stops there, no // reaches `vault.deleteLine` and stops there, no
// envelope leaves the phone, and the recipient's copy // envelope leaves the phone, and the recipient's copy
// stays exactly where it is. Somebody who sent to the // stays exactly where it is. Somebody who sent to the
@@ -2367,7 +2367,7 @@ private fun ColumnScope.VoiceComposer(
// Only the ACTIVE recording. Starting one is the mic icon in the // Only the ACTIVE recording. Starting one is the mic icon in the
// composer row now; this is the timer and the two decisions, which the kit // composer row now; this is the timer and the two decisions, which the kit
// has no equivalent for and which genuinely need a line of their own. // has no equivalent for and which need a line of their own.
val seconds = chat.recordingSeconds ?: return val seconds = chat.recordingSeconds ?: return
run { run {
Row( Row(
@@ -2467,8 +2467,8 @@ private fun Bubble(
Text("Forwarded", fontSize = 10.sp, color = Palette.SecondaryText, Text("Forwarded", fontSize = 10.sp, color = Palette.SecondaryText,
modifier = Modifier.testTag("forwarded-marker")) modifier = Modifier.testTag("forwarded-marker"))
} }
// **A message nobody received must not look like one that was // A message nobody received must not look like one that was
// received.** That sameness is the whole of what makes a ghost // received. That sameness is the whole of what makes a ghost
// message possible; every specific cause behind one is downstream // message possible; every specific cause behind one is downstream
// of it. Only `false` marks, null means nothing was claimed, which // of it. Only `false` marks, null means nothing was claimed, which
// is every received message and everything stored before the field // is every received message and everything stored before the field
@@ -2964,8 +2964,8 @@ private fun ColumnScope.GroupMembers(
) { ) {
Text("${group.memberIds.size} members", style = MaterialTheme.typography.bodySmall) Text("${group.memberIds.size} members", style = MaterialTheme.typography.bodySmall)
Row { Row {
// **The only way a group gets a name at all after the first // The only way a group gets a name at all after the first
// one.** Names used to be fixed at creation and served by the // one. Names used to be fixed at creation and served by the
// relay; the relay has stopped holding them, so every group made // relay; the relay has stopped holding them, so every group made
// before that change reads as a placeholder on every phone and // before that change reads as a placeholder on every phone and
// there is nobody left to ask what it was called. // there is nobody left to ask what it was called.
@@ -3250,8 +3250,8 @@ fun ColumnScope.AddContactForm(
return return
} }
// **A scanned code is an ADDRESS, and this form treated everything // A scanned code is an ADDRESS, and this form treated everything
// as a username.** The scanner already prefills here, so somebody who // as a username. The scanner already prefills here, so somebody who
// scanned a card from another relay got their address looked up as a // scanned a card from another relay got their address looked up as a
// handle on ours, was told no such person exists, and had no other way // handle on ours, was told no such person exists, and had no other way
// in. One field either way, what it is, is a fact about the text, not // in. One field either way, what it is, is a fact about the text, not
@@ -4124,8 +4124,8 @@ private fun EditMessageSheet(
/** /**
* Pass a message's words on to another conversation (§6.16). * Pass a message's words on to another conversation (§6.16).
* *
* **The list is people and groups, and the message travels without its * The list is people and groups, and the message travels without its
* author.** What is sent is the text with `fwd` set, no original thread, no * author. What is sent is the text with `fwd` set, no original thread, no
* name, no quote. A forward that named its source would republish a private * name, no quote. A forward that named its source would republish a private
* message's author to somebody who was never in that conversation, which is * message's author to somebody who was never in that conversation, which is
* the one thing §6.16 exists to prevent. * the one thing §6.16 exists to prevent.
@@ -13,8 +13,8 @@ import android.content.Context
* Security, and somebody switching them off to disappear would have been wrong. * Security, and somebody switching them off to disappear would have been wrong.
* They were renamed on 2026-09-06; these are named that way from the start. * They were renamed on 2026-09-06; these are named that way from the start.
* *
* **There is no last-seen setting here, because Android does not show last * There is no last-seen setting here, because Android does not show last
* seen at all.** iOS does. That is a missing feature rather than a missing * seen at all. iOS does. That is a missing feature rather than a missing
* toggle, and adding a switch for something the app never draws would be the * toggle, and adding a switch for something the app never draws would be the
* exact decoration the parity scrub exists to catch. * exact decoration the parity scrub exists to catch.
*/ */
@@ -118,8 +118,8 @@ private fun ColumnScope.FilesSetUp(
var passphrase by remember { mutableStateOf("") } var passphrase by remember { mutableStateOf("") }
var again by remember { mutableStateOf("") } var again by remember { mutableStateOf("") }
// **"A passphrase of their own" told people to invent a second secret, // "A passphrase of their own" told people to invent a second secret,
// and iOS never did.** iOS says "your passphrase" and takes whatever is // and iOS never did. iOS says "your passphrase" and takes whatever is
// typed, so a person naturally uses their Flamenet one and it simply // typed, so a person naturally uses their Flamenet one and it simply
// works. Android's copy sent them off to make up something else, then // works. Android's copy sent them off to make up something else, then
// asked for it again weeks later, and forgetting it is the one-way door // asked for it again weeks later, and forgetting it is the one-way door
@@ -425,8 +425,8 @@ private fun bytes(count: Int): String = when {
// ---- Sheets -------------------------------------------------------------- // ---- Sheets --------------------------------------------------------------
/** /**
* **The choice between copying and linking is made here, with both consequences * The choice between copying and linking is made here, with both consequences
* written out**, rather than by a default somebody discovers later. They are not * written out, rather than by a default somebody discovers later. They are not
* two ways of doing one thing: one produces ciphertext this app owns, the other * two ways of doing one thing: one produces ciphertext this app owns, the other
* produces a pointer at a file it does not. * produces a pointer at a file it does not.
*/ */
@@ -641,8 +641,8 @@ fun ColumnScope.PickFileToSendSheet(
fontSize = 12.sp, color = Palette.SecondaryText, fontSize = 12.sp, color = Palette.SecondaryText,
) )
Spacer(Modifier.height(Space.xs)) Spacer(Modifier.height(Space.xs))
// **The photo button strips location; this one does not // The photo button strips location; this one does not
// (android#17).** A picture attached from the picker is re-encoded // (android#17). A picture attached from the picker is re-encoded
// through ImagePrep and video through MediaScrub, so no metadata // through ImagePrep and video through MediaScrub, so no metadata
// container survives. `Session.sendFile` passes the vault's bytes // container survives. `Session.sendFile` passes the vault's bytes
// straight through, EXIF and all, correct for "send this file", // straight through, EXIF and all, correct for "send this file",
@@ -87,8 +87,8 @@ object ImagePrep {
/** /**
* Decode bytes that came from somebody else, bounded. * Decode bytes that came from somebody else, bounded.
* *
* **The render paths were calling `decodeByteArray` with no options at * The render paths were calling `decodeByteArray` with no options at
* all.** Sending was careful, bounds first, then a sampled decode, but * all. Sending was careful, bounds first, then a sampled decode, but
* the two places that draw an image RECEIVED from a peer decoded at full * the two places that draw an image RECEIVED from a peer decoded at full
* size. Inbound attachments are capped at 12 MiB, and 12 MiB of cleverly * size. Inbound attachments are capped at 12 MiB, and 12 MiB of cleverly
* chosen JPEG is a few hundred megabytes of ARGB_8888 once expanded. A * chosen JPEG is a few hundred megabytes of ARGB_8888 once expanded. A
@@ -97,7 +97,7 @@ object ImagePrep {
* way for the victim to get past it. * way for the victim to get past it.
* *
* Bounds are read first so an enormous picture is never fully decoded * Bounds are read first so an enormous picture is never fully decoded
* just to discover how big it is. That is the whole point; a size check on * just to discover how big it is. That is the point; a size check on
* the byte count alone does not bound the decoded bitmap. * the byte count alone does not bound the decoded bitmap.
*/ */
fun decodeBounded(source: ByteArray, maxSide: Int = STANDARD_MAX_SIDE): Bitmap? { fun decodeBounded(source: ByteArray, maxSide: Int = STANDARD_MAX_SIDE): Bitmap? {
@@ -21,8 +21,8 @@ import javax.crypto.SecretKey
* The key does not leave this device, so neither does the vault. It is * The key does not leave this device, so neither does the vault. It is
* generated here on first launch and is not exportable or backupable, which is * generated here on first launch and is not exportable or backupable, which is
* the Android equivalent of the iOS client's `ThisDeviceOnly` Keychain item. A * the Android equivalent of the iOS client's `ThisDeviceOnly` Keychain item. A
* consequence worth stating plainly: **uninstalling the app destroys the key, * consequence worth stating plainly: uninstalling the app destroys the key,
* and every stored conversation with it.** That is the intended behaviour, * and every stored conversation with it. That is the intended behaviour,
* "deleted means deleted", but it means the vault is not a backup, and the * "deleted means deleted", but it means the vault is not a backup, and the
* manifest turns `allowBackup` off so the records cannot be restored somewhere * manifest turns `allowBackup` off so the records cannot be restored somewhere
* their key can never follow. * their key can never follow.
@@ -88,7 +88,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
// Two platforms disagreeing about a security guarantee is how one of // Two platforms disagreeing about a security guarantee is how one of
// them ends up being the one that leaks. // them ends up being the one that leaks.
// //
// It also blocks screen recording and casting, which is the point, // It also blocks screen recording and casting,
// and is not something to make optional: a setting here is a setting // and is not something to make optional: a setting here is a setting
// people leave off and then get surprised by. // people leave off and then get surprised by.
window.setFlags( window.setFlags(
@@ -223,8 +223,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
// already happening. // already happening.
CallBanner(model, current) CallBanner(model, current)
StoryComposerDialog(model, current) StoryComposerDialog(model, current)
// **Here, above the screen switch, for the same reason // Here, above the screen switch, for the same reason
// the call banner is.** Nothing installs this app but the // the call banner is. Nothing installs this app but the
// person holding the phone, so the one moment they can be // person holding the phone, so the one moment they can be
// told a fix exists is while they have it open, whatever // told a fix exists is while they have it open, whatever
// screen that happens to be. // screen that happens to be.
@@ -236,8 +236,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
ChatScreen(model, chat, current.error, current.buddies, current.avatars, ChatScreen(model, chat, current.error, current.buddies, current.avatars,
myUserId = current.userId, groups = current.groups) myUserId = current.userId, groups = current.groups)
} else { } else {
// **The content area is weighted, and the tab bar is // The content area is weighted, and the tab bar is
// not.** These used to be plain siblings, so the tab bar // not. These used to be plain siblings, so the tab bar
// sat immediately under whatever the screen had drawn: // sat immediately under whatever the screen had drawn:
// near the TOP of an empty Alerts tab, and pushed clean // near the TOP of an empty Alerts tab, and pushed clean
// OFF the bottom by the Account tab, which is taller // OFF the bottom by the Account tab, which is taller
@@ -326,8 +326,8 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
// Only a relay keeping its own accounts needs an address for reset mail. // Only a relay keeping its own accounts needs an address for reset mail.
val needsEmail = registering && site?.accountProvider == SiteConfig.AccountProvider.LOCAL val needsEmail = registering && site?.accountProvider == SiteConfig.AccountProvider.LOCAL
// **The kit's Welcome screen, which is a masthead and not a form // The kit's Welcome screen, which is a masthead and not a form
// heading.** A logo mark, a 40pt greeting, and one line saying what this // heading. A logo mark, a 40pt greeting, and one line saying what this
// is, centred, with room above it. This was a 24pt product name jammed // is, centred, with room above it. This was a 24pt product name jammed
// against a Server field, which is the layout of a login dialog rather // against a Server field, which is the layout of a login dialog rather
// than the first thing anyone sees of the app. See `DESIGN.md`. // than the first thing anyone sees of the app. See `DESIGN.md`.
@@ -752,8 +752,8 @@ internal fun ColumnScope.SignedIn(
return return
} }
// **Home is two things now, on purpose: your people, and their // Home is two things now, on purpose: your people, and their
// stories.** The conversation list, the groups and the search moved to // stories. The conversation list, the groups and the search moved to
// the Messages tab, this screen is the kit's social front page: a // the Messages tab, this screen is the kit's social front page: a
// horizontal row of contacts, then everyone's stories. Managing the // horizontal row of contacts, then everyone's stories. Managing the
// roster stays here (the Add action, and a long-press on a circle for // roster stays here (the Add action, and a long-press on a circle for
@@ -108,8 +108,8 @@ class MediaSettings(context: Context) {
/** /**
* Refuse to send at all when the send cannot be sealed (§14.5, ios#16). * Refuse to send at all when the send cannot be sealed (§14.5, ios#16).
* *
* **Off by default, and that is the honest default rather than the lazy * 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 * 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 * this account sent something to that one. Refusing turns a metadata leak
* into an undelivered message, which for most people is the worse outcome * into an undelivered message, which for most people is the worse outcome
* so the default degrades and SAYS SO. * so the default degrades and SAYS SO.
@@ -139,8 +139,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/** /**
* The relays that hold mail for this account (§20.4). * The relays that hold mail for this account (§20.4).
* *
* **Your address survives a relay disappearing; your mailbox * Your address survives a relay disappearing; your mailbox
* does not.** Mail sitting at a relay that goes away is gone, and a * does not. Mail sitting at a relay that goes away is gone, and a
* contact holding only that relay's name has nowhere to write. A * contact holding only that relay's name has nowhere to write. A
* second home fixes both, and only if it was added first, because * second home fixes both, and only if it was added first, because
* afterwards there is nothing left to publish a new record from. * afterwards there is nothing left to publish a new record from.
@@ -460,8 +460,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/** /**
* Put this device back in the transparency log if it is not in it (android#6). * Put this device back in the transparency log if it is not in it (android#6).
* *
* **A device registers exactly once, at sign-in, and never again, so a * A device registers exactly once, at sign-in, and never again, so a
* key-log append that did not land is permanent.** `signIn` calls * key-log append that did not land is permanent. `signIn` calls
* `registerDevice`; `restore` did not. If the append failed for any reason * `registerDevice`; `restore` did not. If the append failed for any reason
* a transient write, a relay mid-restart, the device stays registered * a transient write, a relay mid-restart, the device stays registered
* with no leaf, and every peer that enforces the log then refuses to send * with no leaf, and every peer that enforces the log then refuses to send
@@ -501,8 +501,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/** /**
* Forget whatever the last sign-in attempt said. * Forget whatever the last sign-in attempt said.
* *
* **Switching between signing in and creating an account has to do * Switching between signing in and creating an account has to do
* this.** The message belongs to the mode it was produced in, "that * this. The message belongs to the mode it was produced in, "that
* handle is already taken" is advice about signing up, and it stayed on * handle is already taken" is advice about signing up, and it stayed on
* screen after the person took that advice and switched to signing in, * screen after the person took that advice and switched to signing in,
* where it reads as a refusal of the thing they are now trying to do. * where it reads as a refusal of the thing they are now trying to do.
@@ -600,8 +600,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
} }
val credential = account.appPassword val credential = account.appPassword
// **The address, derived here because this is the only moment the // The address, derived here because this is the only moment the
// passphrase exists.** Android could encode an address and had no way // passphrase exists. Android could encode an address and had no way
// to produce its own, so the Account screen could not show one and // to produce its own, so the Account screen could not show one and
// nobody on another relay could be given one, the federation work was // nobody on another relay could be given one, the federation work was
// iOS-only in practice. A shared account gets the seed back from the // iOS-only in practice. A shared account gets the seed back from the
@@ -623,8 +623,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
} }
val vault = vault ?: error("the vault is not open") val vault = vault ?: error("the vault is not open")
// **The stored identity FIRST, and this line is why signing out and // The stored identity FIRST, and this line is why signing out and
// back in used to cost every contact a prompt.** Sign-in only ever // back in used to cost every contact a prompt. Sign-in only ever
// consulted the in-memory field, and sign-out clears it, so it minted // consulted the in-memory field, and sign-out clears it, so it minted
// a fresh identity and overwrote the vault's, making this phone a brand // a fresh identity and overwrote the vault's, making this phone a brand
// new device on every sign-in. Contacts saw a safety-number change, // new device on every sign-in. Contacts saw a safety-number change,
@@ -902,7 +902,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
runCatching { runCatching {
val record = current.relay.homes(fnid) ?: return@runCatching null val record = current.relay.homes(fnid) ?: return@runCatching null
// And not older than one already seen. An old record is // And not older than one already seen. An old record is
// genuinely signed, published before a second home was added, // signed, published before a second home was added,
// so a relay wanting to be somebody's only mailbox does not // so a relay wanting to be somebody's only mailbox does not
// have to forge anything; it just serves the record it saw // have to forge anything; it just serves the record it saw
// first. The signature check alone accepts that, which is what // first. The signature check alone accepts that, which is what
@@ -913,7 +913,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// Advanced only after the record was ACCEPTED. Recording the // Advanced only after the record was ACCEPTED. Recording the
// serial of a record that failed verification would let a // serial of a record that failed verification would let a
// hostile relay raise the floor with a forgery and lock out // hostile relay raise the floor with a forgery and lock out
// every later genuine record. // every later record.
io.flamenet.client.AccountIdentity.verifiedSerial(record) io.flamenet.client.AccountIdentity.verifiedSerial(record)
?.let { if (relays != null) current.vault.saveHomeSerial(fnid, it) } ?.let { if (relays != null) current.vault.saveHomeSerial(fnid, it) }
relays relays
@@ -1174,8 +1174,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/** /**
* Rename a group, and tell the other members. * Rename a group, and tell the other members.
* *
* **The only way a group gets its name back after the relay stopped * The only way a group gets its name back after the relay stopped
* holding one.** Names were fixed at creation and served by the relay for * holding one. Names were fixed at creation and served by the relay for
* ever, so nothing needed this. Now the server has dropped them, every * ever, so nothing needed this. Now the server has dropped them, every
* group that existed before the change reads as the placeholder on every * group that existed before the change reads as the placeholder on every
* phone, and there is nobody left to ask what it used to be called, the * phone, and there is nobody left to ask what it used to be called, the
@@ -1351,8 +1351,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/** /**
* Make a wallet, and show the phrase once. * Make a wallet, and show the phrase once.
* *
* **The phrase is shown and not stored anywhere it can be read back * The phrase is shown and not stored anywhere it can be read back
* casually.** It is in the returned state for as long as the screen holds * casually. It is in the returned state for as long as the screen holds
* it, and the record on disk is the ENTROPY, the words are regenerated * it, and the record on disk is the ENTROPY, the words are regenerated
* from it when somebody deliberately asks. Somebody who never writes them * from it when somebody deliberately asks. Somebody who never writes them
* down and later erases the device has nothing, which is what a recovery * down and later erases the device has nothing, which is what a recovery
@@ -1817,8 +1817,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/** /**
* Send a document from the vault into this conversation. * Send a document from the vault into this conversation.
* *
* **This is a downgrade, and the screen says so before the tap rather * This is a downgrade, and the screen says so before the tap rather
* than after.** The content key goes in the same envelope, so the * than after. The content key goes in the same envelope, so the
* classification does not stop the recipient reading it, it travels so * classification does not stop the recipient reading it, it travels so
* their phone files it the same way. Nothing here refuses on the label, * their phone files it the same way. Nothing here refuses on the label,
* because nothing here could: refusing in a client whose user holds the * because nothing here could: refusing in a client whose user holds the
@@ -1868,8 +1868,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* phone to point at, the bytes arrived over the wire, so the two storage * phone to point at, the bytes arrived over the wire, so the two storage
* modes are not a choice here. * modes are not a choice here.
* *
* **Filed under the label it arrived with, not under one this device * Filed under the label it arrived with, not under one this device
* chose.** Re-labelling somebody else's document on receipt would quietly * chose. Re-labelling somebody else's document on receipt would quietly
* launder its classification, and the number is the only thing that survived * launder its classification, and the number is the only thing that survived
* the trip. * the trip.
*/ */
@@ -2389,8 +2389,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// WebRTC callback thread it is raised from. // WebRTC callback thread it is raised from.
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
runCatching { session?.sendCallSignal(peerId, signal) } runCatching { session?.sendCallSignal(peerId, signal) }
// **This was `runCatching { }` and nothing else, so // This was `runCatching { }` and nothing else, so
// every failure here vanished.** The one that matters // every failure here vanished. The one that matters
// is a changed key: the send path REFUSES to encrypt to // is a changed key: the send path REFUSES to encrypt to
// an unreviewed identity. TOFU working exactly as // an unreviewed identity. TOFU working exactly as
// intended, and the exception was swallowed, so the // intended, and the exception was swallowed, so the
@@ -2585,8 +2585,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/** /**
* Ask to place a call. * Ask to place a call.
* *
* **The first call on this device explains what a direct connection * The first call on this device explains what a direct connection
* discloses, and waits.** A peer-to-peer call shows the other person your * discloses, and waits. A peer-to-peer call shows the other person your
* IP address; that is what makes it direct, and it is not something to * IP address; that is what makes it direct, and it is not something to
* find out afterwards. Asked once, not every time, a prompt people see * find out afterwards. Asked once, not every time, a prompt people see
* repeatedly is a prompt they stop reading. * repeatedly is a prompt they stop reading.
@@ -2670,7 +2670,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/** /**
* The live manager, for the call screen's video renderers only. Views * The live manager, for the call screen's video renderers only. Views
* otherwise take plain values; a video surface is the one thing that * otherwise take plain values; a video surface is the one thing that
* genuinely needs the track objects and the shared EGL context. * needs the track objects and the shared EGL context.
*/ */
fun activeCallManager(): CallManager? = calls fun activeCallManager(): CallManager? = calls
@@ -3726,8 +3726,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* End the session, and optionally erase this phone with it. * End the session, and optionally erase this phone with it.
* *
* Signing out used to cost every one of your contacts a prompt. It * Signing out used to cost every one of your contacts a prompt. It
* destroyed the vault unconditionally, so signing back in was a genuinely * destroyed the vault unconditionally, so signing back in was a * NEW device: every contact had the old key pinned, saw a safety-number
* 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 * change, and could not message you until they accepted it. For someone
* stepping away for an afternoon that is a strange price to pay, and it is * stepping away for an afternoon that is a strange price to pay, and it is
* why signing out and back in kept looking like the app breaking. * why signing out and back in kept looking like the app breaking.
@@ -3742,8 +3741,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/** /**
* Throw away a vault that cannot be opened, so sign-in is possible again. * Throw away a vault that cannot be opened, so sign-in is possible again.
* *
* **The "Erase and start fresh" button used to call `signOut()`, which * The "Erase and start fresh" button used to call `signOut()`, which
* erases nothing.** `signOut` defaults to `erasingDevice = false`, the * erases nothing. `signOut` defaults to `erasingDevice = false`, the
* door that deliberately KEEPS the device so coming back is silent, so it * door that deliberately KEEPS the device so coming back is silent, so it
* set the phase to SignedOut and left the unreadable vault exactly where it * set the phase to SignedOut and left the unreadable vault exactly where it
* was. The next `restore()` opened it, failed again, and went straight back * was. The next `restore()` opened it, failed again, and went straight back
@@ -7,8 +7,8 @@ import java.io.ByteArrayOutputStream
/** /**
* Strips metadata from an image on its way out, whichever door it left by. * Strips metadata from an image on its way out, whichever door it left by.
* *
* **A photo sent as a *file* used to keep its GPS; sent as a *photo* it did * A photo sent as a *file* used to keep its GPS; sent as a *photo* it did
* not** (android#17, ios#69). The picker path runs through [ImagePrep], which * not (android#17, ios#69). The picker path runs through [ImagePrep], which
* decodes to a bitmap and re-encodes, the output is pixels, with no container * decodes to a bitmap and re-encodes, the output is pixels, with no container
* to carry EXIF. `Session.sendFile` handed the stored bytes straight to * to carry EXIF. `Session.sendFile` handed the stored bytes straight to
* `sendAttachment`. Somebody who had learned "Flamenet strips location from my * `sendAttachment`. Somebody who had learned "Flamenet strips location from my
@@ -76,8 +76,8 @@ fun ColumnScope.NetworkSection(
modifier = Modifier.testTag("obfs4-status"), modifier = Modifier.testTag("obfs4-status"),
) )
// **Offers from contacts, and the reason they are a list and not a // Offers from contacts, and the reason they are a list and not a
// setting.** A bridge line routes every relay connection through a machine // setting. A bridge line routes every relay connection through a machine
// somebody else chose. Nothing applies one on arrival, a contact, or // somebody else chose. Nothing applies one on arrival, a contact, or
// whoever has taken their account, could otherwise move this phone's // whoever has taken their account, could otherwise move this phone's
// traffic onto a host they control. They could not read it, but they would // traffic onto a host they control. They could not read it, but they would
@@ -37,8 +37,8 @@ class NotificationSettings(context: Context) {
/** /**
* Put the sender's name, and a hint of the message, on the lock screen. * Put the sender's name, and a hint of the message, on the lock screen.
* *
* **Off by default, and that is the honest default rather than the * Off by default, and that is the honest default rather than the
* cautious one.** Turning it on is a real choice with a real cost: anyone * cautious one. Turning it on is a real choice with a real cost: anyone
* who can see the phone can see who is writing to you, which on some * who can see the phone can see who is writing to you, which on some
* phones is a more sensitive fact than what they said. The people for whom * phones is a more sensitive fact than what they said. The people for whom
* that is fine can have it; nobody should acquire it by installing an * that is fine can have it; nobody should acquire it by installing an
@@ -74,8 +74,8 @@ class NotificationSettings(context: Context) {
companion object { companion object {
/** /**
* **Pure, and takes the preference as an argument, so a test can * Pure, and takes the preference as an argument, so a test can
* call THIS rather than a copy of it.** The first version of the test * call THIS rather than a copy of it. The first version of the test
* beside this file reimplemented the rules against a fake and passed * beside this file reimplemented the rules against a fake and passed
* perfectly while asserting nothing about the code that ships, the * perfectly while asserting nothing about the code that ships, the
* same shape as two identically-wrong engines agreeing. A `Context` is * same shape as two identically-wrong engines agreeing. A `Context` is
@@ -54,8 +54,8 @@ class SafReferences(private val context: Context) : LocalVault.ReferenceResolver
} }
/** /**
* **Opening the stream, not asking the provider whether the file * Opening the stream, not asking the provider whether the file
* exists.** `DocumentsContract` will happily describe a document that can no * exists. `DocumentsContract` will happily describe a document that can no
* longer be read, a cloud provider with the account signed out is the * longer be read, a cloud provider with the account signed out is the
* everyday case, and a row that says it is fine and then fails on tap is * everyday case, and a row that says it is fine and then fails on tap is
* the thing this whole distinction exists to avoid. * the thing this whole distinction exists to avoid.
@@ -44,8 +44,8 @@ class Session(
/** /**
* What identifies this message to the other side. * What identifies this message to the other side.
* *
* **Null for anything stored before ids existed, and that is the * Null for anything stored before ids existed, and that is the
* whole compatibility story.** A reaction, an edit and a pin all say * whole compatibility story. A reaction, an edit and a pin all say
* "this one" by id; a line with none simply cannot be the subject of * "this one" by id; a line with none simply cannot be the subject of
* any of them. Making one up on read would be worse, two devices * any of them. Making one up on read would be worse, two devices
* would invent different ids for the same message and a reaction would * would invent different ids for the same message and a reaction would
@@ -427,15 +427,15 @@ class Session(
/** /**
* Delete cached attachment bytes that nothing refers to any more. * Delete cached attachment bytes that nothing refers to any more.
* *
* **`attachmentBytes` writes DECRYPTED bytes to the cache and nothing * `attachmentBytes` writes DECRYPTED bytes to the cache and nothing
* ever removed them.** So a disappearing message expired, its bubble went, * ever removed them. So a disappearing message expired, its bubble went,
* and the plaintext photo or voice note stayed on disk indefinitely, which * and the plaintext photo or voice note stayed on disk indefinitely, which
* is the exact failure the feature exists to prevent. Same for a story once * is the exact failure the feature exists to prevent. Same for a story once
* its expiry passed. Spec §6.11 says it plainly: attachments MUST be purged * its expiry passed. Spec §6.11 says it plainly: attachments MUST be purged
* with the message that referenced them. * with the message that referenced them.
* *
* **A sweep, not a purge at the point of deletion, and that is a * A sweep, not a purge at the point of deletion, and that is a
* deliberate choice.** Four different paths drop a row, expiry, story * deliberate choice. Four different paths drop a row, expiry, story
* expiry, delete-for-me and clear-thread, and threading "here is what I * expiry, delete-for-me and clear-thread, and threading "here is what I
* removed" out of each one would still miss anything a future path drops, * removed" out of each one would still miss anything a future path drops,
* and would never reach the orphans already on disk. Asking instead "what * and would never reach the orphans already on disk. Asking instead "what
@@ -501,8 +501,8 @@ class Session(
/** /**
* Send, and record it either way, with what happened. * Send, and record it either way, with what happened.
* *
* **This used to record only on success, and that is how a message * This used to record only on success, and that is how a message
* disappeared.** Sent to somebody signed out, nothing reached a device, so * disappeared. Sent to somebody signed out, nothing reached a device, so
* nothing was written and nothing was shown: the sender watched their own * nothing was written and nothing was shown: the sender watched their own
* text vanish out of the box with no bubble and no error, and sent it * text vanish out of the box with no bubble and no error, and sent it
* again to see whether it would stick. The alternative the group path took * again to see whether it would stick. The alternative the group path took
@@ -529,8 +529,8 @@ class Session(
/** /**
* The message this one answers (android#16). * The message this one answers (android#16).
* *
* **The preview inside it is the sender's own copy of the quoted * The preview inside it is the sender's own copy of the quoted
* words, and it has to travel.** A recipient who joined the thread * words, and it has to travel. A recipient who joined the thread
* later, or cleared their history, has never seen the message being * later, or cleared their history, has never seen the message being
* answered, a reply rendering as "> [missing]" is worse than one that * answered, a reply rendering as "> [missing]" is worse than one that
* shows what was said. * shows what was said.
@@ -917,8 +917,8 @@ class Session(
* receiving side enforces the same thing (`applyEdit` refuses an edit from * receiving side enforces the same thing (`applyEdit` refuses an edit from
* anybody but the author), so neither end is trusting the other. * anybody but the author), so neither end is trusting the other.
* *
* **Applied locally first, then sent, and rolled back if the send * Applied locally first, then sent, and rolled back if the send
* fails**, the same shape as `react`. Waiting on a round trip to see your * fails, the same shape as `react`. Waiting on a round trip to see your
* own correction appear makes it feel broken on a slow connection, and * own correction appear makes it feel broken on a slow connection, and
* leaving a correction that exists only on this phone is worse: you would * leaving a correction that exists only on this phone is worse: you would
* believe you had fixed it. * believe you had fixed it.
@@ -974,7 +974,7 @@ class Session(
* Pass somebody's words on to another conversation (§6.16). * Pass somebody's words on to another conversation (§6.16).
* *
* The forward carries the words and NOT where they came from. No * The forward carries the words and NOT where they came from. No
* author, no original thread, no quote, that is the whole point of §6.16, * author, no original thread, no quote, that is the point of §6.16,
* and it is why this sends a plain text envelope with `fwd` set rather than * and it is why this sends a plain text envelope with `fwd` set rather than
* a reply that references the original. A forward that named its source * a reply that references the original. A forward that named its source
* would republish a private message's author to somebody who was never in * would republish a private message's author to somebody who was never in
@@ -1124,7 +1124,7 @@ class Session(
// restating on the first success would mark it delivered while other // restating on the first success would mark it delivered while other
// members are still owed it, and "will send" would quietly stop being // members are still owed it, and "will send" would quietly stop being
// true for them. Until the set drains, PENDING remains the honest // true for them. Until the set drains, PENDING remains the honest
// label, the queue genuinely will keep sending. // label, the queue will keep sending.
for ((thread, at) in groupDelivered) { for ((thread, at) in groupDelivered) {
var remaining = false var remaining = false
for (i in 0 until stillWaiting.length()) { for (i in 0 until stillWaiting.length()) {
@@ -1541,8 +1541,8 @@ class Session(
/** /**
* Post a story to a group (§6.24). * Post a story to a group (§6.24).
* *
* **The group's roster is the audience, and `storyAudience` does NOT * The group's roster is the audience, and `storyAudience` does NOT
* apply.** Two different lists making two different statements, who may * apply. Two different lists making two different statements, who may
* see this account's stories, and who is in a conversation, and they will * see this account's stories, and who is in a conversation, and they will
* disagree. The roster wins: a story addressed to a group that silently * disagree. The roster wins: a story addressed to a group that silently
* dropped members would be a group message part of the group cannot see. * dropped members would be a group message part of the group cannot see.
@@ -1608,8 +1608,8 @@ class Session(
/** /**
* File a report about a MESSAGE (§6.25, android#20 / ios#12). * File a report about a MESSAGE (§6.25, android#20 / ios#12).
* *
* **`kind = "message"` has always been accepted and nothing ever sent * `kind = "message"` has always been accepted and nothing ever sent
* one.** Reporting existed on stories only, so the surface where * one. Reporting existed on stories only, so the surface where
* unsolicited contact, spam and harassment actually arrive had no way to * unsolicited contact, spam and harassment actually arrive had no way to
* tell anybody. The person being messaged by a stranger had Block, which * tell anybody. The person being messaged by a stranger had Block, which
* protects them and tells the operator nothing, so the account moves on * protects them and tells the operator nothing, so the account moves on
@@ -1936,14 +1936,14 @@ class Session(
/** /**
* Add or remove members of a sealed group. * Add or remove members of a sealed group.
* *
* **Owner only, and the relay checks a signature rather than an * Owner only, and the relay checks a signature rather than an
* account.** We hold the owner's private key iff we created the group; a * account. We hold the owner's private key iff we created the group; a
* member who is not the owner has nothing to sign with, and is refused here * member who is not the owner has nothing to sign with, and is refused here
* rather than sent to be rejected. * rather than sent to be rejected.
* *
* The version must increase. A roster signature stays valid for ever, so * The version must increase. A roster signature stays valid for ever, so
* a stale roster could otherwise be replayed to re-add somebody who was just * a stale roster could otherwise be replayed to re-add somebody who was just
* removed, with a signature that genuinely verifies. * removed, with a signature that verifies.
*/ */
fun updateSealedGroupMembers(gid: String, add: List<Long>, remove: List<Long>) { fun updateSealedGroupMembers(gid: String, add: List<Long>, remove: List<Long>) {
val secrets = vault.groupSecrets(gid) val secrets = vault.groupSecrets(gid)
@@ -2032,7 +2032,7 @@ class Session(
/** /**
* Seal a group that already exists in the clear, keeping its gid. * Seal a group that already exists in the clear, keeping its gid.
* *
* The whole point of the feature for anybody who already has groups. * The point of the feature for anybody who already has groups.
* The migration is additive by design, the relay keeps a plaintext roster * The migration is additive by design, the relay keeps a plaintext roster
* until a client replaces it, so a group made before sealing stays readable * until a client replaces it, so a group made before sealing stays readable
* in the database for ever unless something does this. * in the database for ever unless something does this.
@@ -2131,7 +2131,7 @@ class Session(
* Adopt the group-name claims that arrived as control traffic, checking * Adopt the group-name claims that arrived as control traffic, checking
* each against the relay's roster. * each against the relay's roster.
* *
* The check is the whole point. A claim naming a group we are not in * The check is the point. A claim naming a group we are not in
* or one from somebody who is not in it, is dropped. Without that, * or one from somebody who is not in it, is dropped. Without that,
* anyone able to reach us could write whatever they liked into our chat * anyone able to reach us could write whatever they liked into our chat
* list under a group heading the reader has no reason to doubt. * list under a group heading the reader has no reason to doubt.
@@ -2150,8 +2150,8 @@ class Session(
* The only way these ever reach us. The relay holds neither, by * The only way these ever reach us. The relay holds neither, by
* construction, so a member who is not told cannot fetch the group at all. * construction, so a member who is not told cannot fetch the group at all.
* *
* **Not subject to the roster check the NAME goes through, and cannot * Not subject to the roster check the NAME goes through, and cannot
* be.** That check asks the relay who is in the group, and for a group we * 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 this instead * have not joined yet, we cannot fetch it to ask. What guards this instead
* is that an invitation is accepted only for a group we hold no secrets * 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 * for: a stranger cannot overwrite a group we are really in, which would
@@ -32,8 +32,8 @@ data class SiteConfig(
/** /**
* The probe was made and came back with nothing. * The probe was made and came back with nothing.
* *
* **Without this, "still asking" and "asked and could not reach it" are * Without this, "still asking" and "asked and could not reach it" are
* the same state.** `discovered` is null in both, so a relay that never * the same state. `discovered` is null in both, so a relay that never
* answers leaves "Checking what this server uses…" on screen forever and * answers leaves "Checking what this server uses…" on screen forever and
* the form permanently unusable, no error, no retry, nothing to act on. * the form permanently unusable, no error, no retry, nothing to act on.
* Seen 2026-09-05 pointing the app at a plain-HTTP relay, which Android * Seen 2026-09-05 pointing the app at a plain-HTTP relay, which Android
@@ -179,7 +179,7 @@ private fun StoryCard(
Text(author, fontSize = 14.sp, fontWeight = FontWeight.Medium) Text(author, fontSize = 14.sp, fontWeight = FontWeight.Medium)
// The deadline, in the slot the kit gives a description. // The deadline, in the slot the kit gives a description.
// It is the one fact about a story that changes while you are // It is the one fact about a story that changes while you are
// looking at it, and the whole point of the feature. // looking at it, and the point of the feature.
Text(remaining, fontSize = 12.sp, color = Palette.SecondaryText) Text(remaining, fontSize = 12.sp, color = Palette.SecondaryText)
} }
// Unseen is marked here rather than by styling the whole card: a // Unseen is marked here rather than by styling the whole card: a
@@ -11,7 +11,7 @@ import android.provider.MediaStore
* *
* MediaStore, so no storage permission is asked for. An app writing its * MediaStore, so no storage permission is asked for. An app writing its
* own entry through `MediaStore` needs no runtime grant on any version this app * own entry through `MediaStore` needs no runtime grant on any version this app
* supports. Asking for `WRITE_EXTERNAL_STORAGE` in order to save one picture * supports. Asking for `WRITE_EXTERNAL_STORAGE` to save one picture
* would request read access to everything the person has, to perform a write, * would request read access to everything the person has, to perform a write,
* a much larger ask than the feature. * a much larger ask than the feature.
*/ */
@@ -5,8 +5,8 @@ import android.content.Context
/** /**
* Which chain node the wallet reads from. * Which chain node the wallet reads from.
* *
* **Pointing at a node is a privacy decision, and the app says so rather * Pointing at a node is a privacy decision, and the app says so rather
* than choosing quietly.** Asking a public RPC endpoint what an address holds * than choosing quietly. Asking a public RPC endpoint what an address holds
* ties that address to this device's IP in somebody else's logs, every time * ties that address to this device's IP in somebody else's logs, every time
* the screen refreshes. Running your own node is the only answer that removes * the screen refreshes. 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. * it. The default exists so the wallet works at all, not because it is private.
@@ -49,8 +49,8 @@ object WalletChain {
/** /**
* What a transfer costs, and what must stay behind. * What a transfer costs, and what must stay behind.
* *
* **An account below the rent-exempt minimum is reclaimed by the * An account below the rent-exempt minimum is reclaimed by the
* network.** Emptying a wallet to the last lamport does not leave it empty; * network. Emptying a wallet to the last lamport does not leave it empty;
* it deletes it. Anything offering to send "everything" has to stop here, * it deletes it. Anything offering to send "everything" has to stop here,
* and the two numbers are named rather than buried in an expression so that * and the two numbers are named rather than buried in an expression so that
* the reason survives. * the reason survives.
@@ -39,8 +39,8 @@ import java.util.Locale
/** /**
* The wallet. * The wallet.
* *
* **A balance this app could not read is drawn as an em dash, never as * A balance this app could not read is drawn as an em dash, never as
* zero.** Showing 0.00 because a node did not answer tells somebody their money * zero. Showing 0.00 because a node did not answer tells somebody their money
* is gone, the single worst sentence this screen can say, and one it would say * is gone, the single worst sentence this screen can say, and one it would say
* on every flaky connection. Null is a different fact from empty and is drawn * on every flaky connection. Null is a different fact from empty and is drawn
* as one. * as one.
@@ -135,8 +135,8 @@ class AttachmentPurgeTest {
} }
/** /**
* **An id containing a slash, which is the reason the transformation * An id containing a slash, which is the reason the transformation
* exists.** `attachmentBytes` writes the file as `att-<id with / -> _>`, so * exists. `attachmentBytes` writes the file as `att-<id with / -> _>`, so
* membership has to be tested against the SAME mangling. Compare raw ids to * membership has to be tested against the SAME mangling. Compare raw ids to
* mangled filenames and nothing matches, every attachment with a slash in * mangled filenames and nothing matches, every attachment with a slash in
* its id is deleted while still on screen. * its id is deleted while still on screen.
@@ -7,8 +7,8 @@ import kotlin.test.assertTrue
/** /**
* A refused-because-the-key-changed failure has to say so (android#20). * A refused-because-the-key-changed failure has to say so (android#20).
* *
* **Reported from a real session: "the calls wouldn't connect because the * Reported from a real session: "the calls wouldn't connect because the
* keys were changed. it wasn't obvious why the call failed."** It was not * keys were changed. it wasn't obvious why the call failed." It was not
* obvious because nothing said it. The call's signalling lambda was * obvious because nothing said it. The call's signalling lambda was
* `runCatching { … }` with no `onFailure`, so `IdentityChangedException`, the * `runCatching { … }` with no `onFailure`, so `IdentityChangedException`, the
* send path refusing to encrypt to an unreviewed identity, which is TOFU * send path refusing to encrypt to an unreviewed identity, which is TOFU
@@ -15,8 +15,8 @@ import kotlin.test.assertEquals
class DisplaySettingsTest { class DisplaySettingsTest {
/** /**
* **16sp with the setting off, because that is what the kit specifies * 16sp with the setting off, because that is what the kit specifies
* for a message body.** A "larger type" switch that quietly restyled every * for a message body. A "larger type" switch that quietly restyled every
* message when it was turned OFF would be a worse bug than the absence it * message when it was turned OFF would be a worse bug than the absence it
* fixes, and the number is easy to get wrong because it lives in one file * fixes, and the number is easy to get wrong because it lives in one file
* and is drawn in another. * and is drawn in another.
@@ -10,8 +10,8 @@ import kotlin.test.assertTrue
/** /**
* What the three new controls actually put on the wire (§6.15/§6.16/§6.19). * What the three new controls actually put on the wire (§6.15/§6.16/§6.19).
* *
* **The privacy rule for a forward is the one worth testing, because it is * The privacy rule for a forward is the one worth testing, because it is
* the one a "helpful" change would break.** §6.16 says a forward carries the * the one a "helpful" change would break. §6.16 says a forward carries the
* words and nothing about where they came from. The obvious improvement, * words and nothing about where they came from. The obvious improvement,
* carrying a reference so the reader can see the original, would republish a * carrying a reference so the reader can see the original, would republish a
* private message's author to somebody who was never in that conversation. So * private message's author to somebody who was never in that conversation. So
@@ -29,7 +29,7 @@ class EditForwardPinWireTest {
// ---- Forward (§6.16) ------------------------------------------------- // ---- Forward (§6.16) -------------------------------------------------
/** /**
* The whole point of the feature: forwarded, and no trail back. * The point of the feature: forwarded, and no trail back.
*/ */
@Test @Test
fun `a forward carries the words and nothing about their origin`() { fun `a forward carries the words and nothing about their origin`() {
@@ -7,8 +7,8 @@ import kotlin.test.assertTrue
/** /**
* Files asks for the Flamenet passphrase, not a second secret. * Files asks for the Flamenet passphrase, not a second secret.
* *
* **The screen used to say the files were encrypted "with a passphrase of * The screen used to say the files were encrypted "with a passphrase of
* their own", and iOS never said anything like it.** iOS says "your * their own", and iOS never said anything like it. iOS says "your
* passphrase" and takes whatever is typed, so people use their Flamenet one * passphrase" and takes whatever is typed, so people use their Flamenet one
* and it works. Android's copy sent them off to invent something else, and * and it works. Android's copy sent them off to invent something else, and
* forgetting it is a one-way door: the label keys are derived from the * forgetting it is a one-way door: the label keys are derived from the
@@ -125,7 +125,7 @@ class GroupNameClaimTest {
} }
/** /**
* The whole point. A stranger naming a group they are not in must get *A stranger naming a group they are not in must get
* nowhere, however well-formed their envelope is. * nowhere, however well-formed their envelope is.
*/ */
@Test @Test
@@ -61,8 +61,8 @@ class InboundControlTest {
} }
/** /**
* **The case an allowlist cannot cover, and the reason the app branches * The case an allowlist cannot cover, and the reason the app branches
* on `isRenderable` rather than on a list of kinds it knows.** A control * on `isRenderable` rather than on a list of kinds it knows. A control
* envelope from a NEWER peer has a kind nothing here has heard of. Keying on * envelope from a NEWER peer has a kind nothing here has heard of. Keying on
* known kinds ships a bubble full of JSON to every older install the first * known kinds ships a bubble full of JSON to every older install the first
* time a new control message is added on the other platform. * time a new control message is added on the other platform.
@@ -26,8 +26,8 @@ import org.robolectric.annotation.Config
* transcript does record a `mid`, `MessageReactionTest` folds a reaction onto * transcript does record a `mid`, `MessageReactionTest` folds a reaction onto
* a message by id, so the stated reason is already false. * a message by id, so the stated reason is already false.
* *
* But relay#46's rule is the point: **do not flip a claim because the code * But relay#46's rule is the point: do not flip a claim because the code
* looks present; flip it because a run proved it, and say which run.** * looks present; flip it because a run proved it, and say which run.
* `applyEdit` was implemented and wired with nothing exercising it, because * `applyEdit` was implemented and wired with nothing exercising it, because
* `InboundControlTest` covers the codec and says in its own header that the * `InboundControlTest` covers the codec and says in its own header that the
* app-side application needs a `Vault` and is out of scope there. This is that * app-side application needs a `Vault` and is out of scope there. This is that
@@ -8,8 +8,8 @@ import kotlin.test.assertTrue
/** /**
* A photo sent as a file must lose its location, like one sent as a photo. * A photo sent as a file must lose its location, like one sent as a photo.
* *
* **The bug this pins was found by an audit, not by a test, because no test * The bug this pins was found by an audit, not by a test, because no test
* went down the file path at all** (android#17, ios#69). The picker path was * went down the file path at all (android#17, ios#69). The picker path was
* checked and correct; `Session.sendFile` handed stored bytes straight to * checked and correct; `Session.sendFile` handed stored bytes straight to
* `sendAttachment`. The two doors disagreed, and the safer one was the one that * `sendAttachment`. The two doors disagreed, and the safer one was the one that
* looked like a shortcut. * looked like a shortcut.
@@ -8,8 +8,8 @@ import kotlin.test.assertTrue
/** /**
* What the Network section says is happening. * What the Network section says is happening.
* *
* **A settings screen that reads the stored line and calls it a status is * A settings screen that reads the stored line and calls it a status is
* the failure this feature is built around.** A saved bridge that is not there * the failure this feature is built around. A saved bridge that is not there
* leaves the app connecting directly; the preference cannot tell you that and * leaves the app connecting directly; the preference cannot tell you that and
* a completed handshake can. `statusLine` is lifted out of the Composable * a completed handshake can. `statusLine` is lifted out of the Composable
* precisely so this can be asserted, a string built inline in a `Text(...)` is * precisely so this can be asserted, a string built inline in a `Text(...)` is
@@ -28,7 +28,7 @@ import org.robolectric.annotation.Config
* *
* Nothing expires. There is no TTL and nothing is discarded: a contact * Nothing expires. There is no TTL and nothing is discarded: a contact
* who reinstalls in a month gets it in a month. Timing out would mean deleting * 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. * something somebody wrote to tidy up a label.
* *
* The honest limit, which the UI wording has to respect: the queue is on * The honest limit, which the UI wording has to respect: the queue is on
* the sender's phone, because an envelope is sealed to a device's keys and * the sender's phone, because an envelope is sealed to a device's keys and
@@ -223,7 +223,7 @@ class OutboxTest {
* look broken to the person reading the thread. * look broken to the person reading the thread.
* *
* Serves a REAL bundle built from a second identity, not a hand-written * Serves a REAL bundle built from a second identity, not a hand-written
* fake, so the send has to complete genuine X3DH against it. A fake bundle * fake, so the send has to complete X3DH against it. A fake bundle
* would agree with whatever the client did, including the wrong thing. * would agree with whatever the client did, including the wrong thing.
*/ */
@Test @Test
@@ -48,7 +48,7 @@ class StoryReportTest {
} }
/** /**
* Without one, the picker forces a person to mis-categorise in order to * Without one, the picker forces a person to mis-categorise to
* report at all. * report at all.
*/ */
@Test @Test
@@ -50,8 +50,8 @@ class WalletDisplayTest {
// ---- What can actually be sent --------------------------------------- // ---- What can actually be sent ---------------------------------------
/** /**
* **An account below the rent-exempt minimum is RECLAIMED by the * An account below the rent-exempt minimum is RECLAIMED by the
* network.** Emptying a wallet to the last lamport does not leave it empty, * network. Emptying a wallet to the last lamport does not leave it empty,
* it deletes it, so "send everything" has to stop short of everything. * it deletes it, so "send everything" has to stop short of everything.
*/ */
@Test @Test
@@ -15,8 +15,8 @@ import io.flamenet.e2e.FNID
* the address. No server issues it, so no server can take it away. * the address. No server issues it, so no server can take it away.
* *
* The practical consequence is the one worth stating: sign in with the same * The practical consequence is the one worth stating: sign in with the same
* passphrase at a relay that has never heard of you and you get **the same * passphrase at a relay that has never heard of you and you get the same
* address**. Contacts who accepted you before still see the same person. A * address. Contacts who accepted you before still see the same person. A
* relay shutting down costs a mailbox, not an identity. * relay shutting down costs a mailbox, not an identity.
* *
* ## Why it is here and not in the engine * ## Why it is here and not in the engine
@@ -124,8 +124,8 @@ object AccountIdentity {
* Relays named by a record, but only once it verifies AND is not older than * Relays named by a record, but only once it verifies AND is not older than
* one already seen. * one already seen.
* *
* **A signature alone does not stop a rollback, and that is the whole * A signature alone does not stop a rollback, and that is the whole
* reason the serial exists.** An older record is *genuinely signed*: it was * reason the serial exists. An older record is *signed*: it was
* published by the account holder, before they added a second home. A relay * published by the account holder, before they added a second home. A relay
* that wants to be somebody's only mailbox does not have to forge anything * 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 * it serves the record it saw first, that record verifies perfectly, and
@@ -141,7 +141,7 @@ object AccountIdentity {
* re-read is the ordinary case. Only strictly lower is a rollback. * re-read is the ordinary case. Only strictly lower is a rollback.
* *
* A refusal here is not proof of an attack, and the caller must not * A refusal here is not proof of an attack, and the caller must not
* present it as one. A relay that has genuinely fallen behind serves an old * present it as one. A relay that has fallen behind serves an old
* record for honest reasons; what the client can say is that it has seen a * record for honest reasons; what the client can say is that it has seen a
* newer one, and that it will keep using it. * newer one, and that it will keep using it.
*/ */
@@ -16,7 +16,7 @@ import java.security.SecureRandom
* The passphrase never leaves the device. It is stretched here and only * The passphrase never leaves the device. It is stretched here and only
* the derived secret is sent, which is why sign-in is two round trips rather * the derived secret is sent, which is why sign-in is two round trips rather
* than one: the salt and iteration count are per account, and the service * than one: the salt and iteration count are per account, and the service
* cannot derive the secret itself. That is the entire point, and collapsing it * cannot derive the secret itself. That is the point, and collapsing it
* to one call would mean sending the passphrase. * to one call would mean sending the passphrase.
* *
* Used only for relays whose accounts live in the shared service. A self-hosted * Used only for relays whose accounts live in the shared service. A self-hosted
@@ -10,14 +10,14 @@ import org.json.JSONObject
/** /**
* Talking to a chain node, for the wallet. * Talking to a chain node, for the wallet.
* *
* **The node is somebody else's server and it is not trusted with * The node is somebody else's server and it is not trusted with
* anything.** It is asked what a public address holds and handed transactions * anything. It is asked what a public address holds and handed transactions
* that are already signed; it never sees a key, and it cannot move money. What * that are already signed; it never sees a key, and it cannot move money. What
* it CAN do is lie about a balance or withhold a history, so nothing here * 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". * treats an answer as authoritative beyond "this is what that node said".
* *
* **Which node somebody points at is a privacy decision, and the app has to * Which node somebody points at is a privacy decision, and the app has to
* say so.** Asking a public RPC endpoint for the balance of an address ties * say so. Asking a public RPC endpoint for the balance of an address ties
* that address to this device's IP, on somebody else's logs. Running your own * that address to this device's IP, on somebody else's logs. Running your own
* node is the only answer that removes it; the default exists so the wallet * node is the only answer that removes it; the default exists so the wallet
* works at all, not because it is private. * works at all, not because it is private.
@@ -19,8 +19,8 @@ import io.flamenet.e2e.Vault
* recipient device, each under its own ratchet. A client that encrypts to * recipient device, each under its own ratchet. A client that encrypts to
* "the" device works perfectly in testing, where everybody has one. * "the" device works perfectly in testing, where everybody has one.
* *
* **Every send and every receive moves ratchet state, and unsaved ratchet * Every send and every receive moves ratchet state, and unsaved ratchet
* state is a broken conversation.** Each operation persists before it returns. * state is a broken conversation. Each operation persists before it returns.
* That costs a write per message and buys the difference between an app that * That costs a write per message and buys the difference between an app that
* survives being killed and one that has to be reinstalled. * survives being killed and one that has to be reinstalled.
*/ */
@@ -122,8 +122,8 @@ class Conversations(
* Whether this arrived sealed (§14). * Whether this arrived sealed (§14).
* *
* For a sealed message [fromUser] and [fromDevice] come from inside * For a sealed message [fromUser] and [fromDevice] come from inside
* the blob, and are trustworthy **only because the inner envelope * the blob, and are trustworthy only because the inner envelope
* decrypted** under a session those identities selected. Never surface * decrypted under a session those identities selected. Never surface
* a sealed message's sender on a path that skips that. * a sealed message's sender on a path that skips that.
*/ */
val sealed: Boolean = false, val sealed: Boolean = false,
@@ -689,8 +689,8 @@ class Conversations(
proto = o.optInt("proto", 1), proto = o.optInt("proto", 1),
) )
} }
// **A stub cannot open a session, so the two lists must be merged, // A stub cannot open a session, so the two lists must be merged,
// not chosen between.** `listDevices` returns identity keys only, no // not chosen between. `listDevices` returns identity keys only, no
// signed prekey, no signature, which is all an EXISTING session needs. // signed prekey, no signature, which is all an EXISTING session needs.
// A device with no session needs a full bundle, and `E2ESession.initiate` // A device with no session needs a full bundle, and `E2ESession.initiate`
// verifies the signed prekey's signature before anything else: handed a // verifies the signed prekey's signature before anything else: handed a
@@ -726,8 +726,8 @@ class Conversations(
// where the key route returned one. // where the key route returned one.
stubs.map { full[it.deviceId] ?: it } stubs.map { full[it.deviceId] ?: it }
} }
// **The send path had no logging at all, and that is why a message // The send path had no logging at all, and that is why a message
// that never left the phone was indistinguishable from one that did.** // that never left the phone was indistinguishable from one that did.
// The relay records refusals, so an absent POST looks identical to a // The relay records refusals, so an absent POST looks identical to a
// successful one from the server side; only this side knows. // successful one from the server side; only this side knows.
System.err.println("fnmsg-send: to=$toUserId stubs=${stubs.size} " + System.err.println("fnmsg-send: to=$toUserId stubs=${stubs.size} " +
@@ -754,7 +754,7 @@ class Conversations(
// //
// That is the whole bug behind "I wasn't signed in on the other end and // That is the whole bug behind "I wasn't signed in on the other end and
// the message got lost": signing out deregisters the device, so the // the message got lost": signing out deregisters the device, so the
// recipient genuinely has none, and this is the one path that has to // recipient has none, and this is the one path that has to
// notice. iOS already threw here; this side never did. // notice. iOS already threw here; this side never did.
// //
// Recorded in `failed` rather than thrown, because the group senders // Recorded in `failed` rather than thrown, because the group senders
@@ -971,7 +971,7 @@ class Conversations(
throw IdentityChangedException(0L) throw IdentityChangedException(0L)
} }
// Full bundles only when something genuinely needs establishing. Same // Full bundles only when something needs establishing. Same
// stub/full union as the local path and for the same reason: a stub // stub/full union as the local path and for the same reason: a stub
// cannot open a session, and a device silently dropping out of the // cannot open a session, and a device silently dropping out of the
// fan-out is how somebody stops receiving on their tablet with nothing // fan-out is how somebody stops receiving on their tablet with nothing
@@ -1132,8 +1132,8 @@ class Conversations(
/** /**
* Read the inbox, decrypt what we can, and advance the watermark. * Read the inbox, decrypt what we can, and advance the watermark.
* *
* The watermark advances **per message, after it is decrypted and its * The watermark advances per message, after it is decrypted and its
* session is stored**, not once for the batch. Advancing for the batch up * session is stored, not once for the batch. Advancing for the batch up
* front means a crash halfway loses every message after the crash, and the * front means a crash halfway loses every message after the crash, and the
* relay will never offer those ids again. * relay will never offer those ids again.
* *
@@ -1264,8 +1264,8 @@ class Conversations(
val existing = existingSession(fromDevice) val existing = existingSession(fromDevice)
val retired = runCatching { vault.loadRetiredSession(fromDevice) }.getOrNull() val retired = runCatching { vault.loadRetiredSession(fromDevice) }.getOrNull()
// **Which slot the winner belongs in is not the same question // Which slot the winner belongs in is not the same question
// as which chain opened it**, and conflating the two swaps them. // as which chain opened it, and conflating the two swaps them.
// Each branch writes back deliberately. // Each branch writes back deliberately.
val fromCurrent = openWith(existing, envelope) val fromCurrent = openWith(existing, envelope)
val fromRetired = if (fromCurrent == null) openWith(retired, envelope) else null val fromRetired = if (fromCurrent == null) openWith(retired, envelope) else null
@@ -1283,8 +1283,8 @@ class Conversations(
Wire.unb64(envelope.handshake!!.getString("ik_sig")) Wire.unb64(envelope.handshake!!.getString("ik_sig"))
}.getOrNull() }.getOrNull()
val claimant = opened?.claimedFromUser ?: message.fromUser val claimant = opened?.claimedFromUser ?: message.fromUser
// **The gate applies in whichever keyspace the // The gate applies in whichever keyspace the
// sender is in.** A first handshake from another relay // sender is in. A first handshake from another relay
// carries a user id that means nothing here, so // carries a user id that means nothing here, so
// checking it against the local pins would either // checking it against the local pins would either
// compare it to an unrelated contact's keys or fall // compare it to an unrelated contact's keys or fall
@@ -1353,8 +1353,8 @@ class Conversations(
// renegotiation, a device claiming a different user now is // renegotiation, a device claiming a different user now is
// lying, and its message files under the user it is bound to. // lying, and its message files under the user it is bound to.
val claimed = opened?.claimedFromUser ?: message.fromUser val claimed = opened?.claimedFromUser ?: message.fromUser
// **A federated sender is resolved in the ADDRESS keyspace, // A federated sender is resolved in the ADDRESS keyspace,
// never the local one.** Their `from_user` is a row number on // never the local one. Their `from_user` is a row number on
// their relay; binding a device to it here would either file // their relay; binding a device to it here would either file
// the message under a local contact who happens to share the // the message under a local contact who happens to share the
// number, or, where the relay recorded no sender at all, // number, or, where the relay recorded no sender at all,
@@ -1579,8 +1579,8 @@ class Conversations(
* act means rotating for everybody and redistributing to the rest, which is * act means rotating for everybody and redistributing to the rest, which is
* why in practice it never happens. * why in practice it never happens.
* *
* **This moves them back onto the shared key; it does not silence * This moves them back onto the shared key; it does not silence
* them.** A complete cut-off is this AND [rotateDeliveryKey], every other * them. A complete cut-off is this AND [rotateDeliveryKey], every other
* contact recovers from that on its own now, because the new key rides the * contact recovers from that on its own now, because the new key rides the
* same prelude a batch does. Two doors, and closing one does not close the * same prelude a batch does. Two doors, and closing one does not close the
* other. * other.
@@ -1633,7 +1633,7 @@ class Conversations(
val key = vault.loadDelivery()?.key ?: return val key = vault.loadDelivery()?.key ?: return
val payload = SealedSender.deliveryKeyEnvelope( val payload = SealedSender.deliveryKeyEnvelope(
key, groupId = groupId, groupName = name) key, groupId = groupId, groupName = name)
// Our own account is included, and that is the point. Fan-out // Our own account is included. Fan-out
// excludes us everywhere else, so a second phone we own would be told // excludes us everywhere else, so a second phone we own would be told
// by nobody: the relay has no name to serve it and the members only // by nobody: the relay has no name to serve it and the members only
// send to each other. The fan-out loop already skips our own device // send to each other. The fan-out loop already skips our own device
@@ -8,8 +8,8 @@ import okhttp3.Request
/** /**
* Fetches a preview for a link the user is about to send (spec §6.12). * Fetches a preview for a link the user is about to send (spec §6.12).
* *
* **This runs on the SENDER's device, and the result travels inside the * This runs on the SENDER's device, and the result travels inside the
* ciphertext.** The obvious implementation, each recipient fetching the URL to * ciphertext. The obvious implementation, each recipient fetching the URL to
* draw a card, is the one that must not be built: it tells the site the * draw a card, is the one that must not be built: it tells the site the
* reader's IP address, that they received a link, and roughly when they opened * 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 * it, once per member on a group message. A messenger that hides the message
@@ -243,8 +243,8 @@ class LocalVault(
* value, wrapped to the recipient, the same grant the server used to * value, wrapped to the recipient, the same grant the server used to
* broker, minus the broker. * broker, minus the broker.
* *
* **Binds the whole canonical label encoding, not just the * Binds the whole canonical label encoding, not just the
* classification.** Deriving from the level alone would give one key to * classification. Deriving from the level alone would give one key to
* every compartment at that level, which is precisely the separation * every compartment at that level, which is precisely the separation
* compartments exist for. * compartments exist for.
*/ */
@@ -322,8 +322,8 @@ class LocalVault(
.put("bookmark", node.bookmark ?: JSONObject.NULL) .put("bookmark", node.bookmark ?: JSONObject.NULL)
/** /**
* **`optString` is not safe on a nullable field, and the JVM will not * `optString` is not safe on a nullable field, and the JVM will not
* tell you.** Android's bundled `org.json` coerces a JSON `null` to the * tell you. Android's bundled `org.json` coerces a JSON `null` to the
* four-character string `"null"`; the Maven `org.json` these tests run * four-character string `"null"`; the Maven `org.json` these tests run
* against returns `""`. Every nullable field below therefore passed on a * against returns `""`. Every nullable field below therefore passed on a
* laptop and broke on a phone: * laptop and broke on a phone:
@@ -529,15 +529,15 @@ class LocalVault(
/** /**
* Turn a reference into an encrypted copy, in place. * Turn a reference into an encrypted copy, in place.
* *
* **The bridge between the two ways of holding a file, and the reason * The bridge between the two ways of holding a file, and the reason
* both exist.** Referencing avoids duplicating anything; vaulting is what * both exist. Referencing avoids duplicating anything; vaulting is what
* makes a classification a control rather than a note. Most files want the * makes a classification a control rather than a note. Most files want the
* first and a few want the second, and nobody knows which is which until * first and a few want the second, and nobody knows which is which until
* after they have added them, so this promotes one without re-picking it. * after they have added them, so this promotes one without re-picking it.
* *
* It does NOT delete the person's original: that is their file, and * It does NOT delete the person's original: that is their file, and
* removing it is a separate decision the interface asks about afterwards. * removing it is a separate decision the interface asks about afterwards.
* Until they do, this genuinely is two copies, the honest trade for having * Until they do, this is two copies, the honest trade for having
* the encrypted one. * the encrypted one.
* *
* Returns the bookmark the original was read from, so a caller can offer * Returns the bookmark the original was read from, so a caller can offer
@@ -565,7 +565,7 @@ class LocalVault(
storage = Storage.VAULT, storage = Storage.VAULT,
// The bookmark goes. Keeping it would leave a vaulted row still // The bookmark goes. Keeping it would leave a vaulted row still
// able to read through to a plaintext original, so the encrypted // able to read through to a plaintext original, so the encrypted
// copy would not be the only way in, which is the entire point of // copy would not be the only way in, which is the point of
// vaulting it. // vaulting it.
bookmark = null, bookmark = null,
wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, node.label), wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, node.label),
@@ -166,8 +166,8 @@ class MailboxHoming(
/** /**
* Stop keeping a mailbox at [relay]. * Stop keeping a mailbox at [relay].
* *
* **The shorter list is published first, and the local credential goes * The shorter list is published first, and the local credential goes
* only if that worked.** Forgetting locally while senders are still told to * only if that worked. Forgetting locally while senders are still told to
* deliver there leaves mail arriving at a mailbox this device no longer * deliver there leaves mail arriving at a mailbox this device no longer
* reads, indistinguishable, from the sender's side, from delivery. * reads, indistinguishable, from the sender's side, from delivery.
* *
@@ -243,8 +243,8 @@ class MessengerApi(
* through `send()`, which does `JSONObject(text)` and throws on an array, * through `send()`, which does `JSONObject(text)` and throws on an array,
* and the throw was swallowed by `runCatching { }.getOrNull()`. * and the throw was swallowed by `runCatching { }.getOrNull()`.
* *
* The result: **every lookup reported "no account called X here" even when * The result: every lookup reported "no account called X here" even when
* the relay had found the account and returned it.** Adding a contact was * the relay had found the account and returned it. Adding a contact was
* impossible on Android, which is the first thing anyone does. Measured * impossible on Android, which is the first thing anyone does. Measured
* against relay.flamenet.io, the relay returned * against relay.flamenet.io, the relay returned
* `[{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]` while the * `[{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]` while the
@@ -7,8 +7,8 @@ import java.util.concurrent.TimeUnit
/** /**
* Which bridge, if any, the app's HTTP clients route through. * Which bridge, if any, the app's HTTP clients route through.
* *
* **A holder rather than a constructor argument, because there are ten * A holder rather than a constructor argument, because there are ten
* construction sites and no way to be sure of catching them all.** `RelayClient`, * construction sites and no way to be sure of catching them all. `RelayClient`,
* `MessengerApi`, `AccountsClient`, `RelayAccounts`, `SignupProofOfWork`, * `MessengerApi`, `AccountsClient`, `RelayAccounts`, `SignupProofOfWork`,
* `RelayDiscovery` and the background service each build their own client, some * `RelayDiscovery` and the background service each build their own client, some
* from default parameters evaluated at field-initialisation time. Threading a * from default parameters evaluated at field-initialisation time. Threading a
@@ -56,8 +56,7 @@ class RelayAccounts(
account(post("account/signup", body())) account(post("account/signup", body()))
} catch (asked: RelayException.ChallengeRequired) { } catch (asked: RelayException.ChallengeRequired) {
// Rethrown, not swallowed, when we cannot answer. A difficulty // Rethrown, not swallowed, when we cannot answer. A difficulty
// above the cap or a search that ran out leaves this app genuinely // above the cap or a search that ran out leaves this app // unable to sign up here, and "update the app" is then the true
// unable to sign up here, and "update the app" is then the true
// sentence, quietly failing with something else would send // sentence, quietly failing with something else would send
// somebody looking in the wrong place. // somebody looking in the wrong place.
val solved = SignupProofOfWork.satisfy(base.toString(), http) ?: throw asked val solved = SignupProofOfWork.satisfy(base.toString(), http) ?: throw asked
@@ -11,8 +11,8 @@ import java.util.UUID
* design decision rather than an oversight to work around. A credential good * design decision rather than an oversight to work around. A credential good
* for every route on every one of an account's devices means a token lifted * for every route on every one of an account's devices means a token lifted
* from one device drains another's prekey pool and reads its inbox. What the * from one device drains another's prekey pool and reads its inbox. What the
* relay wants instead is a short-lived token scoped to **one device and one * relay wants instead is a short-lived token scoped to one device and one
* relay**, plus a per-request signature proving the sender holds the identity * relay, plus a per-request signature proving the sender holds the identity
* key that token names, so a stolen token, on its own, is inert. * key that token names, so a stolen token, on its own, is inert.
* *
* The nonce is not decoration. Without it, two inbox polls in the same * The nonce is not decoration. Without it, two inbox polls in the same
@@ -75,8 +75,8 @@ class RelayClient(
* host where the bridge shares the relay's address, would not even help * host where the bridge shares the relay's address, would not even help
* against IP blocking. * against IP blocking.
* *
* **The bridge is a separate hop, so obfs4 hides traffic from the * The bridge is a separate hop, so obfs4 hides traffic from the
* NETWORK, not from us.** It is not a privacy feature against the * NETWORK, not from us. It is not a privacy feature against the
* operator, and must never be described as one. * operator, and must never be described as one.
*/ */
fun defaultHttpClient(bridge: Obfs4Bridge? = null): OkHttpClient = fun defaultHttpClient(bridge: Obfs4Bridge? = null): OkHttpClient =
@@ -86,7 +86,7 @@ class RelayClient(
// sealed blobs and envelope traffic surviving on disk after the user // sealed blobs and envelope traffic surviving on disk after the user
// deletes a chat is exactly what "deleted means deleted" forbids. // deletes a chat is exactly what "deleted means deleted" forbids.
.cache(null) .cache(null)
// Installed unconditionally, and that is the point. The // Installed unconditionally. The
// parameter above is for tests and interop, which pin one // parameter above is for tests and interop, which pin one
// bridge. Everything the app builds passes nothing, so if the // bridge. Everything the app builds passes nothing, so if the
// factory were only installed when a bridge was already known, // factory were only installed when a bridge was already known,
@@ -176,8 +176,8 @@ class RelayClient(
data class Keys(val fnid: String?, val bundles: List<PeerBundle>) data class Keys(val fnid: String?, val bundles: List<PeerBundle>)
/** /**
* **The `fnid` is not decoration; without it the key log cannot be * The `fnid` is not decoration; without it the key log cannot be
* checked.** The relay files a leaf under `fnid:<address>` once an account * checked. The relay files a leaf under `fnid:<address>` once an account
* has claimed one, and under `local:<audience>/<id>` before that. A * has claimed one, and under `local:<audience>/<id>` before that. A
* verifier that does not know the address computes the other subject, finds * verifier that does not know the address computes the other subject, finds
* no matching leaf, and refuses to send, reporting "has published keys, * no matching leaf, and refuses to send, reporting "has published keys,
@@ -254,7 +254,7 @@ class RelayClient(
* submission is sealed: the relay is not told who is sending, and * submission is sealed: the relay is not told who is sending, and
* `fromDevice` is omitted entirely rather than sent as a courtesy. * `fromDevice` is omitted entirely rather than sent as a courtesy.
* *
* Omitting `from_device` is the whole point, not tidiness. Sending it * Omitting `from_device` is the point, not tidiness. Sending it
* alongside a sealed payload would hand the relay the sender's identity at * alongside a sealed payload would hand the relay the sender's identity at
* request time, so the seal would hide the sender from the stored row and * request time, so the seal would hide the sender from the stored row and
* from nobody else, strictly worse than not sealing, because it looks like * from nobody else, strictly worse than not sealing, because it looks like
@@ -399,8 +399,8 @@ class RelayClient(
/** /**
* `POST /e2e/moderation/report`, file a report (§6.25). * `POST /e2e/moderation/report`, file a report (§6.25).
* *
* **The one call that deliberately sends readable content to the * The one call that deliberately sends readable content to the
* server.** Everything else this client sends is sealed. A relay that * server. Everything else this client sends is sealed. A relay that
* cannot open a reported story cannot act on an accusation about it, so * cannot open a reported story cannot act on an accusation about it, so
* the reporter's own copy travels with the report, and the caller is * the reporter's own copy travels with the report, and the caller is
* required to have obtained consent naming that, before reaching here. * required to have obtained consent naming that, before reaching here.
@@ -475,8 +475,8 @@ class RelayClient(
* client-side against the contact list. * client-side against the contact list.
* *
* This is the exact shape the iOS client got wrong: it required a `name` * This is the exact shape the iOS client got wrong: it required a `name`
* on each member that the relay has never sent, so **every group decode * on each member that the relay has never sent, so every group decode
* failed** and groups were completely broken while looking implemented. * failed and groups were completely broken while looking implemented.
*/ */
data class Group( data class Group(
val id: String, val id: String,
@@ -658,7 +658,7 @@ class RelayClient(
* Prekey bundles for a remote address. * Prekey bundles for a remote address.
* *
* Pops a one-time prekey on the far side, exactly as the local * Pops a one-time prekey on the far side, exactly as the local
* `/e2e/keys` route does. Ask only when there is genuinely a session to * `/e2e/keys` route does. Ask only when there is a session to
* open, never to find out who somebody is. * open, never to find out who somebody is.
*/ */
fun remoteBundles(fnid: String, relay: String): JSONArray = fun remoteBundles(fnid: String, relay: String): JSONArray =
@@ -670,7 +670,7 @@ class RelayClient(
* The reply says how many copies were taken, which addresses the far relay * The reply says how many copies were taken, which addresses the far relay
* did not recognise, and which of that account's mailboxes could not be * did not recognise, and which of that account's mailboxes could not be
* reached, all three matter, because with several homes a send is * reached, all three matter, because with several homes a send is
* genuinely partial and a client that cannot tell cannot decide between * partial and a client that cannot tell cannot decide between
* retrying and warning. * retrying and warning.
*/ */
fun sendRemote(toFnid: String, toRelay: String, messages: List<RemoteMessage>): JSONObject { fun sendRemote(toFnid: String, toRelay: String, messages: List<RemoteMessage>): JSONObject {
@@ -714,7 +714,7 @@ class RelayClient(
* *
* [serial] must exceed whatever is already published. It is not a version * [serial] must exceed whatever is already published. It is not a version
* for humans to read, it is the only thing that stops a relay replaying an * for humans to read, it is the only thing that stops a relay replaying an
* older, genuinely signed list that happens to name only itself. * older, signed list that happens to name only itself.
*/ */
fun publishHomes(serial: Int, relays: List<String>, signatureB64: String): JSONObject = fun publishHomes(serial: Int, relays: List<String>, signatureB64: String): JSONObject =
postJson("/e2e/account/homes", JSONObject() postJson("/e2e/account/homes", JSONObject()
@@ -8,8 +8,8 @@ import org.json.JSONObject
/** /**
* What a relay says about itself (`GET /.well-known/flamenet/relay`). * What a relay says about itself (`GET /.well-known/flamenet/relay`).
* *
* **A client must know where accounts live before it sends a credential * A client must know where accounts live before it sends a credential
* anywhere.** The two modes stretch the passphrase differently and send the * anywhere. The two modes stretch the passphrase differently and send the
* result to different hosts; guessing wrong means sending an authentication * result to different hosts; guessing wrong means sending an authentication
* secret to a service that was never meant to receive it. * secret to a service that was never meant to receive it.
* *
@@ -72,8 +72,8 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti
* The account exists and the credential is right; the address has not been * The account exists and the credential is right; the address has not been
* proved reachable yet. * proved reachable yet.
* *
* **Its own type because everything else about it looks like a * Its own type because everything else about it looks like a
* credential failure.** Without this it arrives as a bare 403 and * credential failure. Without this it arrives as a bare 403 and
* [RelayAuth] logs "minting failed" before falling back to Basic, which * [RelayAuth] logs "minting failed" before falling back to Basic, which
* the e2e routes refuse, so the person sees calls not working and an inbox * the e2e routes refuse, so the person sees calls not working and an inbox
* that does nothing, and nothing anywhere says the remedy is a code sitting * that does nothing, and nothing anywhere says the remedy is a code sitting
@@ -3,8 +3,8 @@ package io.flamenet.client
/** /**
* Which relay this is, beyond which certificate it presented (ios#19). * Which relay this is, beyond which certificate it presented (ios#19).
* *
* **TLS answers "is this host who DNS says it is". It does not answer "is * TLS answers "is this host who DNS says it is". It does not answer "is
* this the same relay I have been talking to".** A mis-issued certificate, a * this the same relay I have been talking to". A mis-issued certificate, a
* hijacked DNS record, or a hostile party who obtains the domain all produce a * hijacked DNS record, or a hostile party who obtains the domain all produce a
* server with a perfectly valid certificate for the right name, and every one * server with a perfectly valid certificate for the right name, and every one
* of them is a different relay wearing the same address. * of them is a different relay wearing the same address.
@@ -25,8 +25,8 @@ enum class IdentityTrust {
/** /**
* A key we have never seen is being offered for someone we have. * A key we have never seen is being offered for someone we have.
* *
* **This is not "they got a new phone" and it is not "you are being * This is not "they got a new phone" and it is not "you are being
* attacked".** It is exactly the state where those two are * attacked". It is exactly the state where those two are
* indistinguishable to software, which is why it stops and asks a human * indistinguishable to software, which is why it stops and asks a human
* rather than picking one. * rather than picking one.
*/ */
@@ -62,8 +62,8 @@ class KeyLogForkException(detail: String) :
/** /**
* A served identity key could not be found in the log the relay publishes. * A served identity key could not be found in the log the relay publishes.
* *
* **Two very different situations, and the difference matters enough to * Two very different situations, and the difference matters enough to
* carry.** [hadEntries] is false when the log holds nothing at all for this * carry. [hadEntries] is false when the log holds nothing at all for this
* device, the key was never published, which is what a substitution looks * device, the key was never published, which is what a substitution looks
* like. It is true when the device HAS publications but none of them hash to * like. It is true when the device HAS publications but none of them hash to
* the leaf this bundle implies under the subject we were able to compute. * the leaf this bundle implies under the subject we were able to compute.
@@ -380,8 +380,8 @@ class Trust(
* hashed bytes, so guessing wrong makes an honest key unverifiable. * hashed bytes, so guessing wrong makes an honest key unverifiable.
* *
* Taking it from the contact record rather than from the relay's current * Taking it from the contact record rather than from the relay's current
* answer is the point: it checks the key was logged under **the person this * answer is the point: it checks the key was logged under the person this
* contact has always been**, not under whatever subject the relay would * contact has always been, not under whatever subject the relay would
* like to file it against today. * like to file it against today.
*/ */
/** /**
@@ -410,7 +410,7 @@ class Trust(
val head = try { val head = try {
head().also { vault.rememberKeyLogServed(relay.host) } head().also { vault.rememberKeyLogServed(relay.host) }
} catch (e: RelayException.NotFound) { } catch (e: RelayException.NotFound) {
// Only a route that is genuinely absent. A timeout, a 500 or a 429 // Only a route that is absent. A timeout, a 500 or a 429
// is a relay having a bad day and must not read as one without // is a relay having a bad day and must not read as one without
// transparency, that would make enforcement skippable by anybody // transparency, that would make enforcement skippable by anybody
// able to make one request fail. // able to make one request fail.
@@ -461,8 +461,8 @@ class Trust(
// by whichever relay signed this head, and the qualified subject embeds // by whichever relay signed this head, and the qualified subject embeds
// that relay's own name. // that relay's own name.
// //
// **Both subject forms are accepted, and the reason is a migration // Both subject forms are accepted, and the reason is a migration
// rather than a weakness.** A leaf names its subject as `fnid:` once // rather than a weakness. A leaf names its subject as `fnid:` once
// that account has claimed an address and `local:<audience>/<id>` // that account has claimed an address and `local:<audience>/<id>`
// before, and the leaf hash committed to whichever string was true // before, and the leaf hash committed to whichever string was true
// when it was written. A contact who registered a device and then // when it was written. A contact who registered a device and then
@@ -15,7 +15,7 @@ import org.json.JSONObject
* updater to install it. Without this check a person can run a build from * updater to install it. Without this check a person can run a build from
* months ago indefinitely and never know, and the builds they are missing * months ago indefinitely and never know, and the builds they are missing
* include security fixes. A sideloaded app that cannot tell you it is out of * include security fixes. A sideloaded app that cannot tell you it is out of
* date is the part of sideloading that is genuinely worse, and this is the only * date is the part of sideloading that is worse, and this is the only
* thing that closes it. * thing that closes it.
* *
* Asked of the relay you already talk to, not of a fixed address of ours. * Asked of the relay you already talk to, not of a fixed address of ours.
@@ -116,8 +116,8 @@ class AccountIdentityTest {
} }
/** /**
* **A relay serving a truncated list is the exact failure multi-homing * A relay serving a truncated list is the exact failure multi-homing
* exists to survive.** Drop a home from the record and it stops being * exists to survive. Drop a home from the record and it stops being
* signed, the sender then delivers everywhere the real record names, * signed, the sender then delivers everywhere the real record names,
* because it checks rather than trusts. * because it checks rather than trusts.
*/ */
@@ -141,7 +141,7 @@ class AccountIdentityTest {
/** /**
* The serial is inside the signature, which is what stops the rollback a * The serial is inside the signature, which is what stops the rollback a
* signature alone permits: an old list is genuinely signed, so without a * signature alone permits: an old list is signed, so without a
* counter a relay could serve one from before a second home was added and * counter a relay could serve one from before a second home was added and
* quietly become the only mailbox again. * quietly become the only mailbox again.
*/ */
@@ -18,8 +18,8 @@ import kotlin.test.assertTrue
/** /**
* §19 enforcement with no setting to find first. * §19 enforcement with no setting to find first.
* *
* **This used to be opt-in and off, so the protection shipped and almost * This used to be opt-in and off, so the protection shipped and almost
* nobody had it.** The argument for a switch, a relay predating the log has * nobody had it. The argument for a switch, a relay predating the log has
* none, and refusing every peer there is worse than the attack, was an * none, and refusing every peer there is worse than the attack, was an
* argument for skipping relays that *cannot* answer, never for skipping the * argument for skipping relays that *cannot* answer, never for skipping the
* ones that can. These tests pin that distinction, because it is the whole * ones that can. These tests pin that distinction, because it is the whole
@@ -21,8 +21,8 @@ import kotlin.test.assertTrue
/** /**
* Single-use delivery capabilities (§21.4). * Single-use delivery capabilities (§21.4).
* *
* **The property under test is that a capability is SPENT, not merely * The property under test is that a capability is SPENT, not merely
* checked.** One that survives its use is a reusable per-contact key, which is * checked. One that survives its use is a reusable per-contact key, which is
* a stable value the relay sees on every message from that sender, exactly the * a stable value the relay sees on every message from that sender, exactly the
* pseudonym §21.2 rejects. So "the same one twice is refused" is not a * pseudonym §21.2 rejects. So "the same one twice is refused" is not a
* nice-to-have; it is the whole reason this is not per-contact delivery keys * nice-to-have; it is the whole reason this is not per-contact delivery keys
@@ -195,7 +195,7 @@ class DeliveryTokenTest {
assertEquals(2, spent) assertEquals(2, spent)
} }
// MARK: - Revocation, which is the point // MARK: - Revocation
@Test @Test
fun `revoking drops exactly that contact's batch`() { fun `revoking drops exactly that contact's batch`() {
@@ -213,8 +213,8 @@ class DeliveryTokenTest {
} }
/** /**
* **Revoking moves a sender back onto the shared key; it does not * Revoking moves a sender back onto the shared key; it does not
* silence them.** Stated as a test because a client that implied otherwise * silence them. Stated as a test because a client that implied otherwise
* would be lying about the one thing this section is for. * would be lying about the one thing this section is for.
*/ */
@Test @Test
@@ -134,7 +134,7 @@ class GossipHeadSentTest {
} }
/** /**
* In-band is the whole point. The relay is the party being audited, so * In-band is the point. The relay is the party being audited, so
* it must not be able to read which head we hold nor rewrite it in transit. * it must not be able to read which head we hold nor rewrite it in transit.
* A head that appears in the clear in the POST body would let a forked * A head that appears in the clear in the POST body would let a forked
* relay answer each client with the branch that client already believes, * relay answer each client with the branch that client already believes,
@@ -103,8 +103,8 @@ class GroupNameTest {
} }
/** /**
* **The announcement rides `delivery_key` precisely because that kind is * The announcement rides `delivery_key` precisely because that kind is
* old.** A new `group_name` kind would arrive on every phone running v5 as * old. A new `group_name` kind would arrive on every phone running v5 as
* a bubble full of raw JSON: those builds recognise control traffic by kind * a bubble full of raw JSON: those builds recognise control traffic by kind
* and read the `ctl` flag with `optBoolean`, which does not coerce the * and read the `ctl` flag with `optBoolean`, which does not coerce the
* number §6.10 specifies. This asserts the envelope is still a well-formed * number §6.10 specifies. This asserts the envelope is still a well-formed
@@ -294,8 +294,8 @@ class GroupNameTest {
} }
/** /**
* **`Received` carries whether the sender was proven, because a caller * `Received` carries whether the sender was proven, because a caller
* that acts on `fromUser` has to be able to ask.** An unproven device's * that acts on `fromUser` has to be able to ask. An unproven device's
* sender id is a claim, the binding says it belongs to somebody else, * sender id is a claim, the binding says it belongs to somebody else,
* so a roster check keyed on it asks "is the person they are pretending to * so a roster check keyed on it asks "is the person they are pretending to
* be in this group", which answers yes for any member they name. * be in this group", which answers yes for any member they name.
@@ -315,7 +315,7 @@ class GroupNameTest {
} }
/** /**
* The announcement goes to our own account too, and that is the point. * The announcement goes to our own account too.
* Fan-out excludes us everywhere else, so a second phone we own would be * Fan-out excludes us everywhere else, so a second phone we own would be
* told by nobody: the relay has no name to serve it and the members only * told by nobody: the relay has no name to serve it and the members only
* send to each other. * send to each other.
@@ -13,8 +13,8 @@ import org.json.JSONObject
/** /**
* A home record that is older than one already seen must be refused (§20.4). * A home record that is older than one already seen must be refused (§20.4).
* *
* **A signature alone does not stop a rollback, and both clients checked * A signature alone does not stop a rollback, and both clients checked
* only the signature.** An older home record is *genuinely signed*, the * only the signature. An older home record is *signed*, the
* account holder published it, before they added a second relay. So a relay * 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 * 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 * serves the record it saw first, that record verifies perfectly, and the
@@ -92,7 +92,7 @@ class HomeRecordRollbackTest {
* A serial is only reported for a record that VERIFIES. Otherwise a * A serial is only reported for a record that VERIFIES. Otherwise a
* hostile relay could hand over an unsigned record carrying serial * hostile relay could hand over an unsigned record carrying serial
* 2,000,000,000, have the client raise its floor to it, and lock out every * 2,000,000,000, have the client raise its floor to it, and lock out every
* genuine record for ever, turning the rollback defence into a denial of * record for ever, turning the rollback defence into a denial of
* service against the account holder. * service against the account holder.
*/ */
@Test @Test
@@ -269,7 +269,7 @@ class LiveConversationTest {
* and deliberately will not grow one, because a relay caching display names * and deliberately will not grow one, because a relay caching display names
* is a relay that leaks them. Every group decode failed, so groups were * is a relay that leaks them. Every group decode failed, so groups were
* completely broken while looking implemented. Asserting the member shape * completely broken while looking implemented. Asserting the member shape
* against live bytes is the whole point of this test. * against live bytes is the point of this test.
*/ */
@Test @Test
fun `a group is created, described and messaged`() { fun `a group is created, described and messaged`() {
@@ -13,8 +13,8 @@ import kotlin.test.assertTrue
/** /**
* §19 enforcement against a relay that is actually running. * §19 enforcement against a relay that is actually running.
* *
* **Opt-in via `FN_LIVE_FLEET`, and pointed at the disposable fleet rather * Opt-in via `FN_LIVE_FLEET`, and pointed at the disposable fleet rather
* than production.** It creates accounts and publishes keys, which is what a * than production. It creates accounts and publishes keys, which is what a
* throwaway relay is for and what `relay.flamenet.io` is not. * throwaway relay is for and what `relay.flamenet.io` is not.
* *
* FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test * FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test
@@ -90,7 +90,7 @@ class LiveKeyLogTest {
val bob = Actor(url, "b") val bob = Actor(url, "b")
// Alice looks Bob up the way a send does, and enforces §19 on what she // Alice looks Bob up the way a send does, and enforces §19 on what she
// was served. Bob genuinely published this key a moment ago, so the // was served. Bob published this key a moment ago, so the
// relay can prove it, and if this throws, enforcement is refusing // relay can prove it, and if this throws, enforcement is refusing
// honest keys, which is worse than not enforcing at all. // honest keys, which is worse than not enforcing at all.
val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId } val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId }
@@ -12,8 +12,8 @@ import kotlin.test.assertTrue
/** /**
* All three deployment shapes, three rounds each, repeatably. * All three deployment shapes, three rounds each, repeatably.
* *
* The shapes are not variations on a theme, they differ in **where a * The shapes are not variations on a theme, they differ in where a
* passphrase-derived secret is sent**, which is the one thing a messenger must * passphrase-derived secret is sent, which is the one thing a messenger must
* not get wrong: * not get wrong:
* *
* - Managed (`relay.flamenet.io`), accounts in the shared FlameNet * - Managed (`relay.flamenet.io`), accounts in the shared FlameNet
@@ -94,8 +94,8 @@ class LiveShapesTest {
return return
} catch (e: AccountsClient.Failure.RateLimited) { } catch (e: AccountsClient.Failure.RateLimited) {
if (attempt == 2) { if (attempt == 2) {
// **Not a product failure, and not something to retry // Not a product failure, and not something to retry
// through.** The service allows three registrations per // through. The service allows three registrations per
// address per HOUR, deliberately, a short backoff cannot // address per HOUR, deliberately, a short backoff cannot
// outwait that, and pretending otherwise would turn a // outwait that, and pretending otherwise would turn a
// correct defence into a flaky test. Say what happened. // correct defence into a flaky test. Say what happened.
@@ -5,8 +5,8 @@ import java.net.URI
/** /**
* Where a live suite is allowed to point. * Where a live suite is allowed to point.
* *
* **On 2026-08-31 a thirty-minute test round created 39 accounts in the * On 2026-08-31 a thirty-minute test round created 39 accounts in the
* PRODUCTION database.** They are still there: `jvmt…`, `kt…`, `sl…`, `live…`, * PRODUCTION database. They are still there: `jvmt…`, `kt…`, `sl…`, `live…`,
* 55 of 86 accounts on the relay, which made the launch signup metric unusable * 55 of 86 accounts on the relay, which made the launch signup metric unusable
* a real person signing up was invisible inside the burst. * a real person signing up was invisible inside the burst.
* *
@@ -81,8 +81,8 @@ object LiveTarget {
/** /**
* The passphrase a live suite signs up with. * The passphrase a live suite signs up with.
* *
* **From the environment, with no fallback, and that is the whole * From the environment, with no fallback, and that is the whole
* point.** Both live suites carried * point. Both live suites carried
* `const val PASSPHRASE = "a-long-enough-passphrase"` and used it to create * `const val PASSPHRASE = "a-long-enough-passphrase"` and used it to create
* accounts on `accounts.flamenet.io` whenever somebody set * accounts on `accounts.flamenet.io` whenever somebody set
* `FN_LIVE_MANAGED=1`. A credential written into a source file is a * `FN_LIVE_MANAGED=1`. A credential written into a source file is a
@@ -11,8 +11,8 @@ import kotlin.test.assertTrue
/** /**
* One credential, two products, and whether either is optional. * One credential, two products, and whether either is optional.
* *
* The claim the product makes is narrow and worth testing exactly: **hosting * The claim the product makes is narrow and worth testing exactly: hosting
* only Messenger leaves nothing missing.** Flamenet Files is not a dependency, * only Messenger leaves nothing missing. Flamenet Files is not a dependency,
* a fallback, or a hidden requirement. What the shared account service buys is * a fallback, or a hidden requirement. What the shared account service buys is
* one credential across both, and nothing else. * one credential across both, and nothing else.
* *
@@ -369,7 +369,7 @@ class LocalVaultTest {
/** /**
* Promoting to the vault drops the bookmark. Keeping it would leave a * Promoting to the vault drops the bookmark. Keeping it would leave a
* vaulted row still able to read through to a plaintext original, so the * vaulted row still able to read through to a plaintext original, so the
* encrypted copy would not be the only way in, which is the entire point of * encrypted copy would not be the only way in, which is the point of
* vaulting it. * vaulting it.
*/ */
@Test @Test
@@ -435,7 +435,7 @@ class LocalVaultTest {
assertTrue(listOf(before) != listOf(after), "the LIST compares equal too") assertTrue(listOf(before) != listOf(after), "the LIST compares equal too")
} }
/** ...while two reads of an unchanged row still are equal, which is the point. */ /** ...while two reads of an unchanged row still are equal. */
@Test @Test
fun `an unchanged row read twice compares equal`() { fun `an unchanged row read twice compares equal`() {
val node = vault.add("x".toByteArray(), "f.txt", secret) val node = vault.add("x".toByteArray(), "f.txt", secret)
@@ -486,8 +486,8 @@ class LocalVaultTest {
* Those bytes are their file, sitting where they put it. This is the one * Those bytes are their file, sitting where they put it. This is the one
* mistake in the whole class that cannot be undone. * mistake in the whole class that cannot be undone.
* *
* **Asserting only that the file survived would be a test that cannot * Asserting only that the file survived would be a test that cannot
* fail**, because the delete path does not hold a resolver call to remove, * fail, because the delete path does not hold a resolver call to remove,
* the protection is structural: [LocalVault.ReferenceResolver] has no way to * the protection is structural: [LocalVault.ReferenceResolver] has no way to
* delete anything. So this asserts the property that actually constrains * delete anything. So this asserts the property that actually constrains
* future code: removing a row reaches the person's storage not at all. * future code: removing a row reaches the person's storage not at all.
@@ -226,8 +226,8 @@ class MailboxHomingTest {
// MARK: - Dropping one // MARK: - Dropping one
/** /**
* **The shorter list is published first and the credential goes only if * The shorter list is published first and the credential goes only if
* that worked.** Forgetting locally while senders are still told to deliver * that worked. Forgetting locally while senders are still told to deliver
* there leaves mail arriving at a mailbox this device no longer reads, * there leaves mail arriving at a mailbox this device no longer reads,
* which, from the sender's side, is indistinguishable from delivery. * which, from the sender's side, is indistinguishable from delivery.
*/ */
@@ -266,7 +266,7 @@ class MailboxHomingTest {
/** /**
* The serial has to move forward or the relay refuses the record as a * The serial has to move forward or the relay refuses the record as a
* rollback, by the very check that stops an attacker replaying an old, * rollback, by the very check that stops an attacker replaying an old,
* genuinely signed list that happens to name only itself. * signed list that happens to name only itself.
*/ */
@Test @Test
fun `each published record carries a serial above the last`() { fun `each published record carries a serial above the last`() {
@@ -10,8 +10,8 @@ import kotlin.test.assertNotNull
import kotlin.test.assertNull import kotlin.test.assertNull
/** /**
* **The relay answers a lookup with an ARRAY, and the client used to insist * The relay answers a lookup with an ARRAY, and the client used to insist
* it was an object.** * it was an object.
* *
* `RoutesAccount.lookup` returns `[MeResponse]`, empty for a miss, one element * `RoutesAccount.lookup` returns `[MeResponse]`, empty for a miss, one element
* for a hit, deliberately, so that a miss and a hit differ only in length and * for a hit, deliberately, so that a miss and a hit differ only in length and
@@ -13,8 +13,8 @@ import kotlin.test.assertTrue
/** /**
* What the roster, notification and account routes actually decode. * What the roster, notification and account routes actually decode.
* *
* **Three separate field-name bugs are recorded in comments in this one * Three separate field-name bugs are recorded in comments in this one
* file, and all three shipped.** The block list read `user_id` where the relay * file, and all three shipped. The block list read `user_id` where the relay
* sends `id`, so the Block button did nothing and survived review because the * sends `id`, so the Block button did nothing and survived review because the
* list was always empty. Notifications read `kind`/`body`/`read`, names that * list was always empty. Notifications read `kind`/`body`/`read`, names that
* exist nowhere, for months, so every alert decoded as a blank unread row and * exist nowhere, for months, so every alert decoded as a blank unread row and
@@ -118,7 +118,7 @@ class MultiMailboxPollTest {
// MARK: - Reading // MARK: - Reading
/** /**
* The whole point. A relay this device is not signed in to is holding *A relay this device is not signed in to is holding
* mail, and without this the message is simply never read. * mail, and without this the message is simply never read.
*/ */
@Test @Test
@@ -134,8 +134,8 @@ class MultiMailboxPollTest {
} }
/** /**
* **A copy at every home is the design, so a copy read twice is the * A copy at every home is the design, so a copy read twice is the
* failure.** And it would not present as a duplicate: the ratchet deletes a * failure. And it would not present as a duplicate: the ratchet deletes a
* message key once used, so the second copy fails to open and is logged as * message key once used, so the second copy fails to open and is logged as
* a dropped envelope, a message that looks lost rather than repeated. * a dropped envelope, a message that looks lost rather than repeated.
*/ */
@@ -180,8 +180,8 @@ class MultiMailboxPollTest {
// MARK: - When something goes wrong // MARK: - When something goes wrong
/** /**
* **A 410 from a BACKUP home is that mailbox ending, not this account * A 410 from a BACKUP home is that mailbox ending, not this account
* ending.** Treating it like the signed-in relay's 410 would purge * ending. Treating it like the signed-in relay's 410 would purge
* somebody's messages because one spare relay dropped a registration. * somebody's messages because one spare relay dropped a registration.
*/ */
@Test @Test
@@ -44,8 +44,8 @@ class Obfs4RoutingTest {
} }
/** /**
* **The factory is installed whether or not a bridge is set, so the * The factory is installed whether or not a bridge is set, so the
* no-bridge path has to be an ordinary socket.** If it were an * no-bridge path has to be an ordinary socket. If it were an
* `Obfs4Socket` pointed at nothing, installing the factory would break the * `Obfs4Socket` pointed at nothing, installing the factory would break the
* app for the overwhelming majority who never configure a bridge, and it * app for the overwhelming majority who never configure a bridge, and it
* would break it at connect time, on the network, not at build time. * would break it at connect time, on the network, not at build time.
@@ -24,7 +24,7 @@ import kotlin.test.fail
* A fake written alongside the client agrees with it about everything, * A fake written alongside the client agrees with it about everything,
* including the field name it got wrong, which is exactly how this protocol's * including the field name it got wrong, which is exactly how this protocol's
* one shipped bug survived until a live round trip. MockWebServer at least * one shipped bug survived until a live round trip. MockWebServer at least
* makes the client produce genuine bytes and parse genuine responses. * makes the client produce bytes and parse responses.
*/ */
class RelayClientTest { class RelayClientTest {
@@ -9,8 +9,8 @@ import kotlin.test.assertTrue
/** /**
* Pinning the relay's identity (ios#19, Android half). * Pinning the relay's identity (ios#19, Android half).
* *
* **The finding this closes is not a missing feature, it is a wrong * The finding this closes is not a missing feature, it is a wrong
* conclusion.** Android already pinned `issuerPublicKey`, inside * conclusion. Android already pinned `issuerPublicKey`, inside
* `Trust.verifyHeadSignature`. When the key later changed, the head signature * `Trust.verifyHeadSignature`. When the key later changed, the head signature
* stopped verifying and the only conclusion available at that point was * stopped verifying and the only conclusion available at that point was
* `KeyLogForkException`, so a rotation somebody performed on purpose was * `KeyLogForkException`, so a rotation somebody performed on purpose was
@@ -58,8 +58,8 @@ class RelayIdentityPinTest {
} }
/** /**
* **A relay that publishes no key is a weaker deployment, not an * A relay that publishes no key is a weaker deployment, not an
* attack.** Somebody self-hosting an older build has nothing to pin, and * attack. Somebody self-hosting an older build has nothing to pin, and
* refusing to talk to them would be a worse failure than the one this * refusing to talk to them would be a worse failure than the one this
* prevents. * prevents.
*/ */
@@ -121,8 +121,8 @@ class RelayIdentityPinTest {
} }
/** /**
* **The exception is not a fork exception, and that separation is the * The exception is not a fork exception, and that separation is the
* point of this change.** A caller catching `KeyLogForkException` to decide * point of this change. A caller catching `KeyLogForkException` to decide
* "the log is untrustworthy" must not be handed a rotation. * "the log is untrustworthy" must not be handed a rotation.
*/ */
@Test @Test
@@ -214,8 +214,8 @@ class SendToAddressTest {
// MARK: - Trust in the address keyspace // MARK: - Trust in the address keyspace
/** /**
* **A key never seen before is either a new device of theirs or one their * A key never seen before is either a new device of theirs or one their
* relay minted, and nothing on the wire tells those apart.** The pin hangs * relay minted, and nothing on the wire tells those apart. The pin hangs
* off the address, because a user id on their relay means nothing on ours. * off the address, because a user id on their relay means nothing on ours.
*/ */
@Test @Test
@@ -17,7 +17,7 @@ import kotlin.test.assertTrue
* A message nobody can receive must not report success. * A message nobody can receive must not report success.
* *
* This shipped, and it lost real messages. Signing out deregisters the * This shipped, and it lost real messages. Signing out deregisters the
* device, so a signed-out recipient genuinely has no devices. Envelopes are * device, so a signed-out recipient has no devices. Envelopes are
* addressed per device, so `sendPlaintext` looped over an empty bundle list, * addressed per device, so `sendPlaintext` looped over an empty bundle list,
* made no request at all, and returned `Sent(delivered = [], failed = {})`. * made no request at all, and returned `Sent(delivered = [], failed = {})`.
* Every caller reads an empty `failed` as "it went", the message was written * Every caller reads an empty `failed` as "it went", the message was written
@@ -23,8 +23,8 @@ import java.security.MessageDigest
* leading zeros" could see it, because overshooting also satisfies N. That is * leading zeros" could see it, because overshooting also satisfies N. That is
* why the vectors below are pinned to exact values. * why the vectors below are pinned to exact values.
* *
* The other half of the contract is that **nothing here may prevent a * The other half of the contract is that nothing here may prevent a
* sign-up**. A relay that predates the feature, a request that times out, a * sign-up. A relay that predates the feature, a request that times out, a
* proxy returning HTML, all of those mean "no work wanted", not "no account * proxy returning HTML, all of those mean "no work wanted", not "no account
* for you". * for you".
*/ */
@@ -176,7 +176,7 @@ class SignupProofOfWorkTest {
* and get one, solve it, and send the same fields again with the answer. * and get one, solve it, and send the same fields again with the answer.
* *
* Three exchanges, in this order. The first sign-up carries no challenge * Three exchanges, in this order. The first sign-up carries no challenge
* at all, which is the point: a relay with this feature off, every relay * at all: a relay with this feature off, every relay
* running today, sees exactly one request and nobody waits twice. * running today, sees exactly one request and nobody waits twice.
*/ */
@Test @Test
@@ -209,7 +209,7 @@ class SignupProofOfWorkTest {
} }
/** /**
* Rethrown, not swallowed. When this app genuinely cannot answer, a * Rethrown, not swallowed. When this app cannot answer, a
* difficulty above the cap, or a relay that will not hand out a challenge, * difficulty above the cap, or a relay that will not hand out a challenge,
* "update the app" is the true sentence, and failing with anything else * "update the app" is the true sentence, and failing with anything else
* sends somebody looking in the wrong place. * sends somebody looking in the wrong place.
@@ -7,8 +7,8 @@ import kotlin.test.assertTrue
/** /**
* A test run must not publish a credential (the Kotlin half of ios#62). * A test run must not publish a credential (the Kotlin half of ios#62).
* *
* **The iOS side had this bug twice, and Android had a quieter version of * The iOS side had this bug twice, and Android had a quieter version of
* it.** Every live suite here is properly gated behind `assumeTrue`, better * it. Every live suite here is properly gated behind `assumeTrue`, better
* than iOS managed, but two of them carried * than iOS managed, but two of them carried
* `const val PASSPHRASE = "a-long-enough-passphrase"` and used it to create * `const val PASSPHRASE = "a-long-enough-passphrase"` and used it to create
* accounts on `accounts.flamenet.io` whenever somebody set `FN_LIVE_MANAGED=1`. * accounts on `accounts.flamenet.io` whenever somebody set `FN_LIVE_MANAGED=1`.
@@ -69,8 +69,8 @@ class TestSafetyTest {
/** /**
* Whether this file can actually reach a relay somebody else runs. * Whether this file can actually reach a relay somebody else runs.
* *
* **`MockWebServer` is the discriminator, and getting this wrong made * `MockWebServer` is the discriminator, and getting this wrong made
* the guard useless twice.** Keying on the `Live` name prefix flagged the * the guard useless twice. Keying on the `Live` name prefix flagged the
* guard helper and its own unit test; keying on the string "http" flagged * guard helper and its own unit test; keying on the string "http" flagged
* four suites that sign up against a local mock, which is exactly the * four suites that sign up against a local mock, which is exactly the
* correct way to test sign-up. A file that stands up a mock server cannot * correct way to test sign-up. A file that stands up a mock server cannot
@@ -17,8 +17,8 @@ import kotlin.test.assertTrue
/** /**
* The scheduled half of key transparency (§19.3, §19.4, audit S4/M1). * The scheduled half of key transparency (§19.3, §19.4, audit S4/M1).
* *
* **`verifyLogConsistencyIfDue` and `consistent` were at 0%, and * `verifyLogConsistencyIfDue` and `consistent` were at 0%, and
* `verifyHeadSignature` at 7%.** These are what make transparency *act* rather * `verifyHeadSignature` at 7%. These are what make transparency *act* rather
* than merely record. Checking the log only while establishing a session * than merely record. Checking the log only while establishing a session
* leaves an established conversation unchecked indefinitely, which is exactly * leaves an established conversation unchecked indefinitely, which is exactly
* the conversation worth attacking, so a relay that rewound or forked between * the conversation worth attacking, so a relay that rewound or forked between
@@ -68,7 +68,7 @@ class TrustLogConsistencyTest {
private fun json(body: String) = MockResponse() private fun json(body: String) = MockResponse()
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
// -- A real tree, so proofs are genuine rather than stubbed -------------- // -- A real tree, so proofs are rather than stubbed --------------
private val leaves = (0 until 8).map { KeyLog.leafHash(byteArrayOf(it.toByte())) } private val leaves = (0 until 8).map { KeyLog.leafHash(byteArrayOf(it.toByte())) }
@@ -96,8 +96,8 @@ class TrustTest {
} }
/** /**
* **The doc comment on `rememberFnid` said this and the code did the * The doc comment on `rememberFnid` said this and the code did the
* opposite.** It overwrote a different address in place, the one thing an * opposite. It overwrote a different address in place, the one thing an
* address pin exists to prevent, since an fnid is derived from the account * address pin exists to prevent, since an fnid is derived from the account
* key, so a new address is not a new device but a different account. * key, so a new address is not a new device but a different account.
* *
@@ -182,7 +182,7 @@ class TrustTest {
/** /**
* The attack. A relay adds ONE key of its own alongside the real ones. * The attack. A relay adds ONE key of its own alongside the real ones.
* *
* Every genuine device still works, so nothing looks wrong to either * Every device still works, so nothing looks wrong to either
* person, while a device the attacker controls receives a copy of * person, while a device the attacker controls receives a copy of
* everything. A check written as "any offered key is pinned" accepts this. * everything. A check written as "any offered key is pinned" accepts this.
*/ */
@@ -317,7 +317,7 @@ class TrustTest {
server.enqueue(json("""{"device_id":"d1","size":1,"entries":[{"index":0,"kind":"publish","leaf":"$leaf"}]}""")) server.enqueue(json("""{"device_id":"d1","size":1,"entries":[{"index":0,"kind":"publish","leaf":"$leaf"}]}"""))
server.enqueue(json("""{"index":0,"size":1,"leaf":"$leaf","root":"$leaf","proof":[]}""")) server.enqueue(json("""{"index":0,"size":1,"leaf":"$leaf","root":"$leaf","proof":[]}"""))
// No fnid argument: it comes from the pin, which is the whole point. // No fnid argument: it comes from the pin.
t.enforceKeyLog(b, 7) t.enforceKeyLog(b, 7)
assertEquals(leaf, vault.loadKeyLogPin(relayHost)?.root) assertEquals(leaf, vault.loadKeyLogPin(relayHost)?.root)
} }
@@ -345,7 +345,7 @@ class TrustTest {
/** /**
* A key the relay serves but never logged must be refused. * A key the relay serves but never logged must be refused.
* *
* This is the whole point of the mechanism: the relay is free to hand out * This is the point of the mechanism: the relay is free to hand out
* whatever key it likes, and the client's job is to notice that no such * whatever key it likes, and the client's job is to notice that no such
* publication exists in the log the relay itself publishes. * publication exists in the log the relay itself publishes.
*/ */
@@ -6,9 +6,9 @@ import javax.crypto.spec.PBEKeySpec
/** /**
* Key derivation for the shared FlameNet account service. * Key derivation for the shared FlameNet account service.
* *
* One passphrase produces several unrelated secrets: an **authentication * One passphrase produces several unrelated secrets: an authentication
* secret, which is sent to the account service, and per-product identity * secret, which is sent to the account service, and per-product identity
* keys**, which never leave the device. Both come from the same expensive * keys, which never leave the device. Both come from the same expensive
* stretch and are separated afterwards by an HKDF context string, so a fully * stretch and are separated afterwards by an HKDF context string, so a fully
* compromised account service can impersonate someone to itself and decrypt * compromised account service can impersonate someone to itself and decrypt
* nothing of theirs. * nothing of theirs.
@@ -27,8 +27,8 @@ object Aead {
* Seal [plaintext], returning `nonce || ciphertext || tag`. * Seal [plaintext], returning `nonce || ciphertext || tag`.
* *
* The nonce is supplied rather than generated so callers cannot accidentally * The nonce is supplied rather than generated so callers cannot accidentally
* reuse one and so this is testable against fixed vectors. **A nonce must * reuse one and so this is testable against fixed vectors. A nonce must
* never repeat under one key**, GCM does not degrade gracefully there, it * never repeat under one key, GCM does not degrade gracefully there, it
* leaks the authentication subkey and with it the ability to forge. * leaks the authentication subkey and with it the ability to forge.
*/ */
fun seal(key: ByteArray, nonce: ByteArray, plaintext: ByteArray, fun seal(key: ByteArray, nonce: ByteArray, plaintext: ByteArray,
@@ -163,8 +163,8 @@ object Bip39 {
/** /**
* PBKDF2-HMAC-SHA512. * PBKDF2-HMAC-SHA512.
* *
* **Hand-written on top of [Mac] rather than through * Hand-written on top of [Mac] rather than through
* `SecretKeyFactory("PBKDF2WithHmacSHA512")`, and the reason is money.** * `SecretKeyFactory("PBKDF2WithHmacSHA512")`, and the reason is money.
* The JCA path takes a `CharArray` and the provider decides how to encode * The JCA path takes a `CharArray` and the provider decides how to encode
* it, `AccountKdf.master` already carries a note about that for the * it, `AccountKdf.master` already carries a note about that for the
* account KDF, where the same ambiguity exists. For a wallet the * account KDF, where the same ambiguity exists. For a wallet the
@@ -576,8 +576,8 @@ data class Content(
* and would need adding to two clients, an engine and a spec before * and would need adding to two clients, an engine and a spec before
* anything worked. * anything worked.
* *
* **`re.author` is not evidence and the receiver must not read it as * `re.author` is not evidence and the receiver must not read it as
* evidence.** Anyone can put any user id there. The receiver decides * evidence. Anyone can put any user id there. The receiver decides
* whether an edit is allowed from the copy of the message it already * whether an edit is allowed from the copy of the message it already
* holds, never from what the envelope claims about itself. * holds, never from what the envelope claims about itself.
*/ */
@@ -73,8 +73,8 @@ class DeviceIdentity private constructor(
const val ROTATE_AFTER_MS = 30L * 24 * 60 * 60 * 1000 const val ROTATE_AFTER_MS = 30L * 24 * 60 * 60 * 1000
/** /**
* How long a retired prekey stays usable. **Must exceed the relay's * How long a retired prekey stays usable. Must exceed the relay's
* undelivered-envelope retention** (30 days by default), with margin * undelivered-envelope retention (30 days by default), with margin
* for a sender that fetched a bundle and then went offline. Too short * for a sender that fetched a bundle and then went offline. Too short
* does not fail loudly, it drops old messages. * does not fail loudly, it drops old messages.
*/ */
@@ -230,7 +230,7 @@ class DeviceIdentity private constructor(
/** /**
* The seed for whichever prekey a peer encapsulated to. * The seed for whichever prekey a peer encapsulated to.
* *
* A one-time key is removed, which is the entire point of it; the * A one-time key is removed, which is the point of it; the
* last-resort one is not. Returns null when the id names neither, which a * last-resort one is not. Returns null when the id names neither, which a
* responder must treat as "this handshake cannot be v2" rather than as an * responder must treat as "this handshake cannot be v2" rather than as an
* error, an envelope naming a prekey we already spent is what a duplicate * error, an envelope naming a prekey we already spent is what a duplicate
@@ -57,8 +57,8 @@ class DoubleRatchet private constructor(
/** /**
* `{pub, ct}` to attach to outgoing messages (`PQpending`). * `{pub, ct}` to attach to outgoing messages (`PQpending`).
* *
* **Repeated on every message of the sending chain, not just the first * Repeated on every message of the sending chain, not just the first
* (§13.3).** Sending it once would mean losing that single message strands * (§13.3). Sending it once would mean losing that single message strands
* the whole chain: the receiver never gets the material to advance its root * the whole chain: the receiver never gets the material to advance its root
* key, and nothing later in the chain can be decrypted. It is replaced by * key, and nothing later in the chain can be decrypted. It is replaced by
* the next ratchet step, and is NOT cleared when the peer answers, it * the next ratchet step, and is NOT cleared when the peer answers, it
@@ -284,7 +284,7 @@ class DoubleRatchet private constructor(
// //
// Removed only AFTER the tag verifies (audit LOW). Consuming it // Removed only AFTER the tag verifies (audit LOW). Consuming it
// first meant a forged ciphertext for a stashed (dh, n) burned the key // first meant a forged ciphertext for a stashed (dh, n) burned the key
// the real delayed message needed, the genuine message could then // the real delayed message needed, the message could then
// never be opened. Today the caller discards the whole session object // never be opened. Today the caller discards the whole session object
// on a failed decrypt, so the loss never reaches the vault; that is a // on a failed decrypt, so the loss never reaches the vault; that is a
// property of the CALLER, and the moment anything caches a ratchet in // property of the CALLER, and the moment anything caches a ratchet in
@@ -352,8 +352,8 @@ class DoubleRatchet private constructor(
sent = 0 sent = 0
received = 0 received = 0
// **Decapsulate BEFORE adopting their new PQ key, and getting this // Decapsulate BEFORE adopting their new PQ key, and getting this
// order wrong is SILENT.** The peer encapsulated to the key we held // order wrong is SILENT. The peer encapsulated to the key we held
// going into this step, so `pqSelf` here must still be the old one // going into this step, so `pqSelf` here must still be the old one
// (§13.2). Reversing these derives a different root key with no error // (§13.2). Reversing these derives a different root key with no error
// until an AEAD open fails somewhere later. // until an AEAD open fails somewhere later.
@@ -8,10 +8,10 @@ import org.json.JSONObject
* This is the layer an app actually uses: [initiate] or [respond] once, then * This is the layer an app actually uses: [initiate] or [respond] once, then
* [encrypt] and [decrypt] strings. Everything below it, the handshake, the * [encrypt] and [decrypt] strings. Everything below it, the handshake, the
* ratchet, the associated data, is set up here and then never thought about * ratchet, the associated data, is set up here and then never thought about
* again, which is the point. * again.
* *
* **The first messages are `prekey` envelopes and must stay that way until * The first messages are `prekey` envelopes and must stay that way until
* the peer replies.** Not until the first one is *sent*, until one is * the peer replies. Not until the first one is *sent*, until one is
* *answered*. Any single message may be the first to arrive, so all of them * *answered*. Any single message may be the first to arrive, so all of them
* have to carry the handshake block; sending it once means losing that one * have to carry the handshake block; sending it once means losing that one
* message strands the whole conversation, and the symptom is a contact whose * message strands the whole conversation, and the symptom is a contact whose
@@ -173,8 +173,8 @@ class E2ESession private constructor(
associatedData = X3DH.associatedData( associatedData = X3DH.associatedData(
theirIdentitySigning, identity.identitySigningPublic, pq?.second), theirIdentitySigning, identity.identitySigningPublic, pq?.second),
pendingHandshake = null, pendingHandshake = null,
// **Honour what the initiator declared; this could never // Honour what the initiator declared; this could never
// return 3.** `initiate` above sets 3 whenever the PQ ratchet // return 3. `initiate` above sets 3 whenever the PQ ratchet
// is on, but responding capped at 2, so a session this device // is on, but responding capped at 2, so a session this device
// ANSWERED was labelled v2 while the far side ran it as v3. // ANSWERED was labelled v2 while the far side ran it as v3.
// Every message then arrived at a v3 peer without the // Every message then arrived at a v3 peer without the
@@ -95,7 +95,7 @@ object FNID {
* What a client signs to say which relays hold its mailboxes. * What a client signs to say which relays hold its mailboxes.
* *
* The serial is inside the signature, which is what stops the rollback a * The serial is inside the signature, which is what stops the rollback a
* signature alone permits: an old list is genuinely signed, so without a * signature alone permits: an old list is signed, so without a
* counter a relay could serve one from before a second home was added and * counter a relay could serve one from before a second home was added and
* quietly become the only mailbox again. * quietly become the only mailbox again.
*/ */
@@ -3,8 +3,8 @@ package io.flamenet.e2e
/** /**
* File contents in the Files vault. Mirrors §7 of the published specification. * File contents in the Files vault. Mirrors §7 of the published specification.
* *
* **Framed into independently authenticated chunks, and that framing is the * Framed into independently authenticated chunks, and that framing is the
* security.** A single AES-GCM blob cannot be verified until all of it has been * security. A single AES-GCM blob cannot be verified until all of it has been
* buffered, which is untenable for a large file on a phone, so a naive * buffered, which is untenable for a large file on a phone, so a naive
* implementation streams it out unverified, and the person reads attacker-chosen * implementation streams it out unverified, and the person reads attacker-chosen
* bytes before the tag ever fails. * bytes before the tag ever fails.
@@ -82,8 +82,8 @@ object FileWrap {
/** /**
* The key hierarchy. * The key hierarchy.
* *
* **A node key is wrapped to its OWN label key, never to the folder above * A node key is wrapped to its OWN label key, never to the folder above
* it.** The tree deliberately allows a Restricted file to sit inside a * it. The tree deliberately allows a Restricted file to sit inside a
* Confidential folder, so a folder-derived key would hand every holder of that * Confidential folder, so a folder-derived key would hand every holder of that
* folder's key content above their clearance. When the rows lived in Postgres * folder's key content above their clearance. When the rows lived in Postgres
* an RLS predicate would still have refused to return them; there is no * an RLS predicate would still have refused to return them; there is no
@@ -6,8 +6,8 @@ import org.json.JSONObject
* The Files identity keypair, and the record that protects it. Mirrors §3 of * The Files identity keypair, and the record that protects it. Mirrors §3 of
* the published specification. * the published specification.
* *
* **The record is written by every FlameNet client and must open in all of * The record is written by every FlameNet client and must open in all of
* them.** The browser writes it, iOS writes it, and this reads what either * them. The browser writes it, iOS writes it, and this reads what either
* produced. That is why the awkward details below are pinned rather than * produced. That is why the awkward details below are pinned rather than
* tidied: each one is a place where two implementations could disagree and only * tidied: each one is a place where two implementations could disagree and only
* find out when somebody's files will not open on their new phone. * find out when somebody's files will not open on their new phone.
@@ -13,7 +13,7 @@ import javax.crypto.spec.SecretKeySpec
* says nothing about the Files one. * says nothing about the Files one.
* *
* Not [AccountKdf]. Same shape, different context strings and a different * Not [AccountKdf]. Same shape, different context strings and a different
* salt, and the two must not be collapsed: the whole point of the split is that * salt, and the two must not be collapsed: the point of the split is that
* one product's derived secret is useless in another. * one product's derived secret is useless in another.
*/ */
object FilesKdf { object FilesKdf {
@@ -43,9 +43,9 @@ object FilesKdf {
/** /**
* PBKDF2-HMAC-SHA256. * PBKDF2-HMAC-SHA256.
* *
* **Hand-written on top of [Mac] rather than through * Hand-written on top of [Mac] rather than through
* `SecretKeyFactory("PBKDF2WithHmacSHA256")`, for the same reason * `SecretKeyFactory("PBKDF2WithHmacSHA256")`, for the same reason
* [Bip39.pbkdf2HmacSha512] is.** The JCA path takes a `CharArray` and lets * [Bip39.pbkdf2HmacSha512] is. The JCA path takes a `CharArray` and lets
* the provider decide how to encode it. The reference implementation here * the provider decide how to encode it. The reference implementation here
* is WebCrypto, which takes *bytes*, and CommonCrypto on iOS takes bytes * is WebCrypto, which takes *bytes*, and CommonCrypto on iOS takes bytes
* too. Handing the JCA characters and hoping puts a provider between us and * too. Handing the JCA characters and hoping puts a provider between us and
@@ -34,8 +34,8 @@ data class HdAccount(
/** /**
* The address at [index], and the path it came from. * The address at [index], and the path it came from.
* *
* **Base58 of the raw public key, with no checksum, because Solana * Base58 of the raw public key, with no checksum, because Solana
* addresses have none.** A mistyped address that still decodes to 32 bytes * addresses have none. A mistyped address that still decodes to 32 bytes
* is a *valid* address belonging to nobody, money sent there is gone, and * is a *valid* address belonging to nobody, money sent there is gone, and
* there is no layer below this that will catch it. Anything offering an * there is no layer below this that will catch it. Anything offering an
* address to be typed has to say so; this function cannot. * address to be typed has to say so; this function cannot.
@@ -76,8 +76,8 @@ data class HdAccount(
/** /**
* Whether [address] could be an address on this chain. * Whether [address] could be an address on this chain.
* *
* **This says "well formed", NEVER "correct", and the difference is * This says "well formed", NEVER "correct", and the difference is
* money.** A Solana address is Base58 of a 32-byte public key and * money. A Solana address is Base58 of a 32-byte public key and
* carries no checksum, so a single mistyped character that happens to * carries no checksum, so a single mistyped character that happens to
* decode to 32 bytes produces a perfectly valid address that nobody * decode to 32 bytes produces a perfectly valid address that nobody
* holds the key for. Bitcoin's Base58Check would catch that; this * holds the key for. Bitcoin's Base58Check would catch that; this
@@ -12,7 +12,7 @@ import javax.crypto.spec.SecretKeySpec
* A history backup the platform cannot read. * A history backup the platform cannot read.
* *
* The relay keeps no history, that is the product's whole promise, which * The relay keeps no history, that is the product's whole promise, which
* means a new device genuinely starts empty. This is the designed remedy: the * means a new device starts empty. This is the designed remedy: the
* vault's history, re-sealed under a key derived from a passphrase the user * vault's history, re-sealed under a key derived from a passphrase the user
* chooses, so the file is safe to park anywhere and useless without the words. * chooses, so the file is safe to park anywhere and useless without the words.
* *
@@ -3,8 +3,8 @@ package io.flamenet.e2e
/** /**
* The security lattice. Mirrors §1 of the published Files specification. * The security lattice. Mirrors §1 of the published Files specification.
* *
* **The ten-byte encoding is authenticated data for every construction that * The ten-byte encoding is authenticated data for every construction that
* uses it**, so a label that encodes differently on two platforms does not * uses it, so a label that encodes differently on two platforms does not
* produce a warning, it produces a decryption failure, or worse, plaintext * produce a warning, it produces a decryption failure, or worse, plaintext
* that quietly belongs to the wrong sensitivity. That is why [bytes] is pinned * that quietly belongs to the wrong sensitivity. That is why [bytes] is pinned
* to a vector rather than left to look obviously right. * to a vector rather than left to look obviously right.
@@ -84,8 +84,8 @@ data class Clearance(val maximum: Label, val minimum: Label = Label.bottom) {
/** /**
* Whether a write to [label] is allowed. * Whether a write to [label] is allowed.
* *
* **The SAME check as reading, and that is a deliberate match with iOS * The SAME check as reading, and that is a deliberate match with iOS
* rather than an oversight.** `FilesSession.ensureLabelKey` and `use` both * rather than an oversight. `FilesSession.ensureLabelKey` and `use` both
* gate on `clearance.permits`, so the range is the whole rule on both * gate on `clearance.permits`, so the range is the whole rule on both
* platforms. * platforms.
* *
@@ -193,8 +193,8 @@ object SealedSender {
* The control message handing a contact our delivery key (§14.4) and, since * The control message handing a contact our delivery key (§14.4) and, since
* §21.4, a batch of single-use capabilities alongside it. * §21.4, a batch of single-use capabilities alongside it.
* *
* **One envelope, not two, and that is a compatibility decision rather * One envelope, not two, and that is a compatibility decision rather
* than tidiness.** A receiver recognises control traffic by KIND first and * than tidiness. A receiver recognises control traffic by KIND first and
* falls back to the `ctl` flag only for kinds it has never heard of, and * falls back to the `ctl` flag only for kinds it has never heard of, and
* the build in the field reads that flag with `optBoolean`, which does not * the build in the field reads that flag with `optBoolean`, which does not
* coerce the number §6.10 specifies. A new `delivery_tokens` kind would * coerce the number §6.10 specifies. A new `delivery_tokens` kind would
@@ -5,15 +5,15 @@ import java.util.Arrays
/** /**
* Zeroing key material once it has been used (ios#39). * Zeroing key material once it has been used (ios#39).
* *
* **This narrows a window. It does not remove the material, and claiming * This narrows a window. It does not remove the material, and claiming
* otherwise would spend the whole value of the feature on a lie.** What it buys * otherwise would spend the whole value of the feature on a lie. What it buys
* is that a derived key does not sit in the process for the lifetime of the app * is that a derived key does not sit in the process for the lifetime of the app
* after the one moment it was needed, which matters for a heap dump, a crash * after the one moment it was needed, which matters for a heap dump, a crash
* report, or a debugger attached later. It buys nothing against somebody * report, or a debugger attached later. It buys nothing against somebody
* reading memory *while* the secret is in use. * reading memory *while* the secret is in use.
* *
* **The JVM guarantee is weaker than the iOS one, and the difference is not * The JVM guarantee is weaker than the iOS one, and the difference is not
* a detail.** iOS has `memset_s`, which is specified not to be optimised away, * a detail. iOS has `memset_s`, which is specified not to be optimised away,
* and iOS does not page application memory to disk. Neither holds here: * and iOS does not page application memory to disk. Neither holds here:
* *
* - A compacting garbage collector moves objects. A `ByteArray` can be * - A compacting garbage collector moves objects. A `ByteArray` can be
@@ -36,8 +36,8 @@ import java.util.Arrays
* `AccountKdf.master` already calls `PBEKeySpec.clearPassword()` rather than * `AccountKdf.master` already calls `PBEKeySpec.clearPassword()` rather than
* trying to do anything about the `String` it was handed. * trying to do anything about the `String` it was handed.
* *
* So the claim this type supports, precisely: **a derived key is not left * So the claim this type supports, precisely: a derived key is not left
* sitting in the heap after use.** Not "the key is gone". * sitting in the heap after use. Not "the key is gone".
*/ */
object SecureBytes { object SecureBytes {
@@ -68,7 +68,7 @@ object SecureBytes {
} }
/** /**
* A copy of [source], for callers that genuinely need one. * A copy of [source], for callers that need one.
* *
* The copy is ordinary memory and this object stops being responsible * The copy is ordinary memory and this object stops being responsible
* for it. Every use of this is a decision to leave a second copy behind, * for it. Every use of this is a decision to leave a second copy behind,
@@ -3,8 +3,8 @@ package io.flamenet.e2e
/** /**
* What a viewer is told before an embedded live stream connects (spec §6.13). * What a viewer is told before an embedded live stream connects (spec §6.13).
* *
* **This is the one media path where the viewer must contact a third party, * This is the one media path where the viewer must contact a third party,
* and it cannot be designed away.** A photo or video story is a sealed blob: * and it cannot be designed away. A photo or video story is a sealed blob:
* the relay stores ciphertext and nobody else is involved. A live stream is * the relay stores ciphertext and nobody else is involved. A live stream is
* continuous and hosted elsewhere, so there is nothing to seal, watching it * continuous and hosted elsewhere, so there is nothing to seal, watching it
* means the viewer's device connects to that host, which learns their IP * means the viewer's device connects to that host, which learns their IP
+23 -23
View File
@@ -215,8 +215,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* How long a message actually lives, given its own timer and its * How long a message actually lives, given its own timer and its
* conversation's (§6.17). Null means it does not expire. * conversation's (§6.17). Null means it does not expire.
* *
* **The shorter of the two always wins, and that is the whole * The shorter of the two always wins, and that is the whole
* rule.** A per-message timer may only take life away, never add it. * rule. A per-message timer may only take life away, never add it.
* Otherwise a sender could defeat the recipient's conversation timer by * Otherwise a sender could defeat the recipient's conversation timer by
* putting a long one on every message, turning a setting the recipient * putting a long one on every message, turning a setting the recipient
* chose, on their own device, into something the other party overrules. * chose, on their own device, into something the other party overrules.
@@ -237,8 +237,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
/** /**
* Whether this story may be written out of the app (ios#34). * Whether this story may be written out of the app (ios#34).
* *
* **Only ever your own, and the rule lives here rather than in the * Only ever your own, and the rule lives here rather than in the
* button.** Saving somebody else's story keeps a copy of something * button. Saving somebody else's story keeps a copy of something
* they were told would disappear, the opposite of what the feature * they were told would disappear, the opposite of what the feature
* promises them, and no wording on a menu item makes that acceptable. * promises them, and no wording on a menu item makes that acceptable.
* A guard in a composable is one refactor away from being lost. * A guard in a composable is one refactor away from being lost.
@@ -406,8 +406,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
// -- Identity trust (§19) ------------------------------------------------ // -- Identity trust (§19) ------------------------------------------------
// //
// **Encrypted at rest like everything else here, and that is the point // Encrypted at rest like everything else here
// rather than a nicety.** These records are what the client compares a // rather than a nicety. These records are what the client compares a
// served key against. Somewhere an attacker can rewrite them is somewhere // served key against. Somewhere an attacker can rewrite them is somewhere
// an attacker can pin their own key and have the comparison agree with // an attacker can pin their own key and have the comparison agree with
// them for ever afterwards, silently, because a match is what "fine" // them for ever afterwards, silently, because a match is what "fine"
@@ -464,8 +464,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
Trust( Trust(
identities = (0 until a.length()).map { a.getString(it) }, identities = (0 until a.length()).map { a.getString(it) },
verified = o.optBoolean("verified", false), verified = o.optBoolean("verified", false),
// **`"null"` is rejected, and that is not paranoia, records // `"null"` is rejected, and that is not paranoia, records
// written that way are already on phones.** `saveTrust` used to // written that way are already on phones. `saveTrust` used to
// write an explicit JSON null here, and Android's `org.json` // write an explicit JSON null here, and Android's `org.json`
// coerces that to the four-character string on the way back out. // coerces that to the four-character string on the way back out.
// See the note on [saveTrust]; this is the half that repairs what // See the note on [saveTrust]; this is the half that repairs what
@@ -476,8 +476,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
} }
/** /**
* **The key is OMITTED when there is no address, never written as an * The key is OMITTED when there is no address, never written as an
* explicit JSON null.** Android's bundled `org.json` reads a JSON null back * explicit JSON null. Android's bundled `org.json` reads a JSON null back
* as the four-character string `"null"`, so a contact pinned before their * as the four-character string `"null"`, so a contact pinned before their
* fnid was known reloaded with `fnid = "null"`, a non-null value that is * fnid was known reloaded with `fnid = "null"`, a non-null value that is
* not an address. What that caused: * not an address. What that caused:
@@ -651,7 +651,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* What this device holds for one sealed group. * What this device holds for one sealed group.
* *
* Lose these and the group is gone. The relay cannot tell you what * Lose these and the group is gone. The relay cannot tell you what
* you are a member of, that is the entire point of sealing, so the tag is * you are a member of, that is the point of sealing, so the tag is
* the only way to fetch the group and the key is the only way to read it. * the only way to fetch the group and the key is the only way to read it.
* Both ride the encrypted history export and the group invitation, the same * Both ride the encrypted history export and the group invitation, the same
* paths the group name already uses. * paths the group name already uses.
@@ -751,8 +751,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
/** /**
* A half-written message, kept across a relaunch. * A half-written message, kept across a relaunch.
* *
* **In the vault rather than in SharedPreferences, and that is the whole * In the vault rather than in SharedPreferences, and that is the whole
* point.** A draft is plaintext the person has not decided to send, very * point. A draft is plaintext the person has not decided to send, very
* often the most sensitive text in the app, because it is the thing they * often the most sensitive text in the app, because it is the thing they
* are still weighing. `SharedPreferences` writes it as readable XML that * are still weighing. `SharedPreferences` writes it as readable XML that
* survives into backups; here it is sealed under the same key as the * survives into backups; here it is sealed under the same key as the
@@ -1068,8 +1068,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
/** /**
* Whether this message has already been read from another mailbox. * Whether this message has already been read from another mailbox.
* *
* **The point of several mailboxes is that a message goes to all of * The point of several mailboxes is that a message goes to all of
* them.** Without this the person reads it once per home, and would not, * them. Without this the person reads it once per home, and would not,
* in fact, because the ratchet deletes a message key once it is used, so * in fact, because the ratchet deletes a message key once it is used, so
* the second copy fails to decrypt. The visible symptom is therefore not a * the second copy fails to decrypt. The visible symptom is therefore not a
* duplicate but a dropped envelope and a message that looks lost. * duplicate but a dropped envelope and a message that looks lost.
@@ -1337,8 +1337,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
/** /**
* Messages typed but not yet delivered to anybody. * Messages typed but not yet delivered to anybody.
* *
* **This has to live here, on the sender's phone, and that is forced by * This has to live here, on the sender's phone, and that is forced by
* the encryption rather than chosen.** A relay cannot hold a message for * the encryption rather than chosen. A relay cannot hold a message for
* somebody with no registered device: every envelope is sealed to a * somebody with no registered device: every envelope is sealed to a
* specific device's keys, so with no device there is nothing to seal it to. * specific device's keys, so with no device there is nothing to seal it to.
* The relay could only queue plaintext, which is the one thing this app * The relay could only queue plaintext, which is the one thing this app
@@ -1565,8 +1565,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
// of groups all called "Group", the exact loss this file exists to // of groups all called "Group", the exact loss this file exists to
// prevent. // prevent.
.also { p -> readJson(GROUP_NAMES)?.let { p.put("groupNames", it) } } .also { p -> readJson(GROUP_NAMES)?.let { p.put("groupNames", it) } }
// **Without these a restored device loses its sealed groups for // Without these a restored device loses its sealed groups for
// good.** The relay cannot say what you are a member of, that is // good. The relay cannot say what you are a member of, that is
// the point of sealing, so the tag is the only way to fetch a group // the point of sealing, so the tag is the only way to fetch a group
// and the key the only way to read it. A backup carrying the // and the key the only way to read it. A backup carrying the
// messages but not these restores a device that can see old // messages but not these restores a device that can see old
@@ -1671,8 +1671,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
saveRoster(current, offServer || imported.optBoolean("offServer", false)) saveRoster(current, offServer || imported.optBoolean("offServer", false))
} }
// **Group names ride the backup, because nothing else can supply // Group names ride the backup, because nothing else can supply
// them now.** The relay stopped holding them, so a restored device that // them now. The relay stopped holding them, so a restored device that
// did not read them here would show every group as "Group" until some // did not read them here would show every group as "Group" until some
// other member happened to send a message. // other member happened to send a message.
// //
@@ -1813,7 +1813,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* Sealed by the vault, which means erasing this device destroys it. * Sealed by the vault, which means erasing this device destroys it.
* Plain sign-out keeps it (and so does a password change, which signs out * Plain sign-out keeps it (and so does a password change, which signs out
* plainly); "erase this phone" and "delete account" do not. After those, the * plainly); "erase this phone" and "delete account" do not. After those, the
* recovery phrase is the only copy that exists, which is the whole point of * recovery phrase is the only copy that exists, which is the point of
* a recovery phrase, and has to be SAID rather than discovered. * a recovery phrase, and has to be SAID rather than discovered.
*/ */
fun walletEntropy(): ByteArray? = fun walletEntropy(): ByteArray? =
@@ -1890,7 +1890,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* The highest home-record serial seen for an address, or null. * The highest home-record serial seen for an address, or null.
* *
* This is what makes the serial mean anything on the reading side. An * This is what makes the serial mean anything on the reading side. An
* older home record is genuinely signed, published by the account holder * older home record is signed, published by the account holder
* before they added a second relay, so a relay serving it is handing out * before they added a second relay, so a relay serving it is handing out
* something that verifies. Remembering the highest seen is the only thing * something that verifies. Remembering the highest seen is the only thing
* that turns "this signature is valid" into "this is not a rollback". * that turns "this signature is valid" into "this is not a rollback".

Some files were not shown because too many files have changed in this diff Show More