mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
* PTT hardening: burst-ID collision hijack + live-capture quota bypass Fix C — burst-ID collision hijack: inbound live-voice assemblies and the finished-burst registry were keyed by the sender-chosen burst ID alone, so an attacker who observed a public burst ID could race a START and capture the real talker's frames (packets from the true sender were dropped as "collisions"). Assemblies now key on (peerID, scope, burstID): a colliding START from another peer opens its own capped assembly and can never divert the victim's frames; finalized-note absorption matches on the same triple, preserving the sender/scope binding. Live capture files also gain the peer ID in their name so colliding bursts land on distinct paths (still rejected by burstID(fromVoiceFileName:), so live names remain unabsorbable). Fix B — live-burst files bypassed the incoming-media quota: progressive voice_live_*.aac captures were written via raw FileHandle without ever touching BLEIncomingFileStore.enforceQuota, growing disk unbounded outside the 100 MB LRU accounting. The coordinator now takes an injected BLEIncomingFileStore, reserves pttMaxBurstBytes before opening a capture, and sweeps orphaned voice_live_* partials from previous sessions at startup. enforceQuota gains an `excluding:` set so in-flight partials are never LRU-evicted mid-stream; existing callers are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Review fixes: pattern-guard live captures in quota, scope in capture names Quota-eviction gap: BLEFileTransferHandler enforces the quota for every finalized arrival via enforceQuota(reservingBytes:) with no exclusion set, so a file landing at quota could LRU-evict an in-flight live capture — unlinking the inode under the coordinator's open FileHandle and leaving a dead bubble. Protection is now layer-independent: enforceQuota itself skips voice_live_* names (they still count toward usage), and the excluding: parameter is gone since the pattern guard covers its only caller. Orphaned partials are still reclaimed by the coordinator's startup sweep, which the quota deliberately never touches. Same-peer cross-scope truncation: makeIncomingURL omitted the scope, so one peer running a DM burst and a public burst with the same burst ID mapped both assemblies onto one path — and FileManager.createFile truncates, so each START corrupted the other capture. Names now mirror the assembly key: voice_live_<burstHex>_<peerID>_<dm|mesh>.aac. The sweep and quota guard match on the voice_live_ prefix and burstID(fromVoiceFileName:) still rejects every live name, so live captures remain unabsorbable; sameBurstIDCoexistsAcrossScopes now asserts both files survive with intact contents. Nits: the coordinator's file operations route through the store's injectable FileManager instead of FileManager.default, and the TestEnvironment.isRunningTests branch in init is replaced by a sweepsOnInit parameter (tests sharing the real application-support directory pass false for hermeticity). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Promote kept live captures off the voice_live_ name at finalize Codex P1 follow-up: when a burst finalizes with frames but its .m4a note never arrives, the voice_live_*.aac capture stays behind as the bubble's replayable audio — yet the startup sweep deletes every voice_live_* file and the quota guard skips them forever, so a kept fallback was both quota-immune for the rest of the session and doomed at the next launch. finalize now promotes the capture to a plain voice_ name (same suffix) and republishes the row pointing at it; the finished-burst registry tracks the promoted URL so a late note still absorbs and deletes it. voice_live_ is thereby scoped to genuinely in-flight captures: the sweep never touches a referenced fallback, and promoted files age out of the quota like any finalized media. If the move fails the live name is kept — exactly the pre-promotion behavior. Premise correction, verified while tracing the reference model: chat rows are NOT persisted across restarts (ConversationStore is in-memory; the gossip archive replays MessageType.message packets only), so today's sweep never orphaned a persisted row — the fix removes the in-session quota immunity and makes the invariant hold if row persistence ever lands. Crash case stays deliberate and documented: a mid-burst partial from a dead process is swept because no surviving row can reference it. Tests: finalize-as-fallback promotes the file, repoints the row, and survives a later coordinator's startup sweep; promoted fallbacks are LRU-evictable by the quota; the sweep still removes true orphans while sparing promoted names; existing burst tests updated for the promoted paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
426 lines
19 KiB
Swift
426 lines
19 KiB
Swift
import BitFoundation
|
|
import Foundation
|
|
import Testing
|
|
@testable import bitchat
|
|
|
|
struct BLEFileTransferHandlerTests {
|
|
private final class Recorder {
|
|
var localNickname = "Me"
|
|
var peers: [PeerID: BLEPeerInfo] = [:]
|
|
var signedName: String?
|
|
var signatureVerifies = false
|
|
var saveResult: URL? = URL(fileURLWithPath: "/tmp/files/incoming/sample.pdf")
|
|
|
|
var signatureVerifyCount = 0
|
|
var signedNameQueries: [PeerID] = []
|
|
var trackedPackets: [BitchatPacket] = []
|
|
var quotaReservations: [Int] = []
|
|
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
|
|
var lastSeenUpdates: [PeerID] = []
|
|
var deliveredMessages: [BitchatMessage] = []
|
|
}
|
|
|
|
private let localPeerID = PeerID(str: "0102030405060708")
|
|
private let remotePeerID = PeerID(str: "1122334455667788")
|
|
private let sampleSigningKey = Data(repeating: 0xAB, count: 32)
|
|
|
|
private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler {
|
|
let environment = BLEFileTransferHandlerEnvironment(
|
|
localPeerID: { [localPeerID] in localPeerID },
|
|
localNickname: { recorder.localNickname },
|
|
peersSnapshot: { recorder.peers },
|
|
verifyPacketSignature: { _, _ in
|
|
recorder.signatureVerifyCount += 1
|
|
return recorder.signatureVerifies
|
|
},
|
|
signedSenderDisplayName: { _, peerID in
|
|
recorder.signedNameQueries.append(peerID)
|
|
return recorder.signedName
|
|
},
|
|
trackPacketSeen: { packet in
|
|
recorder.trackedPackets.append(packet)
|
|
},
|
|
enforceStorageQuota: { reservingBytes in
|
|
recorder.quotaReservations.append(reservingBytes)
|
|
},
|
|
saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
|
recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix))
|
|
return recorder.saveResult
|
|
},
|
|
updatePeerLastSeen: { peerID in
|
|
recorder.lastSeenUpdates.append(peerID)
|
|
},
|
|
deliverMessage: { message in
|
|
recorder.deliveredMessages.append(message)
|
|
}
|
|
)
|
|
return BLEFileTransferHandler(environment: environment)
|
|
}
|
|
|
|
@Test
|
|
func broadcastFileFromVerifiedPeerIsSavedAndDelivered() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
|
recorder.signatureVerifies = true
|
|
let handler = makeHandler(recorder: recorder)
|
|
let content = Data("%PDF-1.7".utf8)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: content)
|
|
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
#expect(recorder.signatureVerifyCount == 1)
|
|
#expect(recorder.signedNameQueries.isEmpty)
|
|
#expect(recorder.trackedPackets.count == 1)
|
|
#expect(recorder.quotaReservations == [content.count])
|
|
#expect(recorder.saveCalls.count == 1)
|
|
#expect(recorder.saveCalls.first?.data == content)
|
|
#expect(recorder.saveCalls.first?.preferredName == "sample")
|
|
#expect(recorder.saveCalls.first?.subdirectory == "files/incoming")
|
|
#expect(recorder.saveCalls.first?.fallbackExtension == "pdf")
|
|
#expect(recorder.saveCalls.first?.defaultPrefix == "file")
|
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
|
#expect(recorder.deliveredMessages.count == 1)
|
|
let message = recorder.deliveredMessages.first
|
|
#expect(message?.sender == "Alice")
|
|
#expect(message?.content == "[file] sample.pdf")
|
|
#expect(message?.isPrivate == false)
|
|
#expect(message?.senderPeerID == remotePeerID)
|
|
#expect(message?.timestamp == Date(timeIntervalSince1970: 900))
|
|
#expect(message?.deliveryStatus == nil)
|
|
}
|
|
|
|
@Test
|
|
func selfEchoIsDropped() throws {
|
|
let recorder = Recorder()
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3)
|
|
|
|
// The relay pipeline already suppresses self-originated packets, so the
|
|
// handler reports "relayable" rather than treating the echo as forged.
|
|
#expect(handler.handle(packet, from: localPeerID))
|
|
|
|
expectNoSideEffects(recorder)
|
|
}
|
|
|
|
@Test
|
|
func unknownPeerWithoutValidSignatureIsDropped() throws {
|
|
let recorder = Recorder()
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
|
|
|
#expect(!handler.handle(packet, from: remotePeerID))
|
|
|
|
#expect(recorder.signedNameQueries == [remotePeerID])
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func broadcastFromConnectedUnverifiedPeerWithoutSignatureIsDropped() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
|
|
|
// Failed sender authentication must also stop the packet from being
|
|
// relayed to downstream nodes.
|
|
#expect(!handler.handle(packet, from: remotePeerID))
|
|
|
|
// Broadcast files carry an attacker-controllable senderID, so — like
|
|
// public messages — a connected-but-unverified peer must present a valid
|
|
// packet signature. No signing key + no signed identity means dropped.
|
|
#expect(recorder.signedNameQueries == [remotePeerID])
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func broadcastFromConnectedUnverifiedPeerWithSignedIdentityIsAccepted() throws {
|
|
let recorder = Recorder()
|
|
// Connected but nickname not yet verified and no registry signing key —
|
|
// the persisted-identity signature lookup still authenticates the
|
|
// sender, so the transfer is accepted under that verified name.
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
|
recorder.signedName = "Bob"
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
|
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
#expect(recorder.signedNameQueries == [remotePeerID])
|
|
#expect(recorder.deliveredMessages.count == 1)
|
|
#expect(recorder.deliveredMessages.first?.sender == "Bob")
|
|
}
|
|
|
|
@Test
|
|
func selfBroadcastReplayIsDeliveredWithoutSignatureCheck() throws {
|
|
// Our own broadcast file replayed via gossip sync arrives with ttl==0
|
|
// (so it is not treated as a self-echo) and cannot be verified against
|
|
// the peer registry — it must still be accepted, matching
|
|
// BLEPublicMessageHandler's self exemption.
|
|
let recorder = Recorder()
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(
|
|
sender: localPeerID,
|
|
mimeType: "application/pdf",
|
|
content: Data("%PDF-1.7".utf8),
|
|
ttl: 0
|
|
)
|
|
|
|
#expect(handler.handle(packet, from: localPeerID))
|
|
|
|
#expect(recorder.signatureVerifyCount == 0)
|
|
#expect(recorder.signedNameQueries.isEmpty)
|
|
#expect(recorder.deliveredMessages.count == 1)
|
|
#expect(recorder.deliveredMessages.first?.sender == "Me")
|
|
}
|
|
|
|
@Test
|
|
func broadcastFromPeerNotInRegistryAcceptedViaSignedIdentity() throws {
|
|
let recorder = Recorder()
|
|
recorder.signedName = "Carol"
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
|
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
// Peer absent from the registry: fall back to the persisted-identity
|
|
// signature lookup (mirrors BLEPublicMessageHandler).
|
|
#expect(recorder.signedNameQueries == [remotePeerID])
|
|
#expect(recorder.deliveredMessages.count == 1)
|
|
#expect(recorder.deliveredMessages.first?.sender == "Carol")
|
|
}
|
|
|
|
@Test
|
|
func spoofedBroadcastVoiceNoteWithoutSignatureIsDropped() throws {
|
|
// Regression for the PR #1406 finding: an in-range peer that observed a
|
|
// public voice burst tries to overwrite the live bubble by broadcasting
|
|
// a `voice_<burstID>.m4a` note under the talker's senderID. Without a
|
|
// valid signature the note never reaches the coordinator's absorption.
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Mallory", isVerified: false, isConnected: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let m4a = Data([0x00, 0x00, 0x00, 0x18]) + Data("ftypM4A ".utf8)
|
|
let packet = try makeFileTransferPacket(
|
|
sender: remotePeerID,
|
|
mimeType: "audio/mp4",
|
|
content: m4a,
|
|
fileName: "voice_1122334455667788"
|
|
)
|
|
|
|
// The spoofed note must be dropped locally AND not relayed onward.
|
|
#expect(!handler.handle(packet, from: remotePeerID))
|
|
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func privateFileFromConnectedUnverifiedPeerIsAccepted() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(
|
|
sender: remotePeerID,
|
|
mimeType: "application/pdf",
|
|
content: Data("%PDF-1.7".utf8),
|
|
recipientID: Data(hexString: localPeerID.id)
|
|
)
|
|
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
// Directed transfers keep the lenient connected-peer path (no broadcast
|
|
// exposure); no signature check is required.
|
|
#expect(recorder.signatureVerifyCount == 0)
|
|
#expect(recorder.signedNameQueries.isEmpty)
|
|
#expect(recorder.deliveredMessages.count == 1)
|
|
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
|
}
|
|
|
|
@Test
|
|
func fileDirectedToAnotherPeerIsIgnored() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(
|
|
sender: remotePeerID,
|
|
mimeType: "application/pdf",
|
|
content: Data("%PDF-1.7".utf8),
|
|
recipientID: Data(hexString: "AABBCCDDEEFF0011")
|
|
)
|
|
|
|
// Not for us, but it must keep relaying toward the real recipient.
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.quotaReservations.isEmpty)
|
|
#expect(recorder.saveCalls.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(
|
|
sender: remotePeerID,
|
|
mimeType: "application/pdf",
|
|
content: Data("%PDF-1.7".utf8),
|
|
recipientID: Data(hexString: localPeerID.id)
|
|
)
|
|
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
// Directed transfers are not tracked for gossip sync.
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
|
#expect(recorder.deliveredMessages.count == 1)
|
|
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
|
// Must be explicit: BitchatMessage defaults private messages to
|
|
// .sending, which the media views render as an in-flight send
|
|
// (empty reveal mask, disabled reveal tap).
|
|
#expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900)))
|
|
}
|
|
|
|
@Test
|
|
func malformedPayloadIsTrackedForSyncButDropped() {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
|
recorder.signatureVerifies = true
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = BitchatPacket(
|
|
type: MessageType.fileTransfer.rawValue,
|
|
senderID: Data(hexString: remotePeerID.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: 900_000,
|
|
payload: Data([0x01, 0x02, 0x03]),
|
|
signature: nil,
|
|
ttl: TransportConfig.messageTTLDefault
|
|
)
|
|
|
|
// Local decode failures are not proof of forgery; the packet stays relayable.
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
// Sync tracking happens before payload validation, matching the original order.
|
|
#expect(recorder.trackedPackets.count == 1)
|
|
#expect(recorder.quotaReservations.isEmpty)
|
|
#expect(recorder.saveCalls.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func unsupportedMimeIsDroppedBeforeQuotaAndSave() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
|
recorder.signatureVerifies = true
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: nil, content: Data([0x4D, 0x5A, 0x00, 0x00]))
|
|
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
#expect(recorder.trackedPackets.count == 1)
|
|
#expect(recorder.quotaReservations.isEmpty)
|
|
#expect(recorder.saveCalls.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func saveFailureSkipsDelivery() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
|
recorder.signatureVerifies = true
|
|
recorder.saveResult = nil
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
|
|
|
// A local save failure must not stop the mesh relay.
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
#expect(recorder.quotaReservations.count == 1)
|
|
#expect(recorder.saveCalls.count == 1)
|
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func quotaEvictionForFinalizedArrivalSkipsInFlightLiveCaptures() throws {
|
|
let base = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("quota-live-capture-\(UUID().uuidString)", isDirectory: true)
|
|
defer { try? FileManager.default.removeItem(at: base) }
|
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
|
let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming")
|
|
|
|
// The in-flight partial is the LRU-oldest eviction candidate; without
|
|
// the voice_live_ pattern guard it would be deleted first, unlinking
|
|
// the inode under the coordinator's open FileHandle.
|
|
let inFlight = incoming.appendingPathComponent("voice_live_00112233445566ff_1122334455667788_dm.aac")
|
|
let evictable = incoming.appendingPathComponent("voice_old.m4a")
|
|
try Data(count: 51 * 1024 * 1024).write(to: inFlight)
|
|
try Data(count: 51 * 1024 * 1024).write(to: evictable)
|
|
try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -7200)], ofItemAtPath: inFlight.path)
|
|
try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -60)], ofItemAtPath: evictable.path)
|
|
|
|
// 102 MB used against the 100 MB quota forces one eviction. This is
|
|
// the finalized-file arrival path (BLEFileTransferHandler via
|
|
// BLEService), which knows nothing about in-flight captures — the
|
|
// store itself must protect them.
|
|
store.enforceQuota(reservingBytes: 0)
|
|
|
|
#expect(FileManager.default.fileExists(atPath: inFlight.path))
|
|
#expect(!FileManager.default.fileExists(atPath: evictable.path))
|
|
}
|
|
|
|
private func expectNoSideEffects(_ recorder: Recorder) {
|
|
#expect(recorder.signedNameQueries.isEmpty)
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.quotaReservations.isEmpty)
|
|
#expect(recorder.saveCalls.isEmpty)
|
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
private func makePeerInfo(
|
|
_ peerID: PeerID,
|
|
nickname: String,
|
|
isVerified: Bool,
|
|
isConnected: Bool = true,
|
|
signingPublicKey: Data? = nil
|
|
) -> BLEPeerInfo {
|
|
BLEPeerInfo(
|
|
peerID: peerID,
|
|
nickname: nickname,
|
|
isConnected: isConnected,
|
|
noisePublicKey: nil,
|
|
signingPublicKey: signingPublicKey,
|
|
isVerifiedNickname: isVerified,
|
|
lastSeen: Date(timeIntervalSince1970: 999)
|
|
)
|
|
}
|
|
|
|
private func makeFileTransferPacket(
|
|
sender: PeerID,
|
|
mimeType: String?,
|
|
content: Data,
|
|
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
|
recipientID: Data? = nil,
|
|
fileName: String = "sample"
|
|
) throws -> BitchatPacket {
|
|
let filePacket = BitchatFilePacket(
|
|
fileName: fileName,
|
|
fileSize: UInt64(content.count),
|
|
mimeType: mimeType,
|
|
content: content
|
|
)
|
|
let payload = try #require(filePacket.encode())
|
|
return BitchatPacket(
|
|
type: MessageType.fileTransfer.rawValue,
|
|
senderID: Data(hexString: sender.id) ?? Data(),
|
|
recipientID: recipientID,
|
|
timestamp: 900_000,
|
|
payload: payload,
|
|
signature: nil,
|
|
ttl: ttl
|
|
)
|
|
}
|
|
}
|