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
22 changed files with 875 additions and 1374 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 {
+1 -1
View File
@@ -54,7 +54,7 @@ private extension GeoRelayDirectoryDependencies {
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
awaitTorReady: { await TorManager.shared.awaitEgressReady() },
awaitTorReady: { await TorManager.shared.awaitReady() },
makeFetchData: {
let session = TorURLSession.shared.session
return { request in
+6 -43
View File
@@ -61,11 +61,6 @@ struct NostrRelayManagerDependencies {
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
var torEnforced: () -> Bool
var torIsReady: () -> Bool
/// Synchronous cached egress-gate check: `true` only while a positive Tor
/// egress verification is within its TTL (or Tor is not enforced). When
/// `false`, connections must be queued behind `awaitTorReady`, which runs
/// the async egress self-check.
var torEgressVerified: () -> Bool
var torIsForeground: () -> Bool
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
var makeSession: () -> NostrRelaySessionProtocol
@@ -88,14 +83,10 @@ private extension NostrRelayManagerDependencies {
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
torEnforced: { TorManager.shared.torEnforced },
torIsReady: { TorManager.shared.isReady },
torEgressVerified: { TorManager.shared.isEgressVerified },
torIsForeground: { TorManager.shared.isForeground() },
awaitTorReady: { completion in
Task.detached {
// Require both Tor bootstrap AND a positive egress self-check
// so relay sockets never open unless traffic is proven to
// route through Tor (fail-closed).
let ready = await TorManager.shared.awaitEgressReady()
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
completion(ready)
}
@@ -634,14 +625,8 @@ final class NostrRelayManager: ObservableObject {
// MARK: - Private Methods
/// Every path that opens a relay socket funnels through this check (initial
/// connect, reconnect backoff timers, subscription-triggered connects,
/// manual retry). It must hold connections back when Tor isn't bootstrapped
/// OR when the runtime egress self-check has no fresh positive verdict
/// otherwise the already-bootstrapped path would open sockets without ever
/// running the egress canary.
private var shouldWaitForTorBeforeConnecting: Bool {
shouldUseTor && (!dependencies.torIsReady() || !dependencies.torEgressVerified())
shouldUseTor && !dependencies.torIsReady()
}
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
@@ -689,20 +674,16 @@ final class NostrRelayManager: ObservableObject {
guard ready else {
self.torReadyWaitAttempts += 1
if self.torReadyWaitAttempts < TransportConfig.nostrTorReadyMaxWaitAttempts {
SecureLogger.warning("Tor not ready or egress unverified; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session)
SecureLogger.warning("Tor not ready; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session)
self.queueConnectionsUntilTorReady(pending)
} else {
// Still fail-closed (no network), but unblock any callers
// waiting on EOSE so the UI doesn't hang indefinitely.
// Queued subscriptions/sends are kept; a bounded-cadence
// retry (below) re-enters the gate so a transient failure
// (Tor stall, canary outage keeping the egress unverified)
// recovers automatically, and any later trigger (e.g. app
// foreground) also re-enters it.
SecureLogger.error("❌ Tor not ready or egress unverified after \(self.torReadyWaitAttempts) wait(s); relays stay closed (fail-closed), retrying in \(Int(TransportConfig.nostrTorGateRetrySeconds))s", category: .session)
// Queued subscriptions/sends are kept and flush if a later
// trigger (e.g. app foreground) brings Tor up.
SecureLogger.error("❌ Tor not ready after \(self.torReadyWaitAttempts) wait(s); aborting relay connections (fail-closed)", category: .session)
self.torReadyWaitAttempts = 0
self.unblockPendingEOSECallbacks(reason: "tor-unavailable")
self.scheduleTorGateRetry(pending)
}
return
}
@@ -712,24 +693,6 @@ final class NostrRelayManager: ObservableObject {
}
}
/// After the Tor-gate wait attempts are exhausted, keep a low-frequency
/// retry alive so the gate re-opens without an external trigger once the
/// transient failure clears. Bounded cadence: one wait cycle per
/// `nostrTorGateRetrySeconds`; the egress verifier additionally throttles
/// actual canary probes to one per its `minRetryInterval`.
private func scheduleTorGateRetry(_ relayUrls: [String]) {
guard !relayUrls.isEmpty else { return }
let generation = connectionGeneration
dependencies.scheduleAfter(TransportConfig.nostrTorGateRetrySeconds) { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
// Void after disconnect/reset: those paths bump the generation.
guard generation == self.connectionGeneration else { return }
self.queueConnectionsUntilTorReady(relayUrls)
}
}
}
/// Park an EOSE callback while Tor is not yet ready, and schedule the same
/// fallback timeout `startEOSETracking` uses. Without it, a parked callback
/// would only be unblocked by Tor-readiness retry exhaustion (several
+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.
-4
View File
@@ -171,10 +171,6 @@ enum TransportConfig {
// How many consecutive Tor-readiness waits (each bounded by TorManager's
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
static let nostrTorReadyMaxWaitAttempts: Int = 3
// After Tor-gate wait attempts are exhausted (Tor never ready, or egress
// self-check unverified), retry the whole gate at this bounded cadence so
// a transient failure (e.g. canary outage) recovers without user action.
static let nostrTorGateRetrySeconds: TimeInterval = 30.0
static let nostrPendingSendQueueCap: Int = 200
// Sample interval for the send-queue overflow warning (first + every Nth
// dropped event). Drops are ephemeral presence/geo traffic log-only.
@@ -1,403 +0,0 @@
//
// TorEgressVerifierTests.swift
// bitchatTests
//
// Unit tests for the runtime Tor-egress self-check policy/caching. The network
// probe is injected, so these tests are deterministic and offline.
//
import Testing
import Foundation
@testable import bitchat
import Tor
@Suite(.serialized)
struct TorEgressVerifierTests {
/// Deterministic, controllable clock + probe.
private final class Harness: @unchecked Sendable {
private let lock = NSLock()
private var _now = Date(timeIntervalSince1970: 1_000_000)
private var _result: TorEgressVerifier.ProbeResult = .verifiedTor
private var _hanging = false
private var _gated = false
private var _released = false
private var _probeCount = 0
private var _cancelledCount = 0
var now: Date {
lock.lock(); defer { lock.unlock() }; return _now
}
var probeCount: Int {
lock.lock(); defer { lock.unlock() }; return _probeCount
}
/// Number of hung probes that observed cooperative cancellation.
var cancelledCount: Int {
lock.lock(); defer { lock.unlock() }; return _cancelledCount
}
func advance(_ seconds: TimeInterval) {
lock.lock(); _now = _now.addingTimeInterval(seconds); lock.unlock()
}
func setResult(_ r: TorEgressVerifier.ProbeResult) {
lock.lock(); _result = r; lock.unlock()
}
/// When `true`, probes park forever and only exit via cooperative
/// cancellation models a canary request wedged by
/// `waitsForConnectivity` deferring the request timer.
func setHanging(_ hanging: Bool) {
lock.lock(); _hanging = hanging; lock.unlock()
}
/// When `true`, probes wait for `release()` before returning models
/// a slow-but-completing canary for join-semantics tests.
func setGated(_ gated: Bool) {
lock.lock(); _gated = gated; lock.unlock()
}
func release() {
lock.lock(); _released = true; lock.unlock()
}
/// Suspends until at least `n` probes have started.
func waitUntilProbeCount(atLeast n: Int) async {
while probeCount < n {
try? await Task.sleep(nanoseconds: 1_000_000)
}
}
func makeProbe() -> @Sendable () async -> TorEgressVerifier.ProbeResult {
return { [self] in
lock.lock()
_probeCount += 1
let r = _result
let hang = _hanging
let gated = _gated
lock.unlock()
if hang {
// Park until cancelled (verifier watchdog or invalidate());
// cancellation-responsive so no task outlives the test.
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 2_000_000)
}
lock.lock(); _cancelledCount += 1; lock.unlock()
return .unreachable("hung probe cancelled")
}
if gated {
while !Task.isCancelled {
lock.lock(); let released = _released; lock.unlock()
if released { break }
try? await Task.sleep(nanoseconds: 1_000_000)
}
}
return r
}
}
func nowProvider() -> @Sendable () -> Date {
return { [self] in self.now }
}
}
private func makeVerifier(
_ h: Harness,
ttl: TimeInterval = 300,
minRetry: TimeInterval = 5,
probeTimeout: TimeInterval = TorEgressVerifier.defaultProbeTimeout
) -> TorEgressVerifier {
TorEgressVerifier(
ttl: ttl,
minRetryInterval: minRetry,
probeTimeout: probeTimeout,
now: h.nowProvider(),
probe: h.makeProbe()
)
}
@Test("verifiedTor allows and is cached within TTL (single probe)")
func verifiedIsCached() async {
let h = Harness()
h.setResult(.verifiedTor)
let v = makeVerifier(h, ttl: 300)
#expect(await v.verify() == true)
// Second call within TTL must not re-probe.
#expect(await v.verify() == true)
#expect(h.probeCount == 1)
}
@Test("cache expires after TTL and re-probes")
func cacheExpires() async {
let h = Harness()
h.setResult(.verifiedTor)
let v = makeVerifier(h, ttl: 300)
#expect(await v.verify() == true)
#expect(h.probeCount == 1)
h.advance(301)
#expect(await v.verify() == true)
#expect(h.probeCount == 2)
}
@Test("notTor refuses (leak detected) and is never cached as allowed")
func notTorRefuses() async {
let h = Harness()
h.setResult(.notTor)
let v = makeVerifier(h, ttl: 300, minRetry: 0)
#expect(await v.verify() == false)
// A subsequent success recovers.
h.setResult(.verifiedTor)
#expect(await v.verify() == true)
}
@Test("unreachable refuses: an unverified egress must not proceed (fail-closed)")
func unreachableRefuses() async {
let h = Harness()
h.setResult(.unreachable("down"))
let v = makeVerifier(h, ttl: 300, minRetry: 0)
#expect(await v.verify() == false)
#expect(v.hasFreshVerification == false)
}
@Test("unreachable-then-reachable recovers via retry")
func unreachableRecoversWhenCanaryReturns() async {
let h = Harness()
h.setResult(.unreachable("down"))
let v = makeVerifier(h, ttl: 300, minRetry: 5)
#expect(await v.verify() == false)
#expect(h.probeCount == 1)
// Canary comes back; the next probe (after the retry throttle window)
// verifies and allows again.
h.setResult(.verifiedTor)
h.advance(5)
#expect(await v.verify() == true)
#expect(h.probeCount == 2)
#expect(v.hasFreshVerification == true)
}
@Test("cached verifiedTor within TTL allows during a canary blip without re-probing")
func cachedVerifiedAllowsDuringCanaryBlip() async {
let h = Harness()
h.setResult(.verifiedTor)
let v = makeVerifier(h, ttl: 300, minRetry: 0)
#expect(await v.verify() == true)
#expect(h.probeCount == 1)
// The canary goes down inside the TTL window: the cached positive
// verdict is authoritative, no probe runs, traffic stays allowed.
h.setResult(.unreachable("down"))
h.advance(100)
#expect(await v.verify() == true)
#expect(h.probeCount == 1)
#expect(v.hasFreshVerification == true)
}
@Test("TTL expiry + unreachable refuses until a probe succeeds again")
func expiredCacheWithUnreachableRefuses() async {
let h = Harness()
h.setResult(.verifiedTor)
let v = makeVerifier(h, ttl: 300, minRetry: 0)
#expect(await v.verify() == true)
// Past the TTL the old verdict no longer stands: an unreachable canary
// means unverified egress, so connection opens are refused.
h.setResult(.unreachable("down"))
h.advance(301)
#expect(v.hasFreshVerification == false)
#expect(await v.verify() == false)
#expect(h.probeCount == 2)
// A subsequent successful probe restores service.
h.setResult(.verifiedTor)
#expect(await v.verify() == true)
#expect(v.hasFreshVerification == true)
}
@Test("minRetryInterval bounds re-probing while unverified (no canary hammering)")
func throttleReprobe() async {
let h = Harness()
h.setResult(.unreachable("down"))
let v = makeVerifier(h, ttl: 300, minRetry: 5)
#expect(await v.verify() == false)
#expect(h.probeCount == 1)
// Within minRetry window: reuse last (refusing) decision, no new probe.
h.advance(1)
#expect(await v.verify() == false)
#expect(h.probeCount == 1)
// After the window: re-probe.
h.advance(5)
#expect(await v.verify() == false)
#expect(h.probeCount == 2)
}
@Test("invalidate clears the synchronous cache snapshot")
func invalidateClearsSnapshot() async {
let h = Harness()
h.setResult(.verifiedTor)
let v = makeVerifier(h, ttl: 300)
#expect(await v.verify() == true)
#expect(v.hasFreshVerification == true)
await v.invalidate()
#expect(v.hasFreshVerification == false)
}
@Test("notTor drops any cached verification snapshot")
func notTorClearsSnapshot() async {
let h = Harness()
h.setResult(.verifiedTor)
let v = makeVerifier(h, ttl: 300, minRetry: 0)
#expect(await v.verify() == true)
#expect(v.hasFreshVerification == true)
h.setResult(.notTor)
h.advance(301)
#expect(await v.verify() == false)
#expect(v.hasFreshVerification == false)
}
@Test("invalidate forces a fresh probe")
func invalidateForcesReprobe() async {
let h = Harness()
h.setResult(.verifiedTor)
let v = makeVerifier(h, ttl: 300)
#expect(await v.verify() == true)
#expect(h.probeCount == 1)
await v.invalidate()
#expect(await v.verify() == true)
#expect(h.probeCount == 2)
}
@Test("lastProbeResult reflects the most recent outcome")
func lastResultTracked() async {
let h = Harness()
h.setResult(.notTor)
let v = makeVerifier(h, ttl: 300, minRetry: 0)
_ = await v.verify()
#expect(await v.lastProbeResult() == .notTor)
}
// MARK: - Liveness (probe timeout watchdog + invalidate cancellation)
@Test("a probe that never completes is bounded by probeTimeout and fails closed")
func hungProbeIsBoundedByTimeout() async {
let h = Harness()
h.setHanging(true)
let v = makeVerifier(h, ttl: 300, minRetry: 0, probeTimeout: 0.05)
// Without the watchdog this would wedge: waitsForConnectivity can
// defer the request timer, leaving the canary bounded only by the
// 7-day resource timeout.
#expect(await v.verify() == false)
let last = await v.lastProbeResult()
switch last {
case .unreachable:
break // fail-closed timeout verdict recorded
default:
Issue.record("expected .unreachable after probe timeout, got \(String(describing: last))")
}
#expect(v.hasFreshVerification == false)
// The hung probe task itself was cancelled (URLSession task would be
// torn down), not abandoned.
while h.cancelledCount < 1 {
try? await Task.sleep(nanoseconds: 1_000_000)
}
#expect(h.cancelledCount == 1)
// The in-flight slot was cleared: the next verify() starts a fresh
// probe (does not join the hung one) and recovers.
h.setHanging(false)
h.setResult(.verifiedTor)
#expect(await v.verify() == true)
#expect(h.probeCount == 2)
#expect(v.hasFreshVerification == true)
}
@Test("invalidate() cancels the in-flight probe and the awaiting caller fails closed")
func invalidateCancelsInFlightProbe() async {
let h = Harness()
h.setHanging(true)
// Long (real-time) timeout and throttle: only invalidate() can
// unblock the caller, and only invalidate() clearing the throttle
// lets the follow-up probe run without advancing the clock.
let v = makeVerifier(h, ttl: 300, minRetry: 600, probeTimeout: 600)
let first = Task { await v.verify() }
await h.waitUntilProbeCount(atLeast: 1)
await v.invalidate()
// The awaiting caller resolves promptly (no 600s wait) and refuses.
#expect(await first.value == false)
#expect(v.hasFreshVerification == false)
// The hung probe observed cancellation (a Tor restart genuinely
// shakes the wedged canary request).
while h.cancelledCount < 1 {
try? await Task.sleep(nanoseconds: 1_000_000)
}
// Recovery: a fresh verify() runs a NEW probe it neither joins the
// cancelled one nor inherits its throttle/last-result state.
h.setHanging(false)
h.setResult(.verifiedTor)
#expect(await v.verify() == true)
#expect(h.probeCount == 2)
#expect(v.hasFreshVerification == true)
}
@Test("recovery after a hung probe survives invalidate + Tor restart cycle")
func hungThenInvalidatedThenRecovers() async {
let h = Harness()
h.setHanging(true)
let v = makeVerifier(h, ttl: 300, minRetry: 5, probeTimeout: 600)
// Wedge one probe, then simulate a Tor restart mid-flight.
let wedged = Task { await v.verify() }
await h.waitUntilProbeCount(atLeast: 1)
await v.invalidate()
#expect(await wedged.value == false)
// Canary still down right after restart: fresh probe, fail closed
// and the throttle applies to the FRESH result (bounded retry intact).
h.setHanging(false)
h.setResult(.unreachable("circuit not built"))
#expect(await v.verify() == false)
#expect(h.probeCount == 2)
h.advance(1)
#expect(await v.verify() == false)
#expect(h.probeCount == 2) // throttled, no hammering
// Canary returns after the retry window: verification recovers.
h.setResult(.verifiedTor)
h.advance(5)
#expect(await v.verify() == true)
#expect(v.hasFreshVerification == true)
}
@Test("concurrent verify() callers still share a single in-flight probe")
func concurrentCallersShareOneProbe() async {
let h = Harness()
h.setResult(.verifiedTor)
h.setGated(true)
let v = makeVerifier(h, ttl: 300, minRetry: 0)
let t1 = Task { await v.verify() }
await h.waitUntilProbeCount(atLeast: 1)
let t2 = Task { await v.verify() }
// Give t2 a chance to join the in-flight probe before releasing it.
// Either way the invariant holds: t2 joins the shared probe, or (if
// scheduled after completion) hits the fresh TTL cache exactly one
// probe runs.
try? await Task.sleep(nanoseconds: 20_000_000)
h.release()
#expect(await t1.value == true)
#expect(await t2.value == true)
#expect(h.probeCount == 1)
}
}
@@ -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")
@@ -111,116 +111,6 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertTrue(connected)
}
func test_connect_whenTorAlreadyReady_waitsForEgressVerificationBeforeCreatingSessions() async {
// Tor is bootstrapped, but the egress self-check has no fresh verdict:
// the ready path must still queue behind the async egress gate instead
// of opening sockets directly.
let context = makeContext(
permission: .authorized,
userTorEnabled: true,
torEnforced: true,
torIsReady: true,
torEgressVerified: false
)
context.manager.connect()
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
// Egress verification succeeds (awaitEgressReady returned true, which
// implies the verifier now holds a fresh cached verdict).
context.torEgressVerified.value = true
context.torWaiter.resolve(true)
let connectedAfterVerification = await waitUntil {
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
context.manager.relays.allSatisfy(\.isConnected)
}
XCTAssertTrue(connectedAfterVerification)
}
func test_reconnect_requeuesBehindEgressGateWhenVerificationLapses() async {
// Reconnect backoff timers call connectToRelay directly; when the
// cached egress verification has lapsed by then, the reconnect must go
// back through the gate rather than opening a socket.
let relayURL = "wss://egress-reconnect.example"
let context = makeContext(
permission: .denied,
userTorEnabled: true,
torEnforced: true,
torIsReady: true,
torEgressVerified: true
)
context.manager.ensureConnections(to: [relayURL])
let connected = await waitUntil {
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(connected)
XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1)
// The socket drops and the cached verification expires meanwhile.
context.torEgressVerified.value = false
context.sessionFactory.latestConnection(for: relayURL)?
.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut))
let reconnectScheduled = await waitUntil { context.scheduler.scheduled.count == 1 }
XCTAssertTrue(reconnectScheduled)
context.scheduler.runNext()
let queuedBehindGate = await waitUntil { context.torWaiter.awaitCallCount == 1 }
XCTAssertTrue(queuedBehindGate)
// No new socket until the egress gate passes.
XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1)
context.torEgressVerified.value = true
context.torWaiter.resolve(true)
let reconnected = await waitUntil {
context.sessionFactory.requestedURLs.count == 2 &&
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(reconnected)
}
func test_connect_egressGateExhaustionSchedulesBoundedRetryAndRecovers() async {
// A persistent unverified egress exhausts the wait attempts; a bounded
// low-frequency retry must then recover automatically once
// verification succeeds (e.g. transient canary outage ends).
let relayURL = "wss://egress-gate-retry.example"
let context = makeContext(
permission: .denied,
userTorEnabled: true,
torEnforced: true,
torIsReady: true,
torEgressVerified: false
)
context.manager.ensureConnections(to: [relayURL])
for _ in 0..<TransportConfig.nostrTorReadyMaxWaitAttempts {
context.torWaiter.resolve(false)
}
// Fail-closed, with a single bounded-cadence retry scheduled.
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
XCTAssertEqual(context.scheduler.scheduled.count, 1)
XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrTorGateRetrySeconds)
// The outage ends before the retry fires.
let attemptsBefore = context.torWaiter.awaitCallCount
context.scheduler.runNext()
let regated = await waitUntil { context.torWaiter.awaitCallCount == attemptsBefore + 1 }
XCTAssertTrue(regated)
context.torEgressVerified.value = true
context.torWaiter.resolve(true)
let recovered = await waitUntil {
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(recovered)
}
func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async {
let relayURL = "wss://tor-eose-unblock.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
@@ -1559,7 +1449,6 @@ final class NostrRelayManagerTests: XCTestCase {
userTorEnabled: Bool = false,
torEnforced: Bool = false,
torIsReady: Bool = true,
torEgressVerified: Bool = true,
torIsForeground: Bool = true,
jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter)
) -> RelayManagerTestContext {
@@ -1569,7 +1458,6 @@ final class NostrRelayManagerTests: XCTestCase {
let scheduler = MockRelayScheduler()
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
let torWaiter = MockTorWaiter(isReady: torIsReady)
let torEgressVerifiedFlag = MutableBool(value: torEgressVerified)
let torForeground = MutableBool(value: torIsForeground)
let activationFlag = MutableBool(value: activationAllowed)
let manager = NostrRelayManager(
@@ -1582,7 +1470,6 @@ final class NostrRelayManagerTests: XCTestCase {
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
torEnforced: { torEnforced },
torIsReady: { torWaiter.isReady },
torEgressVerified: { torEgressVerifiedFlag.value },
torIsForeground: { torForeground.value },
awaitTorReady: torWaiter.await(completion:),
makeSession: { sessionFactory },
@@ -1602,7 +1489,6 @@ final class NostrRelayManagerTests: XCTestCase {
clock: clock,
activationAllowed: activationFlag,
torWaiter: torWaiter,
torEgressVerified: torEgressVerifiedFlag,
torForeground: torForeground
)
}
@@ -1657,7 +1543,6 @@ private struct RelayManagerTestContext {
let clock: MutableClock
let activationAllowed: MutableBool
let torWaiter: MockTorWaiter
let torEgressVerified: MutableBool
let torForeground: MutableBool
}
@@ -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)
-1
View File
@@ -30,7 +30,6 @@ let package = Package(
"TorManager.swift",
"TorURLSession.swift",
"TorNotifications.swift",
"TorEgressVerifier.swift",
],
linkerSettings: [
.linkedLibrary("resolv"),
@@ -1,365 +0,0 @@
import BitLogger
import Foundation
/// Runtime self-check that the proxied `URLSession` egress is *actually* routed
/// through Tor defense-in-depth for the case where a platform silently ignores
/// `URLSessionConfiguration.connectionProxyDictionary` SOCKS settings and lets
/// traffic egress directly (leaking the real IP while Tor appears enabled).
///
/// Runtime verification (see `scripts/tor-egress-verification/`) showed that
/// macOS and the iOS simulator DO honor the SOCKS proxy for both plain HTTPS and
/// `URLSessionWebSocketTask`, and that the proxied session is fail-closed (every
/// request errors when the SOCKS proxy is down). Apple does not officially
/// support SOCKS for URLSession on iOS, so on a physical device the behavior is
/// not contractually guaranteed. This verifier closes that gap: before relay
/// connections are opened under enforced Tor, it performs a canary request whose
/// response positively reports whether the egress hit the network via Tor.
///
/// Policy (`verify()` return value) fail-closed on unverified egress:
/// - `.verifiedTor` allow, and cache the positive result for `ttl`.
/// - `.notTor` REFUSE, and drop any cached verification. The canary
/// reached the internet but the exit is NOT a Tor node:
/// a real leak. Never allow relays.
/// - `.unreachable` REFUSE (egress unverified). The canary itself failed
/// (endpoint down / circuit not built), so we cannot tell
/// whether the platform honored the SOCKS proxy the
/// exact ambiguity this verifier exists to resolve.
/// Unverified traffic must not proceed on the
/// enforced-Tor path.
///
/// TTL / retry semantics:
/// - A `verifiedTor` verdict allows connection *opens* for `ttl` without
/// re-probing, so a brief canary blip inside the TTL window does not take
/// relays offline (a fresh positive verdict is authoritative for the
/// window). Already-open sockets are never torn down by verification
/// they were opened under a verified egress and the proxied session is
/// fail-closed by construction.
/// - After TTL expiry (or `invalidate()` on Tor restart/dormant/shutdown),
/// the next `verify()` re-probes; while the canary stays `.unreachable`,
/// new connection opens are refused until a probe succeeds again.
/// - Probe cadence is bounded: at most one probe per `minRetryInterval`
/// (callers within the window reuse the last decision), and concurrent
/// `verify()` calls share one in-flight probe. Recovery from a transient
/// canary outage is automatic: callers that keep retrying (relay connect
/// gate, GeoRelayDirectory backoff) re-probe and succeed once the canary
/// is reachable again.
///
/// Liveness:
/// - Every probe is hard-bounded by `probeTimeout` via an independent async
/// watchdog. The proxied session sets `waitsForConnectivity = true`, which
/// can defer the per-request timer indefinitely, leaving the request
/// bounded only by the default 7-day resource timeout without the
/// watchdog a hung canary would wedge every subsequent `verify()` caller.
/// On timeout the probe task is cancelled (cooperatively cancelling the
/// underlying `URLSessionTask`), the verdict is the fail-closed
/// `.unreachable`, and the in-flight slot is cleared so the next
/// `verify()` (after the retry throttle) starts a fresh probe.
/// - `invalidate()` cancels any in-flight probe and clears all cached state
/// (including the retry throttle), so a Tor restart/dormant/shutdown
/// genuinely resets the verifier: a hung probe cannot survive it.
///
/// The probe is injectable so the policy/caching logic is unit-tested without a
/// live network (see `TorEgressVerifierTests`).
public actor TorEgressVerifier {
public enum ProbeResult: Equatable, Sendable {
/// Canary succeeded and the exit is a Tor node.
case verifiedTor
/// Canary succeeded but the exit is NOT Tor a direct-egress leak.
case notTor
/// Canary could not complete (endpoint down, no circuit, parse error).
case unreachable(String)
}
/// Hard upper bound for a single canary probe, enforced independently of
/// URLSession timers (see the "Liveness" section of the type doc). Matches
/// the live probe's per-request timeout.
public static let defaultProbeTimeout: TimeInterval = 20
private let probe: @Sendable () async -> ProbeResult
private let now: @Sendable () -> Date
private let ttl: TimeInterval
/// Minimum spacing between probes when not currently verified, so a
/// persistent `.unreachable` cannot hammer the canary endpoint on every
/// reconnect burst.
private let minRetryInterval: TimeInterval
/// Outer wall-clock bound on a single probe (watchdog; fail-closed).
private let probeTimeout: TimeInterval
private var lastVerifiedAt: Date?
private var lastProbeAt: Date?
private var lastResult: ProbeResult?
private var inFlight: Task<Bool, Never>?
/// Bumped whenever a new probe starts or `invalidate()` runs. A completing
/// probe only records its outcome (cache/throttle) and clears `inFlight`
/// if its generation is still current, so a cancelled/superseded probe
/// cannot clobber state owned by a fresh one (actor-reentrancy safety).
private var probeGeneration = 0
/// Lock-protected mirror of "verified within TTL" so synchronous gates
/// (e.g. `NostrRelayManager`'s connect path) can consult the cache without
/// awaiting the actor.
private let verifiedSnapshot = VerifiedSnapshot()
private final class VerifiedSnapshot: @unchecked Sendable {
private let lock = NSLock()
private var verifiedUntil: Date?
func update(_ until: Date?) {
lock.lock()
verifiedUntil = until
lock.unlock()
}
func isFresh(at date: Date) -> Bool {
lock.lock()
defer { lock.unlock() }
guard let verifiedUntil else { return false }
return date < verifiedUntil
}
}
public init(
ttl: TimeInterval,
minRetryInterval: TimeInterval = 5.0,
probeTimeout: TimeInterval = TorEgressVerifier.defaultProbeTimeout,
now: @escaping @Sendable () -> Date = Date.init,
probe: @escaping @Sendable () async -> ProbeResult
) {
self.ttl = ttl
self.minRetryInterval = minRetryInterval
self.probeTimeout = probeTimeout
self.now = now
self.probe = probe
}
/// Drop any cached verification (e.g. after a Tor restart or when the
/// network path changes) AND cancel any in-flight probe. The next
/// `verify()` starts a fresh probe it neither joins the cancelled one
/// nor is throttled by its outcome, so a probe hung from before a Tor
/// restart cannot wedge callers after it.
public func invalidate() {
probeGeneration += 1
inFlight?.cancel()
inFlight = nil
lastVerifiedAt = nil
lastProbeAt = nil
lastResult = nil
verifiedSnapshot.update(nil)
}
/// The most recent probe outcome, for diagnostics/tests.
public func lastProbeResult() -> ProbeResult? { lastResult }
/// Synchronous view of the cache: `true` while a `verifiedTor` verdict is
/// within its TTL. Callers that get `false` must route through the async
/// `verify()` gate (which probes) before opening connections.
public nonisolated var hasFreshVerification: Bool {
verifiedSnapshot.isFresh(at: now())
}
/// Returns `true` only when the proxied egress is verified to exit via Tor
/// (a fresh probe or a cached `verifiedTor` verdict within TTL). Returns
/// `false` when a non-Tor egress was positively detected *or* when the
/// egress could not be verified. See the type doc for the full policy.
public func verify() async -> Bool {
if isFreshlyVerified() { return true }
// Throttle re-probes when the last attempt did not verify.
if let last = lastProbeAt,
let result = lastResult,
now().timeIntervalSince(last) < minRetryInterval {
return decision(for: result)
}
if let inFlight { return await inFlight.value }
probeGeneration += 1
let generation = probeGeneration
let task = Task<Bool, Never> { await self.runProbe(generation: generation) }
inFlight = task
let allowed = await task.value
// Only clear the slot if this probe is still the current one: an
// `invalidate()` while we were suspended has already cleared it and a
// newer probe may occupy it (do not clobber the fresh task).
if probeGeneration == generation { inFlight = nil }
return allowed
}
private func isFreshlyVerified() -> Bool {
guard let last = lastVerifiedAt else { return false }
return now().timeIntervalSince(last) < ttl
}
private func decision(for result: ProbeResult) -> Bool {
switch result {
case .verifiedTor: return true
// Fail closed: both a positively detected leak and an unverifiable
// egress refuse connections. Only a fresh `verifiedTor` allows.
case .unreachable, .notTor: return false
}
}
private func runProbe(generation: Int) async -> Bool {
let result = await boundedProbe()
// Superseded by `invalidate()` (Tor restart/dormant/shutdown) while the
// probe ran: its verdict predates the reset, so discard it recording
// it would re-seed the throttle/cache that invalidate() just cleared.
// Fail closed for the callers that were awaiting this probe.
guard generation == probeGeneration else { return false }
lastProbeAt = now()
lastResult = result
switch result {
case .verifiedTor:
lastVerifiedAt = now()
verifiedSnapshot.update(now().addingTimeInterval(ttl))
return true
case .notTor:
lastVerifiedAt = nil
verifiedSnapshot.update(nil)
SecureLogger.error(
"🧅 Tor egress self-check FAILED: request exited via a NON-Tor address — refusing relay connections (possible IP leak)",
category: .session
)
return false
case .unreachable(let why):
// Note: a probe only runs when no fresh cached verdict exists, so
// there is no still-valid cache to preserve or drop here.
SecureLogger.warning(
"🧅 Tor egress self-check could not complete (\(why)) — egress UNVERIFIED; refusing relay connections until the canary succeeds (bounded retry)",
category: .session
)
return false
}
}
/// Runs the injected probe raced against `probeTimeout`, guaranteeing a
/// result in bounded time regardless of URLSession timer behavior (the
/// proxied session's `waitsForConnectivity` can defer the per-request
/// timeout indefinitely). Whichever side loses the race is cancelled:
/// - on timeout, the probe task is cancelled (URLSession's async APIs
/// cancel the underlying `URLSessionTask` cooperatively) and the result
/// is the fail-closed `.unreachable`;
/// - on completion, the watchdog's sleep is cancelled so no timer lingers.
/// Cancelling the enclosing task (`invalidate()`) resolves immediately as
/// `.unreachable` and cancels both sides.
///
/// `nonisolated` so the race body never re-enters the actor; it touches
/// only immutable `Sendable` state.
nonisolated private func boundedProbe() async -> ProbeResult {
let probe = self.probe
let timeout = self.probeTimeout
let race = ProbeRace()
return await withTaskCancellationHandler {
await withCheckedContinuation { (continuation: CheckedContinuation<ProbeResult, Never>) in
race.install(continuation)
let probeTask = Task { race.finish(await probe()) }
let watchdog = Task {
try? await Task.sleep(nanoseconds: UInt64(max(0, timeout) * 1_000_000_000))
guard !Task.isCancelled else { return }
race.finish(.unreachable("probe timed out after \(Int(timeout))s"))
}
race.register(probeTask: probeTask, watchdog: watchdog)
}
} onCancel: {
race.finish(.unreachable("probe cancelled"))
}
}
/// Resolve-once rendezvous for the probe/watchdog race. Lock-protected
/// (never held across an await); the first `finish()` wins, resumes the
/// continuation exactly once, and cancels both tasks.
private final class ProbeRace: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<ProbeResult, Never>?
private var pendingResult: ProbeResult?
private var resolved = false
private var probeTask: Task<Void, Never>?
private var watchdog: Task<Void, Never>?
func install(_ continuation: CheckedContinuation<ProbeResult, Never>) {
lock.lock()
if let result = pendingResult {
// finish() ran before the continuation existed (e.g. the
// enclosing task was already cancelled): resolve immediately.
pendingResult = nil
lock.unlock()
continuation.resume(returning: result)
return
}
self.continuation = continuation
lock.unlock()
}
func register(probeTask: Task<Void, Never>, watchdog: Task<Void, Never>) {
lock.lock()
if resolved {
lock.unlock()
probeTask.cancel()
watchdog.cancel()
return
}
self.probeTask = probeTask
self.watchdog = watchdog
lock.unlock()
}
func finish(_ result: ProbeResult) {
lock.lock()
guard !resolved else { lock.unlock(); return }
resolved = true
let continuation = self.continuation
self.continuation = nil
if continuation == nil { pendingResult = result }
let probeTask = self.probeTask
let watchdog = self.watchdog
self.probeTask = nil
self.watchdog = nil
lock.unlock()
probeTask?.cancel()
watchdog?.cancel()
continuation?.resume(returning: result)
}
}
}
// MARK: - Live probe
public extension TorEgressVerifier {
/// Default canary: fetch Tor Project's connectivity check API through the
/// shared proxied session and assert `IsTor == true`. Because the response
/// is served from the *exit's* vantage point, a silent direct egress is
/// caught here as `.notTor`. `check.torproject.org` is clearnet, so this
/// works without onion-service support.
///
/// Follow-up (see PR): make the canary endpoint configurable and add an
/// onion-service canary so verification does not depend on a single host.
/// Note: the per-request `timeoutInterval` below is best-effort only the
/// proxied session's `waitsForConnectivity` can defer it. The authoritative
/// bound is the verifier's `probeTimeout` watchdog, whose cancellation
/// propagates into `session.data(for:)` and cancels the URLSessionTask.
static func liveProbe(
endpoint: URL = URL(string: "https://check.torproject.org/api/ip")!,
timeout: TimeInterval = TorEgressVerifier.defaultProbeTimeout
) -> @Sendable () async -> ProbeResult {
return {
var request = URLRequest(url: endpoint)
request.timeoutInterval = timeout
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
let session = TorURLSession.shared.session
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
return .unreachable("http status \((response as? HTTPURLResponse)?.statusCode ?? -1)")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return .unreachable("unparseable canary response")
}
if let isTor = json["IsTor"] as? Bool {
return isTor ? .verifiedTor : .notTor
}
return .unreachable("canary response missing IsTor")
} catch {
return .unreachable(error.localizedDescription)
}
}
}
}
@@ -59,14 +59,6 @@ public final class TorManager: ObservableObject {
private var socksReady: Bool = false { didSet { recomputeReady() } }
private var restarting: Bool = false
/// Runtime egress self-check: proves the proxied session actually exits via
/// Tor before relay connections are opened (defense-in-depth against a
/// platform silently ignoring the SOCKS proxy). Cached for a few minutes.
public let egressVerifier = TorEgressVerifier(
ttl: 300,
probe: TorEgressVerifier.liveProbe()
)
// Whether the app must enforce Tor for all connections (fail-closed).
public var torEnforced: Bool {
#if BITCHAT_DEV_ALLOW_CLEARNET
@@ -133,32 +125,6 @@ public final class TorManager: ObservableObject {
return await MainActor.run(body: { self.networkPermitted })
}
/// Synchronous, cached view of the egress gate: `true` while a positive
/// egress verification is within its TTL (or when Tor is not enforced).
/// When this is `false`, callers must route through `awaitEgressReady()`
/// (which probes) before opening any connection never connect directly.
public var isEgressVerified: Bool {
guard torEnforced else { return true }
return egressVerifier.hasFreshVerification
}
/// Like `awaitReady`, but additionally requires that a canary request
/// through the proxied session positively verifies Tor egress. Returns
/// `false` if Tor never became ready, or if the egress self-check could
/// not positively verify a Tor exit (non-Tor egress detected, or canary
/// unreachable unverified). Callers must fail closed on `false` never
/// fall back to a direct connection.
nonisolated
public func awaitEgressReady(timeout: TimeInterval = 75.0) async -> Bool {
let ready = await awaitReady(timeout: timeout)
guard ready else { return false }
// Clearnet dev builds don't route through Tor, so the canary would
// (correctly) report non-Tor; skip it there.
let enforced = await MainActor.run { self.torEnforced }
guard enforced else { return true }
return await egressVerifier.verify()
}
// MARK: - Filesystem
func dataDirectoryURL() -> URL? {
@@ -359,8 +325,6 @@ public final class TorManager: ObservableObject {
self.socksReady = false
self.isStarting = false
}
// Force a fresh egress self-check once Tor comes back.
Task { await egressVerifier.invalidate() }
}
public func shutdownCompletely() {
@@ -389,7 +353,6 @@ public final class TorManager: ObservableObject {
// Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded()
// Clearing it here races with startup and defeats the grace period
}
await self.egressVerifier.invalidate()
}
}
@@ -405,8 +368,6 @@ public final class TorManager: ObservableObject {
self.isDormant = false
self.lastRestartAt = Date()
}
// New Arti instance means new circuits; re-verify egress after restart.
await egressVerifier.invalidate()
_ = arti_stop()
@@ -1,90 +0,0 @@
// Standalone harness: does Apple's URLSession honor connectionProxyDictionary
// SOCKS settings for a plain HTTPS GET and for URLSessionWebSocketTask?
//
// Build: swiftc -O proxy_probe.swift -o proxy_probe
// Usage: proxy_probe <http|ws> <cf|raw> <proxyPort> [targetURL]
//
// Prints a single RESULT line: RESULT <mode> <keyStyle> <outcome> <detail>
// The caller correlates this with the SOCKS proxy's connection log to decide
// whether the request was proxied.
#if canImport(CFNetwork)
import CFNetwork
#endif
import Foundation
let args = CommandLine.arguments
guard args.count >= 4 else {
FileHandle.standardError.write(Data("usage: proxy_probe <http|ws> <cf|raw> <proxyPort> [targetURL]\n".utf8))
exit(2)
}
let mode = args[1]
let keyStyle = args[2]
let proxyPort = Int(args[3]) ?? 19999
let host = "127.0.0.1"
func makeProxyDict() -> [AnyHashable: Any] {
switch keyStyle {
#if os(macOS)
case "cf":
// The exact constants the app uses on macOS.
return [
kCFNetworkProxiesSOCKSEnable as String: 1,
kCFNetworkProxiesSOCKSProxy as String: host,
kCFNetworkProxiesSOCKSPort as String: proxyPort
]
#endif
default:
// The exact raw string keys the app uses on iOS.
return [
"SOCKSEnable": 1,
"SOCKSProxy": host,
"SOCKSPort": proxyPort
]
}
}
let cfg = URLSessionConfiguration.ephemeral
cfg.waitsForConnectivity = false
cfg.timeoutIntervalForRequest = 20
cfg.connectionProxyDictionary = makeProxyDict()
let session = URLSession(configuration: cfg)
func emit(_ outcome: String, _ detail: String) {
print("RESULT \(mode) \(keyStyle) \(outcome) \(detail)")
exit(outcome == "ERROR" ? 1 : 0)
}
let sem = DispatchSemaphore(value: 0)
if mode == "http" {
let target = URL(string: args.count >= 5 ? args[4] : "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
let task = session.dataTask(with: target) { data, resp, err in
if let err = err {
emit("ERROR", "\(err.localizedDescription)")
} else if let http = resp as? HTTPURLResponse {
emit("OK", "status=\(http.statusCode) bytes=\(data?.count ?? 0)")
} else {
emit("OK", "bytes=\(data?.count ?? 0)")
}
}
task.resume()
} else {
// WebSocket
let target = URL(string: args.count >= 5 ? args[4] : "wss://relay.damus.io")!
let ws = session.webSocketTask(with: target)
ws.resume()
// A successful ping proves the TLS+WS handshake completed end-to-end.
ws.sendPing { err in
if let err = err {
emit("ERROR", "\(err.localizedDescription)")
} else {
emit("OK", "ws-ping-ok")
}
}
}
// Global watchdog so we never hang.
DispatchQueue.global().asyncAfter(deadline: .now() + 25) {
emit("ERROR", "timeout")
}
sem.wait()
@@ -1,73 +0,0 @@
#!/usr/bin/env bash
# Orchestrates the Tor-egress proxy-honoring verification on macOS.
#
# For each (request-type x key-style) it runs two experiments:
# A) proxy UP — did a connection arrive at the SOCKS proxy? (log grows)
# B) proxy DOWN — pointed at a dead port; does the request still SUCCEED?
# If it succeeds with no proxy, egress went DIRECT (proxy ignored).
# If it fails, the proxy setting is being enforced (fail-closed).
#
# Discriminator: PROXIED = connection observed at proxy AND fails when proxy down
# DIRECT = no connection at proxy OR succeeds when proxy down
set -u
DIR="$(cd "$(dirname "$0")" && pwd)"
PORT=19999
DEADPORT=19998 # nothing listens here
LOG="$(mktemp -t sockslog)"
BIN="$(mktemp -t proxyprobe)"
echo "== building swift probe =="
swiftc -O "$DIR/proxy_probe.swift" -o "$BIN" || { echo "swiftc failed"; exit 1; }
echo "== starting SOCKS proxy on $PORT =="
: > "$LOG"
python3 "$DIR/socks5_probe_proxy.py" "$PORT" "$LOG" >/tmp/socksproxy.out 2>&1 &
PROXY_PID=$!
trap 'kill $PROXY_PID 2>/dev/null' EXIT
# wait for READY
for _ in $(seq 1 50); do
grep -q READY /tmp/socksproxy.out 2>/dev/null && break
sleep 0.1
done
run_case() {
local mode="$1" key="$2"
# Experiment A: proxy up, watch log
local before after target
before=$(wc -l < "$LOG" | tr -d ' ')
local outA
outA=$("$BIN" "$mode" "$key" "$PORT" 2>/dev/null | grep '^RESULT' || echo "RESULT $mode $key ERROR no-output")
sleep 0.3
after=$(wc -l < "$LOG" | tr -d ' ')
local proxied="NO"
if [ "$after" -gt "$before" ]; then proxied="YES"; fi
local newlines
newlines=$(tail -n +"$((before+1))" "$LOG" | tr '\t' ' ' | tr '\n' '|')
# Experiment B: proxy down (dead port), same request
local outB
outB=$("$BIN" "$mode" "$key" "$DEADPORT" 2>/dev/null | grep '^RESULT' || echo "RESULT $mode $key ERROR no-output")
echo "----------------------------------------"
echo "CASE mode=$mode key=$key"
echo " A(proxy up): $outA | connection_at_proxy=$proxied [$newlines]"
echo " B(proxy down): $outB"
# verdict
local a_ok b_ok
a_ok=$(echo "$outA" | awk '{print $4}')
b_ok=$(echo "$outB" | awk '{print $4}')
local verdict="UNKNOWN"
if [ "$proxied" = "YES" ] && [ "$b_ok" = "ERROR" ]; then verdict="PROXIED (enforced)"; fi
if [ "$proxied" = "NO" ] && [ "$b_ok" = "OK" ]; then verdict="DIRECT (proxy ignored)"; fi
if [ "$proxied" = "YES" ] && [ "$b_ok" = "OK" ]; then verdict="AMBIGUOUS (uses proxy if up, but egresses direct if down)"; fi
if [ "$proxied" = "NO" ] && [ "$b_ok" = "ERROR" ]; then verdict="BLOCKED both (network/target issue?)"; fi
echo " VERDICT: $verdict"
}
for mode in http ws; do
for key in cf raw; do
run_case "$mode" "$key"
done
done
echo "========================================"
echo "raw proxy log:"; cat "$LOG" | tr '\t' ' '
@@ -1,166 +0,0 @@
#!/usr/bin/env python3
"""
Minimal threaded SOCKS5 CONNECT proxy used to verify whether Apple's
URLSession actually honors `connectionProxyDictionary` SOCKS settings for
different request types (plain HTTPS vs URLSessionWebSocketTask).
Behavior:
- Speaks enough SOCKS5 (no-auth) to complete a CONNECT and then relays
bytes bidirectionally to the real destination.
- Every accepted CONNECT is appended to a log file as one line:
<iso8601>\tCONNECT\t<host>:<port>
- Any raw connection that is NOT valid SOCKS5 is logged as:
<iso8601>\tNON_SOCKS\t<first-bytes-hex>
(this catches the feared case where URLSession sends a raw TLS/HTTP
ClientHello straight at the proxy port instead of a SOCKS greeting).
If a request egresses DIRECTLY (proxy ignored), nothing is logged at all.
Usage: socks5_probe_proxy.py <listen_port> <log_file>
"""
import selectors
import socket
import sys
import threading
from datetime import datetime, timezone
LOG_LOCK = threading.Lock()
def log(logfile, kind, detail):
line = f"{datetime.now(timezone.utc).isoformat()}\t{kind}\t{detail}\n"
with LOG_LOCK:
with open(logfile, "a") as f:
f.write(line)
sys.stderr.write("[proxy] " + line)
sys.stderr.flush()
def recv_exact(sock, n):
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
return None
buf += chunk
return buf
def handle(client, logfile):
client.settimeout(15)
try:
# SOCKS5 greeting: VER=0x05, NMETHODS, METHODS...
head = recv_exact(client, 2)
if not head:
return
if head[0] != 0x05:
# Not SOCKS5 at all — this is the smoking gun for a direct egress
# that mistakenly hit the proxy port. Log the first bytes.
rest = b""
try:
client.setblocking(False)
rest = client.recv(64)
except Exception:
pass
log(logfile, "NON_SOCKS", (head + rest).hex())
return
nmethods = head[1]
if nmethods:
recv_exact(client, nmethods)
# Reply: no authentication required
client.sendall(b"\x05\x00")
# Request: VER, CMD, RSV, ATYP, ADDR, PORT
req = recv_exact(client, 4)
if not req or req[1] != 0x01: # only CONNECT
client.sendall(b"\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00")
return
atyp = req[3]
if atyp == 0x01: # IPv4
addr = socket.inet_ntoa(recv_exact(client, 4))
elif atyp == 0x03: # domain
ln = recv_exact(client, 1)[0]
addr = recv_exact(client, ln).decode("ascii", errors="replace")
elif atyp == 0x04: # IPv6
addr = socket.inet_ntop(socket.AF_INET6, recv_exact(client, 16))
else:
client.sendall(b"\x05\x08\x00\x01\x00\x00\x00\x00\x00\x00")
return
port = int.from_bytes(recv_exact(client, 2), "big")
log(logfile, "CONNECT", f"{addr}:{port}")
# Connect to the real destination and reply success.
try:
remote = socket.create_connection((addr, port), timeout=15)
except Exception as e:
log(logfile, "CONNECT_FAIL", f"{addr}:{port} {e}")
client.sendall(b"\x05\x01\x00\x01\x00\x00\x00\x00\x00\x00")
return
client.sendall(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
relay(client, remote)
except Exception:
pass
finally:
try:
client.close()
except Exception:
pass
def relay(a, b):
a.setblocking(False)
b.setblocking(False)
sel = selectors.DefaultSelector()
sel.register(a, selectors.EVENT_READ, b)
sel.register(b, selectors.EVENT_READ, a)
try:
while True:
events = sel.select(timeout=30)
if not events:
break
for key, _ in events:
src = key.fileobj
dst = key.data
try:
data = src.recv(65536)
except (BlockingIOError, InterruptedError):
continue
except Exception:
return
if not data:
return
try:
dst.sendall(data)
except Exception:
return
finally:
sel.close()
for s in (a, b):
try:
s.close()
except Exception:
pass
def main():
if len(sys.argv) != 3:
print("usage: socks5_probe_proxy.py <port> <logfile>", file=sys.stderr)
sys.exit(2)
port = int(sys.argv[1])
logfile = sys.argv[2]
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", port))
srv.listen(64)
sys.stderr.write(f"[proxy] listening on 127.0.0.1:{port}, log={logfile}\n")
sys.stderr.flush()
print("READY", flush=True)
while True:
client, _ = srv.accept()
threading.Thread(target=handle, args=(client, logfile), daemon=True).start()
if __name__ == "__main__":
main()