The previous commit moved cryptographicIdentities into the persisted
IdentityCache, which made the pre-existing off-queue cache access in
the save path fatal instead of merely racy: forceSave() -> performSave()
read and JSON-encoded `cache` on the caller's thread while a concurrent
`queue.async(.barrier)` writer mutated the same dictionary. ThreadSanitizer
flags this as a data race in saveIdentityCache(), and because JSONEncoder
walks the dictionary storage, an interleaved mutation can spin forever —
which is what hung the CI "Run Swift Tests (app)" job (killed at the
watchdog timeout).
The naive fix (snapshot `cache` under `queue.sync` in forceSave) instead
introduced a deadlock: forceSave() is reachable from deinit, and the
object's final release can run *on* the identity queue (the fire-and-forget
barrier saves capture self), so queue.sync there is a re-entrant same-queue
wait -> SIGTRAP. That matched the intermittent crash the hang investigation
surfaced.
Fix:
- Split persistence into persist(snapshot:) which encodes/seals/writes a
by-value IdentityCache snapshot, decoupled from reading `cache`.
- saveIdentityCache() (only ever called inside a barrier writer) passes
`cache` directly — already serialized, race-free.
- forceSave() now snapshots + persists inside `queue.async(flags:.barrier)`
(async, never sync): the read is on the barrier context so it never races
an in-flight write, and being async it can't deadlock when invoked from
deinit running on the queue. Durability is unaffected: every mutating API
already persists inline within its own barrier, so forceSave is a
belt-and-suspenders flush.
Test: test_concurrentUpsertsAndForceSaveDoNotRaceOrHang hammers concurrent
upserts interleaved with forceSave; it reproduces the data race under
`--sanitize=thread` on the old code (SecureIdentityStateManager.swift:250)
and passes cleanly with the fix. A lock-guarded LockedKeychain double is
used so the test exercises the manager's own cache race rather than the
non-thread-safe MockKeychain. Verified green over repeated
`swift test --parallel` runs both with and without --enable-code-coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The TOFU signing-key pin lived only in the in-memory peerRegistry. When
a previously seen peer was no longer in the registry (app restart, or
reconcileConnectivity pruning an offline peer), the announce trust check
saw no pinned key, treated the announce as first contact, and
persistIdentity overwrote the cached signing key/nickname — so an
attacker could replay a victim's noiseKey/peerID with their own signing
key and bypass the spoofing protection for returning/offline peers.
Fixes:
- BLEAnnounceHandler now falls back to the persisted cryptographic
identity (via a new persistedSigningPublicKey environment closure)
when the registry has no signing key for the peer, so the pin remains
effective across registry eviction and app restarts. BLEService wires
it to SecureIdentityStateManager.getCryptoIdentitiesByPeerIDPrefix,
the same synchronous identity-manager read already used on the packet
path by signedSenderDisplayName.
- SecureIdentityStateManager.upsertCryptographicIdentity refuses to
replace a persisted signing key with a different one (security-logged,
nickname update included in the refusal), mirroring the registry
policy. First-writer-wins persistence also removes any race where a
concurrent announce could poison the stored identity.
- CryptographicIdentity entries (incl. the signing-key pin) are now part
of the encrypted IdentityCache, so they actually persist across app
restarts; previously they were in-memory only. Old caches without the
new field still decode (decodeIfPresent), and clearAllIdentityData /
panic wipe clears the pins as before.
Legitimate re-keying: presenting a different signing key for the same
noise key is exactly the attack being blocked, so refusal is correct
and permanent until the peer adopts a new noise identity (new peerID)
or the user explicitly clears identity data. Repeated rejected announces
follow the existing unverified-announce path (log + ignore); no retry
loops or crashes.
Tests: handler-level persisted-pin mismatch/match/precedence cases, an
end-to-end restart+eviction test with real Ed25519 keys and a real
SecureIdentityStateManager showing the attacker replay is rejected and
the persisted identity is untouched while the victim re-announce is
accepted, and identity-manager tests for the pinned-key refusal and the
keychain round-trip of the pin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Refactor BLE transport event handling
* Make image output paths unique
* Keep queued Nostr read receipts alive
* Refine BLE ingress fanout
* Rediscover BLE service after invalidation
* Extract BLE notification retry buffer
* Extract BLE inbound write buffer
* Extract BLE fragment assembly buffer
* Tidy secure log handling from device run
* Allow self-authored RSR ingress replies
* Harden read receipt queue test timing
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
* Expand coverage for relay, identity, and location flows
* Fix macOS SwiftPM CI failures
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>