Commit Graph
70 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5 28169a648f PARITY: post-quantum is shipped on both clients, not missing on either
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>
2026-09-04 20:29:01 -04:00
Paul HittandClaude Opus 5 0e70c78e98 PARITY: the file round trip is proven live, not merely written
`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>
2026-09-04 16:55:33 -04:00
Paul HittandClaude Opus 5 ead31053a4 A live test for the file round trip, and four published passwords
`LiveFileTransferTest` covers the one seam nothing else does: a document
leaving one vault, crossing a real relay, and arriving in another's with
its label intact. `FileShareWireTest` proves the envelope's shape and
`LocalVaultTest` proves the vault at both ends; neither puts a byte on a
wire.

⚠️ **Written, compiles, UNRUN** — the same state `LiveSignUpFleetTest` is
in, and the same caveat applies: an unrun test proves nothing. It creates
accounts, so it needs a throwaway relay and an explicit decision to point
it at one.

🔴 **Writing it surfaced four published passwords and a guard that could
not see them.**

`TestSafetyTest` exists to stop a credential being written into a suite
that creates real accounts. It matched `val X = "…"` only. So:

- `LiveConversationTest` passed the literal straight as an ARGUMENT,
  twice — invisible to the check.
- `LiveSignUpTest`, `LiveKeyLogTest` and `LiveSealedSenderTest` wrote
  `password = "…"` on its own line inside a multi-line argument list, the
  most ordinary Kotlin formatting there is — also invisible.

I copied the same literal into the new suite before noticing, which is
how I found it.

**A guard that passes while the thing it guards against is present in the
tree is worse than no guard, because it is read as evidence.** All five
call sites now read `LiveTarget.passphrase()` from the environment with no
fallback, and announce a SKIP when it is unset.

The check itself took two wrong turns worth recording:

- Skipping every named argument, to avoid flagging
  `product = "messenger"` — which let the three `password = "…"` suites
  through. Trading one blind spot for another is not a fix; a named
  argument is now judged by its NAME.
- Widening the sign-up match to a few lines around the call — which
  flagged forty test tags, assertion messages and message bodies as
  credentials. That is how a check stops being read, so it is same-line
  only, and rule 1 covers the multi-line case properly.

"secret" was dropped from the credential names for the same reason: test
prose is full of it, and a marker string called `secret` is a fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:28:11 -04:00
Paul HittandClaude Opus 5 e6913ae60e PARITY: history at rest was already built — the third stale row
Marked ⚪ and implemented the whole time. Every read goes to disk:
`Session.history` calls `Vault.history`, which decrypts the record. There
is no in-memory copy to be fooled by.

Checked against iOS's `E2EHistoryStore` rather than assumed. Same cipher,
same posture, two honest differences recorded rather than papered over:

- 🟢 Android binds the RECORD NAME into the AAD, so a sealed blob cannot
  be moved between slots. iOS keeps one snapshot file and does not need
  that — but would, the day it splits.
- iOS's `.completeFileProtection` is a per-file assertion. Android's
  equivalent is Credential-Encrypted storage, a property of the volume
  rather than of this app's request.

Neither favours one platform overall, and both clients agree on the
behaviour that matters: a plain sign-out KEEPS history, and erasing the
device destroys it — on Android by wiping every record and deleting the
Keystore key, so what is left is unopenable as well as gone.

⚠️ Also recorded: `Vault.history` prunes expired messages on the way out,
so disappearing messages are enforced on READ rather than by a timer
nothing runs — and it rewrites the record only when something actually
expired, so a read is not a write.

Services (AIM/ICQ) retired rather than left as a gap: not a goal for this
app (owner, 2026-09-04).

That leaves only the two post-quantum rows, and the note above them
already says why they are not a gap in effect: iOS's PQ files are inert
because CryptoKit exposes ML-KEM on iOS 26 and the app targets 16, so
neither client does post-quantum anything today. Closing it properly is
one decision for both clients, not an Android task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:22:01 -04:00
Paul HittandClaude Opus 5 0a4709dd0e Fix: promoting a link to the vault appeared to do nothing
🔴 `Node.equals` compared only the id. It was written that way to dodge a
real trap — a data class compares `ByteArray` by identity, so the
generated equals calls two identical rows different — and dodging it broke
a whole feature.

Promoting a link keeps the node's id and changes its `storage`. So the new
row compared EQUAL to the one it replaced, the state object holding it
compared equal too, `StateFlow` conflated the update away, and Compose
never saw it. On screen the file vanished from Linked and did not appear
in the vault until the app was restarted. That reads as losing somebody's
file.

Every field is compared now, with `contentEquals` for the four byte
arrays — the identity trap handled rather than avoided.

⚠️ **This suite could not have caught it, and the new tests say why.**
Everything here reads the vault, and the vault was right the whole time:
the index on disk had both rows, and a fresh load rendered both. The
failure was in what the vault handed a state holder that conflates on
equality. So the tests assert that property directly — a promoted row must
not equal its old self, an unchanged row read twice must, and every
mutable field must be visible in equality.

Found by watching it on an emulator. Confirmed by restoring the old
equals and watching both go red, then re-verified on device: the promoted
row now appears immediately with no restart, and its size corrects from
the provider's reported 4 kB to the real 14 kB plaintext length — which is
itself proof the row genuinely updated.

Also verified on device while here: a persistable read grant survives a
reboot, a full cold boot and an app update (`persistable=0x1
persisted=0x1`), promoting drops the bookmark from the index, and the
person's own file is left untouched where they put it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:19:18 -04:00
Paul HittandClaude Opus 5 436ce240c7 Fix: the vault index failed to load on a real phone
🔴 **Android's bundled `org.json` coerces a JSON `null` to the
four-character string `"null"`. The Maven `org.json` the tests run against
returns `""`.** Every nullable field in the vault index therefore passed
on a laptop and broke on a device:

- `parentID` threw `Invalid UUID string: null`, so a vault holding any
  top-level file failed to load AT ALL. Adding the first file worked,
  because it was still in memory; reopening the app lost everything, with
  an error nobody could act on.
- `wrappedContentKey` was worse, because it did NOT throw. `"null"` is
  valid base64, so the node came back with a plausible three-byte content
  key and simply refused to decrypt, for ever, with nothing to explain it.
- `bookmark` produced a reference pointing at `"null"`, permanently
  missing.

Absence is now decided by `JSONObject.isNull` and never by comparing a
decoded string to a sentinel. The literal `"null"` is rejected as well: it
is not a valid value for any of these fields, and tolerating it costs
nothing next to reintroducing this.

⚠️ **The two regression tests write the literal string `"null"` into the
index on purpose.** That is exactly what the decoder is handed on a
device, so it is what makes a JVM run reproduce the device's behaviour.
Feeding a real JSON `null` — which is what the vault actually writes —
passes with the broken code and proves nothing. Both were confirmed by
restoring the old decoding and watching them go red.

Found by running the app (android!52). No JVM test could have caught it,
which is an argument for the device run rather than against the tests.

⚠️ `optString` on a nullable field is wrong everywhere in this app, not
only here, and it fails only on the platform it ships to. Worth a sweep.

Verified on the emulator afterwards: the vault survives a cold start, and
on disk the blob begins `ENCL\x01`, both wrapped keys carry version 0x11,
the name 0x01, the filename is not in the clear, and the plaintext is
absent from the whole app container.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:08:19 -04:00
Paul HittandClaude Opus 5 6ec626cde2 Share a file into a chat, and save one back — with the label it carries
Both directions: pick a document from the vault inside a conversation, and
save a received one into the vault at the classification it arrived with.

🔴 **This is the answer to android#15, and the codebase had already given
it.** `Attachment.FILE` has carried this since before the lattice existed:
*the label is a MARKING, not a permission — the content key is in the same
envelope, so whoever can read the message can read the file.* That settles
the ★-property question. The key travels with the file by design, because
that is what makes sending work at all; there is no trusted computing base
to enforce against, since the person holds every key on a device they
control. A client-side refusal would be a suggestion, and **a suggestion
dressed as a control is worse than none** — it is the one people plan
around.

So neither client implements the ★-property, `LabelTest` keeps asserting
that absence, and PARITY.md now says plainly that Files is not mandatory
access control. What it IS: cryptographic read separation, plus a
classification that travels. The picker says so before the tap, in those
words.

Three things found on the way:

🔴 **A second copy of the vocabulary.** `classificationName` in
ChatScreens listed the same four names as `FileLevels`, agreeing by
coincidence with nothing making them agree. Rename a level for the Files
screen and the chat bubble keeps showing the old word for the same number,
in the same conversation, on the same phone. Collapsed to one.

🔴 **Attach and Camera did the same thing.** Both launched the gallery, and
there was no way to send a document at all. Attach now opens a small panel
— photo, or from Files — which keeps the kit's composer row at its four
controls.

🔴 **iOS's picker lists the root only**, so a file inside a folder cannot
be sent from a conversation, and nothing says so — it is simply absent,
which reads as "it isn't there". `LocalVault.allFiles()` walks the whole
tree. Filed as ios#68.

⚠️ The picker is the ONE place this vault hides a row it cannot read.
Everywhere else it insists on showing them, because hiding one leaves
somebody unable to see anything is there; a picker exists only for
choosing, and a row that can never be chosen is furniture.

⚠️ A received file is always saved as an encrypted copy, never a link —
there is no file on this phone to point at — and always at the label it
arrived with. Re-labelling somebody else's document on receipt would
launder its classification, and the number is the only thing that survived
the trip.

9 new tests. Each guard proved by breaking the code: writing the label for
every attachment kind fails the image test; dropping compartments fails
the two that name them; removing the fallback caption fails the
older-build test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:34:29 -04:00
Paul HittandClaude Opus 5 fbf2c4c531 Files on Android: set up, unlock, browse, add, folders, delete, promote
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>
2026-09-04 15:22:56 -04:00
Paul HittandClaude Opus 5 3a2c10f7bc The local Files vault in Kotlin
The index, folders, both ways of holding a file, and the delete
semantics — everything iOS's `LocalVault` does, minus the screen.

🔴 One deliberate divergence, and it is a fix rather than a port.
`descendants(of:)` on iOS bounds a cyclic index with `guardRail < 10_000`.
That terminates and then returns ten thousand copies of the same two ids
— and the screen calling it is the one offering "delete this folder and
everything in it", so a corrupt index turns that into an offer to delete
10,000 things. Bounding by what has already been visited terminates AND
returns the right answer, with no magic number. Filed as ios#66.

The test that found it writes a real cycle into a real index. It matters
because the index is a file on disk: the input is not only ever our own
writes.

⚠️ The index file is device-local and is NOT an interchange format. The
node FIELDS match iOS's exactly so a courier transfer stays a copy rather
than a translation; the JSON around them does not. iOS encodes dates as
Swift's reference-date doubles, which is an accident of `JSONEncoder`
rather than a decision, and pinning Kotlin to it would promote an accident
to a format. Anybody building vault migration should move nodes through a
declared wire shape, not by copying this file.

🔴 `ReferenceResolver` is required with no default. A no-op default would
make every reference row read as "missing" in an app that simply forgot to
wire it up — a vault that looks like it has lost half its contents, with
nothing anywhere saying why.

Two tests worth naming:

- **Nothing on disk contains the plaintext.** A round trip passes just as
  happily if `add` wrote the file out unencrypted and read it straight
  back, so the bytes are searched for across the whole container, and the
  index is checked for the filename.
- **Deleting a reference row does not reach the person's storage at all.**
  Asserting only that their file survived would be a test that cannot
  fail: the delete path holds no call to remove it, and the protection is
  structural — the resolver has no way to delete anything. Asserting zero
  resolver calls is the property that constrains future code, and it was
  verified by routing the delete path through the resolver and watching it
  go red.

29 tests. Each guard proved by breaking the code: the label key salted
with the level alone, a node key wrapped to its parent's label, and a bulk
delete that ignores unselected survivors each fail exactly the test that
names them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:14:05 -04:00
Paul HittandClaude Opus 5 2a3187308d The Files vault crypto in Kotlin, pinned to the browser's vectors
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>
2026-09-04 15:06:30 -04:00
Paul HittandClaude Opus 5 6a0dce6d4e The Files security lattice in Kotlin (labels, clearance)
`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>
2026-09-04 14:53:26 -04:00
Paul HittandClaude Opus 5 d915001dcc The wallet screen (Android)
Create a wallet, restore one from a phrase, see the balance and the history,
and take a fresh address to be paid at.

