`MessengerViewModel.update` persists the unread set through
`Vault.saveUnreadThreads` on every change, unfiltered. A thread id for a
user conversation is `user:<id>`, so an off-the-record conversation with
something unread put the peer's id on disk, and it came back at the next
launch. `saveOffTheRecord` says the setting is kept and only the
setting.
The filter goes in `saveUnreadThreads` rather than at the call site, for
the reason the call site gives for being a chokepoint: a later caller
cannot forget it. The vault already answers `offTheRecord(threadId)` for
exactly that key, so the check was available and unused.
Nothing is lost. An off-the-record thread holds no lines at rest, so a
restored mark would badge a conversation with nothing in it.
iOS had the same fault on `unreadPeers` and is fixed in ios!221. Both
are the September pins-and-drafts finding again, on the one dictionary
each side had left.
Falsified: dropping the filter turns two of the four tests red, and the
report XML says `tests="4" skipped="0"` in both directions, since gradle
will report success having run nothing.
Engine 711 and client 469, both green. The 55 engine failures a worktree
shows first are `vectors not found`: the vector tests resolve the
published vectors through a sibling `flamenet-e2e` checkout, which a
worktree has no path to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The receive half. PeerBundle now carries ik_pq and spk_pq_sig, PQIdentity
decides what to do with them, and Conversations enforces it where a session is
established.
The three refusals match the web engine and iOS: PQDowngradeException when a
device that was seen with an identity appears without one, PQVerificationException
when the signature does not verify, and PQIdentityChangedException when the
identity is not the pinned one.
The pin is the SHA-256 of ik_pq, not a boolean, because section 20.5 is
explicit about why: a relay that substituted its own ML-DSA key would satisfy
a boolean pin and the substitution would go unnoticed. Same fingerprint the
web engine stores, so it means the same thing on both.
Absent to present is allowed and present to absent is refused. That asymmetry
is section 20.2 and 20.5 together: a device that has never published an
identity is classical and may start later, and one that has may never stop.
Enforced on the new-session path only, because section 20.5 says the pin is
consulted when a session is established, which is the only moment a downgrade
can be attempted; an existing session never refetches a bundle. A bundle that
never opens a session therefore cannot move a pin.
PQIdentity.check is a pure function of the pin and the bundle, so every case
is testable without a vault or a relay. Thirteen cases, most of them a relay
misbehaving: stripping, substituting, a bent signature, an identity with no
signature, and a signature over a different device's prekey.
Falsified: disabling the two refusals turns the suite red on exactly the two
tests that cover them. Engine 707 green, client 469 green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The DeviceIdentity half. A new device now mints a 32-byte ML-DSA-65 identity
seed, archives it, and publishes ik_pq and spk_pq_sig in the registration
bundle.
spk_pq_sig covers EXACTLY the bytes spk_sig covers, the raw X25519 signed
prekey, because section 20.1 requires a verifier to check one message under
two schemes rather than two messages under two schemes. Signing anything else
would have verified against ourselves and failed against the web engine and
iOS, which is the failure this whole section is designed to prevent.
Published before the v2 early return, not after, because section 20.2 makes a
post-quantum identity independent of post-quantum key agreement. Putting it
after would have tied the two together against the spec.
The archive key is the web engine's ikPQSeed, so an export crosses platforms.
An archive written before this restores as classical-only and is deliberately
NOT given an identity behind the user's back: the reference implementation
mints only on create, and a device that starts publishing an identity it did
not have is a decision to take on purpose.
Proven rather than assumed, both directions across implementations:
BouncyCastle and the web engine derive the same public key from the same seed,
each verifies a signature the other made, and each refuses the other's
tampered signature. Ten wiring tests, ten primitive tests, engine suite 694
green.
Still not done: nothing VERIFIES a peer's ik_pq yet, and there is no pinning,
so this publishes an identity that no Android device checks. That is the
receive half.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section 20 post-quantum authentication shipped on the web engine and iOS.
Android had nothing, so it published no ik_pq, and section 20.2 makes an
absent identity a supported state rather than an error. Every conversation
with an Android device therefore fell back to classical authentication
SILENTLY. This is the first half of closing that.
PQSign is ML-DSA-65 on BouncyCastle 1.79, which is already a dependency and
already carries the sibling mlkem package PQKEM uses. Seeded key generation
via MLDSAPrivateKeyParameters(params, seed): 32 bytes persisted, the signing
key rederived on demand, so a post-quantum identity lives in the existing
vault without a new storage format.
The load-bearing test derives the published seed's public key and compares it
byte for byte against protocol-v1.json. FIPS 204 keygen is deterministic in
the seed, so a device that derived a different key would publish a different
identity on restore and every contact would read it as a substitution.
Signatures are deliberately not pinned because ML-DSA signing is randomised;
verification is checked against the public key instead.
Falsified rather than assumed: flipping one bit of the derived key turns the
suite red on two assertions including that one. Engine suite is 684 tests
green. The test reads FLAMENET_VECTORS, which is the name CI exports; an
invented name would have passed here and found nothing there.
NOT yet wired: DeviceIdentity still mints no identity seed and no bundle
carries ik_pq or spk_pq_sig, so nothing changes on the wire yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both were built and neither was reachable.
§6.24, group stories. The receive side has been complete for a long
time: an arriving story carries its gid and the gid is checked against
the group's roster before it is believed. `postGroupStory` had no
callers, so nothing could ever produce one. The composer offers the
groups this account is in as destinations now.
A group REPLACES the audience rather than filtering it. Running a story
audience over a group's roster would quietly drop the members somebody
excluded from their contact stories, which is not what posting to a
group means, and the branch runs before the contact check so a person
with no contacts at all can still post to a group they are in.
§6.23, a sequence posted as one story. Android was further behind: the
receive side stored `set` and the feed never read it, and
`Conversations.postStory` never passed one to the encoder, so this
phone could not make a sequence and drew an iPhone's three-picture set
as three separate stories from the same person, one after another.
The feed groups through `Vault.groupedStories`, which already existed
and had no callers outside its own test, and says how many pictures a
card stands for. The sender shares one set id across the items and puts
the caption on the first only: a sequence has one thing to say and
repeating it under every picture reads as a stutter. The poster's own
copies carry the same id, so they see the sequence their contacts will.
Capped at ten, matching iOS. Each item is sealed, uploaded and sent to
every recipient separately, so a set of forty is forty uploads and the
person waiting cannot tell it from a stuck app.
Falsifying this found a duplicate: I had added a second `groupedStories`
as an instance method beside the companion one that was already there.
It compiled, the feed resolved to the original, and mine was dead the
moment it was written. Removed, and the one case my test had that the
existing `StorySetTest` did not now lives in that file instead of a
parallel one.
engine 674, client 469, app 626, all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Android had no part of this: no type, no route, no fetch. The relay's
half was complete and iOS had the crypto, so a lost Android phone was
the one that could not be erased at all.
DeviceWipe in the engine, signing and verifying the same canonical
string as the iOS half, `fnwipe:v1:<device>:<seconds>`. A vector in
the tests takes a signature the SWIFT implementation produced and
verifies it here, and the iOS suite takes one produced here. Not a
comparison of the two signatures: CryptoKit randomises Ed25519 signing
and this does not, so they never match and never need to. What interop
needs is that each side verifies what the other signed.
The poll asks once a session and stops there if it erased, rather than
carrying on into a vault it has just emptied. The account public key it
checks against comes out of the address this device already stored, not
out of anything the relay just said.
Issuing is on the Devices card and asks for the passphrase, which is
what somebody holding the phone does not have. A passphrase that does
not derive this account's published address is refused before anything
reaches the relay. The dialog shows DeviceWipe.HONEST_DESCRIPTION,
kept word for word with the iOS copy: two apps describing one guarantee
differently is how somebody ends up believing the stronger sentence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same data loss as the iOS side, in the other direction. When a
contact reinstalls, every message they send before this phone accepts
their new keys was destroyed: the poll advances its watermark whatever
happens, so the relay marked them delivered and forgot them, and the
catch in ingest swallowed the refusal and moved on. Tapping "Accept new
keys" brought nothing back. The sender saw delivered messages and this
side saw an empty thread under a security banner.
The identity gate is right and stays. The discard is what changes.
A refused envelope is now set aside as it arrived, still ciphertext, in
the sealed vault so it survives the app being closed -- which is the
ordinary way this happens. Bounded at a hundred per sender and thirty
days, expired on the way in so it needs no timer that a rarely-opened
app would never run. Blocking or removing a contact drops their hold
rather than keeping a removed contact's messages.
Two extractions were needed to make a replay possible, and both are
worth having on their own. The per-envelope work in the poll loop is
now `consume`, so a held envelope can go through exactly the path a
fresh one does; the replay calls it directly rather than through the
de-duplicating check at the top of the loop, which has already seen
every one of those envelope ids and would deliver nothing while
reporting success. And everything `receive` does with a batch of
decrypted messages is now `absorb`, because a replay that stopped at
the decrypt would write nothing and show nothing, which is this same
bug one layer further in.
The banner says how many are waiting, and accepting re-reads the thread
it just filled rather than only re-badging the trust state.
engine 663, client 464, app 582 across both variants, all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each verified by bare-word grep across app, client and engine, not by a
counter: the twenty-five WebRTC, BiometricPrompt and capturer callbacks
that also show zero Kotlin-side references are invoked by the SDK and
are not dead.
refreshGroups Session calls relay.listGroups directly
filesGoUp the breadcrumb bar is how Files goes up here
clearStoryArchive
WalletChain.setEndpoint
RelayAccounts.verifyEmail a wrapper; the path is posted elsewhere
Vault.visibleStories Session filters mutes inline at 1665
Vault.forgetWallet KeystoreVault.destroy covers it
Vault.forgetFilesIdentity same
postGroupStory looked dead by the same measure and is not being
deleted: it is the only implementation of group stories on this side,
so removing it would drop a spec feature rather than tidy up after one.
Whether to wire it to a screen or take it out is a decision, not a
cleanup.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"This used to be drawn inside SignedIn", "nulling current first (as this
used to)", "this used to report chat.peerId". Each keeps the reason it
carried, written as the property rather than the change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unread markers were a Set in the view model's UI state and nowhere
else. Kill the app with unread conversations and every badge was gone at
the next launch, whether or not anything had been read. The same fault as
the story reactions were: a mark somebody has not yet dealt with is
theirs until they deal with it, not until the process ends.
The vault keeps the set now. It is seeded from there when the signed-in
state is built, and written from `update()`, the one chokepoint every
state change passes through, whenever the set differs from before. That
is one write for the four sites that mutate it today and for any fifth
one, rather than four writes that a fifth could forget. Synchronous on
purpose: it is a short list of ids, and dispatching it would let two quick
updates land out of order.
Coverage, honestly: the vault round trip is tested in both directions,
including that a cleared set stays cleared. The view model chokepoint is
covered by compile and the full suite; there is no view model harness
beyond a screenshot fixture, so the seed and the write are verified by
reading rather than by a failing test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two faults, neither of which raised anything.
Replying to somebody's story sent the message and wrote nothing here, so
the person who typed it could not see what they had said. The iPhone has
recorded its own copy since story replies existed. The local copy is
written now, whether or not the send reached anybody: what somebody typed
is theirs either way.
And `Content.repliesToStory` had been parsed, unit tested and read by
NOTHING since the day it was added. It stopped at the parser, so an
arriving reply filed as an ordinary one quoting a `mid` that matches no
message here, because a story key is not a message id and the bubble had
nothing to resolve. Wiring it took four links: `record` writes `reStory`,
the ingest passes it, `Line` surfaces it, and the export carries it in
both directions under the name the other engine already uses.
That last one is its own lesson. The tests around it asserted the parser
set the field correctly, and it did. None of them asked whether anybody
read it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This engine has written `seen` into the shared shape all along, on the
grounds that the iPhone had no unread mark on a story. That turned out to
be wrong in a way worth recording: the iPhone HAS the mark and was not
writing it, locally or to a backup, so the ring around every contact
relit at each launch and a restore through it lost the lot.
The iPhone writes it now, so the comment claiming the field is ours alone
is false and is replaced, and `stories[].seen` moves out of the vector's
divergence list and into the canonical set.
No behaviour change here. The code was already right; only what it said
about the other engine was not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This app keeps one `threads` map and namespaces it with prefixes: `group:`
for a group, `addr:` for somebody on another relay. `toShared` strips the
group one and never stripped the address one, so a backup written here
said `addr:ada@relay.example`, which is not an address. The iPhone writes
the bare one.
Neither side could read the other's federated conversations, and the
damaging direction is iPhone to Android: a bare address arriving here
lands in a map whose unprefixed keys are user threads.
Same mistake as the story id one commit ago, one field over, and the group
prefix beside it was already being handled correctly, which makes it a
slip rather than a decision. An internal storage detail does not belong in
a shared shape.
Coming back in, the prefix goes on unless the file already carries it, so
an older backup does not end up with two.
One test was pinning the bug: it asserted the shared shape contained the
prefixed key. It now asserts the bare address, and that the prefix is
absent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two engines both had a notion of "this story, wherever it is read",
and kept it in different fields. This app put it in `id`. The iPhone
numbers its rows locally in `id` and kept the shared identity in `key`.
One name, two meanings, so a story travelling between the two phones came
back unrecognisable and the import filed it as a second copy of itself.
`key` is the identity now and `id` is written by neither engine. A local
handle has no business in a shared shape.
`id` is still read as a fallback for files written before this was
settled, and only when it is a STRING. A number there is the writing
device's row handle: stringifying it produced a confident, meaningless
identity, which is the original bug reached by another path. A story with
neither falls back to its post time, the same value the iPhone falls back
to, so the two still land together.
Two tests were pinning the old contract rather than testing it. One
asserted the internal id came from the shared `id` when both were present;
the other was called `a numeric story id from the iphone becomes a usable
name`, which is the bug stated as a requirement. Both rewritten, and the
new sibling covering the post-time fallback is what caught the numeric
case above.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The vector's canonical set just widened to every field both engines carry,
and pins was the one this app failed: it never read or wrote them, so a
pinned message was lost crossing in either direction and nothing said so.
A pin is kept whole now, at and by included, though this app shows
neither. Dropping a field an engine cannot render turns a backup into a
lossy hop: restore an iPhone through an Android phone and the pins come
back stripped of who pinned them and when. The vault keeps the record
rather than just the mid, so setting a pin here no longer discards what
travelled with it.
Import merges into gaps, like the drafts beside it: a pin set on THIS
phone is the current one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This app stores the audience mode as its enum name, EXCEPT, and the iPhone
writes and reads it lower case. The export started carrying the field one
commit ago, so a backup moving between the two phones would have had the
mode dropped in silence.
Dropped is not neutral here. An audience that fails to restore is an
account back on everyone, which shows stories to the people somebody had
deliberately left out, and nothing on screen would have said so.
Converted in both directions where the ok/connected rename already lives,
and asserted in both directions, because a one-way test would pass on two
Android phones and still lose this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Following the SPEC 16.3 rule the same sweep just wrote down. The export
held threads, groups, calls, stories, the roster and the group keys, and
left behind the story audience, muted authors, the story archive, drafts,
polls in flight and the 8.9c presence tokens. Restoring on a new phone
lost every one of them silently, which looks exactly like a person who
never set them.
They travel under the names the iPhone already writes, so a backup moves
between the two phones rather than only between two Android ones. The
presence tokens travel as the two per-contact maps alone, never the
delivery record around them, which holds this account's own key and 16.1
excludes.
Import merges into gaps rather than overwriting: a draft typed on THIS
phone is the newer thought, and a presence token this device already
holds is the live one the relay knows the digest of, so an older copy
from a file would make this account invisible to that contact until they
issued another.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They lived in a map in memory. An author watched a tally empty itself
overnight and the reactor watched their own reaction disappear, and there
was nothing on screen to say either had happened. The iPhone was fixed
yesterday; this is the other half.
The vault keeps them now, loaded when the session opens and written on
every change, withdrawals included. The export carries them too, in the
same shape the iPhone reads, so a tally survives a move between the two
phones as well as a restart. Import merges per reactor rather than per
story: two devices can hold different halves of one story's tally, and
taking a story's map wholesale would drop the other half.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spec 8.9c, the Android half, matching iOS. Each contact gets a random 32-byte
token, sent over the encrypted channel on the delivery-key envelope, which is
already the per-contact one. The relay is given only SHA-256 of it, and
presenting the token is what reads a presence.
Per pair rather than one shared secret, so taking one contact off the list
costs the others nothing. That is the difference from the sealed-sender
delivery key, where cutting somebody off replaces a key everybody holds.
Where a token is held its answer wins; a contact with no token yet keeps
whatever the roster said, so a half-migrated pair does not read as offline
while both ends catch up.
The HTTP stays out of the engine. Conversations has the vault and the e2e
surface but not the mobile one, so it mints the token and raises a flag, the
same arrangement avatarsUpdated already uses, and Session publishes.
A star inside a block comment opens a nested one in Kotlin, which is how a
doc comment naming a route path took out the whole file. The path is described
rather than written now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On a call on the phone, walk to the desk, take it on the tablet, and keep
talking. The other person hears a short gap and nothing else.
This rides the channel that already exists: the device that answers a call
tells this account's others to stop ringing. Handoff is that channel carrying
two more signals. Nothing new reaches the relay.
Every rule here is about not breaking a call that is currently working.
The peer is never hung up on. No end is sent to them at any point in either
direction. An end would make the far end drop the call and redial, which is
the failure this replaces.
The order in `takeHandoff` is load-bearing and has a test on it: the peer gets
the offer first, this account's other devices are told second. The device that
had the call is still carrying it until that line, so a failure while taking
it leaves somebody holding a working call rather than nobody.
An offer nobody takes lapses at both ends rather than being withdrawn. A
withdrawal envelope can be lost and leave a device believing an offer still
stands; a timeout cannot.
And whether the far end can take a renegotiating offer is known before any of
this starts, from a flag both ends set during call setup. A build that
predates handoff sets nothing, absent reads as no, and the control is absent
rather than disabled.
An offer for the call already in progress is answered where it stands: no
ring, no missed call filed, and the call screen stays.
13 tests. 639 in the engine, 425 in the client, 210 in the app, all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The secure-aggregation core has been in the engine since it was written and
nothing used it: there was no way to ask a poll, vote in one, or see a
result. This is the rest.
`PollBook` is the sequencing, deliberately the same shape as the iOS one. A
poll crosses between the two engines, so a rule enforced on one side and not
the other is a poll that tallies differently depending on who is looking at
it. No sum is published until every member has voted: a member who has not
voted has handed nobody a piece, so nobody holds a complete set, and a sum
published early is noise rather than a partial answer.
Asking a poll sends the same envelope to the group. Voting does not: each
piece is addressed to the member it belongs to and sent to them alone. That
is the one group envelope in the app which is not the same bytes to
everybody, and a test pins it, because sending one share to the group would
let any member subtract it from the tally and read the vote, with the counts
still coming out right and nothing at runtime to notice.
Kept in the vault, or a poll could never finish: it waits for everybody, and
the pieces gathered would be gone the first time the app closed.
The card says what the maths actually gives. Everyone else would have to work
together to know how one person voted, never that nobody can know. A poll of
two is not anonymous at all, because the other person holds a piece of the
vote and sees the tally, so the word is not offered for a pair and the card
says why.
22 new tests. 647 in the engine and 430 in the client, all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three kinds (§6.29): the poll itself, one piece of one vote, and the sum
a member publishes. A piece goes to a single member and to nobody else,
which is what makes a vote unattributable; a sum is what everybody sees.
All three are marked as control traffic. They are deliberately not in the
CONTROL kind set, because that check returns a bare envelope before the
specialised branches and these carry structure worth reading, but without
the flag they satisfied every renderable test and would have drawn as
empty bubbles.
The poll carries the member set it is counted over, hashed. Pieces split
across a different set do not add up, and that should be visible rather
than arriving as a wrong tally.
Also adds call-reaction to the known kinds. It went into the call actions
when reactions during a call were built and never here, so an arriving
one was classed as needing a newer client. It is the same file as this
change, and a parallel branch for one line would only collide with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A vote is never sent to anybody. It is split into random pieces that sum
to it, one per member, and each member publishes only the sum of the
pieces they were handed. Adding those sums gives the counts.
Every member other than the voter would have to collude to recover one
vote. That is the guarantee, and it is not the same as nobody being able
to know, so the interface will have to say the true thing rather than the
shorter one.
A missing publisher does not undercount, it gives a wrong answer: the
total keeps a random number in it. That is why a poll waits for everybody
rather than showing what it has, and a test pins it so the waiting is not
mistaken later for timidity.
Tested by doing the arithmetic rather than by reading the source that
does it: the counts, that no single piece is the vote, and that all but
one of the pieces reveal nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A thread held in memory and written to disk by neither side. Close the
app and it is gone: no file to seize, no backup to leak.
It is a proposal, not a switch. The device turning it on sends the
control envelope and keeps writing until the peer echoes it back. A build
that does not know the kind never echoes, so the thread stays on the
record on both sides rather than on one of them, which is worse than not
having the feature because both people would believe the first thing.
Every message reaches disk through one call, so one gate is enough, and a
test pins that count so a second write path cannot quietly walk around
it. Ratchet state is still persisted: messages stop decrypting without
it, and it is not message content.
The control for it comes next.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tap a reaction while somebody is talking and it shows on their screen
for a moment. Agreement without interrupting, on a voice call as well as
video.
The signal rides the ratchet like every other call signal, is accepted
only for the call in progress and only once it is up, and is recorded
nowhere: when the call ends there is no trace of it. An older build
ignores the action it does not know.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
React to a message in a group and only its author sees it. A quiet note
without turning it into an event for everybody else.
The reaction is sent to the author and to nobody else, which is the whole
guarantee: asking the other members' apps not to display it would be a
request, and a request is not privacy. The envelope carries a flag so the
author's app can label what it got, not to ask anyone to hide anything.
Private reactions are kept apart from the public tally on both devices
that hold them, and drawn with a label, so one is never mistaken for
something the group watched arrive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The content layer had no vectors. Everything from section 6.6 onward was
guarded only by same-code round trips: this engine encodes and parses
through its own codec and would pass unchanged if it and the iPhone
disagreed about every key. That is how SPEC 6.18 specified `ment` for as
long as it has existed while both clients have always sent `men`.
`content-v1.json` holds fifteen envelopes generated by RUNNING the
iPhone's encoders, and this suite parses each one and asserts field by
field. "It did not crash" is what the old tests already proved.
One of them pins something easy to get wrong twice: the iPhone writes
`fwd` as the integer 1, not as `true`, and `optBoolean` does not coerce a
number. A parser reading only the boolean answers false and the message
quietly loses the fact that it was passed on.
The suite also fails if the vector file grows a case nobody asserts on.
Separately, and the reason any of this is trustworthy: the test task did
not track the vector files at all. They arrive by environment variable,
Gradle does not track one as an input, so a vector could change under a
build directory that already had results and the task reported
UP-TO-DATE without running. That is not hypothetical here, it is how
twenty vector tests once passed in CI for weeks without executing, on
this same runner, whose build directory survives between runs. All six
vector files are now declared inputs.
Proven rather than asserted: with the inputs declared, flipping `men` to
`ment` in one vector turns the suite red without `--rerun-tasks`. Before
the fix the same change left it UP-TO-DATE and green.
Full suite: 1294 tests, no failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both apps were taught one backup shape and nothing checked that they
agreed. Each side's tests asserted against what the OTHER was believed
to write, transcribed by hand from reading its source, which is a test
of one person's reading rather than of interoperability.
`backup-payload-v1.json` in flamenet-e2e is now the authority, and the
bar it sets is exact: decode the canonical payload, encode it again, and
the result must match byte for byte once both are put in a canonical
order. The same vector is checked on the iPhone, so the two are
compared against one file rather than against each other's source.
One real fix came out of writing it. `messageFromShared` wrote
`author` unconditionally, so a one-to-one message that carried no author
came back claiming user zero sent it, and a round trip through this app
changed the file. Storage keeps an author on every message and reads a
missing one as 0, so it is now written only when the file carried one.
The env var is exported in BOTH ci jobs, the pinned one and the one that
runs at vectors HEAD, and added to the gate that checks every vector
file exists before the suite runs. A path that silently does not exist
puts the tests back where they were and the failure reads as a protocol
bug rather than a missing file. `.e2e-ref` moves to the commit that adds
the vector.
Full suite: 1279 tests, no failures. `ci/lint` accepts the pipeline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Ref.toJson` is what the vault persists next to a message, and it never
wrote `filename`, `classification` or `compartments`. They reach the far
end correctly, as additive top-level keys on the envelope, so a received
document ARRIVED named. It was the local copy that lost them, on the
first read back out of storage. A file in a chat therefore had a name
until the app was restarted and never again, which reads as a damaged
file rather than as the app forgetting.
`fromJson` now defaults those three to null rather than to empty.
Passing nothing means "not a wire parse, read what was stored"; passing
a value, including an empty one, means the envelope said so and wins.
Empty defaults could not tell those two apart, and answering "" for both
is exactly what made a stored file nameless.
The same rule fixes `expires`. The history path hands in 0.0, and 0
means "does not expire", so every stored story read back as immortal:
its bytes were never dropped and it rendered forever. A caller that
names an expiry still means it; one that does not now gets what the
vault kept.
They are stored under the same names the shared backup format uses, so
`BackupPayload` carries them straight through. A document restored from
a backup arrived with no name and no label before this, which is worse
than not restoring it.
Four tests on the storage round trip, one on the envelope still winning,
one that an attachment which never had a name does not invent one, and
one carrying a document through a backup whole. Full suite: 1276 tests,
no failures. No new ktlint violations in Content.kt (3 at HEAD, 3 now).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section 16 pins the envelope and says nothing about what is sealed, so
each app sealed its own storage verbatim and stamped the file with the
platform that wrote it. Each then refused the other's, in as many words:
"import it on a device of the same kind". Somebody moving between an
Android phone and an iPhone lost every conversation they had.
The iPhone half landed first. This is the other half.
`BackupPayload` translates rather than dumps, which is the entire point.
This app stores a message under the keys its wire format uses (`secs`,
`fwd`, `rx`), keeps one-to-one and group threads in one map under
prefixed ids, hangs the attachment kind beside `att` rather than inside
it, abbreviates the sizes, and holds expiries in unix seconds. The
shared shape spells all of that out and counts in milliseconds.
Exporting storage as-is is what made the two formats incompatible in the
first place, so a copy would have reproduced the bug with more steps.
Federated threads are the one thing that does not fit. This app keys
them by address and the iPhone's history is keyed by numeric user id,
with a decoder that skips anything else, so an address left in `threads`
would be dropped in silence. They travel under `addrThreads` instead: an
iPhone ignores the key, an Android phone gets them back, and nothing
disappears without somebody having decided it should.
Import tells the two formats apart by SHAPE, not by file name, because a
name is whatever the person saving it chose. Section 16 announces itself
with `alg` and `ct`; the older export announces itself with `magic`. A
wrong passphrase on a section 16 file reports a wrong passphrase and
does NOT fall through to the legacy reader, which would have answered
"that file is not a Flamenet history export" for a file that is one and
sent somebody hunting for corruption instead of a typo.
Nothing writes the old format any more, and everything still reads it.
Seventeen tests: the shared side against the key names the iPhone
writes, the round trip back into this app's own storage shape, the unit
conversions in both directions, and a payload transcribed from the
iPhone's encoder. That last one is the weak point and the test says so:
it proves this app reads the shape the iPhone is BELIEVED to write. Only
a vector both apps are built against proves they agree, and
`backup-v1.json` pins the envelope alone, which is exactly how the two
shapes drifted apart.
Full suite: 1271 tests, no failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spec §16, the third engine, checked against pinned bytes rather than against
either of the other two.
Reuses the module's own primitives: FilesKdf for PBKDF2 and Aead for GCM.
Aead returns nonce || ciphertext || tag and §16 keeps the nonce in its own
field, so the prefix comes off here; what remains is what WebCrypto calls ct.
The AAD is built by interpolation, not by a JSON writer, because it has to
match the other engines down to key order. A JSONObject would be the obvious
choice and would silently disagree, and the AAD is what stops a file being
edited into deriving a cheaper key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This screen showed chips and no explanation; iOS has carried one all along,
under a comment claiming both apps use the same words. They now do.
The last sentence is the boundary: a level decides who can open a file, not
where it can be put. Neither app stops somebody filing sensitive material
somewhere low, and saying so is the honest half of android#15.
Also repairs a comment the emoji sweep truncated, which left the test
saying 'the -property is NOT enforced'.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Remove dates and "this used to say X" framing from prose and comments.
What the tree does now is the only thing that matters; the history is in
git.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every comment is reduced to its first sentence plus @param, @return
and @throws tags. Comment lines: 20040 to 8526. ktlint 1.8 applied
with its default (official Kotlin) style across all modules; 262 files
reformatted. The 264 remaining ktlint findings are naming and filename
rules, which are code changes, and are left alone.
Comments and formatting only. 1208 tests pass, counted from the XML.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The skill's detector scored the README, PARITY.md and the extracted
comment prose. Fixed what it flagged: hollow intensifiers, "in order
to", "additionally", "the whole point", bold sentences in docs, and
bold that had wrapped across comment lines. Comment prose scores a
maximum of 4 out of 100 across the repo.
One sweep rule mangled four comments ("which is the entire point of X"
lost its clause); repaired, and the rule now keeps the clause.
Comments and strings only. 1208 tests pass, counted from the XML.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Emoji markers, markdown bold inside code comments, and em-dashes are
gone from source comments, string literals, scripts, Gradle files and
the markdown docs. Counted before and after:
emoji markers 2680 -> 1 (the emoji picker's own list, which is the feature)
em-dashes 2490 -> 9 (table placeholders and fenced code)
Two warning glyphs in user-facing strings in ChatScreens.kt are removed
as well; the sentences say the same thing without them. Two backtick
test names carried an em-dash and now carry a comma.
Nothing else changed. 1208 tests pass, counted from the result XML.
The three obfs4 interop tests are opted out with OBFS4_INTEROP_OPTOUT=1
for this run, as their own message instructs when no bridge is set up.
CONTRIBUTING gets a four-line rule so it does not grow back.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A bridge only works while whoever wants to block it does not have the
line. Every ordinary way of distributing one — a web page, an email, a
channel — is a place a censor can go and collect them, which is why Tor
needs BridgeDB, moat and domain-fronted rendezvous and still gets its
bridges enumerated. This app already has the thing that solves it: an
encrypted channel between people who know each other. A line handed over
that rail is invisible to the relay, and enumerating it means infiltrating
one friendship at a time.
🔴 **An offer, never a setting.** A bridge line tells a phone to route
every relay connection through a machine somebody else chose. Applying
one on arrival would let a contact — or whoever has taken their account —
move another person's traffic onto a host they control. They could not
read it, because the ratchet does not care what carries it, but they
would learn who is online and when, and could cut them off at will. So
the transport stores it and a button applies it, pressed by the person it
was sent to.
The test that holds that line asserts storing an offer writes ONLY the
offers record. Make `saveBridgeOffer` also write a configured bridge and
it reddens, alone, while every other test in the file still passes —
which is what makes it worth having.
The line is shown in full on the screen, not hidden behind "Accept".
Somebody may want to check the fingerprint against another source before
routing their traffic through it, and they cannot check what they cannot
see. A line that arrived broken says which part is wrong, using the same
validator the paste field uses, rather than failing when accepted.
⚠️ **`"ctl":1`, so 0.10 swallows it.** A build that has never heard of
this kind consumes the envelope and shows nothing. The alternative — a
bubble full of JSON in somebody's conversation — is the exact bug the
`ctl` flag was introduced to stop, and it would have shipped to every
older install. Asserted on the wire as the NUMBER one, because iOS
decodes it as a number and a boolean fails the whole envelope's decode
rather than merely failing to set the flag.
1175 tests, 0 failures.
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>
The same platform difference as the vault index, in shipped code, with a
worse consequence.
🔴 `saveTrust` wrote `"fnid": null` explicitly when a contact's global
address was not yet known. Android's bundled `org.json` reads a JSON null
back as the four-character string `"null"` — a non-null value that is not
an address. What that caused on a phone:
- `Trust.pin` compares a newly offered fnid against the pinned one and
treats a difference as a different person. `"null" != "fnid:…"`, so the
FIRST TIME a real address arrived the contact was marked **CHANGED** —
the "security keys changed" warning, which is supposed to mean an
attack, firing on an ordinary first sighting. The vault's own comment
about minting a fresh device warns about exactly this shape of false
alarm.
- `rememberIdentity` returns early once `fnid != null`, so the real
address was never resolved and the record stayed wrong permanently.
- `pinnedFnid` fed `"null"` into key-log verification, which recomputes a
leaf against the address a contact was accepted under — every check
against the wrong subject.
Two halves, because records written the old way are already on phones:
the writer now omits the key entirely (an absent key reads as `""` on both
platforms, which is what the reader always expected), and the reader
rejects the literal `"null"` so existing records repair themselves.
⚠️ The test that matters is the one feeding the reader the literal string.
On the JVM a real JSON null reads back as `""` whether the key is omitted
or not, so the plain round-trip passes with the bug intact — which is why
this survived every suite. Confirmed by restoring the old reader and
watching it go red.
Found by the sweep flagged in android!54. `JSONObject.NULL` is written in
exactly two places in this app, and both were wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The screen, the Storage Access Framework resolver behind
`LocalVault.ReferenceResolver`, and the passphrase-protected identity the
vault opens under.
🔴 **Android asks for a Files passphrase, as iOS does, and that 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, with the Keystore seal
around it 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.
🔴 **`OpenDocument`, never `GetContent`.** `GetContent` hands back a URI
whose read grant dies with the process, so a linked file works for the
rest of the session and is broken on the next launch — and whoever added
it watched it work. `filesReference` REFUSES when the grant cannot be
persisted and offers to copy the file in instead, rather than filing a row
that will be dead tomorrow.
🔴 **Locking drops the identity; it does not hide a screen.** The open
vault is a field, deliberately not part of `FilesState` — a state object
gets logged, compared and copied into recompositions, none of which should
ever have a private key in it.
The classification ordinals are pinned by a change-detector test, on
purpose: the NUMBER travels with a file and the word is only how it is
drawn, so renumbering them would file a Restricted document as Internal on
the other phone with nothing erroring and neither screen looking wrong.
⚠️ **Not yet run on a device.** The machine was carrying an iOS simulator
at 99% CPU on 8 cores, and booting an emulator on top of that produces a
flaky run rather than evidence. Two things need a real device before this
is done: the SAF picker with its persistable grant, and whether a linked
file still reads after a reboot. Neither can be faked on the JVM.
⚠️ Files is its own destination, not a section of the wallet. They share an
unlock, which is true and is an implementation detail; iOS grouped them
once and what it produced was somebody pressing "wallet" and arriving at a
file list.
Full build and all JVM suites green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Names, key wrapping, chunked content, the KDF and the identity record —
everything needed to open a Files vault written by any other FlameNet
client.
🔴 Held to `vectors/files-v1.json`, produced by the JAVASCRIPT engine in
the browser. Not to the Swift engine. Checking Kotlin against Swift would
establish only 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, because a vector file regenerated by the implementation
under test proves nothing.
Two vectors were recomputed from scratch in Python before any Kotlin ran
against them — `struct.pack('>hQ')` for the label encoding and
`hmac.new(...).digest()` for the name digests. The `café` case matches
under NFC and NOT under NFD, which is what pins the normalisation form
rather than assuming it: a platform normalising differently does not
error, it silently fails to find a file filed on another device.
Each guard was proven by breaking the code and watching the right test go
red: normalisation removed → the composed/decomposed digest test; the
wrap salt halves swapped → the browser's label-key grant; the end-flag or
the chunk index dropped from the authenticated data → the browser's file
stops decrypting at all.
⚠️ The truncation and reordering tests each go on to show the surviving
bytes are still authentic under their ORIGINAL index and end-flag. Without
that second half "it threw" is all they would establish, and any bug at
all makes a mangled blob throw.
Two notes on the platform:
- PBKDF2 is hand-written on `Mac`, as `Bip39.pbkdf2HmacSha512` already is.
The JCA path takes a `CharArray` and lets the provider decide the
encoding; the reference implementations take bytes. A passphrase outside
ASCII would otherwise stretch differently under a different provider and
the record simply would not open, with nothing to say why.
- The public half of an identity is recovered by agreeing against the
X25519 base point, because the JCA will not derive one from an XDH
private key. That it returns 32 bytes proves nothing, so it is pinned to
the vector file's own `identityPublicKey`.
43 tests. `FileContent` rather than `Content`: the messenger's `Content` is
a message payload and the two are not interchangeable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Label` and `Clearance`: the two-dimensional lattice Files is built on, and the
ten-byte encoding that is authenticated data for every construction using it.
🔴 The encoding vectors were computed independently, not read off this
implementation. A label that encodes differently on two platforms does not warn
— it produces a decryption failure, or plaintext that quietly belongs to the
wrong sensitivity. A vector taken from the code under test would pin whatever
the code happens to do.
🔴 Two dimensions, and the tests are shaped around the failure that READS AS
WORKING: an implementation comparing classification and forgetting compartments
behaves correctly on every test built from levels alone, right up until
somebody sees a compartment they are not in. Domination needs a superset of
areas, not an overlap; two labels can be incomparable in both directions.
⚠️ Bit 63 is the sign bit of a Long. Building the mask with an Int shift loses
it, and the compartment that vanishes is the one nobody notices until somebody
sees a file they are not cleared for. Pinned, along with the all-ones case where
a sign-extension mistake still produces plausible-looking bytes.
🔴 **A finding, and a divergence I deliberately did NOT create.** Files
implements the simple security property (no read up) and NOT the ★-property (no
write down) — on either client. iOS gates `ensureLabelKey` and `use` on
`clearance.permits` and nothing else, so Kotlin's `mayWrite` calls the same
check, with a comment saying why and a test asserting the current behaviour that
will fail loudly the day it changes.
Implementing it on Android alone would mean a file that saves on one phone and
is refused on the other, with neither person able to tell which rule they had
met — worse than the gap, because an apparent bug gets worked around. Filed as
android#15 / ios#65 with the trade written out: no-write-down is the half a
lattice actually exists for, and it is also the half that is awkward enough to
need a stated downgrade path rather than a bare refusal. It interacts with
sharing a file into a chat — a write to a place with no label at all — so it
needs an answer before that ships.
16 tests. App builds, every JVM suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Wallet` makes one, adopts one from a phrase somebody typed, and reads the
phrase back; the vault seals the record at rest.
🔴 The property guarded hardest is a NEGATIVE one: a wallet must not be
derivable from the account. 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. A test
asserts that eight wallets made in a row share nothing, which is what would
catch that shortcut being taken.
🔴 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 to another
wallet. Self-custody in name only.
⚠️ Erasing the device destroys the record, and the phrase is then the only copy
that exists. Checked rather than assumed while writing this: plain sign-out
keeps the vault and `updatePassword` uses the plain door, so a password change
does NOT cost somebody their wallet; `signOut(erasingDevice = true)` and
`deleteAccount` destroy it. 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.
⚠️ An imported phrase is validated BEFORE it is stored. One that fails its
checksum is refused at import rather than saved and discovered later, when the
wallet it names turns out to be empty and nobody can say why.
⚠️ The receive counter is carried across a rewrite rather than reset. Resuming
at zero would hand out addresses somebody has already been paid at, undoing the
one linkability a wallet can prevent.
⚠️ `SecureRandom()` with no seeding call. Seeding it from a timestamp or a
device id would make the wallet reconstructible from things that are not
secret — the same failure as deriving it from the passphrase, wearing a
different hat.
9 tests. App builds, every JVM suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`HdAccount` turns a recovery phrase into the BIP-44 path every other Solana
wallet uses, the Base58 address somebody can be paid at, and the key that signs
for it.
🔴 The property everything else rests on is that the signing key OWNS the
address that was displayed — same path, same curve, same code. If those ever
disagreed the wallet would sign with a key that does not own the address it
showed. The transaction being rejected by the network is the GOOD outcome; the
bad one is signing for an address somebody else controls. Tested across five
indices, and separately by signing a message and verifying it under the address.
🔴 Android can sign, and iOS's constraint does not apply. AccountKdf notes that
a raw Ed25519 seed needs EdECPrivateKeySpec, which Android exposes only from
API 33 — but the engine carries its own RFC 8032, so this works on any
supported device. The wallet is not blocked on a platform floor.
🔴 A fresh address per receive, by default rather than on request. Reusing one
lets anybody watching a public chain group every payment somebody ever received
— the single linkability a wallet can actually prevent, because the chain will
not forget.
⚠️ `looksLikeAddress` says WELL FORMED, never CORRECT, and the difference is
money. A Solana address carries no checksum, so one mistyped character that
still decodes to 32 bytes is a perfectly valid address nobody holds the key
for. Bitcoin's Base58Check would catch it; this cannot, and neither can the
network. There is a passing test that goes and 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 the point at which the warnings around typed addresses can be
revisited.
⚠️ A test bug found by the test failing rather than passing: `Ed25519.verify`
takes (publicKey, signature, message) and I had passed (publicKey, message,
signature). All three are ByteArray, so it compiled perfectly. Noted in the file
— it is a decent argument for asserting the signature verifies at all rather
than trusting that a key which derives must be a key that signs.
12 tests. App builds, every JVM suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The foundation Files and Wallet rest on. PARITY.md was right that this is a
build rather than a port of a screen — these had no Kotlin counterpart at all.
🔴 Pinned to the STANDARDS, not to the Swift engine. 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 this file guards 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, and the way somebody finds out is restoring
their phrase and seeing nothing there.
Two Kotlin-specific decisions, both money-relevant:
🔴 PBKDF2-HMAC-SHA512 is hand-written over `Mac` rather than taken from
`SecretKeyFactory("PBKDF2WithHmacSHA512")`. The JCA path takes a CharArray and
the provider decides how to encode it — 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 to different
bytes under a different provider and derive a different, empty, perfectly valid
wallet with no error anywhere. Taking the bytes ourselves removes the provider
from the decision. (It is also absent below API 26.)
🔴 Base58 is the digit loop, not `BigInteger`. The obvious Kotlin version is
`BigInteger(1, bytes).toString(58)` with a remapped alphabet, and it is wrong in
the one way that matters: BigInteger discards leading zeros, so a key beginning
0x00 loses a character and encodes to another key's address.
⚠️ One test was WRONG on the first run and the implementation was right. The
all-ones 24-word vector carried the seed from a different BIP-39 vector. It was
resolved by computing the value independently — PBKDF2-HMAC-SHA512, NFKD, 2048
rounds — rather than by adopting whatever the implementation produced, and the
file says so. A pinned value taken from the code under test pins the bug.
⚠️ The vendored word list is byte-identical to the iOS one and verified on every
load, not once at build: it ships inside the app and a check that only ran in CI
would not notice one that arrived corrupted.
23 tests. App builds, every JVM suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both remaining ⚪ protocol rows turned out to be stale.
`AccountKdf.localAccountSalt` is pinned to the published vector and wired into
sign-in for a local relay; `MailboxHoming` and `AccountIdentity.publishHomes`
have shipped the publish and read halves for a while. PARITY.md was describing
a gap that had closed, which is its own kind of wrong — a definition of done
that overstates the work left is one people stop reading.
🔴 What was genuinely missing was on the READING side, and on BOTH clients.
An older home record is GENUINELY SIGNED — the account holder published it,
before they added a second relay. So a relay that wants to be somebody's only
mailbox does not have to forge anything: it serves the record it saw first,
that record verifies perfectly, and the sender delivers to one relay believing
it is delivering to all of them. That is the attack multi-homing exists to
survive, and the serial is what was added to stop it.
The relay refuses a lower serial on WRITE. Nothing refused one on READ:
`verifiedRelays` here and `verifyHomes` on iOS both checked the signature and
stopped. Fixed on both — iOS in ios!116, this is the Kotlin half.
The highest serial seen per address is remembered in the vault and a lower one
is refused. Equal is fine; only strictly lower is a rollback.
🔴 The floor rises only for a record that VERIFIED, via a separate
`verifiedSerial`. Otherwise a hostile relay could hand over an unsigned record
carrying Int.MAX_VALUE, have the client raise its floor to it, and lock out
every later genuine record — turning the rollback defence into a denial of
service against the account holder. Tested.
⚠️ Sealed in the vault and keyed by a hash of the address: which addresses this
device has looked up is exactly the metadata the vault exists to keep off a
stolen phone.
⚠️ The refusal does not claim an attack. A relay that has genuinely fallen
behind serves an old record for honest reasons, and nothing in a client can
tell that from the other case.
8 unit tests. App builds, every JVM suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>