mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 12:25:19 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
726906681f | ||
|
|
34961b9ad6 | ||
|
|
0288404e26 | ||
|
|
6063d42500 | ||
|
|
add586eb2c | ||
|
|
8c6b60c675 | ||
|
|
7faa9d7d4a | ||
|
|
a7551a3046 |
@@ -12,6 +12,7 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
@Published private(set) var currentNickname: String
|
@Published private(set) var currentNickname: String
|
||||||
@Published private(set) var isBatchingPublic = false
|
@Published private(set) var isBatchingPublic = false
|
||||||
@Published private(set) var canSendMediaInCurrentContext = true
|
@Published private(set) var canSendMediaInCurrentContext = true
|
||||||
|
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
|
||||||
/// Who is talking live in the public mesh channel right now (floor
|
/// Who is talking live in the public mesh channel right now (floor
|
||||||
/// courtesy: the composer mic tints "busy" while someone holds the floor).
|
/// courtesy: the composer mic tints "busy" while someone holds the floor).
|
||||||
@Published private(set) var activeLiveVoiceTalker: String?
|
@Published private(set) var activeLiveVoiceTalker: String?
|
||||||
@@ -153,6 +154,13 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
chatViewModel.sendVoiceNote(at: url)
|
chatViewModel.sendVoiceNote(at: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) {
|
||||||
|
chatViewModel.resolveLegacyPrivateMediaConsent(
|
||||||
|
requestID: requestID,
|
||||||
|
approved: approved
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Capture backend for the mic gesture: live PTT when the current DM
|
/// Capture backend for the mic gesture: live PTT when the current DM
|
||||||
/// peer can hear it now, classic voice note otherwise.
|
/// peer can hear it now, classic voice note otherwise.
|
||||||
func makeVoiceCaptureSession() -> VoiceCaptureSession {
|
func makeVoiceCaptureSession() -> VoiceCaptureSession {
|
||||||
@@ -193,6 +201,10 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: &$activeLiveVoiceTalker)
|
.assign(to: &$activeLiveVoiceTalker)
|
||||||
|
|
||||||
|
chatViewModel.$legacyPrivateMediaConsentRequest
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$legacyPrivateMediaConsentRequest)
|
||||||
|
|
||||||
conversations.$activeChannel
|
conversations.$activeChannel
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] channel in
|
.sink { [weak self] channel in
|
||||||
|
|||||||
@@ -189,6 +189,18 @@ struct IdentityCache: Codable {
|
|||||||
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
||||||
// entries verified before this field exists sort as oldest)
|
// entries verified before this field exists sort as oldest)
|
||||||
var verifiedAt: [String: Date]? = nil
|
var verifiedAt: [String: Date]? = nil
|
||||||
|
|
||||||
|
// Stable Noise fingerprints that proved encrypted private-media support
|
||||||
|
// inside an authenticated Noise session. Optional for decoding caches
|
||||||
|
// written before this migration. Entries are monotonic until a panic wipe
|
||||||
|
// so an old/replayed announce cannot silently downgrade a peer.
|
||||||
|
var privateMediaCapableFingerprints: Set<String>? = nil
|
||||||
|
|
||||||
|
// Noise-fingerprint -> Ed25519 announcement key, learned only from the
|
||||||
|
// authenticated peer-state payload. This prevents a self-signed announce
|
||||||
|
// containing a copied public Noise key from replacing a previously bound
|
||||||
|
// public-message signing identity. Optional for old cache compatibility.
|
||||||
|
var authenticatedSigningKeysByFingerprint: [String: Data]? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -140,6 +140,14 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
||||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
|
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
|
||||||
|
|
||||||
|
// MARK: Noise-authenticated announcement identity
|
||||||
|
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String)
|
||||||
|
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||||
|
|
||||||
|
// MARK: Private-media downgrade protection
|
||||||
|
func markPrivateMediaCapable(fingerprint: String)
|
||||||
|
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Singleton manager for secure identity state persistence and retrieval.
|
/// Singleton manager for secure identity state persistence and retrieval.
|
||||||
@@ -157,6 +165,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// Thread safety
|
// Thread safety
|
||||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||||
|
private let queueSpecificKey = DispatchSpecificKey<UInt8>()
|
||||||
|
|
||||||
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
||||||
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
||||||
@@ -214,6 +223,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
self.encryptionKey = loadedKey
|
self.encryptionKey = loadedKey
|
||||||
self.encryptionKeyIsEphemeral = keyIsEphemeral
|
self.encryptionKeyIsEphemeral = keyIsEphemeral
|
||||||
|
queue.setSpecific(key: queueSpecificKey, value: 1)
|
||||||
|
|
||||||
// Only read the persisted cache when we hold the real key; with an
|
// Only read the persisted cache when we hold the real key; with an
|
||||||
// ephemeral key the decrypt would fail and discard the real cache.
|
// ephemeral key the decrypt would fail and discard the real cache.
|
||||||
@@ -371,6 +381,66 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private-media downgrade protection
|
||||||
|
|
||||||
|
func markPrivateMediaCapable(fingerprint: String) {
|
||||||
|
guard !fingerprint.isEmpty else { return }
|
||||||
|
let insertAndPersist = {
|
||||||
|
var pinned = self.cache.privateMediaCapableFingerprints ?? []
|
||||||
|
guard pinned.insert(fingerprint).inserted else { return }
|
||||||
|
self.cache.privateMediaCapableFingerprints = pinned
|
||||||
|
self.saveIdentityCache()
|
||||||
|
}
|
||||||
|
// Downgrade decisions can run immediately after an authenticated
|
||||||
|
// announce. Make the pin visible before returning; merely enqueueing a
|
||||||
|
// barrier leaves a cross-queue window where a replay can look legacy.
|
||||||
|
// The queue-specific fast path prevents self-deadlock if a future
|
||||||
|
// identity-state mutation records the capability from inside `queue`.
|
||||||
|
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
|
||||||
|
insertAndPersist()
|
||||||
|
} else {
|
||||||
|
queue.sync(flags: .barrier, execute: insertAndPersist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
|
||||||
|
guard !fingerprint.isEmpty else { return false }
|
||||||
|
return queue.sync {
|
||||||
|
cache.privateMediaCapableFingerprints?.contains(fingerprint) == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Noise-authenticated announcement identity
|
||||||
|
|
||||||
|
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
|
||||||
|
guard signingPublicKey.count == AuthenticatedPeerStatePacket.signingPublicKeyLength,
|
||||||
|
!fingerprint.isEmpty else { return }
|
||||||
|
let bindAndPersist = {
|
||||||
|
var bindings = self.cache.authenticatedSigningKeysByFingerprint ?? [:]
|
||||||
|
let bindingChanged = bindings[fingerprint] != signingPublicKey
|
||||||
|
bindings[fingerprint] = signingPublicKey
|
||||||
|
self.cache.authenticatedSigningKeysByFingerprint = bindings
|
||||||
|
if var cryptoIdentity = self.cryptographicIdentities[fingerprint] {
|
||||||
|
cryptoIdentity.signingPublicKey = signingPublicKey
|
||||||
|
self.cryptographicIdentities[fingerprint] = cryptoIdentity
|
||||||
|
}
|
||||||
|
guard bindingChanged else { return }
|
||||||
|
self.saveIdentityCache()
|
||||||
|
}
|
||||||
|
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
|
||||||
|
bindAndPersist()
|
||||||
|
} else {
|
||||||
|
queue.sync(flags: .barrier, execute: bindAndPersist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||||
|
guard !fingerprint.isEmpty else { return nil }
|
||||||
|
return queue.sync {
|
||||||
|
cache.authenticatedSigningKeysByFingerprint?[fingerprint]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ struct NoisePayload {
|
|||||||
|
|
||||||
// Safely get the first byte
|
// Safely get the first byte
|
||||||
let firstByte = data[data.startIndex]
|
let firstByte = data[data.startIndex]
|
||||||
guard let type = NoisePayloadType(rawValue: firstByte) else {
|
guard let type = NoisePayloadType.decoded(rawValue: firstByte) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,37 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum NoiseSecurityConstants {
|
enum NoiseSecurityConstants {
|
||||||
// Maximum message size to prevent memory exhaustion
|
// Maximum message size to prevent memory exhaustion
|
||||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||||
|
|
||||||
|
/// The extracted transport nonce (4 bytes) and Poly1305 tag (16 bytes)
|
||||||
|
/// added by `NoiseCipherState` around every transport plaintext.
|
||||||
|
static let transportCiphertextOverhead = 20
|
||||||
|
|
||||||
|
/// Private files are an explicit BitChat extension to the ordinary Noise
|
||||||
|
/// message-size ceiling. They remain bounded by the same framed-file cap
|
||||||
|
/// used by the binary and fragment decoders. Only the `.privateFile`
|
||||||
|
/// typed-payload path is allowed to use this larger budget.
|
||||||
|
private static let privateFileOuterPacketOverhead =
|
||||||
|
(BinaryProtocol.v1HeaderSize + 2) // v2 adds two length bytes
|
||||||
|
+ BinaryProtocol.senderIDSize
|
||||||
|
+ BinaryProtocol.recipientIDSize
|
||||||
|
static let maxPrivateFilePlaintextSize = FileTransferLimits.maxFramedFileBytes
|
||||||
|
- privateFileOuterPacketOverhead
|
||||||
|
- transportCiphertextOverhead
|
||||||
|
static let maxPrivateFileCiphertextSize =
|
||||||
|
maxPrivateFilePlaintextSize + transportCiphertextOverhead
|
||||||
|
|
||||||
// Maximum handshake message size
|
// Maximum handshake message size
|
||||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||||
|
|
||||||
|
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
|
||||||
|
static let xxInitialMessageSize = 32
|
||||||
|
|
||||||
// Session timeout - sessions older than this should be renegotiated
|
// Session timeout - sessions older than this should be renegotiated
|
||||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ struct NoiseSecurityValidator {
|
|||||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func validateCiphertextSize(_ data: Data) -> Bool {
|
||||||
|
data.count <= NoiseSecurityConstants.maxMessageSize
|
||||||
|
+ NoiseSecurityConstants.transportCiphertextOverhead
|
||||||
|
}
|
||||||
|
|
||||||
|
static func validatePrivateFileMessageSize(_ data: Data) -> Bool {
|
||||||
|
data.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize
|
||||||
|
}
|
||||||
|
|
||||||
|
static func validatePrivateFileCiphertextSize(_ data: Data) -> Bool {
|
||||||
|
data.count <= NoiseSecurityConstants.maxPrivateFileCiphertextSize
|
||||||
|
}
|
||||||
|
|
||||||
/// Validate handshake message size
|
/// Validate handshake message size
|
||||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ enum NoiseSessionError: Error, Equatable {
|
|||||||
case notEstablished
|
case notEstablished
|
||||||
case sessionNotFound
|
case sessionNotFound
|
||||||
case alreadyEstablished
|
case alreadyEstablished
|
||||||
|
case peerIdentityMismatch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,20 @@ import BitFoundation
|
|||||||
|
|
||||||
final class NoiseSessionManager {
|
final class NoiseSessionManager {
|
||||||
private var sessions: [PeerID: NoiseSession] = [:]
|
private var sessions: [PeerID: NoiseSession] = [:]
|
||||||
|
/// Opaque identity for each exact entry in `sessions`. The generation is
|
||||||
|
/// created and removed under the same barrier as the session itself, so a
|
||||||
|
/// caller can never authenticate data with one session and lease another.
|
||||||
|
private var sessionGenerations: [PeerID: UUID] = [:]
|
||||||
|
/// A responder rehandshake must not evict a working transport session
|
||||||
|
/// before the candidate proves that its authenticated static key belongs
|
||||||
|
/// to the claimed wire ID. Candidates therefore live outside `sessions`
|
||||||
|
/// until the XX handshake completes and the binding is validated.
|
||||||
|
private var responderCandidates: [PeerID: NoiseSession] = [:]
|
||||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)?
|
||||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||||
|
|
||||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||||
@@ -54,6 +63,10 @@ final class NoiseSessionManager {
|
|||||||
if let session = sessions.removeValue(forKey: peerID) {
|
if let session = sessions.removeValue(forKey: peerID) {
|
||||||
session.reset() // Clear sensitive data before removing
|
session.reset() // Clear sensitive data before removing
|
||||||
}
|
}
|
||||||
|
sessionGenerations.removeValue(forKey: peerID)
|
||||||
|
if let candidate = responderCandidates.removeValue(forKey: peerID) {
|
||||||
|
candidate.reset()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +75,12 @@ final class NoiseSessionManager {
|
|||||||
for (_, session) in sessions {
|
for (_, session) in sessions {
|
||||||
session.reset()
|
session.reset()
|
||||||
}
|
}
|
||||||
|
for (_, candidate) in responderCandidates {
|
||||||
|
candidate.reset()
|
||||||
|
}
|
||||||
sessions.removeAll()
|
sessions.removeAll()
|
||||||
|
sessionGenerations.removeAll()
|
||||||
|
responderCandidates.removeAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,11 +97,14 @@ final class NoiseSessionManager {
|
|||||||
// Remove any existing non-established session
|
// Remove any existing non-established session
|
||||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
sessionGenerations.removeValue(forKey: peerID)
|
||||||
|
existingSession.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new initiator session
|
// Create new initiator session
|
||||||
let session = sessionFactory(peerID, .initiator)
|
let session = sessionFactory(peerID, .initiator)
|
||||||
sessions[peerID] = session
|
sessions[peerID] = session
|
||||||
|
sessionGenerations[peerID] = UUID()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let handshakeData = try session.startHandshake()
|
let handshakeData = try session.startHandshake()
|
||||||
@@ -91,6 +112,8 @@ final class NoiseSessionManager {
|
|||||||
} catch {
|
} catch {
|
||||||
// Clean up failed session
|
// Clean up failed session
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
sessionGenerations.removeValue(forKey: peerID)
|
||||||
|
session.reset()
|
||||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -98,41 +121,64 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
// Process everything within the synchronized block to prevent race conditions
|
// Process everything within the synchronized block to prevent race conditions.
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
// Return establishment metadata and publish the callback only after the
|
||||||
var shouldCreateNew = false
|
// manager barrier is released, avoiding both a deadlock and a window in
|
||||||
var existingSession: NoiseSession? = nil
|
// which `processHandshakeMessage` returns before authentication state.
|
||||||
|
let result: (
|
||||||
if let existing = sessions[peerID] {
|
response: Data?,
|
||||||
// If we have an established session, the peer must have cleared their session
|
establishedSession: (
|
||||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||||
// We should accept the new handshake to re-establish encryption
|
generation: UUID
|
||||||
if existing.isEstablished() {
|
)?
|
||||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
) = try managerQueue.sync(flags: .barrier) {
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
shouldCreateNew = true
|
|
||||||
} else {
|
|
||||||
// If we're in the middle of a handshake and receive a new initiation,
|
|
||||||
// reset and start fresh (the other side may have restarted)
|
|
||||||
if existing.getState() == .handshaking && message.count == 32 {
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
shouldCreateNew = true
|
|
||||||
} else {
|
|
||||||
existingSession = existing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
shouldCreateNew = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create session
|
|
||||||
let session: NoiseSession
|
let session: NoiseSession
|
||||||
if shouldCreateNew {
|
let isReplacementCandidate: Bool
|
||||||
|
|
||||||
|
if let candidate = responderCandidates[peerID] {
|
||||||
|
// A fresh XX message 1 supersedes an incomplete candidate,
|
||||||
|
// but never the established session it is trying to replace.
|
||||||
|
if message.count == NoiseSecurityConstants.xxInitialMessageSize {
|
||||||
|
candidate.reset()
|
||||||
|
let replacement = sessionFactory(peerID, .responder)
|
||||||
|
responderCandidates[peerID] = replacement
|
||||||
|
session = replacement
|
||||||
|
} else {
|
||||||
|
session = candidate
|
||||||
|
}
|
||||||
|
isReplacementCandidate = true
|
||||||
|
} else if let existing = sessions[peerID] {
|
||||||
|
if existing.isEstablished() {
|
||||||
|
SecureLogger.info(
|
||||||
|
"Validating replacement handshake from \(peerID) while preserving the established session",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
let candidate = sessionFactory(peerID, .responder)
|
||||||
|
responderCandidates[peerID] = candidate
|
||||||
|
session = candidate
|
||||||
|
isReplacementCandidate = true
|
||||||
|
} else if existing.getState() == .handshaking,
|
||||||
|
message.count == NoiseSecurityConstants.xxInitialMessageSize {
|
||||||
|
// No established transport state exists to preserve. A
|
||||||
|
// fresh initiation replaces the incomplete handshake.
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
sessionGenerations.removeValue(forKey: peerID)
|
||||||
|
existing.reset()
|
||||||
|
let replacement = sessionFactory(peerID, .responder)
|
||||||
|
sessions[peerID] = replacement
|
||||||
|
sessionGenerations[peerID] = UUID()
|
||||||
|
session = replacement
|
||||||
|
isReplacementCandidate = false
|
||||||
|
} else {
|
||||||
|
session = existing
|
||||||
|
isReplacementCandidate = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
let newSession = sessionFactory(peerID, .responder)
|
let newSession = sessionFactory(peerID, .responder)
|
||||||
sessions[peerID] = newSession
|
sessions[peerID] = newSession
|
||||||
|
sessionGenerations[peerID] = UUID()
|
||||||
session = newSession
|
session = newSession
|
||||||
} else {
|
isReplacementCandidate = false
|
||||||
session = existingSession!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the handshake message within the synchronized block
|
// Process the handshake message within the synchronized block
|
||||||
@@ -140,19 +186,46 @@ final class NoiseSessionManager {
|
|||||||
let response = try session.processHandshakeMessage(message)
|
let response = try session.processHandshakeMessage(message)
|
||||||
|
|
||||||
// Check if session is established after processing
|
// Check if session is established after processing
|
||||||
|
var establishedSession: (
|
||||||
|
remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||||
|
generation: UUID
|
||||||
|
)?
|
||||||
if session.isEstablished() {
|
if session.isEstablished() {
|
||||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
guard let remoteKey = session.getRemoteStaticPublicKey(),
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
authenticatedRemoteKey(remoteKey, matches: peerID) else {
|
||||||
DispatchQueue.global().async { [weak self] in
|
throw NoiseSessionError.peerIdentityMismatch
|
||||||
self?.onSessionEstablished?(peerID, remoteKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
if isReplacementCandidate {
|
||||||
|
_ = responderCandidates.removeValue(forKey: peerID)
|
||||||
|
let previous = sessions.updateValue(session, forKey: peerID)
|
||||||
|
sessionGenerations[peerID] = UUID()
|
||||||
|
if let previous, previous !== session {
|
||||||
|
previous.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let generation = sessionGenerations[peerID] else {
|
||||||
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
|
}
|
||||||
|
establishedSession = (remoteKey, generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response, establishedSession)
|
||||||
} catch {
|
} catch {
|
||||||
// Reset the session on handshake failure so next attempt can start fresh
|
// A failed candidate is discarded without touching the
|
||||||
|
// established session. Ordinary failed handshakes retain the
|
||||||
|
// historical cleanup behavior.
|
||||||
|
if isReplacementCandidate {
|
||||||
|
if let storedCandidate = responderCandidates[peerID],
|
||||||
|
storedCandidate === session {
|
||||||
|
_ = responderCandidates.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
} else if let storedSession = sessions[peerID],
|
||||||
|
storedSession === session {
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
sessionGenerations.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
session.reset()
|
||||||
|
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
DispatchQueue.global().async { [weak self] in
|
DispatchQueue.global().async { [weak self] in
|
||||||
@@ -163,24 +236,102 @@ final class NoiseSessionManager {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let established = result.establishedSession {
|
||||||
|
onSessionEstablished?(peerID, established.remoteKey, established.generation)
|
||||||
|
}
|
||||||
|
return result.response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
|
||||||
|
/// also accepted by internal callers when they exactly match the static
|
||||||
|
/// key. Non-wire identifiers remain available to protocol test harnesses;
|
||||||
|
/// BLE packet ingress always supplies a short hexadecimal ID.
|
||||||
|
private func authenticatedRemoteKey(
|
||||||
|
_ remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||||
|
matches claimedPeerID: PeerID
|
||||||
|
) -> Bool {
|
||||||
|
let rawKey = remoteKey.rawRepresentation
|
||||||
|
if claimedPeerID.isShort {
|
||||||
|
return PeerID(publicKey: rawKey) == claimedPeerID
|
||||||
|
}
|
||||||
|
if let claimedNoiseKey = claimedPeerID.noiseKey {
|
||||||
|
return claimedNoiseKey == rawKey
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||||
guard let session = getSession(for: peerID) else {
|
try managerQueue.sync {
|
||||||
|
guard let session = sessions[peerID] else {
|
||||||
throw NoiseSessionError.sessionNotFound
|
throw NoiseSessionError.sessionNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return try session.encrypt(plaintext)
|
return try session.encrypt(plaintext)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypts only if `expected` still names the current established entry.
|
||||||
|
/// A rekey between capability proof and media encryption therefore fails
|
||||||
|
/// closed instead of sending on an unproven replacement session.
|
||||||
|
func encrypt(
|
||||||
|
_ plaintext: Data,
|
||||||
|
for peerID: PeerID,
|
||||||
|
expectedSessionGeneration expected: UUID
|
||||||
|
) throws -> Data {
|
||||||
|
try managerQueue.sync {
|
||||||
|
guard let session = sessions[peerID],
|
||||||
|
session.isEstablished(),
|
||||||
|
sessionGenerations[peerID] == expected else {
|
||||||
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
|
}
|
||||||
|
return try session.encrypt(plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
||||||
guard let session = getSession(for: peerID) else {
|
try decryptWithSessionGeneration(ciphertext, from: peerID).plaintext
|
||||||
throw NoiseSessionError.sessionNotFound
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return try session.decrypt(ciphertext)
|
func sessionGeneration(for peerID: PeerID) -> UUID? {
|
||||||
|
managerQueue.sync {
|
||||||
|
guard sessions[peerID]?.isEstablished() == true else { return nil }
|
||||||
|
return sessionGenerations[peerID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypts while holding the manager's read lease. Session promotion and
|
||||||
|
/// removal require its barrier, so the returned generation always names
|
||||||
|
/// the exact session object that authenticated these bytes.
|
||||||
|
func decryptWithSessionGeneration(
|
||||||
|
_ ciphertext: Data,
|
||||||
|
from peerID: PeerID
|
||||||
|
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||||
|
try managerQueue.sync {
|
||||||
|
guard let session = sessions[peerID] else {
|
||||||
|
throw NoiseSessionError.sessionNotFound
|
||||||
|
}
|
||||||
|
guard session.isEstablished(),
|
||||||
|
let generation = sessionGenerations[peerID] else {
|
||||||
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
|
}
|
||||||
|
return (try session.decrypt(ciphertext), generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a state commit under a read lease for the exact established
|
||||||
|
/// session. Rekey, replacement, and removal all need the same barrier.
|
||||||
|
func withCurrentSessionGeneration<Result>(
|
||||||
|
for peerID: PeerID,
|
||||||
|
expected: UUID,
|
||||||
|
_ body: () -> Result
|
||||||
|
) -> Result? {
|
||||||
|
managerQueue.sync {
|
||||||
|
guard sessions[peerID]?.isEstablished() == true,
|
||||||
|
sessionGenerations[peerID] == expected else { return nil }
|
||||||
|
return body()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Key Management
|
// MARK: - Key Management
|
||||||
@@ -207,11 +358,11 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func initiateRekey(for peerID: PeerID) throws {
|
func initiateRekey(for peerID: PeerID) throws -> Data {
|
||||||
// Remove old session
|
// Remove old session
|
||||||
removeSession(for: peerID)
|
removeSession(for: peerID)
|
||||||
|
|
||||||
// Initiate new handshake
|
// Initiate new handshake
|
||||||
_ = try initiateHandshake(with: peerID)
|
return try initiateHandshake(with: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ final class SecureNoiseSession: NoiseSession {
|
|||||||
throw NoiseSecurityError.sessionExhausted
|
throw NoiseSecurityError.sessionExhausted
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate message size
|
// Ordinary Noise messages keep the protocol ceiling. Finalized media
|
||||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
// is the sole typed-payload extension and remains under the framed-file
|
||||||
|
// cap enforced again at the service and file-decoder layers.
|
||||||
|
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: plaintext.first)
|
||||||
|
&& NoiseSecurityValidator.validatePrivateFileMessageSize(plaintext)
|
||||||
|
guard NoiseSecurityValidator.validateMessageSize(plaintext) || isPrivateFile else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,8 +46,11 @@ final class SecureNoiseSession: NoiseSession {
|
|||||||
throw NoiseSecurityError.sessionExpired
|
throw NoiseSecurityError.sessionExpired
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate message size
|
// The payload type is encrypted, so a large candidate can only be
|
||||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
// bounded here; `NoiseEncryptionService.decrypt` authenticates it and
|
||||||
|
// then requires the resulting type to be `.privateFile`.
|
||||||
|
guard NoiseSecurityValidator.validateCiphertextSize(ciphertext)
|
||||||
|
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(ciphertext) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,12 +79,35 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
||||||
// Live voice (push-to-talk)
|
// Live voice (push-to-talk)
|
||||||
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
|
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
|
||||||
|
// Finalized private media. `0x20` is the value already deployed by the
|
||||||
|
// Android client. The complete BitchatFilePacket is encrypted inside
|
||||||
|
// Noise before the outer noiseEncrypted packet is fragmented.
|
||||||
|
case privateFile = 0x20
|
||||||
|
// Versioned peer state authenticated by the surrounding Noise session.
|
||||||
|
// This is intentionally distinct from the public announce: announce
|
||||||
|
// capabilities are discovery hints, while this payload proves possession
|
||||||
|
// of the advertised Noise static key before downgrade state is pinned.
|
||||||
|
case authenticatedPeerState = 0x21
|
||||||
// Verification (QR-based OOB binding)
|
// Verification (QR-based OOB binding)
|
||||||
case verifyChallenge = 0x10 // Verification challenge
|
case verifyChallenge = 0x10 // Verification challenge
|
||||||
case verifyResponse = 0x11 // Verification response
|
case verifyResponse = 0x11 // Verification response
|
||||||
// Transitive verification (web of trust)
|
// Transitive verification (web of trust)
|
||||||
case vouch = 0x12 // Batch of vouch attestations
|
case vouch = 0x12 // Batch of vouch attestations
|
||||||
|
|
||||||
|
/// #1434 briefly used 0x09 before release. Accept it while prerelease
|
||||||
|
/// builds age out, but never emit it. Decoders canonicalize both values to
|
||||||
|
/// `.privateFile` so the compatibility alias cannot leak into app logic.
|
||||||
|
static let prereleasePrivateFileRawValue: UInt8 = 0x09
|
||||||
|
|
||||||
|
static func decoded(rawValue: UInt8) -> NoisePayloadType? {
|
||||||
|
rawValue == prereleasePrivateFileRawValue ? .privateFile : Self(rawValue: rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isPrivateFile(rawValue: UInt8?) -> Bool {
|
||||||
|
guard let rawValue else { return false }
|
||||||
|
return rawValue == privateFile.rawValue || rawValue == prereleasePrivateFileRawValue
|
||||||
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .privateMessage: return "privateMessage"
|
case .privateMessage: return "privateMessage"
|
||||||
@@ -93,6 +116,8 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case .groupInvite: return "groupInvite"
|
case .groupInvite: return "groupInvite"
|
||||||
case .groupKeyUpdate: return "groupKeyUpdate"
|
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||||
case .voiceFrame: return "voiceFrame"
|
case .voiceFrame: return "voiceFrame"
|
||||||
|
case .privateFile: return "privateFile"
|
||||||
|
case .authenticatedPeerState: return "authenticatedPeerState"
|
||||||
case .verifyChallenge: return "verifyChallenge"
|
case .verifyChallenge: return "verifyChallenge"
|
||||||
case .verifyResponse: return "verifyResponse"
|
case .verifyResponse: return "verifyResponse"
|
||||||
case .vouch: return "vouch"
|
case .vouch: return "vouch"
|
||||||
|
|||||||
@@ -156,6 +156,89 @@ struct AnnouncementPacket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// State that is authoritative only because it is carried inside an
|
||||||
|
/// established Noise session. The public announce remains useful for
|
||||||
|
/// discovery, but its self-signature cannot prove possession of the copied
|
||||||
|
/// Noise public key it contains.
|
||||||
|
///
|
||||||
|
/// Wire format (v1):
|
||||||
|
/// `[version=0x01][type][length][value]...`
|
||||||
|
/// - TLV `0x01`: canonical minimal little-endian `PeerCapabilities`
|
||||||
|
/// - TLV `0x02`: 32-byte Ed25519 signing public key
|
||||||
|
///
|
||||||
|
/// Unknown TLVs are skipped for forward compatibility. Unknown versions,
|
||||||
|
/// duplicates, non-canonical capability fields, and malformed lengths are
|
||||||
|
/// rejected without changing authenticated state.
|
||||||
|
struct AuthenticatedPeerStatePacket: Equatable {
|
||||||
|
static let currentVersion: UInt8 = 1
|
||||||
|
static let signingPublicKeyLength = 32
|
||||||
|
|
||||||
|
let capabilities: PeerCapabilities
|
||||||
|
let signingPublicKey: Data
|
||||||
|
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case capabilities = 0x01
|
||||||
|
case signingPublicKey = 0x02
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
guard signingPublicKey.count == Self.signingPublicKeyLength else { return nil }
|
||||||
|
let capabilityBytes = capabilities.encoded()
|
||||||
|
guard !capabilityBytes.isEmpty, capabilityBytes.count <= 8 else { return nil }
|
||||||
|
|
||||||
|
var data = Data([Self.currentVersion])
|
||||||
|
data.append(TLVType.capabilities.rawValue)
|
||||||
|
data.append(UInt8(capabilityBytes.count))
|
||||||
|
data.append(capabilityBytes)
|
||||||
|
data.append(TLVType.signingPublicKey.rawValue)
|
||||||
|
data.append(UInt8(signingPublicKey.count))
|
||||||
|
data.append(signingPublicKey)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(from data: Data) -> AuthenticatedPeerStatePacket? {
|
||||||
|
guard data.first == Self.currentVersion else { return nil }
|
||||||
|
|
||||||
|
var offset = 1
|
||||||
|
var capabilities: PeerCapabilities?
|
||||||
|
var signingPublicKey: Data?
|
||||||
|
|
||||||
|
while offset < data.count {
|
||||||
|
guard offset + 2 <= data.count else { return nil }
|
||||||
|
let typeRaw = data[offset]
|
||||||
|
let length = Int(data[offset + 1])
|
||||||
|
offset += 2
|
||||||
|
guard offset + length <= data.count else { return nil }
|
||||||
|
let value = Data(data[offset..<(offset + length)])
|
||||||
|
offset += length
|
||||||
|
|
||||||
|
guard let type = TLVType(rawValue: typeRaw) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch type {
|
||||||
|
case .capabilities:
|
||||||
|
guard capabilities == nil,
|
||||||
|
!value.isEmpty,
|
||||||
|
value.count <= 8 else { return nil }
|
||||||
|
let decoded = PeerCapabilities(encoded: value)
|
||||||
|
guard decoded.encoded() == value else { return nil }
|
||||||
|
capabilities = decoded
|
||||||
|
|
||||||
|
case .signingPublicKey:
|
||||||
|
guard signingPublicKey == nil,
|
||||||
|
value.count == Self.signingPublicKeyLength else { return nil }
|
||||||
|
signingPublicKey = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let capabilities, let signingPublicKey else { return nil }
|
||||||
|
return AuthenticatedPeerStatePacket(
|
||||||
|
capabilities: capabilities,
|
||||||
|
signingPublicKey: signingPublicKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct PrivateMessagePacket {
|
struct PrivateMessagePacket {
|
||||||
let messageID: String
|
let messageID: String
|
||||||
let content: String
|
let content: String
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ import BitFoundation
|
|||||||
extension PeerCapabilities {
|
extension PeerCapabilities {
|
||||||
/// Capabilities this build advertises in its announce packets.
|
/// Capabilities this build advertises in its announce packets.
|
||||||
/// Each feature adds its bit here when it ships.
|
/// Each feature adds its bit here when it ships.
|
||||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
|
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups, .privateMedia]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ struct BLEAnnounceHandlerEnvironment {
|
|||||||
let now: () -> Date
|
let now: () -> Date
|
||||||
/// Noise public key already recorded for the peer, if any (registry read).
|
/// Noise public key already recorded for the peer, if any (registry read).
|
||||||
let existingNoisePublicKey: (PeerID) -> Data?
|
let existingNoisePublicKey: (PeerID) -> Data?
|
||||||
|
/// Ed25519 key previously bound to this Noise identity by an authenticated
|
||||||
|
/// peer-state payload, if any (persistent identity-state read).
|
||||||
|
let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data?
|
||||||
/// Verifies the packet signature against the announced signing key.
|
/// Verifies the packet signature against the announced signing key.
|
||||||
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||||
/// Direct link state for the peer (BLE-queue read).
|
/// Direct link state for the peer (BLE-queue read).
|
||||||
@@ -130,11 +133,21 @@ final class BLEAnnounceHandler {
|
|||||||
hasSignature: hasSignature,
|
hasSignature: hasSignature,
|
||||||
signatureValid: signatureValid,
|
signatureValid: signatureValid,
|
||||||
existingNoisePublicKey: existingNoisePublicKey,
|
existingNoisePublicKey: existingNoisePublicKey,
|
||||||
announcedNoisePublicKey: announcement.noisePublicKey
|
announcedNoisePublicKey: announcement.noisePublicKey,
|
||||||
|
authenticatedSigningPublicKey: env.authenticatedSigningPublicKey(
|
||||||
|
announcement.noisePublicKey
|
||||||
|
),
|
||||||
|
announcedSigningPublicKey: announcement.signingPublicKey
|
||||||
)
|
)
|
||||||
if case .reject(.keyMismatch) = trustDecision {
|
if case .reject(.keyMismatch) = trustDecision {
|
||||||
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
||||||
}
|
}
|
||||||
|
if case .reject(.authenticatedSigningKeyMismatch) = trustDecision {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))…",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
}
|
||||||
let verifiedAnnounce = trustDecision.isVerified
|
let verifiedAnnounce = trustDecision.isVerified
|
||||||
|
|
||||||
var isNewPeer = false
|
var isNewPeer = false
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ enum BLEAnnounceTrustRejection: Equatable {
|
|||||||
case missingSignature
|
case missingSignature
|
||||||
case invalidSignature
|
case invalidSignature
|
||||||
case keyMismatch
|
case keyMismatch
|
||||||
|
case authenticatedSigningKeyMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BLEAnnounceTrustDecision: Equatable {
|
enum BLEAnnounceTrustDecision: Equatable {
|
||||||
@@ -72,12 +73,19 @@ enum BLEAnnounceTrustPolicy {
|
|||||||
hasSignature: Bool,
|
hasSignature: Bool,
|
||||||
signatureValid: Bool,
|
signatureValid: Bool,
|
||||||
existingNoisePublicKey: Data?,
|
existingNoisePublicKey: Data?,
|
||||||
announcedNoisePublicKey: Data
|
announcedNoisePublicKey: Data,
|
||||||
|
authenticatedSigningPublicKey: Data? = nil,
|
||||||
|
announcedSigningPublicKey: Data? = nil
|
||||||
) -> BLEAnnounceTrustDecision {
|
) -> BLEAnnounceTrustDecision {
|
||||||
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||||
return .reject(.keyMismatch)
|
return .reject(.keyMismatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let authenticatedSigningPublicKey,
|
||||||
|
announcedSigningPublicKey != authenticatedSigningPublicKey {
|
||||||
|
return .reject(.authenticatedSigningKeyMismatch)
|
||||||
|
}
|
||||||
|
|
||||||
guard hasSignature else {
|
guard hasSignature else {
|
||||||
return .reject(.missingSignature)
|
return .reject(.missingSignature)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Thread-safe announce admission state.
|
struct BLEAnnounceThrottle {
|
||||||
///
|
|
||||||
/// Announce requests originate from the Bluetooth delegate queue, the
|
|
||||||
/// concurrent message queue, and the maintenance timer. Keeping the timestamp
|
|
||||||
/// behind a lock makes admission and maintenance snapshots atomic when those
|
|
||||||
/// request sources race.
|
|
||||||
final class BLEAnnounceThrottle: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var lastSent: Date
|
private var lastSent: Date
|
||||||
private let normalMinimumInterval: TimeInterval
|
private let normalMinimumInterval: TimeInterval
|
||||||
private let forcedMinimumInterval: TimeInterval
|
private let forcedMinimumInterval: TimeInterval
|
||||||
@@ -23,18 +16,16 @@ final class BLEAnnounceThrottle: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func elapsed(since now: Date) -> TimeInterval {
|
func elapsed(since now: Date) -> TimeInterval {
|
||||||
lock.withLock { now.timeIntervalSince(lastSent) }
|
now.timeIntervalSince(lastSent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldSend(force: Bool, now: Date) -> Bool {
|
mutating func shouldSend(force: Bool, now: Date) -> Bool {
|
||||||
lock.withLock {
|
|
||||||
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
||||||
guard now.timeIntervalSince(lastSent) >= minimumInterval else {
|
guard elapsed(since: now) >= minimumInterval else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSent = now
|
lastSent = now
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ struct BLEFileTransferHandlerEnvironment {
|
|||||||
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
||||||
/// Verifies a packet's signature against a candidate signing key (registry path).
|
/// Verifies a packet's signature against a candidate signing key (registry path).
|
||||||
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||||
|
/// Local signing key used to authenticate our own gossip-sync replays.
|
||||||
|
let localSigningPublicKey: () -> Data
|
||||||
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
||||||
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
||||||
/// Tracks the broadcast file packet for gossip sync.
|
/// Tracks the broadcast file packet for gossip sync.
|
||||||
@@ -46,54 +48,105 @@ final class BLEFileTransferHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `false` when the packet fails sender authentication and must
|
/// Returns `false` when the raw packet fails sender authentication (or is
|
||||||
/// not be relayed onward. Every other outcome returns `true`: files
|
/// a live self-echo) and must not be relayed onward. Authentication runs
|
||||||
/// directed to another peer are forwarded untouched, and local-only drops
|
/// before the routing decision, so a forged directed packet cannot use a
|
||||||
/// (malformed payload, quota, save failure) don't affect multi-hop
|
/// node that is not its recipient as an unsigned forwarding hop.
|
||||||
/// delivery to nodes that may handle them fine.
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||||
let env = environment
|
let env = environment
|
||||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true }
|
let localPeerID = env.localPeerID()
|
||||||
|
|
||||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
let peersSnapshot = env.peersSnapshot()
|
let peersSnapshot = env.peersSnapshot()
|
||||||
guard let senderNickname = resolveSenderNickname(
|
|
||||||
|
guard let senderNickname = authenticatedRawSenderNickname(
|
||||||
packet: packet,
|
packet: packet,
|
||||||
from: peerID,
|
from: peerID,
|
||||||
isBroadcast: !deliveryPlan.isPrivateMessage,
|
|
||||||
peers: peersSnapshot,
|
peers: peersSnapshot,
|
||||||
env: env
|
env: env
|
||||||
) else {
|
) else {
|
||||||
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
SecureLogger.warning("🚫 Dropping raw file transfer with missing/invalid signature from \(peerID.id.prefix(8))…", category: .security)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: localPeerID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: localPeerID) else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if deliveryPlan.shouldTrackForSync {
|
if deliveryPlan.shouldTrackForSync {
|
||||||
env.trackPacketSeen(packet)
|
env.trackPacketSeen(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = storeIncomingPayload(
|
||||||
|
packet.payload,
|
||||||
|
from: peerID,
|
||||||
|
senderNickname: senderNickname,
|
||||||
|
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
||||||
|
isPrivate: deliveryPlan.isPrivateMessage,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
// Once authenticated, a local decode/quota/save failure is not proof
|
||||||
|
// that downstream nodes should be denied the valid signed packet.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts a file packet only after it has been authenticated and
|
||||||
|
/// decrypted by the peer's Noise session. The inner packet deliberately
|
||||||
|
/// has no redundant signature: Noise supplies sender authentication and
|
||||||
|
/// confidentiality, while this handler retains the same validation,
|
||||||
|
/// quota, persistence, and UI-delivery behavior as public files.
|
||||||
|
@discardableResult
|
||||||
|
func handlePrivatePayload(_ payload: Data, from peerID: PeerID, timestamp: Date) -> Bool {
|
||||||
|
let env = environment
|
||||||
|
let peers = env.peersSnapshot()
|
||||||
|
let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
|
peerID: peerID,
|
||||||
|
localPeerID: env.localPeerID(),
|
||||||
|
localNickname: env.localNickname(),
|
||||||
|
peers: peers,
|
||||||
|
allowConnectedUnverified: true
|
||||||
|
) ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
|
||||||
|
|
||||||
|
return storeIncomingPayload(
|
||||||
|
payload,
|
||||||
|
from: peerID,
|
||||||
|
senderNickname: senderNickname,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isPrivate: true,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func storeIncomingPayload(
|
||||||
|
_ payload: Data,
|
||||||
|
from peerID: PeerID,
|
||||||
|
senderNickname: String,
|
||||||
|
timestamp: Date,
|
||||||
|
isPrivate: Bool,
|
||||||
|
env: BLEFileTransferHandlerEnvironment
|
||||||
|
) -> Bool {
|
||||||
|
|
||||||
let filePacket: BitchatFilePacket
|
let filePacket: BitchatFilePacket
|
||||||
let mime: MimeType
|
let mime: MimeType
|
||||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
switch BLEIncomingFileValidator.validate(payload: payload) {
|
||||||
case .success(let acceptance):
|
case .success(let acceptance):
|
||||||
filePacket = acceptance.filePacket
|
filePacket = acceptance.filePacket
|
||||||
mime = acceptance.mime
|
mime = acceptance.mime
|
||||||
case .failure(.malformedPayload):
|
case .failure(.malformedPayload):
|
||||||
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
||||||
return true
|
return false
|
||||||
case .failure(.payloadTooLarge(let bytes)):
|
case .failure(.payloadTooLarge(let bytes)):
|
||||||
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
||||||
return true
|
return false
|
||||||
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
||||||
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
|
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
|
||||||
return true
|
return false
|
||||||
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
|
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
|
||||||
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
|
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
|
||||||
return true
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-002: Enforce storage quota before saving
|
// BCH-01-002: Enforce storage quota before saving
|
||||||
@@ -106,28 +159,27 @@ final class BLEFileTransferHandler {
|
|||||||
mime.defaultExtension,
|
mime.defaultExtension,
|
||||||
mime.category.rawValue
|
mime.category.rawValue
|
||||||
) else {
|
) else {
|
||||||
return true
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if deliveryPlan.isPrivateMessage {
|
if isPrivate {
|
||||||
env.updatePeerLastSeen(peerID)
|
env.updatePeerLastSeen(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
sender: senderNickname,
|
sender: senderNickname,
|
||||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
||||||
timestamp: ts,
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: deliveryPlan.isPrivateMessage,
|
isPrivate: isPrivate,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: peerID,
|
senderPeerID: peerID,
|
||||||
// Received messages need an explicit status: BitchatMessage
|
// Received messages need an explicit status: BitchatMessage
|
||||||
// defaults private messages to .sending, which the media views
|
// defaults private messages to .sending, which the media views
|
||||||
// render as an in-flight send (empty reveal mask, disabled tap).
|
// render as an in-flight send (empty reveal mask, disabled tap).
|
||||||
deliveryStatus: deliveryPlan.isPrivateMessage
|
deliveryStatus: isPrivate
|
||||||
? .delivered(to: env.localNickname(), at: ts)
|
? .delivered(to: env.localNickname(), at: timestamp)
|
||||||
: nil
|
: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,51 +189,38 @@ final class BLEFileTransferHandler {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the authenticated display name for a file transfer's sender.
|
/// Every remaining raw file transfer is signed, regardless of whether it
|
||||||
///
|
/// is broadcast, addressed to us, or merely passing through. Registry
|
||||||
/// Directed (private) transfers are addressed to us specifically and keep
|
/// signing keys are preferred; persisted identities cover peers that have
|
||||||
/// the lenient connected-peer path. Broadcast transfers carry an
|
/// rotated or are not currently present in the registry.
|
||||||
/// attacker-controllable `senderID` exactly like public messages and public
|
private func authenticatedRawSenderNickname(
|
||||||
/// voice frames — registry membership alone is NOT proof of identity, so a
|
|
||||||
/// valid packet signature from the claimed sender is required before we
|
|
||||||
/// trust it. Without this, a peer that observed a public voice burst could
|
|
||||||
/// spoof a broadcast `voice_<burstID>.m4a` note under the talker's ID and
|
|
||||||
/// overwrite the signature-verified live bubble with attacker audio.
|
|
||||||
private func resolveSenderNickname(
|
|
||||||
packet: BitchatPacket,
|
packet: BitchatPacket,
|
||||||
from peerID: PeerID,
|
from peerID: PeerID,
|
||||||
isBroadcast: Bool,
|
|
||||||
peers: [PeerID: BLEPeerInfo],
|
peers: [PeerID: BLEPeerInfo],
|
||||||
env: BLEFileTransferHandlerEnvironment
|
env: BLEFileTransferHandlerEnvironment
|
||||||
) -> String? {
|
) -> String? {
|
||||||
guard isBroadcast else {
|
guard packet.signature != nil else { return nil }
|
||||||
|
|
||||||
|
let localPeerID = env.localPeerID()
|
||||||
|
let candidateKey = peerID == localPeerID
|
||||||
|
? env.localSigningPublicKey()
|
||||||
|
: peers[peerID]?.signingPublicKey
|
||||||
|
let verifiedWithKnownKey = candidateKey.map {
|
||||||
|
env.verifyPacketSignature(packet, $0)
|
||||||
|
} ?? false
|
||||||
|
let signedDisplayName = verifiedWithKnownKey
|
||||||
|
? nil
|
||||||
|
: env.signedSenderDisplayName(packet, peerID)
|
||||||
|
guard verifiedWithKnownKey || signedDisplayName != nil else { return nil }
|
||||||
|
|
||||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
localPeerID: env.localPeerID(),
|
localPeerID: localPeerID,
|
||||||
localNickname: env.localNickname(),
|
localNickname: env.localNickname(),
|
||||||
peers: peers,
|
peers: peers,
|
||||||
|
// The packet signature authenticates the announced peer; the old
|
||||||
|
// connected-but-unsigned leniency is not involved.
|
||||||
allowConnectedUnverified: true
|
allowConnectedUnverified: true
|
||||||
) ?? env.signedSenderDisplayName(packet, peerID)
|
) ?? signedDisplayName ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
|
||||||
}
|
|
||||||
|
|
||||||
// Our own broadcasts replayed back via gossip sync (ttl==0) are
|
|
||||||
// trivially authentic and cannot be verified against the peer registry
|
|
||||||
// or identity cache, so exempt self exactly as `BLEPublicMessageHandler`
|
|
||||||
// does. Verify against the signing key already in the
|
|
||||||
// (synchronously-updated) registry first, then fall back to the
|
|
||||||
// persisted-identity signature lookup for peers not yet cached there.
|
|
||||||
let isSelf = peerID == env.localPeerID()
|
|
||||||
let registrySigningKey = peers[peerID]?.signingPublicKey
|
|
||||||
let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
|
|
||||||
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
|
|
||||||
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil }
|
|
||||||
|
|
||||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
|
||||||
peerID: peerID,
|
|
||||||
localPeerID: env.localPeerID(),
|
|
||||||
localNickname: env.localNickname(),
|
|
||||||
peers: peers,
|
|
||||||
allowConnectedUnverified: false
|
|
||||||
) ?? signedDisplayName
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,8 +201,11 @@ struct BLEFragmentAssemblyBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
||||||
if originalType == MessageType.fileTransfer.rawValue {
|
if originalType == MessageType.fileTransfer.rawValue
|
||||||
|
|| originalType == MessageType.noiseEncrypted.rawValue {
|
||||||
// Allow headroom for TLV metadata and binary framing overhead.
|
// Allow headroom for TLV metadata and binary framing overhead.
|
||||||
|
// A large noiseEncrypted packet can be an E2E-encrypted private
|
||||||
|
// file; its authenticated plaintext is validated after decrypt.
|
||||||
return FileTransferLimits.maxFramedFileBytes
|
return FileTransferLimits.maxFramedFileBytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLELocalIdentitySnapshot: Equatable, Sendable {
|
|
||||||
let peerID: PeerID
|
|
||||||
let peerIDData: Data
|
|
||||||
let nickname: String
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lock-backed local identity state shared by the transport's message,
|
|
||||||
/// Bluetooth, maintenance, and main-actor entry points.
|
|
||||||
///
|
|
||||||
/// `peerID` and its binary wire representation must change as one unit during
|
|
||||||
/// panic rotation. A snapshot also gives announce construction one consistent
|
|
||||||
/// view of the nickname and identity instead of reading three independently
|
|
||||||
/// mutable properties across queues.
|
|
||||||
final class BLELocalIdentityStateStore: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var state: BLELocalIdentitySnapshot
|
|
||||||
|
|
||||||
init(
|
|
||||||
peerID: PeerID = PeerID(str: ""),
|
|
||||||
nickname: String = "anon"
|
|
||||||
) {
|
|
||||||
state = BLELocalIdentitySnapshot(
|
|
||||||
peerID: peerID,
|
|
||||||
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
|
||||||
nickname: nickname
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func snapshot() -> BLELocalIdentitySnapshot {
|
|
||||||
lock.withLock { state }
|
|
||||||
}
|
|
||||||
|
|
||||||
func setNickname(_ nickname: String) {
|
|
||||||
lock.withLock {
|
|
||||||
state = BLELocalIdentitySnapshot(
|
|
||||||
peerID: state.peerID,
|
|
||||||
peerIDData: state.peerIDData,
|
|
||||||
nickname: nickname
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func replacePeerIdentity(with peerID: PeerID) {
|
|
||||||
lock.withLock {
|
|
||||||
state = BLELocalIdentitySnapshot(
|
|
||||||
peerID: peerID,
|
|
||||||
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
|
||||||
nickname: state.nickname
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,11 @@ import BitFoundation
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
struct BLENoiseDecryptionResult {
|
||||||
|
let plaintext: Data
|
||||||
|
let sessionGeneration: UUID
|
||||||
|
}
|
||||||
|
|
||||||
/// Narrow environment for `BLENoisePacketHandler`.
|
/// Narrow environment for `BLENoisePacketHandler`.
|
||||||
///
|
///
|
||||||
/// All queue hops (collections barrier writes, main-actor UI notification)
|
/// All queue hops (collections barrier writes, main-actor UI notification)
|
||||||
@@ -27,9 +32,16 @@ struct BLENoisePacketHandlerEnvironment {
|
|||||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||||
let updatePeerLastSeen: (PeerID) -> Void
|
let updatePeerLastSeen: (PeerID) -> Void
|
||||||
/// Decrypts an encrypted payload from the peer (crypto).
|
/// Decrypts an encrypted payload from the peer (crypto).
|
||||||
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data
|
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> BLENoiseDecryptionResult
|
||||||
/// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
|
/// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
|
||||||
let clearSession: (PeerID) -> Void
|
let clearSession: (PeerID) -> Void
|
||||||
|
/// Consumes session-authenticated protocol state inside the transport. It
|
||||||
|
/// must never escape to UI or Nostr payload dispatch.
|
||||||
|
let handleAuthenticatedPeerState: (
|
||||||
|
_ peerID: PeerID,
|
||||||
|
_ payload: Data,
|
||||||
|
_ sessionGeneration: UUID
|
||||||
|
) -> Void
|
||||||
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
|
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
|
||||||
let deliverNoisePayload: (
|
let deliverNoisePayload: (
|
||||||
_ peerID: PeerID,
|
_ peerID: PeerID,
|
||||||
@@ -49,7 +61,11 @@ final class BLENoisePacketHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
/// Returns true when the handshake message was processed successfully.
|
||||||
|
/// Callers use this to distinguish an authenticated replacement completion
|
||||||
|
/// from a rejected candidate while an older session remains established.
|
||||||
|
@discardableResult
|
||||||
|
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||||
let env = environment
|
let env = environment
|
||||||
// Use NoiseEncryptionService for handshake processing
|
// Use NoiseEncryptionService for handshake processing
|
||||||
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
||||||
@@ -72,14 +88,26 @@ final class BLENoisePacketHandler {
|
|||||||
|
|
||||||
// Session establishment will trigger onPeerAuthenticated callback
|
// Session establishment will trigger onPeerAuthenticated callback
|
||||||
// which will send any pending messages at the right time
|
// which will send any pending messages at the right time
|
||||||
|
return true
|
||||||
|
} catch NoiseSessionError.peerIdentityMismatch {
|
||||||
|
// The candidate was already discarded by the session manager.
|
||||||
|
// Do not let a spoofed claimed ID trigger a fresh outbound
|
||||||
|
// handshake or recreate state for the attacker-selected ID.
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Rejected Noise handshake whose static key does not match \(peerID.id.prefix(8))…",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
return false
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to process handshake: \(error)")
|
SecureLogger.error("Failed to process handshake: \(error)")
|
||||||
// Try initiating a new handshake
|
// Try initiating a new handshake
|
||||||
if !env.hasNoiseSession(peerID) {
|
if !env.hasNoiseSession(peerID) {
|
||||||
env.initiateHandshake(peerID)
|
env.initiateHandshake(peerID)
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
@@ -98,20 +126,30 @@ final class BLENoisePacketHandler {
|
|||||||
env.updatePeerLastSeen(peerID)
|
env.updatePeerLastSeen(peerID)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let decrypted = try env.decrypt(packet.payload, peerID)
|
let decryption = try env.decrypt(packet.payload, peerID)
|
||||||
|
let decrypted = decryption.plaintext
|
||||||
guard decrypted.count > 0 else { return }
|
guard decrypted.count > 0 else { return }
|
||||||
|
|
||||||
// First byte indicates the payload type
|
// First byte indicates the payload type
|
||||||
let payloadType = decrypted[0]
|
let payloadType = decrypted[0]
|
||||||
let payloadData = decrypted.dropFirst()
|
let payloadData = decrypted.dropFirst()
|
||||||
|
|
||||||
guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else {
|
guard let noisePayloadType = NoisePayloadType.decoded(rawValue: payloadType) else {
|
||||||
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
|
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session)
|
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
|
||||||
|
if noisePayloadType == .authenticatedPeerState {
|
||||||
|
env.handleAuthenticatedPeerState(
|
||||||
|
peerID,
|
||||||
|
Data(payloadData),
|
||||||
|
decryption.sessionGeneration
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
|
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
|
||||||
} catch NoiseEncryptionError.sessionNotEstablished {
|
} catch NoiseEncryptionError.sessionNotEstablished {
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ enum BLENoisePayloadFactory {
|
|||||||
typedPayload(.delivered, payload: Data(messageID.utf8))
|
typedPayload(.delivered, payload: Data(messageID.utf8))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func privateFile(_ filePacket: BitchatFilePacket) -> Data? {
|
||||||
|
guard let payload = filePacket.encode() else { return nil }
|
||||||
|
return typedPayload(.privateFile, payload: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func authenticatedPeerState(_ state: AuthenticatedPeerStatePacket) -> Data? {
|
||||||
|
guard let payload = state.encode() else { return nil }
|
||||||
|
return typedPayload(.authenticatedPeerState, payload: payload)
|
||||||
|
}
|
||||||
|
|
||||||
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
|
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
|
||||||
var typed = Data([type.rawValue])
|
var typed = Data([type.rawValue])
|
||||||
typed.append(payload)
|
typed.append(payload)
|
||||||
|
|||||||
@@ -6,9 +6,16 @@ struct BLEPendingPrivateMessage: Equatable {
|
|||||||
let messageID: String
|
let messageID: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct BLEPendingTypedPayload: Equatable {
|
||||||
|
let payload: Data
|
||||||
|
/// Present for app-initiated media so handshake queuing preserves the
|
||||||
|
/// fragment scheduler's progress/cancellation identity.
|
||||||
|
let transferId: String?
|
||||||
|
}
|
||||||
|
|
||||||
struct BLENoiseSessionQueues {
|
struct BLENoiseSessionQueues {
|
||||||
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
|
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
|
||||||
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:]
|
private var typedPayloadsByPeerID: [PeerID: [BLEPendingTypedPayload]] = [:]
|
||||||
|
|
||||||
var isEmpty: Bool {
|
var isEmpty: Bool {
|
||||||
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
|
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
|
||||||
@@ -34,13 +41,35 @@ struct BLENoiseSessionQueues {
|
|||||||
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
|
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) {
|
mutating func appendTypedPayload(_ payload: Data, transferId: String? = nil, for peerID: PeerID) {
|
||||||
typedPayloadsByPeerID[peerID, default: []].append(payload)
|
typedPayloadsByPeerID[peerID, default: []].append(
|
||||||
|
BLEPendingTypedPayload(payload: payload, transferId: transferId)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] {
|
mutating func takeTypedPayloads(for peerID: PeerID) -> [BLEPendingTypedPayload] {
|
||||||
let payloads = typedPayloadsByPeerID[peerID] ?? []
|
let payloads = typedPayloadsByPeerID[peerID] ?? []
|
||||||
typedPayloadsByPeerID.removeValue(forKey: peerID)
|
typedPayloadsByPeerID.removeValue(forKey: peerID)
|
||||||
return payloads
|
return payloads
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func containsTypedPayload(transferId: String) -> Bool {
|
||||||
|
typedPayloadsByPeerID.values.contains { payloads in
|
||||||
|
payloads.contains { $0.transferId == transferId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func removeTypedPayload(transferId: String) -> Bool {
|
||||||
|
for peerID in Array(typedPayloadsByPeerID.keys) {
|
||||||
|
guard var payloads = typedPayloadsByPeerID[peerID],
|
||||||
|
let index = payloads.firstIndex(where: { $0.transferId == transferId }) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payloads.remove(at: index)
|
||||||
|
typedPayloadsByPeerID[peerID] = payloads.isEmpty ? nil : payloads
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ struct BLEOutboundFragmentPlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum BLEOutboundFragmentPlanner {
|
enum BLEOutboundFragmentPlanner {
|
||||||
|
/// Current Android receivers reject fragment sets above 256. Private
|
||||||
|
/// media v1 treats that deployed ceiling as a cross-platform contract.
|
||||||
|
static let privateMediaV1MaxFragments = 256
|
||||||
private static let minimumChunkSize = 64
|
private static let minimumChunkSize = 64
|
||||||
private static let fragmentIDLength = 8
|
private static let fragmentIDLength = 8
|
||||||
|
|
||||||
@@ -71,6 +74,10 @@ enum BLEOutboundFragmentPlanner {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func isPrivateMediaV1Compatible(_ plan: BLEOutboundFragmentPlan) -> Bool {
|
||||||
|
plan.totalFragments <= privateMediaV1MaxFragments
|
||||||
|
}
|
||||||
|
|
||||||
private static func sizingPolicy(
|
private static func sizingPolicy(
|
||||||
for packet: BitchatPacket,
|
for packet: BitchatPacket,
|
||||||
requestedMaxChunk: Int?,
|
requestedMaxChunk: Int?,
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ struct BLEOutboundFragmentTransferRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var resolvedTransferId: String? {
|
var resolvedTransferId: String? {
|
||||||
|
if let transferId { return transferId }
|
||||||
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
||||||
return transferId ?? packet.payload.sha256Hex()
|
return packet.payload.sha256Hex()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content identity independent of the caller-chosen transfer ID: the
|
/// Content identity independent of the caller-chosen transfer ID: the
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ struct BLEPeerInfo: Equatable {
|
|||||||
var isVerifiedNickname: Bool
|
var isVerifiedNickname: Bool
|
||||||
var lastSeen: Date
|
var lastSeen: Date
|
||||||
var capabilities: PeerCapabilities = []
|
var capabilities: PeerCapabilities = []
|
||||||
|
/// Distinguishes an old client that omitted the capabilities TLV from a
|
||||||
|
/// modern client that explicitly advertised a set without a given bit.
|
||||||
|
var capabilitiesWereExplicitlyAdvertised: Bool = false
|
||||||
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
|
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
|
||||||
var bridgeGeohash: String?
|
var bridgeGeohash: String?
|
||||||
}
|
}
|
||||||
@@ -114,6 +117,10 @@ struct BLEPeerRegistry {
|
|||||||
peers[peerID.toShort()]?.capabilities ?? []
|
peers[peerID.toShort()]?.capabilities ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func capabilitiesWereExplicitlyAdvertised(for peerID: PeerID) -> Bool {
|
||||||
|
peers[peerID.toShort()]?.capabilitiesWereExplicitlyAdvertised == true
|
||||||
|
}
|
||||||
|
|
||||||
/// Peers whose last verified announce advertised the given capability.
|
/// Peers whose last verified announce advertised the given capability.
|
||||||
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
|
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
|
||||||
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
|
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
|
||||||
@@ -174,6 +181,14 @@ struct BLEPeerRegistry {
|
|||||||
peers[peerID] = peer
|
peers[peerID] = peer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces the announcement signing key only after the surrounding Noise
|
||||||
|
/// session proved possession of this peer's static key.
|
||||||
|
mutating func bindAuthenticatedSigningPublicKey(_ key: Data, for peerID: PeerID) {
|
||||||
|
guard var peer = peers[peerID.toShort()] else { return }
|
||||||
|
peer.signingPublicKey = key
|
||||||
|
peers[peer.peerID] = peer
|
||||||
|
}
|
||||||
|
|
||||||
mutating func upsertVerifiedAnnounce(
|
mutating func upsertVerifiedAnnounce(
|
||||||
peerID: PeerID,
|
peerID: PeerID,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
@@ -181,7 +196,7 @@ struct BLEPeerRegistry {
|
|||||||
signingPublicKey: Data?,
|
signingPublicKey: Data?,
|
||||||
isConnected: Bool,
|
isConnected: Bool,
|
||||||
now: Date,
|
now: Date,
|
||||||
capabilities: PeerCapabilities = [],
|
capabilities: PeerCapabilities? = nil,
|
||||||
bridgeGeohash: String? = nil
|
bridgeGeohash: String? = nil
|
||||||
) -> BLEPeerAnnounceUpdate {
|
) -> BLEPeerAnnounceUpdate {
|
||||||
let existing = peers[peerID]
|
let existing = peers[peerID]
|
||||||
@@ -199,7 +214,8 @@ struct BLEPeerRegistry {
|
|||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
lastSeen: now,
|
lastSeen: now,
|
||||||
capabilities: capabilities,
|
capabilities: capabilities ?? [],
|
||||||
|
capabilitiesWereExplicitlyAdvertised: capabilities != nil,
|
||||||
bridgeGeohash: bridgeGeohash
|
bridgeGeohash: bridgeGeohash
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -165,7 +165,6 @@ final class NoiseEncryptionService {
|
|||||||
// Peer fingerprints (SHA256 hash of static public key)
|
// Peer fingerprints (SHA256 hash of static public key)
|
||||||
private var peerFingerprints: [PeerID: String] = [:]
|
private var peerFingerprints: [PeerID: String] = [:]
|
||||||
private var fingerprintToPeerID: [String: PeerID] = [:]
|
private var fingerprintToPeerID: [String: PeerID] = [:]
|
||||||
|
|
||||||
// Thread safety
|
// Thread safety
|
||||||
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
||||||
|
|
||||||
@@ -183,12 +182,18 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||||
|
private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = []
|
||||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||||
|
/// Automatic rekey removed the old session and produced XX message 1.
|
||||||
|
/// The transport must clear session-scoped state and put these exact bytes
|
||||||
|
/// on the wire; merely reporting "handshake required" strands the partial
|
||||||
|
/// initiator session because a second initiate call sees it already exists.
|
||||||
|
var onRekeyHandshakeReady: ((_ peerID: PeerID, _ message: Data) -> Void)?
|
||||||
|
|
||||||
// Add a handler for peer authentication
|
// Add a handler for peer authentication
|
||||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
||||||
serviceQueue.async(flags: .barrier) { [weak self] in
|
serviceQueue.sync(flags: .barrier) {
|
||||||
self?.onPeerAuthenticatedHandlers.append(handler)
|
onPeerAuthenticatedHandlers.append(handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +207,18 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generation-aware authentication notifications are used by protocols
|
||||||
|
/// whose state must be bound to one exact Noise transport session.
|
||||||
|
var onPeerAuthenticatedWithGeneration: ((PeerID, String, UUID) -> Void)? {
|
||||||
|
get { nil }
|
||||||
|
set {
|
||||||
|
guard let handler = newValue else { return }
|
||||||
|
serviceQueue.sync(flags: .barrier) {
|
||||||
|
onPeerAuthenticatedWithGenerationHandlers.append(handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol) {
|
init(keychain: KeychainManagerProtocol) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
|
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
|
||||||
@@ -295,8 +312,12 @@ final class NoiseEncryptionService {
|
|||||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
||||||
|
|
||||||
// Set up session callbacks
|
// Set up session callbacks
|
||||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
|
||||||
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
self?.handleSessionEstablished(
|
||||||
|
peerID: peerID,
|
||||||
|
remoteStaticKey: remoteStaticKey,
|
||||||
|
sessionGeneration: generation
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start session maintenance timer
|
// Start session maintenance timer
|
||||||
@@ -726,10 +747,55 @@ final class NoiseEncryptionService {
|
|||||||
return try sessionManager.encrypt(data, for: peerID)
|
return try sessionManager.encrypt(data, for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encrypts a finalized private-media packet. Ordinary Noise application
|
||||||
|
/// messages retain the 64 KiB ceiling; this purpose-specific path permits
|
||||||
|
/// the bounded `BitchatFilePacket` envelope and refuses every other typed
|
||||||
|
/// payload so the larger allocation budget cannot become a generic bypass.
|
||||||
|
func encryptPrivateFilePayload(
|
||||||
|
_ data: Data,
|
||||||
|
for peerID: PeerID,
|
||||||
|
sessionGeneration: UUID? = nil
|
||||||
|
) throws -> Data {
|
||||||
|
guard NoisePayloadType.isPrivateFile(rawValue: data.first),
|
||||||
|
NoiseSecurityValidator.validatePrivateFileMessageSize(data) else {
|
||||||
|
throw NoiseSecurityError.messageTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
guard rateLimiter.allowMessage(from: peerID) else {
|
||||||
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
guard hasEstablishedSession(with: peerID) else {
|
||||||
|
onHandshakeRequired?(peerID)
|
||||||
|
throw NoiseEncryptionError.handshakeRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
// `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed
|
||||||
|
// nonce/tag overhead, so the result is bounded without a second copy.
|
||||||
|
if let sessionGeneration {
|
||||||
|
return try sessionManager.encrypt(
|
||||||
|
data,
|
||||||
|
for: peerID,
|
||||||
|
expectedSessionGeneration: sessionGeneration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return try sessionManager.encrypt(data, for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
/// Decrypt data from a specific peer
|
/// Decrypt data from a specific peer
|
||||||
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||||
// Validate message size
|
try decryptWithSessionGeneration(data, from: peerID).plaintext
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
}
|
||||||
|
|
||||||
|
func decryptWithSessionGeneration(
|
||||||
|
_ data: Data,
|
||||||
|
from peerID: PeerID
|
||||||
|
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||||
|
// Standard transport ciphertext has 20 bytes of nonce/tag overhead.
|
||||||
|
// A larger candidate is admitted only up to the framed-file ceiling;
|
||||||
|
// after authenticated decryption it must prove it is `.privateFile`.
|
||||||
|
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
|
||||||
|
guard isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,7 +809,14 @@ final class NoiseEncryptionService {
|
|||||||
throw NoiseEncryptionError.sessionNotEstablished
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
}
|
}
|
||||||
|
|
||||||
return try sessionManager.decrypt(data, from: peerID)
|
let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID)
|
||||||
|
if !isStandardCiphertext {
|
||||||
|
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
|
||||||
|
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
|
||||||
|
throw NoiseSecurityError.messageTooLarge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Peer Management
|
// MARK: - Peer Management
|
||||||
@@ -755,6 +828,25 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sessionGeneration(for peerID: PeerID) -> UUID? {
|
||||||
|
sessionManager.sessionGeneration(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `body` while holding a read lease on the exact session generation.
|
||||||
|
/// Session insertion, replacement, and removal use the same manager
|
||||||
|
/// barrier, so they cannot interleave with an authenticated-state commit.
|
||||||
|
func withCurrentSessionGeneration<Result>(
|
||||||
|
for peerID: PeerID,
|
||||||
|
expected: UUID,
|
||||||
|
_ body: () -> Result
|
||||||
|
) -> Result? {
|
||||||
|
sessionManager.withCurrentSessionGeneration(
|
||||||
|
for: peerID,
|
||||||
|
expected: expected,
|
||||||
|
body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func clearEphemeralStateForPanic() {
|
func clearEphemeralStateForPanic() {
|
||||||
sessionManager.removeAllSessions()
|
sessionManager.removeAllSessions()
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
@@ -777,24 +869,36 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
private func handleSessionEstablished(
|
||||||
|
peerID: PeerID,
|
||||||
|
remoteStaticKey: Curve25519.KeyAgreement.PublicKey,
|
||||||
|
sessionGeneration: UUID
|
||||||
|
) {
|
||||||
// Calculate fingerprint
|
// Calculate fingerprint
|
||||||
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
||||||
|
|
||||||
// Store fingerprint mapping
|
// Registering handlers is synchronous, and this barrier snapshots them
|
||||||
serviceQueue.sync(flags: .barrier) {
|
// with the fingerprint update. Invoke the snapshot outside the queue:
|
||||||
|
// parallel Swift Testing workers must not block behind queued callback
|
||||||
|
// registration or allow a handler to re-enter serviceQueue.
|
||||||
|
let handlers: (
|
||||||
|
generationAware: [(PeerID, String, UUID) -> Void],
|
||||||
|
legacy: [(PeerID, String) -> Void]
|
||||||
|
) = serviceQueue.sync(flags: .barrier) {
|
||||||
peerFingerprints[peerID] = fingerprint
|
peerFingerprints[peerID] = fingerprint
|
||||||
fingerprintToPeerID[fingerprint] = peerID
|
fingerprintToPeerID[fingerprint] = peerID
|
||||||
|
return (onPeerAuthenticatedWithGenerationHandlers, onPeerAuthenticatedHandlers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log security event
|
// Log security event
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
|
|
||||||
// Notify all handlers about authentication
|
// Notify all handlers about authentication.
|
||||||
serviceQueue.async { [weak self] in
|
handlers.generationAware.forEach { handler in
|
||||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
handler(peerID, fingerprint, sessionGeneration)
|
||||||
handler(peerID, fingerprint)
|
|
||||||
}
|
}
|
||||||
|
handlers.legacy.forEach { handler in
|
||||||
|
handler(peerID, fingerprint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,20 +919,27 @@ final class NoiseEncryptionService {
|
|||||||
let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey()
|
let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey()
|
||||||
|
|
||||||
for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey {
|
for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey {
|
||||||
|
|
||||||
// Attempt to rekey the session
|
|
||||||
do {
|
do {
|
||||||
try sessionManager.initiateRekey(for: peerID)
|
try initiateAutomaticRekey(for: peerID)
|
||||||
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
|
|
||||||
|
|
||||||
// Signal that handshake is needed
|
|
||||||
onHandshakeRequired?(peerID)
|
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session)
|
SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func initiateAutomaticRekey(for peerID: PeerID) throws {
|
||||||
|
let handshakeMessage = try sessionManager.initiateRekey(for: peerID)
|
||||||
|
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
|
||||||
|
onRekeyHandshakeReady?(peerID, handshakeMessage)
|
||||||
|
onHandshakeRequired?(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
func _test_initiateAutomaticRekey(for peerID: PeerID) throws {
|
||||||
|
try initiateAutomaticRekey(for: peerID)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
stopRekeyTimer()
|
stopRekeyTimer()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ final class TransferProgressManager {
|
|||||||
case updated(id: String, sentFragments: Int, totalFragments: Int)
|
case updated(id: String, sentFragments: Int, totalFragments: Int)
|
||||||
case completed(id: String, totalFragments: Int)
|
case completed(id: String, totalFragments: Int)
|
||||||
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
|
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
|
||||||
|
case rejected(id: String, reason: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
private let subject = PassthroughSubject<Event, Never>()
|
private let subject = PassthroughSubject<Event, Never>()
|
||||||
@@ -49,6 +50,17 @@ final class TransferProgressManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fails a preflight check while keeping the outgoing placeholder visible
|
||||||
|
/// with an actionable reason instead of treating policy/size rejection as
|
||||||
|
/// a user cancellation.
|
||||||
|
func rejectBeforeStart(id: String, reason: String) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.states.removeValue(forKey: id)
|
||||||
|
self.subject.send(.rejected(id: id, reason: reason))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func snapshot(id: String) -> (sent: Int, total: Int)? {
|
func snapshot(id: String) -> (sent: Int, total: Int)? {
|
||||||
var result: (sent: Int, total: Int)?
|
var result: (sent: Int, total: Int)?
|
||||||
queue.sync {
|
queue.sync {
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ enum TransportEvent: @unchecked Sendable {
|
|||||||
case bluetoothStateUpdated(CBManagerState)
|
case bluetoothStateUpdated(CBManagerState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Downgrade-safe decision for a private-media recipient. Callers ask before
|
||||||
|
/// prompting, and BLEService checks again when it consumes any one-shot
|
||||||
|
/// legacy consent.
|
||||||
|
enum PrivateMediaSendPolicy: Equatable {
|
||||||
|
case encrypted
|
||||||
|
/// A public announce hinted at encrypted media (or a prior authenticated
|
||||||
|
/// pin exists), but this exact Noise session has not yet supplied its
|
||||||
|
/// authenticated peer-state proof. Callers wait boundedly; they must not
|
||||||
|
/// pre-queue encrypted bytes or silently select the legacy path.
|
||||||
|
case awaitingCapabilityProof
|
||||||
|
case legacyRequiresConsent
|
||||||
|
case blockedDowngrade
|
||||||
|
}
|
||||||
|
|
||||||
protocol TransportEventDelegate: AnyObject {
|
protocol TransportEventDelegate: AnyObject {
|
||||||
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
||||||
}
|
}
|
||||||
@@ -163,6 +177,12 @@ protocol Transport: AnyObject {
|
|||||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
||||||
|
func sendFilePrivate(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
allowLegacyFallback: Bool
|
||||||
|
)
|
||||||
func cancelTransfer(_ transferId: String)
|
func cancelTransfer(_ transferId: String)
|
||||||
|
|
||||||
// Live voice / push-to-talk (mesh transports only): one encoded
|
// Live voice / push-to-talk (mesh transports only): one encoded
|
||||||
@@ -208,6 +228,11 @@ protocol Transport: AnyObject {
|
|||||||
/// Capabilities the peer advertised in its last verified announce;
|
/// Capabilities the peer advertised in its last verified announce;
|
||||||
/// empty for peers that predate the capabilities TLV.
|
/// empty for peers that predate the capabilities TLV.
|
||||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
|
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
|
||||||
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
||||||
|
func resolvePrivateMediaSendPolicy(
|
||||||
|
to peerID: PeerID,
|
||||||
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
|
)
|
||||||
/// Sends an encoded vouch-attestation batch inside the Noise session.
|
/// Sends an encoded vouch-attestation batch inside the Noise session.
|
||||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
|
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
|
||||||
/// Appends a peer-authenticated observer. Unlike
|
/// Appends a peer-authenticated observer. Unlike
|
||||||
@@ -278,6 +303,16 @@ extension Transport {
|
|||||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
|
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
|
||||||
func broadcastGroupMessage(_ envelope: Data) {}
|
func broadcastGroupMessage(_ envelope: Data) {}
|
||||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
||||||
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
|
||||||
|
func resolvePrivateMediaSendPolicy(
|
||||||
|
to peerID: PeerID,
|
||||||
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
|
) {
|
||||||
|
let policy = privateMediaSendPolicy(to: peerID)
|
||||||
|
Task { @MainActor in
|
||||||
|
completion(policy == .awaitingCapabilityProof ? .blockedDowngrade : policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
|
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
|
||||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
||||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
||||||
@@ -294,6 +329,15 @@ extension Transport {
|
|||||||
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
|
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||||
|
func sendFilePrivate(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
allowLegacyFallback: Bool
|
||||||
|
) {
|
||||||
|
guard !allowLegacyFallback else { return }
|
||||||
|
sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||||
|
}
|
||||||
func cancelTransfer(_ transferId: String) {}
|
func cancelTransfer(_ transferId: String) {}
|
||||||
|
|
||||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ enum TransportConfig {
|
|||||||
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
||||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
||||||
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
||||||
|
// Bounded wait for the session-authenticated capability proof used by
|
||||||
|
// private-media migration. Expiry never auto-sends clear bytes; it only
|
||||||
|
// resolves to the existing one-shot consent or downgrade-blocked path.
|
||||||
|
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
|
||||||
|
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
|
||||||
|
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
|
||||||
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
||||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
||||||
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
||||||
|
|||||||
@@ -6,6 +6,19 @@ import Foundation
|
|||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
struct LegacyPrivateMediaConsentRequest: Identifiable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
let peerID: PeerID
|
||||||
|
let peerName: String
|
||||||
|
let transferId: String
|
||||||
|
let messageID: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingLegacyPrivateMediaConsent {
|
||||||
|
let request: LegacyPrivateMediaConsentRequest
|
||||||
|
let completion: @MainActor (Bool) -> Void
|
||||||
|
}
|
||||||
|
|
||||||
/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
|
/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
|
||||||
///
|
///
|
||||||
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
||||||
@@ -43,7 +56,24 @@ protocol ChatMediaTransferContext: AnyObject {
|
|||||||
func recordContentKey(_ key: String, timestamp: Date)
|
func recordContentKey(_ key: String, timestamp: Date)
|
||||||
|
|
||||||
// MARK: Mesh file transfer
|
// MARK: Mesh file transfer
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
||||||
|
func resolvePrivateMediaSendPolicy(
|
||||||
|
to peerID: PeerID,
|
||||||
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
|
)
|
||||||
|
func requestLegacyPrivateMediaConsent(
|
||||||
|
for peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
messageID: String,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
)
|
||||||
|
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String)
|
||||||
|
func sendFilePrivate(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
allowLegacyFallback: Bool
|
||||||
|
)
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
||||||
func cancelTransfer(_ transferId: String)
|
func cancelTransfer(_ transferId: String)
|
||||||
}
|
}
|
||||||
@@ -59,8 +89,50 @@ extension ChatViewModel: ChatMediaTransferContext {
|
|||||||
// other contexts or satisfied by existing `ChatViewModel` members. The
|
// other contexts or satisfied by existing `ChatViewModel` members. The
|
||||||
// members below flatten mesh service accesses.
|
// members below flatten mesh service accesses.
|
||||||
|
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||||
meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
meshService.privateMediaSendPolicy(to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePrivateMediaSendPolicy(
|
||||||
|
to peerID: PeerID,
|
||||||
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
|
) {
|
||||||
|
meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestLegacyPrivateMediaConsent(
|
||||||
|
for peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
messageID: String,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
) {
|
||||||
|
enqueueLegacyPrivateMediaConsent(
|
||||||
|
for: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID,
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
||||||
|
invalidateLegacyPrivateMediaConsent(
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFilePrivate(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
allowLegacyFallback: Bool
|
||||||
|
) {
|
||||||
|
meshService.sendFilePrivate(
|
||||||
|
packet,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
allowLegacyFallback: allowLegacyFallback
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||||
@@ -75,12 +147,19 @@ extension ChatViewModel: ChatMediaTransferContext {
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class ChatMediaTransferCoordinator {
|
final class ChatMediaTransferCoordinator {
|
||||||
private unowned let context: any ChatMediaTransferContext
|
private unowned let context: any ChatMediaTransferContext
|
||||||
|
private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket
|
||||||
|
|
||||||
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
||||||
private(set) var messageIDToTransferId: [String: String] = [:]
|
private(set) var messageIDToTransferId: [String: String] = [:]
|
||||||
|
|
||||||
init(context: any ChatMediaTransferContext) {
|
init(
|
||||||
|
context: any ChatMediaTransferContext,
|
||||||
|
prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = {
|
||||||
|
try ChatMediaPreparation.prepareVoiceNotePacket(at: $0)
|
||||||
|
}
|
||||||
|
) {
|
||||||
self.context = context
|
self.context = context
|
||||||
|
self.prepareVoiceNotePacket = prepareVoiceNotePacket
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendVoiceNote(at url: URL) {
|
func sendVoiceNote(at url: URL) {
|
||||||
@@ -98,16 +177,28 @@ final class ChatMediaTransferCoordinator {
|
|||||||
)
|
)
|
||||||
let messageID = message.id
|
let messageID = message.id
|
||||||
let transferId = makeTransferID(messageID: messageID)
|
let transferId = makeTransferID(messageID: messageID)
|
||||||
|
// Own the transfer before detached preparation begins. Cancel/delete
|
||||||
|
// must be able to invalidate this exact invocation even while file I/O
|
||||||
|
// is still running off the main actor.
|
||||||
|
registerTransfer(transferId: transferId, messageID: messageID)
|
||||||
|
let prepareVoiceNotePacket = self.prepareVoiceNotePacket
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
do {
|
do {
|
||||||
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
let packet = try prepareVoiceNotePacket(url)
|
||||||
|
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self,
|
||||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
if let peerID = targetPeer {
|
if let peerID = targetPeer {
|
||||||
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
self.beginPrivateMediaSend(
|
||||||
|
packet,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
self.context.sendFileBroadcast(packet, transferId: transferId)
|
self.context.sendFileBroadcast(packet, transferId: transferId)
|
||||||
}
|
}
|
||||||
@@ -116,13 +207,19 @@ final class ChatMediaTransferCoordinator {
|
|||||||
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
||||||
try? FileManager.default.removeItem(at: url)
|
try? FileManager.default.removeItem(at: url)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self,
|
||||||
|
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self,
|
||||||
|
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,7 +290,12 @@ final class ChatMediaTransferCoordinator {
|
|||||||
let transferId = self.makeTransferID(messageID: messageID)
|
let transferId = self.makeTransferID(messageID: messageID)
|
||||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||||
if let peerID = targetPeer {
|
if let peerID = targetPeer {
|
||||||
self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId)
|
self.beginPrivateMediaSend(
|
||||||
|
prepared.packet,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
|
self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
|
||||||
}
|
}
|
||||||
@@ -253,17 +355,127 @@ final class ChatMediaTransferCoordinator {
|
|||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func beginPrivateMediaSend(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
messageID: String
|
||||||
|
) {
|
||||||
|
continuePrivateMediaSend(
|
||||||
|
packet,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID,
|
||||||
|
policy: context.privateMediaSendPolicy(to: peerID)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func continuePrivateMediaSend(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
messageID: String,
|
||||||
|
policy: PrivateMediaSendPolicy
|
||||||
|
) {
|
||||||
|
switch policy {
|
||||||
|
case .encrypted:
|
||||||
|
context.sendFilePrivate(
|
||||||
|
packet,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
allowLegacyFallback: false
|
||||||
|
)
|
||||||
|
|
||||||
|
case .awaitingCapabilityProof:
|
||||||
|
context.resolvePrivateMediaSendPolicy(to: peerID) { [weak self] resolvedPolicy in
|
||||||
|
guard let self,
|
||||||
|
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard resolvedPolicy != .awaitingCapabilityProof else {
|
||||||
|
self.handleMediaSendFailure(
|
||||||
|
messageID: messageID,
|
||||||
|
reason: String(
|
||||||
|
localized: "content.delivery.reason.private_media_capability_unresolved",
|
||||||
|
defaultValue: "Could not confirm encrypted media support",
|
||||||
|
comment: "Failure reason when private-media capability negotiation did not resolve"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.continuePrivateMediaSend(
|
||||||
|
packet,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID,
|
||||||
|
policy: resolvedPolicy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .legacyRequiresConsent:
|
||||||
|
context.requestLegacyPrivateMediaConsent(
|
||||||
|
for: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID
|
||||||
|
) { [weak self] approved in
|
||||||
|
guard let self else { return }
|
||||||
|
// Consent belongs to this exact placeholder/transfer. A late
|
||||||
|
// dialog callback after cancel/delete must never resurrect it.
|
||||||
|
guard self.messageIDToTransferId[messageID] == transferId,
|
||||||
|
self.transferIdToMessageIDs[transferId]?.contains(messageID) == true else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard approved else {
|
||||||
|
self.handleMediaSendFailure(
|
||||||
|
messageID: messageID,
|
||||||
|
reason: String(
|
||||||
|
localized: "content.delivery.reason.legacy_media_declined",
|
||||||
|
defaultValue: "Not sent without end-to-end encryption",
|
||||||
|
comment: "Failure reason after declining the warning for a legacy clear private-media send"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.context.sendFilePrivate(
|
||||||
|
packet,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
allowLegacyFallback: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .blockedDowngrade:
|
||||||
|
handleMediaSendFailure(
|
||||||
|
messageID: messageID,
|
||||||
|
reason: String(
|
||||||
|
localized: "content.delivery.reason.private_media_downgrade_blocked",
|
||||||
|
defaultValue: "Encrypted media required; ask this contact to upgrade",
|
||||||
|
comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func registerTransfer(transferId: String, messageID: String) {
|
func registerTransfer(transferId: String, messageID: String) {
|
||||||
transferIdToMessageIDs[transferId, default: []].append(messageID)
|
transferIdToMessageIDs[transferId, default: []].append(messageID)
|
||||||
messageIDToTransferId[messageID] = transferId
|
messageIDToTransferId[messageID] = transferId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isRegisteredTransfer(_ transferId: String, messageID: String) -> Bool {
|
||||||
|
messageIDToTransferId[messageID] == transferId
|
||||||
|
&& transferIdToMessageIDs[transferId]?.contains(messageID) == true
|
||||||
|
}
|
||||||
|
|
||||||
func makeTransferID(messageID: String) -> String {
|
func makeTransferID(messageID: String) -> String {
|
||||||
"\(messageID)-\(UUID().uuidString)"
|
"\(messageID)-\(UUID().uuidString)"
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearTransferMapping(for messageID: String) {
|
func clearTransferMapping(for messageID: String) {
|
||||||
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
|
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
|
||||||
|
context.cancelLegacyPrivateMediaConsent(
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
guard var queue = transferIdToMessageIDs[transferId] else { return }
|
guard var queue = transferIdToMessageIDs[transferId] else { return }
|
||||||
|
|
||||||
if !queue.isEmpty {
|
if !queue.isEmpty {
|
||||||
@@ -298,6 +510,9 @@ final class ChatMediaTransferCoordinator {
|
|||||||
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||||
clearTransferMapping(for: messageID)
|
clearTransferMapping(for: messageID)
|
||||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||||
|
case .rejected(let id, let reason):
|
||||||
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||||
|
handleMediaSendFailure(messageID: messageID, reason: reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +553,13 @@ final class ChatMediaTransferCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteMediaMessage(messageID: String) {
|
func deleteMediaMessage(messageID: String) {
|
||||||
|
// Delete is also a send cancellation. In particular, an approved
|
||||||
|
// legacy-clear send may still be waiting on BLEService.messageQueue;
|
||||||
|
// removing only the UI mapping would let that deferred work transmit.
|
||||||
|
if let transferId = messageIDToTransferId[messageID],
|
||||||
|
transferIdToMessageIDs[transferId]?.first == messageID {
|
||||||
|
context.cancelTransfer(transferId)
|
||||||
|
}
|
||||||
clearTransferMapping(for: messageID)
|
clearTransferMapping(for: messageID)
|
||||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -407,6 +407,13 @@ private extension ChatTransportEventCoordinator {
|
|||||||
|
|
||||||
case .voiceFrame:
|
case .voiceFrame:
|
||||||
context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp)
|
context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp)
|
||||||
|
|
||||||
|
case .privateFile, .authenticatedPeerState:
|
||||||
|
// BLEService validates and persists decrypted private files before
|
||||||
|
// emitting a normal `.messageReceived` event, and consumes peer
|
||||||
|
// state inside the transport. Neither payload crosses this
|
||||||
|
// UI-facing typed-payload fallback.
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -347,6 +347,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
@Published var showBluetoothAlert = false
|
@Published var showBluetoothAlert = false
|
||||||
@Published var bluetoothAlertMessage = ""
|
@Published var bluetoothAlertMessage = ""
|
||||||
@Published var bluetoothState: CBManagerState = .unknown
|
@Published var bluetoothState: CBManagerState = .unknown
|
||||||
|
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
|
||||||
|
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
|
||||||
|
|
||||||
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
||||||
if Thread.isMainThread {
|
if Thread.isMainThread {
|
||||||
@@ -1156,6 +1158,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
func panicClearAllData() {
|
func panicClearAllData() {
|
||||||
// Messages are processed immediately - nothing to flush
|
// Messages are processed immediately - nothing to flush
|
||||||
|
|
||||||
|
// Deny and release any clear-media confirmations before identities,
|
||||||
|
// message state, and local files are wiped.
|
||||||
|
cancelAllLegacyPrivateMediaConsents()
|
||||||
|
|
||||||
// Clear all messages (public timelines and private chats live in the
|
// Clear all messages (public timelines and private chats live in the
|
||||||
// single-writer ConversationStore; the derived `messages` view and
|
// single-writer ConversationStore; the derived `messages` view and
|
||||||
// the legacy mirror empty with it)
|
// the legacy mirror empty with it)
|
||||||
@@ -1804,4 +1810,91 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
publicConversationCoordinator.sendHapticFeedback(for: message)
|
publicConversationCoordinator.sendHapticFeedback(for: message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
extension ChatViewModel {
|
||||||
|
func enqueueLegacyPrivateMediaConsent(
|
||||||
|
for peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
messageID: String,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
) {
|
||||||
|
let request = LegacyPrivateMediaConsentRequest(
|
||||||
|
id: UUID(),
|
||||||
|
peerID: peerID,
|
||||||
|
peerName: nicknameForPeer(peerID),
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
pendingLegacyPrivateMediaConsents.append(PendingLegacyPrivateMediaConsent(
|
||||||
|
request: request,
|
||||||
|
completion: completion
|
||||||
|
))
|
||||||
|
if legacyPrivateMediaConsentRequest == nil {
|
||||||
|
legacyPrivateMediaConsentRequest = request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) {
|
||||||
|
// SwiftUI may report both the selected button and the presentation
|
||||||
|
// binding's dismissal. Resolve only the exact request that was shown;
|
||||||
|
// a duplicate callback for it must not consume the next queued send.
|
||||||
|
guard legacyPrivateMediaConsentRequest?.id == requestID,
|
||||||
|
pendingLegacyPrivateMediaConsents.first?.request.id == requestID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let resolved = pendingLegacyPrivateMediaConsents.removeFirst()
|
||||||
|
// Drive the boolean presentation state through false before showing
|
||||||
|
// the next queued per-send warning. Otherwise SwiftUI sees true→true,
|
||||||
|
// closes the first dialog, and never presents the second.
|
||||||
|
legacyPrivateMediaConsentRequest = nil
|
||||||
|
resolved.completion(approved)
|
||||||
|
presentNextLegacyPrivateMediaConsentDeferred()
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
||||||
|
let invalidatedIDs = Set(
|
||||||
|
pendingLegacyPrivateMediaConsents.compactMap { pending -> UUID? in
|
||||||
|
let request = pending.request
|
||||||
|
return request.transferId == transferId && request.messageID == messageID
|
||||||
|
? request.id
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
guard !invalidatedIDs.isEmpty else { return }
|
||||||
|
|
||||||
|
pendingLegacyPrivateMediaConsents.removeAll {
|
||||||
|
invalidatedIDs.contains($0.request.id)
|
||||||
|
}
|
||||||
|
if let currentID = legacyPrivateMediaConsentRequest?.id,
|
||||||
|
invalidatedIDs.contains(currentID) {
|
||||||
|
legacyPrivateMediaConsentRequest = nil
|
||||||
|
presentNextLegacyPrivateMediaConsentDeferred()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelAllLegacyPrivateMediaConsents() {
|
||||||
|
let pending = pendingLegacyPrivateMediaConsents
|
||||||
|
pendingLegacyPrivateMediaConsents.removeAll()
|
||||||
|
legacyPrivateMediaConsentRequest = nil
|
||||||
|
for item in pending {
|
||||||
|
item.completion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentNextLegacyPrivateMediaConsentDeferred() {
|
||||||
|
guard legacyPrivateMediaConsentRequest == nil,
|
||||||
|
let nextRequestID = pendingLegacyPrivateMediaConsents.first?.request.id else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.legacyPrivateMediaConsentRequest == nil,
|
||||||
|
self.pendingLegacyPrivateMediaConsents.first?.request.id == nextRequestID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.legacyPrivateMediaConsentRequest = self.pendingLegacyPrivateMediaConsents[0].request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// End of ChatViewModel class
|
// End of ChatViewModel class
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ final class NostrInboundPipeline {
|
|||||||
// claiming to be group traffic over Nostr is ignored.
|
// claiming to be group traffic over Nostr is ignored.
|
||||||
// Live voice is mesh-only: latency and relay cost make it
|
// Live voice is mesh-only: latency and relay cost make it
|
||||||
// meaningless over Nostr.
|
// meaningless over Nostr.
|
||||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
|
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,7 +363,7 @@ final class NostrInboundPipeline {
|
|||||||
// claiming to be group traffic over Nostr is ignored.
|
// claiming to be group traffic over Nostr is ignored.
|
||||||
// Live voice is mesh-only: latency and relay cost make it
|
// Live voice is mesh-only: latency and relay cost make it
|
||||||
// meaningless over Nostr.
|
// meaningless over Nostr.
|
||||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
|
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -446,7 +446,7 @@ final class NostrInboundPipeline {
|
|||||||
// in v1; group traffic over Nostr is ignored.
|
// in v1; group traffic over Nostr is ignored.
|
||||||
// Live voice is mesh-only: latency and relay cost make it
|
// Live voice is mesh-only: latency and relay cost make it
|
||||||
// meaningless over Nostr.
|
// meaningless over Nostr.
|
||||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
|
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ struct ContentPeopleSheetView: View {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if privateConversationModel.selectedPeerID != nil {
|
if privateConversationModel.selectedPeerID != nil {
|
||||||
@@ -97,6 +98,63 @@ struct ContentPeopleSheetView: View {
|
|||||||
}
|
}
|
||||||
.themedSheetBackground()
|
.themedSheetBackground()
|
||||||
.foregroundColor(palette.primary)
|
.foregroundColor(palette.primary)
|
||||||
|
.confirmationDialog(
|
||||||
|
String(
|
||||||
|
localized: "content.private_media.legacy_warning.title",
|
||||||
|
defaultValue: "Send without end-to-end encryption?",
|
||||||
|
comment: "Title warning before sending private media to an older client in a clear signed envelope"
|
||||||
|
),
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { legacyConsentRequest != nil },
|
||||||
|
set: { isPresented in
|
||||||
|
if !isPresented, let requestID = legacyConsentRequest?.id {
|
||||||
|
conversationUIModel.resolveLegacyPrivateMediaConsent(
|
||||||
|
requestID: requestID,
|
||||||
|
approved: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
String(
|
||||||
|
localized: "content.private_media.legacy_warning.send",
|
||||||
|
defaultValue: "send visible file",
|
||||||
|
comment: "Destructive confirmation action for one legacy clear private-media send"
|
||||||
|
),
|
||||||
|
role: .destructive
|
||||||
|
) {
|
||||||
|
if let requestID = legacyConsentRequest?.id {
|
||||||
|
conversationUIModel.resolveLegacyPrivateMediaConsent(
|
||||||
|
requestID: requestID,
|
||||||
|
approved: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("common.cancel", role: .cancel) {
|
||||||
|
if let requestID = legacyConsentRequest?.id {
|
||||||
|
conversationUIModel.resolveLegacyPrivateMediaConsent(
|
||||||
|
requestID: requestID,
|
||||||
|
approved: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
if let request = legacyConsentRequest {
|
||||||
|
Text(
|
||||||
|
String(
|
||||||
|
format: String(
|
||||||
|
localized: "content.private_media.legacy_warning.message",
|
||||||
|
defaultValue: "%@'s client does not advertise encrypted private media. This file will be signed but not end-to-end encrypted, so mesh relays can see it. Send this file anyway?",
|
||||||
|
comment: "Warning explaining the confidentiality loss for one legacy private-media send; parameter is the peer name"
|
||||||
|
),
|
||||||
|
locale: .current,
|
||||||
|
request.peerName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 420, minHeight: 520)
|
.frame(minWidth: 420, minHeight: 520)
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -99,6 +99,95 @@ struct BLEServiceCoreTests {
|
|||||||
#expect(ble.currentPeerSnapshots().isEmpty)
|
#expect(ble.currentPeerSnapshots().isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func unsignedAndBadSignatureLeaveDoNotEvictOrRelayClaimedPeer() async throws {
|
||||||
|
let ble = makeService()
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let outbound = OutboundPacketTap()
|
||||||
|
ble._test_onOutboundPacket = outbound.record
|
||||||
|
|
||||||
|
let unsigned = makeLeavePacket(sender: alicePeerID, marker: "unsigned")
|
||||||
|
ble._test_handlePacket(
|
||||||
|
unsigned,
|
||||||
|
fromPeerID: alicePeerID,
|
||||||
|
signingPublicKey: alice.getSigningPublicKeyData()
|
||||||
|
)
|
||||||
|
|
||||||
|
let unsignedRelayed = await TestHelpers.waitUntil(
|
||||||
|
{ outbound.count(ofType: .leave) > 0 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!unsignedRelayed)
|
||||||
|
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||||
|
|
||||||
|
let badSignature = try #require(
|
||||||
|
mallory.signPacket(makeLeavePacket(sender: alicePeerID, marker: "bad-signature"))
|
||||||
|
)
|
||||||
|
ble._test_handlePacket(
|
||||||
|
badSignature,
|
||||||
|
fromPeerID: alicePeerID,
|
||||||
|
signingPublicKey: alice.getSigningPublicKeyData()
|
||||||
|
)
|
||||||
|
|
||||||
|
let badSignatureRelayed = await TestHelpers.waitUntil(
|
||||||
|
{ outbound.count(ofType: .leave) > 0 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!badSignatureRelayed)
|
||||||
|
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func validSignedLeaveEvictsSessionAndRelays() async throws {
|
||||||
|
let ble = makeService()
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
// Establish a real session so the leave regression also verifies that
|
||||||
|
// stale secure-delivery state is retired, not just the peer-list row.
|
||||||
|
let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try alice.processHandshakeMessage(from: ble.myPeerID, message: message1)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2)
|
||||||
|
)
|
||||||
|
_ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3)
|
||||||
|
#expect(ble.canDeliverSecurely(to: alicePeerID))
|
||||||
|
let centralUUID = "central-valid-leave"
|
||||||
|
ble._test_bindCentral(centralUUID, to: alicePeerID)
|
||||||
|
ble._test_markNoiseAuthenticatedCentral(centralUUID, to: alicePeerID)
|
||||||
|
#expect(ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID))
|
||||||
|
|
||||||
|
let outbound = OutboundPacketTap()
|
||||||
|
ble._test_onOutboundPacket = outbound.record
|
||||||
|
let signedLeave = try #require(
|
||||||
|
alice.signPacket(makeLeavePacket(sender: alicePeerID, marker: "valid"))
|
||||||
|
)
|
||||||
|
ble._test_handlePacket(
|
||||||
|
signedLeave,
|
||||||
|
fromPeerID: alicePeerID,
|
||||||
|
signingPublicKey: alice.getSigningPublicKeyData()
|
||||||
|
)
|
||||||
|
|
||||||
|
let evicted = await TestHelpers.waitUntil(
|
||||||
|
{
|
||||||
|
!ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }
|
||||||
|
&& !ble.canDeliverSecurely(to: alicePeerID)
|
||||||
|
&& !ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID)
|
||||||
|
},
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(evicted)
|
||||||
|
let relayed = await TestHelpers.waitUntil(
|
||||||
|
{ outbound.count(ofType: .leave) == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(relayed)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func ingressAllowsRelayedSenderOnBoundLink() async throws {
|
func ingressAllowsRelayedSenderOnBoundLink() async throws {
|
||||||
let ble = makeService()
|
let ble = makeService()
|
||||||
@@ -690,6 +779,18 @@ private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeLeavePacket(sender: PeerID, marker: String) -> BitchatPacket {
|
||||||
|
BitchatPacket(
|
||||||
|
type: MessageType.leave.rawValue,
|
||||||
|
senderID: Data(hexString: sender.id) ?? Data(),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: Data(marker.utf8),
|
||||||
|
signature: nil,
|
||||||
|
ttl: TransportConfig.messageTTLDefault
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private final class PublicCaptureDelegate: BitchatDelegate {
|
private final class PublicCaptureDelegate: BitchatDelegate {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private(set) var publicMessages: [BitchatMessage] = []
|
private(set) var publicMessages: [BitchatMessage] = []
|
||||||
|
|||||||
@@ -7,10 +7,9 @@
|
|||||||
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
|
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
|
||||||
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
||||||
//
|
//
|
||||||
// Scope note: the async media-preparation pipelines (`ImageUtils`,
|
// Real file/codec work remains covered by `ChatMediaPreparationTests`. These
|
||||||
// `ChatMediaPreparation`) run real file/codec work and remain covered by
|
// tests inject a paused voice-note preparer to exercise cancellation ownership
|
||||||
// `ChatMediaPreparationTests`; here we cover message enqueueing, transfer
|
// across the detached-preparation/MainActor boundary deterministically.
|
||||||
// bookkeeping, and the blocked-context guards.
|
|
||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
@@ -86,11 +85,72 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|||||||
|
|
||||||
// Mesh file transfer
|
// Mesh file transfer
|
||||||
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
|
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
|
||||||
|
private(set) var privateFileLegacyAllowances: [Bool] = []
|
||||||
private(set) var broadcastFileSends: [String] = []
|
private(set) var broadcastFileSends: [String] = []
|
||||||
private(set) var cancelledTransfers: [String] = []
|
private(set) var cancelledTransfers: [String] = []
|
||||||
|
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
|
||||||
|
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
|
||||||
|
private(set) var legacyConsentRequests: [(
|
||||||
|
id: UUID,
|
||||||
|
peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
messageID: String
|
||||||
|
)] = []
|
||||||
|
private(set) var invalidatedLegacyConsents: [(transferId: String, messageID: String)] = []
|
||||||
|
private var pendingLegacyConsentIDs: [UUID] = []
|
||||||
|
private var legacyConsentCompletions: [UUID: @MainActor (Bool) -> Void] = [:]
|
||||||
|
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||||
|
privateMediaPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePrivateMediaSendPolicy(
|
||||||
|
to peerID: PeerID,
|
||||||
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
|
) {
|
||||||
|
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestLegacyPrivateMediaConsent(
|
||||||
|
for peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
messageID: String,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
) {
|
||||||
|
let id = UUID()
|
||||||
|
legacyConsentRequests.append((id, peerID, transferId, messageID))
|
||||||
|
pendingLegacyConsentIDs.append(id)
|
||||||
|
legacyConsentCompletions[id] = completion
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
||||||
|
invalidatedLegacyConsents.append((transferId, messageID))
|
||||||
|
let matchingIDs = Set(legacyConsentRequests.compactMap { request in
|
||||||
|
request.transferId == transferId && request.messageID == messageID
|
||||||
|
? request.id
|
||||||
|
: nil
|
||||||
|
})
|
||||||
|
pendingLegacyConsentIDs.removeAll { matchingIDs.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveNextLegacyConsent(_ approved: Bool) {
|
||||||
|
guard !pendingLegacyConsentIDs.isEmpty else { return }
|
||||||
|
let id = pendingLegacyConsentIDs.removeFirst()
|
||||||
|
legacyConsentCompletions[id]?(approved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func invokeLegacyConsentEvenIfInvalidated(id: UUID, approved: Bool) {
|
||||||
|
legacyConsentCompletions[id]?(approved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFilePrivate(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
allowLegacyFallback: Bool
|
||||||
|
) {
|
||||||
privateFileSends.append((peerID, transferId))
|
privateFileSends.append((peerID, transferId))
|
||||||
|
privateFileLegacyAllowances.append(allowLegacyFallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||||
@@ -102,6 +162,56 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
||||||
|
private let condition = NSCondition()
|
||||||
|
private var started = false
|
||||||
|
private var released = false
|
||||||
|
private var finished = false
|
||||||
|
private let packet: BitchatFilePacket
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let content = Data("voice".utf8)
|
||||||
|
packet = BitchatFilePacket(
|
||||||
|
fileName: "paused.m4a",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "audio/mp4",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepare(_: URL) throws -> BitchatFilePacket {
|
||||||
|
condition.lock()
|
||||||
|
started = true
|
||||||
|
condition.broadcast()
|
||||||
|
while !released {
|
||||||
|
condition.wait()
|
||||||
|
}
|
||||||
|
finished = true
|
||||||
|
condition.broadcast()
|
||||||
|
condition.unlock()
|
||||||
|
return packet
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasStarted: Bool {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return started
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasFinished: Bool {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return finished
|
||||||
|
}
|
||||||
|
|
||||||
|
func release() {
|
||||||
|
condition.lock()
|
||||||
|
released = true
|
||||||
|
condition.broadcast()
|
||||||
|
condition.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Coordinator Tests Against Mock Context
|
// MARK: - Coordinator Tests Against Mock Context
|
||||||
|
|
||||||
/// Exercises `ChatMediaTransferCoordinator` against
|
/// Exercises `ChatMediaTransferCoordinator` against
|
||||||
@@ -166,6 +276,14 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(context.removedMessages.count == 1)
|
#expect(context.removedMessages.count == 1)
|
||||||
#expect(context.removedMessages.first?.messageID == "m2")
|
#expect(context.removedMessages.first?.messageID == "m2")
|
||||||
#expect(context.removedMessages.first?.cleanupFile == true)
|
#expect(context.removedMessages.first?.cleanupFile == true)
|
||||||
|
|
||||||
|
// A pre-start rejection keeps the placeholder visible and failed,
|
||||||
|
// including queued post-handshake encryption failures.
|
||||||
|
coordinator.registerTransfer(transferId: "t3", messageID: "m3")
|
||||||
|
coordinator.handleTransferEvent(.rejected(id: "t3", reason: "encryption failed"))
|
||||||
|
#expect(context.deliveryStatusUpdates.last?.messageID == "m3")
|
||||||
|
#expect(context.deliveryStatusUpdates.last?.status == .failed(reason: "encryption failed"))
|
||||||
|
#expect(coordinator.messageIDToTransferId["m3"] == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -188,6 +306,20 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deleteMediaMessage_cancelsApprovedTransferBeforeRemovingMapping() {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
coordinator.registerTransfer(transferId: "approved-delete", messageID: "message-delete")
|
||||||
|
|
||||||
|
coordinator.deleteMediaMessage(messageID: "message-delete")
|
||||||
|
|
||||||
|
#expect(context.cancelledTransfers == ["approved-delete"])
|
||||||
|
#expect(coordinator.messageIDToTransferId["message-delete"] == nil)
|
||||||
|
#expect(context.removedMessages.map(\.messageID) == ["message-delete"])
|
||||||
|
#expect(context.removedMessages.first?.cleanupFile == true)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
||||||
let context = MockChatMediaTransferContext()
|
let context = MockChatMediaTransferContext()
|
||||||
@@ -206,4 +338,249 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(context.appendedPublicMessages.isEmpty)
|
#expect(context.appendedPublicMessages.isEmpty)
|
||||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let peerID = PeerID(str: "5566778899aabbcc")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
let preparer = PausedVoiceNotePreparer()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(
|
||||||
|
context: context,
|
||||||
|
prepareVoiceNotePacket: { url in try preparer.prepare(url) }
|
||||||
|
)
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("paused-private-\(UUID().uuidString).m4a")
|
||||||
|
try Data("voice".utf8).write(to: url)
|
||||||
|
defer {
|
||||||
|
preparer.release()
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: url)
|
||||||
|
#expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout))
|
||||||
|
let messageID = try #require(context.privateChats[peerID]?.first?.id)
|
||||||
|
let transferId = try #require(coordinator.messageIDToTransferId[messageID])
|
||||||
|
|
||||||
|
coordinator.cancelMediaSend(messageID: messageID)
|
||||||
|
preparer.release()
|
||||||
|
#expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout))
|
||||||
|
for _ in 0..<10 { await Task.yield() }
|
||||||
|
|
||||||
|
#expect(context.cancelledTransfers == [transferId])
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
#expect(context.broadcastFileSends.isEmpty)
|
||||||
|
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||||
|
#expect(coordinator.transferIdToMessageIDs[transferId] == nil)
|
||||||
|
#expect(context.removedMessages.map(\.messageID) == [messageID])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deletePublicVoiceNoteDuringDetachedPreparationCannotBroadcastOrRestoreMapping() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let preparer = PausedVoiceNotePreparer()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(
|
||||||
|
context: context,
|
||||||
|
prepareVoiceNotePacket: { url in try preparer.prepare(url) }
|
||||||
|
)
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("paused-public-\(UUID().uuidString).m4a")
|
||||||
|
try Data("voice".utf8).write(to: url)
|
||||||
|
defer {
|
||||||
|
preparer.release()
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: url)
|
||||||
|
#expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout))
|
||||||
|
let messageID = try #require(context.appendedPublicMessages.first?.message.id)
|
||||||
|
let transferId = try #require(coordinator.messageIDToTransferId[messageID])
|
||||||
|
|
||||||
|
coordinator.deleteMediaMessage(messageID: messageID)
|
||||||
|
preparer.release()
|
||||||
|
#expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout))
|
||||||
|
for _ in 0..<10 { await Task.yield() }
|
||||||
|
|
||||||
|
#expect(context.cancelledTransfers == [transferId])
|
||||||
|
#expect(context.broadcastFileSends.isEmpty)
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||||
|
#expect(coordinator.transferIdToMessageIDs[transferId] == nil)
|
||||||
|
#expect(context.removedMessages.map(\.messageID) == [messageID])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func voicePreparationFailureMarksPlaceholderFailedAndClearsEarlyMapping() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let peerID = PeerID(str: "66778899aabbccdd")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(
|
||||||
|
context: context,
|
||||||
|
prepareVoiceNotePacket: { _ in
|
||||||
|
throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: 999_999)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("failing-private-\(UUID().uuidString).m4a")
|
||||||
|
try Data("voice".utf8).write(to: url)
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: url)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{
|
||||||
|
context.deliveryStatusUpdates.contains { update in
|
||||||
|
if case .failed = update.status { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
let messageID = try #require(context.privateChats[peerID]?.first?.id)
|
||||||
|
|
||||||
|
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||||
|
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
#expect(context.broadcastFileSends.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func legacyPrivateVoiceNoteWaitsForPerSendConsent() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
context.privateMediaPolicy = .legacyRequiresConsent
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("legacy-consent-\(UUID().uuidString).m4a")
|
||||||
|
try (Data([0x00, 0x00, 0x00, 0x18]) + Data("ftypM4A 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.legacyConsentRequests.map { $0.peerID } == [peerID])
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
|
||||||
|
context.resolveNextLegacyConsent(true)
|
||||||
|
|
||||||
|
#expect(context.privateFileSends.count == 1)
|
||||||
|
#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()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "2233445566778899")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
context.privateMediaPolicy = .legacyRequiresConsent
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("legacy-cancel-\(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)
|
||||||
|
let request = try #require(context.legacyConsentRequests.first)
|
||||||
|
|
||||||
|
coordinator.cancelMediaSend(messageID: request.messageID)
|
||||||
|
#expect(context.invalidatedLegacyConsents.contains {
|
||||||
|
$0.transferId == request.transferId && $0.messageID == request.messageID
|
||||||
|
})
|
||||||
|
|
||||||
|
// Model a stale framework callback that escaped active invalidation.
|
||||||
|
// The coordinator's transfer/message binding check is the final gate.
|
||||||
|
context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true)
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
#expect(coordinator.messageIDToTransferId[request.messageID] == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func legacyConsentApprovalAfterDeleteCannotSend() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "33445566778899aa")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
context.privateMediaPolicy = .legacyRequiresConsent
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("legacy-delete-\(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)
|
||||||
|
let request = try #require(context.legacyConsentRequests.first)
|
||||||
|
|
||||||
|
coordinator.deleteMediaMessage(messageID: request.messageID)
|
||||||
|
context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true)
|
||||||
|
|
||||||
|
#expect(context.invalidatedLegacyConsents.contains {
|
||||||
|
$0.transferId == request.transferId && $0.messageID == request.messageID
|
||||||
|
})
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
#expect(coordinator.messageIDToTransferId[request.messageID] == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func pinnedPrivateMediaDowngradeNeverPromptsOrSends() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
context.privateMediaPolicy = .blockedDowngrade
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("blocked-downgrade-\(UUID().uuidString).m4a")
|
||||||
|
try Data("voice".utf8).write(to: url)
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: url)
|
||||||
|
|
||||||
|
let failed = await TestHelpers.waitUntil(
|
||||||
|
{ context.deliveryStatusUpdates.contains { update in
|
||||||
|
if case .failed = update.status { return true }
|
||||||
|
return false
|
||||||
|
} },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(failed)
|
||||||
|
#expect(context.legacyConsentRequests.isEmpty)
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1048,6 +1048,89 @@ struct ChatViewModelMediaTransferTests {
|
|||||||
#expect(viewModel.transferIdToMessageIDs.count == 1)
|
#expect(viewModel.transferIdToMessageIDs.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func legacyPrivateMediaConsentRequestsArePerSendAndQueued() async throws {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
let firstPeer = PeerID(str: "1111111111111111")
|
||||||
|
let secondPeer = PeerID(str: "2222222222222222")
|
||||||
|
var decisions: [Bool] = []
|
||||||
|
|
||||||
|
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||||
|
for: firstPeer,
|
||||||
|
transferId: "transfer-1",
|
||||||
|
messageID: "message-1"
|
||||||
|
) { decisions.append($0) }
|
||||||
|
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||||
|
for: secondPeer,
|
||||||
|
transferId: "transfer-2",
|
||||||
|
messageID: "message-2"
|
||||||
|
) { decisions.append($0) }
|
||||||
|
|
||||||
|
#expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == firstPeer)
|
||||||
|
let firstRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||||
|
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true)
|
||||||
|
let showedSecond = await TestHelpers.waitUntil(
|
||||||
|
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(showedSecond)
|
||||||
|
let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||||
|
|
||||||
|
// A button action and the dialog binding may both resolve the first
|
||||||
|
// ID. The stale second callback must not consume the queued request.
|
||||||
|
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: false)
|
||||||
|
#expect(decisions == [true])
|
||||||
|
#expect(viewModel.legacyPrivateMediaConsentRequest?.id == secondRequestID)
|
||||||
|
|
||||||
|
viewModel.resolveLegacyPrivateMediaConsent(requestID: secondRequestID, approved: false)
|
||||||
|
|
||||||
|
#expect(decisions == [true, false])
|
||||||
|
#expect(viewModel.legacyPrivateMediaConsentRequest == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func invalidatingPresentedLegacyConsentAdvancesQueueAndStaleResolutionNoops() async throws {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
let firstPeer = PeerID(str: "3333333333333333")
|
||||||
|
let secondPeer = PeerID(str: "4444444444444444")
|
||||||
|
var decisions: [String] = []
|
||||||
|
|
||||||
|
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||||
|
for: firstPeer,
|
||||||
|
transferId: "transfer-cancelled",
|
||||||
|
messageID: "message-cancelled"
|
||||||
|
) { decisions.append("first:\($0)") }
|
||||||
|
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||||
|
for: secondPeer,
|
||||||
|
transferId: "transfer-kept",
|
||||||
|
messageID: "message-kept"
|
||||||
|
) { decisions.append("second:\($0)") }
|
||||||
|
|
||||||
|
let cancelledRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||||
|
viewModel.invalidateLegacyPrivateMediaConsent(
|
||||||
|
transferId: "transfer-cancelled",
|
||||||
|
messageID: "message-cancelled"
|
||||||
|
)
|
||||||
|
let advanced = await TestHelpers.waitUntil(
|
||||||
|
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(advanced)
|
||||||
|
#expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send")
|
||||||
|
|
||||||
|
viewModel.resolveLegacyPrivateMediaConsent(
|
||||||
|
requestID: cancelledRequestID,
|
||||||
|
approved: true
|
||||||
|
)
|
||||||
|
#expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer)
|
||||||
|
#expect(decisions.isEmpty)
|
||||||
|
|
||||||
|
let keptRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||||
|
viewModel.resolveLegacyPrivateMediaConsent(requestID: keptRequestID, approved: true)
|
||||||
|
#expect(decisions == ["second:true"])
|
||||||
|
#expect(viewModel.legacyPrivateMediaConsentRequest == nil)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
|
func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,8 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
|||||||
private var blockedFingerprints: Set<String> = []
|
private var blockedFingerprints: Set<String> = []
|
||||||
private var blockedNostrPubkeys: Set<String> = []
|
private var blockedNostrPubkeys: Set<String> = []
|
||||||
private var socialIdentities: [String: SocialIdentity] = [:]
|
private var socialIdentities: [String: SocialIdentity] = [:]
|
||||||
|
private var privateMediaCapableFingerprints: Set<String> = []
|
||||||
|
private var authenticatedSigningKeys: [String: Data] = [:]
|
||||||
|
|
||||||
init(_: KeychainManagerProtocol) {}
|
init(_: KeychainManagerProtocol) {}
|
||||||
|
|
||||||
@@ -87,7 +89,10 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
|
||||||
|
|
||||||
func clearAllIdentityData() {}
|
func clearAllIdentityData() {
|
||||||
|
privateMediaCapableFingerprints.removeAll()
|
||||||
|
authenticatedSigningKeys.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
func removeEphemeralSession(peerID: PeerID) {}
|
func removeEphemeralSession(peerID: PeerID) {}
|
||||||
|
|
||||||
@@ -101,6 +106,22 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
|||||||
Set()
|
Set()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markPrivateMediaCapable(fingerprint: String) {
|
||||||
|
privateMediaCapableFingerprints.insert(fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
|
||||||
|
privateMediaCapableFingerprints.contains(fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
|
||||||
|
authenticatedSigningKeys[fingerprint] = signingPublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||||
|
authenticatedSigningKeys[fingerprint]
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Vouching (transitive verification)
|
// MARK: Vouching (transitive verification)
|
||||||
|
|
||||||
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
|
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ final class MockTransport: Transport {
|
|||||||
private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
||||||
private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = []
|
private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = []
|
||||||
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
|
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
|
||||||
|
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
|
||||||
private(set) var cancelledTransfers: [String] = []
|
private(set) var cancelledTransfers: [String] = []
|
||||||
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
@@ -58,6 +59,7 @@ final class MockTransport: Transport {
|
|||||||
var peerNicknames: [PeerID: String] = [:]
|
var peerNicknames: [PeerID: String] = [:]
|
||||||
var peerFingerprints: [PeerID: String] = [:]
|
var peerFingerprints: [PeerID: String] = [:]
|
||||||
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
||||||
|
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
|
||||||
private let mockKeychain = MockKeychain()
|
private let mockKeychain = MockKeychain()
|
||||||
|
|
||||||
// MARK: - Transport Protocol Implementation
|
// MARK: - Transport Protocol Implementation
|
||||||
@@ -186,6 +188,29 @@ final class MockTransport: Transport {
|
|||||||
|
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||||
sentPrivateFiles.append((packet, peerID, transferId))
|
sentPrivateFiles.append((packet, peerID, transferId))
|
||||||
|
sentPrivateFileLegacyAllowances.append(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFilePrivate(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
allowLegacyFallback: Bool
|
||||||
|
) {
|
||||||
|
sentPrivateFiles.append((packet, peerID, transferId))
|
||||||
|
sentPrivateFileLegacyAllowances.append(allowLegacyFallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||||
|
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) {
|
func cancelTransfer(_ transferId: String) {
|
||||||
|
|||||||
@@ -12,8 +12,15 @@ struct NoiseCoverageTests {
|
|||||||
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
|
|
||||||
private let alicePeerID = PeerID(str: "0011223344556677")
|
// Manager test dictionaries are keyed by the remote peer. Keep the
|
||||||
private let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
// historical names, but derive each wire ID from the static key that the
|
||||||
|
// corresponding manager authenticates during the handshake.
|
||||||
|
private var alicePeerID: PeerID {
|
||||||
|
PeerID(publicKey: bobStaticKey.publicKey.rawRepresentation)
|
||||||
|
}
|
||||||
|
private var bobPeerID: PeerID {
|
||||||
|
PeerID(publicKey: aliceStaticKey.publicKey.rawRepresentation)
|
||||||
|
}
|
||||||
private let charliePeerID = PeerID(str: "fedcba9876543210")
|
private let charliePeerID = PeerID(str: "fedcba9876543210")
|
||||||
|
|
||||||
@Test("Protocol metadata and handshake patterns expose expected values")
|
@Test("Protocol metadata and handshake patterns expose expected values")
|
||||||
@@ -535,8 +542,12 @@ struct NoiseCoverageTests {
|
|||||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
||||||
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||||
|
|
||||||
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
|
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(
|
||||||
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
|
peerID:remoteKey:sessionGeneration:
|
||||||
|
)
|
||||||
|
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(
|
||||||
|
peerID:remoteKey:sessionGeneration:
|
||||||
|
)
|
||||||
|
|
||||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||||
|
|
||||||
@@ -643,13 +654,121 @@ struct NoiseCoverageTests {
|
|||||||
try aliceManager.initiateHandshake(with: alicePeerID)
|
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))
|
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
|
||||||
|
|
||||||
#expect(rekeyedSession !== establishedSession)
|
#expect(rekeyedSession !== establishedSession)
|
||||||
#expect(rekeyedSession.getState() == .handshaking)
|
#expect(rekeyedSession.getState() == .handshaking)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("A stale decrypt generation cannot commit across session promotion")
|
||||||
|
func staleDecryptGenerationCannotCommitAcrossPromotion() throws {
|
||||||
|
let aliceManager = NoiseSessionManager(
|
||||||
|
localStaticKey: aliceStaticKey,
|
||||||
|
keychain: keychain,
|
||||||
|
sessionFactory: { peerID, role in
|
||||||
|
BlockingDecryptNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: role,
|
||||||
|
keychain: self.keychain,
|
||||||
|
localStaticKey: self.aliceStaticKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||||
|
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||||
|
|
||||||
|
let oldSession = try #require(
|
||||||
|
aliceManager.getSession(for: alicePeerID) as? BlockingDecryptNoiseSession
|
||||||
|
)
|
||||||
|
let oldGeneration = try #require(aliceManager.sessionGeneration(for: alicePeerID))
|
||||||
|
|
||||||
|
// Prepare a fully authenticated responder candidate without promoting
|
||||||
|
// it yet. Its final XX message is the exact operation that replaces
|
||||||
|
// the old `sessions[peerID]` entry.
|
||||||
|
let replacementInitiator = NoiseSession(
|
||||||
|
peerID: bobPeerID,
|
||||||
|
role: .initiator,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: bobStaticKey
|
||||||
|
)
|
||||||
|
let message1 = try replacementInitiator.startHandshake()
|
||||||
|
let message2 = try #require(
|
||||||
|
try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message1)
|
||||||
|
)
|
||||||
|
let message3 = try #require(try replacementInitiator.processHandshakeMessage(message2))
|
||||||
|
|
||||||
|
let ciphertext = try bobManager.encrypt(Data("old session".utf8), for: bobPeerID)
|
||||||
|
oldSession.pauseNextDecrypt()
|
||||||
|
|
||||||
|
let decryptResult = ConcurrentTestResult<(plaintext: Data, sessionGeneration: UUID)>()
|
||||||
|
var promotionResultForCleanup: ConcurrentTestResult<Data?>?
|
||||||
|
defer {
|
||||||
|
// A failed startup requirement must not strand a late thread in
|
||||||
|
// the blocking test double after the test has returned.
|
||||||
|
oldSession.resumeDecrypt()
|
||||||
|
_ = decryptResult.wait(timeout: 5)
|
||||||
|
if let promotionResultForCleanup {
|
||||||
|
_ = promotionResultForCleanup.wait(timeout: 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let decryptThread = Thread {
|
||||||
|
decryptResult.capture {
|
||||||
|
try aliceManager.decryptWithSessionGeneration(ciphertext, from: self.alicePeerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decryptThread.name = "NoiseCoverageTests.staleDecrypt.decrypt"
|
||||||
|
decryptThread.qualityOfService = .userInitiated
|
||||||
|
decryptThread.start()
|
||||||
|
try #require(oldSession.waitForDecryptStart(timeout: 5))
|
||||||
|
|
||||||
|
let promotionStarted = DispatchSemaphore(value: 0)
|
||||||
|
let promotionResult = ConcurrentTestResult<Data?>()
|
||||||
|
promotionResultForCleanup = promotionResult
|
||||||
|
let promotionThread = Thread {
|
||||||
|
promotionStarted.signal()
|
||||||
|
promotionResult.capture {
|
||||||
|
try aliceManager.handleIncomingHandshake(from: self.alicePeerID, message: message3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote"
|
||||||
|
promotionThread.qualityOfService = .userInitiated
|
||||||
|
promotionThread.start()
|
||||||
|
try #require(promotionStarted.wait(timeout: .now() + 5) == .success)
|
||||||
|
#expect(
|
||||||
|
promotionResult.wait(timeout: 0.05) == nil,
|
||||||
|
"Promotion must wait for the exact decrypting-session lease"
|
||||||
|
)
|
||||||
|
|
||||||
|
oldSession.resumeDecrypt()
|
||||||
|
let decrypted = try #require(decryptResult.wait(timeout: 5)).get()
|
||||||
|
_ = try #require(promotionResult.wait(timeout: 5)).get()
|
||||||
|
|
||||||
|
#expect(decrypted.plaintext == Data("old session".utf8))
|
||||||
|
#expect(decrypted.sessionGeneration == oldGeneration)
|
||||||
|
#expect(aliceManager.sessionGeneration(for: alicePeerID) != oldGeneration)
|
||||||
|
#expect(throws: NoiseEncryptionError.sessionNotEstablished) {
|
||||||
|
try aliceManager.encrypt(
|
||||||
|
Data("stale send".utf8),
|
||||||
|
for: alicePeerID,
|
||||||
|
expectedSessionGeneration: oldGeneration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var staleCommitRan = false
|
||||||
|
let staleCommit = aliceManager.withCurrentSessionGeneration(
|
||||||
|
for: alicePeerID,
|
||||||
|
expected: decrypted.sessionGeneration
|
||||||
|
) {
|
||||||
|
staleCommitRan = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#expect(staleCommit == nil)
|
||||||
|
#expect(!staleCommitRan)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Secure noise sessions enforce limits and renegotiation thresholds")
|
@Test("Secure noise sessions enforce limits and renegotiation thresholds")
|
||||||
func secureNoiseSessionsEnforceLimitsAndThresholds() throws {
|
func secureNoiseSessionsEnforceLimitsAndThresholds() throws {
|
||||||
let initiator = SecureNoiseSession(
|
let initiator = SecureNoiseSession(
|
||||||
@@ -844,7 +963,11 @@ private final class SessionCallbackRecorder: @unchecked Sendable {
|
|||||||
return establishedEntries.map(\.0)
|
return establishedEntries.map(\.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) {
|
func recordEstablished(
|
||||||
|
peerID: PeerID,
|
||||||
|
remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||||
|
sessionGeneration _: UUID
|
||||||
|
) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
establishedEntries.append((peerID, remoteKey.rawRepresentation))
|
establishedEntries.append((peerID, remoteKey.rawRepresentation))
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
@@ -866,3 +989,62 @@ private final class FailingNoiseSession: NoiseSession {
|
|||||||
throw Error.synthetic
|
throw Error.synthetic
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class BlockingDecryptNoiseSession: NoiseSession, @unchecked Sendable {
|
||||||
|
private let controlLock = NSLock()
|
||||||
|
private var shouldPauseNextDecrypt = false
|
||||||
|
private let decryptStarted = DispatchSemaphore(value: 0)
|
||||||
|
private let resumeDecryptSemaphore = DispatchSemaphore(value: 0)
|
||||||
|
|
||||||
|
func pauseNextDecrypt() {
|
||||||
|
controlLock.lock()
|
||||||
|
shouldPauseNextDecrypt = true
|
||||||
|
controlLock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForDecryptStart(timeout: TimeInterval) -> Bool {
|
||||||
|
decryptStarted.wait(timeout: .now() + timeout) == .success
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeDecrypt() {
|
||||||
|
resumeDecryptSemaphore.signal()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||||
|
controlLock.lock()
|
||||||
|
let shouldPause = shouldPauseNextDecrypt
|
||||||
|
shouldPauseNextDecrypt = false
|
||||||
|
controlLock.unlock()
|
||||||
|
|
||||||
|
if shouldPause {
|
||||||
|
decryptStarted.signal()
|
||||||
|
resumeDecryptSemaphore.wait()
|
||||||
|
}
|
||||||
|
return try super.decrypt(ciphertext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ConcurrentTestResult<Value>: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private let completed = DispatchGroup()
|
||||||
|
private var storedResult: Result<Value, Error>?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
completed.enter()
|
||||||
|
}
|
||||||
|
|
||||||
|
func capture(_ operation: () throws -> Value) {
|
||||||
|
let result = Result(catching: operation)
|
||||||
|
lock.lock()
|
||||||
|
storedResult = result
|
||||||
|
lock.unlock()
|
||||||
|
completed.leave()
|
||||||
|
}
|
||||||
|
|
||||||
|
func wait(timeout: TimeInterval) -> Result<Value, Error>? {
|
||||||
|
guard completed.wait(timeout: .now() + timeout) == .success else { return nil }
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return storedResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,6 +145,39 @@ struct PacketsTests {
|
|||||||
#expect(decoded.capabilities?.rawValue == 0x0180)
|
#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
|
@Test
|
||||||
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
|
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
|
||||||
let unknownTLV = Data([0x7F, 0x01, 0x41])
|
let unknownTLV = Data([0x7F, 0x01, 0x41])
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Testing
|
|||||||
struct BLEAnnounceHandlerTests {
|
struct BLEAnnounceHandlerTests {
|
||||||
private final class Recorder {
|
private final class Recorder {
|
||||||
var existingNoisePublicKey: Data?
|
var existingNoisePublicKey: Data?
|
||||||
|
var authenticatedSigningPublicKey: Data?
|
||||||
var signatureValid = true
|
var signatureValid = true
|
||||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||||
var linkBoundToOtherPeer = false
|
var linkBoundToOtherPeer = false
|
||||||
@@ -37,6 +38,7 @@ struct BLEAnnounceHandlerTests {
|
|||||||
messageTTL: TransportConfig.messageTTLDefault,
|
messageTTL: TransportConfig.messageTTLDefault,
|
||||||
now: { now },
|
now: { now },
|
||||||
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
||||||
|
authenticatedSigningPublicKey: { _ in recorder.authenticatedSigningPublicKey },
|
||||||
verifySignature: { packet, signingPublicKey in
|
verifySignature: { packet, signingPublicKey in
|
||||||
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
||||||
return recorder.signatureValid
|
return recorder.signatureValid
|
||||||
|
|||||||
@@ -169,6 +169,23 @@ struct BLEAnnounceHandlingPolicyTests {
|
|||||||
#expect(decision.isVerified)
|
#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
|
@Test
|
||||||
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
||||||
let directNew = BLEAnnounceResponsePolicy.plan(
|
let directNew = BLEAnnounceResponsePolicy.plan(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Testing
|
|||||||
struct BLEAnnounceThrottleTests {
|
struct BLEAnnounceThrottleTests {
|
||||||
@Test
|
@Test
|
||||||
func firstAnnounceIsAllowed() {
|
func firstAnnounceIsAllowed() {
|
||||||
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||||
|
|
||||||
let shouldSend = throttle.shouldSend(force: false, now: Date(timeIntervalSince1970: 100))
|
let shouldSend = throttle.shouldSend(force: false, now: Date(timeIntervalSince1970: 100))
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ struct BLEAnnounceThrottleTests {
|
|||||||
@Test
|
@Test
|
||||||
func regularAnnounceUsesNormalMinimumInterval() {
|
func regularAnnounceUsesNormalMinimumInterval() {
|
||||||
let now = Date(timeIntervalSince1970: 100)
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||||
|
|
||||||
let first = throttle.shouldSend(force: false, now: now)
|
let first = throttle.shouldSend(force: false, now: now)
|
||||||
let suppressed = throttle.shouldSend(force: false, now: now.addingTimeInterval(9.9))
|
let suppressed = throttle.shouldSend(force: false, now: now.addingTimeInterval(9.9))
|
||||||
@@ -29,7 +29,7 @@ struct BLEAnnounceThrottleTests {
|
|||||||
@Test
|
@Test
|
||||||
func forcedAnnounceUsesShorterMinimumInterval() {
|
func forcedAnnounceUsesShorterMinimumInterval() {
|
||||||
let now = Date(timeIntervalSince1970: 100)
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||||
|
|
||||||
let first = throttle.shouldSend(force: false, now: now)
|
let first = throttle.shouldSend(force: false, now: now)
|
||||||
let suppressed = throttle.shouldSend(force: true, now: now.addingTimeInterval(1.9))
|
let suppressed = throttle.shouldSend(force: true, now: now.addingTimeInterval(1.9))
|
||||||
@@ -43,40 +43,10 @@ struct BLEAnnounceThrottleTests {
|
|||||||
@Test
|
@Test
|
||||||
func elapsedReportsTimeSinceAcceptedSend() {
|
func elapsedReportsTimeSinceAcceptedSend() {
|
||||||
let now = Date(timeIntervalSince1970: 100)
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||||
|
|
||||||
_ = throttle.shouldSend(force: false, now: now)
|
_ = throttle.shouldSend(force: false, now: now)
|
||||||
|
|
||||||
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
func concurrentRequestsAdmitOnlyOneAnnounce() {
|
|
||||||
let now = Date(timeIntervalSince1970: 100)
|
|
||||||
let throttle = BLEAnnounceThrottle(
|
|
||||||
normalMinimumInterval: 10,
|
|
||||||
forcedMinimumInterval: 2
|
|
||||||
)
|
|
||||||
let accepted = LockedCounter()
|
|
||||||
|
|
||||||
DispatchQueue.concurrentPerform(iterations: 1_000) { _ in
|
|
||||||
if throttle.shouldSend(force: false, now: now) {
|
|
||||||
accepted.increment()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(accepted.value == 1)
|
|
||||||
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class LockedCounter: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var count = 0
|
|
||||||
|
|
||||||
var value: Int { lock.withLock { count } }
|
|
||||||
|
|
||||||
func increment() {
|
|
||||||
lock.withLock { count += 1 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
recorder.signatureVerifyCount += 1
|
recorder.signatureVerifyCount += 1
|
||||||
return recorder.signatureVerifies
|
return recorder.signatureVerifies
|
||||||
},
|
},
|
||||||
|
localSigningPublicKey: { [sampleSigningKey] in sampleSigningKey },
|
||||||
signedSenderDisplayName: { _, peerID in
|
signedSenderDisplayName: { _, peerID in
|
||||||
recorder.signedNameQueries.append(peerID)
|
recorder.signedNameQueries.append(peerID)
|
||||||
return recorder.signedName
|
return recorder.signedName
|
||||||
@@ -92,12 +93,11 @@ struct BLEFileTransferHandlerTests {
|
|||||||
@Test
|
@Test
|
||||||
func selfEchoIsDropped() throws {
|
func selfEchoIsDropped() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
|
recorder.signatureVerifies = true
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3)
|
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
|
#expect(!handler.handle(packet, from: localPeerID))
|
||||||
// handler reports "relayable" rather than treating the echo as forged.
|
|
||||||
#expect(handler.handle(packet, from: localPeerID))
|
|
||||||
|
|
||||||
expectNoSideEffects(recorder)
|
expectNoSideEffects(recorder)
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,12 @@ struct BLEFileTransferHandlerTests {
|
|||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
let packet = try makeFileTransferPacket(
|
||||||
|
sender: remotePeerID,
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
content: Data("%PDF-1.7".utf8),
|
||||||
|
hasSignature: false
|
||||||
|
)
|
||||||
|
|
||||||
// Failed sender authentication must also stop the packet from being
|
// Failed sender authentication must also stop the packet from being
|
||||||
// relayed to downstream nodes.
|
// relayed to downstream nodes.
|
||||||
@@ -129,7 +134,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
// Broadcast files carry an attacker-controllable senderID, so — like
|
// Broadcast files carry an attacker-controllable senderID, so — like
|
||||||
// public messages — a connected-but-unverified peer must present a valid
|
// public messages — a connected-but-unverified peer must present a valid
|
||||||
// packet signature. No signing key + no signed identity means dropped.
|
// packet signature. No signing key + no signed identity means dropped.
|
||||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
#expect(recorder.trackedPackets.isEmpty)
|
#expect(recorder.trackedPackets.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.isEmpty)
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
}
|
}
|
||||||
@@ -153,12 +158,11 @@ struct BLEFileTransferHandlerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func selfBroadcastReplayIsDeliveredWithoutSignatureCheck() throws {
|
func signedSelfBroadcastReplayIsDelivered() throws {
|
||||||
// Our own broadcast file replayed via gossip sync arrives with ttl==0
|
// 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
|
// it is verified against our local signing key before delivery.
|
||||||
// the peer registry — it must still be accepted, matching
|
|
||||||
// BLEPublicMessageHandler's self exemption.
|
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
|
recorder.signatureVerifies = true
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
let packet = try makeFileTransferPacket(
|
let packet = try makeFileTransferPacket(
|
||||||
sender: localPeerID,
|
sender: localPeerID,
|
||||||
@@ -169,7 +173,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
|
|
||||||
#expect(handler.handle(packet, from: localPeerID))
|
#expect(handler.handle(packet, from: localPeerID))
|
||||||
|
|
||||||
#expect(recorder.signatureVerifyCount == 0)
|
#expect(recorder.signatureVerifyCount == 1)
|
||||||
#expect(recorder.signedNameQueries.isEmpty)
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.count == 1)
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
#expect(recorder.deliveredMessages.first?.sender == "Me")
|
#expect(recorder.deliveredMessages.first?.sender == "Me")
|
||||||
@@ -205,7 +209,8 @@ struct BLEFileTransferHandlerTests {
|
|||||||
sender: remotePeerID,
|
sender: remotePeerID,
|
||||||
mimeType: "audio/mp4",
|
mimeType: "audio/mp4",
|
||||||
content: m4a,
|
content: m4a,
|
||||||
fileName: "voice_1122334455667788"
|
fileName: "voice_1122334455667788",
|
||||||
|
hasSignature: false
|
||||||
)
|
)
|
||||||
|
|
||||||
// The spoofed note must be dropped locally AND not relayed onward.
|
// The spoofed note must be dropped locally AND not relayed onward.
|
||||||
@@ -215,7 +220,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func privateFileFromConnectedUnverifiedPeerIsAccepted() throws {
|
func rawDirectedFileWithoutVerifiableSignatureIsDroppedWithoutWriteOrRelay() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
@@ -223,23 +228,25 @@ struct BLEFileTransferHandlerTests {
|
|||||||
sender: remotePeerID,
|
sender: remotePeerID,
|
||||||
mimeType: "application/pdf",
|
mimeType: "application/pdf",
|
||||||
content: Data("%PDF-1.7".utf8),
|
content: Data("%PDF-1.7".utf8),
|
||||||
recipientID: Data(hexString: localPeerID.id)
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
hasSignature: false
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(handler.handle(packet, from: remotePeerID))
|
#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.signatureVerifyCount == 0)
|
||||||
#expect(recorder.signedNameQueries.isEmpty)
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.count == 1)
|
#expect(recorder.trackedPackets.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
#expect(recorder.quotaReservations.isEmpty)
|
||||||
|
#expect(recorder.saveCalls.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func fileDirectedToAnotherPeerIsIgnored() throws {
|
func fileDirectedToAnotherPeerIsIgnored() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
||||||
|
recorder.signatureVerifies = true
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
let packet = try makeFileTransferPacket(
|
let packet = try makeFileTransferPacket(
|
||||||
sender: remotePeerID,
|
sender: remotePeerID,
|
||||||
@@ -260,7 +267,8 @@ struct BLEFileTransferHandlerTests {
|
|||||||
@Test
|
@Test
|
||||||
func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws {
|
func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
||||||
|
recorder.signatureVerifies = true
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
let packet = try makeFileTransferPacket(
|
let packet = try makeFileTransferPacket(
|
||||||
sender: remotePeerID,
|
sender: remotePeerID,
|
||||||
@@ -282,6 +290,56 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900)))
|
#expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: "secret.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
let timestamp = Date(timeIntervalSince1970: 1_234)
|
||||||
|
|
||||||
|
#expect(handler.handlePrivatePayload(payload, from: remotePeerID, timestamp: timestamp))
|
||||||
|
|
||||||
|
#expect(recorder.signatureVerifyCount == 0)
|
||||||
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
|
#expect(recorder.trackedPackets.isEmpty)
|
||||||
|
#expect(recorder.quotaReservations == [content.count])
|
||||||
|
#expect(recorder.saveCalls.first?.data == content)
|
||||||
|
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||||
|
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func decryptedPrivateFileOverPayloadCapIsRejectedBeforeQuotaOrDiskWrite() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let oversizedCount = FileTransferLimits.maxPayloadBytes + 1
|
||||||
|
var length = UInt32(oversizedCount).bigEndian
|
||||||
|
var payload = Data([0x04]) // BitchatFilePacket CONTENT TLV
|
||||||
|
withUnsafeBytes(of: &length) { payload.append(contentsOf: $0) }
|
||||||
|
payload.append(Data(repeating: 0x41, count: oversizedCount))
|
||||||
|
|
||||||
|
#expect(!handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(recorder.quotaReservations.isEmpty)
|
||||||
|
#expect(recorder.saveCalls.isEmpty)
|
||||||
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func malformedPayloadIsTrackedForSyncButDropped() {
|
func malformedPayloadIsTrackedForSyncButDropped() {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
@@ -294,7 +352,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: 900_000,
|
timestamp: 900_000,
|
||||||
payload: Data([0x01, 0x02, 0x03]),
|
payload: Data([0x01, 0x02, 0x03]),
|
||||||
signature: nil,
|
signature: Data(repeating: 0x5A, count: 64),
|
||||||
ttl: TransportConfig.messageTTLDefault
|
ttl: TransportConfig.messageTTLDefault
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -403,7 +461,8 @@ struct BLEFileTransferHandlerTests {
|
|||||||
content: Data,
|
content: Data,
|
||||||
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
||||||
recipientID: Data? = nil,
|
recipientID: Data? = nil,
|
||||||
fileName: String = "sample"
|
fileName: String = "sample",
|
||||||
|
hasSignature: Bool = true
|
||||||
) throws -> BitchatPacket {
|
) throws -> BitchatPacket {
|
||||||
let filePacket = BitchatFilePacket(
|
let filePacket = BitchatFilePacket(
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
@@ -418,7 +477,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
recipientID: recipientID,
|
recipientID: recipientID,
|
||||||
timestamp: 900_000,
|
timestamp: 900_000,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: hasSignature ? Data(repeating: 0x5A, count: 64) : nil,
|
||||||
ttl: ttl
|
ttl: ttl
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,35 @@ struct BLEFragmentAssemblyBufferTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func encryptedPrivateFileAssemblyGetsFramedFileHeadroom() throws {
|
||||||
|
var buffer = BLEFragmentAssemblyBuffer()
|
||||||
|
let fragmentID = Data(repeating: 0x15, count: 8)
|
||||||
|
let first = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
|
||||||
|
fragmentID: fragmentID,
|
||||||
|
index: 0,
|
||||||
|
total: 2,
|
||||||
|
originalType: MessageType.noiseEncrypted.rawValue,
|
||||||
|
fragmentData: Data(repeating: 0x01, count: FileTransferLimits.maxPayloadBytes)
|
||||||
|
)))
|
||||||
|
let second = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
|
||||||
|
fragmentID: fragmentID,
|
||||||
|
index: 1,
|
||||||
|
total: 2,
|
||||||
|
originalType: MessageType.noiseEncrypted.rawValue,
|
||||||
|
fragmentData: Data([0x02])
|
||||||
|
)))
|
||||||
|
|
||||||
|
_ = buffer.append(first, maxInFlightAssemblies: 8)
|
||||||
|
let result = buffer.append(second, maxInFlightAssemblies: 8)
|
||||||
|
|
||||||
|
if case let .complete(_, data, _) = result {
|
||||||
|
#expect(data.count == FileTransferLimits.maxPayloadBytes + 1)
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected encrypted private-file assembly to use framed-file limit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func removeExpiredDropsOldAssemblies() throws {
|
func removeExpiredDropsOldAssemblies() throws {
|
||||||
var buffer = BLEFragmentAssemblyBuffer()
|
var buffer = BLEFragmentAssemblyBuffer()
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct BLELocalIdentityStateStoreTests {
|
|
||||||
@Test
|
|
||||||
func identityReplacementUpdatesWireBytesAtomically() throws {
|
|
||||||
let initial = PeerID(str: "0011223344556677")
|
|
||||||
let replacement = PeerID(str: "8899aabbccddeeff")
|
|
||||||
let store = BLELocalIdentityStateStore(peerID: initial, nickname: "alice")
|
|
||||||
|
|
||||||
store.replacePeerIdentity(with: replacement)
|
|
||||||
|
|
||||||
let snapshot = store.snapshot()
|
|
||||||
#expect(snapshot.peerID == replacement)
|
|
||||||
#expect(snapshot.peerIDData == Data(hexString: replacement.id))
|
|
||||||
#expect(snapshot.nickname == "alice")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func concurrentReadsNeverObserveSplitIdentityState() {
|
|
||||||
let peerIDs = [
|
|
||||||
PeerID(str: "0011223344556677"),
|
|
||||||
PeerID(str: "8899aabbccddeeff")
|
|
||||||
]
|
|
||||||
let store = BLELocalIdentityStateStore(peerID: peerIDs[0], nickname: "alice")
|
|
||||||
let failures = LockedFailureRecorder()
|
|
||||||
|
|
||||||
DispatchQueue.concurrentPerform(iterations: 2_000) { index in
|
|
||||||
if index.isMultiple(of: 2) {
|
|
||||||
store.replacePeerIdentity(with: peerIDs[index % peerIDs.count])
|
|
||||||
} else {
|
|
||||||
store.setNickname(index.isMultiple(of: 3) ? "alice" : "bob")
|
|
||||||
}
|
|
||||||
|
|
||||||
let snapshot = store.snapshot()
|
|
||||||
let expectedWireID = Data(hexString: snapshot.peerID.id) ?? Data()
|
|
||||||
if snapshot.peerIDData != expectedWireID {
|
|
||||||
failures.record()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(!failures.hasFailure)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class LockedFailureRecorder: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var failed = false
|
|
||||||
|
|
||||||
var hasFailure: Bool { lock.withLock { failed } }
|
|
||||||
|
|
||||||
func record() {
|
|
||||||
lock.withLock { failed = true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@ struct BLENoisePacketHandlerTests {
|
|||||||
private final class Recorder {
|
private final class Recorder {
|
||||||
var handshakeResult: Result<Data?, Error> = .success(nil)
|
var handshakeResult: Result<Data?, Error> = .success(nil)
|
||||||
var hasSession = false
|
var hasSession = false
|
||||||
|
let sessionGeneration = UUID()
|
||||||
var decryptResult: Result<Data, Error> = .success(Data())
|
var decryptResult: Result<Data, Error> = .success(Data())
|
||||||
|
|
||||||
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
|
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
|
||||||
@@ -18,6 +19,7 @@ struct BLENoisePacketHandlerTests {
|
|||||||
var lastSeenUpdates: [PeerID] = []
|
var lastSeenUpdates: [PeerID] = []
|
||||||
var decryptCalls: [(payload: Data, peerID: PeerID)] = []
|
var decryptCalls: [(payload: Data, peerID: PeerID)] = []
|
||||||
var clearedSessions: [PeerID] = []
|
var clearedSessions: [PeerID] = []
|
||||||
|
var authenticatedPeerStates: [(peerID: PeerID, payload: Data, generation: UUID)] = []
|
||||||
var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
|
var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
|
||||||
/// Ordered side-effect log to assert recovery sequencing.
|
/// Ordered side-effect log to assert recovery sequencing.
|
||||||
var events: [String] = []
|
var events: [String] = []
|
||||||
@@ -56,12 +58,18 @@ struct BLENoisePacketHandlerTests {
|
|||||||
},
|
},
|
||||||
decrypt: { payload, peerID in
|
decrypt: { payload, peerID in
|
||||||
recorder.decryptCalls.append((payload, peerID))
|
recorder.decryptCalls.append((payload, peerID))
|
||||||
return try recorder.decryptResult.get()
|
return BLENoiseDecryptionResult(
|
||||||
|
plaintext: try recorder.decryptResult.get(),
|
||||||
|
sessionGeneration: recorder.sessionGeneration
|
||||||
|
)
|
||||||
},
|
},
|
||||||
clearSession: { peerID in
|
clearSession: { peerID in
|
||||||
recorder.clearedSessions.append(peerID)
|
recorder.clearedSessions.append(peerID)
|
||||||
recorder.events.append("clearSession")
|
recorder.events.append("clearSession")
|
||||||
},
|
},
|
||||||
|
handleAuthenticatedPeerState: { peerID, payload, generation in
|
||||||
|
recorder.authenticatedPeerStates.append((peerID, payload, generation))
|
||||||
|
},
|
||||||
deliverNoisePayload: { peerID, type, payload, timestamp in
|
deliverNoisePayload: { peerID, type, payload, timestamp in
|
||||||
recorder.deliveries.append((peerID, type, payload, timestamp))
|
recorder.deliveries.append((peerID, type, payload, timestamp))
|
||||||
}
|
}
|
||||||
@@ -152,6 +160,21 @@ struct BLENoisePacketHandlerTests {
|
|||||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func peerIdentityMismatchDoesNotRecreateHandshakeState() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.handshakeResult = .failure(NoiseSessionError.peerIdentityMismatch)
|
||||||
|
recorder.hasSession = false
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id))
|
||||||
|
|
||||||
|
#expect(!handler.handleHandshake(packet, from: remotePeerID))
|
||||||
|
|
||||||
|
#expect(recorder.hasSessionQueries.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
#expect(recorder.broadcastPackets.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Encrypted
|
// MARK: Encrypted
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -206,6 +229,25 @@ struct BLENoisePacketHandlerTests {
|
|||||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
#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.authenticatedPeerStates.first?.generation == recorder.sessionGeneration)
|
||||||
|
#expect(recorder.deliveries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func emptyDecryptedPayloadIsIgnored() {
|
func emptyDecryptedPayloadIsIgnored() {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct BLENoisePayloadFactoryTests {
|
struct BLENoisePayloadFactoryTests {
|
||||||
@@ -31,4 +32,63 @@ struct BLENoisePayloadFactoryTests {
|
|||||||
|
|
||||||
#expect(payload == Data([NoisePayloadType.verifyChallenge.rawValue, 0xCA, 0xFE]))
|
#expect(payload == Data([NoisePayloadType.verifyChallenge.rawValue, 0xCA, 0xFE]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func privateFilePayloadPrefixesCanonicalFilePacket() throws {
|
||||||
|
let content = Data("%PDF-secret".utf8)
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: "secret.pdf",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
|
||||||
|
let payload = try #require(BLENoisePayloadFactory.privateFile(file))
|
||||||
|
|
||||||
|
#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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ struct BLENoiseSessionQueuesTests {
|
|||||||
queues.appendTypedPayload(Data([0x01]), for: peerID)
|
queues.appendTypedPayload(Data([0x01]), for: peerID)
|
||||||
queues.appendTypedPayload(Data([0x02]), for: peerID)
|
queues.appendTypedPayload(Data([0x02]), for: peerID)
|
||||||
|
|
||||||
#expect(queues.takeTypedPayloads(for: peerID) == [Data([0x01]), Data([0x02])])
|
#expect(queues.takeTypedPayloads(for: peerID) == [
|
||||||
|
BLEPendingTypedPayload(payload: Data([0x01]), transferId: nil),
|
||||||
|
BLEPendingTypedPayload(payload: Data([0x02]), transferId: nil)
|
||||||
|
])
|
||||||
#expect(queues.takeTypedPayloads(for: peerID).isEmpty)
|
#expect(queues.takeTypedPayloads(for: peerID).isEmpty)
|
||||||
#expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"])
|
#expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"])
|
||||||
}
|
}
|
||||||
@@ -64,4 +67,21 @@ struct BLENoiseSessionQueuesTests {
|
|||||||
|
|
||||||
#expect(queues.isEmpty)
|
#expect(queues.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func transferIDSurvivesHandshakeQueueAndCanBeCancelledBeforeDrain() {
|
||||||
|
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||||
|
var queues = BLENoiseSessionQueues()
|
||||||
|
|
||||||
|
queues.appendTypedPayload(Data([0x20, 0xAA]), transferId: "media-1", for: peerID)
|
||||||
|
queues.appendTypedPayload(Data([0x01, 0xBB]), for: peerID)
|
||||||
|
|
||||||
|
let removed = queues.removeTypedPayload(transferId: "media-1")
|
||||||
|
let removedAgain = queues.removeTypedPayload(transferId: "media-1")
|
||||||
|
#expect(removed)
|
||||||
|
#expect(!removedAgain)
|
||||||
|
#expect(queues.takeTypedPayloads(for: peerID) == [
|
||||||
|
BLEPendingTypedPayload(payload: Data([0x01, 0xBB]), transferId: nil)
|
||||||
|
])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,58 @@ struct BLEOutboundFragmentPlannerTests {
|
|||||||
) == nil)
|
) == 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(
|
private func makePacket(
|
||||||
payload: Data,
|
payload: Data,
|
||||||
route: [Data]? = nil,
|
route: [Data]? = nil,
|
||||||
|
|||||||
@@ -20,6 +20,24 @@ struct BLEOutboundFragmentTransferSchedulerTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func explicitTransferIDReservesEncryptedPrivateFileFragments() {
|
||||||
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||||
|
let request = makeRequest(
|
||||||
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
|
transferId: "private-media"
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = scheduler.submit(request, maxConcurrentTransfers: 1)
|
||||||
|
|
||||||
|
if case let .start(_, reservedTransferId) = result {
|
||||||
|
#expect(reservedTransferId == "private-media")
|
||||||
|
#expect(scheduler.activeCount == 1)
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected encrypted private media to reserve its progress slot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func submitQueuesFileTransferWhenSlotsAreFull() {
|
func submitQueuesFileTransferWhenSlotsAreFull() {
|
||||||
var scheduler = BLEOutboundFragmentTransferScheduler()
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||||
|
|||||||
@@ -42,6 +42,37 @@ struct BLEPeerRegistryTests {
|
|||||||
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
|
#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")
|
@Test("reachability keeps recent verified offline peers only when mesh is attached")
|
||||||
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
||||||
let offlinePeer = PeerID(str: "1122334455667788")
|
let offlinePeer = PeerID(str: "1122334455667788")
|
||||||
|
|||||||
@@ -91,39 +91,266 @@ struct NoiseEncryptionServiceTests {
|
|||||||
func handshakeEncryptionAndFingerprintLifecycle() async throws {
|
func handshakeEncryptionAndFingerprintLifecycle() async throws {
|
||||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
let alicePeerID = PeerID(str: "0011223344556677")
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
let recorder = AuthenticationRecorder()
|
let recorder = AuthenticationRecorder()
|
||||||
|
|
||||||
#expect(alice.onPeerAuthenticated == nil)
|
#expect(alice.onPeerAuthenticated == nil)
|
||||||
|
#expect(bob.onPeerAuthenticatedWithGeneration == nil)
|
||||||
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||||
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
|
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
|
||||||
|
bob.onPeerAuthenticatedWithGeneration = recorder.record(
|
||||||
|
peerID:fingerprint:sessionGeneration:
|
||||||
|
)
|
||||||
|
|
||||||
try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID)
|
try establishSessions(alice: alice, bob: bob)
|
||||||
|
|
||||||
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
||||||
#expect(authenticated)
|
#expect(authenticated)
|
||||||
#expect(alice.hasEstablishedSession(with: alicePeerID))
|
let generationAuthenticated = await TestHelpers.waitUntil(
|
||||||
#expect(bob.hasEstablishedSession(with: bobPeerID))
|
{ recorder.generationCount >= 1 },
|
||||||
#expect(alice.hasSession(with: alicePeerID))
|
timeout: 5.0
|
||||||
#expect(bob.hasSession(with: bobPeerID))
|
)
|
||||||
#expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
#expect(generationAuthenticated)
|
||||||
#expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32)
|
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
||||||
#expect(alice.getPeerFingerprint(alicePeerID) != nil)
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||||
#expect(bob.getPeerFingerprint(bobPeerID) != nil)
|
#expect(alice.hasSession(with: bobPeerID))
|
||||||
|
#expect(bob.hasSession(with: alicePeerID))
|
||||||
|
#expect(alice.getPeerPublicKeyData(bobPeerID)?.count == 32)
|
||||||
|
#expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
||||||
|
#expect(alice.getPeerFingerprint(bobPeerID) != nil)
|
||||||
|
#expect(bob.getPeerFingerprint(alicePeerID) != nil)
|
||||||
|
#expect(recorder.generation(for: alicePeerID) == bob.sessionGeneration(for: alicePeerID))
|
||||||
|
|
||||||
let plaintext = Data("secret payload".utf8)
|
let plaintext = Data("secret payload".utf8)
|
||||||
let ciphertext = try alice.encrypt(plaintext, for: alicePeerID)
|
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
|
||||||
let decrypted = try bob.decrypt(ciphertext, from: bobPeerID)
|
let decrypted = try bob.decrypt(ciphertext, from: alicePeerID)
|
||||||
#expect(decrypted == plaintext)
|
#expect(decrypted == plaintext)
|
||||||
|
|
||||||
alice.clearSession(for: alicePeerID)
|
alice.clearSession(for: bobPeerID)
|
||||||
#expect(!alice.hasSession(with: alicePeerID))
|
#expect(!alice.hasSession(with: bobPeerID))
|
||||||
#expect(alice.getPeerFingerprint(alicePeerID) == nil)
|
#expect(alice.getPeerFingerprint(bobPeerID) == nil)
|
||||||
|
|
||||||
bob.clearEphemeralStateForPanic()
|
bob.clearEphemeralStateForPanic()
|
||||||
#expect(!bob.hasSession(with: bobPeerID))
|
#expect(!bob.hasSession(with: alicePeerID))
|
||||||
#expect(bob.getPeerFingerprint(bobPeerID) == nil)
|
#expect(bob.getPeerFingerprint(alicePeerID) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Handshake rejects a claimed peer ID that does not match the authenticated static key")
|
||||||
|
func handshakeRejectsClaimedPeerIDStaticKeyMismatch() async throws {
|
||||||
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let claimedAlice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||||
|
let claimedAlicePeerID = PeerID(publicKey: claimedAlice.getStaticPublicKeyData())
|
||||||
|
let recorder = AuthenticationRecorder()
|
||||||
|
receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||||
|
|
||||||
|
let message1 = try mallory.initiateHandshake(with: receiverPeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message1)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try mallory.processHandshakeMessage(from: receiverPeerID, message: message2)
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message3)
|
||||||
|
Issue.record("Expected the authenticated Mallory key to be rejected for Alice's peer ID")
|
||||||
|
} catch let error as NoiseSessionError {
|
||||||
|
#expect(error == .peerIdentityMismatch)
|
||||||
|
} catch {
|
||||||
|
Issue.record("Unexpected mismatch error: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!receiver.hasSession(with: claimedAlicePeerID))
|
||||||
|
let emittedAuthentication = await TestHelpers.waitUntil(
|
||||||
|
{ recorder.count > 0 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!emittedAuthentication)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Failed forged replacement preserves the established peer session")
|
||||||
|
func forgedReplacementPreservesEstablishedSession() async throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||||
|
let recorder = AuthenticationRecorder()
|
||||||
|
receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||||
|
|
||||||
|
try establishSessions(alice: alice, bob: receiver)
|
||||||
|
let initialAuthentication = await TestHelpers.waitUntil(
|
||||||
|
{ recorder.count == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(initialAuthentication)
|
||||||
|
|
||||||
|
let before = try alice.encrypt(Data("before".utf8), for: receiverPeerID)
|
||||||
|
#expect(try receiver.decrypt(before, from: alicePeerID) == Data("before".utf8))
|
||||||
|
|
||||||
|
let forgedMessage1 = try mallory.initiateHandshake(with: receiverPeerID)
|
||||||
|
let forgedMessage2 = try #require(
|
||||||
|
try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage1)
|
||||||
|
)
|
||||||
|
// The replacement has not authenticated yet; the working Alice
|
||||||
|
// transport session must remain available throughout the candidate.
|
||||||
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let forgedMessage3 = try #require(
|
||||||
|
try mallory.processHandshakeMessage(from: receiverPeerID, message: forgedMessage2)
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage3)
|
||||||
|
Issue.record("Expected forged replacement to fail peer binding")
|
||||||
|
} catch let error as NoiseSessionError {
|
||||||
|
#expect(error == .peerIdentityMismatch)
|
||||||
|
} catch {
|
||||||
|
Issue.record("Unexpected replacement error: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let after = try alice.encrypt(Data("after".utf8), for: receiverPeerID)
|
||||||
|
#expect(try receiver.decrypt(after, from: alicePeerID) == Data("after".utf8))
|
||||||
|
let emittedReplacementAuthentication = await TestHelpers.waitUntil(
|
||||||
|
{ recorder.count > 1 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!emittedReplacementAuthentication)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Valid rehandshake atomically replaces the established session")
|
||||||
|
func validRehandshakeReplacesEstablishedSession() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
try establishSessions(alice: alice, bob: receiver)
|
||||||
|
alice.clearSession(for: receiverPeerID)
|
||||||
|
|
||||||
|
let message1 = try alice.initiateHandshake(with: receiverPeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try receiver.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||||
|
)
|
||||||
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let message3 = try #require(
|
||||||
|
try alice.processHandshakeMessage(from: receiverPeerID, message: message2)
|
||||||
|
)
|
||||||
|
_ = try receiver.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||||
|
|
||||||
|
#expect(alice.hasEstablishedSession(with: receiverPeerID))
|
||||||
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let ciphertext = try alice.encrypt(Data("new session".utf8), for: receiverPeerID)
|
||||||
|
#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)
|
||||||
|
let originalGeneration = try #require(alice.sessionGeneration(for: bobPeerID))
|
||||||
|
var leaseRan = false
|
||||||
|
let leased = alice.withCurrentSessionGeneration(
|
||||||
|
for: bobPeerID,
|
||||||
|
expected: originalGeneration
|
||||||
|
) {
|
||||||
|
leaseRan = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#expect(leased == true)
|
||||||
|
#expect(leaseRan)
|
||||||
|
|
||||||
|
var emittedPeerID: PeerID?
|
||||||
|
var emittedMessage: Data?
|
||||||
|
alice.onRekeyHandshakeReady = { peerID, message in
|
||||||
|
emittedPeerID = peerID
|
||||||
|
emittedMessage = message
|
||||||
|
}
|
||||||
|
try alice._test_initiateAutomaticRekey(for: bobPeerID)
|
||||||
|
|
||||||
|
#expect(emittedPeerID == bobPeerID)
|
||||||
|
#expect(alice.sessionGeneration(for: bobPeerID) == nil)
|
||||||
|
leaseRan = false
|
||||||
|
let staleLease = alice.withCurrentSessionGeneration(
|
||||||
|
for: bobPeerID,
|
||||||
|
expected: originalGeneration
|
||||||
|
) {
|
||||||
|
leaseRan = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#expect(staleLease == nil)
|
||||||
|
#expect(!leaseRan)
|
||||||
|
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))
|
||||||
|
#expect(alice.sessionGeneration(for: bobPeerID) != originalGeneration)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Large private-file payloads use the bounded Noise extension")
|
||||||
|
func largePrivateFileNoiseRoundTrip() 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)
|
||||||
|
|
||||||
|
let content = Data("%PDF-1.7\n".utf8) + Data(repeating: 0x51, count: 96 * 1024)
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: "large-private.pdf",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let typedPayload = try #require(BLENoisePayloadFactory.privateFile(file))
|
||||||
|
#expect(typedPayload.count > NoiseSecurityConstants.maxMessageSize)
|
||||||
|
#expect(typedPayload.first == NoisePayloadType.privateFile.rawValue)
|
||||||
|
#expect(
|
||||||
|
typedPayload.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize,
|
||||||
|
"typedBytes=\(typedPayload.count) limit=\(NoiseSecurityConstants.maxPrivateFilePlaintextSize)"
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try alice.encrypt(typedPayload, for: bobPeerID)
|
||||||
|
Issue.record("Ordinary Noise payload path must retain its 64 KiB ceiling")
|
||||||
|
} catch NoiseSecurityError.messageTooLarge {
|
||||||
|
// Expected: only the purpose-specific private-file API may extend it.
|
||||||
|
}
|
||||||
|
|
||||||
|
let ciphertext: Data
|
||||||
|
do {
|
||||||
|
ciphertext = try alice.encryptPrivateFilePayload(typedPayload, for: bobPeerID)
|
||||||
|
} catch {
|
||||||
|
Issue.record("Private-file encryption failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let decrypted: Data
|
||||||
|
do {
|
||||||
|
decrypted = try bob.decrypt(ciphertext, from: alicePeerID)
|
||||||
|
} catch {
|
||||||
|
Issue.record("Private-file decryption failed: \(error); ciphertextBytes=\(ciphertext.count)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(ciphertext.range(of: content) == nil)
|
||||||
|
#expect(decrypted == typedPayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
||||||
@@ -200,16 +427,16 @@ struct NoiseEncryptionServiceTests {
|
|||||||
|
|
||||||
private func establishSessions(
|
private func establishSessions(
|
||||||
alice: NoiseEncryptionService,
|
alice: NoiseEncryptionService,
|
||||||
bob: NoiseEncryptionService,
|
bob: NoiseEncryptionService
|
||||||
alicePeerID: PeerID,
|
|
||||||
bobPeerID: PeerID
|
|
||||||
) throws {
|
) throws {
|
||||||
let message1 = try alice.initiateHandshake(with: alicePeerID)
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
let response = try bob.processHandshakeMessage(from: bobPeerID, message: message1)
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
let message1 = try alice.initiateHandshake(with: bobPeerID)
|
||||||
|
let response = try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||||
let message2 = try #require(response, "Expected handshake response")
|
let message2 = try #require(response, "Expected handshake response")
|
||||||
let final = try alice.processHandshakeMessage(from: alicePeerID, message: message2)
|
let final = try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
||||||
let message3 = try #require(final, "Expected handshake final")
|
let message3 = try #require(final, "Expected handshake final")
|
||||||
let finalMessage = try bob.processHandshakeMessage(from: bobPeerID, message: message3)
|
let finalMessage = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||||
#expect(finalMessage == nil)
|
#expect(finalMessage == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,6 +444,7 @@ struct NoiseEncryptionServiceTests {
|
|||||||
private final class AuthenticationRecorder: @unchecked Sendable {
|
private final class AuthenticationRecorder: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var entries: [(PeerID, String)] = []
|
private var entries: [(PeerID, String)] = []
|
||||||
|
private var generationEntries: [(PeerID, UUID)] = []
|
||||||
|
|
||||||
var count: Int {
|
var count: Int {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
@@ -224,9 +452,27 @@ private final class AuthenticationRecorder: @unchecked Sendable {
|
|||||||
return entries.count
|
return entries.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var generationCount: Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return generationEntries.count
|
||||||
|
}
|
||||||
|
|
||||||
func record(peerID: PeerID, fingerprint: String) {
|
func record(peerID: PeerID, fingerprint: String) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
entries.append((peerID, fingerprint))
|
entries.append((peerID, fingerprint))
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func record(peerID: PeerID, fingerprint _: String, sessionGeneration: UUID) {
|
||||||
|
lock.lock()
|
||||||
|
generationEntries.append((peerID, sessionGeneration))
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func generation(for peerID: PeerID) -> UUID? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return generationEntries.last { $0.0 == peerID }?.1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -399,6 +399,59 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(cleared)
|
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_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 {
|
func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {
|
||||||
let keychain = FailingCacheSaveKeychain()
|
let keychain = FailingCacheSaveKeychain()
|
||||||
let manager = SecureIdentityStateManager(keychain)
|
let manager = SecureIdentityStateManager(keychain)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ struct TransferProgressManagerTests {
|
|||||||
recorder.append("updated:\(id):\(sent):\(total)")
|
recorder.append("updated:\(id):\(sent):\(total)")
|
||||||
case .completed(let id, let total):
|
case .completed(let id, let total):
|
||||||
recorder.append("completed:\(id):\(total)")
|
recorder.append("completed:\(id):\(total)")
|
||||||
case .cancelled:
|
case .cancelled, .rejected:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ struct TransferProgressManagerTests {
|
|||||||
recorder.append("started:\(id):\(total)")
|
recorder.append("started:\(id):\(total)")
|
||||||
case .cancelled(let id, let sent, let total):
|
case .cancelled(let id, let sent, let total):
|
||||||
recorder.append("cancelled:\(id):\(sent):\(total)")
|
recorder.append("cancelled:\(id):\(sent):\(total)")
|
||||||
case .updated, .completed:
|
case .updated, .completed, .rejected:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,6 +105,28 @@ struct TransferProgressManagerTests {
|
|||||||
#expect(manager.snapshot(id: transferID) == nil)
|
#expect(manager.snapshot(id: transferID) == nil)
|
||||||
_ = cancellable
|
_ = cancellable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Preflight policy rejection publishes a visible failure reason")
|
||||||
|
@MainActor
|
||||||
|
func rejectBeforeStartPublishesReason() async {
|
||||||
|
let manager = TransferProgressManager()
|
||||||
|
let transferID = "transfer-visible-reject"
|
||||||
|
let recorder = EventRecorder()
|
||||||
|
let cancellable = manager.publisher.sink { event in
|
||||||
|
if case .rejected(let id, let reason) = event {
|
||||||
|
recorder.append("rejected:\(id):\(reason)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.rejectBeforeStart(id: transferID, reason: "upgrade required")
|
||||||
|
|
||||||
|
let didReceive = await TestHelpers.waitUntil({
|
||||||
|
recorder.values == ["rejected:\(transferID):upgrade required"]
|
||||||
|
}, timeout: 5.0)
|
||||||
|
#expect(didReceive)
|
||||||
|
#expect(manager.snapshot(id: transferID) == nil)
|
||||||
|
_ = cancellable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class EventRecorder: @unchecked Sendable {
|
private final class EventRecorder: @unchecked Sendable {
|
||||||
|
|||||||
@@ -266,6 +266,11 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
|||||||
verified.removeAll()
|
verified.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {}
|
func removeEphemeralSession(peerID: PeerID) {}
|
||||||
|
|
||||||
func setVerified(fingerprint: String, verified: Bool) {
|
func setVerified(fingerprint: String, verified: Bool) {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Private-media wire migration
|
||||||
|
|
||||||
|
Private files use the `BitchatFilePacket` TLV shared by iOS and Android. The
|
||||||
|
preferred direct-message wire form encrypts that complete TLV inside the
|
||||||
|
peer's Noise session before BLE fragmentation.
|
||||||
|
|
||||||
|
## Wire values and capability
|
||||||
|
|
||||||
|
- `NoisePayloadType.privateFile` is `0x20`, the value already deployed by the
|
||||||
|
Android client. New sends must use this value.
|
||||||
|
- iOS temporarily accepts `0x09`, which appeared in prerelease builds of the
|
||||||
|
private-media change. Decoders canonicalize it to `privateFile`; they never
|
||||||
|
emit it.
|
||||||
|
- `NoisePayloadType.authenticatedPeerState` is permanently assigned `0x21`.
|
||||||
|
It is emitted after every completed/rekeyed Noise XX session and echoed at
|
||||||
|
most once when the remote state arrives, so message-3/proof reordering over
|
||||||
|
different mesh links converges. This type is part of the protocol security
|
||||||
|
boundary and is not removed when the media migration ends.
|
||||||
|
- The `0x21` payload starts with version `0x01`, followed by one-byte
|
||||||
|
type/length/value fields. Version 1 requires canonical TLV `0x01` (the
|
||||||
|
minimal little-endian `PeerCapabilities` bitfield, 1-8 bytes) and TLV `0x02`
|
||||||
|
(the 32-byte Ed25519 announcement signing key). Duplicate required fields,
|
||||||
|
non-minimal capabilities, malformed lengths, missing fields, and unknown
|
||||||
|
versions are ignored without changing state. Unknown TLVs are skipped.
|
||||||
|
- The public `PeerCapabilities.privateMedia` announce bit is a discovery hint:
|
||||||
|
it starts a Noise handshake, but never selects encrypted sending or creates
|
||||||
|
a pin. A private transfer waits boundedly for the exact session's encrypted
|
||||||
|
`0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a
|
||||||
|
no-proof timeout reaches the explicit legacy-consent path for an unpinned
|
||||||
|
peer. No timeout automatically sends raw bytes.
|
||||||
|
- An unpinned peer with a stable Noise key but without that capability is
|
||||||
|
eligible for one signed, directed
|
||||||
|
`fileTransfer`, matching the pre-migration wire form used by older iOS and
|
||||||
|
accepted by current Android clients, only after the sender confirms a
|
||||||
|
per-send warning that the file is not end-to-end encrypted and mesh relays
|
||||||
|
can see it. The
|
||||||
|
consent is consumed by that invocation and is never remembered.
|
||||||
|
- A signed announce never creates a pin by itself: an attacker can copy a
|
||||||
|
victim's public Noise key, supply its own Ed25519 key and capability bits,
|
||||||
|
and self-sign an internally consistent announce. Only successfully
|
||||||
|
decrypted `0x21` state pins the authenticated Noise fingerprint and binds
|
||||||
|
the Ed25519 key used by later announces/public messages. A later valid
|
||||||
|
no-bit `0x21` is treated as a downgrade, and raw fallback is blocked even if
|
||||||
|
a caller presents legacy consent. Public no-bit announces cannot overwrite
|
||||||
|
current session-authenticated state.
|
||||||
|
- During migration, both an absent capabilities TLV and an explicit TLV
|
||||||
|
without `privateMedia` are legacy-eligible when that stable fingerprint is
|
||||||
|
not pinned. This supports clients that added capability advertisement before
|
||||||
|
encrypted media. Neither shape bypasses a previously authenticated pin.
|
||||||
|
|
||||||
|
Older clients decrypt and ignore unknown inner type `0x21`; they do not need to
|
||||||
|
understand it to continue using text or the warned legacy media path. They are
|
||||||
|
never inferred capable merely because the handshake succeeded.
|
||||||
|
|
||||||
|
Removal gates are independent and must not share an arbitrary calendar date:
|
||||||
|
|
||||||
|
- Remove the `0x09` receive alias only after every TestFlight/internal build
|
||||||
|
that emitted it has expired and minimum-supported-client policy excludes it.
|
||||||
|
- Remove the signed directed raw `0x22` fallback only after minimum-supported
|
||||||
|
iOS and Android clients emit authenticated bit-8 `0x21` state and the legacy
|
||||||
|
population has aged out.
|
||||||
|
- Nostr kind `1059` compatibility is a separate envelope migration. Its dual
|
||||||
|
publish/removal gate is not evidence that either BLE compatibility shape can
|
||||||
|
be removed.
|
||||||
|
|
||||||
|
## Security boundary
|
||||||
|
|
||||||
|
The encrypted form provides Noise confidentiality and peer authentication.
|
||||||
|
The fallback is signed and its signature is required on receive, so relays
|
||||||
|
cannot forge its sender or contents. It is not confidential: relays can see
|
||||||
|
the raw file TLV. The UI says this explicitly and asks on every send. A peer
|
||||||
|
without a stable Noise key from a verified registry entry cannot use the
|
||||||
|
fallback. Keep it only for the mixed-version migration, and remove it only
|
||||||
|
after minimum-supported Android and iOS releases emit authenticated bit-8
|
||||||
|
`0x21` state and the legacy population has aged out. Never replace it with an
|
||||||
|
unsigned fallback, persist blanket consent, or send both forms.
|
||||||
|
|
||||||
|
Incoming clients accept all three migration-era shapes:
|
||||||
|
|
||||||
|
| Sender | Inbound form | Result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Current Android | Noise `0x20` | Decrypt and deliver |
|
||||||
|
| Prerelease iOS | Noise `0x09` | Decrypt, canonicalize, and deliver |
|
||||||
|
| Older client | Signed directed `fileTransfer` | Verify signature and deliver |
|
||||||
|
| Forged/unsigned raw sender | Directed `fileTransfer` | Reject |
|
||||||
|
|
||||||
|
Panic wipe clears the persistent capability pins together with the rest of
|
||||||
|
the encrypted identity cache.
|
||||||
|
|
||||||
|
This migration path is mesh-Noise-only (BLE and compatible direct mesh links).
|
||||||
|
Nostr private-media transport is unchanged and remains a follow-up. Nostr
|
||||||
|
inbound paths explicitly ignore `0x21`; do not infer the mesh consent fallback
|
||||||
|
or capability-pin semantics for Nostr delivery.
|
||||||
|
|
||||||
|
## Size interoperability
|
||||||
|
|
||||||
|
iOS bounds inbound file content at 1 MiB and applies the expanded allocation
|
||||||
|
budget only after a large Noise ciphertext authenticates to `0x20` or the
|
||||||
|
temporary `0x09` alias. Ordinary Noise messages retain their 64 KiB limit.
|
||||||
|
|
||||||
|
Current Android builds cap each reassembly at 256 fragments. Depending on the
|
||||||
|
negotiated BLE packet size and routing overhead, that is roughly 110-120 KiB,
|
||||||
|
well below iOS's absolute inbound ceiling. Private-media v1 therefore runs the
|
||||||
|
actual route-aware BLE fragment planner before both encrypted and consented
|
||||||
|
legacy sends and rejects any plan above 256 fragments with a visible failure.
|
||||||
|
This fragment-count contract, rather than a guessed byte threshold, stays
|
||||||
|
correct as route overhead changes.
|
||||||
@@ -24,6 +24,10 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
|
|||||||
/// (uplink/downlink carriers for mesh-only peers). Advertised alongside
|
/// (uplink/downlink carriers for mesh-only peers). Advertised alongside
|
||||||
/// a `bridgeGeohash` TLV carrying the rendezvous cell.
|
/// a `bridgeGeohash` TLV carrying the rendezvous cell.
|
||||||
public static let bridge = PeerCapabilities(rawValue: 1 << 7)
|
public static let bridge = PeerCapabilities(rawValue: 1 << 7)
|
||||||
|
/// Finalized direct-message media encrypted as Noise payload `0x20`
|
||||||
|
/// before outer BLE fragmentation. Peers that omit this bit require the
|
||||||
|
/// signed directed raw-file migration fallback.
|
||||||
|
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
|
||||||
|
|
||||||
/// Minimal little-endian byte encoding; always at least one byte so an
|
/// Minimal little-endian byte encoding; always at least one byte so an
|
||||||
/// empty set is distinguishable from an absent TLV.
|
/// empty set is distinguishable from an absent TLV.
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ struct PeerCapabilitiesTests {
|
|||||||
#expect(PeerCapabilities([]).encoded() == Data([0x00]))
|
#expect(PeerCapabilities([]).encoded() == Data([0x00]))
|
||||||
#expect(PeerCapabilities.prekeys.encoded() == Data([0x01]))
|
#expect(PeerCapabilities.prekeys.encoded() == Data([0x01]))
|
||||||
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
||||||
|
#expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01]))
|
||||||
|
|
||||||
let high = PeerCapabilities(rawValue: 1 << 9)
|
let high = PeerCapabilities(rawValue: 1 << 9)
|
||||||
#expect(high.encoded() == Data([0x00, 0x02]))
|
#expect(high.encoded() == Data([0x00, 0x02]))
|
||||||
|
|
||||||
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics]
|
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
|
||||||
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
||||||
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
||||||
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
||||||
|
|||||||
Reference in New Issue
Block a user