mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:05:21 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10ba71b1f8 | ||
|
|
2d5250e49a | ||
|
|
fef775bae2 | ||
|
|
68eeba97ff | ||
|
|
8af6fc2d5f | ||
|
|
f688e529f6 | ||
|
|
96e32ba990 | ||
|
|
0a2f4d9c9d | ||
|
|
914135adb0 | ||
|
|
cd7ffa0df9 | ||
|
|
bbe1ed0652 | ||
|
|
f07b032b99 | ||
|
|
2cbcb290f7 | ||
|
|
9cf7c80518 | ||
|
|
f76fd8a538 | ||
|
|
09c2c12838 | ||
|
|
8378ff949a | ||
|
|
ca63893197 | ||
|
|
fdf28aa5bb |
@@ -29,17 +29,22 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Swift
|
# Use the Xcode-bundled Swift toolchain: it always matches the SDK on
|
||||||
uses: swift-actions/setup-swift@v2
|
# the runner image. A standalone swift.org toolchain (setup-swift) broke
|
||||||
|
# whenever the image's Xcode moved ahead of it ("this SDK is not
|
||||||
|
# supported by the compiler").
|
||||||
|
- name: Note toolchain version (cache key)
|
||||||
|
id: swift-version
|
||||||
|
run: echo "version=$(swift --version 2>/dev/null | head -1 | shasum | cut -c1-12)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Cache build artifacts
|
- name: Cache build artifacts
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ matrix.path }}/.build
|
path: ${{ matrix.path }}/.build
|
||||||
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
key: ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
||||||
${{ runner.os }}-${{ matrix.name }}-
|
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-
|
||||||
|
|
||||||
- name: Build tests
|
- name: Build tests
|
||||||
# Built separately so the hang watchdog below times only test
|
# Built separately so the hang watchdog below times only test
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.5.2
|
MARKETING_VERSION = 1.5.3
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
Generated
+4
-4
@@ -561,7 +561,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.2;
|
MARKETING_VERSION = 1.5.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -620,7 +620,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.2;
|
MARKETING_VERSION = 1.5.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -655,7 +655,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.5.2;
|
MARKETING_VERSION = 1.5.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -749,7 +749,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.5.2;
|
MARKETING_VERSION = 1.5.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -151,38 +151,68 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
// Thread safety
|
// Thread safety
|
||||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||||
|
|
||||||
// Debouncing for keychain saves
|
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
||||||
private var saveTimer: Timer?
|
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
||||||
private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds
|
// than a retained DispatchSourceTimer: a lingering, never-cancelled timer
|
||||||
|
// keeps the dispatch machinery alive and prevents the unit-test process from
|
||||||
|
// exiting. (The original code used Timer.scheduledTimer on a GCD queue with
|
||||||
|
// no run loop, so saves never actually fired.)
|
||||||
private var pendingSave = false
|
private var pendingSave = false
|
||||||
|
|
||||||
// Encryption key
|
// Encryption key
|
||||||
private let encryptionKey: SymmetricKey
|
private let encryptionKey: SymmetricKey
|
||||||
|
/// True when `encryptionKey` is a throwaway generated this session because the
|
||||||
|
/// persisted key could not be read (device locked / access denied). In that
|
||||||
|
/// state we must NOT persist (it would overwrite the real cache with data the
|
||||||
|
/// next launch can't decrypt) and must NOT delete the existing cache.
|
||||||
|
private let encryptionKeyIsEphemeral: Bool
|
||||||
|
|
||||||
init(_ keychain: KeychainManagerProtocol) {
|
init(_ keychain: KeychainManagerProtocol) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
|
||||||
// Generate or retrieve encryption key from keychain
|
// Retrieve (or, only on genuine first run, generate) the cache
|
||||||
|
// encryption key. We MUST distinguish "key doesn't exist yet" from a
|
||||||
|
// transient failure (device locked / access denied): the legacy
|
||||||
|
// getIdentityKey(forKey:) collapses both to nil, and generating+saving a
|
||||||
|
// new key deletes the existing one first — permanently orphaning the
|
||||||
|
// encrypted cache on a launch that merely couldn't read the key.
|
||||||
let loadedKey: SymmetricKey
|
let loadedKey: SymmetricKey
|
||||||
|
let keyIsEphemeral: Bool
|
||||||
|
|
||||||
// Try to load from keychain
|
switch keychain.getIdentityKeyWithResult(forKey: encryptionKeyName) {
|
||||||
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
|
case .success(let keyData):
|
||||||
loadedKey = SymmetricKey(data: keyData)
|
loadedKey = SymmetricKey(data: keyData)
|
||||||
|
keyIsEphemeral = false
|
||||||
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
|
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
|
||||||
}
|
|
||||||
// Generate new key if needed
|
case .itemNotFound:
|
||||||
else {
|
// Genuine first run: generate and persist a new key.
|
||||||
loadedKey = SymmetricKey(size: .bits256)
|
let newKey = SymmetricKey(size: .bits256)
|
||||||
let keyData = loadedKey.withUnsafeBytes { Data($0) }
|
let keyData = newKey.withUnsafeBytes { Data($0) }
|
||||||
// Save to keychain
|
|
||||||
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
|
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
|
||||||
|
loadedKey = newKey
|
||||||
|
// If even the save failed, treat the key as ephemeral so we don't
|
||||||
|
// later try to persist a cache the next launch can't read.
|
||||||
|
keyIsEphemeral = !saved
|
||||||
SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
|
SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
|
||||||
|
|
||||||
|
case .deviceLocked, .authenticationFailed, .accessDenied, .otherError:
|
||||||
|
// Transient/critical read failure. Do NOT overwrite the persisted
|
||||||
|
// key. Use a session-only ephemeral key; the real key and cache are
|
||||||
|
// left intact for a healthy launch.
|
||||||
|
SecureLogger.warning("Identity cache key unavailable; using ephemeral key for this session (not persisting)", category: .security)
|
||||||
|
loadedKey = SymmetricKey(size: .bits256)
|
||||||
|
keyIsEphemeral = true
|
||||||
}
|
}
|
||||||
|
|
||||||
self.encryptionKey = loadedKey
|
self.encryptionKey = loadedKey
|
||||||
|
self.encryptionKeyIsEphemeral = keyIsEphemeral
|
||||||
|
|
||||||
// Load identity cache on init
|
// Only read the persisted cache when we hold the real key; with an
|
||||||
loadIdentityCache()
|
// ephemeral key the decrypt would fail and discard the real cache.
|
||||||
|
if !keyIsEphemeral {
|
||||||
|
loadIdentityCache()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
@@ -211,23 +241,28 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
|
||||||
|
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
|
||||||
|
/// and persists it on the same serialized context — no timer, nothing left
|
||||||
|
/// scheduled to keep the process alive.
|
||||||
private func saveIdentityCache() {
|
private func saveIdentityCache() {
|
||||||
// Mark that we need to save
|
|
||||||
pendingSave = true
|
pendingSave = true
|
||||||
|
performSave()
|
||||||
// Cancel any existing timer
|
|
||||||
saveTimer?.invalidate()
|
|
||||||
|
|
||||||
// Schedule a new save after the debounce interval
|
|
||||||
saveTimer = Timer.scheduledTimer(withTimeInterval: saveDebounceInterval, repeats: false) { [weak self] _ in
|
|
||||||
self?.performSave()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Writes the cache to the keychain. Must run on `queue` with exclusive
|
||||||
|
/// (barrier) access.
|
||||||
private func performSave() {
|
private func performSave() {
|
||||||
guard pendingSave else { return }
|
guard pendingSave else { return }
|
||||||
pendingSave = false
|
pendingSave = false
|
||||||
|
|
||||||
|
// Never persist under an ephemeral key — it would overwrite the real
|
||||||
|
// cache with data the next launch cannot decrypt.
|
||||||
|
guard !encryptionKeyIsEphemeral else {
|
||||||
|
SecureLogger.debug("Skipping identity cache save (ephemeral key this session)", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(cache)
|
let data = try JSONEncoder().encode(cache)
|
||||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||||
@@ -240,9 +275,13 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force immediate save (for app termination)
|
// Force immediate save (for app termination / lifecycle events). Mutations
|
||||||
|
// already persist synchronously via saveIdentityCache, so this is normally a
|
||||||
|
// no-op (performSave early-returns when nothing is pending). Runs directly on
|
||||||
|
// the caller's thread — deliberately NOT a `queue.sync(barrier)`, which is
|
||||||
|
// reachable from `deinit` and from async tests on the swift-concurrency
|
||||||
|
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
|
||||||
func forceSave() {
|
func forceSave() {
|
||||||
saveTimer?.invalidate()
|
|
||||||
performSave()
|
performSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -322,6 +322,13 @@ final class NoiseCipherState {
|
|||||||
throw NoiseError.replayDetected
|
throw NoiseError.replayDetected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The 4-byte nonce prefix has been stripped, so the remaining bytes
|
||||||
|
// must still hold at least the 16-byte Poly1305 tag. The up-front
|
||||||
|
// `ciphertext.count >= 16` guard is not sufficient here (it counts
|
||||||
|
// the nonce), and `prefix(count - 16)` would trap on a short payload.
|
||||||
|
guard actualCiphertext.count >= 16 else {
|
||||||
|
throw NoiseError.invalidCiphertext
|
||||||
|
}
|
||||||
// Split ciphertext and tag
|
// Split ciphertext and tag
|
||||||
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
||||||
tag = actualCiphertext.suffix(16)
|
tag = actualCiphertext.suffix(16)
|
||||||
|
|||||||
@@ -82,6 +82,13 @@ final class NostrIdentityBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deviceSeedCache = nil
|
deviceSeedCache = nil
|
||||||
|
// Also drop the in-memory derived per-geohash identities. These hold the
|
||||||
|
// actual secp256k1 private keys; if left cached, post-panic geohash
|
||||||
|
// messages would still be signed with pre-panic keys (linkable across the
|
||||||
|
// wipe) until the app is force-quit.
|
||||||
|
cacheLock.lock()
|
||||||
|
derivedIdentityCache.removeAll()
|
||||||
|
cacheLock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Per-Geohash Identities (Location Channels)
|
// MARK: - Per-Geohash Identities (Location Channels)
|
||||||
|
|||||||
@@ -39,22 +39,23 @@ struct NostrProtocol {
|
|||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2. Create ephemeral key for this message
|
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
|
||||||
let ephemeralKey = try P256K.Schnorr.PrivateKey()
|
// real identity key. NIP-17 requires the seal be signed by the sender
|
||||||
// Created ephemeral key for seal
|
// so the recipient can authenticate who sent the message; signing with
|
||||||
|
// a throwaway key leaves DMs forgeable/impersonatable.
|
||||||
// 3. Seal the rumor (encrypt to recipient)
|
let senderKey = try senderIdentity.schnorrSigningKey()
|
||||||
let sealedEvent = try createSeal(
|
let sealedEvent = try createSeal(
|
||||||
rumor: rumor,
|
rumor: rumor,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: ephemeralKey
|
senderKey: senderKey
|
||||||
)
|
)
|
||||||
|
|
||||||
// 4. Gift wrap the sealed event (encrypt to recipient again)
|
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
|
||||||
|
// layer hides the sender's identity from relays; createGiftWrap mints
|
||||||
|
// its own ephemeral key internally).
|
||||||
let giftWrap = try createGiftWrap(
|
let giftWrap = try createGiftWrap(
|
||||||
seal: sealedEvent,
|
seal: sealedEvent,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey
|
||||||
senderKey: ephemeralKey
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Created gift wrap
|
// Created gift wrap
|
||||||
@@ -84,7 +85,15 @@ struct NostrProtocol {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Open the seal
|
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
|
||||||
|
// identity key (NIP-17); without this check a DM is forgeable by anyone
|
||||||
|
// who knows the recipient's npub. Verify the seal's own signature.
|
||||||
|
guard seal.isValidSignature() else {
|
||||||
|
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
|
||||||
|
throw NostrError.invalidEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Open the seal
|
||||||
let rumor: NostrEvent
|
let rumor: NostrEvent
|
||||||
do {
|
do {
|
||||||
rumor = try openSeal(
|
rumor = try openSeal(
|
||||||
@@ -97,9 +106,62 @@ struct NostrProtocol {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at)
|
// 4. The sender claimed inside the rumor must match the key that actually
|
||||||
|
// signed the seal, otherwise the sender field is unauthenticated and
|
||||||
|
// spoofable.
|
||||||
|
guard seal.pubkey == rumor.pubkey else {
|
||||||
|
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
|
||||||
|
throw NostrError.invalidEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the seal signer's pubkey as the authenticated sender.
|
||||||
|
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
static func createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||||
|
content: String,
|
||||||
|
recipientPubkey: String,
|
||||||
|
senderIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let rumor = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .dm,
|
||||||
|
tags: [],
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
var seal = try createSeal(
|
||||||
|
rumor: rumor,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
senderKey: senderIdentity.schnorrSigningKey()
|
||||||
|
)
|
||||||
|
seal.sig = String(repeating: "0", count: 128)
|
||||||
|
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
||||||
|
content: String,
|
||||||
|
recipientPubkey: String,
|
||||||
|
rumorIdentity: NostrIdentity,
|
||||||
|
sealSignerIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let rumor = NostrEvent(
|
||||||
|
pubkey: rumorIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .dm,
|
||||||
|
tags: [],
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let seal = try createSeal(
|
||||||
|
rumor: rumor,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
senderKey: sealSignerIdentity.schnorrSigningKey()
|
||||||
|
)
|
||||||
|
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
||||||
static func createEphemeralGeohashEvent(
|
static func createEphemeralGeohashEvent(
|
||||||
content: String,
|
content: String,
|
||||||
@@ -195,8 +257,7 @@ struct NostrProtocol {
|
|||||||
|
|
||||||
private static func createGiftWrap(
|
private static func createGiftWrap(
|
||||||
seal: NostrEvent,
|
seal: NostrEvent,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String
|
||||||
senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal
|
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
|
|
||||||
let sealJSON = try seal.jsonString()
|
let sealJSON = try seal.jsonString()
|
||||||
|
|||||||
@@ -48,7 +48,14 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
|
|||||||
let base: URLSession
|
let base: URLSession
|
||||||
|
|
||||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
|
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
|
||||||
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
|
let task = base.webSocketTask(with: url)
|
||||||
|
// Byte bound per inbound frame; without it the per-relay buffer cap
|
||||||
|
// (nostrInboundPerRelayBufferCap) bounds FRAMES but not BYTES, and a
|
||||||
|
// hostile relay could pile up cap × 1 MiB (URLSession default) per
|
||||||
|
// connection. See TransportConfig.nostrInboundMaxFrameBytes for the
|
||||||
|
// sizing rationale. Oversized frames fail the receive with an error.
|
||||||
|
task.maximumMessageSize = TransportConfig.nostrInboundMaxFrameBytes
|
||||||
|
return URLSessionWebSocketTaskAdapter(base: task)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +113,10 @@ private extension NostrRelayManagerDependencies {
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class NostrRelayManager: ObservableObject {
|
final class NostrRelayManager: ObservableObject {
|
||||||
static let shared = NostrRelayManager()
|
static let shared = NostrRelayManager()
|
||||||
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
|
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info.
|
||||||
|
// Entries are removed only on OK acks (or panic wipe); relays that never
|
||||||
|
// ack leave entries behind for the process lifetime. Observability-only
|
||||||
|
// state, bounded in practice by outbound DM volume.
|
||||||
private(set) static var pendingGiftWrapIDs = Set<String>()
|
private(set) static var pendingGiftWrapIDs = Set<String>()
|
||||||
static func registerPendingGiftWrap(id: String) {
|
static func registerPendingGiftWrap(id: String) {
|
||||||
pendingGiftWrapIDs.insert(id)
|
pendingGiftWrapIDs.insert(id)
|
||||||
@@ -213,6 +223,32 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
||||||
private var connectionGeneration: Int = 0
|
private var connectionGeneration: Int = 0
|
||||||
|
|
||||||
|
// Per-relay off-main inbound pipeline: raw socket frames are parsed and
|
||||||
|
// Schnorr-verified in arrival order OFF the main actor (this is the single
|
||||||
|
// signature verification for the whole inbound path — downstream handlers
|
||||||
|
// receive only verified events), then hop back to the main actor for dedup
|
||||||
|
// recording and handler dispatch.
|
||||||
|
//
|
||||||
|
// Each relay connection owns its OWN AsyncStream + consumer task, so N
|
||||||
|
// relays verify in parallel while every relay's frames stay in arrival
|
||||||
|
// order (a single subscription's events for a relay all arrive on that
|
||||||
|
// relay's socket, so per-relay ordering preserves per-subscription
|
||||||
|
// ordering). A burst of EVENT frames from one busy/malicious relay only
|
||||||
|
// blocks that relay's own verification backlog — DMs, OKs, EOSEs, and
|
||||||
|
// events from every other relay keep flowing on their own pipelines.
|
||||||
|
//
|
||||||
|
// Each stream is bounded (`.bufferingNewest`) so a relay flooding faster
|
||||||
|
// than its verification drains sheds its own oldest frames instead of
|
||||||
|
// growing memory without bound; it can never starve other relays.
|
||||||
|
//
|
||||||
|
// Continuations live in a lock-guarded, `Sendable` router (see
|
||||||
|
// `InboundFrameRouter` at file scope) so the raw socket receive callback
|
||||||
|
// (which is NOT main-actor isolated) can route a frame to the right relay
|
||||||
|
// stream without a per-frame main hop, while the main actor owns pipeline
|
||||||
|
// creation/teardown. The expensive work (Schnorr verify) is what runs
|
||||||
|
// off-main; the yield stays cheap.
|
||||||
|
private let inboundRouter = InboundFrameRouter()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.dependencies = .live()
|
self.dependencies = .live()
|
||||||
hasMutualFavorites = dependencies.hasMutualFavorites()
|
hasMutualFavorites = dependencies.hasMutualFavorites()
|
||||||
@@ -267,6 +303,69 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
inboundRouter.finishAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure a serial off-main consumer pipeline exists for a relay. Called on
|
||||||
|
/// the main actor when a socket is (re)armed for receiving. Idempotent.
|
||||||
|
///
|
||||||
|
/// Ordering within the relay is deliberate and security/performance-critical:
|
||||||
|
/// 1. `precheckInboundEvent` (main hop): per-relay stats plus a cheap
|
||||||
|
/// duplicate LOOKUP — duplicate fan-in from multiple relays dominates
|
||||||
|
/// real traffic and must never pay for Schnorr verification.
|
||||||
|
/// 2. `isValidSignature()` runs here, off the main actor — the ONLY
|
||||||
|
/// signature verification on the inbound path (JSON re-serialization +
|
||||||
|
/// SHA-256 + secp256k1 Schnorr per event).
|
||||||
|
/// 3. `deliverVerifiedInboundEvent` (main hop): authoritative
|
||||||
|
/// check-and-RECORD plus handler dispatch. Recording only after
|
||||||
|
/// verification means a forged-signature copy can never poison the
|
||||||
|
/// dedup cache and suppress the genuine event.
|
||||||
|
private func ensureRelayInboundPipeline(for relayUrl: String) {
|
||||||
|
let started = inboundRouter.startPipeline(for: relayUrl) { [weak self] stream in
|
||||||
|
Task.detached(priority: .userInitiated) {
|
||||||
|
for await frame in stream {
|
||||||
|
guard let parsed = ParsedInbound(frame.message) else { continue }
|
||||||
|
guard let self else { return }
|
||||||
|
switch parsed {
|
||||||
|
case .event(let subId, let event):
|
||||||
|
guard await self.precheckInboundEvent(
|
||||||
|
subscriptionID: subId,
|
||||||
|
eventID: event.id,
|
||||||
|
relayUrl: relayUrl
|
||||||
|
) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard event.isValidSignature() else {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await self.deliverVerifiedInboundEvent(subscriptionID: subId, event: event, from: relayUrl)
|
||||||
|
case .eose, .ok, .notice:
|
||||||
|
await self.handleParsedMessage(parsed, from: relayUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if started {
|
||||||
|
SecureLogger.debug("🧵 Started inbound verify pipeline for \(relayUrl)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tear down a relay's inbound pipeline (socket gone or state wiped). The
|
||||||
|
/// consumer drains any already-buffered frames before finishing, so
|
||||||
|
/// in-flight verified events are still delivered.
|
||||||
|
private func teardownRelayInboundPipeline(for relayUrl: String) {
|
||||||
|
inboundRouter.finishPipeline(for: relayUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func teardownAllRelayInboundPipelines() {
|
||||||
|
inboundRouter.finishAll()
|
||||||
|
}
|
||||||
|
|
||||||
/// Connect to all configured relays
|
/// Connect to all configured relays
|
||||||
func connect() {
|
func connect() {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
@@ -281,6 +380,9 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
task.cancel(with: .goingAway, reason: nil)
|
task.cancel(with: .goingAway, reason: nil)
|
||||||
}
|
}
|
||||||
connections.removeAll()
|
connections.removeAll()
|
||||||
|
// Sockets are gone; drop every relay's inbound verify pipeline.
|
||||||
|
teardownAllRelayInboundPipelines()
|
||||||
|
markRelaySocketsClosed(resetState: false)
|
||||||
// Sockets are gone, so per-relay subscription state is cleared — but
|
// Sockets are gone, so per-relay subscription state is cleared — but
|
||||||
// durable intent (subscriptionRequestState, messageHandlers, parked
|
// durable intent (subscriptionRequestState, messageHandlers, parked
|
||||||
// EOSE callbacks) is kept so REQs replay when relays reconnect
|
// EOSE callbacks) is kept so REQs replay when relays reconnect
|
||||||
@@ -299,6 +401,61 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Panic wipe reset: close sockets and drop every user/session-specific
|
||||||
|
/// relay intent without invoking old callbacks. Unlike `disconnect()`, this
|
||||||
|
/// must not preserve subscription replay state because geohash DM handlers
|
||||||
|
/// can capture pre-wipe Nostr private keys.
|
||||||
|
func resetForPanicWipe() {
|
||||||
|
connectionGeneration &+= 1
|
||||||
|
for (_, task) in connections {
|
||||||
|
task.cancel(with: .goingAway, reason: nil)
|
||||||
|
}
|
||||||
|
connections.removeAll()
|
||||||
|
teardownAllRelayInboundPipelines()
|
||||||
|
markRelaySocketsClosed(resetState: true)
|
||||||
|
subscriptions.removeAll()
|
||||||
|
pendingSubscriptions.removeAll()
|
||||||
|
messageHandlers.removeAll()
|
||||||
|
subscriptionRequestState.removeAll()
|
||||||
|
subscribeCoalesce.removeAll()
|
||||||
|
eoseTrackers.removeAll()
|
||||||
|
pendingEOSECallbacks.removeAll()
|
||||||
|
pendingTorConnectionURLs.removeAll()
|
||||||
|
awaitingTorForConnections = false
|
||||||
|
torReadyWaitAttempts = 0
|
||||||
|
recentInboundEventKeys.removeAll()
|
||||||
|
recentInboundEventKeyOrder.removeAll()
|
||||||
|
duplicateInboundEventDropCount = 0
|
||||||
|
duplicateInboundEventDropCountBySubscription.removeAll()
|
||||||
|
inboundEventLogCount = 0
|
||||||
|
Self.pendingGiftWrapIDs.removeAll()
|
||||||
|
|
||||||
|
messageQueueLock.lock()
|
||||||
|
messageQueue.removeAll()
|
||||||
|
pendingSendDropCount = 0
|
||||||
|
messageQueueLock.unlock()
|
||||||
|
|
||||||
|
updateConnectionStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markRelaySocketsClosed(resetState: Bool) {
|
||||||
|
let now = dependencies.now()
|
||||||
|
for index in relays.indices {
|
||||||
|
relays[index].isConnected = false
|
||||||
|
relays[index].nextReconnectTime = nil
|
||||||
|
if resetState {
|
||||||
|
relays[index].lastError = nil
|
||||||
|
relays[index].lastConnectedAt = nil
|
||||||
|
relays[index].lastDisconnectedAt = nil
|
||||||
|
relays[index].messagesSent = 0
|
||||||
|
relays[index].messagesReceived = 0
|
||||||
|
relays[index].reconnectAttempts = 0
|
||||||
|
} else {
|
||||||
|
relays[index].lastDisconnectedAt = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||||
func ensureConnections(to relayUrls: [String]) {
|
func ensureConnections(to relayUrls: [String]) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
@@ -505,6 +662,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
connection.cancel(with: .goingAway, reason: nil)
|
connection.cancel(with: .goingAway, reason: nil)
|
||||||
}
|
}
|
||||||
connections.removeValue(forKey: url)
|
connections.removeValue(forKey: url)
|
||||||
|
teardownRelayInboundPipeline(for: url)
|
||||||
subscriptions.removeValue(forKey: url)
|
subscriptions.removeValue(forKey: url)
|
||||||
pendingSubscriptions.removeValue(forKey: url)
|
pendingSubscriptions.removeValue(forKey: url)
|
||||||
}
|
}
|
||||||
@@ -845,6 +1003,10 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
connections[urlString] = task
|
connections[urlString] = task
|
||||||
task.resume()
|
task.resume()
|
||||||
|
|
||||||
|
// Bring up this relay's own serial verify pipeline before arming the
|
||||||
|
// socket, so inbound frames have somewhere to land.
|
||||||
|
ensureRelayInboundPipeline(for: urlString)
|
||||||
|
|
||||||
// Start receiving messages
|
// Start receiving messages
|
||||||
receiveMessage(from: task, relayUrl: urlString)
|
receiveMessage(from: task, relayUrl: urlString)
|
||||||
|
|
||||||
@@ -903,13 +1065,12 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let message):
|
case .success(let message):
|
||||||
// Parse off-main to reduce UI jank, then hop back for state updates
|
// Hand the raw frame to this relay's serial inbound pipeline:
|
||||||
Task.detached(priority: .utility) {
|
// parsing and signature verification run off-main, in arrival
|
||||||
guard let parsed = ParsedInbound(message) else { return }
|
// order, independently of every other relay's pipeline. Routing
|
||||||
await MainActor.run {
|
// through the lock-guarded router keeps this off the main actor
|
||||||
self.handleParsedMessage(parsed, from: relayUrl)
|
// (no per-frame main hop).
|
||||||
}
|
self.inboundRouter.yield(InboundFrame(message: message), to: relayUrl)
|
||||||
}
|
|
||||||
|
|
||||||
// Continue receiving
|
// Continue receiving
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -928,35 +1089,55 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Note: declared at file scope below to avoid MainActor isolation inside this class
|
// Note: declared at file scope below to avoid MainActor isolation inside this class
|
||||||
// and keep parsing off the main actor.
|
// and keep parsing off the main actor.
|
||||||
|
|
||||||
// Handle parsed message on MainActor (state updates and handlers)
|
/// First main-actor hop for an inbound EVENT: per-relay stats plus a cheap
|
||||||
|
/// duplicate LOOKUP (no recording) so duplicate fan-in from multiple
|
||||||
|
/// relays never pays for Schnorr verification. Recording happens only
|
||||||
|
/// after the signature verifies (`deliverVerifiedInboundEvent`), so a
|
||||||
|
/// forged-signature copy can never poison the dedup cache and suppress
|
||||||
|
/// the genuine event.
|
||||||
|
private func precheckInboundEvent(subscriptionID: String, eventID: String, relayUrl: String) -> Bool {
|
||||||
|
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||||
|
relays[index].messagesReceived += 1
|
||||||
|
}
|
||||||
|
guard !eventID.isEmpty else { return true }
|
||||||
|
let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID)
|
||||||
|
if recentInboundEventKeys.contains(key) {
|
||||||
|
recordDuplicateInboundEventDrop(subscriptionID: subscriptionID)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Second main-actor hop, after off-main signature verification:
|
||||||
|
/// authoritative check-and-record (the serial pipeline means the same
|
||||||
|
/// event is never in flight twice, but the record must stay atomic with
|
||||||
|
/// delivery) and handler dispatch.
|
||||||
|
private func deliverVerifiedInboundEvent(subscriptionID subId: String, event: NostrEvent, from relayUrl: String) {
|
||||||
|
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.kind != 1059 {
|
||||||
|
// Per-event logging floods dev builds in busy geohashes; sample it.
|
||||||
|
inboundEventLogCount += 1
|
||||||
|
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||||
|
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let handler = self.messageHandlers[subId] {
|
||||||
|
handler(event)
|
||||||
|
} else {
|
||||||
|
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle parsed non-EVENT messages on MainActor (state updates and handlers)
|
||||||
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
|
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
|
||||||
switch parsed {
|
switch parsed {
|
||||||
case .event(let subId, let event):
|
case .event:
|
||||||
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
|
// Events flow through the serial inbound pipeline (precheck →
|
||||||
self.relays[index].messagesReceived += 1
|
// off-main signature verification → deliverVerifiedInboundEvent)
|
||||||
}
|
// and never reach this fallback.
|
||||||
guard event.isValidSignature() else {
|
assertionFailure("inbound EVENT bypassed the verified pipeline")
|
||||||
SecureLogger.warning(
|
|
||||||
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if event.kind != 1059 {
|
|
||||||
// Per-event logging floods dev builds in busy geohashes; sample it.
|
|
||||||
inboundEventLogCount += 1
|
|
||||||
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
|
||||||
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let handler = self.messageHandlers[subId] {
|
|
||||||
handler(event)
|
|
||||||
} else {
|
|
||||||
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
|
|
||||||
}
|
|
||||||
case .eose(let subId):
|
case .eose(let subId):
|
||||||
if var tracker = eoseTrackers[subId] {
|
if var tracker = eoseTrackers[subId] {
|
||||||
tracker.pendingRelays.remove(relayUrl)
|
tracker.pendingRelays.remove(relayUrl)
|
||||||
@@ -1051,6 +1232,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||||
connections.removeValue(forKey: relayUrl)
|
connections.removeValue(forKey: relayUrl)
|
||||||
|
teardownRelayInboundPipeline(for: relayUrl)
|
||||||
subscriptions.removeValue(forKey: relayUrl)
|
subscriptions.removeValue(forKey: relayUrl)
|
||||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||||
settleEOSETrackers(droppingRelay: relayUrl)
|
settleEOSETrackers(droppingRelay: relayUrl)
|
||||||
@@ -1139,6 +1321,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
if let connection = connections[normalizedRelayUrl] {
|
if let connection = connections[normalizedRelayUrl] {
|
||||||
connection.cancel(with: .goingAway, reason: nil)
|
connection.cancel(with: .goingAway, reason: nil)
|
||||||
connections.removeValue(forKey: normalizedRelayUrl)
|
connections.removeValue(forKey: normalizedRelayUrl)
|
||||||
|
teardownRelayInboundPipeline(for: normalizedRelayUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt immediate reconnection
|
// Attempt immediate reconnection
|
||||||
@@ -1170,6 +1353,18 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
return Set(map.keys)
|
return Set(map.keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debugMessageHandlerCount: Int {
|
||||||
|
messageHandlers.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var debugSubscriptionRequestCount: Int {
|
||||||
|
subscriptionRequestState.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var debugPendingEOSECallbackCount: Int {
|
||||||
|
pendingEOSECallbacks.count
|
||||||
|
}
|
||||||
|
|
||||||
var debugDuplicateInboundEventDropCount: Int {
|
var debugDuplicateInboundEventDropCount: Int {
|
||||||
duplicateInboundEventDropCount
|
duplicateInboundEventDropCount
|
||||||
}
|
}
|
||||||
@@ -1221,6 +1416,77 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
|
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
|
||||||
|
|
||||||
|
/// A single raw socket frame awaiting off-main parse + Schnorr verification.
|
||||||
|
private struct InboundFrame: Sendable {
|
||||||
|
let message: URLSessionWebSocketTask.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock-guarded registry of per-relay inbound streams.
|
||||||
|
///
|
||||||
|
/// The raw WebSocket receive callback is not main-actor isolated, so it needs a
|
||||||
|
/// `Sendable` path to route a frame to the correct relay's stream without a
|
||||||
|
/// per-frame hop onto the main actor. Pipeline lifecycle (start/finish) is
|
||||||
|
/// driven from the main actor; frame delivery (`yield`) can come from any
|
||||||
|
/// thread. All access is serialized by a single lock — contention is negligible
|
||||||
|
/// because the guarded critical section is only a dictionary lookup + yield.
|
||||||
|
private final class InboundFrameRouter: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var continuations: [String: AsyncStream<InboundFrame>.Continuation] = [:]
|
||||||
|
private var tasks: [String: Task<Void, Never>] = [:]
|
||||||
|
|
||||||
|
/// Start a relay's stream + consumer if one does not already exist.
|
||||||
|
/// Returns true when a new pipeline was created. The bounded
|
||||||
|
/// `.bufferingNewest` policy makes a single relay shed its OWN oldest
|
||||||
|
/// frames under a flood, never other relays' frames. Buffered memory per
|
||||||
|
/// relay is bounded (not eliminated) at the frame cap times the per-frame
|
||||||
|
/// byte cap (`maximumMessageSize`) — see TransportConfig.
|
||||||
|
func startPipeline(
|
||||||
|
for relayUrl: String,
|
||||||
|
makeConsumer: (AsyncStream<InboundFrame>) -> Task<Void, Never>
|
||||||
|
) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if continuations[relayUrl] != nil { return false }
|
||||||
|
let (stream, continuation) = AsyncStream<InboundFrame>.makeStream(
|
||||||
|
bufferingPolicy: .bufferingNewest(TransportConfig.nostrInboundPerRelayBufferCap)
|
||||||
|
)
|
||||||
|
continuations[relayUrl] = continuation
|
||||||
|
tasks[relayUrl] = makeConsumer(stream)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route a frame to a relay's stream. No-op if the relay has no live
|
||||||
|
/// pipeline (socket already torn down) — the frame is simply dropped, which
|
||||||
|
/// is safe for best-effort Nostr inbound.
|
||||||
|
func yield(_ frame: InboundFrame, to relayUrl: String) {
|
||||||
|
lock.lock()
|
||||||
|
let continuation = continuations[relayUrl]
|
||||||
|
lock.unlock()
|
||||||
|
continuation?.yield(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finish a relay's stream. The consumer drains any already-buffered frames
|
||||||
|
/// before exiting, so in-flight verified events are still delivered.
|
||||||
|
func finishPipeline(for relayUrl: String) {
|
||||||
|
lock.lock()
|
||||||
|
let continuation = continuations.removeValue(forKey: relayUrl)
|
||||||
|
tasks.removeValue(forKey: relayUrl)
|
||||||
|
lock.unlock()
|
||||||
|
continuation?.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishAll() {
|
||||||
|
lock.lock()
|
||||||
|
let allContinuations = continuations
|
||||||
|
continuations.removeAll()
|
||||||
|
tasks.removeAll()
|
||||||
|
lock.unlock()
|
||||||
|
for continuation in allContinuations.values {
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private enum ParsedInbound {
|
private enum ParsedInbound {
|
||||||
case event(subId: String, event: NostrEvent)
|
case event(subId: String, event: NostrEvent)
|
||||||
case ok(eventId: String, success: Bool, reason: String)
|
case ok(eventId: String, success: Bool, reason: String)
|
||||||
|
|||||||
@@ -168,8 +168,13 @@ final class BLEAnnounceHandler {
|
|||||||
env.updateTopology(peerID, neighbors)
|
env.updateTopology(peerID, neighbors)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist cryptographic identity and signing key for robust offline verification
|
// Persist cryptographic identity and signing key for robust offline
|
||||||
env.persistIdentity(announcement)
|
// verification — only for verified announces. Persisting unverified
|
||||||
|
// announces would let an attacker who replays a victim's noisePublicKey
|
||||||
|
// overwrite the victim's stored signing key/nickname (identity poisoning).
|
||||||
|
if verifiedAnnounce {
|
||||||
|
env.persistIdentity(announcement)
|
||||||
|
}
|
||||||
|
|
||||||
let announceBackID = "announce-back-\(peerID)"
|
let announceBackID = "announce-back-\(peerID)"
|
||||||
let shouldSendBack = !env.dedupContains(announceBackID)
|
let shouldSendBack = !env.dedupContains(announceBackID)
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ struct BLEPublicMessageHandlerEnvironment {
|
|||||||
let now: () -> Date
|
let now: () -> Date
|
||||||
/// Snapshot of known peers keyed by ID (registry read).
|
/// Snapshot of known peers keyed by ID (registry read).
|
||||||
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
||||||
|
/// Verifies a packet's signature against a known signing public key.
|
||||||
|
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||||
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
||||||
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
||||||
/// Tracks the broadcast message packet for gossip sync.
|
/// Tracks the broadcast message packet for gossip sync.
|
||||||
@@ -68,14 +70,39 @@ final class BLEPublicMessageHandler {
|
|||||||
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
|
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
|
||||||
let peersSnapshot = env.peersSnapshot()
|
let peersSnapshot = env.peersSnapshot()
|
||||||
|
|
||||||
|
// Public messages are always signed by their sender. `senderID` is
|
||||||
|
// attacker-controlled, so registry membership alone is NOT proof of
|
||||||
|
// identity — a peer in the registry as "verified" could be impersonated
|
||||||
|
// by anyone spoofing their senderID. Require a valid packet signature
|
||||||
|
// from the claimed sender (our own echoes are exempt; they are matched
|
||||||
|
// by self-broadcast tracking below).
|
||||||
|
//
|
||||||
|
// Verify against the signing key already in the (synchronously-updated)
|
||||||
|
// peer registry first: identity-cache persistence is asynchronous, so a
|
||||||
|
// message arriving right after a verified announce would otherwise be
|
||||||
|
// dropped because `signedSenderDisplayName` only searches the persisted
|
||||||
|
// cache. Fall back to that persisted-identity lookup for peers not (yet)
|
||||||
|
// in the registry.
|
||||||
|
let isSelf = peerID == env.localPeerID()
|
||||||
|
let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey
|
||||||
|
let verifiedViaRegistry = !isSelf
|
||||||
|
&& (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
|
||||||
|
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
|
||||||
|
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else {
|
||||||
|
SecureLogger.warning("🚫 Dropping public message with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticity is established; prefer the registry's collision-resolved
|
||||||
|
// display name, then the signature-derived name.
|
||||||
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
localPeerID: env.localPeerID(),
|
localPeerID: env.localPeerID(),
|
||||||
localNickname: env.localNickname(),
|
localNickname: env.localNickname(),
|
||||||
peers: peersSnapshot,
|
peers: peersSnapshot,
|
||||||
allowConnectedUnverified: false
|
allowConnectedUnverified: false
|
||||||
) ?? env.signedSenderDisplayName(packet, peerID) else {
|
) ?? signedDisplayName else {
|
||||||
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
SecureLogger.warning("🚫 Dropping public message from unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ struct BLEReceivedPacketContext: Equatable {
|
|||||||
struct BLEReceivePipeline {
|
struct BLEReceivePipeline {
|
||||||
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
|
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
|
||||||
let senderID = PeerID(hexData: packet.senderID)
|
let senderID = PeerID(hexData: packet.senderID)
|
||||||
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)"
|
// Include a payload digest so that distinct packets sharing the same
|
||||||
|
// sender/timestamp(ms)/type are not collapsed as duplicates. The
|
||||||
|
// post-handshake flush sends queued messages, delivery and read receipts
|
||||||
|
// back-to-back within a single millisecond; without the digest every
|
||||||
|
// packet after the first would be silently dropped.
|
||||||
|
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
||||||
|
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||||
let messageType = MessageType(rawValue: packet.type)
|
let messageType = MessageType(rawValue: packet.type)
|
||||||
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
|
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
|
||||||
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
|
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
|
||||||
|
|||||||
@@ -135,6 +135,13 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
|
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
|
||||||
private var maintenanceCounter = 0 // Track maintenance cycles
|
private var maintenanceCounter = 0 // Track maintenance cycles
|
||||||
|
/// Whether real CoreBluetooth managers were initialized. When false (unit
|
||||||
|
/// tests), periodic mesh background work is not started — the maintenance
|
||||||
|
/// timer and the gossip-sync timers only drain BLE writes/notifications,
|
||||||
|
/// re-announce, and sign/broadcast sync packets, all meaningless without
|
||||||
|
/// Bluetooth. Leaving them running in the test process is pure background
|
||||||
|
/// churn that aggravates flaky exit hangs.
|
||||||
|
private var meshBackgroundEnabled = false
|
||||||
|
|
||||||
// MARK: - Connection budget & scheduling (central role)
|
// MARK: - Connection budget & scheduling (central role)
|
||||||
private var connectionScheduler = BLEConnectionScheduler<CBPeripheral>()
|
private var connectionScheduler = BLEConnectionScheduler<CBPeripheral>()
|
||||||
@@ -233,16 +240,10 @@ final class BLEService: NSObject {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single maintenance timer for all periodic tasks (dispatch-based for determinism)
|
// Single maintenance timer for all periodic tasks (dispatch-based for
|
||||||
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
// determinism). Only run it when real Bluetooth managers exist.
|
||||||
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
|
meshBackgroundEnabled = initializeBluetoothManagers
|
||||||
repeating: TransportConfig.bleMaintenanceInterval,
|
startMaintenanceTimer()
|
||||||
leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))
|
|
||||||
timer.setEventHandler { [weak self] in
|
|
||||||
self?.performMaintenance()
|
|
||||||
}
|
|
||||||
timer.resume()
|
|
||||||
maintenanceTimer = timer
|
|
||||||
|
|
||||||
// Publish initial empty state
|
// Publish initial empty state
|
||||||
requestPeerDataPublish()
|
requestPeerDataPublish()
|
||||||
@@ -272,7 +273,12 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||||
manager.delegate = self
|
manager.delegate = self
|
||||||
manager.start()
|
// Only start the periodic sync timers when real Bluetooth exists. In unit
|
||||||
|
// tests there is no mesh to sync with, and the periodic sign/broadcast
|
||||||
|
// churn just keeps the process busy and aggravates flaky exit hangs.
|
||||||
|
if meshBackgroundEnabled {
|
||||||
|
manager.start()
|
||||||
|
}
|
||||||
gossipSyncManager = manager
|
gossipSyncManager = manager
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +441,29 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: Lifecycle
|
||||||
|
|
||||||
|
/// Creates and starts the periodic maintenance timer if it is not already
|
||||||
|
/// running. Idempotent so it can be called from both `init` and
|
||||||
|
/// `startServices()` — the latter matters after a panic reset, where
|
||||||
|
/// `stopServices()` cancels and nils the timer.
|
||||||
|
private func startMaintenanceTimer() {
|
||||||
|
guard meshBackgroundEnabled, maintenanceTimer == nil else { return }
|
||||||
|
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
||||||
|
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
|
||||||
|
repeating: TransportConfig.bleMaintenanceInterval,
|
||||||
|
leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))
|
||||||
|
timer.setEventHandler { [weak self] in
|
||||||
|
self?.performMaintenance()
|
||||||
|
}
|
||||||
|
timer.resume()
|
||||||
|
maintenanceTimer = timer
|
||||||
|
}
|
||||||
|
|
||||||
func startServices() {
|
func startServices() {
|
||||||
|
// Restart the maintenance timer if a prior stopServices() cancelled it
|
||||||
|
// (e.g. the panic flow), otherwise periodic announces, peer reconciliation
|
||||||
|
// and cache cleanup would never resume until app restart.
|
||||||
|
startMaintenanceTimer()
|
||||||
|
|
||||||
// Start BLE services if not already running
|
// Start BLE services if not already running
|
||||||
if centralManager?.state == .poweredOn {
|
if centralManager?.state == .poweredOn {
|
||||||
centralManager?.scanForPeripherals(
|
centralManager?.scanForPeripherals(
|
||||||
@@ -1602,7 +1630,7 @@ private extension BLEService {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Test-only helper to inject packets into the receive pipeline
|
// Test-only helper to inject packets into the receive pipeline
|
||||||
extension BLEService {
|
extension BLEService {
|
||||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true) {
|
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
|
||||||
if preseedPeer {
|
if preseedPeer {
|
||||||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
// Ensure the synthetic peer is known and marked verified for public-message tests
|
||||||
let normalizedID = PeerID(hexData: packet.senderID)
|
let normalizedID = PeerID(hexData: packet.senderID)
|
||||||
@@ -1610,6 +1638,7 @@ extension BLEService {
|
|||||||
if var existing = peerRegistry.info(for: normalizedID) {
|
if var existing = peerRegistry.info(for: normalizedID) {
|
||||||
existing.isConnected = true
|
existing.isConnected = true
|
||||||
existing.isVerifiedNickname = true
|
existing.isVerifiedNickname = true
|
||||||
|
if let signingPublicKey { existing.signingPublicKey = signingPublicKey }
|
||||||
existing.lastSeen = Date()
|
existing.lastSeen = Date()
|
||||||
peerRegistry.upsert(existing)
|
peerRegistry.upsert(existing)
|
||||||
} else {
|
} else {
|
||||||
@@ -1618,7 +1647,7 @@ extension BLEService {
|
|||||||
nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
|
nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
noisePublicKey: packet.senderID,
|
noisePublicKey: packet.senderID,
|
||||||
signingPublicKey: nil,
|
signingPublicKey: signingPublicKey,
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
lastSeen: Date()
|
lastSeen: Date()
|
||||||
))
|
))
|
||||||
@@ -3110,6 +3139,9 @@ extension BLEService {
|
|||||||
guard let self = self else { return [:] }
|
guard let self = self else { return [:] }
|
||||||
return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
|
return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
|
||||||
},
|
},
|
||||||
|
verifyPacketSignature: { [weak self] packet, signingPublicKey in
|
||||||
|
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||||||
|
},
|
||||||
signedSenderDisplayName: { [weak self] packet, peerID in
|
signedSenderDisplayName: { [weak self] packet, peerID in
|
||||||
self?.signedSenderDisplayName(for: packet, from: peerID)
|
self?.signedSenderDisplayName(for: packet, from: peerID)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -594,6 +594,22 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes all persisted location state and resets the in-memory view.
|
||||||
|
/// Used by the panic wipe — selected channel, teleport set and bookmarks
|
||||||
|
/// (which reveal where the user has been) must not survive on device.
|
||||||
|
func panicWipe() {
|
||||||
|
storage.removeObject(forKey: selectedChannelKey)
|
||||||
|
storage.removeObject(forKey: teleportedStoreKey)
|
||||||
|
storage.removeObject(forKey: bookmarksKey)
|
||||||
|
storage.removeObject(forKey: bookmarkNamesKey)
|
||||||
|
teleportedSet.removeAll()
|
||||||
|
bookmarkMembership.removeAll()
|
||||||
|
bookmarks = []
|
||||||
|
bookmarkNames = [:]
|
||||||
|
teleported = false
|
||||||
|
selectedChannel = .mesh
|
||||||
|
}
|
||||||
|
|
||||||
private static func normalizeGeohash(_ s: String) -> String {
|
private static func normalizeGeohash(_ s: String) -> String {
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
private let permissionProvider: () -> LocationChannelManager.PermissionState
|
private let permissionProvider: () -> LocationChannelManager.PermissionState
|
||||||
private let mutualFavoritesProvider: () -> Set<Data>
|
private let mutualFavoritesProvider: () -> Set<Data>
|
||||||
private let torController: NetworkActivationTorControlling
|
private let torController: NetworkActivationTorControlling
|
||||||
private let relayController: NetworkActivationRelayControlling
|
// Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared
|
||||||
|
// (via its live dependencies), so capturing NostrRelayManager.shared here would
|
||||||
|
// re-enter whichever singleton's dispatch_once started first and trap at launch.
|
||||||
|
private lazy var relayController: NetworkActivationRelayControlling = relayControllerProvider()
|
||||||
|
private let relayControllerProvider: () -> NetworkActivationRelayControlling
|
||||||
private let proxyController: NetworkActivationProxyControlling
|
private let proxyController: NetworkActivationProxyControlling
|
||||||
private let notificationCenter: NotificationCenter
|
private let notificationCenter: NotificationCenter
|
||||||
|
|
||||||
@@ -55,7 +59,7 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
permissionProvider = { LocationChannelManager.shared.permissionState }
|
permissionProvider = { LocationChannelManager.shared.permissionState }
|
||||||
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
|
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
|
||||||
torController = TorManager.shared
|
torController = TorManager.shared
|
||||||
relayController = NostrRelayManager.shared
|
relayControllerProvider = { NostrRelayManager.shared }
|
||||||
proxyController = TorURLSession.shared
|
proxyController = TorURLSession.shared
|
||||||
notificationCenter = .default
|
notificationCenter = .default
|
||||||
}
|
}
|
||||||
@@ -77,7 +81,7 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
self.permissionProvider = permissionProvider
|
self.permissionProvider = permissionProvider
|
||||||
self.mutualFavoritesProvider = mutualFavoritesProvider
|
self.mutualFavoritesProvider = mutualFavoritesProvider
|
||||||
self.torController = torController
|
self.torController = torController
|
||||||
self.relayController = relayController
|
self.relayControllerProvider = { relayController }
|
||||||
self.proxyController = proxyController
|
self.proxyController = proxyController
|
||||||
self.notificationCenter = notificationCenter
|
self.notificationCenter = notificationCenter
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,26 @@ enum TransportConfig {
|
|||||||
static let nostrDuplicateEventLogInterval: Int = 50
|
static let nostrDuplicateEventLogInterval: Int = 50
|
||||||
// Sample interval for per-event debug logs on the inbound hot path.
|
// Sample interval for per-event debug logs on the inbound hot path.
|
||||||
static let nostrInboundEventLogInterval: Int = 100
|
static let nostrInboundEventLogInterval: Int = 100
|
||||||
|
// Bounded per-relay inbound frame buffer. Each relay connection owns its
|
||||||
|
// own serial verify pipeline; if a relay floods faster than its Schnorr
|
||||||
|
// verification drains, the oldest buffered frames for THAT relay are
|
||||||
|
// dropped (bufferingNewest) so one relay cannot stall other relays.
|
||||||
|
// Nostr inbound is already best-effort (relays are redundant and events
|
||||||
|
// replay), so dropping a flooding relay's backlog is safe. Together with
|
||||||
|
// nostrInboundMaxFrameBytes this caps buffered inbound bytes at
|
||||||
|
// cap × maxFrameBytes (128 MiB) per hostile relay — bounded, not zero.
|
||||||
|
static let nostrInboundPerRelayBufferCap: Int = 256
|
||||||
|
// Hard per-frame byte bound, applied as URLSessionWebSocketTask
|
||||||
|
// .maximumMessageSize (oversized frames fail the receive instead of
|
||||||
|
// buffering). BitChat's legitimate Nostr traffic is small: geohash chat /
|
||||||
|
// presence events (kind 20000/20001), kind-1 notes, and NIP-17
|
||||||
|
// gift-wrapped DMs carrying text payloads or receipts are all a few KiB,
|
||||||
|
// and most public relays reject events beyond ~64–256 KiB anyway. 512 KiB
|
||||||
|
// leaves an order-of-magnitude margin over anything we produce or expect
|
||||||
|
// while halving the URLSession default (1 MiB), so a hostile relay's
|
||||||
|
// worst-case buffered pile-up per connection is
|
||||||
|
// nostrInboundPerRelayBufferCap × 512 KiB = 128 MiB instead of 256 MiB.
|
||||||
|
static let nostrInboundMaxFrameBytes: Int = 512 * 1024
|
||||||
|
|
||||||
// Conversation store diagnostics (field observability)
|
// Conversation store diagnostics (field observability)
|
||||||
// Sample interval for the periodic store-audit "OK" heartbeat line
|
// Sample interval for the periodic store-audit "OK" heartbeat line
|
||||||
|
|||||||
@@ -1129,6 +1129,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey")
|
userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey")
|
||||||
userDefaults.removeObject(forKey: "bitchat.messageRetentionKey")
|
userDefaults.removeObject(forKey: "bitchat.messageRetentionKey")
|
||||||
|
|
||||||
|
// Wipe persisted location state (selected channel, teleport set,
|
||||||
|
// bookmarks). For an activist-safety wipe, where the user has been is
|
||||||
|
// exactly the data an adversary inspecting the device wants.
|
||||||
|
LocationStateManager.shared.panicWipe()
|
||||||
|
|
||||||
// Reset nickname to anonymous
|
// Reset nickname to anonymous
|
||||||
nickname = "anon\(Int.random(in: 1000...9999))"
|
nickname = "anon\(Int.random(in: 1000...9999))"
|
||||||
saveNickname()
|
saveNickname()
|
||||||
@@ -1153,13 +1158,30 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// Clear selected private chat
|
// Clear selected private chat
|
||||||
selectedPrivateChatPeer = nil
|
selectedPrivateChatPeer = nil
|
||||||
|
|
||||||
|
// Clear live location/geohash session state. Persisted location state
|
||||||
|
// was wiped above, but the running view model can still be scoped to a
|
||||||
|
// geohash channel and hold subscriptions tied to the old Nostr identity.
|
||||||
|
activeChannel = .mesh
|
||||||
|
setGeoChatSubscriptionID(nil)
|
||||||
|
setGeoDmSubscriptionID(nil)
|
||||||
|
_ = clearGeoSamplingSubs()
|
||||||
|
cachedGeohashIdentity = nil
|
||||||
|
nostrKeyMapping.removeAll()
|
||||||
|
|
||||||
// Clear read receipt tracking
|
// Clear read receipt tracking
|
||||||
sentReadReceipts.removeAll()
|
sentReadReceipts.removeAll()
|
||||||
deduplicationService.clearAll()
|
deduplicationService.clearAll()
|
||||||
|
|
||||||
// IMPORTANT: Clear Nostr-related state
|
// IMPORTANT: Clear Nostr-related state
|
||||||
// Disconnect from Nostr relays and clear subscriptions
|
// Drop relay subscriptions, handlers, pending sends, and replay state.
|
||||||
nostrRelayManager?.disconnect()
|
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
|
||||||
|
// disconnect is not enough here.
|
||||||
|
NostrRelayManager.shared.resetForPanicWipe()
|
||||||
|
// Clearing relay handlers stops NEW events, but a detached gift-wrap
|
||||||
|
// decrypt spawned just before the wipe still holds a pre-wipe key and
|
||||||
|
// ciphertext; bump the pipeline's wipe generation so its result is
|
||||||
|
// dropped at the main-actor delivery hop instead of landing here.
|
||||||
|
nostrCoordinator.inbound.invalidateInFlightDecrypts()
|
||||||
nostrRelayManager = nil
|
nostrRelayManager = nil
|
||||||
|
|
||||||
// Clear Nostr identity associations
|
// Clear Nostr identity associations
|
||||||
@@ -1175,15 +1197,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// No need to force UserDefaults synchronization
|
// No need to force UserDefaults synchronization
|
||||||
|
|
||||||
// Reinitialize Nostr with new identity
|
// Reinitialize Nostr with new identity
|
||||||
// This will generate new Nostr keys derived from new Noise keys
|
// This will generate new Nostr keys derived from new Noise keys.
|
||||||
Task { @MainActor in
|
// Skipped under tests: connecting the shared relay singleton starts
|
||||||
// Small delay to ensure cleanup completes
|
// real network/reconnect work that never completes and would keep the
|
||||||
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
|
// test process alive (the singleton, unlike a discardable instance, is
|
||||||
|
// never deallocated to cancel it).
|
||||||
|
if !TestEnvironment.isRunningTests {
|
||||||
|
Task { @MainActor in
|
||||||
|
// Small delay to ensure cleanup completes
|
||||||
|
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
|
||||||
|
|
||||||
// Reinitialize Nostr relay manager with new identity
|
// Reinitialize Nostr relay manager with new identity. Reuse the
|
||||||
nostrRelayManager = NostrRelayManager()
|
// shared singleton — every other component (NostrTransport, geohash
|
||||||
setupNostrMessageHandling()
|
// subscriptions, AppRuntime observers) is bound to `.shared`, so
|
||||||
nostrRelayManager?.connect()
|
// creating a fresh instance here would split relay state and leave
|
||||||
|
// sends running against a disconnected manager.
|
||||||
|
nostrRelayManager = NostrRelayManager.shared
|
||||||
|
setupNostrMessageHandling()
|
||||||
|
nostrRelayManager?.connect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete ALL media files (incoming and outgoing) in background
|
// Delete ALL media files (incoming and outgoing) in background
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ final class GeoPresenceTracker {
|
|||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard event.isValidSignature() else { return }
|
// The signature was already verified (exactly once, off the main
|
||||||
|
// actor) by NostrRelayManager before delivery.
|
||||||
guard shouldProcessGeoSamplingEvent(event.id) else { return }
|
guard shouldProcessGeoSamplingEvent(event.id) else { return }
|
||||||
|
|
||||||
let existingCount = context.geoParticipantCount(for: gh)
|
let existingCount = context.geoParticipantCount(for: gh)
|
||||||
|
|||||||
@@ -75,20 +75,38 @@ extension ChatViewModel: NostrInboundPipelineContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The inbound Nostr hot path: raw relay events in, chat messages / Noise
|
/// The inbound Nostr hot path: verified relay events in, chat messages /
|
||||||
/// payloads out. Pure transformation plus dedup — no relay lifecycle.
|
/// Noise payloads out. Pure transformation plus dedup — no relay lifecycle.
|
||||||
///
|
///
|
||||||
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
|
/// Every event arriving here already had its Schnorr signature verified
|
||||||
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
|
/// exactly once, off the main actor, by `NostrRelayManager`'s serial inbound
|
||||||
/// dominate real relay traffic; events are recorded only AFTER verification so
|
/// pipeline (which records events into its own dedup cache only AFTER
|
||||||
/// a forged-signature copy can never poison the dedup set; gift-wrap
|
/// verification, so forged copies can't suppress genuine events). This
|
||||||
/// verification for the account mailbox runs off-main with an atomic
|
/// pipeline therefore never re-verifies; it keeps its own event-ID dedup
|
||||||
/// main-actor check-and-record.
|
/// (cheap main-actor lookups) and moves NIP-17 gift-wrap decryption — two
|
||||||
|
/// ECDH+ChaCha layers — off the main actor with an atomic main-actor
|
||||||
|
/// check-and-record.
|
||||||
final class NostrInboundPipeline {
|
final class NostrInboundPipeline {
|
||||||
private weak var context: (any NostrInboundPipelineContext)?
|
private weak var context: (any NostrInboundPipelineContext)?
|
||||||
private let presence: GeoPresenceTracker
|
private let presence: GeoPresenceTracker
|
||||||
private var geoEventLogCount = 0
|
private var geoEventLogCount = 0
|
||||||
|
|
||||||
|
/// Monotonic panic-wipe generation for this pipeline. A panic wipe clears
|
||||||
|
/// relay handlers so no NEW events flow, but a detached decrypt task
|
||||||
|
/// spawned just BEFORE the wipe — which strongly captures a pre-wipe Nostr
|
||||||
|
/// private key and ciphertext — survives it. Spawn sites capture this
|
||||||
|
/// value; the task compares it at its main-actor hops and drops its result
|
||||||
|
/// (no delivery; the captured identity and plaintext die with the task)
|
||||||
|
/// if `invalidateInFlightDecrypts()` bumped it in between.
|
||||||
|
@MainActor private(set) var wipeGeneration: UInt64 = 0
|
||||||
|
|
||||||
|
/// Called from `ChatViewModel.panicClearAllData()` so plaintext decrypted
|
||||||
|
/// with pre-wipe keys can never land in post-wipe state.
|
||||||
|
@MainActor
|
||||||
|
func invalidateInFlightDecrypts() {
|
||||||
|
wipeGeneration &+= 1
|
||||||
|
}
|
||||||
|
|
||||||
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
|
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.presence = presence
|
self.presence = presence
|
||||||
@@ -97,17 +115,15 @@ final class NostrInboundPipeline {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Cheap rejects (kind, dedup lookup) before Schnorr verification —
|
// Cheap rejects (kind, dedup lookup) — duplicates dominate real
|
||||||
// duplicates dominate real traffic and must not pay for crypto.
|
// traffic. The signature was already verified (exactly once, off the
|
||||||
// Only verified events are recorded, so a forged-signature copy can
|
// main actor) by NostrRelayManager before delivery.
|
||||||
// never poison the dedup set and suppress the genuine event.
|
|
||||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
||||||
!context.hasProcessedNostrEvent(event.id)
|
!context.hasProcessedNostrEvent(event.id)
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard event.isValidSignature() else { return }
|
|
||||||
|
|
||||||
context.recordProcessedNostrEvent(event.id)
|
context.recordProcessedNostrEvent(event.id)
|
||||||
|
|
||||||
@@ -176,15 +192,14 @@ final class NostrInboundPipeline {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func handleNostrEvent(_ event: NostrEvent) {
|
func handleNostrEvent(_ event: NostrEvent) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Cheap rejects (kind, dedup lookup) before Schnorr verification —
|
// Cheap rejects (kind, dedup lookup) — the signature was already
|
||||||
// duplicates dominate real traffic and must not pay for crypto.
|
// verified (exactly once, off the main actor) by NostrRelayManager.
|
||||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if context.hasProcessedNostrEvent(event.id) { return }
|
if context.hasProcessedNostrEvent(event.id) { return }
|
||||||
guard event.isValidSignature() else { return }
|
|
||||||
context.recordProcessedNostrEvent(event.id)
|
context.recordProcessedNostrEvent(event.id)
|
||||||
|
|
||||||
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
||||||
@@ -264,104 +279,126 @@ final class NostrInboundPipeline {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
// Cheap dedup pre-check only; processGeohashGiftWrap does the
|
||||||
|
// authoritative main-actor check-and-record before the off-main
|
||||||
|
// NIP-17 unwrap. The outer signature was already verified (exactly
|
||||||
|
// once, off the main actor) by NostrRelayManager.
|
||||||
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
|
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||||
guard giftWrap.isValidSignature() else { return }
|
|
||||||
context.recordProcessedNostrEvent(giftWrap.id)
|
|
||||||
|
|
||||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
// Capture the wipe generation at spawn, alongside the per-geohash
|
||||||
giftWrap: giftWrap,
|
// identity (private key) the detached task strongly captures. A panic
|
||||||
recipientIdentity: id
|
// wipe between spawn and delivery bumps the generation, and the task
|
||||||
),
|
// drops its result instead of delivering plaintext post-wipe.
|
||||||
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
let wipeGeneration = self.wipeGeneration
|
||||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
let noisePayload = NoisePayload.decode(packet.payload)
|
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: false, wipeGeneration: wipeGeneration)
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
|
||||||
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
|
||||||
|
|
||||||
switch noisePayload.type {
|
|
||||||
case .privateMessage:
|
|
||||||
context.handlePrivateMessage(
|
|
||||||
noisePayload,
|
|
||||||
senderPubkey: senderPubkey,
|
|
||||||
convKey: convKey,
|
|
||||||
id: id,
|
|
||||||
messageTimestamp: messageTimestamp
|
|
||||||
)
|
|
||||||
case .delivered:
|
|
||||||
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
|
||||||
case .readReceipt:
|
|
||||||
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
|
||||||
case .verifyChallenge, .verifyResponse:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
// Cheap dedup pre-check only; see subscribeGiftWrap.
|
||||||
if context.hasProcessedNostrEvent(giftWrap.id) {
|
if context.hasProcessedNostrEvent(giftWrap.id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard giftWrap.isValidSignature() else { return }
|
|
||||||
context.recordProcessedNostrEvent(giftWrap.id)
|
// Spawn-time wipe-generation capture; see subscribeGiftWrap.
|
||||||
|
let wipeGeneration = self.wipeGeneration
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
|
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: true, wipeGeneration: wipeGeneration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Geohash-DM gift wrap ingest. The NIP-17 unwrap (two ECDH+ChaCha
|
||||||
|
/// layers) runs off the main actor; results hop back for state updates.
|
||||||
|
/// `verbose` keeps `handleGiftWrap`'s decrypt logging without adding it
|
||||||
|
/// to the sampling path.
|
||||||
|
///
|
||||||
|
/// `wipeGeneration` is this pipeline's generation captured at spawn (the
|
||||||
|
/// moment the pre-wipe `id` was captured); a mismatch at either main-actor
|
||||||
|
/// hop means a panic wipe happened in between, so the task bails without
|
||||||
|
/// decrypting (first hop) or without delivering the plaintext (second
|
||||||
|
/// hop) — the captured identity and any decrypted material are simply
|
||||||
|
/// dropped with the task.
|
||||||
|
private func processGeohashGiftWrap(
|
||||||
|
_ giftWrap: NostrEvent,
|
||||||
|
id: NostrIdentity,
|
||||||
|
verbose: Bool,
|
||||||
|
wipeGeneration: UInt64
|
||||||
|
) async {
|
||||||
|
guard let context else { return }
|
||||||
|
// Authoritative check-and-record, atomic on the main actor so two
|
||||||
|
// concurrent detached tasks can't both process the same event.
|
||||||
|
let alreadyProcessed: Bool = await MainActor.run {
|
||||||
|
guard self.wipeGeneration == wipeGeneration else { return true }
|
||||||
|
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
|
||||||
|
context.recordProcessedNostrEvent(giftWrap.id)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if alreadyProcessed { return }
|
||||||
|
|
||||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||||
giftWrap: giftWrap,
|
giftWrap: giftWrap,
|
||||||
recipientIdentity: id
|
recipientIdentity: id
|
||||||
) else {
|
) else {
|
||||||
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
if verbose {
|
||||||
|
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.debug(
|
if verbose {
|
||||||
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
SecureLogger.debug(
|
||||||
category: .session
|
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
||||||
)
|
category: .session
|
||||||
|
|
||||||
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
|
||||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
|
||||||
let payload = NoisePayload.decode(packet.payload)
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
|
||||||
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
|
||||||
|
|
||||||
switch payload.type {
|
|
||||||
case .privateMessage:
|
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
|
||||||
context.handlePrivateMessage(
|
|
||||||
payload,
|
|
||||||
senderPubkey: senderPubkey,
|
|
||||||
convKey: convKey,
|
|
||||||
id: id,
|
|
||||||
messageTimestamp: messageTimestamp
|
|
||||||
)
|
)
|
||||||
case .delivered:
|
}
|
||||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
|
||||||
case .readReceipt:
|
await MainActor.run {
|
||||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
// A panic wipe during the off-main decrypt must not let the
|
||||||
case .verifyChallenge, .verifyResponse:
|
// pre-wipe plaintext reach post-wipe state; drop it here, atomic
|
||||||
break
|
// with the wipe on the main actor.
|
||||||
|
guard self.wipeGeneration == wipeGeneration else { return }
|
||||||
|
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||||
|
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||||
|
let payload = NoisePayload.decode(packet.payload)
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let convKey = PeerID(nostr_: senderPubkey)
|
||||||
|
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||||
|
|
||||||
|
switch payload.type {
|
||||||
|
case .privateMessage:
|
||||||
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||||
|
context.handlePrivateMessage(
|
||||||
|
payload,
|
||||||
|
senderPubkey: senderPubkey,
|
||||||
|
convKey: convKey,
|
||||||
|
id: id,
|
||||||
|
messageTimestamp: messageTimestamp
|
||||||
|
)
|
||||||
|
case .delivered:
|
||||||
|
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
|
case .readReceipt:
|
||||||
|
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
|
case .verifyChallenge, .verifyResponse:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Cheap dedup pre-check only; Schnorr verification runs off-main in
|
// Cheap dedup pre-check only; processNostrMessage does the
|
||||||
// processNostrMessage, which then does the authoritative
|
// authoritative check-and-record before the off-main NIP-17 unwrap.
|
||||||
// check-and-record. Recording stays after verification so a
|
// The outer signature was already verified (exactly once, off the
|
||||||
// forged-signature copy can never poison the dedup set and suppress
|
// main actor) by NostrRelayManager, and only verified events are
|
||||||
// the genuine event.
|
// recorded, so a forged-signature copy can never poison the dedup
|
||||||
|
// set and suppress the genuine event.
|
||||||
if context.hasProcessedNostrEvent(giftWrap.id) { return }
|
if context.hasProcessedNostrEvent(giftWrap.id) { return }
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
@@ -370,7 +407,6 @@ final class NostrInboundPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||||
guard giftWrap.isValidSignature() else { return }
|
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Authoritative check-and-record, atomic on the main actor so two
|
// Authoritative check-and-record, atomic on the main actor so two
|
||||||
// concurrent detached tasks can't both process the same event.
|
// concurrent detached tasks can't both process the same event.
|
||||||
@@ -380,8 +416,13 @@ final class NostrInboundPipeline {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if alreadyProcessed { return }
|
if alreadyProcessed { return }
|
||||||
let currentIdentity: NostrIdentity? = await MainActor.run {
|
// Fetch the identity and the wipe generation in ONE main-actor hop:
|
||||||
context.currentNostrIdentity()
|
// the generation then vouches for exactly this identity. A wipe after
|
||||||
|
// this point bumps the generation and the delivery hop below drops
|
||||||
|
// the decrypted result (same guard as processGeohashGiftWrap; this
|
||||||
|
// account-mailbox path had the identical hazard).
|
||||||
|
let (currentIdentity, wipeGeneration): (NostrIdentity?, UInt64) = await MainActor.run {
|
||||||
|
(context.currentNostrIdentity(), self.wipeGeneration)
|
||||||
}
|
}
|
||||||
guard let currentIdentity else { return }
|
guard let currentIdentity else { return }
|
||||||
|
|
||||||
@@ -413,6 +454,9 @@ final class NostrInboundPipeline {
|
|||||||
let payload = NoisePayload.decode(packet.payload) {
|
let payload = NoisePayload.decode(packet.payload) {
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
// Drop pre-wipe plaintext if a panic wipe landed
|
||||||
|
// during the off-main decrypt (see above).
|
||||||
|
guard self.wipeGeneration == wipeGeneration else { return }
|
||||||
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
|
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
|
||||||
|
|
||||||
switch payload.type {
|
switch payload.type {
|
||||||
|
|||||||
@@ -14,23 +14,29 @@ import BitFoundation
|
|||||||
struct BLEServiceCoreTests {
|
struct BLEServiceCoreTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func duplicatePacket_isDeduped() async {
|
func duplicatePacket_isDeduped() async throws {
|
||||||
let ble = makeService()
|
let ble = makeService()
|
||||||
let delegate = PublicCaptureDelegate()
|
let delegate = PublicCaptureDelegate()
|
||||||
ble.delegate = delegate
|
ble.delegate = delegate
|
||||||
|
|
||||||
|
// Public messages must carry a valid signature from the claimed sender;
|
||||||
|
// sign the packet and preseed the sender's signing key so the receiver
|
||||||
|
// can verify it (production `sendMessage` signs public broadcasts too).
|
||||||
|
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
let sender = PeerID(str: "1122334455667788")
|
let sender = PeerID(str: "1122334455667788")
|
||||||
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
let unsigned = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
||||||
|
let packet = try #require(signer.signPacket(unsigned), "Failed to sign public message")
|
||||||
|
let signingKey = signer.getSigningPublicKeyData()
|
||||||
|
|
||||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
|
||||||
let receivedFirst = await TestHelpers.waitUntil(
|
let receivedFirst = await TestHelpers.waitUntil(
|
||||||
{ delegate.publicMessagesSnapshot().count == 1 },
|
{ delegate.publicMessagesSnapshot().count == 1 },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.defaultTimeout
|
||||||
)
|
)
|
||||||
#expect(receivedFirst)
|
#expect(receivedFirst)
|
||||||
|
|
||||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
|
||||||
let receivedDuplicate = await TestHelpers.waitUntil(
|
let receivedDuplicate = await TestHelpers.waitUntil(
|
||||||
{ delegate.publicMessagesSnapshot().count > 1 },
|
{ delegate.publicMessagesSnapshot().count > 1 },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.shortTimeout
|
||||||
|
|||||||
@@ -382,10 +382,12 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
|
|
||||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||||
|
|
||||||
|
// The NIP-17 unwrap runs off the main actor; wait for the hop back.
|
||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
|
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
|
||||||
|
#expect(routed)
|
||||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||||
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
||||||
#expect(context.handledPrivateMessages.count == 1)
|
|
||||||
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
|
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
|
||||||
#expect(context.handledPrivateMessages.first?.convKey == convKey)
|
#expect(context.handledPrivateMessages.first?.convKey == convKey)
|
||||||
|
|
||||||
@@ -398,30 +400,76 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
|
|
||||||
// The same gift wrap is dropped on replay.
|
// The same gift wrap is dropped on replay.
|
||||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||||
|
await drainMainQueue()
|
||||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||||
#expect(context.handledPrivateMessages.count == 1)
|
#expect(context.handledPrivateMessages.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
|
func handleGiftWrap_panicWipeAfterSpawnDropsDecryptedResult() async throws {
|
||||||
let context = MockChatNostrContext()
|
let context = MockChatNostrContext()
|
||||||
let coordinator = ChatNostrCoordinator(context: context)
|
let coordinator = ChatNostrCoordinator(context: context)
|
||||||
|
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
|
let embedded = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||||
|
content: "pre-wipe secret",
|
||||||
|
messageID: "gm-wipe-1",
|
||||||
|
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||||
|
))
|
||||||
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
|
content: embedded,
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spawn the detached decrypt (it strongly captures the pre-wipe
|
||||||
|
// identity), then panic-wipe in the SAME main-actor turn — guaranteed
|
||||||
|
// to land before the task's first main-actor hop.
|
||||||
|
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||||
|
coordinator.inbound.invalidateInFlightDecrypts()
|
||||||
|
|
||||||
|
// Give the detached task ample time to have delivered if the wipe
|
||||||
|
// guard were broken.
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
await drainMainQueue()
|
||||||
|
|
||||||
|
#expect(context.handledPrivateMessages.isEmpty)
|
||||||
|
#expect(context.recordedNostrEventIDs.isEmpty)
|
||||||
|
|
||||||
|
// The pipeline itself stays usable: a gift wrap spawned AFTER the
|
||||||
|
// wipe (new generation) still decrypts and delivers.
|
||||||
|
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||||
|
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
|
||||||
|
#expect(delivered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Inbound Schnorr signature verification (and the forged-copy
|
||||||
|
// dedup-poisoning invariant) is enforced once, off the main actor, at the
|
||||||
|
// relay boundary — see NostrRelayManagerTests
|
||||||
|
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and
|
||||||
|
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
|
||||||
|
// The inbound pipeline only ever sees verified events.
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func processNostrMessage_duplicateDeliveryProcessesOnce() async throws {
|
||||||
|
let context = MockChatNostrContext()
|
||||||
|
let coordinator = ChatNostrCoordinator(context: context)
|
||||||
|
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
context.nostrIdentity = recipient
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
content: "verify:noop",
|
content: "verify:noop",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
var invalidGiftWrap = giftWrap
|
|
||||||
invalidGiftWrap.sig = String(repeating: "0", count: 128)
|
|
||||||
|
|
||||||
// A forged-signature copy is rejected WITHOUT entering the dedup set...
|
// Fan-in of the same (already verified) gift wrap from several relays
|
||||||
await coordinator.inbound.processNostrMessage(invalidGiftWrap)
|
// records and processes exactly once.
|
||||||
#expect(context.recordedNostrEventIDs.isEmpty)
|
await coordinator.inbound.processNostrMessage(giftWrap)
|
||||||
|
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||||
|
|
||||||
// ...so the genuine event with the same ID still processes and records.
|
|
||||||
await coordinator.inbound.processNostrMessage(giftWrap)
|
await coordinator.inbound.processNostrMessage(giftWrap)
|
||||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,31 +352,11 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
|
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
// NOTE: Tampered-signature rejection is enforced once, off the main
|
||||||
func handleNostrEvent_rejectsInvalidSignature() async throws {
|
// actor, at the relay boundary (events only reach the inbound pipeline
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
// after verification) — see NostrRelayManagerTests
|
||||||
let geohash = "u4pruydq"
|
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and
|
||||||
let identity = try NostrIdentity.generate()
|
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
|
||||||
|
|
||||||
let event = NostrEvent(
|
|
||||||
pubkey: identity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: [["g", geohash]],
|
|
||||||
content: "Valid"
|
|
||||||
)
|
|
||||||
var signed = try event.sign(with: identity.schnorrSigningKey())
|
|
||||||
signed.id = "deadbeef"
|
|
||||||
|
|
||||||
viewModel.handleNostrEvent(signed)
|
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
|
||||||
|
|
||||||
#expect(!viewModel.messages.contains { $0.content == "Tampered" })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
|
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
|
||||||
@@ -565,9 +545,14 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
|
|
||||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
// Gift-wrap decryption runs off the main actor; wait for the ack
|
||||||
|
// (sent even for blocked senders) to know processing finished.
|
||||||
|
let didAck = await TestHelpers.waitUntil(
|
||||||
|
{ viewModel.sentGeoDeliveryAcks.contains(messageID) },
|
||||||
|
timeout: 5.0
|
||||||
|
)
|
||||||
|
#expect(didAck)
|
||||||
#expect(viewModel.privateChats[convKey] == nil)
|
#expect(viewModel.privateChats[convKey] == nil)
|
||||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
|
|||||||
@@ -1042,6 +1042,38 @@ struct ChatViewModelPanicTests {
|
|||||||
#expect(viewModel.unreadPrivateMessages.isEmpty)
|
#expect(viewModel.unreadPrivateMessages.isEmpty)
|
||||||
#expect(viewModel.selectedPrivateChatPeer == nil)
|
#expect(viewModel.selectedPrivateChatPeer == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicClearAllData_resetsLiveGeohashAndNostrState() async throws {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
let geohash = "u4pruy"
|
||||||
|
let channel = GeohashChannel(level: .city, geohash: geohash)
|
||||||
|
let identity = try NostrIdentity.generate()
|
||||||
|
let pubkey = String(repeating: "ab", count: 32)
|
||||||
|
let peerID = PeerID(nostr: pubkey)
|
||||||
|
|
||||||
|
viewModel.activeChannel = .location(channel)
|
||||||
|
viewModel.setGeoChatSubscriptionID("geo-\(geohash)")
|
||||||
|
viewModel.setGeoDmSubscriptionID("geo-dm-\(geohash)")
|
||||||
|
viewModel.addGeoSamplingSub("geo-sample-\(geohash)", forGeohash: geohash)
|
||||||
|
viewModel.cachedGeohashIdentity = (geohash, identity)
|
||||||
|
viewModel.registerNostrKeyMapping(pubkey, for: peerID)
|
||||||
|
viewModel.currentGeohash = geohash
|
||||||
|
viewModel.geoNicknames = [pubkey: "alice"]
|
||||||
|
viewModel.teleportedGeo = [pubkey]
|
||||||
|
|
||||||
|
viewModel.panicClearAllData()
|
||||||
|
|
||||||
|
#expect(viewModel.activeChannel == .mesh)
|
||||||
|
#expect(viewModel.geoSubscriptionID == nil)
|
||||||
|
#expect(viewModel.geoDmSubscriptionID == nil)
|
||||||
|
#expect(viewModel.geoSamplingSubs.isEmpty)
|
||||||
|
#expect(viewModel.cachedGeohashIdentity == nil)
|
||||||
|
#expect(viewModel.nostrKeyMapping.isEmpty)
|
||||||
|
#expect(viewModel.currentGeohash == nil)
|
||||||
|
#expect(viewModel.geoNicknames.isEmpty)
|
||||||
|
#expect(viewModel.teleportedGeo.isEmpty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Service Lifecycle Tests
|
// MARK: - Service Lifecycle Tests
|
||||||
|
|||||||
@@ -21,9 +21,16 @@ struct FragmentationTests {
|
|||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
// Construct a big packet (3KB) from a remote sender (not our own ID)
|
// Construct a big SIGNED public packet (3KB) from a remote sender. Public
|
||||||
|
// messages must carry a valid signature, so the reassembled packet is
|
||||||
|
// signed and the sender's signing key is preseeded into the registry.
|
||||||
|
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let signingKey = signer.getSigningPublicKeyData()
|
||||||
let remoteShortID = PeerID(str: "1122334455667788")
|
let remoteShortID = PeerID(str: "1122334455667788")
|
||||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
|
let original = try #require(
|
||||||
|
signer.signPacket(makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)),
|
||||||
|
"Failed to sign public packet"
|
||||||
|
)
|
||||||
|
|
||||||
// Use a small fragment size to ensure multiple pieces
|
// Use a small fragment size to ensure multiple pieces
|
||||||
let fragments = fragmentPacket(original, fragmentSize: 400)
|
let fragments = fragmentPacket(original, fragmentSize: 400)
|
||||||
@@ -36,7 +43,7 @@ struct FragmentationTests {
|
|||||||
if i > 0 {
|
if i > 0 {
|
||||||
try await Task.sleep(for: .milliseconds(5))
|
try await Task.sleep(for: .milliseconds(5))
|
||||||
}
|
}
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for delegate callback with proper timeout
|
// Wait for delegate callback with proper timeout
|
||||||
@@ -52,8 +59,13 @@ struct FragmentationTests {
|
|||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
|
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let signingKey = signer.getSigningPublicKeyData()
|
||||||
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
|
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
|
||||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
|
let original = try #require(
|
||||||
|
signer.signPacket(makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)),
|
||||||
|
"Failed to sign public packet"
|
||||||
|
)
|
||||||
var frags = fragmentPacket(original, fragmentSize: 300)
|
var frags = fragmentPacket(original, fragmentSize: 300)
|
||||||
|
|
||||||
// Duplicate one fragment
|
// Duplicate one fragment
|
||||||
@@ -66,7 +78,7 @@ struct FragmentationTests {
|
|||||||
if i > 0 {
|
if i > 0 {
|
||||||
try await Task.sleep(for: .milliseconds(5))
|
try await Task.sleep(for: .milliseconds(5))
|
||||||
}
|
}
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for delegate callback with proper timeout
|
// Wait for delegate callback with proper timeout
|
||||||
|
|||||||
@@ -326,26 +326,11 @@ struct ChatViewModelPresenceHandlingTests {
|
|||||||
#expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1)
|
#expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func subscribeNostrEvent_samplingInvalidSignatureDoesNotPoisonDedup() async throws {
|
// NOTE: Tampered-signature rejection (and the forged-copy dedup-poisoning
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
// invariant) is enforced once, off the main actor, at the relay boundary —
|
||||||
let sampleGeohash = "u4pru"
|
// the sampling path only ever sees verified events. See
|
||||||
let identity = try NostrIdentity.generate()
|
// NostrRelayManagerTests
|
||||||
let event = NostrEvent(
|
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache`.
|
||||||
pubkey: identity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .geohashPresence,
|
|
||||||
tags: [["g", sampleGeohash]],
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
|
||||||
var invalid = signed
|
|
||||||
invalid.sig = String(repeating: "0", count: 128)
|
|
||||||
|
|
||||||
viewModel.subscribeNostrEvent(invalid, gh: sampleGeohash)
|
|
||||||
viewModel.subscribeNostrEvent(signed, gh: sampleGeohash)
|
|
||||||
|
|
||||||
#expect(viewModel.geohashParticipantCount(for: sampleGeohash) == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Test Helper
|
// MARK: - Test Helper
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,42 @@ struct NostrProtocolTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func decryptRejectsInvalidSealSignature() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let giftWrap = try NostrProtocol.createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||||
|
content: "forged signature",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
|
||||||
|
expectInvalidEvent {
|
||||||
|
_ = try NostrProtocol.decryptPrivateMessage(
|
||||||
|
giftWrap: giftWrap,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decryptRejectsSealRumorPubkeyMismatch() throws {
|
||||||
|
let claimedSender = try NostrIdentity.generate()
|
||||||
|
let sealSigner = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let giftWrap = try NostrProtocol.createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
||||||
|
content: "spoofed sender",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
rumorIdentity: claimedSender,
|
||||||
|
sealSignerIdentity: sealSigner
|
||||||
|
)
|
||||||
|
|
||||||
|
expectInvalidEvent {
|
||||||
|
_ = try NostrProtocol.decryptPrivateMessage(
|
||||||
|
giftWrap: giftWrap,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testAckRoundTripNIP44V2_Delivered() throws {
|
func testAckRoundTripNIP44V2_Delivered() throws {
|
||||||
// Identities
|
// Identities
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
@@ -260,4 +296,15 @@ struct NostrProtocolTests {
|
|||||||
if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) }
|
if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) }
|
||||||
return Data(base64Encoded: str)
|
return Data(base64Encoded: str)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func expectInvalidEvent(_ operation: () throws -> Void) {
|
||||||
|
do {
|
||||||
|
try operation()
|
||||||
|
Issue.record("Expected NostrError.invalidEvent")
|
||||||
|
} catch NostrError.invalidEvent {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
Issue.record("Expected NostrError.invalidEvent, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,8 +77,10 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
// MARK: - 1a. Nostr inbound event handling (fresh events)
|
// MARK: - 1a. Nostr inbound event handling (fresh events)
|
||||||
|
|
||||||
/// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events
|
/// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events
|
||||||
/// (kind 20000): signature verification, dedup record, presence/nickname
|
/// (kind 20000): dedup record, presence/nickname bookkeeping, and
|
||||||
/// bookkeeping, and public-message ingest scheduling.
|
/// public-message ingest scheduling. Schnorr signature verification is
|
||||||
|
/// NOT part of this path anymore — it runs exactly once, off the main
|
||||||
|
/// actor, in `NostrRelayManager` before delivery.
|
||||||
func testNostrInboundEventHandling_freshEvents() throws {
|
func testNostrInboundEventHandling_freshEvents() throws {
|
||||||
let events = try Self.makeSignedGeohashEvents(count: 500)
|
let events = try Self.makeSignedGeohashEvents(count: 500)
|
||||||
// A fresh context per measure pass so every event takes the
|
// A fresh context per measure pass so every event takes the
|
||||||
@@ -106,8 +108,9 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
|
|
||||||
/// The dedup-hit path: identical events replayed. Duplicates dominate
|
/// The dedup-hit path: identical events replayed. Duplicates dominate
|
||||||
/// real relay traffic (the same event arrives from several relays), so
|
/// real relay traffic (the same event arrives from several relays), so
|
||||||
/// this path runs hundreds of times a minute in busy geohashes. Note it
|
/// this path runs hundreds of times a minute in busy geohashes. It is a
|
||||||
/// still pays full Schnorr signature verification before the dedup check.
|
/// pure dedup lookup: no crypto (verification happens upstream in
|
||||||
|
/// `NostrRelayManager`, and only for the first-seen copy).
|
||||||
func testNostrInboundEventHandling_duplicateEvents() throws {
|
func testNostrInboundEventHandling_duplicateEvents() throws {
|
||||||
let events = try Self.makeSignedGeohashEvents(count: 500)
|
let events = try Self.makeSignedGeohashEvents(count: 500)
|
||||||
let context = PerfNostrContext()
|
let context = PerfNostrContext()
|
||||||
|
|||||||
@@ -173,7 +173,10 @@ struct BLEAnnounceHandlerTests {
|
|||||||
#expect(recorder.uiEventDeliveries.count == 1)
|
#expect(recorder.uiEventDeliveries.count == 1)
|
||||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||||
#expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false)
|
#expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false)
|
||||||
#expect(recorder.persistedIdentities.count == 1)
|
// Identity persistence MUST NOT occur for unverified announces:
|
||||||
|
// persisting would let an attacker who replays a victim's noisePublicKey
|
||||||
|
// overwrite the victim's stored signing key/nickname (identity poisoning).
|
||||||
|
#expect(recorder.persistedIdentities.isEmpty)
|
||||||
#expect(recorder.trackedPackets.count == 1)
|
#expect(recorder.trackedPackets.count == 1)
|
||||||
#expect(recorder.announceBacks == 1)
|
#expect(recorder.announceBacks == 1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ struct BLEPublicMessageHandlerTests {
|
|||||||
var localNickname = "Me"
|
var localNickname = "Me"
|
||||||
var peers: [PeerID: BLEPeerInfo] = [:]
|
var peers: [PeerID: BLEPeerInfo] = [:]
|
||||||
var signedName: String?
|
var signedName: String?
|
||||||
|
var verifyPacketSignatureResult = false
|
||||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||||
var selfBroadcastMessageID: String?
|
var selfBroadcastMessageID: String?
|
||||||
|
|
||||||
var peersSnapshotReads = 0
|
var peersSnapshotReads = 0
|
||||||
|
var verifyPacketSignatureQueries: [PeerID] = []
|
||||||
var signedNameQueries: [PeerID] = []
|
var signedNameQueries: [PeerID] = []
|
||||||
var trackedPackets: [BitchatPacket] = []
|
var trackedPackets: [BitchatPacket] = []
|
||||||
var selfBroadcastTakes: [BitchatPacket] = []
|
var selfBroadcastTakes: [BitchatPacket] = []
|
||||||
@@ -35,6 +37,10 @@ struct BLEPublicMessageHandlerTests {
|
|||||||
recorder.peersSnapshotReads += 1
|
recorder.peersSnapshotReads += 1
|
||||||
return recorder.peers
|
return recorder.peers
|
||||||
},
|
},
|
||||||
|
verifyPacketSignature: { packet, _ in
|
||||||
|
recorder.verifyPacketSignatureQueries.append(PeerID(hexData: packet.senderID))
|
||||||
|
return recorder.verifyPacketSignatureResult
|
||||||
|
},
|
||||||
signedSenderDisplayName: { _, peerID in
|
signedSenderDisplayName: { _, peerID in
|
||||||
recorder.signedNameQueries.append(peerID)
|
recorder.signedNameQueries.append(peerID)
|
||||||
return recorder.signedName
|
return recorder.signedName
|
||||||
@@ -59,13 +65,17 @@ struct BLEPublicMessageHandlerTests {
|
|||||||
let now = Date(timeIntervalSince1970: 1_000)
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
|
// A valid packet signature is required even for a registry-verified peer:
|
||||||
|
// senderID is spoofable, so registry membership alone is not authentication.
|
||||||
|
recorder.signedName = "SignedAlice"
|
||||||
let handler = makeHandler(recorder: recorder, now: now)
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
let packet = makeMessagePacket(sender: remotePeerID, content: "hello mesh", timestamp: timestamp(now))
|
let packet = makeMessagePacket(sender: remotePeerID, content: "hello mesh", timestamp: timestamp(now))
|
||||||
|
|
||||||
handler.handle(packet, from: remotePeerID)
|
handler.handle(packet, from: remotePeerID)
|
||||||
|
|
||||||
#expect(recorder.peersSnapshotReads == 1)
|
#expect(recorder.peersSnapshotReads == 1)
|
||||||
#expect(recorder.signedNameQueries.isEmpty)
|
// Signature is verified, then the registry's collision-resolved name is preferred.
|
||||||
|
#expect(recorder.signedNameQueries == [remotePeerID])
|
||||||
#expect(recorder.trackedPackets.count == 1)
|
#expect(recorder.trackedPackets.count == 1)
|
||||||
#expect(recorder.selfBroadcastTakes.isEmpty)
|
#expect(recorder.selfBroadcastTakes.isEmpty)
|
||||||
#expect(recorder.deliveries.count == 1)
|
#expect(recorder.deliveries.count == 1)
|
||||||
@@ -133,6 +143,63 @@ struct BLEPublicMessageHandlerTests {
|
|||||||
#expect(recorder.deliveries.isEmpty)
|
#expect(recorder.deliveries.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func registryVerifiedPeerDeliveredBeforeIdentityCachePersists() {
|
||||||
|
// A freshly verified announce updates the peer registry synchronously,
|
||||||
|
// but identity-cache persistence is async. A message arriving in that
|
||||||
|
// window has a valid signature and a registry signing key, yet the
|
||||||
|
// persisted-identity lookup (signedName) would still return nil. It must
|
||||||
|
// be verified against the registry key and delivered, not dropped.
|
||||||
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true,
|
||||||
|
signingPublicKey: Data(repeating: 0xAB, count: 32)
|
||||||
|
)]
|
||||||
|
recorder.verifyPacketSignatureResult = true
|
||||||
|
recorder.signedName = nil
|
||||||
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
|
let packet = makeMessagePacket(sender: remotePeerID, content: "first msg", timestamp: timestamp(now))
|
||||||
|
|
||||||
|
handler.handle(packet, from: remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.verifyPacketSignatureQueries == [remotePeerID])
|
||||||
|
// Verified via the registry key, so no fallback to the persisted lookup.
|
||||||
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
|
#expect(recorder.trackedPackets.count == 1)
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.nickname == "Alice")
|
||||||
|
#expect(recorder.deliveries.first?.content == "first msg")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func registryPeerWithInvalidSignatureFallsBackAndDrops() {
|
||||||
|
// Spoofed senderID: the peer is in the registry with a signing key, but
|
||||||
|
// the packet signature does not verify against it. The handler must fall
|
||||||
|
// back to the persisted lookup and, finding nothing, drop the message.
|
||||||
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true,
|
||||||
|
signingPublicKey: Data(repeating: 0xAB, count: 32)
|
||||||
|
)]
|
||||||
|
recorder.verifyPacketSignatureResult = false
|
||||||
|
recorder.signedName = nil
|
||||||
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
|
let packet = makeMessagePacket(sender: remotePeerID, content: "spoofed", timestamp: timestamp(now))
|
||||||
|
|
||||||
|
handler.handle(packet, from: remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.verifyPacketSignatureQueries == [remotePeerID])
|
||||||
|
#expect(recorder.signedNameQueries == [remotePeerID])
|
||||||
|
#expect(recorder.trackedPackets.isEmpty)
|
||||||
|
#expect(recorder.deliveries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func signedSenderFallbackDeliversWithSignedName() {
|
func signedSenderFallbackDeliversWithSignedName() {
|
||||||
let now = Date(timeIntervalSince1970: 1_000)
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
@@ -154,6 +221,7 @@ struct BLEPublicMessageHandlerTests {
|
|||||||
let now = Date(timeIntervalSince1970: 1_000)
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
|
recorder.signedName = "SignedAlice"
|
||||||
let handler = makeHandler(recorder: recorder, now: now)
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
let packet = makeMessagePacket(sender: remotePeerID, payload: Data([0xFF, 0xFE, 0xFD]), timestamp: timestamp(now))
|
let packet = makeMessagePacket(sender: remotePeerID, payload: Data([0xFF, 0xFE, 0xFD]), timestamp: timestamp(now))
|
||||||
|
|
||||||
@@ -187,6 +255,7 @@ struct BLEPublicMessageHandlerTests {
|
|||||||
let now = Date(timeIntervalSince1970: 1_000)
|
let now = Date(timeIntervalSince1970: 1_000)
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
|
recorder.signedName = "SignedAlice"
|
||||||
let handler = makeHandler(recorder: recorder, now: now)
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
let packet = makeMessagePacket(
|
let packet = makeMessagePacket(
|
||||||
sender: remotePeerID,
|
sender: remotePeerID,
|
||||||
@@ -213,14 +282,15 @@ struct BLEPublicMessageHandlerTests {
|
|||||||
_ peerID: PeerID,
|
_ peerID: PeerID,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
isVerified: Bool,
|
isVerified: Bool,
|
||||||
isConnected: Bool = true
|
isConnected: Bool = true,
|
||||||
|
signingPublicKey: Data? = nil
|
||||||
) -> BLEPeerInfo {
|
) -> BLEPeerInfo {
|
||||||
BLEPeerInfo(
|
BLEPeerInfo(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
isConnected: isConnected,
|
isConnected: isConnected,
|
||||||
noisePublicKey: nil,
|
noisePublicKey: nil,
|
||||||
signingPublicKey: nil,
|
signingPublicKey: signingPublicKey,
|
||||||
isVerifiedNickname: isVerified,
|
isVerifiedNickname: isVerified,
|
||||||
lastSeen: Date(timeIntervalSince1970: 999)
|
lastSeen: Date(timeIntervalSince1970: 999)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ struct BLEReceivePipelineTests {
|
|||||||
let context = BLEReceivePipeline.context(for: packet, localPeerID: local)
|
let context = BLEReceivePipeline.context(for: packet, localPeerID: local)
|
||||||
|
|
||||||
#expect(context.senderID == sender)
|
#expect(context.senderID == sender)
|
||||||
#expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)")
|
// The message ID includes a payload digest so distinct packets sharing a
|
||||||
|
// sender/timestamp(ms)/type are not collapsed as duplicates.
|
||||||
|
let digest = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
||||||
|
#expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)-\(digest)")
|
||||||
#expect(context.messageType == .message)
|
#expect(context.messageType == .message)
|
||||||
#expect(context.shouldDeduplicate)
|
#expect(context.shouldDeduplicate)
|
||||||
#expect(context.logsHandlingDetails)
|
#expect(context.logsHandlingDetails)
|
||||||
|
|||||||
@@ -639,11 +639,15 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||||
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||||
|
|
||||||
let countedOnBothRelays = await waitUntil {
|
// Wait on the DELIVERY-side state: handler dispatch and the duplicate
|
||||||
|
// drop both land on the second main hop, after off-main verification.
|
||||||
|
let settled = await waitUntil {
|
||||||
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
|
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
|
||||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
|
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
|
||||||
|
receivedIDs == [event.id] &&
|
||||||
|
context.manager.debugDuplicateInboundEventDropCount == 1
|
||||||
}
|
}
|
||||||
XCTAssertTrue(countedOnBothRelays)
|
XCTAssertTrue(settled)
|
||||||
XCTAssertEqual(receivedIDs, [event.id])
|
XCTAssertEqual(receivedIDs, [event.id])
|
||||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1)
|
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1)
|
||||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 1)
|
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 1)
|
||||||
@@ -676,12 +680,17 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let countedOnEveryRelay = await waitUntil {
|
// Wait on the DELIVERY-side state: the winner's handler dispatch and
|
||||||
|
// the losers' duplicate drops both land on the second main hop, after
|
||||||
|
// off-main verification — messagesReceived (first hop) settles sooner.
|
||||||
|
let settled = await waitUntil {
|
||||||
relayURLs.allSatisfy { relayURL in
|
relayURLs.allSatisfy { relayURL in
|
||||||
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
|
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
|
||||||
}
|
} &&
|
||||||
|
receivedIDs == [event.id] &&
|
||||||
|
context.manager.debugDuplicateInboundEventDropCount == relayURLs.count - 1
|
||||||
}
|
}
|
||||||
XCTAssertTrue(countedOnEveryRelay)
|
XCTAssertTrue(settled)
|
||||||
XCTAssertEqual(receivedIDs, [event.id])
|
XCTAssertEqual(receivedIDs, [event.id])
|
||||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1)
|
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
@@ -714,16 +723,153 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: invalidEvent)
|
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: invalidEvent)
|
||||||
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||||
|
|
||||||
let countedOnBothRelays = await waitUntil {
|
// Wait on the DELIVERY-side state (second main hop, after off-main
|
||||||
|
// verification), not just messagesReceived (first main hop) — the
|
||||||
|
// handler only fires after verify + a second hop.
|
||||||
|
let genuineDelivered = await waitUntil {
|
||||||
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
|
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
|
||||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
|
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
|
||||||
|
receivedIDs == [event.id]
|
||||||
}
|
}
|
||||||
XCTAssertTrue(countedOnBothRelays)
|
XCTAssertTrue(genuineDelivered)
|
||||||
XCTAssertEqual(receivedIDs, [event.id])
|
XCTAssertEqual(receivedIDs, [event.id])
|
||||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
|
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
|
||||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 0)
|
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The relay boundary is the single signature-verification point for the
|
||||||
|
/// whole inbound path (downstream pipelines no longer re-verify), so a
|
||||||
|
/// tampered gift wrap (kind 1059, the DM/mailbox path) must be dropped
|
||||||
|
/// here — and must not poison the dedup cache against the genuine copy.
|
||||||
|
func test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup() async throws {
|
||||||
|
let firstRelayURL = "wss://giftwrap-one.example"
|
||||||
|
let secondRelayURL = "wss://giftwrap-two.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
|
content: "psst",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
let tampered = invalidSignatureCopy(of: giftWrap)
|
||||||
|
var receivedIDs: [String] = []
|
||||||
|
|
||||||
|
context.manager.subscribe(
|
||||||
|
filter: makeFilter(),
|
||||||
|
id: "gift-wraps",
|
||||||
|
relayUrls: [firstRelayURL, secondRelayURL]
|
||||||
|
) { event in
|
||||||
|
receivedIDs.append(event.id)
|
||||||
|
}
|
||||||
|
let subscriptionsSent = await waitUntil {
|
||||||
|
context.sessionFactory.latestConnection(for: firstRelayURL)?.sentStrings.count == 1 &&
|
||||||
|
context.sessionFactory.latestConnection(for: secondRelayURL)?.sentStrings.count == 1
|
||||||
|
}
|
||||||
|
XCTAssertTrue(subscriptionsSent)
|
||||||
|
|
||||||
|
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "gift-wraps", event: tampered)
|
||||||
|
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "gift-wraps", event: giftWrap)
|
||||||
|
|
||||||
|
// Wait on the DELIVERY-side state (second main hop, after off-main
|
||||||
|
// verification), not just messagesReceived (first main hop) — the
|
||||||
|
// handler only fires after verify + a second hop.
|
||||||
|
let genuineDelivered = await waitUntil {
|
||||||
|
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
|
||||||
|
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
|
||||||
|
receivedIDs == [giftWrap.id]
|
||||||
|
}
|
||||||
|
XCTAssertTrue(genuineDelivered)
|
||||||
|
XCTAssertEqual(receivedIDs, [giftWrap.id])
|
||||||
|
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signature verification runs off-main in a per-relay serial consumer;
|
||||||
|
/// several frames buffered on one socket must still be delivered to the
|
||||||
|
/// handler in that relay's arrival order.
|
||||||
|
func test_receiveEvent_deliversBackToBackEventsInArrivalOrder() async throws {
|
||||||
|
let relayURL = "wss://ordered.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
let events = try (0..<12).map { try makeSignedEvent(content: "ordered-\($0)") }
|
||||||
|
var receivedIDs: [String] = []
|
||||||
|
|
||||||
|
context.manager.subscribe(filter: makeFilter(), id: "ordered", relayUrls: [relayURL]) { event in
|
||||||
|
receivedIDs.append(event.id)
|
||||||
|
}
|
||||||
|
let subscriptionSent = await waitUntil {
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||||
|
}
|
||||||
|
XCTAssertTrue(subscriptionSent)
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "ordered", event: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
let allDelivered = await waitUntil(timeout: 5.0) {
|
||||||
|
receivedIDs.count == events.count
|
||||||
|
}
|
||||||
|
XCTAssertTrue(allDelivered)
|
||||||
|
XCTAssertEqual(receivedIDs, events.map(\.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each relay owns its own off-main verify pipeline, so a large backlog of
|
||||||
|
/// EVENT frames on one relay must NOT head-of-line-block a frame that
|
||||||
|
/// arrives on a different relay. Under the previous single global consumer,
|
||||||
|
/// relay B's single event (emitted after relay A's whole burst) could only
|
||||||
|
/// be delivered once every frame in A's backlog had been Schnorr-verified;
|
||||||
|
/// with per-relay pipelines B verifies concurrently and lands before A's
|
||||||
|
/// backlog drains.
|
||||||
|
func test_receiveEvent_busyRelayDoesNotBlockOtherRelayDelivery() async throws {
|
||||||
|
let busyRelayURL = "wss://busy-relay.example"
|
||||||
|
let quietRelayURL = "wss://quiet-relay.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
|
||||||
|
// Distinct subscriptions per relay so dedup never coalesces A vs. B.
|
||||||
|
let busyEvents = try (0..<200).map { try makeSignedEvent(content: "busy-\($0)") }
|
||||||
|
let quietEvent = try makeSignedEvent(content: "quiet")
|
||||||
|
|
||||||
|
var busyDeliveredCount = 0
|
||||||
|
var quietDeliveredAfterBusyCount = -1 // busy-count observed when B lands
|
||||||
|
|
||||||
|
context.manager.subscribe(filter: makeFilter(), id: "busy", relayUrls: [busyRelayURL]) { _ in
|
||||||
|
busyDeliveredCount += 1
|
||||||
|
}
|
||||||
|
context.manager.subscribe(filter: makeFilter(), id: "quiet", relayUrls: [quietRelayURL]) { _ in
|
||||||
|
if quietDeliveredAfterBusyCount < 0 {
|
||||||
|
quietDeliveredAfterBusyCount = busyDeliveredCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let subscribed = await waitUntil {
|
||||||
|
context.sessionFactory.latestConnection(for: busyRelayURL)?.sentStrings.count == 1 &&
|
||||||
|
context.sessionFactory.latestConnection(for: quietRelayURL)?.sentStrings.count == 1
|
||||||
|
}
|
||||||
|
XCTAssertTrue(subscribed)
|
||||||
|
|
||||||
|
// Flood relay A first, then emit a single frame on relay B.
|
||||||
|
for event in busyEvents {
|
||||||
|
try context.sessionFactory.latestConnection(for: busyRelayURL)?.emitEventMessage(subscriptionID: "busy", event: event)
|
||||||
|
}
|
||||||
|
try context.sessionFactory.latestConnection(for: quietRelayURL)?.emitEventMessage(subscriptionID: "quiet", event: quietEvent)
|
||||||
|
|
||||||
|
let quietDelivered = await waitUntil(timeout: 5.0) { quietDeliveredAfterBusyCount >= 0 }
|
||||||
|
XCTAssertTrue(quietDelivered, "relay B's event was never delivered")
|
||||||
|
|
||||||
|
// The signal: B did not have to wait for A's entire backlog. If the two
|
||||||
|
// pipelines were globally serialized, B could only land after all 200 of
|
||||||
|
// A's frames, so busyDeliveredCount would be 200 when B arrived.
|
||||||
|
XCTAssertLessThan(
|
||||||
|
quietDeliveredAfterBusyCount,
|
||||||
|
busyEvents.count,
|
||||||
|
"relay B was head-of-line blocked behind relay A's backlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Both relays still drain fully and in order.
|
||||||
|
let allDelivered = await waitUntil(timeout: 5.0) {
|
||||||
|
busyDeliveredCount == busyEvents.count
|
||||||
|
}
|
||||||
|
XCTAssertTrue(allDelivered)
|
||||||
|
}
|
||||||
|
|
||||||
func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws {
|
func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws {
|
||||||
let relayURL = "wss://missing-handler.example"
|
let relayURL = "wss://missing-handler.example"
|
||||||
let context = makeContext(permission: .denied)
|
let context = makeContext(permission: .denied)
|
||||||
@@ -1328,6 +1474,68 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
|
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func test_resetForPanicWipe_dropsSessionRelayStateWithoutFiringCallbacks() async throws {
|
||||||
|
let relayURL = "wss://panic-reset.example"
|
||||||
|
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||||
|
let event = try makeSignedEvent(content: "queued before panic")
|
||||||
|
var handledEvents = 0
|
||||||
|
var eoseCount = 0
|
||||||
|
|
||||||
|
context.manager.subscribe(
|
||||||
|
filter: makeFilter(),
|
||||||
|
id: "panic-sub",
|
||||||
|
relayUrls: [relayURL],
|
||||||
|
handler: { _ in handledEvents += 1 },
|
||||||
|
onEOSE: { eoseCount += 1 }
|
||||||
|
)
|
||||||
|
context.manager.sendEvent(event, to: [relayURL])
|
||||||
|
|
||||||
|
XCTAssertEqual(context.manager.debugMessageHandlerCount, 1)
|
||||||
|
XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 1)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 1)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1)
|
||||||
|
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||||
|
|
||||||
|
context.manager.resetForPanicWipe()
|
||||||
|
|
||||||
|
XCTAssertEqual(context.manager.debugMessageHandlerCount, 0)
|
||||||
|
XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 0)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 0)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
|
||||||
|
XCTAssertEqual(handledEvents, 0)
|
||||||
|
XCTAssertEqual(eoseCount, 0)
|
||||||
|
|
||||||
|
// Stale Tor wait and fallback callbacks from the pre-wipe generation
|
||||||
|
// must not resurrect connections or settle callbacks after reset.
|
||||||
|
context.torWaiter.resolve(true)
|
||||||
|
context.scheduler.runNext()
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
|
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||||
|
XCTAssertEqual(eoseCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_resetForPanicWipe_marksConnectedRelaysDisconnected() async {
|
||||||
|
let relayURL = "wss://panic-connected.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
|
||||||
|
context.manager.ensureConnections(to: [relayURL])
|
||||||
|
let connected = await waitUntil {
|
||||||
|
context.manager.isConnected &&
|
||||||
|
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||||
|
}
|
||||||
|
XCTAssertTrue(connected)
|
||||||
|
|
||||||
|
context.manager.resetForPanicWipe()
|
||||||
|
|
||||||
|
XCTAssertFalse(context.manager.isConnected)
|
||||||
|
XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.isConnected, false)
|
||||||
|
XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts, 0)
|
||||||
|
XCTAssertNil(context.manager.relays.first(where: { $0.url == relayURL })?.lastError)
|
||||||
|
}
|
||||||
|
|
||||||
func test_reconnectBackoff_appliesJitterWithinConfiguredBounds() async {
|
func test_reconnectBackoff_appliesJitterWithinConfiguredBounds() async {
|
||||||
let relayURL = "wss://jitter-bounds.example"
|
let relayURL = "wss://jitter-bounds.example"
|
||||||
// Pin the jitter source to the extremes and the midpoint of [0, 1).
|
// Pin the jitter source to the extremes and the midpoint of [0, 1).
|
||||||
@@ -1633,7 +1841,11 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||||
receiveHandler = completionHandler
|
if !pendingResults.isEmpty {
|
||||||
|
completionHandler(pendingResults.removeFirst())
|
||||||
|
} else {
|
||||||
|
receiveHandler = completionHandler
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
|
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
|
||||||
@@ -1666,15 +1878,24 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func emitRawString(_ string: String) throws {
|
func emitRawString(_ string: String) throws {
|
||||||
let handler = receiveHandler
|
deliver(.success(.string(string)))
|
||||||
receiveHandler = nil
|
|
||||||
handler?(.success(.string(string)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func emit(jsonObject: Any) throws {
|
private func emit(jsonObject: Any) throws {
|
||||||
let data = try JSONSerialization.data(withJSONObject: jsonObject)
|
let data = try JSONSerialization.data(withJSONObject: jsonObject)
|
||||||
let handler = receiveHandler
|
deliver(.success(.data(data)))
|
||||||
receiveHandler = nil
|
}
|
||||||
handler?(.success(.data(data)))
|
|
||||||
|
// Frames emitted before the manager re-arms `receive` are queued so
|
||||||
|
// back-to-back emissions model a socket with several buffered frames.
|
||||||
|
private var pendingResults: [Result<URLSessionWebSocketTask.Message, Error>] = []
|
||||||
|
|
||||||
|
private func deliver(_ result: Result<URLSessionWebSocketTask.Message, Error>) {
|
||||||
|
if let handler = receiveHandler {
|
||||||
|
receiveHandler = nil
|
||||||
|
handler(result)
|
||||||
|
} else {
|
||||||
|
pendingResults.append(result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+401
-426
@@ -1,441 +1,416 @@
|
|||||||
Relay URL,Latitude,Longitude
|
Relay URL,Latitude,Longitude
|
||||||
nostr.bitcoiner.social:443,47.6743,-117.112
|
|
||||||
relay-dev.satlantis.io:443,40.8302,-74.1299
|
|
||||||
relay.lightning.pub:443,39.0438,-77.4874
|
|
||||||
ribo.us.nostria.app:443,43.6532,-79.3832
|
|
||||||
relay.notoshi.win,13.3622,100.983
|
|
||||||
openrelay.ziomc.com,50.0755,14.4378
|
|
||||||
relay.agentry.com:443,42.8864,-78.8784
|
|
||||||
nostr-relay.xbytez.io,50.6924,3.20113
|
|
||||||
relay.gulugulu.moe,43.6532,-79.3832
|
|
||||||
nostr.thebiglake.org:443,32.71,-96.6745
|
|
||||||
relay.fountain.fm,43.6532,-79.3832
|
|
||||||
freelay.sovbit.host,60.1699,24.9384
|
|
||||||
nostr.girino.org:443,43.6532,-79.3832
|
|
||||||
relay.openresist.com,43.6532,-79.3832
|
|
||||||
nas01xanthosnet.synology.me:7778,47.1285,8.74735
|
|
||||||
relay.ohstr.com:443,43.6532,-79.3832
|
|
||||||
nostr-pub.wellorder.net,45.5201,-122.99
|
|
||||||
relay.nostrdice.com,-33.8688,151.209
|
|
||||||
bbw-nostr.xyz,41.5284,-87.4237
|
|
||||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
|
||||||
top.testrelay.top,43.6532,-79.3832
|
|
||||||
relay.trotters.cc,43.6532,-79.3832
|
|
||||||
blossom.gnostr.cloud,43.6532,-79.3832
|
|
||||||
ribo.eu.nostria.app:443,43.6532,-79.3832
|
|
||||||
public.crostr.com,43.6532,-79.3832
|
|
||||||
relay.nostar.org,43.6532,-79.3832
|
|
||||||
strfry.bonsai.com:443,39.0438,-77.4874
|
|
||||||
nostr.carroarmato0.be,50.914,3.21378
|
|
||||||
relay.endfiat.money,59.3327,18.0656
|
|
||||||
nostr.overmind.lol:443,43.6532,-79.3832
|
|
||||||
nostr.myshosholoza.co.za:443,52.3913,4.66545
|
|
||||||
relay.wellorder.net,45.5201,-122.99
|
|
||||||
nostr.tagomago.me,42.3601,-71.0589
|
|
||||||
relay.internationalright-wing.org,-22.5022,-48.7114
|
|
||||||
relay.fckstate.net,59.3293,18.0686
|
|
||||||
nostr.azzamo.net,52.2633,21.0283
|
|
||||||
relay.0xchat.com,43.6532,-79.3832
|
|
||||||
relayone.geektank.ai,39.1008,-94.5811
|
|
||||||
relay.homeinhk.xyz,35.694,139.754
|
|
||||||
nostr.nodesmap.com,59.3327,18.0656
|
|
||||||
relay.islandbitcoin.com:443,12.8498,77.6545
|
|
||||||
relay.mrmave.work,43.6532,-79.3832
|
|
||||||
relay.arx-ccn.com,50.4754,12.3683
|
|
||||||
fanfares.nostr1.com:443,40.7057,-74.0136
|
|
||||||
wot.shaving.kiwi,43.6532,-79.3832
|
|
||||||
relay.nearhood.co.uk,51.5072,-0.127586
|
|
||||||
relay.fundstr.me,42.3601,-71.0589
|
|
||||||
syb.lol:443,43.6532,-79.3832
|
|
||||||
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
|
||||||
relay.nostx.io,43.6532,-79.3832
|
|
||||||
no.str.cr,10.6352,-85.4378
|
|
||||||
relay.jbnco.co,43.6532,-79.3832
|
|
||||||
ribo.nostria.app:443,43.6532,-79.3832
|
|
||||||
us-east.nostr.pikachat.org,39.0438,-77.4874
|
|
||||||
nexus.libernet.app,43.6532,-79.3832
|
|
||||||
nostr.dlcdevkit.com,40.0992,-83.1141
|
|
||||||
nostr.debate.report,50.1109,8.68213
|
|
||||||
str-define-contributing-jackets.trycloudflare.com,43.6532,-79.3832
|
|
||||||
nostr2.girino.org,43.6532,-79.3832
|
|
||||||
nostr.unkn0wn.world,46.8499,9.53287
|
|
||||||
nostr.spicyz.io:443,43.6532,-79.3832
|
|
||||||
relay.layer.systems:443,49.0291,8.35695
|
|
||||||
nostr-relay.amethyst.name,39.0067,-77.4291
|
|
||||||
slick.mjex.me,39.0418,-77.4744
|
|
||||||
nostr.girino.org,43.6532,-79.3832
|
|
||||||
nostr.quali.chat:443,60.1699,24.9384
|
|
||||||
purplerelay.com:443,43.6532,-79.3832
|
|
||||||
nostr.notribe.net,40.8302,-74.1299
|
|
||||||
relay.plebeian.market:443,50.1109,8.68213
|
|
||||||
relay.samt.st,40.8302,-74.1299
|
|
||||||
relay.mitchelltribe.com:443,39.0438,-77.4874
|
|
||||||
nostr.0x7e.xyz,47.4949,8.71954
|
|
||||||
relayone.soundhsa.com:443,39.1008,-94.5811
|
|
||||||
nostr.thalheim.io:443,60.1699,24.9384
|
|
||||||
relay.getsafebox.app:443,43.6532,-79.3832
|
|
||||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
|
||||||
bucket.coracle.social,37.7775,-122.397
|
|
||||||
nostr.carroarmato0.be:443,50.914,3.21378
|
|
||||||
relay.staging.commonshub.brussels,49.4543,11.0746
|
|
||||||
relay-dev.satlantis.io,40.8302,-74.1299
|
|
||||||
relay.lacompagniemaximus.com:443,45.3147,-73.8785
|
|
||||||
relay-rpi.edufeed.org,49.4521,11.0767
|
|
||||||
nostr.computingcache.com:443,34.0356,-118.442
|
|
||||||
relay.lanavault.space:443,60.1699,24.9384
|
|
||||||
relay.getsafebox.app,43.6532,-79.3832
|
|
||||||
nrs-02.darkcloudarcade.com:443,39.9526,-75.1652
|
|
||||||
nostr.hekster.org,37.3986,-121.964
|
|
||||||
node.kommonzenze.de,49.4521,11.0767
|
|
||||||
0x-nostr-relay.fly.dev,37.7648,-122.432
|
|
||||||
speakeasy.cellar.social,49.4543,11.0746
|
|
||||||
nostr.0x7e.xyz:443,47.4949,8.71954
|
|
||||||
nostr.vulpem.com,49.4543,11.0746
|
|
||||||
relay5.bitransfer.org,43.6532,-79.3832
|
|
||||||
relay.inforsupports.com,43.6532,-79.3832
|
|
||||||
relay.plebeian.market,50.1109,8.68213
|
|
||||||
shu02.shugur.net,21.4902,39.2246
|
|
||||||
nostr.easycryptosend.it,43.6532,-79.3832
|
|
||||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
|
||||||
relay.minibolt.info,43.6532,-79.3832
|
|
||||||
nostr.pbfs.io:443,50.4754,12.3683
|
|
||||||
nos.lol:443,50.4754,12.3683
|
|
||||||
nostr.thebiglake.org,32.71,-96.6745
|
|
||||||
relay.nostriot.com,41.5695,-83.9786
|
|
||||||
strfry.shock.network:443,39.0438,-77.4874
|
|
||||||
relay01.lnfi.network,35.6764,139.65
|
|
||||||
r.0kb.io:443,32.789,-96.7989
|
|
||||||
bcast.girino.org,43.6532,-79.3832
|
|
||||||
nostr-relay.psfoundation.info:443,39.0438,-77.4874
|
|
||||||
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
|
||||||
testnet.samt.st,43.6532,-79.3832
|
|
||||||
relay.bornheimer.app,51.5072,-0.127586
|
|
||||||
nrs-02.darkcloudarcade.com,39.9526,-75.1652
|
|
||||||
relay.binaryrobot.com:443,43.6532,-79.3832
|
|
||||||
nostr.computingcache.com,34.0356,-118.442
|
|
||||||
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
|
|
||||||
schnorr.me,43.6532,-79.3832
|
|
||||||
nostr.twinkle.lol,51.902,7.6657
|
|
||||||
nostr.overmind.lol,43.6532,-79.3832
|
|
||||||
relay.mmwaves.de:443,48.8575,2.35138
|
|
||||||
relay2.veganostr.com,60.1699,24.9384
|
|
||||||
nostr.mom,50.4754,12.3683
|
|
||||||
nostr2.girino.org:443,43.6532,-79.3832
|
|
||||||
wot.sudocarlos.com,43.6532,-79.3832
|
|
||||||
dev.relay.stream,43.6532,-79.3832
|
|
||||||
nostr.thalheim.io,60.1699,24.9384
|
|
||||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
|
||||||
conduitl2.fly.dev,37.7648,-122.432
|
|
||||||
relay.bullishbounty.com,43.6532,-79.3832
|
|
||||||
myvoiceourstory.org,37.3598,-121.981
|
|
||||||
relay.angor.io,48.1046,11.6002
|
|
||||||
r.0kb.io,32.789,-96.7989
|
|
||||||
nexus.libernet.app:443,43.6532,-79.3832
|
|
||||||
us-east.nostr.pikachat.org:443,39.0438,-77.4874
|
|
||||||
nostr.bitcoiner.social,47.6743,-117.112
|
|
||||||
nostrelay.circum.space:443,52.6907,4.8181
|
|
||||||
relayone.soundhsa.com,39.1008,-94.5811
|
|
||||||
nostr.snowbla.de,60.1699,24.9384
|
|
||||||
relay1.orangesync.tech,44.7839,-106.941
|
|
||||||
nostr-2.21crypto.ch:443,47.5356,8.73209
|
|
||||||
espelho.girino.org,43.6532,-79.3832
|
|
||||||
nostr.blankfors.se,60.1699,24.9384
|
|
||||||
relay.sigit.io:443,50.4754,12.3683
|
|
||||||
relay.vrtmrz.net:443,43.6532,-79.3832
|
|
||||||
nostr.wecsats.io:443,43.6532,-79.3832
|
|
||||||
nostrcity-club.fly.dev,37.7648,-122.432
|
|
||||||
nostrelay.circum.space,52.6907,4.8181
|
|
||||||
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
|
|
||||||
relay.edufeed.org,49.4521,11.0767
|
|
||||||
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
|
||||||
x.kojira.io,43.6532,-79.3832
|
|
||||||
relay.binaryrobot.com,43.6532,-79.3832
|
|
||||||
relay.nostrhub.fr,48.1045,11.6004
|
|
||||||
nostr.sathoarder.com,48.5734,7.75211
|
|
||||||
prl.plus,55.7628,37.5983
|
|
||||||
relay.cosmicbolt.net:443,37.3986,-121.964
|
|
||||||
relay.nostu.be:443,40.4167,-3.70329
|
|
||||||
cs-relay.nostrdev.com,50.4754,12.3683
|
|
||||||
satsage.xyz,37.3986,-121.964
|
|
||||||
relay.lab.rytswd.com:443,49.4543,11.0746
|
|
||||||
rilo.nostria.app:443,43.6532,-79.3832
|
|
||||||
portal-relay.pareto.space,49.0291,8.35696
|
|
||||||
relay.wavlake.com:443,41.2619,-95.8608
|
|
||||||
relay.beginningend.com,35.2227,-97.4786
|
|
||||||
relay.openresist.com:443,43.6532,-79.3832
|
|
||||||
bitcoiner.social:443,47.6743,-117.112
|
|
||||||
relay.notoshi.win:443,13.3622,100.983
|
|
||||||
dev.relay.edufeed.org:443,49.4521,11.0767
|
|
||||||
relay.cypherflow.ai:443,48.8575,2.35138
|
|
||||||
relay.ru.ac.th,13.7607,100.627
|
|
||||||
shu03.shugur.net,25.2048,55.2708
|
|
||||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
|
||||||
relay.agorist.space:443,52.3734,4.89406
|
|
||||||
relay02.lnfi.network,35.6764,139.65
|
|
||||||
relay-testnet.k8s.layer3.news:443,37.3387,-121.885
|
|
||||||
nostr.notribe.net:443,40.8302,-74.1299
|
|
||||||
relay.ditto.pub,43.6532,-79.3832
|
|
||||||
nostr.rikmeijer.nl,51.7111,5.36809
|
|
||||||
blossom.gnostr.cloud:443,43.6532,-79.3832
|
|
||||||
nostr.oxtr.dev,50.4754,12.3683
|
|
||||||
cache.trustr.ing,43.6548,-79.3885
|
|
||||||
nostr.bitczat.pl,60.1699,24.9384
|
|
||||||
relay.nostrverse.net,43.6532,-79.3832
|
|
||||||
shu04.shugur.net,25.2048,55.2708
|
|
||||||
eu.nostr.pikachat.org:443,49.4543,11.0746
|
|
||||||
ribo.us.nostria.app,43.6532,-79.3832
|
|
||||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
|
||||||
nostr.bitczat.pl:443,60.1699,24.9384
|
|
||||||
nostr-relay.corb.net:443,38.8353,-104.822
|
|
||||||
relay.libernet.app,43.6532,-79.3832
|
|
||||||
nostr-verified.wellorder.net,45.5201,-122.99
|
|
||||||
relay.nostu.be,40.4167,-3.70329
|
|
||||||
rele.speyhard.fi,51.5072,-0.127586
|
|
||||||
relay.staging.plebeian.market,51.5072,-0.127586
|
|
||||||
nostr.spicyz.io,43.6532,-79.3832
|
|
||||||
nostrride.io,37.3986,-121.964
|
|
||||||
x.kojira.io:443,43.6532,-79.3832
|
|
||||||
relay.staging.plebeian.market:443,51.5072,-0.127586
|
|
||||||
relay.sigit.io,50.4754,12.3683
|
|
||||||
nostr.stakey.net:443,52.3676,4.90414
|
|
||||||
relay.nostr.blockhenge.com,39.0438,-77.4874
|
|
||||||
relay.comcomponent.com,43.6532,-79.3832
|
|
||||||
wot.nostr.place,43.6532,-79.3832
|
|
||||||
relay.mostr.pub:443,43.6532,-79.3832
|
|
||||||
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
|
|
||||||
no.str.cr:443,10.6352,-85.4378
|
|
||||||
relay.chorus.community:443,48.5333,10.7
|
|
||||||
relay.aarpia.com,37.3986,-121.964
|
|
||||||
relay.nostrmap.net,60.1699,24.9384
|
|
||||||
relay.snotr.nl:49999,52.0195,4.42946
|
|
||||||
relay.bebond.net,43.6532,-79.3832
|
|
||||||
relay.illuminodes.com,43.6532,-79.3832
|
|
||||||
chat-relay.zap-work.com:443,43.6532,-79.3832
|
|
||||||
testr.nymble.world,40.8054,-74.0241
|
|
||||||
relay.underorion.se,50.1109,8.68213
|
|
||||||
fanfares.nostr1.com,40.7057,-74.0136
|
|
||||||
relay.damus.io,43.6532,-79.3832
|
|
||||||
relay.nostriches.club,43.6532,-79.3832
|
|
||||||
nostr-dev.wellorder.net,45.5201,-122.99
|
|
||||||
relay2.angor.io,48.1046,11.6002
|
|
||||||
relay.openfarmtools.org,60.1699,24.9384
|
|
||||||
wot.makenomistakes.ca,43.7064,-79.3986
|
|
||||||
relay.thecryptosquid.com,50.4754,12.3683
|
|
||||||
test.thedude.cloud,50.1109,8.68213
|
|
||||||
nostr.rtvslawenia.com,49.4543,11.0746
|
|
||||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
|
||||||
relay.olas.app:443,60.1699,24.9384
|
|
||||||
strfry.bonsai.com,39.0438,-77.4874
|
|
||||||
relayrs.notoshi.win,43.6532,-79.3832
|
|
||||||
articles.layer3.news,37.3387,-121.885
|
|
||||||
wot.utxo.one,43.6532,-79.3832
|
|
||||||
relay.angor.io:443,48.1046,11.6002
|
|
||||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
|
||||||
soloco.nl,43.6532,-79.3832
|
|
||||||
armada.sharegap.net,43.6532,-79.3832
|
|
||||||
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
|
||||||
nostr.4rs.nl,49.0291,8.35696
|
|
||||||
nrs-01.darkcloudarcade.com,39.1008,-94.5811
|
|
||||||
relay.beginningend.com:443,35.2227,-97.4786
|
|
||||||
relay.nostr.place,43.6532,-79.3832
|
|
||||||
relay.layer.systems,49.0291,8.35695
|
|
||||||
adre.su,59.9311,30.3609
|
|
||||||
relay.0xchat.com:443,43.6532,-79.3832
|
|
||||||
nostr.infero.net,35.6764,139.65
|
|
||||||
relay.typedcypher.com:443,51.5072,-0.127586
|
|
||||||
relay.mostro.network:443,40.8302,-74.1299
|
|
||||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
|
||||||
relay.mitchelltribe.com,39.0438,-77.4874
|
|
||||||
relay.mostr.pub,43.6532,-79.3832
|
|
||||||
bitcoiner.social,47.6743,-117.112
|
|
||||||
nostr.tac.lol:443,47.4748,-122.273
|
|
||||||
nostr.hekster.org:443,37.3986,-121.964
|
|
||||||
relay.satmaxt.xyz:443,43.6532,-79.3832
|
|
||||||
relay.cypherflow.ai,48.8575,2.35138
|
|
||||||
relay.zone667.com:443,60.1699,24.9384
|
|
||||||
relay.jeffg.fyi:443,43.6532,-79.3832
|
|
||||||
relay.agorist.space,52.3734,4.89406
|
|
||||||
nostr.spaceshell.xyz:443,43.6532,-79.3832
|
|
||||||
top.testrelay.top:443,43.6532,-79.3832
|
|
||||||
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
|
||||||
relay.nostrian-conquest.com:443,41.223,-111.974
|
|
||||||
relay.laantungir.net:443,-19.4692,-42.5315
|
|
||||||
relay.islandbitcoin.com,12.8498,77.6545
|
|
||||||
purplerelay.com,43.6532,-79.3832
|
|
||||||
nostr.tac.lol,47.4748,-122.273
|
|
||||||
nostr.wecsats.io,43.6532,-79.3832
|
|
||||||
nostr.2b9t.xyz,34.0549,-118.243
|
|
||||||
nostr.quali.chat,60.1699,24.9384
|
|
||||||
relay.dreamith.to,43.6532,-79.3832
|
|
||||||
nostr.islandarea.net:443,35.4669,-97.6473
|
|
||||||
relay.primal.net,43.6532,-79.3832
|
|
||||||
eu.nostr.pikachat.org,49.4543,11.0746
|
|
||||||
relay.nostr.net,43.6532,-79.3832
|
|
||||||
nostr.n7ekb.net,47.4941,-122.294
|
|
||||||
relay.mulatta.io,37.5665,126.978
|
|
||||||
nittom.nostr1.com,40.7057,-74.0136
|
|
||||||
relay2.orangesync.tech,40.7128,-74.006
|
|
||||||
relay.artx.market,43.6548,-79.3885
|
|
||||||
relay.wavefunc.live,41.8781,-87.6298
|
|
||||||
bitchat.nostr1.com,40.7057,-74.0136
|
|
||||||
relay.ditto.pub:443,43.6532,-79.3832
|
|
||||||
relay.wisp.talk,49.4543,11.0746
|
|
||||||
nostrelites.org,41.8781,-87.6298
|
|
||||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
|
||||||
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
|
||||||
relay.chorus.community,48.5333,10.7
|
|
||||||
relay.plebchain.club,43.6532,-79.3832
|
|
||||||
nostr-relay.amethyst.name:443,39.0067,-77.4291
|
|
||||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
|
||||||
relay.edufeed.org:443,49.4521,11.0767
|
|
||||||
relay.lacompagniemaximus.com,45.3147,-73.8785
|
|
||||||
relay.dreamith.to:443,43.6532,-79.3832
|
|
||||||
nostr.stakey.net,52.3676,4.90414
|
|
||||||
nostr.n7ekb.net:443,47.4941,-122.294
|
|
||||||
relay.dyne.org,49.0291,8.35705
|
|
||||||
nostr.janx.com,43.6532,-79.3832
|
|
||||||
relay.trustr.ing,43.6548,-79.3885
|
|
||||||
relay.paulstephenborile.com:443,49.4543,11.0746
|
|
||||||
relay.wisp.talk:443,49.4543,11.0746
|
|
||||||
yabu.me,35.6092,139.73
|
|
||||||
bcast.seutoba.com.br,43.6532,-79.3832
|
|
||||||
nos.xmark.cc,50.6924,3.20113
|
|
||||||
nos.lol,50.4754,12.3683
|
|
||||||
relay.satmaxt.xyz,43.6532,-79.3832
|
|
||||||
relay.endfiat.money:443,59.3327,18.0656
|
|
||||||
relay.tapestry.ninja,40.8054,-74.0241
|
|
||||||
relay.lab.rytswd.com,49.4543,11.0746
|
relay.lab.rytswd.com,49.4543,11.0746
|
||||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
relay.paulstephenborile.com:443,49.4543,11.0746
|
||||||
relay.damus.io:443,43.6532,-79.3832
|
relay.binaryrobot.com,43.6532,-79.3832
|
||||||
treuzkas.branruz.com,48.8575,2.35138
|
nostr-2.21crypto.ch,47.5356,8.73209
|
||||||
|
spookstr2.nostr1.com:443,40.7057,-74.0136
|
||||||
|
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||||
|
x.kojira.io,43.6532,-79.3832
|
||||||
|
freelay.sovbit.host,60.1699,24.9384
|
||||||
|
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
|
||||||
|
testnet.samt.st,43.6532,-79.3832
|
||||||
|
relay.angor.io,48.1046,11.6002
|
||||||
|
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||||
nostr-01.yakihonne.com,1.32123,103.695
|
nostr-01.yakihonne.com,1.32123,103.695
|
||||||
relay.nostriot.com:443,41.5695,-83.9786
|
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
relay.guggero.org,46.5971,9.59652
|
||||||
nostr.aruku.ovh,1.27994,103.849
|
nostr.snowbla.de,60.1699,24.9384
|
||||||
nostr.chaima.info,50.1109,8.68213
|
|
||||||
ynostr.yael.at,60.1699,24.9384
|
|
||||||
ynostr.yael.at:443,60.1699,24.9384
|
|
||||||
nostr-01.yakihonne.com:443,1.32123,103.695
|
|
||||||
spookstr2.nostr1.com,40.7057,-74.0136
|
|
||||||
relay.trustr.ing:443,43.6548,-79.3885
|
|
||||||
dev.relay.edufeed.org,49.4521,11.0767
|
|
||||||
relay2.angor.io:443,48.1046,11.6002
|
|
||||||
nostr.azzamo.net:443,52.2633,21.0283
|
|
||||||
offchain.bostr.online,43.6532,-79.3832
|
|
||||||
relay.zone667.com,60.1699,24.9384
|
relay.zone667.com,60.1699,24.9384
|
||||||
relay.veganostr.com,60.1699,24.9384
|
nexus.libernet.app:443,43.6532,-79.3832
|
||||||
vault.iris.to:443,43.6532,-79.3832
|
relay.islandbitcoin.com,12.8498,77.6545
|
||||||
relay.artx.market:443,43.6548,-79.3885
|
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||||
wot.rejecttheframe.xyz,43.6532,-79.3832
|
nostr-relay.xbytez.io,50.6924,3.20113
|
||||||
nostr.pbfs.io,50.4754,12.3683
|
kasztanowa.bieda.it,43.6532,-79.3832
|
||||||
relay.mostro.network,40.8302,-74.1299
|
nostrcity-club.fly.dev,37.7648,-122.432
|
||||||
strfry.shock.network,39.0438,-77.4874
|
relay.typedcypher.com,51.5072,-0.127586
|
||||||
relay.klabo.world,47.2343,-119.853
|
nostr.na.social:443,43.6532,-79.3832
|
||||||
relay.fountain.fm:443,43.6532,-79.3832
|
|
||||||
nostrja-kari.heguro.com,43.6532,-79.3832
|
|
||||||
relaisnostr.trivaco.fr,48.5734,7.75211
|
|
||||||
offchain.pub,39.1585,-94.5728
|
|
||||||
relay.degmods.com,50.4754,12.3683
|
|
||||||
nostr-relay.xbytez.io:443,50.6924,3.20113
|
|
||||||
relay.paulstephenborile.com,49.4543,11.0746
|
|
||||||
nittom.nostr1.com:443,40.7057,-74.0136
|
|
||||||
dev-relay.nostreon.com,60.1699,24.9384
|
|
||||||
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
|
||||||
relay.jeffg.fyi,43.6532,-79.3832
|
|
||||||
relay.laantungir.net,-19.4692,-42.5315
|
relay.laantungir.net,-19.4692,-42.5315
|
||||||
nostr.data.haus:443,50.4754,12.3683
|
relay-dev.satlantis.io:443,40.8302,-74.1299
|
||||||
wot.codingarena.top,50.4754,12.3683
|
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
|
nostr.2b9t.xyz:443,34.0549,-118.243
|
||||||
relay.libernet.app:443,43.6532,-79.3832
|
relay.libernet.app:443,43.6532,-79.3832
|
||||||
nostr.ps1829.com,33.8851,130.883
|
relay.dreamith.to:443,43.6532,-79.3832
|
||||||
wot.dergigi.com,64.1476,-21.9392
|
relay.lightning.pub:443,39.0438,-77.4874
|
||||||
relay.olas.app,60.1699,24.9384
|
nostr.rtvslawenia.com,49.4543,11.0746
|
||||||
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
|
||||||
nostr.data.haus,50.4754,12.3683
|
|
||||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
|
||||||
relay.ohstr.com,43.6532,-79.3832
|
|
||||||
relay.lightning.pub,39.0438,-77.4874
|
|
||||||
relay.guggero.org,46.5971,9.59652
|
|
||||||
testnet-relay.samt.st:443,40.8302,-74.1299
|
|
||||||
thecitadel.nostr1.com,40.7057,-74.0136
|
|
||||||
nostr.ps1829.com:443,33.8851,130.883
|
|
||||||
nostr-relay.corb.net,38.8353,-104.822
|
|
||||||
relay.npubhaus.com,43.6532,-79.3832
|
|
||||||
nostr.21crypto.ch,47.5356,8.73209
|
nostr.21crypto.ch,47.5356,8.73209
|
||||||
nostr.tadryanom.me,43.6532,-79.3832
|
relay.ditto.pub:443,43.6532,-79.3832
|
||||||
nostr.myshosholoza.co.za,52.3913,4.66545
|
relay.plebchain.club,43.6532,-79.3832
|
||||||
relay.bitmacro.cloud,43.6532,-79.3832
|
memlay.v0l.io,53.3498,-6.26031
|
||||||
ribo.nostria.app,43.6532,-79.3832
|
|
||||||
relay.vrtmrz.net,43.6532,-79.3832
|
|
||||||
syb.lol,43.6532,-79.3832
|
|
||||||
relay.bebond.net:443,43.6532,-79.3832
|
|
||||||
relay.agentry.com,42.8864,-78.8784
|
|
||||||
nostr-2.21crypto.ch,47.5356,8.73209
|
|
||||||
nostrcity-club.fly.dev:443,37.7648,-122.432
|
|
||||||
articles.layer3.news:443,37.3387,-121.885
|
|
||||||
relay.internationalright-wing.org:443,-22.5022,-48.7114
|
|
||||||
nostr-relay.zimage.com,34.0549,-118.243
|
|
||||||
chat-relay.zap-work.com,43.6532,-79.3832
|
|
||||||
relay.mwaters.net,50.9871,2.12554
|
|
||||||
nostr.liberty.fans,36.9104,-89.5875
|
|
||||||
spookstr2.nostr1.com:443,40.7057,-74.0136
|
|
||||||
nostr.chaima.info:443,50.1109,8.68213
|
nostr.chaima.info:443,50.1109,8.68213
|
||||||
schnorr.me:443,43.6532,-79.3832
|
relay.wavlake.com:443,41.2619,-95.8608
|
||||||
ribo.eu.nostria.app,43.6532,-79.3832
|
nostr.thalheim.io:443,60.1699,24.9384
|
||||||
nostr.bond,50.1109,8.68213
|
relay.lightning.pub,39.0438,-77.4874
|
||||||
wot.nostr.party,36.1659,-86.7844
|
dev.relay.edufeed.org:443,49.4521,11.0767
|
||||||
temp.iris.to,43.6532,-79.3832
|
nostr.myshosholoza.co.za:443,52.3913,4.66545
|
||||||
relay.lotek-distro.com,43.6532,-79.3832
|
relay.binaryrobot.com:443,43.6532,-79.3832
|
||||||
relay.minibolt.info:443,43.6532,-79.3832
|
wot.nostr.place,43.6532,-79.3832
|
||||||
relay.lanavault.space,60.1699,24.9384
|
|
||||||
relay.mmwaves.de,48.8575,2.35138
|
|
||||||
social.amanah.eblessing.co,48.1046,11.6002
|
|
||||||
nostr2.thalheim.io,49.4543,11.0746
|
|
||||||
relay.typedcypher.com,51.5072,-0.127586
|
|
||||||
relay.cosmicbolt.net,37.3986,-121.964
|
|
||||||
relayrs.notoshi.win:443,43.6532,-79.3832
|
|
||||||
nostr-relay-1.trustlessenterprise.com:443,43.6532,-79.3832
|
|
||||||
nostr.islandarea.net,35.4669,-97.6473
|
|
||||||
relay.nostrmap.net:443,60.1699,24.9384
|
|
||||||
relay.bullishbounty.com:443,43.6532,-79.3832
|
|
||||||
nostr.oxtr.dev:443,50.4754,12.3683
|
|
||||||
srtrelay.c-stellar.net,43.6532,-79.3832
|
|
||||||
relay.mccormick.cx,52.3563,4.95714
|
|
||||||
vault.iris.to,43.6532,-79.3832
|
|
||||||
relay.mccormick.cx:443,52.3563,4.95714
|
|
||||||
relay.toastr.net,40.8054,-74.0241
|
|
||||||
nostr.hifish.org,47.4244,8.57658
|
|
||||||
speakeasy.cellar.social:443,49.4543,11.0746
|
|
||||||
relay.wavlake.com,41.2619,-95.8608
|
|
||||||
testnet-relay.samt.st,40.8302,-74.1299
|
|
||||||
aeon.libretechsystems.xyz,55.486,9.86577
|
|
||||||
mostro-p2p.tech,50.1109,8.68213
|
|
||||||
nostr.wild-vibes.ts.net,48.8566,2.35222
|
|
||||||
nostr.88mph.life,52.1941,-2.21905
|
|
||||||
relay.nostrcheck.me,43.6532,-79.3832
|
|
||||||
rilo.nostria.app,43.6532,-79.3832
|
|
||||||
relay.bowlafterbowl.com,32.9483,-96.7299
|
|
||||||
relay.nostr.place:443,43.6532,-79.3832
|
|
||||||
nostr.mom:443,50.4754,12.3683
|
|
||||||
relay.nostrian-conquest.com,41.223,-111.974
|
|
||||||
herbstmeister.com,34.0549,-118.243
|
|
||||||
nrs-01.darkcloudarcade.com:443,39.1008,-94.5811
|
|
||||||
nostr.red5d.dev,43.6532,-79.3832
|
|
||||||
nostrbtc.com,43.6532,-79.3832
|
|
||||||
strfry.apps3.slidestr.net,40.4167,-3.70329
|
|
||||||
relay.sharegap.net,43.6532,-79.3832
|
|
||||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
|
||||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
|
||||||
nostr.sathoarder.com:443,48.5734,7.75211
|
nostr.sathoarder.com:443,48.5734,7.75211
|
||||||
relay.veganostr.com:443,60.1699,24.9384
|
thecitadel.nostr1.com,40.7057,-74.0136
|
||||||
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
|
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
|
premium.primal.net,43.6532,-79.3832
|
||||||
relay.wavefunc.live:443,41.8781,-87.6298
|
nas01xanthosnet.synology.me:7778,47.1285,8.74735
|
||||||
|
nostrja-kari.heguro.com,43.6532,-79.3832
|
||||||
|
relay.mrmave.work,43.6532,-79.3832
|
||||||
|
nostrelay.circum.space,52.6907,4.8181
|
||||||
|
mostro-p2p.tech,50.1109,8.68213
|
||||||
|
wot.shaving.kiwi,43.6532,-79.3832
|
||||||
|
relay.fundstr.me,42.3601,-71.0589
|
||||||
|
nostrelay.circum.space:443,52.6907,4.8181
|
||||||
|
relay.nostrdice.com,-33.8688,151.209
|
||||||
|
relay.getvia.xyz,60.1699,24.9384
|
||||||
|
strfry.shock.network:443,39.0438,-77.4874
|
||||||
|
relay.nostrmap.net:443,60.1699,24.9384
|
||||||
|
relay.nearhood.co.uk,51.5072,-0.127586
|
||||||
|
no.str.cr,10.6352,-85.4378
|
||||||
|
relay.getsafebox.app:443,43.6532,-79.3832
|
||||||
|
relay0.gfcom.info,13.6992,100.694
|
||||||
|
nostr.ps1829.com,33.8851,130.883
|
||||||
|
relay2.angor.io,48.1046,11.6002
|
||||||
|
relay.stickeroo.is-cool.dev,37.3387,-121.885
|
||||||
|
ricardo-oem.tailb5546.ts.net,40.7128,-74.006
|
||||||
|
relay.typedcypher.com:443,51.5072,-0.127586
|
||||||
|
relay.paulstephenborile.com,49.4543,11.0746
|
||||||
|
nittom.nostr1.com,40.7057,-74.0136
|
||||||
|
conduitl2.fly.dev,37.7648,-122.432
|
||||||
|
nostr.rikmeijer.nl,51.7111,5.36809
|
||||||
|
relay.thecryptosquid.com,50.4754,12.3683
|
||||||
|
spookstr2.nostr1.com,40.7057,-74.0136
|
||||||
|
offchain.bostr.online,43.6532,-79.3832
|
||||||
|
nostr.planix.org,43.6532,-79.3832
|
||||||
|
relay.mccormick.cx,52.3563,4.95714
|
||||||
|
0x-nostr-relay.fly.dev,37.7648,-122.432
|
||||||
|
nostr.wecsats.io,43.6532,-79.3832
|
||||||
|
schnorr.me,43.6532,-79.3832
|
||||||
|
relay.satmaxt.xyz,43.6532,-79.3832
|
||||||
|
relay.bornheimer.app,51.5072,-0.127586
|
||||||
|
relay.nostrhub.fr,48.1045,11.6004
|
||||||
|
blossom.gnostr.cloud:443,43.6532,-79.3832
|
||||||
|
nostr-02.yakihonne.com:443,1.32123,103.695
|
||||||
|
dev.relay.stream,43.6532,-79.3832
|
||||||
|
ithurtswhenip.ee,51.5072,-0.127586
|
||||||
|
nostr.myshosholoza.co.za,52.3913,4.66545
|
||||||
|
relayrs.notoshi.win:443,43.6532,-79.3832
|
||||||
relay-rpi.edufeed.org:443,49.4521,11.0767
|
relay-rpi.edufeed.org:443,49.4521,11.0767
|
||||||
kotukonostr.onrender.com,37.7775,-122.397
|
relay.olas.app:443,60.1699,24.9384
|
||||||
bridge.tagomago.me,42.3601,-71.0589
|
nostr.unkn0wn.world,46.8499,9.53287
|
||||||
nostr.tadryanom.me:443,43.6532,-79.3832
|
relay.mitchelltribe.com,39.0438,-77.4874
|
||||||
relay.gulugulu.moe:443,43.6532,-79.3832
|
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
|
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.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
|
nostr.snowbla.de:443,60.1699,24.9384
|
||||||
|
relay.ditto.pub,43.6532,-79.3832
|
||||||
|
relay.damus.io,43.6532,-79.3832
|
||||||
|
relay.ru.ac.th,13.7607,100.627
|
||||||
|
nrs-01.darkcloudarcade.com,39.1008,-94.5811
|
||||||
|
testnet-relay.samt.st,40.8302,-74.1299
|
||||||
|
antiprimal.net,43.6532,-79.3832
|
||||||
|
bitchat.nostr1.com,40.7057,-74.0136
|
||||||
|
relay.snort.social,53.3498,-6.26031
|
||||||
|
relay.mccormick.cx:443,52.3563,4.95714
|
||||||
|
relay02.lnfi.network,35.6764,139.65
|
||||||
|
srtrelay.c-stellar.net,43.6532,-79.3832
|
||||||
|
relay.minibolt.info,43.6532,-79.3832
|
||||||
|
nostrride.io,37.3986,-121.964
|
||||||
|
articles.layer3.news:443,37.3387,-121.885
|
||||||
|
rele.speyhard.fi,51.5072,-0.127586
|
||||||
|
relay.aarpia.com,37.3986,-121.964
|
||||||
|
nostr.chaima.info,50.1109,8.68213
|
||||||
|
relay.wisp.talk:443,49.4543,11.0746
|
||||||
|
relay.agorist.space:443,52.3734,4.89406
|
||||||
|
strfry.bonsai.com,39.0438,-77.4874
|
||||||
|
nostr.hifish.org,47.4244,8.57658
|
||||||
|
offchain.pub,39.1585,-94.5728
|
||||||
|
nostr.spicyz.io:443,43.6532,-79.3832
|
||||||
|
relay.beginningend.com,35.2227,-97.4786
|
||||||
|
relay.sharegap.net,43.6532,-79.3832
|
||||||
|
nostr.purpura.cloud,43.6532,-79.3832
|
||||||
|
nrs-01.darkcloudarcade.com:443,39.1008,-94.5811
|
||||||
|
relay.fountain.fm:443,43.6532,-79.3832
|
||||||
|
relay.olas.app,60.1699,24.9384
|
||||||
|
relay.mmwaves.de,48.8575,2.35138
|
||||||
|
relay.openresist.com:443,43.6532,-79.3832
|
||||||
|
relay.homeinhk.xyz,35.694,139.754
|
||||||
|
relay.libernet.app,43.6532,-79.3832
|
||||||
|
relay.comcomponent.com,43.6532,-79.3832
|
||||||
|
nostr.tac.lol,47.4748,-122.273
|
||||||
|
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||||
|
relay.nostriot.com:443,41.5695,-83.9786
|
||||||
|
bcast.girino.org,43.6532,-79.3832
|
||||||
|
nostr.azzamo.net:443,52.2633,21.0283
|
||||||
|
relay.islandbitcoin.com:443,12.8498,77.6545
|
||||||
|
pool.libernet.app,43.6532,-79.3832
|
||||||
|
test.thedude.cloud,50.1109,8.68213
|
||||||
|
nostrelites.org,41.8781,-87.6298
|
||||||
|
nostr.infero.net,35.6764,139.65
|
||||||
|
relay.primal.net,43.6532,-79.3832
|
||||||
|
ribo.nostria.app,43.6532,-79.3832
|
||||||
|
relay.chorus.community,48.5333,10.7
|
||||||
|
bitcoiner.social:443,47.6743,-117.112
|
||||||
|
relay.wisp.talk,49.4543,11.0746
|
||||||
|
relay.layer.systems,49.0291,8.35695
|
||||||
|
relay-dev.satlantis.io,40.8302,-74.1299
|
||||||
|
nostr.bitcoiner.social,47.6743,-117.112
|
||||||
|
relay.lanavault.space:443,60.1699,24.9384
|
||||||
|
relay.staging.plebeian.market,51.5072,-0.127586
|
||||||
|
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
|
||||||
|
relay.fountain.fm,43.6532,-79.3832
|
||||||
|
nostr.middling.mydns.jp,35.8099,140.12
|
||||||
|
relay.dreamith.to,43.6532,-79.3832
|
||||||
|
relay.satmaxt.xyz:443,43.6532,-79.3832
|
||||||
|
shu03.shugur.net,25.2048,55.2708
|
||||||
|
zealand-charts-craig-thru.trycloudflare.com,43.6532,-79.3832
|
||||||
|
nostr.computingcache.com,34.0356,-118.442
|
||||||
|
ribo.us.nostria.app,43.6532,-79.3832
|
||||||
|
relay.agentry.com,42.8864,-78.8784
|
||||||
|
nostr.hifish.org:443,47.4244,8.57658
|
||||||
|
nostr.vulpem.com,49.4543,11.0746
|
||||||
|
relay.cosmicbolt.net:443,37.3986,-121.964
|
||||||
|
nostr-02.yakihonne.com,1.32123,103.695
|
||||||
|
r.0kb.io:443,32.789,-96.7989
|
||||||
|
nostr-relay.corb.net,38.8353,-104.822
|
||||||
|
ribo.eu.nostria.app:443,43.6532,-79.3832
|
||||||
|
nostr-relay.psfoundation.info,39.0438,-77.4874
|
||||||
|
relay.wellorder.net,45.5201,-122.99
|
||||||
|
relay.novospes.com,43.6532,-79.3832
|
||||||
|
nostr-dev.wellorder.net,45.5201,-122.99
|
||||||
|
relay.endfiat.money,59.3327,18.0656
|
||||||
|
relay.angor.io:443,48.1046,11.6002
|
||||||
|
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
|
||||||
|
strfry.openhoofd.nl,51.5717,3.70417
|
||||||
|
relay.getsafebox.app,43.6532,-79.3832
|
||||||
|
relay.openresist.com,43.6532,-79.3832
|
||||||
|
relay5.bitransfer.org,43.6532,-79.3832
|
||||||
|
nostr.na.social,43.6532,-79.3832
|
||||||
|
portal-relay.pareto.space,49.0291,8.35696
|
||||||
|
nostr.notribe.net:443,40.8302,-74.1299
|
||||||
|
relay.bitmacro.cloud,43.6532,-79.3832
|
||||||
|
no.str.cr:443,10.6352,-85.4378
|
||||||
|
relay.klabo.world,47.2343,-119.853
|
||||||
|
nostr.notribe.net,40.8302,-74.1299
|
||||||
|
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||||
|
relay.nostrmap.net,60.1699,24.9384
|
||||||
|
temp.iris.to,43.6532,-79.3832
|
||||||
|
nostr.sovereignservices.xyz,43.6532,-79.3832
|
||||||
|
nostr.liberty.fans,36.9104,-89.5875
|
||||||
|
relay.nostrian-conquest.com:443,41.223,-111.974
|
||||||
|
relay.nostriot.com,41.5695,-83.9786
|
||||||
|
nostrbtc.com,43.6532,-79.3832
|
||||||
|
shu02.shugur.net,21.4902,39.2246
|
||||||
|
relay.kalcafe.xyz,37.3986,-121.964
|
||||||
|
relay.illuminodes.com,43.6532,-79.3832
|
||||||
|
relay.wavlake.com,41.2619,-95.8608
|
||||||
|
nostr.ps1829.com:443,33.8851,130.883
|
||||||
|
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||||
|
nostr.wecsats.io:443,43.6532,-79.3832
|
||||||
|
nostr-pub.wellorder.net,45.5201,-122.99
|
||||||
|
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
||||||
|
nostr.mom:443,50.4754,12.3683
|
||||||
|
ribo.nostria.app:443,43.6532,-79.3832
|
||||||
|
nostr.2b9t.xyz,34.0549,-118.243
|
||||||
|
nostr.data.haus:443,50.4754,12.3683
|
||||||
|
staging.yabu.me,35.6092,139.73
|
||||||
|
relay.sigit.io:443,50.4754,12.3683
|
||||||
|
relay.edufeed.org:443,49.4521,11.0767
|
||||||
|
nostr-01.yakihonne.com:443,1.32123,103.695
|
||||||
|
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||||
|
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||||
|
herbstmeister.com,34.0549,-118.243
|
||||||
|
relay.minibolt.info:443,43.6532,-79.3832
|
||||||
|
relay2.angor.io:443,48.1046,11.6002
|
||||||
|
social.amanah.eblessing.co,48.1046,11.6002
|
||||||
|
nostr.stakey.net,52.3676,4.90414
|
||||||
|
nostr.computingcache.com:443,34.0356,-118.442
|
||||||
|
slick.mjex.me,39.0418,-77.4744
|
||||||
|
fanfares.nostr1.com,40.7057,-74.0136
|
||||||
|
bitcoinostr.duckdns.org,43.3434,-3.99532
|
||||||
|
nostr.oxtr.dev,50.4754,12.3683
|
||||||
|
cache.trustr.ing,43.6548,-79.3885
|
||||||
|
purplerelay.com,43.6532,-79.3832
|
||||||
|
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||||
|
nostr-relay.corb.net:443,38.8353,-104.822
|
||||||
|
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||||
|
prl.plus,55.7628,37.5983
|
||||||
|
nostr.tac.lol:443,47.4748,-122.273
|
||||||
|
relay.mostr.pub,43.6532,-79.3832
|
||||||
|
schnorr.me:443,43.6532,-79.3832
|
||||||
|
dev-relay.nostreon.com,60.1699,24.9384
|
||||||
|
nostr.islandarea.net:443,35.4669,-97.6473
|
||||||
|
bucket.coracle.social,37.7775,-122.397
|
||||||
|
blossom.gnostr.cloud,43.6532,-79.3832
|
||||||
|
relay.solife.me,43.6532,-79.3832
|
||||||
|
nostr.quali.chat,60.1699,24.9384
|
||||||
|
relay.vrtmrz.net,43.6532,-79.3832
|
||||||
|
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||||
|
relay.bullishbounty.com,43.6532,-79.3832
|
||||||
|
relay.fckstate.net,59.3293,18.0686
|
||||||
|
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||||
|
relay.nostx.io,43.6532,-79.3832
|
||||||
|
relay.agorist.space,52.3734,4.89406
|
||||||
|
relay.notoshi.win,13.7829,100.546
|
||||||
|
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
||||||
|
relay.trotters.cc,43.6532,-79.3832
|
||||||
|
relay.lanavault.space,60.1699,24.9384
|
||||||
|
public.crostr.com:443,43.6532,-79.3832
|
||||||
|
nostr.stakey.net:443,52.3676,4.90414
|
||||||
|
relay.nostr.place:443,43.6532,-79.3832
|
||||||
|
nostr.dlcdevkit.com,40.0992,-83.1141
|
||||||
|
nostr.aruku.ovh,1.27994,103.849
|
||||||
|
satsage.xyz,37.3986,-121.964
|
||||||
|
strfry.apps3.slidestr.net,40.4167,-3.70329
|
||||||
|
nostr2.girino.org,43.6532,-79.3832
|
||||||
|
relay.samt.st,40.8302,-74.1299
|
||||||
|
articles.layer3.news,37.3387,-121.885
|
||||||
|
aeon.libretechsystems.xyz,55.486,9.86577
|
||||||
|
relay.routstr.com,59.4016,17.9455
|
||||||
|
relay.ohstr.com:443,43.6532,-79.3832
|
||||||
|
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
||||||
|
strfry.openhoofd.nl:443,51.5717,3.70417
|
||||||
|
nostr.blankfors.se,60.1699,24.9384
|
||||||
|
nostr-2.21crypto.ch:443,47.5356,8.73209
|
||||||
|
relayone.soundhsa.com:443,39.1008,-94.5811
|
||||||
|
relay.lab.rytswd.com:443,49.4543,11.0746
|
||||||
|
nostr.tagomago.me,42.3601,-71.0589
|
||||||
|
relay.0xchat.com:443,43.6532,-79.3832
|
||||||
|
|||||||
|
Reference in New Issue
Block a user