mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
Bind capability state to Noise generations
This commit is contained in:
@@ -13,6 +13,10 @@ import BitFoundation
|
|||||||
|
|
||||||
final class NoiseSessionManager {
|
final class NoiseSessionManager {
|
||||||
private var sessions: [PeerID: NoiseSession] = [:]
|
private var sessions: [PeerID: NoiseSession] = [:]
|
||||||
|
/// Opaque identity for each exact entry in `sessions`. The generation is
|
||||||
|
/// created and removed under the same barrier as the session itself, so a
|
||||||
|
/// caller can never authenticate data with one session and lease another.
|
||||||
|
private var sessionGenerations: [PeerID: UUID] = [:]
|
||||||
/// A responder rehandshake must not evict a working transport session
|
/// A responder rehandshake must not evict a working transport session
|
||||||
/// before the candidate proves that its authenticated static key belongs
|
/// before the candidate proves that its authenticated static key belongs
|
||||||
/// to the claimed wire ID. Candidates therefore live outside `sessions`
|
/// to the claimed wire ID. Candidates therefore live outside `sessions`
|
||||||
@@ -22,7 +26,7 @@ final class NoiseSessionManager {
|
|||||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)?
|
||||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||||
|
|
||||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||||
@@ -59,6 +63,7 @@ final class NoiseSessionManager {
|
|||||||
if let session = sessions.removeValue(forKey: peerID) {
|
if let session = sessions.removeValue(forKey: peerID) {
|
||||||
session.reset() // Clear sensitive data before removing
|
session.reset() // Clear sensitive data before removing
|
||||||
}
|
}
|
||||||
|
sessionGenerations.removeValue(forKey: peerID)
|
||||||
if let candidate = responderCandidates.removeValue(forKey: peerID) {
|
if let candidate = responderCandidates.removeValue(forKey: peerID) {
|
||||||
candidate.reset()
|
candidate.reset()
|
||||||
}
|
}
|
||||||
@@ -74,6 +79,7 @@ final class NoiseSessionManager {
|
|||||||
candidate.reset()
|
candidate.reset()
|
||||||
}
|
}
|
||||||
sessions.removeAll()
|
sessions.removeAll()
|
||||||
|
sessionGenerations.removeAll()
|
||||||
responderCandidates.removeAll()
|
responderCandidates.removeAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,12 +97,14 @@ final class NoiseSessionManager {
|
|||||||
// Remove any existing non-established session
|
// Remove any existing non-established session
|
||||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
sessionGenerations.removeValue(forKey: peerID)
|
||||||
existingSession.reset()
|
existingSession.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new initiator session
|
// Create new initiator session
|
||||||
let session = sessionFactory(peerID, .initiator)
|
let session = sessionFactory(peerID, .initiator)
|
||||||
sessions[peerID] = session
|
sessions[peerID] = session
|
||||||
|
sessionGenerations[peerID] = UUID()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let handshakeData = try session.startHandshake()
|
let handshakeData = try session.startHandshake()
|
||||||
@@ -104,6 +112,7 @@ final class NoiseSessionManager {
|
|||||||
} catch {
|
} catch {
|
||||||
// Clean up failed session
|
// Clean up failed session
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
sessionGenerations.removeValue(forKey: peerID)
|
||||||
session.reset()
|
session.reset()
|
||||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||||
throw error
|
throw error
|
||||||
@@ -112,8 +121,17 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
// Process everything within the synchronized block to prevent race conditions
|
// Process everything within the synchronized block to prevent race conditions.
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
// Return establishment metadata and publish the callback only after the
|
||||||
|
// manager barrier is released, avoiding both a deadlock and a window in
|
||||||
|
// which `processHandshakeMessage` returns before authentication state.
|
||||||
|
let result: (
|
||||||
|
response: Data?,
|
||||||
|
establishedSession: (
|
||||||
|
remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||||
|
generation: UUID
|
||||||
|
)?
|
||||||
|
) = try managerQueue.sync(flags: .barrier) {
|
||||||
let session: NoiseSession
|
let session: NoiseSession
|
||||||
let isReplacementCandidate: Bool
|
let isReplacementCandidate: Bool
|
||||||
|
|
||||||
@@ -144,9 +162,11 @@ final class NoiseSessionManager {
|
|||||||
// No established transport state exists to preserve. A
|
// No established transport state exists to preserve. A
|
||||||
// fresh initiation replaces the incomplete handshake.
|
// fresh initiation replaces the incomplete handshake.
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
sessionGenerations.removeValue(forKey: peerID)
|
||||||
existing.reset()
|
existing.reset()
|
||||||
let replacement = sessionFactory(peerID, .responder)
|
let replacement = sessionFactory(peerID, .responder)
|
||||||
sessions[peerID] = replacement
|
sessions[peerID] = replacement
|
||||||
|
sessionGenerations[peerID] = UUID()
|
||||||
session = replacement
|
session = replacement
|
||||||
isReplacementCandidate = false
|
isReplacementCandidate = false
|
||||||
} else {
|
} else {
|
||||||
@@ -156,6 +176,7 @@ final class NoiseSessionManager {
|
|||||||
} else {
|
} else {
|
||||||
let newSession = sessionFactory(peerID, .responder)
|
let newSession = sessionFactory(peerID, .responder)
|
||||||
sessions[peerID] = newSession
|
sessions[peerID] = newSession
|
||||||
|
sessionGenerations[peerID] = UUID()
|
||||||
session = newSession
|
session = newSession
|
||||||
isReplacementCandidate = false
|
isReplacementCandidate = false
|
||||||
}
|
}
|
||||||
@@ -165,6 +186,10 @@ final class NoiseSessionManager {
|
|||||||
let response = try session.processHandshakeMessage(message)
|
let response = try session.processHandshakeMessage(message)
|
||||||
|
|
||||||
// Check if session is established after processing
|
// Check if session is established after processing
|
||||||
|
var establishedSession: (
|
||||||
|
remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||||
|
generation: UUID
|
||||||
|
)?
|
||||||
if session.isEstablished() {
|
if session.isEstablished() {
|
||||||
guard let remoteKey = session.getRemoteStaticPublicKey(),
|
guard let remoteKey = session.getRemoteStaticPublicKey(),
|
||||||
authenticatedRemoteKey(remoteKey, matches: peerID) else {
|
authenticatedRemoteKey(remoteKey, matches: peerID) else {
|
||||||
@@ -174,18 +199,18 @@ final class NoiseSessionManager {
|
|||||||
if isReplacementCandidate {
|
if isReplacementCandidate {
|
||||||
_ = responderCandidates.removeValue(forKey: peerID)
|
_ = responderCandidates.removeValue(forKey: peerID)
|
||||||
let previous = sessions.updateValue(session, forKey: peerID)
|
let previous = sessions.updateValue(session, forKey: peerID)
|
||||||
|
sessionGenerations[peerID] = UUID()
|
||||||
if let previous, previous !== session {
|
if let previous, previous !== session {
|
||||||
previous.reset()
|
previous.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
guard let generation = sessionGenerations[peerID] else {
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
DispatchQueue.global().async { [weak self] in
|
|
||||||
self?.onSessionEstablished?(peerID, remoteKey)
|
|
||||||
}
|
}
|
||||||
|
establishedSession = (remoteKey, generation)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return (response, establishedSession)
|
||||||
} catch {
|
} catch {
|
||||||
// A failed candidate is discarded without touching the
|
// A failed candidate is discarded without touching the
|
||||||
// established session. Ordinary failed handshakes retain the
|
// established session. Ordinary failed handshakes retain the
|
||||||
@@ -198,6 +223,7 @@ final class NoiseSessionManager {
|
|||||||
} else if let storedSession = sessions[peerID],
|
} else if let storedSession = sessions[peerID],
|
||||||
storedSession === session {
|
storedSession === session {
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
sessionGenerations.removeValue(forKey: peerID)
|
||||||
}
|
}
|
||||||
session.reset()
|
session.reset()
|
||||||
|
|
||||||
@@ -210,6 +236,11 @@ final class NoiseSessionManager {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let established = result.establishedSession {
|
||||||
|
onSessionEstablished?(peerID, established.remoteKey, established.generation)
|
||||||
|
}
|
||||||
|
return result.response
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
|
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
|
||||||
@@ -233,19 +264,74 @@ final class NoiseSessionManager {
|
|||||||
// MARK: - Encryption/Decryption
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||||
guard let session = getSession(for: peerID) else {
|
try managerQueue.sync {
|
||||||
throw NoiseSessionError.sessionNotFound
|
guard let session = sessions[peerID] else {
|
||||||
|
throw NoiseSessionError.sessionNotFound
|
||||||
|
}
|
||||||
|
return try session.encrypt(plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypts only if `expected` still names the current established entry.
|
||||||
|
/// A rekey between capability proof and media encryption therefore fails
|
||||||
|
/// closed instead of sending on an unproven replacement session.
|
||||||
|
func encrypt(
|
||||||
|
_ plaintext: Data,
|
||||||
|
for peerID: PeerID,
|
||||||
|
expectedSessionGeneration expected: UUID
|
||||||
|
) throws -> Data {
|
||||||
|
try managerQueue.sync {
|
||||||
|
guard let session = sessions[peerID],
|
||||||
|
session.isEstablished(),
|
||||||
|
sessionGenerations[peerID] == expected else {
|
||||||
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
|
}
|
||||||
|
return try session.encrypt(plaintext)
|
||||||
}
|
}
|
||||||
|
|
||||||
return try session.encrypt(plaintext)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
||||||
guard let session = getSession(for: peerID) else {
|
try decryptWithSessionGeneration(ciphertext, from: peerID).plaintext
|
||||||
throw NoiseSessionError.sessionNotFound
|
}
|
||||||
|
|
||||||
|
func sessionGeneration(for peerID: PeerID) -> UUID? {
|
||||||
|
managerQueue.sync {
|
||||||
|
guard sessions[peerID]?.isEstablished() == true else { return nil }
|
||||||
|
return sessionGenerations[peerID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypts while holding the manager's read lease. Session promotion and
|
||||||
|
/// removal require its barrier, so the returned generation always names
|
||||||
|
/// the exact session object that authenticated these bytes.
|
||||||
|
func decryptWithSessionGeneration(
|
||||||
|
_ ciphertext: Data,
|
||||||
|
from peerID: PeerID
|
||||||
|
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||||
|
try managerQueue.sync {
|
||||||
|
guard let session = sessions[peerID] else {
|
||||||
|
throw NoiseSessionError.sessionNotFound
|
||||||
|
}
|
||||||
|
guard session.isEstablished(),
|
||||||
|
let generation = sessionGenerations[peerID] else {
|
||||||
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
|
}
|
||||||
|
return (try session.decrypt(ciphertext), generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a state commit under a read lease for the exact established
|
||||||
|
/// session. Rekey, replacement, and removal all need the same barrier.
|
||||||
|
func withCurrentSessionGeneration<Result>(
|
||||||
|
for peerID: PeerID,
|
||||||
|
expected: UUID,
|
||||||
|
_ body: () -> Result
|
||||||
|
) -> Result? {
|
||||||
|
managerQueue.sync {
|
||||||
|
guard sessions[peerID]?.isEstablished() == true,
|
||||||
|
sessionGenerations[peerID] == expected else { return nil }
|
||||||
|
return body()
|
||||||
}
|
}
|
||||||
|
|
||||||
return try session.decrypt(ciphertext)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Key Management
|
// MARK: - Key Management
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import BitFoundation
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
struct BLENoiseDecryptionResult {
|
||||||
|
let plaintext: Data
|
||||||
|
let sessionGeneration: UUID
|
||||||
|
}
|
||||||
|
|
||||||
/// Narrow environment for `BLENoisePacketHandler`.
|
/// Narrow environment for `BLENoisePacketHandler`.
|
||||||
///
|
///
|
||||||
/// All queue hops (collections barrier writes, main-actor UI notification)
|
/// All queue hops (collections barrier writes, main-actor UI notification)
|
||||||
@@ -27,12 +32,16 @@ struct BLENoisePacketHandlerEnvironment {
|
|||||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||||
let updatePeerLastSeen: (PeerID) -> Void
|
let updatePeerLastSeen: (PeerID) -> Void
|
||||||
/// Decrypts an encrypted payload from the peer (crypto).
|
/// Decrypts an encrypted payload from the peer (crypto).
|
||||||
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data
|
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> BLENoiseDecryptionResult
|
||||||
/// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
|
/// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
|
||||||
let clearSession: (PeerID) -> Void
|
let clearSession: (PeerID) -> Void
|
||||||
/// Consumes session-authenticated protocol state inside the transport. It
|
/// Consumes session-authenticated protocol state inside the transport. It
|
||||||
/// must never escape to UI or Nostr payload dispatch.
|
/// must never escape to UI or Nostr payload dispatch.
|
||||||
let handleAuthenticatedPeerState: (_ peerID: PeerID, _ payload: Data) -> Void
|
let handleAuthenticatedPeerState: (
|
||||||
|
_ peerID: PeerID,
|
||||||
|
_ payload: Data,
|
||||||
|
_ sessionGeneration: UUID
|
||||||
|
) -> Void
|
||||||
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
|
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
|
||||||
let deliverNoisePayload: (
|
let deliverNoisePayload: (
|
||||||
_ peerID: PeerID,
|
_ peerID: PeerID,
|
||||||
@@ -117,7 +126,8 @@ final class BLENoisePacketHandler {
|
|||||||
env.updatePeerLastSeen(peerID)
|
env.updatePeerLastSeen(peerID)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let decrypted = try env.decrypt(packet.payload, peerID)
|
let decryption = try env.decrypt(packet.payload, peerID)
|
||||||
|
let decrypted = decryption.plaintext
|
||||||
guard decrypted.count > 0 else { return }
|
guard decrypted.count > 0 else { return }
|
||||||
|
|
||||||
// First byte indicates the payload type
|
// First byte indicates the payload type
|
||||||
@@ -132,7 +142,11 @@ final class BLENoisePacketHandler {
|
|||||||
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session)
|
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
|
||||||
if noisePayloadType == .authenticatedPeerState {
|
if noisePayloadType == .authenticatedPeerState {
|
||||||
env.handleAuthenticatedPeerState(peerID, Data(payloadData))
|
env.handleAuthenticatedPeerState(
|
||||||
|
peerID,
|
||||||
|
Data(payloadData),
|
||||||
|
decryption.sessionGeneration
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -960,6 +960,7 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||||
|
let normalizedPeerID = peerID.toShort()
|
||||||
let state: (
|
let state: (
|
||||||
capabilities: PeerCapabilities,
|
capabilities: PeerCapabilities,
|
||||||
fingerprint: String?,
|
fingerprint: String?,
|
||||||
@@ -967,7 +968,6 @@ final class BLEService: NSObject {
|
|||||||
authenticatedState: BLEAuthenticatedPeerStateObservation?,
|
authenticatedState: BLEAuthenticatedPeerStateObservation?,
|
||||||
timedOut: BLEPrivateMediaProofTimeoutMarker?
|
timedOut: BLEPrivateMediaProofTimeoutMarker?
|
||||||
) = collectionsQueue.sync {
|
) = collectionsQueue.sync {
|
||||||
let normalizedPeerID = peerID.toShort()
|
|
||||||
let info = peerRegistry.info(for: normalizedPeerID)
|
let info = peerRegistry.info(for: normalizedPeerID)
|
||||||
return (
|
return (
|
||||||
info?.capabilities ?? [],
|
info?.capabilities ?? [],
|
||||||
@@ -977,6 +977,14 @@ final class BLEService: NSObject {
|
|||||||
privateMediaProofTimeoutMarkers[normalizedPeerID]
|
privateMediaProofTimeoutMarkers[normalizedPeerID]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
let currentNoiseGeneration = noiseService.sessionGeneration(for: normalizedPeerID)
|
||||||
|
|
||||||
|
// A session replacement can happen before its authentication callback
|
||||||
|
// reaches messageQueue. Never reuse an observation from the previous
|
||||||
|
// transport generation during that window.
|
||||||
|
if state.sessionGeneration != currentNoiseGeneration {
|
||||||
|
return .awaitingCapabilityProof
|
||||||
|
}
|
||||||
|
|
||||||
guard let fingerprint = state.fingerprint else {
|
guard let fingerprint = state.fingerprint else {
|
||||||
// A raw fallback must be bound to the stable Noise key from a
|
// A raw fallback must be bound to the stable Noise key from a
|
||||||
@@ -4282,10 +4290,14 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||||||
service.onPeerAuthenticated = { [weak self] peerID, fingerprint 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
|
self?.messageQueue.async { [weak self] in
|
||||||
self?.handleNoisePeerAuthenticated(peerID: peerID, fingerprint: fingerprint)
|
self?.handleNoisePeerAuthenticated(
|
||||||
|
peerID: peerID,
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
sessionGeneration: generation
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
service.onRekeyHandshakeReady = { [weak self] peerID, message in
|
service.onRekeyHandshakeReady = { [weak self] peerID, message in
|
||||||
@@ -4297,45 +4309,59 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleNoisePeerAuthenticated(peerID: PeerID, fingerprint: String) {
|
private func handleNoisePeerAuthenticated(
|
||||||
|
peerID: PeerID,
|
||||||
|
fingerprint: String,
|
||||||
|
sessionGeneration generation: UUID
|
||||||
|
) {
|
||||||
let normalizedPeerID = peerID.toShort()
|
let normalizedPeerID = peerID.toShort()
|
||||||
let generation = UUID()
|
guard let transition = noiseService.withCurrentSessionGeneration(
|
||||||
let transition = collectionsQueue.sync(flags: .barrier) {
|
for: normalizedPeerID,
|
||||||
() -> (
|
expected: generation,
|
||||||
watchdog: (fingerprint: String, nonce: UUID),
|
{
|
||||||
rejected: [@MainActor (PrivateMediaSendPolicy) -> Void]
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
) in
|
() -> (
|
||||||
let watchdogNonce = UUID()
|
watchdog: (fingerprint: String, nonce: UUID)?,
|
||||||
privateMediaSessionGenerations[normalizedPeerID] = generation
|
rejected: [@MainActor (PrivateMediaSendPolicy) -> Void]
|
||||||
authenticatedPeerStates.removeValue(forKey: normalizedPeerID)
|
) in
|
||||||
privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID)
|
guard privateMediaSessionGenerations[normalizedPeerID] != generation else {
|
||||||
privateMediaProofWatchdogs[normalizedPeerID] = BLEPrivateMediaProofWatchdog(
|
return (nil, [])
|
||||||
fingerprint: fingerprint,
|
}
|
||||||
sessionGeneration: generation,
|
let watchdogNonce = UUID()
|
||||||
timeoutNonce: watchdogNonce
|
privateMediaSessionGenerations[normalizedPeerID] = generation
|
||||||
)
|
authenticatedPeerStates.removeValue(forKey: normalizedPeerID)
|
||||||
authenticatedPeerStateSendProgress[normalizedPeerID] =
|
privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID)
|
||||||
BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation)
|
privateMediaProofWatchdogs[normalizedPeerID] = BLEPrivateMediaProofWatchdog(
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
sessionGeneration: generation,
|
||||||
|
timeoutNonce: watchdogNonce
|
||||||
|
)
|
||||||
|
authenticatedPeerStateSendProgress[normalizedPeerID] =
|
||||||
|
BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation)
|
||||||
|
|
||||||
guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else {
|
guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else {
|
||||||
return ((fingerprint, watchdogNonce), [])
|
return ((fingerprint, watchdogNonce), [])
|
||||||
|
}
|
||||||
|
guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame else {
|
||||||
|
pendingPrivateMediaPolicyResolutions.removeValue(forKey: normalizedPeerID)
|
||||||
|
return ((fingerprint, watchdogNonce), Array(pending.completions.values))
|
||||||
|
}
|
||||||
|
pending.sessionGeneration = generation
|
||||||
|
pending.timeoutNonce = watchdogNonce
|
||||||
|
pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending
|
||||||
|
return ((pending.fingerprint, watchdogNonce), [])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame else {
|
) else { return }
|
||||||
pendingPrivateMediaPolicyResolutions.removeValue(forKey: normalizedPeerID)
|
|
||||||
return ((fingerprint, watchdogNonce), Array(pending.completions.values))
|
guard let watchdog = transition.watchdog else { return }
|
||||||
}
|
|
||||||
pending.sessionGeneration = generation
|
|
||||||
pending.timeoutNonce = watchdogNonce
|
|
||||||
pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending
|
|
||||||
return ((pending.fingerprint, watchdogNonce), [])
|
|
||||||
}
|
|
||||||
|
|
||||||
completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade)
|
completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade)
|
||||||
schedulePrivateMediaProofTimeout(
|
schedulePrivateMediaProofTimeout(
|
||||||
for: normalizedPeerID,
|
for: normalizedPeerID,
|
||||||
fingerprint: transition.watchdog.fingerprint,
|
fingerprint: watchdog.fingerprint,
|
||||||
sessionGeneration: generation,
|
sessionGeneration: generation,
|
||||||
nonce: transition.watchdog.nonce
|
nonce: watchdog.nonce
|
||||||
)
|
)
|
||||||
|
|
||||||
// `onPeerAuthenticated` can fire while the initiator is returning XX
|
// `onPeerAuthenticated` can fire while the initiator is returning XX
|
||||||
@@ -4383,7 +4409,11 @@ extension BLEService {
|
|||||||
sendNoisePayload(payload, to: normalizedPeerID)
|
sendNoisePayload(payload, to: normalizedPeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleAuthenticatedPeerState(_ payload: Data, from peerID: PeerID) {
|
private func handleAuthenticatedPeerState(
|
||||||
|
_ payload: Data,
|
||||||
|
from peerID: PeerID,
|
||||||
|
sessionGeneration generation: UUID
|
||||||
|
) {
|
||||||
let normalizedPeerID = peerID.toShort()
|
let normalizedPeerID = peerID.toShort()
|
||||||
guard let state = AuthenticatedPeerStatePacket.decode(from: payload) else {
|
guard let state = AuthenticatedPeerStatePacket.decode(from: payload) else {
|
||||||
SecureLogger.warning(
|
SecureLogger.warning(
|
||||||
@@ -4401,59 +4431,67 @@ extension BLEService {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let generation = collectionsQueue.sync {
|
guard let application = noiseService.withCurrentSessionGeneration(
|
||||||
privateMediaSessionGenerations[normalizedPeerID]
|
for: normalizedPeerID,
|
||||||
}
|
expected: generation,
|
||||||
guard let generation else { return }
|
{
|
||||||
|
() -> (accepted: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) in
|
||||||
|
guard collectionsQueue.sync(execute: {
|
||||||
|
privateMediaSessionGenerations[normalizedPeerID] == generation
|
||||||
|
}) else {
|
||||||
|
return (false, [])
|
||||||
|
}
|
||||||
|
|
||||||
// Bind the Ed25519 key and capability pin before waking senders. Both
|
// The generation lease prevents rekey/session promotion from
|
||||||
// mutations are synchronous so no announce or send-policy race can
|
// interleaving between validation and these durable mutations.
|
||||||
// observe a half-applied authenticated state.
|
identityManager.bindAuthenticatedSigningPublicKey(
|
||||||
identityManager.bindAuthenticatedSigningPublicKey(
|
state.signingPublicKey,
|
||||||
state.signingPublicKey,
|
fingerprint: fingerprint
|
||||||
fingerprint: fingerprint
|
)
|
||||||
)
|
identityManager.upsertCryptographicIdentity(
|
||||||
identityManager.upsertCryptographicIdentity(
|
fingerprint: fingerprint,
|
||||||
fingerprint: fingerprint,
|
noisePublicKey: publicKey,
|
||||||
noisePublicKey: publicKey,
|
signingPublicKey: state.signingPublicKey,
|
||||||
signingPublicKey: state.signingPublicKey,
|
claimedNickname: nil
|
||||||
claimedNickname: nil
|
)
|
||||||
)
|
if state.capabilities.contains(.privateMedia) {
|
||||||
if state.capabilities.contains(.privateMedia) {
|
identityManager.markPrivateMediaCapable(fingerprint: fingerprint)
|
||||||
identityManager.markPrivateMediaCapable(fingerprint: fingerprint)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let completions = collectionsQueue.sync(flags: .barrier) {
|
let completions = collectionsQueue.sync(flags: .barrier) {
|
||||||
() -> [@MainActor (PrivateMediaSendPolicy) -> Void] in
|
() -> [@MainActor (PrivateMediaSendPolicy) -> Void] in
|
||||||
guard privateMediaSessionGenerations[normalizedPeerID] == generation else {
|
guard privateMediaSessionGenerations[normalizedPeerID] == generation else {
|
||||||
return []
|
return []
|
||||||
|
}
|
||||||
|
peerRegistry.bindAuthenticatedSigningPublicKey(
|
||||||
|
state.signingPublicKey,
|
||||||
|
for: normalizedPeerID
|
||||||
|
)
|
||||||
|
authenticatedPeerStates[normalizedPeerID] = BLEAuthenticatedPeerStateObservation(
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
sessionGeneration: generation,
|
||||||
|
capabilities: state.capabilities
|
||||||
|
)
|
||||||
|
privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID)
|
||||||
|
privateMediaProofWatchdogs.removeValue(forKey: normalizedPeerID)
|
||||||
|
guard let pending = pendingPrivateMediaPolicyResolutions.removeValue(
|
||||||
|
forKey: normalizedPeerID
|
||||||
|
), pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
|
||||||
|
pending.sessionGeneration == generation else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return Array(pending.completions.values)
|
||||||
|
}
|
||||||
|
return (true, completions)
|
||||||
}
|
}
|
||||||
peerRegistry.bindAuthenticatedSigningPublicKey(
|
), application.accepted else { return }
|
||||||
state.signingPublicKey,
|
|
||||||
for: normalizedPeerID
|
|
||||||
)
|
|
||||||
authenticatedPeerStates[normalizedPeerID] = BLEAuthenticatedPeerStateObservation(
|
|
||||||
fingerprint: fingerprint,
|
|
||||||
sessionGeneration: generation,
|
|
||||||
capabilities: state.capabilities
|
|
||||||
)
|
|
||||||
privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID)
|
|
||||||
privateMediaProofWatchdogs.removeValue(forKey: normalizedPeerID)
|
|
||||||
guard let pending = pendingPrivateMediaPolicyResolutions.removeValue(
|
|
||||||
forKey: normalizedPeerID
|
|
||||||
), pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
|
|
||||||
pending.sessionGeneration == generation else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return Array(pending.completions.values)
|
|
||||||
}
|
|
||||||
|
|
||||||
// One bounded echo makes initiator/responder proof ordering converge
|
// One bounded echo makes initiator/responder proof ordering converge
|
||||||
// even when message 3 and the first proof take different mesh links.
|
// even when message 3 and the first proof take different mesh links.
|
||||||
sendAuthenticatedPeerState(to: normalizedPeerID, echo: true)
|
sendAuthenticatedPeerState(to: normalizedPeerID, echo: true)
|
||||||
let policy = privateMediaSendPolicy(to: normalizedPeerID)
|
let policy = privateMediaSendPolicy(to: normalizedPeerID)
|
||||||
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
|
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
|
||||||
completePrivateMediaPolicyResolution(completions, with: policy)
|
completePrivateMediaPolicyResolution(application.completions, with: policy)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func noteNoiseSessionCleared(for peerID: PeerID) {
|
private func noteNoiseSessionCleared(for peerID: PeerID) {
|
||||||
@@ -4543,7 +4581,22 @@ extension BLEService {
|
|||||||
let encrypted: Data
|
let encrypted: Data
|
||||||
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
|
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
|
||||||
if isPrivateFile {
|
if isPrivateFile {
|
||||||
encrypted = try noiseService.encryptPrivateFilePayload(typedPayload, for: peerID)
|
let provenGeneration: UUID? = collectionsQueue.sync {
|
||||||
|
() -> UUID? in
|
||||||
|
guard let generation = privateMediaSessionGenerations[peerID],
|
||||||
|
let authenticated = authenticatedPeerStates[peerID],
|
||||||
|
authenticated.sessionGeneration == generation,
|
||||||
|
authenticated.capabilities.contains(.privateMedia) else { return nil }
|
||||||
|
return generation
|
||||||
|
}
|
||||||
|
guard let provenGeneration else {
|
||||||
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
|
}
|
||||||
|
encrypted = try noiseService.encryptPrivateFilePayload(
|
||||||
|
typedPayload,
|
||||||
|
for: peerID,
|
||||||
|
sessionGeneration: provenGeneration
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
encrypted = try noiseService.encrypt(typedPayload, for: peerID)
|
encrypted = try noiseService.encrypt(typedPayload, for: peerID)
|
||||||
}
|
}
|
||||||
@@ -6680,13 +6733,24 @@ extension BLEService {
|
|||||||
},
|
},
|
||||||
decrypt: { [weak self] payload, peerID in
|
decrypt: { [weak self] payload, peerID in
|
||||||
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
|
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
|
||||||
return try self.noiseService.decrypt(payload, from: peerID)
|
let result = try self.noiseService.decryptWithSessionGeneration(
|
||||||
|
payload,
|
||||||
|
from: peerID
|
||||||
|
)
|
||||||
|
return BLENoiseDecryptionResult(
|
||||||
|
plaintext: result.plaintext,
|
||||||
|
sessionGeneration: result.sessionGeneration
|
||||||
|
)
|
||||||
},
|
},
|
||||||
clearSession: { [weak self] peerID in
|
clearSession: { [weak self] peerID in
|
||||||
self?.clearNoiseSession(for: peerID)
|
self?.clearNoiseSession(for: peerID)
|
||||||
},
|
},
|
||||||
handleAuthenticatedPeerState: { [weak self] peerID, payload in
|
handleAuthenticatedPeerState: { [weak self] peerID, payload, generation in
|
||||||
self?.handleAuthenticatedPeerState(payload, from: peerID)
|
self?.handleAuthenticatedPeerState(
|
||||||
|
payload,
|
||||||
|
from: peerID,
|
||||||
|
sessionGeneration: generation
|
||||||
|
)
|
||||||
},
|
},
|
||||||
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
|
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
|
||||||
if type == .privateFile {
|
if type == .privateFile {
|
||||||
|
|||||||
@@ -165,7 +165,6 @@ final class NoiseEncryptionService {
|
|||||||
// Peer fingerprints (SHA256 hash of static public key)
|
// Peer fingerprints (SHA256 hash of static public key)
|
||||||
private var peerFingerprints: [PeerID: String] = [:]
|
private var peerFingerprints: [PeerID: String] = [:]
|
||||||
private var fingerprintToPeerID: [String: PeerID] = [:]
|
private var fingerprintToPeerID: [String: PeerID] = [:]
|
||||||
|
|
||||||
// Thread safety
|
// Thread safety
|
||||||
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
||||||
|
|
||||||
@@ -183,6 +182,7 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||||
|
private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = []
|
||||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||||
/// Automatic rekey removed the old session and produced XX message 1.
|
/// Automatic rekey removed the old session and produced XX message 1.
|
||||||
/// The transport must clear session-scoped state and put these exact bytes
|
/// The transport must clear session-scoped state and put these exact bytes
|
||||||
@@ -206,6 +206,18 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generation-aware authentication notifications are used by protocols
|
||||||
|
/// whose state must be bound to one exact Noise transport session.
|
||||||
|
var onPeerAuthenticatedWithGeneration: ((PeerID, String, UUID) -> Void)? {
|
||||||
|
get { nil }
|
||||||
|
set {
|
||||||
|
guard let handler = newValue else { return }
|
||||||
|
serviceQueue.async(flags: .barrier) { [weak self] in
|
||||||
|
self?.onPeerAuthenticatedWithGenerationHandlers.append(handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol) {
|
init(keychain: KeychainManagerProtocol) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
@@ -300,8 +312,12 @@ final class NoiseEncryptionService {
|
|||||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
||||||
|
|
||||||
// Set up session callbacks
|
// Set up session callbacks
|
||||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
|
||||||
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
self?.handleSessionEstablished(
|
||||||
|
peerID: peerID,
|
||||||
|
remoteStaticKey: remoteStaticKey,
|
||||||
|
sessionGeneration: generation
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start session maintenance timer
|
// Start session maintenance timer
|
||||||
@@ -735,7 +751,11 @@ final class NoiseEncryptionService {
|
|||||||
/// messages retain the 64 KiB ceiling; this purpose-specific path permits
|
/// messages retain the 64 KiB ceiling; this purpose-specific path permits
|
||||||
/// the bounded `BitchatFilePacket` envelope and refuses every other typed
|
/// the bounded `BitchatFilePacket` envelope and refuses every other typed
|
||||||
/// payload so the larger allocation budget cannot become a generic bypass.
|
/// payload so the larger allocation budget cannot become a generic bypass.
|
||||||
func encryptPrivateFilePayload(_ data: Data, for peerID: PeerID) throws -> Data {
|
func encryptPrivateFilePayload(
|
||||||
|
_ data: Data,
|
||||||
|
for peerID: PeerID,
|
||||||
|
sessionGeneration: UUID? = nil
|
||||||
|
) throws -> Data {
|
||||||
guard NoisePayloadType.isPrivateFile(rawValue: data.first),
|
guard NoisePayloadType.isPrivateFile(rawValue: data.first),
|
||||||
NoiseSecurityValidator.validatePrivateFileMessageSize(data) else {
|
NoiseSecurityValidator.validatePrivateFileMessageSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
@@ -752,11 +772,25 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed
|
// `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed
|
||||||
// nonce/tag overhead, so the result is bounded without a second copy.
|
// nonce/tag overhead, so the result is bounded without a second copy.
|
||||||
|
if let sessionGeneration {
|
||||||
|
return try sessionManager.encrypt(
|
||||||
|
data,
|
||||||
|
for: peerID,
|
||||||
|
expectedSessionGeneration: sessionGeneration
|
||||||
|
)
|
||||||
|
}
|
||||||
return try sessionManager.encrypt(data, for: peerID)
|
return try sessionManager.encrypt(data, for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt data from a specific peer
|
/// Decrypt data from a specific peer
|
||||||
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||||
|
try decryptWithSessionGeneration(data, from: peerID).plaintext
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptWithSessionGeneration(
|
||||||
|
_ data: Data,
|
||||||
|
from peerID: PeerID
|
||||||
|
) 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 candidate 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`.
|
||||||
@@ -775,14 +809,14 @@ final class NoiseEncryptionService {
|
|||||||
throw NoiseEncryptionError.sessionNotEstablished
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
}
|
}
|
||||||
|
|
||||||
let decrypted = try sessionManager.decrypt(data, from: peerID)
|
let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID)
|
||||||
if !isStandardCiphertext {
|
if !isStandardCiphertext {
|
||||||
guard NoisePayloadType.isPrivateFile(rawValue: decrypted.first),
|
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
|
||||||
NoiseSecurityValidator.validatePrivateFileMessageSize(decrypted) else {
|
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return decrypted
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Peer Management
|
// MARK: - Peer Management
|
||||||
@@ -794,6 +828,25 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sessionGeneration(for peerID: PeerID) -> UUID? {
|
||||||
|
sessionManager.sessionGeneration(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `body` while holding a read lease on the exact session generation.
|
||||||
|
/// Session insertion, replacement, and removal use the same manager
|
||||||
|
/// barrier, so they cannot interleave with an authenticated-state commit.
|
||||||
|
func withCurrentSessionGeneration<Result>(
|
||||||
|
for peerID: PeerID,
|
||||||
|
expected: UUID,
|
||||||
|
_ body: () -> Result
|
||||||
|
) -> Result? {
|
||||||
|
sessionManager.withCurrentSessionGeneration(
|
||||||
|
for: peerID,
|
||||||
|
expected: expected,
|
||||||
|
body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func clearEphemeralStateForPanic() {
|
func clearEphemeralStateForPanic() {
|
||||||
sessionManager.removeAllSessions()
|
sessionManager.removeAllSessions()
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
@@ -816,7 +869,11 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
private func handleSessionEstablished(
|
||||||
|
peerID: PeerID,
|
||||||
|
remoteStaticKey: Curve25519.KeyAgreement.PublicKey,
|
||||||
|
sessionGeneration: UUID
|
||||||
|
) {
|
||||||
// Calculate fingerprint
|
// Calculate fingerprint
|
||||||
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
||||||
|
|
||||||
@@ -831,6 +888,9 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// Notify all handlers about authentication
|
// Notify all handlers about authentication
|
||||||
serviceQueue.async { [weak self] in
|
serviceQueue.async { [weak self] in
|
||||||
|
self?.onPeerAuthenticatedWithGenerationHandlers.forEach { handler in
|
||||||
|
handler(peerID, fingerprint, sessionGeneration)
|
||||||
|
}
|
||||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||||
handler(peerID, fingerprint)
|
handler(peerID, fingerprint)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -542,8 +542,12 @@ struct NoiseCoverageTests {
|
|||||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
||||||
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||||
|
|
||||||
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
|
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(
|
||||||
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
|
peerID:remoteKey:sessionGeneration:
|
||||||
|
)
|
||||||
|
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(
|
||||||
|
peerID:remoteKey:sessionGeneration:
|
||||||
|
)
|
||||||
|
|
||||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||||
|
|
||||||
@@ -658,6 +662,95 @@ struct NoiseCoverageTests {
|
|||||||
#expect(rekeyedSession.getState() == .handshaking)
|
#expect(rekeyedSession.getState() == .handshaking)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("A stale decrypt generation cannot commit across session promotion")
|
||||||
|
func staleDecryptGenerationCannotCommitAcrossPromotion() throws {
|
||||||
|
let aliceManager = NoiseSessionManager(
|
||||||
|
localStaticKey: aliceStaticKey,
|
||||||
|
keychain: keychain,
|
||||||
|
sessionFactory: { peerID, role in
|
||||||
|
BlockingDecryptNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: role,
|
||||||
|
keychain: self.keychain,
|
||||||
|
localStaticKey: self.aliceStaticKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||||
|
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||||
|
|
||||||
|
let oldSession = try #require(
|
||||||
|
aliceManager.getSession(for: alicePeerID) as? BlockingDecryptNoiseSession
|
||||||
|
)
|
||||||
|
let oldGeneration = try #require(aliceManager.sessionGeneration(for: alicePeerID))
|
||||||
|
|
||||||
|
// Prepare a fully authenticated responder candidate without promoting
|
||||||
|
// it yet. Its final XX message is the exact operation that replaces
|
||||||
|
// the old `sessions[peerID]` entry.
|
||||||
|
let replacementInitiator = NoiseSession(
|
||||||
|
peerID: bobPeerID,
|
||||||
|
role: .initiator,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: bobStaticKey
|
||||||
|
)
|
||||||
|
let message1 = try replacementInitiator.startHandshake()
|
||||||
|
let message2 = try #require(
|
||||||
|
try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message1)
|
||||||
|
)
|
||||||
|
let message3 = try #require(try replacementInitiator.processHandshakeMessage(message2))
|
||||||
|
|
||||||
|
let ciphertext = try bobManager.encrypt(Data("old session".utf8), for: bobPeerID)
|
||||||
|
oldSession.pauseNextDecrypt()
|
||||||
|
|
||||||
|
let decryptResult = ConcurrentTestResult<(plaintext: Data, sessionGeneration: UUID)>()
|
||||||
|
DispatchQueue.global().async {
|
||||||
|
decryptResult.capture {
|
||||||
|
try aliceManager.decryptWithSessionGeneration(ciphertext, from: self.alicePeerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#expect(oldSession.waitForDecryptStart(timeout: 2))
|
||||||
|
|
||||||
|
let promotionStarted = DispatchSemaphore(value: 0)
|
||||||
|
let promotionResult = ConcurrentTestResult<Data?>()
|
||||||
|
DispatchQueue.global().async {
|
||||||
|
promotionStarted.signal()
|
||||||
|
promotionResult.capture {
|
||||||
|
try aliceManager.handleIncomingHandshake(from: self.alicePeerID, message: message3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#expect(promotionStarted.wait(timeout: .now() + 2) == .success)
|
||||||
|
#expect(
|
||||||
|
promotionResult.wait(timeout: 0.05) == nil,
|
||||||
|
"Promotion must wait for the exact decrypting-session lease"
|
||||||
|
)
|
||||||
|
|
||||||
|
oldSession.resumeDecrypt()
|
||||||
|
let decrypted = try #require(decryptResult.wait(timeout: 2)).get()
|
||||||
|
_ = try #require(promotionResult.wait(timeout: 2)).get()
|
||||||
|
|
||||||
|
#expect(decrypted.plaintext == Data("old session".utf8))
|
||||||
|
#expect(decrypted.sessionGeneration == oldGeneration)
|
||||||
|
#expect(aliceManager.sessionGeneration(for: alicePeerID) != oldGeneration)
|
||||||
|
#expect(throws: NoiseEncryptionError.sessionNotEstablished) {
|
||||||
|
try aliceManager.encrypt(
|
||||||
|
Data("stale send".utf8),
|
||||||
|
for: alicePeerID,
|
||||||
|
expectedSessionGeneration: oldGeneration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var staleCommitRan = false
|
||||||
|
let staleCommit = aliceManager.withCurrentSessionGeneration(
|
||||||
|
for: alicePeerID,
|
||||||
|
expected: decrypted.sessionGeneration
|
||||||
|
) {
|
||||||
|
staleCommitRan = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#expect(staleCommit == nil)
|
||||||
|
#expect(!staleCommitRan)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Secure noise sessions enforce limits and renegotiation thresholds")
|
@Test("Secure noise sessions enforce limits and renegotiation thresholds")
|
||||||
func secureNoiseSessionsEnforceLimitsAndThresholds() throws {
|
func secureNoiseSessionsEnforceLimitsAndThresholds() throws {
|
||||||
let initiator = SecureNoiseSession(
|
let initiator = SecureNoiseSession(
|
||||||
@@ -852,7 +945,11 @@ private final class SessionCallbackRecorder: @unchecked Sendable {
|
|||||||
return establishedEntries.map(\.0)
|
return establishedEntries.map(\.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) {
|
func recordEstablished(
|
||||||
|
peerID: PeerID,
|
||||||
|
remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||||
|
sessionGeneration _: UUID
|
||||||
|
) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
establishedEntries.append((peerID, remoteKey.rawRepresentation))
|
establishedEntries.append((peerID, remoteKey.rawRepresentation))
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
@@ -874,3 +971,58 @@ private final class FailingNoiseSession: NoiseSession {
|
|||||||
throw Error.synthetic
|
throw Error.synthetic
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class BlockingDecryptNoiseSession: NoiseSession, @unchecked Sendable {
|
||||||
|
private let controlLock = NSLock()
|
||||||
|
private var shouldPauseNextDecrypt = false
|
||||||
|
private let decryptStarted = DispatchSemaphore(value: 0)
|
||||||
|
private let resumeDecryptSemaphore = DispatchSemaphore(value: 0)
|
||||||
|
|
||||||
|
func pauseNextDecrypt() {
|
||||||
|
controlLock.lock()
|
||||||
|
shouldPauseNextDecrypt = true
|
||||||
|
controlLock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForDecryptStart(timeout: TimeInterval) -> Bool {
|
||||||
|
decryptStarted.wait(timeout: .now() + timeout) == .success
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeDecrypt() {
|
||||||
|
resumeDecryptSemaphore.signal()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||||
|
controlLock.lock()
|
||||||
|
let shouldPause = shouldPauseNextDecrypt
|
||||||
|
shouldPauseNextDecrypt = false
|
||||||
|
controlLock.unlock()
|
||||||
|
|
||||||
|
if shouldPause {
|
||||||
|
decryptStarted.signal()
|
||||||
|
resumeDecryptSemaphore.wait()
|
||||||
|
}
|
||||||
|
return try super.decrypt(ciphertext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ConcurrentTestResult<Value>: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private let completed = DispatchSemaphore(value: 0)
|
||||||
|
private var storedResult: Result<Value, Error>?
|
||||||
|
|
||||||
|
func capture(_ operation: () throws -> Value) {
|
||||||
|
let result = Result(catching: operation)
|
||||||
|
lock.lock()
|
||||||
|
storedResult = result
|
||||||
|
lock.unlock()
|
||||||
|
completed.signal()
|
||||||
|
}
|
||||||
|
|
||||||
|
func wait(timeout: TimeInterval) -> Result<Value, Error>? {
|
||||||
|
guard completed.wait(timeout: .now() + timeout) == .success else { return nil }
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return storedResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct BLENoisePacketHandlerTests {
|
|||||||
private final class Recorder {
|
private final class Recorder {
|
||||||
var handshakeResult: Result<Data?, Error> = .success(nil)
|
var handshakeResult: Result<Data?, Error> = .success(nil)
|
||||||
var hasSession = false
|
var hasSession = false
|
||||||
|
let sessionGeneration = UUID()
|
||||||
var decryptResult: Result<Data, Error> = .success(Data())
|
var decryptResult: Result<Data, Error> = .success(Data())
|
||||||
|
|
||||||
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
|
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
|
||||||
@@ -18,7 +19,7 @@ struct BLENoisePacketHandlerTests {
|
|||||||
var lastSeenUpdates: [PeerID] = []
|
var lastSeenUpdates: [PeerID] = []
|
||||||
var decryptCalls: [(payload: Data, peerID: PeerID)] = []
|
var decryptCalls: [(payload: Data, peerID: PeerID)] = []
|
||||||
var clearedSessions: [PeerID] = []
|
var clearedSessions: [PeerID] = []
|
||||||
var authenticatedPeerStates: [(peerID: PeerID, payload: Data)] = []
|
var authenticatedPeerStates: [(peerID: PeerID, payload: Data, generation: UUID)] = []
|
||||||
var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
|
var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
|
||||||
/// Ordered side-effect log to assert recovery sequencing.
|
/// Ordered side-effect log to assert recovery sequencing.
|
||||||
var events: [String] = []
|
var events: [String] = []
|
||||||
@@ -57,14 +58,17 @@ struct BLENoisePacketHandlerTests {
|
|||||||
},
|
},
|
||||||
decrypt: { payload, peerID in
|
decrypt: { payload, peerID in
|
||||||
recorder.decryptCalls.append((payload, peerID))
|
recorder.decryptCalls.append((payload, peerID))
|
||||||
return try recorder.decryptResult.get()
|
return BLENoiseDecryptionResult(
|
||||||
|
plaintext: try recorder.decryptResult.get(),
|
||||||
|
sessionGeneration: recorder.sessionGeneration
|
||||||
|
)
|
||||||
},
|
},
|
||||||
clearSession: { peerID in
|
clearSession: { peerID in
|
||||||
recorder.clearedSessions.append(peerID)
|
recorder.clearedSessions.append(peerID)
|
||||||
recorder.events.append("clearSession")
|
recorder.events.append("clearSession")
|
||||||
},
|
},
|
||||||
handleAuthenticatedPeerState: { peerID, payload in
|
handleAuthenticatedPeerState: { peerID, payload, generation in
|
||||||
recorder.authenticatedPeerStates.append((peerID, payload))
|
recorder.authenticatedPeerStates.append((peerID, payload, generation))
|
||||||
},
|
},
|
||||||
deliverNoisePayload: { peerID, type, payload, timestamp in
|
deliverNoisePayload: { peerID, type, payload, timestamp in
|
||||||
recorder.deliveries.append((peerID, type, payload, timestamp))
|
recorder.deliveries.append((peerID, type, payload, timestamp))
|
||||||
@@ -240,6 +244,7 @@ struct BLENoisePacketHandlerTests {
|
|||||||
#expect(recorder.authenticatedPeerStates.count == 1)
|
#expect(recorder.authenticatedPeerStates.count == 1)
|
||||||
#expect(recorder.authenticatedPeerStates.first?.peerID == remotePeerID)
|
#expect(recorder.authenticatedPeerStates.first?.peerID == remotePeerID)
|
||||||
#expect(recorder.authenticatedPeerStates.first?.payload == Data([0x01, 0x02, 0x03]))
|
#expect(recorder.authenticatedPeerStates.first?.payload == Data([0x01, 0x02, 0x03]))
|
||||||
|
#expect(recorder.authenticatedPeerStates.first?.generation == recorder.sessionGeneration)
|
||||||
#expect(recorder.deliveries.isEmpty)
|
#expect(recorder.deliveries.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,13 +96,22 @@ struct NoiseEncryptionServiceTests {
|
|||||||
let recorder = AuthenticationRecorder()
|
let recorder = AuthenticationRecorder()
|
||||||
|
|
||||||
#expect(alice.onPeerAuthenticated == nil)
|
#expect(alice.onPeerAuthenticated == nil)
|
||||||
|
#expect(bob.onPeerAuthenticatedWithGeneration == nil)
|
||||||
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||||
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
|
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
|
||||||
|
bob.onPeerAuthenticatedWithGeneration = recorder.record(
|
||||||
|
peerID:fingerprint:sessionGeneration:
|
||||||
|
)
|
||||||
|
|
||||||
try establishSessions(alice: alice, bob: bob)
|
try establishSessions(alice: alice, bob: bob)
|
||||||
|
|
||||||
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
||||||
#expect(authenticated)
|
#expect(authenticated)
|
||||||
|
let generationAuthenticated = await TestHelpers.waitUntil(
|
||||||
|
{ recorder.generationCount >= 1 },
|
||||||
|
timeout: 5.0
|
||||||
|
)
|
||||||
|
#expect(generationAuthenticated)
|
||||||
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
||||||
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||||
#expect(alice.hasSession(with: bobPeerID))
|
#expect(alice.hasSession(with: bobPeerID))
|
||||||
@@ -111,6 +120,7 @@ struct NoiseEncryptionServiceTests {
|
|||||||
#expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
#expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
||||||
#expect(alice.getPeerFingerprint(bobPeerID) != nil)
|
#expect(alice.getPeerFingerprint(bobPeerID) != nil)
|
||||||
#expect(bob.getPeerFingerprint(alicePeerID) != nil)
|
#expect(bob.getPeerFingerprint(alicePeerID) != nil)
|
||||||
|
#expect(recorder.generation(for: alicePeerID) == bob.sessionGeneration(for: alicePeerID))
|
||||||
|
|
||||||
let plaintext = Data("secret payload".utf8)
|
let plaintext = Data("secret payload".utf8)
|
||||||
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
|
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
|
||||||
@@ -244,6 +254,17 @@ struct NoiseEncryptionServiceTests {
|
|||||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
try establishSessions(alice: alice, bob: bob)
|
try establishSessions(alice: alice, bob: bob)
|
||||||
|
let originalGeneration = try #require(alice.sessionGeneration(for: bobPeerID))
|
||||||
|
var leaseRan = false
|
||||||
|
let leased = alice.withCurrentSessionGeneration(
|
||||||
|
for: bobPeerID,
|
||||||
|
expected: originalGeneration
|
||||||
|
) {
|
||||||
|
leaseRan = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#expect(leased == true)
|
||||||
|
#expect(leaseRan)
|
||||||
|
|
||||||
var emittedPeerID: PeerID?
|
var emittedPeerID: PeerID?
|
||||||
var emittedMessage: Data?
|
var emittedMessage: Data?
|
||||||
@@ -254,6 +275,17 @@ struct NoiseEncryptionServiceTests {
|
|||||||
try alice._test_initiateAutomaticRekey(for: bobPeerID)
|
try alice._test_initiateAutomaticRekey(for: bobPeerID)
|
||||||
|
|
||||||
#expect(emittedPeerID == bobPeerID)
|
#expect(emittedPeerID == bobPeerID)
|
||||||
|
#expect(alice.sessionGeneration(for: bobPeerID) == nil)
|
||||||
|
leaseRan = false
|
||||||
|
let staleLease = alice.withCurrentSessionGeneration(
|
||||||
|
for: bobPeerID,
|
||||||
|
expected: originalGeneration
|
||||||
|
) {
|
||||||
|
leaseRan = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#expect(staleLease == nil)
|
||||||
|
#expect(!leaseRan)
|
||||||
let message1 = try #require(emittedMessage)
|
let message1 = try #require(emittedMessage)
|
||||||
#expect(!message1.isEmpty)
|
#expect(!message1.isEmpty)
|
||||||
#expect(alice.hasSession(with: bobPeerID))
|
#expect(alice.hasSession(with: bobPeerID))
|
||||||
@@ -269,6 +301,7 @@ struct NoiseEncryptionServiceTests {
|
|||||||
|
|
||||||
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
||||||
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||||
|
#expect(alice.sessionGeneration(for: bobPeerID) != originalGeneration)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Large private-file payloads use the bounded Noise extension")
|
@Test("Large private-file payloads use the bounded Noise extension")
|
||||||
@@ -411,6 +444,7 @@ struct NoiseEncryptionServiceTests {
|
|||||||
private final class AuthenticationRecorder: @unchecked Sendable {
|
private final class AuthenticationRecorder: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var entries: [(PeerID, String)] = []
|
private var entries: [(PeerID, String)] = []
|
||||||
|
private var generationEntries: [(PeerID, UUID)] = []
|
||||||
|
|
||||||
var count: Int {
|
var count: Int {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
@@ -418,9 +452,27 @@ private final class AuthenticationRecorder: @unchecked Sendable {
|
|||||||
return entries.count
|
return entries.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var generationCount: Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return generationEntries.count
|
||||||
|
}
|
||||||
|
|
||||||
func record(peerID: PeerID, fingerprint: String) {
|
func record(peerID: PeerID, fingerprint: String) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
entries.append((peerID, fingerprint))
|
entries.append((peerID, fingerprint))
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func record(peerID: PeerID, fingerprint _: String, sessionGeneration: UUID) {
|
||||||
|
lock.lock()
|
||||||
|
generationEntries.append((peerID, sessionGeneration))
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func generation(for peerID: PeerID) -> UUID? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return generationEntries.last { $0.0 == peerID }?.1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,10 +70,10 @@ The fallback is signed and its signature is required on receive, so relays
|
|||||||
cannot forge its sender or contents. It is not confidential: relays can see
|
cannot forge its sender or contents. It is not confidential: relays can see
|
||||||
the raw file TLV. The UI says this explicitly and asks on every send. A peer
|
the raw file TLV. The UI says this explicitly and asks on every send. A peer
|
||||||
without a stable Noise key from a verified registry entry cannot use the
|
without a stable Noise key from a verified registry entry cannot use the
|
||||||
fallback. Keep
|
fallback. Keep it only for the mixed-version migration, and remove it only
|
||||||
the fallback only for the mixed-version migration and remove it after
|
after minimum-supported Android and iOS releases emit authenticated bit-8
|
||||||
supported Android and iOS releases advertise `privateMedia`. Never replace it
|
`0x21` state and the legacy population has aged out. Never replace it with an
|
||||||
with an unsigned fallback, persist blanket consent, or send both forms.
|
unsigned fallback, persist blanket consent, or send both forms.
|
||||||
|
|
||||||
Incoming clients accept all three migration-era shapes:
|
Incoming clients accept all three migration-era shapes:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user