Compare commits

..
Author SHA1 Message Date
jackandClaude Fable 5 14ab04d2bb Correct doc comment on signing-key pin recovery
The comment on upsertCryptographicIdentity claimed a legitimately
re-keyed peer could recover via "explicit user re-verification", but no
such path exists: setVerified does not reset the signing-key pin. State
the actual recovery options — a new noise identity (new peerID) or
clearAllIdentityData.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:52:52 +02:00
jackandClaude Fable 5 c37e376568 Make identity-cache persistence synchronous to fix CI teardown hang
Root cause of the CI teardown wedge: SecureIdentityStateManager persisted
via fire-and-forget `queue.async(flags: .barrier)` in all ~10 mutating
methods (each doing encrypt + keychain write). This branch moved
`cryptographicIdentities` into the persisted IdentityCache, so the
announce/identity paths and their tests now schedule far more of these
async barrier saves than main ever did. Under `--enable-code-coverage`
(CI only) on the runner's constrained 2-3 cores, that backlog of
INSTRUMENTED fire-and-forget barrier work is still draining when LLVM
writes `.profraw` from its `atexit` handler; the dump deadlocks against
the in-flight instrumented threads -> all tests dispatch, ~5-min wedge,
Killed:9. main and the other PRs pass because they don't add this save
volume; it doesn't repro on an 18-core dev box because the backlog drains
before exit.

Fix (correct-by-construction, no CI-timing repro needed): convert every
mutating method's `queue.async(flags: .barrier)` to
`queue.sync(flags: .barrier)`. When a mutating API returns, the encrypt +
keychain write is already complete and NOTHING is scheduled on the queue,
so teardown/atexit has zero outstanding dispatch to wait on.

Re-entrancy audit (sync barriers deadlock if entered on-queue):
- No mutating method calls another mutating method (or forceSave) from
  inside a `queue` block — verified by grep; `saveIdentityCache` is a
  private inline helper, not a dispatch, so no nested self-hop exists.
- deinit remains queue-free (direct persist of in-hand state), so no
  mutating method is reachable from deinit.
- forceSave already uses queue.sync(.barrier) and is never called from
  deinit.
No `_onQueue` helper was needed.

Hot-path: upsertCryptographicIdentity runs on the BLE announce path (off
main, on the message/BLE queue) — a synchronous sub-millisecond keychain
write there is fine (announces are throttle/verified-gated). The other
mutating APIs (setFavorite/setBlocked/setVerified/updateSocialIdentity/
setNostrBlocked) are user-initiated one-shot UI/command actions; a fast
synchronous keychain write is acceptable and not in any hot loop.

Verified: no `queue.async` remains in code (comments only);
`time swift test --parallel --enable-code-coverage --skip
PerformanceBaselineTests` green and exits ~3s after the last test across
repeated runs, including under LIBDISPATCH_COOPERATIVE_POOL_STRICT=1 with
2 workers (~8s, no wedge); ThreadSanitizer clean on the identity +
announce suites; canonical `swift build && swift test --parallel` green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 01:26:57 +02:00
jackandClaude Fable 5 51186c3be6 Remove identity concurrency stress tests that wedged CI at teardown
The two stress tests I added (test_concurrentUpsertsAndForceSaveDoNotRaceOrHang
and test_manyManagersDeinitDoNotWedgeTeardown) spawned units of work that the
test could not deterministically join before returning:

- test_manyManagersDeinitDoNotWedgeTeardown created 200 managers that each
  scheduled a fire-and-forget queue.async(.barrier) save on the manager's own
  private queue; the test has no handle to await that per-manager barrier work,
  so a large backlog of it could still be executing after the test returned.
- test_concurrentUpsertsAndForceSaveDoNotRaceOrHang spawned 32
  DispatchQueue.global().async workers, each also scheduling per-manager
  barrier saves; the DispatchGroup only joined the worker loops, not the
  manager's internal barrier work.

Under --enable-code-coverage (CI only), LLVM writes .profraw from an atexit
handler; if instrumented worker threads are still live during that dump the
process deadlocks at exit — matching the CI signature exactly: all 145 tests
start, then a ~5-minute wedge, then Killed:9. It reproduced only on the
constrained CI runner, not locally (18 cores drained the backlog before exit),
which is why earlier local runs looked clean.

The production fix is already verified: ThreadSanitizer is clean on the
identity + announce-handler suites (no data race, no re-entrant deadlock), and
the deterministic unit tests cover the signing-key pin refusal, persistence
across re-init, and the persisted-pin fallback. A stress test that
destabilizes CI is worse than no stress test, so both are removed along with
the now-unused LockedKeychain double.

Verified: `time swift test --parallel --enable-code-coverage
--skip PerformanceBaselineTests` is green 6x and the process exits ~2.9s after
the last test (tests run in ~1.5s); no teardown wedge under coverage even with
LIBDISPATCH_COOPERATIVE_POOL_STRICT=1 and a single worker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 01:10:47 +02:00
jackandClaude Fable 5 8df3871096 Remove stray coverage artifact and ignore *.profraw
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:54:13 +02:00
jackandClaude Fable 5 a8b741d605 Stop identity-manager deinit from dispatching onto its own queue
The previous fix routed forceSave() through queue.async(flags: .barrier),
and deinit called forceSave(). Dispatching onto the private concurrent
queue from deinit is a teardown hazard: the async block resurrects self
and enqueues barrier work that may not drain before process exit, so at
teardown swift-testing/libdispatch waits on that outstanding work and the
process never exits — the CI "Run Swift Tests (app)" job reached
[144/144], then wedged for ~5 minutes and was Killed:9. This surfaced now
because moving cryptographicIdentities into the persisted cache made far
more test-created managers persist on deinit.

Root cause confirmed locally: under LIBDISPATCH_COOPERATIVE_POOL_STRICT=1
(single-worker pool, mimicking a constrained CI runner) the old code
intermittently stalled the whole suite ~15s from deinit-scheduled barrier
work starving the pool; the fix is stable across 14 full coverage+parallel
runs with the process exiting ~3s after the last test.

Fix:
- deinit no longer touches `queue`. Persistence is already durable (every
  mutating API persists inline within its own barrier), so deinit only
  does a queue-free best-effort flush if `pendingSave` is still set — a
  direct read of in-hand state, safe because a deallocating object has no
  other live references mutating `cache`.
- forceSave() (lifecycle/app-termination only, never deinit) now uses a
  synchronous queue.sync(flags: .barrier): race-free `cache` read on the
  barrier context and nothing left scheduled at teardown. No re-entrant
  deadlock risk since it is never called from deinit.

Test: test_manyManagersDeinitDoNotWedgeTeardown churns 200 managers that
mutate then deinit and asserts the workload completes promptly; combined
with the existing concurrent race guard (still TSan-clean) this covers the
deinit path. Verified: swift test --parallel --enable-code-coverage green
14x with prompt process exit, and TSan-clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:54:02 +02:00
jackandClaude Fable 5 a55a16adcd Fix identity-cache save race and deinit deadlock in forceSave
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>
2026-07-02 00:25:14 +02:00
jackandClaude Fable 5 dbe11641ad Extend signing-key pin to persisted identity so it survives restart/eviction
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>
2026-07-01 23:55:40 +02:00
jackandGitHub ccf17326d2 Merge branch 'main' into fix/announce-signing-key-binding 2026-07-01 23:52:35 +02:00
jackandClaude Fable 5 385d32063f Pin announce signing keys to stop mesh identity spoofing
BLE announce trust verified the packet signature against the Ed25519
signing key carried inside the same announce, and the trust policy only
rejected on noise-key mismatch. Since peerIDs derive from the broadcast
(public) noise key, an on-mesh attacker could replay a victim's
peerID+noiseKey with their own signing key, nickname, and a valid
self-signature; BLEPeerRegistry.upsertVerifiedAnnounce then overwrote
the victim's entry unconditionally. That enabled mesh nickname spoofing
and, via the persisted identity, forged attribution of signed
public/broadcast messages.

Fix: TOFU-pin the signing key per peer (noise-key-derived peerID).

- BLEAnnounceTrustPolicy now rejects announces whose signing key
  differs from the one already recorded for the peer
  (.signingKeyMismatch) and logs a security event.
- BLEPeerRegistry.upsertVerifiedAnnounce refuses to replace a pinned
  signing key (returns nil), closing the race where the pre-barrier
  trust check reads the registry outside the collections barrier. It
  also never drops a pinned key when an announce omits one.
- BLEAnnounceHandler skips registry upsert, topology updates, and
  identity persistence for rejected announces.

No wire-format change: the packet signature already covers senderID,
timestamp, and the full announce payload (noise key, signing key,
nickname), so mixed-version meshes are unaffected. First contact for an
unknown peer behaves exactly as before (trust on first use); the pin is
scoped to the registry entry lifetime, so a legitimately re-keyed
identity recovers after normal peer eviction.

