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