mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 17:25:22 +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
|
/// Transfers one bounded recovery generation to whatever ordinary XX
|
||||||
/// handshake currently owns the peer, or starts the generation's single
|
/// handshake currently owns the peer, or starts the generation's single
|
||||||
/// retry. The request token prevents stale transport callbacks from
|
/// retry. The request token prevents stale transport callbacks from
|
||||||
@@ -1121,12 +1131,22 @@ final class NoiseSessionManager {
|
|||||||
/// the exact session object that authenticated these bytes.
|
/// the exact session object that authenticated these bytes.
|
||||||
func decryptWithSessionGeneration(
|
func decryptWithSessionGeneration(
|
||||||
_ ciphertext: Data,
|
_ ciphertext: Data,
|
||||||
from peerID: PeerID
|
from peerID: PeerID,
|
||||||
|
establishedGenerationIsReady: (UUID) -> Bool = { _ in true },
|
||||||
|
authorizeDecrypt: () throws -> Void = {}
|
||||||
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||||
try managerQueue.sync {
|
try managerQueue.sync {
|
||||||
if let session = sessions[peerID],
|
if let session = sessions[peerID],
|
||||||
session.isEstablished(),
|
session.isEstablished(),
|
||||||
let generation = sessionGenerations[peerID] {
|
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)
|
return (try session.decrypt(ciphertext), generation)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1138,6 +1158,7 @@ final class NoiseSessionManager {
|
|||||||
responder.role == .responder,
|
responder.role == .responder,
|
||||||
responder.getState() == .handshaking,
|
responder.getState() == .handshaking,
|
||||||
let quarantined = quarantinedTransports[peerID] {
|
let quarantined = quarantinedTransports[peerID] {
|
||||||
|
try authorizeDecrypt()
|
||||||
return (
|
return (
|
||||||
try quarantined.session.decrypt(ciphertext),
|
try quarantined.session.decrypt(ciphertext),
|
||||||
quarantined.generation
|
quarantined.generation
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct BLENoiseHandshakeHandlingResult {
|
struct BLENoiseHandshakeHandlingResult {
|
||||||
@@ -33,6 +34,8 @@ struct BLENoisePacketHandlerEnvironment {
|
|||||||
-> NoiseHandshakeProcessingResult
|
-> NoiseHandshakeProcessingResult
|
||||||
/// Whether any Noise session (established or pending) exists for the peer (crypto).
|
/// Whether any Noise session (established or pending) exists for the peer (crypto).
|
||||||
let hasNoiseSession: (PeerID) -> Bool
|
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).
|
/// Initiates a fresh Noise handshake with the peer (crypto + send).
|
||||||
let initiateHandshake: (PeerID) -> Void
|
let initiateHandshake: (PeerID) -> Void
|
||||||
/// Broadcasts a packet on the mesh (caller is already on the message queue).
|
/// 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,
|
/// processing (with response), encrypted payload decryption and dispatch,
|
||||||
/// and session recovery on decrypt failure.
|
/// and session recovery on decrypt failure.
|
||||||
final class BLENoisePacketHandler {
|
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 environment: BLENoisePacketHandlerEnvironment
|
||||||
|
private let deferredLock = NSLock()
|
||||||
|
private var deferredCiphertexts: [PeerID: [DeferredCiphertext]] = [:]
|
||||||
|
private var deferredCiphertextBytes = 0
|
||||||
|
|
||||||
init(environment: BLENoisePacketHandlerEnvironment) {
|
init(environment: BLENoisePacketHandlerEnvironment) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
@@ -105,8 +129,8 @@ final class BLENoisePacketHandler {
|
|||||||
env.broadcastPacket(responsePacket)
|
env.broadcastPacket(responsePacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session establishment will trigger onPeerAuthenticated callback
|
// The serialized authentication callback installs transport
|
||||||
// which will send any pending messages at the right time
|
// state before it drains any bounded early ciphertext.
|
||||||
return BLENoiseHandshakeHandlingResult(
|
return BLENoiseHandshakeHandlingResult(
|
||||||
processed: true,
|
processed: true,
|
||||||
didEstablishAuthenticatedSession:
|
didEstablishAuthenticatedSession:
|
||||||
@@ -151,6 +175,20 @@ final class BLENoisePacketHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
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
|
let env = environment
|
||||||
guard let recipientID = PeerID(hexData: packet.recipientID) else {
|
guard let recipientID = PeerID(hexData: packet.recipientID) else {
|
||||||
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
|
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)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
|
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 {
|
} 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.
|
// 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")
|
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
|
||||||
if !env.hasNoiseSession(peerID) {
|
if !env.hasNoiseSession(peerID) {
|
||||||
env.initiateHandshake(peerID)
|
env.initiateHandshake(peerID)
|
||||||
}
|
}
|
||||||
} catch {
|
} 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
|
// 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")
|
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
|
||||||
env.clearSession(peerID)
|
env.clearSession(peerID)
|
||||||
env.initiateHandshake(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.
|
/// Builds an authenticated-session packet from an exact typed plaintext.
|
||||||
/// Compatibility tests use this to model Android's deployed 0x20 file
|
/// Compatibility tests use this to model Android's deployed 0x20 file
|
||||||
/// payload and the short-lived 0x09 prerelease payload without exposing a
|
/// payload and the short-lived 0x09 prerelease payload without exposing a
|
||||||
@@ -4649,7 +4671,10 @@ extension BLEService {
|
|||||||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||||||
service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in
|
service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in
|
||||||
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))…")
|
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(
|
self?.handleNoisePeerAuthenticated(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
@@ -4724,7 +4749,9 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in
|
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in
|
||||||
guard let self, let service else { return }
|
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,
|
guard let self,
|
||||||
let service,
|
let service,
|
||||||
self.noiseService === service,
|
self.noiseService === service,
|
||||||
@@ -4792,7 +4819,16 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
) else { return }
|
) 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)
|
completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade)
|
||||||
schedulePrivateMediaProofTimeout(
|
schedulePrivateMediaProofTimeout(
|
||||||
@@ -4801,6 +4837,10 @@ extension BLEService {
|
|||||||
sessionGeneration: generation,
|
sessionGeneration: generation,
|
||||||
nonce: watchdog.nonce
|
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
|
// `onPeerAuthenticated` can fire while the initiator is returning XX
|
||||||
// message 3. This callback is queued behind the handshake handler, so
|
// message 3. This callback is queued behind the handshake handler, so
|
||||||
@@ -6415,7 +6455,12 @@ extension BLEService {
|
|||||||
// MARK: Packet Reception
|
// MARK: Packet Reception
|
||||||
|
|
||||||
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
|
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 {
|
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||||||
guard let lifecycleGeneration =
|
guard let lifecycleGeneration =
|
||||||
capturePanicLifecycleGeneration() else {
|
capturePanicLifecycleGeneration() else {
|
||||||
@@ -6424,7 +6469,8 @@ extension BLEService {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
_test_beforeReceivePacketHandoff?()
|
_test_beforeReceivePacketHandoff?()
|
||||||
#endif
|
#endif
|
||||||
messageQueue.async { [weak self] in
|
let flags: DispatchWorkItemFlags = isNoisePacket ? .barrier : []
|
||||||
|
messageQueue.async(flags: flags) { [weak self] in
|
||||||
guard let self,
|
guard let self,
|
||||||
self.isCurrentPanicLifecycleGeneration(
|
self.isCurrentPanicLifecycleGeneration(
|
||||||
lifecycleGeneration
|
lifecycleGeneration
|
||||||
@@ -6434,11 +6480,34 @@ extension BLEService {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
self._test_onReceivePacketHandoff?()
|
self._test_onReceivePacketHandoff?()
|
||||||
#endif
|
#endif
|
||||||
self.handleReceivedPacket(packet, from: peerID)
|
self.handleReceivedPacketOnQueue(packet, from: peerID)
|
||||||
}
|
}
|
||||||
return
|
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 context = BLEReceivePipeline.context(for: packet, localPeerID: myPeerID)
|
||||||
let senderID = context.senderID
|
let senderID = context.senderID
|
||||||
let messageID = context.messageID
|
let messageID = context.messageID
|
||||||
@@ -7214,16 +7283,16 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
|
|
||||||
let result = noisePacketHandler.handleHandshakeWithResult(
|
let result = noisePacketHandler.handleHandshakeWithResult(
|
||||||
packet,
|
packet,
|
||||||
from: peerID
|
from: peerID
|
||||||
)
|
)
|
||||||
let isEstablished = noiseService.hasEstablishedSession(with: peerID)
|
// An inbound message 1 quarantines the old transport receive-only.
|
||||||
if wasEstablished, result.processed,
|
// Keep its generation-bound BLE state intact: the manager's new
|
||||||
!isEstablished {
|
// handshaking generation already gates every outbound policy, while
|
||||||
noteNoiseSessionCleared(for: peerID)
|
// 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 {
|
if result.didEstablishAuthenticatedSession {
|
||||||
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
||||||
}
|
}
|
||||||
@@ -7261,6 +7330,11 @@ extension BLEService {
|
|||||||
hasNoiseSession: { [weak self] peerID in
|
hasNoiseSession: { [weak self] peerID in
|
||||||
self?.noiseService.hasSession(with: peerID) ?? false
|
self?.noiseService.hasSession(with: peerID) ?? false
|
||||||
},
|
},
|
||||||
|
isAwaitingResponderHandshakeCompletion: { [weak self] peerID in
|
||||||
|
self?.noiseService.isAwaitingResponderHandshakeCompletion(
|
||||||
|
with: peerID
|
||||||
|
) ?? false
|
||||||
|
},
|
||||||
initiateHandshake: { [weak self] peerID in
|
initiateHandshake: { [weak self] peerID in
|
||||||
self?.initiateNoiseHandshake(with: peerID)
|
self?.initiateNoiseHandshake(with: peerID)
|
||||||
},
|
},
|
||||||
@@ -7274,7 +7348,14 @@ extension BLEService {
|
|||||||
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
|
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
|
||||||
let result = try self.noiseService.decryptWithSessionGeneration(
|
let result = try self.noiseService.decryptWithSessionGeneration(
|
||||||
payload,
|
payload,
|
||||||
from: peerID
|
from: peerID,
|
||||||
|
establishedGenerationIsReady: { generation in
|
||||||
|
self.collectionsQueue.sync {
|
||||||
|
self.privateMediaSessionGenerations[
|
||||||
|
peerID.toShort()
|
||||||
|
] == generation
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
return BLENoiseDecryptionResult(
|
return BLENoiseDecryptionResult(
|
||||||
plaintext: result.plaintext,
|
plaintext: result.plaintext,
|
||||||
|
|||||||
@@ -853,6 +853,13 @@ final class NoiseEncryptionService {
|
|||||||
func hasSession(with peerID: PeerID) -> Bool {
|
func hasSession(with peerID: PeerID) -> Bool {
|
||||||
return sessionManager.getSession(for: peerID) != nil
|
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
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
@@ -920,20 +927,15 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
func decryptWithSessionGeneration(
|
func decryptWithSessionGeneration(
|
||||||
_ data: Data,
|
_ data: Data,
|
||||||
from peerID: PeerID
|
from peerID: PeerID,
|
||||||
|
establishedGenerationIsReady: (UUID) -> Bool = { _ in true }
|
||||||
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||||
// Standard transport ciphertext has 20 bytes of nonce/tag overhead.
|
// 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`.
|
// after authenticated decryption it must prove it is `.privateFile`.
|
||||||
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
|
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
|
||||||
guard isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) else {
|
let isAdmittedCiphertext = isStandardCiphertext
|
||||||
throw NoiseSecurityError.messageTooLarge
|
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(data)
|
||||||
}
|
|
||||||
|
|
||||||
// Check rate limit
|
|
||||||
guard rateLimiter.allowMessage(from: peerID) else {
|
|
||||||
throw NoiseSecurityError.rateLimitExceeded
|
|
||||||
}
|
|
||||||
|
|
||||||
// A quarantined transport is deliberately unavailable for outbound
|
// A quarantined transport is deliberately unavailable for outbound
|
||||||
// state, but remains receive-only until the responder proves identity
|
// state, but remains receive-only until the responder proves identity
|
||||||
@@ -942,7 +944,20 @@ final class NoiseEncryptionService {
|
|||||||
throw NoiseEncryptionError.sessionNotEstablished
|
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 {
|
if !isStandardCiphertext {
|
||||||
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
|
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
|
||||||
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
|
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
|
||||||
@@ -1159,6 +1174,9 @@ struct NoiseMessage: Codable {
|
|||||||
enum NoiseEncryptionError: Error {
|
enum NoiseEncryptionError: Error {
|
||||||
case handshakeRequired
|
case handshakeRequired
|
||||||
case sessionNotEstablished
|
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
|
/// Envelope references a prekey ID we don't hold (never ours, already
|
||||||
/// deleted after its grace window, or wiped in a panic).
|
/// deleted after its grace window, or wiped in a panic).
|
||||||
case unknownPrekey
|
case unknownPrekey
|
||||||
|
|||||||
@@ -639,7 +639,7 @@ struct BLEServiceCoreTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func failedInboundReconnectRestoresAndDrainsTypedPayloadQueue() async throws {
|
func failedInboundReconnectRestoresAndDrainsWaitingWorkOnce() async throws {
|
||||||
let ble = makeService()
|
let ble = makeService()
|
||||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
@@ -722,21 +722,48 @@ struct BLEServiceCoreTests {
|
|||||||
message: forgedMessage2
|
message: forgedMessage2
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
let forgedEarlyPayload = try #require(
|
||||||
|
BLENoisePayloadFactory.privateMessage(
|
||||||
|
content: "forged early message",
|
||||||
|
messageID: "forged-early"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try #require(
|
||||||
|
mallory.hasEstablishedSession(with: ble.myPeerID),
|
||||||
|
"forged initiator did not establish after producing message three"
|
||||||
|
)
|
||||||
|
let forgedEarlyCiphertext = try mallory.encrypt(
|
||||||
|
forgedEarlyPayload,
|
||||||
|
for: ble.myPeerID
|
||||||
|
)
|
||||||
|
let earlyPacket = BitchatPacket(
|
||||||
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
|
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||||
|
recipientID: Data(hexString: ble.myPeerID.id),
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1,
|
||||||
|
payload: forgedEarlyCiphertext,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 7
|
||||||
|
)
|
||||||
|
ble._test_handlePacket(earlyPacket, fromPeerID: alicePeerID)
|
||||||
|
await ble._test_drainNoiseMessagePipeline()
|
||||||
|
|
||||||
let thirdPacket = BitchatPacket(
|
let thirdPacket = BitchatPacket(
|
||||||
type: MessageType.noiseHandshake.rawValue,
|
type: MessageType.noiseHandshake.rawValue,
|
||||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||||
recipientID: Data(hexString: ble.myPeerID.id),
|
recipientID: Data(hexString: ble.myPeerID.id),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1,
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 2,
|
||||||
payload: forgedMessage3,
|
payload: forgedMessage3,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: 7
|
ttl: 7
|
||||||
)
|
)
|
||||||
ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID)
|
ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID)
|
||||||
|
|
||||||
// Restore re-enters the generation-bound authentication transition:
|
// Rollback restores the same generation. It retries the bounded early
|
||||||
// authenticated state and both outbound queues drain exactly once.
|
// ciphertext and drains both outbound queues, but must not repeat a
|
||||||
|
// new-generation capability proof or forced announce.
|
||||||
let drained = await TestHelpers.waitUntil(
|
let drained = await TestHelpers.waitUntil(
|
||||||
{ outbound.count(ofType: .noiseEncrypted) >= 3 },
|
{ outbound.count(ofType: .noiseEncrypted) >= 2 },
|
||||||
timeout: TestConstants.longTimeout
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
try #require(drained)
|
try #require(drained)
|
||||||
@@ -744,11 +771,11 @@ struct BLEServiceCoreTests {
|
|||||||
let plaintexts = try outbound.snapshot()
|
let plaintexts = try outbound.snapshot()
|
||||||
.filter { $0.type == MessageType.noiseEncrypted.rawValue }
|
.filter { $0.type == MessageType.noiseEncrypted.rawValue }
|
||||||
.map { try alice.decrypt($0.payload, from: ble.myPeerID) }
|
.map { try alice.decrypt($0.payload, from: ble.myPeerID) }
|
||||||
#expect(plaintexts.count == 3)
|
#expect(plaintexts.count == 2)
|
||||||
#expect(
|
#expect(
|
||||||
plaintexts.filter {
|
plaintexts.filter {
|
||||||
$0.first == NoisePayloadType.authenticatedPeerState.rawValue
|
$0.first == NoisePayloadType.authenticatedPeerState.rawValue
|
||||||
}.count == 1
|
}.isEmpty
|
||||||
)
|
)
|
||||||
#expect(
|
#expect(
|
||||||
plaintexts.filter {
|
plaintexts.filter {
|
||||||
@@ -760,6 +787,13 @@ struct BLEServiceCoreTests {
|
|||||||
$0.first == NoisePayloadType.groupInvite.rawValue
|
$0.first == NoisePayloadType.groupInvite.rawValue
|
||||||
}.count == 1
|
}.count == 1
|
||||||
)
|
)
|
||||||
|
#expect(outbound.count(ofType: .announce) == 0)
|
||||||
|
|
||||||
|
// A duplicate ready callback cannot replay either buffer.
|
||||||
|
ble._test_reconcileCurrentNoiseSession(for: alicePeerID)
|
||||||
|
await ble._test_drainNoiseMessagePipeline()
|
||||||
|
#expect(outbound.count(ofType: .noiseEncrypted) == 2)
|
||||||
|
#expect(outbound.count(ofType: .announce) == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A legitimate rotation announce necessarily arrives on a link still
|
/// A legitimate rotation announce necessarily arrives on a link still
|
||||||
@@ -1255,6 +1289,7 @@ private final class PublicCaptureDelegate: BitchatDelegate {
|
|||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
return publicMessages
|
return publicMessages
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
@@ -11,7 +12,11 @@ struct BLENoisePacketHandlerTests {
|
|||||||
var handshakeAuthenticated = false
|
var handshakeAuthenticated = false
|
||||||
var hasSession = false
|
var hasSession = false
|
||||||
let sessionGeneration = UUID()
|
let sessionGeneration = UUID()
|
||||||
|
var awaitingResponderHandshake = false
|
||||||
var decryptResult: Result<Data, Error> = .success(Data())
|
var decryptResult: Result<Data, Error> = .success(Data())
|
||||||
|
var currentDate = Date(timeIntervalSince1970: 1_000)
|
||||||
|
var transportGenerationReady = false
|
||||||
|
var forcedServiceDecryptError: Error?
|
||||||
|
|
||||||
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
|
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
|
||||||
var hasSessionQueries: [PeerID] = []
|
var hasSessionQueries: [PeerID] = []
|
||||||
@@ -34,11 +39,12 @@ struct BLENoisePacketHandlerTests {
|
|||||||
recorder: Recorder,
|
recorder: Recorder,
|
||||||
now: Date = Date(timeIntervalSince1970: 1_000)
|
now: Date = Date(timeIntervalSince1970: 1_000)
|
||||||
) -> BLENoisePacketHandler {
|
) -> BLENoisePacketHandler {
|
||||||
|
recorder.currentDate = now
|
||||||
let environment = BLENoisePacketHandlerEnvironment(
|
let environment = BLENoisePacketHandlerEnvironment(
|
||||||
localPeerID: { [localPeerID] in localPeerID },
|
localPeerID: { [localPeerID] in localPeerID },
|
||||||
localPeerIDData: { [localPeerIDData] in localPeerIDData },
|
localPeerIDData: { [localPeerIDData] in localPeerIDData },
|
||||||
messageTTL: TransportConfig.messageTTLDefault,
|
messageTTL: TransportConfig.messageTTLDefault,
|
||||||
now: { now },
|
now: { recorder.currentDate },
|
||||||
processHandshakeMessage: { peerID, message in
|
processHandshakeMessage: { peerID, message in
|
||||||
recorder.processedHandshakes.append((peerID, message))
|
recorder.processedHandshakes.append((peerID, message))
|
||||||
return NoiseHandshakeProcessingResult(
|
return NoiseHandshakeProcessingResult(
|
||||||
@@ -51,6 +57,9 @@ struct BLENoisePacketHandlerTests {
|
|||||||
recorder.hasSessionQueries.append(peerID)
|
recorder.hasSessionQueries.append(peerID)
|
||||||
return recorder.hasSession
|
return recorder.hasSession
|
||||||
},
|
},
|
||||||
|
isAwaitingResponderHandshakeCompletion: { _ in
|
||||||
|
recorder.awaitingResponderHandshake
|
||||||
|
},
|
||||||
initiateHandshake: { peerID in
|
initiateHandshake: { peerID in
|
||||||
recorder.initiatedHandshakes.append(peerID)
|
recorder.initiatedHandshakes.append(peerID)
|
||||||
recorder.events.append("initiateHandshake")
|
recorder.events.append("initiateHandshake")
|
||||||
@@ -82,6 +91,120 @@ struct BLENoisePacketHandlerTests {
|
|||||||
return BLENoisePacketHandler(environment: environment)
|
return BLENoisePacketHandler(environment: environment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeServiceBackedHandler(
|
||||||
|
service: NoiseEncryptionService,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
recorder: Recorder,
|
||||||
|
transportGenerationIsReady:
|
||||||
|
@escaping (UUID) -> Bool
|
||||||
|
) -> BLENoisePacketHandler {
|
||||||
|
BLENoisePacketHandler(
|
||||||
|
environment: BLENoisePacketHandlerEnvironment(
|
||||||
|
localPeerID: { localPeerID },
|
||||||
|
localPeerIDData: {
|
||||||
|
Data(hexString: localPeerID.id) ?? Data()
|
||||||
|
},
|
||||||
|
messageTTL: TransportConfig.messageTTLDefault,
|
||||||
|
now: { recorder.currentDate },
|
||||||
|
processHandshakeMessage: { peerID, message in
|
||||||
|
try service.processHandshakeMessageWithResult(
|
||||||
|
from: peerID,
|
||||||
|
message: message
|
||||||
|
)
|
||||||
|
},
|
||||||
|
hasNoiseSession: { peerID in
|
||||||
|
service.hasSession(with: peerID)
|
||||||
|
},
|
||||||
|
isAwaitingResponderHandshakeCompletion: { peerID in
|
||||||
|
service.isAwaitingResponderHandshakeCompletion(
|
||||||
|
with: peerID
|
||||||
|
)
|
||||||
|
},
|
||||||
|
initiateHandshake: { peerID in
|
||||||
|
recorder.initiatedHandshakes.append(peerID)
|
||||||
|
},
|
||||||
|
broadcastPacket: { packet in
|
||||||
|
recorder.broadcastPackets.append(packet)
|
||||||
|
},
|
||||||
|
updatePeerLastSeen: { peerID in
|
||||||
|
recorder.lastSeenUpdates.append(peerID)
|
||||||
|
},
|
||||||
|
decrypt: { payload, peerID in
|
||||||
|
recorder.decryptCalls.append((payload, peerID))
|
||||||
|
if let error = recorder.forcedServiceDecryptError {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
let result =
|
||||||
|
try service.decryptWithSessionGeneration(
|
||||||
|
payload,
|
||||||
|
from: peerID,
|
||||||
|
establishedGenerationIsReady:
|
||||||
|
transportGenerationIsReady
|
||||||
|
)
|
||||||
|
return BLENoiseDecryptionResult(
|
||||||
|
plaintext: result.plaintext,
|
||||||
|
sessionGeneration: result.sessionGeneration
|
||||||
|
)
|
||||||
|
},
|
||||||
|
clearSession: { peerID in
|
||||||
|
recorder.clearedSessions.append(peerID)
|
||||||
|
service.clearSession(for: peerID)
|
||||||
|
},
|
||||||
|
handleAuthenticatedPeerState: {
|
||||||
|
peerID, payload, generation in
|
||||||
|
recorder.authenticatedPeerStates.append(
|
||||||
|
(peerID, payload, generation)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
deliverNoisePayload: {
|
||||||
|
peerID, type, payload, timestamp in
|
||||||
|
recorder.deliveries.append(
|
||||||
|
(peerID, type, payload, timestamp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func establishedServices() throws -> (
|
||||||
|
sender: NoiseEncryptionService,
|
||||||
|
receiver: NoiseEncryptionService,
|
||||||
|
senderPeerID: PeerID,
|
||||||
|
receiverPeerID: PeerID
|
||||||
|
) {
|
||||||
|
let sender = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let senderPeerID = PeerID(
|
||||||
|
publicKey: sender.getStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
let receiverPeerID = PeerID(
|
||||||
|
publicKey: receiver.getStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
let message1 = try sender.initiateHandshake(with: receiverPeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try receiver.processHandshakeMessage(
|
||||||
|
from: senderPeerID,
|
||||||
|
message: message1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try sender.processHandshakeMessage(
|
||||||
|
from: receiverPeerID,
|
||||||
|
message: message2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = try receiver.processHandshakeMessage(
|
||||||
|
from: senderPeerID,
|
||||||
|
message: message3
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
senderPeerID,
|
||||||
|
receiverPeerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Handshake
|
// MARK: Handshake
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -364,6 +487,745 @@ struct BLENoisePacketHandlerTests {
|
|||||||
#expect(recorder.deliveries.isEmpty)
|
#expect(recorder.deliveries.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func earlyCiphertextIsRetriedAfterResponderHandshakeCompletes() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let encrypted = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 1)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 2)
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.type == .privateMessage)
|
||||||
|
#expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE]))
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func ciphertextQueuedAheadOfEstablishmentCallbackDoesNotConsumeNonce()
|
||||||
|
throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(
|
||||||
|
publicKey: alice.getStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
let message1 = try alice.initiateHandshake(with: bobPeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: message1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try alice.processHandshakeMessage(
|
||||||
|
from: bobPeerID,
|
||||||
|
message: message2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let typedPayload = Data([
|
||||||
|
NoisePayloadType.privateMessage.rawValue,
|
||||||
|
0xCA, 0xFE
|
||||||
|
])
|
||||||
|
let ciphertext = try alice.encrypt(
|
||||||
|
typedPayload,
|
||||||
|
for: bobPeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager promotion has completed, but the serialized BLE callback is
|
||||||
|
// deliberately still behind this ciphertext.
|
||||||
|
_ = try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: message3
|
||||||
|
)
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.transportGenerationReady = false
|
||||||
|
let handler = makeServiceBackedHandler(
|
||||||
|
service: bob,
|
||||||
|
localPeerID: bobPeerID,
|
||||||
|
recorder: recorder,
|
||||||
|
transportGenerationIsReady: { _ in
|
||||||
|
recorder.transportGenerationReady
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let packet = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: bobPeerID.id),
|
||||||
|
payload: ciphertext
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(packet, from: alicePeerID)
|
||||||
|
#expect(recorder.deliveries.isEmpty)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
|
||||||
|
// The exact ciphertext must still authenticate, proving the readiness
|
||||||
|
// rejection happened before the receive nonce was consumed.
|
||||||
|
recorder.transportGenerationReady = true
|
||||||
|
handler.handleSessionAuthenticated(alicePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 2)
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.type == .privateMessage)
|
||||||
|
#expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE]))
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func ciphertextQueuedAheadOfRestoreCallbackDoesNotConsumeNonce()
|
||||||
|
throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(
|
||||||
|
publicKey: alice.getStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
let initial1 = try alice.initiateHandshake(with: bobPeerID)
|
||||||
|
let initial2 = try #require(
|
||||||
|
try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: initial1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let initial3 = try #require(
|
||||||
|
try alice.processHandshakeMessage(
|
||||||
|
from: bobPeerID,
|
||||||
|
message: initial2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: initial3
|
||||||
|
)
|
||||||
|
let typedPayload = Data([
|
||||||
|
NoisePayloadType.privateMessage.rawValue,
|
||||||
|
0xBE, 0xEF
|
||||||
|
])
|
||||||
|
let delayedCiphertext = try alice.encrypt(
|
||||||
|
typedPayload,
|
||||||
|
for: bobPeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
let forged1 = try mallory.initiateHandshake(with: bobPeerID)
|
||||||
|
let forged2 = try #require(
|
||||||
|
try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: forged1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let forged3 = try #require(
|
||||||
|
try mallory.processHandshakeMessage(
|
||||||
|
from: bobPeerID,
|
||||||
|
message: forged2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(throws: NoiseSessionError.peerIdentityMismatch) {
|
||||||
|
try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: forged3
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||||
|
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.transportGenerationReady = false
|
||||||
|
let handler = makeServiceBackedHandler(
|
||||||
|
service: bob,
|
||||||
|
localPeerID: bobPeerID,
|
||||||
|
recorder: recorder,
|
||||||
|
transportGenerationIsReady: { _ in
|
||||||
|
recorder.transportGenerationReady
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let packet = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: bobPeerID.id),
|
||||||
|
payload: delayedCiphertext
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager rollback is visible, while the BLE restore callback is
|
||||||
|
// deliberately still queued behind this ciphertext.
|
||||||
|
handler.handleEncrypted(packet, from: alicePeerID)
|
||||||
|
#expect(recorder.deliveries.isEmpty)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
|
||||||
|
recorder.transportGenerationReady = true
|
||||||
|
handler.handleSessionAuthenticated(alicePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 2)
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.type == .privateMessage)
|
||||||
|
#expect(recorder.deliveries.first?.payload == Data([0xBE, 0xEF]))
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func oversizedCiphertextCannotEvictEstablishedTransport() throws {
|
||||||
|
let pair = try establishedServices()
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.transportGenerationReady = true
|
||||||
|
let handler = makeServiceBackedHandler(
|
||||||
|
service: pair.receiver,
|
||||||
|
localPeerID: pair.receiverPeerID,
|
||||||
|
recorder: recorder,
|
||||||
|
transportGenerationIsReady: { _ in
|
||||||
|
recorder.transportGenerationReady
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||||
|
payload: Data(
|
||||||
|
count:
|
||||||
|
NoiseSecurityConstants
|
||||||
|
.maxPrivateFileCiphertextSize + 1
|
||||||
|
)
|
||||||
|
),
|
||||||
|
from: pair.senderPeerID
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
|
||||||
|
)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
|
||||||
|
let valid = try pair.sender.encrypt(
|
||||||
|
Data([NoisePayloadType.privateMessage.rawValue, 0x01]),
|
||||||
|
for: pair.receiverPeerID
|
||||||
|
)
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||||
|
payload: valid
|
||||||
|
),
|
||||||
|
from: pair.senderPeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.payload == Data([0x01]))
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func forgedAuthenticationFailureCannotEvictEstablishedTransport()
|
||||||
|
throws {
|
||||||
|
let pair = try establishedServices()
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.transportGenerationReady = true
|
||||||
|
let handler = makeServiceBackedHandler(
|
||||||
|
service: pair.receiver,
|
||||||
|
localPeerID: pair.receiverPeerID,
|
||||||
|
recorder: recorder,
|
||||||
|
transportGenerationIsReady: { _ in
|
||||||
|
recorder.transportGenerationReady
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let valid = try pair.sender.encrypt(
|
||||||
|
Data([NoisePayloadType.privateMessage.rawValue, 0x02]),
|
||||||
|
for: pair.receiverPeerID
|
||||||
|
)
|
||||||
|
var forged = valid
|
||||||
|
forged[forged.index(before: forged.endIndex)] ^= 0xFF
|
||||||
|
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||||
|
payload: forged
|
||||||
|
),
|
||||||
|
from: pair.senderPeerID
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
|
||||||
|
)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
|
||||||
|
// Authentication failure leaves nonce state untouched, so the exact
|
||||||
|
// original ciphertext remains valid.
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||||
|
payload: valid
|
||||||
|
),
|
||||||
|
from: pair.senderPeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.payload == Data([0x02]))
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func replayCannotEvictEstablishedTransportOrBlockNextNonce() throws {
|
||||||
|
let pair = try establishedServices()
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.transportGenerationReady = true
|
||||||
|
let handler = makeServiceBackedHandler(
|
||||||
|
service: pair.receiver,
|
||||||
|
localPeerID: pair.receiverPeerID,
|
||||||
|
recorder: recorder,
|
||||||
|
transportGenerationIsReady: { _ in
|
||||||
|
recorder.transportGenerationReady
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let first = try pair.sender.encrypt(
|
||||||
|
Data([NoisePayloadType.privateMessage.rawValue, 0x03]),
|
||||||
|
for: pair.receiverPeerID
|
||||||
|
)
|
||||||
|
let firstPacket = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||||
|
payload: first
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(firstPacket, from: pair.senderPeerID)
|
||||||
|
handler.handleEncrypted(firstPacket, from: pair.senderPeerID)
|
||||||
|
#expect(
|
||||||
|
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
|
||||||
|
)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
|
||||||
|
let next = try pair.sender.encrypt(
|
||||||
|
Data([NoisePayloadType.privateMessage.rawValue, 0x04]),
|
||||||
|
for: pair.receiverPeerID
|
||||||
|
)
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||||
|
payload: next
|
||||||
|
),
|
||||||
|
from: pair.senderPeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(recorder.deliveries.count == 2)
|
||||||
|
#expect(recorder.deliveries.map { $0.payload } == [
|
||||||
|
Data([0x03]), Data([0x04])
|
||||||
|
])
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func rateLimitFailureCannotEvictEstablishedTransportOrConsumeNonce()
|
||||||
|
throws {
|
||||||
|
let pair = try establishedServices()
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.transportGenerationReady = true
|
||||||
|
recorder.forcedServiceDecryptError =
|
||||||
|
NoiseSecurityError.rateLimitExceeded
|
||||||
|
let handler = makeServiceBackedHandler(
|
||||||
|
service: pair.receiver,
|
||||||
|
localPeerID: pair.receiverPeerID,
|
||||||
|
recorder: recorder,
|
||||||
|
transportGenerationIsReady: { _ in
|
||||||
|
recorder.transportGenerationReady
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let valid = try pair.sender.encrypt(
|
||||||
|
Data([NoisePayloadType.privateMessage.rawValue, 0x05]),
|
||||||
|
for: pair.receiverPeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||||
|
payload: Data(repeating: 0xA5, count: 20)
|
||||||
|
),
|
||||||
|
from: pair.senderPeerID
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
|
||||||
|
)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
|
||||||
|
recorder.forcedServiceDecryptError = nil
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||||
|
payload: valid
|
||||||
|
),
|
||||||
|
from: pair.senderPeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.payload == Data([0x05]))
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func maximumPrivateFileCiphertextIsEligibleForDeferredRetry() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let encrypted = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
payload: Data(
|
||||||
|
count: NoiseSecurityConstants.maxPrivateFileCiphertextSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.privateFile.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 2)
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.type == .privateFile)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func oversizedEarlyCiphertextIsNotDeferred() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let encrypted = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
payload: Data(
|
||||||
|
count:
|
||||||
|
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 1)
|
||||||
|
#expect(recorder.deliveries.isEmpty)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func missingSessionCiphertextIsRetriedAfterResponderHandshakeCompletes() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
NoiseEncryptionError.sessionNotEstablished
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let encrypted = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||||
|
#expect(recorder.decryptCalls.count == 1)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 2)
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func lowNonceCiphertextIsRetriedAfterResponderHandshakeCompletes() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(NoiseError.replayDetected)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let encrypted = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.readReceipt.rawValue, 0x02])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 2)
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.deliveries.first?.type == .readReceipt)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func invalidDeferredCiphertextDoesNotClearAuthenticatedSession() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let encrypted = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 2)
|
||||||
|
#expect(recorder.deliveries.isEmpty)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func nonCipherFailureDuringResponderHandshakeIsDroppedNotDeferred() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(TestError())
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
),
|
||||||
|
from: remotePeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 1)
|
||||||
|
#expect(recorder.deliveries.isEmpty)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func earlyCiphertextBufferIsBoundedPerPeer() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
|
||||||
|
for index in 0..<5 {
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
timestamp: UInt64(900_000 + index)
|
||||||
|
),
|
||||||
|
from: remotePeerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(recorder.decryptCalls.count == 5)
|
||||||
|
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 9)
|
||||||
|
#expect(recorder.deliveries.count == 4)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func earlyCiphertextBufferIsBoundedGlobally() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let peers = (1...33).map {
|
||||||
|
PeerID(str: String(format: "%016llx", UInt64($0)))
|
||||||
|
}
|
||||||
|
let packet = makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
for peerID in peers {
|
||||||
|
handler.handleEncrypted(packet, from: peerID)
|
||||||
|
}
|
||||||
|
#expect(recorder.decryptCalls.count == 33)
|
||||||
|
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
for peerID in peers {
|
||||||
|
handler.handleSessionAuthenticated(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 65)
|
||||||
|
#expect(recorder.deliveries.count == 32)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func earlyCiphertextBufferKeepsPrivateFileRoomAndByteBound() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let peers = [
|
||||||
|
PeerID(str: "0000000000000001"),
|
||||||
|
PeerID(str: "0000000000000002"),
|
||||||
|
PeerID(str: "0000000000000003")
|
||||||
|
]
|
||||||
|
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
payload: Data(
|
||||||
|
count:
|
||||||
|
NoiseSecurityConstants.maxPrivateFileCiphertextSize
|
||||||
|
)
|
||||||
|
),
|
||||||
|
from: peers[0]
|
||||||
|
)
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
payload: Data(count: 256 * 1024)
|
||||||
|
),
|
||||||
|
from: peers[1]
|
||||||
|
)
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
payload: Data([0x01])
|
||||||
|
),
|
||||||
|
from: peers[2]
|
||||||
|
)
|
||||||
|
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
for peerID in peers {
|
||||||
|
handler.handleSessionAuthenticated(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 5)
|
||||||
|
#expect(recorder.deliveries.count == 2)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func expiredEarlyCiphertextIsNotRetried() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
),
|
||||||
|
from: remotePeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
recorder.currentDate =
|
||||||
|
recorder.currentDate.addingTimeInterval(
|
||||||
|
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||||
|
+ 0.001
|
||||||
|
)
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 1)
|
||||||
|
#expect(recorder.deliveries.isEmpty)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func earlyCiphertextSurvivesResponderHandshakeWindow() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.hasSession = true
|
||||||
|
recorder.awaitingResponderHandshake = true
|
||||||
|
recorder.decryptResult = .failure(
|
||||||
|
CryptoKitError.authenticationFailure
|
||||||
|
)
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
handler.handleEncrypted(
|
||||||
|
makeEncryptedPacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
),
|
||||||
|
from: remotePeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
recorder.currentDate =
|
||||||
|
recorder.currentDate.addingTimeInterval(
|
||||||
|
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||||
|
- 0.001
|
||||||
|
)
|
||||||
|
recorder.awaitingResponderHandshake = false
|
||||||
|
recorder.decryptResult = .success(
|
||||||
|
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||||
|
)
|
||||||
|
handler.handleSessionAuthenticated(remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.decryptCalls.count == 2)
|
||||||
|
#expect(recorder.deliveries.count == 1)
|
||||||
|
#expect(recorder.clearedSessions.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
private func makeHandshakePacket(recipientID: Data?) -> BitchatPacket {
|
private func makeHandshakePacket(recipientID: Data?) -> BitchatPacket {
|
||||||
BitchatPacket(
|
BitchatPacket(
|
||||||
type: MessageType.noiseHandshake.rawValue,
|
type: MessageType.noiseHandshake.rawValue,
|
||||||
@@ -378,14 +1240,15 @@ struct BLENoisePacketHandlerTests {
|
|||||||
|
|
||||||
private func makeEncryptedPacket(
|
private func makeEncryptedPacket(
|
||||||
recipientID: Data?,
|
recipientID: Data?,
|
||||||
timestamp: UInt64 = 900_000
|
timestamp: UInt64 = 900_000,
|
||||||
|
payload: Data = Data([0xC0, 0xFF, 0xEE])
|
||||||
) -> BitchatPacket {
|
) -> BitchatPacket {
|
||||||
BitchatPacket(
|
BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: remotePeerID.id) ?? Data(),
|
senderID: Data(hexString: remotePeerID.id) ?? Data(),
|
||||||
recipientID: recipientID,
|
recipientID: recipientID,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
payload: Data([0xC0, 0xFF, 0xEE]),
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: TransportConfig.messageTTLDefault
|
ttl: TransportConfig.messageTTLDefault
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1351,6 +1351,89 @@ struct NoiseEncryptionServiceTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Responder completion state spans ordinary XX message three")
|
||||||
|
func responderCompletionStateTracksOrdinaryHandshake() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(
|
||||||
|
publicKey: alice.getStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
let message1 = try alice.initiateHandshake(with: bobPeerID)
|
||||||
|
#expect(
|
||||||
|
!bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID)
|
||||||
|
)
|
||||||
|
|
||||||
|
let message2 = try #require(
|
||||||
|
try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: message1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID)
|
||||||
|
)
|
||||||
|
|
||||||
|
let message3 = try #require(
|
||||||
|
try alice.processHandshakeMessage(
|
||||||
|
from: bobPeerID,
|
||||||
|
message: message2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID)
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = try bob.processHandshakeMessage(
|
||||||
|
from: alicePeerID,
|
||||||
|
message: message3
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
!bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Transport readiness rejection spends no message budget")
|
||||||
|
func transportReadinessRejectionSpendsNoMessageBudget() 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 plaintext = Data([
|
||||||
|
NoisePayloadType.privateMessage.rawValue,
|
||||||
|
0xAB, 0xCD
|
||||||
|
])
|
||||||
|
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
|
||||||
|
|
||||||
|
for _ in 0...NoiseSecurityConstants.maxMessagesPerSecond {
|
||||||
|
do {
|
||||||
|
_ = try bob.decryptWithSessionGeneration(
|
||||||
|
ciphertext,
|
||||||
|
from: alicePeerID,
|
||||||
|
establishedGenerationIsReady: { _ in false }
|
||||||
|
)
|
||||||
|
Issue.record(
|
||||||
|
"Expected transport generation readiness rejection"
|
||||||
|
)
|
||||||
|
} catch NoiseEncryptionError.transportGenerationNotReady {
|
||||||
|
// Expected: authorization and nonce mutation are both later.
|
||||||
|
} catch {
|
||||||
|
Issue.record("Unexpected readiness error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let decrypted = try bob.decryptWithSessionGeneration(
|
||||||
|
ciphertext,
|
||||||
|
from: alicePeerID,
|
||||||
|
establishedGenerationIsReady: { _ in true }
|
||||||
|
)
|
||||||
|
#expect(decrypted.plaintext == plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("NoiseMessage JSON and binary encoding round-trip")
|
@Test("NoiseMessage JSON and binary encoding round-trip")
|
||||||
func noiseMessageRoundTrips() throws {
|
func noiseMessageRoundTrips() throws {
|
||||||
let message = NoiseMessage(
|
let message = NoiseMessage(
|
||||||
|
|||||||
Reference in New Issue
Block a user