Both rows said ⚪/⚠️ and the note under them said "neither client does post-quantum anything today". That was wrong in the understating direction — the same direction relay#45 warns about, and the same one that had the comparison page scoring Signal unfairly in our favour. - **Android** uses BouncyCastle's ML-KEM (FIPS 203), shipped in the engine. The old note's "no ML-KEM at any API level" was about the PLATFORM, which is true and is why the engine carries its own — not a reason it has none. - **iOS** uses CryptoKit `MLKEM768`, unconditional since `project.yml` moved the deployment target to 26.0. The runtime gate that made those files inert is gone, and `PQKEM.swift` says so at the top. ⚠️ Corrected by a run rather than by reading the source, which is the rule this file keeps to. `test/interop-swift-kotlin.mjs` establishes it end to end in both directions: "Android picks v3 for an iPhone peer", "the first Android envelope carries a PQ ratchet step", "the iPhone agrees the session is v3", "a v3 session survives being archived and restored mid-conversation". ⚠️ The honest limit is unchanged and kept: it counts only once BOTH ends have it, so today that means two Android phones — or an Android phone and an iPhone running a build that has not shipped. Found while correcting relay#11, where I went in expecting to find our own cells overstated and found the opposite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1215 lines
69 KiB
Markdown
1215 lines
69 KiB
Markdown
# Android ↔ iOS parity
|
||
|
||
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
|
||
it can be re-checked rather than trusted.
|
||
|
||
**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
|
||
screen renders.
|
||
|
||
---
|
||
|
||
## Where things stand
|
||
|
||
| | iOS | Android |
|
||
|---|---|---|
|
||
| Protocol engine | `Sources/E2E`, 15 files | `engine`, 10 files |
|
||
| App layer | ~9,700 lines, 45 files | accounts, contacts, chat, photos |
|
||
| 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 |
|
||
|
||
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
|
||
could make for most of its history. What Android has no version of at all is
|
||
everything above the protocol.
|
||
|
||
---
|
||
|
||
## Protocol engine
|
||
|
||
| Feature | iOS | Android | Note |
|
||
|---|:--:|:--:|---|
|
||
| X25519, HKDF, AES-GCM | ✅ | ✅ | Android verified against RFC 5869 / NIST vectors |
|
||
| Ed25519 | ✅ | ✅ | RFC 8032 vectors, on the JVM **and on device**; see the provider note below |
|
||
| X3DH v1 | ✅ | ✅ | cross-engine verified |
|
||
| Double Ratchet | ✅ | ✅ | cross-engine verified, including reordering and loss |
|
||
| Safety numbers | ✅ | ✅ | cross-engine verified |
|
||
| Transparency log (`KeyLog`) | ✅ | ✅ | canonical leaf + inclusion proofs, cross-engine verified |
|
||
| Account KDF / fnid | ✅ | ✅ | both pinned to the published vector file |
|
||
| Sealed sender | ✅ | ✅ | cross-engine verified, and the relay records no sender |
|
||
| Content envelopes (§6.6–§6.9) | ✅ | ✅ | attachments, replies, reactions, groups, calls — one codec |
|
||
| Attachment encryption | ✅ | ✅ | verified end to end; relay holds only ciphertext |
|
||
| Envelope + wire codec | ✅ | ✅ | cross-engine verified at the envelope level |
|
||
| Session archive / restore | ✅ | ✅ | cross-engine verified, in-flight messages included |
|
||
| Device identity + prekey rotation | ✅ | ✅ | retired prekeys retained past the relay window |
|
||
| Vault (encryption at rest) | ✅ | ✅ | Android Keystore, AES-GCM, record name bound into the AAD |
|
||
|
||
| ML-KEM-768 (PQ agreement) | ✅ | ✅ | cross-engine verified — see below |
|
||
| ML-DSA-65 (PQ identity) | ✅ | ✅ | see below |
|
||
|
||
🔴 **These two rows said ⚪ and ⚠️, and both were wrong — understating, which
|
||
is the same direction relay#45 warns about and the same one that had the
|
||
comparison page scoring a competitor unfairly.** The note that used to sit here
|
||
said iOS's PQ files were inert because CryptoKit exposes ML-KEM only on iOS 26
|
||
while the app targeted 16, that Android had no ML-KEM at any API level, and so
|
||
"neither client does post-quantum anything today". None of that is true now, and
|
||
the middle claim was never quite the point.
|
||
|
||
- **Android** uses BouncyCastle's `MLKEMExtractor` / `MLKEMGenerator` — FIPS 203,
|
||
a real implementation shipped in the engine. There is no *platform* ML-KEM on
|
||
Android at any API level, which is what the old note was reaching for, and it
|
||
is why the engine carries its own rather than a reason it has none.
|
||
- **iOS** uses CryptoKit's `MLKEM768`, and it is **unconditional**:
|
||
`project.yml` sets the deployment target to 26.0, so the runtime gate that
|
||
made those files inert is gone. `PQKEM.swift` says so at the top.
|
||
|
||
⚠️ Corrected by a run, not by reading the source. The cross-engine harness
|
||
(`test/interop-swift-kotlin.mjs`) establishes it end to end: *"Android picks v3
|
||
for an iPhone peer"*, *"the first Android envelope carries a PQ ratchet step"*,
|
||
*"the iPhone agrees the session is v3"*, and *"a v3 session survives being
|
||
archived and restored mid-conversation"* — with the same four in the other
|
||
direction.
|
||
|
||
⚠️ The honest limit, which has not changed: post-quantum protection counts only
|
||
once **both** ends have it, so today that means two Android phones, or an
|
||
Android phone and an iPhone running a build that has not shipped yet.
|
||
|
||
---
|
||
|
||
## Accounts and identity
|
||
|
||
From `Sources/Auth/*` and `Sources/Views/Sign*`.
|
||
|
||
| Feature | Android |
|
||
|---|:--:|
|
||
| Sign up — local self-hosted | ✅ verified against `relay1` through the UI |
|
||
| Sign up — managed / shared self-hosted | 🟡 `LiveSignUpFleetTest` written, compiles, **unrun** |
|
||
| Sign in, session restore | ✅ |
|
||
| Sign out (erases the vault key) | ✅ |
|
||
| Password reset request + confirm | ✅ on the sign-in screen |
|
||
| Change password | ✅ signs the device out, because that is what it does |
|
||
| Delete account | ✅ confirms in place, erases the phone too |
|
||
| Relay picker / `SiteConfig` equivalent | ✅ `SiteConfig.kt`, a `relay` field, and an `identity-mode` label |
|
||
| Deterministic local-relay KDF salt | ✅ `AccountKdf.localAccountSalt`, pinned to the published vector, and wired into sign-in for a LOCAL relay |
|
||
| Multi-homing (publish + read home records) | ✅ publish, read, verify and the serial floor — see below |
|
||
|
||
⚠️ Sign-up is where the three deployment modes become visible to a user, and
|
||
each derives its salt differently. Getting this wrong does not error — it
|
||
derives a *different account* from the same passphrase.
|
||
|
||
---
|
||
|
||
## Messaging
|
||
|
||
From `E2E/E2EInbox.swift`, which is the real feature list.
|
||
|
||
| Feature | Android |
|
||
|---|:--:|
|
||
| Send / receive text | ✅ **app ↔ the iOS app**, through a real relay — see below |
|
||
| Inbox polling | ✅ every 5 s while open; no push channel exists |
|
||
| Multi-device fan-out | ✅ one envelope per device, partial delivery reported |
|
||
| History at rest | ✅ encrypted, keyed by person not device |
|
||
| Session restore across restarts | ✅ credential and device both survive |
|
||
| Reactions | ✅ transcript carries a `mid`; long-press gives a five-emoji row, one emoji per person |
|
||
| Edit a sent message (§6.15) | ✅ inbound edits apply to the message they name; **Edit** in the long-press menu, own messages only, bubble marked `edited` |
|
||
| Drafts that survive a relaunch | ✅ sealed in the vault, not SharedPreferences |
|
||
| Forwarding (§6.16) | ✅ `fwd` reaches the transcript and the bubble; **Forward** offers people and groups, and carries no trail back to the source |
|
||
| Group message ids and quotes (§6.9) | ✅ transcript stores both; sent and queued copies share one id |
|
||
| Mentions (§6.18) | ✅ codec, an `@` picker in the group composer, and ids recorded at pick time rather than re-derived from the text |
|
||
| Per-message timers (§6.17) | ✅ combining rule and per-message pruning; composer control in progress |
|
||
| Search inside messages | ✅ scanned, never indexed |
|
||
| Search inside messages | ✅ scanned, never indexed — no plaintext index at rest |
|
||
| Pinned message (§6.19) | ✅ inbound pins stored per thread sealed in the vault; **Pin/Unpin** in the menu and a banner above the thread |
|
||
| Photos | ✅ send via the system picker, render inline |
|
||
| Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send |
|
||
| Stories, and seen-state | ✅ sent per-contact, expired bytes deleted |
|
||
| Stories: text-only, lifetime, reactions, replies, mute | ✅ a menu on + rather than straight to the picker; lifetime rides inside the sealed envelope |
|
||
| Groups: create, list, members, leave | ✅ members editable after creation |
|
||
| Group text and images | ✅ one upload, one envelope per member |
|
||
| Delivery keys + rotation | ✅ published as a digest, rotatable from Account |
|
||
| Trust state, verification, identity-change acceptance | ✅ pinned in the vault, surfaced on the thread |
|
||
| Key-log enforcement and head gossip | ✅ opt-in enforcement, gossip detects a fork |
|
||
| Clear thread | ✅ keeps the session, so the chat still works |
|
||
| History at rest (encrypted, device-only key) | ✅ **this row was stale** — sealed per thread under a Keystore key, read straight from disk; see below |
|
||
| Presence (read and set) | ✅ shown from the relay, never inferred |
|
||
| Buddy list, add by username | ✅ |
|
||
| Remove, block, unblock | ✅ |
|
||
| Member lookup | ✅ look up before adding; adding is gated on it |
|
||
| Buddy commands | — retired; not a feature any more (owner, 2026-08-25) |
|
||
| Voice calls (WebRTC, DTLS-SRTP, TURN) | ✅ P2P-first; ⚠️ no TURN deployed |
|
||
| Notifications list, read state | ✅ |
|
||
| Services (AIM/ICQ via `flamenet-oscar`) | — retired; not a goal for this app (owner, 2026-09-04) |
|
||
|
||
🔴 **History at rest was marked ⚪ and it has been built the whole time — the
|
||
third stale row in this file.** Every read goes to disk: `Session.history` calls
|
||
`Vault.history`, which decrypts the record. There is no in-memory copy to be
|
||
fooled by.
|
||
|
||
What Android does, against what iOS's `E2EHistoryStore` does:
|
||
|
||
| | iOS | Android |
|
||
|---|---|---|
|
||
| Cipher | AES-256-GCM | AES-256-GCM |
|
||
| Key | Keychain, `ThisDeviceOnly` | Android Keystore — never leaves the process at all |
|
||
| Bound to its slot | one file, nothing to confuse | 🟢 **the record NAME is the AAD**, so a blob cannot be moved between slots |
|
||
| Shape | one snapshot for everything | one record per thread |
|
||
| Off backups | excluded from iCloud | `allowBackup="false"`, and a Keystore key is unbackupable anyway |
|
||
| At rest, locked | `.completeFileProtection` | Credential-Encrypted storage: `filesDir` is unreadable until the first unlock after boot |
|
||
| Plain sign-out | keeps history | keeps history |
|
||
| Erase this device | `eraseLocalData()` | `forgetEverything()` **and** the Keystore key is deleted |
|
||
|
||
⚠️ Two honest differences rather than a claim of equivalence. Android's
|
||
per-record AAD is a binding iOS's single file does not need; iOS's
|
||
`.completeFileProtection` is a per-file assertion, while Android's equivalent is
|
||
a property of the storage volume rather than of this app's request. Neither gap
|
||
favours one platform overall.
|
||
|
||
⚠️ `Vault.history` prunes expired messages on the way out, so disappearing
|
||
messages are enforced on READ rather than by a timer nothing runs. It rewrites
|
||
the record only when something actually expired — a read is not a write.
|
||
|
||
✅ Calls are built, on `io.github.webrtc-sdk:android` — the one third-party
|
||
library in this app, and unavoidable: the alternative is hand-rolling ICE,
|
||
DTLS-SRTP and an audio pipeline.
|
||
|
||
🔴 **Peer to peer by default.** ICE tries a direct path first and only falls
|
||
back to TURN when both ends are behind NAT that refuses one. Signalling rides
|
||
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
|
||
on a call it never carries.
|
||
|
||
⚠️ **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 —
|
||
once, not every time, because a prompt people see repeatedly is one they stop
|
||
reading.
|
||
|
||
⚠️ **No TURN server is deployed** (`FN_TURN_SECRET` / `FN_TURN_HOST` unset), so
|
||
`/e2e/turn` answers 503 and calls are P2P-or-fail. Handled rather than hidden:
|
||
STUN alone still connects through most NATs, and the call banner says plainly
|
||
that this server has no relay so a call only connects if a direct path exists.
|
||
|
||
⚠️ **WebRTC's native libraries dominate the APK** — ~41 MB across four ABIs, of
|
||
which x86/x86_64 (~24 MB) exist only for emulators. Release is restricted to
|
||
arm64-v8a and armeabi-v7a; debug keeps all four so it still runs on an
|
||
emulator.
|
||
|
||
---
|
||
|
||
## Hardening
|
||
|
||
The security work iOS did as ios#19/#39/#41/#45. Android had none of it, and
|
||
two platforms disagreeing about a security guarantee is how one of them ends up
|
||
being the one that leaks.
|
||
|
||
| Feature | iOS | Android | Note |
|
||
|---|:--:|:--:|---|
|
||
| Back off on repeated app-lock attempts | ✅ | ✅ | ios#41 — 3 free attempts, then 30s doubling to a 5-minute ceiling |
|
||
| Handle a wrong clock | ✅ | ✅ | ios#45 — skew learned free from the `Date` header; expiry takes the LATER of device and relay |
|
||
| 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 |
|
||
|
||
⚠️ **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
|
||
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
|
||
promising more.
|
||
|
||
🔴 **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
|
||
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
|
||
device's clock and the relay's: a device set back cannot extend a message's
|
||
life, and neither can a relay that lies about the time, because the other source
|
||
overrules it. The remaining failure, expiring early, costs the reader something
|
||
they were about to lose anyway.
|
||
|
||
⚠️ Android needs synchronisation iOS does not. The Swift version is
|
||
`nonisolated(unsafe) static var` because its writers sit on one actor; here the
|
||
observation is written from whichever OkHttp thread finished a call and read from
|
||
the UI thread, so the two fields are `@Volatile` and updated together under a
|
||
lock. A torn read of "skew from now, timestamp from an hour ago" would age out an
|
||
observation just made, or trust one that had not been.
|
||
|
||
🔴 **The relay pin was not a missing feature on Android — it was a wrong
|
||
conclusion.** `Trust.verifyHeadSignature` already pinned `issuerPublicKey` the
|
||
first time a signed log head arrived. When the key later changed, the signature
|
||
stopped verifying and the only conclusion available at that point was
|
||
`KeyLogForkException` — so a rotation somebody performed on purpose was reported
|
||
to the user as *the relay showing two different histories*.
|
||
|
||
Those need different responses. A fork means nothing the relay says can be
|
||
trusted. A rotation means somebody ran a command, or somebody else is answering
|
||
on that address, and only a person can tell which. `RelayIdentityChangedException`
|
||
is deliberately **not** a `KeyLogForkException`, and `forkDetected` is not set —
|
||
an alarm people cannot act on is one they learn to dismiss, which would cost the
|
||
real fork detection its credibility too.
|
||
|
||
🔴 **Zeroing is where Android cannot match iOS, and the row says 🟡 for that
|
||
reason rather than being rounded up to ✅.** iOS has `memset_s`, specified not to
|
||
be optimised away, and iOS does not page application memory to disk. Neither
|
||
holds here: a compacting collector can relocate a `ByteArray` and leave the
|
||
bytes at the old address untouched, Android has had compressed swap on by
|
||
default for years, and `Arrays.fill` is merely "not eliminated in practice"
|
||
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.
|
||
|
||
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
|
||
"the key is gone".
|
||
|
||
🔴 One divergence from iOS, and it is a bug the Swift version cannot have. iOS
|
||
computes `30 * pow(2, steps)` in a `Double`, which saturates to infinity and
|
||
still caps correctly. Kotlin shifting a `Long` does not: `30L shl 60` wraps
|
||
NEGATIVE, and `minOf(negative, cap)` returns the negative — a wait in the past,
|
||
which reads as no wait at all and removes the whole feature at exactly the point
|
||
somebody is grinding attempts. The steps are clamped before the shift, and
|
||
`AppLockBackoffTest` fails without the clamp.
|
||
|
||
---
|
||
|
||
## Multi-homing (§20.4)
|
||
|
||
Both rows above were ⚪ and both were **stale**: `AccountKdf.localAccountSalt` is
|
||
pinned to the published vector and wired into sign-in for a local relay, and
|
||
`MailboxHoming` / `AccountIdentity.publishHomes` have shipped the publish and
|
||
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
|
||
older home record is *genuinely signed* — the account holder published it,
|
||
before they added a second relay — so a relay that wants to be somebody's only
|
||
mailbox does not have to forge anything: it serves the record it saw first, that
|
||
record verifies perfectly, and the sender delivers to one relay believing it is
|
||
delivering to all of them.
|
||
|
||
The relay refuses a lower serial on **write**. Nothing refused one on **read**:
|
||
`verifiedRelays` here and `verifyHomes` on iOS both checked the signature and
|
||
stopped. Fixed on both (Android this MR, iOS `ios!116`) by remembering the
|
||
highest serial seen per address and refusing anything below it.
|
||
|
||
⚠️ The floor rises only for a record that verified — otherwise a hostile relay
|
||
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
|
||
account holder.
|
||
|
||
⚠️ Neither client claims an attack when it refuses. A relay that has genuinely
|
||
fallen behind serves an old record for honest reasons, and nothing in a client
|
||
can tell that from the other case.
|
||
|
||
---
|
||
|
||
## Inbound control envelopes
|
||
|
||
🔴 **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
|
||
not consult it, so three things arrived from the iPhone app and were filed as
|
||
ordinary bubbles:
|
||
|
||
- an **edit** carries its new wording in `text`, so it drew as a duplicate
|
||
beside the typo it was meant to replace — a correction that made the thread
|
||
worse;
|
||
- a **pin** carries only an id, so it drew as a blank bubble;
|
||
- and any **control envelope from a newer peer** would have drawn a bubble full
|
||
of JSON — the failure `isControl` was written to prevent, defeated by nobody
|
||
calling it.
|
||
|
||
All three now branch before filing, and the last is guarded by `isRenderable`
|
||
itself rather than by a list of kinds this build happens to know: an allowlist is
|
||
only correct until the other platform adds one.
|
||
|
||
⚠️ An inbound edit is applied only when it comes from the person whose message
|
||
it is. Without that check any contact sharing a session could rewrite anybody's
|
||
words in the reader's transcript — a better attack than sending a message of
|
||
their own, because the words would carry somebody else's name.
|
||
|
||
---
|
||
|
||
## Test safety
|
||
|
||
| Feature | iOS | Android | Note |
|
||
|---|:--:|:--:|---|
|
||
| Live suites cannot run by accident | ✅ | ✅ | Android was already ahead here — every live suite gated on `assumeTrue`, and `LiveTarget.refuseProduction` refuses a production host outright |
|
||
| 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` |
|
||
|
||
🔴 **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
|
||
properly gated but still carried
|
||
`const val PASSPHRASE = "a-long-enough-passphrase"` and used it to sign up on
|
||
`accounts.flamenet.io` whenever somebody opted in. The gate stops it running by
|
||
accident. It does not stop the password being written down.
|
||
|
||
Both now read the passphrase from the environment with **no fallback**, and an
|
||
unconfigured run reports SKIPPED rather than signing up with a value anybody can
|
||
read. `FN_LIVE_PASSPHRASE` on Android, `TEST_RUNNER_FN_DEMO_PASSPHRASE` on iOS.
|
||
|
||
⚠️ Both guards are source scans, and both had to be narrowed before they were
|
||
worth keeping — the Swift one flagged an email default because "password"
|
||
appeared in a trailing comment, the Kotlin one flagged four suites that sign up
|
||
against a `MockWebServer`, which is the correct way to test sign-up. A guard
|
||
with standing false positives is one somebody switches off.
|
||
|
||
---
|
||
|
||
## The 2 September revert
|
||
|
||
Twenty-five Android merge requests landed that evening and `aebf41e`
|
||
reverted all of them — 5,016 lines across 41 files — for the same reason iOS
|
||
was reverted an hour earlier: the branches had never existed together, and
|
||
resolving them mechanically produced code that compiled and passed while
|
||
being wrong.
|
||
|
||
**All twenty-five are back**, reinstated one at a time on
|
||
`feat/files-and-wallet-parity`, each built and tested before the next was
|
||
started. Six of them could not be cherry-picked at all: the originals were cut
|
||
from a tree whose story features looked different from the versions rebuilt in
|
||
the meantime, so applying them whole would have left two implementations of
|
||
mute, text stories and story replies under different names — no compile error,
|
||
and exactly the kind of divergence that rots. Those were taken by hand, feature
|
||
by feature.
|
||
|
||
⚠️ One deliberate divergence from the originals: the mention wire key stays
|
||
`men`, not `ment`, and mentions are `List<Int>` rather than `List<Long>`. That
|
||
is what the iOS engine speaks and what the two were verified interoperating on;
|
||
switching to match the reverted commits would have broken iOS silently.
|
||
|
||
---
|
||
|
||
## Files and Wallet
|
||
|
||
From `Sources/Views/Files/*` and `Sources/Wallet/*`, both of which moved into
|
||
the messenger rather than staying separate apps.
|
||
|
||
| Feature | Android |
|
||
|---|:--:|
|
||
| Files engine: names, key wrapping, chunked content | ✅ pinned to the **browser's** published vectors, not to the Swift engine |
|
||
| Files engine: KDF and identity record | ✅ an identity written by the browser unlocks here |
|
||
| Local vault: add, organise, delete | ✅ index, folders, both storage modes, bulk delete with its report |
|
||
| Files screen: set up, unlock, browse, add, folders, delete, promote | ✅ **verified on a device** — set up, encrypt, lock, cold-start reload, link via SAF |
|
||
| The security lattice (labels, clearance) | ✅ encoding and clearance in Kotlin, pinned to independently-computed vectors. ⚠️ **Not Bell–LaPadula** — the ★-property is deliberately unenforced on both clients; see android#15 |
|
||
| Share a file into a chat or group | ✅ both directions; **proven live 2026-09-04** — a Restricted document crossed two vaults through a real relay with its label intact |
|
||
| Wallet engine: BIP-39, Base58, SLIP-0010 | ✅ pinned to the published vectors |
|
||
| 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: 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** |
|
||
|
||
🔴 **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` the tests run against returns `""`. Every nullable field in the vault
|
||
index therefore passed on a laptop and broke on a phone:
|
||
|
||
- `parentID` threw `Invalid UUID string: null`, so **a vault holding any
|
||
top-level file failed to load at all.** Adding the first file worked, because
|
||
it was still in memory; reopening the app lost everything, with an error
|
||
nobody could act on.
|
||
- `wrappedContentKey` was worse because it did NOT throw — `"null"` is valid
|
||
base64, so the node came back with a plausible three-byte content key and
|
||
simply refused to decrypt, for ever.
|
||
- `bookmark` produced a reference pointing at `"null"`, permanently missing.
|
||
|
||
Absence is now decided by `JSONObject.isNull`, never by comparing a decoded
|
||
string to a sentinel, and the literal `"null"` is rejected too. Two regression
|
||
tests reproduce the device's behaviour on the JVM by writing the literal string
|
||
the platform hands over — feeding a real JSON `null` would pass with the broken
|
||
code and prove nothing.
|
||
|
||
⚠️ **Worth a sweep.** `optString` on a nullable field is wrong everywhere in
|
||
this app, not only here, and it fails only on the platform it ships to.
|
||
|
||
**What the device run covered** (emulator, API 35): set-up with the passphrase,
|
||
a wrong passphrase refused, the SAF `OpenDocument` picker with the display name
|
||
pre-filled, encrypting into the vault at a chosen classification, lock and
|
||
unlock, and a cold-start reload of the vault. On disk: the blob begins `ENCL\x01`,
|
||
both wrapped keys carry version `0x11` and the name `0x01`, the filename is not
|
||
in the clear, and the plaintext is absent from the entire app container. Linking
|
||
a file works and reports "Nothing was copied".
|
||
|
||
✅ **The persistable read grant survives.** Checked after a device reboot, a
|
||
full emulator cold boot AND an app update: `dumpsys activity permissions` shows
|
||
`persistable=0x1 persisted=0x1` for the document, and the row reads through to
|
||
the file rather than reporting it moved. Promoting a link to the vault also
|
||
works on device — the bookmark is dropped from the index, the original file is
|
||
left untouched where its owner put it, and the byte count corrects from the
|
||
provider's reported size to the real plaintext length.
|
||
|
||
🔴 **The device run found a second bug: promoting a link appeared to do
|
||
nothing.** `Node.equals` compared only the id — written to dodge `ByteArray`
|
||
identity comparison — so a promoted row compared EQUAL to the reference it
|
||
replaced. The state object holding it compared equal too, `StateFlow` conflated
|
||
the update away, and the screen never changed: the file vanished from Linked and
|
||
did not appear in the vault until the app was restarted, which reads as losing
|
||
somebody's file. Every field is now compared, with `contentEquals` for the byte
|
||
arrays.
|
||
|
||
⚠️ Neither of the two device bugs was catchable by this suite as it stood. The
|
||
tests read the vault; the failures were in what the vault handed a `StateFlow`
|
||
and in what the platform's JSON handed the decoder.
|
||
|
||
🔴 **Android asks for a Files passphrase, exactly as iOS does, and this was a
|
||
decision rather than a port.** The Android wallet sits behind the Keystore-sealed
|
||
vault alone, so an unlocked phone opens it. Files does not: the identity record
|
||
is encrypted under a key stretched from a passphrase this app deliberately keeps
|
||
no copy of. The Keystore seal around it stays as the second layer. Dropping the
|
||
inner one for convenience would be a real reduction and not a refactor — a vault
|
||
that opens because the phone is open is a folder with a lock drawn on it.
|
||
|
||
|
||
🔴 **Files is NOT a mandatory access control system, and nothing should say it
|
||
is.** The sharing path forced the question android#15 asked, and the answer was
|
||
already written on the wire format: *the label is a MARKING, not a permission.*
|
||
The content key travels in the same envelope as the file — by design, because
|
||
that is what makes sending work — so no client-side refusal is a control. There
|
||
is no trusted computing base: the person holds every key, on a device they
|
||
control, running a client they could modify. **A suggestion dressed as a control
|
||
is worse than no control**, because it is the one people plan around.
|
||
|
||
What Files does provide, and what may be claimed: cryptographic read separation
|
||
(no label key, no plaintext — enforced by mathematics, not by a check), and a
|
||
classification that travels with a file and is preserved on the recipient's
|
||
phone. Bell–LaPadula's ★-property is deliberately absent on both clients, and
|
||
the test in `LabelTest` asserts that absence so it fails loudly if one platform
|
||
implements it alone.
|
||
|
||
🔴 **The Files crypto is held to `vectors/files-v1.json`, which the JavaScript
|
||
engine in the browser produced — a third implementation, not the Swift one.**
|
||
Checking Kotlin against Swift would only establish that the two agree, and two
|
||
implementations agreeing on the wrong answer is how somebody's files stop
|
||
opening when they change phones. The file is copied byte for byte from the iOS
|
||
repository rather than regenerated here, because a vector file regenerated by
|
||
the implementation under test proves nothing at all.
|
||
|
||
Two of those vectors were additionally recomputed from scratch in Python before
|
||
any Kotlin ran against them — `struct.pack('>hQ')` for the label encoding,
|
||
`hmac.new(...).digest()` for the name digests. The `café` case matches under NFC
|
||
and **not** under NFD, which is what makes the normalisation form pinned rather
|
||
than assumed: a platform normalising differently does not error, it just fails
|
||
to find a file somebody filed on another device.
|
||
|
||
⚠️ The tampering tests (truncation, reordering) each prove the surviving bytes
|
||
are still *authentic* under their original index and end-flag. Without that
|
||
second half, "it threw" would be all they established — and any bug at all makes
|
||
a mangled blob throw.
|
||
|
||
🔴 **The wallet primitives are now in Kotlin and pinned to the STANDARDS, not to
|
||
the Swift engine.** `Bip39`, `Base58` and `HdKey` are held to BIP-39's own
|
||
vectors, SLIP-0010's own ed25519 vectors and the published word-list SHA-256 —
|
||
the same things the Swift side is held to. Checking one implementation against
|
||
the other would only prove they agree, and two implementations agreeing on the
|
||
wrong answer is exactly how a wallet ends up empty: a phrase written down on an
|
||
iPhone has to restore on an Android handset, and on somebody else's software
|
||
years from now.
|
||
|
||
Every failure in that file is silent in production. A wrong checksum bit, a lost
|
||
leading zero, a missed NFKD — none of them error. They derive a different,
|
||
perfectly valid, empty wallet.
|
||
|
||
Two Kotlin-specific decisions, both money-relevant:
|
||
|
||
- **PBKDF2 is hand-written over `Mac`**, not `SecretKeyFactory`. The JCA path
|
||
takes a `CharArray` and the provider decides the encoding — `AccountKdf`
|
||
already carries that caveat for the account KDF. For a wallet the consequence
|
||
is worse than a failed sign-in: a phrase containing anything outside ASCII
|
||
would stretch differently under a different provider and derive a different
|
||
empty wallet with no error anywhere.
|
||
- **Base58 is a digit loop, not `BigInteger`.** The obvious Kotlin version is
|
||
`BigInteger(1, bytes).toString(58)` remapped, and it silently drops leading
|
||
zero bytes — encoding two different keys to one address.
|
||
|
||
🔴 **Android can SIGN, and iOS's constraint does not apply here.** `AccountKdf`
|
||
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
|
||
`HdAccount.signingSeed` produces a key that signs on any supported device. The
|
||
wallet is not blocked on a platform floor.
|
||
|
||
🔴 **A wallet is not derivable from the account, and that is asserted rather
|
||
than intended.** The entropy is fresh randomness — never the passphrase, the
|
||
handle, or the Flamenet identity. Deriving it from the passphrase would be
|
||
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
|
||
forget, so there is no undoing it later.
|
||
|
||
⚠️ **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
|
||
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.
|
||
Self-custody in name only.
|
||
|
||
⚠️ **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
|
||
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
|
||
shown rather than discovered afterwards.
|
||
|
||
⚠️ **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
|
||
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
|
||
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
|
||
this device's IP in somebody's logs.
|
||
|
||
🔴 **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
|
||
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
|
||
for the activity list: "not read yet" and "nothing yet" are different sentences.
|
||
|
||
⚠️ 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
|
||
occasionally does not earn the slot that decision refused.
|
||
|
||
🔴 **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
|
||
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
|
||
error nobody can act on.
|
||
|
||
⚠️ **A Solana address has no checksum, and `looksLikeAddress` says "well
|
||
formed", never "correct".** A single mistyped character that still decodes to 32
|
||
bytes is a perfectly valid address nobody holds the key for; money sent there is
|
||
gone, and neither the client nor the network will catch it. There is a passing
|
||
test that *finds* such a near-miss, so the limit is recorded rather than implied
|
||
— and it fails if the format ever gains a checksum, which is when the warnings
|
||
around typed addresses can be revisited.
|
||
|
||
---
|
||
|
||
## Settings
|
||
|
||
Every key in `Views/Settings/SettingsKeys.swift` has to exist, because each one
|
||
is a promise the UI already makes: push on/off, per-kind notification toggles,
|
||
in-app sounds, previews, read receipts, last-seen, online status, app lock and
|
||
its grace period, send-on-return, timestamps, larger type, appearance, recent
|
||
emoji, photo auto-download.
|
||
|
||
App lock also needs a lock screen that covers **everything including sheets**,
|
||
and obscures on backgrounding rather than only on return.
|
||
|
||
---
|
||
|
||
## Build order
|
||
|
||
Each step is useless without the one before it, which is why they are in this
|
||
order and not a nicer-looking one.
|
||
|
||
1. ~~**Session layer** — envelope codec, device identity, archive/restore.~~ ✅
|
||
Cross-engine verified at the envelope level, restarts included.
|
||
2. ~~**`:client`** — REST, relay auth, device registration, prekey upload,
|
||
inbox polling, attachments, groups, transparency log.~~ ✅ 13 tests against
|
||
a real HTTP server, plus an opt-in check against the deployed relay.
|
||
3. ~~**Vault** — Android Keystore, encryption at rest.~~ ✅
|
||
4. ~~**`:app` scaffolding** — Compose, sign-in/up/out, on-device tests.~~ ✅
|
||
5. ~~**Buddy list and chat.**~~ ✅
|
||
6. **Voice notes, groups, stories, calls** — in that order.
|
||
7. **More instrumented tests** — the JVM tests prove nothing about Conscrypt. This
|
||
engine has already been bitten once by exactly that.
|
||
|
||
---
|
||
|
||
## Two findings worth keeping
|
||
|
||
**Android has no ordinary Ed25519 provider.** Measured on API 35, not assumed:
|
||
`KeyPairGenerator.Ed25519` and `KeyFactory.Ed25519` are served by
|
||
`AndroidKeyStore` and by nothing else, and Keystore only ever makes keys it
|
||
keeps. Every Ed25519 key here is one we must hold — a device identity in our own
|
||
vault, an account key derived from a passphrase — so the JCA route cannot work
|
||
by construction. It compiled, passed 81 JVM tests, and threw
|
||
`IllegalStateException: Not initialized` on a phone. The engine now uses
|
||
BouncyCastle's `math.ec.rfc8032.Ed25519` directly: its one real dependency, and
|
||
it is there because the platform leaves a hole. `ProviderProbeTest` records the
|
||
measurement so nobody has to guess again.
|
||
|
||
**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
|
||
`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
|
||
list uses `id`/`presence`/`screen_name`, not `user_id`/`status`. None of these
|
||
were findable without a real relay, which is why the live tests exist and why
|
||
they are opt-in rather than absent.
|
||
|
||
## The CRUD rule
|
||
|
||
**Every capability must be reachable from the UI, with its full create / read /
|
||
update / delete surface.** Owner standing instruction, and it exists because
|
||
features kept landing in `:client` with a live test proving them and no door for
|
||
a person to walk through — remove a contact, block someone, clear a thread,
|
||
change a password. `CrudSurfaceTest` walks the app and asserts the controls
|
||
exist; it checks reachability rather than behaviour, because behaviour is what
|
||
the live tests already cover and the door is what was missing.
|
||
|
||
It found two real gaps on its first run: sign-out was reachable only from the
|
||
contact list rather than from Account, and several controls sat below the fold.
|
||
|
||
## Cross-platform, 2026-08-25
|
||
|
||
**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
|
||
instrumentation logged the exact text back:
|
||
|
||
ANDROID_HANDLE=andcross073349
|
||
ANDROID_RECEIVED=hello from iOS 073413
|
||
|
||
That is the direction the parity table's "separate program" row used to mean,
|
||
now done against the real client rather than a harness.
|
||
|
||
**Android → iOS is delivered but not yet *seen*.** The headless `:interop:peer`
|
||
decrypted a message from the iOS app and its reply came back from the relay as
|
||
`{"delivered":1,"failed":0,"to":239}` — so the envelope reached the iOS
|
||
device. What has not been watched is the iOS UI rendering it.
|
||
|
||
⚠️ The obstacle is the machine, not the protocol. XCUITest starts failing with
|
||
"Timed out while synthesizing event" and runs stretch from ~200s to over 1700s
|
||
when macOS background daemons are busy — `lsdiagnose`, `mds_stores` and
|
||
`wifianalyticsd` were between them taking several cores, with load averages
|
||
around 69. Nothing in a test log names that as the cause. Check `uptime` before
|
||
believing a slow or flaky XCUITest run.
|
||
|
||
⚠️ Point `-derivedDataPath` at a directory ending `.noindex`. Spotlight will
|
||
otherwise index every build product, which is a large share of the above.
|
||
|
||
## §19 on Android, 2026-08-25
|
||
|
||
Pinning, verification, identity-change acceptance, key-log enforcement and head
|
||
gossip now exist and are tested. What each one is worth:
|
||
|
||
**Pinning is always on.** Every identity key a peer offers is compared against
|
||
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`,
|
||
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.
|
||
|
||
**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,
|
||
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
|
||
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
|
||
tree. Consistency against a pinned head is the check it cannot satisfy.
|
||
|
||
Tested: 100 engine / 52 client, none failing. The consistency tests run over
|
||
**every pair of prefix sizes** in the published vectors, because the traversal
|
||
has a special case when the old size is a power of two — an implementation that
|
||
gets it wrong still passes at 3→5 and fails at 2→5. The refusals are tested
|
||
explicitly: a shrinking head, two roots at one size, a tampered proof node, a
|
||
served key absent from the log, an inclusion proof for somebody else's leaf,
|
||
and a rewritten log that cannot be reconciled with a pinned root.
|
||
|
||
✅ **Exercised against `relay1`**, 2026-08-25 — `LiveKeyLogTest`, opt-in via
|
||
`FN_LIVE_FLEET`. Against the real 231-leaf log: a key the relay genuinely
|
||
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
|
||
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.
|
||
|
||
✅ **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
|
||
`local:<audience>/<user_id>` before that. The subject is inside the hashed
|
||
bytes, so a verifier that does not know the address cannot recompute the leaf —
|
||
which made an honest key from such an account unverifiable. That was a false
|
||
positive on a real conversation, which is how a security setting gets switched
|
||
off and left off.
|
||
|
||
The address is now resolved once from the contact's handle
|
||
(`GET /e2e/account/identity/{handle}`, unauthenticated) and **pinned** beside
|
||
their identity keys, exactly as the keys are. `enforceKeyLog` recomputes against
|
||
the pinned address by default.
|
||
|
||
⚠️ **Pinned, not looked up per check.** Re-reading it each time would let a
|
||
relay that wants to substitute a key simply also change the name it files it
|
||
under, and every verification would agree with the new one. First contact is
|
||
still trust-on-first-use — taking the relay's word once is not a check; what it
|
||
buys is that every later answer has to match. The safety number covers the
|
||
first sighting and nothing here replaces it.
|
||
|
||
🔴 **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
|
||
new device — it is a different account key, which is to say a different person.
|
||
|
||
Proven against `relay1` in three states, because only all three make it legible:
|
||
refused with nothing pinned, accepted once the contact carries the right
|
||
address, and refused again with a wrong one — so what makes it pass is the
|
||
address being right, not the check having been softened. A changed address is
|
||
asserted to come back CHANGED against the real relay too.
|
||
|
||
⚠️ A relay that is unreachable pins **nothing** rather than pinning "no
|
||
address". Baking that in would be permanent, and the next successful lookup
|
||
would then read as the address changing — the alarm, for a network blip.
|
||
|
||
## Sealed sender, 2026-08-25
|
||
|
||
✅ **The relay no longer learns who sent a message.** Verified against relay1:
|
||
a sealed submission is accepted unauthenticated, the stored row carries
|
||
`from_user = 0`, and the recipient still reads it with the sender recovered
|
||
from inside the seal.
|
||
|
||
Cross-engine verified in **both directions** against the normative web engine
|
||
(`engine/test/interop-kotlin.mjs`, 43 assertions) — a seal made by Kotlin opens
|
||
in JS and vice versa, the inner envelope survives, and a seal addressed to one
|
||
device does **not** open on another.
|
||
|
||
🔴 **The seal is confidentiality, never authentication.** The recipient's
|
||
`IK_dh` is published to the world, so anyone can produce a well-formed seal
|
||
claiming to be from anybody. The claimed sender selects a session and nothing
|
||
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
|
||
something that looks authoritative.
|
||
|
||
⚠️ **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
|
||
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
|
||
would be lying about the one thing the feature is for.
|
||
|
||
⚠️ **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
|
||
every contact loses sealed access until the next time they are written to.
|
||
Reachable from Account; the copy says so.
|
||
|
||
The relay stores only `SHA-256(key)` — the key itself reaches contacts inside
|
||
E2E messages and never passes through the relay from its owner, which is the
|
||
only reason an unauthenticated submission can be authorised at all.
|
||
|
||
## Known flakiness
|
||
|
||
⚠️ **The emulator's network drops occasionally** — seen twice as "Failed to
|
||
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
|
||
before treating a red instrumented run as a regression.
|
||
|
||
⚠️ **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
|
||
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
|
||
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.
|
||
|
||
⚠️ **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
|
||
sent". Run a single `autoreply` at a time.
|
||
|
||
## Signing, on both sides (2026-08-25)
|
||
|
||
**iOS was pinned to the wrong Apple team, and that — not a missing membership —
|
||
is what made TestFlight unreachable.** `project.yml` carried
|
||
`DEVELOPMENT_TEAM: "2W7DA3A8W2"`, the free personal team attached to the iCloud
|
||
address on the build Mac. Meanwhile an **App Store provisioning profile for
|
||
`net.flamenet.messenger` already exists**, issued to team `M5923Y84P5` — the
|
||
team that ships hittfitness and shepherdgiving — and valid until 2027-06-04. A
|
||
personal team cannot produce a distribution build at all, so every archive would
|
||
have failed at signing while naming the certificate rather than the team ID that
|
||
put it out of reach. The team is now `M5923Y84P5`.
|
||
|
||
⚠️ **Still outstanding: no distribution certificate in the build Mac's
|
||
keychain.** `security find-identity -v -p codesigning` returns exactly one
|
||
identity, the personal-team *development* one. Signing in to Xcode with the App
|
||
Store Connect account mints the distribution certificate and the matching
|
||
profile; nothing in this repository can do it, and nothing here should try.
|
||
|
||
**Android needs none of that.** The release `signingConfig` reads
|
||
`~/.flamenet-android-signing.properties` — present on the build Mac since
|
||
2026-08-23 — so `:app:assembleRelease` produces a signed APK that can go
|
||
straight to a tester. That asymmetry is why Android reaches testers first, and
|
||
it has nothing to do with which app is more finished.
|
||
|
||
## Two reproducible symptoms in the iOS pair (2026-08-25)
|
||
|
||
Found running `TwoSimulatorUITests` against `relay1`. **Both reproduce with the
|
||
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
|
||
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.
|
||
|
||
Already ruled out: `E2EInbox.transmit` throws before recording anything when the
|
||
recipient has no device, and `send` appends to the thread only *after*
|
||
`transmit` returns. So alpha's line existing in alpha's thread is positive
|
||
evidence the relay accepted envelopes addressed to bravo's device. Bravo
|
||
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
|
||
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
|
||
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."
|
||
`createGroup` appends to `groups` on success and `groups` is `@Published`, so a
|
||
successful create would have rendered a row. It did not — the create threw.
|
||
|
||
⚠️ Both concern sending **to** the second account. Suggestive, not a diagnosis.
|
||
The next run needs the thread dump (added) plus the relay's own view of what is
|
||
queued for that device.
|
||
|
||
### Narrowed 2026-08-25: it is the RECEIVE side, and only when both sides opened first
|
||
|
||
The headless peer tool settles the direction. `qz89925` (user 402), a real
|
||
account driven from a shell, was sent to by the iOS app:
|
||
|
||
```
|
||
CrossPlatformUITests/testAMessageReachesANonIOSClient passed (39.2s)
|
||
peer poll → {"messages":[{"text":"","from_user":403},
|
||
{"text":"hello from iOS to the peer tool","from_user":403}]}
|
||
```
|
||
|
||
✅ **iOS → non-iOS delivery works.** The sender encrypts correctly and the relay
|
||
delivers. (The empty first message is the §14.4 delivery-key prelude, as
|
||
designed.) So neither the send path nor the relay explains the two-simulator
|
||
failure.
|
||
|
||
**What differs in the two-simulator case is that both sides initiate a session
|
||
with each other at the same time** — each signs up, opens the chat, and sends
|
||
before either has received anything. The peer tool never initiates, which is
|
||
exactly why it works.
|
||
|
||
### Disproved, and narrowed further (same day)
|
||
|
||
🔴 **The `existing != nil` theory is WRONG.** The suspect was
|
||
`E2EInbox.swift:995`, where `ingest` returns `true` — consume the envelope —
|
||
rather than holding the cursor, whenever the recipient already has its own
|
||
session. Two things kill it:
|
||
|
||
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,
|
||
so it cannot drop a first message.
|
||
2. Measured. `testARoundTripWithANonIOSClient` **passed in 54.2s**: iOS signed
|
||
up, sent first — creating its own outgoing session — and then received and
|
||
rendered the peer's reply.
|
||
|
||
```
|
||
peer add qrt90210 → {"added":"qrt90210"}
|
||
peer send → {"delivered":1,"failed":0,"to":404}
|
||
iOS round trip → passed (54.234s)
|
||
```
|
||
|
||
✅ So **non-iOS → iOS works even when the iOS client already holds an outgoing
|
||
session to that device.** Both directions are proven in isolation.
|
||
|
||
**What is left is genuine simultaneous initiation.** In the round trip the peer
|
||
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
|
||
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.
|
||
|
||
⚠️ **Leading hypothesis, not confirmed:** `E2EInbox.swift:1006` calls
|
||
`saveRatchet` on the session built from an inbound prekey **without archiving
|
||
the session it replaces**. `saveArchivedRatchet` appears exactly once in the
|
||
file, at line 914, and that call only re-saves an already-archived ratchet —
|
||
nothing ever archives a *live* session before overwriting it. The
|
||
archived-ratchet fallback at line 911 exists precisely for "messages sent on the
|
||
old chain before the peer reset", which is this situation, but it can only help
|
||
if something was archived.
|
||
|
||
To confirm, instrument both clients' ratchet ids across a concurrent send. Do
|
||
not patch on this reasoning alone: the file's history includes an
|
||
advance-unconditionally bug that lost messages silently, and a careless fix here
|
||
trades one silent loss for another.
|
||
|
||
### Why confirming it is expensive
|
||
|
||
⚠️ **A two-simulator run advances roughly one second of test time per minute of
|
||
wall clock**, and this is not machine load — measured with the host healthy
|
||
(38 runnable processes, load 27). The cost is `Capturing element debug
|
||
description`: XCUITest snapshots the entire accessibility tree on every query,
|
||
and this app's home screen carries a stories strip, a buddy list, a groups
|
||
section and a tab bar.
|
||
|
||
A DM run takes 20-40 minutes per side. The group phase has never completed
|
||
inside a sitting. **Budget hours, not minutes** — or give the pair a trimmed
|
||
screen to work against before running it.
|
||
|
||
### Reproduced cheaply, and now fixed — verified against relay1
|
||
|
||
2026-08-25, two `:interop:peer` accounts, no simulators, no UI. Neither polls
|
||
before sending, which is the simultaneous-initiation case.
|
||
|
||
**Before** (retired-session retention alone):
|
||
|
||
```
|
||
p1/p2 send delivered:1, failed:0 both first messages arrive ✅
|
||
p1/p2 send delivered:1, failed:0
|
||
p1/p2 poll {"messages":[]} {"messages":[]} ❌ both lost
|
||
```
|
||
|
||
**After**:
|
||
|
||
```
|
||
p1/p2 poll second-from-Q2 / second-from-Q1 ✅ both arrive
|
||
```
|
||
|
||
🔴 **What the first attempt got wrong, and it is worth remembering.** Retaining
|
||
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
|
||
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
|
||
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
|
||
vault was never handed to a decrypt, so it is the pristine one: **re-read it
|
||
before retiring it.**
|
||
|
||
⚠️ Second thing the first attempt got wrong: **which slot the winner belongs in
|
||
is not the same question as which chain opened it.** When the RETIRED chain is
|
||
the one that opens an envelope, it must be written back to the retired slot —
|
||
promoting it to current swaps the two and discards the chain we are sending on.
|
||
Each branch now writes back deliberately.
|
||
|
||
⚠️ Reproduce in ~2 minutes rather than the 20-40 a simulator pair costs:
|
||
|
||
```
|
||
peer q1.json create <relay> <h1>; peer q2.json create <relay> <h2>
|
||
peer q1.json add <h2>; peer q2.json add <h1>
|
||
peer q1.json send <h2> a; peer q2.json send <h1> b # neither polled
|
||
peer q1.json poll; peer q2.json poll
|
||
peer q1.json send <h2> c; peer q2.json send <h1> d # the exchange that used to die
|
||
peer q1.json poll; peer q2.json poll
|
||
```
|
||
|
||
## iOS TestFlight readiness, checked 2026-08-25
|
||
|
||
Verified against the BUILT PRODUCT, not the source tree — the difference matters,
|
||
and caught one thing that would have bounced the upload.
|
||
|
||
| | |
|
||
|---|---|
|
||
| `xcodebuild archive`, `generic/platform=iOS` | ✅ succeeded, arm64, WebRTC embedded |
|
||
| Unit tests | ✅ 38 passed, 0 failures |
|
||
| `PrivacyInfo.xcprivacy` | ✅ added, **confirmed present in the .app bundle** |
|
||
| `UILaunchScreen` | ✅ present (empty dict is the modern form) |
|
||
| Bundle / version / min OS | `net.flamenet.messenger`, 0.1.0 (1), iOS 16.0 |
|
||
| Usage strings | mic + Face ID; no camera use, so none required |
|
||
| App icon | 1024×1024 |
|
||
| ATS | fully on — no dev exception left behind |
|
||
| Third-party SDK manifest | WebRTC ships its own (no ITMS-91061) |
|
||
| Trackers | none — grepped for firebase/analytics/crashlytics/sentry/etc. |
|
||
|
||
🔴 **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
|
||
— 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
|
||
silently omitted it because the file was added after the project was generated:
|
||
**check the bundle, not the repo.**
|
||
|
||
⚠️ Two things a human must settle before submitting:
|
||
- `NSPrivacyCollectedDataTypes` is a legal declaration and must match the privacy
|
||
label in App Store Connect. Written from what the code sends; confirm it.
|
||
- `CURRENT_PROJECT_VERSION` is `1`. Fine once; every later upload needs a bump.
|
||
|
||
⚠️ **`plutil -extract` WRITES BACK IN PLACE** unless given `-o -`. Using it to
|
||
"inspect" an archive destroyed that archive's `Info.plist` down to 3 bytes. Use
|
||
`plutil -p` to read.
|
||
|
||
Still unproven: the feature surface end-to-end on a device — voice notes, calls,
|
||
groups, stories. Unit tests and an archive do not cover those, and testers will
|
||
be the first to exercise them.
|
||
|
||
## Muted story authors (ios#33)
|
||
|
||
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.
|
||
|
||
⚠️ **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 is not readable by anything that reaches the file. iOS puts it in the
|
||
sealed history snapshot for the same reason.
|
||
|
||
⚠️ **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 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.
|
||
|
||
⚠️ Your own stories are never hidden, even if your own id reaches the muted set
|
||
from a restored backup. Hiding somebody's own posts from them reads as the posts
|
||
having failed to send.
|
||
## Screenshot notices (§6.27, ios#36)
|
||
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.
|
||
🔴 **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
|
||
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
|
||
nothing**. Neither platform counts notices — a running total would be read as a
|
||
measurement, and its zero as "nobody screenshotted", which is exactly the
|
||
inference §6.27 forbids.
|
||
⚠️ **The platforms genuinely differ, and the copy says so rather than hiding
|
||
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
|
||
recording. So the disclaimer says "you are told when their app can tell", and
|
||
the Android text adds its own limitation explicitly. Claiming a symmetry that
|
||
does not exist would be worse than the gap, because somebody would rely on it.
|
||
⚠️ **Only where a timer is set**, on both. Everywhere else this would be a
|
||
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
|
||
untimed conversation start reporting.
|
||
⚠️ **Last-notice times are in memory on both.** Persisting them would store who
|
||
screenshotted whom, on disk, to enforce a five-second window.
|
||
## Text and link stories (§6.21, ios#32)
|
||
A story may carry no attachment; the caption is then the whole story. Both
|
||
platforms encode, decode, store, render and post one.
|
||
🔴 **The decode sits BEFORE the attachment branch on both.** That branch
|
||
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
|
||
never expires — both of the things §6.6 says a story must not be. Each engine
|
||
has a test named for exactly that.
|
||
🔴 **`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
|
||
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.
|
||
⚠️ **Android's stored record omits `ref` entirely** rather than writing an
|
||
empty object; a reload would otherwise resurrect a blob that fails to fetch.
|
||
`expiresAt` folds the two cases so no caller has to know which kind it holds.
|
||
⚠️ A text story has no blob, so nothing dies at the server's retention purge.
|
||
Client-enforced expiry is the whole mechanism rather than a backstop to one,
|
||
and both composers say so instead of implying more.
|
||
## Saving your own story (ios#34)
|
||
Both platforms can write a story you posted yourself out to the device gallery
|
||
before it expires. Local only; no protocol change.
|
||
🔴 **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
|
||
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
|
||
tested from both directions.
|
||
⚠️ **Neither asks for broad storage access.** Android writes through
|
||
`MediaStore`, which needs no runtime grant; iOS asks for `.addOnly`
|
||
authorisation. Requesting read access in order to perform a write would be a
|
||
much larger ask than the feature.
|
||
⚠️ **iOS needs `NSPhotoLibraryAddUsageDescription`** — without it the app is
|
||
terminated on first save rather than warned. Android has no equivalent trap.
|
||
⚠️ Both messages say the saved copy has left the disappearing model, and where
|
||
it went. "Saved" alone would let somebody assume the expiry still covered it.
|
||
## Story replies (§6.20, ios#30)
|
||
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` 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
|
||
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
|
||
back would resurrect it at a moment they did not choose.
|
||
🔴 **`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
|
||
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.
|
||
⚠️ **The disclosure comes before the first reply**, on both platforms.
|
||
Replying tells the author you watched, which stories otherwise never reveal.
|
||
Neither platform sends anything automatic when a story is merely opened.
|
||
## Private story archive (ios#54)
|
||
Your own stories, kept past their expiry. Local only; no protocol change.
|
||
🔴 **This keeps something the interface told everybody would disappear**, so
|
||
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**.
|
||
`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
|
||
rewritten. Both are tested from both directions.
|
||
⚠️ **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
|
||
loaded into `stories`); Android catches it in `Session.stories()` before
|
||
`saveStories` drops it. Both run repeatedly, so the fold is idempotent —
|
||
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
|
||
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 injects the setting into `Session` as a lambda rather than reading
|
||
preferences in the engine layer, which has no Android dependency. An absent
|
||
value reads as OFF on both platforms.
|
||
## Multi-item stories (§6.23, ios#53)
|
||
Several pictures as one sequence. Each item is an ordinary story envelope
|
||
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
|
||
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
|
||
conversation. Separate envelopes degrade to what they actually are — several
|
||
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
|
||
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
|
||
decay is confusing to look at *and* a disclosure — the gaps reveal the order the
|
||
author added things. Both engines have `setExpiry` and a test for it. A zero
|
||
means "never" and is skipped rather than read as "expires at the epoch", which
|
||
would kill the whole set.
|
||
⚠️ **Grouping renders what arrived.** `n` is the sender's claim and any item can
|
||
fail to arrive, so a set is never held back or shown as incomplete. Stories with
|
||
no set are keyed so two of them cannot collide into one group.
|
||
|
||
## Story reactions (§6.22, ios#52)
|
||
One tap, sealed to the author like a story reply.
|
||
🔴 **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
|
||
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
|
||
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.
|
||
⚠️ **Its own disclosure and its own stored key** on both — `reactDisclosureSeen`,
|
||
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
|
||
already filling the screen, so an account told about replies has been warned
|
||
about the considered case and not the careless one.
|
||
⚠️ Neither platform puts the control on a gesture that advances or dismisses a
|
||
story. An acknowledgement delivered by a mis-tap is a disclosure the person did
|
||
not make.
|
||
## Stories posted to a group (§6.24, ios#55)
|
||
An ordinary story envelope carrying `gid`, fanned out to the members and
|
||
rendered in the story surface.
|
||
🔴 **The group's roster is the audience; the sender's story audience does NOT
|
||
apply.** Two different lists making two different statements — who may see this
|
||
account's stories, and who is in a conversation — and they will disagree. The
|
||
roster wins: a group story that silently dropped members would be a group
|
||
message part of the group cannot see. Both platforms expose `groupStoryReach`,
|
||
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
|
||
non-contact was never on that list.
|
||
🔴 **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
|
||
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
|
||
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
|
||
as having come from it. The label is dropped to null rather than the story
|
||
refused — the story is legitimately theirs; only the claim about where it was
|
||
posted is unverified.
|
||
⚠️ `exp` is the ordinary story lifetime on both. Group membership does not
|
||
extend it.
|
||
## Report or block from a story (§6.25, ios#56)
|
||
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
|
||
all, on either platform or in the relay.
|
||
🔴 **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
|
||
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
|
||
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 send only what they were passed.** Neither reaches into the thread for
|
||
surrounding context — §6.25 forbids including anything the reporter did not
|
||
choose to include, and a helpful "we also attached the conversation" would be
|
||
exactly the wrong kind of help.
|
||
⚠️ **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
|
||
request to somebody else and nothing may come of it. A combined control would
|
||
make one of the two invisible.
|
||
⚠️ **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
|
||
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
|
||
triage what nobody can name.
|
||
## Sealed-to-unsealed fallback (§14.5, ios#16)
|
||
Sealing hides which account sent a message from the relay. When the peer's
|
||
delivery key or capability is missing, the send still goes — end-to-end
|
||
encrypted as always — but unsealed, and the relay learns the sender. Both
|
||
platforms now count every outcome and surface the share; both offer refusing
|
||
rather than degrading, **off by default**.
|
||
🔴 **The copy distinguishes sender from content on both, with tests asserting
|
||
it.** Somebody who reads "sealed" as "encrypted" makes the wrong decision in
|
||
both directions — relaxed when it is off, and alarmed when it degrades on a
|
||
message the server could never read anyway.
|
||
⚠️ **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
|
||
thing sealing exists to keep off a server, kept instead on the device.
|
||
⚠️ **Zero sends reports as "nothing sent yet"**, never a reassuring 0%.
|
||
⚠️ **Refusing costs nothing that was already spent.** On both platforms the
|
||
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.
|
||
⚠️ Android injects the policy and the counter into `Conversations`/`Session` as
|
||
lambdas — those modules have no Android dependency — and wires them in ONE
|
||
helper, because two sign-in paths building a session and only one applying the
|
||
policy is how a setting silently stops working on half the app.
|