mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
Authenticate private media capabilities in Noise
This commit is contained in:
@@ -89,6 +89,7 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
private(set) var broadcastFileSends: [String] = []
|
||||
private(set) var cancelledTransfers: [String] = []
|
||||
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
|
||||
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
|
||||
private(set) var legacyConsentRequests: [(
|
||||
id: UUID,
|
||||
peerID: PeerID,
|
||||
@@ -103,6 +104,13 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
privateMediaPolicy
|
||||
}
|
||||
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
) {
|
||||
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
|
||||
}
|
||||
|
||||
func requestLegacyPrivateMediaConsent(
|
||||
for peerID: PeerID,
|
||||
transferId: String,
|
||||
@@ -463,6 +471,31 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(context.privateFileLegacyAllowances == [true])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func capabilityProofTimeoutTransitionsToConsentWithoutAutomaticRawSend() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1020304050607080")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .awaitingCapabilityProof
|
||||
context.resolvedPrivateMediaPolicy = .legacyRequiresConsent
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("proof-timeout-consent-\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
|
||||
let prompted = await TestHelpers.waitUntil(
|
||||
{ context.legacyConsentRequests.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(prompted)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
context.resolveNextLegacyConsent(false)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func legacyConsentApprovalAfterCancelCannotSend() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
|
||||
@@ -218,26 +218,40 @@ struct PrivateMediaEndToEndTests {
|
||||
capabilities: .privateMedia,
|
||||
noisePublicKey: bobKey
|
||||
)
|
||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
|
||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof)
|
||||
let bobFingerprint = bobKey.sha256Fingerprint()
|
||||
#expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint))
|
||||
|
||||
try establishSession(alice: alice, bob: bob)
|
||||
try await establishSession(alice: alice, bob: bob)
|
||||
let capabilityPinned = await TestHelpers.waitUntil(
|
||||
{ identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint) },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(capabilityPinned)
|
||||
|
||||
// Model a later verified old/replayed announce for the same stable
|
||||
// Noise identity. The current bit disappears, but the authenticated
|
||||
// persistent pin wins.
|
||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
|
||||
|
||||
// A public no-bit announce cannot override state authenticated by the
|
||||
// current session. A later authenticated no-bit state is a real
|
||||
// downgrade and must block despite a caller offering legacy consent.
|
||||
alice._test_seedConnectedPeer(
|
||||
bob.myPeerID,
|
||||
nickname: "Bob",
|
||||
capabilities: [],
|
||||
noisePublicKey: bobKey
|
||||
)
|
||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
|
||||
let authenticatedNoBit = try authenticatedPeerStatePacket(
|
||||
from: bob,
|
||||
to: alice,
|
||||
capabilities: []
|
||||
)
|
||||
alice._test_handlePacket(authenticatedNoBit, fromPeerID: bob.myPeerID)
|
||||
let downgradeObserved = await TestHelpers.waitUntil(
|
||||
{ alice.privateMediaSendPolicy(to: bob.myPeerID) == .blockedDowngrade },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(downgradeObserved)
|
||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .blockedDowngrade)
|
||||
|
||||
let tap = PacketTap()
|
||||
@@ -307,7 +321,7 @@ struct PrivateMediaEndToEndTests {
|
||||
preseedPeer: false
|
||||
)
|
||||
let advertised = await TestHelpers.waitUntil(
|
||||
{ alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted },
|
||||
{ alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(advertised)
|
||||
@@ -331,6 +345,181 @@ struct PrivateMediaEndToEndTests {
|
||||
#expect(remainedLegacyEligible)
|
||||
}
|
||||
|
||||
@Test
|
||||
func copiedNoiseKeyPreannounceCannotPinWhenRealOwnerAuthenticates() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("private-media-copied-static-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let identity = MockIdentityManager(MockKeychain())
|
||||
let alice = makeService(
|
||||
baseDirectory: root.appendingPathComponent("alice", isDirectory: true),
|
||||
identityManager: identity
|
||||
)
|
||||
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
|
||||
let attacker = makeService(baseDirectory: root.appendingPathComponent("attacker", isDirectory: true))
|
||||
let bobKey = bob.noiseStaticPublicKeyData()
|
||||
let bobFingerprint = bobKey.sha256Fingerprint()
|
||||
|
||||
// Mallory copies Bob's public Noise key, advertises bit 8, supplies
|
||||
// Mallory's Ed25519 key, and self-signs. This is internally consistent
|
||||
// but does not prove possession of Bob's Noise private key.
|
||||
let forged = try copiedStaticAnnounce(
|
||||
claimedOwner: bob,
|
||||
signedBy: attacker,
|
||||
capabilities: .privateMedia
|
||||
)
|
||||
alice._test_handlePacket(forged, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
let hintAccepted = await TestHelpers.waitUntil(
|
||||
{ alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(hintAccepted)
|
||||
#expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint))
|
||||
|
||||
let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob)
|
||||
#expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint))
|
||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof)
|
||||
|
||||
// Only Bob's encrypted state authorizes bit 8 and replaces the forged
|
||||
// announcement signing key with Bob's Noise-authenticated Ed key.
|
||||
alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID)
|
||||
let pinned = await TestHelpers.waitUntil(
|
||||
{ identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint) },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(pinned)
|
||||
#expect(identity.authenticatedSigningPublicKey(forFingerprint: bobFingerprint)
|
||||
== bob.noiseSigningPublicKeyData())
|
||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
|
||||
|
||||
bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID)
|
||||
alice._test_onOutboundPacket = nil
|
||||
bob._test_onOutboundPacket = nil
|
||||
}
|
||||
|
||||
@Test
|
||||
func droppedInitiatorProofConvergesViaSingleAuthenticatedEcho() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("private-media-proof-echo-\(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))
|
||||
alice._test_seedConnectedPeer(
|
||||
bob.myPeerID,
|
||||
nickname: "Bob",
|
||||
capabilities: .privateMedia,
|
||||
noisePublicKey: bob.noiseStaticPublicKeyData()
|
||||
)
|
||||
bob._test_seedConnectedPeer(
|
||||
alice.myPeerID,
|
||||
nickname: "Alice",
|
||||
capabilities: .privateMedia,
|
||||
noisePublicKey: alice.noiseStaticPublicKeyData()
|
||||
)
|
||||
|
||||
let initial = try await establishSessionCapturingPeerState(alice: alice, bob: bob)
|
||||
// Model Alice's first proof racing ahead of Bob's message-3 handling
|
||||
// and being dropped. Bob's proof reaches Alice; Alice must emit one
|
||||
// idempotent echo that lets Bob converge without a new handshake.
|
||||
_ = initial.alice
|
||||
let echoTap = PacketTap()
|
||||
alice._test_onOutboundPacket = echoTap.record
|
||||
alice._test_handlePacket(initial.bob, fromPeerID: bob.myPeerID)
|
||||
let echoed = await TestHelpers.waitUntil(
|
||||
{ echoTap.snapshot().contains { $0.type == MessageType.noiseEncrypted.rawValue } },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(echoed)
|
||||
let echo = try #require(
|
||||
echoTap.snapshot().first { $0.type == MessageType.noiseEncrypted.rawValue }
|
||||
)
|
||||
bob._test_handlePacket(echo, fromPeerID: alice.myPeerID)
|
||||
let converged = await TestHelpers.waitUntil(
|
||||
{
|
||||
alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted
|
||||
&& bob.privateMediaSendPolicy(to: alice.myPeerID) == .encrypted
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(converged)
|
||||
alice._test_onOutboundPacket = nil
|
||||
bob._test_onOutboundPacket = nil
|
||||
}
|
||||
|
||||
@Test
|
||||
func noProofTimeoutResolvesToConsentWithoutSendingRawMedia() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("private-media-proof-timeout-\(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))
|
||||
alice._test_seedConnectedPeer(
|
||||
bob.myPeerID,
|
||||
nickname: "Prerelease Bob",
|
||||
capabilities: .privateMedia,
|
||||
noisePublicKey: bob.noiseStaticPublicKeyData()
|
||||
)
|
||||
|
||||
_ = try await establishSessionCapturingPeerState(alice: alice, bob: bob)
|
||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof)
|
||||
let recorder = PrivateMediaPolicyRecorder()
|
||||
alice.resolvePrivateMediaSendPolicy(to: bob.myPeerID) { recorder.record($0) }
|
||||
let registered = await TestHelpers.waitUntil(
|
||||
{ alice._test_hasPendingPrivateMediaPolicyResolution(for: bob.myPeerID) },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(registered)
|
||||
alice._test_forcePrivateMediaProofTimeout(for: bob.myPeerID)
|
||||
let resolved = await TestHelpers.waitUntil(
|
||||
{ recorder.snapshot() == .legacyRequiresConsent },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(resolved)
|
||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
|
||||
#expect(recorder.snapshot() != .encrypted)
|
||||
alice._test_onOutboundPacket = nil
|
||||
bob._test_onOutboundPacket = nil
|
||||
}
|
||||
|
||||
@Test
|
||||
func queuedPrivatePayloadWaitsForProofNotHandshakeCompletion() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("private-media-proof-drain-\(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))
|
||||
alice._test_seedConnectedPeer(
|
||||
bob.myPeerID,
|
||||
nickname: "Bob",
|
||||
capabilities: .privateMedia,
|
||||
noisePublicKey: bob.noiseStaticPublicKeyData()
|
||||
)
|
||||
let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob)
|
||||
|
||||
let content = Data("proof-gated-private-file".utf8)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "proof.txt",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "text/plain",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(BLENoisePayloadFactory.privateFile(file))
|
||||
let transferID = "proof-gated-\(UUID().uuidString)"
|
||||
alice._test_enqueuePendingNoisePayload(payload, transferId: transferID, for: bob.myPeerID)
|
||||
alice._test_sendPendingNoisePayloadsAfterHandshake(for: bob.myPeerID)
|
||||
|
||||
#expect(alice._test_privateMediaTransferState(transferId: transferID).pendingNoise)
|
||||
alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID)
|
||||
let drained = await TestHelpers.waitUntil(
|
||||
{ !alice._test_privateMediaTransferState(transferId: transferID).pendingNoise },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(drained)
|
||||
bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID)
|
||||
alice._test_onOutboundPacket = nil
|
||||
bob._test_onOutboundPacket = nil
|
||||
}
|
||||
|
||||
@Test
|
||||
func authenticatedFingerprintMismatchCannotPoisonCapabilityPin() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
@@ -353,7 +542,7 @@ struct PrivateMediaEndToEndTests {
|
||||
capabilities: .privateMedia,
|
||||
noisePublicKey: impostorKey
|
||||
)
|
||||
try establishSession(alice: alice, bob: bob)
|
||||
try await establishSession(alice: alice, bob: bob)
|
||||
|
||||
let sessionReconciled = await TestHelpers.waitUntil(
|
||||
{ reconciliations.contains(bob.myPeerID) },
|
||||
@@ -373,7 +562,7 @@ struct PrivateMediaEndToEndTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func capabilityAnnounceAfterNoiseSessionPinsMatchingFingerprint() async throws {
|
||||
func capabilityAnnounceAfterNoiseSessionStillRequiresEncryptedProof() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("private-media-race-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
@@ -384,7 +573,7 @@ struct PrivateMediaEndToEndTests {
|
||||
)
|
||||
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
|
||||
|
||||
try establishSession(alice: alice, bob: bob)
|
||||
let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob)
|
||||
let bobKey = bob.noiseStaticPublicKeyData()
|
||||
let capableAnnounce = try signedAnnounce(
|
||||
from: bob,
|
||||
@@ -396,6 +585,17 @@ struct PrivateMediaEndToEndTests {
|
||||
preseedPeer: false
|
||||
)
|
||||
|
||||
let announceDidNotPin = await TestHelpers.waitUntil(
|
||||
{ alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(announceDidNotPin)
|
||||
#expect(!identity.hasObservedPrivateMediaCapability(
|
||||
fingerprint: bobKey.sha256Fingerprint()
|
||||
))
|
||||
|
||||
alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID)
|
||||
|
||||
let pinned = await TestHelpers.waitUntil(
|
||||
{
|
||||
identity.hasObservedPrivateMediaCapability(
|
||||
@@ -405,6 +605,9 @@ struct PrivateMediaEndToEndTests {
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(pinned)
|
||||
bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID)
|
||||
alice._test_onOutboundPacket = nil
|
||||
bob._test_onOutboundPacket = nil
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -427,7 +630,7 @@ struct PrivateMediaEndToEndTests {
|
||||
nickname: "Old Carol",
|
||||
noisePublicKey: oldCarol.noiseStaticPublicKeyData()
|
||||
)
|
||||
try establishSession(alice: alice, bob: bob)
|
||||
try await establishSession(alice: alice, bob: bob)
|
||||
|
||||
var state: UInt64 = 0x1234_5678_9ABC_DEF0
|
||||
let body = Data((0..<(130 * 1024)).map { _ in
|
||||
@@ -475,7 +678,7 @@ struct PrivateMediaEndToEndTests {
|
||||
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))
|
||||
try establishSession(alice: alice, bob: bob)
|
||||
try await establishSession(alice: alice, bob: bob)
|
||||
|
||||
let transferID = "queued-encryption-failure-\(UUID().uuidString)"
|
||||
let rejections = TransferCancellationRecorder()
|
||||
@@ -615,7 +818,7 @@ struct PrivateMediaEndToEndTests {
|
||||
let bob = makeService(baseDirectory: bobRoot)
|
||||
let delegate = MessageCaptureDelegate()
|
||||
bob.delegate = delegate
|
||||
try establishSession(alice: alice, bob: bob)
|
||||
try await establishSession(alice: alice, bob: bob)
|
||||
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "\(directoryLabel).pdf",
|
||||
@@ -736,7 +939,6 @@ struct PrivateMediaEndToEndTests {
|
||||
let bob = makeService(baseDirectory: bobRoot)
|
||||
let tap = PacketTap()
|
||||
let delegate = MessageCaptureDelegate()
|
||||
alice._test_onOutboundPacket = tap.record
|
||||
bob.delegate = delegate
|
||||
|
||||
alice._test_seedConnectedPeer(
|
||||
@@ -751,7 +953,8 @@ struct PrivateMediaEndToEndTests {
|
||||
capabilities: .privateMedia,
|
||||
noisePublicKey: alice.noiseStaticPublicKeyData()
|
||||
)
|
||||
try establishSession(alice: alice, bob: bob)
|
||||
try await establishSession(alice: alice, bob: bob)
|
||||
alice._test_onOutboundPacket = tap.record
|
||||
|
||||
let file = BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
@@ -825,7 +1028,27 @@ struct PrivateMediaEndToEndTests {
|
||||
)
|
||||
}
|
||||
|
||||
private func establishSession(alice: BLEService, bob: BLEService) throws {
|
||||
private func establishSession(alice: BLEService, bob: BLEService) async throws {
|
||||
let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob)
|
||||
bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID)
|
||||
alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID)
|
||||
// Fence the message/identity mutations without assuming either test
|
||||
// seeded a registry entry (inbound-only tests intentionally do not).
|
||||
await alice._test_drainNoiseMessagePipeline()
|
||||
await bob._test_drainNoiseMessagePipeline()
|
||||
alice._test_onOutboundPacket = nil
|
||||
bob._test_onOutboundPacket = nil
|
||||
}
|
||||
|
||||
private func establishSessionCapturingPeerState(
|
||||
alice: BLEService,
|
||||
bob: BLEService
|
||||
) async throws -> (alice: BitchatPacket, bob: BitchatPacket) {
|
||||
let aliceTap = PacketTap()
|
||||
let bobTap = PacketTap()
|
||||
alice._test_onOutboundPacket = aliceTap.record
|
||||
bob._test_onOutboundPacket = bobTap.record
|
||||
|
||||
let first = try alice._test_noiseInitiateHandshake(with: bob.myPeerID)
|
||||
let second = try #require(
|
||||
try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: first)
|
||||
@@ -836,6 +1059,35 @@ struct PrivateMediaEndToEndTests {
|
||||
_ = try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: third)
|
||||
#expect(alice.canDeliverSecurely(to: bob.myPeerID))
|
||||
#expect(bob.canDeliverSecurely(to: alice.myPeerID))
|
||||
|
||||
let emitted = await TestHelpers.waitUntil(
|
||||
{
|
||||
aliceTap.snapshot().contains { $0.type == MessageType.noiseEncrypted.rawValue }
|
||||
&& bobTap.snapshot().contains { $0.type == MessageType.noiseEncrypted.rawValue }
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(emitted)
|
||||
let aliceProof = try #require(
|
||||
aliceTap.snapshot().first { $0.type == MessageType.noiseEncrypted.rawValue }
|
||||
)
|
||||
let bobProof = try #require(
|
||||
bobTap.snapshot().first { $0.type == MessageType.noiseEncrypted.rawValue }
|
||||
)
|
||||
return (aliceProof, bobProof)
|
||||
}
|
||||
|
||||
private func authenticatedPeerStatePacket(
|
||||
from sender: BLEService,
|
||||
to recipient: BLEService,
|
||||
capabilities: PeerCapabilities
|
||||
) throws -> BitchatPacket {
|
||||
let state = AuthenticatedPeerStatePacket(
|
||||
capabilities: capabilities,
|
||||
signingPublicKey: sender.noiseSigningPublicKeyData()
|
||||
)
|
||||
let typed = try #require(BLENoisePayloadFactory.authenticatedPeerState(state))
|
||||
return try sender._test_makeEncryptedNoisePacket(typed, to: recipient.myPeerID)
|
||||
}
|
||||
|
||||
private func signedAnnounce(
|
||||
@@ -862,6 +1114,33 @@ struct PrivateMediaEndToEndTests {
|
||||
return service.signPacketForBroadcast(unsigned)
|
||||
}
|
||||
|
||||
private func copiedStaticAnnounce(
|
||||
claimedOwner: BLEService,
|
||||
signedBy signer: BLEService,
|
||||
capabilities: PeerCapabilities
|
||||
) throws -> BitchatPacket {
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: "Mallory-as-Bob",
|
||||
noisePublicKey: claimedOwner.noiseStaticPublicKeyData(),
|
||||
signingPublicKey: signer.noiseSigningPublicKeyData(),
|
||||
directNeighbors: nil,
|
||||
capabilities: capabilities
|
||||
)
|
||||
let payload = try #require(announcement.encode())
|
||||
let unsigned = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data(hexString: claimedOwner.myPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
// Relayed shape avoids the proactive direct-hint handshake in
|
||||
// this deterministic test; it does not change signature validity.
|
||||
ttl: TransportConfig.messageTTLDefault - 1
|
||||
)
|
||||
return signer.signPacketForBroadcast(unsigned)
|
||||
}
|
||||
|
||||
private func recursivelyStoredFiles(under root: URL) -> [URL] {
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: root,
|
||||
@@ -953,6 +1232,23 @@ private final class PeerIDRecorder: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private final class PrivateMediaPolicyRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var policy: PrivateMediaSendPolicy?
|
||||
|
||||
func record(_ policy: PrivateMediaSendPolicy) {
|
||||
lock.lock()
|
||||
self.policy = policy
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func snapshot() -> PrivateMediaSendPolicy? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return policy
|
||||
}
|
||||
}
|
||||
|
||||
private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var messages: [BitchatMessage] = []
|
||||
|
||||
@@ -15,6 +15,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
private var blockedNostrPubkeys: Set<String> = []
|
||||
private var socialIdentities: [String: SocialIdentity] = [:]
|
||||
private var privateMediaCapableFingerprints: Set<String> = []
|
||||
private var authenticatedSigningKeys: [String: Data] = [:]
|
||||
|
||||
init(_: KeychainManagerProtocol) {}
|
||||
|
||||
@@ -90,6 +91,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func clearAllIdentityData() {
|
||||
privateMediaCapableFingerprints.removeAll()
|
||||
authenticatedSigningKeys.removeAll()
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {}
|
||||
@@ -112,6 +114,14 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
privateMediaCapableFingerprints.contains(fingerprint)
|
||||
}
|
||||
|
||||
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
|
||||
authenticatedSigningKeys[fingerprint] = signingPublicKey
|
||||
}
|
||||
|
||||
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||
authenticatedSigningKeys[fingerprint]
|
||||
}
|
||||
|
||||
// MARK: Vouching (transitive verification)
|
||||
|
||||
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
|
||||
|
||||
@@ -205,6 +205,14 @@ final class MockTransport: Transport {
|
||||
privateMediaPolicies[peerID] ?? .encrypted
|
||||
}
|
||||
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
) {
|
||||
let policy = privateMediaPolicies[peerID] ?? .encrypted
|
||||
Task { @MainActor in completion(policy) }
|
||||
}
|
||||
|
||||
func cancelTransfer(_ transferId: String) {
|
||||
cancelledTransfers.append(transferId)
|
||||
}
|
||||
|
||||
@@ -650,7 +650,8 @@ struct NoiseCoverageTests {
|
||||
try aliceManager.initiateHandshake(with: alicePeerID)
|
||||
}
|
||||
|
||||
try aliceManager.initiateRekey(for: alicePeerID)
|
||||
let rekeyHandshake = try aliceManager.initiateRekey(for: alicePeerID)
|
||||
#expect(!rekeyHandshake.isEmpty)
|
||||
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
|
||||
|
||||
#expect(rekeyedSession !== establishedSession)
|
||||
|
||||
@@ -145,6 +145,39 @@ struct PacketsTests {
|
||||
#expect(decoded.capabilities?.rawValue == 0x0180)
|
||||
}
|
||||
|
||||
@Test
|
||||
func authenticatedPeerStateUsesVersionedCanonicalTLVs() throws {
|
||||
let signingKey = Data(repeating: 0xA5, count: 32)
|
||||
let packet = AuthenticatedPeerStatePacket(
|
||||
capabilities: [.privateMedia, .vouch],
|
||||
signingPublicKey: signingKey
|
||||
)
|
||||
|
||||
var encoded = try #require(packet.encode())
|
||||
#expect(encoded.prefix(5) == Data([0x01, 0x01, 0x02, 0x20, 0x01]))
|
||||
// Unknown TLVs are forward-compatible and do not alter v1 state.
|
||||
encoded.append(makeTLV(type: 0x7F, value: Data([0xCA, 0xFE])))
|
||||
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: encoded) == packet)
|
||||
}
|
||||
|
||||
@Test
|
||||
func authenticatedPeerStateRejectsMalformedAmbiguousOrUnknownVersion() {
|
||||
let key = Data(repeating: 0x44, count: 32)
|
||||
let capabilities = makeTLV(type: 0x01, value: Data([0x00, 0x01]))
|
||||
let signing = makeTLV(type: 0x02, value: key)
|
||||
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x02]) + capabilities + signing) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + signing) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + capabilities + signing) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01, 0x01, 0x00]) + signing) == nil)
|
||||
// 0x0001 is non-minimal little endian; the canonical form is [0x01].
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data([0x01, 0x00])) + signing) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + makeTLV(type: 0x02, value: Data(key.dropLast()))) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + Data(signing.dropLast())) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data(repeating: 0x01, count: 9)) + signing) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
|
||||
let unknownTLV = Data([0x7F, 0x01, 0x41])
|
||||
|
||||
@@ -6,6 +6,7 @@ import Testing
|
||||
struct BLEAnnounceHandlerTests {
|
||||
private final class Recorder {
|
||||
var existingNoisePublicKey: Data?
|
||||
var authenticatedSigningPublicKey: Data?
|
||||
var signatureValid = true
|
||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||
var linkBoundToOtherPeer = false
|
||||
@@ -37,6 +38,7 @@ struct BLEAnnounceHandlerTests {
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { now },
|
||||
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
||||
authenticatedSigningPublicKey: { _ in recorder.authenticatedSigningPublicKey },
|
||||
verifySignature: { packet, signingPublicKey in
|
||||
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
||||
return recorder.signatureValid
|
||||
|
||||
@@ -169,6 +169,23 @@ struct BLEAnnounceHandlingPolicyTests {
|
||||
#expect(decision.isVerified)
|
||||
}
|
||||
|
||||
@Test
|
||||
func trustPolicyRejectsSigningKeyReplacementAfterNoiseBinding() {
|
||||
let noiseKey = Data(repeating: 0xCC, count: 32)
|
||||
let boundSigningKey = Data(repeating: 0x11, count: 32)
|
||||
|
||||
let decision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: true,
|
||||
signatureValid: true,
|
||||
existingNoisePublicKey: noiseKey,
|
||||
announcedNoisePublicKey: noiseKey,
|
||||
authenticatedSigningPublicKey: boundSigningKey,
|
||||
announcedSigningPublicKey: Data(repeating: 0x22, count: 32)
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.authenticatedSigningKeyMismatch))
|
||||
}
|
||||
|
||||
@Test
|
||||
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
||||
let directNew = BLEAnnounceResponsePolicy.plan(
|
||||
|
||||
@@ -18,6 +18,7 @@ struct BLENoisePacketHandlerTests {
|
||||
var lastSeenUpdates: [PeerID] = []
|
||||
var decryptCalls: [(payload: Data, peerID: PeerID)] = []
|
||||
var clearedSessions: [PeerID] = []
|
||||
var authenticatedPeerStates: [(peerID: PeerID, payload: Data)] = []
|
||||
var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
|
||||
/// Ordered side-effect log to assert recovery sequencing.
|
||||
var events: [String] = []
|
||||
@@ -62,6 +63,9 @@ struct BLENoisePacketHandlerTests {
|
||||
recorder.clearedSessions.append(peerID)
|
||||
recorder.events.append("clearSession")
|
||||
},
|
||||
handleAuthenticatedPeerState: { peerID, payload in
|
||||
recorder.authenticatedPeerStates.append((peerID, payload))
|
||||
},
|
||||
deliverNoisePayload: { peerID, type, payload, timestamp in
|
||||
recorder.deliveries.append((peerID, type, payload, timestamp))
|
||||
}
|
||||
@@ -221,6 +225,24 @@ struct BLENoisePacketHandlerTests {
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func authenticatedPeerStateIsConsumedByTransportNotDeliveredToUI() {
|
||||
let recorder = Recorder()
|
||||
recorder.decryptResult = .success(Data([
|
||||
NoisePayloadType.authenticatedPeerState.rawValue,
|
||||
0x01, 0x02, 0x03
|
||||
]))
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
|
||||
|
||||
handler.handleEncrypted(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.authenticatedPeerStates.count == 1)
|
||||
#expect(recorder.authenticatedPeerStates.first?.peerID == remotePeerID)
|
||||
#expect(recorder.authenticatedPeerStates.first?.payload == Data([0x01, 0x02, 0x03]))
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func emptyDecryptedPayloadIsIgnored() {
|
||||
let recorder = Recorder()
|
||||
|
||||
@@ -79,4 +79,16 @@ struct BLENoisePayloadFactoryTests {
|
||||
#expect(decoded.data == Data([0xCA, 0xFE]))
|
||||
#expect(decoded.encode().first == 0x20)
|
||||
}
|
||||
|
||||
@Test
|
||||
func authenticatedPeerStateUsesPermanent0x21Type() throws {
|
||||
let state = AuthenticatedPeerStatePacket(
|
||||
capabilities: .privateMedia,
|
||||
signingPublicKey: Data(repeating: 0x77, count: 32)
|
||||
)
|
||||
let encoded = try #require(BLENoisePayloadFactory.authenticatedPeerState(state))
|
||||
|
||||
#expect(encoded.first == 0x21)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data(encoded.dropFirst())) == state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,40 @@ struct NoiseEncryptionServiceTests {
|
||||
#expect(try receiver.decrypt(ciphertext, from: alicePeerID) == Data("new session".utf8))
|
||||
}
|
||||
|
||||
@Test("Automatic rekey exposes and completes its exact handshake bytes")
|
||||
func automaticRekeyHandshakeIsNotStranded() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||
try establishSessions(alice: alice, bob: bob)
|
||||
|
||||
var emittedPeerID: PeerID?
|
||||
var emittedMessage: Data?
|
||||
alice.onRekeyHandshakeReady = { peerID, message in
|
||||
emittedPeerID = peerID
|
||||
emittedMessage = message
|
||||
}
|
||||
try alice._test_initiateAutomaticRekey(for: bobPeerID)
|
||||
|
||||
#expect(emittedPeerID == bobPeerID)
|
||||
let message1 = try #require(emittedMessage)
|
||||
#expect(!message1.isEmpty)
|
||||
#expect(alice.hasSession(with: bobPeerID))
|
||||
#expect(!alice.hasEstablishedSession(with: bobPeerID))
|
||||
|
||||
let message2 = try #require(
|
||||
try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
||||
)
|
||||
_ = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||
|
||||
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
||||
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||
}
|
||||
|
||||
@Test("Large private-file payloads use the bounded Noise extension")
|
||||
func largePrivateFileNoiseRoundTrip() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
|
||||
@@ -427,6 +427,31 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
func test_noiseAuthenticatedSigningKeyBindingPersistsAndPanicClearRemovesIt() async {
|
||||
let keychain = MockKeychain()
|
||||
let fingerprint = Data(repeating: 0x31, count: 32).sha256Fingerprint()
|
||||
let firstKey = Data(repeating: 0x41, count: 32)
|
||||
let rotatedKey = Data(repeating: 0x42, count: 32)
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
|
||||
manager.bindAuthenticatedSigningPublicKey(firstKey, fingerprint: fingerprint)
|
||||
XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), firstKey)
|
||||
// A later authenticated Noise session may legitimately rotate the
|
||||
// announcement signing key.
|
||||
manager.bindAuthenticatedSigningPublicKey(rotatedKey, fingerprint: fingerprint)
|
||||
XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey)
|
||||
|
||||
manager.forceSave()
|
||||
let reloaded = SecureIdentityStateManager(keychain)
|
||||
XCTAssertEqual(reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey)
|
||||
|
||||
reloaded.clearAllIdentityData()
|
||||
let cleared = await waitUntil {
|
||||
reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint) == nil
|
||||
}
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {
|
||||
let keychain = FailingCacheSaveKeychain()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
|
||||
@@ -268,6 +268,8 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func markPrivateMediaCapable(fingerprint: String) {}
|
||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false }
|
||||
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {}
|
||||
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? { nil }
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user