🔴 An unread balance is an em dash, never 0.00. Null means this app could not
read the balance; zero means the account is empty. Drawing the first as the
second tells somebody their money is gone — on every flaky connection, which is
exactly when they are most likely to believe it. The same rule governs the
activity list: "not read yet" and "nothing yet" are different sentences and the
screen says whichever is true. Both cards are always present once a wallet
exists, because a screen that hides them until it has something looks broken at
the moment somebody wants reassurance nothing went wrong.

🔴 The phrase card sits ABOVE everything about the chain. Somebody who has just
made a wallet has one job — write these words down — and putting a balance in
front of it invites them to skip it and come back later, which for most people
means never. The card says both halves at once: anybody who has these words
owns the wallet, and anybody who loses them loses it. Saying only the first
makes people hide it; saying only the second makes them screenshot it.

⚠️ Copy is offered for the phrase and is the SECOND option, not the first. It
puts the words on a clipboard every other app can read — offered anyway because
people who are not given it photograph the screen, which is worse and permanent.
The text asks for paper.

⚠️ The receive card says the address changes BEFORE somebody writes one down. A
fresh address per payment is the one linkability a wallet can prevent, and an
address that silently changed would read as a bug to anybody who had saved it.
Older addresses keep working, and the card says that too.

⚠️ Reached from Account, not the tab bar. The bar is four icons by design — the
kit's artboard is "Bottom Bar/Without Label" — and Alerts was taken OUT of it in
August for being a fifth. A wallet people open occasionally does not earn the
slot that decision refused.

