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
@@ -529,6 +529,26 @@ struct NostrEvent: Codable {
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 = [
0,
+2 -1
View File
@@ -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
}
+1
View File
@@ -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 }
+4 -2
View File
@@ -2038,7 +2038,8 @@ extension BLEService {
#if DEBUG
// Test-only helper to inject packets into the receive pipeline
extension BLEService {
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID) {
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) {
@@ -2060,6 +2061,7 @@ extension BLEService {
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,8 +632,8 @@ 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) {
if packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
// Store Nostr mapping
await MainActor.run {
@@ -651,7 +652,6 @@ extension ChatViewModel {
}
}
}
}
} else {
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
}
@@ -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? {
+34
View File
@@ -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 {
+68 -17
View File
@@ -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: "")
}
+5 -3
View File
@@ -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)
}
}
+30 -30
View File
@@ -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)
+6 -5
View File
@@ -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)
+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
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
@@ -316,6 +316,34 @@ struct BinaryProtocolTests {
#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
@Test func messagePadding() throws {