mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05: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:
@@ -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