mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 10:05:19 +00:00
Harden private media migration compatibility
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct BLENoisePayloadFactoryTests {
|
||||
@@ -44,10 +45,38 @@ struct BLENoisePayloadFactoryTests {
|
||||
|
||||
let payload = try #require(BLENoisePayloadFactory.privateFile(file))
|
||||
|
||||
#expect(payload.first == NoisePayloadType.privateFile.rawValue)
|
||||
#expect(payload.first == 0x20, "Encrypted files must use Android's deployed wire value")
|
||||
let decoded = try #require(BitchatFilePacket.decode(Data(payload.dropFirst())))
|
||||
#expect(decoded.fileName == "secret.pdf")
|
||||
#expect(decoded.mimeType == "application/pdf")
|
||||
#expect(decoded.content == content)
|
||||
}
|
||||
|
||||
@Test
|
||||
func androidB7f0b33PrivateFilePlaintextFixtureIsByteCompatible() throws {
|
||||
// Runtime-emitted by Android commit b7f0b33d from
|
||||
// BitchatFilePacket("a.txt", 3, "text/plain", [01, 02, 03]) and
|
||||
// NoisePayload(type = FILE_TRANSFER, data = file.encode()).encode().
|
||||
let fixtureHex = "20010005612e7478740200040000000303000a746578742f706c61696e0400000003010203"
|
||||
let fixture = try #require(Data(hexString: fixtureHex))
|
||||
|
||||
let typed = try #require(NoisePayload.decode(fixture))
|
||||
#expect(typed.type == .privateFile)
|
||||
let file = try #require(BitchatFilePacket.decode(typed.data))
|
||||
#expect(file.fileName == "a.txt")
|
||||
#expect(file.fileSize == 3)
|
||||
#expect(file.mimeType == "text/plain")
|
||||
#expect(file.content == Data([0x01, 0x02, 0x03]))
|
||||
#expect(BLENoisePayloadFactory.privateFile(file) == fixture)
|
||||
}
|
||||
|
||||
@Test
|
||||
func prereleasePrivateFileTypeCanonicalizesOnDecode() throws {
|
||||
let encoded = Data([NoisePayloadType.prereleasePrivateFileRawValue, 0xCA, 0xFE])
|
||||
let decoded = try #require(NoisePayload.decode(encoded))
|
||||
|
||||
#expect(decoded.type == .privateFile)
|
||||
#expect(decoded.data == Data([0xCA, 0xFE]))
|
||||
#expect(decoded.encode().first == 0x20)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ struct BLENoiseSessionQueuesTests {
|
||||
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||
var queues = BLENoiseSessionQueues()
|
||||
|
||||
queues.appendTypedPayload(Data([0x09, 0xAA]), transferId: "media-1", for: peerID)
|
||||
queues.appendTypedPayload(Data([0x20, 0xAA]), transferId: "media-1", for: peerID)
|
||||
queues.appendTypedPayload(Data([0x01, 0xBB]), for: peerID)
|
||||
|
||||
let removed = queues.removeTypedPayload(transferId: "media-1")
|
||||
|
||||
@@ -108,6 +108,58 @@ struct BLEOutboundFragmentPlannerTests {
|
||||
) == nil)
|
||||
}
|
||||
|
||||
@Test("private media v1 accepts exactly 256 fragments and rejects 257")
|
||||
func privateMediaCrossPlatformFragmentBoundary() throws {
|
||||
let maxPayload = makePayload(count: 160 * 1024, seed: 0xFACE_CAFE)
|
||||
|
||||
func plan(payloadCount: Int) throws -> BLEOutboundFragmentPlan {
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: "0011223344556677") ?? Data(),
|
||||
recipientID: Data(hexString: "8877665544332211"),
|
||||
timestamp: 0x0102030405,
|
||||
payload: Data(maxPayload.prefix(payloadCount)),
|
||||
signature: nil,
|
||||
ttl: 3,
|
||||
version: 2
|
||||
)
|
||||
return try #require(BLEOutboundFragmentPlanner.makePlan(
|
||||
for: BLEOutboundFragmentTransferRequest(
|
||||
packet: packet,
|
||||
pad: false,
|
||||
maxChunk: nil,
|
||||
directedPeer: PeerID(str: "8877665544332211"),
|
||||
transferId: "boundary"
|
||||
),
|
||||
defaultChunkSize: TransportConfig.bleDefaultFragmentSize,
|
||||
bleMaxMTU: 512,
|
||||
fragmentID: Data(repeating: 0xD4, count: 8)
|
||||
))
|
||||
}
|
||||
|
||||
func firstPlan(withAtLeast target: Int) throws -> BLEOutboundFragmentPlan {
|
||||
var low = 1
|
||||
var high = maxPayload.count
|
||||
while low < high {
|
||||
let mid = low + (high - low) / 2
|
||||
if try plan(payloadCount: mid).totalFragments >= target {
|
||||
high = mid
|
||||
} else {
|
||||
low = mid + 1
|
||||
}
|
||||
}
|
||||
return try plan(payloadCount: low)
|
||||
}
|
||||
|
||||
let at256 = try firstPlan(withAtLeast: 256)
|
||||
let at257 = try firstPlan(withAtLeast: 257)
|
||||
|
||||
#expect(at256.totalFragments == 256)
|
||||
#expect(BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at256))
|
||||
#expect(at257.totalFragments == 257)
|
||||
#expect(!BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at257))
|
||||
}
|
||||
|
||||
private func makePacket(
|
||||
payload: Data,
|
||||
route: [Data]? = nil,
|
||||
|
||||
@@ -42,6 +42,37 @@ struct BLEPeerRegistryTests {
|
||||
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
|
||||
}
|
||||
|
||||
@Test("registry preserves absent versus explicit empty capabilities")
|
||||
func capabilitiesPresenceIsPreserved() {
|
||||
var registry = BLEPeerRegistry()
|
||||
let oldPeer = PeerID(str: "1122334455667788")
|
||||
let modernPeer = PeerID(str: "8877665544332211")
|
||||
|
||||
_ = registry.upsertVerifiedAnnounce(
|
||||
peerID: oldPeer,
|
||||
nickname: "old",
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x12, count: 32),
|
||||
isConnected: true,
|
||||
now: Date(),
|
||||
capabilities: nil
|
||||
)
|
||||
_ = registry.upsertVerifiedAnnounce(
|
||||
peerID: modernPeer,
|
||||
nickname: "modern",
|
||||
noisePublicKey: Data(repeating: 0x21, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||
isConnected: true,
|
||||
now: Date(),
|
||||
capabilities: []
|
||||
)
|
||||
|
||||
#expect(registry.capabilities(for: oldPeer).isEmpty)
|
||||
#expect(!registry.capabilitiesWereExplicitlyAdvertised(for: oldPeer))
|
||||
#expect(registry.capabilities(for: modernPeer).isEmpty)
|
||||
#expect(registry.capabilitiesWereExplicitlyAdvertised(for: modernPeer))
|
||||
}
|
||||
|
||||
@Test("reachability keeps recent verified offline peers only when mesh is attached")
|
||||
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
||||
let offlinePeer = PeerID(str: "1122334455667788")
|
||||
|
||||
@@ -399,6 +399,34 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
func test_privateMediaCapabilityPinPersistsMonotonicallyAndPanicClearRemovesIt() async {
|
||||
let keychain = MockKeychain()
|
||||
let fingerprint = Data(repeating: 0x42, count: 32).sha256Fingerprint()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
|
||||
XCTAssertFalse(manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint))
|
||||
manager.markPrivateMediaCapable(fingerprint: fingerprint)
|
||||
XCTAssertTrue(
|
||||
manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint),
|
||||
"pin insertion must be synchronously visible to the next downgrade decision"
|
||||
)
|
||||
|
||||
// Re-marking is idempotent, and the encrypted cache carries the pin
|
||||
// across launches.
|
||||
manager.markPrivateMediaCapable(fingerprint: fingerprint)
|
||||
manager.forceSave()
|
||||
let reloaded = SecureIdentityStateManager(keychain)
|
||||
XCTAssertTrue(reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint))
|
||||
|
||||
// ChatViewModel's panic path calls this same wipe after deleting
|
||||
// keychain data; the in-memory pin must disappear immediately too.
|
||||
reloaded.clearAllIdentityData()
|
||||
let cleared = await waitUntil {
|
||||
!reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint)
|
||||
}
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {
|
||||
let keychain = FailingCacheSaveKeychain()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
|
||||
@@ -49,7 +49,7 @@ struct TransferProgressManagerTests {
|
||||
recorder.append("updated:\(id):\(sent):\(total)")
|
||||
case .completed(let id, let total):
|
||||
recorder.append("completed:\(id):\(total)")
|
||||
case .cancelled:
|
||||
case .cancelled, .rejected:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ struct TransferProgressManagerTests {
|
||||
recorder.append("started:\(id):\(total)")
|
||||
case .cancelled(let id, let sent, let total):
|
||||
recorder.append("cancelled:\(id):\(sent):\(total)")
|
||||
case .updated, .completed:
|
||||
case .updated, .completed, .rejected:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -106,24 +106,22 @@ struct TransferProgressManagerTests {
|
||||
_ = cancellable
|
||||
}
|
||||
|
||||
@Test("Preflight rejection publishes cancellation without a started state")
|
||||
@Test("Preflight policy rejection publishes a visible failure reason")
|
||||
@MainActor
|
||||
func rejectBeforeStartPublishesCancellation() async {
|
||||
func rejectBeforeStartPublishesReason() async {
|
||||
let manager = TransferProgressManager()
|
||||
let transferID = "transfer-preflight-reject"
|
||||
var cancellable: AnyCancellable?
|
||||
let transferID = "transfer-visible-reject"
|
||||
let recorder = EventRecorder()
|
||||
|
||||
cancellable = manager.publisher.sink { event in
|
||||
if case .cancelled(let id, let sent, let total) = event {
|
||||
recorder.append("cancelled:\(id):\(sent):\(total)")
|
||||
let cancellable = manager.publisher.sink { event in
|
||||
if case .rejected(let id, let reason) = event {
|
||||
recorder.append("rejected:\(id):\(reason)")
|
||||
}
|
||||
}
|
||||
|
||||
manager.rejectBeforeStart(id: transferID)
|
||||
manager.rejectBeforeStart(id: transferID, reason: "upgrade required")
|
||||
|
||||
let didReceive = await TestHelpers.waitUntil({
|
||||
recorder.values == ["cancelled:\(transferID):0:0"]
|
||||
recorder.values == ["rejected:\(transferID):upgrade required"]
|
||||
}, timeout: 5.0)
|
||||
#expect(didReceive)
|
||||
#expect(manager.snapshot(id: transferID) == nil)
|
||||
|
||||
@@ -266,6 +266,9 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
verified.removeAll()
|
||||
}
|
||||
|
||||
func markPrivateMediaCapable(fingerprint: String) {}
|
||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false }
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {}
|
||||
|
||||
func setVerified(fingerprint: String, verified: Bool) {
|
||||
|
||||
Reference in New Issue
Block a user