mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 13:25:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14ab04d2bb | ||
|
|
c37e376568 | ||
|
|
51186c3be6 | ||
|
|
8df3871096 | ||
|
|
a8b741d605 | ||
|
|
a55a16adcd | ||
|
|
dbe11641ad | ||
|
|
ccf17326d2 | ||
|
|
385d32063f |
@@ -80,3 +80,4 @@ build.log
|
||||
|
||||
# Local configs
|
||||
Local.xcconfig
|
||||
*.profraw
|
||||
|
||||
@@ -141,22 +141,44 @@ enum TrustLevel: String, Codable {
|
||||
struct IdentityCache: Codable {
|
||||
// Fingerprint -> Social mapping
|
||||
var socialIdentities: [String: SocialIdentity] = [:]
|
||||
|
||||
|
||||
// Nickname -> [Fingerprints] reverse index
|
||||
// Multiple fingerprints can claim same nickname
|
||||
var nicknameIndex: [String: Set<String>] = [:]
|
||||
|
||||
|
||||
// Verified fingerprints (cryptographic proof)
|
||||
var verifiedFingerprints: Set<String> = []
|
||||
|
||||
|
||||
// Last interaction timestamps (privacy: optional)
|
||||
var lastInteractions: [String: Date] = [:]
|
||||
|
||||
var lastInteractions: [String: Date] = [:]
|
||||
|
||||
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||
var blockedNostrPubkeys: Set<String> = []
|
||||
|
||||
|
||||
// Fingerprint -> Cryptographic identity (noise + pinned signing key).
|
||||
// Persisting the signing-key pin is security-critical: it must survive
|
||||
// app restarts so an attacker cannot replay a known peer's
|
||||
// noiseKey/peerID with their own signing key and be treated as first
|
||||
// contact (TOFU downgrade).
|
||||
var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||
|
||||
// Schema version for future migrations
|
||||
var version: Int = 1
|
||||
|
||||
init() {}
|
||||
|
||||
// Custom decoding so caches written by older builds (without
|
||||
// `cryptographicIdentities`) still load instead of being discarded.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
socialIdentities = try container.decodeIfPresent([String: SocialIdentity].self, forKey: .socialIdentities) ?? [:]
|
||||
nicknameIndex = try container.decodeIfPresent([String: Set<String>].self, forKey: .nicknameIndex) ?? [:]
|
||||
verifiedFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .verifiedFingerprints) ?? []
|
||||
lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:]
|
||||
blockedNostrPubkeys = try container.decodeIfPresent(Set<String>.self, forKey: .blockedNostrPubkeys) ?? []
|
||||
cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:]
|
||||
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -145,18 +145,29 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
// In-memory state
|
||||
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||
// Cryptographic identities (including pinned signing keys) live inside
|
||||
// `cache` so they persist across app restarts; see IdentityCache.
|
||||
private var cache: IdentityCache = IdentityCache()
|
||||
|
||||
// Thread safety
|
||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||
|
||||
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
||||
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
||||
// 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.)
|
||||
//
|
||||
// Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt
|
||||
// + keychain write inside `queue.sync(flags: .barrier)`, so when the call
|
||||
// returns the write is already complete and NOTHING is left scheduled on
|
||||
// the queue. This is deliberate — a retained DispatchSourceTimer (the
|
||||
// original design) kept the dispatch machinery alive and prevented the
|
||||
// unit-test process from exiting, and fire-and-forget `queue.async(.barrier)`
|
||||
// (a later design) left a backlog of instrumented barrier saves still
|
||||
// draining when LLVM's `--enable-code-coverage` `atexit` handler dumped
|
||||
// `.profraw`, deadlocking the process at teardown on the constrained CI
|
||||
// runner. Synchronous persistence has zero outstanding dispatch at exit, so
|
||||
// neither failure mode is possible. `pendingSave` is now effectively always
|
||||
// false after any mutation (saveIdentityCache persists inline and clears
|
||||
// it); it remains only as a belt-and-suspenders flag read by `forceSave`
|
||||
// and `deinit`.
|
||||
private var pendingSave = false
|
||||
|
||||
// Encryption key
|
||||
@@ -216,7 +227,22 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
deinit {
|
||||
forceSave()
|
||||
// Do NOT dispatch onto `queue` here. `deinit` can run on any thread
|
||||
// (including one draining `queue`), and the object is being
|
||||
// deallocated: a `queue.sync` risks a re-entrant same-queue wait
|
||||
// (deadlock) and a `queue.async` schedules work that resurrects `self`
|
||||
// and may not drain before process exit.
|
||||
//
|
||||
// A flush here is redundant anyway: every mutating API already
|
||||
// persists inline within its own barrier, so the keychain is already
|
||||
// up to date. As a queue-free best-effort belt-and-suspenders, only
|
||||
// flush if something is still pending. This is a direct read of
|
||||
// in-hand state — safe because a deallocating object has no other
|
||||
// live references, so nothing can be mutating `cache` concurrently.
|
||||
if pendingSave {
|
||||
pendingSave = false
|
||||
persist(snapshot: cache)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Secure Loading/Saving
|
||||
@@ -241,21 +267,27 @@ 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.
|
||||
/// Persists the cache. Always invoked on `queue` under a barrier (its
|
||||
/// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read
|
||||
/// while serialized. The encode + keychain write are done here (already on
|
||||
/// the exclusive barrier context), synchronously, so no separate hop is
|
||||
/// scheduled and nothing is left to keep the process alive.
|
||||
private func saveIdentityCache() {
|
||||
pendingSave = true
|
||||
performSave()
|
||||
// On the barrier context already: snapshot is trivially consistent.
|
||||
persist(snapshot: cache)
|
||||
pendingSave = false
|
||||
}
|
||||
|
||||
/// Writes the cache to the keychain. Must run on `queue` with exclusive
|
||||
/// (barrier) access.
|
||||
private func performSave() {
|
||||
guard pendingSave else { return }
|
||||
pendingSave = false
|
||||
|
||||
/// Encodes, seals, and writes a *snapshot* of the cache to the keychain.
|
||||
///
|
||||
/// Takes the cache by value so callers can capture a consistent snapshot
|
||||
/// under `queue` and then encode without holding it. Reading `cache`
|
||||
/// concurrently with a barrier writer would be a data race on the
|
||||
/// dictionary storage, which — because `JSONEncoder` walks that storage —
|
||||
/// can spin forever (observed as a CI test-suite hang), so the snapshot
|
||||
/// must be taken on `queue`, never off it.
|
||||
private func persist(snapshot: IdentityCache) {
|
||||
// Never persist under an ephemeral key — it would overwrite the real
|
||||
// cache with data the next launch cannot decrypt.
|
||||
guard !encryptionKeyIsEphemeral else {
|
||||
@@ -264,7 +296,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try JSONEncoder().encode(cache)
|
||||
let data = try JSONEncoder().encode(snapshot)
|
||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
|
||||
if saved {
|
||||
@@ -275,14 +307,26 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Force a flush (for app-termination / lifecycle events — NOT from
|
||||
// `deinit`, which persists inline; see the deinit note). Every mutating
|
||||
// API already persists inline inside its own barrier via
|
||||
// `saveIdentityCache`, so by the time this is called the keychain is
|
||||
// already up to date and this is normally a no-op; it exists as a
|
||||
// belt-and-suspenders flush of any `pendingSave` left set.
|
||||
//
|
||||
// Runs synchronously inside a `queue.sync(flags: .barrier)`: the barrier
|
||||
// makes the `cache` read race-free (a plain off-queue read races in-flight
|
||||
// barrier writers — JSONEncoder walking a concurrently-mutated dictionary
|
||||
// can spin forever, which surfaced as a CI hang), and being synchronous it
|
||||
// leaves nothing scheduled to keep the process alive at teardown. Safe
|
||||
// against re-entrant deadlock because this is never invoked from `deinit`
|
||||
// (the only path that can run *on* `queue`).
|
||||
func forceSave() {
|
||||
performSave()
|
||||
queue.sync(flags: .barrier) {
|
||||
guard pendingSave else { return }
|
||||
pendingSave = false
|
||||
persist(snapshot: cache)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Social Identity Management
|
||||
@@ -296,15 +340,33 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
// MARK: - Cryptographic Identities
|
||||
|
||||
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
|
||||
///
|
||||
/// TOFU signing-key pinning: once a signing key has been persisted for a
|
||||
/// fingerprint, an update carrying a *different* signing key is refused in
|
||||
/// full (including the claimed-nickname update) and security-logged. This
|
||||
/// mirrors `BLEPeerRegistry.upsertVerifiedAnnounce` — without it, an
|
||||
/// attacker replaying a victim's noiseKey/peerID with their own signing
|
||||
/// key could overwrite the victim's persisted identity while the victim is
|
||||
/// offline or after an app restart. The refusal is permanent: there is
|
||||
/// currently no targeted in-app way to reset the pin (`setVerified` does
|
||||
/// not touch it). Recovering from a legitimate signing re-key requires the
|
||||
/// peer to establish a new noise identity (new peerID) or the local user
|
||||
/// to wipe all identity data (`clearAllIdentityData`, e.g. panic wipe).
|
||||
/// - Parameters:
|
||||
/// - fingerprint: SHA-256 hex of the Noise static public key
|
||||
/// - noisePublicKey: Noise static public key data
|
||||
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
|
||||
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
if var existing = self.cryptographicIdentities[fingerprint] {
|
||||
if var existing = self.cache.cryptographicIdentities[fingerprint] {
|
||||
if let pinnedSigningKey = existing.signingPublicKey,
|
||||
let announcedSigningKey = signingPublicKey,
|
||||
pinnedSigningKey != announcedSigningKey {
|
||||
SecureLogger.warning("🚨 Refusing to replace pinned signing key for \(fingerprint.prefix(8))… (possible impersonation attempt)", category: .security)
|
||||
return
|
||||
}
|
||||
// Update keys if changed
|
||||
if existing.publicKey != noisePublicKey {
|
||||
existing = CryptographicIdentity(
|
||||
@@ -314,7 +376,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
firstSeen: existing.firstSeen,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = existing
|
||||
self.cache.cryptographicIdentities[fingerprint] = existing
|
||||
} else {
|
||||
// Update signing key and lastHandshake
|
||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||
@@ -325,7 +387,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
firstSeen: existing.firstSeen,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = updated
|
||||
self.cache.cryptographicIdentities[fingerprint] = updated
|
||||
}
|
||||
// Persist updated state (already assigned in branches above)
|
||||
} else {
|
||||
@@ -337,7 +399,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
firstSeen: now,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = entry
|
||||
self.cache.cryptographicIdentities[fingerprint] = entry
|
||||
}
|
||||
|
||||
// Optionally persist claimed nickname into social identity
|
||||
@@ -369,12 +431,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
queue.sync {
|
||||
// Defensive: ensure hex and correct length
|
||||
guard peerID.isShort else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
}
|
||||
}
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||
|
||||
@@ -410,7 +472,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||
identity.isFavorite = isFavorite
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
@@ -448,7 +510,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
|
||||
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||
identity.isBlocked = isBlocked
|
||||
if isBlocked {
|
||||
@@ -482,7 +544,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
||||
let key = pubkeyHexLowercased.lowercased()
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
if isBlocked {
|
||||
self.cache.blockedNostrPubkeys.insert(key)
|
||||
} else {
|
||||
@@ -499,7 +561,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
// MARK: - Ephemeral Session Management
|
||||
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||
peerID: peerID,
|
||||
sessionStart: Date(),
|
||||
@@ -509,7 +571,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||
|
||||
// If handshake completed, update last interaction
|
||||
@@ -525,11 +587,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
func clearAllIdentityData() {
|
||||
SecureLogger.warning("Clearing all identity data", category: .security)
|
||||
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.cache = IdentityCache()
|
||||
self.ephemeralSessions.removeAll()
|
||||
self.cryptographicIdentities.removeAll()
|
||||
|
||||
|
||||
// Delete from keychain
|
||||
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
|
||||
SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
|
||||
@@ -537,7 +598,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
@@ -547,7 +608,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
func setVerified(fingerprint: String, verified: Bool) {
|
||||
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
|
||||
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
if verified {
|
||||
self.cache.verifiedFingerprints.insert(fingerprint)
|
||||
} else {
|
||||
|
||||
@@ -48,14 +48,7 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
|
||||
let base: URLSession
|
||||
|
||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
|
||||
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)
|
||||
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +106,7 @@ private extension NostrRelayManagerDependencies {
|
||||
@MainActor
|
||||
final class NostrRelayManager: ObservableObject {
|
||||
static let shared = NostrRelayManager()
|
||||
// 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.
|
||||
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
|
||||
private(set) static var pendingGiftWrapIDs = Set<String>()
|
||||
static func registerPendingGiftWrap(id: String) {
|
||||
pendingGiftWrapIDs.insert(id)
|
||||
@@ -222,33 +212,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
||||
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() {
|
||||
self.dependencies = .live()
|
||||
hasMutualFavorites = dependencies.hasMutualFavorites()
|
||||
@@ -302,70 +266,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
.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
|
||||
func connect() {
|
||||
// Global network policy gate
|
||||
@@ -380,8 +281,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
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
|
||||
// durable intent (subscriptionRequestState, messageHandlers, parked
|
||||
@@ -411,7 +310,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
teardownAllRelayInboundPipelines()
|
||||
markRelaySocketsClosed(resetState: true)
|
||||
subscriptions.removeAll()
|
||||
pendingSubscriptions.removeAll()
|
||||
@@ -662,7 +560,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeValue(forKey: url)
|
||||
teardownRelayInboundPipeline(for: url)
|
||||
subscriptions.removeValue(forKey: url)
|
||||
pendingSubscriptions.removeValue(forKey: url)
|
||||
}
|
||||
@@ -1002,11 +899,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
connections[urlString] = task
|
||||
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
|
||||
receiveMessage(from: task, relayUrl: urlString)
|
||||
|
||||
@@ -1065,13 +958,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
// Hand the raw frame to this relay's serial inbound pipeline:
|
||||
// parsing and signature verification run off-main, in arrival
|
||||
// order, independently of every other relay's pipeline. Routing
|
||||
// through the lock-guarded router keeps this off the main actor
|
||||
// (no per-frame main hop).
|
||||
self.inboundRouter.yield(InboundFrame(message: message), to: relayUrl)
|
||||
|
||||
// Parse off-main to reduce UI jank, then hop back for state updates
|
||||
Task.detached(priority: .utility) {
|
||||
guard let parsed = ParsedInbound(message) else { return }
|
||||
await MainActor.run {
|
||||
self.handleParsedMessage(parsed, from: relayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// Continue receiving
|
||||
Task { @MainActor in
|
||||
self.receiveMessage(from: task, relayUrl: relayUrl)
|
||||
@@ -1089,55 +983,35 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Note: declared at file scope below to avoid MainActor isolation inside this class
|
||||
// and keep parsing off the main actor.
|
||||
|
||||
/// 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)
|
||||
// Handle parsed message on MainActor (state updates and handlers)
|
||||
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
|
||||
switch parsed {
|
||||
case .event:
|
||||
// Events flow through the serial inbound pipeline (precheck →
|
||||
// off-main signature verification → deliverVerifiedInboundEvent)
|
||||
// and never reach this fallback.
|
||||
assertionFailure("inbound EVENT bypassed the verified pipeline")
|
||||
case .event(let subId, let event):
|
||||
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||
self.relays[index].messagesReceived += 1
|
||||
}
|
||||
guard event.isValidSignature() else {
|
||||
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):
|
||||
if var tracker = eoseTrackers[subId] {
|
||||
tracker.pendingRelays.remove(relayUrl)
|
||||
@@ -1232,7 +1106,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
teardownRelayInboundPipeline(for: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||
settleEOSETrackers(droppingRelay: relayUrl)
|
||||
@@ -1321,9 +1194,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
if let connection = connections[normalizedRelayUrl] {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
connections.removeValue(forKey: normalizedRelayUrl)
|
||||
teardownRelayInboundPipeline(for: normalizedRelayUrl)
|
||||
}
|
||||
|
||||
|
||||
// Attempt immediate reconnection
|
||||
connectToRelay(normalizedRelayUrl)
|
||||
}
|
||||
@@ -1416,77 +1288,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// 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 {
|
||||
case event(subId: String, event: NostrEvent)
|
||||
case ok(eventId: String, success: Bool, reason: String)
|
||||
|
||||
@@ -14,8 +14,14 @@ struct BLEAnnounceHandlerEnvironment {
|
||||
let messageTTL: UInt8
|
||||
/// Current time source.
|
||||
let now: () -> Date
|
||||
/// Noise public key already recorded for the peer, if any (registry read).
|
||||
let existingNoisePublicKey: (PeerID) -> Data?
|
||||
/// Noise and signing public keys already recorded for the peer, if any
|
||||
/// (single registry read so both come from one consistent snapshot).
|
||||
let existingPeerKeys: (PeerID) -> (noisePublicKey: Data?, signingPublicKey: Data?)
|
||||
/// Signing key from the persisted cryptographic identity for the peer, if
|
||||
/// any. Registry pins do not survive app restarts or offline-peer
|
||||
/// eviction; this fallback keeps the TOFU signing-key pin effective for
|
||||
/// returning peers.
|
||||
let persistedSigningPublicKey: (PeerID) -> Data?
|
||||
/// Verifies the packet signature against the announced signing key.
|
||||
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||
/// Direct link state for the peer (BLE-queue read).
|
||||
@@ -23,13 +29,15 @@ struct BLEAnnounceHandlerEnvironment {
|
||||
/// Runs the registry mutation phase under the collections barrier.
|
||||
let withRegistryBarrier: (() -> Void) -> Void
|
||||
/// Upserts the verified announce into the peer registry.
|
||||
/// Returns `nil` when the registry refuses the announce because it carries
|
||||
/// a signing key different from the one already pinned for this peer.
|
||||
/// Must only be called from inside `withRegistryBarrier`.
|
||||
let upsertVerifiedAnnounce: (
|
||||
_ peerID: PeerID,
|
||||
_ announcement: AnnouncementPacket,
|
||||
_ isConnected: Bool,
|
||||
_ now: Date
|
||||
) -> BLEPeerAnnounceUpdate
|
||||
) -> BLEPeerAnnounceUpdate?
|
||||
/// Debounced reconnect-log decision.
|
||||
/// Must only be called from inside `withRegistryBarrier`.
|
||||
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
|
||||
@@ -99,7 +107,16 @@ final class BLEAnnounceHandler {
|
||||
// Suppress announce logs to reduce noise
|
||||
|
||||
// Precompute signature verification outside barrier to reduce contention
|
||||
let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
|
||||
var existingPeerKeys = env.existingPeerKeys(peerID)
|
||||
if existingPeerKeys.signingPublicKey == nil {
|
||||
// The registry entry (and its signing-key pin) is dropped on app
|
||||
// restart and offline-peer eviction, but the persisted
|
||||
// cryptographic identity survives both. Fall back to it so a
|
||||
// returning peer is not treated as first contact — otherwise an
|
||||
// attacker could replay the peer's noiseKey/peerID with their own
|
||||
// signing key and re-pin the identity (TOFU downgrade).
|
||||
existingPeerKeys.signingPublicKey = env.persistedSigningPublicKey(peerID)
|
||||
}
|
||||
let hasSignature = packet.signature != nil
|
||||
let signatureValid: Bool
|
||||
if hasSignature {
|
||||
@@ -113,13 +130,18 @@ final class BLEAnnounceHandler {
|
||||
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: hasSignature,
|
||||
signatureValid: signatureValid,
|
||||
existingNoisePublicKey: existingNoisePublicKey,
|
||||
announcedNoisePublicKey: announcement.noisePublicKey
|
||||
existingNoisePublicKey: existingPeerKeys.noisePublicKey,
|
||||
announcedNoisePublicKey: announcement.noisePublicKey,
|
||||
existingSigningPublicKey: existingPeerKeys.signingPublicKey,
|
||||
announcedSigningPublicKey: announcement.signingPublicKey
|
||||
)
|
||||
if case .reject(.keyMismatch) = trustDecision {
|
||||
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
||||
}
|
||||
let verifiedAnnounce = trustDecision.isVerified
|
||||
if case .reject(.signingKeyMismatch) = trustDecision {
|
||||
SecureLogger.warning("🚨 Announce signing-key mismatch for \(peerID.id.prefix(8))… — refusing to replace pinned signing key (possible impersonation attempt)", category: .security)
|
||||
}
|
||||
var verifiedAnnounce = trustDecision.isVerified
|
||||
|
||||
var isNewPeer = false
|
||||
var isReconnectedPeer = false
|
||||
@@ -139,12 +161,22 @@ final class BLEAnnounceHandler {
|
||||
return
|
||||
}
|
||||
|
||||
let update = env.upsertVerifiedAnnounce(
|
||||
// The registry re-checks the signing-key pin inside the barrier.
|
||||
// The pre-barrier trust check reads the registry outside the
|
||||
// barrier, so this closes the race where two announces for the
|
||||
// same peer are evaluated concurrently.
|
||||
guard let update = env.upsertVerifiedAnnounce(
|
||||
peerID,
|
||||
announcement,
|
||||
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||
now
|
||||
)
|
||||
) else {
|
||||
SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security)
|
||||
verifiedAnnounce = false
|
||||
isNewPeer = false
|
||||
isReconnectedPeer = false
|
||||
return
|
||||
}
|
||||
isNewPeer = update.isNewPeer
|
||||
isReconnectedPeer = update.wasDisconnected
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ enum BLEAnnounceTrustRejection: Equatable {
|
||||
case missingSignature
|
||||
case invalidSignature
|
||||
case keyMismatch
|
||||
case signingKeyMismatch
|
||||
}
|
||||
|
||||
enum BLEAnnounceTrustDecision: Equatable {
|
||||
@@ -72,12 +73,25 @@ enum BLEAnnounceTrustPolicy {
|
||||
hasSignature: Bool,
|
||||
signatureValid: Bool,
|
||||
existingNoisePublicKey: Data?,
|
||||
announcedNoisePublicKey: Data
|
||||
announcedNoisePublicKey: Data,
|
||||
existingSigningPublicKey: Data?,
|
||||
announcedSigningPublicKey: Data
|
||||
) -> BLEAnnounceTrustDecision {
|
||||
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||
return .reject(.keyMismatch)
|
||||
}
|
||||
|
||||
// TOFU signing-key pinning. The packet signature only proves the
|
||||
// announce is self-consistent — it is verified against the Ed25519 key
|
||||
// carried *inside the same announce*. Since peerIDs derive from the
|
||||
// broadcast (public) noise key, an attacker can replay a victim's
|
||||
// peerID+noiseKey with their own signing key and a valid
|
||||
// self-signature. Once we have bound a signing key to this peer,
|
||||
// refuse to silently replace it.
|
||||
if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey {
|
||||
return .reject(.signingKeyMismatch)
|
||||
}
|
||||
|
||||
guard hasSignature else {
|
||||
return .reject(.missingSignature)
|
||||
}
|
||||
|
||||
@@ -150,6 +150,14 @@ struct BLEPeerRegistry {
|
||||
peers[peerID] = peer
|
||||
}
|
||||
|
||||
/// Applies a verified announce to the registry.
|
||||
///
|
||||
/// TOFU signing-key pinning: once a signing key has been bound to this
|
||||
/// peer entry, an announce carrying a *different* signing key is refused
|
||||
/// (returns `nil`) and the existing record is left untouched. PeerIDs are
|
||||
/// derived from the (public) noise key, so without pinning an attacker
|
||||
/// could replay a victim's noiseKey/peerID with their own signing key and
|
||||
/// silently take over the victim's mesh identity and nickname.
|
||||
mutating func upsertVerifiedAnnounce(
|
||||
peerID: PeerID,
|
||||
nickname: String,
|
||||
@@ -157,8 +165,15 @@ struct BLEPeerRegistry {
|
||||
signingPublicKey: Data?,
|
||||
isConnected: Bool,
|
||||
now: Date
|
||||
) -> BLEPeerAnnounceUpdate {
|
||||
) -> BLEPeerAnnounceUpdate? {
|
||||
let existing = peers[peerID]
|
||||
|
||||
if let pinnedSigningKey = existing?.signingPublicKey,
|
||||
let announcedSigningKey = signingPublicKey,
|
||||
pinnedSigningKey != announcedSigningKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
let update = BLEPeerAnnounceUpdate(
|
||||
isNewPeer: existing == nil,
|
||||
wasDisconnected: existing?.isConnected == false,
|
||||
@@ -170,7 +185,8 @@ struct BLEPeerRegistry {
|
||||
nickname: nickname,
|
||||
isConnected: isConnected,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
// Never drop an already-pinned signing key.
|
||||
signingPublicKey: signingPublicKey ?? existing?.signingPublicKey,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: now
|
||||
)
|
||||
|
||||
@@ -3028,9 +3028,21 @@ extension BLEService {
|
||||
},
|
||||
messageTTL: messageTTL,
|
||||
now: { Date() },
|
||||
existingNoisePublicKey: { [weak self] peerID in
|
||||
existingPeerKeys: { [weak self] peerID in
|
||||
guard let self = self else { return (nil, nil) }
|
||||
return self.collectionsQueue.sync {
|
||||
let info = self.peerRegistry.info(for: peerID)
|
||||
return (info?.noisePublicKey, info?.signingPublicKey)
|
||||
}
|
||||
},
|
||||
persistedSigningPublicKey: { [weak self] peerID in
|
||||
// Same synchronous identity-manager read pattern as
|
||||
// signedSenderDisplayName(for:from:); the manager serializes
|
||||
// access on its own internal queue.
|
||||
guard let self = self else { return nil }
|
||||
return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
|
||||
return self.identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
||||
.compactMap { $0.signingPublicKey }
|
||||
.first
|
||||
},
|
||||
verifySignature: { [weak self] packet, signingPublicKey in
|
||||
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||||
@@ -3043,14 +3055,17 @@ extension BLEService {
|
||||
},
|
||||
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
|
||||
// Called from inside withRegistryBarrier; access registry directly.
|
||||
self?.peerRegistry.upsertVerifiedAnnounce(
|
||||
guard let self = self else {
|
||||
return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||
}
|
||||
return self.peerRegistry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: announcement.nickname,
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
signingPublicKey: announcement.signingPublicKey,
|
||||
isConnected: isConnected,
|
||||
now: now
|
||||
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||
)
|
||||
},
|
||||
shouldEmitReconnectLog: { [weak self] peerID, now in
|
||||
// Called from inside withRegistryBarrier; access debouncer directly.
|
||||
|
||||
@@ -55,26 +55,6 @@ enum TransportConfig {
|
||||
static let nostrDuplicateEventLogInterval: Int = 50
|
||||
// Sample interval for per-event debug logs on the inbound hot path.
|
||||
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)
|
||||
// Sample interval for the periodic store-audit "OK" heartbeat line
|
||||
|
||||
@@ -1177,11 +1177,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// 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
|
||||
|
||||
// Clear Nostr identity associations
|
||||
|
||||
@@ -103,8 +103,7 @@ final class GeoPresenceTracker {
|
||||
else {
|
||||
return
|
||||
}
|
||||
// The signature was already verified (exactly once, off the main
|
||||
// actor) by NostrRelayManager before delivery.
|
||||
guard event.isValidSignature() else { return }
|
||||
guard shouldProcessGeoSamplingEvent(event.id) else { return }
|
||||
|
||||
let existingCount = context.geoParticipantCount(for: gh)
|
||||
|
||||
@@ -75,38 +75,20 @@ extension ChatViewModel: NostrInboundPipelineContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// The inbound Nostr hot path: verified relay events in, chat messages /
|
||||
/// Noise payloads out. Pure transformation plus dedup — no relay lifecycle.
|
||||
/// The inbound Nostr hot path: raw relay events in, chat messages / Noise
|
||||
/// payloads out. Pure transformation plus dedup — no relay lifecycle.
|
||||
///
|
||||
/// Every event arriving here already had its Schnorr signature verified
|
||||
/// exactly once, off the main actor, by `NostrRelayManager`'s serial inbound
|
||||
/// pipeline (which records events into its own dedup cache only AFTER
|
||||
/// verification, so forged copies can't suppress genuine events). This
|
||||
/// pipeline therefore never re-verifies; it keeps its own event-ID dedup
|
||||
/// (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.
|
||||
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
|
||||
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
|
||||
/// dominate real relay traffic; events are recorded only AFTER verification so
|
||||
/// a forged-signature copy can never poison the dedup set; gift-wrap
|
||||
/// verification for the account mailbox runs off-main with an atomic
|
||||
/// main-actor check-and-record.
|
||||
final class NostrInboundPipeline {
|
||||
private weak var context: (any NostrInboundPipelineContext)?
|
||||
private let presence: GeoPresenceTracker
|
||||
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) {
|
||||
self.context = context
|
||||
self.presence = presence
|
||||
@@ -115,15 +97,17 @@ final class NostrInboundPipeline {
|
||||
@MainActor
|
||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||
guard let context else { return }
|
||||
// Cheap rejects (kind, dedup lookup) — duplicates dominate real
|
||||
// traffic. The signature was already verified (exactly once, off the
|
||||
// main actor) by NostrRelayManager before delivery.
|
||||
// Cheap rejects (kind, dedup lookup) before Schnorr verification —
|
||||
// duplicates dominate real traffic and must not pay for crypto.
|
||||
// Only verified events are recorded, so a forged-signature copy can
|
||||
// never poison the dedup set and suppress the genuine event.
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
||||
!context.hasProcessedNostrEvent(event.id)
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard event.isValidSignature() else { return }
|
||||
|
||||
context.recordProcessedNostrEvent(event.id)
|
||||
|
||||
@@ -192,14 +176,15 @@ final class NostrInboundPipeline {
|
||||
@MainActor
|
||||
func handleNostrEvent(_ event: NostrEvent) {
|
||||
guard let context else { return }
|
||||
// Cheap rejects (kind, dedup lookup) — the signature was already
|
||||
// verified (exactly once, off the main actor) by NostrRelayManager.
|
||||
// Cheap rejects (kind, dedup lookup) before Schnorr verification —
|
||||
// duplicates dominate real traffic and must not pay for crypto.
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
||||
else {
|
||||
return
|
||||
}
|
||||
if context.hasProcessedNostrEvent(event.id) { return }
|
||||
guard event.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(event.id)
|
||||
|
||||
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
||||
@@ -279,126 +264,104 @@ final class NostrInboundPipeline {
|
||||
@MainActor
|
||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard let context else { return }
|
||||
// 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.
|
||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
|
||||
// Capture the wipe generation at spawn, alongside the per-geohash
|
||||
// identity (private key) the detached task strongly captures. A panic
|
||||
// wipe between spawn and delivery bumps the generation, and the task
|
||||
// drops its result instead of delivering plaintext post-wipe.
|
||||
let wipeGeneration = self.wipeGeneration
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: false, wipeGeneration: wipeGeneration)
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: id
|
||||
),
|
||||
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let noisePayload = NoisePayload.decode(packet.payload)
|
||||
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
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard let context else { return }
|
||||
// Cheap dedup pre-check only; see subscribeGiftWrap.
|
||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) {
|
||||
return
|
||||
}
|
||||
|
||||
// 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 giftWrap.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: id
|
||||
) else {
|
||||
if verbose {
|
||||
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
||||
}
|
||||
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
if verbose {
|
||||
SecureLogger.debug(
|
||||
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
||||
category: .session
|
||||
)
|
||||
SecureLogger.debug(
|
||||
"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
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
// A panic wipe during the off-main decrypt must not let the
|
||||
// pre-wipe plaintext reach post-wipe state; drop it here, atomic
|
||||
// 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)
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||
guard let context else { return }
|
||||
// Cheap dedup pre-check only; processNostrMessage does the
|
||||
// authoritative check-and-record before the off-main NIP-17 unwrap.
|
||||
// The outer signature was already verified (exactly once, off the
|
||||
// main actor) by NostrRelayManager, and only verified events are
|
||||
// recorded, so a forged-signature copy can never poison the dedup
|
||||
// set and suppress the genuine event.
|
||||
// Cheap dedup pre-check only; Schnorr verification runs off-main in
|
||||
// processNostrMessage, which then does the authoritative
|
||||
// check-and-record. Recording stays after verification so a
|
||||
// forged-signature copy can never poison the dedup set and suppress
|
||||
// the genuine event.
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) { return }
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
@@ -407,6 +370,7 @@ final class NostrInboundPipeline {
|
||||
}
|
||||
|
||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
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.
|
||||
@@ -416,13 +380,8 @@ final class NostrInboundPipeline {
|
||||
return false
|
||||
}
|
||||
if alreadyProcessed { return }
|
||||
// Fetch the identity and the wipe generation in ONE main-actor hop:
|
||||
// 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)
|
||||
let currentIdentity: NostrIdentity? = await MainActor.run {
|
||||
context.currentNostrIdentity()
|
||||
}
|
||||
guard let currentIdentity else { return }
|
||||
|
||||
@@ -454,9 +413,6 @@ final class NostrInboundPipeline {
|
||||
let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
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)
|
||||
|
||||
switch payload.type {
|
||||
|
||||
@@ -382,12 +382,10 @@ struct ChatNostrCoordinatorContextTests {
|
||||
|
||||
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 routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
|
||||
#expect(routed)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
||||
#expect(context.handledPrivateMessages.count == 1)
|
||||
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
|
||||
#expect(context.handledPrivateMessages.first?.convKey == convKey)
|
||||
|
||||
@@ -400,76 +398,30 @@ struct ChatNostrCoordinatorContextTests {
|
||||
|
||||
// The same gift wrap is dropped on replay.
|
||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||
await drainMainQueue()
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
#expect(context.handledPrivateMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_panicWipeAfterSpawnDropsDecryptedResult() async throws {
|
||||
func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
|
||||
let recipient = 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(
|
||||
content: "verify:noop",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
var invalidGiftWrap = giftWrap
|
||||
invalidGiftWrap.sig = String(repeating: "0", count: 128)
|
||||
|
||||
// Fan-in of the same (already verified) gift wrap from several relays
|
||||
// records and processes exactly once.
|
||||
await coordinator.inbound.processNostrMessage(giftWrap)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
// A forged-signature copy is rejected WITHOUT entering the dedup set...
|
||||
await coordinator.inbound.processNostrMessage(invalidGiftWrap)
|
||||
#expect(context.recordedNostrEventIDs.isEmpty)
|
||||
|
||||
// ...so the genuine event with the same ID still processes and records.
|
||||
await coordinator.inbound.processNostrMessage(giftWrap)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
}
|
||||
|
||||
@@ -352,11 +352,31 @@ struct ChatViewModelNostrExtensionTests {
|
||||
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
|
||||
}
|
||||
|
||||
// NOTE: Tampered-signature rejection is enforced once, off the main
|
||||
// actor, at the relay boundary (events only reach the inbound pipeline
|
||||
// after verification) — see NostrRelayManagerTests
|
||||
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and
|
||||
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_rejectsInvalidSignature() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let identity = try NostrIdentity.generate()
|
||||
|
||||
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
|
||||
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
|
||||
@@ -545,14 +565,9 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
|
||||
// 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)
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
#expect(viewModel.privateChats[convKey] == nil)
|
||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
|
||||
@@ -326,11 +326,26 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
#expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1)
|
||||
}
|
||||
|
||||
// NOTE: Tampered-signature rejection (and the forged-copy dedup-poisoning
|
||||
// invariant) is enforced once, off the main actor, at the relay boundary —
|
||||
// the sampling path only ever sees verified events. See
|
||||
// NostrRelayManagerTests
|
||||
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache`.
|
||||
@Test func subscribeNostrEvent_samplingInvalidSignatureDoesNotPoisonDedup() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sampleGeohash = "u4pru"
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
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
|
||||
|
||||
|
||||
@@ -77,10 +77,8 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
// MARK: - 1a. Nostr inbound event handling (fresh events)
|
||||
|
||||
/// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events
|
||||
/// (kind 20000): dedup record, presence/nickname bookkeeping, and
|
||||
/// 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.
|
||||
/// (kind 20000): signature verification, dedup record, presence/nickname
|
||||
/// bookkeeping, and public-message ingest scheduling.
|
||||
func testNostrInboundEventHandling_freshEvents() throws {
|
||||
let events = try Self.makeSignedGeohashEvents(count: 500)
|
||||
// A fresh context per measure pass so every event takes the
|
||||
@@ -108,9 +106,8 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
|
||||
/// The dedup-hit path: identical events replayed. Duplicates dominate
|
||||
/// real relay traffic (the same event arrives from several relays), so
|
||||
/// this path runs hundreds of times a minute in busy geohashes. It is a
|
||||
/// pure dedup lookup: no crypto (verification happens upstream in
|
||||
/// `NostrRelayManager`, and only for the first-seen copy).
|
||||
/// this path runs hundreds of times a minute in busy geohashes. Note it
|
||||
/// still pays full Schnorr signature verification before the dedup check.
|
||||
func testNostrInboundEventHandling_duplicateEvents() throws {
|
||||
let events = try Self.makeSignedGeohashEvents(count: 500)
|
||||
let context = PerfNostrContext()
|
||||
|
||||
@@ -6,9 +6,12 @@ import Testing
|
||||
struct BLEAnnounceHandlerTests {
|
||||
private final class Recorder {
|
||||
var existingNoisePublicKey: Data?
|
||||
var existingSigningPublicKey: Data?
|
||||
var persistedSigningPublicKey: Data?
|
||||
var persistedSigningKeyQueries: [PeerID] = []
|
||||
var signatureValid = true
|
||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||
var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||
var upsertResult: BLEPeerAnnounceUpdate? = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||
var dedupSeenIDs: Set<String> = []
|
||||
var shouldEmitReconnectLogResult = true
|
||||
|
||||
@@ -35,7 +38,11 @@ struct BLEAnnounceHandlerTests {
|
||||
localPeerID: { localPeerID },
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { now },
|
||||
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
||||
existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) },
|
||||
persistedSigningPublicKey: { peerID in
|
||||
recorder.persistedSigningKeyQueries.append(peerID)
|
||||
return recorder.persistedSigningPublicKey
|
||||
},
|
||||
verifySignature: { packet, signingPublicKey in
|
||||
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
||||
return recorder.signatureValid
|
||||
@@ -368,6 +375,168 @@ struct BLEAnnounceHandlerTests {
|
||||
#expect(recorder.topologyUpdates.first?.neighbors == neighbors)
|
||||
}
|
||||
|
||||
@Test
|
||||
func matchingPinnedSigningKeyIsAccepted() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x9A, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.existingNoisePublicKey = noiseKey
|
||||
// Matches the signing key encoded by makeAnnouncePacket.
|
||||
recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.upsertCalls.count == 1)
|
||||
#expect(recorder.persistedIdentities.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func signingKeyMismatchWithPinnedKeySkipsUpsertAndIdentityPersistence() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x9B, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
// Attacker announce: victim's noiseKey/peerID, attacker's signing key
|
||||
// (0x99 from makeAnnouncePacket) with a "valid" self-signature.
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.existingNoisePublicKey = noiseKey
|
||||
recorder.existingSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's pinned key
|
||||
recorder.signatureValid = true
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
#expect(recorder.persistedIdentities.isEmpty)
|
||||
#expect(recorder.topologyUpdates.isEmpty)
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func persistedSigningKeyMismatchWithoutRegistryEntryIsRejected() throws {
|
||||
// Registry has no entry (app restart or offline-peer eviction), but
|
||||
// the persisted cryptographic identity still pins the victim's
|
||||
// signing key. An attacker replaying the victim's noiseKey/peerID
|
||||
// with their own signing key must not be treated as first contact.
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x9D, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.existingNoisePublicKey = nil
|
||||
recorder.existingSigningPublicKey = nil
|
||||
recorder.persistedSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's persisted pin
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.persistedSigningKeyQueries == [peerID])
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
#expect(recorder.persistedIdentities.isEmpty)
|
||||
#expect(recorder.topologyUpdates.isEmpty)
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func persistedSigningKeyMatchWithoutRegistryEntryIsAccepted() throws {
|
||||
// Legitimate returning peer: registry entry evicted, persisted pin
|
||||
// matches the announced signing key — accepted like a normal announce.
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x9E, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
// Matches the signing key encoded by makeAnnouncePacket.
|
||||
recorder.persistedSigningPublicKey = Data(repeating: 0x99, count: 32)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.upsertCalls.count == 1)
|
||||
#expect(recorder.persistedIdentities.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func registryPinnedSigningKeySkipsPersistedLookup() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x9F, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.existingNoisePublicKey = noiseKey
|
||||
recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.persistedSigningKeyQueries.isEmpty)
|
||||
#expect(recorder.upsertCalls.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func registryPinRejectionSkipsTopologyAndIdentityPersistence() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x9C, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64),
|
||||
directNeighbors: [Data(repeating: 0xAB, count: 8)]
|
||||
)
|
||||
|
||||
// Pre-barrier trust check sees no pinned key (e.g. concurrent race),
|
||||
// but the registry itself refuses to replace its pinned signing key.
|
||||
let recorder = Recorder()
|
||||
recorder.upsertResult = nil
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.upsertCalls.count == 1)
|
||||
#expect(recorder.persistedIdentities.isEmpty)
|
||||
#expect(recorder.topologyUpdates.isEmpty)
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
#expect(recorder.afterglowDelays.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
@@ -391,6 +560,251 @@ struct BLEAnnounceHandlerTests {
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func attackerReplayingVictimNoiseKeyWithOwnSigningKeyIsRejectedEndToEnd() throws {
|
||||
// Real crypto: the attacker crafts a fully self-consistent announce
|
||||
// (victim's noiseKey/peerID, attacker's signing key and nickname,
|
||||
// valid packet signature made with the attacker's key). Without
|
||||
// signing-key pinning this used to overwrite the victim's registry
|
||||
// entry and persisted identity.
|
||||
let victim = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let attacker = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let victimNoiseKey = victim.getStaticPublicKeyData()
|
||||
let peerID = PeerID(publicKey: victimNoiseKey)
|
||||
let now = Date()
|
||||
|
||||
final class RegistryBox {
|
||||
var registry = BLEPeerRegistry()
|
||||
var persistedIdentities: [AnnouncementPacket] = []
|
||||
}
|
||||
let box = RegistryBox()
|
||||
|
||||
let environment = BLEAnnounceHandlerEnvironment(
|
||||
localPeerID: { PeerID(str: "0102030405060708") },
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { now },
|
||||
existingPeerKeys: { peerID in
|
||||
let info = box.registry.info(for: peerID)
|
||||
return (info?.noisePublicKey, info?.signingPublicKey)
|
||||
},
|
||||
persistedSigningPublicKey: { _ in nil },
|
||||
verifySignature: { packet, signingPublicKey in
|
||||
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
|
||||
},
|
||||
linkState: { _ in (hasPeripheral: true, hasCentral: false) },
|
||||
withRegistryBarrier: { body in body() },
|
||||
upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in
|
||||
box.registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: announcement.nickname,
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
signingPublicKey: announcement.signingPublicKey,
|
||||
isConnected: isConnected,
|
||||
now: now
|
||||
)
|
||||
},
|
||||
shouldEmitReconnectLog: { _, _ in false },
|
||||
updateTopology: { _, _ in },
|
||||
persistIdentity: { announcement in
|
||||
box.persistedIdentities.append(announcement)
|
||||
},
|
||||
dedupContains: { _ in true },
|
||||
dedupMarkProcessed: { _ in },
|
||||
deliverAnnounceUIEvents: { _, _, _ in },
|
||||
trackPacketSeen: { _ in },
|
||||
sendAnnounceBack: {},
|
||||
scheduleAfterglow: { _ in }
|
||||
)
|
||||
let handler = BLEAnnounceHandler(environment: environment)
|
||||
|
||||
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket {
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: nickname,
|
||||
noisePublicKey: victimNoiseKey,
|
||||
signingPublicKey: signer.getSigningPublicKeyData(),
|
||||
directNeighbors: nil
|
||||
)
|
||||
let payload = try #require(announcement.encode())
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data(hexString: peerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
return try #require(signer.signPacket(packet))
|
||||
}
|
||||
|
||||
// Legitimate announce from the victim is accepted and pinned.
|
||||
let victimAnnounce = try makeSignedAnnounce(nickname: "victim", signer: victim)
|
||||
handler.handle(victimAnnounce, from: peerID)
|
||||
|
||||
#expect(box.registry.info(for: peerID)?.nickname == "victim")
|
||||
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
#expect(box.persistedIdentities.count == 1)
|
||||
|
||||
// Attacker announce with a valid self-signature must be rejected.
|
||||
let attackerAnnounce = try makeSignedAnnounce(nickname: "attacker", signer: attacker)
|
||||
handler.handle(attackerAnnounce, from: peerID)
|
||||
|
||||
#expect(box.registry.info(for: peerID)?.nickname == "victim")
|
||||
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
#expect(box.persistedIdentities.count == 1)
|
||||
|
||||
// The victim's subsequent announces (same pinned key) still work.
|
||||
let victimRename = try makeSignedAnnounce(nickname: "victim-renamed", signer: victim)
|
||||
handler.handle(victimRename, from: peerID)
|
||||
|
||||
#expect(box.registry.info(for: peerID)?.nickname == "victim-renamed")
|
||||
#expect(box.persistedIdentities.count == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func signingKeyPinSurvivesRegistryEvictionAndRestartEndToEnd() throws {
|
||||
// Real crypto + real persistence: the victim announces and gets
|
||||
// pinned, then the registry entry disappears (offline-peer eviction
|
||||
// via reconcileConnectivity, or app restart which starts with an
|
||||
// empty registry). The attacker replays the victim's
|
||||
// noiseKey/peerID with their own signing key and a valid
|
||||
// self-signature — the persisted identity must still block the
|
||||
// takeover, and must not be overwritten. The victim (same signing
|
||||
// key) must be re-accepted.
|
||||
let victim = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let attacker = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let victimNoiseKey = victim.getStaticPublicKeyData()
|
||||
let peerID = PeerID(publicKey: victimNoiseKey)
|
||||
let now = Date()
|
||||
|
||||
let identityKeychain = MockKeychain()
|
||||
let identityManager = SecureIdentityStateManager(identityKeychain)
|
||||
|
||||
final class RegistryBox {
|
||||
var registry = BLEPeerRegistry()
|
||||
}
|
||||
let box = RegistryBox()
|
||||
|
||||
func makeEnvironment(identityManager: SecureIdentityStateManager) -> BLEAnnounceHandlerEnvironment {
|
||||
BLEAnnounceHandlerEnvironment(
|
||||
localPeerID: { PeerID(str: "0102030405060708") },
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { now },
|
||||
existingPeerKeys: { peerID in
|
||||
let info = box.registry.info(for: peerID)
|
||||
return (info?.noisePublicKey, info?.signingPublicKey)
|
||||
},
|
||||
// Mirrors the BLEService wiring: fall back to the persisted
|
||||
// cryptographic identity.
|
||||
persistedSigningPublicKey: { peerID in
|
||||
identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
||||
.compactMap { $0.signingPublicKey }
|
||||
.first
|
||||
},
|
||||
verifySignature: { packet, signingPublicKey in
|
||||
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
|
||||
},
|
||||
linkState: { _ in (hasPeripheral: true, hasCentral: false) },
|
||||
withRegistryBarrier: { body in body() },
|
||||
upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in
|
||||
box.registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: announcement.nickname,
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
signingPublicKey: announcement.signingPublicKey,
|
||||
isConnected: isConnected,
|
||||
now: now
|
||||
)
|
||||
},
|
||||
shouldEmitReconnectLog: { _, _ in false },
|
||||
updateTopology: { _, _ in },
|
||||
persistIdentity: { announcement in
|
||||
identityManager.upsertCryptographicIdentity(
|
||||
fingerprint: announcement.noisePublicKey.sha256Fingerprint(),
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
signingPublicKey: announcement.signingPublicKey,
|
||||
claimedNickname: announcement.nickname
|
||||
)
|
||||
},
|
||||
dedupContains: { _ in true },
|
||||
dedupMarkProcessed: { _ in },
|
||||
deliverAnnounceUIEvents: { _, _, _ in },
|
||||
trackPacketSeen: { _ in },
|
||||
sendAnnounceBack: {},
|
||||
scheduleAfterglow: { _ in }
|
||||
)
|
||||
}
|
||||
let handler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: identityManager))
|
||||
|
||||
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket {
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: nickname,
|
||||
noisePublicKey: victimNoiseKey,
|
||||
signingPublicKey: signer.getSigningPublicKeyData(),
|
||||
directNeighbors: nil
|
||||
)
|
||||
let payload = try #require(announcement.encode())
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data(hexString: peerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
return try #require(signer.signPacket(packet))
|
||||
}
|
||||
|
||||
func persistedIdentity() -> CryptographicIdentity? {
|
||||
// queue.sync read; fences the manager's pending barrier writes.
|
||||
identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first
|
||||
}
|
||||
|
||||
// 1. Victim announces: pinned in the registry and persisted.
|
||||
handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
|
||||
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
#expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
|
||||
// 2. Registry entry disappears (eviction / restart).
|
||||
_ = box.registry.remove(peerID)
|
||||
#expect(box.registry.info(for: peerID) == nil)
|
||||
|
||||
// 3. Attacker replay with own signing key: rejected via the persisted
|
||||
// pin, and neither the registry nor the persisted identity change.
|
||||
handler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID)
|
||||
#expect(box.registry.info(for: peerID) == nil)
|
||||
#expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
#expect(identityManager.getSocialIdentity(for: victimNoiseKey.sha256Fingerprint())?.claimedNickname == "victim")
|
||||
|
||||
// 4. Victim re-announces with the same signing key: accepted again.
|
||||
handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
|
||||
#expect(box.registry.info(for: peerID)?.nickname == "victim")
|
||||
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
|
||||
// 5. Simulated app restart: a fresh identity manager reloads the pin
|
||||
// from the (mock) keychain, and a fresh registry starts empty. The
|
||||
// attacker replay is still rejected.
|
||||
identityManager.forceSave()
|
||||
let reloadedManager = SecureIdentityStateManager(identityKeychain)
|
||||
#expect(
|
||||
reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey
|
||||
== victim.getSigningPublicKeyData()
|
||||
)
|
||||
box.registry = BLEPeerRegistry()
|
||||
let restartedHandler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: reloadedManager))
|
||||
restartedHandler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID)
|
||||
#expect(box.registry.info(for: peerID) == nil)
|
||||
#expect(
|
||||
reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey
|
||||
== victim.getSigningPublicKeyData()
|
||||
)
|
||||
|
||||
// ...while the victim is accepted after the restart.
|
||||
restartedHandler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
|
||||
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
}
|
||||
|
||||
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||
#expect(recorder.barrierCount == 0)
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
|
||||
@@ -123,7 +123,9 @@ struct BLEAnnounceHandlingPolicyTests {
|
||||
hasSignature: false,
|
||||
signatureValid: false,
|
||||
existingNoisePublicKey: nil,
|
||||
announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
|
||||
announcedNoisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
existingSigningPublicKey: nil,
|
||||
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.missingSignature))
|
||||
@@ -136,7 +138,9 @@ struct BLEAnnounceHandlingPolicyTests {
|
||||
hasSignature: true,
|
||||
signatureValid: false,
|
||||
existingNoisePublicKey: nil,
|
||||
announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
|
||||
announcedNoisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
existingSigningPublicKey: nil,
|
||||
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.invalidSignature))
|
||||
@@ -148,7 +152,9 @@ struct BLEAnnounceHandlingPolicyTests {
|
||||
hasSignature: true,
|
||||
signatureValid: true,
|
||||
existingNoisePublicKey: Data(repeating: 0xAA, count: 32),
|
||||
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32)
|
||||
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32),
|
||||
existingSigningPublicKey: nil,
|
||||
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.keyMismatch))
|
||||
@@ -162,13 +168,51 @@ struct BLEAnnounceHandlingPolicyTests {
|
||||
hasSignature: true,
|
||||
signatureValid: true,
|
||||
existingNoisePublicKey: noiseKey,
|
||||
announcedNoisePublicKey: noiseKey
|
||||
announcedNoisePublicKey: noiseKey,
|
||||
existingSigningPublicKey: nil,
|
||||
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||
)
|
||||
|
||||
#expect(decision == .verified)
|
||||
#expect(decision.isVerified)
|
||||
}
|
||||
|
||||
@Test
|
||||
func trustPolicyRejectsPinnedSigningKeyMismatchEvenWithValidSignature() {
|
||||
let noiseKey = Data(repeating: 0xCC, count: 32)
|
||||
|
||||
// Attacker replays the victim's noiseKey/peerID with their own signing
|
||||
// key and a valid self-signature; the pinned key must win.
|
||||
let decision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: true,
|
||||
signatureValid: true,
|
||||
existingNoisePublicKey: noiseKey,
|
||||
announcedNoisePublicKey: noiseKey,
|
||||
existingSigningPublicKey: Data(repeating: 0x99, count: 32),
|
||||
announcedSigningPublicKey: Data(repeating: 0x66, count: 32)
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.signingKeyMismatch))
|
||||
#expect(!decision.isVerified)
|
||||
}
|
||||
|
||||
@Test
|
||||
func trustPolicyAcceptsMatchingPinnedSigningKey() {
|
||||
let noiseKey = Data(repeating: 0xCC, count: 32)
|
||||
let signingKey = Data(repeating: 0x99, count: 32)
|
||||
|
||||
let decision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: true,
|
||||
signatureValid: true,
|
||||
existingNoisePublicKey: noiseKey,
|
||||
announcedNoisePublicKey: noiseKey,
|
||||
existingSigningPublicKey: signingKey,
|
||||
announcedSigningPublicKey: signingKey
|
||||
)
|
||||
|
||||
#expect(decision == .verified)
|
||||
}
|
||||
|
||||
@Test
|
||||
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
||||
let directNew = BLEAnnounceResponsePolicy.plan(
|
||||
|
||||
@@ -6,12 +6,12 @@ import Testing
|
||||
@Suite("BLE peer registry tests")
|
||||
struct BLEPeerRegistryTests {
|
||||
@Test("upserted announces track new, reconnect, and rename transitions")
|
||||
func upsertVerifiedAnnounceTracksTransitions() {
|
||||
func upsertVerifiedAnnounceTracksTransitions() throws {
|
||||
var registry = BLEPeerRegistry()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let firstSeen = Date(timeIntervalSince1970: 100)
|
||||
|
||||
let first = registry.upsertVerifiedAnnounce(
|
||||
let firstResult = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "alice",
|
||||
noisePublicKey: Data([1, 2, 3]),
|
||||
@@ -19,6 +19,7 @@ struct BLEPeerRegistryTests {
|
||||
isConnected: true,
|
||||
now: firstSeen
|
||||
)
|
||||
let first = try #require(firstResult)
|
||||
|
||||
#expect(first.isNewPeer)
|
||||
#expect(!first.wasDisconnected)
|
||||
@@ -27,7 +28,7 @@ struct BLEPeerRegistryTests {
|
||||
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
|
||||
|
||||
registry.markDisconnected(peerID)
|
||||
let reconnect = registry.upsertVerifiedAnnounce(
|
||||
let reconnectResult = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "alice-renamed",
|
||||
noisePublicKey: Data([1, 2, 3]),
|
||||
@@ -35,6 +36,7 @@ struct BLEPeerRegistryTests {
|
||||
isConnected: true,
|
||||
now: firstSeen.addingTimeInterval(1)
|
||||
)
|
||||
let reconnect = try #require(reconnectResult)
|
||||
|
||||
#expect(!reconnect.isNewPeer)
|
||||
#expect(reconnect.wasDisconnected)
|
||||
@@ -42,6 +44,85 @@ struct BLEPeerRegistryTests {
|
||||
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
|
||||
}
|
||||
|
||||
@Test("pinned signing key cannot be silently replaced by a later announce")
|
||||
func upsertVerifiedAnnounceRefusesToReplacePinnedSigningKey() throws {
|
||||
var registry = BLEPeerRegistry()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let noiseKey = Data(repeating: 0x11, count: 32)
|
||||
let victimSigningKey = Data(repeating: 0x42, count: 32)
|
||||
let attackerSigningKey = Data(repeating: 0x66, count: 32)
|
||||
let firstSeen = Date(timeIntervalSince1970: 100)
|
||||
|
||||
let pinResult = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "victim",
|
||||
noisePublicKey: noiseKey,
|
||||
signingPublicKey: victimSigningKey,
|
||||
isConnected: true,
|
||||
now: firstSeen
|
||||
)
|
||||
#expect(pinResult != nil)
|
||||
|
||||
// Attacker replays the victim's noiseKey/peerID with their own
|
||||
// signing key and nickname; the upsert must be refused wholesale.
|
||||
let attack = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "attacker",
|
||||
noisePublicKey: noiseKey,
|
||||
signingPublicKey: attackerSigningKey,
|
||||
isConnected: true,
|
||||
now: firstSeen.addingTimeInterval(1)
|
||||
)
|
||||
|
||||
#expect(attack == nil)
|
||||
let info = try #require(registry.info(for: peerID))
|
||||
#expect(info.nickname == "victim")
|
||||
#expect(info.signingPublicKey == victimSigningKey)
|
||||
|
||||
// A legitimate re-announce with the pinned key is still accepted.
|
||||
let legit = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "victim-renamed",
|
||||
noisePublicKey: noiseKey,
|
||||
signingPublicKey: victimSigningKey,
|
||||
isConnected: true,
|
||||
now: firstSeen.addingTimeInterval(2)
|
||||
)
|
||||
#expect(legit != nil)
|
||||
#expect(registry.info(for: peerID)?.nickname == "victim-renamed")
|
||||
}
|
||||
|
||||
@Test("announce without a signing key keeps the pinned key")
|
||||
func upsertVerifiedAnnounceKeepsPinnedSigningKeyWhenAnnounceOmitsIt() throws {
|
||||
var registry = BLEPeerRegistry()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let noiseKey = Data(repeating: 0x11, count: 32)
|
||||
let signingKey = Data(repeating: 0x42, count: 32)
|
||||
let firstSeen = Date(timeIntervalSince1970: 100)
|
||||
|
||||
let initialResult = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "alice",
|
||||
noisePublicKey: noiseKey,
|
||||
signingPublicKey: signingKey,
|
||||
isConnected: true,
|
||||
now: firstSeen
|
||||
)
|
||||
#expect(initialResult != nil)
|
||||
|
||||
let update = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "alice",
|
||||
noisePublicKey: noiseKey,
|
||||
signingPublicKey: nil,
|
||||
isConnected: true,
|
||||
now: firstSeen.addingTimeInterval(1)
|
||||
)
|
||||
|
||||
#expect(update != nil)
|
||||
#expect(registry.info(for: peerID)?.signingPublicKey == signingKey)
|
||||
}
|
||||
|
||||
@Test("reachability keeps recent verified offline peers only when mesh is attached")
|
||||
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
||||
let offlinePeer = PeerID(str: "1122334455667788")
|
||||
|
||||
@@ -639,15 +639,11 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||
|
||||
// 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 {
|
||||
let countedOnBothRelays = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
|
||||
receivedIDs == [event.id] &&
|
||||
context.manager.debugDuplicateInboundEventDropCount == 1
|
||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
|
||||
}
|
||||
XCTAssertTrue(settled)
|
||||
XCTAssertTrue(countedOnBothRelays)
|
||||
XCTAssertEqual(receivedIDs, [event.id])
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1)
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 1)
|
||||
@@ -680,17 +676,12 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let countedOnEveryRelay = await waitUntil {
|
||||
relayURLs.allSatisfy { relayURL in
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
|
||||
} &&
|
||||
receivedIDs == [event.id] &&
|
||||
context.manager.debugDuplicateInboundEventDropCount == relayURLs.count - 1
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(settled)
|
||||
XCTAssertTrue(countedOnEveryRelay)
|
||||
XCTAssertEqual(receivedIDs, [event.id])
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1)
|
||||
XCTAssertEqual(
|
||||
@@ -723,153 +714,16 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: invalidEvent)
|
||||
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||
|
||||
// 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 {
|
||||
let countedOnBothRelays = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
|
||||
receivedIDs == [event.id]
|
||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
|
||||
}
|
||||
XCTAssertTrue(genuineDelivered)
|
||||
XCTAssertTrue(countedOnBothRelays)
|
||||
XCTAssertEqual(receivedIDs, [event.id])
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 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 {
|
||||
let relayURL = "wss://missing-handler.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
@@ -1841,11 +1695,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
}
|
||||
|
||||
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||
if !pendingResults.isEmpty {
|
||||
completionHandler(pendingResults.removeFirst())
|
||||
} else {
|
||||
receiveHandler = completionHandler
|
||||
}
|
||||
receiveHandler = completionHandler
|
||||
}
|
||||
|
||||
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
|
||||
@@ -1878,24 +1728,15 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
}
|
||||
|
||||
func emitRawString(_ string: String) throws {
|
||||
deliver(.success(.string(string)))
|
||||
let handler = receiveHandler
|
||||
receiveHandler = nil
|
||||
handler?(.success(.string(string)))
|
||||
}
|
||||
|
||||
private func emit(jsonObject: Any) throws {
|
||||
let data = try JSONSerialization.data(withJSONObject: jsonObject)
|
||||
deliver(.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)
|
||||
}
|
||||
let handler = receiveHandler
|
||||
receiveHandler = nil
|
||||
handler?(.success(.data(data)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,100 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey)
|
||||
}
|
||||
|
||||
func test_upsertCryptographicIdentity_refusesToReplacePinnedSigningKey() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let noisePublicKey = Data(repeating: 0x11, count: 32)
|
||||
let fingerprint = noisePublicKey.sha256Fingerprint()
|
||||
let peerID = PeerID(publicKey: noisePublicKey)
|
||||
let victimSigningKey = Data(repeating: 0x22, count: 32)
|
||||
let attackerSigningKey = Data(repeating: 0x66, count: 32)
|
||||
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: victimSigningKey,
|
||||
claimedNickname: "victim"
|
||||
)
|
||||
let pinned = await waitUntil {
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey
|
||||
}
|
||||
XCTAssertTrue(pinned)
|
||||
|
||||
// Attacker upsert with a different signing key must be refused in
|
||||
// full — signing key AND claimed nickname stay the victim's.
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: attackerSigningKey,
|
||||
claimedNickname: "attacker"
|
||||
)
|
||||
|
||||
// Synchronous reads fence the manager's pending barrier writes.
|
||||
XCTAssertEqual(
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
|
||||
victimSigningKey
|
||||
)
|
||||
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim")
|
||||
|
||||
// The legitimate peer (same signing key) can still update.
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: victimSigningKey,
|
||||
claimedNickname: "victim-renamed"
|
||||
)
|
||||
let renamed = await waitUntil {
|
||||
manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "victim-renamed"
|
||||
}
|
||||
XCTAssertTrue(renamed)
|
||||
XCTAssertEqual(
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
|
||||
victimSigningKey
|
||||
)
|
||||
}
|
||||
|
||||
func test_cryptographicIdentity_persistsAcrossReinitAndKeepsSigningKeyPin() async {
|
||||
let keychain = MockKeychain()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
let noisePublicKey = Data(repeating: 0x13, count: 32)
|
||||
let fingerprint = noisePublicKey.sha256Fingerprint()
|
||||
let peerID = PeerID(publicKey: noisePublicKey)
|
||||
let victimSigningKey = Data(repeating: 0x24, count: 32)
|
||||
let attackerSigningKey = Data(repeating: 0x77, count: 32)
|
||||
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: victimSigningKey,
|
||||
claimedNickname: "victim"
|
||||
)
|
||||
let pinned = await waitUntil {
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey
|
||||
}
|
||||
XCTAssertTrue(pinned)
|
||||
manager.forceSave()
|
||||
|
||||
// Simulated app restart: the pin must survive and still refuse a
|
||||
// different signing key.
|
||||
let reloaded = SecureIdentityStateManager(keychain)
|
||||
XCTAssertEqual(
|
||||
reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
|
||||
victimSigningKey
|
||||
)
|
||||
|
||||
reloaded.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: attackerSigningKey,
|
||||
claimedNickname: "attacker"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
|
||||
victimSigningKey
|
||||
)
|
||||
XCTAssertEqual(reloaded.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim")
|
||||
}
|
||||
|
||||
func test_setBlocked_clearsFavoriteState() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "ab", count: 32)
|
||||
|
||||
Reference in New Issue
Block a user