mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 10:05:19 +00:00
Preserve early Noise ciphertext across reconnects
This commit is contained in:
@@ -141,6 +141,16 @@ final class NoiseSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this peer has an inbound ordinary XX responder that still
|
||||
/// needs message 3 before its receive keys can become authoritative.
|
||||
func isAwaitingResponderHandshakeCompletion(for peerID: PeerID) -> Bool {
|
||||
managerQueue.sync {
|
||||
guard let session = sessions[peerID] else { return false }
|
||||
return session.role == .responder
|
||||
&& session.getState() == .handshaking
|
||||
}
|
||||
}
|
||||
|
||||
/// Transfers one bounded recovery generation to whatever ordinary XX
|
||||
/// handshake currently owns the peer, or starts the generation's single
|
||||
/// retry. The request token prevents stale transport callbacks from
|
||||
@@ -1121,12 +1131,22 @@ final class NoiseSessionManager {
|
||||
/// the exact session object that authenticated these bytes.
|
||||
func decryptWithSessionGeneration(
|
||||
_ ciphertext: Data,
|
||||
from peerID: PeerID
|
||||
from peerID: PeerID,
|
||||
establishedGenerationIsReady: (UUID) -> Bool = { _ in true },
|
||||
authorizeDecrypt: () throws -> Void = {}
|
||||
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||
try managerQueue.sync {
|
||||
if let session = sessions[peerID],
|
||||
session.isEstablished(),
|
||||
let generation = sessionGenerations[peerID] {
|
||||
// Keep the generation lease across the transport-readiness
|
||||
// check and decrypt. Promotion/restoration needs this queue's
|
||||
// barrier, so no new receive nonce can be consumed before BLE
|
||||
// installs state for the exact generation.
|
||||
guard establishedGenerationIsReady(generation) else {
|
||||
throw NoiseEncryptionError.transportGenerationNotReady
|
||||
}
|
||||
try authorizeDecrypt()
|
||||
return (try session.decrypt(ciphertext), generation)
|
||||
}
|
||||
|
||||
@@ -1138,6 +1158,7 @@ final class NoiseSessionManager {
|
||||
responder.role == .responder,
|
||||
responder.getState() == .handshaking,
|
||||
let quarantined = quarantinedTransports[peerID] {
|
||||
try authorizeDecrypt()
|
||||
return (
|
||||
try quarantined.session.decrypt(ciphertext),
|
||||
quarantined.generation
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct BLENoiseHandshakeHandlingResult {
|
||||
@@ -33,6 +34,8 @@ struct BLENoisePacketHandlerEnvironment {
|
||||
-> NoiseHandshakeProcessingResult
|
||||
/// Whether any Noise session (established or pending) exists for the peer (crypto).
|
||||
let hasNoiseSession: (PeerID) -> Bool
|
||||
/// Whether an inbound ordinary XX responder is waiting for message 3.
|
||||
let isAwaitingResponderHandshakeCompletion: (PeerID) -> Bool
|
||||
/// Initiates a fresh Noise handshake with the peer (crypto + send).
|
||||
let initiateHandshake: (PeerID) -> Void
|
||||
/// Broadcasts a packet on the mesh (caller is already on the message queue).
|
||||
@@ -63,7 +66,28 @@ struct BLENoisePacketHandlerEnvironment {
|
||||
/// processing (with response), encrypted payload decryption and dispatch,
|
||||
/// and session recovery on decrypt failure.
|
||||
final class BLENoisePacketHandler {
|
||||
private struct DeferredCiphertext {
|
||||
let packet: BitchatPacket
|
||||
let receivedAt: Date
|
||||
}
|
||||
|
||||
/// Early post-handshake packets are normally tiny control messages or
|
||||
/// queued DMs. Keep the recovery surface deliberately small so an
|
||||
/// unauthenticated half-handshake cannot create an unbounded memory queue.
|
||||
private static let maxDeferredPacketsPerPeer = 4
|
||||
private static let maxDeferredPacketsGlobal = 32
|
||||
/// One legacy sender can immediately follow message 3 with the largest
|
||||
/// valid private-file ciphertext and has no application-level retry. Keep
|
||||
/// room for that packet plus a small control-message budget.
|
||||
private static let maxDeferredBytes =
|
||||
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 256 * 1024
|
||||
private static let deferredLifetime =
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||
|
||||
private let environment: BLENoisePacketHandlerEnvironment
|
||||
private let deferredLock = NSLock()
|
||||
private var deferredCiphertexts: [PeerID: [DeferredCiphertext]] = [:]
|
||||
private var deferredCiphertextBytes = 0
|
||||
|
||||
init(environment: BLENoisePacketHandlerEnvironment) {
|
||||
self.environment = environment
|
||||
@@ -105,8 +129,8 @@ final class BLENoisePacketHandler {
|
||||
env.broadcastPacket(responsePacket)
|
||||
}
|
||||
|
||||
// Session establishment will trigger onPeerAuthenticated callback
|
||||
// which will send any pending messages at the right time
|
||||
// The serialized authentication callback installs transport
|
||||
// state before it drains any bounded early ciphertext.
|
||||
return BLENoiseHandshakeHandlingResult(
|
||||
processed: true,
|
||||
didEstablishAuthenticatedSession:
|
||||
@@ -151,6 +175,20 @@ final class BLENoisePacketHandler {
|
||||
}
|
||||
|
||||
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
handleEncrypted(packet, from: peerID, isDeferredRetry: false)
|
||||
}
|
||||
|
||||
/// Called by the transport's serialized authentication callback after it
|
||||
/// has installed state for the promoted or restored session generation.
|
||||
func handleSessionAuthenticated(_ peerID: PeerID) {
|
||||
drainDeferredCiphertextsIfReady(for: peerID)
|
||||
}
|
||||
|
||||
private func handleEncrypted(
|
||||
_ packet: BitchatPacket,
|
||||
from peerID: PeerID,
|
||||
isDeferredRetry: Bool
|
||||
) {
|
||||
let env = environment
|
||||
guard let recipientID = PeerID(hexData: packet.recipientID) else {
|
||||
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
|
||||
@@ -192,19 +230,205 @@ final class BLENoisePacketHandler {
|
||||
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
|
||||
} catch NoiseEncryptionError.transportGenerationNotReady {
|
||||
if isDeferredRetry {
|
||||
SecureLogger.warning(
|
||||
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because its authenticated transport generation changed again",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
// The manager promoted or restored keys before BLE's serialized
|
||||
// callback installed generation-bound transport state. The
|
||||
// manager rejected this before decrypting, so replay is safe.
|
||||
deferCiphertext(packet, from: peerID)
|
||||
} catch NoiseEncryptionError.sessionNotEstablished {
|
||||
if isDeferredRetry {
|
||||
SecureLogger.warning(
|
||||
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because the authenticated session is unavailable",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
// We received an encrypted message before establishing a session with this peer.
|
||||
// Trigger a handshake so future messages can be decrypted.
|
||||
// An initiator may already have sent message 3 followed by this
|
||||
// ciphertext, with BLE delivering the ciphertext first.
|
||||
if env.isAwaitingResponderHandshakeCompletion(peerID) {
|
||||
deferCiphertext(packet, from: peerID)
|
||||
return
|
||||
}
|
||||
// Otherwise trigger a handshake so future messages can decrypt.
|
||||
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
|
||||
if !env.hasNoiseSession(peerID) {
|
||||
env.initiateHandshake(peerID)
|
||||
}
|
||||
} catch {
|
||||
if isDeferredRetry {
|
||||
// An early packet cannot tear down the authenticated session
|
||||
// merely because its single bounded retry still fails.
|
||||
SecureLogger.warning(
|
||||
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… after retry failed: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
// A responder may retain an older transport as receive-only
|
||||
// rollback state while ordinary XX waits for message 3. New-key
|
||||
// ciphertext can fail against those retained receive keys first.
|
||||
if env.isAwaitingResponderHandshakeCompletion(peerID) {
|
||||
if isDeferrableEarlyHandshakeFailure(error) {
|
||||
deferCiphertext(packet, from: peerID)
|
||||
} else {
|
||||
SecureLogger.warning(
|
||||
"Dropping invalid Noise ciphertext from \(peerID.id.prefix(8))… while responder handshake is completing: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if isDropOnlyCiphertextFailure(error) {
|
||||
// The packet is attacker-controlled and did not prove a
|
||||
// transport-state failure. Never let malformed, replayed,
|
||||
// forged, oversized, or rate-limited bytes evict working keys.
|
||||
SecureLogger.warning(
|
||||
"Dropping rejected Noise ciphertext from \(peerID.id.prefix(8))… without clearing its session: \(error)",
|
||||
category: .security
|
||||
)
|
||||
return
|
||||
}
|
||||
// Decryption failed - clear the corrupted session and re-initiate handshake
|
||||
// This handles cases where session state got out of sync (nonce mismatch, etc.)
|
||||
// Only local/session lifecycle failures reach this path.
|
||||
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
|
||||
env.clearSession(peerID)
|
||||
env.initiateHandshake(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func isDeferrableEarlyHandshakeFailure(_ error: Error) -> Bool {
|
||||
if let noiseError = error as? NoiseError {
|
||||
switch noiseError {
|
||||
case .authenticationFailure, .replayDetected:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
if let cryptoError = error as? CryptoKitError,
|
||||
case .authenticationFailure = cryptoError {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isDropOnlyCiphertextFailure(_ error: Error) -> Bool {
|
||||
if let securityError = error as? NoiseSecurityError {
|
||||
switch securityError {
|
||||
case .messageTooLarge, .rateLimitExceeded, .invalidPeerID:
|
||||
return true
|
||||
case .sessionExpired, .sessionExhausted:
|
||||
return false
|
||||
}
|
||||
}
|
||||
if let noiseError = error as? NoiseError {
|
||||
switch noiseError {
|
||||
case .invalidCiphertext, .authenticationFailure, .replayDetected:
|
||||
return true
|
||||
case .uninitializedCipher, .handshakeComplete,
|
||||
.handshakeNotComplete, .missingLocalStaticKey,
|
||||
.missingKeys, .invalidMessage, .invalidPublicKey,
|
||||
.nonceExceeded:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return error is CryptoKitError
|
||||
}
|
||||
|
||||
private func deferCiphertext(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
guard NoiseSecurityValidator.validatePrivateFileCiphertextSize(
|
||||
packet.payload
|
||||
) else {
|
||||
SecureLogger.warning(
|
||||
"Dropping oversized early Noise ciphertext from \(peerID.id.prefix(8))…",
|
||||
category: .security
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let now = environment.now()
|
||||
deferredLock.lock()
|
||||
defer { deferredLock.unlock() }
|
||||
purgeExpiredCiphertextsLocked(now: now)
|
||||
|
||||
let peerCount = deferredCiphertexts[peerID]?.count ?? 0
|
||||
let globalCount = deferredCiphertexts.values.reduce(0) {
|
||||
$0 + $1.count
|
||||
}
|
||||
guard peerCount < Self.maxDeferredPacketsPerPeer,
|
||||
globalCount < Self.maxDeferredPacketsGlobal,
|
||||
deferredCiphertextBytes + packet.payload.count
|
||||
<= Self.maxDeferredBytes else {
|
||||
SecureLogger.warning(
|
||||
"Dropping early Noise ciphertext from \(peerID.id.prefix(8))… because the handshake buffer is full",
|
||||
category: .security
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
deferredCiphertexts[peerID, default: []].append(
|
||||
DeferredCiphertext(packet: packet, receivedAt: now)
|
||||
)
|
||||
deferredCiphertextBytes += packet.payload.count
|
||||
SecureLogger.debug(
|
||||
"Deferring early Noise ciphertext from \(peerID.id.prefix(8))… until responder handshake completion",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
|
||||
private func drainDeferredCiphertextsIfReady(for peerID: PeerID) {
|
||||
let env = environment
|
||||
guard !env.isAwaitingResponderHandshakeCompletion(peerID),
|
||||
env.hasNoiseSession(peerID) else {
|
||||
return
|
||||
}
|
||||
|
||||
let now = env.now()
|
||||
deferredLock.lock()
|
||||
purgeExpiredCiphertextsLocked(now: now)
|
||||
let deferred = deferredCiphertexts.removeValue(forKey: peerID) ?? []
|
||||
deferredCiphertextBytes -= deferred.reduce(0) {
|
||||
$0 + $1.packet.payload.count
|
||||
}
|
||||
deferredLock.unlock()
|
||||
|
||||
guard !deferred.isEmpty else { return }
|
||||
SecureLogger.debug(
|
||||
"Retrying \(deferred.count) early Noise ciphertext packet(s) from \(peerID.id.prefix(8))… after handshake completion",
|
||||
category: .session
|
||||
)
|
||||
for item in deferred {
|
||||
handleEncrypted(item.packet, from: peerID, isDeferredRetry: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func purgeExpiredCiphertextsLocked(now: Date) {
|
||||
for peerID in Array(deferredCiphertexts.keys) {
|
||||
guard let items = deferredCiphertexts[peerID] else { continue }
|
||||
let retained = items.filter {
|
||||
now.timeIntervalSince($0.receivedAt) <= Self.deferredLifetime
|
||||
}
|
||||
guard retained.count != items.count else { continue }
|
||||
|
||||
deferredCiphertextBytes -= items.reduce(0) {
|
||||
$0 + $1.packet.payload.count
|
||||
}
|
||||
deferredCiphertextBytes += retained.reduce(0) {
|
||||
$0 + $1.packet.payload.count
|
||||
}
|
||||
if retained.isEmpty {
|
||||
deferredCiphertexts.removeValue(forKey: peerID)
|
||||
} else {
|
||||
deferredCiphertexts[peerID] = retained
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3556,6 +3556,28 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replays the current generation's ready callback. Restore tests use
|
||||
/// this to prove same-generation reconciliation is idempotent.
|
||||
func _test_reconcileCurrentNoiseSession(for peerID: PeerID) {
|
||||
let normalizedPeerID = peerID.toShort()
|
||||
messageQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self,
|
||||
let generation = self.noiseService.sessionGeneration(
|
||||
for: normalizedPeerID
|
||||
),
|
||||
let fingerprint = self.noiseService.getPeerFingerprint(
|
||||
normalizedPeerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.handleNoisePeerAuthenticated(
|
||||
peerID: normalizedPeerID,
|
||||
fingerprint: fingerprint,
|
||||
sessionGeneration: generation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an authenticated-session packet from an exact typed plaintext.
|
||||
/// Compatibility tests use this to model Android's deployed 0x20 file
|
||||
/// payload and the short-lived 0x09 prerelease payload without exposing a
|
||||
@@ -4649,7 +4671,10 @@ extension BLEService {
|
||||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||||
service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in
|
||||
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))…")
|
||||
self?.messageQueue.async { [weak self] in
|
||||
// Authentication can be reported while an initiator is still
|
||||
// returning XX message 3. Serialize generation-bound state and
|
||||
// every post-handshake drain behind the handshake packet handler.
|
||||
self?.messageQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.handleNoisePeerAuthenticated(
|
||||
peerID: peerID,
|
||||
fingerprint: fingerprint,
|
||||
@@ -4724,7 +4749,9 @@ extension BLEService {
|
||||
}
|
||||
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in
|
||||
guard let self, let service else { return }
|
||||
self.messageQueue.async { [weak self, weak service] in
|
||||
// The manager makes restored keys visible atomically. Reconcile
|
||||
// transport state and queued sends as the next serialized phase.
|
||||
self.messageQueue.async(flags: .barrier) { [weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service,
|
||||
@@ -4792,7 +4819,16 @@ extension BLEService {
|
||||
}
|
||||
) else { return }
|
||||
|
||||
guard let watchdog = transition.watchdog else { return }
|
||||
guard let watchdog = transition.watchdog else {
|
||||
// A quarantined transport restored the same cryptographic
|
||||
// generation. Its capability proof and announce state never
|
||||
// became stale; only work queued while outbound keys were paused
|
||||
// needs one idempotent ready transition.
|
||||
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
|
||||
sendPendingMessagesAfterHandshake(for: normalizedPeerID)
|
||||
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
|
||||
return
|
||||
}
|
||||
|
||||
completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade)
|
||||
schedulePrivateMediaProofTimeout(
|
||||
@@ -4801,6 +4837,10 @@ extension BLEService {
|
||||
sessionGeneration: generation,
|
||||
nonce: watchdog.nonce
|
||||
)
|
||||
// Cross-link delivery can put ciphertext sent immediately after
|
||||
// message 3 ahead of message 3 itself. Retry the bounded queue only
|
||||
// after this generation's transport state has been fully installed.
|
||||
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
|
||||
|
||||
// `onPeerAuthenticated` can fire while the initiator is returning XX
|
||||
// message 3. This callback is queued behind the handshake handler, so
|
||||
@@ -6415,7 +6455,12 @@ extension BLEService {
|
||||
// MARK: Packet Reception
|
||||
|
||||
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
// Call directly if already on messageQueue, otherwise dispatch
|
||||
let isNoisePacket = packet.type == MessageType.noiseHandshake.rawValue
|
||||
|| packet.type == MessageType.noiseEncrypted.rawValue
|
||||
|
||||
// Capture the panic lifecycle at the first off-messageQueue handoff.
|
||||
// Noise packets still enter through a barrier so handshake promotion,
|
||||
// quarantine, and encrypted delivery share one ordered session.
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||||
guard let lifecycleGeneration =
|
||||
capturePanicLifecycleGeneration() else {
|
||||
@@ -6424,7 +6469,8 @@ extension BLEService {
|
||||
#if DEBUG
|
||||
_test_beforeReceivePacketHandoff?()
|
||||
#endif
|
||||
messageQueue.async { [weak self] in
|
||||
let flags: DispatchWorkItemFlags = isNoisePacket ? .barrier : []
|
||||
messageQueue.async(flags: flags) { [weak self] in
|
||||
guard let self,
|
||||
self.isCurrentPanicLifecycleGeneration(
|
||||
lifecycleGeneration
|
||||
@@ -6434,11 +6480,34 @@ extension BLEService {
|
||||
#if DEBUG
|
||||
self._test_onReceivePacketHandoff?()
|
||||
#endif
|
||||
self.handleReceivedPacket(packet, from: peerID)
|
||||
self.handleReceivedPacketOnQueue(packet, from: peerID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if isNoisePacket {
|
||||
guard let lifecycleGeneration =
|
||||
capturePanicLifecycleGeneration() else {
|
||||
return
|
||||
}
|
||||
messageQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self,
|
||||
self.isCurrentPanicLifecycleGeneration(
|
||||
lifecycleGeneration
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.handleReceivedPacketOnQueue(packet, from: peerID)
|
||||
}
|
||||
} else {
|
||||
handleReceivedPacketOnQueue(packet, from: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleReceivedPacketOnQueue(
|
||||
_ packet: BitchatPacket,
|
||||
from peerID: PeerID
|
||||
) {
|
||||
let context = BLEReceivePipeline.context(for: packet, localPeerID: myPeerID)
|
||||
let senderID = context.senderID
|
||||
let messageID = context.messageID
|
||||
@@ -7214,16 +7283,16 @@ extension BLEService {
|
||||
}
|
||||
|
||||
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
|
||||
let result = noisePacketHandler.handleHandshakeWithResult(
|
||||
packet,
|
||||
from: peerID
|
||||
)
|
||||
let isEstablished = noiseService.hasEstablishedSession(with: peerID)
|
||||
if wasEstablished, result.processed,
|
||||
!isEstablished {
|
||||
noteNoiseSessionCleared(for: peerID)
|
||||
}
|
||||
// An inbound message 1 quarantines the old transport receive-only.
|
||||
// Keep its generation-bound BLE state intact: the manager's new
|
||||
// handshaking generation already gates every outbound policy, while
|
||||
// a rollback can become ready again without repeating capability
|
||||
// proof or announce side effects. Only the exact handshake candidate's
|
||||
// authenticated completion may promote the physical ingress link.
|
||||
if result.didEstablishAuthenticatedSession {
|
||||
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
||||
}
|
||||
@@ -7261,6 +7330,11 @@ extension BLEService {
|
||||
hasNoiseSession: { [weak self] peerID in
|
||||
self?.noiseService.hasSession(with: peerID) ?? false
|
||||
},
|
||||
isAwaitingResponderHandshakeCompletion: { [weak self] peerID in
|
||||
self?.noiseService.isAwaitingResponderHandshakeCompletion(
|
||||
with: peerID
|
||||
) ?? false
|
||||
},
|
||||
initiateHandshake: { [weak self] peerID in
|
||||
self?.initiateNoiseHandshake(with: peerID)
|
||||
},
|
||||
@@ -7274,7 +7348,14 @@ extension BLEService {
|
||||
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
|
||||
let result = try self.noiseService.decryptWithSessionGeneration(
|
||||
payload,
|
||||
from: peerID
|
||||
from: peerID,
|
||||
establishedGenerationIsReady: { generation in
|
||||
self.collectionsQueue.sync {
|
||||
self.privateMediaSessionGenerations[
|
||||
peerID.toShort()
|
||||
] == generation
|
||||
}
|
||||
}
|
||||
)
|
||||
return BLENoiseDecryptionResult(
|
||||
plaintext: result.plaintext,
|
||||
|
||||
@@ -853,6 +853,13 @@ final class NoiseEncryptionService {
|
||||
func hasSession(with peerID: PeerID) -> Bool {
|
||||
return sessionManager.getSession(for: peerID) != nil
|
||||
}
|
||||
|
||||
/// True while an inbound ordinary XX responder is waiting for message 3.
|
||||
/// A small amount of immediately-following ciphertext may arrive first
|
||||
/// over BLE and must be retried only after responder promotion.
|
||||
func isAwaitingResponderHandshakeCompletion(with peerID: PeerID) -> Bool {
|
||||
sessionManager.isAwaitingResponderHandshakeCompletion(for: peerID)
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
@@ -920,20 +927,15 @@ final class NoiseEncryptionService {
|
||||
|
||||
func decryptWithSessionGeneration(
|
||||
_ data: Data,
|
||||
from peerID: PeerID
|
||||
from peerID: PeerID,
|
||||
establishedGenerationIsReady: (UUID) -> Bool = { _ in true }
|
||||
) 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;
|
||||
// A larger ciphertext 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
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
guard rateLimiter.allowMessage(from: peerID) else {
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
let isAdmittedCiphertext = isStandardCiphertext
|
||||
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(data)
|
||||
|
||||
// A quarantined transport is deliberately unavailable for outbound
|
||||
// state, but remains receive-only until the responder proves identity
|
||||
@@ -942,7 +944,20 @@ final class NoiseEncryptionService {
|
||||
throw NoiseEncryptionError.sessionNotEstablished
|
||||
}
|
||||
|
||||
let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID)
|
||||
let result = try sessionManager.decryptWithSessionGeneration(
|
||||
data,
|
||||
from: peerID,
|
||||
establishedGenerationIsReady:
|
||||
establishedGenerationIsReady,
|
||||
authorizeDecrypt: { [rateLimiter] in
|
||||
guard isAdmittedCiphertext else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
guard rateLimiter.allowMessage(from: peerID) else {
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
)
|
||||
if !isStandardCiphertext {
|
||||
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
|
||||
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
|
||||
@@ -1159,6 +1174,9 @@ struct NoiseMessage: Codable {
|
||||
enum NoiseEncryptionError: Error {
|
||||
case handshakeRequired
|
||||
case sessionNotEstablished
|
||||
/// Manager keys are established or restored, but BLE has not installed
|
||||
/// generation-bound transport state. No receive nonce was consumed.
|
||||
case transportGenerationNotReady
|
||||
/// Envelope references a prekey ID we don't hold (never ours, already
|
||||
/// deleted after its grace window, or wiped in a panic).
|
||||
case unknownPrekey
|
||||
|
||||
Reference in New Issue
Block a user