Compare commits

..
Author SHA1 Message Date
jackandClaude Opus 4.8 14749e9454 Cap only migration-fallback media at Android's 256 fragments
The private-media preflight applied Android's 256-fragment reassembly
cap to every directed media send, including the encrypted 0x20 path.
That regressed iOS→iOS private photos in the ~120-512 KiB range, which
work today: iOS reassembles up to 10,000 fragments and main compresses
images to <=512 KiB.

Encrypted private media is only ever sent to peers that advertised the
.privateMedia capability (modern clients with the full receiver
ceiling); current Android receives private media exclusively over the
directed raw-file migration fallback. Restrict the 256-fragment cap to
that fileTransfer fallback path so capable peers use the normal ceiling.
Leaves a TODO(#1434) to negotiate an explicit per-peer fragment limit
for a future Android client that adopts 0x20 but keeps a small
reassembler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 10:53:25 +02:00
jackandjack 301144289c Discard private-media callbacks during panic 2026-07-26 10:47:23 +02:00
jackandjack 2d334835ee Fix private media Noise round-trip fixture 2026-07-26 10:47:23 +02:00
jack ebb7c4851a Make Noise generation race test deterministic 2026-07-26 10:47:23 +02:00
jack c0770bd5b4 Avoid authentication callback queue starvation 2026-07-26 10:47:23 +02:00
jack 2acd938505 Bind capability state to Noise generations 2026-07-26 10:47:23 +02:00
jack e1a53bcb3b Authenticate private media capabilities in Noise 2026-07-26 10:47:23 +02:00
jack a1bdfd10e5 Harden private media migration compatibility 2026-07-26 10:47:23 +02:00
jack df7a8f7ddd Encrypt private media before fragmentation 2026-07-26 10:47:23 +02:00
jackandGitHub 16324c819f Bind Noise sessions to claimed peer identities; require signed leaves (#1432)
Adds remote-static-key->peerID binding at Noise handshake completion (closes a mesh impersonation/MITM hole where a peer could complete a handshake under another peer's ID). Also hardens LEAVE handling to require a verified signature and suppresses relay of unverifiable leaves.
2026-07-26 10:30:55 +02:00
cd727c6867 Make panic wipe deterministic and device-bound (#1431)
* Make panic wipe deterministic and device-bound

* Scope install markers to iOS

* Harden panic recovery and service shutdown

* Invalidate queued BLE ingress during panic

* Harden panic keychain and media cleanup

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jack@deck.local>
2026-07-26 10:28:50 +02:00
GitHub Action fb8fe39713 Automated update of relay data - Sun Jul 26 06:54:16 UTC 2026 2026-07-26 06:54:16 +00:00
27 changed files with 750 additions and 5156 deletions
@@ -36,30 +36,6 @@ enum NoiseSecurityConstants {
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
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
static let sessionTimeout: TimeInterval = 86400 // 24 hours
-6
View File
@@ -13,9 +13,3 @@ enum NoiseSessionError: Error, Equatable {
case alreadyEstablished
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,6 +1,5 @@
import BitFoundation
import BitLogger
import CryptoKit
import Foundation
struct BLENoiseHandshakeHandlingResult {
@@ -34,8 +33,6 @@ struct BLENoisePacketHandlerEnvironment {
-> NoiseHandshakeProcessingResult
/// Whether any Noise session (established or pending) exists for the peer (crypto).
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).
let initiateHandshake: (PeerID) -> Void
/// Broadcasts a packet on the mesh (caller is already on the message queue).
@@ -66,36 +63,15 @@ struct BLENoisePacketHandlerEnvironment {
/// processing (with response), encrypted payload decryption and dispatch,
/// and session recovery on decrypt failure.
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 deferredLock = NSLock()
private var deferredCiphertexts: [PeerID: [DeferredCiphertext]] = [:]
private var deferredCiphertextBytes = 0
init(environment: BLENoisePacketHandlerEnvironment) {
self.environment = environment
}
/// Returns true when the handshake message was processed successfully.
/// Callers use this to distinguish an authenticated reconnect completion
/// from a rejected ordinary responder while rollback state is restored.
/// Callers use this to distinguish an authenticated replacement completion
/// from a rejected candidate while an older session remains established.
@discardableResult
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
handleHandshakeWithResult(packet, from: peerID).processed
@@ -129,23 +105,15 @@ final class BLENoisePacketHandler {
env.broadcastPacket(responsePacket)
}
// The serialized authentication callback installs transport
// state before it drains any bounded early ciphertext.
// Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time
return BLENoiseHandshakeHandlingResult(
processed: true,
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 {
// The responder was already discarded by the session manager.
// The candidate was already discarded by the session manager.
// Do not let a spoofed claimed ID trigger a fresh outbound
// handshake or recreate state for the attacker-selected ID.
SecureLogger.warning(
@@ -175,30 +143,6 @@ final class BLENoisePacketHandler {
}
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
guard let recipientID = PeerID(hexData: packet.recipientID) else {
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
@@ -240,205 +184,19 @@ final class BLENoisePacketHandler {
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
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 {
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.
// 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.
// Trigger a handshake so future messages can be decrypted.
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
} 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
// Only local/session lifecycle failures reach this path.
// This handles cases where session state got out of sync (nonce mismatch, etc.)
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
env.clearSession(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
}
}
}
}
@@ -1,40 +0,0 @@
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()
}
}
+51 -370
View File
@@ -241,7 +241,6 @@ final class BLEService: NSObject {
// that the session was established *on this current ingress link*, not
// merely that some session exists for the claimed ID. bleQueue-owned.
private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:]
private var noiseReconnectPolicy = BLENoiseReconnectPolicy()
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
// store): entries older than the cooldown are pruned on insert.
@@ -312,9 +311,6 @@ final class BLEService: NSObject {
/// May block in tests to hold the serial message queue immediately before
/// the deferred private-media admission check.
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
private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker()
@@ -707,7 +703,6 @@ final class BLEService: NSObject {
/// or advertising while the full panic transaction is incomplete.
func suspendForPanicReset() {
setPanicSuspended(true)
noisePacketHandler.resetForPanic()
gossipSyncManager?.stop()
gossipSyncManager = nil
// Stop the radio and drain CoreBluetooth's delegate queue first. A
@@ -718,11 +713,7 @@ final class BLEService: NSObject {
// Drain every receive/send submitted by callbacks that finished ahead
// of the radio stop. Later callbacks observe the closed lifecycle, and
// generation-bound handoffs that raced this barrier reject themselves.
// 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()
}
messageQueue.sync(flags: .barrier) {}
clearEmergencySessionState()
}
@@ -741,7 +732,6 @@ final class BLEService: NSObject {
gossipSyncManager?.stop()
gossipSyncManager = nil
messageQueue.sync(flags: .barrier) {
noisePacketHandler.resetForPanic()
pendingNoiseSessionQueues.removeAll()
}
@@ -774,8 +764,6 @@ final class BLEService: NSObject {
bleQueue.sync {
pendingWriteBuffers.removeAll()
noiseAuthenticatedLinkOwners.removeAll()
noiseReconnectPolicy.removeAll()
connectionScheduler.reset()
}
disconnectNotifyDebouncer.removeAll()
@@ -1073,7 +1061,6 @@ final class BLEService: NSObject {
bleQueue.sync {
linkStateStore.clearAll()
noiseAuthenticatedLinkOwners.removeAll()
noiseReconnectPolicy.removeAll()
connectionScheduler.reset()
subscriptionAnnounceLimiter.removeAll()
}
@@ -1963,15 +1950,26 @@ final class BLEService: NSObject {
// Encode once using a small per-type padding policy, then delegate by type
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
// Cross-platform private-media v1 is bounded by Android's deployed
// 256-fragment receive cap. Run the same planner the scheduler will
// use, after route application, for both encrypted and consented raw
// migration sends. Reject before reserving a transfer slot or writing
// any fragment; public media is intentionally unaffected.
// The 256-fragment ceiling exists to protect *current Android*
// receivers, which only ever receive private media over the directed
// raw-file migration fallback (they do not implement the encrypted
// 0x20 path). Encrypted private media (`noiseEncrypted`) is sent only to
// peers that advertised the `.privateMedia` capability modern clients
// 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,
let recipientPeerID = PeerID(hexData: packetToSend.recipientID),
packetToSend.type == MessageType.noiseEncrypted.rawValue
|| packetToSend.type == MessageType.fileTransfer.rawValue {
packetToSend.type == MessageType.fileTransfer.rawValue {
let compatibilityRequest = BLEOutboundFragmentTransferRequest(
packet: packetToSend,
pad: padForBLE,
@@ -2630,7 +2628,6 @@ final class BLEService: NSObject {
}
for link in departedLinks {
noiseAuthenticatedLinkOwners.removeValue(forKey: link)
noiseReconnectPolicy.endLinkEpoch(link)
}
}
_ = collectionsQueue.sync(flags: .barrier) {
@@ -2943,21 +2940,14 @@ extension BLEService: CBCentralManagerDelegate {
startScanning()
case .poweredOff:
// 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.
// Bluetooth was turned off - stop scanning and clean up connection state
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 peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID)
for state in peripheralStates {
let peripheralID = state.peripheral.identifier.uuidString
collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
noiseAuthenticatedLinkOwners.removeValue(
forKey: .peripheral(peripheralID)
)
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
central.cancelPeripheralConnection(state.peripheral)
}
_ = linkStateStore.clearPeripherals()
// Notify UI of disconnections
@@ -2970,6 +2960,7 @@ extension BLEService: CBCentralManagerDelegate {
case .unauthorized:
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
central.stopScan()
_ = linkStateStore.clearPeripherals()
case .unsupported:
@@ -3117,7 +3108,6 @@ extension BLEService: CBCentralManagerDelegate {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = linkStateStore.removePeripheral(peripheralID)
// A duplicate link can drop while the peer stays live on another
// (the dual-role central link, or a second bound link after a
@@ -3172,7 +3162,6 @@ extension BLEService: CBCentralManagerDelegate {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = linkStateStore.removePeripheral(peripheralID)
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
@@ -3280,7 +3269,6 @@ extension BLEService {
self.pendingPeripheralWrites.discardAll(for: peripheralID)
}
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = self.linkStateStore.removePeripheral(peripheralID)
self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
self.tryConnectFromQueue()
@@ -3562,28 +3550,6 @@ 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.
/// Compatibility tests use this to model Android's deployed 0x20 file
/// payload and the short-lived 0x09 prerelease payload without exposing a
@@ -3883,19 +3849,8 @@ extension BLEService: CBPeripheralManagerDelegate {
case .poweredOff:
// Bluetooth was turned off - clean up peripheral state
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
peripheral.stopAdvertising()
// 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()
subscriptionAnnounceLimiter.removeAll()
characteristic = nil
@@ -3909,6 +3864,7 @@ extension BLEService: CBPeripheralManagerDelegate {
case .unauthorized:
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
peripheral.stopAdvertising()
_ = linkStateStore.clearCentrals()
subscriptionAnnounceLimiter.removeAll()
characteristic = nil
@@ -4021,7 +3977,6 @@ extension BLEService: CBPeripheralManagerDelegate {
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID))
noiseReconnectPolicy.endLinkEpoch(.central(centralID))
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
// Ensure we're still advertising for other devices to find us
@@ -4251,40 +4206,16 @@ extension BLEService {
private func emitTransportEvent(_ event: TransportEvent) {
notifyUI { [weak self] in
_ = self?.deliverTransportEvent(event)
self?.deliverTransportEvent(event)
}
}
@MainActor
@discardableResult
private func deliverTransportEvent(_ event: TransportEvent) -> Bool {
if case .messageReceived(let message) = event {
if let synchronousDelegate =
eventDelegate as? SynchronousMessageTransportEventDelegate {
return synchronousDelegate
.didReceiveTransportMessageSynchronously(message)
}
if let eventDelegate {
eventDelegate.didReceiveTransportEvent(event)
return false
}
if let synchronousDelegate =
delegate as? SynchronousMessageTransportEventDelegate {
return synchronousDelegate
.didReceiveTransportMessageSynchronously(message)
}
}
private func deliverTransportEvent(_ event: TransportEvent) {
if let eventDelegate {
eventDelegate.didReceiveTransportEvent(event)
return true
} else {
guard let delegate else { return false }
delegate.receiveTransportEvent(event)
if case .messageReceived = event {
return false
}
return true
delegate?.receiveTransportEvent(event)
}
}
@@ -4663,48 +4594,11 @@ 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) {
service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))")
// 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?.messageQueue.async { [weak self] in
self?.handleNoisePeerAuthenticated(
peerID: peerID,
fingerprint: fingerprint,
@@ -4712,96 +4606,13 @@ extension BLEService {
)
}
}
service.onRekeyHandshakeReady = {
[weak self, weak service] peerID, initiation in
self?.messageQueue.async(flags: .barrier) {
[weak self, weak service] in
guard let self,
let service,
self.noiseService === service else {
return
}
service.onRekeyHandshakeReady = { [weak self] peerID, message in
self?.messageQueue.async { [weak self] in
guard let self else { return }
self.noteNoiseSessionCleared(for: peerID)
guard let message = service.claimHandshakeInitiation(
initiation,
for: peerID
) else {
return
}
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(
@@ -4849,16 +4660,7 @@ extension BLEService {
}
) 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
}
guard let watchdog = transition.watchdog else { return }
completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade)
schedulePrivateMediaProofTimeout(
@@ -4867,10 +4669,6 @@ extension BLEService {
sessionGeneration: generation,
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
// message 3. This callback is queued behind the handshake handler, so
@@ -5068,9 +4866,8 @@ extension BLEService {
}
return
}
guard noiseService.hasEstablishedSession(with: peerID) else {
// No established session yet - queue the payload synchronously
// before initiating a handshake
guard noiseService.hasSession(with: peerID) else {
// No session yet - queue the payload SYNCHRONOUSLY before initiating handshake
// to prevent race where fast handshake completion drains empty queue
collectionsQueue.sync(flags: .barrier) {
self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID)
@@ -6000,7 +5797,6 @@ extension BLEService {
self.pendingPeripheralWrites.discardAll(for: peripheralID)
}
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = self.linkStateStore.removePeripheral(peripheralID)
cancelled += 1
}
@@ -6066,27 +5862,12 @@ extension BLEService {
}
private func initiateNoiseHandshake(with peerID: PeerID) {
let service = noiseService
// Use NoiseEncryptionService for handshake
guard !noiseService.hasSession(with: peerID) else { return }
do {
guard let initiation = try service.initiateHandshakeIfNeeded(
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)
}
let handshakeData = try noiseService.initiateHandshake(with: peerID)
broadcastNoiseHandshake(handshakeData, to: peerID)
} catch {
SecureLogger.error("Failed to initiate handshake: \(error)")
}
@@ -6104,42 +5885,6 @@ extension BLEService {
)
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) {
// Atomically take all pending messages to process (prevents concurrent modification)
@@ -6485,12 +6230,7 @@ extension BLEService {
// MARK: Packet Reception
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
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.
// Call directly if already on messageQueue, otherwise dispatch
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
guard let lifecycleGeneration =
capturePanicLifecycleGeneration() else {
@@ -6499,8 +6239,7 @@ extension BLEService {
#if DEBUG
_test_beforeReceivePacketHandoff?()
#endif
let flags: DispatchWorkItemFlags = isNoisePacket ? .barrier : []
messageQueue.async(flags: flags) { [weak self] in
messageQueue.async { [weak self] in
guard let self,
self.isCurrentPanicLifecycleGeneration(
lifecycleGeneration
@@ -6510,34 +6249,11 @@ extension BLEService {
#if DEBUG
self._test_onReceivePacketHandoff?()
#endif
self.handleReceivedPacketOnQueue(packet, from: peerID)
self.handleReceivedPacket(packet, from: peerID)
}
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 senderID = context.senderID
let messageID = context.messageID
@@ -6724,9 +6440,6 @@ extension BLEService {
// consolidate duplicate same-role connections onto that link.
if let result, result.isVerified, result.isDirectAnnounce {
rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID)
#if DEBUG
_test_afterVerifiedDirectRebindEnqueued?()
#endif
retireRedundantPeripheralLinks(packet, to: result.peerID)
}
@@ -6760,9 +6473,11 @@ extension BLEService {
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
if result.isDirectAnnounce,
!hasCurrentNoiseAuthenticatedLink(to: result.peerID) {
// A cached session may predate this physical link.
// rebindLinkAfterVerifiedDirectAnnounce performs its atomic
// ordinary reconnect after the binding is published.
if noiseService.hasEstablishedSession(with: result.peerID) {
// A session with no surviving authenticated link is stale;
// force the current link to prove possession again.
clearNoiseSession(for: result.peerID)
}
if !noiseService.hasSession(with: result.peerID) {
initiateNoiseHandshake(with: result.peerID)
}
@@ -6791,14 +6506,7 @@ extension BLEService {
linkUUID = centralUUID
previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID)
}
guard let previousPeerID else { return }
guard previousPeerID != peerID else {
self.refreshNoiseSessionForVerifiedDirectLink(
packet,
peerID: peerID
)
return
}
guard let previousPeerID, previousPeerID != peerID else { return }
// The signature does not authenticate directness (TTL is excluded
// from signing because relays mutate it), so a "verified direct"
@@ -6825,20 +6533,12 @@ extension BLEService {
// it across an announce-driven rebind, whose direct TTL is
// replayable; the new owner must complete a fresh handshake.
self.noiseAuthenticatedLinkOwners.removeValue(forKey: link)
self.noiseReconnectPolicy.endLinkEpoch(link)
switch link {
case .peripheral(let peripheralUUID):
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
case .central(let centralUUID):
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)
self.refreshLocalTopology()
// The announce that triggered this rebind was upserted as
@@ -6930,7 +6630,6 @@ extension BLEService {
pendingPeripheralWrites.discardAll(for: uuid)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid))
noiseReconnectPolicy.endLinkEpoch(.peripheral(uuid))
_ = linkStateStore.removePeripheral(uuid)
SecureLogger.info(
"🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")",
@@ -7317,12 +7016,6 @@ extension BLEService {
packet,
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 {
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
}
@@ -7360,11 +7053,6 @@ extension BLEService {
hasNoiseSession: { [weak self] peerID in
self?.noiseService.hasSession(with: peerID) ?? false
},
isAwaitingResponderHandshakeCompletion: { [weak self] peerID in
self?.noiseService.isAwaitingResponderHandshakeCompletion(
with: peerID
) ?? false
},
initiateHandshake: { [weak self] peerID in
self?.initiateNoiseHandshake(with: peerID)
},
@@ -7378,14 +7066,7 @@ extension BLEService {
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
let result = try self.noiseService.decryptWithSessionGeneration(
payload,
from: peerID,
establishedGenerationIsReady: { generation in
self.collectionsQueue.sync {
self.privateMediaSessionGenerations[
peerID.toShort()
] == generation
}
}
from: peerID
)
return BLENoiseDecryptionResult(
plaintext: result.plaintext,
+22 -158
View File
@@ -184,17 +184,11 @@ final class NoiseEncryptionService {
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = []
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
/// Automatic rekey prepared XX message 1. The transport must claim the
/// exact attempt at its actual BLE handoff; a crossed inbound initiation
/// can invalidate the token before that point.
var onRekeyHandshakeReady:
((_ 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)?
/// Automatic rekey removed the old session and produced XX message 1.
/// The transport must clear session-scoped state and put these exact bytes
/// on the wire; merely reporting "handshake required" strands the partial
/// initiator session because a second initiate call sees it already exists.
var onRekeyHandshakeReady: ((_ peerID: PeerID, _ message: Data) -> Void)?
// Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
@@ -225,17 +219,7 @@ final class NoiseEncryptionService {
}
}
init(
keychain: KeychainManagerProtocol,
ordinaryHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryHandshakeTimeout,
ordinaryResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
recentInitiatorCompletionGracePeriod: TimeInterval =
NoiseSecurityConstants.recentInitiatorCompletionGracePeriod,
ordinaryReconnectRollbackCooldown: TimeInterval =
NoiseSecurityConstants.ordinaryReconnectRollbackCooldown
) {
init(keychain: KeychainManagerProtocol) {
self.keychain = keychain
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
@@ -325,17 +309,7 @@ final class NoiseEncryptionService {
self.signingPublicKey = signingKey.publicKey
// Initialize session manager
self.sessionManager = NoiseSessionManager(
localStaticKey: staticIdentityKey,
keychain: keychain,
ordinaryHandshakeTimeout: ordinaryHandshakeTimeout,
ordinaryResponderHandshakeTimeout:
ordinaryResponderHandshakeTimeout,
recentInitiatorCompletionGracePeriod:
recentInitiatorCompletionGracePeriod,
ordinaryReconnectRollbackCooldown:
ordinaryReconnectRollbackCooldown
)
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
// Set up session callbacks
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
@@ -345,12 +319,6 @@ final class NoiseEncryptionService {
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
startRekeyTimer()
@@ -714,90 +682,6 @@ final class NoiseEncryptionService {
let handshakeData = try sessionManager.initiateHandshake(with: peerID)
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
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
@@ -853,13 +737,6 @@ final class NoiseEncryptionService {
func hasSession(with peerID: PeerID) -> Bool {
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
@@ -927,37 +804,27 @@ final class NoiseEncryptionService {
func decryptWithSessionGeneration(
_ data: Data,
from peerID: PeerID,
establishedGenerationIsReady: (UUID) -> Bool = { _ in true }
from peerID: PeerID
) throws -> (plaintext: Data, sessionGeneration: UUID) {
// Standard transport ciphertext has 20 bytes of nonce/tag overhead.
// A larger ciphertext is admitted only up to the framed-file ceiling;
// A larger candidate is admitted only up to the framed-file ceiling;
// after authenticated decryption it must prove it is `.privateFile`.
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
let isAdmittedCiphertext = isStandardCiphertext
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(data)
guard isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) else {
throw NoiseSecurityError.messageTooLarge
}
// A quarantined transport is deliberately unavailable for outbound
// state, but remains receive-only until the responder proves identity
// or the bounded rollback restores it.
guard sessionManager.hasReceiveSession(for: peerID) else {
// Check rate limit
guard rateLimiter.allowMessage(from: peerID) else {
throw NoiseSecurityError.rateLimitExceeded
}
// Check if we have an established session
guard hasEstablishedSession(with: peerID) else {
throw NoiseEncryptionError.sessionNotEstablished
}
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
}
}
)
let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID)
if !isStandardCiphertext {
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
@@ -1076,9 +943,9 @@ final class NoiseEncryptionService {
}
private func initiateAutomaticRekey(for peerID: PeerID) throws {
let initiation = try sessionManager.initiateRekey(for: peerID)
let handshakeMessage = try sessionManager.initiateRekey(for: peerID)
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
onRekeyHandshakeReady?(peerID, initiation)
onRekeyHandshakeReady?(peerID, handshakeMessage)
onHandshakeRequired?(peerID)
}
@@ -1174,9 +1041,6 @@ struct NoiseMessage: Codable {
enum NoiseEncryptionError: Error {
case handshakeRequired
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
/// deleted after its grace window, or wiped in a panic).
case unknownPrekey
-7
View File
@@ -101,13 +101,6 @@ protocol TransportEventDelegate: AnyObject {
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
}
/// Optional typed-event contract for sinks that can synchronously decide
/// whether an inbound message was accepted.
protocol SynchronousMessageTransportEventDelegate: TransportEventDelegate {
@MainActor
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool
}
protocol Transport: AnyObject {
// Event sink
var delegate: BitchatDelegate? { get set }
@@ -100,14 +100,9 @@ final class ChatPeerListCoordinator: @unchecked Sendable {
func didUpdatePeerList(_ peers: [PeerID]) {
Task { @MainActor [weak self] in
self?.didUpdatePeerListSynchronously(peers)
self?.handlePeerListUpdate(peers)
}
}
@MainActor
func didUpdatePeerListSynchronously(_ peers: [PeerID]) {
handlePeerListUpdate(peers)
}
}
private extension ChatPeerListCoordinator {
@@ -163,18 +163,19 @@ final class ChatTransportEventCoordinator {
}
func didReceiveMessage(_ message: BitchatMessage) {
runOnMain { [self] context in
handleReceivedMessage(message, in: context)
}
}
runOnMain { context in
guard !context.isMessageBlocked(message) else { return }
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
/// Typed transport events already arrive on the main actor. Handle them
/// synchronously so observers see the ConversationStore mutation before
/// the transport completes delivery.
@MainActor
@discardableResult
func didReceiveMessageSynchronously(_ message: BitchatMessage) -> Bool {
handleReceivedMessage(message, in: context)
if message.isPrivate {
context.handlePrivateMessage(message)
} else {
context.handlePublicMessage(message)
}
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
}
func didReceivePublicMessage(
@@ -184,34 +185,26 @@ final class ChatTransportEventCoordinator {
timestamp: Date,
messageID: String?
) {
runOnMain { [self] context in
handlePublicMessage(
from: peerID,
nickname: nickname,
content: content,
runOnMain { context in
let normalized = content.trimmed
let mentions = context.parseMentions(from: normalized)
let message = BitchatMessage(
id: messageID,
sender: nickname,
content: normalized,
timestamp: timestamp,
messageID: messageID,
in: context
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: mentions.isEmpty ? nil : mentions
)
}
}
@MainActor
func didReceivePublicMessageSynchronously(
from peerID: PeerID,
nickname: String,
content: String,
timestamp: Date,
messageID: String?
) {
handlePublicMessage(
from: peerID,
nickname: nickname,
content: content,
timestamp: timestamp,
messageID: messageID,
in: context
)
context.handlePublicMessage(message)
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
}
func didReceiveNoisePayload(
@@ -231,134 +224,59 @@ final class ChatTransportEventCoordinator {
}
}
@MainActor
func didReceiveNoisePayloadSynchronously(
from peerID: PeerID,
type: NoisePayloadType,
payload: Data,
timestamp: Date
) {
handleNoisePayload(
from: peerID,
type: type,
payload: payload,
timestamp: timestamp,
in: context
)
}
func didConnectToPeer(_ peerID: PeerID) {
runOnMain { [weak self] _ in
self?.didConnectToPeerSynchronously(peerID)
}
}
@MainActor
func didConnectToPeerSynchronously(_ peerID: PeerID) {
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
context.isConnected = true
context.registerEphemeralSession(peerID: peerID)
context.notifyUIChanged()
runOnMain { context in
context.isConnected = true
context.registerEphemeralSession(peerID: peerID)
context.notifyUIChanged()
if let peer = context.unifiedPeer(for: peerID) {
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
context.cacheStablePeerID(stablePeerID, for: peerID)
if let peer = context.unifiedPeer(for: peerID) {
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
context.cacheStablePeerID(stablePeerID, for: peerID)
}
context.flushRouterOutbox(for: peerID)
context.retryCourierDeposits(via: peerID)
}
context.flushRouterOutbox(for: peerID)
context.retryCourierDeposits(via: peerID)
}
func didDisconnectFromPeer(_ peerID: PeerID) {
runOnMain { [weak self] _ in
self?.didDisconnectFromPeerSynchronously(peerID)
}
}
@MainActor
func didDisconnectFromPeerSynchronously(_ peerID: PeerID) {
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
context.removeEphemeralSession(peerID: peerID)
runOnMain { context in
context.removeEphemeralSession(peerID: peerID)
var stablePeerID = context.cachedStablePeerID(for: peerID)
if stablePeerID == nil,
let key = context.noiseSessionPublicKeyData(for: peerID) {
let derivedPeerID = PeerID(hexData: key)
context.cacheStablePeerID(derivedPeerID, for: peerID)
stablePeerID = derivedPeerID
var stablePeerID = context.cachedStablePeerID(for: peerID)
if stablePeerID == nil,
let key = context.noiseSessionPublicKeyData(for: peerID) {
let derivedPeerID = PeerID(hexData: key)
context.cacheStablePeerID(derivedPeerID, for: peerID)
stablePeerID = derivedPeerID
}
if let currentPeerID = context.selectedPrivateChatPeer,
currentPeerID == peerID,
let stablePeerID {
self.migrateSelectedConversationIfNeeded(
from: peerID,
to: stablePeerID,
in: context
)
}
let receiptIDs = context.privateMessages(for: peerID)
.filter { $0.senderPeerID == peerID }
.map(\.id)
context.unmarkReadReceiptsSent(receiptIDs)
context.notifyUIChanged()
}
if let currentPeerID = context.selectedPrivateChatPeer,
currentPeerID == peerID,
let stablePeerID {
migrateSelectedConversationIfNeeded(
from: peerID,
to: stablePeerID,
in: context
)
}
let receiptIDs = context.privateMessages(for: peerID)
.filter { $0.senderPeerID == peerID }
.map(\.id)
context.unmarkReadReceiptsSent(receiptIDs)
context.notifyUIChanged()
}
}
private extension ChatTransportEventCoordinator {
@MainActor
func handlePublicMessage(
from peerID: PeerID,
nickname: String,
content: String,
timestamp: Date,
messageID: String?,
in context: any ChatTransportEventContext
) {
let normalized = content.trimmed
let mentions = context.parseMentions(from: normalized)
let message = BitchatMessage(
id: messageID,
sender: nickname,
content: normalized,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: mentions.isEmpty ? nil : mentions
)
context.handlePublicMessage(message)
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
@MainActor
@discardableResult
func handleReceivedMessage(
_ message: BitchatMessage,
in context: any ChatTransportEventContext
) -> Bool {
guard !context.isMessageBlocked(message) else { return false }
guard !message.content.trimmed.isEmpty || message.isPrivate else { return false }
if message.isPrivate {
context.handlePrivateMessage(message)
} else {
context.handlePublicMessage(message)
}
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
return true
}
func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) {
Task { @MainActor [weak context = self.context] in
guard let context else { return }
+2 -76
View File
@@ -112,7 +112,7 @@ struct PanicNetworkLifecycle {
/// Manages the application state and business logic for BitChat.
/// Acts as the primary coordinator between UI components and backend services,
/// implementing the BitchatDelegate protocol to handle network events.
final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessageTransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
// Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled)
typealias Patterns = MessageFormattingEngine.Patterns
@@ -1701,81 +1701,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
@MainActor
func didReceiveTransportEvent(_ event: TransportEvent) {
switch event {
case .messageReceived(let message):
_ = didReceiveTransportMessageSynchronously(message)
case let .publicMessageReceived(
peerID,
nickname,
content,
timestamp,
messageID
):
transportEventCoordinator.didReceivePublicMessageSynchronously(
from: peerID,
nickname: nickname,
content: content,
timestamp: timestamp,
messageID: messageID
)
case let .noisePayloadReceived(peerID, type, payload, timestamp):
transportEventCoordinator.didReceiveNoisePayloadSynchronously(
from: peerID,
type: type,
payload: payload,
timestamp: timestamp
)
case let .groupMessageReceived(payload, timestamp):
groupCoordinator.handleGroupMessagePayload(
payload,
timestamp: timestamp
)
case let .publicVoiceFrameReceived(
peerID,
nickname,
payload,
timestamp
):
liveVoiceCoordinator.handlePublicVoiceFramePayload(
from: peerID,
nickname: nickname,
payload: payload,
timestamp: timestamp
)
case .peerConnected(let peerID):
transportEventCoordinator.didConnectToPeerSynchronously(peerID)
case .peerDisconnected(let peerID):
transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID)
case .peerListUpdated(let peers):
peerListCoordinator.didUpdatePeerListSynchronously(peers)
// A peer-list update follows every verified announce, which is
// where a peer's `.vouch` capability actually arrives.
vouchCoordinator.peersUpdated(peers)
case .peerSnapshotsUpdated:
break
case let .messageDeliveryStatusUpdated(messageID, status):
deliveryCoordinator.didUpdateMessageDeliveryStatus(
messageID,
status: status
)
case .bluetoothStateUpdated(let state):
updateBluetoothState(state)
}
}
@MainActor
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool {
transportEventCoordinator.didReceiveMessageSynchronously(message)
receiveTransportEvent(event)
}
func didReceiveMessage(_ message: BitchatMessage) {
+11 -225
View File
@@ -502,26 +502,14 @@ struct BLEServiceCoreTests {
)
let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
#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)
let announcePaused = await TestHelpers.waitUntil(
{ rebindGate.hasPaused },
let rebound = await TestHelpers.waitUntil(
{ ble._test_centralBinding(attackerLink) == victimPeerID },
timeout: TestConstants.longTimeout
)
try #require(announcePaused)
// 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()
#expect(rebound)
#expect(ble.canDeliverSecurely(to: victimPeerID))
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = { outbound.record($0) }
@@ -549,26 +537,20 @@ struct BLEServiceCoreTests {
// Preserve a working victim session while an unauthenticated
// replacement candidate arrives on a newly bound physical link.
// 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 message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID)
let message2 = try #require(
try ble._test_noiseProcessHandshakeMessage(
from: victimPeerID,
message: message1
)
try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
)
let message3 = try #require(
try victim.processHandshakeMessage(
from: ble.myPeerID,
try ble._test_noiseProcessHandshakeMessage(
from: victimPeerID,
message: message2
)
)
_ = try ble._test_noiseProcessHandshakeMessage(
from: victimPeerID,
_ = try victim.processHandshakeMessage(
from: ble.myPeerID,
message: message3
)
await ble._test_drainNoiseMessagePipeline()
#expect(ble.canDeliverSecurely(to: victimPeerID))
let centralUUID = "central-replacement-xx-message-one"
@@ -632,168 +614,7 @@ struct BLEServiceCoreTests {
for: 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)
#expect(ble.canDeliverSecurely(to: victimPeerID))
}
/// A legitimate rotation announce necessarily arrives on a link still
@@ -1122,40 +943,6 @@ private final class OutboundPacketTap {
lock.lock(); defer { lock.unlock() }
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 {
@@ -1289,7 +1076,6 @@ private final class PublicCaptureDelegate: BitchatDelegate {
defer { lock.unlock() }
return publicMessages
}
}
@MainActor
@@ -104,20 +104,6 @@ private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMess
/// no `ChatViewModel`.
struct ChatPeerListCoordinatorContextTests {
@Test @MainActor
func synchronousPeerListUpdate_appliesBeforeReturning() {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let peerID = PeerID(str: "0011223344556677")
coordinator.didUpdatePeerListSynchronously([peerID])
#expect(context.isConnected)
#expect(context.registeredEphemeralSessions == [peerID])
#expect(context.updateEncryptionStatusForPeersCount == 1)
#expect(context.cleanupOldReadReceiptsCount == 1)
}
@Test @MainActor
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
let context = MockChatPeerListContext()
@@ -242,30 +242,6 @@ struct ChatTransportEventCoordinatorContextTests {
#expect(context.hapticMessageIDs == ["pm", "pub"])
}
@Test @MainActor
func synchronousMessageDeliveryReportsAcceptanceForAckGating() {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let blocked = makeMessage(
id: "blocked-private-media",
isPrivate: true,
senderPeerID: peerID
)
context.blockedMessageIDs = [blocked.id]
#expect(coordinator.didReceiveMessageSynchronously(blocked) == false)
#expect(context.handledPrivateMessages.isEmpty)
let accepted = makeMessage(
id: "accepted-private-media",
isPrivate: true,
senderPeerID: peerID
)
#expect(coordinator.didReceiveMessageSynchronously(accepted) == true)
#expect(context.handledPrivateMessages.map(\.id) == [accepted.id])
}
@Test @MainActor
func didReceivePublicMessage_trimsContentAndParsesMentions() async {
let context = MockChatTransportEventContext()
@@ -319,32 +295,6 @@ struct ChatTransportEventCoordinatorContextTests {
#expect(context.notifyUIChangedCount == 2)
}
@Test @MainActor
func synchronousConnectAndDisconnect_applyBeforeReturning() {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "2233445566778899")
let incoming = makeMessage(
id: "incoming-receipt",
isPrivate: true,
senderPeerID: peerID
)
context.privateChats[peerID] = [incoming]
coordinator.didConnectToPeerSynchronously(peerID)
#expect(context.isConnected)
#expect(context.registeredEphemeralSessions == [peerID])
#expect(context.flushedOutboxPeerIDs == [peerID])
#expect(context.courierRetryPeerIDs == [peerID])
coordinator.didDisconnectFromPeerSynchronously(peerID)
#expect(context.removedEphemeralSessions == [peerID])
#expect(context.unmarkedReadReceiptBatches == [[incoming.id]])
#expect(context.notifyUIChangedCount == 2)
}
@Test @MainActor
func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async {
let context = MockChatTransportEventContext()
-74
View File
@@ -864,80 +864,6 @@ struct ChatViewModelPublicConversationTests {
struct ChatViewModelPeerTests {
@Test @MainActor
func typedPeerLifecycleEvents_applyBeforeReturning() {
let (viewModel, _) = makeTestableViewModel()
let peerID = PeerID(str: "1122334455667788")
let incoming = BitchatMessage(
id: "typed-peer-incoming",
sender: "Alice",
content: "Hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: peerID
)
viewModel.seedPrivateChat([incoming], for: peerID)
viewModel.sentReadReceipts.insert(incoming.id)
viewModel.didReceiveTransportEvent(.peerConnected(peerID))
#expect(viewModel.isConnected)
viewModel.didReceiveTransportEvent(.peerDisconnected(peerID))
#expect(!viewModel.sentReadReceipts.contains(incoming.id))
}
@Test @MainActor
func typedPeerListDeliveryAndBluetoothEvents_applyBeforeReturning() {
let (viewModel, transport) = makeTestableViewModel()
let stalePeer = PeerID(str: "00000000000000a2")
let deliveryPeer = PeerID(str: "0102030405060708")
let messageID = "typed-delivery-status"
let delivered = DeliveryStatus.delivered(
to: "Alice",
at: Date(timeIntervalSince1970: 1_234)
)
let outgoing = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "On the way",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Alice",
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
viewModel.markPrivateChatUnread(stalePeer)
viewModel.seedPrivateChat([outgoing], for: deliveryPeer)
viewModel.didReceiveTransportEvent(.peerListUpdated([]))
#expect(!viewModel.unreadPrivateMessages.contains(stalePeer))
viewModel.didReceiveTransportEvent(
.messageDeliveryStatusUpdated(
messageID: messageID,
status: delivered
)
)
#expect(
viewModel.privateMessages(for: deliveryPeer).first?.deliveryStatus
== delivered
)
viewModel.didReceiveTransportEvent(.bluetoothStateUpdated(.poweredOff))
#expect(viewModel.bluetoothState == .poweredOff)
#expect(viewModel.showBluetoothAlert)
// Snapshot events belong to TransportPeerEventsDelegate and are
// intentionally ignored at this typed sink.
viewModel.didReceiveTransportEvent(.peerSnapshotsUpdated([]))
#expect(viewModel.bluetoothState == .poweredOff)
}
@Test @MainActor
func didConnectToPeer_notifiesDelegate() async {
let (_, transport) = makeTestableViewModel()
@@ -741,7 +741,7 @@ struct PrivateMediaEndToEndTests {
}
@Test
func encryptedAndConsentedLegacySendsRejectAboveAndroidFragmentCap() async throws {
func consentedLegacySendRejectsAboveAndroidFragmentCapButEncryptedDoesNot() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-fragment-cap-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
@@ -790,14 +790,24 @@ struct PrivateMediaEndToEndTests {
allowLegacyFallback: true
)
let bothRejected = await TestHelpers.waitUntil(
{ rejections.contains(encryptedID) && rejections.contains(legacyID) },
// The directed raw-file migration fallback (Android-style peer without
// the .privateMedia capability) still honors the 256-fragment ceiling.
let legacyRejected = await TestHelpers.waitUntil(
{ rejections.contains(legacyID) },
timeout: TestConstants.longTimeout
)
#expect(bothRejected)
#expect(rejections.reason(for: encryptedID)?.contains("256") == true)
#expect(legacyRejected)
#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
}
@@ -12,7 +12,6 @@ import Testing
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat
@Suite("Integration Tests", .serialized)
struct IntegrationTests {
private var helper = TestNetworkHelper()
@@ -273,18 +272,8 @@ struct IntegrationTests {
// Re-establish Noise handshake explicitly via managers
do {
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
let m2 = try #require(
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
)
)
let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)!
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: m3)
} catch {
Issue.record("Failed to re-establish Noise session after restart: \(error)")
@@ -8,7 +8,6 @@
import Foundation
import CryptoKit
import Testing
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat
@@ -28,14 +27,9 @@ final class TestNetworkHelper {
node.mockNickname = name
nodes[name] = node
// This synchronous helper directly drives all three XX messages and
// has no transport callback loop for delayed collision recovery.
// Create/replace Noise manager for this node
let key = Curve25519.KeyAgreement.PrivateKey()
noiseManagers[name] = NoiseSessionManager(
localStaticKey: key,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
return node
}
@@ -114,18 +108,8 @@ final class TestNetworkHelper {
let peer2ID = nodes[node2]?.peerID else { return }
let msg1 = try manager1.initiateHandshake(with: peer2ID)
let msg2 = try #require(
try manager2.handleIncomingHandshake(
from: peer1ID,
message: msg1
)
)
let msg3 = try #require(
try manager1.handleIncomingHandshake(
from: peer2ID,
message: msg2
)
)
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
}
}
+4 -19
View File
@@ -5,7 +5,7 @@ import BitFoundation
@testable import bitchat
@Suite("Noise Coverage Tests", .serialized)
@Suite("Noise Coverage Tests")
struct NoiseCoverageTests {
private let keychain = MockKeychain()
private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey()
@@ -633,16 +633,8 @@ struct NoiseCoverageTests {
)
let replacementSession = try #require(manager.getSession(for: alicePeerID))
let localPeerID = PeerID(
publicKey: aliceStaticKey.publicKey.rawRepresentation
)
if localPeerID < alicePeerID {
#expect(replacementResponse == nil)
#expect(replacementSession === restartedSession)
} else {
#expect(replacementResponse != nil)
#expect(replacementSession !== restartedSession)
}
#expect(replacementResponse != nil)
#expect(replacementSession !== restartedSession)
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
@@ -662,13 +654,7 @@ struct NoiseCoverageTests {
try aliceManager.initiateHandshake(with: alicePeerID)
}
let rekeyInitiation = try aliceManager.initiateRekey(for: alicePeerID)
let rekeyHandshake = try #require(
aliceManager.claimHandshakeInitiation(
rekeyInitiation,
for: alicePeerID
)
)
let rekeyHandshake = try aliceManager.initiateRekey(for: alicePeerID)
#expect(!rekeyHandshake.isEmpty)
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
@@ -681,7 +667,6 @@ struct NoiseCoverageTests {
let aliceManager = NoiseSessionManager(
localStaticKey: aliceStaticKey,
keychain: keychain,
recentInitiatorCompletionGracePeriod: 0,
sessionFactory: { peerID, role in
BlockingDecryptNoiseSession(
peerID: peerID,
+18 -57
View File
@@ -357,18 +357,8 @@ struct NoiseProtocolTests {
@Test func peerRestartDetection() throws {
// Establish initial sessions
// This test explicitly drives the three synchronous XX messages and
// 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
)
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -387,24 +377,15 @@ struct NoiseProtocolTests {
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
// Alice should accept the new handshake (clearing old session)
let newHandshake2 = try #require(
try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: newHandshake1
)
)
let newHandshake2 = try aliceManager.handleIncomingHandshake(
from: alicePeerID, message: newHandshake1)
#expect(newHandshake2 != nil)
// Complete the new handshake
let newHandshake3 = try #require(
try bobManagerRestarted.handleIncomingHandshake(
from: bobPeerID,
message: newHandshake2
)
)
_ = try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: newHandshake3
)
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(
from: bobPeerID, message: newHandshake2!)
#expect(newHandshake3 != nil)
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
// Should be able to exchange messages with new sessions
let testMessage = Data("After restart".utf8)
@@ -562,18 +543,8 @@ struct NoiseProtocolTests {
@Test func nonceDesynchronizationCausesRehandshake() throws {
// Test that nonce desynchronization leads to proper re-handshake
// This test explicitly drives the three synchronous XX messages and
// 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
)
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -601,25 +572,15 @@ struct NoiseProtocolTests {
let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
// Alice should accept despite having a "valid" (but desynced) session
let rehandshake2 = try #require(
try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: rehandshake1
),
"Alice should accept handshake to fix desync"
)
let rehandshake2 = try aliceManager.handleIncomingHandshake(
from: alicePeerID, message: rehandshake1)
#expect(rehandshake2 != nil, "Alice should accept handshake to fix desync")
// Complete handshake
let rehandshake3 = try #require(
try bobManager.handleIncomingHandshake(
from: bobPeerID,
message: rehandshake2
)
)
_ = try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: rehandshake3
)
let rehandshake3 = try bobManager.handleIncomingHandshake(
from: bobPeerID, message: rehandshake2!)
#expect(rehandshake3 != nil)
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
// Verify communication works again
let testResynced = Data("Resynced".utf8)
@@ -1,5 +1,4 @@
import BitFoundation
import CryptoKit
import Foundation
import Testing
@testable import bitchat
@@ -12,11 +11,7 @@ struct BLENoisePacketHandlerTests {
var handshakeAuthenticated = false
var hasSession = false
let sessionGeneration = UUID()
var awaitingResponderHandshake = false
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 hasSessionQueries: [PeerID] = []
@@ -39,12 +34,11 @@ struct BLENoisePacketHandlerTests {
recorder: Recorder,
now: Date = Date(timeIntervalSince1970: 1_000)
) -> BLENoisePacketHandler {
recorder.currentDate = now
let environment = BLENoisePacketHandlerEnvironment(
localPeerID: { [localPeerID] in localPeerID },
localPeerIDData: { [localPeerIDData] in localPeerIDData },
messageTTL: TransportConfig.messageTTLDefault,
now: { recorder.currentDate },
now: { now },
processHandshakeMessage: { peerID, message in
recorder.processedHandshakes.append((peerID, message))
return NoiseHandshakeProcessingResult(
@@ -57,9 +51,6 @@ struct BLENoisePacketHandlerTests {
recorder.hasSessionQueries.append(peerID)
return recorder.hasSession
},
isAwaitingResponderHandshakeCompletion: { _ in
recorder.awaitingResponderHandshake
},
initiateHandshake: { peerID in
recorder.initiatedHandshakes.append(peerID)
recorder.events.append("initiateHandshake")
@@ -91,120 +82,6 @@ struct BLENoisePacketHandlerTests {
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
@Test
@@ -321,24 +198,6 @@ struct BLENoisePacketHandlerTests {
#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
@Test
@@ -487,799 +346,6 @@ struct BLENoisePacketHandlerTests {
#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 {
BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
@@ -1294,15 +360,14 @@ struct BLENoisePacketHandlerTests {
private func makeEncryptedPacket(
recipientID: Data?,
timestamp: UInt64 = 900_000,
payload: Data = Data([0xC0, 0xFF, 0xEE])
timestamp: UInt64 = 900_000
) -> BitchatPacket {
BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: remotePeerID.id) ?? Data(),
recipientID: recipientID,
timestamp: timestamp,
payload: payload,
payload: Data([0xC0, 0xFF, 0xEE]),
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
@@ -1,115 +0,0 @@
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
+13 -5
View File
@@ -100,8 +100,16 @@ temporary `0x09` alias. Ordinary Noise messages retain their 64 KiB limit.
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,
well below iOS's absolute inbound ceiling. Private-media v1 therefore runs the
actual route-aware BLE fragment planner before both encrypted and consented
legacy sends and rejects any plan above 256 fragments with a visible failure.
This fragment-count contract, rather than a guessed byte threshold, stays
correct as route overhead changes.
well below iOS's absolute inbound ceiling. That cap only applies to those
receivers, which take private media exclusively over the directed raw-file
migration fallback (they do not implement the encrypted `0x20` path).
Private-media v1 therefore runs the actual route-aware BLE fragment planner
before a consented legacy send and rejects any plan above 256 fragments with a
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,11 +28,6 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
/// before outer BLE fragmentation. Peers that omit this bit require the
/// signed directed raw-file migration fallback.
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
/// empty set is distinguishable from an absent TLV.
@@ -20,10 +20,6 @@ struct PeerCapabilitiesTests {
let high = PeerCapabilities(rawValue: 1 << 9)
#expect(high.encoded() == Data([0x00, 0x02]))
#expect(
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
== Data([0x00, 0x04])
)
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
#expect(PeerCapabilities(encoded: all.encoded()) == all)
+433 -407
View File
@@ -1,416 +1,442 @@
Relay URL,Latitude,Longitude
relay.lab.rytswd.com,49.4543,11.0746
relay.paulstephenborile.com:443,49.4543,11.0746
relay.binaryrobot.com,43.6532,-79.3832
nostr-2.21crypto.ch,47.5356,8.73209
spookstr2.nostr1.com:443,40.7057,-74.0136
fanfares.nostr1.com:443,40.7057,-74.0136
x.kojira.io,43.6532,-79.3832
freelay.sovbit.host,60.1699,24.9384
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
testnet.samt.st,43.6532,-79.3832
relay.angor.io,48.1046,11.6002
relay-arg.zombi.cloudrodion.com,1.35208,103.82
nostr-01.yakihonne.com,1.32123,103.695
nostr-relay.cbrx.io,43.6532,-79.3832
relay.guggero.org,46.5971,9.59652
nostr.snowbla.de,60.1699,24.9384
relay.zone667.com,60.1699,24.9384
nexus.libernet.app:443,43.6532,-79.3832
relay.islandbitcoin.com,12.8498,77.6545
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr-relay.xbytez.io,50.6924,3.20113
kasztanowa.bieda.it,43.6532,-79.3832
nostrcity-club.fly.dev,37.7648,-122.432
relay.typedcypher.com,51.5072,-0.127586
nostr.na.social:443,43.6532,-79.3832
relay.laantungir.net,-19.4692,-42.5315
relay-dev.satlantis.io:443,40.8302,-74.1299
rilo.nostria.app,43.6532,-79.3832
nostr.hekster.org:443,37.3986,-121.964
nostr-relay.amethyst.name:443,39.0067,-77.4291
chat-relay.zap-work.com:443,43.6532,-79.3832
relay.edufeed.org,49.4521,11.0767
syb.lol:443,43.6532,-79.3832
relay.sigit.io,50.4754,12.3683
nostr-relay.xbytez.io:443,50.6924,3.20113
relay.wavefunc.live,41.8781,-87.6298
nostr.sathoarder.com,48.5734,7.75211
myvoiceourstory.org,37.3598,-121.981
relay.underorion.se,50.1109,8.68213
nostr.data.haus,50.4754,12.3683
relay.erybody.com,41.4513,-81.7021
espelho.girino.org,43.6532,-79.3832
nostr.pbfs.io:443,50.4754,12.3683
wot.dergigi.com,64.1476,-21.9392
nostr.bitcoiner.social:443,47.6743,-117.112
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.gulugulu.moe,43.6532,-79.3832
nostr.spicyz.io,43.6532,-79.3832
relay.cypherflow.ai,48.8575,2.35138
treuzkas.branruz.com,48.8575,2.35138
relay1.nostrchat.io,60.1699,24.9384
kotukonostr.onrender.com,37.7775,-122.397
nostr.plantroon.com,50.1013,8.62643
nostr.davenov.com,50.1109,8.68213
node.kommonzenze.de,49.4521,11.0767
relay2.veganostr.com,60.1699,24.9384
armada.sharegap.net,43.6532,-79.3832
wot.makenomistakes.ca,43.7064,-79.3986
nostr.2b9t.xyz:443,34.0549,-118.243
relay.libernet.app:443,43.6532,-79.3832
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.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
relay.fundstr.me,42.3601,-71.0589
nostr.2b9t.xyz,34.0549,-118.243
nostr.data.haus:443,50.4754,12.3683
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
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
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
relay.kaleidoswap.com,50.8476,4.35717
relay.libernet.app:443,43.6532,-79.3832
relay.homeinhk.xyz,35.694,139.754
relay.manneken.brussels,49.4543,11.0746
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
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
relay2.angor.io:443,48.1046,11.6002
social.amanah.eblessing.co,48.1046,11.6002
nostr.stakey.net,52.3676,4.90414
adre.su,59.9311,30.3609
bitcoinostr.duckdns.org,41.1976,1.11167
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
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
nostr.data.haus,50.4754,12.3683
wot.sudocarlos.com,43.6532,-79.3832
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
shu01.shugur.net,21.4902,39.2246
relay.gulugulu.moe:443,43.6532,-79.3832
relay2.angor.io:443,48.1046,11.6002
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
nostr.wild-vibes.ts.net,48.8566,2.35222
relay.nostr.com,50.1109,8.68213
nostr.iskarion.ddns.net,43.3076,-2.95421
relay-dev.satlantis.io,39.0438,-77.4874
relay.sovereignresonance.org,48.9006,2.25929
relay.nostrian-conquest.com,41.223,-111.974
relay.aidatanorge.no,43.6532,-79.3832
strfry.apps3.slidestr.net,40.4167,-3.70329
relay.klabo.world,47.2343,-119.853
nostr.data.haus:443,50.4754,12.3683
testr.nymble.world,40.8054,-74.0241
relay.inforsupports.com,43.6532,-79.3832
relay.nostrmap.net:443,60.1699,24.9384
nostr.stakey.net:443,52.3676,4.90414
dev-relay.nostreon.com,60.1699,24.9384
nostr.islandarea.net:443,35.4669,-97.6473
bucket.coracle.social,37.7775,-122.397
blossom.gnostr.cloud,43.6532,-79.3832
relay.solife.me,43.6532,-79.3832
nostr.quali.chat,60.1699,24.9384
relay.vrtmrz.net,43.6532,-79.3832
relay-dev.gulugulu.moe:443,43.6532,-79.3832
relay.bullishbounty.com,43.6532,-79.3832
relay.fckstate.net,59.3293,18.0686
nostr.rtvslawenia.com:443,49.4543,11.0746
relay.nostx.io,43.6532,-79.3832
relay.agorist.space,52.3734,4.89406
relay.notoshi.win,13.7829,100.546
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
relay.trotters.cc,43.6532,-79.3832
relay.lanavault.space,60.1699,24.9384
public.crostr.com:443,43.6532,-79.3832
nostr.stakey.net:443,52.3676,4.90414
relay.nostr.place:443,43.6532,-79.3832
nostr.dlcdevkit.com,40.0992,-83.1141
nostr.aruku.ovh,1.27994,103.849
satsage.xyz,37.3986,-121.964
strfry.apps3.slidestr.net,40.4167,-3.70329
nostr2.girino.org,43.6532,-79.3832
relay.samt.st,40.8302,-74.1299
articles.layer3.news,37.3387,-121.885
aeon.libretechsystems.xyz,55.486,9.86577
relay.routstr.com,59.4016,17.9455
relay.ohstr.com:443,43.6532,-79.3832
relay.lanacoin-eternity.com:443,40.8302,-74.1299
strfry.openhoofd.nl:443,51.5717,3.70417
nostr.blankfors.se,60.1699,24.9384
nostr-2.21crypto.ch:443,47.5356,8.73209
relayone.soundhsa.com:443,39.1008,-94.5811
relay.lab.rytswd.com:443,49.4543,11.0746
nostr.rtvslawenia.com,49.4543,11.0746
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
relay.0xchat.com:443,43.6532,-79.3832
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
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
yabu.me,35.6092,139.73
relay.islandbitcoin.com,12.8498,77.6545
nostr.wecsats.io,43.6532,-79.3832
nostr.tac.lol:443,47.4748,-122.273
relay.arx-ccn.com,50.4754,12.3683
nostrride.io,37.3986,-121.964
r.0kb.io:443,32.789,-96.7989
herbstmeister.com,34.0549,-118.243
relay.artx.market,43.6548,-79.3885
vault.iris.to,43.6532,-79.3832
relay.ru.ac.th,13.7607,100.627
temp.iris.to,43.6532,-79.3832
social.amanah.eblessing.co,48.1046,11.6002
nostr-relay.nextblockvending.com,47.2343,-119.853
wot.codingarena.top,50.4754,12.3683
relay.sincensura.org,43.6532,-79.3832
nostr.dlcdevkit.com,40.0992,-83.1141
1 Relay URL Latitude Longitude
relay.lab.rytswd.com 49.4543 11.0746
relay.paulstephenborile.com:443 49.4543 11.0746
relay.binaryrobot.com 43.6532 -79.3832
nostr-2.21crypto.ch 47.5356 8.73209
spookstr2.nostr1.com:443 40.7057 -74.0136
fanfares.nostr1.com:443 40.7057 -74.0136
x.kojira.io 43.6532 -79.3832
freelay.sovbit.host 60.1699 24.9384
nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
testnet.samt.st 43.6532 -79.3832
relay.angor.io 48.1046 11.6002
relay-arg.zombi.cloudrodion.com 1.35208 103.82
nostr-01.yakihonne.com 1.32123 103.695
nostr-relay.cbrx.io 43.6532 -79.3832
relay.guggero.org 46.5971 9.59652
nostr.snowbla.de 60.1699 24.9384
relay.zone667.com 60.1699 24.9384
nexus.libernet.app:443 43.6532 -79.3832
relay.islandbitcoin.com 12.8498 77.6545
relay-testnet.k8s.layer3.news 37.3387 -121.885
nostr-relay.xbytez.io 50.6924 3.20113
kasztanowa.bieda.it 43.6532 -79.3832
nostrcity-club.fly.dev 37.7648 -122.432
relay.typedcypher.com 51.5072 -0.127586
nostr.na.social:443 43.6532 -79.3832
relay.laantungir.net -19.4692 -42.5315
relay-dev.satlantis.io:443 40.8302 -74.1299
rilo.nostria.app 43.6532 -79.3832
nostr.hekster.org:443 37.3986 -121.964
nostr-relay.amethyst.name:443 39.0067 -77.4291
chat-relay.zap-work.com:443 43.6532 -79.3832
relay.edufeed.org 49.4521 11.0767
syb.lol:443 43.6532 -79.3832
relay.sigit.io 50.4754 12.3683
nostr-relay.xbytez.io:443 50.6924 3.20113
relay.wavefunc.live 41.8781 -87.6298
nostr.sathoarder.com 48.5734 7.75211
myvoiceourstory.org 37.3598 -121.981
relay.underorion.se 50.1109 8.68213
nostr.data.haus 50.4754 12.3683
relay.erybody.com 41.4513 -81.7021
espelho.girino.org 43.6532 -79.3832
nostr.pbfs.io:443 50.4754 12.3683
wot.dergigi.com 64.1476 -21.9392
nostr.bitcoiner.social:443 47.6743 -117.112
dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
relay.gulugulu.moe 43.6532 -79.3832
nostr.spicyz.io 43.6532 -79.3832
relay.cypherflow.ai 48.8575 2.35138
treuzkas.branruz.com 48.8575 2.35138
relay1.nostrchat.io 60.1699 24.9384
kotukonostr.onrender.com 37.7775 -122.397
nostr.plantroon.com 50.1013 8.62643
nostr.davenov.com 50.1109 8.68213
node.kommonzenze.de 49.4521 11.0767
relay2.veganostr.com 60.1699 24.9384
armada.sharegap.net 43.6532 -79.3832
wot.makenomistakes.ca 43.7064 -79.3986
nostr.2b9t.xyz:443 34.0549 -118.243
relay.libernet.app:443 43.6532 -79.3832
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.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
2 bitchat.nostr1.com 40.7057 -74.0136
3 relay.snort.social relay.fundstr.me 53.3498 42.3601 -6.26031 -71.0589
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
4 nostr.2b9t.xyz 34.0549 -118.243
5 nostr.data.haus:443 armada.sharegap.net 50.4754 43.6532 12.3683 -79.3832
6 nostr.chaima.info 51.5072 -0.127586
7 nosflare-leefcore.leefcore.workers.dev 43.6532 -79.3832
8 ribo.eu.nostria.app:443 43.6532 -79.3832
9 relay.lightning.pub 39.0438 -77.4874
10 relay.nostu.be 40.4167 -3.70329
11 nostr.whitenode45.ddns.net 40.55 -74.4758
12 nostr.carroarmato0.be:443 50.914 3.21378
13 cdn.satellite.earth 40.8302 -74.1299
14 relay2.veganostr.com 60.1699 24.9384
15 relay.layer.systems:443 49.0291 8.35695
16 relay0.gfcom.info 13.7653 100.647
17 relay.mmwaves.de:443 48.8575 2.35138
18 offchain.pub 39.1585 -94.5728
19 bcast.girino.org 43.6532 -79.3832
20 staging.yabu.me 35.6092 139.73
21 relay.sigit.io:443 nostr.overpay.com 50.4754 29.7449 12.3683 -95.5343
22 relay.edufeed.org:443 bridge.tagomago.me 49.4521 42.3601 11.0767 -71.0589
23 nostr-01.yakihonne.com:443 nostr-01.yakihonne.com 1.32123 103.695
24 reraw.pbla2fish.cc strfry.bonsai.com 43.6532 39.0438 -79.3832 -77.4874
25 cs-relay.nostrdev.com:443 relay.sharegap.net 50.4754 43.6532 12.3683 -79.3832
26 herbstmeister.com nostr.islandarea.net 34.0549 35.4669 -118.243 -97.6473
27 dm-test-strfry-generic.samt.st 43.6532 -79.3832
28 treuzkas.branruz.com 48.8575 2.35138
29 relay-rpi.edufeed.org:443 49.4521 11.0767
30 vault.iris.to:443 43.6532 -79.3832
31 node.kommonzenze.de 49.4521 11.0767
32 nostr.thalheim.io:443 60.1699 24.9384
33 soloco.nl 43.6532 -79.3832
34 strfry.shock.network 39.0438 -77.4874
35 nostr-relay.zimage.com 34.0549 -118.243
36 public.crostr.com:443 43.6532 -79.3832
37 nostr.sathoarder.com:443 48.5734 7.75211
38 relay.angor.io 48.1046 11.6002
39 relay.wellorder.net 45.5201 -122.99
40 relay.mwaters.net 50.9871 2.12554
41 relay.staging.commonshub.brussels 49.4543 11.0746
42 nostr-verified.wellorder.net 45.5201 -122.99
43 nostr-pub.wellorder.net 45.5201 -122.99
44 nostr-2.21crypto.ch 47.5356 8.73209
45 relay.kaleidoswap.com 50.8476 4.35717
46 relay.libernet.app:443 43.6532 -79.3832
47 relay.homeinhk.xyz 35.694 139.754
48 relay.manneken.brussels 49.4543 11.0746
49 nostr.spicyz.io:443 43.6532 -79.3832
50 relay.lanacoin-eternity.com:443 40.8302 -74.1299
51 ribo.us.nostria.app:443 43.6532 -79.3832
52 relay.loveisbitcoin.com 43.6532 -79.3832
53 relay.angor.io:443 48.1046 11.6002
54 relay02.lnfi.network 35.6764 139.65
55 relay.cosmicbolt.net:443 37.3986 -121.964
56 nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
57 nrs-01.darkcloudarcade.com 39.0997 -94.5786
58 relay.endfiat.money:443 59.3327 18.0656
59 relay.paulstephenborile.com 49.4543 11.0746
60 rele.speyhard.fi 51.5072 -0.127586
61 relay.froth.zone 60.1699 24.9384
62 relay.nostr.blockhenge.com 39.0438 -77.4874
63 nrl.ceskar.xyz 50.5145 16.0119
64 rilo.nostria.app 43.6532 -79.3832
65 nostr.overmind.lol:443 43.6532 -79.3832
66 nostr.snowbla.de:443 50.4754 12.3683
67 nostrrelay.taylorperron.com 45.5029 -73.5723
68 chorus.pjv.me 45.5201 -122.99
69 relay.nostr.place 43.6532 -79.3832
70 bucket.coracle.social 37.7775 -122.397
71 nostr.girino.org:443 43.6532 -79.3832
72 relay.aarpia.com 37.3986 -121.964
73 nostr.thalheim.io 60.1699 24.9384
74 ec2.f7z.io 60.1699 24.9384
75 relay.trotters.cc 43.6532 -79.3832
76 relay.mccormick.cx:443 52.3563 4.95714
77 relay.momostr.pink 43.6532 -79.3832
78 relay.nostr.net 43.6532 -79.3832
79 conduitl2.fly.dev 37.7648 -122.432
80 chat-relay.zap-work.com 43.6532 -79.3832
81 relay.ditto.pub 43.6532 -79.3832
82 relay.veganostr.com 60.1699 24.9384
83 relay.minibolt.info:443 43.6532 -79.3832
84 relay2.angor.io:443 adre.su 48.1046 59.9311 11.6002 30.3609
85 social.amanah.eblessing.co bitcoinostr.duckdns.org 48.1046 41.1976 11.6002 1.11167
nostr.stakey.net 52.3676 4.90414
86 nostr.computingcache.com:443 34.0356 -118.442
87 slick.mjex.me relay-fra.zombi.cloudrodion.com 39.0418 48.8566 -77.4744 2.35222
88 fanfares.nostr1.com nostr.hekster.org:443 40.7057 37.3986 -74.0136 -121.964
89 bitcoinostr.duckdns.org nostr.88mph.life 43.3434 52.1941 -3.99532 -2.21905
90 nostr.oxtr.dev wot.dergigi.com 50.4754 64.1476 12.3683 -21.9392
91 cache.trustr.ing nostr.planix.org 43.6548 43.6532 -79.3885 -79.3832
92 purplerelay.com relay.satsmarkt.club 43.6532 52.6907 -79.3832 4.8181
93 nostr-kyomu-haskell.onrender.com nostrcity-club.fly.dev:443 37.7775 37.7648 -122.397 -122.432
94 nostr-relay.corb.net:443 aeon.libretechsystems.xyz 38.8353 55.486 -104.822 9.86577
95 relay-dev.gulugulu.moe testnet.samt.st 43.6532 -79.3832
96 prl.plus nostr.data.haus 55.7628 50.4754 37.5983 12.3683
97 nostr.tac.lol:443 wot.sudocarlos.com 47.4748 43.6532 -122.273 -79.3832
98 relay.mostr.pub relay-fra.zombi.cloudrodion.com:443 43.6532 48.8566 -79.3832 2.35222
99 schnorr.me:443 shu01.shugur.net 43.6532 21.4902 -79.3832 39.2246
100 relay.gulugulu.moe:443 43.6532 -79.3832
101 relay2.angor.io:443 48.1046 11.6002
102 relay.libernet.app 43.6532 -79.3832
103 directories-safe-motherboard-recipients.trycloudflare.com 43.6532 -79.3832
104 wot.nostr.party 36.1659 -86.7844
105 relay.zone667.com 60.1699 24.9384
106 nostr.wild-vibes.ts.net 48.8566 2.35222
107 relay.nostr.com 50.1109 8.68213
108 nostr.iskarion.ddns.net 43.3076 -2.95421
109 relay-dev.satlantis.io 39.0438 -77.4874
110 relay.sovereignresonance.org 48.9006 2.25929
111 relay.nostrian-conquest.com 41.223 -111.974
112 relay.aidatanorge.no 43.6532 -79.3832
113 strfry.apps3.slidestr.net 40.4167 -3.70329
114 relay.klabo.world 47.2343 -119.853
115 nostr.data.haus:443 50.4754 12.3683
116 testr.nymble.world 40.8054 -74.0241
117 relay.inforsupports.com 43.6532 -79.3832
118 relay.nostrmap.net:443 60.1699 24.9384
119 nostr.stakey.net:443 52.3676 4.90414
120 dev-relay.nostreon.com 60.1699 24.9384
121 nostr.islandarea.net:443 35.4669 -97.6473
122 bucket.coracle.social nostr.rtvslawenia.com 37.7775 49.4543 -122.397 11.0746
123 blossom.gnostr.cloud relay.bowlafterbowl.com 43.6532 32.9483 -79.3832 -96.7299
124 relay.solife.me nostr.quali.chat:443 43.6532 60.1699 -79.3832 24.9384
125 nostr.quali.chat relay.plebeian.market 60.1699 50.1109 24.9384 8.68213
126 relay.vrtmrz.net relay-rpi.edufeed.org 43.6532 49.4521 -79.3832 11.0767
127 relay-dev.gulugulu.moe:443 r.0kb.io 43.6532 32.789 -79.3832 -96.7989
128 relay.bullishbounty.com nostr.notribe.net:443 43.6532 40.8302 -79.3832 -74.1299
129 relay.fckstate.net relay.getsafebox.app:443 59.3293 43.6532 18.0686 -79.3832
130 nostr.rtvslawenia.com:443 nostr.dlcdevkit.com:443 49.4543 40.0992 11.0746 -83.1141
131 relay.nostx.io nostrelites.org 43.6532 34.9582 -79.3832 -81.9907
132 relay.agorist.space nostr.hoppe-relay.it.com 52.3734 42.8864 4.89406 -78.8784
133 relay.notoshi.win nostr.thebiglake.org 13.7829 32.71 100.546 -96.6745
134 dm-test-strfry-discovery.samt.st:443 nostr-kyomu-haskell.onrender.com 43.6532 37.7775 -79.3832 -122.397
135 relay.trotters.cc relay.nostriot.com 43.6532 41.5695 -79.3832 -83.9786
136 relay.lanavault.space nostr.christiansass.de 60.1699 51.7634 24.9384 7.8887
137 public.crostr.com:443 relay.btcforplebs.com 43.6532 -79.3832
nostr.stakey.net:443 52.3676 4.90414
relay.nostr.place:443 43.6532 -79.3832
nostr.dlcdevkit.com 40.0992 -83.1141
nostr.aruku.ovh 1.27994 103.849
satsage.xyz 37.3986 -121.964
strfry.apps3.slidestr.net 40.4167 -3.70329
nostr2.girino.org 43.6532 -79.3832
relay.samt.st 40.8302 -74.1299
articles.layer3.news 37.3387 -121.885
aeon.libretechsystems.xyz 55.486 9.86577
relay.routstr.com 59.4016 17.9455
relay.ohstr.com:443 43.6532 -79.3832
relay.lanacoin-eternity.com:443 40.8302 -74.1299
strfry.openhoofd.nl:443 51.5717 3.70417
nostr.blankfors.se 60.1699 24.9384
nostr-2.21crypto.ch:443 47.5356 8.73209
relayone.soundhsa.com:443 39.1008 -94.5811
relay.lab.rytswd.com:443 49.4543 11.0746
138 nostr.tagomago.me 42.3601 -71.0589
139 relay.0xchat.com:443 relayone.geektank.ai 43.6532 39.0997 -79.3832 -94.5786
140 relay.dreamith.to:443 43.6532 -79.3832
141 nostr.liberty.fans 36.8767 -89.5879
142 wot.makenomistakes.ca 43.7064 -79.3986
143 relay.goodmorningbitcoin.com 43.6532 -79.3832
144 relay.layer.systems 49.0291 8.35695
145 relay.paulstephenborile.com:443 49.4543 11.0746
146 relay.ohstr.com 43.6532 -79.3832
147 nostr-relay.xbytez.io:443 50.6924 3.20113
148 nostr.ac 38.958 -77.3592
149 ribo.us.nostria.app 43.6532 -79.3832
150 nostr.21crypto.ch 47.5356 8.73209
151 relay.chorus.community:443 48.5333 10.7
152 relay.cypherflow.ai 48.8575 2.35138
153 relay.agorist.space:443 52.3734 4.89406
154 relay.nostrian-conquest.com:443 41.223 -111.974
155 relay.keykeeper.world 40.7824 -74.0711
156 relay.getvia.xyz 60.1699 24.9384
157 relay.nuts.cash 52.3676 4.90414
158 kotukonostr.onrender.com 37.7775 -122.397
159 relay.minibolt.info 43.6532 -79.3832
160 relay.dwadziesciajeden.pl 52.2297 21.0122
161 relay.fountain.fm:443 43.6532 -79.3832
162 relay.fountain.fm 43.6532 -79.3832
163 nostr-02.uid.ovh 50.9871 2.12554
164 relay.lanavault.space:443 60.1699 24.9384
165 nostr.carroarmato0.be 50.914 3.21378
166 nexus.libernet.app:443 43.6532 -79.3832
167 relay.artio.inf.unibe.ch 46.9501 7.43678
168 blossom.gnostr.cloud 43.6532 -79.3832
169 relay.binaryrobot.com 43.6532 -79.3832
170 relay.earthly.city 34.1749 -118.54
171 nostr.hifish.org 47.4244 8.57658
172 offchain.pub:443 39.1585 -94.5728
173 relay.bullishbounty.com:443 43.6532 -79.3832
174 strfry.openhoofd.nl:443 51.5717 3.70417
175 cs-relay.nostrdev.com:443 50.4754 12.3683
176 strfry.ymir.cloud 43.6532 -79.3832
177 nostrbtc.com 43.6532 -79.3832
178 relay.directsponsor.net 42.8864 -78.8784
179 nostr2.girino.org 43.6532 -79.3832
180 relay.sigit.io:443 50.4754 12.3683
181 relay.getsafebox.app 43.6532 -79.3832
182 antiprimal.net 43.6532 -79.3832
183 nostr.sathoarder.com 48.5734 7.75211
184 inbox.scuba323.com 40.8218 -74.45
185 nrs-01.darkcloudarcade.com:443 39.0997 -94.5786
186 nostr.tac.lol 47.4748 -122.273
187 nostr.davenov.com 50.1109 8.68213
188 relay.trotters.cc:443 43.6532 -79.3832
189 nostr.plantroon.com:443 50.1013 8.62643
190 relay.nostreon.com 60.1699 24.9384
191 nostr.easycryptosend.it 43.6532 -79.3832
192 nostr-01.yakihonne.com:443 1.32123 103.695
193 relay-testnet.k8s.layer3.news 37.3387 -121.885
194 nostr.purpura.cloud 43.6532 -79.3832
195 insta-relay.apps3.slidestr.net 40.4167 -3.70329
196 nostr.mifen.me 43.6532 -79.3832
197 testnet-relay.samt.st:443 40.8302 -74.1299
198 nostr.2b9t.xyz:443 34.0549 -118.243
199 relay.wavlake.com:443 41.2619 -95.8608
200 relay.wisp.talk:443 49.4543 11.0746
201 relay-dev.satlantis.io:443 39.0438 -77.4874
202 relay.satlantis.io 39.0438 -77.4874
203 relay.staging.plebeian.market 51.5072 -0.127586
204 relay.openfarmtools.org 60.1699 24.9384
205 relay.nostrhub.fr 48.1045 11.6004
206 nostr-relay.xbytez.io 50.6924 3.20113
207 relay.binaryrobot.com:443 43.6532 -79.3832
208 relay.samt.st 40.8302 -74.1299
209 relay.illuminodes.com 43.6532 -79.3832
210 relay.liberbitworld.org 43.6532 -79.3832
211 relay.olas.app:443 60.1699 24.9384
212 no.str.cr 8.96171 -83.5246
213 dm-test-strfry-discovery.samt.st 43.6532 -79.3832
214 wot.rejecttheframe.xyz 43.6532 -79.3832
215 relay.nostriot.com:443 41.5695 -83.9786
216 nostr.plantroon.com 50.1013 8.62643
217 nostr-01.uid.ovh 50.9871 2.12554
218 relay.openresist.com:443 43.6532 -79.3832
219 nostr.overmind.lol 43.6532 -79.3832
220 relay.internationalright-wing.org -22.5022 -48.7114
221 nostr.myshosholoza.co.za:443 52.3676 4.90414
222 nostr.pbfs.io:443 50.4754 12.3683
223 21milionidinostr.duckdns.org 41.8967 12.4822
224 nostr.4rs.nl 49.0291 8.35696
225 relay.lanavault.space 60.1699 24.9384
226 relay.mostr.pub 43.6532 -79.3832
227 relay.nostar.org 43.6532 -79.3832
228 nostr.mom 50.4754 12.3683
229 relay.decentralia.fr 48.122 11.589
230 relay.agentry.com 42.8864 -78.8784
231 relay2.angor.io 48.1046 11.6002
232 slick.mjex.me 39.0418 -77.4744
233 relay-us.zombi.cloudrodion.com 40.7862 -74.0743
234 relay.vrtmrz.net:443 43.6532 -79.3832
235 relay.beginningend.com 35.2227 -97.4786
236 chat-relay.zap-work.com:443 43.6532 -79.3832
237 relay.underorion.se 50.1109 8.68213
238 relay.mitchelltribe.com 39.0438 -77.4874
239 relay.qstr.app 51.5072 -0.127586
240 relay.cyberguy.fyi 52.6907 4.8181
241 strfry.bonsai.com:443 39.0438 -77.4874
242 relayone.soundhsa.com:443 39.0997 -94.5786
243 relay.sigit.io 50.4754 12.3683
244 relay.npubhaus.com 43.6532 -79.3832
245 relayrs.notoshi.win 43.6532 -79.3832
246 relay.mitchelltribe.com:443 39.0438 -77.4874
247 relay.44billion.net 43.6532 -79.3832
248 reraw.pbla2fish.cc 43.6532 -79.3832
249 articles.layer3.news:443 37.3387 -121.885
250 nostr.sovereignservices.xyz 43.6532 -79.3832
251 relay.nostx.io 43.6532 -79.3832
252 nostr-relay.amethyst.name 39.0067 -77.4291
253 0x-nostr-relay.fly.dev 37.7648 -122.432
254 relay.ohstr.com:443 43.6532 -79.3832
255 00f2e774.relay.dev.thunderegg.us 39.0438 -77.4874
256 nostr-relay.cbrx.io 43.6532 -79.3832
257 relay.wavlake.com 41.2619 -95.8608
258 purplerelay.com:443 43.6532 -79.3832
259 nostr-pr02.redscrypt.org 52.3676 4.90414
260 fanfares.nostr1.com:443 40.7057 -74.0136
261 kasztanowa.bieda.it 43.6532 -79.3832
262 relay.flashapp.me 43.6548 -79.3885
263 relay.typedcypher.com 51.5072 -0.127586
264 nostr.bond 50.1109 8.68213
265 nostr.azzamo.net 52.2633 21.0283
266 nexus.libernet.app 43.6532 -79.3832
267 relay.cosmicbolt.net 37.3986 -121.964
268 schnorr.me 43.6532 -79.3832
269 relay.mostro.network:443 40.8302 -74.1299
270 relay-arg.zombi.cloudrodion.com 1.35208 103.82
271 relay.chorus.community 48.5333 10.7
272 blossom.gnostr.cloud:443 43.6532 -79.3832
273 syb.lol:443 34.0549 -118.243
274 relay.dyne.org 49.0291 8.35705
275 btc.klendazu.com 41.2861 1.24993
276 wot.nostr.place 43.6532 -79.3832
277 relay.openresist.com 43.6532 -79.3832
278 rilo.nostria.app:443 43.6532 -79.3832
279 no.str.cr:443 8.96171 -83.5246
280 relay.mostr.pub:443 43.6532 -79.3832
281 relay.edufeed.org:443 49.4521 11.0767
282 nostr.debate.report 50.1109 8.68213
283 relay.satmaxt.xyz:443 43.6532 -79.3832
284 relay.artx.market:443 43.6548 -79.3885
285 relay-dev.gulugulu.moe 43.6532 -79.3832
286 relay.novospes.com 43.6532 -79.3832
287 relay.nostr-check.me 43.6532 -79.3832
288 nostr.computingcache.com 34.0356 -118.442
289 nostr.oxtr.dev 50.4754 12.3683
290 relay.fckstate.net 59.3293 18.0686
291 relay.vrtmrz.net 43.6532 -79.3832
292 relay.bornheimer.app 51.5072 -0.127586
293 relay.guggero.org 46.5971 9.59652
294 relay01.lnfi.network 35.6764 139.65
295 wot.shaving.kiwi 43.6532 -79.3832
296 nostr.twinkle.lol 51.902 7.6657
297 relay.edufeed.org 49.4521 11.0767
298 relay.lanacoin-eternity.com 40.8302 -74.1299
299 relay.satmaxt.xyz 43.6532 -79.3832
300 nostr.hifish.org:443 47.4244 8.57658
301 relay.cypherflow.ai:443 48.8575 2.35138
302 infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
303 nostr.na.social:443 43.6532 -79.3832
304 nostr.rtvslawenia.com:443 49.4543 11.0746
305 relay.mypathtofire.de 42.8864 -78.8784
306 public.crostr.com 43.6532 -79.3832
307 relay.olas.app 60.1699 24.9384
308 relay.agora.social 50.7383 15.0648
309 ribo.nostria.app 43.6532 -79.3832
310 relay.lab.rytswd.com 49.4543 11.0746
311 relay.ditto.pub:443 43.6532 -79.3832
312 porchlight.social 43.6532 -79.3832
313 nostr.notribe.net 40.8302 -74.1299
314 relay.endfiat.money 59.3327 18.0656
315 nostr.myshosholoza.co.za 52.3676 4.90414
316 relay.nearhood.co.uk 51.5134 -0.0890675
317 relay.degmods.com 50.4754 12.3683
318 nostr.novacisko.cz 52.2026 20.9397
319 prl.plus 55.7628 37.5983
320 bruh.samt.st 43.6532 -79.3832
321 strfry.openhoofd.nl 51.5717 3.70417
322 nostr.spicyz.io 43.6532 -79.3832
323 nostr.na.social 43.6532 -79.3832
324 nip85.nosfabrica.com 39.0997 -94.5786
325 premium.primal.net 43.6532 -79.3832
326 fanfares.nostr1.com 40.7057 -74.0136
327 relay.scuba323.com 40.8218 -74.45
328 nostr2.girino.org:443 43.6532 -79.3832
329 relay.mmwaves.de 48.8575 2.35138
330 nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
331 strfry.shock.network:443 39.0438 -77.4874
332 nostr.snowbla.de 50.4754 12.3683
333 nostr.spaceshell.xyz 43.6532 -79.3832
334 nostr.quali.chat 60.1699 24.9384
335 wot.utxo.one 43.6532 -79.3832
336 relay.mccormick.cx 52.3563 4.95714
337 mostro-p2p.tech 50.1109 8.68213
338 basspistol.org 49.0291 8.35696
339 ribo.nostria.app:443 43.6532 -79.3832
340 chorus.mikedilger.com:444 -36.8906 174.794
341 nostr.oxtr.dev:443 50.4754 12.3683
342 nostr.nodesmap.com 59.3327 18.0656
343 offchain.bostr.online 43.6532 -79.3832
344 purplerelay.com 43.6532 -79.3832
345 relayrs.notoshi.win:443 43.6532 -79.3832
346 relay.wavefunc.live 41.8781 -87.6298
347 relay.dreamith.to 43.6532 -79.3832
348 bendernostur.duckdns.org:8443 50.1109 8.68213
349 relay.nmail.li 50.9871 2.12554
350 nostr-relay.corb.net 39.6478 -104.988
351 relay.staging.plebeian.market:443 51.5072 -0.127586
352 spamspamspamspam.rest 43.6532 -79.3832
353 relay1.gfcom.info 13.9215 100.538
354 schnorr.me:443 43.6532 -79.3832
355 relay.lab.rytswd.com:443 49.4543 11.0746
356 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
357 dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
358 relay.nostrmap.net 60.1699 24.9384
359 nostr.relay.hedwig.sh 60.1699 24.9384
360 relay.veganostr.com:443 60.1699 24.9384
361 relay.wavefunc.live:443 41.8781 -87.6298
362 nostr.mikoshi.de 52.52 13.405
363 syb.lol 34.0549 -118.243
364 relay1.nostrchat.io 60.1699 24.9384
365 nostr.wecsats.io:443 43.6532 -79.3832
366 nostr.chaima.info:443 51.5072 -0.127586
367 nostr.azzamo.net:443 52.2633 21.0283
368 relay-can.zombi.cloudrodion.com 43.6532 -79.3832
369 nostr.unkn0wn.world 46.8499 9.53287
370 relayone.soundhsa.com 39.0997 -94.5786
371 x.kojira.io 43.6532 -79.3832
372 dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
373 nostrelay.circum.space 52.6907 4.8181
374 relay.primal.net 43.6532 -79.3832
375 nostr.girino.org 43.6532 -79.3832
376 nostr.pbfs.io 50.4754 12.3683
377 relay.kalcafe.xyz 37.3986 -121.964
378 relay.gulugulu.moe 43.6532 -79.3832
379 top.testrelay.top 43.6532 -79.3832
380 relay.kilombino.com 43.6532 -79.3832
381 nos.lol:443 50.4754 12.3683
382 nos.lol 50.4754 12.3683
383 relay.nostr.place:443 43.6532 -79.3832
384 cache.trustr.ing 43.6548 -79.3885
385 relay.internationalright-wing.org:443 -22.5022 -48.7114
386 relay.laantungir.net -19.4692 -42.5315
387 relay.lightning.pub:443 39.0438 -77.4874
388 nostr.stakey.net 52.3676 4.90414
389 articles.layer3.news 37.3387 -121.885
390 relay.wisp.talk 49.4543 11.0746
391 relay.pyramid.li 47.4093 8.46503
392 relay.typedcypher.com:443 51.5072 -0.127586
393 dev.relay.stream 43.6532 -79.3832
394 relay.bullishbounty.com 43.6532 -79.3832
395 nostr.mom:443 50.4754 12.3683
396 relay.plebeian.market:443 50.1109 8.68213
397 nostr.hekster.org 37.3986 -121.964
398 nostrcity-club.fly.dev 37.7648 -122.432
399 nostr.vulpem.com 49.4543 11.0746
400 relay-dev.gulugulu.moe:443 43.6532 -79.3832
401 weboftrust.libretechsystems.xyz 55.4724 9.87335
402 nostr-relay.corb.net:443 39.6478 -104.988
403 wheat.happytavern.co 43.6532 -79.3832
404 relay.mappingbitcoin.com 43.6532 -79.3832
405 testnet-relay.samt.st 40.8302 -74.1299
406 relay.bitmacro.cloud 43.6532 -79.3832
407 dev.relay.edufeed.org 49.4521 11.0767
408 myvoiceourstory.org 37.3598 -121.981
409 relay.stickeroo.is-cool.dev 37.3387 -121.885
410 relay.agorist.space 52.3734 4.89406
411 freelay.sovbit.host 60.1699 24.9384
412 nostr-dev.wellorder.net 45.5201 -122.99
413 nostr.middling.mydns.jp 35.8099 140.12
414 cs-relay.nostrdev.com 50.4754 12.3683
415 x.kojira.io:443 43.6532 -79.3832
416 nostrelay.circum.space:443 52.6907 4.8181
417 nostr.janx.com 43.6532 -79.3832
418 relay.mrmave.work 43.6532 -79.3832
419 espelho.girino.org 43.6532 -79.3832
420 hol.is 43.6532 -79.3832
421 ribo.eu.nostria.app 43.6532 -79.3832
422 nostr.yutakobayashi.com 43.6532 -79.3832
423 relay.mostro.network 40.8302 -74.1299
424 communities.nos.social 40.8302 -74.1299
425 relay.solife.me 43.6532 -79.3832
426 yabu.me 35.6092 139.73
427 relay.islandbitcoin.com 12.8498 77.6545
428 nostr.wecsats.io 43.6532 -79.3832
429 nostr.tac.lol:443 47.4748 -122.273
430 relay.arx-ccn.com 50.4754 12.3683
431 nostrride.io 37.3986 -121.964
432 r.0kb.io:443 32.789 -96.7989
433 herbstmeister.com 34.0549 -118.243
434 relay.artx.market 43.6548 -79.3885
435 vault.iris.to 43.6532 -79.3832
436 relay.ru.ac.th 13.7607 100.627
437 temp.iris.to 43.6532 -79.3832
438 social.amanah.eblessing.co 48.1046 11.6002
439 nostr-relay.nextblockvending.com 47.2343 -119.853
440 wot.codingarena.top 50.4754 12.3683
441 relay.sincensura.org 43.6532 -79.3832
442 nostr.dlcdevkit.com 40.0992 -83.1141