Codex raised three P1s on the first revision. I checked each against the code
before touching anything; all three are real, and the first two are the same
mistake in two costumes — one predicate asked two different questions.
1. An announced suffix is not a UI decoration.
`sealApplies` stripped a trailing `#abcd` from whatever it was given, including
the LIVE announced nickname. A peer announces whatever string it likes, and `#`
plus four hex is a legal thing to announce — this app's own `splitSuffix`
exists because announced names carry them. So the single predicate was wrong in
both directions at once: a key pinned as `medic` could rename to `medic#cafe`
and keep its seal, and a key honestly verified as `medic#cafe` lost its seal
without renaming at all.
There are now two functions, `sealAppliesToAnnounced` and
`sealAppliesToRendered`, because there are two questions: what a peer claims,
and what a row shows after the list may have decorated it. The rendered one
tries the raw name before undecorating, so a name that genuinely ends in a
suffix still matches itself. The iOS patch has exactly this split and I
collapsed it here.
2. The check was not reactive.
`PeopleSection` caches `isPeerVerified` with
`remember(verifiedFingerprints, peerFingerprints, connectedPeers)`, and the
per-peer sheet with `remember(peerID, verifiedFingerprints)`. My gate reads the
announced nickname inside those lambdas, and a rename changes none of those
keys — so the NAME repainted while the cached `true`, and the seal, survived
until something unrelated invalidated the cache. That is the connected peer
still showing a seal after renaming: the live impersonation, and the single
case this patch exists to stop. `peerNicknames` is now a key at both sites.
3. The watch was never patched at all.
There is a `:wear` module in settings.gradle.kts with its own
`WearPeerIdentityState`, its own verify button and its own seal, and it calls
`setVerifiedFingerprint` without a nickname while deriving `isVerified` from
the fingerprint set alone. Every peer verified on a watch failed open for ever.
I missed it because I audited `app/` and assumed that was the app — I never
read settings.gradle.kts. It now pins the announced name on verify and applies
the binding in `snapshot`.
15 binding cases, up from 13. Mutation-checked: put the stripping back into the
announced check and the new case fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A verified key can rename itself onto a nickname the user trusts and keep
drawing the seal beside the new one.
Verification binds a FINGERPRINT, which is right. But the seal is rendered
beside a self-claimed nickname, and nothing binds those two together:
1. Eve announces "ravi" and gets verified in person by someone.
2. Eve announces "medic". The rename is free and silent — PeerManager
computes `nicknameChanged` only to decide whether to refresh the list.
3. Every device that verified Eve now shows a second trusted-looking medic.
This is the Android mirror of permissionlesstech/bitchat#1708. It is smaller,
because this app has no vouching: there is no transitive trust to launder
onward, only the seal itself to withdraw.
The binding
`SecureIdentityStateManager` records the nickname a key was announcing when it
was verified, in its own store. Deliberately NOT the existing
`cached_fingerprint_nicknames`, which is a LAST SEEN cache overwritten on
every peer-list refresh — comparing against that would always match and catch
nothing.
Pinned on verification and on re-verification (the user just checked this key
again, under whatever name it shows now), cleared on unverify. Never pinned
from `resolvePeerDisplayName`, which falls back to a truncated peerID: that is
an identifier, not a claimed name, and pinning it would drop a seal on the
peer's first real announce.
A missing baseline never suppresses. Peers verified by earlier builds have
none, and dropping their seals on upgrade would teach people to ignore the
signal.
Where the seal comes from
Four surfaces, all gated: the connected peer row and the peer sheet (via
`ChatViewModel.isPeerVerified`), the conversation row, and offline favourite
rows. The last two are checked against the name THEY render rather than a live
announce — an offline favourite shows a name held in the favourites record, so
asking about a "current" name it is not announcing would answer nothing.
`VerificationHandler.isPeerVerified` / `isNoisePublicKeyVerified` have no
callers today and are gated anyway: leaving them ungated would hand the next
caller the answer this change exists to stop giving.
One surface is deliberately half covered
In the fingerprint sheet the seal GLYPH and its green tint are withheld, and
the word "verified" is not. The key genuinely is verified, so saying otherwise
would be false — and leaving the sheet untouched would let someone tap through
from a row whose seal just vanished and be reassured by a green checkmark. The
right fix is a sentence that says both, and a new string has to ship in all 34
locales; machine-translating a security warning is not something to do in
passing. Happy to wire it if someone supplies the wording.
Comparison rules
NFC, then a locale-independent case fold, then NFC again — folding can itself
emit decomposed sequences, and `Locale.ROOT` is not optional or a Turkish
phone would disagree with every other device about whether a peer had renamed.
Recasing your own nickname is not a rename; a fullwidth or Cyrillic look-alike
IS one. A trailing `#abcd` is stripped before comparing, ASCII hex only, since
`Char.isDigit()` accepts fullwidth digits and would truncate a nickname
literally ending in "#ABCD" into something that could match a baseline it
is not.
Tests
`NicknameBindingTest`, 13 cases: the rename attack, the rename-onto-a-
look-alike cases, recasing, combining accents, Turkish dotted I, the hash
suffix and the fullwidth-hex trap, "@" in a nickname, and both fail-open
paths. The logic lives in a pure-Kotlin `NicknameBinding` object with no
Android imports precisely so the security decision is testable without a view.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
If SQLite clearAll fails mid-panic, in-memory state was already cleared
but a process restart could reload encrypted history from disk (#699).
Fall back to deleting the database files before reporting failure.
A relay that fails is retried on an exponential backoff, then abandoned
for the lifetime of the process. Nothing brings it back: the relay layer
registers no connectivity callback, the periodic subscription validator
only repairs subscriptions on sockets that are already open (and returns
immediately when connectedRelayCount is 0, which is exactly the state
after an outage), and connect() runs once from NostrClient.initialize().
The remaining paths that reset reconnectAttempts are a manual retry, a
Tor state change, and a successful open.
Two ways a relay died permanently:
- Any error whose message mentioned DNS returned before scheduling
anything at all. "Unable to resolve host" is what this device reports
when it simply has no network, so a moment in a tunnel killed every
relay at once, with no retry ever.
- Otherwise the schedule stopped at MAX_RECONNECT_ATTEMPTS. With
INITIAL=1s and MULTIPLIER=2 that is nine waits totalling about eight
and a half minutes, after which the relay was dead. MAX_BACKOFF_INTERVAL
was unreachable: attempt 9 asks for 256s and attempt 10 gave up, so the
five-minute ceiling the constant defines never applied to anything.
Let the backoff saturate at MAX_BACKOFF_INTERVAL and keep retrying there.
A name-resolution failure now backs off like any other error. Steady state
costs one connection attempt per relay per five minutes; the previous
behaviour cost the user every internet DM, delivery receipt and geohash
channel until they noticed and restarted the app.
The schedule moves into RelayReconnectPolicy so it is unit-testable
without OkHttp or a Context.
The collision case fails on main: two packets sharing a 64-byte prefix and
a timestamp, where the second was silently dropped.
The other two pass before and after on purpose. Replay of an identical
packet must still be caught, and the same packet arriving from two
different peers must still be tracked separately — strengthening the
identity must not quietly weaken either.
Replay and duplicate detection keyed on a 32-bit contentHashCode over at
most the first 64 bytes of the payload. Two packets from the same peer in
the same millisecond that agreed on that prefix were the same packet as
far as this cache was concerned, and a collision here is a dropped
message: the second is discarded and nothing reports it.
PacketIdUtil is the identity the rest of the stack already uses for this
question — gossip sync membership, and the message IDs MessageHandler
assigns — and iOS derives it identically: first 16 bytes of SHA-256 over
type, senderID, timestamp and the whole payload. The security path now
agrees with the sync path instead of carrying a weaker private notion of
"same packet", and the FRAGMENT special case disappears because the full
payload is covered either way.
Peer scoping is deliberately kept. PacketIdUtil covers the packet's own
senderID, which is not the peer it arrived from once relayed.