mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 13:45:22 +00:00
Bind capability state to Noise generations
This commit is contained in:
@@ -18,6 +18,10 @@ struct NoiseHandshakeProcessingResult {
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [PeerID: NoiseSession] = [:]
|
||||
/// Opaque identity for each exact entry in `sessions`. The generation is
|
||||
/// created and removed under the same barrier as the session itself, so a
|
||||
/// caller can never authenticate data with one session and lease another.
|
||||
private var sessionGenerations: [PeerID: UUID] = [:]
|
||||
/// A responder rehandshake must not evict a working transport session
|
||||
/// before the candidate proves that its authenticated static key belongs
|
||||
/// to the claimed wire ID. Candidates therefore live outside `sessions`
|
||||
@@ -27,7 +31,7 @@ final class NoiseSessionManager {
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)?
|
||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
@@ -64,6 +68,7 @@ final class NoiseSessionManager {
|
||||
if let session = sessions.removeValue(forKey: peerID) {
|
||||
session.reset() // Clear sensitive data before removing
|
||||
}
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
if let candidate = responderCandidates.removeValue(forKey: peerID) {
|
||||
candidate.reset()
|
||||
}
|
||||
@@ -79,6 +84,7 @@ final class NoiseSessionManager {
|
||||
candidate.reset()
|
||||
}
|
||||
sessions.removeAll()
|
||||
sessionGenerations.removeAll()
|
||||
responderCandidates.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -96,12 +102,14 @@ final class NoiseSessionManager {
|
||||
// Remove any existing non-established session
|
||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
existingSession.reset()
|
||||
}
|
||||
|
||||
// Create new initiator session
|
||||
let session = sessionFactory(peerID, .initiator)
|
||||
sessions[peerID] = session
|
||||
sessionGenerations[peerID] = UUID()
|
||||
|
||||
do {
|
||||
let handshakeData = try session.startHandshake()
|
||||
@@ -109,6 +117,7 @@ final class NoiseSessionManager {
|
||||
} catch {
|
||||
// Clean up failed session
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
session.reset()
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||
throw error
|
||||
@@ -132,8 +141,17 @@ final class NoiseSessionManager {
|
||||
from peerID: PeerID,
|
||||
message: Data
|
||||
) throws -> NoiseHandshakeProcessingResult {
|
||||
// Process everything within the synchronized block to prevent race conditions
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
// Process everything within the synchronized block to prevent race conditions.
|
||||
// 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 isReplacementCandidate: Bool
|
||||
|
||||
@@ -164,9 +182,11 @@ final class NoiseSessionManager {
|
||||
// No established transport state exists to preserve. A
|
||||
// fresh initiation replaces the incomplete handshake.
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
existing.reset()
|
||||
let replacement = sessionFactory(peerID, .responder)
|
||||
sessions[peerID] = replacement
|
||||
sessionGenerations[peerID] = UUID()
|
||||
session = replacement
|
||||
isReplacementCandidate = false
|
||||
} else {
|
||||
@@ -176,6 +196,7 @@ final class NoiseSessionManager {
|
||||
} else {
|
||||
let newSession = sessionFactory(peerID, .responder)
|
||||
sessions[peerID] = newSession
|
||||
sessionGenerations[peerID] = UUID()
|
||||
session = newSession
|
||||
isReplacementCandidate = false
|
||||
}
|
||||
@@ -187,8 +208,11 @@ final class NoiseSessionManager {
|
||||
// Check the exact session that processed this message. A
|
||||
// preserved peer-level session can remain established while a
|
||||
// replacement candidate is still unauthenticated.
|
||||
let didEstablishAuthenticatedSession = session.isEstablished()
|
||||
if didEstablishAuthenticatedSession {
|
||||
var establishedSession: (
|
||||
remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||
generation: UUID
|
||||
)?
|
||||
if session.isEstablished() {
|
||||
guard let remoteKey = session.getRemoteStaticPublicKey(),
|
||||
authenticatedRemoteKey(remoteKey, matches: peerID) else {
|
||||
throw NoiseSessionError.peerIdentityMismatch
|
||||
@@ -197,22 +221,18 @@ final class NoiseSessionManager {
|
||||
if isReplacementCandidate {
|
||||
_ = responderCandidates.removeValue(forKey: peerID)
|
||||
let previous = sessions.updateValue(session, forKey: peerID)
|
||||
sessionGenerations[peerID] = UUID()
|
||||
if let previous, previous !== session {
|
||||
previous.reset()
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionEstablished?(peerID, remoteKey)
|
||||
guard let generation = sessionGenerations[peerID] else {
|
||||
throw NoiseEncryptionError.sessionNotEstablished
|
||||
}
|
||||
establishedSession = (remoteKey, generation)
|
||||
}
|
||||
|
||||
return NoiseHandshakeProcessingResult(
|
||||
response: response,
|
||||
didEstablishAuthenticatedSession:
|
||||
didEstablishAuthenticatedSession
|
||||
)
|
||||
return (response, establishedSession)
|
||||
} catch {
|
||||
// A failed candidate is discarded without touching the
|
||||
// established session. Ordinary failed handshakes retain the
|
||||
@@ -225,6 +245,7 @@ final class NoiseSessionManager {
|
||||
} else if let storedSession = sessions[peerID],
|
||||
storedSession === session {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
}
|
||||
session.reset()
|
||||
|
||||
@@ -237,6 +258,14 @@ final class NoiseSessionManager {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if let established = result.establishedSession {
|
||||
onSessionEstablished?(peerID, established.remoteKey, established.generation)
|
||||
}
|
||||
return NoiseHandshakeProcessingResult(
|
||||
response: result.response,
|
||||
didEstablishAuthenticatedSession: result.establishedSession != nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
|
||||
@@ -260,19 +289,74 @@ final class NoiseSessionManager {
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
try managerQueue.sync {
|
||||
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 {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
try decryptWithSessionGeneration(ciphertext, from: peerID).plaintext
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -7,6 +7,11 @@ struct BLENoiseHandshakeHandlingResult {
|
||||
let didEstablishAuthenticatedSession: Bool
|
||||
}
|
||||
|
||||
struct BLENoiseDecryptionResult {
|
||||
let plaintext: Data
|
||||
let sessionGeneration: UUID
|
||||
}
|
||||
|
||||
/// Narrow environment for `BLENoisePacketHandler`.
|
||||
///
|
||||
/// All queue hops (collections barrier writes, main-actor UI notification)
|
||||
@@ -35,12 +40,16 @@ struct BLENoisePacketHandlerEnvironment {
|
||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||
let updatePeerLastSeen: (PeerID) -> Void
|
||||
/// 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).
|
||||
let clearSession: (PeerID) -> Void
|
||||
/// Consumes session-authenticated protocol state inside the transport. It
|
||||
/// must never escape to UI or Nostr payload dispatch.
|
||||
let handleAuthenticatedPeerState: (_ peerID: PeerID, _ payload: Data) -> Void
|
||||
let handleAuthenticatedPeerState: (
|
||||
_ peerID: PeerID,
|
||||
_ payload: Data,
|
||||
_ sessionGeneration: UUID
|
||||
) -> Void
|
||||
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
|
||||
let deliverNoisePayload: (
|
||||
_ peerID: PeerID,
|
||||
@@ -149,7 +158,8 @@ final class BLENoisePacketHandler {
|
||||
env.updatePeerLastSeen(peerID)
|
||||
|
||||
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 }
|
||||
|
||||
// First byte indicates the payload type
|
||||
@@ -164,7 +174,11 @@ final class BLENoisePacketHandler {
|
||||
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session)
|
||||
|
||||
if noisePayloadType == .authenticatedPeerState {
|
||||
env.handleAuthenticatedPeerState(peerID, Data(payloadData))
|
||||
env.handleAuthenticatedPeerState(
|
||||
peerID,
|
||||
Data(payloadData),
|
||||
decryption.sessionGeneration
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1109,6 +1109,7 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||
let normalizedPeerID = peerID.toShort()
|
||||
let state: (
|
||||
capabilities: PeerCapabilities,
|
||||
fingerprint: String?,
|
||||
@@ -1116,7 +1117,6 @@ final class BLEService: NSObject {
|
||||
authenticatedState: BLEAuthenticatedPeerStateObservation?,
|
||||
timedOut: BLEPrivateMediaProofTimeoutMarker?
|
||||
) = collectionsQueue.sync {
|
||||
let normalizedPeerID = peerID.toShort()
|
||||
let info = peerRegistry.info(for: normalizedPeerID)
|
||||
return (
|
||||
info?.capabilities ?? [],
|
||||
@@ -1126,6 +1126,14 @@ final class BLEService: NSObject {
|
||||
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 {
|
||||
// A raw fallback must be bound to the stable Noise key from a
|
||||
@@ -4579,10 +4587,14 @@ extension BLEService {
|
||||
}
|
||||
|
||||
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))…")
|
||||
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
|
||||
@@ -4594,45 +4606,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 generation = UUID()
|
||||
let transition = collectionsQueue.sync(flags: .barrier) {
|
||||
() -> (
|
||||
watchdog: (fingerprint: String, nonce: UUID),
|
||||
rejected: [@MainActor (PrivateMediaSendPolicy) -> Void]
|
||||
) in
|
||||
let watchdogNonce = UUID()
|
||||
privateMediaSessionGenerations[normalizedPeerID] = generation
|
||||
authenticatedPeerStates.removeValue(forKey: normalizedPeerID)
|
||||
privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID)
|
||||
privateMediaProofWatchdogs[normalizedPeerID] = BLEPrivateMediaProofWatchdog(
|
||||
fingerprint: fingerprint,
|
||||
sessionGeneration: generation,
|
||||
timeoutNonce: watchdogNonce
|
||||
)
|
||||
authenticatedPeerStateSendProgress[normalizedPeerID] =
|
||||
BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation)
|
||||
guard let transition = noiseService.withCurrentSessionGeneration(
|
||||
for: normalizedPeerID,
|
||||
expected: generation,
|
||||
{
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
() -> (
|
||||
watchdog: (fingerprint: String, nonce: UUID)?,
|
||||
rejected: [@MainActor (PrivateMediaSendPolicy) -> Void]
|
||||
) in
|
||||
guard privateMediaSessionGenerations[normalizedPeerID] != generation else {
|
||||
return (nil, [])
|
||||
}
|
||||
let watchdogNonce = UUID()
|
||||
privateMediaSessionGenerations[normalizedPeerID] = generation
|
||||
authenticatedPeerStates.removeValue(forKey: normalizedPeerID)
|
||||
privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID)
|
||||
privateMediaProofWatchdogs[normalizedPeerID] = BLEPrivateMediaProofWatchdog(
|
||||
fingerprint: fingerprint,
|
||||
sessionGeneration: generation,
|
||||
timeoutNonce: watchdogNonce
|
||||
)
|
||||
authenticatedPeerStateSendProgress[normalizedPeerID] =
|
||||
BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation)
|
||||
|
||||
guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else {
|
||||
return ((fingerprint, watchdogNonce), [])
|
||||
guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else {
|
||||
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 {
|
||||
pendingPrivateMediaPolicyResolutions.removeValue(forKey: normalizedPeerID)
|
||||
return ((fingerprint, watchdogNonce), Array(pending.completions.values))
|
||||
}
|
||||
pending.sessionGeneration = generation
|
||||
pending.timeoutNonce = watchdogNonce
|
||||
pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending
|
||||
return ((pending.fingerprint, watchdogNonce), [])
|
||||
}
|
||||
) else { return }
|
||||
|
||||
guard let watchdog = transition.watchdog else { return }
|
||||
|
||||
completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade)
|
||||
schedulePrivateMediaProofTimeout(
|
||||
for: normalizedPeerID,
|
||||
fingerprint: transition.watchdog.fingerprint,
|
||||
fingerprint: watchdog.fingerprint,
|
||||
sessionGeneration: generation,
|
||||
nonce: transition.watchdog.nonce
|
||||
nonce: watchdog.nonce
|
||||
)
|
||||
|
||||
// `onPeerAuthenticated` can fire while the initiator is returning XX
|
||||
@@ -4680,7 +4706,11 @@ extension BLEService {
|
||||
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()
|
||||
guard let state = AuthenticatedPeerStatePacket.decode(from: payload) else {
|
||||
SecureLogger.warning(
|
||||
@@ -4698,59 +4728,67 @@ extension BLEService {
|
||||
)
|
||||
return
|
||||
}
|
||||
let generation = collectionsQueue.sync {
|
||||
privateMediaSessionGenerations[normalizedPeerID]
|
||||
}
|
||||
guard let generation else { return }
|
||||
guard let application = noiseService.withCurrentSessionGeneration(
|
||||
for: normalizedPeerID,
|
||||
expected: generation,
|
||||
{
|
||||
() -> (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
|
||||
// mutations are synchronous so no announce or send-policy race can
|
||||
// observe a half-applied authenticated state.
|
||||
identityManager.bindAuthenticatedSigningPublicKey(
|
||||
state.signingPublicKey,
|
||||
fingerprint: fingerprint
|
||||
)
|
||||
identityManager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: publicKey,
|
||||
signingPublicKey: state.signingPublicKey,
|
||||
claimedNickname: nil
|
||||
)
|
||||
if state.capabilities.contains(.privateMedia) {
|
||||
identityManager.markPrivateMediaCapable(fingerprint: fingerprint)
|
||||
}
|
||||
// The generation lease prevents rekey/session promotion from
|
||||
// interleaving between validation and these durable mutations.
|
||||
identityManager.bindAuthenticatedSigningPublicKey(
|
||||
state.signingPublicKey,
|
||||
fingerprint: fingerprint
|
||||
)
|
||||
identityManager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: publicKey,
|
||||
signingPublicKey: state.signingPublicKey,
|
||||
claimedNickname: nil
|
||||
)
|
||||
if state.capabilities.contains(.privateMedia) {
|
||||
identityManager.markPrivateMediaCapable(fingerprint: fingerprint)
|
||||
}
|
||||
|
||||
let completions = collectionsQueue.sync(flags: .barrier) {
|
||||
() -> [@MainActor (PrivateMediaSendPolicy) -> Void] in
|
||||
guard privateMediaSessionGenerations[normalizedPeerID] == generation else {
|
||||
return []
|
||||
let completions = collectionsQueue.sync(flags: .barrier) {
|
||||
() -> [@MainActor (PrivateMediaSendPolicy) -> Void] in
|
||||
guard privateMediaSessionGenerations[normalizedPeerID] == generation else {
|
||||
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(
|
||||
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)
|
||||
}
|
||||
), application.accepted else { return }
|
||||
|
||||
// One bounded echo makes initiator/responder proof ordering converge
|
||||
// even when message 3 and the first proof take different mesh links.
|
||||
sendAuthenticatedPeerState(to: normalizedPeerID, echo: true)
|
||||
let policy = privateMediaSendPolicy(to: normalizedPeerID)
|
||||
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
|
||||
completePrivateMediaPolicyResolution(completions, with: policy)
|
||||
completePrivateMediaPolicyResolution(application.completions, with: policy)
|
||||
}
|
||||
|
||||
private func noteNoiseSessionCleared(for peerID: PeerID) {
|
||||
@@ -4840,7 +4878,22 @@ extension BLEService {
|
||||
let encrypted: Data
|
||||
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
|
||||
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 {
|
||||
encrypted = try noiseService.encrypt(typedPayload, for: peerID)
|
||||
}
|
||||
@@ -7002,13 +7055,24 @@ extension BLEService {
|
||||
},
|
||||
decrypt: { [weak self] payload, peerID in
|
||||
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
|
||||
self?.clearNoiseSession(for: peerID)
|
||||
},
|
||||
handleAuthenticatedPeerState: { [weak self] peerID, payload in
|
||||
self?.handleAuthenticatedPeerState(payload, from: peerID)
|
||||
handleAuthenticatedPeerState: { [weak self] peerID, payload, generation in
|
||||
self?.handleAuthenticatedPeerState(
|
||||
payload,
|
||||
from: peerID,
|
||||
sessionGeneration: generation
|
||||
)
|
||||
},
|
||||
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
|
||||
if type == .privateFile {
|
||||
|
||||
@@ -165,7 +165,6 @@ final class NoiseEncryptionService {
|
||||
// Peer fingerprints (SHA256 hash of static public key)
|
||||
private var peerFingerprints: [PeerID: String] = [:]
|
||||
private var fingerprintToPeerID: [String: PeerID] = [:]
|
||||
|
||||
// Thread safety
|
||||
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
||||
|
||||
@@ -183,6 +182,7 @@ final class NoiseEncryptionService {
|
||||
|
||||
// Callbacks
|
||||
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
|
||||
/// Automatic rekey removed the old session and produced XX message 1.
|
||||
/// 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) {
|
||||
self.keychain = keychain
|
||||
@@ -300,8 +312,12 @@ final class NoiseEncryptionService {
|
||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
||||
|
||||
// Set up session callbacks
|
||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
||||
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
|
||||
self?.handleSessionEstablished(
|
||||
peerID: peerID,
|
||||
remoteStaticKey: remoteStaticKey,
|
||||
sessionGeneration: generation
|
||||
)
|
||||
}
|
||||
|
||||
// Start session maintenance timer
|
||||
@@ -750,7 +766,11 @@ final class NoiseEncryptionService {
|
||||
/// messages retain the 64 KiB ceiling; this purpose-specific path permits
|
||||
/// the bounded `BitchatFilePacket` envelope and refuses every other typed
|
||||
/// payload so the larger allocation budget cannot become a generic bypass.
|
||||
func encryptPrivateFilePayload(_ data: Data, for peerID: PeerID) throws -> Data {
|
||||
func encryptPrivateFilePayload(
|
||||
_ data: Data,
|
||||
for peerID: PeerID,
|
||||
sessionGeneration: UUID? = nil
|
||||
) throws -> Data {
|
||||
guard NoisePayloadType.isPrivateFile(rawValue: data.first),
|
||||
NoiseSecurityValidator.validatePrivateFileMessageSize(data) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
@@ -767,11 +787,25 @@ final class NoiseEncryptionService {
|
||||
|
||||
// `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed
|
||||
// nonce/tag overhead, so the result is bounded without a second copy.
|
||||
if let sessionGeneration {
|
||||
return try sessionManager.encrypt(
|
||||
data,
|
||||
for: peerID,
|
||||
expectedSessionGeneration: sessionGeneration
|
||||
)
|
||||
}
|
||||
return try sessionManager.encrypt(data, for: peerID)
|
||||
}
|
||||
|
||||
/// Decrypt data from a specific peer
|
||||
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.
|
||||
// A larger candidate is admitted only up to the framed-file ceiling;
|
||||
// after authenticated decryption it must prove it is `.privateFile`.
|
||||
@@ -790,14 +824,14 @@ final class NoiseEncryptionService {
|
||||
throw NoiseEncryptionError.sessionNotEstablished
|
||||
}
|
||||
|
||||
let decrypted = try sessionManager.decrypt(data, from: peerID)
|
||||
let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID)
|
||||
if !isStandardCiphertext {
|
||||
guard NoisePayloadType.isPrivateFile(rawValue: decrypted.first),
|
||||
NoiseSecurityValidator.validatePrivateFileMessageSize(decrypted) else {
|
||||
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
|
||||
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
}
|
||||
return decrypted
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Peer Management
|
||||
@@ -809,6 +843,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() {
|
||||
sessionManager.removeAllSessions()
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
@@ -831,7 +884,11 @@ final class NoiseEncryptionService {
|
||||
|
||||
// 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
|
||||
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
||||
|
||||
@@ -846,6 +903,9 @@ final class NoiseEncryptionService {
|
||||
|
||||
// Notify all handlers about authentication
|
||||
serviceQueue.async { [weak self] in
|
||||
self?.onPeerAuthenticatedWithGenerationHandlers.forEach { handler in
|
||||
handler(peerID, fingerprint, sessionGeneration)
|
||||
}
|
||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||
handler(peerID, fingerprint)
|
||||
}
|
||||
|
||||
@@ -244,15 +244,16 @@ final class ChatMediaTransferCoordinator {
|
||||
// is still running off the main actor.
|
||||
registerTransfer(transferId: transferId, messageID: messageID)
|
||||
let prepareVoiceNotePacket = self.prepareVoiceNotePacket
|
||||
let generation = imagePreparationBarrier.currentGeneration
|
||||
let barrier = imagePreparationBarrier
|
||||
let generation = barrier.currentGeneration
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
Task.detached(priority: .userInitiated) { [weak self, barrier] in
|
||||
do {
|
||||
let packet = try prepareVoiceNotePacket(url)
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
await MainActor.run { [weak self, barrier] in
|
||||
guard let self,
|
||||
self.imagePreparationBarrier.isCurrent(generation),
|
||||
barrier.isCurrent(generation),
|
||||
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||
return
|
||||
}
|
||||
@@ -270,9 +271,9 @@ final class ChatMediaTransferCoordinator {
|
||||
} catch ChatMediaPreparationError.voiceNoteTooLarge(let size) {
|
||||
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
await MainActor.run { [weak self] in
|
||||
await MainActor.run { [weak self, barrier] in
|
||||
guard let self,
|
||||
self.imagePreparationBarrier.isCurrent(generation),
|
||||
barrier.isCurrent(generation),
|
||||
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||
return
|
||||
}
|
||||
@@ -280,9 +281,9 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
await MainActor.run { [weak self, barrier] in
|
||||
guard let self,
|
||||
self.imagePreparationBarrier.isCurrent(generation),
|
||||
barrier.isCurrent(generation),
|
||||
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user