Compare commits

..
Author SHA1 Message Date
jack a5043dec2d Retry confirmed private media after reconnect 2026-07-26 07:17:21 +02:00
jack 2f8af04821 Persist private media delivery receipts 2026-07-26 07:17:21 +02:00
jack 78083bd773 Correlate private media delivery receipts 2026-07-26 07:17:21 +02:00
jack a8201b3958 Deliver live transport events synchronously 2026-07-26 07:17:21 +02:00
jack 01ce054238 Discard deferred Noise ciphertext during panic 2026-07-26 07:17:21 +02:00
jack b6c7c77080 Preserve early Noise ciphertext across reconnects 2026-07-26 07:17:21 +02:00
jack 55ee1df0bc Stabilize synchronous Noise restart tests 2026-07-26 07:17:21 +02:00
jack b255355fed Fix ordinary Noise handshake races 2026-07-26 02:09:31 +02:00
jack 6e026c2c22 Fix cached Noise reconnects atomically 2026-07-26 02:09:31 +02:00
jack c8e330777a Discard private-media callbacks during panic 2026-07-26 02:09:31 +02:00
jack 91aba8b597 Fix private media Noise round-trip fixture 2026-07-26 02:09:31 +02:00
jackandjack d9a6dbfca8 Make Noise generation race test deterministic 2026-07-26 02:09:31 +02:00
jackandjack 68f8f03ad8 Avoid authentication callback queue starvation 2026-07-26 02:09:31 +02:00
jackandjack 48026991b2 Bind capability state to Noise generations 2026-07-26 02:09:31 +02:00
jackandjack ec795520ee Authenticate private media capabilities in Noise 2026-07-26 02:09:31 +02:00
jackandjack 40238c5e43 Harden private media migration compatibility 2026-07-26 02:09:31 +02:00
jackandjack a31cd80027 Encrypt private media before fragmentation 2026-07-26 02:08:56 +02:00
jack 974510ad9e Authenticate only completed Noise candidates 2026-07-26 02:08:56 +02:00
jackandjack 5aee7f0f98 Bind Noise sessions to claimed peer identities 2026-07-26 02:08:56 +02:00
jack 6054248765 Harden panic keychain and media cleanup 2026-07-26 02:08:56 +02:00
jack 5f7df63238 Invalidate queued BLE ingress during panic 2026-07-26 00:12:26 +02:00
jack b081c98dba Harden panic recovery and service shutdown 2026-07-26 00:12:26 +02:00
jackandjack 76d3b0f1ed Scope install markers to iOS 2026-07-25 21:43:11 +02:00
jackandjack aa3021c9ca Make panic wipe deterministic and device-bound 2026-07-25 21:43:11 +02:00
43 changed files with 9115 additions and 825 deletions
@@ -36,6 +36,30 @@ enum NoiseSecurityConstants {
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
static let xxInitialMessageSize = 32
// Bounds an ordinary initiator whose message 1 or 2 is lost.
static let ordinaryHandshakeTimeout: TimeInterval = 10
// Bounds the receive-only rollback quarantine created by an unauthenticated
// inbound message 1. A lost message 3 must not strand outbound traffic.
static let ordinaryResponderHandshakeTimeout: TimeInterval = 20
// A released client may immediately retry after both crossed initiators
// yielded. Give that unilateral retry a brief head start before the
// patched side spends its one bounded recovery.
static let handshakeCollisionRecoveryDelay: TimeInterval = 0.2
// Rate-limited recovery remains actionable without spinning.
static let handshakeRateLimitRecoveryDelay: TimeInterval = 60
// Covers only reordering between a winning message 3 and the losing
// crossed message 1.
static let recentInitiatorCompletionGracePeriod: TimeInterval = 1
// After unauthenticated responder rollback, reject another attempt long
// enough that paced message 1 traffic cannot keep outbound paused. A
// legitimate peer converges through the one manager-owned local retry.
static let ordinaryReconnectRollbackCooldown: TimeInterval = 60
// Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours
+6
View File
@@ -13,3 +13,9 @@ enum NoiseSessionError: Error, Equatable {
case alreadyEstablished
case peerIdentityMismatch
}
/// The manager owns the exact attempt's one bounded recovery. Packet handling
/// must not launch its historical second, immediate restart for this failure.
struct NoiseManagedHandshakeFailure: Error {
let underlying: Error
}
File diff suppressed because it is too large Load Diff
+87
View File
@@ -154,3 +154,90 @@ struct BitchatFilePacket {
)
}
}
/// Wire-compatible identity for private media exchanged by clients using the
/// current iOS entropy-bearing filenames, without extending the deployed file
/// TLV. Android clients reject unknown file tags, so eligible senders and
/// receivers derive the receipt key from fields already on the wire.
///
/// Locally-created image and voice-note filenames contain a UUID or live-voice
/// burst ID. Including the normalized direction keeps a reused filename
/// distinct across chats while allowing short and full Noise-key peer IDs to
/// converge. Android and older-iOS timestamp-only names remain ineligible and
/// retain their legacy random local IDs (transfer-compatible, no receipts).
enum PrivateMediaMessageIdentity {
private static let domain = Data("bitchat-private-media-message-v1".utf8)
private static let idPrefix = "media-"
private static let digestHexLength = 32
static func isStableID(_ candidate: String) -> Bool {
guard candidate.hasPrefix(idPrefix) else { return false }
let digest = candidate.dropFirst(idPrefix.count)
guard digest.utf8.count == digestHexLength else { return false }
return digest.utf8.allSatisfy { byte in
(UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte)
|| (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte)
}
}
static func stableID(
senderPeerID: PeerID,
recipientPeerID: PeerID,
fileName: String?
) -> String? {
guard let fileName, !fileName.isEmpty else { return nil }
let leafName = (fileName as NSString).lastPathComponent
guard leafName == fileName else { return nil }
let path = leafName as NSString
let stem = path.deletingPathExtension
let fileExtension = path.pathExtension.lowercased()
switch true {
case stem.hasPrefix("img_"):
guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil }
case stem.hasPrefix("voice_"):
guard fileExtension == "m4a" else { return nil }
default:
return nil
}
let entropyToken = stem.split(separator: "_").last.map(String.init)
let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil
let voiceBurstID = stem.hasPrefix("voice_")
? String(stem.dropFirst("voice_".count))
: ""
let hasBurstEntropy = voiceBurstID.count == 16
&& voiceBurstID.allSatisfy(\.isHexDigit)
guard hasUUIDEntropy || hasBurstEntropy else {
return nil
}
let fields = [
Data(senderPeerID.toShort().bare.utf8),
Data(recipientPeerID.toShort().bare.utf8),
Data(leafName.utf8)
]
var input = domain
for field in fields {
guard let length = UInt32(exactly: field.count) else { return nil }
var bigEndianLength = length.bigEndian
withUnsafeBytes(of: &bigEndianLength) {
input.append(contentsOf: $0)
}
input.append(field)
}
return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))"
}
static func stableID(
for packet: BitchatFilePacket,
senderPeerID: PeerID,
recipientPeerID: PeerID
) -> String? {
stableID(
senderPeerID: senderPeerID,
recipientPeerID: recipientPeerID,
fileName: packet.fileName
)
}
}
@@ -3,5 +3,11 @@ import BitFoundation
extension PeerCapabilities {
/// Capabilities this build advertises in its announce packets.
/// Each feature adds its bit here when it ships.
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups, .privateMedia]
static let localSupported: PeerCapabilities = [
.vouch,
.prekeys,
.groups,
.privateMedia,
.privateMediaReceipts
]
}
+258 -10
View File
@@ -32,10 +32,79 @@ struct BLEFileTransferHandlerEnvironment {
_ fallbackExtension: String?,
_ defaultPrefix: String
) -> URL?
/// Resolves the durable receiver decision for a stable private-media ID.
let privateMediaReceiptState: (
_ messageID: String
) -> BLEPrivateMediaReceiptState
/// Atomically records a stable private-media ID after the payload save.
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
/// Rolls back a saved payload when its durable receipt commit fails.
let removeIncomingFile: (_ storedURL: URL) -> Void
/// Checks the authenticated sender before any private-media disk work.
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
/// Updates the registry last-seen timestamp for the peer (async barrier write).
let updatePeerLastSeen: (PeerID) -> Void
/// Delivers `.messageReceived` to the UI as one main-actor hop.
let deliverMessage: (BitchatMessage) -> Void
/// Acknowledges stable private media only after its synchronous
/// conversation delivery has completed.
let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void
/// Delivers `.messageReceived` as one main-actor hop while
/// `shouldDeliver` remains true before and after the synchronous sink.
/// The completion authorizes the stable-media ACK.
let deliverMessage: (
_ message: BitchatMessage,
_ shouldDeliver: @escaping () -> Bool,
_ completion: @escaping () -> Void
) -> Void
}
/// Process-lifetime reservation cache for stable private-media IDs.
///
/// The first arrival reserves its ID before quota enforcement. Concurrent
/// arrivals remain coalesced in memory, while accepted state is resolved from
/// the durable ID-to-file ledger so it survives relaunch and becomes retryable
/// if quota cleanup removed the file.
private final class PrivateMediaArrivalDeduplicator {
enum Reservation {
case reserved
case pending
case accepted(URL)
case tombstoned
case unavailable
}
private let lock = NSLock()
private var pending: Set<String> = []
func reserve(
_ messageID: String,
receiptState: () -> BLEPrivateMediaReceiptState
) -> Reservation {
lock.lock()
defer { lock.unlock() }
if pending.contains(messageID) {
return .pending
}
switch receiptState() {
case .accepted(let existingURL):
return .accepted(existingURL)
case .tombstoned:
return .tombstoned
case .unavailable:
return .unavailable
case .absent:
break
}
pending.insert(messageID)
return .reserved
}
func finish(_ messageID: String) {
lock.lock()
defer { lock.unlock() }
pending.remove(messageID)
}
}
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
@@ -43,6 +112,7 @@ struct BLEFileTransferHandlerEnvironment {
/// and UI delivery.
final class BLEFileTransferHandler {
private let environment: BLEFileTransferHandlerEnvironment
private let privateMediaArrivals = PrivateMediaArrivalDeduplicator()
init(environment: BLEFileTransferHandlerEnvironment) {
self.environment = environment
@@ -86,6 +156,7 @@ final class BLEFileTransferHandler {
senderNickname: senderNickname,
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
isPrivate: deliveryPlan.isPrivateMessage,
usesDurableReceipts: false,
env: env
)
// Once authenticated, a local decode/quota/save failure is not proof
@@ -116,6 +187,11 @@ final class BLEFileTransferHandler {
senderNickname: senderNickname,
timestamp: timestamp,
isPrivate: true,
// Every authenticated Noise private-file keeps the stable ID/ACK
// contract introduced with capability bit 8. Bit 9 advertises
// sender-side automatic retry support; it must not downgrade
// prior iOS clients to random IDs or single-check delivery.
usesDurableReceipts: true,
env: env
)
}
@@ -126,9 +202,11 @@ final class BLEFileTransferHandler {
senderNickname: String,
timestamp: Date,
isPrivate: Bool,
usesDurableReceipts: Bool,
env: BLEFileTransferHandlerEnvironment
) -> Bool {
let localPeerID = env.localPeerID()
let filePacket: BitchatFilePacket
let mime: MimeType
switch BLEIncomingFileValidator.validate(payload: payload) {
@@ -149,6 +227,89 @@ final class BLEFileTransferHandler {
return false
}
if isPrivate, env.isPrivateMediaSenderBlocked(peerID) {
SecureLogger.debug(
"🚫 Dropping private media from blocked peer \(peerID.id.prefix(8))… before disk write",
category: .security
)
return true
}
let messageID = usesDurableReceipts
? PrivateMediaMessageIdentity.stableID(
for: filePacket,
senderPeerID: peerID,
recipientPeerID: localPeerID
)
: nil
if let messageID {
switch privateMediaArrivals.reserve(
messageID,
receiptState: { env.privateMediaReceiptState(messageID) }
) {
case .reserved:
break
case .pending:
// The first arrival has not reached durable storage yet.
// Coalesce this retry without ACKing so a failed first save
// remains retryable by the sender.
SecureLogger.debug(
"📁 Coalesced in-flight private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))",
category: .session
)
return true
case .accepted(let existingFile):
env.updatePeerLastSeen(peerID)
let message = incomingMessage(
messageID: messageID,
senderNickname: senderNickname,
timestamp: timestamp,
isPrivate: true,
peerID: peerID,
destination: existingFile,
category: storedMediaCategory(
for: existingFile,
fallback: mime.category
),
env: env
)
SecureLogger.debug(
"📁 Restored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))… -> \(existingFile.lastPathComponent)",
category: .session
)
deliverStableMessage(
message,
messageID: messageID,
peerID: peerID,
expectedURL: existingFile,
env: env
)
return true
case .tombstoned:
// Explicit deletion is a durable terminal receiver decision.
env.updatePeerLastSeen(peerID)
env.acknowledgePrivateMedia(messageID, peerID)
SecureLogger.debug(
"📁 Dropped explicitly deleted private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))",
category: .session
)
return true
case .unavailable:
// Never turn an unreadable ledger into an empty ledger. The
// sender can retry after the transient storage failure clears.
SecureLogger.warning(
"📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable",
category: .session
)
return true
}
}
defer {
if let messageID {
privateMediaArrivals.finish(messageID)
}
}
// BCH-01-002: Enforce storage quota before saving
env.enforceStorageQuota(filePacket.content.count)
@@ -162,13 +323,82 @@ final class BLEFileTransferHandler {
return false
}
if let messageID,
!env.commitPrivateMediaFile(messageID, destination) {
// A payload without its durable ID mapping cannot safely suppress
// a retry after relaunch. Roll it back and withhold UI/ACK.
env.removeIncomingFile(destination)
return false
}
if isPrivate {
env.updatePeerLastSeen(peerID)
}
let message = BitchatMessage(
let message = incomingMessage(
messageID: messageID,
senderNickname: senderNickname,
timestamp: timestamp,
isPrivate: isPrivate,
peerID: peerID,
destination: destination,
category: mime.category,
env: env
)
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
if let messageID {
deliverStableMessage(
message,
messageID: messageID,
peerID: peerID,
expectedURL: destination,
env: env
)
} else {
env.deliverMessage(message, { true }, {})
}
return true
}
private func deliverStableMessage(
_ message: BitchatMessage,
messageID: String,
peerID: PeerID,
expectedURL: URL,
env: BLEFileTransferHandlerEnvironment
) {
env.deliverMessage(
message,
{
guard case .accepted(let resolvedURL) =
env.privateMediaReceiptState(messageID) else {
return false
}
return resolvedURL.standardizedFileURL
== expectedURL.standardizedFileURL
},
{
env.acknowledgePrivateMedia(messageID, peerID)
}
)
}
private func incomingMessage(
messageID: String?,
senderNickname: String,
timestamp: Date,
isPrivate: Bool,
peerID: PeerID,
destination: URL,
category: MimeType.Category,
env: BLEFileTransferHandlerEnvironment
) -> BitchatMessage {
BitchatMessage(
id: messageID,
sender: senderNickname,
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
content: "\(category.messagePrefix)\(destination.lastPathComponent)",
timestamp: timestamp,
isRelay: false,
originalSender: nil,
@@ -176,17 +406,35 @@ final class BLEFileTransferHandler {
recipientNickname: nil,
senderPeerID: peerID,
// Received messages need an explicit status: BitchatMessage
// defaults private messages to .sending, which the media views
// render as an in-flight send (empty reveal mask, disabled tap).
// defaults private messages to .sending, which media views render
// as an in-flight send.
deliveryStatus: isPrivate
? .delivered(to: env.localNickname(), at: timestamp)
: nil
)
}
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
env.deliverMessage(message)
return true
/// The durable URL is authoritative during reconstruction. A sender that
/// reuses a stable filename with a different MIME type must not change how
/// the already-stored payload renders.
private func storedMediaCategory(
for url: URL,
fallback: MimeType.Category
) -> MimeType.Category {
let mediaDirectory = url
.deletingLastPathComponent()
.deletingLastPathComponent()
.lastPathComponent
switch mediaDirectory {
case MimeType.Category.audio.mediaDir:
return .audio
case MimeType.Category.image.mediaDir:
return .image
case MimeType.Category.file.mediaDir:
return .file
default:
return fallback
}
}
/// Every remaining raw file transfer is signed, regardless of whether it
@@ -134,6 +134,7 @@ struct BLEIncomingFileStore {
private let baseDirectory: URL?
private let dateProvider: () -> Date
private let panicMarkerWriter: (Data, URL) throws -> Void
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
init(
fileManager: FileManager = .default,
@@ -147,6 +148,11 @@ struct BLEIncomingFileStore {
self.baseDirectory = baseDirectory
self.dateProvider = dateProvider
self.panicMarkerWriter = panicMarkerWriter
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
fileManager: fileManager,
baseDirectory: baseDirectory,
now: dateProvider
)
}
/// Panic-wipe every managed incoming and outgoing media artifact before
@@ -159,6 +165,11 @@ struct BLEIncomingFileStore {
func panicWipe(
hasDurablePendingMarker: Bool = false
) throws {
// The receipt index caches tombstones as well as accepted payloads.
// Always invalidate it on return, including partial-failure paths, so
// no pre-panic receiver decision survives after identity reset.
defer { privateMediaReceipts.resetForPanic() }
let markerError: Error?
do {
try markPanicRecoveryPending()
@@ -257,6 +268,35 @@ struct BLEIncomingFileStore {
}
}
func privateMediaReceiptState(
messageID: String
) -> BLEPrivateMediaReceiptState {
privateMediaReceipts.state(for: messageID)
}
func commitPrivateMediaFile(
messageID: String,
storedURL: URL
) -> Bool {
privateMediaReceipts.commitAccepted(
messageID: messageID,
storedURL: storedURL
)
}
/// Best-effort rollback for a payload whose durable receipt commit failed.
func removeIncomingFile(at storedURL: URL) {
guard isURLInsideFilesDirectory(storedURL) else { return }
do {
try fileManager.removeItem(at: storedURL)
} catch {
SecureLogger.warning(
"⚠️ Failed to roll back uncommitted incoming media: \(error)",
category: .session
)
}
}
/// Frees least-recently-modified incoming files until `reservingBytes`
/// fits under the quota. Files named `voice_live_*` (in-flight live
/// captures) are never evicted regardless of who triggers enforcement
@@ -349,6 +389,13 @@ struct BLEIncomingFileStore {
]
}
private func isURLInsideFilesDirectory(_ url: URL) -> Bool {
guard let filesDirectory = try? filesDirectory().standardizedFileURL else {
return false
}
return url.standardizedFileURL.path.hasPrefix(filesDirectory.path + "/")
}
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
var candidate = (name ?? "")
.replacingOccurrences(of: "\0", with: "")
@@ -1,5 +1,6 @@
import BitFoundation
import BitLogger
import CryptoKit
import Foundation
struct BLENoiseHandshakeHandlingResult {
@@ -33,6 +34,8 @@ struct BLENoisePacketHandlerEnvironment {
-> NoiseHandshakeProcessingResult
/// Whether any Noise session (established or pending) exists for the peer (crypto).
let hasNoiseSession: (PeerID) -> Bool
/// Whether an inbound ordinary XX responder is waiting for message 3.
let isAwaitingResponderHandshakeCompletion: (PeerID) -> Bool
/// Initiates a fresh Noise handshake with the peer (crypto + send).
let initiateHandshake: (PeerID) -> Void
/// Broadcasts a packet on the mesh (caller is already on the message queue).
@@ -63,15 +66,36 @@ struct BLENoisePacketHandlerEnvironment {
/// processing (with response), encrypted payload decryption and dispatch,
/// and session recovery on decrypt failure.
final class BLENoisePacketHandler {
private struct DeferredCiphertext {
let packet: BitchatPacket
let receivedAt: Date
}
/// Early post-handshake packets are normally tiny control messages or
/// queued DMs. Keep the recovery surface deliberately small so an
/// unauthenticated half-handshake cannot create an unbounded memory queue.
private static let maxDeferredPacketsPerPeer = 4
private static let maxDeferredPacketsGlobal = 32
/// One legacy sender can immediately follow message 3 with the largest
/// valid private-file ciphertext and has no application-level retry. Keep
/// room for that packet plus a small control-message budget.
private static let maxDeferredBytes =
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 256 * 1024
private static let deferredLifetime =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
private let environment: BLENoisePacketHandlerEnvironment
private let deferredLock = NSLock()
private var deferredCiphertexts: [PeerID: [DeferredCiphertext]] = [:]
private var deferredCiphertextBytes = 0
init(environment: BLENoisePacketHandlerEnvironment) {
self.environment = environment
}
/// Returns true when the handshake message was processed successfully.
/// Callers use this to distinguish an authenticated replacement completion
/// from a rejected candidate while an older session remains established.
/// Callers use this to distinguish an authenticated reconnect completion
/// from a rejected ordinary responder while rollback state is restored.
@discardableResult
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
handleHandshakeWithResult(packet, from: peerID).processed
@@ -105,15 +129,23 @@ final class BLENoisePacketHandler {
env.broadcastPacket(responsePacket)
}
// Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time
// The serialized authentication callback installs transport
// state before it drains any bounded early ciphertext.
return BLENoiseHandshakeHandlingResult(
processed: true,
didEstablishAuthenticatedSession:
result.didEstablishAuthenticatedSession
)
} catch let managedFailure as NoiseManagedHandshakeFailure {
SecureLogger.error(
"Failed to process handshake; manager owns recovery: \(managedFailure.underlying)"
)
return BLENoiseHandshakeHandlingResult(
processed: false,
didEstablishAuthenticatedSession: false
)
} catch NoiseSessionError.peerIdentityMismatch {
// The 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
// handshake or recreate state for the attacker-selected ID.
SecureLogger.warning(
@@ -143,6 +175,30 @@ final class BLENoisePacketHandler {
}
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
handleEncrypted(packet, from: peerID, isDeferredRetry: false)
}
/// Called by the transport's serialized authentication callback after it
/// has installed state for the promoted or restored session generation.
func handleSessionAuthenticated(_ peerID: PeerID) {
drainDeferredCiphertextsIfReady(for: peerID)
}
/// Synchronously discards ciphertext retained for a pre-panic Noise
/// generation. The handler survives the service's identity replacement,
/// so keeping this queue would replay old bytes after post-panic auth.
func resetForPanic() {
deferredLock.lock()
deferredCiphertexts.removeAll(keepingCapacity: false)
deferredCiphertextBytes = 0
deferredLock.unlock()
}
private func handleEncrypted(
_ packet: BitchatPacket,
from peerID: PeerID,
isDeferredRetry: Bool
) {
let env = environment
guard let recipientID = PeerID(hexData: packet.recipientID) else {
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
@@ -184,19 +240,205 @@ final class BLENoisePacketHandler {
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
} catch NoiseEncryptionError.transportGenerationNotReady {
if isDeferredRetry {
SecureLogger.warning(
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because its authenticated transport generation changed again",
category: .session
)
return
}
// The manager promoted or restored keys before BLE's serialized
// callback installed generation-bound transport state. The
// manager rejected this before decrypting, so replay is safe.
deferCiphertext(packet, from: peerID)
} catch NoiseEncryptionError.sessionNotEstablished {
if isDeferredRetry {
SecureLogger.warning(
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because the authenticated session is unavailable",
category: .session
)
return
}
// We received an encrypted message before establishing a session with this peer.
// 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")
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
} catch {
if isDeferredRetry {
// An early packet cannot tear down the authenticated session
// merely because its single bounded retry still fails.
SecureLogger.warning(
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… after retry failed: \(error)",
category: .session
)
return
}
// A responder may retain an older transport as receive-only
// rollback state while ordinary XX waits for message 3. New-key
// ciphertext can fail against those retained receive keys first.
if env.isAwaitingResponderHandshakeCompletion(peerID) {
if isDeferrableEarlyHandshakeFailure(error) {
deferCiphertext(packet, from: peerID)
} else {
SecureLogger.warning(
"Dropping invalid Noise ciphertext from \(peerID.id.prefix(8))… while responder handshake is completing: \(error)",
category: .session
)
}
return
}
if isDropOnlyCiphertextFailure(error) {
// The packet is attacker-controlled and did not prove a
// transport-state failure. Never let malformed, replayed,
// forged, oversized, or rate-limited bytes evict working keys.
SecureLogger.warning(
"Dropping rejected Noise ciphertext from \(peerID.id.prefix(8))… without clearing its session: \(error)",
category: .security
)
return
}
// Decryption failed - clear the corrupted session and re-initiate handshake
// 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")
env.clearSession(peerID)
env.initiateHandshake(peerID)
}
}
private func isDeferrableEarlyHandshakeFailure(_ error: Error) -> Bool {
if let noiseError = error as? NoiseError {
switch noiseError {
case .authenticationFailure, .replayDetected:
return true
default:
return false
}
}
if let cryptoError = error as? CryptoKitError,
case .authenticationFailure = cryptoError {
return true
}
return false
}
private func isDropOnlyCiphertextFailure(_ error: Error) -> Bool {
if let securityError = error as? NoiseSecurityError {
switch securityError {
case .messageTooLarge, .rateLimitExceeded, .invalidPeerID:
return true
case .sessionExpired, .sessionExhausted:
return false
}
}
if let noiseError = error as? NoiseError {
switch noiseError {
case .invalidCiphertext, .authenticationFailure, .replayDetected:
return true
case .uninitializedCipher, .handshakeComplete,
.handshakeNotComplete, .missingLocalStaticKey,
.missingKeys, .invalidMessage, .invalidPublicKey,
.nonceExceeded:
return false
}
}
return error is CryptoKitError
}
private func deferCiphertext(_ packet: BitchatPacket, from peerID: PeerID) {
guard NoiseSecurityValidator.validatePrivateFileCiphertextSize(
packet.payload
) else {
SecureLogger.warning(
"Dropping oversized early Noise ciphertext from \(peerID.id.prefix(8))",
category: .security
)
return
}
let now = environment.now()
deferredLock.lock()
defer { deferredLock.unlock() }
purgeExpiredCiphertextsLocked(now: now)
let peerCount = deferredCiphertexts[peerID]?.count ?? 0
let globalCount = deferredCiphertexts.values.reduce(0) {
$0 + $1.count
}
guard peerCount < Self.maxDeferredPacketsPerPeer,
globalCount < Self.maxDeferredPacketsGlobal,
deferredCiphertextBytes + packet.payload.count
<= Self.maxDeferredBytes else {
SecureLogger.warning(
"Dropping early Noise ciphertext from \(peerID.id.prefix(8))… because the handshake buffer is full",
category: .security
)
return
}
deferredCiphertexts[peerID, default: []].append(
DeferredCiphertext(packet: packet, receivedAt: now)
)
deferredCiphertextBytes += packet.payload.count
SecureLogger.debug(
"Deferring early Noise ciphertext from \(peerID.id.prefix(8))… until responder handshake completion",
category: .session
)
}
private func drainDeferredCiphertextsIfReady(for peerID: PeerID) {
let env = environment
guard !env.isAwaitingResponderHandshakeCompletion(peerID),
env.hasNoiseSession(peerID) else {
return
}
let now = env.now()
deferredLock.lock()
purgeExpiredCiphertextsLocked(now: now)
let deferred = deferredCiphertexts.removeValue(forKey: peerID) ?? []
deferredCiphertextBytes -= deferred.reduce(0) {
$0 + $1.packet.payload.count
}
deferredLock.unlock()
guard !deferred.isEmpty else { return }
SecureLogger.debug(
"Retrying \(deferred.count) early Noise ciphertext packet(s) from \(peerID.id.prefix(8))… after handshake completion",
category: .session
)
for item in deferred {
handleEncrypted(item.packet, from: peerID, isDeferredRetry: true)
}
}
private func purgeExpiredCiphertextsLocked(now: Date) {
for peerID in Array(deferredCiphertexts.keys) {
guard let items = deferredCiphertexts[peerID] else { continue }
let retained = items.filter {
now.timeIntervalSince($0.receivedAt) <= Self.deferredLifetime
}
guard retained.count != items.count else { continue }
deferredCiphertextBytes -= items.reduce(0) {
$0 + $1.packet.payload.count
}
deferredCiphertextBytes += retained.reduce(0) {
$0 + $1.packet.payload.count
}
if retained.isEmpty {
deferredCiphertexts.removeValue(forKey: peerID)
} else {
deferredCiphertexts[peerID] = retained
}
}
}
}
@@ -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()
}
}
@@ -0,0 +1,556 @@
import BitLogger
import Foundation
enum BLEPrivateMediaReceiptState: Equatable {
/// No durable receiver decision exists for this stable message ID.
case absent
/// The payload is durably mapped to a file that still exists.
case accepted(URL)
/// The user explicitly deleted the payload; retries must not resurrect it.
case tombstoned
/// Durable state could not be read safely. Callers must fail closed and
/// must not save, deliver, or acknowledge the payload.
case unavailable
}
/// Durable, per-message receiver decisions for stable private media.
///
/// Each ID has its own atomic record so one hot lookup never rewrites or
/// decodes the entire ledger. The process-lifetime index is installed only
/// after a complete, successful directory scan. An enumeration, read, decode,
/// or structural-validation failure therefore remains retryable and cannot be
/// mistaken for an empty ledger.
final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
typealias DirectoryReader = (_ directory: URL) throws -> [URL]
typealias DataReader = (_ url: URL) throws -> Data
private static let receiptDirectoryName = ".private-media-receipts"
private struct ReceiptRecord: Codable, Equatable {
enum Kind: String, Codable {
case accepted
case tombstone
}
let kind: Kind
/// Path below the app's `files/` root. Absolute application-container
/// prefixes are not stable across updates, restores, or reinstalls.
let relativePath: String?
let recordedAt: Date
}
private final class Runtime: @unchecked Sendable {
let lock = NSLock()
var records: [String: ReceiptRecord]?
var volatileTombstones: [String: Date] = [:]
}
private let fileManager: FileManager
private let baseDirectory: URL?
private let capacity: Int
private let ttl: TimeInterval
private let now: () -> Date
private let directoryReader: DirectoryReader?
private let dataReader: DataReader?
private let runtime = Runtime()
init(
fileManager: FileManager = .default,
baseDirectory: URL? = nil,
capacity: Int = TransportConfig.privateMediaReceivedLedgerCapacity,
ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds,
now: @escaping () -> Date = Date.init,
directoryReader: DirectoryReader? = nil,
dataReader: DataReader? = nil
) {
self.fileManager = fileManager
self.baseDirectory = baseDirectory
self.capacity = max(1, capacity)
self.ttl = max(0, ttl)
self.now = now
self.directoryReader = directoryReader
self.dataReader = dataReader
}
/// Drops process-lifetime decisions after the enclosing media directory
/// has been panic-wiped. A later lookup must rebuild from the durable
/// ledger instead of retaining an accepted receipt or tombstone whose
/// backing files no longer exist.
func resetForPanic() {
runtime.lock.lock()
runtime.records = nil
runtime.volatileTombstones.removeAll(keepingCapacity: false)
runtime.lock.unlock()
}
func state(for messageID: String) -> BLEPrivateMediaReceiptState {
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
return .absent
}
runtime.lock.lock()
defer { runtime.lock.unlock() }
let date = now()
if let tombstonedAt = runtime.volatileTombstones[messageID] {
if !isExpired(tombstonedAt, at: date) {
return .tombstoned
}
runtime.volatileTombstones.removeValue(forKey: messageID)
}
guard let directory = resolvedReceiptDirectory(),
var records = loadIndexIfNeeded(from: directory, at: date) else {
return .unavailable
}
guard let record = records[messageID] else { return .absent }
if isExpired(record.recordedAt, at: date) {
records.removeValue(forKey: messageID)
runtime.records = records
removeRecord(messageID: messageID, from: directory)
return .absent
}
switch record.kind {
case .tombstone:
removePayloadRecordedByTombstone(record)
return .tombstoned
case .accepted:
guard let relativePath = record.relativePath,
let existingURL = existingPayload(relativePath: relativePath) else {
// Quota cleanup is not explicit deletion. Remove the stale
// receipt so a sender retry can restore the payload and bubble.
records.removeValue(forKey: messageID)
runtime.records = records
removeRecord(messageID: messageID, from: directory)
return .absent
}
return .accepted(existingURL)
}
}
/// Records an accepted ID only after the payload is on disk. Callers must
/// roll the payload back and withhold UI delivery/ACK when this returns
/// false.
func commitAccepted(messageID: String, storedURL: URL) -> Bool {
guard PrivateMediaMessageIdentity.isStableID(messageID),
validExistingPayload(storedURL) != nil,
let relativePath = relativePath(for: storedURL) else {
return false
}
runtime.lock.lock()
defer { runtime.lock.unlock() }
let date = now()
if let tombstonedAt = runtime.volatileTombstones[messageID],
!isExpired(tombstonedAt, at: date) {
return false
}
guard let directory = resolvedReceiptDirectory(),
var records = loadIndexIfNeeded(from: directory, at: date) else {
return false
}
if let existing = records[messageID],
existing.kind == .tombstone,
!isExpired(existing.recordedAt, at: date) {
return false
}
let victim = capacityVictim(
for: .accepted,
replacing: messageID,
in: records
)
if records[messageID]?.kind != .accepted,
records.values.lazy.filter({ $0.kind == .accepted }).count >= capacity,
victim == nil {
return false
}
let record = ReceiptRecord(
kind: .accepted,
relativePath: relativePath,
recordedAt: date
)
guard persist(record, messageID: messageID, to: directory) else {
return false
}
records[messageID] = record
if let victim, victim != messageID {
records.removeValue(forKey: victim)
removeRecord(messageID: victim, from: directory)
}
runtime.records = records
return true
}
/// Foundation for explicit media deletion. This branch does not wire the
/// chat-clear UI; it only makes a tombstone durable and fail closed.
func recordDeleted(messageID: String) -> Bool {
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
return false
}
runtime.lock.lock()
defer { runtime.lock.unlock() }
let date = now()
addVolatileTombstone(messageID, at: date)
guard let directory = resolvedReceiptDirectory(),
var records = loadIndexIfNeeded(from: directory, at: date) else {
runtime.volatileTombstones.removeValue(forKey: messageID)
return false
}
if let existing = records[messageID],
existing.kind == .tombstone,
!isExpired(existing.recordedAt, at: date) {
runtime.volatileTombstones.removeValue(forKey: messageID)
removePayloadRecordedByTombstone(existing)
return true
}
let victim = capacityVictim(
for: .tombstone,
replacing: messageID,
in: records
)
if records[messageID]?.kind != .tombstone,
records.values.lazy.filter({ $0.kind == .tombstone }).count >= capacity,
victim == nil {
runtime.volatileTombstones.removeValue(forKey: messageID)
return false
}
let tombstone = ReceiptRecord(
kind: .tombstone,
// Retain the accepted path so a crash between the atomic record
// write and payload unlink can finish cleanup after relaunch.
relativePath: records[messageID]?.relativePath,
recordedAt: date
)
guard persist(tombstone, messageID: messageID, to: directory) else {
runtime.volatileTombstones.removeValue(forKey: messageID)
return false
}
records[messageID] = tombstone
if let victim, victim != messageID {
records.removeValue(forKey: victim)
removeRecord(messageID: victim, from: directory)
}
runtime.records = records
runtime.volatileTombstones.removeValue(forKey: messageID)
removePayloadRecordedByTombstone(tombstone)
return true
}
private func loadIndexIfNeeded(
from directory: URL,
at date: Date
) -> [String: ReceiptRecord]? {
if let records = runtime.records {
return records
}
do {
try fileManager.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: nil
)
} catch {
SecureLogger.error(
"❌ Failed to create private-media receipt directory: \(error)",
category: .session
)
return nil
}
let urls: [URL]
do {
if let directoryReader {
urls = try directoryReader(directory)
} else {
urls = try fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil,
options: []
)
}
} catch {
SecureLogger.error(
"❌ Failed to enumerate private-media receipts: \(error)",
category: .session
)
return nil
}
var records: [String: ReceiptRecord] = [:]
var expired: [String] = []
var tombstones: [ReceiptRecord] = []
for url in urls {
guard url.pathExtension == "json" else { continue }
let messageID = url.deletingPathExtension().lastPathComponent
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
continue
}
let record: ReceiptRecord
do {
let data = try dataReader?(url) ?? Data(contentsOf: url)
record = try JSONDecoder().decode(ReceiptRecord.self, from: data)
} catch {
// Never delete or skip an unreadable stable-ID record. Treating
// it as absent could resurrect accepted or deleted media.
SecureLogger.error(
"❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)",
category: .session
)
return nil
}
guard isStructurallyValid(record) else {
SecureLogger.error(
"❌ Invalid private-media receipt \(messageID.prefix(12))",
category: .session
)
return nil
}
if isExpired(record.recordedAt, at: date) {
expired.append(messageID)
continue
}
records[messageID] = record
if record.kind == .tombstone {
tombstones.append(record)
}
}
let overflow = overflowVictims(in: records)
for messageID in overflow {
records.removeValue(forKey: messageID)
}
// Install the index only after every stable-ID record was read and
// validated successfully. Cleanup cannot influence a failed scan.
runtime.records = records
for messageID in expired + overflow {
removeRecord(messageID: messageID, from: directory)
}
for tombstone in tombstones {
removePayloadRecordedByTombstone(tombstone)
}
return records
}
private func isStructurallyValid(_ record: ReceiptRecord) -> Bool {
switch record.kind {
case .tombstone:
guard let relativePath = record.relativePath else { return true }
return candidatePayload(relativePath: relativePath) != nil
case .accepted:
guard let relativePath = record.relativePath else { return false }
return candidatePayload(relativePath: relativePath) != nil
}
}
private func isExpired(_ recordedAt: Date, at date: Date) -> Bool {
date.timeIntervalSince(recordedAt) > ttl
}
private func overflowVictims(
in records: [String: ReceiptRecord]
) -> [String] {
var victims: [String] = []
for kind in [ReceiptRecord.Kind.accepted, .tombstone] {
let matching = records.filter { $0.value.kind == kind }
let overflow = matching.count - capacity
guard overflow > 0 else { continue }
victims.append(contentsOf: matching.sorted { lhs, rhs in
if lhs.value.recordedAt == rhs.value.recordedAt {
return lhs.key < rhs.key
}
return lhs.value.recordedAt < rhs.value.recordedAt
}
.prefix(overflow)
.map(\.key))
}
return victims
}
/// Accepted receipts and tombstones have independent capacity. High media
/// volume cannot evict explicit deletion intent, and vice versa.
private func capacityVictim(
for incomingKind: ReceiptRecord.Kind,
replacing messageID: String,
in records: [String: ReceiptRecord]
) -> String? {
guard records[messageID]?.kind != incomingKind else { return nil }
let matching = records.filter {
$0.key != messageID && $0.value.kind == incomingKind
}
guard matching.count >= capacity else { return nil }
return matching.min { lhs, rhs in
if lhs.value.recordedAt == rhs.value.recordedAt {
return lhs.key < rhs.key
}
return lhs.value.recordedAt < rhs.value.recordedAt
}?.key
}
private func persist(
_ record: ReceiptRecord,
messageID: String,
to directory: URL
) -> Bool {
do {
try fileManager.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: nil
)
let data = try JSONEncoder().encode(record)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
let url = recordURL(messageID: messageID, in: directory)
try data.write(to: url, options: options)
return true
} catch {
SecureLogger.error(
"❌ Failed to persist private-media receipt \(messageID.prefix(12))…: \(error)",
category: .session
)
return false
}
}
private func removeRecord(messageID: String, from directory: URL) {
let url = recordURL(messageID: messageID, in: directory)
guard fileManager.fileExists(atPath: url.path) else { return }
do {
try fileManager.removeItem(at: url)
} catch {
SecureLogger.warning(
"⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)",
category: .session
)
}
}
private func recordURL(messageID: String, in directory: URL) -> URL {
directory
.appendingPathComponent(messageID, isDirectory: false)
.appendingPathExtension("json")
}
private func removePayloadRecordedByTombstone(_ record: ReceiptRecord) {
guard record.kind == .tombstone,
let relativePath = record.relativePath,
let payload = candidatePayload(relativePath: relativePath),
fileManager.fileExists(atPath: payload.path) else {
return
}
do {
try fileManager.removeItem(at: payload)
} catch {
SecureLogger.warning(
"⚠️ Failed to remove explicitly deleted private media: \(error)",
category: .session
)
}
}
private func addVolatileTombstone(_ messageID: String, at date: Date) {
runtime.volatileTombstones[messageID] = date
let overflow = runtime.volatileTombstones.count - capacity
guard overflow > 0 else { return }
let oldest = runtime.volatileTombstones.sorted {
if $0.value == $1.value { return $0.key < $1.key }
return $0.value < $1.value
}
for (oldMessageID, _) in oldest.prefix(overflow) {
runtime.volatileTombstones.removeValue(forKey: oldMessageID)
}
}
private func validExistingPayload(_ url: URL) -> URL? {
let standardized = url.standardizedFileURL
guard isInsideFilesDirectory(standardized) else { return nil }
var isDirectory: ObjCBool = false
guard fileManager.fileExists(
atPath: standardized.path,
isDirectory: &isDirectory
), !isDirectory.boolValue else {
return nil
}
return standardized
}
private func relativePath(for url: URL) -> String? {
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
return nil
}
let prefix = filesRoot.path + "/"
let standardized = url.standardizedFileURL
guard standardized.path.hasPrefix(prefix) else { return nil }
let relativePath = String(standardized.path.dropFirst(prefix.count))
return relativePath.isEmpty ? nil : relativePath
}
private func existingPayload(relativePath: String) -> URL? {
guard let candidate = candidatePayload(relativePath: relativePath) else {
return nil
}
return validExistingPayload(candidate)
}
private func candidatePayload(relativePath: String) -> URL? {
guard !relativePath.isEmpty,
let filesRoot = try? filesDirectory().standardizedFileURL else {
return nil
}
let candidate = filesRoot
.appendingPathComponent(relativePath, isDirectory: false)
.standardizedFileURL
guard candidate.path.hasPrefix(filesRoot.path + "/") else { return nil }
return candidate
}
private func isInsideFilesDirectory(_ url: URL) -> Bool {
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
return false
}
return url.standardizedFileURL.path.hasPrefix(filesRoot.path + "/")
}
private func resolvedReceiptDirectory() -> URL? {
return try? filesDirectory().appendingPathComponent(
Self.receiptDirectoryName,
isDirectory: true
)
}
private func filesDirectory() throws -> URL {
let root = try baseDirectory ?? fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let files = root.appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(
at: files,
withIntermediateDirectories: true,
attributes: nil
)
return files
}
}
+562 -62
View File
@@ -241,6 +241,7 @@ final class BLEService: NSObject {
// that the session was established *on this current ingress link*, not
// merely that some session exists for the claimed ID. bleQueue-owned.
private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:]
private var noiseReconnectPolicy = BLENoiseReconnectPolicy()
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
// store): entries older than the cooldown are pruned on insert.
@@ -311,6 +312,9 @@ final class BLEService: NSObject {
/// May block in tests to hold the serial message queue immediately before
/// the deferred private-media admission check.
var _test_beforePrivateMediaDeferredSend: ((String) -> Void)?
/// May block announce handling after verified-link rebind work is queued.
/// Tests use this boundary to prove rebind and reconnect are serialized.
var _test_afterVerifiedDirectRebindEnqueued: (() -> Void)?
#endif
private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker()
@@ -703,6 +707,7 @@ final class BLEService: NSObject {
/// or advertising while the full panic transaction is incomplete.
func suspendForPanicReset() {
setPanicSuspended(true)
noisePacketHandler.resetForPanic()
gossipSyncManager?.stop()
gossipSyncManager = nil
// Stop the radio and drain CoreBluetooth's delegate queue first. A
@@ -713,7 +718,11 @@ final class BLEService: NSObject {
// Drain every receive/send submitted by callbacks that finished ahead
// of the radio stop. Later callbacks observe the closed lifecycle, and
// generation-bound handoffs that raced this barrier reject themselves.
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()
}
@@ -732,6 +741,7 @@ final class BLEService: NSObject {
gossipSyncManager?.stop()
gossipSyncManager = nil
messageQueue.sync(flags: .barrier) {
noisePacketHandler.resetForPanic()
pendingNoiseSessionQueues.removeAll()
}
@@ -764,6 +774,8 @@ final class BLEService: NSObject {
bleQueue.sync {
pendingWriteBuffers.removeAll()
noiseAuthenticatedLinkOwners.removeAll()
noiseReconnectPolicy.removeAll()
connectionScheduler.reset()
}
disconnectNotifyDebouncer.removeAll()
@@ -1061,6 +1073,7 @@ final class BLEService: NSObject {
bleQueue.sync {
linkStateStore.clearAll()
noiseAuthenticatedLinkOwners.removeAll()
noiseReconnectPolicy.removeAll()
connectionScheduler.reset()
subscriptionAnnounceLimiter.removeAll()
}
@@ -1106,6 +1119,51 @@ final class BLEService: NSObject {
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
}
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
let normalizedPeerID = peerID.toShort()
let currentNoiseGeneration =
noiseService.sessionGeneration(for: normalizedPeerID)
return collectionsQueue.sync {
guard let generation =
privateMediaSessionGenerations[normalizedPeerID],
generation == currentNoiseGeneration,
let authenticated =
authenticatedPeerStates[normalizedPeerID],
authenticated.sessionGeneration == generation,
authenticated.capabilities.contains(.privateMedia),
authenticated.capabilities.contains(
.privateMediaReceipts
) else {
return nil
}
return generation
}
}
private func privateMediaPolicyFingerprint(
for peerID: PeerID,
expectedSessionGeneration: UUID?
) -> String? {
let normalizedPeerID = peerID.toShort()
if let expectedSessionGeneration,
noiseService.sessionGeneration(for: normalizedPeerID)
== expectedSessionGeneration,
let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID),
noiseService.sessionGeneration(for: normalizedPeerID)
== expectedSessionGeneration {
// The exact authenticated Noise static key is stronger than a
// registry entry populated by a public announce.
return fingerprint
}
return collectionsQueue.sync {
peerRegistry.info(for: normalizedPeerID)?
.noisePublicKey?
.sha256Fingerprint()
}
}
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
let normalizedPeerID = peerID.toShort()
let state: (
@@ -1133,7 +1191,10 @@ final class BLEService: NSObject {
return .awaitingCapabilityProof
}
guard let fingerprint = state.fingerprint else {
guard let fingerprint = privateMediaPolicyFingerprint(
for: normalizedPeerID,
expectedSessionGeneration: state.sessionGeneration
) ?? state.fingerprint else {
// A raw fallback must be bound to the stable Noise key from a
// verified registry entry; a routing ID alone can rotate or be
// spoofed. Without that key neither proof nor safe migration state
@@ -1187,11 +1248,13 @@ final class BLEService: NSObject {
return
}
let fingerprint: String? = self.collectionsQueue.sync {
self.peerRegistry.info(for: normalizedPeerID)?
.noisePublicKey?
.sha256Fingerprint()
let generation = self.collectionsQueue.sync {
self.privateMediaSessionGenerations[normalizedPeerID]
}
let fingerprint = self.privateMediaPolicyFingerprint(
for: normalizedPeerID,
expectedSessionGeneration: generation
)
guard let fingerprint else {
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
return
@@ -1563,6 +1626,36 @@ final class BLEService: NSObject {
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
sendFilePrivate(
filePacket,
to: peerID,
transferId: transferId,
allowLegacyFallback: allowLegacyFallback,
requiresAuthenticatedPrivateMediaReceipts: false
)
}
func sendFilePrivateReceiptRetry(
_ filePacket: BitchatFilePacket,
to peerID: PeerID,
transferId: String
) {
sendFilePrivate(
filePacket,
to: peerID,
transferId: transferId,
allowLegacyFallback: false,
requiresAuthenticatedPrivateMediaReceipts: true
)
}
private func sendFilePrivate(
_ filePacket: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool,
requiresAuthenticatedPrivateMediaReceipts: Bool
) {
// Register before enqueueing onto messageQueue. This closes the window
// where cancel/delete could run first, observe no scheduler state, and
@@ -1675,6 +1768,25 @@ final class BLEService: NSObject {
self.privateMediaTransferAdmissions.finish(transferId)
return
}
if requiresAuthenticatedPrivateMediaReceipts,
self.authenticatedPrivateMediaReceiptSessionGeneration(
to: targetID
) == nil {
SecureLogger.warning(
"Private media retry blocked without current authenticated receipt support for \(targetID.id.prefix(8))",
category: .security
)
TransferProgressManager.shared.rejectBeforeStart(
id: transferId,
reason: String(
localized: "content.delivery.reason.private_media_capability_unresolved",
defaultValue: "Could not confirm encrypted media support",
comment: "Failure reason when private-media capability negotiation did not resolve"
)
)
self.privateMediaTransferAdmissions.finish(transferId)
return
}
guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else {
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
TransferProgressManager.shared.rejectBeforeStart(
@@ -1685,6 +1797,21 @@ final class BLEService: NSObject {
return
}
guard self.noiseService.hasEstablishedSession(with: targetID) else {
if requiresAuthenticatedPrivateMediaReceipts {
// A retry belongs to one exact authenticated generation.
// Never let it enter the ordinary pending queue where a
// bit-8-only replacement session could later flush it.
TransferProgressManager.shared.rejectBeforeStart(
id: transferId,
reason: String(
localized: "content.delivery.reason.private_media_capability_unresolved",
defaultValue: "Could not confirm encrypted media support",
comment: "Failure reason when private-media capability negotiation did not resolve"
)
)
self.privateMediaTransferAdmissions.finish(transferId)
return
}
let queued = self.collectionsQueue.sync(flags: .barrier) {
self.privateMediaTransferAdmissions.withActive(transferId) {
self.pendingNoiseSessionQueues.appendTypedPayload(
@@ -1716,7 +1843,12 @@ final class BLEService: NSObject {
self.privateMediaTransferAdmissions.finish(transferId)
return
}
let packet = try self.makeEncryptedNoisePacket(typedPayload, to: targetID)
let packet = try self.makeEncryptedNoisePacket(
typedPayload,
to: targetID,
requiresAuthenticatedPrivateMediaReceipts:
requiresAuthenticatedPrivateMediaReceipts
)
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
self.privateMediaTransferAdmissions.finish(transferId)
return
@@ -1950,26 +2082,15 @@ final class BLEService: NSObject {
// Encode once using a small per-type padding policy, then delegate by type
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
// The 256-fragment ceiling exists to protect *current Android*
// receivers, which only ever receive private media over the directed
// raw-file migration fallback (they do not implement the encrypted
// 0x20 path). Encrypted private media (`noiseEncrypted`) is sent only to
// peers that advertised the `.privateMedia` capability modern clients
// that assemble up to the full receiver ceiling (see
// `BLEFragmentAssemblyBuffer`'s 10,000-fragment guard) so forcing them
// down to Android's 256 cap would needlessly reject iOSiOS photos in
// the ~120512 KiB range that work today. Restrict the low cap to the
// migration fallback (directed `fileTransfer`); public media is
// unaffected. Run the same planner the scheduler will use, after route
// application, and reject before reserving a transfer slot or writing
// any fragment.
// TODO(#1434): negotiate an explicit per-peer fragment limit so a future
// Android client that adopts the encrypted 0x20 path but still caps its
// reassembler can advertise its own ceiling instead of relying on the
// capability/type proxy above.
// Cross-platform private-media v1 is bounded by Android's deployed
// 256-fragment receive cap. Run the same planner the scheduler will
// use, after route application, for both encrypted and consented raw
// migration sends. Reject before reserving a transfer slot or writing
// any fragment; public media is intentionally unaffected.
if let transferId,
let recipientPeerID = PeerID(hexData: packetToSend.recipientID),
packetToSend.type == MessageType.fileTransfer.rawValue {
packetToSend.type == MessageType.noiseEncrypted.rawValue
|| packetToSend.type == MessageType.fileTransfer.rawValue {
let compatibilityRequest = BLEOutboundFragmentTransferRequest(
packet: packetToSend,
pad: padForBLE,
@@ -2525,12 +2646,50 @@ final class BLEService: NSObject {
defaultPrefix: defaultPrefix
)
},
privateMediaReceiptState: { [weak self] messageID in
self?.incomingFileStore.privateMediaReceiptState(
messageID: messageID
) ?? .unavailable
},
commitPrivateMediaFile: { [weak self] messageID, storedURL in
self?.incomingFileStore.commitPrivateMediaFile(
messageID: messageID,
storedURL: storedURL
) ?? false
},
removeIncomingFile: { [weak self] storedURL in
self?.incomingFileStore.removeIncomingFile(at: storedURL)
},
isPrivateMediaSenderBlocked: { [weak self] peerID in
guard let self else { return false }
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID)
?? self.collectionsQueue.sync {
self.peerRegistry.info(for: peerID)?.noisePublicKey
}
guard let senderStaticKey else { return false }
return self.identityManager.isBlocked(
fingerprint: senderStaticKey.sha256Fingerprint()
)
},
updatePeerLastSeen: { [weak self] peerID in
self?.updatePeerLastSeen(peerID)
},
deliverMessage: { [weak self] message in
// Single main-actor hop delivering `.messageReceived`.
self?.emitTransportEvent(.messageReceived(message))
acknowledgePrivateMedia: { [weak self] messageID, peerID in
guard let self,
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID),
!self.identityManager.isBlocked(
fingerprint: senderStaticKey.sha256Fingerprint()
) else {
return
}
self.sendDeliveryAck(for: messageID, to: peerID)
},
deliverMessage: { [weak self] message, shouldDeliver, completion in
self?.emitTransportEvent(
.messageReceived(message),
shouldDeliver: shouldDeliver,
completion: completion
)
}
)
}
@@ -2628,6 +2787,7 @@ final class BLEService: NSObject {
}
for link in departedLinks {
noiseAuthenticatedLinkOwners.removeValue(forKey: link)
noiseReconnectPolicy.endLinkEpoch(link)
}
}
_ = collectionsQueue.sync(flags: .barrier) {
@@ -2940,14 +3100,21 @@ extension BLEService: CBCentralManagerDelegate {
startScanning()
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)
central.stopScan()
// Mark all peripheral connections as disconnected (they are now invalid)
let peripheralStates = linkStateStore.peripheralStates
let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID)
for state in peripheralStates {
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()
// Notify UI of disconnections
@@ -2960,7 +3127,6 @@ extension BLEService: CBCentralManagerDelegate {
case .unauthorized:
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
central.stopScan()
_ = linkStateStore.clearPeripherals()
case .unsupported:
@@ -3108,6 +3274,7 @@ extension BLEService: CBCentralManagerDelegate {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = linkStateStore.removePeripheral(peripheralID)
// A duplicate link can drop while the peer stays live on another
// (the dual-role central link, or a second bound link after a
@@ -3162,6 +3329,7 @@ extension BLEService: CBCentralManagerDelegate {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = linkStateStore.removePeripheral(peripheralID)
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
@@ -3269,6 +3437,7 @@ extension BLEService {
self.pendingPeripheralWrites.discardAll(for: peripheralID)
}
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = self.linkStateStore.removePeripheral(peripheralID)
self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
self.tryConnectFromQueue()
@@ -3550,6 +3719,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.
/// Compatibility tests use this to model Android's deployed 0x20 file
/// payload and the short-lived 0x09 prerelease payload without exposing a
@@ -3849,8 +4040,19 @@ extension BLEService: CBPeripheralManagerDelegate {
case .poweredOff:
// Bluetooth was turned off - clean up peripheral state
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
peripheral.stopAdvertising()
// Clear subscribed centrals (they are now invalid)
let centralSnapshot = linkStateStore.subscribedCentralSnapshot
for central in centralSnapshot.centrals {
let centralID = central.identifier.uuidString
noiseAuthenticatedLinkOwners.removeValue(
forKey: .central(centralID)
)
noiseReconnectPolicy.endLinkEpoch(.central(centralID))
}
collectionsQueue.sync(flags: .barrier) {
pendingNotifications.removeAll()
pendingWriteBuffers.removeAll()
}
let centralPeerIDs = linkStateStore.clearCentrals()
subscriptionAnnounceLimiter.removeAll()
characteristic = nil
@@ -3864,7 +4066,6 @@ extension BLEService: CBPeripheralManagerDelegate {
case .unauthorized:
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
peripheral.stopAdvertising()
_ = linkStateStore.clearCentrals()
subscriptionAnnounceLimiter.removeAll()
characteristic = nil
@@ -3977,6 +4178,7 @@ extension BLEService: CBPeripheralManagerDelegate {
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID))
noiseReconnectPolicy.endLinkEpoch(.central(centralID))
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
// Ensure we're still advertising for other devices to find us
@@ -4204,18 +4406,55 @@ extension BLEService {
}
}
private func emitTransportEvent(_ event: TransportEvent) {
private func emitTransportEvent(
_ event: TransportEvent,
shouldDeliver: (() -> Bool)? = nil,
completion: (() -> Void)? = nil
) {
notifyUI { [weak self] in
self?.deliverTransportEvent(event)
guard let self,
shouldDeliver?() ?? true,
self.deliverTransportEvent(event),
// Quota cleanup can race the asynchronous main-actor hop or
// the synchronous ConversationStore upsert. ACK only while
// the exact durable mapping and file still resolve.
shouldDeliver?() ?? true else {
return
}
completion?()
}
}
@MainActor
private func deliverTransportEvent(_ event: TransportEvent) {
@discardableResult
private func deliverTransportEvent(_ event: TransportEvent) -> Bool {
if case .messageReceived(let message) = event {
if let synchronousDelegate =
eventDelegate as? SynchronousMessageTransportEventDelegate {
return synchronousDelegate
.didReceiveTransportMessageSynchronously(message)
}
if let eventDelegate {
eventDelegate.didReceiveTransportEvent(event)
return false
}
if let synchronousDelegate =
delegate as? SynchronousMessageTransportEventDelegate {
return synchronousDelegate
.didReceiveTransportMessageSynchronously(message)
}
}
if let eventDelegate {
eventDelegate.didReceiveTransportEvent(event)
return true
} else {
delegate?.receiveTransportEvent(event)
guard let delegate else { return false }
delegate.receiveTransportEvent(event)
if case .messageReceived = event {
return false
}
return true
}
}
@@ -4594,11 +4833,48 @@ extension BLEService {
})
}
}
/// A peer-level session can outlive the physical link that established it.
/// Revalidate a fresh direct link with an ordinary XX exchange, retiring
/// cached sending keys atomically before message 1 can leave.
private func refreshNoiseSessionForVerifiedDirectLink(
_ packet: BitchatPacket,
peerID: PeerID
) {
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else {
return
}
let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID)
let authenticatedPeerLinks = currentNoiseAuthenticatedLinks(to: peerID)
let shouldRevalidate = readLinkState { store in
guard boundPeerID(for: link, in: store) == peerID else {
return false
}
return noiseReconnectPolicy.shouldRevalidate(
on: link,
hasEstablishedSession: hasEstablishedSession,
isNoiseAuthenticatedLink: noiseAuthenticatedLinkOwners[link] == peerID,
hasAuthenticatedPeerLink: !authenticatedPeerLinks.isEmpty,
now: Date()
)
}
guard shouldRevalidate else { return }
SecureLogger.info(
"🔄 Revalidating cached Noise session on fresh direct link to \(peerID.id.prefix(8))",
category: .session
)
initiateNoiseReconnectHandshake(with: peerID)
}
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))")
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(
peerID: peerID,
fingerprint: fingerprint,
@@ -4606,13 +4882,96 @@ extension BLEService {
)
}
}
service.onRekeyHandshakeReady = { [weak self] peerID, message in
self?.messageQueue.async { [weak self] in
guard let self else { return }
service.onRekeyHandshakeReady = {
[weak self, weak service] peerID, initiation in
self?.messageQueue.async(flags: .barrier) {
[weak self, weak service] in
guard let self,
let service,
self.noiseService === service else {
return
}
self.noteNoiseSessionCleared(for: peerID)
guard let message = service.claimHandshakeInitiation(
initiation,
for: peerID
) else {
return
}
self.broadcastNoiseHandshake(message, to: peerID)
}
}
service.onHandshakeRecoveryRequired = {
[weak self, weak service] request in
guard let self, let service else { return }
self.messageQueue.async(flags: .barrier) {
[weak self, weak service] in
guard let self,
let service,
self.noiseService === service else {
return
}
let peerID = request.peerID
guard self.isPeerReachable(peerID) else {
service.cancelHandshakeRecovery(request)
return
}
do {
guard let preparation =
try service.prepareHandshakeRecovery(request) else {
return
}
switch preparation {
case .ordinary(let initiation):
self.noteNoiseSessionCleared(for: peerID)
guard let handshakeData =
service.claimHandshakeInitiation(
initiation,
for: peerID
) else {
return
}
self.broadcastNoiseHandshake(
handshakeData,
to: peerID
)
case .transferred:
return
}
} catch {
SecureLogger.error(
"Failed to prepare handshake recovery with \(peerID.id.prefix(8))…: \(error)",
category: .session
)
}
}
}
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in
guard let self, let service else { return }
// The manager makes restored keys visible atomically. Reconcile
// transport state and queued sends as the next serialized phase.
self.messageQueue.async(flags: .barrier) { [weak self, weak service] in
guard let self,
let service,
self.noiseService === service,
let fingerprint = service.getPeerFingerprint(peerID) else {
return
}
SecureLogger.debug(
"🔐 Restored quarantined Noise session with \(peerID.id.prefix(8))",
category: .session
)
// Re-enter the same generation-bound transition used after a
// successful handshake. This restores authenticated protocol
// state and drains both PM and typed-payload queues.
self.handleNoisePeerAuthenticated(
peerID: peerID,
fingerprint: fingerprint,
sessionGeneration: generation
)
}
}
}
private func handleNoisePeerAuthenticated(
@@ -4660,7 +5019,16 @@ extension BLEService {
}
) 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)
schedulePrivateMediaProofTimeout(
@@ -4669,6 +5037,10 @@ extension BLEService {
sessionGeneration: generation,
nonce: watchdog.nonce
)
// Cross-link delivery can put ciphertext sent immediately after
// message 3 ahead of message 3 itself. Retry the bounded queue only
// after this generation's transport state has been fully installed.
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
// `onPeerAuthenticated` can fire while the initiator is returning XX
// message 3. This callback is queued behind the handshake handler, so
@@ -4866,8 +5238,9 @@ extension BLEService {
}
return
}
guard noiseService.hasSession(with: peerID) else {
// No session yet - queue the payload SYNCHRONOUSLY before initiating handshake
guard noiseService.hasEstablishedSession(with: peerID) else {
// No established session yet - queue the payload synchronously
// before initiating a handshake
// to prevent race where fast handshake completion drains empty queue
collectionsQueue.sync(flags: .barrier) {
self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID)
@@ -4883,7 +5256,11 @@ extension BLEService {
}
}
private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket {
private func makeEncryptedNoisePacket(
_ typedPayload: Data,
to peerID: PeerID,
requiresAuthenticatedPrivateMediaReceipts: Bool = false
) throws -> BitchatPacket {
let encrypted: Data
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
if isPrivateFile {
@@ -4893,6 +5270,13 @@ extension BLEService {
let authenticated = authenticatedPeerStates[peerID],
authenticated.sessionGeneration == generation,
authenticated.capabilities.contains(.privateMedia) else { return nil }
if requiresAuthenticatedPrivateMediaReceipts {
guard authenticated.capabilities.contains(
.privateMediaReceipts
) else {
return nil
}
}
return generation
}
guard let provenGeneration else {
@@ -5797,6 +6181,7 @@ extension BLEService {
self.pendingPeripheralWrites.discardAll(for: peripheralID)
}
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
_ = self.linkStateStore.removePeripheral(peripheralID)
cancelled += 1
}
@@ -5862,12 +6247,27 @@ extension BLEService {
}
private func initiateNoiseHandshake(with peerID: PeerID) {
// Use NoiseEncryptionService for handshake
guard !noiseService.hasSession(with: peerID) else { return }
let service = noiseService
do {
let handshakeData = try noiseService.initiateHandshake(with: peerID)
broadcastNoiseHandshake(handshakeData, to: peerID)
guard let initiation = try service.initiateHandshakeIfNeeded(
with: peerID,
retryOnTimeout: true
) else {
return
}
messageQueue.async(flags: .barrier) {
[weak self, weak service] in
guard let self,
let service,
self.noiseService === service,
let handshakeData = service.claimHandshakeInitiation(
initiation,
for: peerID
) else {
return
}
self.broadcastNoiseHandshake(handshakeData, to: peerID)
}
} catch {
SecureLogger.error("Failed to initiate handshake: \(error)")
}
@@ -5885,6 +6285,42 @@ extension BLEService {
)
broadcastPacket(packet)
}
/// Starts a wire-compatible ordinary XX reconnect. The manager prepares
/// the initiator before atomically retiring the cached transport; the
/// one-shot claim prevents a crossed inbound message from making a stale
/// message 1 leave after this peer has already become responder.
private func initiateNoiseReconnectHandshake(with peerID: PeerID) {
let service = noiseService
do {
let initiation = try service.initiateReconnectHandshake(
with: peerID,
retryOnTimeout: true
)
messageQueue.async(flags: .barrier) { [weak self, weak service] in
guard let self,
let service,
self.noiseService === service else {
return
}
self.noteNoiseSessionCleared(for: peerID)
guard let handshakeData = service.claimHandshakeInitiation(
initiation,
for: peerID
) else {
return
}
self.broadcastNoiseHandshake(handshakeData, to: peerID)
}
} catch NoiseSessionError.notEstablished {
initiateNoiseHandshake(with: peerID)
} catch {
SecureLogger.error(
"Failed to initiate ordinary reconnect: \(error)",
category: .session
)
}
}
private func sendPendingMessagesAfterHandshake(for peerID: PeerID) {
// Atomically take all pending messages to process (prevents concurrent modification)
@@ -6230,7 +6666,12 @@ extension BLEService {
// MARK: Packet Reception
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 {
guard let lifecycleGeneration =
capturePanicLifecycleGeneration() else {
@@ -6239,7 +6680,8 @@ extension BLEService {
#if DEBUG
_test_beforeReceivePacketHandoff?()
#endif
messageQueue.async { [weak self] in
let flags: DispatchWorkItemFlags = isNoisePacket ? .barrier : []
messageQueue.async(flags: flags) { [weak self] in
guard let self,
self.isCurrentPanicLifecycleGeneration(
lifecycleGeneration
@@ -6249,11 +6691,34 @@ extension BLEService {
#if DEBUG
self._test_onReceivePacketHandoff?()
#endif
self.handleReceivedPacket(packet, from: peerID)
self.handleReceivedPacketOnQueue(packet, from: peerID)
}
return
}
if isNoisePacket {
guard let lifecycleGeneration =
capturePanicLifecycleGeneration() else {
return
}
messageQueue.async(flags: .barrier) { [weak self] in
guard let self,
self.isCurrentPanicLifecycleGeneration(
lifecycleGeneration
) else {
return
}
self.handleReceivedPacketOnQueue(packet, from: peerID)
}
} else {
handleReceivedPacketOnQueue(packet, from: peerID)
}
}
private func handleReceivedPacketOnQueue(
_ packet: BitchatPacket,
from peerID: PeerID
) {
let context = BLEReceivePipeline.context(for: packet, localPeerID: myPeerID)
let senderID = context.senderID
let messageID = context.messageID
@@ -6440,6 +6905,9 @@ extension BLEService {
// consolidate duplicate same-role connections onto that link.
if let result, result.isVerified, result.isDirectAnnounce {
rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID)
#if DEBUG
_test_afterVerifiedDirectRebindEnqueued?()
#endif
retireRedundantPeripheralLinks(packet, to: result.peerID)
}
@@ -6473,11 +6941,9 @@ extension BLEService {
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
if result.isDirectAnnounce,
!hasCurrentNoiseAuthenticatedLink(to: result.peerID) {
if noiseService.hasEstablishedSession(with: result.peerID) {
// A session with no surviving authenticated link is stale;
// force the current link to prove possession again.
clearNoiseSession(for: result.peerID)
}
// A cached session may predate this physical link.
// rebindLinkAfterVerifiedDirectAnnounce performs its atomic
// ordinary reconnect after the binding is published.
if !noiseService.hasSession(with: result.peerID) {
initiateNoiseHandshake(with: result.peerID)
}
@@ -6506,7 +6972,14 @@ extension BLEService {
linkUUID = 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
// from signing because relays mutate it), so a "verified direct"
@@ -6533,12 +7006,20 @@ extension BLEService {
// it across an announce-driven rebind, whose direct TTL is
// replayable; the new owner must complete a fresh handshake.
self.noiseAuthenticatedLinkOwners.removeValue(forKey: link)
self.noiseReconnectPolicy.endLinkEpoch(link)
switch link {
case .peripheral(let peripheralUUID):
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
case .central(let centralUUID):
self.linkStateStore.bindCentral(centralUUID, to: peerID)
}
// Keep the rebind and reconnect decision in one bleQueue critical
// section. No observer may see the new binding while a cached
// peer-level sender is still considered established.
self.refreshNoiseSessionForVerifiedDirectLink(
packet,
peerID: peerID
)
SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))", category: .session)
self.refreshLocalTopology()
// The announce that triggered this rebind was upserted as
@@ -6630,6 +7111,7 @@ extension BLEService {
pendingPeripheralWrites.discardAll(for: uuid)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid))
noiseReconnectPolicy.endLinkEpoch(.peripheral(uuid))
_ = linkStateStore.removePeripheral(uuid)
SecureLogger.info(
"🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")",
@@ -7016,6 +7498,12 @@ extension BLEService {
packet,
from: peerID
)
// An inbound message 1 quarantines the old transport receive-only.
// Keep its generation-bound BLE state intact: the manager's new
// handshaking generation already gates every outbound policy, while
// a rollback can become ready again without repeating capability
// proof or announce side effects. Only the exact handshake candidate's
// authenticated completion may promote the physical ingress link.
if result.didEstablishAuthenticatedSession {
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
}
@@ -7053,6 +7541,11 @@ extension BLEService {
hasNoiseSession: { [weak self] peerID in
self?.noiseService.hasSession(with: peerID) ?? false
},
isAwaitingResponderHandshakeCompletion: { [weak self] peerID in
self?.noiseService.isAwaitingResponderHandshakeCompletion(
with: peerID
) ?? false
},
initiateHandshake: { [weak self] peerID in
self?.initiateNoiseHandshake(with: peerID)
},
@@ -7066,7 +7559,14 @@ extension BLEService {
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
let result = try self.noiseService.decryptWithSessionGeneration(
payload,
from: peerID
from: peerID,
establishedGenerationIsReady: { generation in
self.collectionsQueue.sync {
self.privateMediaSessionGenerations[
peerID.toShort()
] == generation
}
}
)
return BLENoiseDecryptionResult(
plaintext: result.plaintext,
+158 -22
View File
@@ -184,11 +184,17 @@ final class NoiseEncryptionService {
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = []
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
/// Automatic rekey removed the old session and produced XX message 1.
/// The transport must clear session-scoped state and put these exact bytes
/// on the wire; merely reporting "handshake required" strands the partial
/// initiator session because a second initiate call sees it already exists.
var onRekeyHandshakeReady: ((_ peerID: PeerID, _ message: Data) -> Void)?
/// Automatic rekey prepared XX message 1. The transport must claim the
/// exact attempt at its actual BLE handoff; a crossed inbound initiation
/// can invalidate the token before that point.
var onRekeyHandshakeReady:
((_ peerID: PeerID, _ initiation: NoiseHandshakeInitiation) -> Void)?
var onHandshakeRecoveryRequired:
((_ request: NoiseHandshakeRecoveryRequest) -> Void)?
/// An unauthenticated reconnect attempt failed or timed out and the
/// receive-only rollback session became the active transport again.
/// Transport queues must be drained for this exact restored generation.
var onSessionRestoredWithGeneration: ((_ peerID: PeerID, _ generation: UUID) -> Void)?
// Add a handler for peer authentication
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.localPrekeys = LocalPrekeyStore(keychain: keychain)
@@ -309,7 +325,17 @@ final class NoiseEncryptionService {
self.signingPublicKey = signingKey.publicKey
// 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
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
@@ -319,6 +345,12 @@ final class NoiseEncryptionService {
sessionGeneration: generation
)
}
sessionManager.onSessionRestored = { [weak self] peerID, generation in
self?.onSessionRestoredWithGeneration?(peerID, generation)
}
sessionManager.onHandshakeRecoveryRequired = { [weak self] request in
self?.onHandshakeRecoveryRequired?(request)
}
// Start session maintenance timer
startRekeyTimer()
@@ -682,6 +714,90 @@ final class NoiseEncryptionService {
let handshakeData = try sessionManager.initiateHandshake(with: peerID)
return handshakeData
}
/// Atomically admits and prepares one initial ordinary handshake. Returns
/// nil when another discovery callback already created a session.
func initiateHandshakeIfNeeded(
with peerID: PeerID,
retryOnTimeout: Bool = false
) throws -> NoiseHandshakeInitiation? {
guard peerID.isValid else {
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
throw NoiseSecurityError.invalidPeerID
}
guard let initiation = try sessionManager.initiateHandshakeIfAbsent(
with: peerID,
notifyOnTimeout: retryOnTimeout,
authorize: { [rateLimiter] in
guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.warning(
.authenticationFailed(peerID: "Rate limited: \(peerID)")
)
throw NoiseSecurityError.rateLimitExceeded
}
}
) else {
return nil
}
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
return initiation
}
/// Atomically prepares an ordinary reconnect for a peer whose cached
/// transport belongs to an earlier physical link. Failed authorization or
/// handshake setup preserves the established session.
func initiateReconnectHandshake(
with peerID: PeerID,
retryOnTimeout: Bool = false
) throws -> NoiseHandshakeInitiation {
guard peerID.isValid else {
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
throw NoiseSecurityError.invalidPeerID
}
return try sessionManager.initiateReconnectHandshake(
with: peerID,
notifyOnTimeout: retryOnTimeout,
authorize: { [rateLimiter] in
guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.warning(
.authenticationFailed(peerID: "Rate limited: \(peerID)")
)
throw NoiseSecurityError.rateLimitExceeded
}
}
)
}
func prepareHandshakeRecovery(
_ request: NoiseHandshakeRecoveryRequest
) throws -> NoiseHandshakeRecoveryPreparation? {
try sessionManager.prepareHandshakeRecovery(
request,
authorizeAttempt: { [rateLimiter] in
guard rateLimiter.allowHandshake(from: request.peerID) else {
SecureLogger.warning(
.authenticationFailed(
peerID: "Rate limited: \(request.peerID)"
)
)
throw NoiseSecurityError.rateLimitExceeded
}
}
)
}
func cancelHandshakeRecovery(_ request: NoiseHandshakeRecoveryRequest) {
sessionManager.cancelHandshakeRecovery(request)
}
func claimHandshakeInitiation(
_ initiation: NoiseHandshakeInitiation,
for peerID: PeerID
) -> Data? {
sessionManager.claimHandshakeInitiation(initiation, for: peerID)
}
/// Process an incoming handshake message
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
@@ -737,6 +853,13 @@ final class NoiseEncryptionService {
func hasSession(with peerID: PeerID) -> Bool {
return sessionManager.getSession(for: peerID) != nil
}
/// True while an inbound ordinary XX responder is waiting for message 3.
/// A small amount of immediately-following ciphertext may arrive first
/// over BLE and must be retried only after responder promotion.
func isAwaitingResponderHandshakeCompletion(with peerID: PeerID) -> Bool {
sessionManager.isAwaitingResponderHandshakeCompletion(for: peerID)
}
// MARK: - Encryption/Decryption
@@ -804,27 +927,37 @@ final class NoiseEncryptionService {
func decryptWithSessionGeneration(
_ data: Data,
from peerID: PeerID
from peerID: PeerID,
establishedGenerationIsReady: (UUID) -> Bool = { _ in true }
) throws -> (plaintext: Data, sessionGeneration: UUID) {
// 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`.
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
guard isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) else {
throw NoiseSecurityError.messageTooLarge
}
let isAdmittedCiphertext = isStandardCiphertext
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(data)
// Check rate limit
guard rateLimiter.allowMessage(from: peerID) else {
throw NoiseSecurityError.rateLimitExceeded
}
// Check if we have an established session
guard hasEstablishedSession(with: peerID) else {
// A quarantined transport is deliberately unavailable for outbound
// state, but remains receive-only until the responder proves identity
// or the bounded rollback restores it.
guard sessionManager.hasReceiveSession(for: peerID) else {
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 {
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
@@ -943,9 +1076,9 @@ final class NoiseEncryptionService {
}
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)
onRekeyHandshakeReady?(peerID, handshakeMessage)
onRekeyHandshakeReady?(peerID, initiation)
onHandshakeRequired?(peerID)
}
@@ -1041,6 +1174,9 @@ struct NoiseMessage: Codable {
enum NoiseEncryptionError: Error {
case handshakeRequired
case sessionNotEstablished
/// Manager keys are established or restored, but BLE has not installed
/// generation-bound transport state. No receive nonce was consumed.
case transportGenerationNotReady
/// Envelope references a prekey ID we don't hold (never ours, already
/// deleted after its grace window, or wiped in a panic).
case unknownPrekey
+30
View File
@@ -101,6 +101,13 @@ protocol TransportEventDelegate: AnyObject {
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
}
/// Optional typed-event contract for sinks that can synchronously decide
/// whether an inbound message was accepted.
protocol SynchronousMessageTransportEventDelegate: TransportEventDelegate {
@MainActor
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool
}
protocol Transport: AnyObject {
// Event sink
var delegate: BitchatDelegate? { get set }
@@ -183,6 +190,14 @@ protocol Transport: AnyObject {
transferId: String,
allowLegacyFallback: Bool
)
/// Automatic whole-file retry is admitted only while this exact Noise
/// generation authenticates bit 9. It must never queue across a session
/// replacement or enter the signed raw legacy path.
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
)
func cancelTransfer(_ transferId: String)
// Live voice / push-to-talk (mesh transports only): one encoded
@@ -229,6 +244,11 @@ protocol Transport: AnyObject {
/// empty for peers that predate the capabilities TLV.
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
/// The exact current Noise generation that authenticated both encrypted
/// private media (bit 8) and durable receipts/retry (bit 9).
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID?
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
@@ -304,6 +324,11 @@ extension Transport {
func broadcastGroupMessage(_ envelope: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
nil
}
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
@@ -338,6 +363,11 @@ extension Transport {
guard !allowLegacyFallback else { return }
sendFilePrivate(packet, to: peerID, transferId: transferId)
}
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
) {}
func cancelTransfer(_ transferId: String) {}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
+7
View File
@@ -15,6 +15,13 @@ enum TransportConfig {
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
/// Accepted private-media receipts and explicit-deletion tombstones each
/// receive this independent capacity.
static let privateMediaReceivedLedgerCapacity: Int = 4_096
/// A bounded retry horizon prevents stable receipt state from growing into
/// permanent application history.
static let privateMediaReceivedLedgerTTLSeconds: TimeInterval =
7 * 24 * 60 * 60
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
@@ -55,6 +55,9 @@ extension ChatViewModel: ChatDeliveryContext {
func markMessageDelivered(_ messageID: String) {
messageRouter.markDelivered(messageID)
mediaTransferCoordinator.confirmPrivateMediaDelivery(
messageID: messageID
)
}
}
@@ -10,6 +10,7 @@ import Foundation
@MainActor
protocol ChatLiveVoiceContext: AnyObject {
var nickname: String { get }
var myPeerID: PeerID { get }
var selectedPrivateChatPeer: PeerID? { get }
/// Whether the public mesh timeline is what's on screen (autoplay gate
/// for public bursts).
@@ -30,6 +31,12 @@ protocol ChatLiveVoiceContext: AnyObject {
func upsertPublicMeshMessage(_ message: BitchatMessage)
@discardableResult
func removePrivateMessage(withID messageID: String) -> BitchatMessage?
/// Records and sends the finalized note's read receipt after a live
/// bubble adopts its wire-derivable message ID.
func hasSentReadReceipt(_ messageID: String) -> Bool
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
/// Removes a message from whichever conversation holds it.
func removeMessage(withID messageID: String, cleanupFile: Bool)
/// Publishes who is currently talking live in the public mesh channel
@@ -272,8 +279,16 @@ final class ChatLiveVoiceCoordinator {
guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false }
let finished = entry.value
// A DM live bubble starts before the finalized file exists and
// therefore has a receiver-local random ID. Adopt the finalized
// message's deterministic ID so delivery/read ACKs address the same
// row as the sender's media placeholder. Public notes retain their
// live-bubble ID because public transfers have no private receipts.
let replacementID = finished.scope == .directMessage
? message.id
: finished.messageID
let replacement = BitchatMessage(
id: finished.messageID,
id: replacementID,
sender: message.sender,
content: message.content,
timestamp: finished.messageTimestamp,
@@ -287,7 +302,31 @@ final class ChatLiveVoiceCoordinator {
)
switch finished.scope {
case .directMessage:
// Capture read state before rekeying. The user may have read the
// live bubble and navigated away before the finalized .m4a lands.
let shouldSendAdoptedReadReceipt =
context.hasSentReadReceipt(finished.messageID)
|| context.selectedPrivateChatPeer == finished.peerID
// Insert first so replacing the only row in a DM never
// transiently deletes its conversation, unread state, or current
// selection. Then remove the receiver-local live-bubble alias.
context.upsertPrivateMessage(replacement, in: finished.peerID)
if replacementID != finished.messageID {
context.removePrivateMessage(withID: finished.messageID)
}
// The live bubble may already have emitted a receiver-local READ
// before the sender created its finalized media row. Re-emit once
// for the adopted stable ID now that the file has arrived.
if shouldSendAdoptedReadReceipt,
context.markReadReceiptSent(replacementID) {
let receipt = ReadReceipt(
originalMessageID: replacementID,
readerID: context.myPeerID,
readerNickname: context.nickname
)
context.sendMeshReadReceipt(receipt, to: finished.peerID)
}
case .publicMesh:
context.upsertPublicMeshMessage(replacement)
}
File diff suppressed because it is too large Load Diff
@@ -100,9 +100,14 @@ final class ChatPeerListCoordinator: @unchecked Sendable {
func didUpdatePeerList(_ peers: [PeerID]) {
Task { @MainActor [weak self] in
self?.handlePeerListUpdate(peers)
self?.didUpdatePeerListSynchronously(peers)
}
}
@MainActor
func didUpdatePeerListSynchronously(_ peers: [PeerID]) {
handlePeerListUpdate(peers)
}
}
private extension ChatPeerListCoordinator {
@@ -163,21 +163,20 @@ final class ChatTransportEventCoordinator {
}
func didReceiveMessage(_ message: BitchatMessage) {
runOnMain { context in
guard !context.isMessageBlocked(message) else { return }
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
if message.isPrivate {
context.handlePrivateMessage(message)
} else {
context.handlePublicMessage(message)
}
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
runOnMain { [self] context in
handleReceivedMessage(message, in: context)
}
}
/// Typed transport events already arrive on the main actor. Handle them
/// synchronously so observers see the ConversationStore mutation before
/// the transport completes delivery.
@MainActor
@discardableResult
func didReceiveMessageSynchronously(_ message: BitchatMessage) -> Bool {
handleReceivedMessage(message, in: context)
}
func didReceivePublicMessage(
from peerID: PeerID,
nickname: String,
@@ -185,28 +184,36 @@ final class ChatTransportEventCoordinator {
timestamp: Date,
messageID: String?
) {
runOnMain { context in
let normalized = content.trimmed
let mentions = context.parseMentions(from: normalized)
let message = BitchatMessage(
id: messageID,
sender: nickname,
content: normalized,
runOnMain { [self] context in
handlePublicMessage(
from: peerID,
nickname: nickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: mentions.isEmpty ? nil : mentions
messageID: messageID,
in: context
)
context.handlePublicMessage(message)
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
}
@MainActor
func didReceivePublicMessageSynchronously(
from peerID: PeerID,
nickname: String,
content: String,
timestamp: Date,
messageID: String?
) {
handlePublicMessage(
from: peerID,
nickname: nickname,
content: content,
timestamp: timestamp,
messageID: messageID,
in: context
)
}
func didReceiveNoisePayload(
from peerID: PeerID,
type: NoisePayloadType,
@@ -224,59 +231,134 @@ final class ChatTransportEventCoordinator {
}
}
@MainActor
func didReceiveNoisePayloadSynchronously(
from peerID: PeerID,
type: NoisePayloadType,
payload: Data,
timestamp: Date
) {
handleNoisePayload(
from: peerID,
type: type,
payload: payload,
timestamp: timestamp,
in: context
)
}
func didConnectToPeer(_ peerID: PeerID) {
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
runOnMain { context in
context.isConnected = true
context.registerEphemeralSession(peerID: peerID)
context.notifyUIChanged()
if let peer = context.unifiedPeer(for: peerID) {
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
context.cacheStablePeerID(stablePeerID, for: peerID)
}
context.flushRouterOutbox(for: peerID)
context.retryCourierDeposits(via: peerID)
runOnMain { [weak self] _ in
self?.didConnectToPeerSynchronously(peerID)
}
}
@MainActor
func didConnectToPeerSynchronously(_ peerID: PeerID) {
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
context.isConnected = true
context.registerEphemeralSession(peerID: peerID)
context.notifyUIChanged()
if let peer = context.unifiedPeer(for: peerID) {
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
context.cacheStablePeerID(stablePeerID, for: peerID)
}
context.flushRouterOutbox(for: peerID)
context.retryCourierDeposits(via: peerID)
}
func didDisconnectFromPeer(_ peerID: PeerID) {
runOnMain { [weak self] _ in
self?.didDisconnectFromPeerSynchronously(peerID)
}
}
@MainActor
func didDisconnectFromPeerSynchronously(_ peerID: PeerID) {
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
runOnMain { context in
context.removeEphemeralSession(peerID: peerID)
context.removeEphemeralSession(peerID: peerID)
var stablePeerID = context.cachedStablePeerID(for: peerID)
if stablePeerID == nil,
let key = context.noiseSessionPublicKeyData(for: peerID) {
let derivedPeerID = PeerID(hexData: key)
context.cacheStablePeerID(derivedPeerID, for: peerID)
stablePeerID = derivedPeerID
}
if let currentPeerID = context.selectedPrivateChatPeer,
currentPeerID == peerID,
let stablePeerID {
self.migrateSelectedConversationIfNeeded(
from: peerID,
to: stablePeerID,
in: context
)
}
let receiptIDs = context.privateMessages(for: peerID)
.filter { $0.senderPeerID == peerID }
.map(\.id)
context.unmarkReadReceiptsSent(receiptIDs)
context.notifyUIChanged()
var stablePeerID = context.cachedStablePeerID(for: peerID)
if stablePeerID == nil,
let key = context.noiseSessionPublicKeyData(for: peerID) {
let derivedPeerID = PeerID(hexData: key)
context.cacheStablePeerID(derivedPeerID, for: peerID)
stablePeerID = derivedPeerID
}
if let currentPeerID = context.selectedPrivateChatPeer,
currentPeerID == peerID,
let stablePeerID {
migrateSelectedConversationIfNeeded(
from: peerID,
to: stablePeerID,
in: context
)
}
let receiptIDs = context.privateMessages(for: peerID)
.filter { $0.senderPeerID == peerID }
.map(\.id)
context.unmarkReadReceiptsSent(receiptIDs)
context.notifyUIChanged()
}
}
private extension ChatTransportEventCoordinator {
@MainActor
func handlePublicMessage(
from peerID: PeerID,
nickname: String,
content: String,
timestamp: Date,
messageID: String?,
in context: any ChatTransportEventContext
) {
let normalized = content.trimmed
let mentions = context.parseMentions(from: normalized)
let message = BitchatMessage(
id: messageID,
sender: nickname,
content: normalized,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: mentions.isEmpty ? nil : mentions
)
context.handlePublicMessage(message)
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
@MainActor
@discardableResult
func handleReceivedMessage(
_ message: BitchatMessage,
in context: any ChatTransportEventContext
) -> Bool {
guard !context.isMessageBlocked(message) else { return false }
guard !message.content.trimmed.isEmpty || message.isPrivate else { return false }
if message.isPrivate {
context.handlePrivateMessage(message)
} else {
context.handlePublicMessage(message)
}
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
return true
}
func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) {
Task { @MainActor [weak context = self.context] in
guard let context else { return }
@@ -58,6 +58,7 @@ protocol ChatVerificationContext: AnyObject {
func noiseStaticPublicKeyData() -> Data
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
func triggerHandshake(with peerID: PeerID)
func privateMediaPeerDidAuthenticate(_ peerID: PeerID)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
@@ -116,6 +117,10 @@ extension ChatViewModel: ChatVerificationContext {
meshService.noiseStaticPublicKeyData()
}
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {
mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort())
}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
}
@@ -129,6 +134,10 @@ extension ChatViewModel: ChatVerificationContext {
}
}
extension ChatVerificationContext {
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {}
}
@MainActor
final class ChatVerificationCoordinator {
struct PendingVerification {
@@ -197,6 +206,7 @@ final class ChatVerificationCoordinator {
guard let self else { return }
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
self.context.privateMediaPeerDidAuthenticate(peerID)
if self.context.isVerifiedFingerprint(fingerprint) {
self.context.setEncryptionStatus(.noiseVerified, for: peerID)
+86 -2
View File
@@ -112,7 +112,7 @@ struct PanicNetworkLifecycle {
/// Manages the application state and business logic for BitChat.
/// Acts as the primary coordinator between UI components and backend services,
/// implementing the BitchatDelegate protocol to handle network events.
final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessageTransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
// Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled)
typealias Patterns = MessageFormattingEngine.Patterns
@@ -495,6 +495,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
}
}
/// Whether a read receipt has already been recorded for `messageID`.
@MainActor
func hasSentReadReceipt(_ messageID: String) -> Bool {
sentReadReceipts.contains(messageID)
}
/// Records that a read receipt is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@MainActor
@@ -1701,7 +1707,82 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
@MainActor
func didReceiveTransportEvent(_ event: TransportEvent) {
receiveTransportEvent(event)
switch event {
case .messageReceived(let message):
_ = didReceiveTransportMessageSynchronously(message)
case let .publicMessageReceived(
peerID,
nickname,
content,
timestamp,
messageID
):
transportEventCoordinator.didReceivePublicMessageSynchronously(
from: peerID,
nickname: nickname,
content: content,
timestamp: timestamp,
messageID: messageID
)
case let .noisePayloadReceived(peerID, type, payload, timestamp):
transportEventCoordinator.didReceiveNoisePayloadSynchronously(
from: peerID,
type: type,
payload: payload,
timestamp: timestamp
)
case let .groupMessageReceived(payload, timestamp):
groupCoordinator.handleGroupMessagePayload(
payload,
timestamp: timestamp
)
case let .publicVoiceFrameReceived(
peerID,
nickname,
payload,
timestamp
):
liveVoiceCoordinator.handlePublicVoiceFramePayload(
from: peerID,
nickname: nickname,
payload: payload,
timestamp: timestamp
)
case .peerConnected(let peerID):
transportEventCoordinator.didConnectToPeerSynchronously(peerID)
mediaTransferCoordinator.peerDidReconnect(peerID)
case .peerDisconnected(let peerID):
transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID)
case .peerListUpdated(let peers):
peerListCoordinator.didUpdatePeerListSynchronously(peers)
// A peer-list update follows every verified announce, which is
// where a peer's `.vouch` capability actually arrives.
vouchCoordinator.peersUpdated(peers)
case .peerSnapshotsUpdated:
break
case let .messageDeliveryStatusUpdated(messageID, status):
deliveryCoordinator.didUpdateMessageDeliveryStatus(
messageID,
status: status
)
case .bluetoothStateUpdated(let state):
updateBluetoothState(state)
}
}
@MainActor
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool {
transportEventCoordinator.didReceiveMessageSynchronously(message)
}
func didReceiveMessage(_ message: BitchatMessage) {
@@ -1764,6 +1845,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func didConnectToPeer(_ peerID: PeerID) {
transportEventCoordinator.didConnectToPeer(peerID)
Task { @MainActor [weak self] in
self?.mediaTransferCoordinator.peerDidReconnect(peerID)
}
}
func didDisconnectFromPeer(_ peerID: PeerID) {
+225 -11
View File
@@ -502,14 +502,26 @@ struct BLEServiceCoreTests {
)
let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
#expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink))
let rebindGate = VerifiedDirectRebindGate()
ble._test_afterVerifiedDirectRebindEnqueued = rebindGate.pause
defer {
rebindGate.release()
ble._test_afterVerifiedDirectRebindEnqueued = nil
}
ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false)
let rebound = await TestHelpers.waitUntil(
{ ble._test_centralBinding(attackerLink) == victimPeerID },
let announcePaused = await TestHelpers.waitUntil(
{ rebindGate.hasPaused },
timeout: TestConstants.longTimeout
)
#expect(rebound)
#expect(ble.canDeliverSecurely(to: victimPeerID))
try #require(announcePaused)
// Rebind and ordinary reconnect preparation are one bleQueue
// critical section. Once the binding is visible, stale sending keys
// must already be unavailable.
#expect(ble._test_centralBinding(attackerLink) == victimPeerID)
#expect(!ble.canDeliverSecurely(to: victimPeerID))
rebindGate.release()
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = { outbound.record($0) }
@@ -537,20 +549,26 @@ struct BLEServiceCoreTests {
// Preserve a working victim session while an unauthenticated
// 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(
try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
)
let message3 = try #require(
try ble._test_noiseProcessHandshakeMessage(
from: victimPeerID,
message: message1
)
)
let message3 = try #require(
try victim.processHandshakeMessage(
from: ble.myPeerID,
message: message2
)
)
_ = try victim.processHandshakeMessage(
from: ble.myPeerID,
_ = try ble._test_noiseProcessHandshakeMessage(
from: victimPeerID,
message: message3
)
await ble._test_drainNoiseMessagePipeline()
#expect(ble.canDeliverSecurely(to: victimPeerID))
let centralUUID = "central-replacement-xx-message-one"
@@ -614,7 +632,168 @@ struct BLEServiceCoreTests {
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
@@ -943,6 +1122,40 @@ private final class OutboundPacketTap {
lock.lock(); defer { lock.unlock() }
return packets.filter { $0.type == type.rawValue }.count
}
func snapshot() -> [BitchatPacket] {
lock.lock(); defer { lock.unlock() }
return packets
}
}
private final class VerifiedDirectRebindGate: @unchecked Sendable {
private let condition = NSCondition()
private var paused = false
private var released = false
var hasPaused: Bool {
condition.lock()
defer { condition.unlock() }
return paused
}
func pause() {
condition.lock()
paused = true
condition.broadcast()
while !released {
condition.wait()
}
condition.unlock()
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
}
private final class ReceivePacketHandoffGate: @unchecked Sendable {
@@ -1076,6 +1289,7 @@ private final class PublicCaptureDelegate: BitchatDelegate {
defer { lock.unlock() }
return publicMessages
}
}
@MainActor
@@ -14,6 +14,7 @@ import BitFoundation
@MainActor
private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
var nickname = "me"
var myPeerID = PeerID(str: "0102030405060708")
var selectedPrivateChatPeer: PeerID?
var isViewingPublicMeshTimeline = false
var blockedPeers: Set<PeerID> = []
@@ -23,7 +24,10 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = []
private(set) var upsertedPublicMessages: [BitchatMessage] = []
private(set) var removedMessageIDs: [String] = []
private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
private(set) var talkerUpdates: [String?] = []
private(set) var privateMutationLog: [String] = []
private var readReceiptMessageIDs: Set<String> = []
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
func resolveNickname(for peerID: PeerID) -> String { "alice" }
@@ -31,6 +35,7 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) }
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
upsertedMessages.append((message, peerID))
privateMutationLog.append("upsert:\(message.id)")
}
func upsertPublicMeshMessage(_ message: BitchatMessage) {
upsertedPublicMessages.append(message)
@@ -38,8 +43,18 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
@discardableResult
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
removedMessageIDs.append(messageID)
privateMutationLog.append("remove:\(messageID)")
return nil
}
func hasSentReadReceipt(_ messageID: String) -> Bool {
readReceiptMessageIDs.contains(messageID)
}
func markReadReceiptSent(_ messageID: String) -> Bool {
readReceiptMessageIDs.insert(messageID).inserted
}
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
sentReadReceipts.append((receipt, peerID))
}
func removeMessage(withID messageID: String, cleanupFile: Bool) {
removedMessageIDs.append(messageID)
}
@@ -151,17 +166,29 @@ struct ChatLiveVoiceCoordinatorTests {
@Test func absorbsFinalizedNoteIntoLiveBubble() throws {
let context = MockChatLiveVoiceContext()
context.selectedPrivateChatPeer = peer
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0xB2)
let hex = burstID.hexEncodedString()
let fileName = "voice_\(hex).m4a"
let stableMessageID = try #require(PrivateMediaMessageIdentity.stableID(
senderPeerID: peer,
recipientPeerID: context.myPeerID,
fileName: fileName
))
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
let bubble = try #require(context.handledPrivateMessages.first)
// The user read the live bubble, then left before the finalized file
// arrived. Stable-ID adoption must preserve that read state.
#expect(context.markReadReceiptSent(bubble.id))
context.selectedPrivateChatPeer = nil
let note = BitchatMessage(
id: stableMessageID,
sender: "alice",
content: "[voice] voice_\(hex).m4a",
content: "[voice] \(fileName)",
timestamp: Date(),
isRelay: false,
isPrivate: true,
@@ -170,12 +197,21 @@ struct ChatLiveVoiceCoordinatorTests {
)
#expect(coordinator.absorbFinalizedVoiceNote(note))
// The note replaced the live bubble in place: same message ID, new
// content, partial capture deleted.
// The finalized note adopts the sender-correlatable ID, removes the
// receiver-local live ID, and emits a fresh READ now that the sender
// has created its finalized media row.
let replacement = try #require(context.upsertedMessages.last)
#expect(replacement.message.id == bubble.id)
#expect(replacement.message.id == stableMessageID)
#expect(replacement.message.content == note.content)
#expect(replacement.peerID == peer)
#expect(context.removedMessageIDs.contains(bubble.id))
#expect(Array(context.privateMutationLog.suffix(2)) == [
"upsert:\(stableMessageID)",
"remove:\(bubble.id)"
])
#expect(context.sentReadReceipts.count == 1)
#expect(context.sentReadReceipts.first?.receipt.originalMessageID == stableMessageID)
#expect(context.sentReadReceipts.first?.peerID == peer)
// The promoted partial capture is deleted in favor of the note.
let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
#expect(!FileManager.default.fileExists(atPath: url.path))
@@ -449,7 +485,8 @@ struct ChatLiveVoiceCoordinatorTests {
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
)
#expect(coordinator.absorbFinalizedVoiceNote(dmNote))
#expect(try #require(context.upsertedMessages.last).message.id == dmBubble.id)
#expect(try #require(context.upsertedMessages.last).message.id == dmNote.id)
#expect(context.removedMessageIDs.contains(dmBubble.id))
}
@Test func finalizedNoteBindsToItsAuthenticatedSender() throws {
@@ -479,8 +516,9 @@ struct ChatLiveVoiceCoordinatorTests {
)
#expect(coordinator.absorbFinalizedVoiceNote(note))
let replacement = try #require(context.upsertedMessages.last)
#expect(replacement.message.id == victimBubble.id)
#expect(replacement.message.id == note.id)
#expect(replacement.peerID == peer)
#expect(context.removedMessageIDs.contains(victimBubble.id))
// The attacker's note can only ever claim the attacker's own bubble.
let attackerNote = BitchatMessage(
@@ -489,8 +527,9 @@ struct ChatLiveVoiceCoordinatorTests {
)
#expect(coordinator.absorbFinalizedVoiceNote(attackerNote))
let attackerReplacement = try #require(context.upsertedMessages.last)
#expect(attackerReplacement.message.id == attackerBubble.id)
#expect(attackerReplacement.message.id == attackerNote.id)
#expect(attackerReplacement.peerID == attacker)
#expect(context.removedMessageIDs.contains(attackerBubble.id))
// Both registry entries are consumed nothing left to hijack.
#expect(!coordinator.absorbFinalizedVoiceNote(note))
@@ -8,7 +8,7 @@
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Real file/codec work remains covered by `ChatMediaPreparationTests`. These
// tests inject a paused voice-note preparer to exercise cancellation ownership
// tests inject paused media preparers to exercise cancellation ownership
// across the detached-preparation/MainActor boundary deterministically.
//
@@ -89,12 +89,26 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
}
// Mesh file transfer
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
private(set) var privateFileSends: [(
packet: BitchatFilePacket,
peerID: PeerID,
transferId: String
)] = []
private(set) var privateFileLegacyAllowances: [Bool] = []
private(set) var privateFileReceiptRetryTransferIDs: [String] = []
private(set) var broadcastFileSends: [String] = []
private(set) var cancelledTransfers: [String] = []
private(set) var privateMediaPolicyResolutionRequests: [PeerID] = []
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
var resolvesPrivateMediaPolicyImmediately = true
var supportsAuthenticatedPrivateMediaReceipts = false
var authenticatedPrivateMediaReceiptGeneration = UUID(
uuidString: "00000000-0000-0000-0000-000000000001"
)!
private var pendingPrivateMediaPolicyResolutions: [
@MainActor (PrivateMediaSendPolicy) -> Void
] = []
private(set) var legacyConsentRequests: [(
id: UUID,
peerID: PeerID,
@@ -109,11 +123,40 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
privateMediaPolicy
}
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
supportsAuthenticatedPrivateMediaReceipts
? authenticatedPrivateMediaReceiptGeneration
: nil
}
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
privateMediaPolicyResolutionRequests.append(peerID)
if resolvesPrivateMediaPolicyImmediately {
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
} else {
pendingPrivateMediaPolicyResolutions.append(completion)
}
}
var pendingPrivateMediaPolicyResolutionCount: Int {
pendingPrivateMediaPolicyResolutions.count
}
func resolveNextPrivateMediaPolicy(
_ policy: PrivateMediaSendPolicy? = nil
) {
guard !pendingPrivateMediaPolicyResolutions.isEmpty else { return }
let completion = pendingPrivateMediaPolicyResolutions.removeFirst()
completion(
policy
?? resolvedPrivateMediaPolicy
?? privateMediaPolicy
)
}
func requestLegacyPrivateMediaConsent(
@@ -154,10 +197,20 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
transferId: String,
allowLegacyFallback: Bool
) {
privateFileSends.append((peerID, transferId))
privateFileSends.append((packet, peerID, transferId))
privateFileLegacyAllowances.append(allowLegacyFallback)
}
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
) {
privateFileSends.append((packet, peerID, transferId))
privateFileLegacyAllowances.append(false)
privateFileReceiptRetryTransferIDs.append(transferId)
}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
broadcastFileSends.append(transferId)
}
@@ -172,19 +225,8 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
private var started = false
private var released = false
private var finished = false
private let packet: BitchatFilePacket
init() {
let content = Data("voice".utf8)
packet = BitchatFilePacket(
fileName: "paused.m4a",
fileSize: UInt64(content.count),
mimeType: "audio/mp4",
content: content
)
}
func prepare(_: URL) throws -> BitchatFilePacket {
func prepare(_ url: URL) throws -> BitchatFilePacket {
condition.lock()
started = true
condition.broadcast()
@@ -194,7 +236,13 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
finished = true
condition.broadcast()
condition.unlock()
return packet
let content = Data("voice".utf8)
return BitchatFilePacket(
fileName: url.lastPathComponent,
fileSize: UInt64(content.count),
mimeType: "audio/mp4",
content: content
)
}
var hasStarted: Bool {
@@ -217,6 +265,59 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
}
}
private final class StaticVoiceNotePreparer: @unchecked Sendable {
private let packet: BitchatFilePacket
init(fileName: String, content: Data = Data("voice".utf8)) {
packet = BitchatFilePacket(
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: "audio/mp4",
content: content
)
}
func prepare(_ _: URL) throws -> BitchatFilePacket {
packet
}
}
private final class DeterministicMediaTransferIDFactory:
@unchecked Sendable {
private let lock = NSLock()
private var nextOrdinal = 0
func make(messageID: String) -> String {
lock.lock()
defer {
nextOrdinal += 1
lock.unlock()
}
return "\(messageID)-attempt-\(nextOrdinal)"
}
}
private final class MutableMediaRetryClock: @unchecked Sendable {
private let lock = NSLock()
private var value: Date
init(_ value: Date) {
self.value = value
}
func now() -> Date {
lock.lock()
defer { lock.unlock() }
return value
}
func advance(by interval: TimeInterval) {
lock.lock()
value = value.addingTimeInterval(interval)
lock.unlock()
}
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatMediaTransferCoordinator` against
@@ -452,6 +553,109 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(coordinator.transferIdToMessageIDs.isEmpty)
}
@Test @MainActor
func privateVoiceNoteUsesWireDerivableMessageID() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("voice_receipt_\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let message = try #require(context.privateChats[peerID]?.first)
let sentPacket = try #require(context.privateFileSends.first?.packet)
#expect(message.id == PrivateMediaMessageIdentity.stableID(
for: sentPacket,
senderPeerID: context.myPeerID,
recipientPeerID: peerID
))
}
@Test @MainActor
func privateImageUsesWireDerivableMessageID() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "99aabbccddeeff00")
context.selectedPrivateChatPeer = peerID
let sourceURL = try makeCoordinatorTestImageURL()
defer { try? FileManager.default.removeItem(at: sourceURL) }
coordinator.sendImage(from: sourceURL)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let message = try #require(context.privateChats[peerID]?.first)
let sentPacket = try #require(context.privateFileSends.first?.packet)
#expect(message.id == PrivateMediaMessageIdentity.stableID(
for: sentPacket,
senderPeerID: context.myPeerID,
recipientPeerID: peerID
))
coordinator.cleanupLocalFile(forMessage: message)
}
@Test @MainActor
func panicDuringImagePreparationDeletesStaleOutputWithoutSideEffects() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "99aabbccddeeff00")
context.selectedPrivateChatPeer = peerID
let sourceURL = try makeCoordinatorTestImageURL()
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-stale-image-\(UUID().uuidString).jpg"
)
let preparer = PausedImagePreparer(outputURL: outputURL)
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareImagePacket: { url in try preparer.prepare(url) }
)
defer {
preparer.release()
try? FileManager.default.removeItem(at: sourceURL)
try? FileManager.default.removeItem(at: outputURL)
}
coordinator.sendImage(from: sourceURL)
#expect(await TestHelpers.waitUntil(
{ preparer.hasStarted },
timeout: TestConstants.longTimeout
))
DispatchQueue.global(qos: .userInitiated).asyncAfter(
deadline: .now() + .milliseconds(100)
) {
preparer.release()
}
coordinator.resetForPanic()
#expect(await TestHelpers.waitUntil(
{ preparer.hasFinished },
timeout: TestConstants.longTimeout
))
#expect(await TestHelpers.waitUntil(
{ !FileManager.default.fileExists(atPath: outputURL.path) },
timeout: TestConstants.longTimeout
))
#expect(context.privateChats[peerID]?.isEmpty != false)
#expect(context.appendedPublicMessages.isEmpty)
#expect(context.privateFileSends.isEmpty)
#expect(context.broadcastFileSends.isEmpty)
#expect(context.systemMessages.isEmpty)
#expect(context.deliveryStatusUpdates.isEmpty)
#expect(coordinator.transferIdToMessageIDs.isEmpty)
#expect(coordinator.messageIDToTransferId.isEmpty)
}
@Test @MainActor
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
let context = MockChatMediaTransferContext()
@@ -696,6 +900,548 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(context.legacyConsentRequests.isEmpty)
#expect(context.privateFileSends.isEmpty)
}
@Test @MainActor
func receiptCapableEncryptedMediaRetriesExactPacketAfterReconnect() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.supportsAuthenticatedPrivateMediaReceipts = true
let fileName = "voice_0011223344556677.m4a"
let preparer = StaticVoiceNotePreparer(fileName: fileName)
let transferIDs = DeterministicMediaTransferIDFactory()
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try preparer.prepare(url)
},
transferIDFactory: transferIDs.make
)
let url = try makeCoordinatorVoiceURL(fileName: fileName)
defer {
try? FileManager.default.removeItem(
at: url.deletingLastPathComponent()
)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let messageID = try #require(
context.privateChats[peerID]?.first?.id
)
let initial = try #require(
context.privateFileSends.first
)
#expect(PrivateMediaMessageIdentity.isStableID(messageID))
#expect(coordinator.retainedReconnectRetryCount == 1)
coordinator.handleTransferEvent(.completed(
id: initial.transferId,
totalFragments: 1
))
#expect(coordinator.retainedReconnectRetryCount == 1)
#expect(context.deliveryStatusUpdates.last?.status == .sent)
coordinator.peerDidReconnect(peerID)
#expect(context.privateFileSends.count == 2)
let retry = try #require(context.privateFileSends.last)
#expect(retry.packet.encode() == initial.packet.encode())
#expect(retry.peerID == peerID)
#expect(retry.transferId != initial.transferId)
#expect(context.privateFileReceiptRetryTransferIDs == [
retry.transferId
])
#expect(context.privateFileLegacyAllowances == [false, false])
coordinator.confirmPrivateMediaDelivery(messageID: messageID)
#expect(coordinator.retainedReconnectRetryCount == 0)
#expect(context.cancelledTransfers == [retry.transferId])
#expect(context.removedMessages.isEmpty)
#expect(!context.deliveryStatusUpdates.contains {
if case .failed = $0.status { return true }
return false
})
// Receipt confirmation removes retry ownership before transport
// cancellation, so its late callback cannot delete the delivered row
// or re-arm another reconnect retry.
coordinator.handleTransferEvent(.cancelled(
id: retry.transferId,
sentFragments: 1,
totalFragments: 2
))
coordinator.peerDidReconnect(peerID)
#expect(context.privateFileSends.count == 2)
#expect(context.removedMessages.isEmpty)
#expect(coordinator.messageIDToTransferId[messageID] == nil)
}
@Test @MainActor
func bit8OnlyEncryptedMediaNeverRetainsOrAutomaticallyRetries() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .encrypted
context.supportsAuthenticatedPrivateMediaReceipts = false
let fileName = "voice_1111222233334444.m4a"
let preparer = StaticVoiceNotePreparer(fileName: fileName)
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try preparer.prepare(url)
}
)
let url = try makeCoordinatorVoiceURL(fileName: fileName)
defer {
try? FileManager.default.removeItem(
at: url.deletingLastPathComponent()
)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let initial = try #require(context.privateFileSends.first)
coordinator.handleTransferEvent(.completed(
id: initial.transferId,
totalFragments: 1
))
coordinator.peerDidReconnect(peerID)
coordinator.peerDidAuthenticate(peerID)
#expect(coordinator.retainedReconnectRetryCount == 0)
#expect(context.privateFileSends.count == 1)
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
#expect(context.privateMediaPolicyResolutionRequests.isEmpty)
}
@Test @MainActor
func consentedRawLegacyMediaNeverEntersAutomaticRetry() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .legacyRequiresConsent
// Even a contradictory stale bit-9 observation must not retain an
// invocation that actually selected the explicit raw path.
context.supportsAuthenticatedPrivateMediaReceipts = true
let fileName = "voice_2222333344445555.m4a"
let preparer = StaticVoiceNotePreparer(fileName: fileName)
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try preparer.prepare(url)
}
)
let url = try makeCoordinatorVoiceURL(fileName: fileName)
defer {
try? FileManager.default.removeItem(
at: url.deletingLastPathComponent()
)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.legacyConsentRequests.count == 1 },
timeout: TestConstants.longTimeout
))
context.resolveNextLegacyConsent(true)
let initial = try #require(context.privateFileSends.first)
#expect(context.privateFileLegacyAllowances == [true])
#expect(coordinator.retainedReconnectRetryCount == 0)
coordinator.handleTransferEvent(.completed(
id: initial.transferId,
totalFragments: 1
))
context.privateMediaPolicy = .encrypted
coordinator.peerDidReconnect(peerID)
coordinator.peerDidAuthenticate(peerID)
#expect(context.privateFileSends.count == 1)
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
#expect(coordinator.retainedReconnectRetryCount == 0)
}
@Test @MainActor
func authenticatedGenerationSupersedesStaleReconnectResolution() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.supportsAuthenticatedPrivateMediaReceipts = true
context.resolvesPrivateMediaPolicyImmediately = false
let oldGeneration = context
.authenticatedPrivateMediaReceiptGeneration
let newGeneration = UUID(
uuidString: "00000000-0000-0000-0000-000000000002"
)!
let fileName = "voice_3333444455556666.m4a"
let preparer = StaticVoiceNotePreparer(fileName: fileName)
let transferIDs = DeterministicMediaTransferIDFactory()
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try preparer.prepare(url)
},
transferIDFactory: transferIDs.make
)
let url = try makeCoordinatorVoiceURL(fileName: fileName)
defer {
try? FileManager.default.removeItem(
at: url.deletingLastPathComponent()
)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let initial = try #require(context.privateFileSends.first)
coordinator.peerDidReconnect(peerID)
#expect(context.pendingPrivateMediaPolicyResolutionCount == 1)
context.authenticatedPrivateMediaReceiptGeneration = newGeneration
coordinator.peerDidAuthenticate(peerID)
#expect(context.pendingPrivateMediaPolicyResolutionCount == 2)
// The old-generation completion lost ownership and is inert.
context.resolveNextPrivateMediaPolicy(.encrypted)
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
#expect(context.cancelledTransfers.isEmpty)
context.resolveNextPrivateMediaPolicy(.encrypted)
#expect(context.cancelledTransfers == [initial.transferId])
#expect(context.privateFileReceiptRetryTransferIDs.count == 1)
#expect(
context.authenticatedPrivateMediaReceiptGeneration
== newGeneration
)
#expect(oldGeneration != newGeneration)
}
@Test @MainActor
func panicClearsRetainedRetryPendingResolutionAndExpiry() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.supportsAuthenticatedPrivateMediaReceipts = true
context.resolvesPrivateMediaPolicyImmediately = false
let fileName = "voice_3333444455556666.m4a"
let preparer = StaticVoiceNotePreparer(fileName: fileName)
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try preparer.prepare(url)
},
reconnectRetryLimits: PrivateMediaReconnectRetryLimits(
maxRetainedPackets: 1,
maxRetainedBytes: 1_024,
maxRetriesPerMessage: 1,
retentionSeconds: 0.1,
maxRetriesPerReconnect: 1
)
)
let url = try makeCoordinatorVoiceURL(fileName: fileName)
defer {
try? FileManager.default.removeItem(
at: url.deletingLastPathComponent()
)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let initial = try #require(context.privateFileSends.first)
coordinator.handleTransferEvent(.completed(
id: initial.transferId,
totalFragments: 1
))
#expect(coordinator.retainedReconnectRetryCount == 1)
coordinator.peerDidReconnect(peerID)
#expect(context.pendingPrivateMediaPolicyResolutionCount == 1)
let failedBeforePanic = context.deliveryStatusUpdates.filter {
if case .failed = $0.status { return true }
return false
}.count
coordinator.resetForPanic()
context.resolveNextPrivateMediaPolicy(.encrypted)
try await Task.sleep(nanoseconds: 250_000_000)
coordinator._test_expireReconnectRetries()
#expect(coordinator.retainedReconnectRetryCount == 0)
#expect(coordinator.retainedReconnectRetryBytes == 0)
#expect(coordinator.transferIdToMessageIDs.isEmpty)
#expect(coordinator.messageIDToTransferId.isEmpty)
#expect(context.privateFileSends.count == 1)
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
#expect(context.deliveryStatusUpdates.filter {
if case .failed = $0.status { return true }
return false
}.count == failedBeforePanic)
}
@Test @MainActor
func retryCountAndRetentionTimeEndInVisibleFailure() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.supportsAuthenticatedPrivateMediaReceipts = true
let clock = MutableMediaRetryClock(
Date(timeIntervalSince1970: 4_000)
)
let limits = PrivateMediaReconnectRetryLimits(
maxRetainedPackets: 2,
maxRetainedBytes: 1_024,
maxRetriesPerMessage: 1,
retentionSeconds: 10,
maxRetriesPerReconnect: 1
)
let fileName = "voice_4444555566667777.m4a"
let preparer = StaticVoiceNotePreparer(fileName: fileName)
let transferIDs = DeterministicMediaTransferIDFactory()
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try preparer.prepare(url)
},
reconnectRetryLimits: limits,
now: clock.now,
transferIDFactory: transferIDs.make
)
let url = try makeCoordinatorVoiceURL(fileName: fileName)
defer {
try? FileManager.default.removeItem(
at: url.deletingLastPathComponent()
)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let initial = try #require(context.privateFileSends.first)
coordinator.handleTransferEvent(.completed(
id: initial.transferId,
totalFragments: 1
))
coordinator.peerDidReconnect(peerID)
let retryID = try #require(
context.privateFileReceiptRetryTransferIDs.first
)
coordinator.handleTransferEvent(.cancelled(
id: retryID,
sentFragments: 0,
totalFragments: 1
))
#expect(coordinator.retainedReconnectRetryCount == 0)
#expect(context.removedMessages.isEmpty)
#expect(context.deliveryStatusUpdates.contains {
$0.messageID.hasPrefix("media-")
&& $0.status == .failed(reason: String(
localized: "content.delivery.reason.not_delivered",
defaultValue: "Not delivered",
comment: "Failure reason shown when a private media transfer could not finish"
))
})
// A separate retained row that locally completed but never received a
// remote receipt expires to a distinct visible failure.
let ttlFileName = "voice_5555666677778888.m4a"
let ttlPreparer = StaticVoiceNotePreparer(
fileName: ttlFileName
)
let ttlCoordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try ttlPreparer.prepare(url)
},
reconnectRetryLimits: limits,
now: clock.now,
transferIDFactory: transferIDs.make
)
let ttlURL = try makeCoordinatorVoiceURL(
fileName: ttlFileName
)
defer {
try? FileManager.default.removeItem(
at: ttlURL.deletingLastPathComponent()
)
}
let sendsBeforeTTL = context.privateFileSends.count
ttlCoordinator.sendVoiceNote(at: ttlURL)
#expect(await TestHelpers.waitUntil(
{
context.privateFileSends.count
== sendsBeforeTTL + 1
},
timeout: TestConstants.longTimeout
))
let ttlInitial = try #require(context.privateFileSends.last)
ttlCoordinator.handleTransferEvent(.completed(
id: ttlInitial.transferId,
totalFragments: 1
))
clock.advance(by: 10)
ttlCoordinator._test_expireReconnectRetries()
#expect(ttlCoordinator.retainedReconnectRetryCount == 0)
#expect(context.deliveryStatusUpdates.contains {
$0.status == .failed(
reason: String(
localized:
"content.delivery.reason.private_media_delivery_unconfirmed",
defaultValue: "Delivery could not be confirmed"
)
)
})
}
@Test @MainActor
func retentionAndPerReconnectWorkAreBounded() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.supportsAuthenticatedPrivateMediaReceipts = true
let limits = PrivateMediaReconnectRetryLimits(
maxRetainedPackets: 2,
maxRetainedBytes: 10,
maxRetriesPerMessage: 2,
retentionSeconds: 120,
maxRetriesPerReconnect: 1
)
let transferIDs = DeterministicMediaTransferIDFactory()
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
let content = Data("voice".utf8)
return BitchatFilePacket(
fileName: url.lastPathComponent,
fileSize: UInt64(content.count),
mimeType: "audio/mp4",
content: content
)
},
reconnectRetryLimits: limits,
transferIDFactory: transferIDs.make
)
let fileNames = [
"voice_6666777788889999.m4a",
"voice_777788889999aaaa.m4a",
"voice_88889999aaaabbbb.m4a"
]
var roots: [URL] = []
defer {
for root in roots {
try? FileManager.default.removeItem(at: root)
}
}
for fileName in fileNames {
let url = try makeCoordinatorVoiceURL(
fileName: fileName,
bytes: Data("voice".utf8)
)
roots.append(url.deletingLastPathComponent())
// The production preparer preserves this stable filename.
coordinator.sendVoiceNote(at: url)
}
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 3 },
timeout: TestConstants.longTimeout
))
#expect(coordinator.retainedReconnectRetryCount == 2)
#expect(coordinator.retainedReconnectRetryBytes <= 10)
for send in context.privateFileSends {
coordinator.handleTransferEvent(.completed(
id: send.transferId,
totalFragments: 1
))
}
coordinator.peerDidReconnect(peerID)
#expect(context.privateFileReceiptRetryTransferIDs.count == 1)
}
@Test @MainActor
func userCancellationReleasesRetainedBytesAndIgnoresLateEvent() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.supportsAuthenticatedPrivateMediaReceipts = true
let fileName = "voice_9999aaaabbbbcccc.m4a"
let preparer = StaticVoiceNotePreparer(fileName: fileName)
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try preparer.prepare(url)
}
)
let url = try makeCoordinatorVoiceURL(fileName: fileName)
defer {
try? FileManager.default.removeItem(
at: url.deletingLastPathComponent()
)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let messageID = try #require(
context.privateChats[peerID]?.first?.id
)
let transferID = try #require(
context.privateFileSends.first?.transferId
)
coordinator.cancelMediaSend(messageID: messageID)
coordinator.handleTransferEvent(.cancelled(
id: transferID,
sentFragments: 0,
totalFragments: 1
))
#expect(coordinator.retainedReconnectRetryCount == 0)
#expect(coordinator.retainedReconnectRetryBytes == 0)
#expect(context.cancelledTransfers == [transferID])
#expect(context.removedMessages.map(\.messageID) == [
messageID
])
#expect(!context.deliveryStatusUpdates.contains {
if case .failed = $0.status { return true }
return false
})
}
}
private func makeCoordinatorVoiceURL(
fileName: String,
bytes: Data = Data("voice".utf8)
) throws -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent(
"media-retry-\(UUID().uuidString)",
isDirectory: true
)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
let url = directory.appendingPathComponent(fileName)
try bytes.write(to: url)
return url
}
private final class PausedImagePreparer: @unchecked Sendable {
@@ -104,6 +104,20 @@ private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMess
/// no `ChatViewModel`.
struct ChatPeerListCoordinatorContextTests {
@Test @MainActor
func synchronousPeerListUpdate_appliesBeforeReturning() {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let peerID = PeerID(str: "0011223344556677")
coordinator.didUpdatePeerListSynchronously([peerID])
#expect(context.isConnected)
#expect(context.registeredEphemeralSessions == [peerID])
#expect(context.updateEncryptionStatusForPeersCount == 1)
#expect(context.cleanupOldReadReceiptsCount == 1)
}
@Test @MainActor
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
let context = MockChatPeerListContext()
@@ -220,26 +220,85 @@ struct ChatTransportEventCoordinatorContextTests {
func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
// Blocked messages are dropped before any handling.
context.blockedMessageIDs = ["blocked"]
context.blockedMessageIDs = ["blocked", "blocked-private"]
coordinator.didReceiveMessage(makeMessage(id: "blocked"))
coordinator.didReceiveMessage(makeMessage(
id: "blocked-private",
isPrivate: true,
senderPeerID: peerID
))
// Empty public content is dropped too.
coordinator.didReceiveMessage(makeMessage(id: "empty", content: " "))
await drainMainActorTasks()
#expect(context.handledPublicMessages.isEmpty)
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.mentionCheckedMessageIDs.isEmpty)
#expect(context.meshDeliveryAcks.isEmpty)
// Private goes to the private handler, public to the public handler;
// both get mention checks and haptics.
coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true))
// both get mention checks and haptics. Stable-media ACK authorization
// belongs to BLEFileTransferHandler after its durable commit and this
// synchronous acceptance result, not to the generic UI coordinator.
let stableMediaID = "media-\(String(repeating: "a", count: 32))"
coordinator.didReceiveMessage(makeMessage(
id: stableMediaID,
isPrivate: true,
senderPeerID: peerID
))
coordinator.didReceiveMessage(makeMessage(
id: "legacy-media",
isPrivate: true,
senderPeerID: peerID
))
coordinator.didReceiveMessage(makeMessage(id: "pm-missing-sender", isPrivate: true))
coordinator.didReceiveMessage(makeMessage(id: "pub"))
await drainMainActorTasks()
#expect(context.handledPrivateMessages.map(\.id) == ["pm"])
#expect(context.handledPrivateMessages.map(\.id) == [
stableMediaID,
"legacy-media",
"pm-missing-sender"
])
#expect(context.handledPublicMessages.map(\.id) == ["pub"])
#expect(context.mentionCheckedMessageIDs == ["pm", "pub"])
#expect(context.hapticMessageIDs == ["pm", "pub"])
#expect(context.mentionCheckedMessageIDs == [
stableMediaID,
"legacy-media",
"pm-missing-sender",
"pub"
])
#expect(context.hapticMessageIDs == [
stableMediaID,
"legacy-media",
"pm-missing-sender",
"pub"
])
#expect(context.meshDeliveryAcks.isEmpty)
}
@Test @MainActor
func synchronousMessageDeliveryReportsAcceptanceForAckGating() {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let blocked = makeMessage(
id: "blocked-private-media",
isPrivate: true,
senderPeerID: peerID
)
context.blockedMessageIDs = [blocked.id]
#expect(coordinator.didReceiveMessageSynchronously(blocked) == false)
#expect(context.handledPrivateMessages.isEmpty)
let accepted = makeMessage(
id: "accepted-private-media",
isPrivate: true,
senderPeerID: peerID
)
#expect(coordinator.didReceiveMessageSynchronously(accepted) == true)
#expect(context.handledPrivateMessages.map(\.id) == [accepted.id])
}
@Test @MainActor
@@ -295,6 +354,32 @@ struct ChatTransportEventCoordinatorContextTests {
#expect(context.notifyUIChangedCount == 2)
}
@Test @MainActor
func synchronousConnectAndDisconnect_applyBeforeReturning() {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "2233445566778899")
let incoming = makeMessage(
id: "incoming-receipt",
isPrivate: true,
senderPeerID: peerID
)
context.privateChats[peerID] = [incoming]
coordinator.didConnectToPeerSynchronously(peerID)
#expect(context.isConnected)
#expect(context.registeredEphemeralSessions == [peerID])
#expect(context.flushedOutboxPeerIDs == [peerID])
#expect(context.courierRetryPeerIDs == [peerID])
coordinator.didDisconnectFromPeerSynchronously(peerID)
#expect(context.removedEphemeralSessions == [peerID])
#expect(context.unmarkedReadReceiptBatches == [[incoming.id]])
#expect(context.notifyUIChangedCount == 2)
}
@Test @MainActor
func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async {
let context = MockChatTransportEventContext()
@@ -97,6 +97,7 @@ private final class MockChatVerificationContext: ChatVerificationContext {
var noiseSessionKeysByPeerID: [PeerID: Data] = [:]
private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)?
private(set) var triggeredHandshakes: [PeerID] = []
private(set) var privateMediaAuthenticatedPeers: [PeerID] = []
private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
@@ -113,6 +114,9 @@ private final class MockChatVerificationContext: ChatVerificationContext {
establishedNoiseSessions.contains(peerID)
}
func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) }
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {
privateMediaAuthenticatedPeers.append(peerID)
}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
sentChallenges.append((peerID, noiseKeyHex, nonceA))
@@ -277,6 +281,7 @@ struct ChatVerificationCoordinatorContextTests {
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
#expect(context.privateMediaAuthenticatedPeers == [peerID])
// Handshake required -> handshaking status.
callbacks?.onHandshakeRequired(peerID)
+74
View File
@@ -864,6 +864,80 @@ struct ChatViewModelPublicConversationTests {
struct ChatViewModelPeerTests {
@Test @MainActor
func typedPeerLifecycleEvents_applyBeforeReturning() {
let (viewModel, _) = makeTestableViewModel()
let peerID = PeerID(str: "1122334455667788")
let incoming = BitchatMessage(
id: "typed-peer-incoming",
sender: "Alice",
content: "Hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: peerID
)
viewModel.seedPrivateChat([incoming], for: peerID)
viewModel.sentReadReceipts.insert(incoming.id)
viewModel.didReceiveTransportEvent(.peerConnected(peerID))
#expect(viewModel.isConnected)
viewModel.didReceiveTransportEvent(.peerDisconnected(peerID))
#expect(!viewModel.sentReadReceipts.contains(incoming.id))
}
@Test @MainActor
func typedPeerListDeliveryAndBluetoothEvents_applyBeforeReturning() {
let (viewModel, transport) = makeTestableViewModel()
let stalePeer = PeerID(str: "00000000000000a2")
let deliveryPeer = PeerID(str: "0102030405060708")
let messageID = "typed-delivery-status"
let delivered = DeliveryStatus.delivered(
to: "Alice",
at: Date(timeIntervalSince1970: 1_234)
)
let outgoing = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "On the way",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Alice",
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
viewModel.markPrivateChatUnread(stalePeer)
viewModel.seedPrivateChat([outgoing], for: deliveryPeer)
viewModel.didReceiveTransportEvent(.peerListUpdated([]))
#expect(!viewModel.unreadPrivateMessages.contains(stalePeer))
viewModel.didReceiveTransportEvent(
.messageDeliveryStatusUpdated(
messageID: messageID,
status: delivered
)
)
#expect(
viewModel.privateMessages(for: deliveryPeer).first?.deliveryStatus
== delivered
)
viewModel.didReceiveTransportEvent(.bluetoothStateUpdated(.poweredOff))
#expect(viewModel.bluetoothState == .poweredOff)
#expect(viewModel.showBluetoothAlert)
// Snapshot events belong to TransportPeerEventsDelegate and are
// intentionally ignored at this typed sink.
viewModel.didReceiveTransportEvent(.peerSnapshotsUpdated([]))
#expect(viewModel.bluetoothState == .poweredOff)
}
@Test @MainActor
func didConnectToPeer_notifiesDelegate() async {
let (_, transport) = makeTestableViewModel()
@@ -364,6 +364,217 @@ struct PrivateMediaEndToEndTests {
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
}
@Test
func privateMediaRetryRequiresExactAuthenticatedBit9Proof() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent(
"private-media-receipt-proof-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(
baseDirectory: root.appendingPathComponent(
"alice",
isDirectory: true
)
)
let bob = makeService(
baseDirectory: root.appendingPathComponent(
"bob",
isDirectory: true
)
)
let bothCapabilities: PeerCapabilities = [
.privateMedia,
.privateMediaReceipts
]
// A public bit-9 announce is discovery only.
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: bothCapabilities,
noisePublicKey: bob.noiseStaticPublicKeyData()
)
#expect(
alice.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) == nil
)
let proofs = try await establishSessionCapturingPeerState(
alice: alice,
bob: bob
)
#expect(
alice.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) == nil
)
// Bit 8 alone preserves encrypted transfer compatibility but cannot
// authorize automatic resend.
let privateMediaOnly = try authenticatedPeerStatePacket(
from: bob,
to: alice,
capabilities: .privateMedia
)
alice._test_handlePacket(
privateMediaOnly,
fromPeerID: bob.myPeerID
)
#expect(await TestHelpers.waitUntil(
{
alice.privateMediaSendPolicy(to: bob.myPeerID)
== .encrypted
},
timeout: TestConstants.longTimeout
))
#expect(
alice.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) == nil
)
let receiptCapable = try authenticatedPeerStatePacket(
from: bob,
to: alice,
capabilities: bothCapabilities
)
alice._test_handlePacket(
receiptCapable,
fromPeerID: bob.myPeerID
)
#expect(await TestHelpers.waitUntil(
{
alice.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) != nil
},
timeout: TestConstants.longTimeout
))
bob._test_handlePacket(
proofs.alice,
fromPeerID: alice.myPeerID
)
alice._test_onOutboundPacket = nil
bob._test_onOutboundPacket = nil
}
@Test
func receiptRetryRechecksBit9AtDeferredTransportBoundary() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent(
"private-media-retry-proof-race-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(
baseDirectory: root.appendingPathComponent(
"alice",
isDirectory: true
)
)
let bob = makeService(
baseDirectory: root.appendingPathComponent(
"bob",
isDirectory: true
)
)
let receiptCapabilities: PeerCapabilities = [
.privateMedia,
.privateMediaReceipts
]
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: receiptCapabilities,
noisePublicKey: bob.noiseStaticPublicKeyData()
)
bob._test_seedConnectedPeer(
alice.myPeerID,
nickname: "Alice",
capabilities: receiptCapabilities,
noisePublicKey: alice.noiseStaticPublicKeyData()
)
try await establishSession(alice: alice, bob: bob)
#expect(
alice.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) != nil
)
let privateMediaOnly = try authenticatedPeerStatePacket(
from: bob,
to: alice,
capabilities: .privateMedia
)
let transferID =
"receipt-proof-race-\(UUID().uuidString)"
let tap = PacketTap()
let boundaryProofs = ReceiptCapabilityRecorder()
let rejections = TransferCancellationRecorder()
let cancellable = TransferProgressManager.shared.publisher.sink {
rejections.record($0)
}
alice._test_onOutboundPacket = tap.record
alice._test_beforePrivateMediaDeferredSend = { id in
guard id == transferID else { return }
boundaryProofs.record(
alice
.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) != nil
)
}
defer {
alice._test_beforePrivateMediaDeferredSend = nil
alice._test_onOutboundPacket = nil
}
// Rotate authenticated state before the deferred retry reaches its
// admission boundary.
alice._test_handlePacket(
privateMediaOnly,
fromPeerID: bob.myPeerID
)
let content = Data("%PDF-1.7\nreceipt-proof-race".utf8)
alice.sendFilePrivateReceiptRetry(
BitchatFilePacket(
fileName: "receipt-proof-race.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
),
to: bob.myPeerID,
transferId: transferID
)
#expect(await TestHelpers.waitUntil(
{ boundaryProofs.snapshot() == [false] },
timeout: TestConstants.longTimeout
))
await alice._test_drainPrivateMediaSendPipeline()
#expect(await TestHelpers.waitUntil(
{ rejections.contains(transferID) },
timeout: TestConstants.longTimeout
))
#expect(tap.snapshot().allSatisfy {
$0.type != MessageType.fileTransfer.rawValue
&& !(
$0.type == MessageType.noiseEncrypted.rawValue
&& $0.version == 2
)
})
let state = alice._test_privateMediaTransferState(
transferId: transferID
)
#expect(!state.admissionActive)
#expect(!state.pendingNoise)
_ = cancellable
}
@Test
func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws {
let root = FileManager.default.temporaryDirectory
@@ -682,13 +893,18 @@ struct PrivateMediaEndToEndTests {
#expect(!identity.hasObservedPrivateMediaCapability(
fingerprint: impostorKey.sha256Fingerprint()
))
#expect(identity.hasObservedPrivateMediaCapability(
fingerprint: bob.noiseStaticPublicKeyData().sha256Fingerprint()
))
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: [],
noisePublicKey: impostorKey
)
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
// The exact live Noise identity remains authoritative over a later
// impostor registry rewrite.
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
}
@Test
@@ -741,7 +957,7 @@ struct PrivateMediaEndToEndTests {
}
@Test
func consentedLegacySendRejectsAboveAndroidFragmentCapButEncryptedDoesNot() async throws {
func encryptedAndConsentedLegacySendsRejectAboveAndroidFragmentCap() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-fragment-cap-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
@@ -790,24 +1006,14 @@ struct PrivateMediaEndToEndTests {
allowLegacyFallback: true
)
// The directed raw-file migration fallback (Android-style peer without
// the .privateMedia capability) still honors the 256-fragment ceiling.
let legacyRejected = await TestHelpers.waitUntil(
{ rejections.contains(legacyID) },
let bothRejected = await TestHelpers.waitUntil(
{ rejections.contains(encryptedID) && rejections.contains(legacyID) },
timeout: TestConstants.longTimeout
)
#expect(legacyRejected)
#expect(bothRejected)
#expect(rejections.reason(for: encryptedID)?.contains("256") == true)
#expect(rejections.reason(for: legacyID)?.contains("256") == true)
// 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))
#expect(tap.snapshot().isEmpty, "No outer packet or fragment may be exposed before size rejection")
_ = cancellable
}
@@ -876,7 +1082,7 @@ struct PrivateMediaEndToEndTests {
+ marker
+ Data(repeating: 0x4A, count: 6 * 1024)
try await assertPrivateMediaRoundTrip(
fileName: "private.jpg",
fileName: "img_20260725_120000_11111111-1111-1111-1111-111111111111.jpg",
mimeType: "image/jpeg",
content: content,
marker: marker,
@@ -1147,6 +1353,17 @@ struct PrivateMediaEndToEndTests {
#expect(message.isPrivate)
#expect(message.senderPeerID == alice.myPeerID)
#expect(message.content.hasPrefix(expectedMessagePrefix))
if let stableMessageID = PrivateMediaMessageIdentity.stableID(
for: file,
senderPeerID: alice.myPeerID,
recipientPeerID: bob.myPeerID
) {
#expect(message.id == stableMessageID)
} else {
// Generic/legacy filenames retain random per-arrival IDs so two
// unrelated "photo.jpg" transfers are never deduplicated.
#expect(!message.id.hasPrefix("media-"))
}
let stored = recursivelyStoredFiles(under: bobRoot)
#expect(stored.count == 1)
@@ -1289,6 +1506,8 @@ struct PrivateMediaEndToEndTests {
return enumerator.compactMap { item in
guard let url = item as? URL,
!url.pathComponents.contains(".private-media-receipts"),
url.lastPathComponent != ".private-media-receipts.json",
(try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else {
return nil
}
@@ -1389,6 +1608,23 @@ private final class PrivateMediaPolicyRecorder: @unchecked Sendable {
}
}
private final class ReceiptCapabilityRecorder: @unchecked Sendable {
private let lock = NSLock()
private var values: [Bool] = []
func record(_ value: Bool) {
lock.lock()
values.append(value)
lock.unlock()
}
func snapshot() -> [Bool] {
lock.lock()
defer { lock.unlock() }
return values
}
}
private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
private let lock = NSLock()
private var messages: [BitchatMessage] = []
@@ -12,6 +12,7 @@ import Testing
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat
@Suite("Integration Tests", .serialized)
struct IntegrationTests {
private var helper = TestNetworkHelper()
@@ -272,8 +273,18 @@ struct IntegrationTests {
// Re-establish Noise handshake explicitly via managers
do {
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)!
let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)!
let m2 = try #require(
try helper.noiseManagers["Alice"]!.handleIncomingHandshake(
from: helper.nodes["Bob"]!.peerID,
message: m1
)
)
let m3 = try #require(
try helper.noiseManagers["Bob"]!.handleIncomingHandshake(
from: helper.nodes["Alice"]!.peerID,
message: m2
)
)
_ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
} catch {
Issue.record("Failed to re-establish Noise session after restart: \(error)")
@@ -8,6 +8,7 @@
import Foundation
import CryptoKit
import Testing
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat
@@ -27,9 +28,14 @@ final class TestNetworkHelper {
node.mockNickname = name
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()
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
noiseManagers[name] = NoiseSessionManager(
localStaticKey: key,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
return node
}
@@ -108,8 +114,18 @@ final class TestNetworkHelper {
let peer2ID = nodes[node2]?.peerID else { return }
let msg1 = try manager1.initiateHandshake(with: peer2ID)
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
let msg2 = try #require(
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)
}
}
+19 -4
View File
@@ -5,7 +5,7 @@ import BitFoundation
@testable import bitchat
@Suite("Noise Coverage Tests")
@Suite("Noise Coverage Tests", .serialized)
struct NoiseCoverageTests {
private let keychain = MockKeychain()
private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey()
@@ -633,8 +633,16 @@ struct NoiseCoverageTests {
)
let replacementSession = try #require(manager.getSession(for: alicePeerID))
#expect(replacementResponse != nil)
#expect(replacementSession !== restartedSession)
let localPeerID = PeerID(
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 bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
@@ -654,7 +662,13 @@ struct NoiseCoverageTests {
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)
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
@@ -667,6 +681,7 @@ struct NoiseCoverageTests {
let aliceManager = NoiseSessionManager(
localStaticKey: aliceStaticKey,
keychain: keychain,
recentInitiatorCompletionGracePeriod: 0,
sessionFactory: { peerID, role in
BlockingDecryptNoiseSession(
peerID: peerID,
+57 -18
View File
@@ -357,8 +357,18 @@ struct NoiseProtocolTests {
@Test func peerRestartDetection() throws {
// Establish initial sessions
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// This test explicitly drives the three synchronous XX messages and
// does not exercise the transport's delayed collision recovery.
let aliceManager = NoiseSessionManager(
localStaticKey: aliceKey,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
let bobManager = NoiseSessionManager(
localStaticKey: bobKey,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -377,15 +387,24 @@ struct NoiseProtocolTests {
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
// Alice should accept the new handshake (clearing old session)
let newHandshake2 = try aliceManager.handleIncomingHandshake(
from: alicePeerID, message: newHandshake1)
#expect(newHandshake2 != nil)
let newHandshake2 = try #require(
try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: newHandshake1
)
)
// Complete the new handshake
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(
from: bobPeerID, message: newHandshake2!)
#expect(newHandshake3 != nil)
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
let newHandshake3 = try #require(
try bobManagerRestarted.handleIncomingHandshake(
from: bobPeerID,
message: newHandshake2
)
)
_ = try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: newHandshake3
)
// Should be able to exchange messages with new sessions
let testMessage = Data("After restart".utf8)
@@ -543,8 +562,18 @@ struct NoiseProtocolTests {
@Test func nonceDesynchronizationCausesRehandshake() throws {
// Test that nonce desynchronization leads to proper re-handshake
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// This test explicitly drives the three synchronous XX messages and
// does not exercise the transport's delayed collision recovery.
let aliceManager = NoiseSessionManager(
localStaticKey: aliceKey,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
let bobManager = NoiseSessionManager(
localStaticKey: bobKey,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
// Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -572,15 +601,25 @@ struct NoiseProtocolTests {
let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
// Alice should accept despite having a "valid" (but desynced) session
let rehandshake2 = try aliceManager.handleIncomingHandshake(
from: alicePeerID, message: rehandshake1)
#expect(rehandshake2 != nil, "Alice should accept handshake to fix desync")
let rehandshake2 = try #require(
try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: rehandshake1
),
"Alice should accept handshake to fix desync"
)
// Complete handshake
let rehandshake3 = try bobManager.handleIncomingHandshake(
from: bobPeerID, message: rehandshake2!)
#expect(rehandshake3 != nil)
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
let rehandshake3 = try #require(
try bobManager.handleIncomingHandshake(
from: bobPeerID,
message: rehandshake2
)
)
_ = try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: rehandshake3
)
// Verify communication works again
let testResynced = Data("Resynced".utf8)
@@ -1,3 +1,4 @@
import BitFoundation
import XCTest
@testable import bitchat
@@ -73,4 +74,85 @@ final class BitchatFilePacketTests: XCTestCase {
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
XCTAssertEqual(decoded.content, content)
}
func testPrivateMediaMessageIdentityConvergesAcrossPeerIDAliases() throws {
let senderKey = Data(repeating: 0x11, count: 32)
let recipientKey = Data(repeating: 0x22, count: 32)
let senderStable = PeerID(hexData: senderKey)
let recipientStable = PeerID(hexData: recipientKey)
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
let senderID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
senderPeerID: senderStable.toShort(),
recipientPeerID: PeerID(str: "mesh:\(recipientStable.toShort().bare)"),
fileName: fileName
))
let receiverID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
senderPeerID: senderStable,
recipientPeerID: recipientStable.toShort(),
fileName: fileName
))
XCTAssertEqual(senderID, receiverID)
XCTAssertTrue(senderID.hasPrefix("media-"))
XCTAssertEqual(senderID.count, 38)
XCTAssertTrue(PrivateMediaMessageIdentity.isStableID(senderID))
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "A", count: 32))"))
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "a", count: 31))"))
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID(UUID().uuidString))
}
func testPrivateMediaMessageIdentitySeparatesDirectionAndFilename() throws {
let alice = PeerID(str: "0011223344556677")
let bob = PeerID(str: "8899aabbccddeeff")
let firstName = "voice_20260725_105708_11111111-1111-1111-1111-111111111111.m4a"
let secondName = "voice_20260725_105709_22222222-2222-2222-2222-222222222222.m4a"
let first = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: firstName
))
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
senderPeerID: bob,
recipientPeerID: alice,
fileName: firstName
))
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: secondName
))
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: nil
))
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: "photo.jpg"
))
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: "img_11111111-1111-1111-1111-111111111111.pdf"
))
XCTAssertNotNil(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: "voice_0011223344556677.m4a"
))
}
func testPrivateMediaMessageIdentityMatchesVersionOneGoldenVector() {
XCTAssertEqual(
PrivateMediaMessageIdentity.stableID(
senderPeerID: PeerID(str: "0011223344556677"),
recipientPeerID: PeerID(str: "8899aabbccddeeff"),
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
),
"media-910bd42c65060ab76bb6406f220c4516"
)
}
}
@@ -13,11 +13,27 @@ struct BLEFileTransferHandlerTests {
var signatureVerifyCount = 0
var signedNameQueries: [PeerID] = []
var blockedPeers: Set<PeerID> = []
var trackedPackets: [BitchatPacket] = []
var quotaReservations: [Int] = []
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
var receiptStates: [String: BLEPrivateMediaReceiptState] = [:]
var receiptCommits: [(messageID: String, storedURL: URL)] = []
var receiptCommitSucceeds = true
var removedIncomingFiles: [URL] = []
var lastSeenUpdates: [PeerID] = []
var deliveryAcks: [(messageID: String, peerID: PeerID)] = []
var deliveredMessages: [BitchatMessage] = []
var saveOverride: ((
_ data: Data,
_ preferredName: String?,
_ subdirectory: String,
_ fallbackExtension: String?,
_ defaultPrefix: String
) -> URL?)?
var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)?
var receiptCommitOverride: ((String, URL) -> Bool)?
var removeIncomingFileOverride: ((URL) -> Void)?
}
private let localPeerID = PeerID(str: "0102030405060708")
@@ -46,13 +62,44 @@ struct BLEFileTransferHandlerTests {
},
saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix))
if let saveOverride = recorder.saveOverride {
return saveOverride(data, preferredName, subdirectory, fallbackExtension, defaultPrefix)
}
return recorder.saveResult
},
privateMediaReceiptState: { messageID in
if let receiptStateOverride = recorder.receiptStateOverride {
return receiptStateOverride(messageID)
}
return recorder.receiptStates[messageID] ?? .absent
},
commitPrivateMediaFile: { messageID, storedURL in
recorder.receiptCommits.append((messageID, storedURL))
if let receiptCommitOverride = recorder.receiptCommitOverride {
return receiptCommitOverride(messageID, storedURL)
}
guard recorder.receiptCommitSucceeds else { return false }
recorder.receiptStates[messageID] = .accepted(storedURL)
return true
},
removeIncomingFile: { storedURL in
recorder.removedIncomingFiles.append(storedURL)
recorder.removeIncomingFileOverride?(storedURL)
},
isPrivateMediaSenderBlocked: { peerID in
recorder.blockedPeers.contains(peerID)
},
updatePeerLastSeen: { peerID in
recorder.lastSeenUpdates.append(peerID)
},
deliverMessage: { message in
acknowledgePrivateMedia: { messageID, peerID in
recorder.deliveryAcks.append((messageID, peerID))
},
deliverMessage: { message, shouldDeliver, completion in
guard shouldDeliver() else { return }
recorder.deliveredMessages.append(message)
guard shouldDeliver() else { return }
completion()
}
)
return BLEFileTransferHandler(environment: environment)
@@ -284,6 +331,7 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.lastSeenUpdates == [remotePeerID])
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.isPrivate == true)
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
// Must be explicit: BitchatMessage defaults private messages to
// .sending, which the media views render as an in-flight send
// (empty reveal mask, disabled reveal tap).
@@ -291,13 +339,14 @@ struct BLEFileTransferHandlerTests {
}
@Test
func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws {
func bit8EncryptedPrivateFileKeepsStableIDAndAckWithoutBit9Proof() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
let file = BitchatFilePacket(
fileName: "secret.jpg",
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
@@ -316,6 +365,357 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.isPrivate == true)
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
#expect(recorder.deliveredMessages.first?.id == PrivateMediaMessageIdentity.stableID(
senderPeerID: remotePeerID,
recipientPeerID: localPeerID,
fileName: fileName
))
#expect(recorder.receiptCommits.count == 1)
#expect(recorder.deliveryAcks.count == 1)
#expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id)
}
@Test
func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true,
signingPublicKey: sampleSigningKey
)]
recorder.signatureVerifies = true
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
let packet = try makeFileTransferPacket(
sender: remotePeerID,
mimeType: "image/jpeg",
content: content,
recipientID: Data(hexString: localPeerID.id),
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
)
#expect(handler.handle(packet, from: remotePeerID))
#expect(recorder.receiptCommits.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
}
@Test
func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let file = BitchatFilePacket(
fileName: "photo.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
))
#expect(recorder.deliveredMessages.count == 2)
#expect(recorder.deliveredMessages[0].id != recorder.deliveredMessages[1].id)
#expect(recorder.deliveredMessages.allSatisfy { !$0.id.hasPrefix("media-") })
}
@Test
func lostCapabilityProofThenStableRetryReusesDurableIDWithoutSecondDiskWrite() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
let file = BitchatFilePacket(
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
let expectedID = try #require(PrivateMediaMessageIdentity.stableID(
senderPeerID: remotePeerID,
recipientPeerID: localPeerID,
fileName: fileName
))
// First encrypted arrival may precede the sender's authenticated bit-9
// proof. It still uses the bit-8 stable ID/ACK contract.
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
// A later automatic retry after proof must resolve the same durable ID
// rather than create a legacy random-ID bubble.
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
))
#expect(recorder.quotaReservations == [content.count])
#expect(recorder.saveCalls.count == 1)
// The handler re-offers a durable duplicate so a relaunched UI can
// restore its bubble; the synchronous conversation sink deduplicates.
#expect(recorder.deliveredMessages.count == 2)
#expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID])
#expect(recorder.deliveryAcks.count == 2)
#expect(recorder.deliveryAcks.allSatisfy {
$0.messageID == expectedID && $0.peerID == remotePeerID
})
}
@Test
func acceptedPrivateMediaAfterRelaunchRedeliversDurableURLBeforeAck() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
"private-media-handler-relaunch-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEIncomingFileStore(baseDirectory: root)
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
let file = BitchatFilePacket(
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
func configure(_ recorder: Recorder) {
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true
)]
recorder.saveOverride = {
data,
preferredName,
subdirectory,
fallbackExtension,
defaultPrefix in
store.save(
data: data,
preferredName: preferredName,
subdirectory: subdirectory,
fallbackExtension: fallbackExtension,
defaultPrefix: defaultPrefix
)
}
recorder.receiptStateOverride = {
store.privateMediaReceiptState(messageID: $0)
}
recorder.receiptCommitOverride = {
store.commitPrivateMediaFile(messageID: $0, storedURL: $1)
}
recorder.removeIncomingFileOverride = {
store.removeIncomingFile(at: $0)
}
}
let first = Recorder()
configure(first)
#expect(makeHandler(recorder: first).handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
let originalMessage = try #require(first.deliveredMessages.first)
#expect(first.deliveryAcks.count == 1)
// A fresh handler models process relaunch: its in-memory reservation
// cache is empty, so only the durable receipt can suppress disk work.
let relaunched = Recorder()
configure(relaunched)
#expect(makeHandler(recorder: relaunched).handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
))
#expect(relaunched.quotaReservations.isEmpty)
#expect(relaunched.saveCalls.isEmpty)
#expect(relaunched.receiptCommits.isEmpty)
#expect(relaunched.deliveredMessages.count == 1)
#expect(relaunched.deliveredMessages.first?.id == originalMessage.id)
#expect(relaunched.deliveredMessages.first?.content == originalMessage.content)
#expect(relaunched.deliveryAcks.count == 1)
#expect(relaunched.deliveryAcks.first?.messageID == originalMessage.id)
}
@Test
func inFlightStableDuplicateIsNotAcknowledgedAndFailedSaveRemainsRetryable() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let file = BitchatFilePacket(
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
var handler: BLEFileTransferHandler!
var nestedResult: Bool?
var failFirstSave = true
recorder.saveOverride = { _, _, _, _, _ in
if failFirstSave {
failFirstSave = false
nestedResult = handler.handlePrivatePayload(
payload,
from: self.remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
)
return nil
}
return recorder.saveResult
}
handler = makeHandler(recorder: recorder)
// The nested arrival sees the first reservation as pending. It is
// coalesced without an ACK; then the first durable save fails.
#expect(!handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(nestedResult == true)
#expect(recorder.saveCalls.count == 1)
#expect(recorder.deliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
// Failure released the reservation, so the sender's later retry can
// persist and deliver normally.
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_236)
))
#expect(recorder.saveCalls.count == 2)
#expect(recorder.deliveryAcks.count == 1)
#expect(recorder.deliveredMessages.count == 1)
}
@Test
func unavailableDurableReceiptStateWithholdsDiskDeliveryAndAck() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true
)]
let fileName =
"img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
let messageID = try #require(PrivateMediaMessageIdentity.stableID(
senderPeerID: remotePeerID,
recipientPeerID: localPeerID,
fileName: fileName
))
recorder.receiptStates[messageID] = .unavailable
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
let payload = try #require(BitchatFilePacket(
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
).encode())
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.receiptCommits.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
}
@Test
func durableReceiptCommitFailureRollsBackAndWithholdsDeliveryAck() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true
)]
recorder.receiptCommitSucceeds = false
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
let payload = try #require(BitchatFilePacket(
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
).encode())
#expect(!handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(recorder.saveCalls.count == 1)
#expect(recorder.receiptCommits.count == 1)
#expect(recorder.removedIncomingFiles.count == 1)
#expect(recorder.removedIncomingFiles.first == recorder.saveResult)
#expect(recorder.deliveredMessages.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
}
@Test
func blockedPrivateMediaIsDroppedBeforeQuotaDiskAndDedupState() throws {
let recorder = Recorder()
recorder.blockedPeers = [remotePeerID]
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let file = BitchatFilePacket(
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.lastSeenUpdates.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
// Unblocking must allow a retry through; the blocked attempt cannot
// poison the stable-ID dedup reservation.
recorder.blockedPeers = []
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
))
#expect(recorder.saveCalls.count == 1)
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveryAcks.count == 1)
}
@Test
@@ -337,6 +737,7 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.lastSeenUpdates.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}
@@ -468,6 +869,33 @@ struct BLEFileTransferHandlerTests {
}
}
@Test
func panicWipeClearsCachedPrivateMediaReceiptDecisions() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-receipt-cache-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let messageID = "media-00112233445566778899aabbccddeeff"
let seed = BLEPrivateMediaReceiptStore(baseDirectory: base)
#expect(seed.recordDeleted(messageID: messageID))
let store = BLEIncomingFileStore(baseDirectory: base)
#expect(
store.privateMediaReceiptState(messageID: messageID)
== .tombstoned
)
try store.panicWipe()
#expect(
store.privateMediaReceiptState(messageID: messageID)
== .absent
)
}
@Test
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
enum MarkerFailure: Error { case unavailable }
@@ -1,4 +1,5 @@
import BitFoundation
import CryptoKit
import Foundation
import Testing
@testable import bitchat
@@ -11,7 +12,11 @@ struct BLENoisePacketHandlerTests {
var handshakeAuthenticated = false
var hasSession = false
let sessionGeneration = UUID()
var awaitingResponderHandshake = false
var decryptResult: Result<Data, Error> = .success(Data())
var currentDate = Date(timeIntervalSince1970: 1_000)
var transportGenerationReady = false
var forcedServiceDecryptError: Error?
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
var hasSessionQueries: [PeerID] = []
@@ -34,11 +39,12 @@ struct BLENoisePacketHandlerTests {
recorder: Recorder,
now: Date = Date(timeIntervalSince1970: 1_000)
) -> BLENoisePacketHandler {
recorder.currentDate = now
let environment = BLENoisePacketHandlerEnvironment(
localPeerID: { [localPeerID] in localPeerID },
localPeerIDData: { [localPeerIDData] in localPeerIDData },
messageTTL: TransportConfig.messageTTLDefault,
now: { now },
now: { recorder.currentDate },
processHandshakeMessage: { peerID, message in
recorder.processedHandshakes.append((peerID, message))
return NoiseHandshakeProcessingResult(
@@ -51,6 +57,9 @@ struct BLENoisePacketHandlerTests {
recorder.hasSessionQueries.append(peerID)
return recorder.hasSession
},
isAwaitingResponderHandshakeCompletion: { _ in
recorder.awaitingResponderHandshake
},
initiateHandshake: { peerID in
recorder.initiatedHandshakes.append(peerID)
recorder.events.append("initiateHandshake")
@@ -82,6 +91,120 @@ struct BLENoisePacketHandlerTests {
return BLENoisePacketHandler(environment: environment)
}
private func makeServiceBackedHandler(
service: NoiseEncryptionService,
localPeerID: PeerID,
recorder: Recorder,
transportGenerationIsReady:
@escaping (UUID) -> Bool
) -> BLENoisePacketHandler {
BLENoisePacketHandler(
environment: BLENoisePacketHandlerEnvironment(
localPeerID: { localPeerID },
localPeerIDData: {
Data(hexString: localPeerID.id) ?? Data()
},
messageTTL: TransportConfig.messageTTLDefault,
now: { recorder.currentDate },
processHandshakeMessage: { peerID, message in
try service.processHandshakeMessageWithResult(
from: peerID,
message: message
)
},
hasNoiseSession: { peerID in
service.hasSession(with: peerID)
},
isAwaitingResponderHandshakeCompletion: { peerID in
service.isAwaitingResponderHandshakeCompletion(
with: peerID
)
},
initiateHandshake: { peerID in
recorder.initiatedHandshakes.append(peerID)
},
broadcastPacket: { packet in
recorder.broadcastPackets.append(packet)
},
updatePeerLastSeen: { peerID in
recorder.lastSeenUpdates.append(peerID)
},
decrypt: { payload, peerID in
recorder.decryptCalls.append((payload, peerID))
if let error = recorder.forcedServiceDecryptError {
throw error
}
let result =
try service.decryptWithSessionGeneration(
payload,
from: peerID,
establishedGenerationIsReady:
transportGenerationIsReady
)
return BLENoiseDecryptionResult(
plaintext: result.plaintext,
sessionGeneration: result.sessionGeneration
)
},
clearSession: { peerID in
recorder.clearedSessions.append(peerID)
service.clearSession(for: peerID)
},
handleAuthenticatedPeerState: {
peerID, payload, generation in
recorder.authenticatedPeerStates.append(
(peerID, payload, generation)
)
},
deliverNoisePayload: {
peerID, type, payload, timestamp in
recorder.deliveries.append(
(peerID, type, payload, timestamp)
)
}
)
)
}
private func establishedServices() throws -> (
sender: NoiseEncryptionService,
receiver: NoiseEncryptionService,
senderPeerID: PeerID,
receiverPeerID: PeerID
) {
let sender = NoiseEncryptionService(keychain: MockKeychain())
let receiver = NoiseEncryptionService(keychain: MockKeychain())
let senderPeerID = PeerID(
publicKey: sender.getStaticPublicKeyData()
)
let receiverPeerID = PeerID(
publicKey: receiver.getStaticPublicKeyData()
)
let message1 = try sender.initiateHandshake(with: receiverPeerID)
let message2 = try #require(
try receiver.processHandshakeMessage(
from: senderPeerID,
message: message1
)
)
let message3 = try #require(
try sender.processHandshakeMessage(
from: receiverPeerID,
message: message2
)
)
_ = try receiver.processHandshakeMessage(
from: senderPeerID,
message: message3
)
return (
sender,
receiver,
senderPeerID,
receiverPeerID
)
}
// MARK: Handshake
@Test
@@ -198,6 +321,24 @@ struct BLENoisePacketHandlerTests {
#expect(recorder.broadcastPackets.isEmpty)
}
@Test
func managedHandshakeFailureDoesNotStartASecondRecovery() {
let recorder = Recorder()
recorder.handshakeResult = .failure(
NoiseManagedHandshakeFailure(underlying: TestError())
)
recorder.hasSession = false
let handler = makeHandler(recorder: recorder)
let packet = makeHandshakePacket(
recipientID: Data(hexString: localPeerID.id)
)
#expect(!handler.handleHandshake(packet, from: remotePeerID))
#expect(recorder.hasSessionQueries.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
#expect(recorder.broadcastPackets.isEmpty)
}
// MARK: Encrypted
@Test
@@ -346,6 +487,799 @@ struct BLENoisePacketHandlerTests {
#expect(recorder.deliveries.isEmpty)
}
@Test
func earlyCiphertextIsRetriedAfterResponderHandshakeCompletes() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE])
)
handler.handleSessionAuthenticated(remotePeerID)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.type == .privateMessage)
#expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func panicResetDiscardsDeferredCiphertextBeforeFutureAuthentication() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let prePanicCiphertext = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data(
count: NoiseSecurityConstants.maxMessageSize
+ NoiseSecurityConstants.transportCiphertextOverhead
)
)
handler.handleEncrypted(prePanicCiphertext, from: remotePeerID)
#expect(recorder.decryptCalls.count == 1)
handler.resetForPanic()
// Three maximum-sized packets fit only when reset also zeroed the
// global byte accounting. They model ciphertext received under the
// replacement identity before that responder handshake completes.
for index in 0..<3 {
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
timestamp: UInt64(901_000 + index),
payload: Data(
count: NoiseSecurityConstants.maxMessageSize
+ NoiseSecurityConstants.transportCiphertextOverhead
)
),
from: remotePeerID
)
}
#expect(recorder.decryptCalls.count == 4)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE])
)
handler.handleSessionAuthenticated(remotePeerID)
// Only the three post-reset packets replay; the pre-panic packet does
// not survive into the replacement session.
#expect(recorder.decryptCalls.count == 7)
#expect(recorder.deliveries.count == 3)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func ciphertextQueuedAheadOfEstablishmentCallbackDoesNotConsumeNonce()
throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(
publicKey: alice.getStaticPublicKeyData()
)
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
let message1 = try alice.initiateHandshake(with: bobPeerID)
let message2 = try #require(
try bob.processHandshakeMessage(
from: alicePeerID,
message: message1
)
)
let message3 = try #require(
try alice.processHandshakeMessage(
from: bobPeerID,
message: message2
)
)
let typedPayload = Data([
NoisePayloadType.privateMessage.rawValue,
0xCA, 0xFE
])
let ciphertext = try alice.encrypt(
typedPayload,
for: bobPeerID
)
// Manager promotion has completed, but the serialized BLE callback is
// deliberately still behind this ciphertext.
_ = try bob.processHandshakeMessage(
from: alicePeerID,
message: message3
)
let recorder = Recorder()
recorder.transportGenerationReady = false
let handler = makeServiceBackedHandler(
service: bob,
localPeerID: bobPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
let packet = makeEncryptedPacket(
recipientID: Data(hexString: bobPeerID.id),
payload: ciphertext
)
handler.handleEncrypted(packet, from: alicePeerID)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
// The exact ciphertext must still authenticate, proving the readiness
// rejection happened before the receive nonce was consumed.
recorder.transportGenerationReady = true
handler.handleSessionAuthenticated(alicePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.type == .privateMessage)
#expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func ciphertextQueuedAheadOfRestoreCallbackDoesNotConsumeNonce()
throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(
publicKey: alice.getStaticPublicKeyData()
)
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
let initial1 = try alice.initiateHandshake(with: bobPeerID)
let initial2 = try #require(
try bob.processHandshakeMessage(
from: alicePeerID,
message: initial1
)
)
let initial3 = try #require(
try alice.processHandshakeMessage(
from: bobPeerID,
message: initial2
)
)
_ = try bob.processHandshakeMessage(
from: alicePeerID,
message: initial3
)
let typedPayload = Data([
NoisePayloadType.privateMessage.rawValue,
0xBE, 0xEF
])
let delayedCiphertext = try alice.encrypt(
typedPayload,
for: bobPeerID
)
let forged1 = try mallory.initiateHandshake(with: bobPeerID)
let forged2 = try #require(
try bob.processHandshakeMessage(
from: alicePeerID,
message: forged1
)
)
let forged3 = try #require(
try mallory.processHandshakeMessage(
from: bobPeerID,
message: forged2
)
)
#expect(throws: NoiseSessionError.peerIdentityMismatch) {
try bob.processHandshakeMessage(
from: alicePeerID,
message: forged3
)
}
#expect(bob.hasEstablishedSession(with: alicePeerID))
let recorder = Recorder()
recorder.transportGenerationReady = false
let handler = makeServiceBackedHandler(
service: bob,
localPeerID: bobPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
let packet = makeEncryptedPacket(
recipientID: Data(hexString: bobPeerID.id),
payload: delayedCiphertext
)
// Manager rollback is visible, while the BLE restore callback is
// deliberately still queued behind this ciphertext.
handler.handleEncrypted(packet, from: alicePeerID)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
recorder.transportGenerationReady = true
handler.handleSessionAuthenticated(alicePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.type == .privateMessage)
#expect(recorder.deliveries.first?.payload == Data([0xBE, 0xEF]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func oversizedCiphertextCannotEvictEstablishedTransport() throws {
let pair = try establishedServices()
let recorder = Recorder()
recorder.transportGenerationReady = true
let handler = makeServiceBackedHandler(
service: pair.receiver,
localPeerID: pair.receiverPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: Data(
count:
NoiseSecurityConstants
.maxPrivateFileCiphertextSize + 1
)
),
from: pair.senderPeerID
)
#expect(
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
)
#expect(recorder.clearedSessions.isEmpty)
let valid = try pair.sender.encrypt(
Data([NoisePayloadType.privateMessage.rawValue, 0x01]),
for: pair.receiverPeerID
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: valid
),
from: pair.senderPeerID
)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.payload == Data([0x01]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func forgedAuthenticationFailureCannotEvictEstablishedTransport()
throws {
let pair = try establishedServices()
let recorder = Recorder()
recorder.transportGenerationReady = true
let handler = makeServiceBackedHandler(
service: pair.receiver,
localPeerID: pair.receiverPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
let valid = try pair.sender.encrypt(
Data([NoisePayloadType.privateMessage.rawValue, 0x02]),
for: pair.receiverPeerID
)
var forged = valid
forged[forged.index(before: forged.endIndex)] ^= 0xFF
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: forged
),
from: pair.senderPeerID
)
#expect(
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
)
#expect(recorder.clearedSessions.isEmpty)
// Authentication failure leaves nonce state untouched, so the exact
// original ciphertext remains valid.
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: valid
),
from: pair.senderPeerID
)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.payload == Data([0x02]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func replayCannotEvictEstablishedTransportOrBlockNextNonce() throws {
let pair = try establishedServices()
let recorder = Recorder()
recorder.transportGenerationReady = true
let handler = makeServiceBackedHandler(
service: pair.receiver,
localPeerID: pair.receiverPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
let first = try pair.sender.encrypt(
Data([NoisePayloadType.privateMessage.rawValue, 0x03]),
for: pair.receiverPeerID
)
let firstPacket = makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: first
)
handler.handleEncrypted(firstPacket, from: pair.senderPeerID)
handler.handleEncrypted(firstPacket, from: pair.senderPeerID)
#expect(
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
)
#expect(recorder.clearedSessions.isEmpty)
let next = try pair.sender.encrypt(
Data([NoisePayloadType.privateMessage.rawValue, 0x04]),
for: pair.receiverPeerID
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: next
),
from: pair.senderPeerID
)
#expect(recorder.deliveries.count == 2)
#expect(recorder.deliveries.map { $0.payload } == [
Data([0x03]), Data([0x04])
])
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func rateLimitFailureCannotEvictEstablishedTransportOrConsumeNonce()
throws {
let pair = try establishedServices()
let recorder = Recorder()
recorder.transportGenerationReady = true
recorder.forcedServiceDecryptError =
NoiseSecurityError.rateLimitExceeded
let handler = makeServiceBackedHandler(
service: pair.receiver,
localPeerID: pair.receiverPeerID,
recorder: recorder,
transportGenerationIsReady: { _ in
recorder.transportGenerationReady
}
)
let valid = try pair.sender.encrypt(
Data([NoisePayloadType.privateMessage.rawValue, 0x05]),
for: pair.receiverPeerID
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: Data(repeating: 0xA5, count: 20)
),
from: pair.senderPeerID
)
#expect(
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
)
#expect(recorder.clearedSessions.isEmpty)
recorder.forcedServiceDecryptError = nil
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: pair.receiverPeerID.id),
payload: valid
),
from: pair.senderPeerID
)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.payload == Data([0x05]))
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func maximumPrivateFileCiphertextIsEligibleForDeferredRetry() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data(
count: NoiseSecurityConstants.maxPrivateFileCiphertextSize
)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.privateFile.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.type == .privateFile)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func oversizedEarlyCiphertextIsNotDeferred() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data(
count:
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 1
)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func missingSessionCiphertextIsRetriedAfterResponderHandshakeCompletes() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
NoiseEncryptionError.sessionNotEstablished
)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.initiatedHandshakes.isEmpty)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func lowNonceCiphertextIsRetriedAfterResponderHandshakeCompletes() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(NoiseError.replayDetected)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.readReceipt.rawValue, 0x02])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.type == .readReceipt)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func invalidDeferredCiphertextDoesNotClearAuthenticatedSession() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let encrypted = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
)
handler.handleEncrypted(encrypted, from: remotePeerID)
recorder.awaitingResponderHandshake = false
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func nonCipherFailureDuringResponderHandshakeIsDroppedNotDeferred() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(TestError())
let handler = makeHandler(recorder: recorder)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
),
from: remotePeerID
)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func earlyCiphertextBufferIsBoundedPerPeer() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
for index in 0..<5 {
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
timestamp: UInt64(900_000 + index)
),
from: remotePeerID
)
}
#expect(recorder.decryptCalls.count == 5)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 9)
#expect(recorder.deliveries.count == 4)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func earlyCiphertextBufferIsBoundedGlobally() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let peers = (1...33).map {
PeerID(str: String(format: "%016llx", UInt64($0)))
}
let packet = makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
)
for peerID in peers {
handler.handleEncrypted(packet, from: peerID)
}
#expect(recorder.decryptCalls.count == 33)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
for peerID in peers {
handler.handleSessionAuthenticated(peerID)
}
#expect(recorder.decryptCalls.count == 65)
#expect(recorder.deliveries.count == 32)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func earlyCiphertextBufferKeepsPrivateFileRoomAndByteBound() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
let peers = [
PeerID(str: "0000000000000001"),
PeerID(str: "0000000000000002"),
PeerID(str: "0000000000000003")
]
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data(
count:
NoiseSecurityConstants.maxPrivateFileCiphertextSize
)
),
from: peers[0]
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data(count: 256 * 1024)
),
from: peers[1]
)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id),
payload: Data([0x01])
),
from: peers[2]
)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
for peerID in peers {
handler.handleSessionAuthenticated(peerID)
}
#expect(recorder.decryptCalls.count == 5)
#expect(recorder.deliveries.count == 2)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func expiredEarlyCiphertextIsNotRetried() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
),
from: remotePeerID
)
recorder.currentDate =
recorder.currentDate.addingTimeInterval(
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
+ 0.001
)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 1)
#expect(recorder.deliveries.isEmpty)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func earlyCiphertextSurvivesResponderHandshakeWindow() {
let recorder = Recorder()
recorder.hasSession = true
recorder.awaitingResponderHandshake = true
recorder.decryptResult = .failure(
CryptoKitError.authenticationFailure
)
let handler = makeHandler(recorder: recorder)
handler.handleEncrypted(
makeEncryptedPacket(
recipientID: Data(hexString: localPeerID.id)
),
from: remotePeerID
)
recorder.currentDate =
recorder.currentDate.addingTimeInterval(
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
- 0.001
)
recorder.awaitingResponderHandshake = false
recorder.decryptResult = .success(
Data([NoisePayloadType.delivered.rawValue, 0x01])
)
handler.handleSessionAuthenticated(remotePeerID)
#expect(recorder.decryptCalls.count == 2)
#expect(recorder.deliveries.count == 1)
#expect(recorder.clearedSessions.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
}
private func makeHandshakePacket(recipientID: Data?) -> BitchatPacket {
BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
@@ -360,14 +1294,15 @@ struct BLENoisePacketHandlerTests {
private func makeEncryptedPacket(
recipientID: Data?,
timestamp: UInt64 = 900_000
timestamp: UInt64 = 900_000,
payload: Data = Data([0xC0, 0xFF, 0xEE])
) -> BitchatPacket {
BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: remotePeerID.id) ?? Data(),
recipientID: recipientID,
timestamp: timestamp,
payload: Data([0xC0, 0xFF, 0xEE]),
payload: payload,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
@@ -0,0 +1,118 @@
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))
#expect(
PeerCapabilities.localSupported.contains(.privateMediaReceipts)
)
}
}
@@ -0,0 +1,206 @@
import Foundation
import Testing
@testable import bitchat
struct BLEPrivateMediaReceiptStoreTests {
private struct TestError: Error {}
private let messageID = "media-00112233445566778899aabbccddeeff"
@Test
func acceptedReceiptPersistsAcrossStoreInstances() throws {
let root = makeRoot("persist")
defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root)
let first = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(first.commitAccepted(messageID: messageID, storedURL: payload))
#expect(first.state(for: messageID) == .accepted(payload))
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(relaunched.state(for: messageID) == .accepted(payload))
}
@Test
func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws {
let root = makeRoot("list-failure")
defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root)
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
messageID: messageID,
storedURL: payload
))
let record = receiptRecord(in: root)
#expect(FileManager.default.fileExists(atPath: record.path))
var shouldFail = true
let store = BLEPrivateMediaReceiptStore(
baseDirectory: root,
directoryReader: { directory in
if shouldFail {
shouldFail = false
throw TestError()
}
return try FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil
)
}
)
#expect(store.state(for: messageID) == .unavailable)
#expect(FileManager.default.fileExists(atPath: record.path))
#expect(store.state(for: messageID) == .accepted(payload))
}
@Test
func recordReadFailureIsUnavailableAndPreservesReceiptForRetry() throws {
let root = makeRoot("read-failure")
defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root)
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
messageID: messageID,
storedURL: payload
))
let record = receiptRecord(in: root)
var shouldFail = true
let store = BLEPrivateMediaReceiptStore(
baseDirectory: root,
dataReader: { url in
if shouldFail {
shouldFail = false
throw TestError()
}
return try Data(contentsOf: url)
}
)
#expect(store.state(for: messageID) == .unavailable)
#expect(FileManager.default.fileExists(atPath: record.path))
#expect(store.state(for: messageID) == .accepted(payload))
}
@Test
func decodeFailureIsUnavailableAndDoesNotDeleteOrCachePastRepair() throws {
let root = makeRoot("decode-failure")
defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root)
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
messageID: messageID,
storedURL: payload
))
let record = receiptRecord(in: root)
let durableBytes = try Data(contentsOf: record)
let corruptBytes = Data("{not-json".utf8)
try corruptBytes.write(to: record, options: .atomic)
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(store.state(for: messageID) == .unavailable)
#expect(!store.commitAccepted(messageID: messageID, storedURL: payload))
#expect(FileManager.default.fileExists(atPath: record.path))
#expect(try Data(contentsOf: record) == corruptBytes)
try durableBytes.write(to: record, options: .atomic)
#expect(store.state(for: messageID) == .accepted(payload))
}
@Test
func unreadableTombstoneNeverBecomesAbsentOrGetsDeleted() throws {
let root = makeRoot("tombstone-decode")
defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root)
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(seed.commitAccepted(messageID: messageID, storedURL: payload))
#expect(seed.recordDeleted(messageID: messageID))
#expect(!FileManager.default.fileExists(atPath: payload.path))
let record = receiptRecord(in: root)
let durableBytes = try Data(contentsOf: record)
try Data([0xFF, 0x00, 0x7B]).write(to: record, options: .atomic)
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(relaunched.state(for: messageID) == .unavailable)
#expect(FileManager.default.fileExists(atPath: record.path))
try durableBytes.write(to: record, options: .atomic)
#expect(relaunched.state(for: messageID) == .tombstoned)
}
@Test
func failedTombstonePersistenceDoesNotPoisonVolatileState() throws {
let root = makeRoot("failed-tombstone-write")
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(store.state(for: messageID) == .absent)
// Force the atomic record write itself to fail after the store has
// successfully loaded its empty index.
let record = receiptRecord(in: root)
try FileManager.default.createDirectory(
at: record,
withIntermediateDirectories: true
)
#expect(!store.recordDeleted(messageID: messageID))
try FileManager.default.removeItem(at: record)
// The UI must be able to report the deletion failure without a
// process-lifetime tombstone silently hiding a later retry.
#expect(store.state(for: messageID) == .absent)
}
@Test
func unreleasedAggregateLedgerIsIgnoredAndLeftUntouched() throws {
let root = makeRoot("no-legacy-migration")
defer { try? FileManager.default.removeItem(at: root) }
let files = root.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(
at: files,
withIntermediateDirectories: true
)
let legacy = files.appendingPathComponent(
".private-media-receipts.json",
isDirectory: false
)
let bytes = Data(
#"{"entries":{"media-00112233445566778899aabbccddeeff":{"relativePath":"images/incoming/old.jpg","acceptedAt":0}}}"#
.utf8
)
try bytes.write(to: legacy, options: .atomic)
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(store.state(for: messageID) == .absent)
#expect(try Data(contentsOf: legacy) == bytes)
}
private func makeRoot(_ label: String) -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent(
"private-media-receipt-\(label)-\(UUID().uuidString)",
isDirectory: true
)
}
private func makePayload(in root: URL) throws -> URL {
let directory = root.appendingPathComponent(
"files/images/incoming",
isDirectory: true
)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
let payload = directory.appendingPathComponent("image.jpg")
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
return payload
}
private func receiptRecord(in root: URL) -> URL {
root
.appendingPathComponent(
"files/.private-media-receipts",
isDirectory: true
)
.appendingPathComponent(messageID)
.appendingPathExtension("json")
}
}
File diff suppressed because it is too large Load Diff
+12 -13
View File
@@ -28,6 +28,13 @@ peer's Noise session before BLE fragmentation.
`0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a
no-proof timeout reaches the explicit legacy-consent path for an unpinned
peer. No timeout automatically sends raw bytes.
- `PeerCapabilities.privateMediaReceipts` is bit 9. Its exact-session
authenticated proof enables bounded sender-side automatic retry; a public
announce never does. It does not replace bit 8: encrypted `0x20` media from
bit-8-only prior iOS clients keeps the same deterministic stable ID and
delivery ACK. Receivers durably commit that ID before UI delivery or ACK, so
a lost proof followed by a later bit-9 retry cannot create a second,
random-ID bubble.
- An unpinned peer with a stable Noise key but without that capability is
eligible for one signed, directed
`fileTransfer`, matching the pre-migration wire form used by older iOS and
@@ -100,16 +107,8 @@ temporary `0x09` alias. Ordinary Noise messages retain their 64 KiB limit.
Current Android builds cap each reassembly at 256 fragments. Depending on the
negotiated BLE packet size and routing overhead, that is roughly 110-120 KiB,
well below iOS's absolute inbound ceiling. That cap only applies to those
receivers, which take private media exclusively over the directed raw-file
migration fallback (they do not implement the encrypted `0x20` path).
Private-media v1 therefore runs the actual route-aware BLE fragment planner
before a consented legacy send and rejects any plan above 256 fragments with a
visible failure. Encrypted sends go only to peers that advertised the
`privateMedia` capability — modern clients that reassemble up to the full
receiver ceiling (10,000 fragments) — so they are not held to Android's cap and
iOS→iOS photos in the ~120-512 KiB range keep working. This fragment-count
contract, rather than a guessed byte threshold, stays correct as route overhead
changes. A future Android client that adopts `0x20` but still caps its
reassembler would need to negotiate an explicit per-peer fragment limit
(tracked as a #1434 follow-up).
well below iOS's absolute inbound ceiling. Private-media v1 therefore runs the
actual route-aware BLE fragment planner before both encrypted and consented
legacy sends and rejects any plan above 256 fragments with a visible failure.
This fragment-count contract, rather than a guessed byte threshold, stays
correct as route overhead changes.
@@ -28,6 +28,18 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
/// before outer BLE fragmentation. Peers that omit this bit require the
/// signed directed raw-file migration fallback.
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
/// Stable private-media IDs are durably deduplicated by the receiver and
/// correlated delivery/read receipts permit bounded automatic resend.
///
/// Bit 8 remains the encrypted-media compatibility contract. Bit 9 only
/// enables sender-side automatic retry after exact-session proof.
public static let privateMediaReceipts =
PeerCapabilities(rawValue: 1 << 9)
/// Reserved for test builds that briefly advertised non-destructive Noise
/// replacement. Current clients intentionally do not advertise or act on
/// this bit; keep it decodable so the wire assignment is never reused.
public static let nonDestructiveNoiseReplacement =
PeerCapabilities(rawValue: 1 << 10)
/// Minimal little-endian byte encoding; always at least one byte so an
/// empty set is distinguishable from an absent TLV.
@@ -18,10 +18,30 @@ struct PeerCapabilitiesTests {
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
#expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01]))
let high = PeerCapabilities(rawValue: 1 << 9)
#expect(high.encoded() == Data([0x00, 0x02]))
#expect(
PeerCapabilities.privateMediaReceipts.encoded()
== Data([0x00, 0x02])
)
#expect(
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
== Data([0x00, 0x04])
)
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
let high = PeerCapabilities(rawValue: 1 << 11)
#expect(high.encoded() == Data([0x00, 0x08]))
let all: PeerCapabilities = [
.prekeys,
.wifiBulk,
.gateway,
.groups,
.board,
.vouch,
.meshDiagnostics,
.privateMedia,
.privateMediaReceipts,
.nonDestructiveNoiseReplacement
]
#expect(PeerCapabilities(encoded: all.encoded()) == all)
#expect(PeerCapabilities(encoded: high.encoded()) == high)
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
+405 -431
View File
@@ -1,442 +1,416 @@
Relay URL,Latitude,Longitude
bitchat.nostr1.com,40.7057,-74.0136
relay.fundstr.me,42.3601,-71.0589
nostr.2b9t.xyz,34.0549,-118.243
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
relay.lab.rytswd.com,49.4543,11.0746
relay.paulstephenborile.com:443,49.4543,11.0746
relay.binaryrobot.com,43.6532,-79.3832
nostr-2.21crypto.ch,47.5356,8.73209
relay.kaleidoswap.com,50.8476,4.35717
relay.libernet.app:443,43.6532,-79.3832
relay.homeinhk.xyz,35.694,139.754
relay.manneken.brussels,49.4543,11.0746
nostr.spicyz.io:443,43.6532,-79.3832
relay.lanacoin-eternity.com:443,40.8302,-74.1299
ribo.us.nostria.app:443,43.6532,-79.3832
relay.loveisbitcoin.com,43.6532,-79.3832
relay.angor.io:443,48.1046,11.6002
relay02.lnfi.network,35.6764,139.65
relay.cosmicbolt.net:443,37.3986,-121.964
spookstr2.nostr1.com:443,40.7057,-74.0136
fanfares.nostr1.com:443,40.7057,-74.0136
x.kojira.io,43.6532,-79.3832
freelay.sovbit.host,60.1699,24.9384
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
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
nostr.data.haus,50.4754,12.3683
wot.sudocarlos.com,43.6532,-79.3832
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
shu01.shugur.net,21.4902,39.2246
relay.gulugulu.moe:443,43.6532,-79.3832
relay2.angor.io:443,48.1046,11.6002
relay.libernet.app,43.6532,-79.3832
directories-safe-motherboard-recipients.trycloudflare.com,43.6532,-79.3832
wot.nostr.party,36.1659,-86.7844
relay.angor.io,48.1046,11.6002
relay-arg.zombi.cloudrodion.com,1.35208,103.82
nostr-01.yakihonne.com,1.32123,103.695
nostr-relay.cbrx.io,43.6532,-79.3832
relay.guggero.org,46.5971,9.59652
nostr.snowbla.de,60.1699,24.9384
relay.zone667.com,60.1699,24.9384
nostr.wild-vibes.ts.net,48.8566,2.35222
relay.nostr.com,50.1109,8.68213
nostr.iskarion.ddns.net,43.3076,-2.95421
relay-dev.satlantis.io,39.0438,-77.4874
relay.sovereignresonance.org,48.9006,2.25929
relay.nostrian-conquest.com,41.223,-111.974
relay.aidatanorge.no,43.6532,-79.3832
strfry.apps3.slidestr.net,40.4167,-3.70329
relay.klabo.world,47.2343,-119.853
nostr.data.haus:443,50.4754,12.3683
testr.nymble.world,40.8054,-74.0241
relay.inforsupports.com,43.6532,-79.3832
nexus.libernet.app:443,43.6532,-79.3832
relay.islandbitcoin.com,12.8498,77.6545
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr-relay.xbytez.io,50.6924,3.20113
kasztanowa.bieda.it,43.6532,-79.3832
nostrcity-club.fly.dev,37.7648,-122.432
relay.typedcypher.com,51.5072,-0.127586
nostr.na.social:443,43.6532,-79.3832
relay.laantungir.net,-19.4692,-42.5315
relay-dev.satlantis.io:443,40.8302,-74.1299
rilo.nostria.app,43.6532,-79.3832
nostr.hekster.org:443,37.3986,-121.964
nostr-relay.amethyst.name:443,39.0067,-77.4291
chat-relay.zap-work.com:443,43.6532,-79.3832
relay.edufeed.org,49.4521,11.0767
syb.lol:443,43.6532,-79.3832
relay.sigit.io,50.4754,12.3683
nostr-relay.xbytez.io:443,50.6924,3.20113
relay.wavefunc.live,41.8781,-87.6298
nostr.sathoarder.com,48.5734,7.75211
myvoiceourstory.org,37.3598,-121.981
relay.underorion.se,50.1109,8.68213
nostr.data.haus,50.4754,12.3683
relay.erybody.com,41.4513,-81.7021
espelho.girino.org,43.6532,-79.3832
nostr.pbfs.io:443,50.4754,12.3683
wot.dergigi.com,64.1476,-21.9392
nostr.bitcoiner.social:443,47.6743,-117.112
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.gulugulu.moe,43.6532,-79.3832
nostr.spicyz.io,43.6532,-79.3832
relay.cypherflow.ai,48.8575,2.35138
treuzkas.branruz.com,48.8575,2.35138
relay1.nostrchat.io,60.1699,24.9384
kotukonostr.onrender.com,37.7775,-122.397
nostr.plantroon.com,50.1013,8.62643
nostr.davenov.com,50.1109,8.68213
node.kommonzenze.de,49.4521,11.0767
relay2.veganostr.com,60.1699,24.9384
armada.sharegap.net,43.6532,-79.3832
wot.makenomistakes.ca,43.7064,-79.3986
nostr.2b9t.xyz:443,34.0549,-118.243
relay.libernet.app:443,43.6532,-79.3832
relay.dreamith.to:443,43.6532,-79.3832
relay.lightning.pub:443,39.0438,-77.4874
nostr.rtvslawenia.com,49.4543,11.0746
nostr.21crypto.ch,47.5356,8.73209
relay.ditto.pub:443,43.6532,-79.3832
relay.plebchain.club,43.6532,-79.3832
memlay.v0l.io,53.3498,-6.26031
nostr.chaima.info:443,50.1109,8.68213
relay.wavlake.com:443,41.2619,-95.8608
nostr.thalheim.io:443,60.1699,24.9384
relay.lightning.pub,39.0438,-77.4874
dev.relay.edufeed.org:443,49.4521,11.0767
nostr.myshosholoza.co.za:443,52.3913,4.66545
relay.binaryrobot.com:443,43.6532,-79.3832
wot.nostr.place,43.6532,-79.3832
nostr.sathoarder.com:443,48.5734,7.75211
thecitadel.nostr1.com,40.7057,-74.0136
relay.artx.market,43.6548,-79.3885
nos.lol,50.4754,12.3683
nostr.plantroon.com:443,50.1013,8.62643
premium.primal.net,43.6532,-79.3832
nas01xanthosnet.synology.me:7778,47.1285,8.74735
nostrja-kari.heguro.com,43.6532,-79.3832
relay.mrmave.work,43.6532,-79.3832
nostrelay.circum.space,52.6907,4.8181
mostro-p2p.tech,50.1109,8.68213
wot.shaving.kiwi,43.6532,-79.3832
relay.fundstr.me,42.3601,-71.0589
nostrelay.circum.space:443,52.6907,4.8181
relay.nostrdice.com,-33.8688,151.209
relay.getvia.xyz,60.1699,24.9384
strfry.shock.network:443,39.0438,-77.4874
relay.nostrmap.net:443,60.1699,24.9384
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
nostr.islandarea.net:443,35.4669,-97.6473
nostr.rtvslawenia.com,49.4543,11.0746
relay.bowlafterbowl.com,32.9483,-96.7299
nostr.quali.chat:443,60.1699,24.9384
relay.plebeian.market,50.1109,8.68213
relay-rpi.edufeed.org,49.4521,11.0767
r.0kb.io,32.789,-96.7989
nostr.notribe.net:443,40.8302,-74.1299
relay.getsafebox.app:443,43.6532,-79.3832
nostr.dlcdevkit.com:443,40.0992,-83.1141
nostrelites.org,34.9582,-81.9907
nostr.hoppe-relay.it.com,42.8864,-78.8784
nostr.thebiglake.org,32.71,-96.6745
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
relay.nostriot.com,41.5695,-83.9786
nostr.christiansass.de,51.7634,7.8887
relay.btcforplebs.com,43.6532,-79.3832
nostr.tagomago.me,42.3601,-71.0589
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
bucket.coracle.social,37.7775,-122.397
blossom.gnostr.cloud,43.6532,-79.3832
relay.binaryrobot.com,43.6532,-79.3832
relay.earthly.city,34.1749,-118.54
nostr.hifish.org,47.4244,8.57658
offchain.pub:443,39.1585,-94.5728
relay.bullishbounty.com:443,43.6532,-79.3832
strfry.openhoofd.nl:443,51.5717,3.70417
cs-relay.nostrdev.com:443,50.4754,12.3683
strfry.ymir.cloud,43.6532,-79.3832
nostrbtc.com,43.6532,-79.3832
relay.directsponsor.net,42.8864,-78.8784
nostr2.girino.org,43.6532,-79.3832
relay.sigit.io:443,50.4754,12.3683
relay.getsafebox.app,43.6532,-79.3832
antiprimal.net,43.6532,-79.3832
nostr.sathoarder.com,48.5734,7.75211
inbox.scuba323.com,40.8218,-74.45
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
nostr.tac.lol,47.4748,-122.273
nostr.davenov.com,50.1109,8.68213
relay.trotters.cc:443,43.6532,-79.3832
nostr.plantroon.com:443,50.1013,8.62643
relay.nostreon.com,60.1699,24.9384
nostr.easycryptosend.it,43.6532,-79.3832
nostr-01.yakihonne.com:443,1.32123,103.695
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.purpura.cloud,43.6532,-79.3832
insta-relay.apps3.slidestr.net,40.4167,-3.70329
nostr.mifen.me,43.6532,-79.3832
testnet-relay.samt.st:443,40.8302,-74.1299
nostr.2b9t.xyz:443,34.0549,-118.243
relay.wavlake.com:443,41.2619,-95.8608
relay.wisp.talk:443,49.4543,11.0746
relay-dev.satlantis.io:443,39.0438,-77.4874
relay.satlantis.io,39.0438,-77.4874
relay.staging.plebeian.market,51.5072,-0.127586
relay.openfarmtools.org,60.1699,24.9384
relay.nostrhub.fr,48.1045,11.6004
nostr-relay.xbytez.io,50.6924,3.20113
relay.binaryrobot.com:443,43.6532,-79.3832
relay.samt.st,40.8302,-74.1299
relay.illuminodes.com,43.6532,-79.3832
relay.liberbitworld.org,43.6532,-79.3832
relay.olas.app:443,60.1699,24.9384
no.str.cr,8.96171,-83.5246
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
wot.rejecttheframe.xyz,43.6532,-79.3832
relay.nostriot.com:443,41.5695,-83.9786
nostr.plantroon.com,50.1013,8.62643
nostr-01.uid.ovh,50.9871,2.12554
relay.openresist.com:443,43.6532,-79.3832
nostr.overmind.lol,43.6532,-79.3832
relay.internationalright-wing.org,-22.5022,-48.7114
nostr.myshosholoza.co.za:443,52.3676,4.90414
nostr.pbfs.io:443,50.4754,12.3683
21milionidinostr.duckdns.org,41.8967,12.4822
nostr.4rs.nl,49.0291,8.35696
relay.lanavault.space,60.1699,24.9384
relay.mostr.pub,43.6532,-79.3832
relay.nostar.org,43.6532,-79.3832
nostr.mom,50.4754,12.3683
relay.decentralia.fr,48.122,11.589
relay.agentry.com,42.8864,-78.8784
relay2.angor.io,48.1046,11.6002
slick.mjex.me,39.0418,-77.4744
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
relay.vrtmrz.net:443,43.6532,-79.3832
relay.beginningend.com,35.2227,-97.4786
chat-relay.zap-work.com:443,43.6532,-79.3832
relay.underorion.se,50.1109,8.68213
relay.mitchelltribe.com,39.0438,-77.4874
relay.qstr.app,51.5072,-0.127586
relay.cyberguy.fyi,52.6907,4.8181
strfry.bonsai.com:443,39.0438,-77.4874
relayone.soundhsa.com:443,39.0997,-94.5786
relay.sigit.io,50.4754,12.3683
relay.npubhaus.com,43.6532,-79.3832
relayrs.notoshi.win,43.6532,-79.3832
relay.mitchelltribe.com:443,39.0438,-77.4874
relay.44billion.net,43.6532,-79.3832
reraw.pbla2fish.cc,43.6532,-79.3832
articles.layer3.news:443,37.3387,-121.885
nostr.sovereignservices.xyz,43.6532,-79.3832
relay.nostx.io,43.6532,-79.3832
nostr-relay.amethyst.name,39.0067,-77.4291
0x-nostr-relay.fly.dev,37.7648,-122.432
relay.ohstr.com:443,43.6532,-79.3832
00f2e774.relay.dev.thunderegg.us,39.0438,-77.4874
nostr-relay.cbrx.io,43.6532,-79.3832
relay.wavlake.com,41.2619,-95.8608
purplerelay.com:443,43.6532,-79.3832
nostr-pr02.redscrypt.org,52.3676,4.90414
fanfares.nostr1.com:443,40.7057,-74.0136
kasztanowa.bieda.it,43.6532,-79.3832
relay.flashapp.me,43.6548,-79.3885
relay.typedcypher.com,51.5072,-0.127586
nostr.bond,50.1109,8.68213
nostr.azzamo.net,52.2633,21.0283
nexus.libernet.app,43.6532,-79.3832
relay.cosmicbolt.net,37.3986,-121.964
schnorr.me,43.6532,-79.3832
relay.mostro.network:443,40.8302,-74.1299
relay-arg.zombi.cloudrodion.com,1.35208,103.82
relay.chorus.community,48.5333,10.7
blossom.gnostr.cloud:443,43.6532,-79.3832
syb.lol:443,34.0549,-118.243
relay.dyne.org,49.0291,8.35705
btc.klendazu.com,41.2861,1.24993
wot.nostr.place,43.6532,-79.3832
relay.openresist.com,43.6532,-79.3832
rilo.nostria.app:443,43.6532,-79.3832
no.str.cr:443,8.96171,-83.5246
relay.mostr.pub:443,43.6532,-79.3832
relay.edufeed.org:443,49.4521,11.0767
nostr.debate.report,50.1109,8.68213
relay.satmaxt.xyz:443,43.6532,-79.3832
relay.artx.market:443,43.6548,-79.3885
relay-dev.gulugulu.moe,43.6532,-79.3832
relay.novospes.com,43.6532,-79.3832
relay.nostr-check.me,43.6532,-79.3832
nostr.computingcache.com,34.0356,-118.442
nostr.oxtr.dev,50.4754,12.3683
relay.fckstate.net,59.3293,18.0686
relay.vrtmrz.net,43.6532,-79.3832
relay.bornheimer.app,51.5072,-0.127586
relay.guggero.org,46.5971,9.59652
relay01.lnfi.network,35.6764,139.65
wot.shaving.kiwi,43.6532,-79.3832
nostr.twinkle.lol,51.902,7.6657
relay.edufeed.org,49.4521,11.0767
relay.lanacoin-eternity.com,40.8302,-74.1299
relay.satmaxt.xyz,43.6532,-79.3832
nostr.hifish.org:443,47.4244,8.57658
relay.cypherflow.ai:443,48.8575,2.35138
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
nostr.na.social:443,43.6532,-79.3832
nostr.rtvslawenia.com:443,49.4543,11.0746
relay.mypathtofire.de,42.8864,-78.8784
public.crostr.com,43.6532,-79.3832
relay.olas.app,60.1699,24.9384
relay.agora.social,50.7383,15.0648
ribo.nostria.app,43.6532,-79.3832
relay.lab.rytswd.com,49.4543,11.0746
relay.ditto.pub:443,43.6532,-79.3832
porchlight.social,43.6532,-79.3832
nostr.notribe.net,40.8302,-74.1299
relay.endfiat.money,59.3327,18.0656
nostr.myshosholoza.co.za,52.3676,4.90414
relay.nearhood.co.uk,51.5134,-0.0890675
relay.degmods.com,50.4754,12.3683
nostr.novacisko.cz,52.2026,20.9397
prl.plus,55.7628,37.5983
bruh.samt.st,43.6532,-79.3832
strfry.openhoofd.nl,51.5717,3.70417
nostr.spicyz.io,43.6532,-79.3832
nostr.na.social,43.6532,-79.3832
nip85.nosfabrica.com,39.0997,-94.5786
premium.primal.net,43.6532,-79.3832
fanfares.nostr1.com,40.7057,-74.0136
relay.scuba323.com,40.8218,-74.45
nostr2.girino.org:443,43.6532,-79.3832
relay.mmwaves.de,48.8575,2.35138
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
strfry.shock.network:443,39.0438,-77.4874
nostr.snowbla.de,50.4754,12.3683
nostr.spaceshell.xyz,43.6532,-79.3832
nostr.quali.chat,60.1699,24.9384
wot.utxo.one,43.6532,-79.3832
relay.mccormick.cx,52.3563,4.95714
mostro-p2p.tech,50.1109,8.68213
basspistol.org,49.0291,8.35696
ribo.nostria.app:443,43.6532,-79.3832
chorus.mikedilger.com:444,-36.8906,174.794
nostr.oxtr.dev:443,50.4754,12.3683
nostr.nodesmap.com,59.3327,18.0656
offchain.bostr.online,43.6532,-79.3832
purplerelay.com,43.6532,-79.3832
relayrs.notoshi.win:443,43.6532,-79.3832
relay.wavefunc.live,41.8781,-87.6298
relay.dreamith.to,43.6532,-79.3832
bendernostur.duckdns.org:8443,50.1109,8.68213
relay.nmail.li,50.9871,2.12554
nostr-relay.corb.net,39.6478,-104.988
relay.staging.plebeian.market:443,51.5072,-0.127586
spamspamspamspam.rest,43.6532,-79.3832
relay1.gfcom.info,13.9215,100.538
schnorr.me:443,43.6532,-79.3832
relay.lab.rytswd.com:443,49.4543,11.0746
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.nostrmap.net,60.1699,24.9384
nostr.relay.hedwig.sh,60.1699,24.9384
relay.veganostr.com:443,60.1699,24.9384
relay.wavefunc.live:443,41.8781,-87.6298
nostr.mikoshi.de,52.52,13.405
syb.lol,34.0549,-118.243
relay1.nostrchat.io,60.1699,24.9384
nostr.wecsats.io:443,43.6532,-79.3832
nostr.chaima.info:443,51.5072,-0.127586
nostr.azzamo.net:443,52.2633,21.0283
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
nostr.unkn0wn.world,46.8499,9.53287
relayone.soundhsa.com,39.0997,-94.5786
x.kojira.io,43.6532,-79.3832
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
nostrelay.circum.space,52.6907,4.8181
relay.primal.net,43.6532,-79.3832
nostr.girino.org,43.6532,-79.3832
nostr.pbfs.io,50.4754,12.3683
relay.kalcafe.xyz,37.3986,-121.964
relay.gulugulu.moe,43.6532,-79.3832
top.testrelay.top,43.6532,-79.3832
relay.kilombino.com,43.6532,-79.3832
nos.lol:443,50.4754,12.3683
nos.lol,50.4754,12.3683
relay.nostr.place:443,43.6532,-79.3832
cache.trustr.ing,43.6548,-79.3885
relay.internationalright-wing.org:443,-22.5022,-48.7114
relay.laantungir.net,-19.4692,-42.5315
relay.lightning.pub:443,39.0438,-77.4874
nostr.stakey.net,52.3676,4.90414
articles.layer3.news,37.3387,-121.885
relay.wisp.talk,49.4543,11.0746
relay.pyramid.li,47.4093,8.46503
relay.typedcypher.com:443,51.5072,-0.127586
dev.relay.stream,43.6532,-79.3832
relay.bullishbounty.com,43.6532,-79.3832
nostr.mom:443,50.4754,12.3683
relay.plebeian.market:443,50.1109,8.68213
nostr.hekster.org,37.3986,-121.964
nostrcity-club.fly.dev,37.7648,-122.432
nostr.vulpem.com,49.4543,11.0746
relay-dev.gulugulu.moe:443,43.6532,-79.3832
weboftrust.libretechsystems.xyz,55.4724,9.87335
nostr-relay.corb.net:443,39.6478,-104.988
wheat.happytavern.co,43.6532,-79.3832
relay.mappingbitcoin.com,43.6532,-79.3832
testnet-relay.samt.st,40.8302,-74.1299
relay.bitmacro.cloud,43.6532,-79.3832
dev.relay.edufeed.org,49.4521,11.0767
myvoiceourstory.org,37.3598,-121.981
relay.stickeroo.is-cool.dev,37.3387,-121.885
relay.agorist.space,52.3734,4.89406
freelay.sovbit.host,60.1699,24.9384
nostr-dev.wellorder.net,45.5201,-122.99
nostr.middling.mydns.jp,35.8099,140.12
cs-relay.nostrdev.com,50.4754,12.3683
x.kojira.io:443,43.6532,-79.3832
nostrelay.circum.space:443,52.6907,4.8181
nostr.janx.com,43.6532,-79.3832
relay.mrmave.work,43.6532,-79.3832
espelho.girino.org,43.6532,-79.3832
hol.is,43.6532,-79.3832
ribo.eu.nostria.app,43.6532,-79.3832
nostr.yutakobayashi.com,43.6532,-79.3832
relay.mostro.network,40.8302,-74.1299
communities.nos.social,40.8302,-74.1299
relay.solife.me,43.6532,-79.3832
yabu.me,35.6092,139.73
relay.islandbitcoin.com,12.8498,77.6545
nostr.wecsats.io,43.6532,-79.3832
nostr.tac.lol:443,47.4748,-122.273
relay.arx-ccn.com,50.4754,12.3683
nostrride.io,37.3986,-121.964
r.0kb.io:443,32.789,-96.7989
herbstmeister.com,34.0549,-118.243
relay.artx.market,43.6548,-79.3885
vault.iris.to,43.6532,-79.3832
relay.ru.ac.th,13.7607,100.627
temp.iris.to,43.6532,-79.3832
social.amanah.eblessing.co,48.1046,11.6002
nostr-relay.nextblockvending.com,47.2343,-119.853
wot.codingarena.top,50.4754,12.3683
relay.sincensura.org,43.6532,-79.3832
nostr.quali.chat,60.1699,24.9384
relay.vrtmrz.net,43.6532,-79.3832
relay-dev.gulugulu.moe:443,43.6532,-79.3832
relay.bullishbounty.com,43.6532,-79.3832
relay.fckstate.net,59.3293,18.0686
nostr.rtvslawenia.com:443,49.4543,11.0746
relay.nostx.io,43.6532,-79.3832
relay.agorist.space,52.3734,4.89406
relay.notoshi.win,13.7829,100.546
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
relay.trotters.cc,43.6532,-79.3832
relay.lanavault.space,60.1699,24.9384
public.crostr.com:443,43.6532,-79.3832
nostr.stakey.net:443,52.3676,4.90414
relay.nostr.place:443,43.6532,-79.3832
nostr.dlcdevkit.com,40.0992,-83.1141
nostr.aruku.ovh,1.27994,103.849
satsage.xyz,37.3986,-121.964
strfry.apps3.slidestr.net,40.4167,-3.70329
nostr2.girino.org,43.6532,-79.3832
relay.samt.st,40.8302,-74.1299
articles.layer3.news,37.3387,-121.885
aeon.libretechsystems.xyz,55.486,9.86577
relay.routstr.com,59.4016,17.9455
relay.ohstr.com:443,43.6532,-79.3832
relay.lanacoin-eternity.com:443,40.8302,-74.1299
strfry.openhoofd.nl:443,51.5717,3.70417
nostr.blankfors.se,60.1699,24.9384
nostr-2.21crypto.ch:443,47.5356,8.73209
relayone.soundhsa.com:443,39.1008,-94.5811
relay.lab.rytswd.com:443,49.4543,11.0746
nostr.tagomago.me,42.3601,-71.0589
relay.0xchat.com:443,43.6532,-79.3832
1 Relay URL Latitude Longitude
2 bitchat.nostr1.com relay.lab.rytswd.com 40.7057 49.4543 -74.0136 11.0746
3 relay.fundstr.me relay.paulstephenborile.com:443 42.3601 49.4543 -71.0589 11.0746
4 nostr.2b9t.xyz relay.binaryrobot.com 34.0549 43.6532 -118.243 -79.3832
armada.sharegap.net 43.6532 -79.3832
nostr.chaima.info 51.5072 -0.127586
nosflare-leefcore.leefcore.workers.dev 43.6532 -79.3832
ribo.eu.nostria.app:443 43.6532 -79.3832
relay.lightning.pub 39.0438 -77.4874
relay.nostu.be 40.4167 -3.70329
nostr.whitenode45.ddns.net 40.55 -74.4758
nostr.carroarmato0.be:443 50.914 3.21378
cdn.satellite.earth 40.8302 -74.1299
relay2.veganostr.com 60.1699 24.9384
relay.layer.systems:443 49.0291 8.35695
relay0.gfcom.info 13.7653 100.647
relay.mmwaves.de:443 48.8575 2.35138
offchain.pub 39.1585 -94.5728
bcast.girino.org 43.6532 -79.3832
staging.yabu.me 35.6092 139.73
nostr.overpay.com 29.7449 -95.5343
bridge.tagomago.me 42.3601 -71.0589
nostr-01.yakihonne.com 1.32123 103.695
strfry.bonsai.com 39.0438 -77.4874
relay.sharegap.net 43.6532 -79.3832
nostr.islandarea.net 35.4669 -97.6473
dm-test-strfry-generic.samt.st 43.6532 -79.3832
treuzkas.branruz.com 48.8575 2.35138
relay-rpi.edufeed.org:443 49.4521 11.0767
vault.iris.to:443 43.6532 -79.3832
node.kommonzenze.de 49.4521 11.0767
nostr.thalheim.io:443 60.1699 24.9384
soloco.nl 43.6532 -79.3832
strfry.shock.network 39.0438 -77.4874
nostr-relay.zimage.com 34.0549 -118.243
public.crostr.com:443 43.6532 -79.3832
nostr.sathoarder.com:443 48.5734 7.75211
relay.angor.io 48.1046 11.6002
relay.wellorder.net 45.5201 -122.99
relay.mwaters.net 50.9871 2.12554
relay.staging.commonshub.brussels 49.4543 11.0746
nostr-verified.wellorder.net 45.5201 -122.99
nostr-pub.wellorder.net 45.5201 -122.99
5 nostr-2.21crypto.ch 47.5356 8.73209
6 relay.kaleidoswap.com spookstr2.nostr1.com:443 50.8476 40.7057 4.35717 -74.0136
7 relay.libernet.app:443 fanfares.nostr1.com:443 43.6532 40.7057 -79.3832 -74.0136
8 relay.homeinhk.xyz x.kojira.io 35.694 43.6532 139.754 -79.3832
9 relay.manneken.brussels freelay.sovbit.host 49.4543 60.1699 11.0746 24.9384
nostr.spicyz.io:443 43.6532 -79.3832
relay.lanacoin-eternity.com:443 40.8302 -74.1299
ribo.us.nostria.app:443 43.6532 -79.3832
relay.loveisbitcoin.com 43.6532 -79.3832
relay.angor.io:443 48.1046 11.6002
relay02.lnfi.network 35.6764 139.65
relay.cosmicbolt.net:443 37.3986 -121.964
10 nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
nrs-01.darkcloudarcade.com 39.0997 -94.5786
relay.endfiat.money:443 59.3327 18.0656
relay.paulstephenborile.com 49.4543 11.0746
rele.speyhard.fi 51.5072 -0.127586
relay.froth.zone 60.1699 24.9384
relay.nostr.blockhenge.com 39.0438 -77.4874
nrl.ceskar.xyz 50.5145 16.0119
rilo.nostria.app 43.6532 -79.3832
nostr.overmind.lol:443 43.6532 -79.3832
nostr.snowbla.de:443 50.4754 12.3683
nostrrelay.taylorperron.com 45.5029 -73.5723
chorus.pjv.me 45.5201 -122.99
relay.nostr.place 43.6532 -79.3832
bucket.coracle.social 37.7775 -122.397
nostr.girino.org:443 43.6532 -79.3832
relay.aarpia.com 37.3986 -121.964
nostr.thalheim.io 60.1699 24.9384
ec2.f7z.io 60.1699 24.9384
relay.trotters.cc 43.6532 -79.3832
relay.mccormick.cx:443 52.3563 4.95714
relay.momostr.pink 43.6532 -79.3832
relay.nostr.net 43.6532 -79.3832
conduitl2.fly.dev 37.7648 -122.432
chat-relay.zap-work.com 43.6532 -79.3832
relay.ditto.pub 43.6532 -79.3832
relay.veganostr.com 60.1699 24.9384
relay.minibolt.info:443 43.6532 -79.3832
adre.su 59.9311 30.3609
bitcoinostr.duckdns.org 41.1976 1.11167
nostr.computingcache.com:443 34.0356 -118.442
relay-fra.zombi.cloudrodion.com 48.8566 2.35222
nostr.hekster.org:443 37.3986 -121.964
nostr.88mph.life 52.1941 -2.21905
wot.dergigi.com 64.1476 -21.9392
nostr.planix.org 43.6532 -79.3832
relay.satsmarkt.club 52.6907 4.8181
nostrcity-club.fly.dev:443 37.7648 -122.432
aeon.libretechsystems.xyz 55.486 9.86577
11 testnet.samt.st 43.6532 -79.3832
12 nostr.data.haus relay.angor.io 50.4754 48.1046 12.3683 11.6002
13 wot.sudocarlos.com relay-arg.zombi.cloudrodion.com 43.6532 1.35208 -79.3832 103.82
14 relay-fra.zombi.cloudrodion.com:443 nostr-01.yakihonne.com 48.8566 1.32123 2.35222 103.695
15 shu01.shugur.net nostr-relay.cbrx.io 21.4902 43.6532 39.2246 -79.3832
16 relay.gulugulu.moe:443 relay.guggero.org 43.6532 46.5971 -79.3832 9.59652
17 relay2.angor.io:443 nostr.snowbla.de 48.1046 60.1699 11.6002 24.9384
relay.libernet.app 43.6532 -79.3832
directories-safe-motherboard-recipients.trycloudflare.com 43.6532 -79.3832
wot.nostr.party 36.1659 -86.7844
18 relay.zone667.com 60.1699 24.9384
19 nostr.wild-vibes.ts.net nexus.libernet.app:443 48.8566 43.6532 2.35222 -79.3832
20 relay.nostr.com relay.islandbitcoin.com 50.1109 12.8498 8.68213 77.6545
21 nostr.iskarion.ddns.net relay-testnet.k8s.layer3.news 43.3076 37.3387 -2.95421 -121.885
22 relay-dev.satlantis.io nostr-relay.xbytez.io 39.0438 50.6924 -77.4874 3.20113
23 relay.sovereignresonance.org kasztanowa.bieda.it 48.9006 43.6532 2.25929 -79.3832
24 relay.nostrian-conquest.com nostrcity-club.fly.dev 41.223 37.7648 -111.974 -122.432
25 relay.aidatanorge.no relay.typedcypher.com 43.6532 51.5072 -79.3832 -0.127586
26 strfry.apps3.slidestr.net nostr.na.social:443 40.4167 43.6532 -3.70329 -79.3832
27 relay.klabo.world relay.laantungir.net 47.2343 -19.4692 -119.853 -42.5315
28 nostr.data.haus:443 relay-dev.satlantis.io:443 50.4754 40.8302 12.3683 -74.1299
29 testr.nymble.world rilo.nostria.app 40.8054 43.6532 -74.0241 -79.3832
30 relay.inforsupports.com nostr.hekster.org:443 43.6532 37.3986 -79.3832 -121.964
31 nostr-relay.amethyst.name:443 39.0067 -77.4291
32 chat-relay.zap-work.com:443 43.6532 -79.3832
33 relay.edufeed.org 49.4521 11.0767
34 syb.lol:443 43.6532 -79.3832
35 relay.sigit.io 50.4754 12.3683
36 nostr-relay.xbytez.io:443 50.6924 3.20113
37 relay.wavefunc.live 41.8781 -87.6298
38 nostr.sathoarder.com 48.5734 7.75211
39 myvoiceourstory.org 37.3598 -121.981
40 relay.underorion.se 50.1109 8.68213
41 nostr.data.haus 50.4754 12.3683
42 relay.erybody.com 41.4513 -81.7021
43 espelho.girino.org 43.6532 -79.3832
44 nostr.pbfs.io:443 50.4754 12.3683
45 wot.dergigi.com 64.1476 -21.9392
46 nostr.bitcoiner.social:443 47.6743 -117.112
47 dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
48 relay.gulugulu.moe 43.6532 -79.3832
49 nostr.spicyz.io 43.6532 -79.3832
50 relay.cypherflow.ai 48.8575 2.35138
51 treuzkas.branruz.com 48.8575 2.35138
52 relay1.nostrchat.io 60.1699 24.9384
53 kotukonostr.onrender.com 37.7775 -122.397
54 nostr.plantroon.com 50.1013 8.62643
55 nostr.davenov.com 50.1109 8.68213
56 node.kommonzenze.de 49.4521 11.0767
57 relay2.veganostr.com 60.1699 24.9384
58 armada.sharegap.net 43.6532 -79.3832
59 wot.makenomistakes.ca 43.7064 -79.3986
60 nostr.2b9t.xyz:443 34.0549 -118.243
61 relay.libernet.app:443 43.6532 -79.3832
62 relay.dreamith.to:443 43.6532 -79.3832
63 relay.lightning.pub:443 39.0438 -77.4874
64 nostr.rtvslawenia.com 49.4543 11.0746
65 nostr.21crypto.ch 47.5356 8.73209
66 relay.ditto.pub:443 43.6532 -79.3832
67 relay.plebchain.club 43.6532 -79.3832
68 memlay.v0l.io 53.3498 -6.26031
69 nostr.chaima.info:443 50.1109 8.68213
70 relay.wavlake.com:443 41.2619 -95.8608
71 nostr.thalheim.io:443 60.1699 24.9384
72 relay.lightning.pub 39.0438 -77.4874
73 dev.relay.edufeed.org:443 49.4521 11.0767
74 nostr.myshosholoza.co.za:443 52.3913 4.66545
75 relay.binaryrobot.com:443 43.6532 -79.3832
76 wot.nostr.place 43.6532 -79.3832
77 nostr.sathoarder.com:443 48.5734 7.75211
78 thecitadel.nostr1.com 40.7057 -74.0136
79 relay.artx.market 43.6548 -79.3885
80 nos.lol 50.4754 12.3683
81 nostr.plantroon.com:443 50.1013 8.62643
82 premium.primal.net 43.6532 -79.3832
83 nas01xanthosnet.synology.me:7778 47.1285 8.74735
84 nostrja-kari.heguro.com 43.6532 -79.3832
85 relay.mrmave.work 43.6532 -79.3832
86 nostrelay.circum.space 52.6907 4.8181
87 mostro-p2p.tech 50.1109 8.68213
88 wot.shaving.kiwi 43.6532 -79.3832
89 relay.fundstr.me 42.3601 -71.0589
90 nostrelay.circum.space:443 52.6907 4.8181
91 relay.nostrdice.com -33.8688 151.209
92 relay.getvia.xyz 60.1699 24.9384
93 strfry.shock.network:443 39.0438 -77.4874
94 relay.nostrmap.net:443 60.1699 24.9384
95 nostr.stakey.net:443 relay.nearhood.co.uk 52.3676 51.5072 4.90414 -0.127586
96 no.str.cr 10.6352 -85.4378
97 relay.getsafebox.app:443 43.6532 -79.3832
98 relay0.gfcom.info 13.6992 100.694
99 nostr.ps1829.com 33.8851 130.883
100 relay2.angor.io 48.1046 11.6002
101 relay.stickeroo.is-cool.dev 37.3387 -121.885
102 ricardo-oem.tailb5546.ts.net 40.7128 -74.006
103 relay.typedcypher.com:443 51.5072 -0.127586
104 relay.paulstephenborile.com 49.4543 11.0746
105 nittom.nostr1.com 40.7057 -74.0136
106 conduitl2.fly.dev 37.7648 -122.432
107 nostr.rikmeijer.nl 51.7111 5.36809
108 relay.thecryptosquid.com 50.4754 12.3683
109 spookstr2.nostr1.com 40.7057 -74.0136
110 offchain.bostr.online 43.6532 -79.3832
111 nostr.planix.org 43.6532 -79.3832
112 relay.mccormick.cx 52.3563 4.95714
113 0x-nostr-relay.fly.dev 37.7648 -122.432
114 nostr.wecsats.io 43.6532 -79.3832
115 schnorr.me 43.6532 -79.3832
116 relay.satmaxt.xyz 43.6532 -79.3832
117 relay.bornheimer.app 51.5072 -0.127586
118 relay.nostrhub.fr 48.1045 11.6004
119 blossom.gnostr.cloud:443 43.6532 -79.3832
120 nostr-02.yakihonne.com:443 1.32123 103.695
121 dev.relay.stream 43.6532 -79.3832
122 ithurtswhenip.ee 51.5072 -0.127586
123 nostr.myshosholoza.co.za 52.3913 4.66545
124 relayrs.notoshi.win:443 43.6532 -79.3832
125 relay-rpi.edufeed.org:443 49.4521 11.0767
126 relay.olas.app:443 60.1699 24.9384
127 nostr.unkn0wn.world 46.8499 9.53287
128 relay.mitchelltribe.com 39.0438 -77.4874
129 yabu.me 35.6092 139.73
130 nostr.nodesmap.com 59.3327 18.0656
131 dm-test-strfry-generic.samt.st 43.6532 -79.3832
132 nostr2.girino.org:443 43.6532 -79.3832
133 wot.brightbolt.net 47.6735 -116.781
134 strfry.shock.network 39.0438 -77.4874
135 relay.kilombino.com 43.6532 -79.3832
136 relay.nostr.blockhenge.com 39.0438 -77.4874
137 shu04.shugur.net 25.2048 55.2708
138 relay-rpi.edufeed.org 49.4521 11.0767
139 relay.bullishbounty.com:443 43.6532 -79.3832
140 vault.iris.to:443 43.6532 -79.3832
141 relay.mostro.network:443 40.8302 -74.1299
142 offchain.pub:443 39.1585 -94.5728
143 soloco.nl 43.6532 -79.3832
144 relay.nostu.be 40.4167 -3.70329
145 nostr.pbfs.io 50.4754 12.3683
146 relay.directsponsor.net 42.8864 -78.8784
147 relay.decentralia.fr 49.4282 10.9796
148 relayrs.notoshi.win 43.6532 -79.3832
149 nostr-relay.amethyst.name 39.0067 -77.4291
150 relay.arx-ccn.com 50.4754 12.3683
151 nostr.spaceshell.xyz 43.6532 -79.3832
152 relay-fra.zombi.cloudrodion.com 48.8566 2.35222
153 rilo.nostria.app:443 43.6532 -79.3832
154 relay.trotters.cc:443 43.6532 -79.3832
155 nostr.overmind.lol:443 43.6532 -79.3832
156 nostr.girino.org:443 43.6532 -79.3832
157 bitsat.molonlabe.holdings 51.4012 -1.3147
158 nostr.azzamo.net 52.2633 21.0283
159 insta-relay.apps3.slidestr.net 40.4167 -3.70329
160 bridge.tagomago.me 42.3601 -71.0589
161 nostr.thalheim.io 60.1699 24.9384
162 relay.artx.market:443 43.6548 -79.3885
163 nostr.openhoofd.nl 51.5717 3.70417
164 nostr.bond 50.1109 8.68213
165 relay.earthly.city 34.1749 -118.54
166 nexus.libernet.app 43.6532 -79.3832
167 relay.plebeian.market 50.1109 8.68213
168 relay.nostr.net 43.6532 -79.3832
169 nostr.overmind.lol 43.6532 -79.3832
170 relay.ohstr.com 43.6532 -79.3832
171 testnet-relay.samt.st:443 40.8302 -74.1299
172 relay01.lnfi.network 35.6764 139.65
173 relay.mostr.pub:443 43.6532 -79.3832
174 wot.nostr.party 36.1659 -86.7844
175 relayone.soundhsa.com 39.1008 -94.5811
176 relay.mostro.network 40.8302 -74.1299
177 ribo.eu.nostria.app 43.6532 -79.3832
178 chat-relay.zap-work.com 43.6532 -79.3832
179 relay.nostreon.com 60.1699 24.9384
180 nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
181 nostr.quali.chat:443 60.1699 24.9384
182 relay.internationalright-wing.org:443 -22.5022 -48.7114
183 relay.mitchelltribe.com:443 39.0438 -77.4874
184 relay.satlantis.io 40.8054 -74.0241
185 nittom.nostr1.com:443 40.7057 -74.0136
186 nostr.janx.com 43.6532 -79.3832
187 nostr.carroarmato0.be:443 50.914 3.21378
188 relay.mmwaves.de:443 48.8575 2.35138
189 relay.chorus.community:443 48.5333 10.7
190 wot.utxo.one 43.6532 -79.3832
191 relay.plebeian.market:443 50.1109 8.68213
192 relay.cosmicbolt.net 37.3986 -121.964
193 x.kojira.io:443 43.6532 -79.3832
194 top.testrelay.top 43.6532 -79.3832
195 nos.lol:443 50.4754 12.3683
196 dev.relay.edufeed.org 49.4521 11.0767
197 relayone.geektank.ai:443 39.1008 -94.5811
198 relay.nostar.org 43.6532 -79.3832
199 nostr.oxtr.dev:443 50.4754 12.3683
200 nostr.88mph.life 52.1941 -2.21905
201 relay.staging.commonshub.brussels 49.4543 11.0746
202 weboftrust.libretechsystems.xyz 55.4724 9.87335
203 relay.openfarmtools.org 60.1699 24.9384
204 cs-relay.nostrdev.com 50.4754 12.3683
205 relay.inforsupports.com 43.6532 -79.3832
206 nostr-verified.wellorder.net 45.5201 -122.99
207 nostr.hekster.org 37.3986 -121.964
208 relay.gulugulu.moe:443 43.6532 -79.3832
209 relay.mwaters.net 50.9871 2.12554
210 nostrcity-club.fly.dev:443 37.7648 -122.432
211 relay.vrtmrz.net:443 43.6532 -79.3832
212 relay.nostr.place 43.6532 -79.3832
213 relay.wavefunc.live:443 41.8781 -87.6298
214 nostr.islandarea.net 35.4669 -97.6473
215 purplerelay.com:443 43.6532 -79.3832
216 nostr-relay.psfoundation.info:443 39.0438 -77.4874
217 r.0kb.io 32.789 -96.7989
218 relay-us.zombi.cloudrodion.com 40.7862 -74.0743
219 relay.mulatta.io 37.5665 126.978
220 strfry.bonsai.com:443 39.0438 -77.4874
221 bendernostur.duckdns.org:8443 50.1109 8.68213
222 vault.iris.to 43.6532 -79.3832
223 ec2.f7z.io 60.1699 24.9384
224 nostr.debate.report 50.1109 8.68213
225 wot.codingarena.top 50.4754 12.3683
226 relay.layer.systems:443 49.0291 8.35695
227 relay.degmods.com 50.4754 12.3683
228 nostr.mom 50.4754 12.3683
229 ribo.us.nostria.app:443 43.6532 -79.3832
230 adre.su 59.9311 30.3609
231 wot.sudocarlos.com 43.6532 -79.3832
232 relay.nostrian-conquest.com 41.223 -111.974
233 nostr-relay.nextblockvending.com 47.2343 -119.853
234 relay.endfiat.money:443 59.3327 18.0656
235 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
236 nostr.carroarmato0.be 50.914 3.21378
237 relay.cypherflow.ai:443 48.8575 2.35138
238 nostr.girino.org 43.6532 -79.3832
239 nostr.thebiglake.org 32.71 -96.6745
240 strfry.ymir.cloud 43.6532 -79.3832
241 relay.mypathtofire.de 42.8864 -78.8784
242 relay.lanacoin-eternity.com 40.8302 -74.1299
243 nostr.snowbla.de:443 60.1699 24.9384
244 relay.ditto.pub 43.6532 -79.3832
245 relay.damus.io 43.6532 -79.3832
246 relay.ru.ac.th 13.7607 100.627
247 nrs-01.darkcloudarcade.com 39.1008 -94.5811
248 testnet-relay.samt.st 40.8302 -74.1299
249 antiprimal.net 43.6532 -79.3832
250 bitchat.nostr1.com 40.7057 -74.0136
251 relay.snort.social 53.3498 -6.26031
252 relay.mccormick.cx:443 52.3563 4.95714
253 relay02.lnfi.network 35.6764 139.65
254 srtrelay.c-stellar.net 43.6532 -79.3832
255 relay.minibolt.info 43.6532 -79.3832
256 nostrride.io 37.3986 -121.964
257 articles.layer3.news:443 37.3387 -121.885
258 rele.speyhard.fi 51.5072 -0.127586
259 relay.aarpia.com 37.3986 -121.964
260 nostr.chaima.info 50.1109 8.68213
261 relay.wisp.talk:443 49.4543 11.0746
262 relay.agorist.space:443 52.3734 4.89406
263 strfry.bonsai.com 39.0438 -77.4874
264 nostr.hifish.org 47.4244 8.57658
265 offchain.pub 39.1585 -94.5728
266 nostr.spicyz.io:443 43.6532 -79.3832
267 relay.beginningend.com 35.2227 -97.4786
268 relay.sharegap.net 43.6532 -79.3832
269 nostr.purpura.cloud 43.6532 -79.3832
270 nrs-01.darkcloudarcade.com:443 39.1008 -94.5811
271 relay.fountain.fm:443 43.6532 -79.3832
272 relay.olas.app 60.1699 24.9384
273 relay.mmwaves.de 48.8575 2.35138
274 relay.openresist.com:443 43.6532 -79.3832
275 relay.homeinhk.xyz 35.694 139.754
276 relay.libernet.app 43.6532 -79.3832
277 relay.comcomponent.com 43.6532 -79.3832
278 nostr.tac.lol 47.4748 -122.273
279 relay.goodmorningbitcoin.com 43.6532 -79.3832
280 relay.nostriot.com:443 41.5695 -83.9786
281 bcast.girino.org 43.6532 -79.3832
282 nostr.azzamo.net:443 52.2633 21.0283
283 relay.islandbitcoin.com:443 12.8498 77.6545
284 pool.libernet.app 43.6532 -79.3832
285 test.thedude.cloud 50.1109 8.68213
286 nostrelites.org 41.8781 -87.6298
287 nostr.infero.net 35.6764 139.65
288 relay.primal.net 43.6532 -79.3832
289 ribo.nostria.app 43.6532 -79.3832
290 relay.chorus.community 48.5333 10.7
291 bitcoiner.social:443 47.6743 -117.112
292 relay.wisp.talk 49.4543 11.0746
293 relay.layer.systems 49.0291 8.35695
294 relay-dev.satlantis.io 40.8302 -74.1299
295 nostr.bitcoiner.social 47.6743 -117.112
296 relay.lanavault.space:443 60.1699 24.9384
297 relay.staging.plebeian.market 51.5072 -0.127586
298 infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
299 relay.fountain.fm 43.6532 -79.3832
300 nostr.middling.mydns.jp 35.8099 140.12
301 relay.dreamith.to 43.6532 -79.3832
302 relay.satmaxt.xyz:443 43.6532 -79.3832
303 shu03.shugur.net 25.2048 55.2708
304 zealand-charts-craig-thru.trycloudflare.com 43.6532 -79.3832
305 nostr.computingcache.com 34.0356 -118.442
306 ribo.us.nostria.app 43.6532 -79.3832
307 relay.agentry.com 42.8864 -78.8784
308 nostr.hifish.org:443 47.4244 8.57658
309 nostr.vulpem.com 49.4543 11.0746
310 relay.cosmicbolt.net:443 37.3986 -121.964
311 nostr-02.yakihonne.com 1.32123 103.695
312 r.0kb.io:443 32.789 -96.7989
313 nostr-relay.corb.net 38.8353 -104.822
314 ribo.eu.nostria.app:443 43.6532 -79.3832
315 nostr-relay.psfoundation.info 39.0438 -77.4874
316 relay.wellorder.net 45.5201 -122.99
317 relay.novospes.com 43.6532 -79.3832
318 nostr-dev.wellorder.net 45.5201 -122.99
319 relay.endfiat.money 59.3327 18.0656
320 relay.angor.io:443 48.1046 11.6002
321 relay-fra.zombi.cloudrodion.com:443 48.8566 2.35222
322 strfry.openhoofd.nl 51.5717 3.70417
323 relay.getsafebox.app 43.6532 -79.3832
324 relay.openresist.com 43.6532 -79.3832
325 relay5.bitransfer.org 43.6532 -79.3832
326 nostr.na.social 43.6532 -79.3832
327 portal-relay.pareto.space 49.0291 8.35696
328 nostr.notribe.net:443 40.8302 -74.1299
329 relay.bitmacro.cloud 43.6532 -79.3832
330 no.str.cr:443 10.6352 -85.4378
331 relay.klabo.world 47.2343 -119.853
332 nostr.notribe.net 40.8302 -74.1299
333 relay.staging.plebeian.market:443 51.5072 -0.127586
334 relay.nostrmap.net 60.1699 24.9384
335 temp.iris.to 43.6532 -79.3832
336 nostr.sovereignservices.xyz 43.6532 -79.3832
337 nostr.liberty.fans 36.9104 -89.5875
338 relay.nostrian-conquest.com:443 41.223 -111.974
339 relay.nostriot.com 41.5695 -83.9786
340 nostrbtc.com 43.6532 -79.3832
341 shu02.shugur.net 21.4902 39.2246
342 relay.kalcafe.xyz 37.3986 -121.964
343 relay.illuminodes.com 43.6532 -79.3832
344 relay.wavlake.com 41.2619 -95.8608
345 nostr.ps1829.com:443 33.8851 130.883
346 dm-test-strfry-discovery.samt.st 43.6532 -79.3832
347 nostr.wecsats.io:443 43.6532 -79.3832
348 nostr-pub.wellorder.net 45.5201 -122.99
349 nostr.dlcdevkit.com:443 40.0992 -83.1141
350 nostr.mom:443 50.4754 12.3683
351 ribo.nostria.app:443 43.6532 -79.3832
352 nostr.2b9t.xyz 34.0549 -118.243
353 nostr.data.haus:443 50.4754 12.3683
354 staging.yabu.me 35.6092 139.73
355 relay.sigit.io:443 50.4754 12.3683
356 relay.edufeed.org:443 49.4521 11.0767
357 nostr-01.yakihonne.com:443 1.32123 103.695
358 reraw.pbla2fish.cc 43.6532 -79.3832
359 cs-relay.nostrdev.com:443 50.4754 12.3683
360 herbstmeister.com 34.0549 -118.243
361 relay.minibolt.info:443 43.6532 -79.3832
362 relay2.angor.io:443 48.1046 11.6002
363 social.amanah.eblessing.co 48.1046 11.6002
364 nostr.stakey.net 52.3676 4.90414
365 nostr.computingcache.com:443 34.0356 -118.442
366 slick.mjex.me 39.0418 -77.4744
367 fanfares.nostr1.com 40.7057 -74.0136
368 bitcoinostr.duckdns.org 43.3434 -3.99532
369 nostr.oxtr.dev 50.4754 12.3683
370 cache.trustr.ing 43.6548 -79.3885
371 purplerelay.com 43.6532 -79.3832
372 nostr-kyomu-haskell.onrender.com 37.7775 -122.397
373 nostr-relay.corb.net:443 38.8353 -104.822
374 relay-dev.gulugulu.moe 43.6532 -79.3832
375 prl.plus 55.7628 37.5983
376 nostr.tac.lol:443 47.4748 -122.273
377 relay.mostr.pub 43.6532 -79.3832
378 schnorr.me:443 43.6532 -79.3832
379 dev-relay.nostreon.com 60.1699 24.9384
380 nostr.islandarea.net:443 35.4669 -97.6473
381 nostr.rtvslawenia.com bucket.coracle.social 49.4543 37.7775 11.0746 -122.397
relay.bowlafterbowl.com 32.9483 -96.7299
nostr.quali.chat:443 60.1699 24.9384
relay.plebeian.market 50.1109 8.68213
relay-rpi.edufeed.org 49.4521 11.0767
r.0kb.io 32.789 -96.7989
nostr.notribe.net:443 40.8302 -74.1299
relay.getsafebox.app:443 43.6532 -79.3832
nostr.dlcdevkit.com:443 40.0992 -83.1141
nostrelites.org 34.9582 -81.9907
nostr.hoppe-relay.it.com 42.8864 -78.8784
nostr.thebiglake.org 32.71 -96.6745
nostr-kyomu-haskell.onrender.com 37.7775 -122.397
relay.nostriot.com 41.5695 -83.9786
nostr.christiansass.de 51.7634 7.8887
relay.btcforplebs.com 43.6532 -79.3832
nostr.tagomago.me 42.3601 -71.0589
relayone.geektank.ai 39.0997 -94.5786
relay.dreamith.to:443 43.6532 -79.3832
nostr.liberty.fans 36.8767 -89.5879
wot.makenomistakes.ca 43.7064 -79.3986
relay.goodmorningbitcoin.com 43.6532 -79.3832
relay.layer.systems 49.0291 8.35695
relay.paulstephenborile.com:443 49.4543 11.0746
relay.ohstr.com 43.6532 -79.3832
nostr-relay.xbytez.io:443 50.6924 3.20113
nostr.ac 38.958 -77.3592
ribo.us.nostria.app 43.6532 -79.3832
nostr.21crypto.ch 47.5356 8.73209
relay.chorus.community:443 48.5333 10.7
relay.cypherflow.ai 48.8575 2.35138
relay.agorist.space:443 52.3734 4.89406
relay.nostrian-conquest.com:443 41.223 -111.974
relay.keykeeper.world 40.7824 -74.0711
relay.getvia.xyz 60.1699 24.9384
relay.nuts.cash 52.3676 4.90414
kotukonostr.onrender.com 37.7775 -122.397
relay.minibolt.info 43.6532 -79.3832
relay.dwadziesciajeden.pl 52.2297 21.0122
relay.fountain.fm:443 43.6532 -79.3832
relay.fountain.fm 43.6532 -79.3832
nostr-02.uid.ovh 50.9871 2.12554
relay.lanavault.space:443 60.1699 24.9384
nostr.carroarmato0.be 50.914 3.21378
nexus.libernet.app:443 43.6532 -79.3832
relay.artio.inf.unibe.ch 46.9501 7.43678
382 blossom.gnostr.cloud 43.6532 -79.3832
relay.binaryrobot.com 43.6532 -79.3832
relay.earthly.city 34.1749 -118.54
nostr.hifish.org 47.4244 8.57658
offchain.pub:443 39.1585 -94.5728
relay.bullishbounty.com:443 43.6532 -79.3832
strfry.openhoofd.nl:443 51.5717 3.70417
cs-relay.nostrdev.com:443 50.4754 12.3683
strfry.ymir.cloud 43.6532 -79.3832
nostrbtc.com 43.6532 -79.3832
relay.directsponsor.net 42.8864 -78.8784
nostr2.girino.org 43.6532 -79.3832
relay.sigit.io:443 50.4754 12.3683
relay.getsafebox.app 43.6532 -79.3832
antiprimal.net 43.6532 -79.3832
nostr.sathoarder.com 48.5734 7.75211
inbox.scuba323.com 40.8218 -74.45
nrs-01.darkcloudarcade.com:443 39.0997 -94.5786
nostr.tac.lol 47.4748 -122.273
nostr.davenov.com 50.1109 8.68213
relay.trotters.cc:443 43.6532 -79.3832
nostr.plantroon.com:443 50.1013 8.62643
relay.nostreon.com 60.1699 24.9384
nostr.easycryptosend.it 43.6532 -79.3832
nostr-01.yakihonne.com:443 1.32123 103.695
relay-testnet.k8s.layer3.news 37.3387 -121.885
nostr.purpura.cloud 43.6532 -79.3832
insta-relay.apps3.slidestr.net 40.4167 -3.70329
nostr.mifen.me 43.6532 -79.3832
testnet-relay.samt.st:443 40.8302 -74.1299
nostr.2b9t.xyz:443 34.0549 -118.243
relay.wavlake.com:443 41.2619 -95.8608
relay.wisp.talk:443 49.4543 11.0746
relay-dev.satlantis.io:443 39.0438 -77.4874
relay.satlantis.io 39.0438 -77.4874
relay.staging.plebeian.market 51.5072 -0.127586
relay.openfarmtools.org 60.1699 24.9384
relay.nostrhub.fr 48.1045 11.6004
nostr-relay.xbytez.io 50.6924 3.20113
relay.binaryrobot.com:443 43.6532 -79.3832
relay.samt.st 40.8302 -74.1299
relay.illuminodes.com 43.6532 -79.3832
relay.liberbitworld.org 43.6532 -79.3832
relay.olas.app:443 60.1699 24.9384
no.str.cr 8.96171 -83.5246
dm-test-strfry-discovery.samt.st 43.6532 -79.3832
wot.rejecttheframe.xyz 43.6532 -79.3832
relay.nostriot.com:443 41.5695 -83.9786
nostr.plantroon.com 50.1013 8.62643
nostr-01.uid.ovh 50.9871 2.12554
relay.openresist.com:443 43.6532 -79.3832
nostr.overmind.lol 43.6532 -79.3832
relay.internationalright-wing.org -22.5022 -48.7114
nostr.myshosholoza.co.za:443 52.3676 4.90414
nostr.pbfs.io:443 50.4754 12.3683
21milionidinostr.duckdns.org 41.8967 12.4822
nostr.4rs.nl 49.0291 8.35696
relay.lanavault.space 60.1699 24.9384
relay.mostr.pub 43.6532 -79.3832
relay.nostar.org 43.6532 -79.3832
nostr.mom 50.4754 12.3683
relay.decentralia.fr 48.122 11.589
relay.agentry.com 42.8864 -78.8784
relay2.angor.io 48.1046 11.6002
slick.mjex.me 39.0418 -77.4744
relay-us.zombi.cloudrodion.com 40.7862 -74.0743
relay.vrtmrz.net:443 43.6532 -79.3832
relay.beginningend.com 35.2227 -97.4786
chat-relay.zap-work.com:443 43.6532 -79.3832
relay.underorion.se 50.1109 8.68213
relay.mitchelltribe.com 39.0438 -77.4874
relay.qstr.app 51.5072 -0.127586
relay.cyberguy.fyi 52.6907 4.8181
strfry.bonsai.com:443 39.0438 -77.4874
relayone.soundhsa.com:443 39.0997 -94.5786
relay.sigit.io 50.4754 12.3683
relay.npubhaus.com 43.6532 -79.3832
relayrs.notoshi.win 43.6532 -79.3832
relay.mitchelltribe.com:443 39.0438 -77.4874
relay.44billion.net 43.6532 -79.3832
reraw.pbla2fish.cc 43.6532 -79.3832
articles.layer3.news:443 37.3387 -121.885
nostr.sovereignservices.xyz 43.6532 -79.3832
relay.nostx.io 43.6532 -79.3832
nostr-relay.amethyst.name 39.0067 -77.4291
0x-nostr-relay.fly.dev 37.7648 -122.432
relay.ohstr.com:443 43.6532 -79.3832
00f2e774.relay.dev.thunderegg.us 39.0438 -77.4874
nostr-relay.cbrx.io 43.6532 -79.3832
relay.wavlake.com 41.2619 -95.8608
purplerelay.com:443 43.6532 -79.3832
nostr-pr02.redscrypt.org 52.3676 4.90414
fanfares.nostr1.com:443 40.7057 -74.0136
kasztanowa.bieda.it 43.6532 -79.3832
relay.flashapp.me 43.6548 -79.3885
relay.typedcypher.com 51.5072 -0.127586
nostr.bond 50.1109 8.68213
nostr.azzamo.net 52.2633 21.0283
nexus.libernet.app 43.6532 -79.3832
relay.cosmicbolt.net 37.3986 -121.964
schnorr.me 43.6532 -79.3832
relay.mostro.network:443 40.8302 -74.1299
relay-arg.zombi.cloudrodion.com 1.35208 103.82
relay.chorus.community 48.5333 10.7
blossom.gnostr.cloud:443 43.6532 -79.3832
syb.lol:443 34.0549 -118.243
relay.dyne.org 49.0291 8.35705
btc.klendazu.com 41.2861 1.24993
wot.nostr.place 43.6532 -79.3832
relay.openresist.com 43.6532 -79.3832
rilo.nostria.app:443 43.6532 -79.3832
no.str.cr:443 8.96171 -83.5246
relay.mostr.pub:443 43.6532 -79.3832
relay.edufeed.org:443 49.4521 11.0767
nostr.debate.report 50.1109 8.68213
relay.satmaxt.xyz:443 43.6532 -79.3832
relay.artx.market:443 43.6548 -79.3885
relay-dev.gulugulu.moe 43.6532 -79.3832
relay.novospes.com 43.6532 -79.3832
relay.nostr-check.me 43.6532 -79.3832
nostr.computingcache.com 34.0356 -118.442
nostr.oxtr.dev 50.4754 12.3683
relay.fckstate.net 59.3293 18.0686
relay.vrtmrz.net 43.6532 -79.3832
relay.bornheimer.app 51.5072 -0.127586
relay.guggero.org 46.5971 9.59652
relay01.lnfi.network 35.6764 139.65
wot.shaving.kiwi 43.6532 -79.3832
nostr.twinkle.lol 51.902 7.6657
relay.edufeed.org 49.4521 11.0767
relay.lanacoin-eternity.com 40.8302 -74.1299
relay.satmaxt.xyz 43.6532 -79.3832
nostr.hifish.org:443 47.4244 8.57658
relay.cypherflow.ai:443 48.8575 2.35138
infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
nostr.na.social:443 43.6532 -79.3832
nostr.rtvslawenia.com:443 49.4543 11.0746
relay.mypathtofire.de 42.8864 -78.8784
public.crostr.com 43.6532 -79.3832
relay.olas.app 60.1699 24.9384
relay.agora.social 50.7383 15.0648
ribo.nostria.app 43.6532 -79.3832
relay.lab.rytswd.com 49.4543 11.0746
relay.ditto.pub:443 43.6532 -79.3832
porchlight.social 43.6532 -79.3832
nostr.notribe.net 40.8302 -74.1299
relay.endfiat.money 59.3327 18.0656
nostr.myshosholoza.co.za 52.3676 4.90414
relay.nearhood.co.uk 51.5134 -0.0890675
relay.degmods.com 50.4754 12.3683
nostr.novacisko.cz 52.2026 20.9397
prl.plus 55.7628 37.5983
bruh.samt.st 43.6532 -79.3832
strfry.openhoofd.nl 51.5717 3.70417
nostr.spicyz.io 43.6532 -79.3832
nostr.na.social 43.6532 -79.3832
nip85.nosfabrica.com 39.0997 -94.5786
premium.primal.net 43.6532 -79.3832
fanfares.nostr1.com 40.7057 -74.0136
relay.scuba323.com 40.8218 -74.45
nostr2.girino.org:443 43.6532 -79.3832
relay.mmwaves.de 48.8575 2.35138
nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
strfry.shock.network:443 39.0438 -77.4874
nostr.snowbla.de 50.4754 12.3683
nostr.spaceshell.xyz 43.6532 -79.3832
nostr.quali.chat 60.1699 24.9384
wot.utxo.one 43.6532 -79.3832
relay.mccormick.cx 52.3563 4.95714
mostro-p2p.tech 50.1109 8.68213
basspistol.org 49.0291 8.35696
ribo.nostria.app:443 43.6532 -79.3832
chorus.mikedilger.com:444 -36.8906 174.794
nostr.oxtr.dev:443 50.4754 12.3683
nostr.nodesmap.com 59.3327 18.0656
offchain.bostr.online 43.6532 -79.3832
purplerelay.com 43.6532 -79.3832
relayrs.notoshi.win:443 43.6532 -79.3832
relay.wavefunc.live 41.8781 -87.6298
relay.dreamith.to 43.6532 -79.3832
bendernostur.duckdns.org:8443 50.1109 8.68213
relay.nmail.li 50.9871 2.12554
nostr-relay.corb.net 39.6478 -104.988
relay.staging.plebeian.market:443 51.5072 -0.127586
spamspamspamspam.rest 43.6532 -79.3832
relay1.gfcom.info 13.9215 100.538
schnorr.me:443 43.6532 -79.3832
relay.lab.rytswd.com:443 49.4543 11.0746
nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
relay.nostrmap.net 60.1699 24.9384
nostr.relay.hedwig.sh 60.1699 24.9384
relay.veganostr.com:443 60.1699 24.9384
relay.wavefunc.live:443 41.8781 -87.6298
nostr.mikoshi.de 52.52 13.405
syb.lol 34.0549 -118.243
relay1.nostrchat.io 60.1699 24.9384
nostr.wecsats.io:443 43.6532 -79.3832
nostr.chaima.info:443 51.5072 -0.127586
nostr.azzamo.net:443 52.2633 21.0283
relay-can.zombi.cloudrodion.com 43.6532 -79.3832
nostr.unkn0wn.world 46.8499 9.53287
relayone.soundhsa.com 39.0997 -94.5786
x.kojira.io 43.6532 -79.3832
dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
nostrelay.circum.space 52.6907 4.8181
relay.primal.net 43.6532 -79.3832
nostr.girino.org 43.6532 -79.3832
nostr.pbfs.io 50.4754 12.3683
relay.kalcafe.xyz 37.3986 -121.964
relay.gulugulu.moe 43.6532 -79.3832
top.testrelay.top 43.6532 -79.3832
relay.kilombino.com 43.6532 -79.3832
nos.lol:443 50.4754 12.3683
nos.lol 50.4754 12.3683
relay.nostr.place:443 43.6532 -79.3832
cache.trustr.ing 43.6548 -79.3885
relay.internationalright-wing.org:443 -22.5022 -48.7114
relay.laantungir.net -19.4692 -42.5315
relay.lightning.pub:443 39.0438 -77.4874
nostr.stakey.net 52.3676 4.90414
articles.layer3.news 37.3387 -121.885
relay.wisp.talk 49.4543 11.0746
relay.pyramid.li 47.4093 8.46503
relay.typedcypher.com:443 51.5072 -0.127586
dev.relay.stream 43.6532 -79.3832
relay.bullishbounty.com 43.6532 -79.3832
nostr.mom:443 50.4754 12.3683
relay.plebeian.market:443 50.1109 8.68213
nostr.hekster.org 37.3986 -121.964
nostrcity-club.fly.dev 37.7648 -122.432
nostr.vulpem.com 49.4543 11.0746
relay-dev.gulugulu.moe:443 43.6532 -79.3832
weboftrust.libretechsystems.xyz 55.4724 9.87335
nostr-relay.corb.net:443 39.6478 -104.988
wheat.happytavern.co 43.6532 -79.3832
relay.mappingbitcoin.com 43.6532 -79.3832
testnet-relay.samt.st 40.8302 -74.1299
relay.bitmacro.cloud 43.6532 -79.3832
dev.relay.edufeed.org 49.4521 11.0767
myvoiceourstory.org 37.3598 -121.981
relay.stickeroo.is-cool.dev 37.3387 -121.885
relay.agorist.space 52.3734 4.89406
freelay.sovbit.host 60.1699 24.9384
nostr-dev.wellorder.net 45.5201 -122.99
nostr.middling.mydns.jp 35.8099 140.12
cs-relay.nostrdev.com 50.4754 12.3683
x.kojira.io:443 43.6532 -79.3832
nostrelay.circum.space:443 52.6907 4.8181
nostr.janx.com 43.6532 -79.3832
relay.mrmave.work 43.6532 -79.3832
espelho.girino.org 43.6532 -79.3832
hol.is 43.6532 -79.3832
ribo.eu.nostria.app 43.6532 -79.3832
nostr.yutakobayashi.com 43.6532 -79.3832
relay.mostro.network 40.8302 -74.1299
communities.nos.social 40.8302 -74.1299
383 relay.solife.me 43.6532 -79.3832
384 yabu.me nostr.quali.chat 35.6092 60.1699 139.73 24.9384
385 relay.islandbitcoin.com relay.vrtmrz.net 12.8498 43.6532 77.6545 -79.3832
386 nostr.wecsats.io relay-dev.gulugulu.moe:443 43.6532 -79.3832
387 nostr.tac.lol:443 relay.bullishbounty.com 47.4748 43.6532 -122.273 -79.3832
388 relay.arx-ccn.com relay.fckstate.net 50.4754 59.3293 12.3683 18.0686
389 nostrride.io nostr.rtvslawenia.com:443 37.3986 49.4543 -121.964 11.0746
390 r.0kb.io:443 relay.nostx.io 32.789 43.6532 -96.7989 -79.3832
391 herbstmeister.com relay.agorist.space 34.0549 52.3734 -118.243 4.89406
392 relay.artx.market relay.notoshi.win 43.6548 13.7829 -79.3885 100.546
393 vault.iris.to dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
394 relay.ru.ac.th relay.trotters.cc 13.7607 43.6532 100.627 -79.3832
395 temp.iris.to relay.lanavault.space 43.6532 60.1699 -79.3832 24.9384
396 social.amanah.eblessing.co public.crostr.com:443 48.1046 43.6532 11.6002 -79.3832
397 nostr-relay.nextblockvending.com nostr.stakey.net:443 47.2343 52.3676 -119.853 4.90414
398 wot.codingarena.top relay.nostr.place:443 50.4754 43.6532 12.3683 -79.3832
relay.sincensura.org 43.6532 -79.3832
399 nostr.dlcdevkit.com 40.0992 -83.1141
400 nostr.aruku.ovh 1.27994 103.849
401 satsage.xyz 37.3986 -121.964
402 strfry.apps3.slidestr.net 40.4167 -3.70329
403 nostr2.girino.org 43.6532 -79.3832
404 relay.samt.st 40.8302 -74.1299
405 articles.layer3.news 37.3387 -121.885
406 aeon.libretechsystems.xyz 55.486 9.86577
407 relay.routstr.com 59.4016 17.9455
408 relay.ohstr.com:443 43.6532 -79.3832
409 relay.lanacoin-eternity.com:443 40.8302 -74.1299
410 strfry.openhoofd.nl:443 51.5717 3.70417
411 nostr.blankfors.se 60.1699 24.9384
412 nostr-2.21crypto.ch:443 47.5356 8.73209
413 relayone.soundhsa.com:443 39.1008 -94.5811
414 relay.lab.rytswd.com:443 49.4543 11.0746
415 nostr.tagomago.me 42.3601 -71.0589
416 relay.0xchat.com:443 43.6532 -79.3832