🔴 An account below the rent-exempt minimum is RECLAIMED by the network, so
`spendable` holds back the fee and that minimum: emptying a wallet to the last
lamport does not leave it empty, it deletes it. Named constants rather than an
expression, so the reason survives.

⚠️ http endpoints are ignored rather than honoured. A balance read over plain
http is one anybody on the path can rewrite, and a wallet showing a number an
attacker chose is worse than one showing nothing.

8 display tests. App builds, every JVM suite green. Three PARITY wallet rows
are now ✅.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:48:56 -04:00
Paul HittandClaude Opus 5 7b8a440fc4 Read a balance and a transaction history from a chain node (Android)
`ChainRpc` asks a node what a public address holds and what has happened to it.
The node is trusted with nothing: it never sees a key and cannot move money.
What it CAN do is lie about a balance or withhold a history, so nothing here
treats an answer as authoritative beyond "this is what that node said".

🔴 A JSON-RPC error arrives with HTTP 200. The transport succeeded; the node
said no. A client that checks only the status code carries on with a body that
has no `result`, fails somewhere less obvious, and throws away the node's own
explanation on the way — the difference between "insufficient funds for rent
exemption" on screen and a parse error nobody can act on. Both cases are
tested, including an error carrying no message.

🔴 A FAILED transaction still appears in an address's history, and is shown as
failed. Not doing that is the worst kind of wrong: somebody would believe a
payment went through. `err` is an OBJECT for one class of failure and an ARRAY
for another, so the check is "not null" rather than a shape — testing for an
object alone would pass one whole class of failure off as a success. Both
shapes tested.

⚠️ A body with neither result nor error is malformed, never a balance of zero.
Reading a missing result as zero would show somebody an empty wallet that is
not empty.

⚠️ Lamports as a Long throughout, and to the edge of the UI. Five SOL already
exceeds Int.MAX_VALUE, and converting to a fractional unit early means carrying
rounding through code that compares and subtracts — a rounding error in a
balance is money.

⚠️ A transaction the node has not dated yet reports no time rather than 1970.
It has the transaction and has not assigned a block time; "just now" is the
truth and the epoch is not.

⚠️ Which node somebody points at is a privacy decision the app has to state:
asking a public RPC endpoint for an address's balance ties that address to this
device's IP in somebody else's logs. Running your own node is the only answer
that removes it; the default exists so the wallet works at all, not because it
is private.

15 tests, against a MockWebServer. App builds, every JVM suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:43:31 -04:00
Paul HittandClaude Opus 5 227c098da7 Create, import and store a wallet (Android engine)
`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>
2026-09-04 14:35:01 -04:00
Paul HittandClaude Opus 5 2e8c6776e2 Derive wallet addresses and sign with them (Android)
`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>
2026-09-04 14:18:59 -04:00
Paul HittandClaude Opus 5 4ee0170dcc Wallet engine primitives in Kotlin: BIP-39, Base58, SLIP-0010
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>
2026-09-04 14:12:49 -04:00
Paul HittandClaude Opus 5 6e4eca84e3 Refuse a home record older than one already seen (§20.4, Android half)
Both remaining ⚪ protocol rows turned out to be stale.
`AccountKdf.localAccountSalt` is pinned to the published vector and wired into
sign-in for a local relay; `MailboxHoming` and `AccountIdentity.publishHomes`
have shipped the publish and read halves for a while. PARITY.md was describing
a gap that had closed, which is its own kind of wrong — a definition of done
that overstates the work left is one people stop reading.

🔴 What was genuinely missing was on the READING side, and on BOTH clients.

An older home record is GENUINELY SIGNED — the account holder published it,
before they added a second relay. So a relay that wants to be somebody's only
mailbox does not have to forge anything: it serves the record it saw first,
that record verifies perfectly, and the sender delivers to one relay believing
it is delivering to all of them. That is the attack multi-homing exists to
survive, and the serial is what was added to stop it.

The relay refuses a lower serial on WRITE. Nothing refused one on READ:
`verifiedRelays` here and `verifyHomes` on iOS both checked the signature and
stopped. Fixed on both — iOS in ios!116, this is the Kotlin half.

The highest serial seen per address is remembered in the vault and a lower one
is refused. Equal is fine; only strictly lower is a rollback.

🔴 The floor rises only for a record that VERIFIED, via a separate
`verifiedSerial`. Otherwise a hostile relay could hand over an unsigned record
carrying Int.MAX_VALUE, have the client raise its floor to it, and lock out
every later genuine record — turning the rollback defence into a denial of
service against the account holder. Tested.

⚠️ Sealed in the vault and keyed by a hash of the address: which addresses this
device has looked up is exactly the metadata the vault exists to keep off a
stolen phone.

⚠️ The refusal does not claim an attack. A relay that has genuinely fallen
behind serves an old record for honest reasons, and nothing in a client can
tell that from the other case.

8 unit tests. App builds, every JVM suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:07:22 -04:00
Paul HittandClaude Opus 5 455cdcd818 Edit, forward and pin from the conversation (§6.15/§6.16/§6.19)
The outbound half of the three controls whose codecs were already back. The
long-press menu gains Edit, Pin/Unpin and Forward; the thread gains a pinned
banner; a bubble says `edited` and `Forwarded`.

🔴 An edit that silently replaces the words is worse than the typo. The reader
has no way to tell a correction from their own misremembering, and no way to
know the message they replied to is not the message that is there now. So the
bubble is marked, on both ends, and the edit sheet says plainly that everyone in
the conversation sees the new wording and a mark on their copy.

🔴 A forward carries the words and NOTHING about where they came from — no
author, no thread, no quote — and the test asserts the ABSENCE of a reference
rather than only the presence of the flag. The obvious "improvement", carrying
a reference so the reader can see the original, would republish a private
message's author to somebody who was never in that conversation. It also gets a
NEW message id: reusing the original's would make the copy a target for edits
and reactions aimed at the original, one of which would silently rewrite the
other.

⚠️ Only your own message can be edited, checked in `Session` and not only in
the menu. The receiving side enforces the same rule independently, so neither
end is trusting the other. Anyone may PIN, by contrast: a pin is a shared
banner rather than a permission, and restricting it to the author would mean the
useful message in a group is exactly the one nobody but its writer can pin.

⚠️ Edits and pins are applied locally first and rolled back if the send fails,
the same shape `react` already uses. Waiting on a round trip to see your own
correction appear makes it feel broken on a slow connection; leaving one that
exists only on this phone is worse, because you would believe you had fixed it.

⚠️ A forward does not carry an attachment yet. Re-sending a blob means
re-uploading it under a new token, and passing on the ORIGINAL token would hand
the new recipient a capability to a file in a conversation they are not part of.
Text only until that upload exists.

⚠️ The pinned banner resolves the id against the lines on screen, so a pinned
message that was since deleted shows nothing rather than a stale copy. Clearing
a thread clears its pin.

