This fix was made when the iOS end-to-end wiring test was found passing
while BYPASSED, and the same flaw was confirmed here — but it never got
committed, so the assertion has been sitting in a working tree while the
merged test went on proving nothing.
The test fetches `http://127.0.0.1:<upstream>/probe`. That URL points at
the upstream directly, so a request that ignored the socket factory
entirely reaches the same server and returns the same body. Asserting on
the response proved the HTTP server worked; it said nothing at all about
whether obfs4 was in the path.
The bridge now counts sessions that completed a handshake, and the test
asserts the count rose. Removing the socket factory reddens it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RelayClient.defaultHttpClient(bridge) now carries every request inside
obfs4 when a bridge is configured. The parameter defaults to null, which
is the shipping configuration: nothing changes for anyone who does not
ask for it.
A real HTTP request now travels through obfs4 end to end via OkHttp.
That test is deliberately Kotlin on both ends, and says so: the wire
format is already pinned to real lyrebird in both directions by
InteropTest. What is unproven until here is the plumbing -- that the
handshake happens before any stream is handed out, that bytes arriving
alongside the handshake are not dropped, and that a frame decoding to
nothing does not read as end-of-stream. Each of those loses data without
producing a wrong byte anywhere.
The handshake runs inside connect(), not on first write. OkHttp starts
writing a request the moment it has a connected socket, so a deferred
handshake would interleave with request bytes and desynchronise the frame
decoder on the first request of every connection.
Obfs4Socket ignores the endpoint OkHttp asks for. That endpoint is the
RELAY's address; the socket goes to the bridge, which proxies on.
Connecting to the endpoint would defeat the entire transport while
appearing to work.
All five SocketFactory overloads are implemented. Leaving any of them
returning a plain socket means some path silently bypasses the transport
-- and the traffic still works, which is exactly why nobody would notice.
A wrong bridge key fails as a refusal rather than hanging. obfs4 bridges
stay silent for anyone they cannot verify, so without the timeout this is
an app that never finishes a request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
scripts/obfs4-interop.sh builds a real lyrebird bridge and a real
lyrebird client from the flamenet-elligator2 checkout and runs the Kotlin
port against both. The bridge accepts the Kotlin client's handshake and
echoes data; lyrebird's own client accepts the Kotlin bridge and its
echo. 61 tests.
Both directions are needed, and the falsification shows why: swapping the
framing key halves by role leaves the CLIENT test green -- a client takes
the first half either way -- and fails only the reverse one. Encoding the
epoch hour as binary rather than a decimal string fails both. The
probe-resistance test, which asserts the bridge stays silent for garbage,
passes throughout, as it should.
Both clients are pinned to the SAME reference rather than to each other.
Two independent implementations agreeing with lyrebird is a real
statement; agreeing with each other is not, and would hide anything they
share.
The runner's name check earned its keep twice in ten minutes. First it
reported failure while all three tests had passed, because Gradle prints
`name() PASSED` with parentheses and the pattern omitted them. Then, with
that fixed, it caught the tests not running AT ALL: Gradle's up-to-date
check does not know the Go counterparty changed, so a second run skips
the task silently. Hence --rerun-tasks. An exit code alone would have
called both of those green.
The tests never skip quietly: absent configuration is a hard failure
unless OBFS4_INTEROP_OPTOUT=1 is set explicitly. Verified both ways --
the bare run fails, the opted-out run passes 61.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The protocol is now complete in Kotlin, short of a socket. 58 tests.
Handshake messages are checked against RECORDINGS, not computed values:
the vectors are real lyrebird-to-lyrebird handshakes captured off a pipe,
because the message carries random padding chosen inside lyrebird and a
MAC over the current epoch hour. The padding turns out to be recoverable
-- it sits between a fixed-length prefix and the mark -- so rebuilding
from the recovered representative, padding and hour must reproduce the
recording byte for byte. That makes these a test of generation, not only
of parsing, and both directions pass.
The epoch hour is a parameter, never read from the clock inside the
library, because a MAC with a hidden clock dependency cannot be tested
against a recording.
The asymmetry that catches people is preserved: the server searches only
the TAIL for the mark, because a client cannot send anything valid past
its MAC; the client must SUBSTRING-search, because a real server sends
framed data immediately after its handshake. The symmetric reading fails
against every real bridge.
GoRandom reproduces Go's math/rand quirks exactly -- float64 dividing an
int63 by 2^63 rather than the cleaner form Go rejected, and perm's
useless first swap -- so the packet-length distribution derived from a
seed matches lyrebird's. That buys indistinguishability, NOT
interoperability: padding is padding, and any distribution talks to a
real bridge. What a different one costs is looking like other obfs4
traffic, which is the entire point.
The client handshake returns leftover BYTES rather than an offset,
carrying forward the fix the Swift port needed: an offset indexes a
buffer accumulated across all calls, which a caller feeding one socket
read at a time does not have, and it only misfires when the server
handshake spans two reads -- routine over a network, almost never over
loopback.
HONEST GAP: the connection layer here is self-consistency-tested only.
Everything below it is checked against lyrebird's bytes, but two
identically-wrong ends agree perfectly. The Swift port has a live interop
test against a real lyrebird bridge; this one does not yet, and that is
the next thing that matters -- more than the OkHttp seam.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Whole runs of frames now match lyrebird byte for byte and decode back
byte-at-a-time. 33 tests.
XSalsa20 and Poly1305 both come from BouncyCastle. The Swift port
hand-writes both because CryptoKit's AEADs are AES-GCM and ChaChaPoly and
it exposes neither primitive; Poly1305 especially is a MAC, so a bug in
it is a forgery rather than a wrong answer, and taking a vetted
implementation is the right call wherever one exists.
Added standalone Poly1305 and Salsa20 vector tests, because the comment
above them claimed those vectors sat beside the frame ones and nothing
was using them. They do not test BouncyCastle's arithmetic -- that is
vetted -- they test that it is being CALLED correctly, key and parameter
setup being exactly where a wrapper goes wrong.
The frame tests run a sequence, not a single frame: the nonce counter and
the DRBG both advance per frame, so a one-frame test passes for an
implementation whose counter never increments and whose length mask is
recomputed from scratch each time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
64/64 handshakes agree with lyrebird on KEY_SEED, AUTH and KDF output,
from both the client and the server side. 23 tests.
SipHash is the one primitive hand-written here despite BouncyCastle
shipping one, and that is worth justifying because everything else in
this module is BouncyCastle's. BC's SipHash is a Mac: doFinal RESETS.
obfs4's DRBG writes eight bytes, finalises, writes eight more and
finalises again -- without ever resetting. Emulating that with a
resetting MAC means re-hashing the whole history per block, O(n^2) in the
frames on a connection. So the state machine is written out, and `sum`
finalises a COPY.
The DRBG is checked ten blocks deep, not one. obfs4's comment says each
block is a fresh H(K, previous); the code never resets, so blocks are
cumulative. The two readings agree on block one and diverge afterwards --
an implementation built from the comment decodes the first frame of a
connection perfectly and then fails.
ntor carries the same trap the Swift port hit: the transcript is
B | B | X | Y | PROTOID | ID, with B twice, while the comment directly
above that code claims ID | B | Y | X | PROTOID. Both orderings produce a
well-formed 32-byte KEY_SEED. Only one interoperates.
HMAC via javax.crypto, HKDF via BouncyCastle's HKDFBytesGenerator, X25519
via the ladder from part 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256/256 private keys now produce lyrebird's exact public key, and the
whole keypair -- private key to public key and representative -- matches
end to end. 13 tests.
BouncyCastle's X25519.scalarMult cannot be used for this: it clamps
internally and runs 255 bits, while the dirty variant needs the COMBINED
scalar fed to a 256-bit ladder with no re-clamping. The combined scalar
can exceed 2^255 once lsb*L is added, so a 255-bit ladder silently drops
the top bit for a fraction of keys. Only the ladder and the scalar
handling are written out; the field arithmetic underneath is still
BouncyCastle's.
The danger here is that the wrong answer looks entirely fine. A clean
X25519 public key agrees on shared secrets and round-trips; it is just
confined to the prime-order subgroup and therefore distinguishable, and
clamping the private key before the call is all it takes to get one. So
there is a structural test beside the byte comparison: the dirty key must
differ from the clean key exactly when the low three bits of the private
key are non-zero, with both arms asserted to actually occur so neither
passes vacuously.
cswap here uses an explicit mask rather than any BouncyCastle
conditional. Nothing in this module assumes those take a boolean-shaped
condition, after X25519Field.cmov was found to do nothing at all when
handed a plain 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A new `:obfs4` module, a plain JVM library like `:engine` so its tests run
without an emulator -- which matters here because the only tests that mean
anything are vector comparisons against lyrebird.
Far smaller than the Swift port, for a reason worth recording: the Swift
implementation hand-writes SipHash-2-4, XSalsa20, Poly1305 and its own
field arithmetic because CryptoKit exposes none of them. The JVM does not
have that hole. BouncyCastle -- already a dependency of :engine, for the
same class of reason -- has X25519Field, XSalsa20Engine, Poly1305 and
SipHash, vetted and constant-time where it matters.
So the two clients do NOT share an implementation. They share the vectors
and the live interop harness, which is what actually keeps them honest:
two independent implementations agreeing with the same reference is a
stronger statement than one agreeing with itself twice.
Two things BouncyCastle would not do, both found by vectors rather than
by reading.
X25519Field.sqrtRatioVar is not a drop-in. Elligator needs the root even
when the ratio is NOT square -- it is sqrt(i*u/v) then, and the algorithm
uses it to pick a branch rather than to detect an error. BC only promises
a usable root in the square case, and handing its output to the map
produced zeros for 120 of 256 vectors. The exponent chain and branch
selection are written out here; the field arithmetic underneath is still
BouncyCastle's.
X25519Field.cmov did nothing at all with a condition of 1. Both tweak
parities produced the SAME representative -- a map that still
round-trips, still looks uniform, and only ever emits one of the two
valid answers. The failing assertion said "wrong bytes"; it took a
diagnostic printing both candidates to see they were identical. Replaced
with an explicit mask so the convention is stated rather than assumed.
Behind no setting yet because nothing calls it: this is the crypto
foundation only. The transport, the OkHttp socket factory and the
user-facing switch come next, and the switch defaults to off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🔴 **Nine defects, all of them in the test, none in the app.** These
suites were written before the tab split, the age gate and the two
sign-out doors, and had not run since. Each staleness hid behind the one
before it, and every error message blamed the wrong thing.
1. **The age gate was never filled.** `submit` is `enabled = … &&
(!registering || birthYear.length == 4)`, and `performClick()` on a
disabled Compose node does not throw — it does nothing.
2. **`submit` was below the fold.** Adding that field pushed the button
off a 1080x2400 screen. An off-screen node still reports ENABLED —
that is a state check, not a visibility one — so the gesture reached
nothing. Proved by logging inside the app: `signIn` was never called,
through 450 seconds of waiting.
3. **The peer's name was typed into a button.** `new-contact` is the
FIELD; `lookup-contact` is the Look-up button.
4. **The lookup step was skipped**, so `confirm-contact` never appeared —
the app refuses to turn a typed string straight into a contact,
because a typo would silently become a stranger.
5. **`buddy-<id>` vs `buddy-circle-<id>`.** Home shows the circles row.
The suite reported "never became a contact" about a contact on screen.
6. **RECORD_AUDIO was never granted.** The mic dialog is not in the app's
Compose tree, so the failure read "No compose hierarchies found" — as
though the app had died rather than a dialog covering it.
7. **Sign-out was a no-op**: the row needed scrolling to, AND the
destructive rows confirm in place, so one tap only armed it.
8. **It used the wrong door.** A plain sign-out KEEPS the device and
credential by design — right for a person, wrong for a suite whose
next test wants a different account. `sign-out-erase` is the one that
clears the vault.
9. **Groups and stories are on their own tabs**, and `stories` is the
strip on Home, not the Stories tab, which carries `new-story`.
⚠️ `signOutIfSignedIn` no longer wraps itself in `runCatching`. Swallowing
that failure is what made the damage appear three screens later in a
different test.
⚠️ The two waits that span a relay POLL — a contact reaching the roster,
a group reaching the list — are 150s. They are the only two that flake,
because neither is refreshed by the response to its own write.
All four tests pass. Not yet all in one run: the add-contact lookup is
throttled per window, and four tests share it.
🔴 **Sign-up requires a year of birth and no instrumented test filled it.**
`submit` is `enabled = … && (!registering || birthYear.length == 4)`, so
the button was DISABLED — and `performClick()` on a disabled Compose node
does not throw, it does nothing. Every sign-up in this suite has been
tapping a dead button since the age gate landed, failing two minutes later
with 'nav-contacts did not appear' and no error anywhere on screen.
Nothing was wrong with the app. The suite simply stopped being able to
create an account, and a failing instrumented suite reads as a flaky one.
Also fixes the skip counter in scripts/instrumented.sh: it grepped a bare
SKIPPED, which matches Gradle's own `Task … SKIPPED` lines, so a verdict
written to stop a skip being read as a pass was inventing skips instead.
relay1/relay2 are what the fleet is FOR, and the suites need https because
Android blocks cleartext — so a local http relay cannot serve them at all.
Refusing both left no way to run the suite as designed.
Production stays refused outright. The test fleet is allowed with a loud
line saying accounts will be created on it, because they will be.
🔴 **The relay probe had no deadline and no way to say it failed.**
`discovered` is null both while the probe is in flight and after it has
come back with nothing, so `identityDescription` shows "Checking what
this server uses…" in both cases. A relay that never answers leaves the
sign-up form on that sentence permanently — no error, no retry, nothing
to act on.
Found by the instrumented suite on a clean emulator: nine of ten tests
failed with `nav-contacts did not appear within 120000ms`, and the screen
dump showed the form still checking.
⚠️ **The reason it never answered is worth writing down.** The app was
pointed at `http://10.0.2.2:18810`, and Android has blocked cleartext
HTTP by default since API 28 — there is no `usesCleartextTraffic` and no
network security config here. The request never left the device.
🔴 That refusal is CORRECT and is not what changed. `SiteConfig` already
says why: a bare hostname becoming `http://` would hand key material to
anyone on the path "and it would work, which is the worst outcome." What
was wrong is that it failed silently, so somebody self-hosting who omits
the scheme gets a dead screen and no idea why.
Now: bounded at fifteen seconds — far longer than a healthy relay needs —
and a failed probe is a distinct state that says
"Couldn't reach that server. Check the address — and note it has to be
https, not http."
Same defect as android#19 one screen earlier, which I fixed this morning
without noticing the probe had it too.
Also adds `scripts/instrumented.sh`, which closes two ways an on-device
run silently points at production: the suites DEFAULT to live hosts when
no relay is named, and a saved emulator snapshot restores whatever the
AVD was last doing — observed today as a snapshot signed in as
`fnreviewbeta` against relay.flamenet.io, under a run told to use a local
relay. The script has no default, refuses both live hosts by name, cold
boots with `-no-snapshot-load`, and exits 3 on a skip because a skip is
not a pass.
**The relay has always accepted `kind: "message"`. Nothing ever sent one.**
Reporting existed on stories only — so the surface where unsolicited
contact, spam and harassment actually arrive had no way to tell anybody.
The person being messaged by a stranger had Block, which protects them
and tells whoever runs the server nothing, so the account moves on to the
next person.
🔴 **Android had `reportStory` wired all the way to the relay and no
screen anywhere calling it.** The plumbing was complete and unreachable.
This is the first report UI on this platform, and the sheet is not
story-specific, so stories can use it too.
🔴 **The consent is the feature; the form is scaffolding.** Everything
else this app sends is sealed and a report is not: the relay cannot open
a message, so a report is actionable only if the reporter hands over what
they saw. §6.25 requires the client to say so, and it says so in these
words — what leaves the device, who can read it, and that it cannot be
taken back. Turning the copy off states what that costs rather than
quietly weakening the report.
⚠️ Block is offered beside it and never merged into it. A block takes
effect immediately and is the reporter's own decision; a report is a
request to somebody who may do nothing, and the sheet says which is
which.
⚠️ One message travels, never the thread around it — §6.25 forbids
including anything the reporter did not choose to.
⚠️ Report is absent, not disabled, on your own messages, and on the
stranger banner it sits below both buttons rather than beside them. Most
first messages are somebody you gave your address to; leading with Report
would frame every one of them as a threat.
⚠️ A relay may decline reports entirely (`reports_not_accepted`, 501).
That is a property of the server, not a mistake the reporter made, and
the message does not read like one.
sha256 39ccfbde…, signed CN=Flamenet Messenger with 791fc826… — the same
certificate as every build since 0.3, so 0.9 installs are offered this
rather than refused.
⚠️ APK into downloads/ BEFORE the manifest, and the served copy hashed
over HTTPS rather than trusted as copied. A manifest that lands first
announces a version the file is not, and every client checking in the gap
downloads the old one and rejects it against the new hash.
Second release through the notes gate, and it did its job unprompted:
`release-manifest.sh` read 0.10 back out of the APK and confirmed
`Resources/releases.json` names it before writing a byte.
**android#19 — sign-in sat for four minutes and said nothing.** The
failure path was always correct: anything `authenticate` throws is
reported. What it could not report was a call that never RETURNS —
observed 2026-09-05 with the requests demonstrably going out and the
screen unchanged. Nothing was thrown, so nothing was shown.
⚠️ Sixty seconds, not ten. Sign-in derives a key from the passphrase and
then talks to two services; an emulator or an older handset is
legitimately slow at the first part. This is a backstop for "never", not
a latency budget.
**android#6 — a failed key-log append was permanent.** `signIn` calls
`registerDevice`; `restore` did not. So a device registers exactly once,
and if the transparency-log append did not land — a slow write, a relay
mid-restart — it stays registered with no leaf, and every peer that
enforces the log refuses it forever with an error that reads as an
accusation of tampering. Relaunching did not help, which is the cruel
part: the one thing anybody would try was the one thing that could not
work. Restore now checks for a leaf and republishes if there is none.
⚠️ Checks first. Registering unconditionally would publish twenty fresh
prekeys every launch and churn the log for nothing — iOS's
`registerDeviceIfNeeded` tops up only when low for the same reason. Silent
on failure, because this repairs a state almost nobody is in.
**android#17 — a photo sent as a file keeps its GPS.** Same as ios#69 and
the same answer: the picker strips through ImagePrep and MediaScrub,
`Session.sendFile` passes the vault's bytes straight through, and the
send screen now says which path you are on rather than leaving the safer
one looking like a shortcut.
🔴 **0.9, not 0.10.** The tree said 0.10 because "Put the app back to the
last state that worked" bumped past 9 without publishing anything, so 9
belonged to nothing and the live manifest still said 8. Going back to 9
keeps the published line contiguous; the only rule that matters here is
that `versionCode` never goes backwards from what is INSTALLED, and
nothing carrying 10 was ever served.
Published: sha256 b86219dc…, signed CN=Flamenet Messenger with
791fc826…, the same certificate as every build since 0.3 — an update
installs over 0.8 rather than being refused.
⚠️ The APK went into downloads/ BEFORE the manifest, and the served copy
was hashed over HTTPS rather than trusted as copied. A manifest that
lands first announces a version the file is not, and every client that
checks in the gap downloads 0.8 and rejects it against 0.9's hash.
This is the first release through the notes gate that landed one commit
earlier: `release-manifest.sh` refused to emit anything until
`Resources/releases.json` in the relay named Android 0.9.
Publishing was three steps done by hand, and only two of them left a trace.
The manifest and the APK could go out with nothing anywhere saying what was in
the build — which is how 0.8 shipped with notes and 0.9 shipped with none.
The gate goes on the manifest rather than on a checklist, because the manifest
is the step that cannot be skipped: it is what the in-app updater reads, so an
APK published without it offers nobody an update. A checklist item is skipped
on exactly the busy evening the notes matter most.
🔴 It matches on the `versionName` read out of the APK, never a number typed
here — the same reason this script already reads the version rather than
accepting one. The two cannot disagree.
The check itself lives in the relay repository (Scripts/covers-android.py),
because that is what serves the /releases page and defines the format. Parsing
the JSON here would leave a second copy of the schema to drift out of step
with the first, and a drifted gate is a gate that passes everything.
⚠️ FN_SKIP_RELEASE_NOTES=1 still publishes, and says so loudly on stderr — a
skipped gate that printed nothing would be a gate nobody remembered turning
off. A missing relay checkout is a refusal, not a pass, and names FN_RELAY_DIR.
Proven on a real APK in all four states: covered (manifest written, valid
JSON), uncovered (refused, and stdout stays EMPTY so no half-written manifest
lands), skipped, and no checkout. Every message goes to stderr, because this
script's stdout is the manifest itself.
Reported: "on android, the files passcode should just reuse the account like
ios does."
🔴 **The difference was entirely in the words, and iOS has been right all
along.** iOS says "your passphrase" and takes whatever is typed, so a person
naturally uses their Flamenet one and it simply works. Android said the files
were "encrypted with a passphrase of their own", labelled the field "Choose a
passphrase", and later asked for "your Files passphrase" — three separate
places telling somebody to invent, and then remember, a second secret.
⚠️ **Forgetting it is a one-way door.** The label keys are derived from the
identity that passphrase protects and exist nowhere else, so a forgotten
Files passphrase makes every vaulted file permanently unreadable. Sending
people off to mint an extra secret for no reason put them one lapse of memory
from that, and the app already has a paragraph explaining the consequence.
⚠️ **Nothing in the derivation changed, deliberately.** It is still a
free-choice string protecting the identity record, so anyone who already set
a different one keeps opening their files with it exactly as before. Making
the account passphrase the ACTUAL key would be a migration with permanent
data loss on the far side of any mistake, and it is not what was asked for.
That also means the copy IS the feature here, so a copy guard is the only
guard there can be. Proven by reverting the wording: both tests go red naming
the file and the phrase.
1194 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from a live session: "the calls wouldn't connect because the keys
were changed. it wasn't obvious why the call failed." Confirmed by the
reporter — accepting the new key made everything work.
🔴 **It was not obvious because nothing said it.** The call's signalling
lambda was `runCatching { session?.sendCallSignal(...) }` with no
`onFailure`, so `IdentityChangedException` vanished. That exception is the
send path REFUSING to encrypt to an unreviewed identity — TOFU working
exactly as designed — and swallowing it meant the offer never left while the
screen sat in DIALLING until it timed out.
⚠️ **`describe()` had no case for it either**, so anywhere it WAS caught the
person got "Something went wrong reaching that server." The server was fine.
That sentence sent somebody hunting a network problem that did not exist,
which is the specific thing the neighbouring branches in that function exist
to prevent — they all say what to DO.
⚠️ **And the fix is on a screen the caller is not looking at.** Accepting a
changed key is a banner in the CHAT; somebody calling from the Calls tab
never sees it. So the message names the conversation, not just the problem.
This is a parity gap, not a new idea. iOS has named this on the call path
since it was built — "Their security code changed", with the review opened —
and its comment describes this exact bug: "sent someone hunting network
problems when the actual cause was their contact reinstalling".
`describeFailure` moves to file scope so the mapping can be tested without an
`Application`. It never touched instance state, so that is a move rather than
a rewrite — the alternative was a Robolectric run to assert a string.
Proven by removing the branch: all three tests go red reporting the old
sentence verbatim, "Something went wrong reaching that server."
1196 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
relay#49 requires that labels and security levels be kept as Messenger and
Files converge. Both engines have implemented §1's lattice independently
since the Files apps shipped, each pins its own vectors, and nothing has ever
compared the two.
🔴 **The ten-byte encoding is authenticated data for every construction that
uses a label**, so a platform disagreement does not warn. It produces a
decryption failure, or — worse — plaintext that quietly belongs to the wrong
sensitivity. Each side passing its own vectors is necessary and not
sufficient; two engines can pass their own and still disagree.
`label_encode` reports the mask as a STRING. Bit 63 makes it a negative Long,
and a JSON number would arrive on the other side as a double and lose the low
bits — turning the one compartment most likely to be mis-shifted into the one
this check cannot see.
The matching harness stage is flamenet-e2e!28. Proven by breaking it rather
than by watching it pass: dropping `compartments.containsAll(...)` from
`dominates` — the classic "compares the level and forgets the areas" bug —
turned exactly four checks red, all of them compartment cases, each reporting
iOS and Android disagreeing. That bug behaves correctly on every case built
from levels alone, which is why it needs two engines to catch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The interop driver dispatched 19 commands — device, initiate, encrypt, seal,
log_leaf — and not one touched a content envelope. Swift has had
`content_encode`/`content_parse` for a while; Android has never been checked
against any other implementation at this layer.
🔴 **This is the half that most needed it.** SPEC.md §6.10 says so in as many
words, after the `ctl:true` bug: "no vector covers this envelope. It is the
standing argument for the interop harness." That bug was Android writing
`"ctl":true` where iOS decoded a number — and the cost was not a missed flag
but a failed decode that dropped the whole message onto the plaintext path,
while each engine's own tests passed on both sides.
⚠️ The two parsers fail DIFFERENTLY, which is why a round-trip on either
proves so little. This engine reads field by field through `org.json`, whose
bundled Android build coerces a JSON null to the string "null" where Maven's
returns "". iOS decodes all-or-nothing through `Codable`, so one bad field
downgrades the entire envelope to a raw-JSON bubble. The same additive change
degrades differently on each platform.
`content_parse` also reports `isControl`, so the harness can check §6.10's
rule across engines instead of each engine checking itself.
The matching harness stage is flamenet-e2e!27. Proven by breaking it: making
this engine read the SPEC's `ment` instead of the shipped `men` turned
"iPhone mentions parse on Android" red — the exact drift that has been
invisible since the key was chosen.
1190 tests still pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The message menu labelled the delete `if (line.mine) "Unsend" else "Delete
for me"`, and both branches call `deleteMessage` → `Session.deleteMessage` →
`vault.deleteLine`. That is the whole implementation. No envelope leaves the
phone; the recipient's copy stays exactly where it is.
🔴 **Worse than an overclaiming page**, which is why this got a test and not
just an edit. A page that overclaims can be read sceptically. A button that
reports success cannot: the feedback IS the claim, and it arrives at the
moment the person is most relieved to believe it.
"Delete for me" was already the other half of that ternary, and it was always
the accurate half.
⚠️ Two tests, not one, because the first is worthless without the second: a
source scan that walks the wrong directory finds no files and passes every
assertion in it. `the scan actually reaches the chat screen` pins that the
walk reaches the file the bug was on. The walk climbs to the module root
rather than resolving a relative path, for the same reason.
Proved by breaking it, not by watching it pass:
label reverted → FAILED, naming ChatScreens.kt line ~1564
restored → 2 tests, 0 failures
1190 tests pass across the suite.
⚠️ This is ios#57 on the other platform, fixed there in the same pass. A real
retract is a new `kind` on rails that already exist — §6.15's edit machinery
is implemented and tested on both clients. But even then it is a request, not
a guarantee: a client can ignore it and the message may already have been
read. Whatever the button eventually says must not promise more than "we
asked them to remove it."
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The published spec says a group pin "is carried correctly on the wire but
cannot resolve yet, because a group transcript does not record" a message
id. `Session.sendGroup` mints a mid and passes it to `record(...)`, so the
stated reason is already false — but relay#46's rule is that a claim is
flipped by a run, not by reading the code, and nothing exercised the group
case specifically.
Five tests, on a group thread rather than a 1:1 one, because that is what
the spec singles out: the transcript carries an id, the pin resolves to the
message it names, and a group pin does not leak into a DM.
⚠️ One of them is about the design rather than the plumbing. The pin stores
an ID and never a copy, so the banner follows an edit instead of quoting
words that are no longer in the conversation — a stored copy would go stale
the moment the message changed and would outlive it being deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Confirmed exactly as filed: 15 red `ScreenshotTest`s, all in the RELEASE
variant, all "Unable to resolve activity for Intent". The diagnosis in the
issue was right — `androidx.compose.ui:ui-test-manifest` is a
`debugImplementation`, and it is what declares the `ComponentActivity`
those tests launch, so the release variant's merged manifest has no such
activity.
⚠️ **The tempting fix is to add that dependency to release as well. Do
not.** It exists to inject a test-only activity into the manifest, so
putting it on `releaseImplementation` would ship that into the APK. The
tests belong to the variant that can run them.
⚠️ Excluded by name rather than by disabling `testReleaseUnitTest`
wholesale — everything else still runs in both variants, so a genuine
release-variant difference would still be caught.
⚠️ And the first green was not evidence: `clean` followed by `test`
reported BUILD SUCCESSFUL in 3 seconds, which is far too fast to have run
anything. Re-run with `--rerun-tasks` and counted: debug 129, release 114,
JVM modules 933, 0 failures. The 15-test gap between the variants is
exactly the excluded screenshot tests, which is the shape that proves the
exclusion did what it says rather than taking the suite with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The published spec says the Android client *"cannot yet apply one, because
its transcript does not record a `mid` for each line"*, and that the same
gap blocks replies and reactions.
The transcript DOES record a mid, and `MessageReactionTest` already folds
a reaction onto a message by id — so the stated reason is false and the
consequence for §6.9 is false with it.
⚠️ But relay#46's rule is the point of this commit: **do not flip a claim
because the code looks present; flip it because a run proved it, and say
which run.** `applyEdit` was implemented and wired with nothing exercising
it — `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 run, written so the correction has something behind it.
Five tests, and the two that matter most are refusals: an edit from
somebody who did not write the message, and an edit of our own message
claiming another author. Without the author check a contact could rewrite
another person's words in a transcript, which reads to the reader as the
sender having said something they never said. Both proved by removing that
check and watching exactly those two go red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`attachmentBytes` wrote DECRYPTED bytes to `cacheDir/att-<id>` and nothing
ever removed them. So a disappearing message expired, the bubble went, and
the photo or voice note stayed on disk indefinitely — the exact failure the
feature exists to prevent. Spec §6.11: attachments MUST be purged with the
message that referenced them.
🔴 **A sweep, not a purge at each deletion point, and that changed what the
fix had to be.** FOUR paths drop a row — message expiry, story expiry,
delete-for-me and clear-thread — so threading "here is what I removed" out
of each would still miss anything a future path drops, and would never
reach the orphans already on disk. Asking instead "what is still
referenced?" is complete by construction and self-healing.
🔴 **Refs are found by SHAPE.** An attachment reference serialises with
`id`, `key` and `digest` together and nothing else in the vault has all
three, so `attachmentIdsInUse` walks every record and matches on that. A
hand-written list of stores is wrong the day somebody adds a store and
does not update it — and the sweep would then delete attachments still in
use, which is far worse than the leak.
⚠️ **That is not hypothetical: enumerating by hand gets it wrong today.**
The obvious sources are history and stories. The vault ALSO holds refs in
the story archive, in per-contact avatars and in this account's own
avatar. A sweep built on the obvious two would have deleted every avatar
on the device the first time it ran, and blanked the user's own story
archive — which is the one thing that archive exists to prevent.
Three more safety properties, each with a test that fails without it:
fails closed when the in-use set cannot be built (a partial answer is what
turns a cleanup into data loss); a grace period so a sweep racing a fetch
cannot delete bytes on their way to the screen; and a throttle inside the
function, since it reads every vault record and is called from the poll
loop.
⚠️ The cost is that a file survives to the next sweep rather than going at
the instant of expiry — named in the code rather than left implied.
17 tests. Every guard proved by breaking it. ⚠️ One did NOT hold at first:
removing the `/` -> `_` filename mangling broke nothing, because every id
in the suite was slash-free — the guard was untested by the tests meant to
be guarding it. Added the case; it now fails as it should.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`LiveFileTransferTest` was committed unrun, with the caveat that an unrun
test proves nothing. It has now run against relay1 on the same image
production runs: a Restricted document left one vault, crossed a real
relay, and arrived in another's with its classification and both
compartments intact.
Ran alongside it, all green: 6 LiveConversationTest, 7 LiveKeyLogTest, 2
LiveSealedSenderTest, LiveSignUpTest, LiveRelayTest and the self-hosted
LiveShapesTest paths — 31 live tests in total.
⚠️ The managed-account paths stay SKIPPED. They need FN_LIVE_MANAGED, which
signs up against accounts.flamenet.io — production, and `LiveTarget`
refuses it by design. Those accounts are real and persist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>