mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
Harden Nostr validation and BLE announce tests (#1012)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -528,6 +528,26 @@ struct NostrEvent: Codable {
|
||||
signed.sig = signatureHex
|
||||
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) {
|
||||
let serialized = [
|
||||
|
||||
@@ -764,7 +764,8 @@ private enum ParsedInbound {
|
||||
if array.count >= 3,
|
||||
let subId = array[1] as? String,
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -343,6 +343,7 @@ struct BinaryProtocol {
|
||||
}
|
||||
|
||||
guard payloadLength >= 0 else { return nil }
|
||||
guard payloadLength <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
||||
|
||||
guard let senderID = readData(senderIDSize) else { return nil }
|
||||
|
||||
|
||||
@@ -2038,26 +2038,28 @@ extension BLEService {
|
||||
#if DEBUG
|
||||
// Test-only helper to inject packets into the receive pipeline
|
||||
extension BLEService {
|
||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID) {
|
||||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
||||
let normalizedID = PeerID(hexData: packet.senderID)
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
if peers[normalizedID] == nil {
|
||||
peers[normalizedID] = PeerInfo(
|
||||
peerID: normalizedID,
|
||||
nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
|
||||
isConnected: true,
|
||||
noisePublicKey: packet.senderID,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: Date()
|
||||
)
|
||||
} else {
|
||||
var p = peers[normalizedID]!
|
||||
p.isConnected = true
|
||||
p.isVerifiedNickname = true
|
||||
p.lastSeen = Date()
|
||||
peers[normalizedID] = p
|
||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true) {
|
||||
if preseedPeer {
|
||||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
||||
let normalizedID = PeerID(hexData: packet.senderID)
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
if peers[normalizedID] == nil {
|
||||
peers[normalizedID] = PeerInfo(
|
||||
peerID: normalizedID,
|
||||
nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
|
||||
isConnected: true,
|
||||
noisePublicKey: packet.senderID,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: Date()
|
||||
)
|
||||
} else {
|
||||
var p = peers[normalizedID]!
|
||||
p.isConnected = true
|
||||
p.isVerifiedNickname = true
|
||||
p.lastSeen = Date()
|
||||
peers[normalizedID] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
handleReceivedPacket(packet, from: fromPeerID)
|
||||
@@ -3839,7 +3841,7 @@ extension BLEService {
|
||||
let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey)
|
||||
if derivedFromKey != peerID {
|
||||
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
|
||||
|
||||
@@ -56,6 +56,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||
guard event.isValidSignature() else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
||||
!deduplicationService.hasProcessedNostrEvent(event.id)
|
||||
@@ -146,13 +147,12 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id),
|
||||
content.hasPrefix("bitchat1:"),
|
||||
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData),
|
||||
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let noisePayload = NoisePayload.decode(packet.payload)
|
||||
else {
|
||||
@@ -254,6 +254,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func handleNostrEvent(_ event: NostrEvent) {
|
||||
guard event.isValidSignature() else { return }
|
||||
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
||||
@@ -367,6 +368,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
|
||||
return
|
||||
}
|
||||
@@ -380,9 +382,7 @@ extension ChatViewModel {
|
||||
|
||||
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
|
||||
|
||||
guard content.hasPrefix("bitchat1:"),
|
||||
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData),
|
||||
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload)
|
||||
else {
|
||||
@@ -485,6 +485,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
||||
guard event.isValidSignature() else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
||||
|
||||
@@ -602,6 +603,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
|
||||
do {
|
||||
@@ -619,8 +621,7 @@ extension ChatViewModel {
|
||||
|
||||
// Check if it's a BitChat packet embedded in the content (bitchat1:...)
|
||||
if content.hasPrefix("bitchat1:") {
|
||||
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData) else {
|
||||
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content) else {
|
||||
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -631,24 +632,23 @@ extension ChatViewModel {
|
||||
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
|
||||
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
|
||||
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||
if let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
// Store Nostr mapping
|
||||
await MainActor.run {
|
||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
|
||||
// Handle packet types
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
// Store Nostr mapping
|
||||
await MainActor.run {
|
||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
|
||||
// Handle packet types
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -801,6 +801,19 @@ extension ChatViewModel {
|
||||
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)
|
||||
|
||||
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
||||
|
||||
@@ -49,6 +49,40 @@ struct BLEServiceCoreTests {
|
||||
#expect(!didReceive)
|
||||
#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 {
|
||||
|
||||
@@ -265,7 +265,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async {
|
||||
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async throws {
|
||||
let geohash = "u4pruydq"
|
||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
||||
|
||||
@@ -278,17 +278,16 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
|
||||
|
||||
var event = NostrEvent(
|
||||
pubkey: "pub1",
|
||||
let signer = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: signer.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Hello Geo"
|
||||
)
|
||||
event.id = "evt1"
|
||||
event.sig = "sig"
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
let signed = try event.sign(with: signer.schnorrSigningKey())
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
let didAppend = await TestHelpers.waitUntil({
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
@@ -305,16 +304,15 @@ struct ChatViewModelNostrExtensionTests {
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
|
||||
|
||||
var event = NostrEvent(
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Self echo"
|
||||
)
|
||||
event.id = "evt-self"
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
@@ -323,24 +321,24 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_skipsBlockedSender() async {
|
||||
func handleNostrEvent_skipsBlockedSender() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
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.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true)
|
||||
|
||||
var event = NostrEvent(
|
||||
let event = NostrEvent(
|
||||
pubkey: blockedPubkey,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Blocked"
|
||||
)
|
||||
event.id = "evt-blocked"
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
let signed = try event.sign(with: blockedIdentity.schnorrSigningKey())
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
@@ -348,6 +346,52 @@ struct ChatViewModelNostrExtensionTests {
|
||||
#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
|
||||
func switchLocationChannel_clearsNostrDedupCache() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
@@ -406,3 +450,10 @@ struct ChatViewModelGeoDMTests {
|
||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
||||
}
|
||||
}
|
||||
|
||||
private func base64URLEncode(_ data: Data) -> String {
|
||||
data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
@@ -195,10 +195,12 @@ struct ChatViewModelReceivingTests {
|
||||
messageID: "pub-001"
|
||||
)
|
||||
|
||||
// Give time for async Task and pipeline processing
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
let found = await TestHelpers.waitUntil({
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ struct NostrFilterPresenceTests {
|
||||
@MainActor
|
||||
struct ChatViewModelPresenceHandlingTests {
|
||||
|
||||
@Test func handleNostrEvent_presenceUpdatesParticipantTracker() async {
|
||||
@Test func handleNostrEvent_presenceUpdatesParticipantTracker() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
@@ -139,18 +139,18 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
// Create a presence event (kind 20001)
|
||||
var event = NostrEvent(
|
||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", geohash]],
|
||||
content: ""
|
||||
)
|
||||
event.id = "presence_evt_1"
|
||||
event.sig = "sig"
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
// Handle the event
|
||||
viewModel.handleNostrEvent(event)
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
// Allow async processing
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
@@ -160,7 +160,7 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
#expect(count >= 1)
|
||||
}
|
||||
|
||||
@Test func handleNostrEvent_presenceDoesNotAddToTimeline() async {
|
||||
@Test func handleNostrEvent_presenceDoesNotAddToTimeline() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
@@ -169,17 +169,17 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
let initialMessageCount = viewModel.messages.count
|
||||
|
||||
// Create a presence event (kind 20001)
|
||||
var event = NostrEvent(
|
||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", geohash]],
|
||||
content: ""
|
||||
)
|
||||
event.id = "presence_evt_2"
|
||||
event.sig = "sig"
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
@@ -187,24 +187,24 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
#expect(viewModel.messages.count == initialMessageCount)
|
||||
}
|
||||
|
||||
@Test func handleNostrEvent_chatMessageUpdatesParticipant() async {
|
||||
@Test func handleNostrEvent_chatMessageUpdatesParticipant() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
// Create a chat event (kind 20000) - NOT presence
|
||||
var event = NostrEvent(
|
||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Hello world"
|
||||
)
|
||||
event.id = "chat_evt_1"
|
||||
event.sig = "sig"
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
@@ -223,25 +223,25 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
#expect(chatKind == 20000)
|
||||
}
|
||||
|
||||
@Test func subscribeNostrEvent_acceptsPresenceKind() async {
|
||||
@Test func subscribeNostrEvent_acceptsPresenceKind() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
// Create presence event
|
||||
var event = NostrEvent(
|
||||
pubkey: "test1234test1234test1234test1234test1234test1234test1234test1234",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", geohash]],
|
||||
content: ""
|
||||
)
|
||||
event.id = "subscribe_presence_evt"
|
||||
event.sig = "sig"
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
// subscribeNostrEvent should accept kind 20001
|
||||
viewModel.subscribeNostrEvent(event)
|
||||
viewModel.subscribeNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
@@ -250,7 +250,7 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
#expect(count >= 1)
|
||||
}
|
||||
|
||||
@Test func subscribeNostrEvent_presenceForNonActiveGeohash() async {
|
||||
@Test func subscribeNostrEvent_presenceForNonActiveGeohash() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let activeGeohash = "u4pruydq"
|
||||
let otherGeohash = "87yw7"
|
||||
@@ -258,18 +258,18 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash)))
|
||||
|
||||
// Create presence event for a DIFFERENT geohash
|
||||
var event = NostrEvent(
|
||||
pubkey: "other1234other1234other1234other1234other1234other1234other1234",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", otherGeohash]],
|
||||
content: ""
|
||||
)
|
||||
event.id = "other_geohash_presence"
|
||||
event.sig = "sig"
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
// Use subscribeNostrEvent with geohash parameter
|
||||
viewModel.subscribeNostrEvent(event, gh: otherGeohash)
|
||||
viewModel.subscribeNostrEvent(signed, gh: otherGeohash)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ struct LocationNotesManagerTests {
|
||||
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
||||
// }
|
||||
|
||||
@Test func subscribeUsesGeoRelaysAndAppendsNotes() {
|
||||
@Test func subscribeUsesGeoRelaysAndAppendsNotes() throws {
|
||||
var relaysCaptured: [String] = []
|
||||
var storedHandler: ((NostrEvent) -> Void)?
|
||||
var storedEOSE: (() -> Void)?
|
||||
@@ -71,15 +71,16 @@ struct LocationNotesManagerTests {
|
||||
#expect(relaysCaptured == ["wss://relay.one"])
|
||||
#expect(manager.state == .loading)
|
||||
|
||||
var event = NostrEvent(
|
||||
pubkey: "pub",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
tags: [["g", "u4pruydq"]],
|
||||
content: "hi"
|
||||
)
|
||||
event.id = "event1"
|
||||
storedHandler?(event)
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
storedHandler?(signed)
|
||||
storedEOSE?()
|
||||
|
||||
#expect(manager.state == .ready)
|
||||
|
||||
@@ -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
|
||||
private static func base64URLDecode(_ s: String) -> Data? {
|
||||
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")
|
||||
#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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user