Authenticate private media capabilities in Noise

This commit is contained in:
jack
2026-07-12 11:19:09 -04:00
parent add586eb2c
commit 6063d42500
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
@@ -272,11 +272,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)
}
@@ -30,6 +30,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,
@@ -128,6 +131,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`).
@@ -307,6 +337,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
@@ -599,7 +638,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()
@@ -608,15 +647,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()
@@ -913,64 +962,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
@@ -1254,6 +1451,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(
@@ -2207,7 +2423,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
@@ -2990,6 +3206,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) {
@@ -3036,6 +3287,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
@@ -4023,20 +4285,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.
@@ -5054,22 +5504,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)
@@ -5585,14 +6037,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
@@ -5642,7 +6097,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)
@@ -5868,6 +6323,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
},
@@ -6223,7 +6683,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 {
@@ -6256,10 +6719,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) {
@@ -849,19 +854,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,
@@ -350,7 +361,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,
@@ -359,6 +386,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
}
}
@@ -307,7 +307,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
}
}
@@ -363,7 +363,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
}
}
@@ -446,7 +446,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
}
}
@@ -89,6 +89,7 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
private(set) var broadcastFileSends: [String] = []
private(set) var cancelledTransfers: [String] = []
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
private(set) var legacyConsentRequests: [(
id: UUID,
peerID: PeerID,
@@ -103,6 +104,13 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
privateMediaPolicy
}
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
}
func requestLegacyPrivateMediaConsent(
for peerID: PeerID,
transferId: String,
@@ -463,6 +471,31 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(context.privateFileLegacyAllowances == [true])
}
@Test @MainActor
func capabilityProofTimeoutTransitionsToConsentWithoutAutomaticRawSend() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "1020304050607080")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .awaitingCapabilityProof
context.resolvedPrivateMediaPolicy = .legacyRequiresConsent
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("proof-timeout-consent-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
let prompted = await TestHelpers.waitUntil(
{ context.legacyConsentRequests.count == 1 },
timeout: TestConstants.longTimeout
)
#expect(prompted)
#expect(context.privateFileSends.isEmpty)
context.resolveNextLegacyConsent(false)
#expect(context.privateFileSends.isEmpty)
}
@Test @MainActor
func legacyConsentApprovalAfterCancelCannotSend() async throws {
let context = MockChatMediaTransferContext()
@@ -218,26 +218,40 @@ struct PrivateMediaEndToEndTests {
capabilities: .privateMedia,
noisePublicKey: bobKey
)
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof)
let bobFingerprint = bobKey.sha256Fingerprint()
#expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint))
try establishSession(alice: alice, bob: bob)
try await establishSession(alice: alice, bob: bob)
let capabilityPinned = await TestHelpers.waitUntil(
{ identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint) },
timeout: TestConstants.longTimeout
)
#expect(capabilityPinned)
// Model a later verified old/replayed announce for the same stable
// Noise identity. The current bit disappears, but the authenticated
// persistent pin wins.
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
// A public no-bit announce cannot override state authenticated by the
// current session. A later authenticated no-bit state is a real
// downgrade and must block despite a caller offering legacy consent.
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: [],
noisePublicKey: bobKey
)
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
let authenticatedNoBit = try authenticatedPeerStatePacket(
from: bob,
to: alice,
capabilities: []
)
alice._test_handlePacket(authenticatedNoBit, fromPeerID: bob.myPeerID)
let downgradeObserved = await TestHelpers.waitUntil(
{ alice.privateMediaSendPolicy(to: bob.myPeerID) == .blockedDowngrade },
timeout: TestConstants.longTimeout
)
#expect(downgradeObserved)
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .blockedDowngrade)
let tap = PacketTap()
@@ -307,7 +321,7 @@ struct PrivateMediaEndToEndTests {
preseedPeer: false
)
let advertised = await TestHelpers.waitUntil(
{ alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted },
{ alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof },
timeout: TestConstants.longTimeout
)
#expect(advertised)
@@ -331,6 +345,181 @@ struct PrivateMediaEndToEndTests {
#expect(remainedLegacyEligible)
}
@Test
func copiedNoiseKeyPreannounceCannotPinWhenRealOwnerAuthenticates() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-copied-static-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let identity = MockIdentityManager(MockKeychain())
let alice = makeService(
baseDirectory: root.appendingPathComponent("alice", isDirectory: true),
identityManager: identity
)
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
let attacker = makeService(baseDirectory: root.appendingPathComponent("attacker", isDirectory: true))
let bobKey = bob.noiseStaticPublicKeyData()
let bobFingerprint = bobKey.sha256Fingerprint()
// Mallory copies Bob's public Noise key, advertises bit 8, supplies
// Mallory's Ed25519 key, and self-signs. This is internally consistent
// but does not prove possession of Bob's Noise private key.
let forged = try copiedStaticAnnounce(
claimedOwner: bob,
signedBy: attacker,
capabilities: .privateMedia
)
alice._test_handlePacket(forged, fromPeerID: bob.myPeerID, preseedPeer: false)
let hintAccepted = await TestHelpers.waitUntil(
{ alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof },
timeout: TestConstants.longTimeout
)
#expect(hintAccepted)
#expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint))
let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob)
#expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint))
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof)
// Only Bob's encrypted state authorizes bit 8 and replaces the forged
// announcement signing key with Bob's Noise-authenticated Ed key.
alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID)
let pinned = await TestHelpers.waitUntil(
{ identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint) },
timeout: TestConstants.longTimeout
)
#expect(pinned)
#expect(identity.authenticatedSigningPublicKey(forFingerprint: bobFingerprint)
== bob.noiseSigningPublicKeyData())
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID)
alice._test_onOutboundPacket = nil
bob._test_onOutboundPacket = nil
}
@Test
func droppedInitiatorProofConvergesViaSingleAuthenticatedEcho() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-proof-echo-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: .privateMedia,
noisePublicKey: bob.noiseStaticPublicKeyData()
)
bob._test_seedConnectedPeer(
alice.myPeerID,
nickname: "Alice",
capabilities: .privateMedia,
noisePublicKey: alice.noiseStaticPublicKeyData()
)
let initial = try await establishSessionCapturingPeerState(alice: alice, bob: bob)
// Model Alice's first proof racing ahead of Bob's message-3 handling
// and being dropped. Bob's proof reaches Alice; Alice must emit one
// idempotent echo that lets Bob converge without a new handshake.
_ = initial.alice
let echoTap = PacketTap()
alice._test_onOutboundPacket = echoTap.record
alice._test_handlePacket(initial.bob, fromPeerID: bob.myPeerID)
let echoed = await TestHelpers.waitUntil(
{ echoTap.snapshot().contains { $0.type == MessageType.noiseEncrypted.rawValue } },
timeout: TestConstants.longTimeout
)
#expect(echoed)
let echo = try #require(
echoTap.snapshot().first { $0.type == MessageType.noiseEncrypted.rawValue }
)
bob._test_handlePacket(echo, fromPeerID: alice.myPeerID)
let converged = await TestHelpers.waitUntil(
{
alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted
&& bob.privateMediaSendPolicy(to: alice.myPeerID) == .encrypted
},
timeout: TestConstants.longTimeout
)
#expect(converged)
alice._test_onOutboundPacket = nil
bob._test_onOutboundPacket = nil
}
@Test
func noProofTimeoutResolvesToConsentWithoutSendingRawMedia() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-proof-timeout-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Prerelease Bob",
capabilities: .privateMedia,
noisePublicKey: bob.noiseStaticPublicKeyData()
)
_ = try await establishSessionCapturingPeerState(alice: alice, bob: bob)
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof)
let recorder = PrivateMediaPolicyRecorder()
alice.resolvePrivateMediaSendPolicy(to: bob.myPeerID) { recorder.record($0) }
let registered = await TestHelpers.waitUntil(
{ alice._test_hasPendingPrivateMediaPolicyResolution(for: bob.myPeerID) },
timeout: TestConstants.longTimeout
)
#expect(registered)
alice._test_forcePrivateMediaProofTimeout(for: bob.myPeerID)
let resolved = await TestHelpers.waitUntil(
{ recorder.snapshot() == .legacyRequiresConsent },
timeout: TestConstants.longTimeout
)
#expect(resolved)
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
#expect(recorder.snapshot() != .encrypted)
alice._test_onOutboundPacket = nil
bob._test_onOutboundPacket = nil
}
@Test
func queuedPrivatePayloadWaitsForProofNotHandshakeCompletion() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-proof-drain-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: .privateMedia,
noisePublicKey: bob.noiseStaticPublicKeyData()
)
let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob)
let content = Data("proof-gated-private-file".utf8)
let file = BitchatFilePacket(
fileName: "proof.txt",
fileSize: UInt64(content.count),
mimeType: "text/plain",
content: content
)
let payload = try #require(BLENoisePayloadFactory.privateFile(file))
let transferID = "proof-gated-\(UUID().uuidString)"
alice._test_enqueuePendingNoisePayload(payload, transferId: transferID, for: bob.myPeerID)
alice._test_sendPendingNoisePayloadsAfterHandshake(for: bob.myPeerID)
#expect(alice._test_privateMediaTransferState(transferId: transferID).pendingNoise)
alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID)
let drained = await TestHelpers.waitUntil(
{ !alice._test_privateMediaTransferState(transferId: transferID).pendingNoise },
timeout: TestConstants.longTimeout
)
#expect(drained)
bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID)
alice._test_onOutboundPacket = nil
bob._test_onOutboundPacket = nil
}
@Test
func authenticatedFingerprintMismatchCannotPoisonCapabilityPin() async throws {
let root = FileManager.default.temporaryDirectory
@@ -353,7 +542,7 @@ struct PrivateMediaEndToEndTests {
capabilities: .privateMedia,
noisePublicKey: impostorKey
)
try establishSession(alice: alice, bob: bob)
try await establishSession(alice: alice, bob: bob)
let sessionReconciled = await TestHelpers.waitUntil(
{ reconciliations.contains(bob.myPeerID) },
@@ -373,7 +562,7 @@ struct PrivateMediaEndToEndTests {
}
@Test
func capabilityAnnounceAfterNoiseSessionPinsMatchingFingerprint() async throws {
func capabilityAnnounceAfterNoiseSessionStillRequiresEncryptedProof() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-race-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
@@ -384,7 +573,7 @@ struct PrivateMediaEndToEndTests {
)
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
try establishSession(alice: alice, bob: bob)
let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob)
let bobKey = bob.noiseStaticPublicKeyData()
let capableAnnounce = try signedAnnounce(
from: bob,
@@ -396,6 +585,17 @@ struct PrivateMediaEndToEndTests {
preseedPeer: false
)
let announceDidNotPin = await TestHelpers.waitUntil(
{ alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof },
timeout: TestConstants.longTimeout
)
#expect(announceDidNotPin)
#expect(!identity.hasObservedPrivateMediaCapability(
fingerprint: bobKey.sha256Fingerprint()
))
alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID)
let pinned = await TestHelpers.waitUntil(
{
identity.hasObservedPrivateMediaCapability(
@@ -405,6 +605,9 @@ struct PrivateMediaEndToEndTests {
timeout: TestConstants.longTimeout
)
#expect(pinned)
bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID)
alice._test_onOutboundPacket = nil
bob._test_onOutboundPacket = nil
}
@Test
@@ -427,7 +630,7 @@ struct PrivateMediaEndToEndTests {
nickname: "Old Carol",
noisePublicKey: oldCarol.noiseStaticPublicKeyData()
)
try establishSession(alice: alice, bob: bob)
try await establishSession(alice: alice, bob: bob)
var state: UInt64 = 0x1234_5678_9ABC_DEF0
let body = Data((0..<(130 * 1024)).map { _ in
@@ -475,7 +678,7 @@ struct PrivateMediaEndToEndTests {
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
try establishSession(alice: alice, bob: bob)
try await establishSession(alice: alice, bob: bob)
let transferID = "queued-encryption-failure-\(UUID().uuidString)"
let rejections = TransferCancellationRecorder()
@@ -615,7 +818,7 @@ struct PrivateMediaEndToEndTests {
let bob = makeService(baseDirectory: bobRoot)
let delegate = MessageCaptureDelegate()
bob.delegate = delegate
try establishSession(alice: alice, bob: bob)
try await establishSession(alice: alice, bob: bob)
let file = BitchatFilePacket(
fileName: "\(directoryLabel).pdf",
@@ -736,7 +939,6 @@ struct PrivateMediaEndToEndTests {
let bob = makeService(baseDirectory: bobRoot)
let tap = PacketTap()
let delegate = MessageCaptureDelegate()
alice._test_onOutboundPacket = tap.record
bob.delegate = delegate
alice._test_seedConnectedPeer(
@@ -751,7 +953,8 @@ struct PrivateMediaEndToEndTests {
capabilities: .privateMedia,
noisePublicKey: alice.noiseStaticPublicKeyData()
)
try establishSession(alice: alice, bob: bob)
try await establishSession(alice: alice, bob: bob)
alice._test_onOutboundPacket = tap.record
let file = BitchatFilePacket(
fileName: fileName,
@@ -825,7 +1028,27 @@ struct PrivateMediaEndToEndTests {
)
}
private func establishSession(alice: BLEService, bob: BLEService) throws {
private func establishSession(alice: BLEService, bob: BLEService) async throws {
let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob)
bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID)
alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID)
// Fence the message/identity mutations without assuming either test
// seeded a registry entry (inbound-only tests intentionally do not).
await alice._test_drainNoiseMessagePipeline()
await bob._test_drainNoiseMessagePipeline()
alice._test_onOutboundPacket = nil
bob._test_onOutboundPacket = nil
}
private func establishSessionCapturingPeerState(
alice: BLEService,
bob: BLEService
) async throws -> (alice: BitchatPacket, bob: BitchatPacket) {
let aliceTap = PacketTap()
let bobTap = PacketTap()
alice._test_onOutboundPacket = aliceTap.record
bob._test_onOutboundPacket = bobTap.record
let first = try alice._test_noiseInitiateHandshake(with: bob.myPeerID)
let second = try #require(
try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: first)
@@ -836,6 +1059,35 @@ struct PrivateMediaEndToEndTests {
_ = try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: third)
#expect(alice.canDeliverSecurely(to: bob.myPeerID))
#expect(bob.canDeliverSecurely(to: alice.myPeerID))
let emitted = await TestHelpers.waitUntil(
{
aliceTap.snapshot().contains { $0.type == MessageType.noiseEncrypted.rawValue }
&& bobTap.snapshot().contains { $0.type == MessageType.noiseEncrypted.rawValue }
},
timeout: TestConstants.longTimeout
)
#expect(emitted)
let aliceProof = try #require(
aliceTap.snapshot().first { $0.type == MessageType.noiseEncrypted.rawValue }
)
let bobProof = try #require(
bobTap.snapshot().first { $0.type == MessageType.noiseEncrypted.rawValue }
)
return (aliceProof, bobProof)
}
private func authenticatedPeerStatePacket(
from sender: BLEService,
to recipient: BLEService,
capabilities: PeerCapabilities
) throws -> BitchatPacket {
let state = AuthenticatedPeerStatePacket(
capabilities: capabilities,
signingPublicKey: sender.noiseSigningPublicKeyData()
)
let typed = try #require(BLENoisePayloadFactory.authenticatedPeerState(state))
return try sender._test_makeEncryptedNoisePacket(typed, to: recipient.myPeerID)
}
private func signedAnnounce(
@@ -862,6 +1114,33 @@ struct PrivateMediaEndToEndTests {
return service.signPacketForBroadcast(unsigned)
}
private func copiedStaticAnnounce(
claimedOwner: BLEService,
signedBy signer: BLEService,
capabilities: PeerCapabilities
) throws -> BitchatPacket {
let announcement = AnnouncementPacket(
nickname: "Mallory-as-Bob",
noisePublicKey: claimedOwner.noiseStaticPublicKeyData(),
signingPublicKey: signer.noiseSigningPublicKeyData(),
directNeighbors: nil,
capabilities: capabilities
)
let payload = try #require(announcement.encode())
let unsigned = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: claimedOwner.myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
payload: payload,
signature: nil,
// Relayed shape avoids the proactive direct-hint handshake in
// this deterministic test; it does not change signature validity.
ttl: TransportConfig.messageTTLDefault - 1
)
return signer.signPacketForBroadcast(unsigned)
}
private func recursivelyStoredFiles(under root: URL) -> [URL] {
guard let enumerator = FileManager.default.enumerator(
at: root,
@@ -953,6 +1232,23 @@ private final class PeerIDRecorder: @unchecked Sendable {
}
}
private final class PrivateMediaPolicyRecorder: @unchecked Sendable {
private let lock = NSLock()
private var policy: PrivateMediaSendPolicy?
func record(_ policy: PrivateMediaSendPolicy) {
lock.lock()
self.policy = policy
lock.unlock()
}
func snapshot() -> PrivateMediaSendPolicy? {
lock.lock()
defer { lock.unlock() }
return policy
}
}
private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
private let lock = NSLock()
private var messages: [BitchatMessage] = []
@@ -15,6 +15,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
private var blockedNostrPubkeys: Set<String> = []
private var socialIdentities: [String: SocialIdentity] = [:]
private var privateMediaCapableFingerprints: Set<String> = []
private var authenticatedSigningKeys: [String: Data] = [:]
init(_: KeychainManagerProtocol) {}
@@ -90,6 +91,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
func clearAllIdentityData() {
privateMediaCapableFingerprints.removeAll()
authenticatedSigningKeys.removeAll()
}
func removeEphemeralSession(peerID: PeerID) {}
@@ -112,6 +114,14 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
privateMediaCapableFingerprints.contains(fingerprint)
}
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
authenticatedSigningKeys[fingerprint] = signingPublicKey
}
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
authenticatedSigningKeys[fingerprint]
}
// MARK: Vouching (transitive verification)
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
+8
View File
@@ -205,6 +205,14 @@ final class MockTransport: Transport {
privateMediaPolicies[peerID] ?? .encrypted
}
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
let policy = privateMediaPolicies[peerID] ?? .encrypted
Task { @MainActor in completion(policy) }
}
func cancelTransfer(_ transferId: String) {
cancelledTransfers.append(transferId)
}
+2 -1
View File
@@ -650,7 +650,8 @@ struct NoiseCoverageTests {
try aliceManager.initiateHandshake(with: alicePeerID)
}
try aliceManager.initiateRekey(for: alicePeerID)
let rekeyHandshake = try aliceManager.initiateRekey(for: alicePeerID)
#expect(!rekeyHandshake.isEmpty)
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
#expect(rekeyedSession !== establishedSession)
+33
View File
@@ -145,6 +145,39 @@ struct PacketsTests {
#expect(decoded.capabilities?.rawValue == 0x0180)
}
@Test
func authenticatedPeerStateUsesVersionedCanonicalTLVs() throws {
let signingKey = Data(repeating: 0xA5, count: 32)
let packet = AuthenticatedPeerStatePacket(
capabilities: [.privateMedia, .vouch],
signingPublicKey: signingKey
)
var encoded = try #require(packet.encode())
#expect(encoded.prefix(5) == Data([0x01, 0x01, 0x02, 0x20, 0x01]))
// Unknown TLVs are forward-compatible and do not alter v1 state.
encoded.append(makeTLV(type: 0x7F, value: Data([0xCA, 0xFE])))
#expect(AuthenticatedPeerStatePacket.decode(from: encoded) == packet)
}
@Test
func authenticatedPeerStateRejectsMalformedAmbiguousOrUnknownVersion() {
let key = Data(repeating: 0x44, count: 32)
let capabilities = makeTLV(type: 0x01, value: Data([0x00, 0x01]))
let signing = makeTLV(type: 0x02, value: key)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x02]) + capabilities + signing) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + signing) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + capabilities + signing) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01, 0x01, 0x00]) + signing) == nil)
// 0x0001 is non-minimal little endian; the canonical form is [0x01].
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data([0x01, 0x00])) + signing) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + makeTLV(type: 0x02, value: Data(key.dropLast()))) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + Data(signing.dropLast())) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data(repeating: 0x01, count: 9)) + signing) == nil)
}
@Test
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
let unknownTLV = Data([0x7F, 0x01, 0x41])
@@ -6,6 +6,7 @@ import Testing
struct BLEAnnounceHandlerTests {
private final class Recorder {
var existingNoisePublicKey: Data?
var authenticatedSigningPublicKey: Data?
var signatureValid = true
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
var linkBoundToOtherPeer = false
@@ -37,6 +38,7 @@ struct BLEAnnounceHandlerTests {
messageTTL: TransportConfig.messageTTLDefault,
now: { now },
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
authenticatedSigningPublicKey: { _ in recorder.authenticatedSigningPublicKey },
verifySignature: { packet, signingPublicKey in
recorder.verifySignatureCalls.append((packet, signingPublicKey))
return recorder.signatureValid
@@ -169,6 +169,23 @@ struct BLEAnnounceHandlingPolicyTests {
#expect(decision.isVerified)
}
@Test
func trustPolicyRejectsSigningKeyReplacementAfterNoiseBinding() {
let noiseKey = Data(repeating: 0xCC, count: 32)
let boundSigningKey = Data(repeating: 0x11, count: 32)
let decision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: true,
signatureValid: true,
existingNoisePublicKey: noiseKey,
announcedNoisePublicKey: noiseKey,
authenticatedSigningPublicKey: boundSigningKey,
announcedSigningPublicKey: Data(repeating: 0x22, count: 32)
)
#expect(decision == .reject(.authenticatedSigningKeyMismatch))
}
@Test
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
let directNew = BLEAnnounceResponsePolicy.plan(
@@ -18,6 +18,7 @@ struct BLENoisePacketHandlerTests {
var lastSeenUpdates: [PeerID] = []
var decryptCalls: [(payload: Data, peerID: PeerID)] = []
var clearedSessions: [PeerID] = []
var authenticatedPeerStates: [(peerID: PeerID, payload: Data)] = []
var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
/// Ordered side-effect log to assert recovery sequencing.
var events: [String] = []
@@ -62,6 +63,9 @@ struct BLENoisePacketHandlerTests {
recorder.clearedSessions.append(peerID)
recorder.events.append("clearSession")
},
handleAuthenticatedPeerState: { peerID, payload in
recorder.authenticatedPeerStates.append((peerID, payload))
},
deliverNoisePayload: { peerID, type, payload, timestamp in
recorder.deliveries.append((peerID, type, payload, timestamp))
}
@@ -221,6 +225,24 @@ struct BLENoisePacketHandlerTests {
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func authenticatedPeerStateIsConsumedByTransportNotDeliveredToUI() {
let recorder = Recorder()
recorder.decryptResult = .success(Data([
NoisePayloadType.authenticatedPeerState.rawValue,
0x01, 0x02, 0x03
]))
let handler = makeHandler(recorder: recorder)
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
handler.handleEncrypted(packet, from: remotePeerID)
#expect(recorder.authenticatedPeerStates.count == 1)
#expect(recorder.authenticatedPeerStates.first?.peerID == remotePeerID)
#expect(recorder.authenticatedPeerStates.first?.payload == Data([0x01, 0x02, 0x03]))
#expect(recorder.deliveries.isEmpty)
}
@Test
func emptyDecryptedPayloadIsIgnored() {
let recorder = Recorder()
@@ -79,4 +79,16 @@ struct BLENoisePayloadFactoryTests {
#expect(decoded.data == Data([0xCA, 0xFE]))
#expect(decoded.encode().first == 0x20)
}
@Test
func authenticatedPeerStateUsesPermanent0x21Type() throws {
let state = AuthenticatedPeerStatePacket(
capabilities: .privateMedia,
signingPublicKey: Data(repeating: 0x77, count: 32)
)
let encoded = try #require(BLENoisePayloadFactory.authenticatedPeerState(state))
#expect(encoded.first == 0x21)
#expect(AuthenticatedPeerStatePacket.decode(from: Data(encoded.dropFirst())) == state)
}
}
@@ -237,6 +237,40 @@ struct NoiseEncryptionServiceTests {
#expect(try receiver.decrypt(ciphertext, from: alicePeerID) == Data("new session".utf8))
}
@Test("Automatic rekey exposes and completes its exact handshake bytes")
func automaticRekeyHandshakeIsNotStranded() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
try establishSessions(alice: alice, bob: bob)
var emittedPeerID: PeerID?
var emittedMessage: Data?
alice.onRekeyHandshakeReady = { peerID, message in
emittedPeerID = peerID
emittedMessage = message
}
try alice._test_initiateAutomaticRekey(for: bobPeerID)
#expect(emittedPeerID == bobPeerID)
let message1 = try #require(emittedMessage)
#expect(!message1.isEmpty)
#expect(alice.hasSession(with: bobPeerID))
#expect(!alice.hasEstablishedSession(with: bobPeerID))
let message2 = try #require(
try bob.processHandshakeMessage(from: alicePeerID, message: message1)
)
let message3 = try #require(
try alice.processHandshakeMessage(from: bobPeerID, message: message2)
)
_ = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
#expect(alice.hasEstablishedSession(with: bobPeerID))
#expect(bob.hasEstablishedSession(with: alicePeerID))
}
@Test("Large private-file payloads use the bounded Noise extension")
func largePrivateFileNoiseRoundTrip() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
@@ -427,6 +427,31 @@ final class SecureIdentityStateManagerTests: XCTestCase {
XCTAssertTrue(cleared)
}
func test_noiseAuthenticatedSigningKeyBindingPersistsAndPanicClearRemovesIt() async {
let keychain = MockKeychain()
let fingerprint = Data(repeating: 0x31, count: 32).sha256Fingerprint()
let firstKey = Data(repeating: 0x41, count: 32)
let rotatedKey = Data(repeating: 0x42, count: 32)
let manager = SecureIdentityStateManager(keychain)
manager.bindAuthenticatedSigningPublicKey(firstKey, fingerprint: fingerprint)
XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), firstKey)
// A later authenticated Noise session may legitimately rotate the
// announcement signing key.
manager.bindAuthenticatedSigningPublicKey(rotatedKey, fingerprint: fingerprint)
XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey)
manager.forceSave()
let reloaded = SecureIdentityStateManager(keychain)
XCTAssertEqual(reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey)
reloaded.clearAllIdentityData()
let cleared = await waitUntil {
reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint) == nil
}
XCTAssertTrue(cleared)
}
func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {
let keychain = FailingCacheSaveKeychain()
let manager = SecureIdentityStateManager(keychain)
@@ -268,6 +268,8 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
func markPrivateMediaCapable(fingerprint: String) {}
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false }
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {}
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? { nil }
func removeEphemeralSession(peerID: PeerID) {}
+43 -16
View File
@@ -11,8 +11,23 @@ peer's Noise session before BLE fragmentation.
- iOS temporarily accepts `0x09`, which appeared in prerelease builds of the
private-media change. Decoders canonicalize it to `privateFile`; they never
emit it.
- A peer advertising `PeerCapabilities.privateMedia` receives one encrypted
`noiseEncrypted` transfer.
- `NoisePayloadType.authenticatedPeerState` is permanently assigned `0x21`.
It is emitted after every completed/rekeyed Noise XX session and echoed at
most once when the remote state arrives, so message-3/proof reordering over
different mesh links converges. This type is part of the protocol security
boundary and is not removed when the media migration ends.
- The `0x21` payload starts with version `0x01`, followed by one-byte
type/length/value fields. Version 1 requires canonical TLV `0x01` (the
minimal little-endian `PeerCapabilities` bitfield, 1-8 bytes) and TLV `0x02`
(the 32-byte Ed25519 announcement signing key). Duplicate required fields,
non-minimal capabilities, malformed lengths, missing fields, and unknown
versions are ignored without changing state. Unknown TLVs are skipped.
- The public `PeerCapabilities.privateMedia` announce bit is a discovery hint:
it starts a Noise handshake, but never selects encrypted sending or creates
a pin. A private transfer waits boundedly for the exact session's encrypted
`0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a
no-proof timeout reaches the explicit legacy-consent path for an unpinned
peer. No timeout automatically sends raw bytes.
- An unpinned peer with a stable Noise key but without that capability is
eligible for one signed, directed
`fileTransfer`, matching the pre-migration wire form used by older iOS and
@@ -20,22 +35,33 @@ peer's Noise session before BLE fragmentation.
per-send warning that the file is not end-to-end encrypted and mesh relays
can see it. The
consent is consumed by that invocation and is never remembered.
- A signed announce never creates a pin by itself. The fingerprint is pinned
in the encrypted identity cache only after a completed Noise session
authenticates the same static key as the capability-bearing registry entry.
This comparison is performed in either event order: session then announce,
or announce then session. A later announce missing the bit is then treated
as a downgrade, and raw fallback is blocked even if a caller presents
legacy consent.
- A signed announce never creates a pin by itself: an attacker can copy a
victim's public Noise key, supply its own Ed25519 key and capability bits,
and self-sign an internally consistent announce. Only successfully
decrypted `0x21` state pins the authenticated Noise fingerprint and binds
the Ed25519 key used by later announces/public messages. A later valid
no-bit `0x21` is treated as a downgrade, and raw fallback is blocked even if
a caller presents legacy consent. Public no-bit announces cannot overwrite
current session-authenticated state.
- During migration, both an absent capabilities TLV and an explicit TLV
without `privateMedia` are legacy-eligible when that stable fingerprint is
not pinned. This supports clients that added capability advertisement before
encrypted media. Neither shape bypasses a previously authenticated pin.
The `0x09` receive alias may be removed only after every TestFlight/internal
build that emitted it has expired and the project's minimum-supported-client
policy excludes those builds. Track that release criterion explicitly; do not
remove the alias on an arbitrary calendar date.
Older clients decrypt and ignore unknown inner type `0x21`; they do not need to
understand it to continue using text or the warned legacy media path. They are
never inferred capable merely because the handshake succeeded.
Removal gates are independent and must not share an arbitrary calendar date:
- Remove the `0x09` receive alias only after every TestFlight/internal build
that emitted it has expired and minimum-supported-client policy excludes it.
- Remove the signed directed raw `0x22` fallback only after minimum-supported
iOS and Android clients emit authenticated bit-8 `0x21` state and the legacy
population has aged out.
- Nostr kind `1059` compatibility is a separate envelope migration. Its dual
publish/removal gate is not evidence that either BLE compatibility shape can
be removed.
## Security boundary
@@ -61,9 +87,10 @@ Incoming clients accept all three migration-era shapes:
Panic wipe clears the persistent capability pins together with the rest of
the encrypted identity cache.
This migration path is BLE-only. Nostr private-media transport is unchanged
and remains a follow-up; do not infer the BLE consent fallback or capability
pin semantics for Nostr delivery.
This migration path is mesh-Noise-only (BLE and compatible direct mesh links).
Nostr private-media transport is unchanged and remains a follow-up. Nostr
inbound paths explicitly ignore `0x21`; do not infer the mesh consent fallback
or capability-pin semantics for Nostr delivery.
## Size interoperability