Retry confirmed private media after reconnect

This commit is contained in:
jack
2026-07-26 05:41:28 +02:00
parent 075306e650
commit 33c5d8ed8f
9 changed files with 1859 additions and 13 deletions
@@ -364,6 +364,217 @@ struct PrivateMediaEndToEndTests {
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
}
@Test
func privateMediaRetryRequiresExactAuthenticatedBit9Proof() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent(
"private-media-receipt-proof-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(
baseDirectory: root.appendingPathComponent(
"alice",
isDirectory: true
)
)
let bob = makeService(
baseDirectory: root.appendingPathComponent(
"bob",
isDirectory: true
)
)
let bothCapabilities: PeerCapabilities = [
.privateMedia,
.privateMediaReceipts
]
// A public bit-9 announce is discovery only.
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: bothCapabilities,
noisePublicKey: bob.noiseStaticPublicKeyData()
)
#expect(
alice.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) == nil
)
let proofs = try await establishSessionCapturingPeerState(
alice: alice,
bob: bob
)
#expect(
alice.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) == nil
)
// Bit 8 alone preserves encrypted transfer compatibility but cannot
// authorize automatic resend.
let privateMediaOnly = try authenticatedPeerStatePacket(
from: bob,
to: alice,
capabilities: .privateMedia
)
alice._test_handlePacket(
privateMediaOnly,
fromPeerID: bob.myPeerID
)
#expect(await TestHelpers.waitUntil(
{
alice.privateMediaSendPolicy(to: bob.myPeerID)
== .encrypted
},
timeout: TestConstants.longTimeout
))
#expect(
alice.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) == nil
)
let receiptCapable = try authenticatedPeerStatePacket(
from: bob,
to: alice,
capabilities: bothCapabilities
)
alice._test_handlePacket(
receiptCapable,
fromPeerID: bob.myPeerID
)
#expect(await TestHelpers.waitUntil(
{
alice.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) != nil
},
timeout: TestConstants.longTimeout
))
bob._test_handlePacket(
proofs.alice,
fromPeerID: alice.myPeerID
)
alice._test_onOutboundPacket = nil
bob._test_onOutboundPacket = nil
}
@Test
func receiptRetryRechecksBit9AtDeferredTransportBoundary() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent(
"private-media-retry-proof-race-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(
baseDirectory: root.appendingPathComponent(
"alice",
isDirectory: true
)
)
let bob = makeService(
baseDirectory: root.appendingPathComponent(
"bob",
isDirectory: true
)
)
let receiptCapabilities: PeerCapabilities = [
.privateMedia,
.privateMediaReceipts
]
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: receiptCapabilities,
noisePublicKey: bob.noiseStaticPublicKeyData()
)
bob._test_seedConnectedPeer(
alice.myPeerID,
nickname: "Alice",
capabilities: receiptCapabilities,
noisePublicKey: alice.noiseStaticPublicKeyData()
)
try await establishSession(alice: alice, bob: bob)
#expect(
alice.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) != nil
)
let privateMediaOnly = try authenticatedPeerStatePacket(
from: bob,
to: alice,
capabilities: .privateMedia
)
let transferID =
"receipt-proof-race-\(UUID().uuidString)"
let tap = PacketTap()
let boundaryProofs = ReceiptCapabilityRecorder()
let rejections = TransferCancellationRecorder()
let cancellable = TransferProgressManager.shared.publisher.sink {
rejections.record($0)
}
alice._test_onOutboundPacket = tap.record
alice._test_beforePrivateMediaDeferredSend = { id in
guard id == transferID else { return }
boundaryProofs.record(
alice
.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) != nil
)
}
defer {
alice._test_beforePrivateMediaDeferredSend = nil
alice._test_onOutboundPacket = nil
}
// Rotate authenticated state before the deferred retry reaches its
// admission boundary.
alice._test_handlePacket(
privateMediaOnly,
fromPeerID: bob.myPeerID
)
let content = Data("%PDF-1.7\nreceipt-proof-race".utf8)
alice.sendFilePrivateReceiptRetry(
BitchatFilePacket(
fileName: "receipt-proof-race.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
),
to: bob.myPeerID,
transferId: transferID
)
#expect(await TestHelpers.waitUntil(
{ boundaryProofs.snapshot() == [false] },
timeout: TestConstants.longTimeout
))
await alice._test_drainPrivateMediaSendPipeline()
#expect(await TestHelpers.waitUntil(
{ rejections.contains(transferID) },
timeout: TestConstants.longTimeout
))
#expect(tap.snapshot().allSatisfy {
$0.type != MessageType.fileTransfer.rawValue
&& !(
$0.type == MessageType.noiseEncrypted.rawValue
&& $0.version == 2
)
})
let state = alice._test_privateMediaTransferState(
transferId: transferID
)
#expect(!state.admissionActive)
#expect(!state.pendingNoise)
_ = cancellable
}
@Test
func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws {
let root = FileManager.default.temporaryDirectory
@@ -1397,6 +1608,23 @@ private final class PrivateMediaPolicyRecorder: @unchecked Sendable {
}
}
private final class ReceiptCapabilityRecorder: @unchecked Sendable {
private let lock = NSLock()
private var values: [Bool] = []
func record(_ value: Bool) {
lock.lock()
values.append(value)
lock.unlock()
}
func snapshot() -> [Bool] {
lock.lock()
defer { lock.unlock() }
return values
}
}
private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
private let lock = NSLock()
private var messages: [BitchatMessage] = []