Tests: trust-policy signing-key mismatch/match cases, registry pinning
(attacker upsert refused, legitimate re-announce accepted, omitted key
keeps pin), handler-level pinned-key rejection, and an end-to-end test
with real Ed25519 keys showing a fully self-consistent attacker
announce cannot displace the victim's pinned identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:31:09 +02:00
21 changed files with 1074 additions and 729 deletions
+1
View File
@@ -80,3 +80,4 @@ build.log
# Local configs
Local.xcconfig
*.profraw
+28 -6
View File
@@ -141,22 +141,44 @@ enum TrustLevel: String, Codable {
struct IdentityCache: Codable {
// Fingerprint -> Social mapping
var socialIdentities: [String: SocialIdentity] = [:]
// Nickname -> [Fingerprints] reverse index
// Multiple fingerprints can claim same nickname
var nicknameIndex: [String: Set<String>] = [:]
// Verified fingerprints (cryptographic proof)
var verifiedFingerprints: Set<String> = []
// Last interaction timestamps (privacy: optional)
var lastInteractions: [String: Date] = [:]
var lastInteractions: [String: Date] = [:]
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
var blockedNostrPubkeys: Set<String> = []
// Fingerprint -> Cryptographic identity (noise + pinned signing key).
// Persisting the signing-key pin is security-critical: it must survive
// app restarts so an attacker cannot replay a known peer's
// noiseKey/peerID with their own signing key and be treated as first
// contact (TOFU downgrade).
var cryptographicIdentities: [String: CryptographicIdentity] = [:]
// Schema version for future migrations
var version: Int = 1
init() {}
// Custom decoding so caches written by older builds (without
// `cryptographicIdentities`) still load instead of being discarded.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
socialIdentities = try container.decodeIfPresent([String: SocialIdentity].self, forKey: .socialIdentities) ?? [:]
nicknameIndex = try container.decodeIfPresent([String: Set<String>].self, forKey: .nicknameIndex) ?? [:]
verifiedFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .verifiedFingerprints) ?? []
lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:]
blockedNostrPubkeys = try container.decodeIfPresent(Set<String>.self, forKey: .blockedNostrPubkeys) ?? []
cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:]
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
}
}
//
+104 -43
View File
@@ -145,18 +145,29 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// In-memory state
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
// Cryptographic identities (including pinned signing keys) live inside
// `cache` so they persist across app restarts; see IdentityCache.
private var cache: IdentityCache = IdentityCache()
// Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
// than a retained DispatchSourceTimer: a lingering, never-cancelled timer
// keeps the dispatch machinery alive and prevents the unit-test process from
// exiting. (The original code used Timer.scheduledTimer on a GCD queue with
// no run loop, so saves never actually fired.)
//
// Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt
// + keychain write inside `queue.sync(flags: .barrier)`, so when the call
// returns the write is already complete and NOTHING is left scheduled on
// the queue. This is deliberate a retained DispatchSourceTimer (the
// original design) kept the dispatch machinery alive and prevented the
// unit-test process from exiting, and fire-and-forget `queue.async(.barrier)`
// (a later design) left a backlog of instrumented barrier saves still
// draining when LLVM's `--enable-code-coverage` `atexit` handler dumped
// `.profraw`, deadlocking the process at teardown on the constrained CI
// runner. Synchronous persistence has zero outstanding dispatch at exit, so
// neither failure mode is possible. `pendingSave` is now effectively always
// false after any mutation (saveIdentityCache persists inline and clears
// it); it remains only as a belt-and-suspenders flag read by `forceSave`
// and `deinit`.
private var pendingSave = false
// Encryption key
@@ -216,7 +227,22 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
deinit {
forceSave()
// Do NOT dispatch onto `queue` here. `deinit` can run on any thread
// (including one draining `queue`), and the object is being
// deallocated: a `queue.sync` risks a re-entrant same-queue wait
// (deadlock) and a `queue.async` schedules work that resurrects `self`
// and may not drain before process exit.
//
// A flush here is redundant anyway: every mutating API already
// persists inline within its own barrier, so the keychain is already
// up to date. As a queue-free best-effort belt-and-suspenders, only
// flush if something is still pending. This is a direct read of
// in-hand state safe because a deallocating object has no other
// live references, so nothing can be mutating `cache` concurrently.
if pendingSave {
pendingSave = false
persist(snapshot: cache)
}
}
// MARK: - Secure Loading/Saving
@@ -241,21 +267,27 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
}
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
/// and persists it on the same serialized context no timer, nothing left
/// scheduled to keep the process alive.
/// Persists the cache. Always invoked on `queue` under a barrier (its
/// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read
/// while serialized. The encode + keychain write are done here (already on
/// the exclusive barrier context), synchronously, so no separate hop is
/// scheduled and nothing is left to keep the process alive.
private func saveIdentityCache() {
pendingSave = true
performSave()
// On the barrier context already: snapshot is trivially consistent.
persist(snapshot: cache)
pendingSave = false
}
/// Writes the cache to the keychain. Must run on `queue` with exclusive
/// (barrier) access.
private func performSave() {
guard pendingSave else { return }
pendingSave = false
/// Encodes, seals, and writes a *snapshot* of the cache to the keychain.
///
/// Takes the cache by value so callers can capture a consistent snapshot
/// under `queue` and then encode without holding it. Reading `cache`
/// concurrently with a barrier writer would be a data race on the
/// dictionary storage, which because `JSONEncoder` walks that storage
/// can spin forever (observed as a CI test-suite hang), so the snapshot
/// must be taken on `queue`, never off it.
private func persist(snapshot: IdentityCache) {
// Never persist under an ephemeral key it would overwrite the real
// cache with data the next launch cannot decrypt.
guard !encryptionKeyIsEphemeral else {
@@ -264,7 +296,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
do {
let data = try JSONEncoder().encode(cache)
let data = try JSONEncoder().encode(snapshot)
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
if saved {
@@ -275,14 +307,26 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
}
// Force immediate save (for app termination / lifecycle events). Mutations
// already persist synchronously via saveIdentityCache, so this is normally a
// no-op (performSave early-returns when nothing is pending). Runs directly on
// the caller's thread deliberately NOT a `queue.sync(barrier)`, which is
// reachable from `deinit` and from async tests on the swift-concurrency
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
// Force a flush (for app-termination / lifecycle events NOT from
// `deinit`, which persists inline; see the deinit note). Every mutating
// API already persists inline inside its own barrier via
// `saveIdentityCache`, so by the time this is called the keychain is
// already up to date and this is normally a no-op; it exists as a
// belt-and-suspenders flush of any `pendingSave` left set.
//
// Runs synchronously inside a `queue.sync(flags: .barrier)`: the barrier
// makes the `cache` read race-free (a plain off-queue read races in-flight
// barrier writers JSONEncoder walking a concurrently-mutated dictionary
// can spin forever, which surfaced as a CI hang), and being synchronous it
// leaves nothing scheduled to keep the process alive at teardown. Safe
// against re-entrant deadlock because this is never invoked from `deinit`
// (the only path that can run *on* `queue`).
func forceSave() {
performSave()
queue.sync(flags: .barrier) {
guard pendingSave else { return }
pendingSave = false
persist(snapshot: cache)
}
}
// MARK: - Social Identity Management
@@ -296,15 +340,33 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// MARK: - Cryptographic Identities
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
///
/// TOFU signing-key pinning: once a signing key has been persisted for a
/// fingerprint, an update carrying a *different* signing key is refused in
/// full (including the claimed-nickname update) and security-logged. This
/// mirrors `BLEPeerRegistry.upsertVerifiedAnnounce` without it, an
/// attacker replaying a victim's noiseKey/peerID with their own signing
/// key could overwrite the victim's persisted identity while the victim is
/// offline or after an app restart. The refusal is permanent: there is
/// currently no targeted in-app way to reset the pin (`setVerified` does
/// not touch it). Recovering from a legitimate signing re-key requires the
/// peer to establish a new noise identity (new peerID) or the local user
/// to wipe all identity data (`clearAllIdentityData`, e.g. panic wipe).
/// - Parameters:
/// - fingerprint: SHA-256 hex of the Noise static public key
/// - noisePublicKey: Noise static public key data
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
let now = Date()
if var existing = self.cryptographicIdentities[fingerprint] {
if var existing = self.cache.cryptographicIdentities[fingerprint] {
if let pinnedSigningKey = existing.signingPublicKey,
let announcedSigningKey = signingPublicKey,
pinnedSigningKey != announcedSigningKey {
SecureLogger.warning("🚨 Refusing to replace pinned signing key for \(fingerprint.prefix(8))… (possible impersonation attempt)", category: .security)
return
}
// Update keys if changed
if existing.publicKey != noisePublicKey {
existing = CryptographicIdentity(
@@ -314,7 +376,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = existing
self.cache.cryptographicIdentities[fingerprint] = existing
} else {
// Update signing key and lastHandshake
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
@@ -325,7 +387,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = updated
self.cache.cryptographicIdentities[fingerprint] = updated
}
// Persist updated state (already assigned in branches above)
} else {
@@ -337,7 +399,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
firstSeen: now,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = entry
self.cache.cryptographicIdentities[fingerprint] = entry
}
// Optionally persist claimed nickname into social identity
@@ -369,12 +431,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
queue.sync {
// Defensive: ensure hex and correct length
guard peerID.isShort else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
}
}
func updateSocialIdentity(_ identity: SocialIdentity) {
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
self.cache.socialIdentities[identity.fingerprint] = identity
@@ -410,7 +472,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] {
identity.isFavorite = isFavorite
self.cache.socialIdentities[fingerprint] = identity
@@ -448,7 +510,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] {
identity.isBlocked = isBlocked
if isBlocked {
@@ -482,7 +544,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
let key = pubkeyHexLowercased.lowercased()
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
if isBlocked {
self.cache.blockedNostrPubkeys.insert(key)
} else {
@@ -499,7 +561,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// MARK: - Ephemeral Session Management
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
self.ephemeralSessions[peerID] = EphemeralIdentity(
peerID: peerID,
sessionStart: Date(),
@@ -509,7 +571,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
self.ephemeralSessions[peerID]?.handshakeState = state
// If handshake completed, update last interaction
@@ -525,11 +587,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func clearAllIdentityData() {
SecureLogger.warning("Clearing all identity data", category: .security)
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
self.cache = IdentityCache()
self.ephemeralSessions.removeAll()
self.cryptographicIdentities.removeAll()
// Delete from keychain
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
@@ -537,7 +598,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
func removeEphemeralSession(peerID: PeerID) {
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peerID)
}
}
@@ -547,7 +608,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setVerified(fingerprint: String, verified: Bool) {
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
if verified {
self.cache.verifiedFingerprints.insert(fingerprint)
} else {
+41 -240
View File
@@ -48,14 +48,7 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
let base: URLSession
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
let task = base.webSocketTask(with: url)
// Byte bound per inbound frame; without it the per-relay buffer cap
// (nostrInboundPerRelayBufferCap) bounds FRAMES but not BYTES, and a
// hostile relay could pile up cap × 1 MiB (URLSession default) per
// connection. See TransportConfig.nostrInboundMaxFrameBytes for the
// sizing rationale. Oversized frames fail the receive with an error.
task.maximumMessageSize = TransportConfig.nostrInboundMaxFrameBytes
return URLSessionWebSocketTaskAdapter(base: task)
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
}
}
@@ -113,10 +106,7 @@ private extension NostrRelayManagerDependencies {
@MainActor
final class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager()
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info.
// Entries are removed only on OK acks (or panic wipe); relays that never
// ack leave entries behind for the process lifetime. Observability-only
// state, bounded in practice by outbound DM volume.
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
private(set) static var pendingGiftWrapIDs = Set<String>()
static func registerPendingGiftWrap(id: String) {
pendingGiftWrapIDs.insert(id)
@@ -222,33 +212,7 @@ final class NostrRelayManager: ObservableObject {
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
private var connectionGeneration: Int = 0
// Per-relay off-main inbound pipeline: raw socket frames are parsed and
// Schnorr-verified in arrival order OFF the main actor (this is the single
// signature verification for the whole inbound path downstream handlers
// receive only verified events), then hop back to the main actor for dedup
// recording and handler dispatch.
//
// Each relay connection owns its OWN AsyncStream + consumer task, so N
// relays verify in parallel while every relay's frames stay in arrival
// order (a single subscription's events for a relay all arrive on that
// relay's socket, so per-relay ordering preserves per-subscription
// ordering). A burst of EVENT frames from one busy/malicious relay only
// blocks that relay's own verification backlog DMs, OKs, EOSEs, and
// events from every other relay keep flowing on their own pipelines.
//
// Each stream is bounded (`.bufferingNewest`) so a relay flooding faster
// than its verification drains sheds its own oldest frames instead of
// growing memory without bound; it can never starve other relays.
//
// Continuations live in a lock-guarded, `Sendable` router (see
// `InboundFrameRouter` at file scope) so the raw socket receive callback
// (which is NOT main-actor isolated) can route a frame to the right relay
// stream without a per-frame main hop, while the main actor owns pipeline
// creation/teardown. The expensive work (Schnorr verify) is what runs
// off-main; the yield stays cheap.
private let inboundRouter = InboundFrameRouter()
init() {
self.dependencies = .live()
hasMutualFavorites = dependencies.hasMutualFavorites()
@@ -302,70 +266,7 @@ final class NostrRelayManager: ObservableObject {
}
.store(in: &cancellables)
}
deinit {
inboundRouter.finishAll()
}
/// Ensure a serial off-main consumer pipeline exists for a relay. Called on
/// the main actor when a socket is (re)armed for receiving. Idempotent.
///
/// Ordering within the relay is deliberate and security/performance-critical:
/// 1. `precheckInboundEvent` (main hop): per-relay stats plus a cheap
/// duplicate LOOKUP duplicate fan-in from multiple relays dominates
/// real traffic and must never pay for Schnorr verification.
/// 2. `isValidSignature()` runs here, off the main actor the ONLY
/// signature verification on the inbound path (JSON re-serialization +
/// SHA-256 + secp256k1 Schnorr per event).
/// 3. `deliverVerifiedInboundEvent` (main hop): authoritative
/// check-and-RECORD plus handler dispatch. Recording only after
/// verification means a forged-signature copy can never poison the
/// dedup cache and suppress the genuine event.
private func ensureRelayInboundPipeline(for relayUrl: String) {
let started = inboundRouter.startPipeline(for: relayUrl) { [weak self] stream in
Task.detached(priority: .userInitiated) {
for await frame in stream {
guard let parsed = ParsedInbound(frame.message) else { continue }
guard let self else { return }
switch parsed {
case .event(let subId, let event):
guard await self.precheckInboundEvent(
subscriptionID: subId,
eventID: event.id,
relayUrl: relayUrl
) else {
continue
}
guard event.isValidSignature() else {
SecureLogger.warning(
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
category: .session
)
continue
}
await self.deliverVerifiedInboundEvent(subscriptionID: subId, event: event, from: relayUrl)
case .eose, .ok, .notice:
await self.handleParsedMessage(parsed, from: relayUrl)
}
}
}
}
if started {
SecureLogger.debug("🧵 Started inbound verify pipeline for \(relayUrl)", category: .session)
}
}
/// Tear down a relay's inbound pipeline (socket gone or state wiped). The
/// consumer drains any already-buffered frames before finishing, so
/// in-flight verified events are still delivered.
private func teardownRelayInboundPipeline(for relayUrl: String) {
inboundRouter.finishPipeline(for: relayUrl)
}
private func teardownAllRelayInboundPipelines() {
inboundRouter.finishAll()
}
/// Connect to all configured relays
func connect() {
// Global network policy gate
@@ -380,8 +281,6 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil)
}
connections.removeAll()
// Sockets are gone; drop every relay's inbound verify pipeline.
teardownAllRelayInboundPipelines()
markRelaySocketsClosed(resetState: false)
// Sockets are gone, so per-relay subscription state is cleared but
// durable intent (subscriptionRequestState, messageHandlers, parked
@@ -411,7 +310,6 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil)
}
connections.removeAll()
teardownAllRelayInboundPipelines()
markRelaySocketsClosed(resetState: true)
subscriptions.removeAll()
pendingSubscriptions.removeAll()
@@ -662,7 +560,6 @@ final class NostrRelayManager: ObservableObject {
connection.cancel(with: .goingAway, reason: nil)
}
connections.removeValue(forKey: url)
teardownRelayInboundPipeline(for: url)
subscriptions.removeValue(forKey: url)
pendingSubscriptions.removeValue(forKey: url)
}
@@ -1002,11 +899,7 @@ final class NostrRelayManager: ObservableObject {
connections[urlString] = task
task.resume()
// Bring up this relay's own serial verify pipeline before arming the
// socket, so inbound frames have somewhere to land.
ensureRelayInboundPipeline(for: urlString)
// Start receiving messages
receiveMessage(from: task, relayUrl: urlString)
@@ -1065,13 +958,14 @@ final class NostrRelayManager: ObservableObject {
switch result {
case .success(let message):
// Hand the raw frame to this relay's serial inbound pipeline:
// parsing and signature verification run off-main, in arrival
// order, independently of every other relay's pipeline. Routing
// through the lock-guarded router keeps this off the main actor
// (no per-frame main hop).
self.inboundRouter.yield(InboundFrame(message: message), to: relayUrl)
// Parse off-main to reduce UI jank, then hop back for state updates
Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return }
await MainActor.run {
self.handleParsedMessage(parsed, from: relayUrl)
}
}
// Continue receiving
Task { @MainActor in
self.receiveMessage(from: task, relayUrl: relayUrl)
@@ -1089,55 +983,35 @@ final class NostrRelayManager: ObservableObject {
// Note: declared at file scope below to avoid MainActor isolation inside this class
// and keep parsing off the main actor.
/// First main-actor hop for an inbound EVENT: per-relay stats plus a cheap
/// duplicate LOOKUP (no recording) so duplicate fan-in from multiple
/// relays never pays for Schnorr verification. Recording happens only
/// after the signature verifies (`deliverVerifiedInboundEvent`), so a
/// forged-signature copy can never poison the dedup cache and suppress
/// the genuine event.
private func precheckInboundEvent(subscriptionID: String, eventID: String, relayUrl: String) -> Bool {
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
relays[index].messagesReceived += 1
}
guard !eventID.isEmpty else { return true }
let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID)
if recentInboundEventKeys.contains(key) {
recordDuplicateInboundEventDrop(subscriptionID: subscriptionID)
return false
}
return true
}
/// Second main-actor hop, after off-main signature verification:
/// authoritative check-and-record (the serial pipeline means the same
/// event is never in flight twice, but the record must stay atomic with
/// delivery) and handler dispatch.
private func deliverVerifiedInboundEvent(subscriptionID subId: String, event: NostrEvent, from relayUrl: String) {
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
return
}
if event.kind != 1059 {
// Per-event logging floods dev builds in busy geohashes; sample it.
inboundEventLogCount += 1
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
}
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
}
// Handle parsed non-EVENT messages on MainActor (state updates and handlers)
// Handle parsed message on MainActor (state updates and handlers)
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
switch parsed {
case .event:
// Events flow through the serial inbound pipeline (precheck
// off-main signature verification deliverVerifiedInboundEvent)
// and never reach this fallback.
assertionFailure("inbound EVENT bypassed the verified pipeline")
case .event(let subId, let event):
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
self.relays[index].messagesReceived += 1
}
guard event.isValidSignature() else {
SecureLogger.warning(
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
category: .session
)
return
}
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
return
}
if event.kind != 1059 {
// Per-event logging floods dev builds in busy geohashes; sample it.
inboundEventLogCount += 1
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
}
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
case .eose(let subId):
if var tracker = eoseTrackers[subId] {
tracker.pendingRelays.remove(relayUrl)
@@ -1232,7 +1106,6 @@ final class NostrRelayManager: ObservableObject {
private func handleDisconnection(relayUrl: String, error: Error) {
connections.removeValue(forKey: relayUrl)
teardownRelayInboundPipeline(for: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error)
settleEOSETrackers(droppingRelay: relayUrl)
@@ -1321,9 +1194,8 @@ final class NostrRelayManager: ObservableObject {
if let connection = connections[normalizedRelayUrl] {
connection.cancel(with: .goingAway, reason: nil)
connections.removeValue(forKey: normalizedRelayUrl)
teardownRelayInboundPipeline(for: normalizedRelayUrl)
}
// Attempt immediate reconnection
connectToRelay(normalizedRelayUrl)
}
@@ -1416,77 +1288,6 @@ final class NostrRelayManager: ObservableObject {
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
/// A single raw socket frame awaiting off-main parse + Schnorr verification.
private struct InboundFrame: Sendable {
let message: URLSessionWebSocketTask.Message
}
/// Lock-guarded registry of per-relay inbound streams.
///
/// The raw WebSocket receive callback is not main-actor isolated, so it needs a
/// `Sendable` path to route a frame to the correct relay's stream without a
/// per-frame hop onto the main actor. Pipeline lifecycle (start/finish) is
/// driven from the main actor; frame delivery (`yield`) can come from any
/// thread. All access is serialized by a single lock contention is negligible
/// because the guarded critical section is only a dictionary lookup + yield.
private final class InboundFrameRouter: @unchecked Sendable {
private let lock = NSLock()
private var continuations: [String: AsyncStream<InboundFrame>.Continuation] = [:]
private var tasks: [String: Task<Void, Never>] = [:]
/// Start a relay's stream + consumer if one does not already exist.
/// Returns true when a new pipeline was created. The bounded
/// `.bufferingNewest` policy makes a single relay shed its OWN oldest
/// frames under a flood, never other relays' frames. Buffered memory per
/// relay is bounded (not eliminated) at the frame cap times the per-frame
/// byte cap (`maximumMessageSize`) see TransportConfig.
func startPipeline(
for relayUrl: String,
makeConsumer: (AsyncStream<InboundFrame>) -> Task<Void, Never>
) -> Bool {
lock.lock()
defer { lock.unlock() }
if continuations[relayUrl] != nil { return false }
let (stream, continuation) = AsyncStream<InboundFrame>.makeStream(
bufferingPolicy: .bufferingNewest(TransportConfig.nostrInboundPerRelayBufferCap)
)
continuations[relayUrl] = continuation
tasks[relayUrl] = makeConsumer(stream)
return true
}
/// Route a frame to a relay's stream. No-op if the relay has no live
/// pipeline (socket already torn down) the frame is simply dropped, which
/// is safe for best-effort Nostr inbound.
func yield(_ frame: InboundFrame, to relayUrl: String) {
lock.lock()
let continuation = continuations[relayUrl]
lock.unlock()
continuation?.yield(frame)
}
/// Finish a relay's stream. The consumer drains any already-buffered frames
/// before exiting, so in-flight verified events are still delivered.
func finishPipeline(for relayUrl: String) {
lock.lock()
let continuation = continuations.removeValue(forKey: relayUrl)
tasks.removeValue(forKey: relayUrl)
lock.unlock()
continuation?.finish()
}
func finishAll() {
lock.lock()
let allContinuations = continuations
continuations.removeAll()
tasks.removeAll()
lock.unlock()
for continuation in allContinuations.values {
continuation.finish()
}
}
}
private enum ParsedInbound {
case event(subId: String, event: NostrEvent)
case ok(eventId: String, success: Bool, reason: String)
+41 -9
View File
@@ -14,8 +14,14 @@ struct BLEAnnounceHandlerEnvironment {
let messageTTL: UInt8
/// Current time source.
let now: () -> Date
/// Noise public key already recorded for the peer, if any (registry read).
let existingNoisePublicKey: (PeerID) -> Data?
/// Noise and signing public keys already recorded for the peer, if any
/// (single registry read so both come from one consistent snapshot).
let existingPeerKeys: (PeerID) -> (noisePublicKey: Data?, signingPublicKey: Data?)
/// Signing key from the persisted cryptographic identity for the peer, if
/// any. Registry pins do not survive app restarts or offline-peer
/// eviction; this fallback keeps the TOFU signing-key pin effective for
/// returning peers.
let persistedSigningPublicKey: (PeerID) -> Data?
/// Verifies the packet signature against the announced signing key.
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Direct link state for the peer (BLE-queue read).
@@ -23,13 +29,15 @@ struct BLEAnnounceHandlerEnvironment {
/// Runs the registry mutation phase under the collections barrier.
let withRegistryBarrier: (() -> Void) -> Void
/// Upserts the verified announce into the peer registry.
/// Returns `nil` when the registry refuses the announce because it carries
/// a signing key different from the one already pinned for this peer.
/// Must only be called from inside `withRegistryBarrier`.
let upsertVerifiedAnnounce: (
_ peerID: PeerID,
_ announcement: AnnouncementPacket,
_ isConnected: Bool,
_ now: Date
) -> BLEPeerAnnounceUpdate
) -> BLEPeerAnnounceUpdate?
/// Debounced reconnect-log decision.
/// Must only be called from inside `withRegistryBarrier`.
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
@@ -99,7 +107,16 @@ final class BLEAnnounceHandler {
// Suppress announce logs to reduce noise
// Precompute signature verification outside barrier to reduce contention
let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
var existingPeerKeys = env.existingPeerKeys(peerID)
if existingPeerKeys.signingPublicKey == nil {
// The registry entry (and its signing-key pin) is dropped on app
// restart and offline-peer eviction, but the persisted
// cryptographic identity survives both. Fall back to it so a
// returning peer is not treated as first contact otherwise an
// attacker could replay the peer's noiseKey/peerID with their own
// signing key and re-pin the identity (TOFU downgrade).
existingPeerKeys.signingPublicKey = env.persistedSigningPublicKey(peerID)
}
let hasSignature = packet.signature != nil
let signatureValid: Bool
if hasSignature {
@@ -113,13 +130,18 @@ final class BLEAnnounceHandler {
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: hasSignature,
signatureValid: signatureValid,
existingNoisePublicKey: existingNoisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey
existingNoisePublicKey: existingPeerKeys.noisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey,
existingSigningPublicKey: existingPeerKeys.signingPublicKey,
announcedSigningPublicKey: announcement.signingPublicKey
)
if case .reject(.keyMismatch) = trustDecision {
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
}
let verifiedAnnounce = trustDecision.isVerified
if case .reject(.signingKeyMismatch) = trustDecision {
SecureLogger.warning("🚨 Announce signing-key mismatch for \(peerID.id.prefix(8))… — refusing to replace pinned signing key (possible impersonation attempt)", category: .security)
}
var verifiedAnnounce = trustDecision.isVerified
var isNewPeer = false
var isReconnectedPeer = false
@@ -139,12 +161,22 @@ final class BLEAnnounceHandler {
return
}
let update = env.upsertVerifiedAnnounce(
// The registry re-checks the signing-key pin inside the barrier.
// The pre-barrier trust check reads the registry outside the
// barrier, so this closes the race where two announces for the
// same peer are evaluated concurrently.
guard let update = env.upsertVerifiedAnnounce(
peerID,
announcement,
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
now
)
) else {
SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security)
verifiedAnnounce = false
isNewPeer = false
isReconnectedPeer = false
return
}
isNewPeer = update.isNewPeer
isReconnectedPeer = update.wasDisconnected
@@ -56,6 +56,7 @@ enum BLEAnnounceTrustRejection: Equatable {
case missingSignature
case invalidSignature
case keyMismatch
case signingKeyMismatch
}
enum BLEAnnounceTrustDecision: Equatable {
@@ -72,12 +73,25 @@ enum BLEAnnounceTrustPolicy {
hasSignature: Bool,
signatureValid: Bool,
existingNoisePublicKey: Data?,
announcedNoisePublicKey: Data
announcedNoisePublicKey: Data,
existingSigningPublicKey: Data?,
announcedSigningPublicKey: Data
) -> BLEAnnounceTrustDecision {
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
return .reject(.keyMismatch)
}
// TOFU signing-key pinning. The packet signature only proves the
// announce is self-consistent it is verified against the Ed25519 key
// carried *inside the same announce*. Since peerIDs derive from the
// broadcast (public) noise key, an attacker can replay a victim's
// peerID+noiseKey with their own signing key and a valid
// self-signature. Once we have bound a signing key to this peer,
// refuse to silently replace it.
if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey {
return .reject(.signingKeyMismatch)
}
guard hasSignature else {
return .reject(.missingSignature)
}
+18 -2
View File
@@ -150,6 +150,14 @@ struct BLEPeerRegistry {
peers[peerID] = peer
}
/// Applies a verified announce to the registry.
///
/// TOFU signing-key pinning: once a signing key has been bound to this
/// peer entry, an announce carrying a *different* signing key is refused
/// (returns `nil`) and the existing record is left untouched. PeerIDs are
/// derived from the (public) noise key, so without pinning an attacker
/// could replay a victim's noiseKey/peerID with their own signing key and
/// silently take over the victim's mesh identity and nickname.
mutating func upsertVerifiedAnnounce(
peerID: PeerID,
nickname: String,
@@ -157,8 +165,15 @@ struct BLEPeerRegistry {
signingPublicKey: Data?,
isConnected: Bool,
now: Date
) -> BLEPeerAnnounceUpdate {
) -> BLEPeerAnnounceUpdate? {
let existing = peers[peerID]
if let pinnedSigningKey = existing?.signingPublicKey,
let announcedSigningKey = signingPublicKey,
pinnedSigningKey != announcedSigningKey {
return nil
}
let update = BLEPeerAnnounceUpdate(
isNewPeer: existing == nil,
wasDisconnected: existing?.isConnected == false,
@@ -170,7 +185,8 @@ struct BLEPeerRegistry {
nickname: nickname,
isConnected: isConnected,
noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
// Never drop an already-pinned signing key.
signingPublicKey: signingPublicKey ?? existing?.signingPublicKey,
isVerifiedNickname: true,
lastSeen: now
)
+19 -4
View File
@@ -3028,9 +3028,21 @@ extension BLEService {
},
messageTTL: messageTTL,
now: { Date() },
existingNoisePublicKey: { [weak self] peerID in
existingPeerKeys: { [weak self] peerID in
guard let self = self else { return (nil, nil) }
return self.collectionsQueue.sync {
let info = self.peerRegistry.info(for: peerID)
return (info?.noisePublicKey, info?.signingPublicKey)
}
},
persistedSigningPublicKey: { [weak self] peerID in
// Same synchronous identity-manager read pattern as
// signedSenderDisplayName(for:from:); the manager serializes
// access on its own internal queue.
guard let self = self else { return nil }
return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
return self.identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
.compactMap { $0.signingPublicKey }
.first
},
verifySignature: { [weak self] packet, signingPublicKey in
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
@@ -3043,14 +3055,17 @@ extension BLEService {
},
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
// Called from inside withRegistryBarrier; access registry directly.
self?.peerRegistry.upsertVerifiedAnnounce(
guard let self = self else {
return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
}
return self.peerRegistry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
)
},
shouldEmitReconnectLog: { [weak self] peerID, now in
// Called from inside withRegistryBarrier; access debouncer directly.
-20
View File
@@ -55,26 +55,6 @@ enum TransportConfig {
static let nostrDuplicateEventLogInterval: Int = 50
// Sample interval for per-event debug logs on the inbound hot path.
static let nostrInboundEventLogInterval: Int = 100
// Bounded per-relay inbound frame buffer. Each relay connection owns its
// own serial verify pipeline; if a relay floods faster than its Schnorr
// verification drains, the oldest buffered frames for THAT relay are
// dropped (bufferingNewest) so one relay cannot stall other relays.
// Nostr inbound is already best-effort (relays are redundant and events
// replay), so dropping a flooding relay's backlog is safe. Together with
// nostrInboundMaxFrameBytes this caps buffered inbound bytes at
// cap × maxFrameBytes (128 MiB) per hostile relay bounded, not zero.
static let nostrInboundPerRelayBufferCap: Int = 256
// Hard per-frame byte bound, applied as URLSessionWebSocketTask
// .maximumMessageSize (oversized frames fail the receive instead of
// buffering). BitChat's legitimate Nostr traffic is small: geohash chat /
// presence events (kind 20000/20001), kind-1 notes, and NIP-17
// gift-wrapped DMs carrying text payloads or receipts are all a few KiB,
// and most public relays reject events beyond ~64256 KiB anyway. 512 KiB
// leaves an order-of-magnitude margin over anything we produce or expect
// while halving the URLSession default (1 MiB), so a hostile relay's
// worst-case buffered pile-up per connection is
// nostrInboundPerRelayBufferCap × 512 KiB = 128 MiB instead of 256 MiB.
static let nostrInboundMaxFrameBytes: Int = 512 * 1024
// Conversation store diagnostics (field observability)
// Sample interval for the periodic store-audit "OK" heartbeat line
-5
View File
@@ -1177,11 +1177,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
// disconnect is not enough here.
NostrRelayManager.shared.resetForPanicWipe()
// Clearing relay handlers stops NEW events, but a detached gift-wrap
// decrypt spawned just before the wipe still holds a pre-wipe key and
// ciphertext; bump the pipeline's wipe generation so its result is
// dropped at the main-actor delivery hop instead of landing here.
nostrCoordinator.inbound.invalidateInFlightDecrypts()
nostrRelayManager = nil
// Clear Nostr identity associations
+1 -2
View File
@@ -103,8 +103,7 @@ final class GeoPresenceTracker {
else {
return
}
// The signature was already verified (exactly once, off the main
// actor) by NostrRelayManager before delivery.
guard event.isValidSignature() else { return }
guard shouldProcessGeoSamplingEvent(event.id) else { return }
let existingCount = context.geoParticipantCount(for: gh)
+89 -133
View File
@@ -75,38 +75,20 @@ extension ChatViewModel: NostrInboundPipelineContext {
}
}
/// The inbound Nostr hot path: verified relay events in, chat messages /
/// Noise payloads out. Pure transformation plus dedup no relay lifecycle.
/// The inbound Nostr hot path: raw relay events in, chat messages / Noise
/// payloads out. Pure transformation plus dedup no relay lifecycle.
///
/// Every event arriving here already had its Schnorr signature verified
/// exactly once, off the main actor, by `NostrRelayManager`'s serial inbound
/// pipeline (which records events into its own dedup cache only AFTER
/// verification, so forged copies can't suppress genuine events). This
/// pipeline therefore never re-verifies; it keeps its own event-ID dedup
/// (cheap main-actor lookups) and moves NIP-17 gift-wrap decryption two
/// ECDH+ChaCha layers off the main actor with an atomic main-actor
/// check-and-record.
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
/// dominate real relay traffic; events are recorded only AFTER verification so
/// a forged-signature copy can never poison the dedup set; gift-wrap
/// verification for the account mailbox runs off-main with an atomic
/// main-actor check-and-record.
final class NostrInboundPipeline {
private weak var context: (any NostrInboundPipelineContext)?
private let presence: GeoPresenceTracker
private var geoEventLogCount = 0
/// Monotonic panic-wipe generation for this pipeline. A panic wipe clears
/// relay handlers so no NEW events flow, but a detached decrypt task
/// spawned just BEFORE the wipe which strongly captures a pre-wipe Nostr
/// private key and ciphertext survives it. Spawn sites capture this
/// value; the task compares it at its main-actor hops and drops its result
/// (no delivery; the captured identity and plaintext die with the task)
/// if `invalidateInFlightDecrypts()` bumped it in between.
@MainActor private(set) var wipeGeneration: UInt64 = 0
/// Called from `ChatViewModel.panicClearAllData()` so plaintext decrypted
/// with pre-wipe keys can never land in post-wipe state.
@MainActor
func invalidateInFlightDecrypts() {
wipeGeneration &+= 1
}
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
self.context = context
self.presence = presence
@@ -115,15 +97,17 @@ final class NostrInboundPipeline {
@MainActor
func subscribeNostrEvent(_ event: NostrEvent) {
guard let context else { return }
// Cheap rejects (kind, dedup lookup) duplicates dominate real
// traffic. The signature was already verified (exactly once, off the
// main actor) by NostrRelayManager before delivery.
// Cheap rejects (kind, dedup lookup) before Schnorr verification
// duplicates dominate real traffic and must not pay for crypto.
// Only verified events are recorded, so a forged-signature copy can
// never poison the dedup set and suppress the genuine event.
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!context.hasProcessedNostrEvent(event.id)
else {
return
}
guard event.isValidSignature() else { return }
context.recordProcessedNostrEvent(event.id)
@@ -192,14 +176,15 @@ final class NostrInboundPipeline {
@MainActor
func handleNostrEvent(_ event: NostrEvent) {
guard let context else { return }
// Cheap rejects (kind, dedup lookup) the signature was already
// verified (exactly once, off the main actor) by NostrRelayManager.
// Cheap rejects (kind, dedup lookup) before Schnorr verification
// duplicates dominate real traffic and must not pay for crypto.
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
else {
return
}
if context.hasProcessedNostrEvent(event.id) { return }
guard event.isValidSignature() else { return }
context.recordProcessedNostrEvent(event.id)
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
@@ -279,126 +264,104 @@ final class NostrInboundPipeline {
@MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard let context else { return }
// Cheap dedup pre-check only; processGeohashGiftWrap does the
// authoritative main-actor check-and-record before the off-main
// NIP-17 unwrap. The outer signature was already verified (exactly
// once, off the main actor) by NostrRelayManager.
// Dedup lookup before Schnorr verification; record only after it passes.
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
guard giftWrap.isValidSignature() else { return }
context.recordProcessedNostrEvent(giftWrap.id)
// Capture the wipe generation at spawn, alongside the per-geohash
// identity (private key) the detached task strongly captures. A panic
// wipe between spawn and delivery bumps the generation, and the task
// drops its result instead of delivering plaintext post-wipe.
let wipeGeneration = self.wipeGeneration
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: false, wipeGeneration: wipeGeneration)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: id
),
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let noisePayload = NoisePayload.decode(packet.payload)
else {
return
}
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
let convKey = PeerID(nostr_: senderPubkey)
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch noisePayload.type {
case .privateMessage:
context.handlePrivateMessage(
noisePayload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
}
}
@MainActor
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard let context else { return }
// Cheap dedup pre-check only; see subscribeGiftWrap.
// Dedup lookup before Schnorr verification; record only after it passes.
if context.hasProcessedNostrEvent(giftWrap.id) {
return
}
// Spawn-time wipe-generation capture; see subscribeGiftWrap.
let wipeGeneration = self.wipeGeneration
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: true, wipeGeneration: wipeGeneration)
}
}
/// Geohash-DM gift wrap ingest. The NIP-17 unwrap (two ECDH+ChaCha
/// layers) runs off the main actor; results hop back for state updates.
/// `verbose` keeps `handleGiftWrap`'s decrypt logging without adding it
/// to the sampling path.
///
/// `wipeGeneration` is this pipeline's generation captured at spawn (the
/// moment the pre-wipe `id` was captured); a mismatch at either main-actor
/// hop means a panic wipe happened in between, so the task bails without
/// decrypting (first hop) or without delivering the plaintext (second
/// hop) the captured identity and any decrypted material are simply
/// dropped with the task.
private func processGeohashGiftWrap(
_ giftWrap: NostrEvent,
id: NostrIdentity,
verbose: Bool,
wipeGeneration: UInt64
) async {
guard let context else { return }
// Authoritative check-and-record, atomic on the main actor so two
// concurrent detached tasks can't both process the same event.
let alreadyProcessed: Bool = await MainActor.run {
guard self.wipeGeneration == wipeGeneration else { return true }
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
context.recordProcessedNostrEvent(giftWrap.id)
return false
}
if alreadyProcessed { return }
guard giftWrap.isValidSignature() else { return }
context.recordProcessedNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: id
) else {
if verbose {
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
}
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
return
}
if verbose {
SecureLogger.debug(
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
category: .session
)
SecureLogger.debug(
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
category: .session
)
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return
}
await MainActor.run {
// A panic wipe during the off-main decrypt must not let the
// pre-wipe plaintext reach post-wipe state; drop it here, atomic
// with the wipe on the main actor.
guard self.wipeGeneration == wipeGeneration else { return }
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return
}
let convKey = PeerID(nostr_: senderPubkey)
context.registerNostrKeyMapping(senderPubkey, for: convKey)
let convKey = PeerID(nostr_: senderPubkey)
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch payload.type {
case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
context.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
}
switch payload.type {
case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
context.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
}
}
@MainActor
func handleNostrMessage(_ giftWrap: NostrEvent) {
guard let context else { return }
// Cheap dedup pre-check only; processNostrMessage does the
// authoritative check-and-record before the off-main NIP-17 unwrap.
// The outer signature was already verified (exactly once, off the
// main actor) by NostrRelayManager, and only verified events are
// recorded, so a forged-signature copy can never poison the dedup
// set and suppress the genuine event.
// Cheap dedup pre-check only; Schnorr verification runs off-main in
// processNostrMessage, which then does the authoritative
// check-and-record. Recording stays after verification so a
// forged-signature copy can never poison the dedup set and suppress
// the genuine event.
if context.hasProcessedNostrEvent(giftWrap.id) { return }
Task.detached(priority: .userInitiated) { [weak self] in
@@ -407,6 +370,7 @@ final class NostrInboundPipeline {
}
func processNostrMessage(_ giftWrap: NostrEvent) async {
guard giftWrap.isValidSignature() else { return }
guard let context else { return }
// Authoritative check-and-record, atomic on the main actor so two
// concurrent detached tasks can't both process the same event.
@@ -416,13 +380,8 @@ final class NostrInboundPipeline {
return false
}
if alreadyProcessed { return }
// Fetch the identity and the wipe generation in ONE main-actor hop:
// the generation then vouches for exactly this identity. A wipe after
// this point bumps the generation and the delivery hop below drops
// the decrypted result (same guard as processGeohashGiftWrap; this
// account-mailbox path had the identical hazard).
let (currentIdentity, wipeGeneration): (NostrIdentity?, UInt64) = await MainActor.run {
(context.currentNostrIdentity(), self.wipeGeneration)
let currentIdentity: NostrIdentity? = await MainActor.run {
context.currentNostrIdentity()
}
guard let currentIdentity else { return }
@@ -454,9 +413,6 @@ final class NostrInboundPipeline {
let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
await MainActor.run {
// Drop pre-wipe plaintext if a panic wipe landed
// during the off-main decrypt (see above).
guard self.wipeGeneration == wipeGeneration else { return }
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
switch payload.type {
@@ -382,12 +382,10 @@ struct ChatNostrCoordinatorContextTests {
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
// The NIP-17 unwrap runs off the main actor; wait for the hop back.
let convKey = PeerID(nostr_: sender.publicKeyHex)
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
#expect(routed)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
#expect(context.handledPrivateMessages.count == 1)
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
#expect(context.handledPrivateMessages.first?.convKey == convKey)
@@ -400,76 +398,30 @@ struct ChatNostrCoordinatorContextTests {
// The same gift wrap is dropped on replay.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
await drainMainQueue()
#expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.handledPrivateMessages.count == 1)
}
@Test @MainActor
func handleGiftWrap_panicWipeAfterSpawnDropsDecryptedResult() async throws {
func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
let embedded = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: "pre-wipe secret",
messageID: "gm-wipe-1",
senderPeerID: PeerID(str: "aabbccddeeff0011")
))
let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
// Spawn the detached decrypt (it strongly captures the pre-wipe
// identity), then panic-wipe in the SAME main-actor turn guaranteed
// to land before the task's first main-actor hop.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
coordinator.inbound.invalidateInFlightDecrypts()
// Give the detached task ample time to have delivered if the wipe
// guard were broken.
try? await Task.sleep(nanoseconds: 200_000_000)
await drainMainQueue()
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.recordedNostrEventIDs.isEmpty)
// The pipeline itself stays usable: a gift wrap spawned AFTER the
// wipe (new generation) still decrypts and delivers.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
#expect(delivered)
}
// NOTE: Inbound Schnorr signature verification (and the forged-copy
// dedup-poisoning invariant) is enforced once, off the main actor, at the
// relay boundary see NostrRelayManagerTests
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
// The inbound pipeline only ever sees verified events.
@Test @MainActor
func processNostrMessage_duplicateDeliveryProcessesOnce() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
context.nostrIdentity = recipient
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "verify:noop",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
var invalidGiftWrap = giftWrap
invalidGiftWrap.sig = String(repeating: "0", count: 128)
// Fan-in of the same (already verified) gift wrap from several relays
// records and processes exactly once.
await coordinator.inbound.processNostrMessage(giftWrap)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
// A forged-signature copy is rejected WITHOUT entering the dedup set...
await coordinator.inbound.processNostrMessage(invalidGiftWrap)
#expect(context.recordedNostrEventIDs.isEmpty)
// ...so the genuine event with the same ID still processes and records.
await coordinator.inbound.processNostrMessage(giftWrap)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
}
+27 -12
View File
@@ -352,11 +352,31 @@ struct ChatViewModelNostrExtensionTests {
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
}
// NOTE: Tampered-signature rejection is enforced once, off the main
// actor, at the relay boundary (events only reach the inbound pipeline
// after verification) see NostrRelayManagerTests
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
@Test @MainActor
func handleNostrEvent_rejectsInvalidSignature() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let identity = try NostrIdentity.generate()
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", geohash]],
content: "Valid"
)
var signed = try event.sign(with: identity.schnorrSigningKey())
signed.id = "deadbeef"
viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 100_000_000)
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(!viewModel.messages.contains { $0.content == "Tampered" })
}
@Test @MainActor
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
@@ -545,14 +565,9 @@ struct ChatViewModelNostrExtensionTests {
viewModel.handleGiftWrap(giftWrap, id: recipient)
// Gift-wrap decryption runs off the main actor; wait for the ack
// (sent even for blocked senders) to know processing finished.
let didAck = await TestHelpers.waitUntil(
{ viewModel.sentGeoDeliveryAcks.contains(messageID) },
timeout: 5.0
)
#expect(didAck)
try? await Task.sleep(nanoseconds: 50_000_000)
#expect(viewModel.privateChats[convKey] == nil)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
}
@Test @MainActor
+20 -5
View File
@@ -326,11 +326,26 @@ struct ChatViewModelPresenceHandlingTests {
#expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1)
}
// NOTE: Tampered-signature rejection (and the forged-copy dedup-poisoning
// invariant) is enforced once, off the main actor, at the relay boundary
// the sampling path only ever sees verified events. See
// NostrRelayManagerTests
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache`.
@Test func subscribeNostrEvent_samplingInvalidSignatureDoesNotPoisonDedup() async throws {
let (viewModel, _) = makeTestableViewModel()
let sampleGeohash = "u4pru"
let identity = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: [["g", sampleGeohash]],
content: ""
)
let signed = try event.sign(with: identity.schnorrSigningKey())
var invalid = signed
invalid.sig = String(repeating: "0", count: 128)
viewModel.subscribeNostrEvent(invalid, gh: sampleGeohash)
viewModel.subscribeNostrEvent(signed, gh: sampleGeohash)
#expect(viewModel.geohashParticipantCount(for: sampleGeohash) == 1)
}
// MARK: - Test Helper
@@ -77,10 +77,8 @@ final class PerformanceBaselineTests: XCTestCase {
// MARK: - 1a. Nostr inbound event handling (fresh events)
/// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events
/// (kind 20000): dedup record, presence/nickname bookkeeping, and
/// public-message ingest scheduling. Schnorr signature verification is
/// NOT part of this path anymore it runs exactly once, off the main
/// actor, in `NostrRelayManager` before delivery.
/// (kind 20000): signature verification, dedup record, presence/nickname
/// bookkeeping, and public-message ingest scheduling.
func testNostrInboundEventHandling_freshEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500)
// A fresh context per measure pass so every event takes the
@@ -108,9 +106,8 @@ final class PerformanceBaselineTests: XCTestCase {
/// The dedup-hit path: identical events replayed. Duplicates dominate
/// real relay traffic (the same event arrives from several relays), so
/// this path runs hundreds of times a minute in busy geohashes. It is a
/// pure dedup lookup: no crypto (verification happens upstream in
/// `NostrRelayManager`, and only for the first-seen copy).
/// this path runs hundreds of times a minute in busy geohashes. Note it
/// still pays full Schnorr signature verification before the dedup check.
func testNostrInboundEventHandling_duplicateEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500)
let context = PerfNostrContext()
@@ -6,9 +6,12 @@ import Testing
struct BLEAnnounceHandlerTests {
private final class Recorder {
var existingNoisePublicKey: Data?
var existingSigningPublicKey: Data?
var persistedSigningPublicKey: Data?
var persistedSigningKeyQueries: [PeerID] = []
var signatureValid = true
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
var upsertResult: BLEPeerAnnounceUpdate? = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
var dedupSeenIDs: Set<String> = []
var shouldEmitReconnectLogResult = true
@@ -35,7 +38,11 @@ struct BLEAnnounceHandlerTests {
localPeerID: { localPeerID },
messageTTL: TransportConfig.messageTTLDefault,
now: { now },
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) },
persistedSigningPublicKey: { peerID in
recorder.persistedSigningKeyQueries.append(peerID)
return recorder.persistedSigningPublicKey
},
verifySignature: { packet, signingPublicKey in
recorder.verifySignatureCalls.append((packet, signingPublicKey))
return recorder.signatureValid
@@ -368,6 +375,168 @@ struct BLEAnnounceHandlerTests {
#expect(recorder.topologyUpdates.first?.neighbors == neighbors)
}
@Test
func matchingPinnedSigningKeyIsAccepted() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9A, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = noiseKey
// Matches the signing key encoded by makeAnnouncePacket.
recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.persistedIdentities.count == 1)
}
@Test
func signingKeyMismatchWithPinnedKeySkipsUpsertAndIdentityPersistence() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9B, count: 32)
let peerID = PeerID(publicKey: noiseKey)
// Attacker announce: victim's noiseKey/peerID, attacker's signing key
// (0x99 from makeAnnouncePacket) with a "valid" self-signature.
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = noiseKey
recorder.existingSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's pinned key
recorder.signatureValid = true
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.isEmpty)
#expect(recorder.persistedIdentities.isEmpty)
#expect(recorder.topologyUpdates.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
}
@Test
func persistedSigningKeyMismatchWithoutRegistryEntryIsRejected() throws {
// Registry has no entry (app restart or offline-peer eviction), but
// the persisted cryptographic identity still pins the victim's
// signing key. An attacker replaying the victim's noiseKey/peerID
// with their own signing key must not be treated as first contact.
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9D, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = nil
recorder.existingSigningPublicKey = nil
recorder.persistedSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's persisted pin
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.persistedSigningKeyQueries == [peerID])
#expect(recorder.upsertCalls.isEmpty)
#expect(recorder.persistedIdentities.isEmpty)
#expect(recorder.topologyUpdates.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
}
@Test
func persistedSigningKeyMatchWithoutRegistryEntryIsAccepted() throws {
// Legitimate returning peer: registry entry evicted, persisted pin
// matches the announced signing key accepted like a normal announce.
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9E, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
// Matches the signing key encoded by makeAnnouncePacket.
recorder.persistedSigningPublicKey = Data(repeating: 0x99, count: 32)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.persistedIdentities.count == 1)
}
@Test
func registryPinnedSigningKeySkipsPersistedLookup() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9F, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = noiseKey
recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.persistedSigningKeyQueries.isEmpty)
#expect(recorder.upsertCalls.count == 1)
}
@Test
func registryPinRejectionSkipsTopologyAndIdentityPersistence() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9C, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64),
directNeighbors: [Data(repeating: 0xAB, count: 8)]
)
// Pre-barrier trust check sees no pinned key (e.g. concurrent race),
// but the registry itself refuses to replace its pinned signing key.
let recorder = Recorder()
recorder.upsertResult = nil
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.persistedIdentities.isEmpty)
#expect(recorder.topologyUpdates.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
#expect(recorder.afterglowDelays.isEmpty)
}
@Test
func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws {
let now = Date(timeIntervalSince1970: 1_000)
@@ -391,6 +560,251 @@ struct BLEAnnounceHandlerTests {
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
}
@Test
func attackerReplayingVictimNoiseKeyWithOwnSigningKeyIsRejectedEndToEnd() throws {
// Real crypto: the attacker crafts a fully self-consistent announce
// (victim's noiseKey/peerID, attacker's signing key and nickname,
// valid packet signature made with the attacker's key). Without
// signing-key pinning this used to overwrite the victim's registry
// entry and persisted identity.
let victim = NoiseEncryptionService(keychain: MockKeychain())
let attacker = NoiseEncryptionService(keychain: MockKeychain())
let victimNoiseKey = victim.getStaticPublicKeyData()
let peerID = PeerID(publicKey: victimNoiseKey)
let now = Date()
final class RegistryBox {
var registry = BLEPeerRegistry()
var persistedIdentities: [AnnouncementPacket] = []
}
let box = RegistryBox()
let environment = BLEAnnounceHandlerEnvironment(
localPeerID: { PeerID(str: "0102030405060708") },
messageTTL: TransportConfig.messageTTLDefault,
now: { now },
existingPeerKeys: { peerID in
let info = box.registry.info(for: peerID)
return (info?.noisePublicKey, info?.signingPublicKey)
},
persistedSigningPublicKey: { _ in nil },
verifySignature: { packet, signingPublicKey in
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
},
linkState: { _ in (hasPeripheral: true, hasCentral: false) },
withRegistryBarrier: { body in body() },
upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in
box.registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now
)
},
shouldEmitReconnectLog: { _, _ in false },
updateTopology: { _, _ in },
persistIdentity: { announcement in
box.persistedIdentities.append(announcement)
},
dedupContains: { _ in true },
dedupMarkProcessed: { _ in },
deliverAnnounceUIEvents: { _, _, _ in },
trackPacketSeen: { _ in },
sendAnnounceBack: {},
scheduleAfterglow: { _ in }
)
let handler = BLEAnnounceHandler(environment: environment)
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket {
let announcement = AnnouncementPacket(
nickname: nickname,
noisePublicKey: victimNoiseKey,
signingPublicKey: signer.getSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode())
let packet = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: peerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
return try #require(signer.signPacket(packet))
}
// Legitimate announce from the victim is accepted and pinned.
let victimAnnounce = try makeSignedAnnounce(nickname: "victim", signer: victim)
handler.handle(victimAnnounce, from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim")
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(box.persistedIdentities.count == 1)
// Attacker announce with a valid self-signature must be rejected.
let attackerAnnounce = try makeSignedAnnounce(nickname: "attacker", signer: attacker)
handler.handle(attackerAnnounce, from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim")
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(box.persistedIdentities.count == 1)
// The victim's subsequent announces (same pinned key) still work.
let victimRename = try makeSignedAnnounce(nickname: "victim-renamed", signer: victim)
handler.handle(victimRename, from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim-renamed")
#expect(box.persistedIdentities.count == 2)
}
@Test
func signingKeyPinSurvivesRegistryEvictionAndRestartEndToEnd() throws {
// Real crypto + real persistence: the victim announces and gets
// pinned, then the registry entry disappears (offline-peer eviction
// via reconcileConnectivity, or app restart which starts with an
// empty registry). The attacker replays the victim's
// noiseKey/peerID with their own signing key and a valid
// self-signature the persisted identity must still block the
// takeover, and must not be overwritten. The victim (same signing
// key) must be re-accepted.
let victim = NoiseEncryptionService(keychain: MockKeychain())
let attacker = NoiseEncryptionService(keychain: MockKeychain())
let victimNoiseKey = victim.getStaticPublicKeyData()
let peerID = PeerID(publicKey: victimNoiseKey)
let now = Date()
let identityKeychain = MockKeychain()
let identityManager = SecureIdentityStateManager(identityKeychain)
final class RegistryBox {
var registry = BLEPeerRegistry()
}
let box = RegistryBox()
func makeEnvironment(identityManager: SecureIdentityStateManager) -> BLEAnnounceHandlerEnvironment {
BLEAnnounceHandlerEnvironment(
localPeerID: { PeerID(str: "0102030405060708") },
messageTTL: TransportConfig.messageTTLDefault,
now: { now },
existingPeerKeys: { peerID in
let info = box.registry.info(for: peerID)
return (info?.noisePublicKey, info?.signingPublicKey)
},
// Mirrors the BLEService wiring: fall back to the persisted
// cryptographic identity.
persistedSigningPublicKey: { peerID in
identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
.compactMap { $0.signingPublicKey }
.first
},
verifySignature: { packet, signingPublicKey in
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
},
linkState: { _ in (hasPeripheral: true, hasCentral: false) },
withRegistryBarrier: { body in body() },
upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in
box.registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now
)
},
shouldEmitReconnectLog: { _, _ in false },
updateTopology: { _, _ in },
persistIdentity: { announcement in
identityManager.upsertCryptographicIdentity(
fingerprint: announcement.noisePublicKey.sha256Fingerprint(),
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
claimedNickname: announcement.nickname
)
},
dedupContains: { _ in true },
dedupMarkProcessed: { _ in },
deliverAnnounceUIEvents: { _, _, _ in },
trackPacketSeen: { _ in },
sendAnnounceBack: {},
scheduleAfterglow: { _ in }
)
}
let handler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: identityManager))
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket {
let announcement = AnnouncementPacket(
nickname: nickname,
noisePublicKey: victimNoiseKey,
signingPublicKey: signer.getSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode())
let packet = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: peerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
return try #require(signer.signPacket(packet))
}
func persistedIdentity() -> CryptographicIdentity? {
// queue.sync read; fences the manager's pending barrier writes.
identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first
}
// 1. Victim announces: pinned in the registry and persisted.
handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData())
// 2. Registry entry disappears (eviction / restart).
_ = box.registry.remove(peerID)
#expect(box.registry.info(for: peerID) == nil)
// 3. Attacker replay with own signing key: rejected via the persisted
// pin, and neither the registry nor the persisted identity change.
handler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID)
#expect(box.registry.info(for: peerID) == nil)
#expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(identityManager.getSocialIdentity(for: victimNoiseKey.sha256Fingerprint())?.claimedNickname == "victim")
// 4. Victim re-announces with the same signing key: accepted again.
handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim")
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
// 5. Simulated app restart: a fresh identity manager reloads the pin
// from the (mock) keychain, and a fresh registry starts empty. The
// attacker replay is still rejected.
identityManager.forceSave()
let reloadedManager = SecureIdentityStateManager(identityKeychain)
#expect(
reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey
== victim.getSigningPublicKeyData()
)
box.registry = BLEPeerRegistry()
let restartedHandler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: reloadedManager))
restartedHandler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID)
#expect(box.registry.info(for: peerID) == nil)
#expect(
reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey
== victim.getSigningPublicKeyData()
)
// ...while the victim is accepted after the restart.
restartedHandler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
}
private func expectNoSideEffects(_ recorder: Recorder) {
#expect(recorder.barrierCount == 0)
#expect(recorder.upsertCalls.isEmpty)
@@ -123,7 +123,9 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: false,
signatureValid: false,
existingNoisePublicKey: nil,
announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
announcedNoisePublicKey: Data(repeating: 0x11, count: 32),
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
)
#expect(decision == .reject(.missingSignature))
@@ -136,7 +138,9 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: true,
signatureValid: false,
existingNoisePublicKey: nil,
announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
announcedNoisePublicKey: Data(repeating: 0x11, count: 32),
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
)
#expect(decision == .reject(.invalidSignature))
@@ -148,7 +152,9 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: true,
signatureValid: true,
existingNoisePublicKey: Data(repeating: 0xAA, count: 32),
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32)
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32),
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
)
#expect(decision == .reject(.keyMismatch))
@@ -162,13 +168,51 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: true,
signatureValid: true,
existingNoisePublicKey: noiseKey,
announcedNoisePublicKey: noiseKey
announcedNoisePublicKey: noiseKey,
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
)
#expect(decision == .verified)
#expect(decision.isVerified)
}
@Test
func trustPolicyRejectsPinnedSigningKeyMismatchEvenWithValidSignature() {
let noiseKey = Data(repeating: 0xCC, count: 32)
// Attacker replays the victim's noiseKey/peerID with their own signing
// key and a valid self-signature; the pinned key must win.
let decision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: true,
signatureValid: true,
existingNoisePublicKey: noiseKey,
announcedNoisePublicKey: noiseKey,
existingSigningPublicKey: Data(repeating: 0x99, count: 32),
announcedSigningPublicKey: Data(repeating: 0x66, count: 32)
)
#expect(decision == .reject(.signingKeyMismatch))
#expect(!decision.isVerified)
}
@Test
func trustPolicyAcceptsMatchingPinnedSigningKey() {
let noiseKey = Data(repeating: 0xCC, count: 32)
let signingKey = Data(repeating: 0x99, count: 32)
let decision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: true,
signatureValid: true,
existingNoisePublicKey: noiseKey,
announcedNoisePublicKey: noiseKey,
existingSigningPublicKey: signingKey,
announcedSigningPublicKey: signingKey
)
#expect(decision == .verified)
}
@Test
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
let directNew = BLEAnnounceResponsePolicy.plan(
@@ -6,12 +6,12 @@ import Testing
@Suite("BLE peer registry tests")
struct BLEPeerRegistryTests {
@Test("upserted announces track new, reconnect, and rename transitions")
func upsertVerifiedAnnounceTracksTransitions() {
func upsertVerifiedAnnounceTracksTransitions() throws {
var registry = BLEPeerRegistry()
let peerID = PeerID(str: "1122334455667788")
let firstSeen = Date(timeIntervalSince1970: 100)
let first = registry.upsertVerifiedAnnounce(
let firstResult = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "alice",
noisePublicKey: Data([1, 2, 3]),
@@ -19,6 +19,7 @@ struct BLEPeerRegistryTests {
isConnected: true,
now: firstSeen
)
let first = try #require(firstResult)
#expect(first.isNewPeer)
#expect(!first.wasDisconnected)
@@ -27,7 +28,7 @@ struct BLEPeerRegistryTests {
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
registry.markDisconnected(peerID)
let reconnect = registry.upsertVerifiedAnnounce(
let reconnectResult = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "alice-renamed",
noisePublicKey: Data([1, 2, 3]),
@@ -35,6 +36,7 @@ struct BLEPeerRegistryTests {
isConnected: true,
now: firstSeen.addingTimeInterval(1)
)
let reconnect = try #require(reconnectResult)
#expect(!reconnect.isNewPeer)
#expect(reconnect.wasDisconnected)
@@ -42,6 +44,85 @@ struct BLEPeerRegistryTests {
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
}
@Test("pinned signing key cannot be silently replaced by a later announce")
func upsertVerifiedAnnounceRefusesToReplacePinnedSigningKey() throws {
var registry = BLEPeerRegistry()
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0x11, count: 32)
let victimSigningKey = Data(repeating: 0x42, count: 32)
let attackerSigningKey = Data(repeating: 0x66, count: 32)
let firstSeen = Date(timeIntervalSince1970: 100)
let pinResult = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "victim",
noisePublicKey: noiseKey,
signingPublicKey: victimSigningKey,
isConnected: true,
now: firstSeen
)
#expect(pinResult != nil)
// Attacker replays the victim's noiseKey/peerID with their own
// signing key and nickname; the upsert must be refused wholesale.
let attack = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "attacker",
noisePublicKey: noiseKey,
signingPublicKey: attackerSigningKey,
isConnected: true,
now: firstSeen.addingTimeInterval(1)
)
#expect(attack == nil)
let info = try #require(registry.info(for: peerID))
#expect(info.nickname == "victim")
#expect(info.signingPublicKey == victimSigningKey)
// A legitimate re-announce with the pinned key is still accepted.
let legit = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "victim-renamed",
noisePublicKey: noiseKey,
signingPublicKey: victimSigningKey,
isConnected: true,
now: firstSeen.addingTimeInterval(2)
)
#expect(legit != nil)
#expect(registry.info(for: peerID)?.nickname == "victim-renamed")
}
@Test("announce without a signing key keeps the pinned key")
func upsertVerifiedAnnounceKeepsPinnedSigningKeyWhenAnnounceOmitsIt() throws {
var registry = BLEPeerRegistry()
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0x11, count: 32)
let signingKey = Data(repeating: 0x42, count: 32)
let firstSeen = Date(timeIntervalSince1970: 100)
let initialResult = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "alice",
noisePublicKey: noiseKey,
signingPublicKey: signingKey,
isConnected: true,
now: firstSeen
)
#expect(initialResult != nil)
let update = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "alice",
noisePublicKey: noiseKey,
signingPublicKey: nil,
isConnected: true,
now: firstSeen.addingTimeInterval(1)
)
#expect(update != nil)
#expect(registry.info(for: peerID)?.signingPublicKey == signingKey)
}
@Test("reachability keeps recent verified offline peers only when mesh is attached")
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
let offlinePeer = PeerID(str: "1122334455667788")
@@ -639,15 +639,11 @@ final class NostrRelayManagerTests: XCTestCase {
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
// Wait on the DELIVERY-side state: handler dispatch and the duplicate
// drop both land on the second main hop, after off-main verification.
let settled = await waitUntil {
let countedOnBothRelays = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
receivedIDs == [event.id] &&
context.manager.debugDuplicateInboundEventDropCount == 1
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
}
XCTAssertTrue(settled)
XCTAssertTrue(countedOnBothRelays)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1)
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 1)
@@ -680,17 +676,12 @@ final class NostrRelayManagerTests: XCTestCase {
)
}
// Wait on the DELIVERY-side state: the winner's handler dispatch and
// the losers' duplicate drops both land on the second main hop, after
// off-main verification messagesReceived (first hop) settles sooner.
let settled = await waitUntil {
let countedOnEveryRelay = await waitUntil {
relayURLs.allSatisfy { relayURL in
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
} &&
receivedIDs == [event.id] &&
context.manager.debugDuplicateInboundEventDropCount == relayURLs.count - 1
}
}
XCTAssertTrue(settled)
XCTAssertTrue(countedOnEveryRelay)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1)
XCTAssertEqual(
@@ -723,153 +714,16 @@ final class NostrRelayManagerTests: XCTestCase {
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: invalidEvent)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
// Wait on the DELIVERY-side state (second main hop, after off-main
// verification), not just messagesReceived (first main hop) the
// handler only fires after verify + a second hop.
let genuineDelivered = await waitUntil {
let countedOnBothRelays = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
receivedIDs == [event.id]
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
}
XCTAssertTrue(genuineDelivered)
XCTAssertTrue(countedOnBothRelays)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 0)
}
/// The relay boundary is the single signature-verification point for the
/// whole inbound path (downstream pipelines no longer re-verify), so a
/// tampered gift wrap (kind 1059, the DM/mailbox path) must be dropped
/// here and must not poison the dedup cache against the genuine copy.
func test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup() async throws {
let firstRelayURL = "wss://giftwrap-one.example"
let secondRelayURL = "wss://giftwrap-two.example"
let context = makeContext(permission: .denied)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "psst",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
let tampered = invalidSignatureCopy(of: giftWrap)
var receivedIDs: [String] = []
context.manager.subscribe(
filter: makeFilter(),
id: "gift-wraps",
relayUrls: [firstRelayURL, secondRelayURL]
) { event in
receivedIDs.append(event.id)
}
let subscriptionsSent = await waitUntil {
context.sessionFactory.latestConnection(for: firstRelayURL)?.sentStrings.count == 1 &&
context.sessionFactory.latestConnection(for: secondRelayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscriptionsSent)
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "gift-wraps", event: tampered)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "gift-wraps", event: giftWrap)
// Wait on the DELIVERY-side state (second main hop, after off-main
// verification), not just messagesReceived (first main hop) the
// handler only fires after verify + a second hop.
let genuineDelivered = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
receivedIDs == [giftWrap.id]
}
XCTAssertTrue(genuineDelivered)
XCTAssertEqual(receivedIDs, [giftWrap.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
}
/// Signature verification runs off-main in a per-relay serial consumer;
/// several frames buffered on one socket must still be delivered to the
/// handler in that relay's arrival order.
func test_receiveEvent_deliversBackToBackEventsInArrivalOrder() async throws {
let relayURL = "wss://ordered.example"
let context = makeContext(permission: .denied)
let events = try (0..<12).map { try makeSignedEvent(content: "ordered-\($0)") }
var receivedIDs: [String] = []
context.manager.subscribe(filter: makeFilter(), id: "ordered", relayUrls: [relayURL]) { event in
receivedIDs.append(event.id)
}
let subscriptionSent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscriptionSent)
for event in events {
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "ordered", event: event)
}
let allDelivered = await waitUntil(timeout: 5.0) {
receivedIDs.count == events.count
}
XCTAssertTrue(allDelivered)
XCTAssertEqual(receivedIDs, events.map(\.id))
}
/// Each relay owns its own off-main verify pipeline, so a large backlog of
/// EVENT frames on one relay must NOT head-of-line-block a frame that
/// arrives on a different relay. Under the previous single global consumer,
/// relay B's single event (emitted after relay A's whole burst) could only
/// be delivered once every frame in A's backlog had been Schnorr-verified;
/// with per-relay pipelines B verifies concurrently and lands before A's
/// backlog drains.
func test_receiveEvent_busyRelayDoesNotBlockOtherRelayDelivery() async throws {
let busyRelayURL = "wss://busy-relay.example"
let quietRelayURL = "wss://quiet-relay.example"
let context = makeContext(permission: .denied)
// Distinct subscriptions per relay so dedup never coalesces A vs. B.
let busyEvents = try (0..<200).map { try makeSignedEvent(content: "busy-\($0)") }
let quietEvent = try makeSignedEvent(content: "quiet")
var busyDeliveredCount = 0
var quietDeliveredAfterBusyCount = -1 // busy-count observed when B lands
context.manager.subscribe(filter: makeFilter(), id: "busy", relayUrls: [busyRelayURL]) { _ in
busyDeliveredCount += 1
}
context.manager.subscribe(filter: makeFilter(), id: "quiet", relayUrls: [quietRelayURL]) { _ in
if quietDeliveredAfterBusyCount < 0 {
quietDeliveredAfterBusyCount = busyDeliveredCount
}
}
let subscribed = await waitUntil {
context.sessionFactory.latestConnection(for: busyRelayURL)?.sentStrings.count == 1 &&
context.sessionFactory.latestConnection(for: quietRelayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscribed)
// Flood relay A first, then emit a single frame on relay B.
for event in busyEvents {
try context.sessionFactory.latestConnection(for: busyRelayURL)?.emitEventMessage(subscriptionID: "busy", event: event)
}
try context.sessionFactory.latestConnection(for: quietRelayURL)?.emitEventMessage(subscriptionID: "quiet", event: quietEvent)
let quietDelivered = await waitUntil(timeout: 5.0) { quietDeliveredAfterBusyCount >= 0 }
XCTAssertTrue(quietDelivered, "relay B's event was never delivered")
// The signal: B did not have to wait for A's entire backlog. If the two
// pipelines were globally serialized, B could only land after all 200 of
// A's frames, so busyDeliveredCount would be 200 when B arrived.
XCTAssertLessThan(
quietDeliveredAfterBusyCount,
busyEvents.count,
"relay B was head-of-line blocked behind relay A's backlog"
)
// Both relays still drain fully and in order.
let allDelivered = await waitUntil(timeout: 5.0) {
busyDeliveredCount == busyEvents.count
}
XCTAssertTrue(allDelivered)
}
func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws {
let relayURL = "wss://missing-handler.example"
let context = makeContext(permission: .denied)
@@ -1841,11 +1695,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
}
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
if !pendingResults.isEmpty {
completionHandler(pendingResults.removeFirst())
} else {
receiveHandler = completionHandler
}
receiveHandler = completionHandler
}
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
@@ -1878,24 +1728,15 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
}
func emitRawString(_ string: String) throws {
deliver(.success(.string(string)))
let handler = receiveHandler
receiveHandler = nil
handler?(.success(.string(string)))
}
private func emit(jsonObject: Any) throws {
let data = try JSONSerialization.data(withJSONObject: jsonObject)
deliver(.success(.data(data)))
}
// Frames emitted before the manager re-arms `receive` are queued so
// back-to-back emissions model a socket with several buffered frames.
private var pendingResults: [Result<URLSessionWebSocketTask.Message, Error>] = []
private func deliver(_ result: Result<URLSessionWebSocketTask.Message, Error>) {
if let handler = receiveHandler {
receiveHandler = nil
handler(result)
} else {
pendingResults.append(result)
}
let handler = receiveHandler
receiveHandler = nil
handler?(.success(.data(data)))
}
}
@@ -79,6 +79,100 @@ final class SecureIdentityStateManagerTests: XCTestCase {
XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey)
}
func test_upsertCryptographicIdentity_refusesToReplacePinnedSigningKey() async {
let manager = SecureIdentityStateManager(MockKeychain())
let noisePublicKey = Data(repeating: 0x11, count: 32)
let fingerprint = noisePublicKey.sha256Fingerprint()
let peerID = PeerID(publicKey: noisePublicKey)
let victimSigningKey = Data(repeating: 0x22, count: 32)
let attackerSigningKey = Data(repeating: 0x66, count: 32)
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: victimSigningKey,
claimedNickname: "victim"
)
let pinned = await waitUntil {
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey
}
XCTAssertTrue(pinned)
// Attacker upsert with a different signing key must be refused in
// full signing key AND claimed nickname stay the victim's.
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: attackerSigningKey,
claimedNickname: "attacker"
)
// Synchronous reads fence the manager's pending barrier writes.
XCTAssertEqual(
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
victimSigningKey
)
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim")
// The legitimate peer (same signing key) can still update.
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: victimSigningKey,
claimedNickname: "victim-renamed"
)
let renamed = await waitUntil {
manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "victim-renamed"
}
XCTAssertTrue(renamed)
XCTAssertEqual(
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
victimSigningKey
)
}
func test_cryptographicIdentity_persistsAcrossReinitAndKeepsSigningKeyPin() async {
let keychain = MockKeychain()
let manager = SecureIdentityStateManager(keychain)
let noisePublicKey = Data(repeating: 0x13, count: 32)
let fingerprint = noisePublicKey.sha256Fingerprint()
let peerID = PeerID(publicKey: noisePublicKey)
let victimSigningKey = Data(repeating: 0x24, count: 32)
let attackerSigningKey = Data(repeating: 0x77, count: 32)
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: victimSigningKey,
claimedNickname: "victim"
)
let pinned = await waitUntil {
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey
}
XCTAssertTrue(pinned)
manager.forceSave()
// Simulated app restart: the pin must survive and still refuse a
// different signing key.
let reloaded = SecureIdentityStateManager(keychain)
XCTAssertEqual(
reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
victimSigningKey
)
reloaded.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: attackerSigningKey,
claimedNickname: "attacker"
)
XCTAssertEqual(
reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
victimSigningKey
)
XCTAssertEqual(reloaded.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim")
}
func test_setBlocked_clearsFavoriteState() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "ab", count: 32)