Compare commits

..
Author SHA1 Message Date
jack 01ce054238 Discard deferred Noise ciphertext during panic 2026-07-26 07:17:21 +02:00
jack b6c7c77080 Preserve early Noise ciphertext across reconnects 2026-07-26 07:17:21 +02:00
jack 55ee1df0bc Stabilize synchronous Noise restart tests 2026-07-26 07:17:21 +02:00
jack b255355fed Fix ordinary Noise handshake races 2026-07-26 02:09:31 +02:00
jack 6e026c2c22 Fix cached Noise reconnects atomically 2026-07-26 02:09:31 +02:00
jack c8e330777a Discard private-media callbacks during panic 2026-07-26 02:09:31 +02:00
jack 91aba8b597 Fix private media Noise round-trip fixture 2026-07-26 02:09:31 +02:00
jackandjack d9a6dbfca8 Make Noise generation race test deterministic 2026-07-26 02:09:31 +02:00
jackandjack 68f8f03ad8 Avoid authentication callback queue starvation 2026-07-26 02:09:31 +02:00
jackandjack 48026991b2 Bind capability state to Noise generations 2026-07-26 02:09:31 +02:00
jackandjack ec795520ee Authenticate private media capabilities in Noise 2026-07-26 02:09:31 +02:00
jackandjack 40238c5e43 Harden private media migration compatibility 2026-07-26 02:09:31 +02:00
jackandjack a31cd80027 Encrypt private media before fragmentation 2026-07-26 02:08:56 +02:00
jack 974510ad9e Authenticate only completed Noise candidates 2026-07-26 02:08:56 +02:00
jackandjack 5aee7f0f98 Bind Noise sessions to claimed peer identities 2026-07-26 02:08:56 +02:00
jack 6054248765 Harden panic keychain and media cleanup 2026-07-26 02:08:56 +02:00
jack 5f7df63238 Invalidate queued BLE ingress during panic 2026-07-26 00:12:26 +02:00
jack b081c98dba Harden panic recovery and service shutdown 2026-07-26 00:12:26 +02:00
jackandjack 76d3b0f1ed Scope install markers to iOS 2026-07-25 21:43:11 +02:00
jackandjack aa3021c9ca Make panic wipe deterministic and device-bound 2026-07-25 21:43:11 +02:00
20 changed files with 4752 additions and 676 deletions
@@ -37,6 +37,30 @@ enum NoiseSecurityConstants {
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key. // Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
static let xxInitialMessageSize = 32 static let xxInitialMessageSize = 32
// Bounds an ordinary initiator whose message 1 or 2 is lost.
static let ordinaryHandshakeTimeout: TimeInterval = 10
// Bounds the receive-only rollback quarantine created by an unauthenticated
// inbound message 1. A lost message 3 must not strand outbound traffic.
static let ordinaryResponderHandshakeTimeout: TimeInterval = 20
// A released client may immediately retry after both crossed initiators
// yielded. Give that unilateral retry a brief head start before the
// patched side spends its one bounded recovery.
static let handshakeCollisionRecoveryDelay: TimeInterval = 0.2
// Rate-limited recovery remains actionable without spinning.
static let handshakeRateLimitRecoveryDelay: TimeInterval = 60
// Covers only reordering between a winning message 3 and the losing
// crossed message 1.
static let recentInitiatorCompletionGracePeriod: TimeInterval = 1
// After unauthenticated responder rollback, reject another attempt long
// enough that paced message 1 traffic cannot keep outbound paused. A
// legitimate peer converges through the one manager-owned local retry.
static let ordinaryReconnectRollbackCooldown: TimeInterval = 60
// Session timeout - sessions older than this should be renegotiated // Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours static let sessionTimeout: TimeInterval = 86400 // 24 hours
+6
View File
@@ -13,3 +13,9 @@ enum NoiseSessionError: Error, Equatable {
case alreadyEstablished case alreadyEstablished
case peerIdentityMismatch case peerIdentityMismatch
} }
/// The manager owns the exact attempt's one bounded recovery. Packet handling
/// must not launch its historical second, immediate restart for this failure.
struct NoiseManagedHandshakeFailure: Error {
let underlying: Error
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
import BitFoundation import BitFoundation
import BitLogger import BitLogger
import CryptoKit
import Foundation import Foundation
struct BLENoiseHandshakeHandlingResult { struct BLENoiseHandshakeHandlingResult {
@@ -33,6 +34,8 @@ struct BLENoisePacketHandlerEnvironment {
-> NoiseHandshakeProcessingResult -> NoiseHandshakeProcessingResult
/// Whether any Noise session (established or pending) exists for the peer (crypto). /// Whether any Noise session (established or pending) exists for the peer (crypto).
let hasNoiseSession: (PeerID) -> Bool let hasNoiseSession: (PeerID) -> Bool
/// Whether an inbound ordinary XX responder is waiting for message 3.
let isAwaitingResponderHandshakeCompletion: (PeerID) -> Bool
/// Initiates a fresh Noise handshake with the peer (crypto + send). /// Initiates a fresh Noise handshake with the peer (crypto + send).
let initiateHandshake: (PeerID) -> Void let initiateHandshake: (PeerID) -> Void
/// Broadcasts a packet on the mesh (caller is already on the message queue). /// Broadcasts a packet on the mesh (caller is already on the message queue).
@@ -63,15 +66,36 @@ struct BLENoisePacketHandlerEnvironment {
/// processing (with response), encrypted payload decryption and dispatch, /// processing (with response), encrypted payload decryption and dispatch,
/// and session recovery on decrypt failure. /// and session recovery on decrypt failure.
final class BLENoisePacketHandler { final class BLENoisePacketHandler {
private struct DeferredCiphertext {
let packet: BitchatPacket
let receivedAt: Date
}
/// Early post-handshake packets are normally tiny control messages or
/// queued DMs. Keep the recovery surface deliberately small so an
/// unauthenticated half-handshake cannot create an unbounded memory queue.
private static let maxDeferredPacketsPerPeer = 4
private static let maxDeferredPacketsGlobal = 32
/// One legacy sender can immediately follow message 3 with the largest
/// valid private-file ciphertext and has no application-level retry. Keep
/// room for that packet plus a small control-message budget.
private static let maxDeferredBytes =
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 256 * 1024
private static let deferredLifetime =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
private let environment: BLENoisePacketHandlerEnvironment private let environment: BLENoisePacketHandlerEnvironment
private let deferredLock = NSLock()
private var deferredCiphertexts: [PeerID: [DeferredCiphertext]] = [:]
private var deferredCiphertextBytes = 0
init(environment: BLENoisePacketHandlerEnvironment) { init(environment: BLENoisePacketHandlerEnvironment) {
self.environment = environment self.environment = environment
} }
/// Returns true when the handshake message was processed successfully. /// Returns true when the handshake message was processed successfully.
/// Callers use this to distinguish an authenticated replacement completion /// Callers use this to distinguish an authenticated reconnect completion
/// from a rejected candidate while an older session remains established. /// from a rejected ordinary responder while rollback state is restored.
@discardableResult @discardableResult
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
handleHandshakeWithResult(packet, from: peerID).processed handleHandshakeWithResult(packet, from: peerID).processed
@@ -105,15 +129,23 @@ final class BLENoisePacketHandler {
env.broadcastPacket(responsePacket) env.broadcastPacket(responsePacket)
} }
// Session establishment will trigger onPeerAuthenticated callback // The serialized authentication callback installs transport
// which will send any pending messages at the right time // state before it drains any bounded early ciphertext.
return BLENoiseHandshakeHandlingResult( return BLENoiseHandshakeHandlingResult(
processed: true, processed: true,
didEstablishAuthenticatedSession: didEstablishAuthenticatedSession:
result.didEstablishAuthenticatedSession result.didEstablishAuthenticatedSession
) )
} catch let managedFailure as NoiseManagedHandshakeFailure {
SecureLogger.error(
"Failed to process handshake; manager owns recovery: \(managedFailure.underlying)"
)
return BLENoiseHandshakeHandlingResult(
processed: false,
didEstablishAuthenticatedSession: false
)
} catch NoiseSessionError.peerIdentityMismatch { } catch NoiseSessionError.peerIdentityMismatch {
// The candidate was already discarded by the session manager. // The responder was already discarded by the session manager.
// Do not let a spoofed claimed ID trigger a fresh outbound // Do not let a spoofed claimed ID trigger a fresh outbound
// handshake or recreate state for the attacker-selected ID. // handshake or recreate state for the attacker-selected ID.
SecureLogger.warning( SecureLogger.warning(
@@ -143,6 +175,30 @@ final class BLENoisePacketHandler {
} }
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) { func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
handleEncrypted(packet, from: peerID, isDeferredRetry: false)
}
/// Called by the transport's serialized authentication callback after it
/// has installed state for the promoted or restored session generation.
func handleSessionAuthenticated(_ peerID: PeerID) {
drainDeferredCiphertextsIfReady(for: peerID)
}
/// Synchronously discards ciphertext retained for a pre-panic Noise
/// generation. The handler survives the service's identity replacement,
/// so keeping this queue would replay old bytes after post-panic auth.
func resetForPanic() {
deferredLock.lock()
deferredCiphertexts.removeAll(keepingCapacity: false)
deferredCiphertextBytes = 0
deferredLock.unlock()
}
private func handleEncrypted(
_ packet: BitchatPacket,
from peerID: PeerID,
isDeferredRetry: Bool
) {
let env = environment let env = environment
guard let recipientID = PeerID(hexData: packet.recipientID) else { guard let recipientID = PeerID(hexData: packet.recipientID) else {
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session) SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
@@ -184,19 +240,205 @@ final class BLENoisePacketHandler {
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts) env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
} catch NoiseEncryptionError.transportGenerationNotReady {
if isDeferredRetry {
SecureLogger.warning(
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because its authenticated transport generation changed again",
category: .session
)
return
}
// The manager promoted or restored keys before BLE's serialized
// callback installed generation-bound transport state. The
// manager rejected this before decrypting, so replay is safe.
deferCiphertext(packet, from: peerID)
} catch NoiseEncryptionError.sessionNotEstablished { } catch NoiseEncryptionError.sessionNotEstablished {
if isDeferredRetry {
SecureLogger.warning(
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because the authenticated session is unavailable",
category: .session
)
return
}
// We received an encrypted message before establishing a session with this peer. // We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted. // An initiator may already have sent message 3 followed by this
// ciphertext, with BLE delivering the ciphertext first.
if env.isAwaitingResponderHandshakeCompletion(peerID) {
deferCiphertext(packet, from: peerID)
return
}
// Otherwise trigger a handshake so future messages can decrypt.
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake") SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
if !env.hasNoiseSession(peerID) { if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID) env.initiateHandshake(peerID)
} }
} catch { } catch {
if isDeferredRetry {
// An early packet cannot tear down the authenticated session
// merely because its single bounded retry still fails.
SecureLogger.warning(
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… after retry failed: \(error)",
category: .session
)
return
}
// A responder may retain an older transport as receive-only
// rollback state while ordinary XX waits for message 3. New-key
// ciphertext can fail against those retained receive keys first.
if env.isAwaitingResponderHandshakeCompletion(peerID) {
if isDeferrableEarlyHandshakeFailure(error) {
deferCiphertext(packet, from: peerID)
} else {
SecureLogger.warning(
"Dropping invalid Noise ciphertext from \(peerID.id.prefix(8))… while responder handshake is completing: \(error)",
category: .session
)
}
return
}
if isDropOnlyCiphertextFailure(error) {
// The packet is attacker-controlled and did not prove a
// transport-state failure. Never let malformed, replayed,
// forged, oversized, or rate-limited bytes evict working keys.
SecureLogger.warning(
"Dropping rejected Noise ciphertext from \(peerID.id.prefix(8))… without clearing its session: \(error)",
category: .security
)
return
}
// Decryption failed - clear the corrupted session and re-initiate handshake // Decryption failed - clear the corrupted session and re-initiate handshake
// This handles cases where session state got out of sync (nonce mismatch, etc.) // Only local/session lifecycle failures reach this path.
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake") SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
env.clearSession(peerID) env.clearSession(peerID)
env.initiateHandshake(peerID) env.initiateHandshake(peerID)
} }
} }
private func isDeferrableEarlyHandshakeFailure(_ error: Error) -> Bool {
if let noiseError = error as? NoiseError {
switch noiseError {
case .authenticationFailure, .replayDetected:
return true
default:
return false
}
}
if let cryptoError = error as? CryptoKitError,
case .authenticationFailure = cryptoError {
return true
}
return false
}
private func isDropOnlyCiphertextFailure(_ error: Error) -> Bool {
if let securityError = error as? NoiseSecurityError {
switch securityError {
case .messageTooLarge, .rateLimitExceeded, .invalidPeerID:
return true
case .sessionExpired, .sessionExhausted:
return false
}
}
if let noiseError = error as? NoiseError {
switch noiseError {
case .invalidCiphertext, .authenticationFailure, .replayDetected:
return true
case .uninitializedCipher, .handshakeComplete,
.handshakeNotComplete, .missingLocalStaticKey,
.missingKeys, .invalidMessage, .invalidPublicKey,
.nonceExceeded:
return false
}
}
return error is CryptoKitError
}
private func deferCiphertext(_ packet: BitchatPacket, from peerID: PeerID) {
guard NoiseSecurityValidator.validatePrivateFileCiphertextSize(
packet.payload
) else {
SecureLogger.warning(
"Dropping oversized early Noise ciphertext from \(peerID.id.prefix(8))",
category: .security
)
return
}
let now = environment.now()
deferredLock.lock()
defer { deferredLock.unlock() }
purgeExpiredCiphertextsLocked(now: now)
let peerCount = deferredCiphertexts[peerID]?.count ?? 0
let globalCount = deferredCiphertexts.values.reduce(0) {
$0 + $1.count
}
guard peerCount < Self.maxDeferredPacketsPerPeer,
globalCount < Self.maxDeferredPacketsGlobal,
deferredCiphertextBytes + packet.payload.count
<= Self.maxDeferredBytes else {
SecureLogger.warning(
"Dropping early Noise ciphertext from \(peerID.id.prefix(8))… because the handshake buffer is full",
category: .security
)
return
}
deferredCiphertexts[peerID, default: []].append(
DeferredCiphertext(packet: packet, receivedAt: now)
)
deferredCiphertextBytes += packet.payload.count
SecureLogger.debug(
"Deferring early Noise ciphertext from \(peerID.id.prefix(8))… until responder handshake completion",
category: .session
)
}
private func drainDeferredCiphertextsIfReady(for peerID: PeerID) {
let env = environment
guard !env.isAwaitingResponderHandshakeCompletion(peerID),
env.hasNoiseSession(peerID) else {
return
}
let now = env.now()
deferredLock.lock()
purgeExpiredCiphertextsLocked(now: now)
let deferred = deferredCiphertexts.removeValue(forKey: peerID) ?? []
deferredCiphertextBytes -= deferred.reduce(0) {
$0 + $1.packet.payload.count
}
deferredLock.unlock()
guard !deferred.isEmpty else { return }
SecureLogger.debug(
"Retrying \(deferred.count) early Noise ciphertext packet(s) from \(peerID.id.prefix(8))… after handshake completion",
category: .session
)
for item in deferred {
handleEncrypted(item.packet, from: peerID, isDeferredRetry: true)
}
}
private func purgeExpiredCiphertextsLocked(now: Date) {
for peerID in Array(deferredCiphertexts.keys) {
guard let items = deferredCiphertexts[peerID] else { continue }
let retained = items.filter {
now.timeIntervalSince($0.receivedAt) <= Self.deferredLifetime
}
guard retained.count != items.count else { continue }
deferredCiphertextBytes -= items.reduce(0) {
$0 + $1.packet.payload.count
}
deferredCiphertextBytes += retained.reduce(0) {
$0 + $1.packet.payload.count
}
if retained.isEmpty {
deferredCiphertexts.removeValue(forKey: peerID)
} else {
deferredCiphertexts[peerID] = retained
}
}
}
} }
@@ -0,0 +1,40 @@
import Foundation
/// Bounds ordinary Noise revalidation to one attempt per physical-link epoch.
/// A live epoch may retry after the cooldown so a lost handshake cannot leave
/// the link permanently unauthenticated.
struct BLENoiseReconnectPolicy {
static let minimumRetryInterval: TimeInterval = 60
private var lastAttemptAt: [BLEIngressLinkID: Date] = [:]
mutating func shouldRevalidate(
on link: BLEIngressLinkID,
hasEstablishedSession: Bool,
isNoiseAuthenticatedLink: Bool,
hasAuthenticatedPeerLink: Bool,
now: Date
) -> Bool {
guard hasEstablishedSession,
!isNoiseAuthenticatedLink,
!hasAuthenticatedPeerLink else {
return false
}
if let previous = lastAttemptAt[link],
now.timeIntervalSince(previous) < Self.minimumRetryInterval {
return false
}
lastAttemptAt[link] = now
return true
}
/// Link identifiers can be stable across CoreBluetooth reconnects, so a
/// disconnect explicitly starts a new epoch and permits one fresh attempt.
mutating func endLinkEpoch(_ link: BLEIngressLinkID) {
lastAttemptAt.removeValue(forKey: link)
}
mutating func removeAll() {
lastAttemptAt.removeAll()
}
}
+343 -48
View File
@@ -241,6 +241,7 @@ final class BLEService: NSObject {
// that the session was established *on this current ingress link*, not // that the session was established *on this current ingress link*, not
// merely that some session exists for the claimed ID. bleQueue-owned. // merely that some session exists for the claimed ID. bleQueue-owned.
private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:] private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:]
private var noiseReconnectPolicy = BLENoiseReconnectPolicy()
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link // Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
// store): entries older than the cooldown are pruned on insert. // store): entries older than the cooldown are pruned on insert.
@@ -311,6 +312,9 @@ final class BLEService: NSObject {
/// May block in tests to hold the serial message queue immediately before /// May block in tests to hold the serial message queue immediately before
/// the deferred private-media admission check. /// the deferred private-media admission check.
var _test_beforePrivateMediaDeferredSend: ((String) -> Void)? var _test_beforePrivateMediaDeferredSend: ((String) -> Void)?
/// May block announce handling after verified-link rebind work is queued.
/// Tests use this boundary to prove rebind and reconnect are serialized.
var _test_afterVerifiedDirectRebindEnqueued: (() -> Void)?
#endif #endif
private var selfBroadcastTracker = BLESelfBroadcastTracker() private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker() private let meshTopology = MeshTopologyTracker()
@@ -703,6 +707,7 @@ final class BLEService: NSObject {
/// or advertising while the full panic transaction is incomplete. /// or advertising while the full panic transaction is incomplete.
func suspendForPanicReset() { func suspendForPanicReset() {
setPanicSuspended(true) setPanicSuspended(true)
noisePacketHandler.resetForPanic()
gossipSyncManager?.stop() gossipSyncManager?.stop()
gossipSyncManager = nil gossipSyncManager = nil
// Stop the radio and drain CoreBluetooth's delegate queue first. A // Stop the radio and drain CoreBluetooth's delegate queue first. A
@@ -713,7 +718,11 @@ final class BLEService: NSObject {
// Drain every receive/send submitted by callbacks that finished ahead // Drain every receive/send submitted by callbacks that finished ahead
// of the radio stop. Later callbacks observe the closed lifecycle, and // of the radio stop. Later callbacks observe the closed lifecycle, and
// generation-bound handoffs that raced this barrier reject themselves. // generation-bound handoffs that raced this barrier reject themselves.
messageQueue.sync(flags: .barrier) {} // Clear the old identity's bounded early-ciphertext queue again after
// those callbacks drain so none can repopulate it after the first wipe.
messageQueue.sync(flags: .barrier) {
noisePacketHandler.resetForPanic()
}
clearEmergencySessionState() clearEmergencySessionState()
} }
@@ -732,6 +741,7 @@ final class BLEService: NSObject {
gossipSyncManager?.stop() gossipSyncManager?.stop()
gossipSyncManager = nil gossipSyncManager = nil
messageQueue.sync(flags: .barrier) { messageQueue.sync(flags: .barrier) {
noisePacketHandler.resetForPanic()
pendingNoiseSessionQueues.removeAll() pendingNoiseSessionQueues.removeAll()
} }
@@ -764,6 +774,8 @@ final class BLEService: NSObject {
bleQueue.sync { bleQueue.sync {
pendingWriteBuffers.removeAll() pendingWriteBuffers.removeAll()
noiseAuthenticatedLinkOwners.removeAll()
noiseReconnectPolicy.removeAll()
connectionScheduler.reset() connectionScheduler.reset()
} }
disconnectNotifyDebouncer.removeAll() disconnectNotifyDebouncer.removeAll()
@@ -1061,6 +1073,7 @@ final class BLEService: NSObject {
bleQueue.sync { bleQueue.sync {
linkStateStore.clearAll() linkStateStore.clearAll()
noiseAuthenticatedLinkOwners.removeAll() noiseAuthenticatedLinkOwners.removeAll()
noiseReconnectPolicy.removeAll()
connectionScheduler.reset() connectionScheduler.reset()
subscriptionAnnounceLimiter.removeAll() subscriptionAnnounceLimiter.removeAll()
} }
@@ -1950,26 +1963,15 @@ final class BLEService: NSObject {
// Encode once using a small per-type padding policy, then delegate by type // Encode once using a small per-type padding policy, then delegate by type
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type) let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
// The 256-fragment ceiling exists to protect *current Android* // Cross-platform private-media v1 is bounded by Android's deployed
// receivers, which only ever receive private media over the directed // 256-fragment receive cap. Run the same planner the scheduler will
// raw-file migration fallback (they do not implement the encrypted // use, after route application, for both encrypted and consented raw
// 0x20 path). Encrypted private media (`noiseEncrypted`) is sent only to // migration sends. Reject before reserving a transfer slot or writing
// peers that advertised the `.privateMedia` capability modern clients // any fragment; public media is intentionally unaffected.
// that assemble up to the full receiver ceiling (see
// `BLEFragmentAssemblyBuffer`'s 10,000-fragment guard) so forcing them
// down to Android's 256 cap would needlessly reject iOSiOS photos in
// the ~120512 KiB range that work today. Restrict the low cap to the
// migration fallback (directed `fileTransfer`); public media is
// unaffected. Run the same planner the scheduler will use, after route
// application, and reject before reserving a transfer slot or writing
// any fragment.
// TODO(#1434): negotiate an explicit per-peer fragment limit so a future
// Android client that adopts the encrypted 0x20 path but still caps its
// reassembler can advertise its own ceiling instead of relying on the
// capability/type proxy above.
if let transferId, if let transferId,
let recipientPeerID = PeerID(hexData: packetToSend.recipientID), let recipientPeerID = PeerID(hexData: packetToSend.recipientID),
packetToSend.type == MessageType.fileTransfer.rawValue { packetToSend.type == MessageType.noiseEncrypted.rawValue
|| packetToSend.type == MessageType.fileTransfer.rawValue {
let compatibilityRequest = BLEOutboundFragmentTransferRequest( let compatibilityRequest = BLEOutboundFragmentTransferRequest(
packet: packetToSend, packet: packetToSend,
pad: padForBLE, pad: padForBLE,
@@ -2628,6 +2630,7 @@ final class BLEService: NSObject {
} }
for link in departedLinks { for link in departedLinks {
noiseAuthenticatedLinkOwners.removeValue(forKey: link) noiseAuthenticatedLinkOwners.removeValue(forKey: link)
noiseReconnectPolicy.endLinkEpoch(link)
} }
} }
_ = collectionsQueue.sync(flags: .barrier) { _ = collectionsQueue.sync(flags: .barrier) {
@@ -2940,14 +2943,21 @@ extension BLEService: CBCentralManagerDelegate {
startScanning() startScanning()
case .poweredOff: case .poweredOff:
// Bluetooth was turned off - stop scanning and clean up connection state // CoreBluetooth has already transitioned out of poweredOn. Do
// not issue stop/cancel commands now; they are rejected as API
// misuse. Retire our link state locally instead.
SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session) SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
central.stopScan()
// Mark all peripheral connections as disconnected (they are now invalid)
let peripheralStates = linkStateStore.peripheralStates let peripheralStates = linkStateStore.peripheralStates
let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID) let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID)
for state in peripheralStates { for state in peripheralStates {
central.cancelPeripheralConnection(state.peripheral) let peripheralID = state.peripheral.identifier.uuidString
collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
noiseAuthenticatedLinkOwners.removeValue(
forKey: .peripheral(peripheralID)
)
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
} }
_ = linkStateStore.clearPeripherals() _ = linkStateStore.clearPeripherals()
// Notify UI of disconnections // Notify UI of disconnections
@@ -2960,7 +2970,6 @@ extension BLEService: CBCentralManagerDelegate {
case .unauthorized: case .unauthorized:
// User denied Bluetooth permission // User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session) SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
central.stopScan()
_ = linkStateStore.clearPeripherals() _ = linkStateStore.clearPeripherals()
case .unsupported: case .unsupported:
@@ -3108,6 +3117,7 @@ extension BLEService: CBCentralManagerDelegate {
pendingPeripheralWrites.discardAll(for: peripheralID) pendingPeripheralWrites.discardAll(for: peripheralID)
} }
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = linkStateStore.removePeripheral(peripheralID) _ = linkStateStore.removePeripheral(peripheralID)
// A duplicate link can drop while the peer stays live on another // A duplicate link can drop while the peer stays live on another
// (the dual-role central link, or a second bound link after a // (the dual-role central link, or a second bound link after a
@@ -3162,6 +3172,7 @@ extension BLEService: CBCentralManagerDelegate {
pendingPeripheralWrites.discardAll(for: peripheralID) pendingPeripheralWrites.discardAll(for: peripheralID)
} }
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = linkStateStore.removePeripheral(peripheralID) _ = linkStateStore.removePeripheral(peripheralID)
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session) SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
@@ -3269,6 +3280,7 @@ extension BLEService {
self.pendingPeripheralWrites.discardAll(for: peripheralID) self.pendingPeripheralWrites.discardAll(for: peripheralID)
} }
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = self.linkStateStore.removePeripheral(peripheralID) _ = self.linkStateStore.removePeripheral(peripheralID)
self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date()) self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
self.tryConnectFromQueue() self.tryConnectFromQueue()
@@ -3550,6 +3562,28 @@ extension BLEService {
} }
} }
/// Replays the current generation's ready callback. Restore tests use
/// this to prove same-generation reconciliation is idempotent.
func _test_reconcileCurrentNoiseSession(for peerID: PeerID) {
let normalizedPeerID = peerID.toShort()
messageQueue.async(flags: .barrier) { [weak self] in
guard let self,
let generation = self.noiseService.sessionGeneration(
for: normalizedPeerID
),
let fingerprint = self.noiseService.getPeerFingerprint(
normalizedPeerID
) else {
return
}
self.handleNoisePeerAuthenticated(
peerID: normalizedPeerID,
fingerprint: fingerprint,
sessionGeneration: generation
)
}
}
/// Builds an authenticated-session packet from an exact typed plaintext. /// Builds an authenticated-session packet from an exact typed plaintext.
/// Compatibility tests use this to model Android's deployed 0x20 file /// Compatibility tests use this to model Android's deployed 0x20 file
/// payload and the short-lived 0x09 prerelease payload without exposing a /// payload and the short-lived 0x09 prerelease payload without exposing a
@@ -3849,8 +3883,19 @@ extension BLEService: CBPeripheralManagerDelegate {
case .poweredOff: case .poweredOff:
// Bluetooth was turned off - clean up peripheral state // Bluetooth was turned off - clean up peripheral state
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session) SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
peripheral.stopAdvertising()
// Clear subscribed centrals (they are now invalid) // Clear subscribed centrals (they are now invalid)
let centralSnapshot = linkStateStore.subscribedCentralSnapshot
for central in centralSnapshot.centrals {
let centralID = central.identifier.uuidString
noiseAuthenticatedLinkOwners.removeValue(
forKey: .central(centralID)
)
noiseReconnectPolicy.endLinkEpoch(.central(centralID))
}
collectionsQueue.sync(flags: .barrier) {
pendingNotifications.removeAll()
pendingWriteBuffers.removeAll()
}
let centralPeerIDs = linkStateStore.clearCentrals() let centralPeerIDs = linkStateStore.clearCentrals()
subscriptionAnnounceLimiter.removeAll() subscriptionAnnounceLimiter.removeAll()
characteristic = nil characteristic = nil
@@ -3864,7 +3909,6 @@ extension BLEService: CBPeripheralManagerDelegate {
case .unauthorized: case .unauthorized:
// User denied Bluetooth permission // User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session) SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
peripheral.stopAdvertising()
_ = linkStateStore.clearCentrals() _ = linkStateStore.clearCentrals()
subscriptionAnnounceLimiter.removeAll() subscriptionAnnounceLimiter.removeAll()
characteristic = nil characteristic = nil
@@ -3977,6 +4021,7 @@ extension BLEService: CBPeripheralManagerDelegate {
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID } pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
} }
noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID)) noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID))
noiseReconnectPolicy.endLinkEpoch(.central(centralID))
let removedPeerID = linkStateStore.removeSubscribedCentral(central) let removedPeerID = linkStateStore.removeSubscribedCentral(central)
// Ensure we're still advertising for other devices to find us // Ensure we're still advertising for other devices to find us
@@ -4595,10 +4640,47 @@ extension BLEService {
} }
} }
/// A peer-level session can outlive the physical link that established it.
/// Revalidate a fresh direct link with an ordinary XX exchange, retiring
/// cached sending keys atomically before message 1 can leave.
private func refreshNoiseSessionForVerifiedDirectLink(
_ packet: BitchatPacket,
peerID: PeerID
) {
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else {
return
}
let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID)
let authenticatedPeerLinks = currentNoiseAuthenticatedLinks(to: peerID)
let shouldRevalidate = readLinkState { store in
guard boundPeerID(for: link, in: store) == peerID else {
return false
}
return noiseReconnectPolicy.shouldRevalidate(
on: link,
hasEstablishedSession: hasEstablishedSession,
isNoiseAuthenticatedLink: noiseAuthenticatedLinkOwners[link] == peerID,
hasAuthenticatedPeerLink: !authenticatedPeerLinks.isEmpty,
now: Date()
)
}
guard shouldRevalidate else { return }
SecureLogger.info(
"🔄 Revalidating cached Noise session on fresh direct link to \(peerID.id.prefix(8))",
category: .session
)
initiateNoiseReconnectHandshake(with: peerID)
}
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) { private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))") SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))")
self?.messageQueue.async { [weak self] in // Authentication can be reported while an initiator is still
// returning XX message 3. Serialize generation-bound state and
// every post-handshake drain behind the handshake packet handler.
self?.messageQueue.async(flags: .barrier) { [weak self] in
self?.handleNoisePeerAuthenticated( self?.handleNoisePeerAuthenticated(
peerID: peerID, peerID: peerID,
fingerprint: fingerprint, fingerprint: fingerprint,
@@ -4606,13 +4688,96 @@ extension BLEService {
) )
} }
} }
service.onRekeyHandshakeReady = { [weak self] peerID, message in service.onRekeyHandshakeReady = {
self?.messageQueue.async { [weak self] in [weak self, weak service] peerID, initiation in
guard let self else { return } self?.messageQueue.async(flags: .barrier) {
[weak self, weak service] in
guard let self,
let service,
self.noiseService === service else {
return
}
self.noteNoiseSessionCleared(for: peerID) self.noteNoiseSessionCleared(for: peerID)
guard let message = service.claimHandshakeInitiation(
initiation,
for: peerID
) else {
return
}
self.broadcastNoiseHandshake(message, to: peerID) self.broadcastNoiseHandshake(message, to: peerID)
} }
} }
service.onHandshakeRecoveryRequired = {
[weak self, weak service] request in
guard let self, let service else { return }
self.messageQueue.async(flags: .barrier) {
[weak self, weak service] in
guard let self,
let service,
self.noiseService === service else {
return
}
let peerID = request.peerID
guard self.isPeerReachable(peerID) else {
service.cancelHandshakeRecovery(request)
return
}
do {
guard let preparation =
try service.prepareHandshakeRecovery(request) else {
return
}
switch preparation {
case .ordinary(let initiation):
self.noteNoiseSessionCleared(for: peerID)
guard let handshakeData =
service.claimHandshakeInitiation(
initiation,
for: peerID
) else {
return
}
self.broadcastNoiseHandshake(
handshakeData,
to: peerID
)
case .transferred:
return
}
} catch {
SecureLogger.error(
"Failed to prepare handshake recovery with \(peerID.id.prefix(8))…: \(error)",
category: .session
)
}
}
}
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in
guard let self, let service else { return }
// The manager makes restored keys visible atomically. Reconcile
// transport state and queued sends as the next serialized phase.
self.messageQueue.async(flags: .barrier) { [weak self, weak service] in
guard let self,
let service,
self.noiseService === service,
let fingerprint = service.getPeerFingerprint(peerID) else {
return
}
SecureLogger.debug(
"🔐 Restored quarantined Noise session with \(peerID.id.prefix(8))",
category: .session
)
// Re-enter the same generation-bound transition used after a
// successful handshake. This restores authenticated protocol
// state and drains both PM and typed-payload queues.
self.handleNoisePeerAuthenticated(
peerID: peerID,
fingerprint: fingerprint,
sessionGeneration: generation
)
}
}
} }
private func handleNoisePeerAuthenticated( private func handleNoisePeerAuthenticated(
@@ -4660,7 +4825,16 @@ extension BLEService {
} }
) else { return } ) else { return }
guard let watchdog = transition.watchdog else { return } guard let watchdog = transition.watchdog else {
// A quarantined transport restored the same cryptographic
// generation. Its capability proof and announce state never
// became stale; only work queued while outbound keys were paused
// needs one idempotent ready transition.
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
sendPendingMessagesAfterHandshake(for: normalizedPeerID)
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
return
}
completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade) completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade)
schedulePrivateMediaProofTimeout( schedulePrivateMediaProofTimeout(
@@ -4669,6 +4843,10 @@ extension BLEService {
sessionGeneration: generation, sessionGeneration: generation,
nonce: watchdog.nonce nonce: watchdog.nonce
) )
// Cross-link delivery can put ciphertext sent immediately after
// message 3 ahead of message 3 itself. Retry the bounded queue only
// after this generation's transport state has been fully installed.
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
// `onPeerAuthenticated` can fire while the initiator is returning XX // `onPeerAuthenticated` can fire while the initiator is returning XX
// message 3. This callback is queued behind the handshake handler, so // message 3. This callback is queued behind the handshake handler, so
@@ -4866,8 +5044,9 @@ extension BLEService {
} }
return return
} }
guard noiseService.hasSession(with: peerID) else { guard noiseService.hasEstablishedSession(with: peerID) else {
// No session yet - queue the payload SYNCHRONOUSLY before initiating handshake // No established session yet - queue the payload synchronously
// before initiating a handshake
// to prevent race where fast handshake completion drains empty queue // to prevent race where fast handshake completion drains empty queue
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID) self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID)
@@ -5797,6 +5976,7 @@ extension BLEService {
self.pendingPeripheralWrites.discardAll(for: peripheralID) self.pendingPeripheralWrites.discardAll(for: peripheralID)
} }
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = self.linkStateStore.removePeripheral(peripheralID) _ = self.linkStateStore.removePeripheral(peripheralID)
cancelled += 1 cancelled += 1
} }
@@ -5862,12 +6042,27 @@ extension BLEService {
} }
private func initiateNoiseHandshake(with peerID: PeerID) { private func initiateNoiseHandshake(with peerID: PeerID) {
// Use NoiseEncryptionService for handshake let service = noiseService
guard !noiseService.hasSession(with: peerID) else { return }
do { do {
let handshakeData = try noiseService.initiateHandshake(with: peerID) guard let initiation = try service.initiateHandshakeIfNeeded(
broadcastNoiseHandshake(handshakeData, to: peerID) with: peerID,
retryOnTimeout: true
) else {
return
}
messageQueue.async(flags: .barrier) {
[weak self, weak service] in
guard let self,
let service,
self.noiseService === service,
let handshakeData = service.claimHandshakeInitiation(
initiation,
for: peerID
) else {
return
}
self.broadcastNoiseHandshake(handshakeData, to: peerID)
}
} catch { } catch {
SecureLogger.error("Failed to initiate handshake: \(error)") SecureLogger.error("Failed to initiate handshake: \(error)")
} }
@@ -5886,6 +6081,42 @@ extension BLEService {
broadcastPacket(packet) broadcastPacket(packet)
} }
/// Starts a wire-compatible ordinary XX reconnect. The manager prepares
/// the initiator before atomically retiring the cached transport; the
/// one-shot claim prevents a crossed inbound message from making a stale
/// message 1 leave after this peer has already become responder.
private func initiateNoiseReconnectHandshake(with peerID: PeerID) {
let service = noiseService
do {
let initiation = try service.initiateReconnectHandshake(
with: peerID,
retryOnTimeout: true
)
messageQueue.async(flags: .barrier) { [weak self, weak service] in
guard let self,
let service,
self.noiseService === service else {
return
}
self.noteNoiseSessionCleared(for: peerID)
guard let handshakeData = service.claimHandshakeInitiation(
initiation,
for: peerID
) else {
return
}
self.broadcastNoiseHandshake(handshakeData, to: peerID)
}
} catch NoiseSessionError.notEstablished {
initiateNoiseHandshake(with: peerID)
} catch {
SecureLogger.error(
"Failed to initiate ordinary reconnect: \(error)",
category: .session
)
}
}
private func sendPendingMessagesAfterHandshake(for peerID: PeerID) { private func sendPendingMessagesAfterHandshake(for peerID: PeerID) {
// Atomically take all pending messages to process (prevents concurrent modification) // Atomically take all pending messages to process (prevents concurrent modification)
let pendingMessages = collectionsQueue.sync(flags: .barrier) { () -> [BLEPendingPrivateMessage] in let pendingMessages = collectionsQueue.sync(flags: .barrier) { () -> [BLEPendingPrivateMessage] in
@@ -6230,7 +6461,12 @@ extension BLEService {
// MARK: Packet Reception // MARK: Packet Reception
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) { private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
// Call directly if already on messageQueue, otherwise dispatch let isNoisePacket = packet.type == MessageType.noiseHandshake.rawValue
|| packet.type == MessageType.noiseEncrypted.rawValue
// Capture the panic lifecycle at the first off-messageQueue handoff.
// Noise packets still enter through a barrier so handshake promotion,
// quarantine, and encrypted delivery share one ordered session.
if DispatchQueue.getSpecific(key: messageQueueKey) == nil { if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
guard let lifecycleGeneration = guard let lifecycleGeneration =
capturePanicLifecycleGeneration() else { capturePanicLifecycleGeneration() else {
@@ -6239,7 +6475,8 @@ extension BLEService {
#if DEBUG #if DEBUG
_test_beforeReceivePacketHandoff?() _test_beforeReceivePacketHandoff?()
#endif #endif
messageQueue.async { [weak self] in let flags: DispatchWorkItemFlags = isNoisePacket ? .barrier : []
messageQueue.async(flags: flags) { [weak self] in
guard let self, guard let self,
self.isCurrentPanicLifecycleGeneration( self.isCurrentPanicLifecycleGeneration(
lifecycleGeneration lifecycleGeneration
@@ -6249,11 +6486,34 @@ extension BLEService {
#if DEBUG #if DEBUG
self._test_onReceivePacketHandoff?() self._test_onReceivePacketHandoff?()
#endif #endif
self.handleReceivedPacket(packet, from: peerID) self.handleReceivedPacketOnQueue(packet, from: peerID)
} }
return return
} }
if isNoisePacket {
guard let lifecycleGeneration =
capturePanicLifecycleGeneration() else {
return
}
messageQueue.async(flags: .barrier) { [weak self] in
guard let self,
self.isCurrentPanicLifecycleGeneration(
lifecycleGeneration
) else {
return
}
self.handleReceivedPacketOnQueue(packet, from: peerID)
}
} else {
handleReceivedPacketOnQueue(packet, from: peerID)
}
}
private func handleReceivedPacketOnQueue(
_ packet: BitchatPacket,
from peerID: PeerID
) {
let context = BLEReceivePipeline.context(for: packet, localPeerID: myPeerID) let context = BLEReceivePipeline.context(for: packet, localPeerID: myPeerID)
let senderID = context.senderID let senderID = context.senderID
let messageID = context.messageID let messageID = context.messageID
@@ -6440,6 +6700,9 @@ extension BLEService {
// consolidate duplicate same-role connections onto that link. // consolidate duplicate same-role connections onto that link.
if let result, result.isVerified, result.isDirectAnnounce { if let result, result.isVerified, result.isDirectAnnounce {
rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID) rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID)
#if DEBUG
_test_afterVerifiedDirectRebindEnqueued?()
#endif
retireRedundantPeripheralLinks(packet, to: result.peerID) retireRedundantPeripheralLinks(packet, to: result.peerID)
} }
@@ -6473,11 +6736,9 @@ extension BLEService {
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey) deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
if result.isDirectAnnounce, if result.isDirectAnnounce,
!hasCurrentNoiseAuthenticatedLink(to: result.peerID) { !hasCurrentNoiseAuthenticatedLink(to: result.peerID) {
if noiseService.hasEstablishedSession(with: result.peerID) { // A cached session may predate this physical link.
// A session with no surviving authenticated link is stale; // rebindLinkAfterVerifiedDirectAnnounce performs its atomic
// force the current link to prove possession again. // ordinary reconnect after the binding is published.
clearNoiseSession(for: result.peerID)
}
if !noiseService.hasSession(with: result.peerID) { if !noiseService.hasSession(with: result.peerID) {
initiateNoiseHandshake(with: result.peerID) initiateNoiseHandshake(with: result.peerID)
} }
@@ -6506,7 +6767,14 @@ extension BLEService {
linkUUID = centralUUID linkUUID = centralUUID
previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID) previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID)
} }
guard let previousPeerID, previousPeerID != peerID else { return } guard let previousPeerID else { return }
guard previousPeerID != peerID else {
self.refreshNoiseSessionForVerifiedDirectLink(
packet,
peerID: peerID
)
return
}
// The signature does not authenticate directness (TTL is excluded // The signature does not authenticate directness (TTL is excluded
// from signing because relays mutate it), so a "verified direct" // from signing because relays mutate it), so a "verified direct"
@@ -6533,12 +6801,20 @@ extension BLEService {
// it across an announce-driven rebind, whose direct TTL is // it across an announce-driven rebind, whose direct TTL is
// replayable; the new owner must complete a fresh handshake. // replayable; the new owner must complete a fresh handshake.
self.noiseAuthenticatedLinkOwners.removeValue(forKey: link) self.noiseAuthenticatedLinkOwners.removeValue(forKey: link)
self.noiseReconnectPolicy.endLinkEpoch(link)
switch link { switch link {
case .peripheral(let peripheralUUID): case .peripheral(let peripheralUUID):
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID) self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
case .central(let centralUUID): case .central(let centralUUID):
self.linkStateStore.bindCentral(centralUUID, to: peerID) self.linkStateStore.bindCentral(centralUUID, to: peerID)
} }
// Keep the rebind and reconnect decision in one bleQueue critical
// section. No observer may see the new binding while a cached
// peer-level sender is still considered established.
self.refreshNoiseSessionForVerifiedDirectLink(
packet,
peerID: peerID
)
SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))", category: .session) SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))", category: .session)
self.refreshLocalTopology() self.refreshLocalTopology()
// The announce that triggered this rebind was upserted as // The announce that triggered this rebind was upserted as
@@ -6630,6 +6906,7 @@ extension BLEService {
pendingPeripheralWrites.discardAll(for: uuid) pendingPeripheralWrites.discardAll(for: uuid)
} }
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid)) noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid))
noiseReconnectPolicy.endLinkEpoch(.peripheral(uuid))
_ = linkStateStore.removePeripheral(uuid) _ = linkStateStore.removePeripheral(uuid)
SecureLogger.info( SecureLogger.info(
"🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")", "🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")",
@@ -7016,6 +7293,12 @@ extension BLEService {
packet, packet,
from: peerID from: peerID
) )
// An inbound message 1 quarantines the old transport receive-only.
// Keep its generation-bound BLE state intact: the manager's new
// handshaking generation already gates every outbound policy, while
// a rollback can become ready again without repeating capability
// proof or announce side effects. Only the exact handshake candidate's
// authenticated completion may promote the physical ingress link.
if result.didEstablishAuthenticatedSession { if result.didEstablishAuthenticatedSession {
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID) markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
} }
@@ -7053,6 +7336,11 @@ extension BLEService {
hasNoiseSession: { [weak self] peerID in hasNoiseSession: { [weak self] peerID in
self?.noiseService.hasSession(with: peerID) ?? false self?.noiseService.hasSession(with: peerID) ?? false
}, },
isAwaitingResponderHandshakeCompletion: { [weak self] peerID in
self?.noiseService.isAwaitingResponderHandshakeCompletion(
with: peerID
) ?? false
},
initiateHandshake: { [weak self] peerID in initiateHandshake: { [weak self] peerID in
self?.initiateNoiseHandshake(with: peerID) self?.initiateNoiseHandshake(with: peerID)
}, },
@@ -7066,7 +7354,14 @@ extension BLEService {
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished } guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
let result = try self.noiseService.decryptWithSessionGeneration( let result = try self.noiseService.decryptWithSessionGeneration(
payload, payload,
from: peerID from: peerID,
establishedGenerationIsReady: { generation in
self.collectionsQueue.sync {
self.privateMediaSessionGenerations[
peerID.toShort()
] == generation
}
}
) )
return BLENoiseDecryptionResult( return BLENoiseDecryptionResult(
plaintext: result.plaintext, plaintext: result.plaintext,
+158 -22
View File
@@ -184,11 +184,17 @@ final class NoiseEncryptionService {
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = [] private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = []
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
/// Automatic rekey removed the old session and produced XX message 1. /// Automatic rekey prepared XX message 1. The transport must claim the
/// The transport must clear session-scoped state and put these exact bytes /// exact attempt at its actual BLE handoff; a crossed inbound initiation
/// on the wire; merely reporting "handshake required" strands the partial /// can invalidate the token before that point.
/// initiator session because a second initiate call sees it already exists. var onRekeyHandshakeReady:
var onRekeyHandshakeReady: ((_ peerID: PeerID, _ message: Data) -> Void)? ((_ peerID: PeerID, _ initiation: NoiseHandshakeInitiation) -> Void)?
var onHandshakeRecoveryRequired:
((_ request: NoiseHandshakeRecoveryRequest) -> Void)?
/// An unauthenticated reconnect attempt failed or timed out and the
/// receive-only rollback session became the active transport again.
/// Transport queues must be drained for this exact restored generation.
var onSessionRestoredWithGeneration: ((_ peerID: PeerID, _ generation: UUID) -> Void)?
// Add a handler for peer authentication // Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) { func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
@@ -219,7 +225,17 @@ final class NoiseEncryptionService {
} }
} }
init(keychain: KeychainManagerProtocol) { init(
keychain: KeychainManagerProtocol,
ordinaryHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryHandshakeTimeout,
ordinaryResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
recentInitiatorCompletionGracePeriod: TimeInterval =
NoiseSecurityConstants.recentInitiatorCompletionGracePeriod,
ordinaryReconnectRollbackCooldown: TimeInterval =
NoiseSecurityConstants.ordinaryReconnectRollbackCooldown
) {
self.keychain = keychain self.keychain = keychain
self.localPrekeys = LocalPrekeyStore(keychain: keychain) self.localPrekeys = LocalPrekeyStore(keychain: keychain)
@@ -309,7 +325,17 @@ final class NoiseEncryptionService {
self.signingPublicKey = signingKey.publicKey self.signingPublicKey = signingKey.publicKey
// Initialize session manager // Initialize session manager
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain) self.sessionManager = NoiseSessionManager(
localStaticKey: staticIdentityKey,
keychain: keychain,
ordinaryHandshakeTimeout: ordinaryHandshakeTimeout,
ordinaryResponderHandshakeTimeout:
ordinaryResponderHandshakeTimeout,
recentInitiatorCompletionGracePeriod:
recentInitiatorCompletionGracePeriod,
ordinaryReconnectRollbackCooldown:
ordinaryReconnectRollbackCooldown
)
// Set up session callbacks // Set up session callbacks
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
@@ -319,6 +345,12 @@ final class NoiseEncryptionService {
sessionGeneration: generation sessionGeneration: generation
) )
} }
sessionManager.onSessionRestored = { [weak self] peerID, generation in
self?.onSessionRestoredWithGeneration?(peerID, generation)
}
sessionManager.onHandshakeRecoveryRequired = { [weak self] request in
self?.onHandshakeRecoveryRequired?(request)
}
// Start session maintenance timer // Start session maintenance timer
startRekeyTimer() startRekeyTimer()
@@ -683,6 +715,90 @@ final class NoiseEncryptionService {
return handshakeData return handshakeData
} }
/// Atomically admits and prepares one initial ordinary handshake. Returns
/// nil when another discovery callback already created a session.
func initiateHandshakeIfNeeded(
with peerID: PeerID,
retryOnTimeout: Bool = false
) throws -> NoiseHandshakeInitiation? {
guard peerID.isValid else {
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
throw NoiseSecurityError.invalidPeerID
}
guard let initiation = try sessionManager.initiateHandshakeIfAbsent(
with: peerID,
notifyOnTimeout: retryOnTimeout,
authorize: { [rateLimiter] in
guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.warning(
.authenticationFailed(peerID: "Rate limited: \(peerID)")
)
throw NoiseSecurityError.rateLimitExceeded
}
}
) else {
return nil
}
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
return initiation
}
/// Atomically prepares an ordinary reconnect for a peer whose cached
/// transport belongs to an earlier physical link. Failed authorization or
/// handshake setup preserves the established session.
func initiateReconnectHandshake(
with peerID: PeerID,
retryOnTimeout: Bool = false
) throws -> NoiseHandshakeInitiation {
guard peerID.isValid else {
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
throw NoiseSecurityError.invalidPeerID
}
return try sessionManager.initiateReconnectHandshake(
with: peerID,
notifyOnTimeout: retryOnTimeout,
authorize: { [rateLimiter] in
guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.warning(
.authenticationFailed(peerID: "Rate limited: \(peerID)")
)
throw NoiseSecurityError.rateLimitExceeded
}
}
)
}
func prepareHandshakeRecovery(
_ request: NoiseHandshakeRecoveryRequest
) throws -> NoiseHandshakeRecoveryPreparation? {
try sessionManager.prepareHandshakeRecovery(
request,
authorizeAttempt: { [rateLimiter] in
guard rateLimiter.allowHandshake(from: request.peerID) else {
SecureLogger.warning(
.authenticationFailed(
peerID: "Rate limited: \(request.peerID)"
)
)
throw NoiseSecurityError.rateLimitExceeded
}
}
)
}
func cancelHandshakeRecovery(_ request: NoiseHandshakeRecoveryRequest) {
sessionManager.cancelHandshakeRecovery(request)
}
func claimHandshakeInitiation(
_ initiation: NoiseHandshakeInitiation,
for peerID: PeerID
) -> Data? {
sessionManager.claimHandshakeInitiation(initiation, for: peerID)
}
/// Process an incoming handshake message /// Process an incoming handshake message
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? { func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
try processHandshakeMessageWithResult( try processHandshakeMessageWithResult(
@@ -738,6 +854,13 @@ final class NoiseEncryptionService {
return sessionManager.getSession(for: peerID) != nil return sessionManager.getSession(for: peerID) != nil
} }
/// True while an inbound ordinary XX responder is waiting for message 3.
/// A small amount of immediately-following ciphertext may arrive first
/// over BLE and must be retried only after responder promotion.
func isAwaitingResponderHandshakeCompletion(with peerID: PeerID) -> Bool {
sessionManager.isAwaitingResponderHandshakeCompletion(for: peerID)
}
// MARK: - Encryption/Decryption // MARK: - Encryption/Decryption
/// Encrypt data for a specific peer /// Encrypt data for a specific peer
@@ -804,27 +927,37 @@ final class NoiseEncryptionService {
func decryptWithSessionGeneration( func decryptWithSessionGeneration(
_ data: Data, _ data: Data,
from peerID: PeerID from peerID: PeerID,
establishedGenerationIsReady: (UUID) -> Bool = { _ in true }
) throws -> (plaintext: Data, sessionGeneration: UUID) { ) throws -> (plaintext: Data, sessionGeneration: UUID) {
// Standard transport ciphertext has 20 bytes of nonce/tag overhead. // Standard transport ciphertext has 20 bytes of nonce/tag overhead.
// A larger candidate is admitted only up to the framed-file ceiling; // A larger ciphertext is admitted only up to the framed-file ceiling;
// after authenticated decryption it must prove it is `.privateFile`. // after authenticated decryption it must prove it is `.privateFile`.
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data) let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
guard isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) else { let isAdmittedCiphertext = isStandardCiphertext
throw NoiseSecurityError.messageTooLarge || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data)
}
// Check rate limit // A quarantined transport is deliberately unavailable for outbound
guard rateLimiter.allowMessage(from: peerID) else { // state, but remains receive-only until the responder proves identity
throw NoiseSecurityError.rateLimitExceeded // or the bounded rollback restores it.
} guard sessionManager.hasReceiveSession(for: peerID) else {
// Check if we have an established session
guard hasEstablishedSession(with: peerID) else {
throw NoiseEncryptionError.sessionNotEstablished throw NoiseEncryptionError.sessionNotEstablished
} }
let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID) let result = try sessionManager.decryptWithSessionGeneration(
data,
from: peerID,
establishedGenerationIsReady:
establishedGenerationIsReady,
authorizeDecrypt: { [rateLimiter] in
guard isAdmittedCiphertext else {
throw NoiseSecurityError.messageTooLarge
}
guard rateLimiter.allowMessage(from: peerID) else {
throw NoiseSecurityError.rateLimitExceeded
}
}
)
if !isStandardCiphertext { if !isStandardCiphertext {
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first), guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else { NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
@@ -943,9 +1076,9 @@ final class NoiseEncryptionService {
} }
private func initiateAutomaticRekey(for peerID: PeerID) throws { private func initiateAutomaticRekey(for peerID: PeerID) throws {
let handshakeMessage = try sessionManager.initiateRekey(for: peerID) let initiation = try sessionManager.initiateRekey(for: peerID)
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security) SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
onRekeyHandshakeReady?(peerID, handshakeMessage) onRekeyHandshakeReady?(peerID, initiation)
onHandshakeRequired?(peerID) onHandshakeRequired?(peerID)
} }
@@ -1041,6 +1174,9 @@ struct NoiseMessage: Codable {
enum NoiseEncryptionError: Error { enum NoiseEncryptionError: Error {
case handshakeRequired case handshakeRequired
case sessionNotEstablished case sessionNotEstablished
/// Manager keys are established or restored, but BLE has not installed
/// generation-bound transport state. No receive nonce was consumed.
case transportGenerationNotReady
/// Envelope references a prekey ID we don't hold (never ours, already /// Envelope references a prekey ID we don't hold (never ours, already
/// deleted after its grace window, or wiped in a panic). /// deleted after its grace window, or wiped in a panic).
case unknownPrekey case unknownPrekey
+225 -11
View File
@@ -502,14 +502,26 @@ struct BLEServiceCoreTests {
) )
let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce") let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
#expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink)) #expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink))
let rebindGate = VerifiedDirectRebindGate()
ble._test_afterVerifiedDirectRebindEnqueued = rebindGate.pause
defer {
rebindGate.release()
ble._test_afterVerifiedDirectRebindEnqueued = nil
}
ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false) ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false)
let rebound = await TestHelpers.waitUntil( let announcePaused = await TestHelpers.waitUntil(
{ ble._test_centralBinding(attackerLink) == victimPeerID }, { rebindGate.hasPaused },
timeout: TestConstants.longTimeout timeout: TestConstants.longTimeout
) )
#expect(rebound) try #require(announcePaused)
#expect(ble.canDeliverSecurely(to: victimPeerID))
// Rebind and ordinary reconnect preparation are one bleQueue
// critical section. Once the binding is visible, stale sending keys
// must already be unavailable.
#expect(ble._test_centralBinding(attackerLink) == victimPeerID)
#expect(!ble.canDeliverSecurely(to: victimPeerID))
rebindGate.release()
let outbound = OutboundPacketTap() let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = { outbound.record($0) } ble._test_onOutboundPacket = { outbound.record($0) }
@@ -537,20 +549,26 @@ struct BLEServiceCoreTests {
// Preserve a working victim session while an unauthenticated // Preserve a working victim session while an unauthenticated
// replacement candidate arrives on a newly bound physical link. // replacement candidate arrives on a newly bound physical link.
let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID) // Establish BLE as responder so the replacement candidate below is
// not coalesced by the initiator-completion grace path.
let message1 = try victim.initiateHandshake(with: ble.myPeerID)
let message2 = try #require( let message2 = try #require(
try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
)
let message3 = try #require(
try ble._test_noiseProcessHandshakeMessage( try ble._test_noiseProcessHandshakeMessage(
from: victimPeerID, from: victimPeerID,
message: message1
)
)
let message3 = try #require(
try victim.processHandshakeMessage(
from: ble.myPeerID,
message: message2 message: message2
) )
) )
_ = try victim.processHandshakeMessage( _ = try ble._test_noiseProcessHandshakeMessage(
from: ble.myPeerID, from: victimPeerID,
message: message3 message: message3
) )
await ble._test_drainNoiseMessagePipeline()
#expect(ble.canDeliverSecurely(to: victimPeerID)) #expect(ble.canDeliverSecurely(to: victimPeerID))
let centralUUID = "central-replacement-xx-message-one" let centralUUID = "central-replacement-xx-message-one"
@@ -614,7 +632,168 @@ struct BLEServiceCoreTests {
for: victimPeerID for: victimPeerID
) )
) )
#expect(ble.canDeliverSecurely(to: victimPeerID)) // Ordinary reconnect hardening quarantines the cached transport while
// this candidate proves the claimed identity. It must be unavailable
// for sending as well as unable to authenticate this ingress link.
#expect(!ble.canDeliverSecurely(to: victimPeerID))
}
@Test
func failedInboundReconnectRestoresAndDrainsWaitingWorkOnce() async throws {
let ble = makeService()
let alice = NoiseEncryptionService(keychain: MockKeychain())
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
// Establish BLE as responder so the following inbound reconnect is
// not intentionally coalesced by the initiator-completion grace path.
let message1 = try alice.initiateHandshake(with: ble.myPeerID)
let message2 = try #require(
try ble._test_noiseProcessHandshakeMessage(
from: alicePeerID,
message: message1
)
)
let message3 = try #require(
try alice.processHandshakeMessage(
from: ble.myPeerID,
message: message2
)
)
_ = try ble._test_noiseProcessHandshakeMessage(
from: alicePeerID,
message: message3
)
await ble._test_drainNoiseMessagePipeline()
#expect(ble.canDeliverSecurely(to: alicePeerID))
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
let forgedMessage1 = try mallory.initiateHandshake(with: ble.myPeerID)
let firstPacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(),
recipientID: Data(hexString: ble.myPeerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
payload: forgedMessage1,
signature: nil,
ttl: 7
)
ble._test_handlePacket(firstPacket, fromPeerID: alicePeerID)
let responseReady = await TestHelpers.waitUntil(
{
outbound.snapshot().contains {
$0.type == MessageType.noiseHandshake.rawValue
&& PeerID(hexData: $0.senderID) == ble.myPeerID
&& $0.payload.count
!= NoiseSecurityConstants.xxInitialMessageSize
}
},
timeout: TestConstants.longTimeout
)
try #require(responseReady)
let forgedMessage2 = try #require(
outbound.snapshot().first {
$0.type == MessageType.noiseHandshake.rawValue
&& PeerID(hexData: $0.senderID) == ble.myPeerID
&& $0.payload.count
!= NoiseSecurityConstants.xxInitialMessageSize
}?.payload
)
#expect(!ble.canDeliverSecurely(to: alicePeerID))
// Typed control traffic must queue behind the ordinary responder,
// rather than attempting encryption and disappearing.
let privateMessageID = "quarantine-pm-\(UUID().uuidString)"
ble.sendPrivateMessage(
"queued private message",
to: alicePeerID,
recipientNickname: "Alice",
messageID: privateMessageID
)
ble.sendGroupInvite(Data("queued-during-quarantine".utf8), to: alicePeerID)
await ble._test_drainNoiseMessagePipeline()
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
let forgedMessage3 = try #require(
try mallory.processHandshakeMessage(
from: ble.myPeerID,
message: forgedMessage2
)
)
let forgedEarlyPayload = try #require(
BLENoisePayloadFactory.privateMessage(
content: "forged early message",
messageID: "forged-early"
)
)
try #require(
mallory.hasEstablishedSession(with: ble.myPeerID),
"forged initiator did not establish after producing message three"
)
let forgedEarlyCiphertext = try mallory.encrypt(
forgedEarlyPayload,
for: ble.myPeerID
)
let earlyPacket = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(),
recipientID: Data(hexString: ble.myPeerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1,
payload: forgedEarlyCiphertext,
signature: nil,
ttl: 7
)
ble._test_handlePacket(earlyPacket, fromPeerID: alicePeerID)
await ble._test_drainNoiseMessagePipeline()
let thirdPacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(),
recipientID: Data(hexString: ble.myPeerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 2,
payload: forgedMessage3,
signature: nil,
ttl: 7
)
ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID)
// Rollback restores the same generation. It retries the bounded early
// ciphertext and drains both outbound queues, but must not repeat a
// new-generation capability proof or forced announce.
let drained = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseEncrypted) >= 2 },
timeout: TestConstants.longTimeout
)
try #require(drained)
await ble._test_drainNoiseMessagePipeline()
let plaintexts = try outbound.snapshot()
.filter { $0.type == MessageType.noiseEncrypted.rawValue }
.map { try alice.decrypt($0.payload, from: ble.myPeerID) }
#expect(plaintexts.count == 2)
#expect(
plaintexts.filter {
$0.first == NoisePayloadType.authenticatedPeerState.rawValue
}.isEmpty
)
#expect(
plaintexts.filter {
$0.first == NoisePayloadType.privateMessage.rawValue
}.count == 1
)
#expect(
plaintexts.filter {
$0.first == NoisePayloadType.groupInvite.rawValue
}.count == 1
)
#expect(outbound.count(ofType: .announce) == 0)
// A duplicate ready callback cannot replay either buffer.
ble._test_reconcileCurrentNoiseSession(for: alicePeerID)
await ble._test_drainNoiseMessagePipeline()
#expect(outbound.count(ofType: .noiseEncrypted) == 2)
#expect(outbound.count(ofType: .announce) == 0)
} }
/// A legitimate rotation announce necessarily arrives on a link still /// A legitimate rotation announce necessarily arrives on a link still
@@ -943,6 +1122,40 @@ private final class OutboundPacketTap {
lock.lock(); defer { lock.unlock() } lock.lock(); defer { lock.unlock() }
return packets.filter { $0.type == type.rawValue }.count return packets.filter { $0.type == type.rawValue }.count
} }
func snapshot() -> [BitchatPacket] {
lock.lock(); defer { lock.unlock() }
return packets
}
}
private final class VerifiedDirectRebindGate: @unchecked Sendable {
private let condition = NSCondition()
private var paused = false
private var released = false
var hasPaused: Bool {
condition.lock()
defer { condition.unlock() }
return paused
}
func pause() {
condition.lock()
paused = true
condition.broadcast()
while !released {
condition.wait()
}
condition.unlock()
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
} }
private final class ReceivePacketHandoffGate: @unchecked Sendable { private final class ReceivePacketHandoffGate: @unchecked Sendable {
@@ -1076,6 +1289,7 @@ private final class PublicCaptureDelegate: BitchatDelegate {
defer { lock.unlock() } defer { lock.unlock() }
return publicMessages return publicMessages
} }
} }
@MainActor @MainActor
@@ -741,7 +741,7 @@ struct PrivateMediaEndToEndTests {
} }
@Test @Test
func consentedLegacySendRejectsAboveAndroidFragmentCapButEncryptedDoesNot() async throws { func encryptedAndConsentedLegacySendsRejectAboveAndroidFragmentCap() async throws {
let root = FileManager.default.temporaryDirectory let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-fragment-cap-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("private-media-fragment-cap-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) } defer { try? FileManager.default.removeItem(at: root) }
@@ -790,24 +790,14 @@ struct PrivateMediaEndToEndTests {
allowLegacyFallback: true allowLegacyFallback: true
) )
// The directed raw-file migration fallback (Android-style peer without let bothRejected = await TestHelpers.waitUntil(
// the .privateMedia capability) still honors the 256-fragment ceiling. { rejections.contains(encryptedID) && rejections.contains(legacyID) },
let legacyRejected = await TestHelpers.waitUntil(
{ rejections.contains(legacyID) },
timeout: TestConstants.longTimeout timeout: TestConstants.longTimeout
) )
#expect(legacyRejected) #expect(bothRejected)
#expect(rejections.reason(for: encryptedID)?.contains("256") == true)
#expect(rejections.reason(for: legacyID)?.contains("256") == true) #expect(rejections.reason(for: legacyID)?.contains("256") == true)
#expect(tap.snapshot().isEmpty, "No outer packet or fragment may be exposed before size rejection")
// Encrypted private media to a .privateMedia-capable peer is NOT forced
// down to Android's 256 cap: it uses the full receiver ceiling and
// proceeds to fragment/emit (a 130 KiB file exceeds 256 fragments).
let encryptedEmitted = await TestHelpers.waitUntil(
{ !tap.snapshot().isEmpty },
timeout: TestConstants.longTimeout
)
#expect(encryptedEmitted, "Encrypted send to a capable peer must not be blocked by the Android cap")
#expect(!rejections.contains(encryptedID))
_ = cancellable _ = cancellable
} }
@@ -12,6 +12,7 @@ import Testing
@testable import BitFoundation // to avoid unnecessary public's @testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
@Suite("Integration Tests", .serialized)
struct IntegrationTests { struct IntegrationTests {
private var helper = TestNetworkHelper() private var helper = TestNetworkHelper()
@@ -272,8 +273,18 @@ struct IntegrationTests {
// Re-establish Noise handshake explicitly via managers // Re-establish Noise handshake explicitly via managers
do { do {
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID) let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)! let m2 = try #require(
let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)! try helper.noiseManagers["Alice"]!.handleIncomingHandshake(
from: helper.nodes["Bob"]!.peerID,
message: m1
)
)
let m3 = try #require(
try helper.noiseManagers["Bob"]!.handleIncomingHandshake(
from: helper.nodes["Alice"]!.peerID,
message: m2
)
)
_ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3) _ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
} catch { } catch {
Issue.record("Failed to re-establish Noise session after restart: \(error)") Issue.record("Failed to re-establish Noise session after restart: \(error)")
@@ -8,6 +8,7 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import Testing
@testable import BitFoundation // to avoid unnecessary public's @testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
@@ -27,9 +28,14 @@ final class TestNetworkHelper {
node.mockNickname = name node.mockNickname = name
nodes[name] = node nodes[name] = node
// Create/replace Noise manager for this node // This synchronous helper directly drives all three XX messages and
// has no transport callback loop for delayed collision recovery.
let key = Curve25519.KeyAgreement.PrivateKey() let key = Curve25519.KeyAgreement.PrivateKey()
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain) noiseManagers[name] = NoiseSessionManager(
localStaticKey: key,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
return node return node
} }
@@ -108,8 +114,18 @@ final class TestNetworkHelper {
let peer2ID = nodes[node2]?.peerID else { return } let peer2ID = nodes[node2]?.peerID else { return }
let msg1 = try manager1.initiateHandshake(with: peer2ID) let msg1 = try manager1.initiateHandshake(with: peer2ID)
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)! let msg2 = try #require(
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)! try manager2.handleIncomingHandshake(
from: peer1ID,
message: msg1
)
)
let msg3 = try #require(
try manager1.handleIncomingHandshake(
from: peer2ID,
message: msg2
)
)
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3) _ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
} }
} }
+19 -4
View File
@@ -5,7 +5,7 @@ import BitFoundation
@testable import bitchat @testable import bitchat
@Suite("Noise Coverage Tests") @Suite("Noise Coverage Tests", .serialized)
struct NoiseCoverageTests { struct NoiseCoverageTests {
private let keychain = MockKeychain() private let keychain = MockKeychain()
private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey() private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey()
@@ -633,8 +633,16 @@ struct NoiseCoverageTests {
) )
let replacementSession = try #require(manager.getSession(for: alicePeerID)) let replacementSession = try #require(manager.getSession(for: alicePeerID))
#expect(replacementResponse != nil) let localPeerID = PeerID(
#expect(replacementSession !== restartedSession) publicKey: aliceStaticKey.publicKey.rawRepresentation
)
if localPeerID < alicePeerID {
#expect(replacementResponse == nil)
#expect(replacementSession === restartedSession)
} else {
#expect(replacementResponse != nil)
#expect(replacementSession !== restartedSession)
}
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
@@ -654,7 +662,13 @@ struct NoiseCoverageTests {
try aliceManager.initiateHandshake(with: alicePeerID) try aliceManager.initiateHandshake(with: alicePeerID)
} }
let rekeyHandshake = try aliceManager.initiateRekey(for: alicePeerID) let rekeyInitiation = try aliceManager.initiateRekey(for: alicePeerID)
let rekeyHandshake = try #require(
aliceManager.claimHandshakeInitiation(
rekeyInitiation,
for: alicePeerID
)
)
#expect(!rekeyHandshake.isEmpty) #expect(!rekeyHandshake.isEmpty)
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID)) let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
@@ -667,6 +681,7 @@ struct NoiseCoverageTests {
let aliceManager = NoiseSessionManager( let aliceManager = NoiseSessionManager(
localStaticKey: aliceStaticKey, localStaticKey: aliceStaticKey,
keychain: keychain, keychain: keychain,
recentInitiatorCompletionGracePeriod: 0,
sessionFactory: { peerID, role in sessionFactory: { peerID, role in
BlockingDecryptNoiseSession( BlockingDecryptNoiseSession(
peerID: peerID, peerID: peerID,
+57 -18
View File
@@ -357,8 +357,18 @@ struct NoiseProtocolTests {
@Test func peerRestartDetection() throws { @Test func peerRestartDetection() throws {
// Establish initial sessions // Establish initial sessions
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) // This test explicitly drives the three synchronous XX messages and
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // does not exercise the transport's delayed collision recovery.
let aliceManager = NoiseSessionManager(
localStaticKey: aliceKey,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
let bobManager = NoiseSessionManager(
localStaticKey: bobKey,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -377,15 +387,24 @@ struct NoiseProtocolTests {
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID) let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
// Alice should accept the new handshake (clearing old session) // Alice should accept the new handshake (clearing old session)
let newHandshake2 = try aliceManager.handleIncomingHandshake( let newHandshake2 = try #require(
from: alicePeerID, message: newHandshake1) try aliceManager.handleIncomingHandshake(
#expect(newHandshake2 != nil) from: alicePeerID,
message: newHandshake1
)
)
// Complete the new handshake // Complete the new handshake
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake( let newHandshake3 = try #require(
from: bobPeerID, message: newHandshake2!) try bobManagerRestarted.handleIncomingHandshake(
#expect(newHandshake3 != nil) from: bobPeerID,
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!) message: newHandshake2
)
)
_ = try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: newHandshake3
)
// Should be able to exchange messages with new sessions // Should be able to exchange messages with new sessions
let testMessage = Data("After restart".utf8) let testMessage = Data("After restart".utf8)
@@ -543,8 +562,18 @@ struct NoiseProtocolTests {
@Test func nonceDesynchronizationCausesRehandshake() throws { @Test func nonceDesynchronizationCausesRehandshake() throws {
// Test that nonce desynchronization leads to proper re-handshake // Test that nonce desynchronization leads to proper re-handshake
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) // This test explicitly drives the three synchronous XX messages and
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // does not exercise the transport's delayed collision recovery.
let aliceManager = NoiseSessionManager(
localStaticKey: aliceKey,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
let bobManager = NoiseSessionManager(
localStaticKey: bobKey,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
// Establish sessions // Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -572,15 +601,25 @@ struct NoiseProtocolTests {
let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID) let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
// Alice should accept despite having a "valid" (but desynced) session // Alice should accept despite having a "valid" (but desynced) session
let rehandshake2 = try aliceManager.handleIncomingHandshake( let rehandshake2 = try #require(
from: alicePeerID, message: rehandshake1) try aliceManager.handleIncomingHandshake(
#expect(rehandshake2 != nil, "Alice should accept handshake to fix desync") from: alicePeerID,
message: rehandshake1
),
"Alice should accept handshake to fix desync"
)
// Complete handshake // Complete handshake
let rehandshake3 = try bobManager.handleIncomingHandshake( let rehandshake3 = try #require(
from: bobPeerID, message: rehandshake2!) try bobManager.handleIncomingHandshake(
#expect(rehandshake3 != nil) from: bobPeerID,
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!) message: rehandshake2
)
)
_ = try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: rehandshake3
)
// Verify communication works again // Verify communication works again
let testResynced = Data("Resynced".utf8) let testResynced = Data("Resynced".utf8)
@@ -1,4 +1,5 @@
import BitFoundation import BitFoundation
import CryptoKit
import Foundation import Foundation
import Testing import Testing
@testable import bitchat @testable import bitchat
@@ -11,7 +12,11 @@ struct BLENoisePacketHandlerTests {
var handshakeAuthenticated = false var handshakeAuthenticated = false
var hasSession = false var hasSession = false
let sessionGeneration = UUID() let sessionGeneration = UUID()
var awaitingResponderHandshake = false
var decryptResult: Result<Data, Error> = .success(Data()) var decryptResult: Result<Data, Error> = .success(Data())
var currentDate = Date(timeIntervalSince1970: 1_000)
var transportGenerationReady = false
var forcedServiceDecryptError: Error?
var processedHandshakes: [(peerID: PeerID, message: Data)] = [] var processedHandshakes: [(peerID: PeerID, message: Data)] = []
var hasSessionQueries: [PeerID] = [] var hasSessionQueries: [PeerID] = []
@@ -34,11 +39,12 @@ struct BLENoisePacketHandlerTests {
recorder: Recorder, recorder: Recorder,
now: Date = Date(timeIntervalSince1970: 1_000) now: Date = Date(timeIntervalSince1970: 1_000)
) -> BLENoisePacketHandler { ) -> BLENoisePacketHandler {
recorder.currentDate = now
let environment = BLENoisePacketHandlerEnvironment( let environment = BLENoisePacketHandlerEnvironment(
localPeerID: { [localPeerID] in localPeerID }, localPeerID: { [localPeerID] in localPeerID },
localPeerIDData: { [localPeerIDData] in localPeerIDData }, localPeerIDData: { [localPeerIDData] in localPeerIDData },
messageTTL: TransportConfig.messageTTLDefault, messageTTL: TransportConfig.messageTTLDefault,
now: { now }, now: { recorder.currentDate },
processHandshakeMessage: { peerID, message in processHandshakeMessage: { peerID, message in
recorder.processedHandshakes.append((peerID, message)) recorder.processedHandshakes.append((peerID, message))
return NoiseHandshakeProcessingResult( return NoiseHandshakeProcessingResult(
@@ -51,6 +57,9 @@ struct BLENoisePacketHandlerTests {
recorder.hasSessionQueries.append(peerID) recorder.hasSessionQueries.append(peerID)
return recorder.hasSession return recorder.hasSession
}, },
isAwaitingResponderHandshakeCompletion: { _ in
recorder.awaitingResponderHandshake
},
initiateHandshake: { peerID in initiateHandshake: { peerID in
recorder.initiatedHandshakes.append(peerID) recorder.initiatedHandshakes.append(peerID)
recorder.events.append("initiateHandshake") recorder.events.append("initiateHandshake")
@@ -82,6 +91,120 @@ struct BLENoisePacketHandlerTests {
return BLENoisePacketHandler(environment: environment) return BLENoisePacketHandler(environment: environment)
} }
private func makeServiceBackedHandler(
service: NoiseEncryptionService,
localPeerID: PeerID,
recorder: Recorder,
transportGenerationIsReady:
@escaping (UUID) -> Bool
) -> BLENoisePacketHandler {
BLENoisePacketHandler(
environment: BLENoisePacketHandlerEnvironment(
localPeerID: { localPeerID },
localPeerIDData: {
Data(hexString: localPeerID.id) ?? Data()
},
messageTTL: TransportConfig.messageTTLDefault,
now: { recorder.currentDate },
processHandshakeMessage: { peerID, message in
try service.processHandshakeMessageWithResult(
from: peerID,
message: message
)
},
hasNoiseSession: { peerID in
service.hasSession(with: peerID)
},
isAwaitingResponderHandshakeCompletion: { peerID in
service.isAwaitingResponderHandshakeCompletion(
with: peerID
)
},
initiateHandshake: { peerID in
recorder.initiatedHandshakes.append(peerID)
},
broadcastPacket: { packet in
recorder.broadcastPackets.append(packet)
},
updatePeerLastSeen: { peerID in
recorder.lastSeenUpdates.append(peerID)
},
decrypt: { payload, peerID in
recorder.decryptCalls.append((payload, peerID))
if let error = recorder.forcedServiceDecryptError {
throw error
}
let result =
try service.decryptWithSessionGeneration(
payload,
from: peerID,
establishedGenerationIsReady:
transportGenerationIsReady
)
return BLENoiseDecryptionResult(
plaintext: result.plaintext,
sessionGeneration: result.sessionGeneration
)
},
clearSession: { peerID in
recorder.clearedSessions.append(peerID)
service.clearSession(for: peerID)
},
handleAuthenticatedPeerState: {
peerID, payload, generation in
recorder.authenticatedPeerStates.append(
(peerID, payload, generation)
)
},
deliverNoisePayload: {
peerID, type, payload, timestamp in
recorder.deliveries.append(
(peerID, type, payload, timestamp)
)
}
)
)
}
private func establishedServices() throws -> (
sender: NoiseEncryptionService,
receiver: NoiseEncryptionService,
senderPeerID: PeerID,
receiverPeerID: PeerID
) {
let sender = NoiseEncryptionService(keychain: MockKeychain())
let receiver = NoiseEncryptionService(keychain: MockKeychain())
let senderPeerID = PeerID(
publicKey: sender.getStaticPublicKeyData()
)
let receiverPeerID = PeerID(
publicKey: receiver.getStaticPublicKeyData()
)
let message1 = try sender.initiateHandshake(with: receiverPeerID)
let message2 = try #require(
try receiver.processHandshakeMessage(
from: senderPeerID,
message: message1
)
)
let message3 = try #require(
try sender.processHandshakeMessage(
from: receiverPeerID,
message: message2
)
)
_ = try receiver.processHandshakeMessage(
from: senderPeerID,
message: message3
)
return (
sender,
receiver,
senderPeerID,
receiverPeerID
)
}
// MARK: Handshake // MARK: Handshake
@Test @Test
@@ -198,6 +321,24 @@ struct BLENoisePacketHandlerTests {
#expect(recorder.broadcastPackets.isEmpty) #expect(recorder.broadcastPackets.isEmpty)
} }
@Test
func managedHandshakeFailureDoesNotStartASecondRecovery() {
let recorder = Recorder()
recorder.handshakeResult = .failure(
NoiseManagedHandshakeFailure(underlying: TestError())
)
recorder.hasSession = false
let handler = makeHandler(recorder: recorder)
let packet = makeHandshakePacket(
recipientID: Data(hexString: localPeerID.id)
)
#expect(!handler.handleHandshake(packet, from: remotePeerID))
#expect(recorder.hasSessionQueries.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
#expect(recorder.broadcastPackets.isEmpty)
}
// MARK: Encrypted // MARK: Encrypted
@Test @Test
@@ -346,6 +487,799 @@ struct BLENoisePacketHandlerTests {
#expect(recorder.deliveries.isEmpty) #expect(recorder.deliveries.isEmpty)
} }
@Test
func earlyCiphertextIsRetriedAfterResponderHandshakeCompletes() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE])
)
handler.handleSessionAuthenticated(remotePeerID)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.type == .privateMessage)
#expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func panicResetDiscardsDeferredCiphertextBeforeFutureAuthentication() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let prePanicCiphertext = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data(
count: NoiseSecurityConstants.maxMessageSize
+ NoiseSecurityConstants.transportCiphertextOverhead
)
)
handler.handleEncrypted(prePanicCiphertext, from: remotePeerID)
#expect(recorder.decryptCalls.count == 1)
handler.resetForPanic()
// Three maximum-sized packets fit only when reset also zeroed the
// global byte accounting. They model ciphertext received under the
// replacement identity before that responder handshake completes.
for index in 0..<3 {
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
timestamp: UInt64(901_000 + index),
payload: Data(
count: NoiseSecurityConstants.maxMessageSize
+ NoiseSecurityConstants.transportCiphertextOverhead
)
),
from: remotePeerID
)
}
#expect(recorder.decryptCalls.count == 4)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE])
)
handler.handleSessionAuthenticated(remotePeerID)
// Only the three post-reset packets replay; the pre-panic packet does
// not survive into the replacement session.
#expect(recorder.decryptCalls.count == 7)
#expect(recorder.deliveries.count == 3)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func ciphertextQueuedAheadOfEstablishmentCallbackDoesNotConsumeNonce()
throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(
publicKey: alice.getStaticPublicKeyData()
)
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
let message1 = try alice.initiateHandshake(with: bobPeerID)
let message2 = try #require(
try bob.processHandshakeMessage(
from: alicePeerID,
message: message1
)
)
let message3 = try #require(
try alice.processHandshakeMessage(
from: bobPeerID,
message: message2
)
)
let typedPayload = Data([
NoisePayloadType.privateMessage.rawValue,
0xCA, 0xFE
])
let ciphertext = try alice.encrypt(
typedPayload,
for: bobPeerID
)
// Manager promotion has completed, but the serialized BLE callback is
// deliberately still behind this ciphertext.
_ = try bob.processHandshakeMessage(
from: alicePeerID,
message: message3
)
let recorder = Recorder()
recorder.transportGenerationReady = false
let handler = makeServiceBackedHandler(
service: bob,
localPeerID: bobPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
let packet = makeEncryptedPacket(
recipientID: Data(hexString: bobPeerID.id),
payload: ciphertext
)
handler.handleEncrypted(packet, from: alicePeerID)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
// The exact ciphertext must still authenticate, proving the readiness
// rejection happened before the receive nonce was consumed.
recorder.transportGenerationReady = true
handler.handleSessionAuthenticated(alicePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.type == .privateMessage)
#expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func ciphertextQueuedAheadOfRestoreCallbackDoesNotConsumeNonce()
throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(
publicKey: alice.getStaticPublicKeyData()
)
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
let initial1 = try alice.initiateHandshake(with: bobPeerID)
let initial2 = try #require(
try bob.processHandshakeMessage(
from: alicePeerID,
message: initial1
)
)
let initial3 = try #require(
try alice.processHandshakeMessage(
from: bobPeerID,
message: initial2
)
)
_ = try bob.processHandshakeMessage(
from: alicePeerID,
message: initial3
)
let typedPayload = Data([
NoisePayloadType.privateMessage.rawValue,
0xBE, 0xEF
])
let delayedCiphertext = try alice.encrypt(
typedPayload,
for: bobPeerID
)
let forged1 = try mallory.initiateHandshake(with: bobPeerID)
let forged2 = try #require(
try bob.processHandshakeMessage(
from: alicePeerID,
message: forged1
)
)
let forged3 = try #require(
try mallory.processHandshakeMessage(
from: bobPeerID,
message: forged2
)
)
#expect(throws: NoiseSessionError.peerIdentityMismatch) {
try bob.processHandshakeMessage(
from: alicePeerID,
message: forged3
)
}
#expect(bob.hasEstablishedSession(with: alicePeerID))
let recorder = Recorder()
recorder.transportGenerationReady = false
let handler = makeServiceBackedHandler(
service: bob,
localPeerID: bobPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
let packet = makeEncryptedPacket(
recipientID: Data(hexString: bobPeerID.id),
payload: delayedCiphertext
)
// Manager rollback is visible, while the BLE restore callback is
// deliberately still queued behind this ciphertext.
handler.handleEncrypted(packet, from: alicePeerID)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
recorder.transportGenerationReady = true
handler.handleSessionAuthenticated(alicePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.type == .privateMessage)
#expect(recorder.deliveries.first?.payload == Data([0xBE, 0xEF]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func oversizedCiphertextCannotEvictEstablishedTransport() throws {
let pair = try establishedServices()
let recorder = Recorder()
recorder.transportGenerationReady = true
let handler = makeServiceBackedHandler(
service: pair.receiver,
localPeerID: pair.receiverPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: Data(
count:
NoiseSecurityConstants
.maxPrivateFileCiphertextSize + 1
)
),
from: pair.senderPeerID
)
#expect(
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
)
#expect(recorder.clearedSessions.isEmpty)
let valid = try pair.sender.encrypt(
Data([NoisePayloadType.privateMessage.rawValue, 0x01]),
for: pair.receiverPeerID
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: valid
),
from: pair.senderPeerID
)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.payload == Data([0x01]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func forgedAuthenticationFailureCannotEvictEstablishedTransport()
throws {
let pair = try establishedServices()
let recorder = Recorder()
recorder.transportGenerationReady = true
let handler = makeServiceBackedHandler(
service: pair.receiver,
localPeerID: pair.receiverPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
let valid = try pair.sender.encrypt(
Data([NoisePayloadType.privateMessage.rawValue, 0x02]),
for: pair.receiverPeerID
)
var forged = valid
forged[forged.index(before: forged.endIndex)] ^= 0xFF
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: forged
),
from: pair.senderPeerID
)
#expect(
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
)
#expect(recorder.clearedSessions.isEmpty)
// Authentication failure leaves nonce state untouched, so the exact
// original ciphertext remains valid.
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: valid
),
from: pair.senderPeerID
)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.payload == Data([0x02]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func replayCannotEvictEstablishedTransportOrBlockNextNonce() throws {
let pair = try establishedServices()
let recorder = Recorder()
recorder.transportGenerationReady = true
let handler = makeServiceBackedHandler(
service: pair.receiver,
localPeerID: pair.receiverPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
let first = try pair.sender.encrypt(
Data([NoisePayloadType.privateMessage.rawValue, 0x03]),
for: pair.receiverPeerID
)
let firstPacket = makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: first
)
handler.handleEncrypted(firstPacket, from: pair.senderPeerID)
handler.handleEncrypted(firstPacket, from: pair.senderPeerID)
#expect(
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
)
#expect(recorder.clearedSessions.isEmpty)
let next = try pair.sender.encrypt(
Data([NoisePayloadType.privateMessage.rawValue, 0x04]),
for: pair.receiverPeerID
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: next
),
from: pair.senderPeerID
)
#expect(recorder.deliveries.count == 2)
#expect(recorder.deliveries.map { $0.payload } == [
Data([0x03]), Data([0x04])
])
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func rateLimitFailureCannotEvictEstablishedTransportOrConsumeNonce()
throws {
let pair = try establishedServices()
let recorder = Recorder()
recorder.transportGenerationReady = true
recorder.forcedServiceDecryptError =
NoiseSecurityError.rateLimitExceeded
let handler = makeServiceBackedHandler(
service: pair.receiver,
localPeerID: pair.receiverPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
let valid = try pair.sender.encrypt(
Data([NoisePayloadType.privateMessage.rawValue, 0x05]),
for: pair.receiverPeerID
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: Data(repeating: 0xA5, count: 20)
),
from: pair.senderPeerID
)
#expect(
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
)
#expect(recorder.clearedSessions.isEmpty)
recorder.forcedServiceDecryptError = nil
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: valid
),
from: pair.senderPeerID
)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.payload == Data([0x05]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func maximumPrivateFileCiphertextIsEligibleForDeferredRetry() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data(
count: NoiseSecurityConstants.maxPrivateFileCiphertextSize
)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.privateFile.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.type == .privateFile)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func oversizedEarlyCiphertextIsNotDeferred() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data(
count:
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 1
)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func missingSessionCiphertextIsRetriedAfterResponderHandshakeCompletes() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
NoiseEncryptionError.sessionNotEstablished
)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.initiatedHandshakes.isEmpty)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func lowNonceCiphertextIsRetriedAfterResponderHandshakeCompletes() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(NoiseError.replayDetected)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.readReceipt.rawValue, 0x02])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.type == .readReceipt)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func invalidDeferredCiphertextDoesNotClearAuthenticatedSession() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
recorder.awaitingResponderHandshake = false
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func nonCipherFailureDuringResponderHandshakeIsDroppedNotDeferred() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(TestError())
let handler = makeHandler(recorder: recorder)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
),
from: remotePeerID
)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func earlyCiphertextBufferIsBoundedPerPeer() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
for index in 0..<5 {
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
timestamp: UInt64(900_000 + index)
),
from: remotePeerID
)
}
#expect(recorder.decryptCalls.count == 5)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 9)
#expect(recorder.deliveries.count == 4)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func earlyCiphertextBufferIsBoundedGlobally() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let peers = (1...33).map {
PeerID(str: String(format: "%016llx", UInt64($0)))
}
let packet = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
)
for peerID in peers {
handler.handleEncrypted(packet, from: peerID)
}
#expect(recorder.decryptCalls.count == 33)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
for peerID in peers {
handler.handleSessionAuthenticated(peerID)
}
#expect(recorder.decryptCalls.count == 65)
#expect(recorder.deliveries.count == 32)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func earlyCiphertextBufferKeepsPrivateFileRoomAndByteBound() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let peers = [
PeerID(str: "0000000000000001"),
PeerID(str: "0000000000000002"),
PeerID(str: "0000000000000003")
]
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data(
count:
NoiseSecurityConstants.maxPrivateFileCiphertextSize
)
),
from: peers[0]
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data(count: 256 * 1024)
),
from: peers[1]
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data([0x01])
),
from: peers[2]
)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
for peerID in peers {
handler.handleSessionAuthenticated(peerID)
}
#expect(recorder.decryptCalls.count == 5)
#expect(recorder.deliveries.count == 2)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func expiredEarlyCiphertextIsNotRetried() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
),
from: remotePeerID
)
recorder.currentDate =
recorder.currentDate.addingTimeInterval(
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
+ 0.001
)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func earlyCiphertextSurvivesResponderHandshakeWindow() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
),
from: remotePeerID
)
recorder.currentDate =
recorder.currentDate.addingTimeInterval(
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
- 0.001
)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
private func makeHandshakePacket(recipientID: Data?) -> BitchatPacket { private func makeHandshakePacket(recipientID: Data?) -> BitchatPacket {
BitchatPacket( BitchatPacket(
type: MessageType.noiseHandshake.rawValue, type: MessageType.noiseHandshake.rawValue,
@@ -360,14 +1294,15 @@ struct BLENoisePacketHandlerTests {
private func makeEncryptedPacket( private func makeEncryptedPacket(
recipientID: Data?, recipientID: Data?,
timestamp: UInt64 = 900_000 timestamp: UInt64 = 900_000,
payload: Data = Data([0xC0, 0xFF, 0xEE])
) -> BitchatPacket { ) -> BitchatPacket {
BitchatPacket( BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: remotePeerID.id) ?? Data(), senderID: Data(hexString: remotePeerID.id) ?? Data(),
recipientID: recipientID, recipientID: recipientID,
timestamp: timestamp, timestamp: timestamp,
payload: Data([0xC0, 0xFF, 0xEE]), payload: payload,
signature: nil, signature: nil,
ttl: TransportConfig.messageTTLDefault ttl: TransportConfig.messageTTLDefault
) )
@@ -0,0 +1,115 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("BLE Noise reconnect policy")
struct BLENoiseReconnectPolicyTests {
@Test("Revalidation requires a cached session and no authenticated link")
func revalidationPreconditions() {
var policy = BLENoiseReconnectPolicy()
let link = BLEIngressLinkID.peripheral("peripheral-a")
let now = Date(timeIntervalSince1970: 1_000)
let withoutSession = policy.shouldRevalidate(
on: link,
hasEstablishedSession: false,
isNoiseAuthenticatedLink: false,
hasAuthenticatedPeerLink: false,
now: now
)
#expect(!withoutSession)
let authenticated = policy.shouldRevalidate(
on: link,
hasEstablishedSession: true,
isNoiseAuthenticatedLink: true,
hasAuthenticatedPeerLink: true,
now: now
)
#expect(!authenticated)
let eligible = policy.shouldRevalidate(
on: link,
hasEstablishedSession: true,
isNoiseAuthenticatedLink: false,
hasAuthenticatedPeerLink: false,
now: now
)
#expect(eligible)
}
@Test("Revalidation is once per link epoch or after sixty seconds")
func revalidationIsBoundPerLinkEpoch() {
var policy = BLENoiseReconnectPolicy()
let link = BLEIngressLinkID.central("central-a")
let start = Date(timeIntervalSince1970: 2_000)
let initial = policy.shouldRevalidate(
on: link,
hasEstablishedSession: true,
isNoiseAuthenticatedLink: false,
hasAuthenticatedPeerLink: false,
now: start
)
#expect(initial)
let duringCooldown = policy.shouldRevalidate(
on: link,
hasEstablishedSession: true,
isNoiseAuthenticatedLink: false,
hasAuthenticatedPeerLink: false,
now: start.addingTimeInterval(59.999)
)
#expect(!duringCooldown)
let afterCooldown = policy.shouldRevalidate(
on: link,
hasEstablishedSession: true,
isNoiseAuthenticatedLink: false,
hasAuthenticatedPeerLink: false,
now: start.addingTimeInterval(60)
)
#expect(afterCooldown)
policy.endLinkEpoch(link)
let nextEpoch = policy.shouldRevalidate(
on: link,
hasEstablishedSession: true,
isNoiseAuthenticatedLink: false,
hasAuthenticatedPeerLink: false,
now: start.addingTimeInterval(60.001)
)
#expect(nextEpoch)
}
@Test("An authenticated sibling suppresses redundant reconnect")
func authenticatedSiblingSuppressesReconnect() {
var policy = BLENoiseReconnectPolicy()
let link = BLEIngressLinkID.peripheral("unproven-sibling")
let start = Date(timeIntervalSince1970: 3_000)
let suppressed = policy.shouldRevalidate(
on: link,
hasEstablishedSession: true,
isNoiseAuthenticatedLink: false,
hasAuthenticatedPeerLink: true,
now: start
)
#expect(!suppressed)
let eligible = policy.shouldRevalidate(
on: link,
hasEstablishedSession: true,
isNoiseAuthenticatedLink: false,
hasAuthenticatedPeerLink: false,
now: start
)
#expect(eligible)
}
@Test("Reserved replacement bit is not advertised")
func reservedReplacementBitIsNotAdvertised() {
#expect(
!PeerCapabilities.localSupported.contains(
.nonDestructiveNoiseReplacement
)
)
#expect(PeerCapabilities.localSupported.contains(.privateMedia))
}
}
File diff suppressed because it is too large Load Diff
+5 -13
View File
@@ -100,16 +100,8 @@ temporary `0x09` alias. Ordinary Noise messages retain their 64 KiB limit.
Current Android builds cap each reassembly at 256 fragments. Depending on the Current Android builds cap each reassembly at 256 fragments. Depending on the
negotiated BLE packet size and routing overhead, that is roughly 110-120 KiB, negotiated BLE packet size and routing overhead, that is roughly 110-120 KiB,
well below iOS's absolute inbound ceiling. That cap only applies to those well below iOS's absolute inbound ceiling. Private-media v1 therefore runs the
receivers, which take private media exclusively over the directed raw-file actual route-aware BLE fragment planner before both encrypted and consented
migration fallback (they do not implement the encrypted `0x20` path). legacy sends and rejects any plan above 256 fragments with a visible failure.
Private-media v1 therefore runs the actual route-aware BLE fragment planner This fragment-count contract, rather than a guessed byte threshold, stays
before a consented legacy send and rejects any plan above 256 fragments with a correct as route overhead changes.
visible failure. Encrypted sends go only to peers that advertised the
`privateMedia` capability — modern clients that reassemble up to the full
receiver ceiling (10,000 fragments) — so they are not held to Android's cap and
iOS→iOS photos in the ~120-512 KiB range keep working. This fragment-count
contract, rather than a guessed byte threshold, stays correct as route overhead
changes. A future Android client that adopts `0x20` but still caps its
reassembler would need to negotiate an explicit per-peer fragment limit
(tracked as a #1434 follow-up).
@@ -28,6 +28,11 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
/// before outer BLE fragmentation. Peers that omit this bit require the /// before outer BLE fragmentation. Peers that omit this bit require the
/// signed directed raw-file migration fallback. /// signed directed raw-file migration fallback.
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8) public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
/// Reserved for test builds that briefly advertised non-destructive Noise
/// replacement. Current clients intentionally do not advertise or act on
/// this bit; keep it decodable so the wire assignment is never reused.
public static let nonDestructiveNoiseReplacement =
PeerCapabilities(rawValue: 1 << 10)
/// Minimal little-endian byte encoding; always at least one byte so an /// Minimal little-endian byte encoding; always at least one byte so an
/// empty set is distinguishable from an absent TLV. /// empty set is distinguishable from an absent TLV.
@@ -20,6 +20,10 @@ struct PeerCapabilitiesTests {
let high = PeerCapabilities(rawValue: 1 << 9) let high = PeerCapabilities(rawValue: 1 << 9)
#expect(high.encoded() == Data([0x00, 0x02])) #expect(high.encoded() == Data([0x00, 0x02]))
#expect(
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
== Data([0x00, 0x04])
)
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia] let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
#expect(PeerCapabilities(encoded: all.encoded()) == all) #expect(PeerCapabilities(encoded: all.encoded()) == all)
+405 -431
View File
@@ -1,442 +1,416 @@
Relay URL,Latitude,Longitude Relay URL,Latitude,Longitude
bitchat.nostr1.com,40.7057,-74.0136 relay.lab.rytswd.com,49.4543,11.0746
relay.fundstr.me,42.3601,-71.0589 relay.paulstephenborile.com:443,49.4543,11.0746
nostr.2b9t.xyz,34.0549,-118.243 relay.binaryrobot.com,43.6532,-79.3832
armada.sharegap.net,43.6532,-79.3832
nostr.chaima.info,51.5072,-0.127586
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
ribo.eu.nostria.app:443,43.6532,-79.3832
relay.lightning.pub,39.0438,-77.4874
relay.nostu.be,40.4167,-3.70329
nostr.whitenode45.ddns.net,40.55,-74.4758
nostr.carroarmato0.be:443,50.914,3.21378
cdn.satellite.earth,40.8302,-74.1299
relay2.veganostr.com,60.1699,24.9384
relay.layer.systems:443,49.0291,8.35695
relay0.gfcom.info,13.7653,100.647
relay.mmwaves.de:443,48.8575,2.35138
offchain.pub,39.1585,-94.5728
bcast.girino.org,43.6532,-79.3832
staging.yabu.me,35.6092,139.73
nostr.overpay.com,29.7449,-95.5343
bridge.tagomago.me,42.3601,-71.0589
nostr-01.yakihonne.com,1.32123,103.695
strfry.bonsai.com,39.0438,-77.4874
relay.sharegap.net,43.6532,-79.3832
nostr.islandarea.net,35.4669,-97.6473
dm-test-strfry-generic.samt.st,43.6532,-79.3832
treuzkas.branruz.com,48.8575,2.35138
relay-rpi.edufeed.org:443,49.4521,11.0767
vault.iris.to:443,43.6532,-79.3832
node.kommonzenze.de,49.4521,11.0767
nostr.thalheim.io:443,60.1699,24.9384
soloco.nl,43.6532,-79.3832
strfry.shock.network,39.0438,-77.4874
nostr-relay.zimage.com,34.0549,-118.243
public.crostr.com:443,43.6532,-79.3832
nostr.sathoarder.com:443,48.5734,7.75211
relay.angor.io,48.1046,11.6002
relay.wellorder.net,45.5201,-122.99
relay.mwaters.net,50.9871,2.12554
relay.staging.commonshub.brussels,49.4543,11.0746
nostr-verified.wellorder.net,45.5201,-122.99
nostr-pub.wellorder.net,45.5201,-122.99
nostr-2.21crypto.ch,47.5356,8.73209 nostr-2.21crypto.ch,47.5356,8.73209
relay.kaleidoswap.com,50.8476,4.35717 spookstr2.nostr1.com:443,40.7057,-74.0136
relay.libernet.app:443,43.6532,-79.3832 fanfares.nostr1.com:443,40.7057,-74.0136
relay.homeinhk.xyz,35.694,139.754 x.kojira.io,43.6532,-79.3832
relay.manneken.brussels,49.4543,11.0746 freelay.sovbit.host,60.1699,24.9384
nostr.spicyz.io:443,43.6532,-79.3832
relay.lanacoin-eternity.com:443,40.8302,-74.1299
ribo.us.nostria.app:443,43.6532,-79.3832
relay.loveisbitcoin.com,43.6532,-79.3832
relay.angor.io:443,48.1046,11.6002
relay02.lnfi.network,35.6764,139.65
relay.cosmicbolt.net:443,37.3986,-121.964
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
nrs-01.darkcloudarcade.com,39.0997,-94.5786
relay.endfiat.money:443,59.3327,18.0656
relay.paulstephenborile.com,49.4543,11.0746
rele.speyhard.fi,51.5072,-0.127586
relay.froth.zone,60.1699,24.9384
relay.nostr.blockhenge.com,39.0438,-77.4874
nrl.ceskar.xyz,50.5145,16.0119
rilo.nostria.app,43.6532,-79.3832
nostr.overmind.lol:443,43.6532,-79.3832
nostr.snowbla.de:443,50.4754,12.3683
nostrrelay.taylorperron.com,45.5029,-73.5723
chorus.pjv.me,45.5201,-122.99
relay.nostr.place,43.6532,-79.3832
bucket.coracle.social,37.7775,-122.397
nostr.girino.org:443,43.6532,-79.3832
relay.aarpia.com,37.3986,-121.964
nostr.thalheim.io,60.1699,24.9384
ec2.f7z.io,60.1699,24.9384
relay.trotters.cc,43.6532,-79.3832
relay.mccormick.cx:443,52.3563,4.95714
relay.momostr.pink,43.6532,-79.3832
relay.nostr.net,43.6532,-79.3832
conduitl2.fly.dev,37.7648,-122.432
chat-relay.zap-work.com,43.6532,-79.3832
relay.ditto.pub,43.6532,-79.3832
relay.veganostr.com,60.1699,24.9384
relay.minibolt.info:443,43.6532,-79.3832
adre.su,59.9311,30.3609
bitcoinostr.duckdns.org,41.1976,1.11167
nostr.computingcache.com:443,34.0356,-118.442
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
nostr.hekster.org:443,37.3986,-121.964
nostr.88mph.life,52.1941,-2.21905
wot.dergigi.com,64.1476,-21.9392
nostr.planix.org,43.6532,-79.3832
relay.satsmarkt.club,52.6907,4.8181
nostrcity-club.fly.dev:443,37.7648,-122.432
aeon.libretechsystems.xyz,55.486,9.86577
testnet.samt.st,43.6532,-79.3832 testnet.samt.st,43.6532,-79.3832
nostr.data.haus,50.4754,12.3683 relay.angor.io,48.1046,11.6002
wot.sudocarlos.com,43.6532,-79.3832 relay-arg.zombi.cloudrodion.com,1.35208,103.82
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222 nostr-01.yakihonne.com,1.32123,103.695
shu01.shugur.net,21.4902,39.2246 nostr-relay.cbrx.io,43.6532,-79.3832
relay.gulugulu.moe:443,43.6532,-79.3832 relay.guggero.org,46.5971,9.59652
relay2.angor.io:443,48.1046,11.6002 nostr.snowbla.de,60.1699,24.9384
relay.libernet.app,43.6532,-79.3832
directories-safe-motherboard-recipients.trycloudflare.com,43.6532,-79.3832
wot.nostr.party,36.1659,-86.7844
relay.zone667.com,60.1699,24.9384 relay.zone667.com,60.1699,24.9384
nostr.wild-vibes.ts.net,48.8566,2.35222 nexus.libernet.app:443,43.6532,-79.3832
relay.nostr.com,50.1109,8.68213 relay.islandbitcoin.com,12.8498,77.6545
nostr.iskarion.ddns.net,43.3076,-2.95421 relay-testnet.k8s.layer3.news,37.3387,-121.885
relay-dev.satlantis.io,39.0438,-77.4874 nostr-relay.xbytez.io,50.6924,3.20113
relay.sovereignresonance.org,48.9006,2.25929 kasztanowa.bieda.it,43.6532,-79.3832
relay.nostrian-conquest.com,41.223,-111.974 nostrcity-club.fly.dev,37.7648,-122.432
relay.aidatanorge.no,43.6532,-79.3832 relay.typedcypher.com,51.5072,-0.127586
strfry.apps3.slidestr.net,40.4167,-3.70329 nostr.na.social:443,43.6532,-79.3832
relay.klabo.world,47.2343,-119.853 relay.laantungir.net,-19.4692,-42.5315
nostr.data.haus:443,50.4754,12.3683 relay-dev.satlantis.io:443,40.8302,-74.1299
testr.nymble.world,40.8054,-74.0241 rilo.nostria.app,43.6532,-79.3832
relay.inforsupports.com,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
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
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
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
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
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.nostrmap.net:443,60.1699,24.9384
nostr.stakey.net:443,52.3676,4.90414 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
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 dev-relay.nostreon.com,60.1699,24.9384
nostr.islandarea.net:443,35.4669,-97.6473 nostr.islandarea.net:443,35.4669,-97.6473
nostr.rtvslawenia.com,49.4543,11.0746 bucket.coracle.social,37.7775,-122.397
relay.bowlafterbowl.com,32.9483,-96.7299
nostr.quali.chat:443,60.1699,24.9384
relay.plebeian.market,50.1109,8.68213
relay-rpi.edufeed.org,49.4521,11.0767
r.0kb.io,32.789,-96.7989
nostr.notribe.net:443,40.8302,-74.1299
relay.getsafebox.app:443,43.6532,-79.3832
nostr.dlcdevkit.com:443,40.0992,-83.1141
nostrelites.org,34.9582,-81.9907
nostr.hoppe-relay.it.com,42.8864,-78.8784
nostr.thebiglake.org,32.71,-96.6745
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
relay.nostriot.com,41.5695,-83.9786
nostr.christiansass.de,51.7634,7.8887
relay.btcforplebs.com,43.6532,-79.3832
nostr.tagomago.me,42.3601,-71.0589
relayone.geektank.ai,39.0997,-94.5786
relay.dreamith.to:443,43.6532,-79.3832
nostr.liberty.fans,36.8767,-89.5879
wot.makenomistakes.ca,43.7064,-79.3986
relay.goodmorningbitcoin.com,43.6532,-79.3832
relay.layer.systems,49.0291,8.35695
relay.paulstephenborile.com:443,49.4543,11.0746
relay.ohstr.com,43.6532,-79.3832
nostr-relay.xbytez.io:443,50.6924,3.20113
nostr.ac,38.958,-77.3592
ribo.us.nostria.app,43.6532,-79.3832
nostr.21crypto.ch,47.5356,8.73209
relay.chorus.community:443,48.5333,10.7
relay.cypherflow.ai,48.8575,2.35138
relay.agorist.space:443,52.3734,4.89406
relay.nostrian-conquest.com:443,41.223,-111.974
relay.keykeeper.world,40.7824,-74.0711
relay.getvia.xyz,60.1699,24.9384
relay.nuts.cash,52.3676,4.90414
kotukonostr.onrender.com,37.7775,-122.397
relay.minibolt.info,43.6532,-79.3832
relay.dwadziesciajeden.pl,52.2297,21.0122
relay.fountain.fm:443,43.6532,-79.3832
relay.fountain.fm,43.6532,-79.3832
nostr-02.uid.ovh,50.9871,2.12554
relay.lanavault.space:443,60.1699,24.9384
nostr.carroarmato0.be,50.914,3.21378
nexus.libernet.app:443,43.6532,-79.3832
relay.artio.inf.unibe.ch,46.9501,7.43678
blossom.gnostr.cloud,43.6532,-79.3832 blossom.gnostr.cloud,43.6532,-79.3832
relay.binaryrobot.com,43.6532,-79.3832
relay.earthly.city,34.1749,-118.54
nostr.hifish.org,47.4244,8.57658
offchain.pub:443,39.1585,-94.5728
relay.bullishbounty.com:443,43.6532,-79.3832
strfry.openhoofd.nl:443,51.5717,3.70417
cs-relay.nostrdev.com:443,50.4754,12.3683
strfry.ymir.cloud,43.6532,-79.3832
nostrbtc.com,43.6532,-79.3832
relay.directsponsor.net,42.8864,-78.8784
nostr2.girino.org,43.6532,-79.3832
relay.sigit.io:443,50.4754,12.3683
relay.getsafebox.app,43.6532,-79.3832
antiprimal.net,43.6532,-79.3832
nostr.sathoarder.com,48.5734,7.75211
inbox.scuba323.com,40.8218,-74.45
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
nostr.tac.lol,47.4748,-122.273
nostr.davenov.com,50.1109,8.68213
relay.trotters.cc:443,43.6532,-79.3832
nostr.plantroon.com:443,50.1013,8.62643
relay.nostreon.com,60.1699,24.9384
nostr.easycryptosend.it,43.6532,-79.3832
nostr-01.yakihonne.com:443,1.32123,103.695
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.purpura.cloud,43.6532,-79.3832
insta-relay.apps3.slidestr.net,40.4167,-3.70329
nostr.mifen.me,43.6532,-79.3832
testnet-relay.samt.st:443,40.8302,-74.1299
nostr.2b9t.xyz:443,34.0549,-118.243
relay.wavlake.com:443,41.2619,-95.8608
relay.wisp.talk:443,49.4543,11.0746
relay-dev.satlantis.io:443,39.0438,-77.4874
relay.satlantis.io,39.0438,-77.4874
relay.staging.plebeian.market,51.5072,-0.127586
relay.openfarmtools.org,60.1699,24.9384
relay.nostrhub.fr,48.1045,11.6004
nostr-relay.xbytez.io,50.6924,3.20113
relay.binaryrobot.com:443,43.6532,-79.3832
relay.samt.st,40.8302,-74.1299
relay.illuminodes.com,43.6532,-79.3832
relay.liberbitworld.org,43.6532,-79.3832
relay.olas.app:443,60.1699,24.9384
no.str.cr,8.96171,-83.5246
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
wot.rejecttheframe.xyz,43.6532,-79.3832
relay.nostriot.com:443,41.5695,-83.9786
nostr.plantroon.com,50.1013,8.62643
nostr-01.uid.ovh,50.9871,2.12554
relay.openresist.com:443,43.6532,-79.3832
nostr.overmind.lol,43.6532,-79.3832
relay.internationalright-wing.org,-22.5022,-48.7114
nostr.myshosholoza.co.za:443,52.3676,4.90414
nostr.pbfs.io:443,50.4754,12.3683
21milionidinostr.duckdns.org,41.8967,12.4822
nostr.4rs.nl,49.0291,8.35696
relay.lanavault.space,60.1699,24.9384
relay.mostr.pub,43.6532,-79.3832
relay.nostar.org,43.6532,-79.3832
nostr.mom,50.4754,12.3683
relay.decentralia.fr,48.122,11.589
relay.agentry.com,42.8864,-78.8784
relay2.angor.io,48.1046,11.6002
slick.mjex.me,39.0418,-77.4744
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
relay.vrtmrz.net:443,43.6532,-79.3832
relay.beginningend.com,35.2227,-97.4786
chat-relay.zap-work.com:443,43.6532,-79.3832
relay.underorion.se,50.1109,8.68213
relay.mitchelltribe.com,39.0438,-77.4874
relay.qstr.app,51.5072,-0.127586
relay.cyberguy.fyi,52.6907,4.8181
strfry.bonsai.com:443,39.0438,-77.4874
relayone.soundhsa.com:443,39.0997,-94.5786
relay.sigit.io,50.4754,12.3683
relay.npubhaus.com,43.6532,-79.3832
relayrs.notoshi.win,43.6532,-79.3832
relay.mitchelltribe.com:443,39.0438,-77.4874
relay.44billion.net,43.6532,-79.3832
reraw.pbla2fish.cc,43.6532,-79.3832
articles.layer3.news:443,37.3387,-121.885
nostr.sovereignservices.xyz,43.6532,-79.3832
relay.nostx.io,43.6532,-79.3832
nostr-relay.amethyst.name,39.0067,-77.4291
0x-nostr-relay.fly.dev,37.7648,-122.432
relay.ohstr.com:443,43.6532,-79.3832
00f2e774.relay.dev.thunderegg.us,39.0438,-77.4874
nostr-relay.cbrx.io,43.6532,-79.3832
relay.wavlake.com,41.2619,-95.8608
purplerelay.com:443,43.6532,-79.3832
nostr-pr02.redscrypt.org,52.3676,4.90414
fanfares.nostr1.com:443,40.7057,-74.0136
kasztanowa.bieda.it,43.6532,-79.3832
relay.flashapp.me,43.6548,-79.3885
relay.typedcypher.com,51.5072,-0.127586
nostr.bond,50.1109,8.68213
nostr.azzamo.net,52.2633,21.0283
nexus.libernet.app,43.6532,-79.3832
relay.cosmicbolt.net,37.3986,-121.964
schnorr.me,43.6532,-79.3832
relay.mostro.network:443,40.8302,-74.1299
relay-arg.zombi.cloudrodion.com,1.35208,103.82
relay.chorus.community,48.5333,10.7
blossom.gnostr.cloud:443,43.6532,-79.3832
syb.lol:443,34.0549,-118.243
relay.dyne.org,49.0291,8.35705
btc.klendazu.com,41.2861,1.24993
wot.nostr.place,43.6532,-79.3832
relay.openresist.com,43.6532,-79.3832
rilo.nostria.app:443,43.6532,-79.3832
no.str.cr:443,8.96171,-83.5246
relay.mostr.pub:443,43.6532,-79.3832
relay.edufeed.org:443,49.4521,11.0767
nostr.debate.report,50.1109,8.68213
relay.satmaxt.xyz:443,43.6532,-79.3832
relay.artx.market:443,43.6548,-79.3885
relay-dev.gulugulu.moe,43.6532,-79.3832
relay.novospes.com,43.6532,-79.3832
relay.nostr-check.me,43.6532,-79.3832
nostr.computingcache.com,34.0356,-118.442
nostr.oxtr.dev,50.4754,12.3683
relay.fckstate.net,59.3293,18.0686
relay.vrtmrz.net,43.6532,-79.3832
relay.bornheimer.app,51.5072,-0.127586
relay.guggero.org,46.5971,9.59652
relay01.lnfi.network,35.6764,139.65
wot.shaving.kiwi,43.6532,-79.3832
nostr.twinkle.lol,51.902,7.6657
relay.edufeed.org,49.4521,11.0767
relay.lanacoin-eternity.com,40.8302,-74.1299
relay.satmaxt.xyz,43.6532,-79.3832
nostr.hifish.org:443,47.4244,8.57658
relay.cypherflow.ai:443,48.8575,2.35138
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
nostr.na.social:443,43.6532,-79.3832
nostr.rtvslawenia.com:443,49.4543,11.0746
relay.mypathtofire.de,42.8864,-78.8784
public.crostr.com,43.6532,-79.3832
relay.olas.app,60.1699,24.9384
relay.agora.social,50.7383,15.0648
ribo.nostria.app,43.6532,-79.3832
relay.lab.rytswd.com,49.4543,11.0746
relay.ditto.pub:443,43.6532,-79.3832
porchlight.social,43.6532,-79.3832
nostr.notribe.net,40.8302,-74.1299
relay.endfiat.money,59.3327,18.0656
nostr.myshosholoza.co.za,52.3676,4.90414
relay.nearhood.co.uk,51.5134,-0.0890675
relay.degmods.com,50.4754,12.3683
nostr.novacisko.cz,52.2026,20.9397
prl.plus,55.7628,37.5983
bruh.samt.st,43.6532,-79.3832
strfry.openhoofd.nl,51.5717,3.70417
nostr.spicyz.io,43.6532,-79.3832
nostr.na.social,43.6532,-79.3832
nip85.nosfabrica.com,39.0997,-94.5786
premium.primal.net,43.6532,-79.3832
fanfares.nostr1.com,40.7057,-74.0136
relay.scuba323.com,40.8218,-74.45
nostr2.girino.org:443,43.6532,-79.3832
relay.mmwaves.de,48.8575,2.35138
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
strfry.shock.network:443,39.0438,-77.4874
nostr.snowbla.de,50.4754,12.3683
nostr.spaceshell.xyz,43.6532,-79.3832
nostr.quali.chat,60.1699,24.9384
wot.utxo.one,43.6532,-79.3832
relay.mccormick.cx,52.3563,4.95714
mostro-p2p.tech,50.1109,8.68213
basspistol.org,49.0291,8.35696
ribo.nostria.app:443,43.6532,-79.3832
chorus.mikedilger.com:444,-36.8906,174.794
nostr.oxtr.dev:443,50.4754,12.3683
nostr.nodesmap.com,59.3327,18.0656
offchain.bostr.online,43.6532,-79.3832
purplerelay.com,43.6532,-79.3832
relayrs.notoshi.win:443,43.6532,-79.3832
relay.wavefunc.live,41.8781,-87.6298
relay.dreamith.to,43.6532,-79.3832
bendernostur.duckdns.org:8443,50.1109,8.68213
relay.nmail.li,50.9871,2.12554
nostr-relay.corb.net,39.6478,-104.988
relay.staging.plebeian.market:443,51.5072,-0.127586
spamspamspamspam.rest,43.6532,-79.3832
relay1.gfcom.info,13.9215,100.538
schnorr.me:443,43.6532,-79.3832
relay.lab.rytswd.com:443,49.4543,11.0746
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.nostrmap.net,60.1699,24.9384
nostr.relay.hedwig.sh,60.1699,24.9384
relay.veganostr.com:443,60.1699,24.9384
relay.wavefunc.live:443,41.8781,-87.6298
nostr.mikoshi.de,52.52,13.405
syb.lol,34.0549,-118.243
relay1.nostrchat.io,60.1699,24.9384
nostr.wecsats.io:443,43.6532,-79.3832
nostr.chaima.info:443,51.5072,-0.127586
nostr.azzamo.net:443,52.2633,21.0283
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
nostr.unkn0wn.world,46.8499,9.53287
relayone.soundhsa.com,39.0997,-94.5786
x.kojira.io,43.6532,-79.3832
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
nostrelay.circum.space,52.6907,4.8181
relay.primal.net,43.6532,-79.3832
nostr.girino.org,43.6532,-79.3832
nostr.pbfs.io,50.4754,12.3683
relay.kalcafe.xyz,37.3986,-121.964
relay.gulugulu.moe,43.6532,-79.3832
top.testrelay.top,43.6532,-79.3832
relay.kilombino.com,43.6532,-79.3832
nos.lol:443,50.4754,12.3683
nos.lol,50.4754,12.3683
relay.nostr.place:443,43.6532,-79.3832
cache.trustr.ing,43.6548,-79.3885
relay.internationalright-wing.org:443,-22.5022,-48.7114
relay.laantungir.net,-19.4692,-42.5315
relay.lightning.pub:443,39.0438,-77.4874
nostr.stakey.net,52.3676,4.90414
articles.layer3.news,37.3387,-121.885
relay.wisp.talk,49.4543,11.0746
relay.pyramid.li,47.4093,8.46503
relay.typedcypher.com:443,51.5072,-0.127586
dev.relay.stream,43.6532,-79.3832
relay.bullishbounty.com,43.6532,-79.3832
nostr.mom:443,50.4754,12.3683
relay.plebeian.market:443,50.1109,8.68213
nostr.hekster.org,37.3986,-121.964
nostrcity-club.fly.dev,37.7648,-122.432
nostr.vulpem.com,49.4543,11.0746
relay-dev.gulugulu.moe:443,43.6532,-79.3832
weboftrust.libretechsystems.xyz,55.4724,9.87335
nostr-relay.corb.net:443,39.6478,-104.988
wheat.happytavern.co,43.6532,-79.3832
relay.mappingbitcoin.com,43.6532,-79.3832
testnet-relay.samt.st,40.8302,-74.1299
relay.bitmacro.cloud,43.6532,-79.3832
dev.relay.edufeed.org,49.4521,11.0767
myvoiceourstory.org,37.3598,-121.981
relay.stickeroo.is-cool.dev,37.3387,-121.885
relay.agorist.space,52.3734,4.89406
freelay.sovbit.host,60.1699,24.9384
nostr-dev.wellorder.net,45.5201,-122.99
nostr.middling.mydns.jp,35.8099,140.12
cs-relay.nostrdev.com,50.4754,12.3683
x.kojira.io:443,43.6532,-79.3832
nostrelay.circum.space:443,52.6907,4.8181
nostr.janx.com,43.6532,-79.3832
relay.mrmave.work,43.6532,-79.3832
espelho.girino.org,43.6532,-79.3832
hol.is,43.6532,-79.3832
ribo.eu.nostria.app,43.6532,-79.3832
nostr.yutakobayashi.com,43.6532,-79.3832
relay.mostro.network,40.8302,-74.1299
communities.nos.social,40.8302,-74.1299
relay.solife.me,43.6532,-79.3832 relay.solife.me,43.6532,-79.3832
yabu.me,35.6092,139.73 nostr.quali.chat,60.1699,24.9384
relay.islandbitcoin.com,12.8498,77.6545 relay.vrtmrz.net,43.6532,-79.3832
nostr.wecsats.io,43.6532,-79.3832 relay-dev.gulugulu.moe:443,43.6532,-79.3832
nostr.tac.lol:443,47.4748,-122.273 relay.bullishbounty.com,43.6532,-79.3832
relay.arx-ccn.com,50.4754,12.3683 relay.fckstate.net,59.3293,18.0686
nostrride.io,37.3986,-121.964 nostr.rtvslawenia.com:443,49.4543,11.0746
r.0kb.io:443,32.789,-96.7989 relay.nostx.io,43.6532,-79.3832
herbstmeister.com,34.0549,-118.243 relay.agorist.space,52.3734,4.89406
relay.artx.market,43.6548,-79.3885 relay.notoshi.win,13.7829,100.546
vault.iris.to,43.6532,-79.3832 dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
relay.ru.ac.th,13.7607,100.627 relay.trotters.cc,43.6532,-79.3832
temp.iris.to,43.6532,-79.3832 relay.lanavault.space,60.1699,24.9384
social.amanah.eblessing.co,48.1046,11.6002 public.crostr.com:443,43.6532,-79.3832
nostr-relay.nextblockvending.com,47.2343,-119.853 nostr.stakey.net:443,52.3676,4.90414
wot.codingarena.top,50.4754,12.3683 relay.nostr.place:443,43.6532,-79.3832
relay.sincensura.org,43.6532,-79.3832
nostr.dlcdevkit.com,40.0992,-83.1141 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
1 Relay URL Latitude Longitude
2 bitchat.nostr1.com relay.lab.rytswd.com 40.7057 49.4543 -74.0136 11.0746
3 relay.fundstr.me relay.paulstephenborile.com:443 42.3601 49.4543 -71.0589 11.0746
4 nostr.2b9t.xyz relay.binaryrobot.com 34.0549 43.6532 -118.243 -79.3832
armada.sharegap.net 43.6532 -79.3832
nostr.chaima.info 51.5072 -0.127586
nosflare-leefcore.leefcore.workers.dev 43.6532 -79.3832
ribo.eu.nostria.app:443 43.6532 -79.3832
relay.lightning.pub 39.0438 -77.4874
relay.nostu.be 40.4167 -3.70329
nostr.whitenode45.ddns.net 40.55 -74.4758
nostr.carroarmato0.be:443 50.914 3.21378
cdn.satellite.earth 40.8302 -74.1299
relay2.veganostr.com 60.1699 24.9384
relay.layer.systems:443 49.0291 8.35695
relay0.gfcom.info 13.7653 100.647
relay.mmwaves.de:443 48.8575 2.35138
offchain.pub 39.1585 -94.5728
bcast.girino.org 43.6532 -79.3832
staging.yabu.me 35.6092 139.73
nostr.overpay.com 29.7449 -95.5343
bridge.tagomago.me 42.3601 -71.0589
nostr-01.yakihonne.com 1.32123 103.695
strfry.bonsai.com 39.0438 -77.4874
relay.sharegap.net 43.6532 -79.3832
nostr.islandarea.net 35.4669 -97.6473
dm-test-strfry-generic.samt.st 43.6532 -79.3832
treuzkas.branruz.com 48.8575 2.35138
relay-rpi.edufeed.org:443 49.4521 11.0767
vault.iris.to:443 43.6532 -79.3832
node.kommonzenze.de 49.4521 11.0767
nostr.thalheim.io:443 60.1699 24.9384
soloco.nl 43.6532 -79.3832
strfry.shock.network 39.0438 -77.4874
nostr-relay.zimage.com 34.0549 -118.243
public.crostr.com:443 43.6532 -79.3832
nostr.sathoarder.com:443 48.5734 7.75211
relay.angor.io 48.1046 11.6002
relay.wellorder.net 45.5201 -122.99
relay.mwaters.net 50.9871 2.12554
relay.staging.commonshub.brussels 49.4543 11.0746
nostr-verified.wellorder.net 45.5201 -122.99
nostr-pub.wellorder.net 45.5201 -122.99
5 nostr-2.21crypto.ch 47.5356 8.73209
6 relay.kaleidoswap.com spookstr2.nostr1.com:443 50.8476 40.7057 4.35717 -74.0136
7 relay.libernet.app:443 fanfares.nostr1.com:443 43.6532 40.7057 -79.3832 -74.0136
8 relay.homeinhk.xyz x.kojira.io 35.694 43.6532 139.754 -79.3832
9 relay.manneken.brussels freelay.sovbit.host 49.4543 60.1699 11.0746 24.9384
nostr.spicyz.io:443 43.6532 -79.3832
relay.lanacoin-eternity.com:443 40.8302 -74.1299
ribo.us.nostria.app:443 43.6532 -79.3832
relay.loveisbitcoin.com 43.6532 -79.3832
relay.angor.io:443 48.1046 11.6002
relay02.lnfi.network 35.6764 139.65
relay.cosmicbolt.net:443 37.3986 -121.964
10 nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
nrs-01.darkcloudarcade.com 39.0997 -94.5786
relay.endfiat.money:443 59.3327 18.0656
relay.paulstephenborile.com 49.4543 11.0746
rele.speyhard.fi 51.5072 -0.127586
relay.froth.zone 60.1699 24.9384
relay.nostr.blockhenge.com 39.0438 -77.4874
nrl.ceskar.xyz 50.5145 16.0119
rilo.nostria.app 43.6532 -79.3832
nostr.overmind.lol:443 43.6532 -79.3832
nostr.snowbla.de:443 50.4754 12.3683
nostrrelay.taylorperron.com 45.5029 -73.5723
chorus.pjv.me 45.5201 -122.99
relay.nostr.place 43.6532 -79.3832
bucket.coracle.social 37.7775 -122.397
nostr.girino.org:443 43.6532 -79.3832
relay.aarpia.com 37.3986 -121.964
nostr.thalheim.io 60.1699 24.9384
ec2.f7z.io 60.1699 24.9384
relay.trotters.cc 43.6532 -79.3832
relay.mccormick.cx:443 52.3563 4.95714
relay.momostr.pink 43.6532 -79.3832
relay.nostr.net 43.6532 -79.3832
conduitl2.fly.dev 37.7648 -122.432
chat-relay.zap-work.com 43.6532 -79.3832
relay.ditto.pub 43.6532 -79.3832
relay.veganostr.com 60.1699 24.9384
relay.minibolt.info:443 43.6532 -79.3832
adre.su 59.9311 30.3609
bitcoinostr.duckdns.org 41.1976 1.11167
nostr.computingcache.com:443 34.0356 -118.442
relay-fra.zombi.cloudrodion.com 48.8566 2.35222
nostr.hekster.org:443 37.3986 -121.964
nostr.88mph.life 52.1941 -2.21905
wot.dergigi.com 64.1476 -21.9392
nostr.planix.org 43.6532 -79.3832
relay.satsmarkt.club 52.6907 4.8181
nostrcity-club.fly.dev:443 37.7648 -122.432
aeon.libretechsystems.xyz 55.486 9.86577
11 testnet.samt.st 43.6532 -79.3832
12 nostr.data.haus relay.angor.io 50.4754 48.1046 12.3683 11.6002
13 wot.sudocarlos.com relay-arg.zombi.cloudrodion.com 43.6532 1.35208 -79.3832 103.82
14 relay-fra.zombi.cloudrodion.com:443 nostr-01.yakihonne.com 48.8566 1.32123 2.35222 103.695
15 shu01.shugur.net nostr-relay.cbrx.io 21.4902 43.6532 39.2246 -79.3832
16 relay.gulugulu.moe:443 relay.guggero.org 43.6532 46.5971 -79.3832 9.59652
17 relay2.angor.io:443 nostr.snowbla.de 48.1046 60.1699 11.6002 24.9384
relay.libernet.app 43.6532 -79.3832
directories-safe-motherboard-recipients.trycloudflare.com 43.6532 -79.3832
wot.nostr.party 36.1659 -86.7844
18 relay.zone667.com 60.1699 24.9384
19 nostr.wild-vibes.ts.net nexus.libernet.app:443 48.8566 43.6532 2.35222 -79.3832
20 relay.nostr.com relay.islandbitcoin.com 50.1109 12.8498 8.68213 77.6545
21 nostr.iskarion.ddns.net relay-testnet.k8s.layer3.news 43.3076 37.3387 -2.95421 -121.885
22 relay-dev.satlantis.io nostr-relay.xbytez.io 39.0438 50.6924 -77.4874 3.20113
23 relay.sovereignresonance.org kasztanowa.bieda.it 48.9006 43.6532 2.25929 -79.3832
24 relay.nostrian-conquest.com nostrcity-club.fly.dev 41.223 37.7648 -111.974 -122.432
25 relay.aidatanorge.no relay.typedcypher.com 43.6532 51.5072 -79.3832 -0.127586
26 strfry.apps3.slidestr.net nostr.na.social:443 40.4167 43.6532 -3.70329 -79.3832
27 relay.klabo.world relay.laantungir.net 47.2343 -19.4692 -119.853 -42.5315
28 nostr.data.haus:443 relay-dev.satlantis.io:443 50.4754 40.8302 12.3683 -74.1299
29 testr.nymble.world rilo.nostria.app 40.8054 43.6532 -74.0241 -79.3832
30 relay.inforsupports.com nostr.hekster.org:443 43.6532 37.3986 -79.3832 -121.964
31 nostr-relay.amethyst.name:443 39.0067 -77.4291
32 chat-relay.zap-work.com:443 43.6532 -79.3832
33 relay.edufeed.org 49.4521 11.0767
34 syb.lol:443 43.6532 -79.3832
35 relay.sigit.io 50.4754 12.3683
36 nostr-relay.xbytez.io:443 50.6924 3.20113
37 relay.wavefunc.live 41.8781 -87.6298
38 nostr.sathoarder.com 48.5734 7.75211
39 myvoiceourstory.org 37.3598 -121.981
40 relay.underorion.se 50.1109 8.68213
41 nostr.data.haus 50.4754 12.3683
42 relay.erybody.com 41.4513 -81.7021
43 espelho.girino.org 43.6532 -79.3832
44 nostr.pbfs.io:443 50.4754 12.3683
45 wot.dergigi.com 64.1476 -21.9392
46 nostr.bitcoiner.social:443 47.6743 -117.112
47 dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
48 relay.gulugulu.moe 43.6532 -79.3832
49 nostr.spicyz.io 43.6532 -79.3832
50 relay.cypherflow.ai 48.8575 2.35138
51 treuzkas.branruz.com 48.8575 2.35138
52 relay1.nostrchat.io 60.1699 24.9384
53 kotukonostr.onrender.com 37.7775 -122.397
54 nostr.plantroon.com 50.1013 8.62643
55 nostr.davenov.com 50.1109 8.68213
56 node.kommonzenze.de 49.4521 11.0767
57 relay2.veganostr.com 60.1699 24.9384
58 armada.sharegap.net 43.6532 -79.3832
59 wot.makenomistakes.ca 43.7064 -79.3986
60 nostr.2b9t.xyz:443 34.0549 -118.243
61 relay.libernet.app:443 43.6532 -79.3832
62 relay.dreamith.to:443 43.6532 -79.3832
63 relay.lightning.pub:443 39.0438 -77.4874
64 nostr.rtvslawenia.com 49.4543 11.0746
65 nostr.21crypto.ch 47.5356 8.73209
66 relay.ditto.pub:443 43.6532 -79.3832
67 relay.plebchain.club 43.6532 -79.3832
68 memlay.v0l.io 53.3498 -6.26031
69 nostr.chaima.info:443 50.1109 8.68213
70 relay.wavlake.com:443 41.2619 -95.8608
71 nostr.thalheim.io:443 60.1699 24.9384
72 relay.lightning.pub 39.0438 -77.4874
73 dev.relay.edufeed.org:443 49.4521 11.0767
74 nostr.myshosholoza.co.za:443 52.3913 4.66545
75 relay.binaryrobot.com:443 43.6532 -79.3832
76 wot.nostr.place 43.6532 -79.3832
77 nostr.sathoarder.com:443 48.5734 7.75211
78 thecitadel.nostr1.com 40.7057 -74.0136
79 relay.artx.market 43.6548 -79.3885
80 nos.lol 50.4754 12.3683
81 nostr.plantroon.com:443 50.1013 8.62643
82 premium.primal.net 43.6532 -79.3832
83 nas01xanthosnet.synology.me:7778 47.1285 8.74735
84 nostrja-kari.heguro.com 43.6532 -79.3832
85 relay.mrmave.work 43.6532 -79.3832
86 nostrelay.circum.space 52.6907 4.8181
87 mostro-p2p.tech 50.1109 8.68213
88 wot.shaving.kiwi 43.6532 -79.3832
89 relay.fundstr.me 42.3601 -71.0589
90 nostrelay.circum.space:443 52.6907 4.8181
91 relay.nostrdice.com -33.8688 151.209
92 relay.getvia.xyz 60.1699 24.9384
93 strfry.shock.network:443 39.0438 -77.4874
94 relay.nostrmap.net:443 60.1699 24.9384
95 nostr.stakey.net:443 relay.nearhood.co.uk 52.3676 51.5072 4.90414 -0.127586
96 no.str.cr 10.6352 -85.4378
97 relay.getsafebox.app:443 43.6532 -79.3832
98 relay0.gfcom.info 13.6992 100.694
99 nostr.ps1829.com 33.8851 130.883
100 relay2.angor.io 48.1046 11.6002
101 relay.stickeroo.is-cool.dev 37.3387 -121.885
102 ricardo-oem.tailb5546.ts.net 40.7128 -74.006
103 relay.typedcypher.com:443 51.5072 -0.127586
104 relay.paulstephenborile.com 49.4543 11.0746
105 nittom.nostr1.com 40.7057 -74.0136
106 conduitl2.fly.dev 37.7648 -122.432
107 nostr.rikmeijer.nl 51.7111 5.36809
108 relay.thecryptosquid.com 50.4754 12.3683
109 spookstr2.nostr1.com 40.7057 -74.0136
110 offchain.bostr.online 43.6532 -79.3832
111 nostr.planix.org 43.6532 -79.3832
112 relay.mccormick.cx 52.3563 4.95714
113 0x-nostr-relay.fly.dev 37.7648 -122.432
114 nostr.wecsats.io 43.6532 -79.3832
115 schnorr.me 43.6532 -79.3832
116 relay.satmaxt.xyz 43.6532 -79.3832
117 relay.bornheimer.app 51.5072 -0.127586
118 relay.nostrhub.fr 48.1045 11.6004
119 blossom.gnostr.cloud:443 43.6532 -79.3832
120 nostr-02.yakihonne.com:443 1.32123 103.695
121 dev.relay.stream 43.6532 -79.3832
122 ithurtswhenip.ee 51.5072 -0.127586
123 nostr.myshosholoza.co.za 52.3913 4.66545
124 relayrs.notoshi.win:443 43.6532 -79.3832
125 relay-rpi.edufeed.org:443 49.4521 11.0767
126 relay.olas.app:443 60.1699 24.9384
127 nostr.unkn0wn.world 46.8499 9.53287
128 relay.mitchelltribe.com 39.0438 -77.4874
129 yabu.me 35.6092 139.73
130 nostr.nodesmap.com 59.3327 18.0656
131 dm-test-strfry-generic.samt.st 43.6532 -79.3832
132 nostr2.girino.org:443 43.6532 -79.3832
133 wot.brightbolt.net 47.6735 -116.781
134 strfry.shock.network 39.0438 -77.4874
135 relay.kilombino.com 43.6532 -79.3832
136 relay.nostr.blockhenge.com 39.0438 -77.4874
137 shu04.shugur.net 25.2048 55.2708
138 relay-rpi.edufeed.org 49.4521 11.0767
139 relay.bullishbounty.com:443 43.6532 -79.3832
140 vault.iris.to:443 43.6532 -79.3832
141 relay.mostro.network:443 40.8302 -74.1299
142 offchain.pub:443 39.1585 -94.5728
143 soloco.nl 43.6532 -79.3832
144 relay.nostu.be 40.4167 -3.70329
145 nostr.pbfs.io 50.4754 12.3683
146 relay.directsponsor.net 42.8864 -78.8784
147 relay.decentralia.fr 49.4282 10.9796
148 relayrs.notoshi.win 43.6532 -79.3832
149 nostr-relay.amethyst.name 39.0067 -77.4291
150 relay.arx-ccn.com 50.4754 12.3683
151 nostr.spaceshell.xyz 43.6532 -79.3832
152 relay-fra.zombi.cloudrodion.com 48.8566 2.35222
153 rilo.nostria.app:443 43.6532 -79.3832
154 relay.trotters.cc:443 43.6532 -79.3832
155 nostr.overmind.lol:443 43.6532 -79.3832
156 nostr.girino.org:443 43.6532 -79.3832
157 bitsat.molonlabe.holdings 51.4012 -1.3147
158 nostr.azzamo.net 52.2633 21.0283
159 insta-relay.apps3.slidestr.net 40.4167 -3.70329
160 bridge.tagomago.me 42.3601 -71.0589
161 nostr.thalheim.io 60.1699 24.9384
162 relay.artx.market:443 43.6548 -79.3885
163 nostr.openhoofd.nl 51.5717 3.70417
164 nostr.bond 50.1109 8.68213
165 relay.earthly.city 34.1749 -118.54
166 nexus.libernet.app 43.6532 -79.3832
167 relay.plebeian.market 50.1109 8.68213
168 relay.nostr.net 43.6532 -79.3832
169 nostr.overmind.lol 43.6532 -79.3832
170 relay.ohstr.com 43.6532 -79.3832
171 testnet-relay.samt.st:443 40.8302 -74.1299
172 relay01.lnfi.network 35.6764 139.65
173 relay.mostr.pub:443 43.6532 -79.3832
174 wot.nostr.party 36.1659 -86.7844
175 relayone.soundhsa.com 39.1008 -94.5811
176 relay.mostro.network 40.8302 -74.1299
177 ribo.eu.nostria.app 43.6532 -79.3832
178 chat-relay.zap-work.com 43.6532 -79.3832
179 relay.nostreon.com 60.1699 24.9384
180 nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
181 nostr.quali.chat:443 60.1699 24.9384
182 relay.internationalright-wing.org:443 -22.5022 -48.7114
183 relay.mitchelltribe.com:443 39.0438 -77.4874
184 relay.satlantis.io 40.8054 -74.0241
185 nittom.nostr1.com:443 40.7057 -74.0136
186 nostr.janx.com 43.6532 -79.3832
187 nostr.carroarmato0.be:443 50.914 3.21378
188 relay.mmwaves.de:443 48.8575 2.35138
189 relay.chorus.community:443 48.5333 10.7
190 wot.utxo.one 43.6532 -79.3832
191 relay.plebeian.market:443 50.1109 8.68213
192 relay.cosmicbolt.net 37.3986 -121.964
193 x.kojira.io:443 43.6532 -79.3832
194 top.testrelay.top 43.6532 -79.3832
195 nos.lol:443 50.4754 12.3683
196 dev.relay.edufeed.org 49.4521 11.0767
197 relayone.geektank.ai:443 39.1008 -94.5811
198 relay.nostar.org 43.6532 -79.3832
199 nostr.oxtr.dev:443 50.4754 12.3683
200 nostr.88mph.life 52.1941 -2.21905
201 relay.staging.commonshub.brussels 49.4543 11.0746
202 weboftrust.libretechsystems.xyz 55.4724 9.87335
203 relay.openfarmtools.org 60.1699 24.9384
204 cs-relay.nostrdev.com 50.4754 12.3683
205 relay.inforsupports.com 43.6532 -79.3832
206 nostr-verified.wellorder.net 45.5201 -122.99
207 nostr.hekster.org 37.3986 -121.964
208 relay.gulugulu.moe:443 43.6532 -79.3832
209 relay.mwaters.net 50.9871 2.12554
210 nostrcity-club.fly.dev:443 37.7648 -122.432
211 relay.vrtmrz.net:443 43.6532 -79.3832
212 relay.nostr.place 43.6532 -79.3832
213 relay.wavefunc.live:443 41.8781 -87.6298
214 nostr.islandarea.net 35.4669 -97.6473
215 purplerelay.com:443 43.6532 -79.3832
216 nostr-relay.psfoundation.info:443 39.0438 -77.4874
217 r.0kb.io 32.789 -96.7989
218 relay-us.zombi.cloudrodion.com 40.7862 -74.0743
219 relay.mulatta.io 37.5665 126.978
220 strfry.bonsai.com:443 39.0438 -77.4874
221 bendernostur.duckdns.org:8443 50.1109 8.68213
222 vault.iris.to 43.6532 -79.3832
223 ec2.f7z.io 60.1699 24.9384
224 nostr.debate.report 50.1109 8.68213
225 wot.codingarena.top 50.4754 12.3683
226 relay.layer.systems:443 49.0291 8.35695
227 relay.degmods.com 50.4754 12.3683
228 nostr.mom 50.4754 12.3683
229 ribo.us.nostria.app:443 43.6532 -79.3832
230 adre.su 59.9311 30.3609
231 wot.sudocarlos.com 43.6532 -79.3832
232 relay.nostrian-conquest.com 41.223 -111.974
233 nostr-relay.nextblockvending.com 47.2343 -119.853
234 relay.endfiat.money:443 59.3327 18.0656
235 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
236 nostr.carroarmato0.be 50.914 3.21378
237 relay.cypherflow.ai:443 48.8575 2.35138
238 nostr.girino.org 43.6532 -79.3832
239 nostr.thebiglake.org 32.71 -96.6745
240 strfry.ymir.cloud 43.6532 -79.3832
241 relay.mypathtofire.de 42.8864 -78.8784
242 relay.lanacoin-eternity.com 40.8302 -74.1299
243 nostr.snowbla.de:443 60.1699 24.9384
244 relay.ditto.pub 43.6532 -79.3832
245 relay.damus.io 43.6532 -79.3832
246 relay.ru.ac.th 13.7607 100.627
247 nrs-01.darkcloudarcade.com 39.1008 -94.5811
248 testnet-relay.samt.st 40.8302 -74.1299
249 antiprimal.net 43.6532 -79.3832
250 bitchat.nostr1.com 40.7057 -74.0136
251 relay.snort.social 53.3498 -6.26031
252 relay.mccormick.cx:443 52.3563 4.95714
253 relay02.lnfi.network 35.6764 139.65
254 srtrelay.c-stellar.net 43.6532 -79.3832
255 relay.minibolt.info 43.6532 -79.3832
256 nostrride.io 37.3986 -121.964
257 articles.layer3.news:443 37.3387 -121.885
258 rele.speyhard.fi 51.5072 -0.127586
259 relay.aarpia.com 37.3986 -121.964
260 nostr.chaima.info 50.1109 8.68213
261 relay.wisp.talk:443 49.4543 11.0746
262 relay.agorist.space:443 52.3734 4.89406
263 strfry.bonsai.com 39.0438 -77.4874
264 nostr.hifish.org 47.4244 8.57658
265 offchain.pub 39.1585 -94.5728
266 nostr.spicyz.io:443 43.6532 -79.3832
267 relay.beginningend.com 35.2227 -97.4786
268 relay.sharegap.net 43.6532 -79.3832
269 nostr.purpura.cloud 43.6532 -79.3832
270 nrs-01.darkcloudarcade.com:443 39.1008 -94.5811
271 relay.fountain.fm:443 43.6532 -79.3832
272 relay.olas.app 60.1699 24.9384
273 relay.mmwaves.de 48.8575 2.35138
274 relay.openresist.com:443 43.6532 -79.3832
275 relay.homeinhk.xyz 35.694 139.754
276 relay.libernet.app 43.6532 -79.3832
277 relay.comcomponent.com 43.6532 -79.3832
278 nostr.tac.lol 47.4748 -122.273
279 relay.goodmorningbitcoin.com 43.6532 -79.3832
280 relay.nostriot.com:443 41.5695 -83.9786
281 bcast.girino.org 43.6532 -79.3832
282 nostr.azzamo.net:443 52.2633 21.0283
283 relay.islandbitcoin.com:443 12.8498 77.6545
284 pool.libernet.app 43.6532 -79.3832
285 test.thedude.cloud 50.1109 8.68213
286 nostrelites.org 41.8781 -87.6298
287 nostr.infero.net 35.6764 139.65
288 relay.primal.net 43.6532 -79.3832
289 ribo.nostria.app 43.6532 -79.3832
290 relay.chorus.community 48.5333 10.7
291 bitcoiner.social:443 47.6743 -117.112
292 relay.wisp.talk 49.4543 11.0746
293 relay.layer.systems 49.0291 8.35695
294 relay-dev.satlantis.io 40.8302 -74.1299
295 nostr.bitcoiner.social 47.6743 -117.112
296 relay.lanavault.space:443 60.1699 24.9384
297 relay.staging.plebeian.market 51.5072 -0.127586
298 infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
299 relay.fountain.fm 43.6532 -79.3832
300 nostr.middling.mydns.jp 35.8099 140.12
301 relay.dreamith.to 43.6532 -79.3832
302 relay.satmaxt.xyz:443 43.6532 -79.3832
303 shu03.shugur.net 25.2048 55.2708
304 zealand-charts-craig-thru.trycloudflare.com 43.6532 -79.3832
305 nostr.computingcache.com 34.0356 -118.442
306 ribo.us.nostria.app 43.6532 -79.3832
307 relay.agentry.com 42.8864 -78.8784
308 nostr.hifish.org:443 47.4244 8.57658
309 nostr.vulpem.com 49.4543 11.0746
310 relay.cosmicbolt.net:443 37.3986 -121.964
311 nostr-02.yakihonne.com 1.32123 103.695
312 r.0kb.io:443 32.789 -96.7989
313 nostr-relay.corb.net 38.8353 -104.822
314 ribo.eu.nostria.app:443 43.6532 -79.3832
315 nostr-relay.psfoundation.info 39.0438 -77.4874
316 relay.wellorder.net 45.5201 -122.99
317 relay.novospes.com 43.6532 -79.3832
318 nostr-dev.wellorder.net 45.5201 -122.99
319 relay.endfiat.money 59.3327 18.0656
320 relay.angor.io:443 48.1046 11.6002
321 relay-fra.zombi.cloudrodion.com:443 48.8566 2.35222
322 strfry.openhoofd.nl 51.5717 3.70417
323 relay.getsafebox.app 43.6532 -79.3832
324 relay.openresist.com 43.6532 -79.3832
325 relay5.bitransfer.org 43.6532 -79.3832
326 nostr.na.social 43.6532 -79.3832
327 portal-relay.pareto.space 49.0291 8.35696
328 nostr.notribe.net:443 40.8302 -74.1299
329 relay.bitmacro.cloud 43.6532 -79.3832
330 no.str.cr:443 10.6352 -85.4378
331 relay.klabo.world 47.2343 -119.853
332 nostr.notribe.net 40.8302 -74.1299
333 relay.staging.plebeian.market:443 51.5072 -0.127586
334 relay.nostrmap.net 60.1699 24.9384
335 temp.iris.to 43.6532 -79.3832
336 nostr.sovereignservices.xyz 43.6532 -79.3832
337 nostr.liberty.fans 36.9104 -89.5875
338 relay.nostrian-conquest.com:443 41.223 -111.974
339 relay.nostriot.com 41.5695 -83.9786
340 nostrbtc.com 43.6532 -79.3832
341 shu02.shugur.net 21.4902 39.2246
342 relay.kalcafe.xyz 37.3986 -121.964
343 relay.illuminodes.com 43.6532 -79.3832
344 relay.wavlake.com 41.2619 -95.8608
345 nostr.ps1829.com:443 33.8851 130.883
346 dm-test-strfry-discovery.samt.st 43.6532 -79.3832
347 nostr.wecsats.io:443 43.6532 -79.3832
348 nostr-pub.wellorder.net 45.5201 -122.99
349 nostr.dlcdevkit.com:443 40.0992 -83.1141
350 nostr.mom:443 50.4754 12.3683
351 ribo.nostria.app:443 43.6532 -79.3832
352 nostr.2b9t.xyz 34.0549 -118.243
353 nostr.data.haus:443 50.4754 12.3683
354 staging.yabu.me 35.6092 139.73
355 relay.sigit.io:443 50.4754 12.3683
356 relay.edufeed.org:443 49.4521 11.0767
357 nostr-01.yakihonne.com:443 1.32123 103.695
358 reraw.pbla2fish.cc 43.6532 -79.3832
359 cs-relay.nostrdev.com:443 50.4754 12.3683
360 herbstmeister.com 34.0549 -118.243
361 relay.minibolt.info:443 43.6532 -79.3832
362 relay2.angor.io:443 48.1046 11.6002
363 social.amanah.eblessing.co 48.1046 11.6002
364 nostr.stakey.net 52.3676 4.90414
365 nostr.computingcache.com:443 34.0356 -118.442
366 slick.mjex.me 39.0418 -77.4744
367 fanfares.nostr1.com 40.7057 -74.0136
368 bitcoinostr.duckdns.org 43.3434 -3.99532
369 nostr.oxtr.dev 50.4754 12.3683
370 cache.trustr.ing 43.6548 -79.3885
371 purplerelay.com 43.6532 -79.3832
372 nostr-kyomu-haskell.onrender.com 37.7775 -122.397
373 nostr-relay.corb.net:443 38.8353 -104.822
374 relay-dev.gulugulu.moe 43.6532 -79.3832
375 prl.plus 55.7628 37.5983
376 nostr.tac.lol:443 47.4748 -122.273
377 relay.mostr.pub 43.6532 -79.3832
378 schnorr.me:443 43.6532 -79.3832
379 dev-relay.nostreon.com 60.1699 24.9384
380 nostr.islandarea.net:443 35.4669 -97.6473
381 nostr.rtvslawenia.com bucket.coracle.social 49.4543 37.7775 11.0746 -122.397
relay.bowlafterbowl.com 32.9483 -96.7299
nostr.quali.chat:443 60.1699 24.9384
relay.plebeian.market 50.1109 8.68213
relay-rpi.edufeed.org 49.4521 11.0767
r.0kb.io 32.789 -96.7989
nostr.notribe.net:443 40.8302 -74.1299
relay.getsafebox.app:443 43.6532 -79.3832
nostr.dlcdevkit.com:443 40.0992 -83.1141
nostrelites.org 34.9582 -81.9907
nostr.hoppe-relay.it.com 42.8864 -78.8784
nostr.thebiglake.org 32.71 -96.6745
nostr-kyomu-haskell.onrender.com 37.7775 -122.397
relay.nostriot.com 41.5695 -83.9786
nostr.christiansass.de 51.7634 7.8887
relay.btcforplebs.com 43.6532 -79.3832
nostr.tagomago.me 42.3601 -71.0589
relayone.geektank.ai 39.0997 -94.5786
relay.dreamith.to:443 43.6532 -79.3832
nostr.liberty.fans 36.8767 -89.5879
wot.makenomistakes.ca 43.7064 -79.3986
relay.goodmorningbitcoin.com 43.6532 -79.3832
relay.layer.systems 49.0291 8.35695
relay.paulstephenborile.com:443 49.4543 11.0746
relay.ohstr.com 43.6532 -79.3832
nostr-relay.xbytez.io:443 50.6924 3.20113
nostr.ac 38.958 -77.3592
ribo.us.nostria.app 43.6532 -79.3832
nostr.21crypto.ch 47.5356 8.73209
relay.chorus.community:443 48.5333 10.7
relay.cypherflow.ai 48.8575 2.35138
relay.agorist.space:443 52.3734 4.89406
relay.nostrian-conquest.com:443 41.223 -111.974
relay.keykeeper.world 40.7824 -74.0711
relay.getvia.xyz 60.1699 24.9384
relay.nuts.cash 52.3676 4.90414
kotukonostr.onrender.com 37.7775 -122.397
relay.minibolt.info 43.6532 -79.3832
relay.dwadziesciajeden.pl 52.2297 21.0122
relay.fountain.fm:443 43.6532 -79.3832
relay.fountain.fm 43.6532 -79.3832
nostr-02.uid.ovh 50.9871 2.12554
relay.lanavault.space:443 60.1699 24.9384
nostr.carroarmato0.be 50.914 3.21378
nexus.libernet.app:443 43.6532 -79.3832
relay.artio.inf.unibe.ch 46.9501 7.43678
382 blossom.gnostr.cloud 43.6532 -79.3832
relay.binaryrobot.com 43.6532 -79.3832
relay.earthly.city 34.1749 -118.54
nostr.hifish.org 47.4244 8.57658
offchain.pub:443 39.1585 -94.5728
relay.bullishbounty.com:443 43.6532 -79.3832
strfry.openhoofd.nl:443 51.5717 3.70417
cs-relay.nostrdev.com:443 50.4754 12.3683
strfry.ymir.cloud 43.6532 -79.3832
nostrbtc.com 43.6532 -79.3832
relay.directsponsor.net 42.8864 -78.8784
nostr2.girino.org 43.6532 -79.3832
relay.sigit.io:443 50.4754 12.3683
relay.getsafebox.app 43.6532 -79.3832
antiprimal.net 43.6532 -79.3832
nostr.sathoarder.com 48.5734 7.75211
inbox.scuba323.com 40.8218 -74.45
nrs-01.darkcloudarcade.com:443 39.0997 -94.5786
nostr.tac.lol 47.4748 -122.273
nostr.davenov.com 50.1109 8.68213
relay.trotters.cc:443 43.6532 -79.3832
nostr.plantroon.com:443 50.1013 8.62643
relay.nostreon.com 60.1699 24.9384
nostr.easycryptosend.it 43.6532 -79.3832
nostr-01.yakihonne.com:443 1.32123 103.695
relay-testnet.k8s.layer3.news 37.3387 -121.885
nostr.purpura.cloud 43.6532 -79.3832
insta-relay.apps3.slidestr.net 40.4167 -3.70329
nostr.mifen.me 43.6532 -79.3832
testnet-relay.samt.st:443 40.8302 -74.1299
nostr.2b9t.xyz:443 34.0549 -118.243
relay.wavlake.com:443 41.2619 -95.8608
relay.wisp.talk:443 49.4543 11.0746
relay-dev.satlantis.io:443 39.0438 -77.4874
relay.satlantis.io 39.0438 -77.4874
relay.staging.plebeian.market 51.5072 -0.127586
relay.openfarmtools.org 60.1699 24.9384
relay.nostrhub.fr 48.1045 11.6004
nostr-relay.xbytez.io 50.6924 3.20113
relay.binaryrobot.com:443 43.6532 -79.3832
relay.samt.st 40.8302 -74.1299
relay.illuminodes.com 43.6532 -79.3832
relay.liberbitworld.org 43.6532 -79.3832
relay.olas.app:443 60.1699 24.9384
no.str.cr 8.96171 -83.5246
dm-test-strfry-discovery.samt.st 43.6532 -79.3832
wot.rejecttheframe.xyz 43.6532 -79.3832
relay.nostriot.com:443 41.5695 -83.9786
nostr.plantroon.com 50.1013 8.62643
nostr-01.uid.ovh 50.9871 2.12554
relay.openresist.com:443 43.6532 -79.3832
nostr.overmind.lol 43.6532 -79.3832
relay.internationalright-wing.org -22.5022 -48.7114
nostr.myshosholoza.co.za:443 52.3676 4.90414
nostr.pbfs.io:443 50.4754 12.3683
21milionidinostr.duckdns.org 41.8967 12.4822
nostr.4rs.nl 49.0291 8.35696
relay.lanavault.space 60.1699 24.9384
relay.mostr.pub 43.6532 -79.3832
relay.nostar.org 43.6532 -79.3832
nostr.mom 50.4754 12.3683
relay.decentralia.fr 48.122 11.589
relay.agentry.com 42.8864 -78.8784
relay2.angor.io 48.1046 11.6002
slick.mjex.me 39.0418 -77.4744
relay-us.zombi.cloudrodion.com 40.7862 -74.0743
relay.vrtmrz.net:443 43.6532 -79.3832
relay.beginningend.com 35.2227 -97.4786
chat-relay.zap-work.com:443 43.6532 -79.3832
relay.underorion.se 50.1109 8.68213
relay.mitchelltribe.com 39.0438 -77.4874
relay.qstr.app 51.5072 -0.127586
relay.cyberguy.fyi 52.6907 4.8181
strfry.bonsai.com:443 39.0438 -77.4874
relayone.soundhsa.com:443 39.0997 -94.5786
relay.sigit.io 50.4754 12.3683
relay.npubhaus.com 43.6532 -79.3832
relayrs.notoshi.win 43.6532 -79.3832
relay.mitchelltribe.com:443 39.0438 -77.4874
relay.44billion.net 43.6532 -79.3832
reraw.pbla2fish.cc 43.6532 -79.3832
articles.layer3.news:443 37.3387 -121.885
nostr.sovereignservices.xyz 43.6532 -79.3832
relay.nostx.io 43.6532 -79.3832
nostr-relay.amethyst.name 39.0067 -77.4291
0x-nostr-relay.fly.dev 37.7648 -122.432
relay.ohstr.com:443 43.6532 -79.3832
00f2e774.relay.dev.thunderegg.us 39.0438 -77.4874
nostr-relay.cbrx.io 43.6532 -79.3832
relay.wavlake.com 41.2619 -95.8608
purplerelay.com:443 43.6532 -79.3832
nostr-pr02.redscrypt.org 52.3676 4.90414
fanfares.nostr1.com:443 40.7057 -74.0136
kasztanowa.bieda.it 43.6532 -79.3832
relay.flashapp.me 43.6548 -79.3885
relay.typedcypher.com 51.5072 -0.127586
nostr.bond 50.1109 8.68213
nostr.azzamo.net 52.2633 21.0283
nexus.libernet.app 43.6532 -79.3832
relay.cosmicbolt.net 37.3986 -121.964
schnorr.me 43.6532 -79.3832
relay.mostro.network:443 40.8302 -74.1299
relay-arg.zombi.cloudrodion.com 1.35208 103.82
relay.chorus.community 48.5333 10.7
blossom.gnostr.cloud:443 43.6532 -79.3832
syb.lol:443 34.0549 -118.243
relay.dyne.org 49.0291 8.35705
btc.klendazu.com 41.2861 1.24993
wot.nostr.place 43.6532 -79.3832
relay.openresist.com 43.6532 -79.3832
rilo.nostria.app:443 43.6532 -79.3832
no.str.cr:443 8.96171 -83.5246
relay.mostr.pub:443 43.6532 -79.3832
relay.edufeed.org:443 49.4521 11.0767
nostr.debate.report 50.1109 8.68213
relay.satmaxt.xyz:443 43.6532 -79.3832
relay.artx.market:443 43.6548 -79.3885
relay-dev.gulugulu.moe 43.6532 -79.3832
relay.novospes.com 43.6532 -79.3832
relay.nostr-check.me 43.6532 -79.3832
nostr.computingcache.com 34.0356 -118.442
nostr.oxtr.dev 50.4754 12.3683
relay.fckstate.net 59.3293 18.0686
relay.vrtmrz.net 43.6532 -79.3832
relay.bornheimer.app 51.5072 -0.127586
relay.guggero.org 46.5971 9.59652
relay01.lnfi.network 35.6764 139.65
wot.shaving.kiwi 43.6532 -79.3832
nostr.twinkle.lol 51.902 7.6657
relay.edufeed.org 49.4521 11.0767
relay.lanacoin-eternity.com 40.8302 -74.1299
relay.satmaxt.xyz 43.6532 -79.3832
nostr.hifish.org:443 47.4244 8.57658
relay.cypherflow.ai:443 48.8575 2.35138
infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
nostr.na.social:443 43.6532 -79.3832
nostr.rtvslawenia.com:443 49.4543 11.0746
relay.mypathtofire.de 42.8864 -78.8784
public.crostr.com 43.6532 -79.3832
relay.olas.app 60.1699 24.9384
relay.agora.social 50.7383 15.0648
ribo.nostria.app 43.6532 -79.3832
relay.lab.rytswd.com 49.4543 11.0746
relay.ditto.pub:443 43.6532 -79.3832
porchlight.social 43.6532 -79.3832
nostr.notribe.net 40.8302 -74.1299
relay.endfiat.money 59.3327 18.0656
nostr.myshosholoza.co.za 52.3676 4.90414
relay.nearhood.co.uk 51.5134 -0.0890675
relay.degmods.com 50.4754 12.3683
nostr.novacisko.cz 52.2026 20.9397
prl.plus 55.7628 37.5983
bruh.samt.st 43.6532 -79.3832
strfry.openhoofd.nl 51.5717 3.70417
nostr.spicyz.io 43.6532 -79.3832
nostr.na.social 43.6532 -79.3832
nip85.nosfabrica.com 39.0997 -94.5786
premium.primal.net 43.6532 -79.3832
fanfares.nostr1.com 40.7057 -74.0136
relay.scuba323.com 40.8218 -74.45
nostr2.girino.org:443 43.6532 -79.3832
relay.mmwaves.de 48.8575 2.35138
nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
strfry.shock.network:443 39.0438 -77.4874
nostr.snowbla.de 50.4754 12.3683
nostr.spaceshell.xyz 43.6532 -79.3832
nostr.quali.chat 60.1699 24.9384
wot.utxo.one 43.6532 -79.3832
relay.mccormick.cx 52.3563 4.95714
mostro-p2p.tech 50.1109 8.68213
basspistol.org 49.0291 8.35696
ribo.nostria.app:443 43.6532 -79.3832
chorus.mikedilger.com:444 -36.8906 174.794
nostr.oxtr.dev:443 50.4754 12.3683
nostr.nodesmap.com 59.3327 18.0656
offchain.bostr.online 43.6532 -79.3832
purplerelay.com 43.6532 -79.3832
relayrs.notoshi.win:443 43.6532 -79.3832
relay.wavefunc.live 41.8781 -87.6298
relay.dreamith.to 43.6532 -79.3832
bendernostur.duckdns.org:8443 50.1109 8.68213
relay.nmail.li 50.9871 2.12554
nostr-relay.corb.net 39.6478 -104.988
relay.staging.plebeian.market:443 51.5072 -0.127586
spamspamspamspam.rest 43.6532 -79.3832
relay1.gfcom.info 13.9215 100.538
schnorr.me:443 43.6532 -79.3832
relay.lab.rytswd.com:443 49.4543 11.0746
nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
relay.nostrmap.net 60.1699 24.9384
nostr.relay.hedwig.sh 60.1699 24.9384
relay.veganostr.com:443 60.1699 24.9384
relay.wavefunc.live:443 41.8781 -87.6298
nostr.mikoshi.de 52.52 13.405
syb.lol 34.0549 -118.243
relay1.nostrchat.io 60.1699 24.9384
nostr.wecsats.io:443 43.6532 -79.3832
nostr.chaima.info:443 51.5072 -0.127586
nostr.azzamo.net:443 52.2633 21.0283
relay-can.zombi.cloudrodion.com 43.6532 -79.3832
nostr.unkn0wn.world 46.8499 9.53287
relayone.soundhsa.com 39.0997 -94.5786
x.kojira.io 43.6532 -79.3832
dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
nostrelay.circum.space 52.6907 4.8181
relay.primal.net 43.6532 -79.3832
nostr.girino.org 43.6532 -79.3832
nostr.pbfs.io 50.4754 12.3683
relay.kalcafe.xyz 37.3986 -121.964
relay.gulugulu.moe 43.6532 -79.3832
top.testrelay.top 43.6532 -79.3832
relay.kilombino.com 43.6532 -79.3832
nos.lol:443 50.4754 12.3683
nos.lol 50.4754 12.3683
relay.nostr.place:443 43.6532 -79.3832
cache.trustr.ing 43.6548 -79.3885
relay.internationalright-wing.org:443 -22.5022 -48.7114
relay.laantungir.net -19.4692 -42.5315
relay.lightning.pub:443 39.0438 -77.4874
nostr.stakey.net 52.3676 4.90414
articles.layer3.news 37.3387 -121.885
relay.wisp.talk 49.4543 11.0746
relay.pyramid.li 47.4093 8.46503
relay.typedcypher.com:443 51.5072 -0.127586
dev.relay.stream 43.6532 -79.3832
relay.bullishbounty.com 43.6532 -79.3832
nostr.mom:443 50.4754 12.3683
relay.plebeian.market:443 50.1109 8.68213
nostr.hekster.org 37.3986 -121.964
nostrcity-club.fly.dev 37.7648 -122.432
nostr.vulpem.com 49.4543 11.0746
relay-dev.gulugulu.moe:443 43.6532 -79.3832
weboftrust.libretechsystems.xyz 55.4724 9.87335
nostr-relay.corb.net:443 39.6478 -104.988
wheat.happytavern.co 43.6532 -79.3832
relay.mappingbitcoin.com 43.6532 -79.3832
testnet-relay.samt.st 40.8302 -74.1299
relay.bitmacro.cloud 43.6532 -79.3832
dev.relay.edufeed.org 49.4521 11.0767
myvoiceourstory.org 37.3598 -121.981
relay.stickeroo.is-cool.dev 37.3387 -121.885
relay.agorist.space 52.3734 4.89406
freelay.sovbit.host 60.1699 24.9384
nostr-dev.wellorder.net 45.5201 -122.99
nostr.middling.mydns.jp 35.8099 140.12
cs-relay.nostrdev.com 50.4754 12.3683
x.kojira.io:443 43.6532 -79.3832
nostrelay.circum.space:443 52.6907 4.8181
nostr.janx.com 43.6532 -79.3832
relay.mrmave.work 43.6532 -79.3832
espelho.girino.org 43.6532 -79.3832
hol.is 43.6532 -79.3832
ribo.eu.nostria.app 43.6532 -79.3832
nostr.yutakobayashi.com 43.6532 -79.3832
relay.mostro.network 40.8302 -74.1299
communities.nos.social 40.8302 -74.1299
383 relay.solife.me 43.6532 -79.3832
384 yabu.me nostr.quali.chat 35.6092 60.1699 139.73 24.9384
385 relay.islandbitcoin.com relay.vrtmrz.net 12.8498 43.6532 77.6545 -79.3832
386 nostr.wecsats.io relay-dev.gulugulu.moe:443 43.6532 -79.3832
387 nostr.tac.lol:443 relay.bullishbounty.com 47.4748 43.6532 -122.273 -79.3832
388 relay.arx-ccn.com relay.fckstate.net 50.4754 59.3293 12.3683 18.0686
389 nostrride.io nostr.rtvslawenia.com:443 37.3986 49.4543 -121.964 11.0746
390 r.0kb.io:443 relay.nostx.io 32.789 43.6532 -96.7989 -79.3832
391 herbstmeister.com relay.agorist.space 34.0549 52.3734 -118.243 4.89406
392 relay.artx.market relay.notoshi.win 43.6548 13.7829 -79.3885 100.546
393 vault.iris.to dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
394 relay.ru.ac.th relay.trotters.cc 13.7607 43.6532 100.627 -79.3832
395 temp.iris.to relay.lanavault.space 43.6532 60.1699 -79.3832 24.9384
396 social.amanah.eblessing.co public.crostr.com:443 48.1046 43.6532 11.6002 -79.3832
397 nostr-relay.nextblockvending.com nostr.stakey.net:443 47.2343 52.3676 -119.853 4.90414
398 wot.codingarena.top relay.nostr.place:443 50.4754 43.6532 12.3683 -79.3832
relay.sincensura.org 43.6532 -79.3832
399 nostr.dlcdevkit.com 40.0992 -83.1141
400 nostr.aruku.ovh 1.27994 103.849
401 satsage.xyz 37.3986 -121.964
402 strfry.apps3.slidestr.net 40.4167 -3.70329
403 nostr2.girino.org 43.6532 -79.3832
404 relay.samt.st 40.8302 -74.1299
405 articles.layer3.news 37.3387 -121.885
406 aeon.libretechsystems.xyz 55.486 9.86577
407 relay.routstr.com 59.4016 17.9455
408 relay.ohstr.com:443 43.6532 -79.3832
409 relay.lanacoin-eternity.com:443 40.8302 -74.1299
410 strfry.openhoofd.nl:443 51.5717 3.70417
411 nostr.blankfors.se 60.1699 24.9384
412 nostr-2.21crypto.ch:443 47.5356 8.73209
413 relayone.soundhsa.com:443 39.1008 -94.5811
414 relay.lab.rytswd.com:443 49.4543 11.0746
415 nostr.tagomago.me 42.3601 -71.0589
416 relay.0xchat.com:443 43.6532 -79.3832