mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
Merge pull request #1345 from permissionlesstech/fix/security-audit-critical-high-batch
Fix security audit findings: 3 critical, 7 high
This commit is contained in:
@@ -151,38 +151,68 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
// Thread safety
|
||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||
|
||||
// Debouncing for keychain saves
|
||||
private var saveTimer: Timer?
|
||||
private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds
|
||||
// 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.)
|
||||
private var pendingSave = false
|
||||
|
||||
|
||||
// Encryption key
|
||||
private let encryptionKey: SymmetricKey
|
||||
/// True when `encryptionKey` is a throwaway generated this session because the
|
||||
/// persisted key could not be read (device locked / access denied). In that
|
||||
/// state we must NOT persist (it would overwrite the real cache with data the
|
||||
/// next launch can't decrypt) and must NOT delete the existing cache.
|
||||
private let encryptionKeyIsEphemeral: Bool
|
||||
|
||||
init(_ keychain: KeychainManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
|
||||
// Generate or retrieve encryption key from keychain
|
||||
|
||||
// Retrieve (or, only on genuine first run, generate) the cache
|
||||
// encryption key. We MUST distinguish "key doesn't exist yet" from a
|
||||
// transient failure (device locked / access denied): the legacy
|
||||
// getIdentityKey(forKey:) collapses both to nil, and generating+saving a
|
||||
// new key deletes the existing one first — permanently orphaning the
|
||||
// encrypted cache on a launch that merely couldn't read the key.
|
||||
let loadedKey: SymmetricKey
|
||||
|
||||
// Try to load from keychain
|
||||
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
|
||||
let keyIsEphemeral: Bool
|
||||
|
||||
switch keychain.getIdentityKeyWithResult(forKey: encryptionKeyName) {
|
||||
case .success(let keyData):
|
||||
loadedKey = SymmetricKey(data: keyData)
|
||||
keyIsEphemeral = false
|
||||
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
|
||||
}
|
||||
// Generate new key if needed
|
||||
else {
|
||||
loadedKey = SymmetricKey(size: .bits256)
|
||||
let keyData = loadedKey.withUnsafeBytes { Data($0) }
|
||||
// Save to keychain
|
||||
|
||||
case .itemNotFound:
|
||||
// Genuine first run: generate and persist a new key.
|
||||
let newKey = SymmetricKey(size: .bits256)
|
||||
let keyData = newKey.withUnsafeBytes { Data($0) }
|
||||
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
|
||||
loadedKey = newKey
|
||||
// If even the save failed, treat the key as ephemeral so we don't
|
||||
// later try to persist a cache the next launch can't read.
|
||||
keyIsEphemeral = !saved
|
||||
SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
|
||||
|
||||
case .deviceLocked, .authenticationFailed, .accessDenied, .otherError:
|
||||
// Transient/critical read failure. Do NOT overwrite the persisted
|
||||
// key. Use a session-only ephemeral key; the real key and cache are
|
||||
// left intact for a healthy launch.
|
||||
SecureLogger.warning("Identity cache key unavailable; using ephemeral key for this session (not persisting)", category: .security)
|
||||
loadedKey = SymmetricKey(size: .bits256)
|
||||
keyIsEphemeral = true
|
||||
}
|
||||
|
||||
|
||||
self.encryptionKey = loadedKey
|
||||
|
||||
// Load identity cache on init
|
||||
loadIdentityCache()
|
||||
self.encryptionKeyIsEphemeral = keyIsEphemeral
|
||||
|
||||
// Only read the persisted cache when we hold the real key; with an
|
||||
// ephemeral key the decrypt would fail and discard the real cache.
|
||||
if !keyIsEphemeral {
|
||||
loadIdentityCache()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -211,23 +241,28 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
|
||||
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
|
||||
/// and persists it on the same serialized context — no timer, nothing left
|
||||
/// scheduled to keep the process alive.
|
||||
private func saveIdentityCache() {
|
||||
// Mark that we need to save
|
||||
pendingSave = true
|
||||
|
||||
// Cancel any existing timer
|
||||
saveTimer?.invalidate()
|
||||
|
||||
// Schedule a new save after the debounce interval
|
||||
saveTimer = Timer.scheduledTimer(withTimeInterval: saveDebounceInterval, repeats: false) { [weak self] _ in
|
||||
self?.performSave()
|
||||
}
|
||||
performSave()
|
||||
}
|
||||
|
||||
|
||||
/// Writes the cache to the keychain. Must run on `queue` with exclusive
|
||||
/// (barrier) access.
|
||||
private func performSave() {
|
||||
guard pendingSave else { return }
|
||||
pendingSave = false
|
||||
|
||||
|
||||
// Never persist under an ephemeral key — it would overwrite the real
|
||||
// cache with data the next launch cannot decrypt.
|
||||
guard !encryptionKeyIsEphemeral else {
|
||||
SecureLogger.debug("Skipping identity cache save (ephemeral key this session)", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try JSONEncoder().encode(cache)
|
||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||
@@ -239,10 +274,14 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
SecureLogger.error(error, context: "Failed to save identity cache", category: .security)
|
||||
}
|
||||
}
|
||||
|
||||
// Force immediate save (for app termination)
|
||||
|
||||
// Force immediate save (for app termination / lifecycle events). Mutations
|
||||
// already persist synchronously via saveIdentityCache, so this is normally a
|
||||
// no-op (performSave early-returns when nothing is pending). Runs directly on
|
||||
// the caller's thread — deliberately NOT a `queue.sync(barrier)`, which is
|
||||
// reachable from `deinit` and from async tests on the swift-concurrency
|
||||
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
|
||||
func forceSave() {
|
||||
saveTimer?.invalidate()
|
||||
performSave()
|
||||
}
|
||||
|
||||
|
||||
@@ -322,6 +322,13 @@ final class NoiseCipherState {
|
||||
throw NoiseError.replayDetected
|
||||
}
|
||||
|
||||
// The 4-byte nonce prefix has been stripped, so the remaining bytes
|
||||
// must still hold at least the 16-byte Poly1305 tag. The up-front
|
||||
// `ciphertext.count >= 16` guard is not sufficient here (it counts
|
||||
// the nonce), and `prefix(count - 16)` would trap on a short payload.
|
||||
guard actualCiphertext.count >= 16 else {
|
||||
throw NoiseError.invalidCiphertext
|
||||
}
|
||||
// Split ciphertext and tag
|
||||
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
||||
tag = actualCiphertext.suffix(16)
|
||||
|
||||
@@ -82,6 +82,13 @@ final class NostrIdentityBridge {
|
||||
}
|
||||
|
||||
deviceSeedCache = nil
|
||||
// Also drop the in-memory derived per-geohash identities. These hold the
|
||||
// actual secp256k1 private keys; if left cached, post-panic geohash
|
||||
// messages would still be signed with pre-panic keys (linkable across the
|
||||
// wipe) until the app is force-quit.
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache.removeAll()
|
||||
cacheLock.unlock()
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
@@ -39,22 +39,23 @@ struct NostrProtocol {
|
||||
content: content
|
||||
)
|
||||
|
||||
// 2. Create ephemeral key for this message
|
||||
let ephemeralKey = try P256K.Schnorr.PrivateKey()
|
||||
// Created ephemeral key for seal
|
||||
|
||||
// 3. Seal the rumor (encrypt to recipient)
|
||||
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
|
||||
// real identity key. NIP-17 requires the seal be signed by the sender
|
||||
// so the recipient can authenticate who sent the message; signing with
|
||||
// a throwaway key leaves DMs forgeable/impersonatable.
|
||||
let senderKey = try senderIdentity.schnorrSigningKey()
|
||||
let sealedEvent = try createSeal(
|
||||
rumor: rumor,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: ephemeralKey
|
||||
senderKey: senderKey
|
||||
)
|
||||
|
||||
// 4. Gift wrap the sealed event (encrypt to recipient again)
|
||||
|
||||
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
|
||||
// layer hides the sender's identity from relays; createGiftWrap mints
|
||||
// its own ephemeral key internally).
|
||||
let giftWrap = try createGiftWrap(
|
||||
seal: sealedEvent,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: ephemeralKey
|
||||
recipientPubkey: recipientPubkey
|
||||
)
|
||||
|
||||
// Created gift wrap
|
||||
@@ -84,7 +85,15 @@ struct NostrProtocol {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 2. Open the seal
|
||||
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
|
||||
// identity key (NIP-17); without this check a DM is forgeable by anyone
|
||||
// who knows the recipient's npub. Verify the seal's own signature.
|
||||
guard seal.isValidSignature() else {
|
||||
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
// 3. Open the seal
|
||||
let rumor: NostrEvent
|
||||
do {
|
||||
rumor = try openSeal(
|
||||
@@ -96,10 +105,63 @@ struct NostrProtocol {
|
||||
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
|
||||
throw error
|
||||
}
|
||||
|
||||
return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at)
|
||||
|
||||
// 4. The sender claimed inside the rumor must match the key that actually
|
||||
// signed the seal, otherwise the sender field is unauthenticated and
|
||||
// spoofable.
|
||||
guard seal.pubkey == rumor.pubkey else {
|
||||
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
// Return the seal signer's pubkey as the authenticated sender.
|
||||
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let rumor = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .dm,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
var seal = try createSeal(
|
||||
rumor: rumor,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: senderIdentity.schnorrSigningKey()
|
||||
)
|
||||
seal.sig = String(repeating: "0", count: 128)
|
||||
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||
}
|
||||
|
||||
static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
rumorIdentity: NostrIdentity,
|
||||
sealSignerIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let rumor = NostrEvent(
|
||||
pubkey: rumorIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .dm,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
let seal = try createSeal(
|
||||
rumor: rumor,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: sealSignerIdentity.schnorrSigningKey()
|
||||
)
|
||||
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
||||
static func createEphemeralGeohashEvent(
|
||||
content: String,
|
||||
@@ -195,10 +257,9 @@ struct NostrProtocol {
|
||||
|
||||
private static func createGiftWrap(
|
||||
seal: NostrEvent,
|
||||
recipientPubkey: String,
|
||||
senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal
|
||||
recipientPubkey: String
|
||||
) throws -> NostrEvent {
|
||||
|
||||
|
||||
let sealJSON = try seal.jsonString()
|
||||
|
||||
// Create new ephemeral key for gift wrap
|
||||
|
||||
@@ -281,6 +281,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
markRelaySocketsClosed(resetState: false)
|
||||
// Sockets are gone, so per-relay subscription state is cleared — but
|
||||
// durable intent (subscriptionRequestState, messageHandlers, parked
|
||||
// EOSE callbacks) is kept so REQs replay when relays reconnect
|
||||
@@ -298,6 +299,60 @@ final class NostrRelayManager: ObservableObject {
|
||||
torReadyWaitAttempts = 0
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
/// Panic wipe reset: close sockets and drop every user/session-specific
|
||||
/// relay intent without invoking old callbacks. Unlike `disconnect()`, this
|
||||
/// must not preserve subscription replay state because geohash DM handlers
|
||||
/// can capture pre-wipe Nostr private keys.
|
||||
func resetForPanicWipe() {
|
||||
connectionGeneration &+= 1
|
||||
for (_, task) in connections {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
markRelaySocketsClosed(resetState: true)
|
||||
subscriptions.removeAll()
|
||||
pendingSubscriptions.removeAll()
|
||||
messageHandlers.removeAll()
|
||||
subscriptionRequestState.removeAll()
|
||||
subscribeCoalesce.removeAll()
|
||||
eoseTrackers.removeAll()
|
||||
pendingEOSECallbacks.removeAll()
|
||||
pendingTorConnectionURLs.removeAll()
|
||||
awaitingTorForConnections = false
|
||||
torReadyWaitAttempts = 0
|
||||
recentInboundEventKeys.removeAll()
|
||||
recentInboundEventKeyOrder.removeAll()
|
||||
duplicateInboundEventDropCount = 0
|
||||
duplicateInboundEventDropCountBySubscription.removeAll()
|
||||
inboundEventLogCount = 0
|
||||
Self.pendingGiftWrapIDs.removeAll()
|
||||
|
||||
messageQueueLock.lock()
|
||||
messageQueue.removeAll()
|
||||
pendingSendDropCount = 0
|
||||
messageQueueLock.unlock()
|
||||
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
private func markRelaySocketsClosed(resetState: Bool) {
|
||||
let now = dependencies.now()
|
||||
for index in relays.indices {
|
||||
relays[index].isConnected = false
|
||||
relays[index].nextReconnectTime = nil
|
||||
if resetState {
|
||||
relays[index].lastError = nil
|
||||
relays[index].lastConnectedAt = nil
|
||||
relays[index].lastDisconnectedAt = nil
|
||||
relays[index].messagesSent = 0
|
||||
relays[index].messagesReceived = 0
|
||||
relays[index].reconnectAttempts = 0
|
||||
} else {
|
||||
relays[index].lastDisconnectedAt = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||
func ensureConnections(to relayUrls: [String]) {
|
||||
@@ -1170,6 +1225,18 @@ final class NostrRelayManager: ObservableObject {
|
||||
return Set(map.keys)
|
||||
}
|
||||
|
||||
var debugMessageHandlerCount: Int {
|
||||
messageHandlers.count
|
||||
}
|
||||
|
||||
var debugSubscriptionRequestCount: Int {
|
||||
subscriptionRequestState.count
|
||||
}
|
||||
|
||||
var debugPendingEOSECallbackCount: Int {
|
||||
pendingEOSECallbacks.count
|
||||
}
|
||||
|
||||
var debugDuplicateInboundEventDropCount: Int {
|
||||
duplicateInboundEventDropCount
|
||||
}
|
||||
|
||||
@@ -168,8 +168,13 @@ final class BLEAnnounceHandler {
|
||||
env.updateTopology(peerID, neighbors)
|
||||
}
|
||||
|
||||
// Persist cryptographic identity and signing key for robust offline verification
|
||||
env.persistIdentity(announcement)
|
||||
// Persist cryptographic identity and signing key for robust offline
|
||||
// verification — only for verified announces. Persisting unverified
|
||||
// announces would let an attacker who replays a victim's noisePublicKey
|
||||
// overwrite the victim's stored signing key/nickname (identity poisoning).
|
||||
if verifiedAnnounce {
|
||||
env.persistIdentity(announcement)
|
||||
}
|
||||
|
||||
let announceBackID = "announce-back-\(peerID)"
|
||||
let shouldSendBack = !env.dedupContains(announceBackID)
|
||||
|
||||
@@ -16,6 +16,8 @@ struct BLEPublicMessageHandlerEnvironment {
|
||||
let now: () -> Date
|
||||
/// Snapshot of known peers keyed by ID (registry read).
|
||||
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
||||
/// Verifies a packet's signature against a known signing public key.
|
||||
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
||||
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
||||
/// Tracks the broadcast message packet for gossip sync.
|
||||
@@ -68,14 +70,39 @@ final class BLEPublicMessageHandler {
|
||||
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
|
||||
let peersSnapshot = env.peersSnapshot()
|
||||
|
||||
// Public messages are always signed by their sender. `senderID` is
|
||||
// attacker-controlled, so registry membership alone is NOT proof of
|
||||
// identity — a peer in the registry as "verified" could be impersonated
|
||||
// by anyone spoofing their senderID. Require a valid packet signature
|
||||
// from the claimed sender (our own echoes are exempt; they are matched
|
||||
// by self-broadcast tracking below).
|
||||
//
|
||||
// Verify against the signing key already in the (synchronously-updated)
|
||||
// peer registry first: identity-cache persistence is asynchronous, so a
|
||||
// message arriving right after a verified announce would otherwise be
|
||||
// dropped because `signedSenderDisplayName` only searches the persisted
|
||||
// cache. Fall back to that persisted-identity lookup for peers not (yet)
|
||||
// in the registry.
|
||||
let isSelf = peerID == env.localPeerID()
|
||||
let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey
|
||||
let verifiedViaRegistry = !isSelf
|
||||
&& (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
|
||||
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
|
||||
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else {
|
||||
SecureLogger.warning("🚫 Dropping public message with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticity is established; prefer the registry's collision-resolved
|
||||
// display name, then the signature-derived name.
|
||||
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localNickname: env.localNickname(),
|
||||
peers: peersSnapshot,
|
||||
allowConnectedUnverified: false
|
||||
) ?? env.signedSenderDisplayName(packet, peerID) else {
|
||||
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||
) ?? signedDisplayName else {
|
||||
SecureLogger.warning("🚫 Dropping public message from unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ struct BLEReceivedPacketContext: Equatable {
|
||||
struct BLEReceivePipeline {
|
||||
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
|
||||
let senderID = PeerID(hexData: packet.senderID)
|
||||
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)"
|
||||
// Include a payload digest so that distinct packets sharing the same
|
||||
// sender/timestamp(ms)/type are not collapsed as duplicates. The
|
||||
// post-handshake flush sends queued messages, delivery and read receipts
|
||||
// back-to-back within a single millisecond; without the digest every
|
||||
// packet after the first would be silently dropped.
|
||||
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
||||
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||
let messageType = MessageType(rawValue: packet.type)
|
||||
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
|
||||
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
|
||||
|
||||
@@ -135,6 +135,13 @@ final class BLEService: NSObject {
|
||||
|
||||
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
|
||||
private var maintenanceCounter = 0 // Track maintenance cycles
|
||||
/// Whether real CoreBluetooth managers were initialized. When false (unit
|
||||
/// tests), periodic mesh background work is not started — the maintenance
|
||||
/// timer and the gossip-sync timers only drain BLE writes/notifications,
|
||||
/// re-announce, and sign/broadcast sync packets, all meaningless without
|
||||
/// Bluetooth. Leaving them running in the test process is pure background
|
||||
/// churn that aggravates flaky exit hangs.
|
||||
private var meshBackgroundEnabled = false
|
||||
|
||||
// MARK: - Connection budget & scheduling (central role)
|
||||
private var connectionScheduler = BLEConnectionScheduler<CBPeripheral>()
|
||||
@@ -233,16 +240,10 @@ final class BLEService: NSObject {
|
||||
#endif
|
||||
}
|
||||
|
||||
// Single maintenance timer for all periodic tasks (dispatch-based for determinism)
|
||||
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
||||
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
|
||||
repeating: TransportConfig.bleMaintenanceInterval,
|
||||
leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))
|
||||
timer.setEventHandler { [weak self] in
|
||||
self?.performMaintenance()
|
||||
}
|
||||
timer.resume()
|
||||
maintenanceTimer = timer
|
||||
// Single maintenance timer for all periodic tasks (dispatch-based for
|
||||
// determinism). Only run it when real Bluetooth managers exist.
|
||||
meshBackgroundEnabled = initializeBluetoothManagers
|
||||
startMaintenanceTimer()
|
||||
|
||||
// Publish initial empty state
|
||||
requestPeerDataPublish()
|
||||
@@ -272,7 +273,12 @@ final class BLEService: NSObject {
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
manager.delegate = self
|
||||
manager.start()
|
||||
// Only start the periodic sync timers when real Bluetooth exists. In unit
|
||||
// tests there is no mesh to sync with, and the periodic sign/broadcast
|
||||
// churn just keeps the process busy and aggravates flaky exit hangs.
|
||||
if meshBackgroundEnabled {
|
||||
manager.start()
|
||||
}
|
||||
gossipSyncManager = manager
|
||||
}
|
||||
|
||||
@@ -435,7 +441,29 @@ final class BLEService: NSObject {
|
||||
|
||||
// MARK: Lifecycle
|
||||
|
||||
/// Creates and starts the periodic maintenance timer if it is not already
|
||||
/// running. Idempotent so it can be called from both `init` and
|
||||
/// `startServices()` — the latter matters after a panic reset, where
|
||||
/// `stopServices()` cancels and nils the timer.
|
||||
private func startMaintenanceTimer() {
|
||||
guard meshBackgroundEnabled, maintenanceTimer == nil else { return }
|
||||
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
||||
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
|
||||
repeating: TransportConfig.bleMaintenanceInterval,
|
||||
leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))
|
||||
timer.setEventHandler { [weak self] in
|
||||
self?.performMaintenance()
|
||||
}
|
||||
timer.resume()
|
||||
maintenanceTimer = timer
|
||||
}
|
||||
|
||||
func startServices() {
|
||||
// Restart the maintenance timer if a prior stopServices() cancelled it
|
||||
// (e.g. the panic flow), otherwise periodic announces, peer reconciliation
|
||||
// and cache cleanup would never resume until app restart.
|
||||
startMaintenanceTimer()
|
||||
|
||||
// Start BLE services if not already running
|
||||
if centralManager?.state == .poweredOn {
|
||||
centralManager?.scanForPeripherals(
|
||||
@@ -1602,7 +1630,7 @@ private extension BLEService {
|
||||
#if DEBUG
|
||||
// Test-only helper to inject packets into the receive pipeline
|
||||
extension BLEService {
|
||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true) {
|
||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
|
||||
if preseedPeer {
|
||||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
||||
let normalizedID = PeerID(hexData: packet.senderID)
|
||||
@@ -1610,6 +1638,7 @@ extension BLEService {
|
||||
if var existing = peerRegistry.info(for: normalizedID) {
|
||||
existing.isConnected = true
|
||||
existing.isVerifiedNickname = true
|
||||
if let signingPublicKey { existing.signingPublicKey = signingPublicKey }
|
||||
existing.lastSeen = Date()
|
||||
peerRegistry.upsert(existing)
|
||||
} else {
|
||||
@@ -1618,7 +1647,7 @@ extension BLEService {
|
||||
nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
|
||||
isConnected: true,
|
||||
noisePublicKey: packet.senderID,
|
||||
signingPublicKey: nil,
|
||||
signingPublicKey: signingPublicKey,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: Date()
|
||||
))
|
||||
@@ -3110,6 +3139,9 @@ extension BLEService {
|
||||
guard let self = self else { return [:] }
|
||||
return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
|
||||
},
|
||||
verifyPacketSignature: { [weak self] packet, signingPublicKey in
|
||||
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||||
},
|
||||
signedSenderDisplayName: { [weak self] packet, peerID in
|
||||
self?.signedSenderDisplayName(for: packet, from: peerID)
|
||||
},
|
||||
|
||||
@@ -594,6 +594,22 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes all persisted location state and resets the in-memory view.
|
||||
/// Used by the panic wipe — selected channel, teleport set and bookmarks
|
||||
/// (which reveal where the user has been) must not survive on device.
|
||||
func panicWipe() {
|
||||
storage.removeObject(forKey: selectedChannelKey)
|
||||
storage.removeObject(forKey: teleportedStoreKey)
|
||||
storage.removeObject(forKey: bookmarksKey)
|
||||
storage.removeObject(forKey: bookmarkNamesKey)
|
||||
teleportedSet.removeAll()
|
||||
bookmarkMembership.removeAll()
|
||||
bookmarks = []
|
||||
bookmarkNames = [:]
|
||||
teleported = false
|
||||
selectedChannel = .mesh
|
||||
}
|
||||
|
||||
private static func normalizeGeohash(_ s: String) -> String {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
return s
|
||||
|
||||
@@ -1129,6 +1129,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey")
|
||||
userDefaults.removeObject(forKey: "bitchat.messageRetentionKey")
|
||||
|
||||
// Wipe persisted location state (selected channel, teleport set,
|
||||
// bookmarks). For an activist-safety wipe, where the user has been is
|
||||
// exactly the data an adversary inspecting the device wants.
|
||||
LocationStateManager.shared.panicWipe()
|
||||
|
||||
// Reset nickname to anonymous
|
||||
nickname = "anon\(Int.random(in: 1000...9999))"
|
||||
saveNickname()
|
||||
@@ -1153,13 +1158,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// Clear selected private chat
|
||||
selectedPrivateChatPeer = nil
|
||||
|
||||
// Clear live location/geohash session state. Persisted location state
|
||||
// was wiped above, but the running view model can still be scoped to a
|
||||
// geohash channel and hold subscriptions tied to the old Nostr identity.
|
||||
activeChannel = .mesh
|
||||
setGeoChatSubscriptionID(nil)
|
||||
setGeoDmSubscriptionID(nil)
|
||||
_ = clearGeoSamplingSubs()
|
||||
cachedGeohashIdentity = nil
|
||||
nostrKeyMapping.removeAll()
|
||||
|
||||
// Clear read receipt tracking
|
||||
sentReadReceipts.removeAll()
|
||||
deduplicationService.clearAll()
|
||||
|
||||
// IMPORTANT: Clear Nostr-related state
|
||||
// Disconnect from Nostr relays and clear subscriptions
|
||||
nostrRelayManager?.disconnect()
|
||||
// Drop relay subscriptions, handlers, pending sends, and replay state.
|
||||
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
|
||||
// disconnect is not enough here.
|
||||
NostrRelayManager.shared.resetForPanicWipe()
|
||||
nostrRelayManager = nil
|
||||
|
||||
// Clear Nostr identity associations
|
||||
@@ -1175,15 +1192,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// No need to force UserDefaults synchronization
|
||||
|
||||
// Reinitialize Nostr with new identity
|
||||
// This will generate new Nostr keys derived from new Noise keys
|
||||
Task { @MainActor in
|
||||
// Small delay to ensure cleanup completes
|
||||
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
|
||||
// This will generate new Nostr keys derived from new Noise keys.
|
||||
// Skipped under tests: connecting the shared relay singleton starts
|
||||
// real network/reconnect work that never completes and would keep the
|
||||
// test process alive (the singleton, unlike a discardable instance, is
|
||||
// never deallocated to cancel it).
|
||||
if !TestEnvironment.isRunningTests {
|
||||
Task { @MainActor in
|
||||
// Small delay to ensure cleanup completes
|
||||
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
|
||||
|
||||
// Reinitialize Nostr relay manager with new identity
|
||||
nostrRelayManager = NostrRelayManager()
|
||||
setupNostrMessageHandling()
|
||||
nostrRelayManager?.connect()
|
||||
// Reinitialize Nostr relay manager with new identity. Reuse the
|
||||
// shared singleton — every other component (NostrTransport, geohash
|
||||
// subscriptions, AppRuntime observers) is bound to `.shared`, so
|
||||
// creating a fresh instance here would split relay state and leave
|
||||
// sends running against a disconnected manager.
|
||||
nostrRelayManager = NostrRelayManager.shared
|
||||
setupNostrMessageHandling()
|
||||
nostrRelayManager?.connect()
|
||||
}
|
||||
}
|
||||
|
||||
// Delete ALL media files (incoming and outgoing) in background
|
||||
|
||||
@@ -14,23 +14,29 @@ import BitFoundation
|
||||
struct BLEServiceCoreTests {
|
||||
|
||||
@Test
|
||||
func duplicatePacket_isDeduped() async {
|
||||
func duplicatePacket_isDeduped() async throws {
|
||||
let ble = makeService()
|
||||
let delegate = PublicCaptureDelegate()
|
||||
ble.delegate = delegate
|
||||
|
||||
// Public messages must carry a valid signature from the claimed sender;
|
||||
// sign the packet and preseed the sender's signing key so the receiver
|
||||
// can verify it (production `sendMessage` signs public broadcasts too).
|
||||
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let sender = PeerID(str: "1122334455667788")
|
||||
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
||||
let unsigned = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
||||
let packet = try #require(signer.signPacket(unsigned), "Failed to sign public message")
|
||||
let signingKey = signer.getSigningPublicKeyData()
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
|
||||
let receivedFirst = await TestHelpers.waitUntil(
|
||||
{ delegate.publicMessagesSnapshot().count == 1 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(receivedFirst)
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
|
||||
let receivedDuplicate = await TestHelpers.waitUntil(
|
||||
{ delegate.publicMessagesSnapshot().count > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
|
||||
@@ -1042,6 +1042,38 @@ struct ChatViewModelPanicTests {
|
||||
#expect(viewModel.unreadPrivateMessages.isEmpty)
|
||||
#expect(viewModel.selectedPrivateChatPeer == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearAllData_resetsLiveGeohashAndNostrState() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruy"
|
||||
let channel = GeohashChannel(level: .city, geohash: geohash)
|
||||
let identity = try NostrIdentity.generate()
|
||||
let pubkey = String(repeating: "ab", count: 32)
|
||||
let peerID = PeerID(nostr: pubkey)
|
||||
|
||||
viewModel.activeChannel = .location(channel)
|
||||
viewModel.setGeoChatSubscriptionID("geo-\(geohash)")
|
||||
viewModel.setGeoDmSubscriptionID("geo-dm-\(geohash)")
|
||||
viewModel.addGeoSamplingSub("geo-sample-\(geohash)", forGeohash: geohash)
|
||||
viewModel.cachedGeohashIdentity = (geohash, identity)
|
||||
viewModel.registerNostrKeyMapping(pubkey, for: peerID)
|
||||
viewModel.currentGeohash = geohash
|
||||
viewModel.geoNicknames = [pubkey: "alice"]
|
||||
viewModel.teleportedGeo = [pubkey]
|
||||
|
||||
viewModel.panicClearAllData()
|
||||
|
||||
#expect(viewModel.activeChannel == .mesh)
|
||||
#expect(viewModel.geoSubscriptionID == nil)
|
||||
#expect(viewModel.geoDmSubscriptionID == nil)
|
||||
#expect(viewModel.geoSamplingSubs.isEmpty)
|
||||
#expect(viewModel.cachedGeohashIdentity == nil)
|
||||
#expect(viewModel.nostrKeyMapping.isEmpty)
|
||||
#expect(viewModel.currentGeohash == nil)
|
||||
#expect(viewModel.geoNicknames.isEmpty)
|
||||
#expect(viewModel.teleportedGeo.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Service Lifecycle Tests
|
||||
|
||||
@@ -21,9 +21,16 @@ struct FragmentationTests {
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
// Construct a big packet (3KB) from a remote sender (not our own ID)
|
||||
// Construct a big SIGNED public packet (3KB) from a remote sender. Public
|
||||
// messages must carry a valid signature, so the reassembled packet is
|
||||
// signed and the sender's signing key is preseeded into the registry.
|
||||
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let signingKey = signer.getSigningPublicKeyData()
|
||||
let remoteShortID = PeerID(str: "1122334455667788")
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
|
||||
let original = try #require(
|
||||
signer.signPacket(makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)),
|
||||
"Failed to sign public packet"
|
||||
)
|
||||
|
||||
// Use a small fragment size to ensure multiple pieces
|
||||
let fragments = fragmentPacket(original, fragmentSize: 400)
|
||||
@@ -36,7 +43,7 @@ struct FragmentationTests {
|
||||
if i > 0 {
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
|
||||
}
|
||||
|
||||
// Wait for delegate callback with proper timeout
|
||||
@@ -52,8 +59,13 @@ struct FragmentationTests {
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
|
||||
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let signingKey = signer.getSigningPublicKeyData()
|
||||
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
|
||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
|
||||
let original = try #require(
|
||||
signer.signPacket(makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)),
|
||||
"Failed to sign public packet"
|
||||
)
|
||||
var frags = fragmentPacket(original, fragmentSize: 300)
|
||||
|
||||
// Duplicate one fragment
|
||||
@@ -66,7 +78,7 @@ struct FragmentationTests {
|
||||
if i > 0 {
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
|
||||
}
|
||||
|
||||
// Wait for delegate callback with proper timeout
|
||||
|
||||
@@ -120,6 +120,42 @@ struct NostrProtocolTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptRejectsInvalidSealSignature() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let giftWrap = try NostrProtocol.createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||
content: "forged signature",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptRejectsSealRumorPubkeyMismatch() throws {
|
||||
let claimedSender = try NostrIdentity.generate()
|
||||
let sealSigner = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let giftWrap = try NostrProtocol.createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
||||
content: "spoofed sender",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
rumorIdentity: claimedSender,
|
||||
sealSignerIdentity: sealSigner
|
||||
)
|
||||
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testAckRoundTripNIP44V2_Delivered() throws {
|
||||
// Identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
@@ -260,4 +296,15 @@ struct NostrProtocolTests {
|
||||
if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) }
|
||||
return Data(base64Encoded: str)
|
||||
}
|
||||
|
||||
private func expectInvalidEvent(_ operation: () throws -> Void) {
|
||||
do {
|
||||
try operation()
|
||||
Issue.record("Expected NostrError.invalidEvent")
|
||||
} catch NostrError.invalidEvent {
|
||||
return
|
||||
} catch {
|
||||
Issue.record("Expected NostrError.invalidEvent, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,10 @@ struct BLEAnnounceHandlerTests {
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
#expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false)
|
||||
#expect(recorder.persistedIdentities.count == 1)
|
||||
// Identity persistence MUST NOT occur for unverified announces:
|
||||
// persisting would let an attacker who replays a victim's noisePublicKey
|
||||
// overwrite the victim's stored signing key/nickname (identity poisoning).
|
||||
#expect(recorder.persistedIdentities.isEmpty)
|
||||
#expect(recorder.trackedPackets.count == 1)
|
||||
#expect(recorder.announceBacks == 1)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ struct BLEPublicMessageHandlerTests {
|
||||
var localNickname = "Me"
|
||||
var peers: [PeerID: BLEPeerInfo] = [:]
|
||||
var signedName: String?
|
||||
var verifyPacketSignatureResult = false
|
||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||
var selfBroadcastMessageID: String?
|
||||
|
||||
var peersSnapshotReads = 0
|
||||
var verifyPacketSignatureQueries: [PeerID] = []
|
||||
var signedNameQueries: [PeerID] = []
|
||||
var trackedPackets: [BitchatPacket] = []
|
||||
var selfBroadcastTakes: [BitchatPacket] = []
|
||||
@@ -35,6 +37,10 @@ struct BLEPublicMessageHandlerTests {
|
||||
recorder.peersSnapshotReads += 1
|
||||
return recorder.peers
|
||||
},
|
||||
verifyPacketSignature: { packet, _ in
|
||||
recorder.verifyPacketSignatureQueries.append(PeerID(hexData: packet.senderID))
|
||||
return recorder.verifyPacketSignatureResult
|
||||
},
|
||||
signedSenderDisplayName: { _, peerID in
|
||||
recorder.signedNameQueries.append(peerID)
|
||||
return recorder.signedName
|
||||
@@ -59,13 +65,17 @@ struct BLEPublicMessageHandlerTests {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
// A valid packet signature is required even for a registry-verified peer:
|
||||
// senderID is spoofable, so registry membership alone is not authentication.
|
||||
recorder.signedName = "SignedAlice"
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, content: "hello mesh", timestamp: timestamp(now))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.peersSnapshotReads == 1)
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
// Signature is verified, then the registry's collision-resolved name is preferred.
|
||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
||||
#expect(recorder.trackedPackets.count == 1)
|
||||
#expect(recorder.selfBroadcastTakes.isEmpty)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
@@ -133,6 +143,63 @@ struct BLEPublicMessageHandlerTests {
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func registryVerifiedPeerDeliveredBeforeIdentityCachePersists() {
|
||||
// A freshly verified announce updates the peer registry synchronously,
|
||||
// but identity-cache persistence is async. A message arriving in that
|
||||
// window has a valid signature and a registry signing key, yet the
|
||||
// persisted-identity lookup (signedName) would still return nil. It must
|
||||
// be verified against the registry key and delivered, not dropped.
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true,
|
||||
signingPublicKey: Data(repeating: 0xAB, count: 32)
|
||||
)]
|
||||
recorder.verifyPacketSignatureResult = true
|
||||
recorder.signedName = nil
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, content: "first msg", timestamp: timestamp(now))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.verifyPacketSignatureQueries == [remotePeerID])
|
||||
// Verified via the registry key, so no fallback to the persisted lookup.
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.count == 1)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.nickname == "Alice")
|
||||
#expect(recorder.deliveries.first?.content == "first msg")
|
||||
}
|
||||
|
||||
@Test
|
||||
func registryPeerWithInvalidSignatureFallsBackAndDrops() {
|
||||
// Spoofed senderID: the peer is in the registry with a signing key, but
|
||||
// the packet signature does not verify against it. The handler must fall
|
||||
// back to the persisted lookup and, finding nothing, drop the message.
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true,
|
||||
signingPublicKey: Data(repeating: 0xAB, count: 32)
|
||||
)]
|
||||
recorder.verifyPacketSignatureResult = false
|
||||
recorder.signedName = nil
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, content: "spoofed", timestamp: timestamp(now))
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.verifyPacketSignatureQueries == [remotePeerID])
|
||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func signedSenderFallbackDeliversWithSignedName() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
@@ -154,6 +221,7 @@ struct BLEPublicMessageHandlerTests {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
recorder.signedName = "SignedAlice"
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, payload: Data([0xFF, 0xFE, 0xFD]), timestamp: timestamp(now))
|
||||
|
||||
@@ -187,6 +255,7 @@ struct BLEPublicMessageHandlerTests {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
recorder.signedName = "SignedAlice"
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let packet = makeMessagePacket(
|
||||
sender: remotePeerID,
|
||||
@@ -213,14 +282,15 @@ struct BLEPublicMessageHandlerTests {
|
||||
_ peerID: PeerID,
|
||||
nickname: String,
|
||||
isVerified: Bool,
|
||||
isConnected: Bool = true
|
||||
isConnected: Bool = true,
|
||||
signingPublicKey: Data? = nil
|
||||
) -> BLEPeerInfo {
|
||||
BLEPeerInfo(
|
||||
peerID: peerID,
|
||||
nickname: nickname,
|
||||
isConnected: isConnected,
|
||||
noisePublicKey: nil,
|
||||
signingPublicKey: nil,
|
||||
signingPublicKey: signingPublicKey,
|
||||
isVerifiedNickname: isVerified,
|
||||
lastSeen: Date(timeIntervalSince1970: 999)
|
||||
)
|
||||
|
||||
@@ -14,7 +14,10 @@ struct BLEReceivePipelineTests {
|
||||
let context = BLEReceivePipeline.context(for: packet, localPeerID: local)
|
||||
|
||||
#expect(context.senderID == sender)
|
||||
#expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)")
|
||||
// The message ID includes a payload digest so distinct packets sharing a
|
||||
// sender/timestamp(ms)/type are not collapsed as duplicates.
|
||||
let digest = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
||||
#expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)-\(digest)")
|
||||
#expect(context.messageType == .message)
|
||||
#expect(context.shouldDeduplicate)
|
||||
#expect(context.logsHandlingDetails)
|
||||
|
||||
@@ -1328,6 +1328,68 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
|
||||
}
|
||||
|
||||
func test_resetForPanicWipe_dropsSessionRelayStateWithoutFiringCallbacks() async throws {
|
||||
let relayURL = "wss://panic-reset.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
let event = try makeSignedEvent(content: "queued before panic")
|
||||
var handledEvents = 0
|
||||
var eoseCount = 0
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "panic-sub",
|
||||
relayUrls: [relayURL],
|
||||
handler: { _ in handledEvents += 1 },
|
||||
onEOSE: { eoseCount += 1 }
|
||||
)
|
||||
context.manager.sendEvent(event, to: [relayURL])
|
||||
|
||||
XCTAssertEqual(context.manager.debugMessageHandlerCount, 1)
|
||||
XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 1)
|
||||
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1)
|
||||
XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 1)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1)
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
|
||||
context.manager.resetForPanicWipe()
|
||||
|
||||
XCTAssertEqual(context.manager.debugMessageHandlerCount, 0)
|
||||
XCTAssertEqual(context.manager.debugSubscriptionRequestCount, 0)
|
||||
XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0)
|
||||
XCTAssertEqual(context.manager.debugPendingEOSECallbackCount, 0)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
|
||||
XCTAssertEqual(handledEvents, 0)
|
||||
XCTAssertEqual(eoseCount, 0)
|
||||
|
||||
// Stale Tor wait and fallback callbacks from the pre-wipe generation
|
||||
// must not resurrect connections or settle callbacks after reset.
|
||||
context.torWaiter.resolve(true)
|
||||
context.scheduler.runNext()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
XCTAssertEqual(eoseCount, 0)
|
||||
}
|
||||
|
||||
func test_resetForPanicWipe_marksConnectedRelaysDisconnected() async {
|
||||
let relayURL = "wss://panic-connected.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.manager.isConnected &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
context.manager.resetForPanicWipe()
|
||||
|
||||
XCTAssertFalse(context.manager.isConnected)
|
||||
XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.isConnected, false)
|
||||
XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts, 0)
|
||||
XCTAssertNil(context.manager.relays.first(where: { $0.url == relayURL })?.lastError)
|
||||
}
|
||||
|
||||
func test_reconnectBackoff_appliesJitterWithinConfiguredBounds() async {
|
||||
let relayURL = "wss://jitter-bounds.example"
|
||||
// Pin the jitter source to the extremes and the midpoint of [0, 1).
|
||||
|
||||
Reference in New Issue
Block a user