Two things found while wiring it: `Content.text` has no `groupId` parameter, so
a group forward needs `groupText` — a compile error rather than a silent drop.
And the edit/forward state is hoisted above the message list, because a row's
own `remember` dies when it scrolls out and an edit box vanishing mid-typing is
the worst way to lose a correction.

9 more unit tests. App builds, every JVM suite green. The three PARITY rows are
now ✅.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:01:19 -04:00
Paul HittandClaude Opus 5 e0420a00c6 Apply inbound edits and pins instead of filing them as messages (§6.15/#6.16/#6.19)
🔴 This was an interop bug, not a missing feature. The engine has parsed
`editOf`, `pins` and `fwd` for as long as they have existed, and it exposes
`isRenderable` — "does this belong in a thread" — as the single answer.
`Session`'s inbound loop never consulted it.

So three things arrived from the iPhone app and were filed as ordinary bubbles.
An edit carries its new wording in `text`, so it drew as a DUPLICATE beside the
typo it was meant to replace — a correction that made the thread worse than the
mistake did. A pin carries only an id, so it drew as a blank bubble. And a
forward was drawn as the sender's own words.

Worst of the three is the one nobody had hit yet: a control envelope from a
NEWER peer carries `ctl` and a kind this build has never heard of, and would
have drawn a bubble full of JSON in somebody's conversation. That is precisely
the failure `isControl` was written to prevent, defeated by nobody calling it —
and it would have shipped to every older Android install the first time a
control kind was added on iOS. The guard is `isRenderable` rather than a list of
kinds this build knows, because an allowlist is only correct until the other
platform adds one.

What is applied now:

- an edit rewrites the message it names and stamps `edited`, so a bubble can
  say so — silently replacing the text is worse than the typo, because the
  reader cannot tell a correction from their own misremembering;
- a pin is stored per thread, sealed in the vault like the transcript, as an ID
  and never a copy: a copy goes stale against an edit and outlives a deletion;
- `fwd` reaches the transcript so second-hand words are not drawn as the
  sender's own — THAT it was forwarded, never from whom.

🔴 An inbound edit is applied only when it comes from the person whose message
it is. Without that check any contact sharing a session could rewrite anybody's
words in the reader's transcript — a better attack than sending a message of
their own, because the words would carry somebody else's name.

⚠️ Clearing a thread clears its pin. A banner pointing at a message that was
just cleared reads as the clear not having worked.

⚠️ `fwd` is read as both a boolean and a number, for the same reason `isControl`
accepts both: `org.json`'s `optBoolean` does not coerce a number, and that is
how this class of bug shipped once already.

9 unit tests. App builds, every JVM suite green. The three PARITY rows stay 🟡:
the inbound half is done, originating each still needs the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:53:13 -04:00
Paul HittandClaude Opus 5 ab8ff658b2 Pin the relay's identity, not just its TLS (ios#19, Android half)
🔴 This was not a missing feature on Android. It was a wrong conclusion.

`Trust.verifyHeadSignature` already pinned `issuerPublicKey` the first time a
signed log head arrived. When the key later changed, the head signature simply
stopped verifying — and the only conclusion available at that point was
`KeyLogForkException`. So a relay key rotation somebody performed on purpose
was reported to the user as the relay showing two different histories, with
`forkDetected` set.

Those need different responses. A fork means nothing the relay says can be
trusted and the client should stop. A rotation means somebody ran a command, or
somebody else is answering on that address, and only a person can tell which.
Reporting the second as the first is alarming and unactionable — and an alarm
people cannot act on is one they learn to dismiss, which costs the real fork
detection its credibility as well.

`RelayIdentityPin` now decides what the published key MEANS before it is used,
with the same four verdicts as iOS, and a change raises
`RelayIdentityChangedException` — deliberately NOT a `KeyLogForkException`, and
without setting `forkDetected`, because the log may be perfectly consistent.

⚠️ A relay that publishes no key is a weaker deployment, not an attack, and an
unparseable key is broken rather than hostile. Both read as "not published"
rather than as a change: refusing to talk to an older self-hosted build would
be a worse failure than the one this prevents.

⚠️ The warning names both possibilities and asserts neither, because nothing in
the client can tell a rotation from an impostor.

Two things found while writing the tests. `RelayClient.host` already existed, so
the one I added was a duplicate declaration — removed rather than renamed. And
the compiler folded `changed !is KeyLogForkException` to a constant when the
value was typed concretely, which is the compiler proving the separation
statically and also an assertion testing nothing; it is now held as the sealed
base, which is the check a caller's `when` actually performs.

9 unit tests. App builds, every JVM suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:46:57 -04:00
Paul HittandClaude Opus 5 4a587a7d4d Zero key material after use (ios#39, Android half)
The stretched master key in AccountKdf is an intermediate nobody needs after
the HKDF expansion, and it is the most valuable thing in that file: every other
account secret is derived from it. It now lives inside
`SecureBytes.withSecret`, which wipes it however the block leaves — including
out of a throw, because a derivation that failed half-way is exactly where
material gets forgotten.

🔴 The Android guarantee is WEAKER than the iOS one, and PARITY.md says 🟡
rather than rounding it up to ✅. iOS has `memset_s`, specified not to be
optimised away, and does not page application memory to disk. Neither holds on
the JVM: a compacting collector can relocate a ByteArray and leave the bytes at
the old address untouched, Android has had compressed swap on by default for
years, and `Arrays.fill` is only "not eliminated in practice" rather than
guaranteed. There is no way to pin a Java array, so that is a ceiling rather
than a gap to close later.

So the claim is written narrowly: a derived key is not left sitting in the heap
after use. Not "the key is gone". Spending the value of this feature on a
stronger claim than it supports would be the worst possible trade.

⚠️ `signInSecrets` does BOTH expansions inside the block. Wiping between them
would derive the account seed from thirty-two zero bytes — a perfectly
consistent, perfectly wrong identity, on every device, silently. That is a far
worse failure than the leak this narrows, and `signInSecrets` had no pinned
vector of its own, so a test compares the pair-path against the individual
derivations. Proven: moving the wipe one line earlier turns it red.

⚠️ The limit is asserted rather than left to prose — a copy taken before a wipe
survives it, and that is a passing test so nobody reads `wipe` as stronger than
it is.

10 unit tests. App builds, every JVM suite green including the pinned KDF
vectors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:43:39 -04:00
Paul HittandClaude Opus 5 f362db0ee7 Handle a wrong clock (ios#45, Android half)
🔴 A wrong device clock breaks a promise made to somebody else. A story
disappearing after an hour is a promise the SENDER made, and it is enforced on
the READER's device — so a reader whose clock runs behind keeps a message the
sender was told would be gone, and nobody involved can tell.

That asymmetry decides the design, and it is the whole of this change. Expiry
takes the LATER of the device's clock and the relay's, never either alone. A
device set back cannot extend a message's life, and a hostile relay claiming an
earlier time cannot either, because the other source overrules it. The
remaining failure — expiring early on a clock set forward — costs the reader
something they were about to lose anyway.

The skew is learned free, from the `Date` header every HTTP response already
carries. Observed in `RelayClient.checked`, which is the one funnel every relay
call passes through, and BEFORE the success check: a phone whose clock is wrong
enough to be refused is exactly the phone that needs to be told why.

Story and disappearing-message expiry on the app side now reads
`DeviceClock.expirySeconds()` rather than `System.currentTimeMillis()`, at all
seven sites.

⚠️ Android needs synchronisation iOS does not. The Swift version is
`nonisolated(unsafe) static var` because its writers sit on one actor; here the
observation is written from whichever OkHttp thread finished a call and read
from the UI thread. The two fields are `@Volatile` and updated together under a
lock — a torn read of "skew from now, timestamp from an hour ago" would age out
an observation just made, or trust one that had not been.

⚠️ `SimpleDateFormat` is built per call rather than shared, because it is not
thread-safe, with a fixed `Locale.US` and GMT: a formatter taking its locale
from the device parses nothing on a phone set to most of the world, and the
failure is silent — the header is simply never understood.

15 unit tests. Proven: replacing the rule with "trust the server" turns the
hostile-relay test and the never-earlier test red. App builds, every JVM suite
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:38:55 -04:00
Paul HittandClaude Opus 5 fffab8e685 Back off on repeated app-lock attempts (ios#41, Android half)
Android had the lock and none of the back-off. iOS has counted failed unlocks
and imposed a growing wait since ios#41; here every attempt was free, so
somebody holding an unlocked phone could work through PINs as fast as the
prompt would reappear. Two platforms disagreeing about a security guarantee is
how one of them ends up being the one that leaks — the same argument that put
the lock on Android in the first place.

Same schedule as iOS: three free attempts, then 30 seconds doubling to a
five-minute ceiling. Both ends matter. A mistyped PIN or a fingerprint that did
not read is ordinary, and making the first slip cost a wait would punish the
owner far more often than anybody else; and an unbounded back-off locks
somebody out of their own messages permanently, which is worse than what it
defends against — no reset, no support desk, and the data is on their phone.

🔴 One real divergence from iOS, and it is a bug the Swift version cannot have.
iOS computes `30 * pow(2, steps)` in a Double, which saturates to infinity and
still caps correctly. Kotlin shifting a Long does not: `30L shl 60` wraps
NEGATIVE, and `minOf(negative, cap)` returns the negative — a "wait" in the
past, which reads as no wait at all and silently removes the whole feature at
exactly the point somebody is grinding attempts. The steps are clamped before
the shift. Proven: removing the clamp turns that test red.

🔴 The refusal happens BEFORE the prompt is shown. Letting the system sheet
appear and then discarding the answer would teach somebody that attempts are
free, which is the opposite of the point.

⚠️ Only a genuine wrong answer counts. `onAuthenticationFailed` is a
fingerprint that did not match; `onAuthenticationError` is a cancel, a platform
lockout or a hardware problem. Only the first moves the counter, so dismissing
the prompt cannot back the owner into a wait — and the platform's own lockout is
not counted on top of ours, because two back-offs stacking produce a wait nobody
can predict from either rule.

⚠️ Switching the lock off clears any outstanding wait. Leaving one behind
strands somebody behind a countdown for a screen that no longer appears.

7 unit tests. App builds, every JVM suite green. PARITY.md gains a Hardening
section listing the three that remain (ios#19, #39, #45).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:35:56 -04:00
Paul HittandClaude Opus 5 775b6e62d0 A test run must not publish a credential (ios#62, Android half)
The Kotlin counterpart of the iOS fix, and Android's version of the bug was
quieter rather than absent.

Android was already ahead on the part iOS got wrong: every live suite is gated
behind `assumeTrue`, and `LiveTarget.refuseProduction` refuses a production
host outright. What it still had was the credential itself.
`LiveTwoProductsTest` and `LiveShapesTest` both carried
`const val PASSPHRASE = "a-long-enough-passphrase"` and used it to create
accounts on accounts.flamenet.io whenever somebody set FN_LIVE_MANAGED=1.

🔴 The gate stops it running by accident. It does not stop the password being
written down. Every account those runs ever created can be signed into by
anybody who can read this repository, and "they were throwaway accounts" does
not change that — they are real accounts on a real service and they persist
until somebody deletes them.

Both now read it from the environment through `LiveTarget.passphrase()` with no
fallback, and both gates assume on it being non-empty, so opting in without
configuring one reports SKIPPED rather than signing up with a published value.

And a guard, matching the Swift one: TestSafetyTest fails the build if a suite
that reaches a live relay carries a credential literal, or cannot announce a
skip. Proven by putting the literal back and watching it go red.

⚠️ The guard had to be narrowed twice before it was worth keeping, and both
narrowings are recorded in it. Keying on the `Live` name prefix flagged
LiveTarget.kt (the helper) and LiveTargetTest.kt (a unit test of the helper).
Keying on the string "http" then flagged four suites that sign up against a
MockWebServer — which is exactly the correct way to test sign-up. The
discriminator is MockWebServer: a file that stands up a mock cannot reach
anybody. It now matches the nine real live suites and nothing else, and a test
asserts the predicate matches at least five, so the checks cannot pass by
guarding an empty set.

PARITY.md gains a Test safety section. Full JVM suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:29:38 -04:00
Paul Hitt 22189bea93 PARITY: record the Android revert and what came back 2026-09-03 19:24:10 -04:00
Paul HittandClaude Opus 4.8 2179615263 Stories you can answer, and an @ that finds people
The story features existed in the codec and nowhere a person could reach
them. + went straight to the file chooser, so a story of words was
unreachable even though the engine could carry one; there was no way to
react, reply, mute, or see what anybody had said back.

+ is a menu now. Reactions are a fixed row of five — a story is glanced
at and scrolled past, and anything that takes two taps to say "nice"
does not get used. Your own story shows the tally instead, because you
cannot react to yourself and a row of dead buttons is worse than none.

Mentions get an `@` picker in the group composer, matching iOS down to
the rule that matters: the id is recorded when somebody is PICKED, never
re-derived from the text at send time. Deriving it would mean typing a
name reached that person whether or not they were chosen, and renaming a
contact would silently unmention them. Only the ones still named in the
body are sent, so picking somebody and then deleting their name doesn't
ring them about a message that no longer mentions them.

A group member who isn't in your contacts has no name to show, so they
appear under their id. Un-mentionable would be worse than ugly — it
would mean a group where some people cannot be reached.

Mentions on Android are carried and rendered but override nothing: there
is no per-conversation notification mute here to override yet.

640 unit tests green across app, engine and client. PARITY.md updated —
mentions and the story features move to ✅, and Files and Wallet get rows
saying plainly that Android has none of either.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 15:11:21 -04:00
Paul HittandClaude Opus 4.8 4c3f30e180 Count the sealed-to-unsealed fallback, and offer to refuse it
Android half of ios#16. `Sent.sealed` already reported each send's
outcome, but nothing accumulated, so nobody could answer "how often does
this happen to me".

🔴 The copy distinguishes sender from content, with tests asserting it.
An unsealed send is still end-to-end encrypted; what the relay learns is
who sent it. Somebody who reads "sealed" as "encrypted" makes the wrong
decision in both directions.

⚠️ Aggregate only. Per-contact counts would be more useful and would also
build a record of who this account talks to and how often — the precise
thing sealing exists to keep off a server.

Refusing is offered and off by default: it turns a metadata leak into an
undelivered message, which for most people is the worse outcome. It has
its own exception type so the refusal can explain itself.

⚠️ Refusing spends nothing. `sealed` is false exactly when neither a
capability nor a key was found, so `takePeerToken` returned null and
there is no token to hand back — my first cut had a line handing one
back, which was unreachable.

⚠️ The policy and the counter are injected as lambdas, since `client` and
the engine have no Android dependency, and both are wired in one helper.
Two sign-in paths build a session, and only one of them applying the
policy is how a setting silently stops working on half the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:57:00 -04:00
Paul HittandClaude Opus 4.8 6fe463d578 Report or block from a story
Android half of ios#56, spec §6.25. Needs the relay endpoint from
flamenet-relay!24.

🔴 A report is the one thing this app sends that the server can read.
Everything else is sealed, and the relay cannot open a story, so a report
about one is only actionable if the reporter hands over what they saw.
The dialog states that before anything is sent: what leaves the device,
who can read it, and that it cannot be undone. Declining to include a
copy is offered, and says plainly there may then be nothing to act on.

⚠️ `reportStory` sends only what it is passed. It does not reach into the
thread for surrounding context — §6.25 forbids including anything the
reporter did not choose to include.

⚠️ Blocking and reporting sit side by side and are never merged. Blocking
is this person's own decision and takes effect now; a report is a request
to somebody else. The copy says which is which, because a combined
control would make one of the two invisible.

The reasons are enumerated to match the relay, and a test asserts the two
lists are equal — a reason the relay rejects is a report somebody
believed they had filed. CSAM is its own reason rather than folded into
abuse: it carries a different obligation, and an operator cannot triage
what nobody can name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:55:29 -04:00
Paul HittandClaude Opus 4.8 b43be1b780 Post a story to a group
Android half of ios#55, spec §6.24. An ordinary story envelope carrying
`gid`, fanned out to the members and recorded in the story set.

🔴 `Vault.groupStoryReach` returns the overlap rather than a warning: who
is excluded by this account's story audience and who is not a contact at
all, counted separately. The roster decides either way — those are two
different lists making two different statements and they will disagree —
so a disclosure that cannot name the consequence is not one.

🔴 The group label is adopted only from an actual member. The roster
check that guards group messages runs after the story branch, so without
this a sender could attach the id of a group they are not in and have
their story shown as having come from it. Dropped to null rather than
refusing the story: the story is legitimately theirs, and only the claim
about where it was posted is unverified.

The story branch already sat before group routing, which is what keeps a
group story out of the conversation — filed there it would be a story
that is a message, and one that never expires out of the thread. That
ordering is now written down rather than incidental.

⚠️ `gid` is persisted, or a reload forgets which story went where.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:53:48 -04:00
Paul HittandClaude Opus 4.8 8b719032c2 React to a story with one tap
Android half of ios#52, spec §6.22.

🔴 Decoded before §6.9's reaction path, which requires `about`. A story
reaction carries `sre` instead — a story is not in the conversation and
has no `mid` there, so one carrying `re` would fold onto whatever message
happened to share that id, or onto nothing. Falling through would leave
it unhandled and drawn as an empty line in the thread. Tested both ways,
including that an ordinary thread reaction still decodes as before.

⚠️ Its own disclosure and its own stored key. A reaction reveals that you
watched exactly as a reply does; what differs is the deliberation. A
reply is typed. A reaction is one tap on a story already filling the
screen, so an account told about replies has been warned about the
considered case and not the careless one.

⚠️ The row does not share a gesture with anything that navigates: an
acknowledgement delivered by a mis-tap is a disclosure the person did not
make.

Branched from feat/story-replies, which carries the `sre` plumbing this
reuses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:52:32 -04:00
Paul HittandClaude Opus 4.8 52a793ce5c Reply to a story, as a sealed direct message
Android half of ios#30, spec §6.20. A reply is an ordinary direct
message to the author carrying `sre.sid`, landing in the existing
conversation. Stories gain no comment thread and no viewer list.

The reference carries the id and nothing else. A story's author had the
story by definition, so the only way they no longer hold it is that their
own timer removed it — sending a copy back would resurrect exactly that,
at a moment they did not choose.

🔴 `Content.text` returns the raw body when it has nothing to wrap, so
`storyReplyId` had to join that condition. Left out, a reply with no
message id and no quote would have gone out as plain text and arrived
with the reference silently dropped — on exactly the path a story reply
normally takes. There is a test for it.

The disclosure is shown before the first reply this account sends, names
what is revealed, and says where the reply goes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:50:17 -04:00
Paul HittandClaude Opus 4.8 d525ef3d69 Post several pictures as one story sequence
Android half of ios#53, spec §6.23. Each item is an ordinary story
envelope carrying a `set` naming the sequence, its position and its size.

🔴 One envelope per item rather than an array of attachments. The array
fails the single-`att` guard on any build predating §6.23 and renders the
whole set as a message in the conversation; separate envelopes degrade to
several stories in order, which every existing build already draws.

🔴 `setExpiry` takes the earliest stamp. Items are required to carry the
same one, but a sender can write different ones, and if the latest won
the difference would keep part of a sequence alive after the rest had
gone. A zero is skipped rather than read as "expires at the epoch",
which would kill the whole set.

⚠️ `groupedStories` renders what arrived: `n` is a claim, any item can
fail to arrive, and a missing one is indistinguishable from one never
sent. Stories with no set are keyed so two of them cannot collide.

⚠️ The set is persisted, or a relaunch scatters every sequence back into
loose stories.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:48:16 -04:00
Paul HittandClaude Opus 4.8 242391119b Keep your own stories in a private archive
Android half of ios#54. Expired stories were gone from the author's
device too, so there was no way to look back at what you posted.

🔴 This keeps something the interface told everybody would disappear, so
the shape of the feature is the argument for it being acceptable: off
unless switched on, labelled as what it is, and only ever holding stories
this account posted. `Vault.archive` refuses anything else — the caller
filters too, and this is the guard that survives the caller being
rewritten.

🔴 The archive is deliberately NOT written through `saveStories`.
Everything in it is expired by definition, and that path drops expired
stories on write — so reusing it would empty the archive on the next
pass. Separate record, and a test named for exactly that.

Archiving happens in `stories()`, before `saveStories` drops the expired
ones: the only moment they can be caught, since expired bytes leave on
write. That runs on every load, so the fold is idempotent.

⚠️ The setting is injected into `Session` as a lambda. The engine layer
has no Android dependency and must not read preferences, and an absent
value has to read as OFF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:47:44 -04:00
Paul HittandClaude Opus 4.8 819fab3161 Reply to a story, as a sealed direct message
Android half of ios#30, spec §6.20. A reply is an ordinary direct
message to the author carrying `sre.sid`, landing in the existing
conversation. Stories gain no comment thread and no viewer list.

The reference carries the id and nothing else. A story's author had the
story by definition, so the only way they no longer hold it is that their
own timer removed it — sending a copy back would resurrect exactly that,
at a moment they did not choose.

🔴 `Content.text` returns the raw body when it has nothing to wrap, so
`storyReplyId` had to join that condition. Left out, a reply with no
message id and no quote would have gone out as plain text and arrived
with the reference silently dropped — on exactly the path a story reply
normally takes. There is a test for it.

The disclosure is shown before the first reply this account sends, names
what is revealed, and says where the reply goes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:46:24 -04:00
Paul HittandClaude Opus 4.8 83d7803f9e Save a story you posted yourself, before it expires
Android half of ios#34. A story disappears from its author's own device
too, so there was no way to keep something you made.

🔴 Only ever your own, and the rule is `Vault.isSaveable` rather than a
condition on the menu item. Saving somebody else's story keeps a copy of
something they were told would disappear — the opposite of what the
feature promises them — and a guard living in a composable is one
refactor away from being lost. Tested from both directions.

⚠️ Written through MediaStore, so no storage permission is asked for. An
app inserting its own entry needs no runtime grant on any version this
app supports; asking for WRITE_EXTERNAL_STORAGE in order to save one
picture would request read access to everything the person has, to
perform a write.

⚠️ The message says the copy has left the disappearing model and names
where it went. "Saved" alone would be true and useless — somebody not
told that may reasonably assume the expiry still covers it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:44:21 -04:00
Paul HittandClaude Opus 4.8 7c51bc2521 Post a story that is only words, or a link
Android half of ios#32, spec §6.21. `att` becomes optional and the
caption becomes the story.

🔴 The decode sits before the attachment branch, and that placement is
the feature. The branch requires `att` for a story; a text story failing
it falls through to the ordinary-message path and shows as a chat bubble
in the conversation that never expires — both of the things §6.6 says a
story must not be. There is a test named for it.

`Vault.Story.ref` is nullable rather than carrying a sentinel, so nothing
can read a blob id off a story that has none. That forced a `key` for
seen-marks and dedup, and an `expiresAt` that folds the two cases so no
caller has to know which kind it is holding.

⚠️ The stored record omits `ref` entirely rather than writing an empty
object — a reload would otherwise resurrect a blob that fails to fetch,
and `loadStories` had to stop treating an absent ref as malformed.

A "Write" entry on the Stories tab posts one, and says who enforces the
expiry: there is no photo to delete, so it relies on the receiving app
honouring `exp`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:43:43 -04:00
Paul HittandClaude Opus 4.8 3614665732 Recognise a pinned message (§6.19)
Codec only — the app does not pin yet — but without this branch an
inbound pin falls through parse to the attachment path and comes back
renderable, so every pin the iPhone app sets would draw an empty bubble
in the conversation.

isRenderable excludes it for the same reason it excludes a reaction, and
it is NOT in Kind.CONTROL: a build that swallowed it as generic control
would drop every pin while looking like it worked.

🔴 The envelope carries an id and never a copy of the message. A pin
carrying the text would keep a message readable after its own timer had
removed it, so pinning would quietly defeat disappearing messages.

Refs paulhitt/flamenet-messenger-ios#50

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 16:05:41 -04:00
Paul HittandClaude Opus 4.8 42b7d1732f Search inside messages, across every conversation
🔴 There is no index, and that is a decision rather than an omission. An
index derived from decrypted history is plaintext by another name: it
would have to be sealed as carefully as the vault, taken by
forgetEverything, and kept consistent with the threads — a second copy
of everything anybody ever wrote, with its own ways to go wrong. This
reads what already exists and throws the plaintext away.

⚠️ It costs more here than on iOS, and the code says so. The iPhone app
keeps the whole decrypted history in memory; Android decrypts each
thread on demand. Hence a two-character minimum, a bound, a 250ms
debounce, and Dispatchers.IO.

🔴 Folding is length-preserving on purpose: the snippet slices the
ORIGINAL string at an index found in the folded one, so a fold that
dropped characters would cut in the wrong place — and for an accented
character, mid-character. There is a test with accents before the match.

Accent-insensitive because somebody typing "cafe" means "café", and a
search that knows the difference quietly fails in every language with
accents — Spanish is next on the localisation list.

Refs paulhitt/flamenet-messenger-ios#25

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 16:03:09 -04:00
Paul HittandClaude Opus 4.8 ddb82b87b6 A timer on one message, not just the conversation (§6.17)
The rule that keeps this from making the product worse: the shorter
timer always wins. Without it a sender could defeat the recipient's
conversation timer by putting a long secs on every message — turning a
setting the recipient chose, on their own device, into something the
other party overrules.

🔴 pruneExpired returned early when the CONVERSATION had no timer, so a
timed message in an untimed thread would have been kept for ever — the
feature working on the sending phone and doing nothing on the receiving
one. Pruning is per message now.

⚠️ Content.text returns raw bytes with no envelope when it has nothing
to carry, so the timer had to join that decision or it would be dropped
and the message would live for ever.

The sender's own copy expires too: a timer that only cleared the
recipient's phone would leave the message with the person who set it.

Refs paulhitt/flamenet-messenger-ios#29

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:59:37 -04:00
Paul HittandClaude Opus 4.8 19f4247b71 Recognise mentions in a group message (§6.18)
Codec only — the app does not compose or act on them yet — but it must
already understand an envelope the iPhone app sends, and more
importantly must not be made to do work by one.

🔴 Bounded and deduplicated on receipt. The list comes off the wire, and
a hostile sender could name ten thousand ids in one envelope; the cost
of that lands on every recipient's device rather than theirs. Capped at
256, far above any real roster.

A malformed list does not break the message: it is an ordinary message
with an odd field, not a reason to drop what somebody wrote.

Refs paulhitt/flamenet-messenger-ios#49

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:56:26 -04:00
Paul HittandClaude Opus 4.8 a43f8a6feb Tell somebody when their disappearing message is screenshotted
Android half of ios#36, spec §6.27. `shot` joins the control kinds, so a
build that does not know it stays quiet rather than rendering JSON.

🔴 The care is in refusing to let the absence of a notice mean anything.
The device that took the screenshot reports it, so a modified build, a
second phone, or a screen recorder produce nothing. A notice means one
was probably taken; no notice means NOTHING, and nothing counts them.

⚠️ Android's half of the asymmetry is stated rather than hidden. There is
no screenshot callback at all before API 34, and the one added there does
not cover screen recording — so the disclaimer says that, in those words.
Claiming a symmetry with iOS that does not exist would be worse than the
gap, because somebody would rely on it.

⚠️ Only where a timer is set, and the timer check comes before the
rate-limit check so a suppression window cannot make an untimed
conversation start reporting.

⚠️ Last-notice times are in memory. Persisting them would store who
screenshotted whom on disk to enforce a five-second window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:50:52 -04:00
Paul HittandClaude Opus 4.8 3b7e69c0eb The transcript records a message id and a reply reference
🔴 The wire always carried both and Session.Line never stored them.
Content.groupText has taken a messageId since §6.9 shipped and parse
reads it back — the transcript was where it stopped. So nothing here
could name an earlier message: no replies to resolve, no reactions to
fold, no edits to apply. All three were "codec done" and none worked.

A reply from the iPhone app arrived as a bare message with the quote
silently removed, which is worse than the missing id: the reader had no
way to know anything had been dropped.

🔴 Also fixes a real bug in sendGroup. It built the envelope twice —
once to send, once for the outbox — so an id minted inside either would
differ between them, and a member who was offline would eventually
receive a DIFFERENT message from everybody else, which nothing could
reconcile with a reply or a reaction. One id, minted by the caller, used
by both.

Both fields written only when present: a null on every message would
grow the transcript for nothing and make "no id" indistinguishable from
"an id of nothing".

Refs paulhitt/flamenet-messenger-ios#58, #10

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:50:34 -04:00
Paul HittandClaude Opus 4.8 01e6d6fe6d Recognise a forwarded message (§6.16)
An inbound forward is shown as one rather than as the sender's own
words. Both flag shapes accepted, like ctl: org.json's optBoolean does
not coerce a number, so an engine reading one shape would silently miss
every forward the other two send.

⚠️ The subtle one: Content.text returns raw bytes with no envelope at
all when there is no id, reply or link to carry. A forward that was not
counted in that condition would lose its flag and arrive looking like
the sender's own composition — so  is part of the test for
whether an envelope is needed, not just a field inside it.

Codec only. Originating a forward needs the transcript work in #10.

Refs paulhitt/flamenet-messenger-ios#28

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:50:17 -04:00
Paul HittandClaude Opus 4.8 f95cb1e6aa Keep a half-written message across a relaunch
Sealed in the vault, not in SharedPreferences — and that is the whole
point. A draft is plaintext the person has not decided to send, very
often the most sensitive text in the app because it is the thing they
are still weighing. SharedPreferences writes readable XML that survives
into backups; the vault seals it under the conversation's own key with
the record name bound into the AAD, and forgetEverything takes it on
sign-out. There is a test that asserts the bytes on disk do not contain
the text, because the wrong implementation would pass every other test
here.

Keyed by the same threadFor* ids the transcript uses, so a group called
"7", the contact who is user 7, and the address "7" are three
different drafts.

Every write to the composer state goes through one setDraft: send clears
it by assigning directly in three places, and any one of those that
forgot to clear the stored copy would bring the message back next time
as a draft of something already sent.

Closes #47 on iOS side; Android half of the same feature

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:50:01 -04:00
Paul HittandClaude Opus 4.8 0c1af3e45f Mute one contact's stories, without blocking them
Android half of ios#33. Same shape as iOS: the muted set lives in the
vault beside the audience list, because who somebody has muted is a
statement about their social graph and shared preferences are readable
by anything that reaches the file.

The filter runs on the way out of `stories()`, after `saveStories` has
already written the live set back. A muted contact's stories are still
received and still stored — dropping them on receipt would be cheaper,
but unmuting would then show an empty strip until their next post, and
muting would quietly mean something other than what it says.

`Vault.visibleStories` never drops the signed-in user, even if their own
id is in the set. `setStoryMuted` refuses to put it there, but a restored
backup from a build without that guard could carry one, and hiding
somebody's own posts from them reads as the posts having failed to send.

Long press on a story bubble to hide or restore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:47:55 -04:00
Paul HittandClaude Opus 4.8 914112b032 Recognise an edit envelope so it is not drawn as a duplicate (§6.15)
Without this branch an edit falls through parse to the last case, which
returns the caption as text — so an iPhone correcting a typo appears on
Android as a SECOND bubble beside the original, with nothing to say
either replaced the other. That is the bug this fixes today, before any
of the applying half exists.

isRenderable excludes it for the same reason it excludes a reaction, and
it is NOT in Kind.CONTROL: a build that swallowed it as generic control
would drop every correction silently, which is worse than the duplicate
because nobody would notice.

⚠️ Codec only. Session.Line records no mid, so nothing in an Android
transcript can be named by an edit and the app cannot apply one. That is
the same gap reactions have been sitting behind (PARITY.md "codec done,
no UI") and one transcript change unlocks replies, reactions and edits
together — filed separately.

Refs paulhitt/flamenet-messenger-ios#26

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:47:39 -04:00
Paul Hitt 6ab4741f8c Merge branch 'test/ios-followup-fixes' into 'main'
Six harness defects the full sweep found, and one of mine

See merge request paulhitt/flamenet-messenger-ios!36
2026-08-25 23:10:53 +00:00
Paul HittandClaude Opus 5 e8d566b3f2 Record what was actually checked for TestFlight
Verified against the built product rather than the source tree, which is what
caught the one blocker: no PrivacyInfo.xcprivacy. Apple requires it for any app
touching a required-reason API and this one uses UserDefaults in five files, so
the upload would have come back ITMS-91053 before reaching TestFlight. The first
archive silently omitted the file because it was added after the project was
generated — hence the note to check the bundle, not the repo.

Also green: archive for generic/platform=iOS, 38 unit tests, launch screen,
usage strings, 1024 icon, ATS with no leftover dev exception, WebRTC's own
manifest, and no tracking SDKs (grepped, not assumed).

Two items left for a human: the collected-data declaration is a legal statement
that must match the ASC privacy label, and versionCode 1 needs bumping after the
first upload.

And a warning that cost an archive: plutil -extract writes back in place without
-o -, so using it to inspect a plist destroys it. Use plutil -p.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:16:09 -04:00
Paul Hitt 3ef8de63b9 Merge branch 'test/ios-two-simulator-and-surface' into 'main'
One sign-up helper, because five copies had the same bug

See merge request paulhitt/flamenet-messenger-ios!35
2026-08-25 21:39:35 +00:00
Paul HittandClaude Opus 5 4f15beb160 Retire the pristine session, not the one a failed decrypt mutated
The retained-session fix was necessary and did not work, and a live two-peer
reproduction is what showed it: both second-exchange messages still vanished
with delivered:1, failed:0.

Cause. `DoubleRatchet` is a final class on iOS and `E2ESession` is a class on
Android, so the failed decrypt that PROVES the peer is on another chain mutates
the instance it was handed — skipped keys consumed, chain stepped. Both fixes
then archived that same instance. Storing a part-advanced chain is
indistinguishable from storing nothing: it cannot open what the peer sends. The
vault copy was never handed to a decrypt, so re-read it before retiring it.

Second cause, Android only. Which slot the winner belongs in is not the same
question as which chain opened it. When the retired chain opens an envelope it
has to be written back to the RETIRED slot; promoting it swaps the two and
discards the chain we are sending on. Each branch now writes back deliberately
instead of sharing one `if (session !== existing)`.

Verified against relay1, two peer accounts, no simulators:

    before   p1/p2 poll -> {"messages":[]} / {"messages":[]}
    after    p1/p2 poll -> second-from-Q2 / second-from-Q1

The repro is in PARITY.md and takes about two minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:12:23 -04:00