mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:05:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6346870250 | ||
|
|
74f2cd98ab | ||
|
|
ad5fb1ddc6 | ||
|
|
c7ee2a4cb4 |
@@ -80,4 +80,3 @@ build.log
|
|||||||
|
|
||||||
# Local configs
|
# Local configs
|
||||||
Local.xcconfig
|
Local.xcconfig
|
||||||
*.profraw
|
|
||||||
|
|||||||
@@ -141,44 +141,22 @@ enum TrustLevel: String, Codable {
|
|||||||
struct IdentityCache: Codable {
|
struct IdentityCache: Codable {
|
||||||
// Fingerprint -> Social mapping
|
// Fingerprint -> Social mapping
|
||||||
var socialIdentities: [String: SocialIdentity] = [:]
|
var socialIdentities: [String: SocialIdentity] = [:]
|
||||||
|
|
||||||
// Nickname -> [Fingerprints] reverse index
|
// Nickname -> [Fingerprints] reverse index
|
||||||
// Multiple fingerprints can claim same nickname
|
// Multiple fingerprints can claim same nickname
|
||||||
var nicknameIndex: [String: Set<String>] = [:]
|
var nicknameIndex: [String: Set<String>] = [:]
|
||||||
|
|
||||||
// Verified fingerprints (cryptographic proof)
|
// Verified fingerprints (cryptographic proof)
|
||||||
var verifiedFingerprints: Set<String> = []
|
var verifiedFingerprints: Set<String> = []
|
||||||
|
|
||||||
// Last interaction timestamps (privacy: optional)
|
// Last interaction timestamps (privacy: optional)
|
||||||
var lastInteractions: [String: Date] = [:]
|
var lastInteractions: [String: Date] = [:]
|
||||||
|
|
||||||
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||||
var blockedNostrPubkeys: Set<String> = []
|
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
|
// Schema version for future migrations
|
||||||
var version: Int = 1
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -145,29 +145,18 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// In-memory state
|
// In-memory state
|
||||||
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||||
// Cryptographic identities (including pinned signing keys) live inside
|
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||||
// `cache` so they persist across app restarts; see IdentityCache.
|
|
||||||
private var cache: IdentityCache = IdentityCache()
|
private var cache: IdentityCache = IdentityCache()
|
||||||
|
|
||||||
// Thread safety
|
// Thread safety
|
||||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||||
|
|
||||||
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
||||||
//
|
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
||||||
// Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt
|
// than a retained DispatchSourceTimer: a lingering, never-cancelled timer
|
||||||
// + keychain write inside `queue.sync(flags: .barrier)`, so when the call
|
// keeps the dispatch machinery alive and prevents the unit-test process from
|
||||||
// returns the write is already complete and NOTHING is left scheduled on
|
// exiting. (The original code used Timer.scheduledTimer on a GCD queue with
|
||||||
// the queue. This is deliberate — a retained DispatchSourceTimer (the
|
// no run loop, so saves never actually fired.)
|
||||||
// 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
|
private var pendingSave = false
|
||||||
|
|
||||||
// Encryption key
|
// Encryption key
|
||||||
@@ -227,22 +216,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
// Do NOT dispatch onto `queue` here. `deinit` can run on any thread
|
forceSave()
|
||||||
// (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
|
// MARK: - Secure Loading/Saving
|
||||||
@@ -267,27 +241,21 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persists the cache. Always invoked on `queue` under a barrier (its
|
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
|
||||||
/// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read
|
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
|
||||||
/// while serialized. The encode + keychain write are done here (already on
|
/// and persists it on the same serialized context — no timer, nothing left
|
||||||
/// the exclusive barrier context), synchronously, so no separate hop is
|
/// scheduled to keep the process alive.
|
||||||
/// scheduled and nothing is left to keep the process alive.
|
|
||||||
private func saveIdentityCache() {
|
private func saveIdentityCache() {
|
||||||
pendingSave = true
|
pendingSave = true
|
||||||
// On the barrier context already: snapshot is trivially consistent.
|
performSave()
|
||||||
persist(snapshot: cache)
|
|
||||||
pendingSave = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encodes, seals, and writes a *snapshot* of the cache to the keychain.
|
/// Writes the cache to the keychain. Must run on `queue` with exclusive
|
||||||
///
|
/// (barrier) access.
|
||||||
/// Takes the cache by value so callers can capture a consistent snapshot
|
private func performSave() {
|
||||||
/// under `queue` and then encode without holding it. Reading `cache`
|
guard pendingSave else { return }
|
||||||
/// concurrently with a barrier writer would be a data race on the
|
pendingSave = false
|
||||||
/// 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
|
// Never persist under an ephemeral key — it would overwrite the real
|
||||||
// cache with data the next launch cannot decrypt.
|
// cache with data the next launch cannot decrypt.
|
||||||
guard !encryptionKeyIsEphemeral else {
|
guard !encryptionKeyIsEphemeral else {
|
||||||
@@ -296,7 +264,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(snapshot)
|
let data = try JSONEncoder().encode(cache)
|
||||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||||
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
|
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
|
||||||
if saved {
|
if saved {
|
||||||
@@ -307,26 +275,14 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force a flush (for app-termination / lifecycle events — NOT from
|
// Force immediate save (for app termination / lifecycle events). Mutations
|
||||||
// `deinit`, which persists inline; see the deinit note). Every mutating
|
// already persist synchronously via saveIdentityCache, so this is normally a
|
||||||
// API already persists inline inside its own barrier via
|
// no-op (performSave early-returns when nothing is pending). Runs directly on
|
||||||
// `saveIdentityCache`, so by the time this is called the keychain is
|
// the caller's thread — deliberately NOT a `queue.sync(barrier)`, which is
|
||||||
// already up to date and this is normally a no-op; it exists as a
|
// reachable from `deinit` and from async tests on the swift-concurrency
|
||||||
// belt-and-suspenders flush of any `pendingSave` left set.
|
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
|
||||||
//
|
|
||||||
// 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() {
|
func forceSave() {
|
||||||
queue.sync(flags: .barrier) {
|
performSave()
|
||||||
guard pendingSave else { return }
|
|
||||||
pendingSave = false
|
|
||||||
persist(snapshot: cache)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Social Identity Management
|
// MARK: - Social Identity Management
|
||||||
@@ -340,33 +296,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
// MARK: - Cryptographic Identities
|
// MARK: - Cryptographic Identities
|
||||||
|
|
||||||
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
|
/// 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:
|
/// - Parameters:
|
||||||
/// - fingerprint: SHA-256 hex of the Noise static public key
|
/// - fingerprint: SHA-256 hex of the Noise static public key
|
||||||
/// - noisePublicKey: Noise static public key data
|
/// - noisePublicKey: Noise static public key data
|
||||||
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
|
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
|
||||||
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
|
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
|
||||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
|
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
|
||||||
queue.sync(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if var existing = self.cache.cryptographicIdentities[fingerprint] {
|
if var existing = self.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
|
// Update keys if changed
|
||||||
if existing.publicKey != noisePublicKey {
|
if existing.publicKey != noisePublicKey {
|
||||||
existing = CryptographicIdentity(
|
existing = CryptographicIdentity(
|
||||||
@@ -376,7 +314,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
firstSeen: existing.firstSeen,
|
firstSeen: existing.firstSeen,
|
||||||
lastHandshake: now
|
lastHandshake: now
|
||||||
)
|
)
|
||||||
self.cache.cryptographicIdentities[fingerprint] = existing
|
self.cryptographicIdentities[fingerprint] = existing
|
||||||
} else {
|
} else {
|
||||||
// Update signing key and lastHandshake
|
// Update signing key and lastHandshake
|
||||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||||
@@ -387,7 +325,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
firstSeen: existing.firstSeen,
|
firstSeen: existing.firstSeen,
|
||||||
lastHandshake: now
|
lastHandshake: now
|
||||||
)
|
)
|
||||||
self.cache.cryptographicIdentities[fingerprint] = updated
|
self.cryptographicIdentities[fingerprint] = updated
|
||||||
}
|
}
|
||||||
// Persist updated state (already assigned in branches above)
|
// Persist updated state (already assigned in branches above)
|
||||||
} else {
|
} else {
|
||||||
@@ -399,7 +337,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
firstSeen: now,
|
firstSeen: now,
|
||||||
lastHandshake: now
|
lastHandshake: now
|
||||||
)
|
)
|
||||||
self.cache.cryptographicIdentities[fingerprint] = entry
|
self.cryptographicIdentities[fingerprint] = entry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally persist claimed nickname into social identity
|
// Optionally persist claimed nickname into social identity
|
||||||
@@ -431,12 +369,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
queue.sync {
|
queue.sync {
|
||||||
// Defensive: ensure hex and correct length
|
// Defensive: ensure hex and correct length
|
||||||
guard peerID.isShort else { return [] }
|
guard peerID.isShort else { return [] }
|
||||||
return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||||
queue.sync(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
||||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||||
|
|
||||||
@@ -472,7 +410,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
|
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
|
||||||
queue.sync(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||||
identity.isFavorite = isFavorite
|
identity.isFavorite = isFavorite
|
||||||
self.cache.socialIdentities[fingerprint] = identity
|
self.cache.socialIdentities[fingerprint] = identity
|
||||||
@@ -510,7 +448,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||||
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
|
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
|
||||||
|
|
||||||
queue.sync(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||||
identity.isBlocked = isBlocked
|
identity.isBlocked = isBlocked
|
||||||
if isBlocked {
|
if isBlocked {
|
||||||
@@ -544,7 +482,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
||||||
let key = pubkeyHexLowercased.lowercased()
|
let key = pubkeyHexLowercased.lowercased()
|
||||||
queue.sync(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
if isBlocked {
|
if isBlocked {
|
||||||
self.cache.blockedNostrPubkeys.insert(key)
|
self.cache.blockedNostrPubkeys.insert(key)
|
||||||
} else {
|
} else {
|
||||||
@@ -561,7 +499,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
// MARK: - Ephemeral Session Management
|
// MARK: - Ephemeral Session Management
|
||||||
|
|
||||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||||
queue.sync(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
sessionStart: Date(),
|
sessionStart: Date(),
|
||||||
@@ -571,7 +509,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||||
queue.sync(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||||
|
|
||||||
// If handshake completed, update last interaction
|
// If handshake completed, update last interaction
|
||||||
@@ -587,10 +525,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
func clearAllIdentityData() {
|
func clearAllIdentityData() {
|
||||||
SecureLogger.warning("Clearing all identity data", category: .security)
|
SecureLogger.warning("Clearing all identity data", category: .security)
|
||||||
|
|
||||||
queue.sync(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.cache = IdentityCache()
|
self.cache = IdentityCache()
|
||||||
self.ephemeralSessions.removeAll()
|
self.ephemeralSessions.removeAll()
|
||||||
|
self.cryptographicIdentities.removeAll()
|
||||||
|
|
||||||
// Delete from keychain
|
// Delete from keychain
|
||||||
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
|
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
|
||||||
SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
|
SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
|
||||||
@@ -598,7 +537,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func removeEphemeralSession(peerID: PeerID) {
|
func removeEphemeralSession(peerID: PeerID) {
|
||||||
queue.sync(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -608,7 +547,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
func setVerified(fingerprint: String, verified: Bool) {
|
func setVerified(fingerprint: String, verified: Bool) {
|
||||||
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
|
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
|
||||||
|
|
||||||
queue.sync(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
if verified {
|
if verified {
|
||||||
self.cache.verifiedFingerprints.insert(fingerprint)
|
self.cache.verifiedFingerprints.insert(fingerprint)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ private extension GeoRelayDirectoryDependencies {
|
|||||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||||
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
||||||
awaitTorReady: { await TorManager.shared.awaitReady() },
|
awaitTorReady: { await TorManager.shared.awaitEgressReady() },
|
||||||
makeFetchData: {
|
makeFetchData: {
|
||||||
let session = TorURLSession.shared.session
|
let session = TorURLSession.shared.session
|
||||||
return { request in
|
return { request in
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ struct NostrRelayManagerDependencies {
|
|||||||
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
||||||
var torEnforced: () -> Bool
|
var torEnforced: () -> Bool
|
||||||
var torIsReady: () -> 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 torIsForeground: () -> Bool
|
||||||
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
|
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
|
||||||
var makeSession: () -> NostrRelaySessionProtocol
|
var makeSession: () -> NostrRelaySessionProtocol
|
||||||
@@ -83,10 +88,14 @@ private extension NostrRelayManagerDependencies {
|
|||||||
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
|
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
|
||||||
torEnforced: { TorManager.shared.torEnforced },
|
torEnforced: { TorManager.shared.torEnforced },
|
||||||
torIsReady: { TorManager.shared.isReady },
|
torIsReady: { TorManager.shared.isReady },
|
||||||
|
torEgressVerified: { TorManager.shared.isEgressVerified },
|
||||||
torIsForeground: { TorManager.shared.isForeground() },
|
torIsForeground: { TorManager.shared.isForeground() },
|
||||||
awaitTorReady: { completion in
|
awaitTorReady: { completion in
|
||||||
Task.detached {
|
Task.detached {
|
||||||
let ready = await TorManager.shared.awaitReady()
|
// 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()
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
completion(ready)
|
completion(ready)
|
||||||
}
|
}
|
||||||
@@ -625,8 +634,14 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Private Methods
|
// 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 {
|
private var shouldWaitForTorBeforeConnecting: Bool {
|
||||||
shouldUseTor && !dependencies.torIsReady()
|
shouldUseTor && (!dependencies.torIsReady() || !dependencies.torEgressVerified())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
|
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
|
||||||
@@ -674,16 +689,20 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
guard ready else {
|
guard ready else {
|
||||||
self.torReadyWaitAttempts += 1
|
self.torReadyWaitAttempts += 1
|
||||||
if self.torReadyWaitAttempts < TransportConfig.nostrTorReadyMaxWaitAttempts {
|
if self.torReadyWaitAttempts < TransportConfig.nostrTorReadyMaxWaitAttempts {
|
||||||
SecureLogger.warning("Tor not ready; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session)
|
SecureLogger.warning("Tor not ready or egress unverified; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session)
|
||||||
self.queueConnectionsUntilTorReady(pending)
|
self.queueConnectionsUntilTorReady(pending)
|
||||||
} else {
|
} else {
|
||||||
// Still fail-closed (no network), but unblock any callers
|
// Still fail-closed (no network), but unblock any callers
|
||||||
// waiting on EOSE so the UI doesn't hang indefinitely.
|
// waiting on EOSE so the UI doesn't hang indefinitely.
|
||||||
// Queued subscriptions/sends are kept and flush if a later
|
// Queued subscriptions/sends are kept; a bounded-cadence
|
||||||
// trigger (e.g. app foreground) brings Tor up.
|
// retry (below) re-enters the gate so a transient failure
|
||||||
SecureLogger.error("❌ Tor not ready after \(self.torReadyWaitAttempts) wait(s); aborting relay connections (fail-closed)", category: .session)
|
// (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)
|
||||||
self.torReadyWaitAttempts = 0
|
self.torReadyWaitAttempts = 0
|
||||||
self.unblockPendingEOSECallbacks(reason: "tor-unavailable")
|
self.unblockPendingEOSECallbacks(reason: "tor-unavailable")
|
||||||
|
self.scheduleTorGateRetry(pending)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -693,6 +712,24 @@ 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
|
/// Park an EOSE callback while Tor is not yet ready, and schedule the same
|
||||||
/// fallback timeout `startEOSETracking` uses. Without it, a parked callback
|
/// fallback timeout `startEOSETracking` uses. Without it, a parked callback
|
||||||
/// would only be unblocked by Tor-readiness retry exhaustion (several
|
/// would only be unblocked by Tor-readiness retry exhaustion (several
|
||||||
|
|||||||
@@ -14,14 +14,8 @@ struct BLEAnnounceHandlerEnvironment {
|
|||||||
let messageTTL: UInt8
|
let messageTTL: UInt8
|
||||||
/// Current time source.
|
/// Current time source.
|
||||||
let now: () -> Date
|
let now: () -> Date
|
||||||
/// Noise and signing public keys already recorded for the peer, if any
|
/// Noise public key already recorded for the peer, if any (registry read).
|
||||||
/// (single registry read so both come from one consistent snapshot).
|
let existingNoisePublicKey: (PeerID) -> Data?
|
||||||
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.
|
/// Verifies the packet signature against the announced signing key.
|
||||||
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||||
/// Direct link state for the peer (BLE-queue read).
|
/// Direct link state for the peer (BLE-queue read).
|
||||||
@@ -29,15 +23,13 @@ struct BLEAnnounceHandlerEnvironment {
|
|||||||
/// Runs the registry mutation phase under the collections barrier.
|
/// Runs the registry mutation phase under the collections barrier.
|
||||||
let withRegistryBarrier: (() -> Void) -> Void
|
let withRegistryBarrier: (() -> Void) -> Void
|
||||||
/// Upserts the verified announce into the peer registry.
|
/// 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`.
|
/// Must only be called from inside `withRegistryBarrier`.
|
||||||
let upsertVerifiedAnnounce: (
|
let upsertVerifiedAnnounce: (
|
||||||
_ peerID: PeerID,
|
_ peerID: PeerID,
|
||||||
_ announcement: AnnouncementPacket,
|
_ announcement: AnnouncementPacket,
|
||||||
_ isConnected: Bool,
|
_ isConnected: Bool,
|
||||||
_ now: Date
|
_ now: Date
|
||||||
) -> BLEPeerAnnounceUpdate?
|
) -> BLEPeerAnnounceUpdate
|
||||||
/// Debounced reconnect-log decision.
|
/// Debounced reconnect-log decision.
|
||||||
/// Must only be called from inside `withRegistryBarrier`.
|
/// Must only be called from inside `withRegistryBarrier`.
|
||||||
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
|
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
|
||||||
@@ -107,16 +99,7 @@ final class BLEAnnounceHandler {
|
|||||||
// Suppress announce logs to reduce noise
|
// Suppress announce logs to reduce noise
|
||||||
|
|
||||||
// Precompute signature verification outside barrier to reduce contention
|
// Precompute signature verification outside barrier to reduce contention
|
||||||
var existingPeerKeys = env.existingPeerKeys(peerID)
|
let existingNoisePublicKey = env.existingNoisePublicKey(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 hasSignature = packet.signature != nil
|
||||||
let signatureValid: Bool
|
let signatureValid: Bool
|
||||||
if hasSignature {
|
if hasSignature {
|
||||||
@@ -130,18 +113,13 @@ final class BLEAnnounceHandler {
|
|||||||
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
||||||
hasSignature: hasSignature,
|
hasSignature: hasSignature,
|
||||||
signatureValid: signatureValid,
|
signatureValid: signatureValid,
|
||||||
existingNoisePublicKey: existingPeerKeys.noisePublicKey,
|
existingNoisePublicKey: existingNoisePublicKey,
|
||||||
announcedNoisePublicKey: announcement.noisePublicKey,
|
announcedNoisePublicKey: announcement.noisePublicKey
|
||||||
existingSigningPublicKey: existingPeerKeys.signingPublicKey,
|
|
||||||
announcedSigningPublicKey: announcement.signingPublicKey
|
|
||||||
)
|
)
|
||||||
if case .reject(.keyMismatch) = trustDecision {
|
if case .reject(.keyMismatch) = trustDecision {
|
||||||
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
||||||
}
|
}
|
||||||
if case .reject(.signingKeyMismatch) = trustDecision {
|
let verifiedAnnounce = trustDecision.isVerified
|
||||||
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 isNewPeer = false
|
||||||
var isReconnectedPeer = false
|
var isReconnectedPeer = false
|
||||||
@@ -161,22 +139,12 @@ final class BLEAnnounceHandler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The registry re-checks the signing-key pin inside the barrier.
|
let update = env.upsertVerifiedAnnounce(
|
||||||
// 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,
|
peerID,
|
||||||
announcement,
|
announcement,
|
||||||
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||||
now
|
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
|
isNewPeer = update.isNewPeer
|
||||||
isReconnectedPeer = update.wasDisconnected
|
isReconnectedPeer = update.wasDisconnected
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ enum BLEAnnounceTrustRejection: Equatable {
|
|||||||
case missingSignature
|
case missingSignature
|
||||||
case invalidSignature
|
case invalidSignature
|
||||||
case keyMismatch
|
case keyMismatch
|
||||||
case signingKeyMismatch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BLEAnnounceTrustDecision: Equatable {
|
enum BLEAnnounceTrustDecision: Equatable {
|
||||||
@@ -73,25 +72,12 @@ enum BLEAnnounceTrustPolicy {
|
|||||||
hasSignature: Bool,
|
hasSignature: Bool,
|
||||||
signatureValid: Bool,
|
signatureValid: Bool,
|
||||||
existingNoisePublicKey: Data?,
|
existingNoisePublicKey: Data?,
|
||||||
announcedNoisePublicKey: Data,
|
announcedNoisePublicKey: Data
|
||||||
existingSigningPublicKey: Data?,
|
|
||||||
announcedSigningPublicKey: Data
|
|
||||||
) -> BLEAnnounceTrustDecision {
|
) -> BLEAnnounceTrustDecision {
|
||||||
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||||
return .reject(.keyMismatch)
|
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 {
|
guard hasSignature else {
|
||||||
return .reject(.missingSignature)
|
return .reject(.missingSignature)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,14 +150,6 @@ struct BLEPeerRegistry {
|
|||||||
peers[peerID] = peer
|
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(
|
mutating func upsertVerifiedAnnounce(
|
||||||
peerID: PeerID,
|
peerID: PeerID,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
@@ -165,15 +157,8 @@ struct BLEPeerRegistry {
|
|||||||
signingPublicKey: Data?,
|
signingPublicKey: Data?,
|
||||||
isConnected: Bool,
|
isConnected: Bool,
|
||||||
now: Date
|
now: Date
|
||||||
) -> BLEPeerAnnounceUpdate? {
|
) -> BLEPeerAnnounceUpdate {
|
||||||
let existing = peers[peerID]
|
let existing = peers[peerID]
|
||||||
|
|
||||||
if let pinnedSigningKey = existing?.signingPublicKey,
|
|
||||||
let announcedSigningKey = signingPublicKey,
|
|
||||||
pinnedSigningKey != announcedSigningKey {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let update = BLEPeerAnnounceUpdate(
|
let update = BLEPeerAnnounceUpdate(
|
||||||
isNewPeer: existing == nil,
|
isNewPeer: existing == nil,
|
||||||
wasDisconnected: existing?.isConnected == false,
|
wasDisconnected: existing?.isConnected == false,
|
||||||
@@ -185,8 +170,7 @@ struct BLEPeerRegistry {
|
|||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
isConnected: isConnected,
|
isConnected: isConnected,
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
// Never drop an already-pinned signing key.
|
signingPublicKey: signingPublicKey,
|
||||||
signingPublicKey: signingPublicKey ?? existing?.signingPublicKey,
|
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
lastSeen: now
|
lastSeen: now
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3028,21 +3028,9 @@ extension BLEService {
|
|||||||
},
|
},
|
||||||
messageTTL: messageTTL,
|
messageTTL: messageTTL,
|
||||||
now: { Date() },
|
now: { Date() },
|
||||||
existingPeerKeys: { [weak self] peerID in
|
existingNoisePublicKey: { [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 }
|
guard let self = self else { return nil }
|
||||||
return self.identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
|
||||||
.compactMap { $0.signingPublicKey }
|
|
||||||
.first
|
|
||||||
},
|
},
|
||||||
verifySignature: { [weak self] packet, signingPublicKey in
|
verifySignature: { [weak self] packet, signingPublicKey in
|
||||||
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||||||
@@ -3055,17 +3043,14 @@ extension BLEService {
|
|||||||
},
|
},
|
||||||
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
|
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
|
||||||
// Called from inside withRegistryBarrier; access registry directly.
|
// Called from inside withRegistryBarrier; access registry directly.
|
||||||
guard let self = self else {
|
self?.peerRegistry.upsertVerifiedAnnounce(
|
||||||
return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
|
||||||
}
|
|
||||||
return self.peerRegistry.upsertVerifiedAnnounce(
|
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
nickname: announcement.nickname,
|
nickname: announcement.nickname,
|
||||||
noisePublicKey: announcement.noisePublicKey,
|
noisePublicKey: announcement.noisePublicKey,
|
||||||
signingPublicKey: announcement.signingPublicKey,
|
signingPublicKey: announcement.signingPublicKey,
|
||||||
isConnected: isConnected,
|
isConnected: isConnected,
|
||||||
now: now
|
now: now
|
||||||
)
|
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||||
},
|
},
|
||||||
shouldEmitReconnectLog: { [weak self] peerID, now in
|
shouldEmitReconnectLog: { [weak self] peerID, now in
|
||||||
// Called from inside withRegistryBarrier; access debouncer directly.
|
// Called from inside withRegistryBarrier; access debouncer directly.
|
||||||
|
|||||||
@@ -171,6 +171,10 @@ enum TransportConfig {
|
|||||||
// How many consecutive Tor-readiness waits (each bounded by TorManager's
|
// How many consecutive Tor-readiness waits (each bounded by TorManager's
|
||||||
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
||||||
static let nostrTorReadyMaxWaitAttempts: Int = 3
|
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
|
static let nostrPendingSendQueueCap: Int = 200
|
||||||
// Sample interval for the send-queue overflow warning (first + every Nth
|
// Sample interval for the send-queue overflow warning (first + every Nth
|
||||||
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
//
|
||||||
|
// 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,12 +6,9 @@ import Testing
|
|||||||
struct BLEAnnounceHandlerTests {
|
struct BLEAnnounceHandlerTests {
|
||||||
private final class Recorder {
|
private final class Recorder {
|
||||||
var existingNoisePublicKey: Data?
|
var existingNoisePublicKey: Data?
|
||||||
var existingSigningPublicKey: Data?
|
|
||||||
var persistedSigningPublicKey: Data?
|
|
||||||
var persistedSigningKeyQueries: [PeerID] = []
|
|
||||||
var signatureValid = true
|
var signatureValid = true
|
||||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||||
var upsertResult: BLEPeerAnnounceUpdate? = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||||
var dedupSeenIDs: Set<String> = []
|
var dedupSeenIDs: Set<String> = []
|
||||||
var shouldEmitReconnectLogResult = true
|
var shouldEmitReconnectLogResult = true
|
||||||
|
|
||||||
@@ -38,11 +35,7 @@ struct BLEAnnounceHandlerTests {
|
|||||||
localPeerID: { localPeerID },
|
localPeerID: { localPeerID },
|
||||||
messageTTL: TransportConfig.messageTTLDefault,
|
messageTTL: TransportConfig.messageTTLDefault,
|
||||||
now: { now },
|
now: { now },
|
||||||
existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) },
|
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
||||||
persistedSigningPublicKey: { peerID in
|
|
||||||
recorder.persistedSigningKeyQueries.append(peerID)
|
|
||||||
return recorder.persistedSigningPublicKey
|
|
||||||
},
|
|
||||||
verifySignature: { packet, signingPublicKey in
|
verifySignature: { packet, signingPublicKey in
|
||||||
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
||||||
return recorder.signatureValid
|
return recorder.signatureValid
|
||||||
@@ -375,168 +368,6 @@ struct BLEAnnounceHandlerTests {
|
|||||||
#expect(recorder.topologyUpdates.first?.neighbors == neighbors)
|
#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
|
@Test
|
||||||
func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws {
|
func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws {
|
||||||
let now = Date(timeIntervalSince1970: 1_000)
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
@@ -560,251 +391,6 @@ struct BLEAnnounceHandlerTests {
|
|||||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
#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) {
|
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||||
#expect(recorder.barrierCount == 0)
|
#expect(recorder.barrierCount == 0)
|
||||||
#expect(recorder.upsertCalls.isEmpty)
|
#expect(recorder.upsertCalls.isEmpty)
|
||||||
|
|||||||
@@ -123,9 +123,7 @@ struct BLEAnnounceHandlingPolicyTests {
|
|||||||
hasSignature: false,
|
hasSignature: false,
|
||||||
signatureValid: false,
|
signatureValid: false,
|
||||||
existingNoisePublicKey: nil,
|
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))
|
#expect(decision == .reject(.missingSignature))
|
||||||
@@ -138,9 +136,7 @@ struct BLEAnnounceHandlingPolicyTests {
|
|||||||
hasSignature: true,
|
hasSignature: true,
|
||||||
signatureValid: false,
|
signatureValid: false,
|
||||||
existingNoisePublicKey: nil,
|
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))
|
#expect(decision == .reject(.invalidSignature))
|
||||||
@@ -152,9 +148,7 @@ struct BLEAnnounceHandlingPolicyTests {
|
|||||||
hasSignature: true,
|
hasSignature: true,
|
||||||
signatureValid: true,
|
signatureValid: true,
|
||||||
existingNoisePublicKey: Data(repeating: 0xAA, count: 32),
|
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))
|
#expect(decision == .reject(.keyMismatch))
|
||||||
@@ -168,51 +162,13 @@ struct BLEAnnounceHandlingPolicyTests {
|
|||||||
hasSignature: true,
|
hasSignature: true,
|
||||||
signatureValid: true,
|
signatureValid: true,
|
||||||
existingNoisePublicKey: noiseKey,
|
existingNoisePublicKey: noiseKey,
|
||||||
announcedNoisePublicKey: noiseKey,
|
announcedNoisePublicKey: noiseKey
|
||||||
existingSigningPublicKey: nil,
|
|
||||||
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(decision == .verified)
|
#expect(decision == .verified)
|
||||||
#expect(decision.isVerified)
|
#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
|
@Test
|
||||||
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
||||||
let directNew = BLEAnnounceResponsePolicy.plan(
|
let directNew = BLEAnnounceResponsePolicy.plan(
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import Testing
|
|||||||
@Suite("BLE peer registry tests")
|
@Suite("BLE peer registry tests")
|
||||||
struct BLEPeerRegistryTests {
|
struct BLEPeerRegistryTests {
|
||||||
@Test("upserted announces track new, reconnect, and rename transitions")
|
@Test("upserted announces track new, reconnect, and rename transitions")
|
||||||
func upsertVerifiedAnnounceTracksTransitions() throws {
|
func upsertVerifiedAnnounceTracksTransitions() {
|
||||||
var registry = BLEPeerRegistry()
|
var registry = BLEPeerRegistry()
|
||||||
let peerID = PeerID(str: "1122334455667788")
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
let firstSeen = Date(timeIntervalSince1970: 100)
|
let firstSeen = Date(timeIntervalSince1970: 100)
|
||||||
|
|
||||||
let firstResult = registry.upsertVerifiedAnnounce(
|
let first = registry.upsertVerifiedAnnounce(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
nickname: "alice",
|
nickname: "alice",
|
||||||
noisePublicKey: Data([1, 2, 3]),
|
noisePublicKey: Data([1, 2, 3]),
|
||||||
@@ -19,7 +19,6 @@ struct BLEPeerRegistryTests {
|
|||||||
isConnected: true,
|
isConnected: true,
|
||||||
now: firstSeen
|
now: firstSeen
|
||||||
)
|
)
|
||||||
let first = try #require(firstResult)
|
|
||||||
|
|
||||||
#expect(first.isNewPeer)
|
#expect(first.isNewPeer)
|
||||||
#expect(!first.wasDisconnected)
|
#expect(!first.wasDisconnected)
|
||||||
@@ -28,7 +27,7 @@ struct BLEPeerRegistryTests {
|
|||||||
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
|
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
|
||||||
|
|
||||||
registry.markDisconnected(peerID)
|
registry.markDisconnected(peerID)
|
||||||
let reconnectResult = registry.upsertVerifiedAnnounce(
|
let reconnect = registry.upsertVerifiedAnnounce(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
nickname: "alice-renamed",
|
nickname: "alice-renamed",
|
||||||
noisePublicKey: Data([1, 2, 3]),
|
noisePublicKey: Data([1, 2, 3]),
|
||||||
@@ -36,7 +35,6 @@ struct BLEPeerRegistryTests {
|
|||||||
isConnected: true,
|
isConnected: true,
|
||||||
now: firstSeen.addingTimeInterval(1)
|
now: firstSeen.addingTimeInterval(1)
|
||||||
)
|
)
|
||||||
let reconnect = try #require(reconnectResult)
|
|
||||||
|
|
||||||
#expect(!reconnect.isNewPeer)
|
#expect(!reconnect.isNewPeer)
|
||||||
#expect(reconnect.wasDisconnected)
|
#expect(reconnect.wasDisconnected)
|
||||||
@@ -44,85 +42,6 @@ struct BLEPeerRegistryTests {
|
|||||||
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
|
#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")
|
@Test("reachability keeps recent verified offline peers only when mesh is attached")
|
||||||
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
||||||
let offlinePeer = PeerID(str: "1122334455667788")
|
let offlinePeer = PeerID(str: "1122334455667788")
|
||||||
|
|||||||
@@ -111,6 +111,116 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(connected)
|
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 {
|
func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async {
|
||||||
let relayURL = "wss://tor-eose-unblock.example"
|
let relayURL = "wss://tor-eose-unblock.example"
|
||||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||||
@@ -1449,6 +1559,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
userTorEnabled: Bool = false,
|
userTorEnabled: Bool = false,
|
||||||
torEnforced: Bool = false,
|
torEnforced: Bool = false,
|
||||||
torIsReady: Bool = true,
|
torIsReady: Bool = true,
|
||||||
|
torEgressVerified: Bool = true,
|
||||||
torIsForeground: Bool = true,
|
torIsForeground: Bool = true,
|
||||||
jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter)
|
jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter)
|
||||||
) -> RelayManagerTestContext {
|
) -> RelayManagerTestContext {
|
||||||
@@ -1458,6 +1569,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
let scheduler = MockRelayScheduler()
|
let scheduler = MockRelayScheduler()
|
||||||
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
|
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
|
||||||
let torWaiter = MockTorWaiter(isReady: torIsReady)
|
let torWaiter = MockTorWaiter(isReady: torIsReady)
|
||||||
|
let torEgressVerifiedFlag = MutableBool(value: torEgressVerified)
|
||||||
let torForeground = MutableBool(value: torIsForeground)
|
let torForeground = MutableBool(value: torIsForeground)
|
||||||
let activationFlag = MutableBool(value: activationAllowed)
|
let activationFlag = MutableBool(value: activationAllowed)
|
||||||
let manager = NostrRelayManager(
|
let manager = NostrRelayManager(
|
||||||
@@ -1470,6 +1582,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
||||||
torEnforced: { torEnforced },
|
torEnforced: { torEnforced },
|
||||||
torIsReady: { torWaiter.isReady },
|
torIsReady: { torWaiter.isReady },
|
||||||
|
torEgressVerified: { torEgressVerifiedFlag.value },
|
||||||
torIsForeground: { torForeground.value },
|
torIsForeground: { torForeground.value },
|
||||||
awaitTorReady: torWaiter.await(completion:),
|
awaitTorReady: torWaiter.await(completion:),
|
||||||
makeSession: { sessionFactory },
|
makeSession: { sessionFactory },
|
||||||
@@ -1489,6 +1602,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
clock: clock,
|
clock: clock,
|
||||||
activationAllowed: activationFlag,
|
activationAllowed: activationFlag,
|
||||||
torWaiter: torWaiter,
|
torWaiter: torWaiter,
|
||||||
|
torEgressVerified: torEgressVerifiedFlag,
|
||||||
torForeground: torForeground
|
torForeground: torForeground
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1543,6 +1657,7 @@ private struct RelayManagerTestContext {
|
|||||||
let clock: MutableClock
|
let clock: MutableClock
|
||||||
let activationAllowed: MutableBool
|
let activationAllowed: MutableBool
|
||||||
let torWaiter: MockTorWaiter
|
let torWaiter: MockTorWaiter
|
||||||
|
let torEgressVerified: MutableBool
|
||||||
let torForeground: MutableBool
|
let torForeground: MutableBool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,100 +79,6 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey)
|
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 {
|
func test_setBlocked_clearsFavoriteState() async {
|
||||||
let manager = SecureIdentityStateManager(MockKeychain())
|
let manager = SecureIdentityStateManager(MockKeychain())
|
||||||
let fingerprint = String(repeating: "ab", count: 32)
|
let fingerprint = String(repeating: "ab", count: 32)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ let package = Package(
|
|||||||
"TorManager.swift",
|
"TorManager.swift",
|
||||||
"TorURLSession.swift",
|
"TorURLSession.swift",
|
||||||
"TorNotifications.swift",
|
"TorNotifications.swift",
|
||||||
|
"TorEgressVerifier.swift",
|
||||||
],
|
],
|
||||||
linkerSettings: [
|
linkerSettings: [
|
||||||
.linkedLibrary("resolv"),
|
.linkedLibrary("resolv"),
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
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,6 +59,14 @@ public final class TorManager: ObservableObject {
|
|||||||
private var socksReady: Bool = false { didSet { recomputeReady() } }
|
private var socksReady: Bool = false { didSet { recomputeReady() } }
|
||||||
private var restarting: Bool = false
|
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).
|
// Whether the app must enforce Tor for all connections (fail-closed).
|
||||||
public var torEnforced: Bool {
|
public var torEnforced: Bool {
|
||||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||||
@@ -125,6 +133,32 @@ public final class TorManager: ObservableObject {
|
|||||||
return await MainActor.run(body: { self.networkPermitted })
|
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
|
// MARK: - Filesystem
|
||||||
|
|
||||||
func dataDirectoryURL() -> URL? {
|
func dataDirectoryURL() -> URL? {
|
||||||
@@ -325,6 +359,8 @@ public final class TorManager: ObservableObject {
|
|||||||
self.socksReady = false
|
self.socksReady = false
|
||||||
self.isStarting = false
|
self.isStarting = false
|
||||||
}
|
}
|
||||||
|
// Force a fresh egress self-check once Tor comes back.
|
||||||
|
Task { await egressVerifier.invalidate() }
|
||||||
}
|
}
|
||||||
|
|
||||||
public func shutdownCompletely() {
|
public func shutdownCompletely() {
|
||||||
@@ -353,6 +389,7 @@ public final class TorManager: ObservableObject {
|
|||||||
// Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded()
|
// 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
|
// Clearing it here races with startup and defeats the grace period
|
||||||
}
|
}
|
||||||
|
await self.egressVerifier.invalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +405,8 @@ public final class TorManager: ObservableObject {
|
|||||||
self.isDormant = false
|
self.isDormant = false
|
||||||
self.lastRestartAt = Date()
|
self.lastRestartAt = Date()
|
||||||
}
|
}
|
||||||
|
// New Arti instance means new circuits; re-verify egress after restart.
|
||||||
|
await egressVerifier.invalidate()
|
||||||
|
|
||||||
_ = arti_stop()
|
_ = arti_stop()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// 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()
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
#!/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' ' '
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
#!/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()
|
||||||
Reference in New Issue
Block a user