mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 13:25:20 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6346870250 | ||
|
|
74f2cd98ab | ||
|
|
ad5fb1ddc6 | ||
|
|
68eeba97ff | ||
|
|
c7ee2a4cb4 | ||
|
|
f688e529f6 | ||
|
|
96e32ba990 | ||
|
|
0a2f4d9c9d | ||
|
|
914135adb0 | ||
|
|
cd7ffa0df9 | ||
|
|
bbe1ed0652 | ||
|
|
f07b032b99 | ||
|
|
2cbcb290f7 | ||
|
|
9cf7c80518 | ||
|
|
f76fd8a538 | ||
|
|
09c2c12838 | ||
|
|
8378ff949a | ||
|
|
ca63893197 | ||
|
|
fdf28aa5bb |
@@ -29,17 +29,22 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Swift
|
||||
uses: swift-actions/setup-swift@v2
|
||||
# Use the Xcode-bundled Swift toolchain: it always matches the SDK on
|
||||
# the runner image. A standalone swift.org toolchain (setup-swift) broke
|
||||
# whenever the image's Xcode moved ahead of it ("this SDK is not
|
||||
# supported by the compiler").
|
||||
- name: Note toolchain version (cache key)
|
||||
id: swift-version
|
||||
run: echo "version=$(swift --version 2>/dev/null | head -1 | shasum | cut -c1-12)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.path }}/.build
|
||||
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
key: ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
${{ runner.os }}-${{ matrix.name }}-
|
||||
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-
|
||||
|
||||
- name: Build tests
|
||||
# Built separately so the hang watchdog below times only test
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.5.2
|
||||
MARKETING_VERSION = 1.5.3
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
Generated
+4
-4
@@ -561,7 +561,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5.2;
|
||||
MARKETING_VERSION = 1.5.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -620,7 +620,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5.2;
|
||||
MARKETING_VERSION = 1.5.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -655,7 +655,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = 1.5.2;
|
||||
MARKETING_VERSION = 1.5.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -749,7 +749,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = 1.5.2;
|
||||
MARKETING_VERSION = 1.5.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -151,38 +151,68 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
// Thread safety
|
||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||
|
||||
// Debouncing for keychain saves
|
||||
private var saveTimer: Timer?
|
||||
private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds
|
||||
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
||||
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
||||
// than a retained DispatchSourceTimer: a lingering, never-cancelled timer
|
||||
// keeps the dispatch machinery alive and prevents the unit-test process from
|
||||
// exiting. (The original code used Timer.scheduledTimer on a GCD queue with
|
||||
// no run loop, so saves never actually fired.)
|
||||
private var pendingSave = false
|
||||
|
||||
|
||||
// Encryption key
|
||||
private let encryptionKey: SymmetricKey
|
||||
/// True when `encryptionKey` is a throwaway generated this session because the
|
||||
/// persisted key could not be read (device locked / access denied). In that
|
||||
/// state we must NOT persist (it would overwrite the real cache with data the
|
||||
/// next launch can't decrypt) and must NOT delete the existing cache.
|
||||
private let encryptionKeyIsEphemeral: Bool
|
||||
|
||||
init(_ keychain: KeychainManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
|
||||
// Generate or retrieve encryption key from keychain
|
||||
|
||||
// Retrieve (or, only on genuine first run, generate) the cache
|
||||
// encryption key. We MUST distinguish "key doesn't exist yet" from a
|
||||
// transient failure (device locked / access denied): the legacy
|
||||
// getIdentityKey(forKey:) collapses both to nil, and generating+saving a
|
||||
// new key deletes the existing one first — permanently orphaning the
|
||||
// encrypted cache on a launch that merely couldn't read the key.
|
||||
let loadedKey: SymmetricKey
|
||||
|
||||
// Try to load from keychain
|
||||
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
|
||||
let keyIsEphemeral: Bool
|
||||
|
||||
switch keychain.getIdentityKeyWithResult(forKey: encryptionKeyName) {
|
||||
case .success(let keyData):
|
||||
loadedKey = SymmetricKey(data: keyData)
|
||||
keyIsEphemeral = false
|
||||
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
|
||||
}
|
||||
// Generate new key if needed
|
||||
else {
|
||||
loadedKey = SymmetricKey(size: .bits256)
|
||||
let keyData = loadedKey.withUnsafeBytes { Data($0) }
|
||||
// Save to keychain
|
||||
|
||||
case .itemNotFound:
|
||||
// Genuine first run: generate and persist a new key.
|
||||
let newKey = SymmetricKey(size: .bits256)
|
||||
let keyData = newKey.withUnsafeBytes { Data($0) }
|
||||
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
|
||||
loadedKey = newKey
|
||||
// If even the save failed, treat the key as ephemeral so we don't
|
||||
// later try to persist a cache the next launch can't read.
|
||||
keyIsEphemeral = !saved
|
||||
SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
|
||||
|
||||
case .deviceLocked, .authenticationFailed, .accessDenied, .otherError:
|
||||
// Transient/critical read failure. Do NOT overwrite the persisted
|
||||
// key. Use a session-only ephemeral key; the real key and cache are
|
||||
// left intact for a healthy launch.
|
||||
SecureLogger.warning("Identity cache key unavailable; using ephemeral key for this session (not persisting)", category: .security)
|
||||
loadedKey = SymmetricKey(size: .bits256)
|
||||
keyIsEphemeral = true
|
||||
}
|
||||
|
||||
|
||||
self.encryptionKey = loadedKey
|
||||
|
||||
// Load identity cache on init
|
||||
loadIdentityCache()
|
||||
self.encryptionKeyIsEphemeral = keyIsEphemeral
|
||||
|
||||
// Only read the persisted cache when we hold the real key; with an
|
||||
// ephemeral key the decrypt would fail and discard the real cache.
|
||||
if !keyIsEphemeral {
|
||||
loadIdentityCache()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -211,23 +241,28 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
|
||||
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
|
||||
/// and persists it on the same serialized context — no timer, nothing left
|
||||
/// scheduled to keep the process alive.
|
||||
private func saveIdentityCache() {
|
||||
// Mark that we need to save
|
||||
pendingSave = true
|
||||
|
||||
// Cancel any existing timer
|
||||
saveTimer?.invalidate()
|
||||
|
||||
// Schedule a new save after the debounce interval
|
||||
saveTimer = Timer.scheduledTimer(withTimeInterval: saveDebounceInterval, repeats: false) { [weak self] _ in
|
||||
self?.performSave()
|
||||
}
|
||||
performSave()
|
||||
}
|
||||
|
||||
|
||||
/// Writes the cache to the keychain. Must run on `queue` with exclusive
|
||||
/// (barrier) access.
|
||||
private func performSave() {
|
||||
guard pendingSave else { return }
|
||||
pendingSave = false
|
||||
|
||||
|
||||
// Never persist under an ephemeral key — it would overwrite the real
|
||||
// cache with data the next launch cannot decrypt.
|
||||
guard !encryptionKeyIsEphemeral else {
|
||||
SecureLogger.debug("Skipping identity cache save (ephemeral key this session)", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try JSONEncoder().encode(cache)
|
||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||
@@ -239,10 +274,14 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
SecureLogger.error(error, context: "Failed to save identity cache", category: .security)
|
||||
}
|
||||
}
|
||||
|
||||
// Force immediate save (for app termination)
|
||||
|
||||
// Force immediate save (for app termination / lifecycle events). Mutations
|
||||
// already persist synchronously via saveIdentityCache, so this is normally a
|
||||
// no-op (performSave early-returns when nothing is pending). Runs directly on
|
||||
// the caller's thread — deliberately NOT a `queue.sync(barrier)`, which is
|
||||
// reachable from `deinit` and from async tests on the swift-concurrency
|
||||
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
|
||||
func forceSave() {
|
||||
saveTimer?.invalidate()
|
||||
performSave()
|
||||
}
|
||||
|
||||
|
||||
@@ -322,6 +322,13 @@ final class NoiseCipherState {
|
||||
throw NoiseError.replayDetected
|
||||
}
|
||||
|
||||
// The 4-byte nonce prefix has been stripped, so the remaining bytes
|
||||
// must still hold at least the 16-byte Poly1305 tag. The up-front
|
||||
// `ciphertext.count >= 16` guard is not sufficient here (it counts
|
||||
// the nonce), and `prefix(count - 16)` would trap on a short payload.
|
||||
guard actualCiphertext.count >= 16 else {
|
||||
throw NoiseError.invalidCiphertext
|
||||
}
|
||||
// Split ciphertext and tag
|
||||
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
||||
tag = actualCiphertext.suffix(16)
|
||||
|
||||
@@ -54,7 +54,7 @@ private extension GeoRelayDirectoryDependencies {
|
||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
||||
awaitTorReady: { await TorManager.shared.awaitReady() },
|
||||
awaitTorReady: { await TorManager.shared.awaitEgressReady() },
|
||||
makeFetchData: {
|
||||
let session = TorURLSession.shared.session
|
||||
return { request in
|
||||
|
||||
@@ -82,6 +82,13 @@ final class NostrIdentityBridge {
|
||||
}
|
||||
|
||||
deviceSeedCache = nil
|
||||
// Also drop the in-memory derived per-geohash identities. These hold the
|
||||
// actual secp256k1 private keys; if left cached, post-panic geohash
|
||||
// messages would still be signed with pre-panic keys (linkable across the
|
||||
// wipe) until the app is force-quit.
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache.removeAll()
|
||||
cacheLock.unlock()
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
@@ -39,22 +39,23 @@ struct NostrProtocol {
|
||||
content: content
|
||||
)
|
||||
|
||||
// 2. Create ephemeral key for this message
|
||||
let ephemeralKey = try P256K.Schnorr.PrivateKey()
|
||||
// Created ephemeral key for seal
|
||||
|
||||
// 3. Seal the rumor (encrypt to recipient)
|
||||
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
|
||||
// real identity key. NIP-17 requires the seal be signed by the sender
|
||||
// so the recipient can authenticate who sent the message; signing with
|
||||
// a throwaway key leaves DMs forgeable/impersonatable.
|
||||
let senderKey = try senderIdentity.schnorrSigningKey()
|
||||
let sealedEvent = try createSeal(
|
||||
rumor: rumor,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: ephemeralKey
|
||||
senderKey: senderKey
|
||||
)
|
||||
|
||||
// 4. Gift wrap the sealed event (encrypt to recipient again)
|
||||
|
||||
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
|
||||
// layer hides the sender's identity from relays; createGiftWrap mints
|
||||
// its own ephemeral key internally).
|
||||
let giftWrap = try createGiftWrap(
|
||||
seal: sealedEvent,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: ephemeralKey
|
||||
recipientPubkey: recipientPubkey
|
||||
)
|
||||
|
||||
// Created gift wrap
|
||||
@@ -84,7 +85,15 @@ struct NostrProtocol {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 2. Open the seal
|
||||
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
|
||||
// identity key (NIP-17); without this check a DM is forgeable by anyone
|
||||
// who knows the recipient's npub. Verify the seal's own signature.
|
||||
guard seal.isValidSignature() else {
|
||||
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
// 3. Open the seal
|
||||
let rumor: NostrEvent
|
||||
do {
|
||||
rumor = try openSeal(
|
||||
@@ -96,10 +105,63 @@ struct NostrProtocol {
|
||||
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
|
||||
throw error
|
||||
}
|
||||
|
||||
return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at)
|
||||
|
||||
// 4. The sender claimed inside the rumor must match the key that actually
|
||||
// signed the seal, otherwise the sender field is unauthenticated and
|
||||
// spoofable.
|
||||
guard seal.pubkey == rumor.pubkey else {
|
||||
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
// Return the seal signer's pubkey as the authenticated sender.
|
||||
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let rumor = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .dm,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
var seal = try createSeal(
|
||||
rumor: rumor,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: senderIdentity.schnorrSigningKey()
|
||||
)
|
||||
seal.sig = String(repeating: "0", count: 128)
|
||||
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||
}
|
||||
|
||||
static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
rumorIdentity: NostrIdentity,
|
||||
sealSignerIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let rumor = NostrEvent(
|
||||
pubkey: rumorIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .dm,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
let seal = try createSeal(
|
||||
rumor: rumor,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: sealSignerIdentity.schnorrSigningKey()
|
||||
)
|
||||
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
||||
static func createEphemeralGeohashEvent(
|
||||
content: String,
|
||||
@@ -195,10 +257,9 @@ struct NostrProtocol {
|
||||
|
||||
private static func createGiftWrap(
|
||||
seal: NostrEvent,
|
||||
recipientPubkey: String,
|
||||
senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal
|
||||
recipientPubkey: String
|
||||
) throws -> NostrEvent {
|
||||
|
||||
|
||||
let sealJSON = try seal.jsonString()
|
||||
|
||||
// Create new ephemeral key for gift wrap
|
||||
|
||||
@@ -61,6 +61,11 @@ struct NostrRelayManagerDependencies {
|
||||
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
||||
var torEnforced: () -> Bool
|
||||
var torIsReady: () -> Bool
|
||||
/// Synchronous cached egress-gate check: `true` only while a positive Tor
|
||||
/// egress verification is within its TTL (or Tor is not enforced). When
|
||||
/// `false`, connections must be queued behind `awaitTorReady`, which runs
|
||||
/// the async egress self-check.
|
||||
var torEgressVerified: () -> Bool
|
||||
var torIsForeground: () -> Bool
|
||||
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
|
||||
var makeSession: () -> NostrRelaySessionProtocol
|
||||
@@ -83,10 +88,14 @@ private extension NostrRelayManagerDependencies {
|
||||
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
|
||||
torEnforced: { TorManager.shared.torEnforced },
|
||||
torIsReady: { TorManager.shared.isReady },
|
||||
torEgressVerified: { TorManager.shared.isEgressVerified },
|
||||
torIsForeground: { TorManager.shared.isForeground() },
|
||||
awaitTorReady: { completion in
|
||||
Task.detached {
|
||||
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 {
|
||||
completion(ready)
|
||||
}
|
||||
@@ -281,6 +290,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
markRelaySocketsClosed(resetState: false)
|
||||
// Sockets are gone, so per-relay subscription state is cleared — but
|
||||
// durable intent (subscriptionRequestState, messageHandlers, parked
|
||||
// EOSE callbacks) is kept so REQs replay when relays reconnect
|
||||
@@ -298,6 +308,60 @@ final class NostrRelayManager: ObservableObject {
|
||||
torReadyWaitAttempts = 0
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
/// Panic wipe reset: close sockets and drop every user/session-specific
|
||||
/// relay intent without invoking old callbacks. Unlike `disconnect()`, this
|
||||
/// must not preserve subscription replay state because geohash DM handlers
|
||||
/// can capture pre-wipe Nostr private keys.
|
||||
func resetForPanicWipe() {
|
||||
connectionGeneration &+= 1
|
||||
for (_, task) in connections {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
markRelaySocketsClosed(resetState: true)
|
||||
subscriptions.removeAll()
|
||||
pendingSubscriptions.removeAll()
|
||||
messageHandlers.removeAll()
|
||||
subscriptionRequestState.removeAll()
|
||||
subscribeCoalesce.removeAll()
|
||||
eoseTrackers.removeAll()
|
||||
pendingEOSECallbacks.removeAll()
|
||||
pendingTorConnectionURLs.removeAll()
|
||||
awaitingTorForConnections = false
|
||||
torReadyWaitAttempts = 0
|
||||
recentInboundEventKeys.removeAll()
|
||||
recentInboundEventKeyOrder.removeAll()
|
||||
duplicateInboundEventDropCount = 0
|
||||
duplicateInboundEventDropCountBySubscription.removeAll()
|
||||
inboundEventLogCount = 0
|
||||
Self.pendingGiftWrapIDs.removeAll()
|
||||
|
||||
messageQueueLock.lock()
|
||||
messageQueue.removeAll()
|
||||
pendingSendDropCount = 0
|
||||
messageQueueLock.unlock()
|
||||
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
private func markRelaySocketsClosed(resetState: Bool) {
|
||||
let now = dependencies.now()
|
||||
for index in relays.indices {
|
||||
relays[index].isConnected = false
|
||||
relays[index].nextReconnectTime = nil
|
||||
if resetState {
|
||||
relays[index].lastError = nil
|
||||
relays[index].lastConnectedAt = nil
|
||||
relays[index].lastDisconnectedAt = nil
|
||||
relays[index].messagesSent = 0
|
||||
relays[index].messagesReceived = 0
|
||||
relays[index].reconnectAttempts = 0
|
||||
} else {
|
||||
relays[index].lastDisconnectedAt = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||
func ensureConnections(to relayUrls: [String]) {
|
||||
@@ -570,8 +634,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Every path that opens a relay socket funnels through this check (initial
|
||||
/// connect, reconnect backoff timers, subscription-triggered connects,
|
||||
/// manual retry). It must hold connections back when Tor isn't bootstrapped
|
||||
/// OR when the runtime egress self-check has no fresh positive verdict —
|
||||
/// otherwise the already-bootstrapped path would open sockets without ever
|
||||
/// running the egress canary.
|
||||
private var shouldWaitForTorBeforeConnecting: Bool {
|
||||
shouldUseTor && !dependencies.torIsReady()
|
||||
shouldUseTor && (!dependencies.torIsReady() || !dependencies.torEgressVerified())
|
||||
}
|
||||
|
||||
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
|
||||
@@ -619,16 +689,20 @@ final class NostrRelayManager: ObservableObject {
|
||||
guard ready else {
|
||||
self.torReadyWaitAttempts += 1
|
||||
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)
|
||||
} else {
|
||||
// Still fail-closed (no network), but unblock any callers
|
||||
// waiting on EOSE so the UI doesn't hang indefinitely.
|
||||
// Queued subscriptions/sends are kept and flush if a later
|
||||
// trigger (e.g. app foreground) brings Tor up.
|
||||
SecureLogger.error("❌ Tor not ready after \(self.torReadyWaitAttempts) wait(s); aborting relay connections (fail-closed)", category: .session)
|
||||
// Queued subscriptions/sends are kept; a bounded-cadence
|
||||
// retry (below) re-enters the gate so a transient failure
|
||||
// (Tor stall, canary outage keeping the egress unverified)
|
||||
// recovers automatically, and any later trigger (e.g. app
|
||||
// foreground) also re-enters it.
|
||||
SecureLogger.error("❌ Tor not ready or egress unverified after \(self.torReadyWaitAttempts) wait(s); relays stay closed (fail-closed), retrying in \(Int(TransportConfig.nostrTorGateRetrySeconds))s", category: .session)
|
||||
self.torReadyWaitAttempts = 0
|
||||
self.unblockPendingEOSECallbacks(reason: "tor-unavailable")
|
||||
self.scheduleTorGateRetry(pending)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -638,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
|
||||
/// fallback timeout `startEOSETracking` uses. Without it, a parked callback
|
||||
/// would only be unblocked by Tor-readiness retry exhaustion (several
|
||||
@@ -1170,6 +1262,18 @@ final class NostrRelayManager: ObservableObject {
|
||||
return Set(map.keys)
|
||||
}
|
||||
|
||||
var debugMessageHandlerCount: Int {
|
||||
messageHandlers.count
|
||||
}
|
||||
|
||||
var debugSubscriptionRequestCount: Int {
|
||||
subscriptionRequestState.count
|
||||
}
|
||||
|
||||
var debugPendingEOSECallbackCount: Int {
|
||||
pendingEOSECallbacks.count
|
||||
}
|
||||
|
||||
var debugDuplicateInboundEventDropCount: Int {
|
||||
duplicateInboundEventDropCount
|
||||
}
|
||||
|
||||
@@ -168,8 +168,13 @@ final class BLEAnnounceHandler {
|
||||
env.updateTopology(peerID, neighbors)
|
||||
}
|
||||
|
||||
// Persist cryptographic identity and signing key for robust offline verification
|
||||
env.persistIdentity(announcement)
|
||||
// Persist cryptographic identity and signing key for robust offline
|
||||
// verification — only for verified announces. Persisting unverified
|
||||
// announces would let an attacker who replays a victim's noisePublicKey
|
||||
// overwrite the victim's stored signing key/nickname (identity poisoning).
|
||||
if verifiedAnnounce {
|
||||
env.persistIdentity(announcement)
|
||||
}
|
||||
|
||||
let announceBackID = "announce-back-\(peerID)"
|
||||
let shouldSendBack = !env.dedupContains(announceBackID)
|
||||
|
||||
@@ -16,6 +16,8 @@ struct BLEPublicMessageHandlerEnvironment {
|
||||
let now: () -> Date
|
||||
/// Snapshot of known peers keyed by ID (registry read).
|
||||
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
||||
/// Verifies a packet's signature against a known signing public key.
|
||||
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
||||
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
||||
/// Tracks the broadcast message packet for gossip sync.
|
||||
@@ -68,14 +70,39 @@ final class BLEPublicMessageHandler {
|
||||
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
|
||||
let peersSnapshot = env.peersSnapshot()
|
||||
|
||||
// Public messages are always signed by their sender. `senderID` is
|
||||
// attacker-controlled, so registry membership alone is NOT proof of
|
||||
// identity — a peer in the registry as "verified" could be impersonated
|
||||
// by anyone spoofing their senderID. Require a valid packet signature
|
||||
// from the claimed sender (our own echoes are exempt; they are matched
|
||||
// by self-broadcast tracking below).
|
||||
//
|
||||
// Verify against the signing key already in the (synchronously-updated)
|
||||
// peer registry first: identity-cache persistence is asynchronous, so a
|
||||
// message arriving right after a verified announce would otherwise be
|
||||
// dropped because `signedSenderDisplayName` only searches the persisted
|
||||
// cache. Fall back to that persisted-identity lookup for peers not (yet)
|
||||
// in the registry.
|
||||
let isSelf = peerID == env.localPeerID()
|
||||
let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey
|
||||
let verifiedViaRegistry = !isSelf
|
||||
&& (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
|
||||
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
|
||||
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else {
|
||||
SecureLogger.warning("🚫 Dropping public message with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticity is established; prefer the registry's collision-resolved
|
||||
// display name, then the signature-derived name.
|
||||
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localNickname: env.localNickname(),
|
||||
peers: peersSnapshot,
|
||||
allowConnectedUnverified: false
|
||||
) ?? env.signedSenderDisplayName(packet, peerID) else {
|
||||
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||
) ?? signedDisplayName else {
|
||||
SecureLogger.warning("🚫 Dropping public message from unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ struct BLEReceivedPacketContext: Equatable {
|
||||
struct BLEReceivePipeline {
|
||||
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
|
||||
let senderID = PeerID(hexData: packet.senderID)
|
||||
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)"
|
||||
// Include a payload digest so that distinct packets sharing the same
|
||||
// sender/timestamp(ms)/type are not collapsed as duplicates. The
|
||||
// post-handshake flush sends queued messages, delivery and read receipts
|
||||
// back-to-back within a single millisecond; without the digest every
|
||||
// packet after the first would be silently dropped.
|
||||
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
||||
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||
let messageType = MessageType(rawValue: packet.type)
|
||||
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
|
||||
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
|
||||
|
||||
@@ -135,6 +135,13 @@ final class BLEService: NSObject {
|
||||
|
||||
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
|
||||
private var maintenanceCounter = 0 // Track maintenance cycles
|
||||
/// Whether real CoreBluetooth managers were initialized. When false (unit
|
||||
/// tests), periodic mesh background work is not started — the maintenance
|
||||
/// timer and the gossip-sync timers only drain BLE writes/notifications,
|
||||
/// re-announce, and sign/broadcast sync packets, all meaningless without
|
||||
/// Bluetooth. Leaving them running in the test process is pure background
|
||||
/// churn that aggravates flaky exit hangs.
|
||||
private var meshBackgroundEnabled = false
|
||||
|
||||
// MARK: - Connection budget & scheduling (central role)
|
||||
private var connectionScheduler = BLEConnectionScheduler<CBPeripheral>()
|
||||
@@ -233,16 +240,10 @@ final class BLEService: NSObject {
|
||||
#endif
|
||||
}
|
||||
|
||||
// Single maintenance timer for all periodic tasks (dispatch-based for determinism)
|
||||
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
||||
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
|
||||
repeating: TransportConfig.bleMaintenanceInterval,
|
||||
leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))
|
||||
timer.setEventHandler { [weak self] in
|
||||
self?.performMaintenance()
|
||||
}
|
||||
timer.resume()
|
||||
maintenanceTimer = timer
|
||||
// Single maintenance timer for all periodic tasks (dispatch-based for
|
||||
// determinism). Only run it when real Bluetooth managers exist.
|
||||
meshBackgroundEnabled = initializeBluetoothManagers
|
||||
startMaintenanceTimer()
|
||||
|
||||
// Publish initial empty state
|
||||
requestPeerDataPublish()
|
||||
@@ -272,7 +273,12 @@ final class BLEService: NSObject {
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
manager.delegate = self
|
||||
manager.start()
|
||||
// Only start the periodic sync timers when real Bluetooth exists. In unit
|
||||
// tests there is no mesh to sync with, and the periodic sign/broadcast
|
||||
// churn just keeps the process busy and aggravates flaky exit hangs.
|
||||
if meshBackgroundEnabled {
|
||||
manager.start()
|
||||
}
|
||||
gossipSyncManager = manager
|
||||
}
|
||||
|
||||
@@ -435,7 +441,29 @@ final class BLEService: NSObject {
|
||||
|
||||
// MARK: Lifecycle
|
||||
|
||||
/// Creates and starts the periodic maintenance timer if it is not already
|
||||
/// running. Idempotent so it can be called from both `init` and
|
||||
/// `startServices()` — the latter matters after a panic reset, where
|
||||
/// `stopServices()` cancels and nils the timer.
|
||||
private func startMaintenanceTimer() {
|
||||
guard meshBackgroundEnabled, maintenanceTimer == nil else { return }
|
||||
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
||||
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
|
||||
repeating: TransportConfig.bleMaintenanceInterval,
|
||||
leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))
|
||||
timer.setEventHandler { [weak self] in
|
||||
self?.performMaintenance()
|
||||
}
|
||||
timer.resume()
|
||||
maintenanceTimer = timer
|
||||
}
|
||||
|
||||
func startServices() {
|
||||
// Restart the maintenance timer if a prior stopServices() cancelled it
|
||||
// (e.g. the panic flow), otherwise periodic announces, peer reconciliation
|
||||
// and cache cleanup would never resume until app restart.
|
||||
startMaintenanceTimer()
|
||||
|
||||
// Start BLE services if not already running
|
||||
if centralManager?.state == .poweredOn {
|
||||
centralManager?.scanForPeripherals(
|
||||
@@ -1602,7 +1630,7 @@ private extension BLEService {
|
||||
#if DEBUG
|
||||
// Test-only helper to inject packets into the receive pipeline
|
||||
extension BLEService {
|
||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true) {
|
||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
|
||||
if preseedPeer {
|
||||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
||||
let normalizedID = PeerID(hexData: packet.senderID)
|
||||
@@ -1610,6 +1638,7 @@ extension BLEService {
|
||||
if var existing = peerRegistry.info(for: normalizedID) {
|
||||
existing.isConnected = true
|
||||
existing.isVerifiedNickname = true
|
||||
if let signingPublicKey { existing.signingPublicKey = signingPublicKey }
|
||||
existing.lastSeen = Date()
|
||||
peerRegistry.upsert(existing)
|
||||
} else {
|
||||
@@ -1618,7 +1647,7 @@ extension BLEService {
|
||||
nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
|
||||
isConnected: true,
|
||||
noisePublicKey: packet.senderID,
|
||||
signingPublicKey: nil,
|
||||
signingPublicKey: signingPublicKey,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: Date()
|
||||
))
|
||||
@@ -3110,6 +3139,9 @@ extension BLEService {
|
||||
guard let self = self else { return [:] }
|
||||
return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
|
||||
},
|
||||
verifyPacketSignature: { [weak self] packet, signingPublicKey in
|
||||
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||||
},
|
||||
signedSenderDisplayName: { [weak self] packet, peerID in
|
||||
self?.signedSenderDisplayName(for: packet, from: peerID)
|
||||
},
|
||||
|
||||
@@ -594,6 +594,22 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes all persisted location state and resets the in-memory view.
|
||||
/// Used by the panic wipe — selected channel, teleport set and bookmarks
|
||||
/// (which reveal where the user has been) must not survive on device.
|
||||
func panicWipe() {
|
||||
storage.removeObject(forKey: selectedChannelKey)
|
||||
storage.removeObject(forKey: teleportedStoreKey)
|
||||
storage.removeObject(forKey: bookmarksKey)
|
||||
storage.removeObject(forKey: bookmarkNamesKey)
|
||||
teleportedSet.removeAll()
|
||||
bookmarkMembership.removeAll()
|
||||
bookmarks = []
|
||||
bookmarkNames = [:]
|
||||
teleported = false
|
||||
selectedChannel = .mesh
|
||||
}
|
||||
|
||||
private static func normalizeGeohash(_ s: String) -> String {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
return s
|
||||
|
||||
@@ -44,7 +44,11 @@ final class NetworkActivationService: ObservableObject {
|
||||
private let permissionProvider: () -> LocationChannelManager.PermissionState
|
||||
private let mutualFavoritesProvider: () -> Set<Data>
|
||||
private let torController: NetworkActivationTorControlling
|
||||
private let relayController: NetworkActivationRelayControlling
|
||||
// Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared
|
||||
// (via its live dependencies), so capturing NostrRelayManager.shared here would
|
||||
// re-enter whichever singleton's dispatch_once started first and trap at launch.
|
||||
private lazy var relayController: NetworkActivationRelayControlling = relayControllerProvider()
|
||||
private let relayControllerProvider: () -> NetworkActivationRelayControlling
|
||||
private let proxyController: NetworkActivationProxyControlling
|
||||
private let notificationCenter: NotificationCenter
|
||||
|
||||
@@ -55,7 +59,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
permissionProvider = { LocationChannelManager.shared.permissionState }
|
||||
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
|
||||
torController = TorManager.shared
|
||||
relayController = NostrRelayManager.shared
|
||||
relayControllerProvider = { NostrRelayManager.shared }
|
||||
proxyController = TorURLSession.shared
|
||||
notificationCenter = .default
|
||||
}
|
||||
@@ -77,7 +81,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
self.permissionProvider = permissionProvider
|
||||
self.mutualFavoritesProvider = mutualFavoritesProvider
|
||||
self.torController = torController
|
||||
self.relayController = relayController
|
||||
self.relayControllerProvider = { relayController }
|
||||
self.proxyController = proxyController
|
||||
self.notificationCenter = notificationCenter
|
||||
}
|
||||
|
||||
@@ -171,6 +171,10 @@ enum TransportConfig {
|
||||
// How many consecutive Tor-readiness waits (each bounded by TorManager's
|
||||
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
||||
static let nostrTorReadyMaxWaitAttempts: Int = 3
|
||||
// After Tor-gate wait attempts are exhausted (Tor never ready, or egress
|
||||
// self-check unverified), retry the whole gate at this bounded cadence so
|
||||
// a transient failure (e.g. canary outage) recovers without user action.
|
||||
static let nostrTorGateRetrySeconds: TimeInterval = 30.0
|
||||
static let nostrPendingSendQueueCap: Int = 200
|
||||
// Sample interval for the send-queue overflow warning (first + every Nth
|
||||
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
||||
|
||||
@@ -1129,6 +1129,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey")
|
||||
userDefaults.removeObject(forKey: "bitchat.messageRetentionKey")
|
||||
|
||||
// Wipe persisted location state (selected channel, teleport set,
|
||||
// bookmarks). For an activist-safety wipe, where the user has been is
|
||||
// exactly the data an adversary inspecting the device wants.
|
||||
LocationStateManager.shared.panicWipe()
|
||||
|
||||
// Reset nickname to anonymous
|
||||
nickname = "anon\(Int.random(in: 1000...9999))"
|
||||
saveNickname()
|
||||
@@ -1153,13 +1158,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// Clear selected private chat
|
||||
selectedPrivateChatPeer = nil
|
||||
|
||||
// Clear live location/geohash session state. Persisted location state
|
||||
// was wiped above, but the running view model can still be scoped to a
|
||||
// geohash channel and hold subscriptions tied to the old Nostr identity.
|
||||
activeChannel = .mesh
|
||||
setGeoChatSubscriptionID(nil)
|
||||
setGeoDmSubscriptionID(nil)
|
||||
_ = clearGeoSamplingSubs()
|
||||
cachedGeohashIdentity = nil
|
||||
nostrKeyMapping.removeAll()
|
||||
|
||||
// Clear read receipt tracking
|
||||
sentReadReceipts.removeAll()
|
||||
deduplicationService.clearAll()
|
||||
|
||||
// IMPORTANT: Clear Nostr-related state
|
||||
// Disconnect from Nostr relays and clear subscriptions
|
||||
nostrRelayManager?.disconnect()
|
||||
// Drop relay subscriptions, handlers, pending sends, and replay state.
|
||||
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
|
||||
// disconnect is not enough here.
|
||||
NostrRelayManager.shared.resetForPanicWipe()
|
||||
nostrRelayManager = nil
|
||||
|
||||
// Clear Nostr identity associations
|
||||
@@ -1175,15 +1192,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// No need to force UserDefaults synchronization
|
||||
|
||||
// Reinitialize Nostr with new identity
|
||||
// This will generate new Nostr keys derived from new Noise keys
|
||||
Task { @MainActor in
|
||||
// Small delay to ensure cleanup completes
|
||||
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
|
||||
// This will generate new Nostr keys derived from new Noise keys.
|
||||
// Skipped under tests: connecting the shared relay singleton starts
|
||||
// real network/reconnect work that never completes and would keep the
|
||||
// test process alive (the singleton, unlike a discardable instance, is
|
||||
// never deallocated to cancel it).
|
||||
if !TestEnvironment.isRunningTests {
|
||||
Task { @MainActor in
|
||||
// Small delay to ensure cleanup completes
|
||||
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
|
||||
|
||||
// Reinitialize Nostr relay manager with new identity
|
||||
nostrRelayManager = NostrRelayManager()
|
||||
setupNostrMessageHandling()
|
||||
nostrRelayManager?.connect()
|
||||
// Reinitialize Nostr relay manager with new identity. Reuse the
|
||||
// shared singleton — every other component (NostrTransport, geohash
|
||||
// subscriptions, AppRuntime observers) is bound to `.shared`, so
|
||||
// creating a fresh instance here would split relay state and leave
|
||||
// sends running against a disconnected manager.
|
||||
nostrRelayManager = NostrRelayManager.shared
|
||||
setupNostrMessageHandling()
|
||||
nostrRelayManager?.connect()
|
||||
}
|
||||
}
|
||||
|
||||
// Delete ALL media files (incoming and outgoing) in background
|
||||
|
||||
@@ -14,23 +14,29 @@ import BitFoundation
|
||||
struct BLEServiceCoreTests {
|
||||
|
||||
@Test
|
||||
func duplicatePacket_isDeduped() async {
|
||||
func duplicatePacket_isDeduped() async throws {
|
||||
let ble = makeService()
|
||||
let delegate = PublicCaptureDelegate()
|
||||
ble.delegate = delegate
|
||||
|
||||
// Public messages must carry a valid signature from the claimed sender;
|
||||
// sign the packet and preseed the sender's signing key so the receiver
|
||||
// can verify it (production `sendMessage` signs public broadcasts too).
|
||||
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let sender = PeerID(str: "1122334455667788")
|
||||
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
||||
let unsigned = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
||||
let packet = try #require(signer.signPacket(unsigned), "Failed to sign public message")
|
||||
let signingKey = signer.getSigningPublicKeyData()
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
|
||||
let receivedFirst = await TestHelpers.waitUntil(
|
||||
{ delegate.publicMessagesSnapshot().count == 1 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(receivedFirst)
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
|
||||
let receivedDuplicate = await TestHelpers.waitUntil(
|
||||
{ delegate.publicMessagesSnapshot().count > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
|
||||
@@ -1042,6 +1042,38 @@ struct ChatViewModelPanicTests {
|
||||
#expect(viewModel.unreadPrivateMessages.isEmpty)
|
||||
#expect(viewModel.selectedPrivateChatPeer == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearAllData_resetsLiveGeohashAndNostrState() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruy"
|
||||
let channel = GeohashChannel(level: .city, geohash: geohash)
|
||||
let identity = try NostrIdentity.generate()
|
||||
let pubkey = String(repeating: "ab", count: 32)
|
||||
let peerID = PeerID(nostr: pubkey)
|
||||
|
||||
viewModel.activeChannel = .location(channel)
|
||||
viewModel.setGeoChatSubscriptionID("geo-\(geohash)")
|
||||
viewModel.setGeoDmSubscriptionID("geo-dm-\(geohash)")
|
||||
viewModel.addGeoSamplingSub("geo-sample-\(geohash)", forGeohash: geohash)
|
||||
viewModel.cachedGeohashIdentity = (geohash, identity)
|
||||
viewModel.registerNostrKeyMapping(pubkey, for: peerID)
|
||||
viewModel.currentGeohash = geohash
|
||||
viewModel.geoNicknames = [pubkey: "alice"]
|
||||
viewModel.teleportedGeo = [pubkey]
|
||||
|
||||
viewModel.panicClearAllData()
|
||||
|
||||
#expect(viewModel.activeChannel == .mesh)
|
||||
#expect(viewModel.geoSubscriptionID == nil)
|
||||
#expect(viewModel.geoDmSubscriptionID == nil)
|
||||
#expect(viewModel.geoSamplingSubs.isEmpty)
|
||||
#expect(viewModel.cachedGeohashIdentity == nil)
|
||||
#expect(viewModel.nostrKeyMapping.isEmpty)
|
||||
#expect(viewModel.currentGeohash == nil)
|
||||
#expect(viewModel.geoNicknames.isEmpty)
|
||||
#expect(viewModel.teleportedGeo.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Service Lifecycle Tests
|
||||
|
||||
@@ -21,9 +21,16 @@ struct FragmentationTests {
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
// Construct a big packet (3KB) from a remote sender (not our own ID)
|
||||
// Construct a big SIGNED public packet (3KB) from a remote sender. Public
|
||||
// messages must carry a valid signature, so the reassembled packet is
|
||||
// signed and the sender's signing key is preseeded into the registry.
|
||||
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let signingKey = signer.getSigningPublicKeyData()
|
||||
let remoteShortID = PeerID(str: "1122334455667788")
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
|
||||
let original = try #require(
|
||||
signer.signPacket(makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)),
|
||||
"Failed to sign public packet"
|
||||
)
|
||||
|
||||
// Use a small fragment size to ensure multiple pieces
|
||||
let fragments = fragmentPacket(original, fragmentSize: 400)
|
||||
@@ -36,7 +43,7 @@ struct FragmentationTests {
|
||||
if i > 0 {
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
|
||||
}
|
||||
|
||||
// Wait for delegate callback with proper timeout
|
||||
@@ -52,8 +59,13 @@ struct FragmentationTests {
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let signingKey = signer.getSigningPublicKeyData()
|
||||
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
|
||||
let original = try #require(
|
||||
signer.signPacket(makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)),
|
||||
"Failed to sign public packet"
|
||||
)
|
||||
var frags = fragmentPacket(original, fragmentSize: 300)
|
||||
|
||||
// Duplicate one fragment
|
||||
@@ -66,7 +78,7 @@ struct FragmentationTests {
|
||||
if i > 0 {
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
|
||||
}
|
||||
|
||||
// Wait for delegate callback with proper timeout
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,42 @@ struct NostrProtocolTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptRejectsInvalidSealSignature() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let giftWrap = try NostrProtocol.createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||
content: "forged signature",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptRejectsSealRumorPubkeyMismatch() throws {
|
||||
let claimedSender = try NostrIdentity.generate()
|
||||
let sealSigner = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let giftWrap = try NostrProtocol.createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
||||
content: "spoofed sender",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
rumorIdentity: claimedSender,
|
||||
sealSignerIdentity: sealSigner
|
||||
)
|
||||
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testAckRoundTripNIP44V2_Delivered() throws {
|
||||
// Identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
@@ -260,4 +296,15 @@ struct NostrProtocolTests {
|
||||
if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) }
|
||||
return Data(base64Encoded: str)
|
||||
}
|
||||
|
||||
private func expectInvalidEvent(_ operation: () throws -> Void) {
|
||||
do {
|
||||
try operation()
|
||||
Issue.record("Expected NostrError.invalidEvent")
|
||||
} catch NostrError.invalidEvent {
|
||||
return
|
||||
} catch {
|
||||
Issue.record("Expected NostrError.invalidEvent, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,10 @@ struct BLEAnnounceHandlerTests {
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
#expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false)
|
||||
#expect(recorder.persistedIdentities.count == 1)
|
||||
// Identity persistence MUST NOT occur for unverified announces:
|
||||
// persisting would let an attacker who replays a victim's noisePublicKey
|
||||
// overwrite the victim's stored signing key/nickname (identity poisoning).
|
||||
#expect(recorder.persistedIdentities.isEmpty)
|
||||
#expect(recorder.trackedPackets.count == 1)
|
||||
#expect(recorder.announceBacks == 1)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ struct BLEPublicMessageHandlerTests {
|
||||
var localNickname = "Me"
|
||||
var peers: [PeerID: BLEPeerInfo] = [:]
|
||||
var signedName: String?
|
||||
var verifyPacketSignatureResult = false
|
||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||
var selfBroadcastMessageID: String?
|
||||
|
||||
var peersSnapshotReads = 0
|
||||
var verifyPacketSignatureQueries: [PeerID] = []
|
||||
var signedNameQueries: [PeerID] = []
|
||||
var trackedPackets: [BitchatPacket] = []
|
||||
var selfBroadcastTakes: [BitchatPacket] = []
|
||||
@@ -35,6 +37,10 @@ struct BLEPublicMessageHandlerTests {
|
||||
recorder.peersSnapshotReads += 1
|
||||
return recorder.peers
|
||||
},
|
||||
verifyPacketSignature: { packet, _ in
|
||||
recorder.verifyPacketSignatureQueries.append(PeerID(hexData: packet.senderID))
|
||||
return recorder.verifyPacketSignatureResult
|
||||
},
|
||||
signedSenderDisplayName: { _, peerID in
|
||||
recorder.signedNameQueries.append(peerID)
|
||||
return recorder.signedName
|
||||
@@ -59,13 +65,17 @@ struct BLEPublicMessageHandlerTests {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
// A valid packet signature is required even for a registry-verified peer:
|
||||
// senderID is spoofable, so registry membership alone is not authentication.
|
||||
recorder.signedName = "SignedAlice"
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, content: "hello mesh", timestamp: timestamp(now))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.peersSnapshotReads == 1)
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
// Signature is verified, then the registry's collision-resolved name is preferred.
|
||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
||||
#expect(recorder.trackedPackets.count == 1)
|
||||
#expect(recorder.selfBroadcastTakes.isEmpty)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
@@ -133,6 +143,63 @@ struct BLEPublicMessageHandlerTests {
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func registryVerifiedPeerDeliveredBeforeIdentityCachePersists() {
|
||||
// A freshly verified announce updates the peer registry synchronously,
|
||||
// but identity-cache persistence is async. A message arriving in that
|
||||
// window has a valid signature and a registry signing key, yet the
|
||||
// persisted-identity lookup (signedName) would still return nil. It must
|
||||
// be verified against the registry key and delivered, not dropped.
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true,
|
||||
signingPublicKey: Data(repeating: 0xAB, count: 32)
|
||||
)]
|
||||
recorder.verifyPacketSignatureResult = true
|
||||
recorder.signedName = nil
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, content: "first msg", timestamp: timestamp(now))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.verifyPacketSignatureQueries == [remotePeerID])
|
||||
// Verified via the registry key, so no fallback to the persisted lookup.
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.count == 1)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.nickname == "Alice")
|
||||
#expect(recorder.deliveries.first?.content == "first msg")
|
||||
}
|
||||
|
||||
@Test
|
||||
func registryPeerWithInvalidSignatureFallsBackAndDrops() {
|
||||
// Spoofed senderID: the peer is in the registry with a signing key, but
|
||||
// the packet signature does not verify against it. The handler must fall
|
||||
// back to the persisted lookup and, finding nothing, drop the message.
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true,
|
||||
signingPublicKey: Data(repeating: 0xAB, count: 32)
|
||||
)]
|
||||
recorder.verifyPacketSignatureResult = false
|
||||
recorder.signedName = nil
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, content: "spoofed", timestamp: timestamp(now))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.verifyPacketSignatureQueries == [remotePeerID])
|
||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func signedSenderFallbackDeliversWithSignedName() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
@@ -154,6 +221,7 @@ struct BLEPublicMessageHandlerTests {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
recorder.signedName = "SignedAlice"
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, payload: Data([0xFF, 0xFE, 0xFD]), timestamp: timestamp(now))
|
||||
|
||||
@@ -187,6 +255,7 @@ struct BLEPublicMessageHandlerTests {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
recorder.signedName = "SignedAlice"
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(
|
||||
sender: remotePeerID,
|
||||
@@ -213,14 +282,15 @@ struct BLEPublicMessageHandlerTests {
|
||||
_ peerID: PeerID,
|
||||
nickname: String,
|
||||
isVerified: Bool,
|
||||
isConnected: Bool = true
|
||||
isConnected: Bool = true,
|
||||
signingPublicKey: Data? = nil
|
||||
) -> BLEPeerInfo {
|
||||
BLEPeerInfo(
|
||||
peerID: peerID,
|
||||
nickname: nickname,
|
||||
isConnected: isConnected,
|
||||
noisePublicKey: nil,
|
||||
signingPublicKey: nil,
|
||||
signingPublicKey: signingPublicKey,
|
||||
isVerifiedNickname: isVerified,
|
||||
lastSeen: Date(timeIntervalSince1970: 999)
|
||||
)
|
||||
|
||||
@@ -14,7 +14,10 @@ struct BLEReceivePipelineTests {
|
||||
let context = BLEReceivePipeline.context(for: packet, localPeerID: local)
|
||||
|
||||
#expect(context.senderID == sender)
|
||||
#expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)")
|
||||
// The message ID includes a payload digest so distinct packets sharing a
|
||||
// sender/timestamp(ms)/type are not collapsed as duplicates.
|
||||
let digest = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
||||
#expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)-\(digest)")
|
||||
#expect(context.messageType == .message)
|
||||
#expect(context.shouldDeduplicate)
|
||||
#expect(context.logsHandlingDetails)
|
||||
|
||||
@@ -111,6 +111,116 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertTrue(connected)
|
||||
}
|
||||
|
||||
func test_connect_whenTorAlreadyReady_waitsForEgressVerificationBeforeCreatingSessions() async {
|
||||
// Tor is bootstrapped, but the egress self-check has no fresh verdict:
|
||||
// the ready path must still queue behind the async egress gate instead
|
||||
// of opening sockets directly.
|
||||
let context = makeContext(
|
||||
permission: .authorized,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: true,
|
||||
torEgressVerified: false
|
||||
)
|
||||
|
||||
context.manager.connect()
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
|
||||
|
||||
// Egress verification succeeds (awaitEgressReady returned true, which
|
||||
// implies the verifier now holds a fresh cached verdict).
|
||||
context.torEgressVerified.value = true
|
||||
context.torWaiter.resolve(true)
|
||||
|
||||
let connectedAfterVerification = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
|
||||
context.manager.relays.allSatisfy(\.isConnected)
|
||||
}
|
||||
XCTAssertTrue(connectedAfterVerification)
|
||||
}
|
||||
|
||||
func test_reconnect_requeuesBehindEgressGateWhenVerificationLapses() async {
|
||||
// Reconnect backoff timers call connectToRelay directly; when the
|
||||
// cached egress verification has lapsed by then, the reconnect must go
|
||||
// back through the gate rather than opening a socket.
|
||||
let relayURL = "wss://egress-reconnect.example"
|
||||
let context = makeContext(
|
||||
permission: .denied,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: true,
|
||||
torEgressVerified: true
|
||||
)
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1)
|
||||
|
||||
// The socket drops and the cached verification expires meanwhile.
|
||||
context.torEgressVerified.value = false
|
||||
context.sessionFactory.latestConnection(for: relayURL)?
|
||||
.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut))
|
||||
let reconnectScheduled = await waitUntil { context.scheduler.scheduled.count == 1 }
|
||||
XCTAssertTrue(reconnectScheduled)
|
||||
|
||||
context.scheduler.runNext()
|
||||
let queuedBehindGate = await waitUntil { context.torWaiter.awaitCallCount == 1 }
|
||||
XCTAssertTrue(queuedBehindGate)
|
||||
// No new socket until the egress gate passes.
|
||||
XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1)
|
||||
|
||||
context.torEgressVerified.value = true
|
||||
context.torWaiter.resolve(true)
|
||||
|
||||
let reconnected = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == 2 &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(reconnected)
|
||||
}
|
||||
|
||||
func test_connect_egressGateExhaustionSchedulesBoundedRetryAndRecovers() async {
|
||||
// A persistent unverified egress exhausts the wait attempts; a bounded
|
||||
// low-frequency retry must then recover automatically once
|
||||
// verification succeeds (e.g. transient canary outage ends).
|
||||
let relayURL = "wss://egress-gate-retry.example"
|
||||
let context = makeContext(
|
||||
permission: .denied,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: true,
|
||||
torEgressVerified: false
|
||||
)
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
for _ in 0..<TransportConfig.nostrTorReadyMaxWaitAttempts {
|
||||
context.torWaiter.resolve(false)
|
||||
}
|
||||
|
||||
// Fail-closed, with a single bounded-cadence retry scheduled.
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
XCTAssertEqual(context.scheduler.scheduled.count, 1)
|
||||
XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrTorGateRetrySeconds)
|
||||
|
||||
// The outage ends before the retry fires.
|
||||
let attemptsBefore = context.torWaiter.awaitCallCount
|
||||
context.scheduler.runNext()
|
||||
let regated = await waitUntil { context.torWaiter.awaitCallCount == attemptsBefore + 1 }
|
||||
XCTAssertTrue(regated)
|
||||
|
||||
context.torEgressVerified.value = true
|
||||
context.torWaiter.resolve(true)
|
||||
|
||||
let recovered = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(recovered)
|
||||
}
|
||||
|
||||
func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async {
|
||||
let relayURL = "wss://tor-eose-unblock.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
@@ -1328,6 +1438,68 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
|
||||
}
|
||||
|
||||
func test_resetForPanicWipe_dropsSessionRelayStateWithoutFiringCallbacks() async throws {
|
||||
let relayURL = "wss://panic-reset.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
let event = try makeSignedEvent(content: "queued before panic")
|
||||
var handledEvents = 0
|
||||
var eoseCount = 0
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "panic-sub",
|
||||
relayUrls: [relayURL],
|
||||
handler: { _ in handledEvents += 1 },
|
||||
onEOSE: { eoseCount += 1 }
|
||||
)
|
||||
context.manager.sendEvent(event, to: [relayURL])
|
||||
|
||||
XCTAssertEqual(context.manager.debugMessageHandlerCount, 1)
|
||||
XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 1)
|
||||
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1)
|
||||
XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 1)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1)
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
|
||||
context.manager.resetForPanicWipe()
|
||||
|
||||
XCTAssertEqual(context.manager.debugMessageHandlerCount, 0)
|
||||
XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 0)
|
||||
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
|
||||
XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 0)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
|
||||
XCTAssertEqual(handledEvents, 0)
|
||||
XCTAssertEqual(eoseCount, 0)
|
||||
|
||||
// Stale Tor wait and fallback callbacks from the pre-wipe generation
|
||||
// must not resurrect connections or settle callbacks after reset.
|
||||
context.torWaiter.resolve(true)
|
||||
context.scheduler.runNext()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
XCTAssertEqual(eoseCount, 0)
|
||||
}
|
||||
|
||||
func test_resetForPanicWipe_marksConnectedRelaysDisconnected() async {
|
||||
let relayURL = "wss://panic-connected.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.manager.isConnected &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
context.manager.resetForPanicWipe()
|
||||
|
||||
XCTAssertFalse(context.manager.isConnected)
|
||||
XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.isConnected, false)
|
||||
XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts, 0)
|
||||
XCTAssertNil(context.manager.relays.first(where: { $0.url == relayURL })?.lastError)
|
||||
}
|
||||
|
||||
func test_reconnectBackoff_appliesJitterWithinConfiguredBounds() async {
|
||||
let relayURL = "wss://jitter-bounds.example"
|
||||
// Pin the jitter source to the extremes and the midpoint of [0, 1).
|
||||
@@ -1387,6 +1559,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
userTorEnabled: Bool = false,
|
||||
torEnforced: Bool = false,
|
||||
torIsReady: Bool = true,
|
||||
torEgressVerified: Bool = true,
|
||||
torIsForeground: Bool = true,
|
||||
jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter)
|
||||
) -> RelayManagerTestContext {
|
||||
@@ -1396,6 +1569,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
let scheduler = MockRelayScheduler()
|
||||
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
|
||||
let torWaiter = MockTorWaiter(isReady: torIsReady)
|
||||
let torEgressVerifiedFlag = MutableBool(value: torEgressVerified)
|
||||
let torForeground = MutableBool(value: torIsForeground)
|
||||
let activationFlag = MutableBool(value: activationAllowed)
|
||||
let manager = NostrRelayManager(
|
||||
@@ -1408,6 +1582,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
||||
torEnforced: { torEnforced },
|
||||
torIsReady: { torWaiter.isReady },
|
||||
torEgressVerified: { torEgressVerifiedFlag.value },
|
||||
torIsForeground: { torForeground.value },
|
||||
awaitTorReady: torWaiter.await(completion:),
|
||||
makeSession: { sessionFactory },
|
||||
@@ -1427,6 +1602,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
clock: clock,
|
||||
activationAllowed: activationFlag,
|
||||
torWaiter: torWaiter,
|
||||
torEgressVerified: torEgressVerifiedFlag,
|
||||
torForeground: torForeground
|
||||
)
|
||||
}
|
||||
@@ -1481,6 +1657,7 @@ private struct RelayManagerTestContext {
|
||||
let clock: MutableClock
|
||||
let activationAllowed: MutableBool
|
||||
let torWaiter: MockTorWaiter
|
||||
let torEgressVerified: MutableBool
|
||||
let torForeground: MutableBool
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ let package = Package(
|
||||
"TorManager.swift",
|
||||
"TorURLSession.swift",
|
||||
"TorNotifications.swift",
|
||||
"TorEgressVerifier.swift",
|
||||
],
|
||||
linkerSettings: [
|
||||
.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 restarting: Bool = false
|
||||
|
||||
/// Runtime egress self-check: proves the proxied session actually exits via
|
||||
/// Tor before relay connections are opened (defense-in-depth against a
|
||||
/// platform silently ignoring the SOCKS proxy). Cached for a few minutes.
|
||||
public let egressVerifier = TorEgressVerifier(
|
||||
ttl: 300,
|
||||
probe: TorEgressVerifier.liveProbe()
|
||||
)
|
||||
|
||||
// Whether the app must enforce Tor for all connections (fail-closed).
|
||||
public var torEnforced: Bool {
|
||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||
@@ -125,6 +133,32 @@ public final class TorManager: ObservableObject {
|
||||
return await MainActor.run(body: { self.networkPermitted })
|
||||
}
|
||||
|
||||
/// Synchronous, cached view of the egress gate: `true` while a positive
|
||||
/// egress verification is within its TTL (or when Tor is not enforced).
|
||||
/// When this is `false`, callers must route through `awaitEgressReady()`
|
||||
/// (which probes) before opening any connection — never connect directly.
|
||||
public var isEgressVerified: Bool {
|
||||
guard torEnforced else { return true }
|
||||
return egressVerifier.hasFreshVerification
|
||||
}
|
||||
|
||||
/// Like `awaitReady`, but additionally requires that a canary request
|
||||
/// through the proxied session positively verifies Tor egress. Returns
|
||||
/// `false` if Tor never became ready, or if the egress self-check could
|
||||
/// not positively verify a Tor exit (non-Tor egress detected, or canary
|
||||
/// unreachable → unverified). Callers must fail closed on `false` — never
|
||||
/// fall back to a direct connection.
|
||||
nonisolated
|
||||
public func awaitEgressReady(timeout: TimeInterval = 75.0) async -> Bool {
|
||||
let ready = await awaitReady(timeout: timeout)
|
||||
guard ready else { return false }
|
||||
// Clearnet dev builds don't route through Tor, so the canary would
|
||||
// (correctly) report non-Tor; skip it there.
|
||||
let enforced = await MainActor.run { self.torEnforced }
|
||||
guard enforced else { return true }
|
||||
return await egressVerifier.verify()
|
||||
}
|
||||
|
||||
// MARK: - Filesystem
|
||||
|
||||
func dataDirectoryURL() -> URL? {
|
||||
@@ -325,6 +359,8 @@ public final class TorManager: ObservableObject {
|
||||
self.socksReady = false
|
||||
self.isStarting = false
|
||||
}
|
||||
// Force a fresh egress self-check once Tor comes back.
|
||||
Task { await egressVerifier.invalidate() }
|
||||
}
|
||||
|
||||
public func shutdownCompletely() {
|
||||
@@ -353,6 +389,7 @@ public final class TorManager: ObservableObject {
|
||||
// Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded()
|
||||
// Clearing it here races with startup and defeats the grace period
|
||||
}
|
||||
await self.egressVerifier.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +405,8 @@ public final class TorManager: ObservableObject {
|
||||
self.isDormant = false
|
||||
self.lastRestartAt = Date()
|
||||
}
|
||||
// New Arti instance means new circuits; re-verify egress after restart.
|
||||
await egressVerifier.invalidate()
|
||||
|
||||
_ = arti_stop()
|
||||
|
||||
|
||||
Vendored
+401
-426
@@ -1,441 +1,416 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
nostr.bitcoiner.social:443,47.6743,-117.112
|
||||
relay-dev.satlantis.io:443,40.8302,-74.1299
|
||||
relay.lightning.pub:443,39.0438,-77.4874
|
||||
ribo.us.nostria.app:443,43.6532,-79.3832
|
||||
relay.notoshi.win,13.3622,100.983
|
||||
openrelay.ziomc.com,50.0755,14.4378
|
||||
relay.agentry.com:443,42.8864,-78.8784
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
relay.gulugulu.moe,43.6532,-79.3832
|
||||
nostr.thebiglake.org:443,32.71,-96.6745
|
||||
relay.fountain.fm,43.6532,-79.3832
|
||||
freelay.sovbit.host,60.1699,24.9384
|
||||
nostr.girino.org:443,43.6532,-79.3832
|
||||
relay.openresist.com,43.6532,-79.3832
|
||||
nas01xanthosnet.synology.me:7778,47.1285,8.74735
|
||||
relay.ohstr.com:443,43.6532,-79.3832
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
relay.nostrdice.com,-33.8688,151.209
|
||||
bbw-nostr.xyz,41.5284,-87.4237
|
||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||
top.testrelay.top,43.6532,-79.3832
|
||||
relay.trotters.cc,43.6532,-79.3832
|
||||
blossom.gnostr.cloud,43.6532,-79.3832
|
||||
ribo.eu.nostria.app:443,43.6532,-79.3832
|
||||
public.crostr.com,43.6532,-79.3832
|
||||
relay.nostar.org,43.6532,-79.3832
|
||||
strfry.bonsai.com:443,39.0438,-77.4874
|
||||
nostr.carroarmato0.be,50.914,3.21378
|
||||
relay.endfiat.money,59.3327,18.0656
|
||||
nostr.overmind.lol:443,43.6532,-79.3832
|
||||
nostr.myshosholoza.co.za:443,52.3913,4.66545
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
nostr.tagomago.me,42.3601,-71.0589
|
||||
relay.internationalright-wing.org,-22.5022,-48.7114
|
||||
relay.fckstate.net,59.3293,18.0686
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
relay.0xchat.com,43.6532,-79.3832
|
||||
relayone.geektank.ai,39.1008,-94.5811
|
||||
relay.homeinhk.xyz,35.694,139.754
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
relay.islandbitcoin.com:443,12.8498,77.6545
|
||||
relay.mrmave.work,43.6532,-79.3832
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||
wot.shaving.kiwi,43.6532,-79.3832
|
||||
relay.nearhood.co.uk,51.5072,-0.127586
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
syb.lol:443,43.6532,-79.3832
|
||||
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
no.str.cr,10.6352,-85.4378
|
||||
relay.jbnco.co,43.6532,-79.3832
|
||||
ribo.nostria.app:443,43.6532,-79.3832
|
||||
us-east.nostr.pikachat.org,39.0438,-77.4874
|
||||
nexus.libernet.app,43.6532,-79.3832
|
||||
nostr.dlcdevkit.com,40.0992,-83.1141
|
||||
nostr.debate.report,50.1109,8.68213
|
||||
str-define-contributing-jackets.trycloudflare.com,43.6532,-79.3832
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
nostr.unkn0wn.world,46.8499,9.53287
|
||||
nostr.spicyz.io:443,43.6532,-79.3832
|
||||
relay.layer.systems:443,49.0291,8.35695
|
||||
nostr-relay.amethyst.name,39.0067,-77.4291
|
||||
slick.mjex.me,39.0418,-77.4744
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
nostr.quali.chat:443,60.1699,24.9384
|
||||
purplerelay.com:443,43.6532,-79.3832
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
relay.plebeian.market:443,50.1109,8.68213
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
relay.mitchelltribe.com:443,39.0438,-77.4874
|
||||
nostr.0x7e.xyz,47.4949,8.71954
|
||||
relayone.soundhsa.com:443,39.1008,-94.5811
|
||||
nostr.thalheim.io:443,60.1699,24.9384
|
||||
relay.getsafebox.app:443,43.6532,-79.3832
|
||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
nostr.carroarmato0.be:443,50.914,3.21378
|
||||
relay.staging.commonshub.brussels,49.4543,11.0746
|
||||
relay-dev.satlantis.io,40.8302,-74.1299
|
||||
relay.lacompagniemaximus.com:443,45.3147,-73.8785
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
nostr.computingcache.com:443,34.0356,-118.442
|
||||
relay.lanavault.space:443,60.1699,24.9384
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
nrs-02.darkcloudarcade.com:443,39.9526,-75.1652
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
node.kommonzenze.de,49.4521,11.0767
|
||||
0x-nostr-relay.fly.dev,37.7648,-122.432
|
||||
speakeasy.cellar.social,49.4543,11.0746
|
||||
nostr.0x7e.xyz:443,47.4949,8.71954
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
relay5.bitransfer.org,43.6532,-79.3832
|
||||
relay.inforsupports.com,43.6532,-79.3832
|
||||
relay.plebeian.market,50.1109,8.68213
|
||||
shu02.shugur.net,21.4902,39.2246
|
||||
nostr.easycryptosend.it,43.6532,-79.3832
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
nostr.pbfs.io:443,50.4754,12.3683
|
||||
nos.lol:443,50.4754,12.3683
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
strfry.shock.network:443,39.0438,-77.4874
|
||||
relay01.lnfi.network,35.6764,139.65
|
||||
r.0kb.io:443,32.789,-96.7989
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
nostr-relay.psfoundation.info:443,39.0438,-77.4874
|
||||
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||
testnet.samt.st,43.6532,-79.3832
|
||||
relay.bornheimer.app,51.5072,-0.127586
|
||||
nrs-02.darkcloudarcade.com,39.9526,-75.1652
|
||||
relay.binaryrobot.com:443,43.6532,-79.3832
|
||||
nostr.computingcache.com,34.0356,-118.442
|
||||
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
|
||||
schnorr.me,43.6532,-79.3832
|
||||
nostr.twinkle.lol,51.902,7.6657
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.mmwaves.de:443,48.8575,2.35138
|
||||
relay2.veganostr.com,60.1699,24.9384
|
||||
nostr.mom,50.4754,12.3683
|
||||
nostr2.girino.org:443,43.6532,-79.3832
|
||||
wot.sudocarlos.com,43.6532,-79.3832
|
||||
dev.relay.stream,43.6532,-79.3832
|
||||
nostr.thalheim.io,60.1699,24.9384
|
||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||
conduitl2.fly.dev,37.7648,-122.432
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
myvoiceourstory.org,37.3598,-121.981
|
||||
relay.angor.io,48.1046,11.6002
|
||||
r.0kb.io,32.789,-96.7989
|
||||
nexus.libernet.app:443,43.6532,-79.3832
|
||||
us-east.nostr.pikachat.org:443,39.0438,-77.4874
|
||||
nostr.bitcoiner.social,47.6743,-117.112
|
||||
nostrelay.circum.space:443,52.6907,4.8181
|
||||
relayone.soundhsa.com,39.1008,-94.5811
|
||||
nostr.snowbla.de,60.1699,24.9384
|
||||
relay1.orangesync.tech,44.7839,-106.941
|
||||
nostr-2.21crypto.ch:443,47.5356,8.73209
|
||||
espelho.girino.org,43.6532,-79.3832
|
||||
nostr.blankfors.se,60.1699,24.9384
|
||||
relay.sigit.io:443,50.4754,12.3683
|
||||
relay.vrtmrz.net:443,43.6532,-79.3832
|
||||
nostr.wecsats.io:443,43.6532,-79.3832
|
||||
nostrcity-club.fly.dev,37.7648,-122.432
|
||||
nostrelay.circum.space,52.6907,4.8181
|
||||
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
|
||||
relay.edufeed.org,49.4521,11.0767
|
||||
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
relay.binaryrobot.com,43.6532,-79.3832
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
prl.plus,55.7628,37.5983
|
||||
relay.cosmicbolt.net:443,37.3986,-121.964
|
||||
relay.nostu.be:443,40.4167,-3.70329
|
||||
cs-relay.nostrdev.com,50.4754,12.3683
|
||||
satsage.xyz,37.3986,-121.964
|
||||
relay.lab.rytswd.com:443,49.4543,11.0746
|
||||
rilo.nostria.app:443,43.6532,-79.3832
|
||||
portal-relay.pareto.space,49.0291,8.35696
|
||||
relay.wavlake.com:443,41.2619,-95.8608
|
||||
relay.beginningend.com,35.2227,-97.4786
|
||||
relay.openresist.com:443,43.6532,-79.3832
|
||||
bitcoiner.social:443,47.6743,-117.112
|
||||
relay.notoshi.win:443,13.3622,100.983
|
||||
dev.relay.edufeed.org:443,49.4521,11.0767
|
||||
relay.cypherflow.ai:443,48.8575,2.35138
|
||||
relay.ru.ac.th,13.7607,100.627
|
||||
shu03.shugur.net,25.2048,55.2708
|
||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||
relay.agorist.space:443,52.3734,4.89406
|
||||
relay02.lnfi.network,35.6764,139.65
|
||||
relay-testnet.k8s.layer3.news:443,37.3387,-121.885
|
||||
nostr.notribe.net:443,40.8302,-74.1299
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
nostr.rikmeijer.nl,51.7111,5.36809
|
||||
blossom.gnostr.cloud:443,43.6532,-79.3832
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
cache.trustr.ing,43.6548,-79.3885
|
||||
nostr.bitczat.pl,60.1699,24.9384
|
||||
relay.nostrverse.net,43.6532,-79.3832
|
||||
shu04.shugur.net,25.2048,55.2708
|
||||
eu.nostr.pikachat.org:443,49.4543,11.0746
|
||||
ribo.us.nostria.app,43.6532,-79.3832
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
nostr.bitczat.pl:443,60.1699,24.9384
|
||||
nostr-relay.corb.net:443,38.8353,-104.822
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
relay.nostu.be,40.4167,-3.70329
|
||||
rele.speyhard.fi,51.5072,-0.127586
|
||||
relay.staging.plebeian.market,51.5072,-0.127586
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
nostrride.io,37.3986,-121.964
|
||||
x.kojira.io:443,43.6532,-79.3832
|
||||
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
nostr.stakey.net:443,52.3676,4.90414
|
||||
relay.nostr.blockhenge.com,39.0438,-77.4874
|
||||
relay.comcomponent.com,43.6532,-79.3832
|
||||
wot.nostr.place,43.6532,-79.3832
|
||||
relay.mostr.pub:443,43.6532,-79.3832
|
||||
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
|
||||
no.str.cr:443,10.6352,-85.4378
|
||||
relay.chorus.community:443,48.5333,10.7
|
||||
relay.aarpia.com,37.3986,-121.964
|
||||
relay.nostrmap.net,60.1699,24.9384
|
||||
relay.snotr.nl:49999,52.0195,4.42946
|
||||
relay.bebond.net,43.6532,-79.3832
|
||||
relay.illuminodes.com,43.6532,-79.3832
|
||||
chat-relay.zap-work.com:443,43.6532,-79.3832
|
||||
testr.nymble.world,40.8054,-74.0241
|
||||
relay.underorion.se,50.1109,8.68213
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
relay.damus.io,43.6532,-79.3832
|
||||
relay.nostriches.club,43.6532,-79.3832
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
relay.openfarmtools.org,60.1699,24.9384
|
||||
wot.makenomistakes.ca,43.7064,-79.3986
|
||||
relay.thecryptosquid.com,50.4754,12.3683
|
||||
test.thedude.cloud,50.1109,8.68213
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
relay.olas.app:443,60.1699,24.9384
|
||||
strfry.bonsai.com,39.0438,-77.4874
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
wot.utxo.one,43.6532,-79.3832
|
||||
relay.angor.io:443,48.1046,11.6002
|
||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||
soloco.nl,43.6532,-79.3832
|
||||
armada.sharegap.net,43.6532,-79.3832
|
||||
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
||||
nostr.4rs.nl,49.0291,8.35696
|
||||
nrs-01.darkcloudarcade.com,39.1008,-94.5811
|
||||
relay.beginningend.com:443,35.2227,-97.4786
|
||||
relay.nostr.place,43.6532,-79.3832
|
||||
relay.layer.systems,49.0291,8.35695
|
||||
adre.su,59.9311,30.3609
|
||||
relay.0xchat.com:443,43.6532,-79.3832
|
||||
nostr.infero.net,35.6764,139.65
|
||||
relay.typedcypher.com:443,51.5072,-0.127586
|
||||
relay.mostro.network:443,40.8302,-74.1299
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
relay.mostr.pub,43.6532,-79.3832
|
||||
bitcoiner.social,47.6743,-117.112
|
||||
nostr.tac.lol:443,47.4748,-122.273
|
||||
nostr.hekster.org:443,37.3986,-121.964
|
||||
relay.satmaxt.xyz:443,43.6532,-79.3832
|
||||
relay.cypherflow.ai,48.8575,2.35138
|
||||
relay.zone667.com:443,60.1699,24.9384
|
||||
relay.jeffg.fyi:443,43.6532,-79.3832
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
nostr.spaceshell.xyz:443,43.6532,-79.3832
|
||||
top.testrelay.top:443,43.6532,-79.3832
|
||||
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
||||
relay.nostrian-conquest.com:443,41.223,-111.974
|
||||
relay.laantungir.net:443,-19.4692,-42.5315
|
||||
relay.islandbitcoin.com,12.8498,77.6545
|
||||
purplerelay.com,43.6532,-79.3832
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
nostr.wecsats.io,43.6532,-79.3832
|
||||
nostr.2b9t.xyz,34.0549,-118.243
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
relay.dreamith.to,43.6532,-79.3832
|
||||
nostr.islandarea.net:443,35.4669,-97.6473
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
eu.nostr.pikachat.org,49.4543,11.0746
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
nostr.n7ekb.net,47.4941,-122.294
|
||||
relay.mulatta.io,37.5665,126.978
|
||||
nittom.nostr1.com,40.7057,-74.0136
|
||||
relay2.orangesync.tech,40.7128,-74.006
|
||||
relay.artx.market,43.6548,-79.3885
|
||||
relay.wavefunc.live,41.8781,-87.6298
|
||||
bitchat.nostr1.com,40.7057,-74.0136
|
||||
relay.ditto.pub:443,43.6532,-79.3832
|
||||
relay.wisp.talk,49.4543,11.0746
|
||||
nostrelites.org,41.8781,-87.6298
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
||||
relay.chorus.community,48.5333,10.7
|
||||
relay.plebchain.club,43.6532,-79.3832
|
||||
nostr-relay.amethyst.name:443,39.0067,-77.4291
|
||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||
relay.edufeed.org:443,49.4521,11.0767
|
||||
relay.lacompagniemaximus.com,45.3147,-73.8785
|
||||
relay.dreamith.to:443,43.6532,-79.3832
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
nostr.n7ekb.net:443,47.4941,-122.294
|
||||
relay.dyne.org,49.0291,8.35705
|
||||
nostr.janx.com,43.6532,-79.3832
|
||||
relay.trustr.ing,43.6548,-79.3885
|
||||
relay.paulstephenborile.com:443,49.4543,11.0746
|
||||
relay.wisp.talk:443,49.4543,11.0746
|
||||
yabu.me,35.6092,139.73
|
||||
bcast.seutoba.com.br,43.6532,-79.3832
|
||||
nos.xmark.cc,50.6924,3.20113
|
||||
nos.lol,50.4754,12.3683
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
relay.endfiat.money:443,59.3327,18.0656
|
||||
relay.tapestry.ninja,40.8054,-74.0241
|
||||
relay.lab.rytswd.com,49.4543,11.0746
|
||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||
relay.damus.io:443,43.6532,-79.3832
|
||||
treuzkas.branruz.com,48.8575,2.35138
|
||||
relay.paulstephenborile.com:443,49.4543,11.0746
|
||||
relay.binaryrobot.com,43.6532,-79.3832
|
||||
nostr-2.21crypto.ch,47.5356,8.73209
|
||||
spookstr2.nostr1.com:443,40.7057,-74.0136
|
||||
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
freelay.sovbit.host,60.1699,24.9384
|
||||
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
|
||||
testnet.samt.st,43.6532,-79.3832
|
||||
relay.angor.io,48.1046,11.6002
|
||||
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
relay.nostriot.com:443,41.5695,-83.9786
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
nostr.aruku.ovh,1.27994,103.849
|
||||
nostr.chaima.info,50.1109,8.68213
|
||||
ynostr.yael.at,60.1699,24.9384
|
||||
ynostr.yael.at:443,60.1699,24.9384
|
||||
nostr-01.yakihonne.com:443,1.32123,103.695
|
||||
spookstr2.nostr1.com,40.7057,-74.0136
|
||||
relay.trustr.ing:443,43.6548,-79.3885
|
||||
dev.relay.edufeed.org,49.4521,11.0767
|
||||
relay2.angor.io:443,48.1046,11.6002
|
||||
nostr.azzamo.net:443,52.2633,21.0283
|
||||
offchain.bostr.online,43.6532,-79.3832
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
relay.guggero.org,46.5971,9.59652
|
||||
nostr.snowbla.de,60.1699,24.9384
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
relay.veganostr.com,60.1699,24.9384
|
||||
vault.iris.to:443,43.6532,-79.3832
|
||||
relay.artx.market:443,43.6548,-79.3885
|
||||
wot.rejecttheframe.xyz,43.6532,-79.3832
|
||||
nostr.pbfs.io,50.4754,12.3683
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
relay.klabo.world,47.2343,-119.853
|
||||
relay.fountain.fm:443,43.6532,-79.3832
|
||||
nostrja-kari.heguro.com,43.6532,-79.3832
|
||||
relaisnostr.trivaco.fr,48.5734,7.75211
|
||||
offchain.pub,39.1585,-94.5728
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
nostr-relay.xbytez.io:443,50.6924,3.20113
|
||||
relay.paulstephenborile.com,49.4543,11.0746
|
||||
nittom.nostr1.com:443,40.7057,-74.0136
|
||||
dev-relay.nostreon.com,60.1699,24.9384
|
||||
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
||||
relay.jeffg.fyi,43.6532,-79.3832
|
||||
nexus.libernet.app:443,43.6532,-79.3832
|
||||
relay.islandbitcoin.com,12.8498,77.6545
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
kasztanowa.bieda.it,43.6532,-79.3832
|
||||
nostrcity-club.fly.dev,37.7648,-122.432
|
||||
relay.typedcypher.com,51.5072,-0.127586
|
||||
nostr.na.social:443,43.6532,-79.3832
|
||||
relay.laantungir.net,-19.4692,-42.5315
|
||||
nostr.data.haus:443,50.4754,12.3683
|
||||
wot.codingarena.top,50.4754,12.3683
|
||||
relay-dev.satlantis.io:443,40.8302,-74.1299
|
||||
rilo.nostria.app,43.6532,-79.3832
|
||||
nostr.hekster.org:443,37.3986,-121.964
|
||||
nostr-relay.amethyst.name:443,39.0067,-77.4291
|
||||
chat-relay.zap-work.com:443,43.6532,-79.3832
|
||||
relay.edufeed.org,49.4521,11.0767
|
||||
syb.lol:443,43.6532,-79.3832
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
nostr-relay.xbytez.io:443,50.6924,3.20113
|
||||
relay.wavefunc.live,41.8781,-87.6298
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
myvoiceourstory.org,37.3598,-121.981
|
||||
relay.underorion.se,50.1109,8.68213
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
relay.erybody.com,41.4513,-81.7021
|
||||
espelho.girino.org,43.6532,-79.3832
|
||||
nostr.pbfs.io:443,50.4754,12.3683
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
nostr.bitcoiner.social:443,47.6743,-117.112
|
||||
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
||||
relay.gulugulu.moe,43.6532,-79.3832
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
relay.cypherflow.ai,48.8575,2.35138
|
||||
treuzkas.branruz.com,48.8575,2.35138
|
||||
relay1.nostrchat.io,60.1699,24.9384
|
||||
kotukonostr.onrender.com,37.7775,-122.397
|
||||
nostr.plantroon.com,50.1013,8.62643
|
||||
nostr.davenov.com,50.1109,8.68213
|
||||
node.kommonzenze.de,49.4521,11.0767
|
||||
relay2.veganostr.com,60.1699,24.9384
|
||||
armada.sharegap.net,43.6532,-79.3832
|
||||
wot.makenomistakes.ca,43.7064,-79.3986
|
||||
nostr.2b9t.xyz:443,34.0549,-118.243
|
||||
relay.libernet.app:443,43.6532,-79.3832
|
||||
nostr.ps1829.com,33.8851,130.883
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
relay.olas.app,60.1699,24.9384
|
||||
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||
relay.ohstr.com,43.6532,-79.3832
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
relay.guggero.org,46.5971,9.59652
|
||||
testnet-relay.samt.st:443,40.8302,-74.1299
|
||||
thecitadel.nostr1.com,40.7057,-74.0136
|
||||
nostr.ps1829.com:443,33.8851,130.883
|
||||
nostr-relay.corb.net,38.8353,-104.822
|
||||
relay.npubhaus.com,43.6532,-79.3832
|
||||
relay.dreamith.to:443,43.6532,-79.3832
|
||||
relay.lightning.pub:443,39.0438,-77.4874
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
nostr.21crypto.ch,47.5356,8.73209
|
||||
nostr.tadryanom.me,43.6532,-79.3832
|
||||
nostr.myshosholoza.co.za,52.3913,4.66545
|
||||
relay.bitmacro.cloud,43.6532,-79.3832
|
||||
ribo.nostria.app,43.6532,-79.3832
|
||||
relay.vrtmrz.net,43.6532,-79.3832
|
||||
syb.lol,43.6532,-79.3832
|
||||
relay.bebond.net:443,43.6532,-79.3832
|
||||
relay.agentry.com,42.8864,-78.8784
|
||||
nostr-2.21crypto.ch,47.5356,8.73209
|
||||
nostrcity-club.fly.dev:443,37.7648,-122.432
|
||||
articles.layer3.news:443,37.3387,-121.885
|
||||
relay.internationalright-wing.org:443,-22.5022,-48.7114
|
||||
nostr-relay.zimage.com,34.0549,-118.243
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
relay.mwaters.net,50.9871,2.12554
|
||||
nostr.liberty.fans,36.9104,-89.5875
|
||||
spookstr2.nostr1.com:443,40.7057,-74.0136
|
||||
relay.ditto.pub:443,43.6532,-79.3832
|
||||
relay.plebchain.club,43.6532,-79.3832
|
||||
memlay.v0l.io,53.3498,-6.26031
|
||||
nostr.chaima.info:443,50.1109,8.68213
|
||||
schnorr.me:443,43.6532,-79.3832
|
||||
ribo.eu.nostria.app,43.6532,-79.3832
|
||||
nostr.bond,50.1109,8.68213
|
||||
wot.nostr.party,36.1659,-86.7844
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
relay.lotek-distro.com,43.6532,-79.3832
|
||||
relay.minibolt.info:443,43.6532,-79.3832
|
||||
relay.lanavault.space,60.1699,24.9384
|
||||
relay.mmwaves.de,48.8575,2.35138
|
||||
social.amanah.eblessing.co,48.1046,11.6002
|
||||
nostr2.thalheim.io,49.4543,11.0746
|
||||
relay.typedcypher.com,51.5072,-0.127586
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
relayrs.notoshi.win:443,43.6532,-79.3832
|
||||
nostr-relay-1.trustlessenterprise.com:443,43.6532,-79.3832
|
||||
nostr.islandarea.net,35.4669,-97.6473
|
||||
relay.nostrmap.net:443,60.1699,24.9384
|
||||
relay.bullishbounty.com:443,43.6532,-79.3832
|
||||
nostr.oxtr.dev:443,50.4754,12.3683
|
||||
srtrelay.c-stellar.net,43.6532,-79.3832
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
relay.mccormick.cx:443,52.3563,4.95714
|
||||
relay.toastr.net,40.8054,-74.0241
|
||||
nostr.hifish.org,47.4244,8.57658
|
||||
speakeasy.cellar.social:443,49.4543,11.0746
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
testnet-relay.samt.st,40.8302,-74.1299
|
||||
aeon.libretechsystems.xyz,55.486,9.86577
|
||||
mostro-p2p.tech,50.1109,8.68213
|
||||
nostr.wild-vibes.ts.net,48.8566,2.35222
|
||||
nostr.88mph.life,52.1941,-2.21905
|
||||
relay.nostrcheck.me,43.6532,-79.3832
|
||||
rilo.nostria.app,43.6532,-79.3832
|
||||
relay.bowlafterbowl.com,32.9483,-96.7299
|
||||
relay.nostr.place:443,43.6532,-79.3832
|
||||
nostr.mom:443,50.4754,12.3683
|
||||
relay.nostrian-conquest.com,41.223,-111.974
|
||||
herbstmeister.com,34.0549,-118.243
|
||||
nrs-01.darkcloudarcade.com:443,39.1008,-94.5811
|
||||
nostr.red5d.dev,43.6532,-79.3832
|
||||
nostrbtc.com,43.6532,-79.3832
|
||||
strfry.apps3.slidestr.net,40.4167,-3.70329
|
||||
relay.sharegap.net,43.6532,-79.3832
|
||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
relay.wavlake.com:443,41.2619,-95.8608
|
||||
nostr.thalheim.io:443,60.1699,24.9384
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
dev.relay.edufeed.org:443,49.4521,11.0767
|
||||
nostr.myshosholoza.co.za:443,52.3913,4.66545
|
||||
relay.binaryrobot.com:443,43.6532,-79.3832
|
||||
wot.nostr.place,43.6532,-79.3832
|
||||
nostr.sathoarder.com:443,48.5734,7.75211
|
||||
relay.veganostr.com:443,60.1699,24.9384
|
||||
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
|
||||
thecitadel.nostr1.com,40.7057,-74.0136
|
||||
relay.artx.market,43.6548,-79.3885
|
||||
nos.lol,50.4754,12.3683
|
||||
nostr.plantroon.com:443,50.1013,8.62643
|
||||
premium.primal.net,43.6532,-79.3832
|
||||
relay.wavefunc.live:443,41.8781,-87.6298
|
||||
nas01xanthosnet.synology.me:7778,47.1285,8.74735
|
||||
nostrja-kari.heguro.com,43.6532,-79.3832
|
||||
relay.mrmave.work,43.6532,-79.3832
|
||||
nostrelay.circum.space,52.6907,4.8181
|
||||
mostro-p2p.tech,50.1109,8.68213
|
||||
wot.shaving.kiwi,43.6532,-79.3832
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
nostrelay.circum.space:443,52.6907,4.8181
|
||||
relay.nostrdice.com,-33.8688,151.209
|
||||
relay.getvia.xyz,60.1699,24.9384
|
||||
strfry.shock.network:443,39.0438,-77.4874
|
||||
relay.nostrmap.net:443,60.1699,24.9384
|
||||
relay.nearhood.co.uk,51.5072,-0.127586
|
||||
no.str.cr,10.6352,-85.4378
|
||||
relay.getsafebox.app:443,43.6532,-79.3832
|
||||
relay0.gfcom.info,13.6992,100.694
|
||||
nostr.ps1829.com,33.8851,130.883
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
relay.stickeroo.is-cool.dev,37.3387,-121.885
|
||||
ricardo-oem.tailb5546.ts.net,40.7128,-74.006
|
||||
relay.typedcypher.com:443,51.5072,-0.127586
|
||||
relay.paulstephenborile.com,49.4543,11.0746
|
||||
nittom.nostr1.com,40.7057,-74.0136
|
||||
conduitl2.fly.dev,37.7648,-122.432
|
||||
nostr.rikmeijer.nl,51.7111,5.36809
|
||||
relay.thecryptosquid.com,50.4754,12.3683
|
||||
spookstr2.nostr1.com,40.7057,-74.0136
|
||||
offchain.bostr.online,43.6532,-79.3832
|
||||
nostr.planix.org,43.6532,-79.3832
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
0x-nostr-relay.fly.dev,37.7648,-122.432
|
||||
nostr.wecsats.io,43.6532,-79.3832
|
||||
schnorr.me,43.6532,-79.3832
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
relay.bornheimer.app,51.5072,-0.127586
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
blossom.gnostr.cloud:443,43.6532,-79.3832
|
||||
nostr-02.yakihonne.com:443,1.32123,103.695
|
||||
dev.relay.stream,43.6532,-79.3832
|
||||
ithurtswhenip.ee,51.5072,-0.127586
|
||||
nostr.myshosholoza.co.za,52.3913,4.66545
|
||||
relayrs.notoshi.win:443,43.6532,-79.3832
|
||||
relay-rpi.edufeed.org:443,49.4521,11.0767
|
||||
kotukonostr.onrender.com,37.7775,-122.397
|
||||
bridge.tagomago.me,42.3601,-71.0589
|
||||
nostr.tadryanom.me:443,43.6532,-79.3832
|
||||
relay.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay.olas.app:443,60.1699,24.9384
|
||||
nostr.unkn0wn.world,46.8499,9.53287
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
yabu.me,35.6092,139.73
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
||||
nostr2.girino.org:443,43.6532,-79.3832
|
||||
wot.brightbolt.net,47.6735,-116.781
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
relay.kilombino.com,43.6532,-79.3832
|
||||
relay.nostr.blockhenge.com,39.0438,-77.4874
|
||||
shu04.shugur.net,25.2048,55.2708
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
relay.bullishbounty.com:443,43.6532,-79.3832
|
||||
vault.iris.to:443,43.6532,-79.3832
|
||||
relay.mostro.network:443,40.8302,-74.1299
|
||||
offchain.pub:443,39.1585,-94.5728
|
||||
soloco.nl,43.6532,-79.3832
|
||||
relay.nostu.be,40.4167,-3.70329
|
||||
nostr.pbfs.io,50.4754,12.3683
|
||||
relay.directsponsor.net,42.8864,-78.8784
|
||||
relay.decentralia.fr,49.4282,10.9796
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
nostr-relay.amethyst.name,39.0067,-77.4291
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
rilo.nostria.app:443,43.6532,-79.3832
|
||||
relay.trotters.cc:443,43.6532,-79.3832
|
||||
nostr.overmind.lol:443,43.6532,-79.3832
|
||||
nostr.girino.org:443,43.6532,-79.3832
|
||||
bitsat.molonlabe.holdings,51.4012,-1.3147
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
||||
bridge.tagomago.me,42.3601,-71.0589
|
||||
nostr.thalheim.io,60.1699,24.9384
|
||||
relay.artx.market:443,43.6548,-79.3885
|
||||
nostr.openhoofd.nl,51.5717,3.70417
|
||||
nostr.bond,50.1109,8.68213
|
||||
relay.earthly.city,34.1749,-118.54
|
||||
nexus.libernet.app,43.6532,-79.3832
|
||||
relay.plebeian.market,50.1109,8.68213
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.ohstr.com,43.6532,-79.3832
|
||||
testnet-relay.samt.st:443,40.8302,-74.1299
|
||||
relay01.lnfi.network,35.6764,139.65
|
||||
relay.mostr.pub:443,43.6532,-79.3832
|
||||
wot.nostr.party,36.1659,-86.7844
|
||||
relayone.soundhsa.com,39.1008,-94.5811
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
ribo.eu.nostria.app,43.6532,-79.3832
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
relay.nostreon.com,60.1699,24.9384
|
||||
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
||||
nostr.quali.chat:443,60.1699,24.9384
|
||||
relay.internationalright-wing.org:443,-22.5022,-48.7114
|
||||
relay.mitchelltribe.com:443,39.0438,-77.4874
|
||||
relay.satlantis.io,40.8054,-74.0241
|
||||
nittom.nostr1.com:443,40.7057,-74.0136
|
||||
nostr.janx.com,43.6532,-79.3832
|
||||
nostr.carroarmato0.be:443,50.914,3.21378
|
||||
relay.mmwaves.de:443,48.8575,2.35138
|
||||
relay.chorus.community:443,48.5333,10.7
|
||||
wot.utxo.one,43.6532,-79.3832
|
||||
relay.plebeian.market:443,50.1109,8.68213
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
x.kojira.io:443,43.6532,-79.3832
|
||||
top.testrelay.top,43.6532,-79.3832
|
||||
nos.lol:443,50.4754,12.3683
|
||||
dev.relay.edufeed.org,49.4521,11.0767
|
||||
relayone.geektank.ai:443,39.1008,-94.5811
|
||||
relay.nostar.org,43.6532,-79.3832
|
||||
nostr.oxtr.dev:443,50.4754,12.3683
|
||||
nostr.88mph.life,52.1941,-2.21905
|
||||
relay.staging.commonshub.brussels,49.4543,11.0746
|
||||
weboftrust.libretechsystems.xyz,55.4724,9.87335
|
||||
relay.openfarmtools.org,60.1699,24.9384
|
||||
cs-relay.nostrdev.com,50.4754,12.3683
|
||||
relay.inforsupports.com,43.6532,-79.3832
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
relay.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay.mwaters.net,50.9871,2.12554
|
||||
nostrcity-club.fly.dev:443,37.7648,-122.432
|
||||
relay.vrtmrz.net:443,43.6532,-79.3832
|
||||
relay.nostr.place,43.6532,-79.3832
|
||||
relay.wavefunc.live:443,41.8781,-87.6298
|
||||
nostr.islandarea.net,35.4669,-97.6473
|
||||
purplerelay.com:443,43.6532,-79.3832
|
||||
nostr-relay.psfoundation.info:443,39.0438,-77.4874
|
||||
r.0kb.io,32.789,-96.7989
|
||||
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
|
||||
relay.mulatta.io,37.5665,126.978
|
||||
strfry.bonsai.com:443,39.0438,-77.4874
|
||||
bendernostur.duckdns.org:8443,50.1109,8.68213
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
ec2.f7z.io,60.1699,24.9384
|
||||
nostr.debate.report,50.1109,8.68213
|
||||
wot.codingarena.top,50.4754,12.3683
|
||||
relay.layer.systems:443,49.0291,8.35695
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
nostr.mom,50.4754,12.3683
|
||||
ribo.us.nostria.app:443,43.6532,-79.3832
|
||||
adre.su,59.9311,30.3609
|
||||
wot.sudocarlos.com,43.6532,-79.3832
|
||||
relay.nostrian-conquest.com,41.223,-111.974
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
relay.endfiat.money:443,59.3327,18.0656
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
nostr.carroarmato0.be,50.914,3.21378
|
||||
relay.cypherflow.ai:443,48.8575,2.35138
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
strfry.ymir.cloud,43.6532,-79.3832
|
||||
relay.mypathtofire.de,42.8864,-78.8784
|
||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||
nostr.snowbla.de:443,60.1699,24.9384
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
relay.damus.io,43.6532,-79.3832
|
||||
relay.ru.ac.th,13.7607,100.627
|
||||
nrs-01.darkcloudarcade.com,39.1008,-94.5811
|
||||
testnet-relay.samt.st,40.8302,-74.1299
|
||||
antiprimal.net,43.6532,-79.3832
|
||||
bitchat.nostr1.com,40.7057,-74.0136
|
||||
relay.snort.social,53.3498,-6.26031
|
||||
relay.mccormick.cx:443,52.3563,4.95714
|
||||
relay02.lnfi.network,35.6764,139.65
|
||||
srtrelay.c-stellar.net,43.6532,-79.3832
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
nostrride.io,37.3986,-121.964
|
||||
articles.layer3.news:443,37.3387,-121.885
|
||||
rele.speyhard.fi,51.5072,-0.127586
|
||||
relay.aarpia.com,37.3986,-121.964
|
||||
nostr.chaima.info,50.1109,8.68213
|
||||
relay.wisp.talk:443,49.4543,11.0746
|
||||
relay.agorist.space:443,52.3734,4.89406
|
||||
strfry.bonsai.com,39.0438,-77.4874
|
||||
nostr.hifish.org,47.4244,8.57658
|
||||
offchain.pub,39.1585,-94.5728
|
||||
nostr.spicyz.io:443,43.6532,-79.3832
|
||||
relay.beginningend.com,35.2227,-97.4786
|
||||
relay.sharegap.net,43.6532,-79.3832
|
||||
nostr.purpura.cloud,43.6532,-79.3832
|
||||
nrs-01.darkcloudarcade.com:443,39.1008,-94.5811
|
||||
relay.fountain.fm:443,43.6532,-79.3832
|
||||
relay.olas.app,60.1699,24.9384
|
||||
relay.mmwaves.de,48.8575,2.35138
|
||||
relay.openresist.com:443,43.6532,-79.3832
|
||||
relay.homeinhk.xyz,35.694,139.754
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
relay.comcomponent.com,43.6532,-79.3832
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
relay.nostriot.com:443,41.5695,-83.9786
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
nostr.azzamo.net:443,52.2633,21.0283
|
||||
relay.islandbitcoin.com:443,12.8498,77.6545
|
||||
pool.libernet.app,43.6532,-79.3832
|
||||
test.thedude.cloud,50.1109,8.68213
|
||||
nostrelites.org,41.8781,-87.6298
|
||||
nostr.infero.net,35.6764,139.65
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
ribo.nostria.app,43.6532,-79.3832
|
||||
relay.chorus.community,48.5333,10.7
|
||||
bitcoiner.social:443,47.6743,-117.112
|
||||
relay.wisp.talk,49.4543,11.0746
|
||||
relay.layer.systems,49.0291,8.35695
|
||||
relay-dev.satlantis.io,40.8302,-74.1299
|
||||
nostr.bitcoiner.social,47.6743,-117.112
|
||||
relay.lanavault.space:443,60.1699,24.9384
|
||||
relay.staging.plebeian.market,51.5072,-0.127586
|
||||
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
|
||||
relay.fountain.fm,43.6532,-79.3832
|
||||
nostr.middling.mydns.jp,35.8099,140.12
|
||||
relay.dreamith.to,43.6532,-79.3832
|
||||
relay.satmaxt.xyz:443,43.6532,-79.3832
|
||||
shu03.shugur.net,25.2048,55.2708
|
||||
zealand-charts-craig-thru.trycloudflare.com,43.6532,-79.3832
|
||||
nostr.computingcache.com,34.0356,-118.442
|
||||
ribo.us.nostria.app,43.6532,-79.3832
|
||||
relay.agentry.com,42.8864,-78.8784
|
||||
nostr.hifish.org:443,47.4244,8.57658
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
relay.cosmicbolt.net:443,37.3986,-121.964
|
||||
nostr-02.yakihonne.com,1.32123,103.695
|
||||
r.0kb.io:443,32.789,-96.7989
|
||||
nostr-relay.corb.net,38.8353,-104.822
|
||||
ribo.eu.nostria.app:443,43.6532,-79.3832
|
||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
relay.novospes.com,43.6532,-79.3832
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
relay.endfiat.money,59.3327,18.0656
|
||||
relay.angor.io:443,48.1046,11.6002
|
||||
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
|
||||
strfry.openhoofd.nl,51.5717,3.70417
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
relay.openresist.com,43.6532,-79.3832
|
||||
relay5.bitransfer.org,43.6532,-79.3832
|
||||
nostr.na.social,43.6532,-79.3832
|
||||
portal-relay.pareto.space,49.0291,8.35696
|
||||
nostr.notribe.net:443,40.8302,-74.1299
|
||||
relay.bitmacro.cloud,43.6532,-79.3832
|
||||
no.str.cr:443,10.6352,-85.4378
|
||||
relay.klabo.world,47.2343,-119.853
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||
relay.nostrmap.net,60.1699,24.9384
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
nostr.sovereignservices.xyz,43.6532,-79.3832
|
||||
nostr.liberty.fans,36.9104,-89.5875
|
||||
relay.nostrian-conquest.com:443,41.223,-111.974
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
nostrbtc.com,43.6532,-79.3832
|
||||
shu02.shugur.net,21.4902,39.2246
|
||||
relay.kalcafe.xyz,37.3986,-121.964
|
||||
relay.illuminodes.com,43.6532,-79.3832
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
nostr.ps1829.com:443,33.8851,130.883
|
||||
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||
nostr.wecsats.io:443,43.6532,-79.3832
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
||||
nostr.mom:443,50.4754,12.3683
|
||||
ribo.nostria.app:443,43.6532,-79.3832
|
||||
nostr.2b9t.xyz,34.0549,-118.243
|
||||
nostr.data.haus:443,50.4754,12.3683
|
||||
staging.yabu.me,35.6092,139.73
|
||||
relay.sigit.io:443,50.4754,12.3683
|
||||
relay.edufeed.org:443,49.4521,11.0767
|
||||
nostr-01.yakihonne.com:443,1.32123,103.695
|
||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||
herbstmeister.com,34.0549,-118.243
|
||||
relay.minibolt.info:443,43.6532,-79.3832
|
||||
relay2.angor.io:443,48.1046,11.6002
|
||||
social.amanah.eblessing.co,48.1046,11.6002
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
nostr.computingcache.com:443,34.0356,-118.442
|
||||
slick.mjex.me,39.0418,-77.4744
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
bitcoinostr.duckdns.org,43.3434,-3.99532
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
cache.trustr.ing,43.6548,-79.3885
|
||||
purplerelay.com,43.6532,-79.3832
|
||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||
nostr-relay.corb.net:443,38.8353,-104.822
|
||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||
prl.plus,55.7628,37.5983
|
||||
nostr.tac.lol:443,47.4748,-122.273
|
||||
relay.mostr.pub,43.6532,-79.3832
|
||||
schnorr.me:443,43.6532,-79.3832
|
||||
dev-relay.nostreon.com,60.1699,24.9384
|
||||
nostr.islandarea.net:443,35.4669,-97.6473
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
blossom.gnostr.cloud,43.6532,-79.3832
|
||||
relay.solife.me,43.6532,-79.3832
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
relay.vrtmrz.net,43.6532,-79.3832
|
||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
relay.fckstate.net,59.3293,18.0686
|
||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
relay.notoshi.win,13.7829,100.546
|
||||
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
||||
relay.trotters.cc,43.6532,-79.3832
|
||||
relay.lanavault.space,60.1699,24.9384
|
||||
public.crostr.com:443,43.6532,-79.3832
|
||||
nostr.stakey.net:443,52.3676,4.90414
|
||||
relay.nostr.place:443,43.6532,-79.3832
|
||||
nostr.dlcdevkit.com,40.0992,-83.1141
|
||||
nostr.aruku.ovh,1.27994,103.849
|
||||
satsage.xyz,37.3986,-121.964
|
||||
strfry.apps3.slidestr.net,40.4167,-3.70329
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
aeon.libretechsystems.xyz,55.486,9.86577
|
||||
relay.routstr.com,59.4016,17.9455
|
||||
relay.ohstr.com:443,43.6532,-79.3832
|
||||
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
||||
strfry.openhoofd.nl:443,51.5717,3.70417
|
||||
nostr.blankfors.se,60.1699,24.9384
|
||||
nostr-2.21crypto.ch:443,47.5356,8.73209
|
||||
relayone.soundhsa.com:443,39.1008,-94.5811
|
||||
relay.lab.rytswd.com:443,49.4543,11.0746
|
||||
nostr.tagomago.me,42.3601,-71.0589
|
||||
relay.0xchat.com:443,43.6532,-79.3832
|
||||
|
||||
|
@@ -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