Harden Nostr validation and BLE announce tests (#1012)

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2026-02-02 18:07:19 -10:00
committed by GitHub
co-authored by jack
parent 90a5e8ba9d
commit 8c7e3e7b9b
12 changed files with 283 additions and 103 deletions
+20
View File
@@ -528,6 +528,26 @@ struct NostrEvent: Codable {
signed.sig = signatureHex signed.sig = signatureHex
return signed return signed
} }
/// Validate that the event ID and Schnorr signature match the content and pubkey.
/// Returns false when the signature is missing, malformed, or does not verify.
func isValidSignature() -> Bool {
guard let sig = sig,
let sigData = Data(hexString: sig),
let pubData = Data(hexString: pubkey),
sigData.count == 64,
pubData.count == 32,
let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData),
let (expectedId, eventHash) = try? calculateEventId(),
expectedId == id
else {
return false
}
var messageBytes = [UInt8](eventHash)
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
return xonly.isValid(signature, for: &messageBytes)
}
private func calculateEventId() throws -> (String, Data) { private func calculateEventId() throws -> (String, Data) {
let serialized = [ let serialized = [
+2 -1
View File
@@ -764,7 +764,8 @@ private enum ParsedInbound {
if array.count >= 3, if array.count >= 3,
let subId = array[1] as? String, let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any], let eventDict = array[2] as? [String: Any],
let event = try? NostrEvent(from: eventDict) { let event = try? NostrEvent(from: eventDict),
event.isValidSignature() {
self = .event(subId: subId, event: event) self = .event(subId: subId, event: event)
return return
} }
+1
View File
@@ -343,6 +343,7 @@ struct BinaryProtocol {
} }
guard payloadLength >= 0 else { return nil } guard payloadLength >= 0 else { return nil }
guard payloadLength <= FileTransferLimits.maxFramedFileBytes else { return nil }
guard let senderID = readData(senderIDSize) else { return nil } guard let senderID = readData(senderIDSize) else { return nil }
+23 -21
View File
@@ -2038,26 +2038,28 @@ extension BLEService {
#if DEBUG #if DEBUG
// Test-only helper to inject packets into the receive pipeline // Test-only helper to inject packets into the receive pipeline
extension BLEService { extension BLEService {
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID) { func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true) {
// Ensure the synthetic peer is known and marked verified for public-message tests if preseedPeer {
let normalizedID = PeerID(hexData: packet.senderID) // Ensure the synthetic peer is known and marked verified for public-message tests
collectionsQueue.sync(flags: .barrier) { let normalizedID = PeerID(hexData: packet.senderID)
if peers[normalizedID] == nil { collectionsQueue.sync(flags: .barrier) {
peers[normalizedID] = PeerInfo( if peers[normalizedID] == nil {
peerID: normalizedID, peers[normalizedID] = PeerInfo(
nickname: "TestPeer_\(fromPeerID.id.prefix(4))", peerID: normalizedID,
isConnected: true, nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
noisePublicKey: packet.senderID, isConnected: true,
signingPublicKey: nil, noisePublicKey: packet.senderID,
isVerifiedNickname: true, signingPublicKey: nil,
lastSeen: Date() isVerifiedNickname: true,
) lastSeen: Date()
} else { )
var p = peers[normalizedID]! } else {
p.isConnected = true var p = peers[normalizedID]!
p.isVerifiedNickname = true p.isConnected = true
p.lastSeen = Date() p.isVerifiedNickname = true
peers[normalizedID] = p p.lastSeen = Date()
peers[normalizedID] = p
}
} }
} }
handleReceivedPacket(packet, from: fromPeerID) handleReceivedPacket(packet, from: fromPeerID)
@@ -3839,7 +3841,7 @@ extension BLEService {
let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey) let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey)
if derivedFromKey != peerID { if derivedFromKey != peerID {
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security) SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security)
return
} }
// Don't add ourselves as a peer // Don't add ourselves as a peer
@@ -56,6 +56,7 @@ extension ChatViewModel {
} }
func subscribeNostrEvent(_ event: NostrEvent) { func subscribeNostrEvent(_ event: NostrEvent) {
guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!deduplicationService.hasProcessedNostrEvent(event.id) !deduplicationService.hasProcessedNostrEvent(event.id)
@@ -146,13 +147,12 @@ extension ChatViewModel {
} }
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard giftWrap.isValidSignature() else { return }
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return } guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
deduplicationService.recordNostrEvent(giftWrap.id) deduplicationService.recordNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id), guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id),
content.hasPrefix("bitchat1:"), let packet = Self.decodeEmbeddedBitChatPacket(from: content),
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData),
packet.type == MessageType.noiseEncrypted.rawValue, packet.type == MessageType.noiseEncrypted.rawValue,
let noisePayload = NoisePayload.decode(packet.payload) let noisePayload = NoisePayload.decode(packet.payload)
else { else {
@@ -254,6 +254,7 @@ extension ChatViewModel {
} }
func handleNostrEvent(_ event: NostrEvent) { func handleNostrEvent(_ event: NostrEvent) {
guard event.isValidSignature() else { return }
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag // Only handle ephemeral kind 20000 or presence kind 20001 with matching tag
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
@@ -367,6 +368,7 @@ extension ChatViewModel {
} }
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard giftWrap.isValidSignature() else { return }
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) { if deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
return return
} }
@@ -380,9 +382,7 @@ extension ChatViewModel {
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 content.hasPrefix("bitchat1:"), guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData),
packet.type == MessageType.noiseEncrypted.rawValue, packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload) let payload = NoisePayload.decode(packet.payload)
else { else {
@@ -485,6 +485,7 @@ extension ChatViewModel {
} }
func subscribeNostrEvent(_ event: NostrEvent, gh: String) { func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
@@ -602,6 +603,7 @@ extension ChatViewModel {
} }
func processNostrMessage(_ giftWrap: NostrEvent) async { func processNostrMessage(_ giftWrap: NostrEvent) async {
guard giftWrap.isValidSignature() else { return }
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return } guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
do { do {
@@ -619,8 +621,7 @@ extension ChatViewModel {
// Check if it's a BitChat packet embedded in the content (bitchat1:...) // Check if it's a BitChat packet embedded in the content (bitchat1:...)
if content.hasPrefix("bitchat1:") { if content.hasPrefix("bitchat1:") {
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))), guard let packet = Self.decodeEmbeddedBitChatPacket(from: content) else {
let packet = BitchatPacket.from(packetData) else {
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session) SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
return return
} }
@@ -631,24 +632,23 @@ extension ChatViewModel {
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer // Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey) let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
if packet.type == MessageType.noiseEncrypted.rawValue { if packet.type == MessageType.noiseEncrypted.rawValue,
if let payload = NoisePayload.decode(packet.payload) { let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
// Store Nostr mapping // Store Nostr mapping
await MainActor.run { await MainActor.run {
nostrKeyMapping[targetPeerID] = senderPubkey nostrKeyMapping[targetPeerID] = senderPubkey
// Handle packet types // Handle packet types
switch payload.type { switch payload.type {
case .privateMessage: case .privateMessage:
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp) handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
case .delivered: case .delivered:
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt: case .readReceipt:
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .verifyChallenge, .verifyResponse: case .verifyChallenge, .verifyResponse:
break break
}
} }
} }
} }
@@ -801,6 +801,19 @@ extension ChatViewModel {
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} }
private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
guard content.hasPrefix("bitchat1:") else { return nil }
let encoded = String(content.dropFirst("bitchat1:".count))
let maxBytes = FileTransferLimits.maxFramedFileBytes
// Base64url length upper bound for maxBytes (padded length; unpadded is <= this).
let maxEncoded = ((maxBytes + 2) / 3) * 4
guard encoded.count <= maxEncoded else { return nil }
guard let packetData = Self.base64URLDecode(encoded),
packetData.count <= maxBytes
else { return nil }
return BitchatPacket.from(packetData)
}
// MARK: - Geohash Nickname Resolution (for /block in geohash) // MARK: - Geohash Nickname Resolution (for /block in geohash)
func nostrPubkeyForDisplayName(_ name: String) -> String? { func nostrPubkeyForDisplayName(_ name: String) -> String? {
+34
View File
@@ -49,6 +49,40 @@ struct BLEServiceCoreTests {
#expect(!didReceive) #expect(!didReceive)
#expect(delegate.publicMessagesSnapshot().isEmpty) #expect(delegate.publicMessagesSnapshot().isEmpty)
} }
@Test
func announceSenderMismatch_isRejected() async throws {
let ble = makeService()
let signer = NoiseEncryptionService(keychain: MockKeychain())
let announcement = AnnouncementPacket(
nickname: "Spoof",
noisePublicKey: signer.getStaticPublicKeyData(),
signingPublicKey: signer.getSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode(), "Failed to encode announcement")
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
let wrongFirst = derivedPeerID.bare.first == "0" ? "1" : "0"
let wrongBare = String(wrongFirst) + String(derivedPeerID.bare.dropFirst())
let wrongPeerID = PeerID(str: wrongBare)
let packet = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: wrongPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 7
)
let signed = try #require(signer.signPacket(packet), "Failed to sign announce packet")
ble._test_handlePacket(signed, fromPeerID: wrongPeerID, preseedPeer: false)
_ = await TestHelpers.waitUntil({ !ble.currentPeerSnapshots().isEmpty }, timeout: 0.3)
#expect(ble.currentPeerSnapshots().isEmpty)
}
} }
private func makeService() -> BLEService { private func makeService() -> BLEService {
+68 -17
View File
@@ -265,7 +265,7 @@ struct ChatViewModelNostrExtensionTests {
} }
@Test @MainActor @Test @MainActor
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async { func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async throws {
let geohash = "u4pruydq" let geohash = "u4pruydq"
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
@@ -278,17 +278,16 @@ struct ChatViewModelNostrExtensionTests {
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }) _ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
var event = NostrEvent( let signer = try NostrIdentity.generate()
pubkey: "pub1", let event = NostrEvent(
pubkey: signer.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .ephemeralEvent, kind: .ephemeralEvent,
tags: [["g", geohash]], tags: [["g", geohash]],
content: "Hello Geo" content: "Hello Geo"
) )
event.id = "evt1" let signed = try event.sign(with: signer.schnorrSigningKey())
event.sig = "sig" viewModel.handleNostrEvent(signed)
viewModel.handleNostrEvent(event)
let didAppend = await TestHelpers.waitUntil({ let didAppend = await TestHelpers.waitUntil({
viewModel.publicMessagePipeline.flushIfNeeded() viewModel.publicMessagePipeline.flushIfNeeded()
@@ -305,16 +304,15 @@ struct ChatViewModelNostrExtensionTests {
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash) let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
var event = NostrEvent( let event = NostrEvent(
pubkey: identity.publicKeyHex, pubkey: identity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .ephemeralEvent, kind: .ephemeralEvent,
tags: [["g", geohash]], tags: [["g", geohash]],
content: "Self echo" content: "Self echo"
) )
event.id = "evt-self" let signed = try event.sign(with: identity.schnorrSigningKey())
viewModel.handleNostrEvent(signed)
viewModel.handleNostrEvent(event)
try? await Task.sleep(nanoseconds: 100_000_000) try? await Task.sleep(nanoseconds: 100_000_000)
viewModel.publicMessagePipeline.flushIfNeeded() viewModel.publicMessagePipeline.flushIfNeeded()
@@ -323,24 +321,24 @@ struct ChatViewModelNostrExtensionTests {
} }
@Test @MainActor @Test @MainActor
func handleNostrEvent_skipsBlockedSender() async { func handleNostrEvent_skipsBlockedSender() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq" let geohash = "u4pruydq"
let blockedPubkey = "0000000000000000000000000000000000000000000000000000000000000001" let blockedIdentity = try NostrIdentity.generate()
let blockedPubkey = blockedIdentity.publicKeyHex
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true) viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true)
var event = NostrEvent( let event = NostrEvent(
pubkey: blockedPubkey, pubkey: blockedPubkey,
createdAt: Date(), createdAt: Date(),
kind: .ephemeralEvent, kind: .ephemeralEvent,
tags: [["g", geohash]], tags: [["g", geohash]],
content: "Blocked" content: "Blocked"
) )
event.id = "evt-blocked" let signed = try event.sign(with: blockedIdentity.schnorrSigningKey())
viewModel.handleNostrEvent(signed)
viewModel.handleNostrEvent(event)
try? await Task.sleep(nanoseconds: 100_000_000) try? await Task.sleep(nanoseconds: 100_000_000)
viewModel.publicMessagePipeline.flushIfNeeded() viewModel.publicMessagePipeline.flushIfNeeded()
@@ -348,6 +346,52 @@ struct ChatViewModelNostrExtensionTests {
#expect(!viewModel.messages.contains { $0.content == "Blocked" }) #expect(!viewModel.messages.contains { $0.content == "Blocked" })
} }
@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 {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
let content = "bitchat1:" + base64URLEncode(oversized)
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(viewModel.privateChats.isEmpty)
}
@Test @MainActor @Test @MainActor
func switchLocationChannel_clearsNostrDedupCache() async { func switchLocationChannel_clearsNostrDedupCache() async {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
@@ -406,3 +450,10 @@ struct ChatViewModelGeoDMTests {
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID)) #expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
} }
} }
private func base64URLEncode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
+5 -3
View File
@@ -195,10 +195,12 @@ struct ChatViewModelReceivingTests {
messageID: "pub-001" messageID: "pub-001"
) )
// Give time for async Task and pipeline processing let found = await TestHelpers.waitUntil({
try? await Task.sleep(nanoseconds: 500_000_000) viewModel.publicMessagePipeline.flushIfNeeded()
return viewModel.messages.contains { $0.content == "Public hello from Bob" }
}, timeout: TestConstants.defaultTimeout)
#expect(viewModel.messages.contains { $0.content == "Public hello from Bob" }) #expect(found)
} }
} }
+30 -30
View File
@@ -131,7 +131,7 @@ struct NostrFilterPresenceTests {
@MainActor @MainActor
struct ChatViewModelPresenceHandlingTests { struct ChatViewModelPresenceHandlingTests {
@Test func handleNostrEvent_presenceUpdatesParticipantTracker() async { @Test func handleNostrEvent_presenceUpdatesParticipantTracker() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq" let geohash = "u4pruydq"
@@ -139,18 +139,18 @@ struct ChatViewModelPresenceHandlingTests {
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
// Create a presence event (kind 20001) // Create a presence event (kind 20001)
var event = NostrEvent( let identity = try NostrIdentity.generate()
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .geohashPresence, kind: .geohashPresence,
tags: [["g", geohash]], tags: [["g", geohash]],
content: "" content: ""
) )
event.id = "presence_evt_1" let signed = try event.sign(with: identity.schnorrSigningKey())
event.sig = "sig"
// Handle the event // Handle the event
viewModel.handleNostrEvent(event) viewModel.handleNostrEvent(signed)
// Allow async processing // Allow async processing
try? await Task.sleep(nanoseconds: 50_000_000) try? await Task.sleep(nanoseconds: 50_000_000)
@@ -160,7 +160,7 @@ struct ChatViewModelPresenceHandlingTests {
#expect(count >= 1) #expect(count >= 1)
} }
@Test func handleNostrEvent_presenceDoesNotAddToTimeline() async { @Test func handleNostrEvent_presenceDoesNotAddToTimeline() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq" let geohash = "u4pruydq"
@@ -169,17 +169,17 @@ struct ChatViewModelPresenceHandlingTests {
let initialMessageCount = viewModel.messages.count let initialMessageCount = viewModel.messages.count
// Create a presence event (kind 20001) // Create a presence event (kind 20001)
var event = NostrEvent( let identity = try NostrIdentity.generate()
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .geohashPresence, kind: .geohashPresence,
tags: [["g", geohash]], tags: [["g", geohash]],
content: "" content: ""
) )
event.id = "presence_evt_2" let signed = try event.sign(with: identity.schnorrSigningKey())
event.sig = "sig"
viewModel.handleNostrEvent(event) viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 50_000_000) try? await Task.sleep(nanoseconds: 50_000_000)
@@ -187,24 +187,24 @@ struct ChatViewModelPresenceHandlingTests {
#expect(viewModel.messages.count == initialMessageCount) #expect(viewModel.messages.count == initialMessageCount)
} }
@Test func handleNostrEvent_chatMessageUpdatesParticipant() async { @Test func handleNostrEvent_chatMessageUpdatesParticipant() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq" let geohash = "u4pruydq"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
// Create a chat event (kind 20000) - NOT presence // Create a chat event (kind 20000) - NOT presence
var event = NostrEvent( let identity = try NostrIdentity.generate()
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .ephemeralEvent, kind: .ephemeralEvent,
tags: [["g", geohash]], tags: [["g", geohash]],
content: "Hello world" content: "Hello world"
) )
event.id = "chat_evt_1" let signed = try event.sign(with: identity.schnorrSigningKey())
event.sig = "sig"
viewModel.handleNostrEvent(event) viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 50_000_000) try? await Task.sleep(nanoseconds: 50_000_000)
@@ -223,25 +223,25 @@ struct ChatViewModelPresenceHandlingTests {
#expect(chatKind == 20000) #expect(chatKind == 20000)
} }
@Test func subscribeNostrEvent_acceptsPresenceKind() async { @Test func subscribeNostrEvent_acceptsPresenceKind() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq" let geohash = "u4pruydq"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
// Create presence event // Create presence event
var event = NostrEvent( let identity = try NostrIdentity.generate()
pubkey: "test1234test1234test1234test1234test1234test1234test1234test1234", let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .geohashPresence, kind: .geohashPresence,
tags: [["g", geohash]], tags: [["g", geohash]],
content: "" content: ""
) )
event.id = "subscribe_presence_evt" let signed = try event.sign(with: identity.schnorrSigningKey())
event.sig = "sig"
// subscribeNostrEvent should accept kind 20001 // subscribeNostrEvent should accept kind 20001
viewModel.subscribeNostrEvent(event) viewModel.subscribeNostrEvent(signed)
try? await Task.sleep(nanoseconds: 50_000_000) try? await Task.sleep(nanoseconds: 50_000_000)
@@ -250,7 +250,7 @@ struct ChatViewModelPresenceHandlingTests {
#expect(count >= 1) #expect(count >= 1)
} }
@Test func subscribeNostrEvent_presenceForNonActiveGeohash() async { @Test func subscribeNostrEvent_presenceForNonActiveGeohash() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let activeGeohash = "u4pruydq" let activeGeohash = "u4pruydq"
let otherGeohash = "87yw7" let otherGeohash = "87yw7"
@@ -258,18 +258,18 @@ struct ChatViewModelPresenceHandlingTests {
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash))) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash)))
// Create presence event for a DIFFERENT geohash // Create presence event for a DIFFERENT geohash
var event = NostrEvent( let identity = try NostrIdentity.generate()
pubkey: "other1234other1234other1234other1234other1234other1234other1234", let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .geohashPresence, kind: .geohashPresence,
tags: [["g", otherGeohash]], tags: [["g", otherGeohash]],
content: "" content: ""
) )
event.id = "other_geohash_presence" let signed = try event.sign(with: identity.schnorrSigningKey())
event.sig = "sig"
// Use subscribeNostrEvent with geohash parameter // Use subscribeNostrEvent with geohash parameter
viewModel.subscribeNostrEvent(event, gh: otherGeohash) viewModel.subscribeNostrEvent(signed, gh: otherGeohash)
try? await Task.sleep(nanoseconds: 50_000_000) try? await Task.sleep(nanoseconds: 50_000_000)
+6 -5
View File
@@ -48,7 +48,7 @@ struct LocationNotesManagerTests {
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays") // XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
// } // }
@Test func subscribeUsesGeoRelaysAndAppendsNotes() { @Test func subscribeUsesGeoRelaysAndAppendsNotes() throws {
var relaysCaptured: [String] = [] var relaysCaptured: [String] = []
var storedHandler: ((NostrEvent) -> Void)? var storedHandler: ((NostrEvent) -> Void)?
var storedEOSE: (() -> Void)? var storedEOSE: (() -> Void)?
@@ -71,15 +71,16 @@ struct LocationNotesManagerTests {
#expect(relaysCaptured == ["wss://relay.one"]) #expect(relaysCaptured == ["wss://relay.one"])
#expect(manager.state == .loading) #expect(manager.state == .loading)
var event = NostrEvent( let identity = try NostrIdentity.generate()
pubkey: "pub", let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .textNote, kind: .textNote,
tags: [["g", "u4pruydq"]], tags: [["g", "u4pruydq"]],
content: "hi" content: "hi"
) )
event.id = "event1" let signed = try event.sign(with: identity.schnorrSigningKey())
storedHandler?(event) storedHandler?(signed)
storedEOSE?() storedEOSE?()
#expect(manager.state == .ready) #expect(manager.state == .ready)
+27
View File
@@ -213,6 +213,33 @@ struct NostrProtocolTests {
} }
} }
@Test func nostrEventSignatureVerification_roundTrip() throws {
let identity = try NostrIdentity.generate()
var event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [],
content: "Signed event"
)
let signed = try event.sign(with: identity.schnorrSigningKey())
#expect(signed.isValidSignature())
}
@Test func nostrEventSignatureVerification_detectsTamper() throws {
let identity = try NostrIdentity.generate()
var event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [],
content: "Original"
)
var signed = try event.sign(with: identity.schnorrSigningKey())
signed.id = "deadbeef"
#expect(!signed.isValidSignature())
}
// MARK: - Helpers // MARK: - Helpers
private static func base64URLDecode(_ s: String) -> Data? { private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
@@ -315,6 +315,34 @@ struct BinaryProtocolTests {
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet") let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet")
#expect(decodedPacket.payload == smallPayload) #expect(decodedPacket.payload == smallPayload)
} }
@Test("Reject payloads larger than the framed file cap")
func oversizedPayloadIsRejected() throws {
let targetSize = FileTransferLimits.maxFramedFileBytes + 1
var oversized = Data()
oversized.reserveCapacity(targetSize)
let byteRun = Data((0...255).map { UInt8($0) })
while oversized.count < targetSize {
let remaining = targetSize - oversized.count
if remaining >= byteRun.count {
oversized.append(byteRun)
} else {
oversized.append(byteRun.prefix(remaining))
}
}
let packet = BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: "0011223344556677") ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: oversized,
signature: nil,
ttl: 1,
version: 2
)
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode oversized packet")
#expect(BinaryProtocol.decode(encoded) == nil)
}
// MARK: - Message Padding Tests // MARK: - Message Padding Tests