Authenticate private media capabilities in Noise

This commit is contained in:
jack
2026-07-26 02:09:31 +02:00
committed by jack
parent 40238c5e43
commit ec795520ee
31 changed files with 1412 additions and 133 deletions
+10 -4
View File
@@ -190,11 +190,17 @@ struct IdentityCache: Codable {
// entries verified before this field exists sort as oldest)
var verifiedAt: [String: Date]? = nil
// Stable Noise fingerprints that have advertised encrypted private media
// in a signature-verified announce. Optional for decoding caches written
// before this migration. Entries are monotonic until a panic wipe so an
// old/replayed announce cannot silently downgrade a peer to clear media.
// Stable Noise fingerprints that proved encrypted private-media support
// inside an authenticated Noise session. Optional for decoding caches
// written before this migration. Entries are monotonic until a panic wipe
// so an old/replayed announce cannot silently downgrade a peer.
var privateMediaCapableFingerprints: Set<String>? = nil
// Noise-fingerprint -> Ed25519 announcement key, learned only from the
// authenticated peer-state payload. This prevents a self-signed announce
// containing a copied public Noise key from replacing a previously bound
// public-message signing identity. Optional for old cache compatibility.
var authenticatedSigningKeysByFingerprint: [String: Data]? = nil
}
//
@@ -141,6 +141,10 @@ protocol SecureIdentityStateManagerProtocol {
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
// MARK: Noise-authenticated announcement identity
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String)
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data?
// MARK: Private-media downgrade protection
func markPrivateMediaCapable(fingerprint: String)
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool
@@ -405,6 +409,37 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
cache.privateMediaCapableFingerprints?.contains(fingerprint) == true
}
}
// MARK: - Noise-authenticated announcement identity
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
guard signingPublicKey.count == AuthenticatedPeerStatePacket.signingPublicKeyLength,
!fingerprint.isEmpty else { return }
let bindAndPersist = {
var bindings = self.cache.authenticatedSigningKeysByFingerprint ?? [:]
let bindingChanged = bindings[fingerprint] != signingPublicKey
bindings[fingerprint] = signingPublicKey
self.cache.authenticatedSigningKeysByFingerprint = bindings
if var cryptoIdentity = self.cryptographicIdentities[fingerprint] {
cryptoIdentity.signingPublicKey = signingPublicKey
self.cryptographicIdentities[fingerprint] = cryptoIdentity
}
guard bindingChanged else { return }
self.saveIdentityCache()
}
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
bindAndPersist()
} else {
queue.sync(flags: .barrier, execute: bindAndPersist)
}
}
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
guard !fingerprint.isEmpty else { return nil }
return queue.sync {
cache.authenticatedSigningKeysByFingerprint?[fingerprint]
}
}
func updateSocialIdentity(_ identity: SocialIdentity) {
queue.async(flags: .barrier) {
+2 -2
View File
@@ -299,11 +299,11 @@ final class NoiseSessionManager {
}
}
func initiateRekey(for peerID: PeerID) throws {
func initiateRekey(for peerID: PeerID) throws -> Data {
// Remove old session
removeSession(for: peerID)
// Initiate new handshake
_ = try initiateHandshake(with: peerID)
return try initiateHandshake(with: peerID)
}
}
+6
View File
@@ -83,6 +83,11 @@ enum NoisePayloadType: UInt8 {
// Android client. The complete BitchatFilePacket is encrypted inside
// Noise before the outer noiseEncrypted packet is fragmented.
case privateFile = 0x20
// Versioned peer state authenticated by the surrounding Noise session.
// This is intentionally distinct from the public announce: announce
// capabilities are discovery hints, while this payload proves possession
// of the advertised Noise static key before downgrade state is pinned.
case authenticatedPeerState = 0x21
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
@@ -112,6 +117,7 @@ enum NoisePayloadType: UInt8 {
case .groupKeyUpdate: return "groupKeyUpdate"
case .voiceFrame: return "voiceFrame"
case .privateFile: return "privateFile"
case .authenticatedPeerState: return "authenticatedPeerState"
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
case .vouch: return "vouch"
+83
View File
@@ -156,6 +156,89 @@ struct AnnouncementPacket {
}
}
/// State that is authoritative only because it is carried inside an
/// established Noise session. The public announce remains useful for
/// discovery, but its self-signature cannot prove possession of the copied
/// Noise public key it contains.
///
/// Wire format (v1):
/// `[version=0x01][type][length][value]...`
/// - TLV `0x01`: canonical minimal little-endian `PeerCapabilities`
/// - TLV `0x02`: 32-byte Ed25519 signing public key
///
/// Unknown TLVs are skipped for forward compatibility. Unknown versions,
/// duplicates, non-canonical capability fields, and malformed lengths are
/// rejected without changing authenticated state.
struct AuthenticatedPeerStatePacket: Equatable {
static let currentVersion: UInt8 = 1
static let signingPublicKeyLength = 32
let capabilities: PeerCapabilities
let signingPublicKey: Data
private enum TLVType: UInt8 {
case capabilities = 0x01
case signingPublicKey = 0x02
}
func encode() -> Data? {
guard signingPublicKey.count == Self.signingPublicKeyLength else { return nil }
let capabilityBytes = capabilities.encoded()
guard !capabilityBytes.isEmpty, capabilityBytes.count <= 8 else { return nil }
var data = Data([Self.currentVersion])
data.append(TLVType.capabilities.rawValue)
data.append(UInt8(capabilityBytes.count))
data.append(capabilityBytes)
data.append(TLVType.signingPublicKey.rawValue)
data.append(UInt8(signingPublicKey.count))
data.append(signingPublicKey)
return data
}
static func decode(from data: Data) -> AuthenticatedPeerStatePacket? {
guard data.first == Self.currentVersion else { return nil }
var offset = 1
var capabilities: PeerCapabilities?
var signingPublicKey: Data?
while offset < data.count {
guard offset + 2 <= data.count else { return nil }
let typeRaw = data[offset]
let length = Int(data[offset + 1])
offset += 2
guard offset + length <= data.count else { return nil }
let value = Data(data[offset..<(offset + length)])
offset += length
guard let type = TLVType(rawValue: typeRaw) else {
continue
}
switch type {
case .capabilities:
guard capabilities == nil,
!value.isEmpty,
value.count <= 8 else { return nil }
let decoded = PeerCapabilities(encoded: value)
guard decoded.encoded() == value else { return nil }
capabilities = decoded
case .signingPublicKey:
guard signingPublicKey == nil,
value.count == Self.signingPublicKeyLength else { return nil }
signingPublicKey = value
}
}
guard let capabilities, let signingPublicKey else { return nil }
return AuthenticatedPeerStatePacket(
capabilities: capabilities,
signingPublicKey: signingPublicKey
)
}
}
struct PrivateMessagePacket {
let messageID: String
let content: String
+14 -1
View File
@@ -16,6 +16,9 @@ struct BLEAnnounceHandlerEnvironment {
let now: () -> Date
/// Noise public key already recorded for the peer, if any (registry read).
let existingNoisePublicKey: (PeerID) -> Data?
/// Ed25519 key previously bound to this Noise identity by an authenticated
/// peer-state payload, if any (persistent identity-state read).
let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data?
/// Verifies the packet signature against the announced signing key.
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Direct link state for the peer (BLE-queue read).
@@ -130,11 +133,21 @@ final class BLEAnnounceHandler {
hasSignature: hasSignature,
signatureValid: signatureValid,
existingNoisePublicKey: existingNoisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey
announcedNoisePublicKey: announcement.noisePublicKey,
authenticatedSigningPublicKey: env.authenticatedSigningPublicKey(
announcement.noisePublicKey
),
announcedSigningPublicKey: announcement.signingPublicKey
)
if case .reject(.keyMismatch) = trustDecision {
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
}
if case .reject(.authenticatedSigningKeyMismatch) = trustDecision {
SecureLogger.warning(
"⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))",
category: .security
)
}
let verifiedAnnounce = trustDecision.isVerified
var isNewPeer = false
@@ -56,6 +56,7 @@ enum BLEAnnounceTrustRejection: Equatable {
case missingSignature
case invalidSignature
case keyMismatch
case authenticatedSigningKeyMismatch
}
enum BLEAnnounceTrustDecision: Equatable {
@@ -72,12 +73,19 @@ enum BLEAnnounceTrustPolicy {
hasSignature: Bool,
signatureValid: Bool,
existingNoisePublicKey: Data?,
announcedNoisePublicKey: Data
announcedNoisePublicKey: Data,
authenticatedSigningPublicKey: Data? = nil,
announcedSigningPublicKey: Data? = nil
) -> BLEAnnounceTrustDecision {
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
return .reject(.keyMismatch)
}
if let authenticatedSigningPublicKey,
announcedSigningPublicKey != authenticatedSigningPublicKey {
return .reject(.authenticatedSigningKeyMismatch)
}
guard hasSignature else {
return .reject(.missingSignature)
}
@@ -38,6 +38,9 @@ struct BLENoisePacketHandlerEnvironment {
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data
/// 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
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
let deliverNoisePayload: (
_ peerID: PeerID,
@@ -160,6 +163,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))
return
}
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
} catch NoiseEncryptionError.sessionNotEstablished {
@@ -22,6 +22,11 @@ enum BLENoisePayloadFactory {
return typedPayload(.privateFile, payload: payload)
}
static func authenticatedPeerState(_ state: AuthenticatedPeerStatePacket) -> Data? {
guard let payload = state.encode() else { return nil }
return typedPayload(.authenticatedPeerState, payload: payload)
}
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
var typed = Data([type.rawValue])
typed.append(payload)
@@ -181,6 +181,14 @@ struct BLEPeerRegistry {
peers[peerID] = peer
}
/// Replaces the announcement signing key only after the surrounding Noise
/// session proved possession of this peer's static key.
mutating func bindAuthenticatedSigningPublicKey(_ key: Data, for peerID: PeerID) {
guard var peer = peers[peerID.toShort()] else { return }
peer.signingPublicKey = key
peers[peer.peerID] = peer
}
mutating func upsertVerifiedAnnounce(
peerID: PeerID,
nickname: String,
+574 -79
View File
@@ -176,6 +176,36 @@ private final class BLEPrivateMediaTransferAdmissionRegistry {
}
}
private struct BLEAuthenticatedPeerStateObservation {
let fingerprint: String
let sessionGeneration: UUID
let capabilities: PeerCapabilities
}
private struct BLEPrivateMediaProofTimeoutMarker {
let fingerprint: String
let sessionGeneration: UUID?
}
private struct BLEPrivateMediaProofWatchdog {
let fingerprint: String
let sessionGeneration: UUID
let timeoutNonce: UUID
}
private struct BLEPendingPrivateMediaPolicyResolution {
let fingerprint: String
var sessionGeneration: UUID?
var timeoutNonce: UUID
var completions: [UUID: @MainActor (PrivateMediaSendPolicy) -> Void]
}
private struct BLEAuthenticatedPeerStateSendProgress {
let sessionGeneration: UUID
var sentInitial = false
var sentEcho = false
}
/// BLEService Bluetooth Mesh Transport
/// - Emits events exclusively via `BitchatDelegate` for UI.
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
@@ -312,6 +342,15 @@ final class BLEService: NSObject {
private lazy var privateMediaTransferAdmissions = BLEPrivateMediaTransferAdmissionRegistry { [weak self] transferId in
self?.handlePrivateMediaAdmissionExpiry(transferId)
}
// All six maps below are protected by `collectionsQueue`. A fresh Noise
// authentication rotates the generation UUID, so stale proof timers and
// proof packets cannot classify a replacement session.
private var privateMediaSessionGenerations: [PeerID: UUID] = [:]
private var authenticatedPeerStates: [PeerID: BLEAuthenticatedPeerStateObservation] = [:]
private var privateMediaProofTimeoutMarkers: [PeerID: BLEPrivateMediaProofTimeoutMarker] = [:]
private var privateMediaProofWatchdogs: [PeerID: BLEPrivateMediaProofWatchdog] = [:]
private var pendingPrivateMediaPolicyResolutions: [PeerID: BLEPendingPrivateMediaPolicyResolution] = [:]
private var authenticatedPeerStateSendProgress: [PeerID: BLEAuthenticatedPeerStateSendProgress] = [:]
private let incomingFileStore: BLEIncomingFileStore
// Simple announce throttling
@@ -696,7 +735,7 @@ final class BLEService: NSObject {
pendingNoiseSessionQueues.removeAll()
}
let cancelledTransfers = collectionsQueue.sync(flags: .barrier) {
let panicReset = collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.removeAll()
pendingNotifications.removeAll()
let transfers = outboundFragmentTransfers.removeAll()
@@ -705,15 +744,25 @@ final class BLEService: NSObject {
ingressLinks.removeAll()
recentTrafficTracker.removeAll()
scheduledRelays.cancelAll()
let policyCompletions = pendingPrivateMediaPolicyResolutions.values.flatMap {
Array($0.completions.values)
}
pendingPrivateMediaPolicyResolutions.removeAll()
privateMediaSessionGenerations.removeAll()
authenticatedPeerStates.removeAll()
privateMediaProofTimeoutMarkers.removeAll()
privateMediaProofWatchdogs.removeAll()
authenticatedPeerStateSendProgress.removeAll()
// Let the post-panic identity publish its fresh bundle promptly.
lastPrekeyBundleSentAt = nil
return transfers
return (transfers, policyCompletions)
}
for entry in cancelledTransfers {
for entry in panicReset.0 {
entry.workItems.forEach { $0.cancel() }
TransferProgressManager.shared.cancel(id: entry.id)
}
completePrivateMediaPolicyResolution(panicReset.1, with: .blockedDowngrade)
bleQueue.sync {
pendingWriteBuffers.removeAll()
@@ -1062,64 +1111,212 @@ final class BLEService: NSObject {
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
let state: (
capabilities: PeerCapabilities,
fingerprint: String?
fingerprint: String?,
sessionGeneration: UUID?,
authenticatedState: BLEAuthenticatedPeerStateObservation?,
timedOut: BLEPrivateMediaProofTimeoutMarker?
) = collectionsQueue.sync {
let info = peerRegistry.info(for: peerID.toShort())
let normalizedPeerID = peerID.toShort()
let info = peerRegistry.info(for: normalizedPeerID)
return (
info?.capabilities ?? [],
info?.noisePublicKey?.sha256Fingerprint()
info?.noisePublicKey?.sha256Fingerprint(),
privateMediaSessionGenerations[normalizedPeerID],
authenticatedPeerStates[normalizedPeerID],
privateMediaProofTimeoutMarkers[normalizedPeerID]
)
}
if state.capabilities.contains(.privateMedia) {
return .encrypted
}
guard let fingerprint = state.fingerprint else {
// A raw fallback must be bound to the stable Noise key from a
// verified registry entry; a routing ID alone can rotate or be
// spoofed. Session authentication is required for pinning, below,
// but old clients may need the consented fallback before a session.
// spoofed. Without that key neither proof nor safe migration state
// can be attributed.
return .blockedDowngrade
}
// During the mixed-version migration, an unpinned peer is legacy
// eligible whether the capabilities TLV was absent or explicitly
// omitted this bit. Only a capability observation bound to a matching
// authenticated Noise session may make the no-bit state a downgrade.
return identityManager.hasObservedPrivateMediaCapability(fingerprint: fingerprint)
? .blockedDowngrade
: .legacyRequiresConsent
let wasPreviouslyCapable = identityManager.hasObservedPrivateMediaCapability(
fingerprint: fingerprint
)
if let authenticated = state.authenticatedState,
authenticated.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
authenticated.sessionGeneration == state.sessionGeneration {
if authenticated.capabilities.contains(.privateMedia) {
return .encrypted
}
return wasPreviouslyCapable ? .blockedDowngrade : .legacyRequiresConsent
}
if let timedOut = state.timedOut,
timedOut.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
timedOut.sessionGeneration == state.sessionGeneration {
return wasPreviouslyCapable ? .blockedDowngrade : .legacyRequiresConsent
}
// The announce bit is a discovery hint only. It can trigger a Noise
// handshake, but it cannot select encrypted media or create a durable
// pin because anyone can copy a public Noise key into a self-signed
// announce. A prior pin also re-confirms on each replacement session
// so an authenticated no-bit response becomes a visible downgrade.
if state.capabilities.contains(.privateMedia) || wasPreviouslyCapable {
return .awaitingCapabilityProof
}
// Old clients that never advertised the bit remain eligible only for
// the explicit, invocation-scoped legacy consent path.
return .legacyRequiresConsent
}
/// Pins private-media support only when the capability-bearing registry
/// identity and a completed Noise session authenticate the same static
/// key. A signed announce alone does not prove possession of that Noise
/// key and must never create a downgrade pin.
private func reconcilePrivateMediaCapabilityPin(
for peerID: PeerID,
authenticatedFingerprint: String? = nil
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
let normalizedPeerID = peerID.toShort()
let advertisedFingerprint: String? = collectionsQueue.sync {
guard let info = peerRegistry.info(for: normalizedPeerID),
info.capabilities.contains(.privateMedia) else { return nil }
return info.noisePublicKey?.sha256Fingerprint()
}
guard let advertisedFingerprint else { return }
messageQueue.async { [weak self] in
guard let self else { return }
let immediate = self.privateMediaSendPolicy(to: normalizedPeerID)
guard immediate == .awaitingCapabilityProof else {
self.completePrivateMediaPolicyResolution([completion], with: immediate)
return
}
let sessionFingerprint = authenticatedFingerprint
?? noiseService.getPeerFingerprint(normalizedPeerID)
guard let sessionFingerprint,
sessionFingerprint.caseInsensitiveCompare(advertisedFingerprint) == .orderedSame else {
if authenticatedFingerprint != nil {
SecureLogger.warning(
"Refusing private-media capability pin for \(normalizedPeerID.id.prefix(8))…: authenticated Noise key does not match verified announce",
category: .security
let fingerprint: String? = self.collectionsQueue.sync {
self.peerRegistry.info(for: normalizedPeerID)?
.noisePublicKey?
.sha256Fingerprint()
}
guard let fingerprint else {
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
return
}
let requestID = UUID()
let registration = self.collectionsQueue.sync(flags: .barrier) {
() -> (registered: Bool, shouldSchedule: Bool, nonce: UUID, generation: UUID?) in
let generation = self.privateMediaSessionGenerations[normalizedPeerID]
if var pending = self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] {
guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
pending.completions.count
< TransportConfig.privateMediaCapabilityProofWaitersPerPeerCap else {
return (false, false, UUID(), generation)
}
pending.completions[requestID] = completion
self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending
return (true, false, pending.timeoutNonce, pending.sessionGeneration)
}
guard self.pendingPrivateMediaPolicyResolutions.count
< TransportConfig.privateMediaCapabilityProofPendingPeerCap else {
return (false, false, UUID(), generation)
}
let currentWatchdog = self.privateMediaProofWatchdogs[normalizedPeerID]
let reusesWatchdog = currentWatchdog?.fingerprint
.caseInsensitiveCompare(fingerprint) == .orderedSame
&& currentWatchdog?.sessionGeneration == generation
let nonce: UUID
if reusesWatchdog, let currentWatchdog {
nonce = currentWatchdog.timeoutNonce
} else {
nonce = UUID()
}
self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] =
BLEPendingPrivateMediaPolicyResolution(
fingerprint: fingerprint,
sessionGeneration: generation,
timeoutNonce: nonce,
completions: [requestID: completion]
)
return (true, !reusesWatchdog, nonce, generation)
}
guard registration.registered else {
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
return
}
if registration.shouldSchedule {
self.schedulePrivateMediaProofTimeout(
for: normalizedPeerID,
fingerprint: fingerprint,
sessionGeneration: registration.generation,
nonce: registration.nonce
)
}
return
if !self.noiseService.hasEstablishedSession(with: normalizedPeerID) {
self.initiateNoiseHandshake(with: normalizedPeerID)
}
}
identityManager.markPrivateMediaCapable(fingerprint: advertisedFingerprint)
}
private func completePrivateMediaPolicyResolution(
_ completions: [@MainActor (PrivateMediaSendPolicy) -> Void],
with policy: PrivateMediaSendPolicy
) {
guard !completions.isEmpty else { return }
Task { @MainActor in
completions.forEach { $0(policy) }
}
}
private func schedulePrivateMediaProofTimeout(
for peerID: PeerID,
fingerprint: String,
sessionGeneration: UUID?,
nonce: UUID
) {
messageQueue.asyncAfter(
deadline: .now() + TransportConfig.privateMediaCapabilityProofTimeoutSeconds
) { [weak self] in
self?.handlePrivateMediaProofTimeout(
for: peerID,
fingerprint: fingerprint,
sessionGeneration: sessionGeneration,
nonce: nonce
)
}
}
private func handlePrivateMediaProofTimeout(
for peerID: PeerID,
fingerprint: String,
sessionGeneration: UUID?,
nonce: UUID
) {
let expiration = collectionsQueue.sync(flags: .barrier) {
() -> (expired: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) in
let pending = pendingPrivateMediaPolicyResolutions[peerID]
let pendingMatches = pending?.timeoutNonce == nonce
&& pending?.sessionGeneration == sessionGeneration
&& pending?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame
let watchdog = privateMediaProofWatchdogs[peerID]
let watchdogMatches = sessionGeneration != nil
&& watchdog?.timeoutNonce == nonce
&& watchdog?.sessionGeneration == sessionGeneration
&& watchdog?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame
guard pendingMatches || watchdogMatches else {
return (false, [])
}
var completions: [@MainActor (PrivateMediaSendPolicy) -> Void] = []
if pendingMatches, let pending {
completions = Array(pending.completions.values)
}
if pendingMatches {
pendingPrivateMediaPolicyResolutions.removeValue(forKey: peerID)
}
if watchdogMatches {
privateMediaProofWatchdogs.removeValue(forKey: peerID)
}
privateMediaProofTimeoutMarkers[peerID] = BLEPrivateMediaProofTimeoutMarker(
fingerprint: fingerprint,
sessionGeneration: sessionGeneration
)
return (true, completions)
}
guard expiration.expired else { return }
let policy = privateMediaSendPolicy(to: peerID)
sendPendingNoisePayloadsAfterHandshake(for: peerID)
completePrivateMediaPolicyResolution(expiration.completions, with: policy)
}
/// Enables or disables a runtime-advertised capability bit (e.g. the
@@ -1408,6 +1605,25 @@ final class BLEService: NSObject {
case .encrypted:
break
case .awaitingCapabilityProof:
// The UI coordinator resolves this state before calling the
// transport. Keep the transport guard fail-closed for direct
// callers and for a session replacement that races the call.
SecureLogger.warning(
"Private media held pending authenticated capability proof for \(targetID.id.prefix(8))",
category: .security
)
TransferProgressManager.shared.rejectBeforeStart(
id: transferId,
reason: String(
localized: "content.delivery.reason.private_media_capability_unresolved",
defaultValue: "Could not confirm encrypted media support",
comment: "Failure reason when private-media capability negotiation did not resolve"
)
)
self.privateMediaTransferAdmissions.finish(transferId)
return
case .legacyRequiresConsent:
guard allowLegacyFallback else {
SecureLogger.warning(
@@ -2388,7 +2604,7 @@ final class BLEService: NSObject {
// A valid departure retires transport state too; otherwise
// canDeliverSecurely could remain true for a peer we just removed.
noiseService.clearSession(for: peerID)
clearNoiseSession(for: peerID)
readLinkState { _ in
let departedLinks = noiseAuthenticatedLinkOwners.compactMap { link, owner in
owner == peerID ? link : nil
@@ -3214,6 +3430,41 @@ extension BLEService {
sendPendingNoisePayloadsAfterHandshake(for: peerID)
}
func _test_hasPendingPrivateMediaPolicyResolution(for peerID: PeerID) -> Bool {
collectionsQueue.sync {
pendingPrivateMediaPolicyResolutions[peerID.toShort()] != nil
}
}
func _test_forcePrivateMediaProofTimeout(for peerID: PeerID) {
let normalizedPeerID = peerID.toShort()
let target = collectionsQueue.sync {
() -> (fingerprint: String, generation: UUID?, nonce: UUID)? in
if let watchdog = privateMediaProofWatchdogs[normalizedPeerID] {
return (
watchdog.fingerprint,
watchdog.sessionGeneration,
watchdog.timeoutNonce
)
}
if let pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] {
return (
pending.fingerprint,
pending.sessionGeneration,
pending.timeoutNonce
)
}
return nil
}
guard let target else { return }
handlePrivateMediaProofTimeout(
for: normalizedPeerID,
fingerprint: target.fingerprint,
sessionGeneration: target.generation,
nonce: target.nonce
)
}
func _test_privateMediaTransferState(
transferId: String
) -> (admissionActive: Bool, pendingNoise: Bool, activeScheduler: Int, pendingScheduler: Int) {
@@ -3271,6 +3522,17 @@ extension BLEService {
)
}
func _test_drainNoiseMessagePipeline() async {
let collectionsQueue = self.collectionsQueue
await withCheckedContinuation { continuation in
messageQueue.async(flags: .barrier) {
collectionsQueue.async(flags: .barrier) {
continuation.resume()
}
}
}
}
/// Builds an authenticated-session packet from an exact typed plaintext.
/// Compatibility tests use this to model Android's deployed 0x20 file
/// payload and the short-lived 0x09 prerelease payload without exposing a
@@ -4320,20 +4582,208 @@ extension BLEService {
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))")
self?.messageQueue.async { [weak self] in
self?.reconcilePrivateMediaCapabilityPin(
for: peerID,
authenticatedFingerprint: fingerprint
)
#if DEBUG
self?._test_onPrivateMediaSessionReconciled?(peerID)
#endif
self?.sendPendingMessagesAfterHandshake(for: peerID)
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
}
self?.messageQueue.async { [weak self] in
self?.sendAnnounce(forceSend: true)
self?.handleNoisePeerAuthenticated(peerID: peerID, fingerprint: fingerprint)
}
}
service.onRekeyHandshakeReady = { [weak self] peerID, message in
self?.messageQueue.async { [weak self] in
guard let self else { return }
self.noteNoiseSessionCleared(for: peerID)
self.broadcastNoiseHandshake(message, to: peerID)
}
}
}
private func handleNoisePeerAuthenticated(peerID: PeerID, fingerprint: String) {
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 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), [])
}
completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade)
schedulePrivateMediaProofTimeout(
for: normalizedPeerID,
fingerprint: transition.watchdog.fingerprint,
sessionGeneration: generation,
nonce: transition.watchdog.nonce
)
// `onPeerAuthenticated` can fire while the initiator is returning XX
// message 3. This callback is queued behind the handshake handler, so
// message 3 is broadcast first. Both peers also send one idempotent
// echo after receiving the other's state to recover cross-link races.
sendAuthenticatedPeerState(to: normalizedPeerID, echo: false)
#if DEBUG
_test_onPrivateMediaSessionReconciled?(normalizedPeerID)
#endif
sendPendingMessagesAfterHandshake(for: normalizedPeerID)
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
sendAnnounce(forceSend: true)
}
private func sendAuthenticatedPeerState(to peerID: PeerID, echo: Bool) {
let normalizedPeerID = peerID.toShort()
let shouldSend = collectionsQueue.sync(flags: .barrier) {
guard let generation = privateMediaSessionGenerations[normalizedPeerID],
var progress = authenticatedPeerStateSendProgress[normalizedPeerID],
progress.sessionGeneration == generation else { return false }
if echo {
guard !progress.sentEcho else { return false }
progress.sentEcho = true
} else {
guard !progress.sentInitial else { return false }
progress.sentInitial = true
}
authenticatedPeerStateSendProgress[normalizedPeerID] = progress
return true
}
guard shouldSend else { return }
let capabilities = collectionsQueue.sync {
PeerCapabilities.localSupported.union(runtimeCapabilities)
}
let state = AuthenticatedPeerStatePacket(
capabilities: capabilities,
signingPublicKey: noiseService.getSigningPublicKeyData()
)
guard let payload = BLENoisePayloadFactory.authenticatedPeerState(state) else {
SecureLogger.error("Failed to encode authenticated peer state", category: .security)
return
}
sendNoisePayload(payload, to: normalizedPeerID)
}
private func handleAuthenticatedPeerState(_ payload: Data, from peerID: PeerID) {
let normalizedPeerID = peerID.toShort()
guard let state = AuthenticatedPeerStatePacket.decode(from: payload) else {
SecureLogger.warning(
"Ignoring malformed authenticated peer state from \(normalizedPeerID.id.prefix(8))",
category: .security
)
return
}
guard let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID),
let publicKey = noiseService.getPeerPublicKeyData(normalizedPeerID),
publicKey.sha256Fingerprint().caseInsensitiveCompare(fingerprint) == .orderedSame else {
SecureLogger.warning(
"Ignoring peer state without a matching authenticated Noise identity",
category: .security
)
return
}
let generation = collectionsQueue.sync {
privateMediaSessionGenerations[normalizedPeerID]
}
guard let generation else { return }
// 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)
}
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)
}
// 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)
}
private func noteNoiseSessionCleared(for peerID: PeerID) {
let normalizedPeerID = peerID.toShort()
let reset = collectionsQueue.sync(flags: .barrier) {
() -> (fingerprint: String, nonce: UUID)? in
privateMediaSessionGenerations.removeValue(forKey: normalizedPeerID)
authenticatedPeerStates.removeValue(forKey: normalizedPeerID)
privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID)
privateMediaProofWatchdogs.removeValue(forKey: normalizedPeerID)
authenticatedPeerStateSendProgress.removeValue(forKey: normalizedPeerID)
guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else {
return nil
}
let nonce = UUID()
pending.sessionGeneration = nil
pending.timeoutNonce = nonce
pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending
return (pending.fingerprint, nonce)
}
if let reset {
schedulePrivateMediaProofTimeout(
for: normalizedPeerID,
fingerprint: reset.fingerprint,
sessionGeneration: nil,
nonce: reset.nonce
)
}
}
private func clearNoiseSession(for peerID: PeerID) {
noiseService.clearSession(for: peerID)
noteNoiseSessionCleared(for: peerID)
}
/// Swaps `myPeerID`/`myPeerIDData` to match the current Noise identity.
@@ -5355,22 +5805,24 @@ extension BLEService {
do {
let handshakeData = try noiseService.initiateHandshake(with: peerID)
// Send handshake init
let packet = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: handshakeData,
signature: nil,
ttl: messageTTL
)
broadcastPacket(packet)
broadcastNoiseHandshake(handshakeData, to: peerID)
} catch {
SecureLogger.error("Failed to initiate handshake: \(error)")
}
}
private func broadcastNoiseHandshake(_ handshakeData: Data, to peerID: PeerID) {
let packet = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: handshakeData,
signature: nil,
ttl: messageTTL
)
broadcastPacket(packet)
}
private func sendPendingMessagesAfterHandshake(for peerID: PeerID) {
// Atomically take all pending messages to process (prevents concurrent modification)
@@ -5902,14 +6354,17 @@ extension BLEService {
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
let result = announceHandler.handle(packet, from: peerID)
// The verified announce and Noise handshake can arrive in either
// order. If authentication already completed, compare its static-key
// fingerprint with the newly upserted registry key before pinning;
// otherwise the session callback performs this reconciliation later.
// A capability bit in the public announce is only a discovery hint.
// Start authentication promptly for a directly connected candidate,
// but never pin or pre-queue private bytes until encrypted 0x21 state
// arrives from the completed Noise session.
if let result,
result.isVerified,
result.announcement.capabilities?.contains(.privateMedia) == true {
reconcilePrivateMediaCapabilityPin(for: result.peerID)
result.isDirectAnnounce,
result.announcement.capabilities?.contains(.privateMedia) == true,
privateMediaSendPolicy(to: result.peerID) == .awaitingCapabilityProof,
!noiseService.hasSession(with: result.peerID) {
initiateNoiseHandshake(with: result.peerID)
}
// A verified announce is the moment a signing key becomes bound to this
@@ -5959,7 +6414,7 @@ extension BLEService {
if noiseService.hasEstablishedSession(with: result.peerID) {
// A session with no surviving authenticated link is stale;
// force the current link to prove possession again.
noiseService.clearSession(for: result.peerID)
clearNoiseSession(for: result.peerID)
}
if !noiseService.hasSession(with: result.peerID) {
initiateNoiseHandshake(with: result.peerID)
@@ -6185,6 +6640,11 @@ extension BLEService {
guard let self = self else { return nil }
return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
},
authenticatedSigningPublicKey: { [weak self] noisePublicKey in
self?.identityManager.authenticatedSigningPublicKey(
forFingerprint: noisePublicKey.sha256Fingerprint()
)
},
verifySignature: { [weak self] packet, signingPublicKey in
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
},
@@ -6545,7 +7005,10 @@ extension BLEService {
return try self.noiseService.decrypt(payload, from: peerID)
},
clearSession: { [weak self] peerID in
self?.noiseService.clearSession(for: peerID)
self?.clearNoiseSession(for: peerID)
},
handleAuthenticatedPeerState: { [weak self] peerID, payload in
self?.handleAuthenticatedPeerState(payload, from: peerID)
},
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
if type == .privateFile {
@@ -6578,10 +7041,42 @@ extension BLEService {
guard !payloads.isEmpty else { return }
SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session)
for pending in payloads {
let privateMediaTransferId: String? = {
guard NoisePayloadType.isPrivateFile(rawValue: pending.payload.first) else { return nil }
return pending.transferId
}()
let isPrivateMedia = NoisePayloadType.isPrivateFile(rawValue: pending.payload.first)
let privateMediaTransferId = isPrivateMedia ? pending.transferId : nil
if isPrivateMedia {
switch privateMediaSendPolicy(to: peerID) {
case .encrypted:
break
case .awaitingCapabilityProof:
// Handshake completion alone is insufficient. Put the
// exact payload back until authenticated 0x21 state
// arrives; that handler calls this drain again.
collectionsQueue.sync(flags: .barrier) {
pendingNoiseSessionQueues.appendTypedPayload(
pending.payload,
transferId: pending.transferId,
for: peerID
)
}
continue
case .legacyRequiresConsent, .blockedDowngrade:
if let transferId = pending.transferId {
TransferProgressManager.shared.rejectBeforeStart(
id: transferId,
reason: String(
localized: "content.delivery.reason.private_media_capability_unresolved",
defaultValue: "Could not confirm encrypted media support",
comment: "Failure reason when queued private media cannot be authenticated after handshake"
)
)
privateMediaTransferAdmissions.finish(transferId)
}
continue
}
}
if let transferId = privateMediaTransferId,
!privateMediaTransferAdmissions.isActive(transferId) {
privateMediaTransferAdmissions.finish(transferId)
+19 -7
View File
@@ -184,6 +184,11 @@ final class NoiseEncryptionService {
// Callbacks
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
/// Automatic rekey removed the old session and produced XX message 1.
/// The transport must clear session-scoped state and put these exact bytes
/// on the wire; merely reporting "handshake required" strands the partial
/// initiator session because a second initiate call sees it already exists.
var onRekeyHandshakeReady: ((_ peerID: PeerID, _ message: Data) -> Void)?
// Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
@@ -864,19 +869,26 @@ final class NoiseEncryptionService {
let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey()
for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey {
// Attempt to rekey the session
do {
try sessionManager.initiateRekey(for: peerID)
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
// Signal that handshake is needed
onHandshakeRequired?(peerID)
try initiateAutomaticRekey(for: peerID)
} catch {
SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session)
}
}
}
private func initiateAutomaticRekey(for peerID: PeerID) throws {
let handshakeMessage = try sessionManager.initiateRekey(for: peerID)
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
onRekeyHandshakeReady?(peerID, handshakeMessage)
onHandshakeRequired?(peerID)
}
#if DEBUG
func _test_initiateAutomaticRekey(for peerID: PeerID) throws {
try initiateAutomaticRekey(for: peerID)
}
#endif
deinit {
stopRekeyTimer()
+18
View File
@@ -88,6 +88,11 @@ enum TransportEvent: @unchecked Sendable {
/// legacy consent.
enum PrivateMediaSendPolicy: Equatable {
case encrypted
/// A public announce hinted at encrypted media (or a prior authenticated
/// pin exists), but this exact Noise session has not yet supplied its
/// authenticated peer-state proof. Callers wait boundedly; they must not
/// pre-queue encrypted bytes or silently select the legacy path.
case awaitingCapabilityProof
case legacyRequiresConsent
case blockedDowngrade
}
@@ -224,6 +229,10 @@ protocol Transport: AnyObject {
/// empty for peers that predate the capabilities TLV.
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
)
/// Sends an encoded vouch-attestation batch inside the Noise session.
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
/// Appends a peer-authenticated observer. Unlike
@@ -295,6 +304,15 @@ extension Transport {
func broadcastGroupMessage(_ envelope: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
let policy = privateMediaSendPolicy(to: peerID)
Task { @MainActor in
completion(policy == .awaitingCapabilityProof ? .blockedDowngrade : policy)
}
}
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
+6
View File
@@ -9,6 +9,12 @@ enum TransportConfig {
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
// Bounded wait for the session-authenticated capability proof used by
// private-media migration. Expiry never auto-sends clear bytes; it only
// resolves to the existing one-shot consent or downgrade-blocked path.
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
@@ -57,6 +57,10 @@ protocol ChatMediaTransferContext: AnyObject {
// MARK: Mesh file transfer
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
)
func requestLegacyPrivateMediaConsent(
for peerID: PeerID,
transferId: String,
@@ -89,6 +93,13 @@ extension ChatViewModel: ChatMediaTransferContext {
meshService.privateMediaSendPolicy(to: peerID)
}
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion)
}
func requestLegacyPrivateMediaConsent(
for peerID: PeerID,
transferId: String,
@@ -492,7 +503,23 @@ final class ChatMediaTransferCoordinator {
transferId: String,
messageID: String
) {
switch context.privateMediaSendPolicy(to: peerID) {
continuePrivateMediaSend(
packet,
to: peerID,
transferId: transferId,
messageID: messageID,
policy: context.privateMediaSendPolicy(to: peerID)
)
}
private func continuePrivateMediaSend(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
messageID: String,
policy: PrivateMediaSendPolicy
) {
switch policy {
case .encrypted:
context.sendFilePrivate(
packet,
@@ -501,6 +528,32 @@ final class ChatMediaTransferCoordinator {
allowLegacyFallback: false
)
case .awaitingCapabilityProof:
context.resolvePrivateMediaSendPolicy(to: peerID) { [weak self] resolvedPolicy in
guard let self,
self.isRegisteredTransfer(transferId, messageID: messageID) else {
return
}
guard resolvedPolicy != .awaitingCapabilityProof else {
self.handleMediaSendFailure(
messageID: messageID,
reason: String(
localized: "content.delivery.reason.private_media_capability_unresolved",
defaultValue: "Could not confirm encrypted media support",
comment: "Failure reason when private-media capability negotiation did not resolve"
)
)
return
}
self.continuePrivateMediaSend(
packet,
to: peerID,
transferId: transferId,
messageID: messageID,
policy: resolvedPolicy
)
}
case .legacyRequiresConsent:
context.requestLegacyPrivateMediaConsent(
for: peerID,
@@ -408,10 +408,11 @@ private extension ChatTransportEventCoordinator {
case .voiceFrame:
context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp)
case .privateFile:
case .privateFile, .authenticatedPeerState:
// BLEService validates and persists decrypted private files before
// emitting a normal `.messageReceived` event. No raw file bytes
// should cross this UI-facing typed-payload fallback.
// emitting a normal `.messageReceived` event, and consumes peer
// state inside the transport. Neither payload crosses this
// UI-facing typed-payload fallback.
break
}
}
@@ -310,7 +310,7 @@ final class NostrInboundPipeline {
// claiming to be group traffic over Nostr is ignored.
// Live voice is mesh-only: latency and relay cost make it
// meaningless over Nostr.
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile:
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
break
}
}
@@ -366,7 +366,7 @@ final class NostrInboundPipeline {
// claiming to be group traffic over Nostr is ignored.
// Live voice is mesh-only: latency and relay cost make it
// meaningless over Nostr.
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile:
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
break
}
}
@@ -449,7 +449,7 @@ final class NostrInboundPipeline {
// in v1; group traffic over Nostr is ignored.
// Live voice is mesh-only: latency and relay cost make it
// meaningless over Nostr.
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile:
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
break
}
}