mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 16:45:22 +00:00
Authenticate private media capabilities in Noise
This commit is contained in:
@@ -190,11 +190,17 @@ struct IdentityCache: Codable {
|
|||||||
// entries verified before this field exists sort as oldest)
|
// entries verified before this field exists sort as oldest)
|
||||||
var verifiedAt: [String: Date]? = nil
|
var verifiedAt: [String: Date]? = nil
|
||||||
|
|
||||||
// Stable Noise fingerprints that have advertised encrypted private media
|
// Stable Noise fingerprints that proved encrypted private-media support
|
||||||
// in a signature-verified announce. Optional for decoding caches written
|
// inside an authenticated Noise session. Optional for decoding caches
|
||||||
// before this migration. Entries are monotonic until a panic wipe so an
|
// written before this migration. Entries are monotonic until a panic wipe
|
||||||
// old/replayed announce cannot silently downgrade a peer to clear media.
|
// so an old/replayed announce cannot silently downgrade a peer.
|
||||||
var privateMediaCapableFingerprints: Set<String>? = nil
|
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 signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
|
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
|
// MARK: Private-media downgrade protection
|
||||||
func markPrivateMediaCapable(fingerprint: String)
|
func markPrivateMediaCapable(fingerprint: String)
|
||||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool
|
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool
|
||||||
@@ -405,6 +409,37 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
cache.privateMediaCapableFingerprints?.contains(fingerprint) == true
|
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) {
|
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
|
|||||||
@@ -299,11 +299,11 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func initiateRekey(for peerID: PeerID) throws {
|
func initiateRekey(for peerID: PeerID) throws -> Data {
|
||||||
// Remove old session
|
// Remove old session
|
||||||
removeSession(for: peerID)
|
removeSession(for: peerID)
|
||||||
|
|
||||||
// Initiate new handshake
|
// Initiate new handshake
|
||||||
_ = try initiateHandshake(with: peerID)
|
return try initiateHandshake(with: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ enum NoisePayloadType: UInt8 {
|
|||||||
// Android client. The complete BitchatFilePacket is encrypted inside
|
// Android client. The complete BitchatFilePacket is encrypted inside
|
||||||
// Noise before the outer noiseEncrypted packet is fragmented.
|
// Noise before the outer noiseEncrypted packet is fragmented.
|
||||||
case privateFile = 0x20
|
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)
|
// Verification (QR-based OOB binding)
|
||||||
case verifyChallenge = 0x10 // Verification challenge
|
case verifyChallenge = 0x10 // Verification challenge
|
||||||
case verifyResponse = 0x11 // Verification response
|
case verifyResponse = 0x11 // Verification response
|
||||||
@@ -112,6 +117,7 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case .groupKeyUpdate: return "groupKeyUpdate"
|
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||||
case .voiceFrame: return "voiceFrame"
|
case .voiceFrame: return "voiceFrame"
|
||||||
case .privateFile: return "privateFile"
|
case .privateFile: return "privateFile"
|
||||||
|
case .authenticatedPeerState: return "authenticatedPeerState"
|
||||||
case .verifyChallenge: return "verifyChallenge"
|
case .verifyChallenge: return "verifyChallenge"
|
||||||
case .verifyResponse: return "verifyResponse"
|
case .verifyResponse: return "verifyResponse"
|
||||||
case .vouch: return "vouch"
|
case .vouch: return "vouch"
|
||||||
|
|||||||
@@ -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 {
|
struct PrivateMessagePacket {
|
||||||
let messageID: String
|
let messageID: String
|
||||||
let content: String
|
let content: String
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ struct BLEAnnounceHandlerEnvironment {
|
|||||||
let now: () -> Date
|
let now: () -> Date
|
||||||
/// Noise public key already recorded for the peer, if any (registry read).
|
/// Noise public key already recorded for the peer, if any (registry read).
|
||||||
let existingNoisePublicKey: (PeerID) -> Data?
|
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.
|
/// Verifies the packet signature against the announced signing key.
|
||||||
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||||
/// Direct link state for the peer (BLE-queue read).
|
/// Direct link state for the peer (BLE-queue read).
|
||||||
@@ -130,11 +133,21 @@ final class BLEAnnounceHandler {
|
|||||||
hasSignature: hasSignature,
|
hasSignature: hasSignature,
|
||||||
signatureValid: signatureValid,
|
signatureValid: signatureValid,
|
||||||
existingNoisePublicKey: existingNoisePublicKey,
|
existingNoisePublicKey: existingNoisePublicKey,
|
||||||
announcedNoisePublicKey: announcement.noisePublicKey
|
announcedNoisePublicKey: announcement.noisePublicKey,
|
||||||
|
authenticatedSigningPublicKey: env.authenticatedSigningPublicKey(
|
||||||
|
announcement.noisePublicKey
|
||||||
|
),
|
||||||
|
announcedSigningPublicKey: announcement.signingPublicKey
|
||||||
)
|
)
|
||||||
if case .reject(.keyMismatch) = trustDecision {
|
if case .reject(.keyMismatch) = trustDecision {
|
||||||
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
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
|
let verifiedAnnounce = trustDecision.isVerified
|
||||||
|
|
||||||
var isNewPeer = false
|
var isNewPeer = false
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ enum BLEAnnounceTrustRejection: Equatable {
|
|||||||
case missingSignature
|
case missingSignature
|
||||||
case invalidSignature
|
case invalidSignature
|
||||||
case keyMismatch
|
case keyMismatch
|
||||||
|
case authenticatedSigningKeyMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BLEAnnounceTrustDecision: Equatable {
|
enum BLEAnnounceTrustDecision: Equatable {
|
||||||
@@ -72,12 +73,19 @@ enum BLEAnnounceTrustPolicy {
|
|||||||
hasSignature: Bool,
|
hasSignature: Bool,
|
||||||
signatureValid: Bool,
|
signatureValid: Bool,
|
||||||
existingNoisePublicKey: Data?,
|
existingNoisePublicKey: Data?,
|
||||||
announcedNoisePublicKey: Data
|
announcedNoisePublicKey: Data,
|
||||||
|
authenticatedSigningPublicKey: Data? = nil,
|
||||||
|
announcedSigningPublicKey: Data? = nil
|
||||||
) -> BLEAnnounceTrustDecision {
|
) -> BLEAnnounceTrustDecision {
|
||||||
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||||
return .reject(.keyMismatch)
|
return .reject(.keyMismatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let authenticatedSigningPublicKey,
|
||||||
|
announcedSigningPublicKey != authenticatedSigningPublicKey {
|
||||||
|
return .reject(.authenticatedSigningKeyMismatch)
|
||||||
|
}
|
||||||
|
|
||||||
guard hasSignature else {
|
guard hasSignature else {
|
||||||
return .reject(.missingSignature)
|
return .reject(.missingSignature)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ struct BLENoisePacketHandlerEnvironment {
|
|||||||
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data
|
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data
|
||||||
/// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
|
/// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
|
||||||
let clearSession: (PeerID) -> Void
|
let clearSession: (PeerID) -> Void
|
||||||
|
/// Consumes session-authenticated protocol state inside the transport. It
|
||||||
|
/// 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.
|
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
|
||||||
let deliverNoisePayload: (
|
let deliverNoisePayload: (
|
||||||
_ peerID: PeerID,
|
_ peerID: PeerID,
|
||||||
@@ -160,6 +163,11 @@ final class BLENoisePacketHandler {
|
|||||||
|
|
||||||
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session)
|
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
|
||||||
|
if noisePayloadType == .authenticatedPeerState {
|
||||||
|
env.handleAuthenticatedPeerState(peerID, Data(payloadData))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
|
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
|
||||||
} catch NoiseEncryptionError.sessionNotEstablished {
|
} catch NoiseEncryptionError.sessionNotEstablished {
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ enum BLENoisePayloadFactory {
|
|||||||
return typedPayload(.privateFile, payload: payload)
|
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 {
|
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
|
||||||
var typed = Data([type.rawValue])
|
var typed = Data([type.rawValue])
|
||||||
typed.append(payload)
|
typed.append(payload)
|
||||||
|
|||||||
@@ -181,6 +181,14 @@ struct BLEPeerRegistry {
|
|||||||
peers[peerID] = peer
|
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(
|
mutating func upsertVerifiedAnnounce(
|
||||||
peerID: PeerID,
|
peerID: PeerID,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
|
|||||||
@@ -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
|
/// BLEService — Bluetooth Mesh Transport
|
||||||
/// - Emits events exclusively via `BitchatDelegate` for UI.
|
/// - Emits events exclusively via `BitchatDelegate` for UI.
|
||||||
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
|
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
|
||||||
@@ -312,6 +342,15 @@ final class BLEService: NSObject {
|
|||||||
private lazy var privateMediaTransferAdmissions = BLEPrivateMediaTransferAdmissionRegistry { [weak self] transferId in
|
private lazy var privateMediaTransferAdmissions = BLEPrivateMediaTransferAdmissionRegistry { [weak self] transferId in
|
||||||
self?.handlePrivateMediaAdmissionExpiry(transferId)
|
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
|
private let incomingFileStore: BLEIncomingFileStore
|
||||||
|
|
||||||
// Simple announce throttling
|
// Simple announce throttling
|
||||||
@@ -696,7 +735,7 @@ final class BLEService: NSObject {
|
|||||||
pendingNoiseSessionQueues.removeAll()
|
pendingNoiseSessionQueues.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelledTransfers = collectionsQueue.sync(flags: .barrier) {
|
let panicReset = collectionsQueue.sync(flags: .barrier) {
|
||||||
pendingPeripheralWrites.removeAll()
|
pendingPeripheralWrites.removeAll()
|
||||||
pendingNotifications.removeAll()
|
pendingNotifications.removeAll()
|
||||||
let transfers = outboundFragmentTransfers.removeAll()
|
let transfers = outboundFragmentTransfers.removeAll()
|
||||||
@@ -705,15 +744,25 @@ final class BLEService: NSObject {
|
|||||||
ingressLinks.removeAll()
|
ingressLinks.removeAll()
|
||||||
recentTrafficTracker.removeAll()
|
recentTrafficTracker.removeAll()
|
||||||
scheduledRelays.cancelAll()
|
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.
|
// Let the post-panic identity publish its fresh bundle promptly.
|
||||||
lastPrekeyBundleSentAt = nil
|
lastPrekeyBundleSentAt = nil
|
||||||
return transfers
|
return (transfers, policyCompletions)
|
||||||
}
|
}
|
||||||
|
|
||||||
for entry in cancelledTransfers {
|
for entry in panicReset.0 {
|
||||||
entry.workItems.forEach { $0.cancel() }
|
entry.workItems.forEach { $0.cancel() }
|
||||||
TransferProgressManager.shared.cancel(id: entry.id)
|
TransferProgressManager.shared.cancel(id: entry.id)
|
||||||
}
|
}
|
||||||
|
completePrivateMediaPolicyResolution(panicReset.1, with: .blockedDowngrade)
|
||||||
|
|
||||||
bleQueue.sync {
|
bleQueue.sync {
|
||||||
pendingWriteBuffers.removeAll()
|
pendingWriteBuffers.removeAll()
|
||||||
@@ -1062,64 +1111,212 @@ final class BLEService: NSObject {
|
|||||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||||
let state: (
|
let state: (
|
||||||
capabilities: PeerCapabilities,
|
capabilities: PeerCapabilities,
|
||||||
fingerprint: String?
|
fingerprint: String?,
|
||||||
|
sessionGeneration: UUID?,
|
||||||
|
authenticatedState: BLEAuthenticatedPeerStateObservation?,
|
||||||
|
timedOut: BLEPrivateMediaProofTimeoutMarker?
|
||||||
) = collectionsQueue.sync {
|
) = collectionsQueue.sync {
|
||||||
let info = peerRegistry.info(for: peerID.toShort())
|
let normalizedPeerID = peerID.toShort()
|
||||||
|
let info = peerRegistry.info(for: normalizedPeerID)
|
||||||
return (
|
return (
|
||||||
info?.capabilities ?? [],
|
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 {
|
guard let fingerprint = state.fingerprint else {
|
||||||
// A raw fallback must be bound to the stable Noise key from a
|
// A raw fallback must be bound to the stable Noise key from a
|
||||||
// verified registry entry; a routing ID alone can rotate or be
|
// verified registry entry; a routing ID alone can rotate or be
|
||||||
// spoofed. Session authentication is required for pinning, below,
|
// spoofed. Without that key neither proof nor safe migration state
|
||||||
// but old clients may need the consented fallback before a session.
|
// can be attributed.
|
||||||
return .blockedDowngrade
|
return .blockedDowngrade
|
||||||
}
|
}
|
||||||
// During the mixed-version migration, an unpinned peer is legacy
|
|
||||||
// eligible whether the capabilities TLV was absent or explicitly
|
let wasPreviouslyCapable = identityManager.hasObservedPrivateMediaCapability(
|
||||||
// omitted this bit. Only a capability observation bound to a matching
|
fingerprint: fingerprint
|
||||||
// authenticated Noise session may make the no-bit state a downgrade.
|
)
|
||||||
return identityManager.hasObservedPrivateMediaCapability(fingerprint: fingerprint)
|
|
||||||
? .blockedDowngrade
|
if let authenticated = state.authenticatedState,
|
||||||
: .legacyRequiresConsent
|
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
|
func resolvePrivateMediaSendPolicy(
|
||||||
/// identity and a completed Noise session authenticate the same static
|
to peerID: PeerID,
|
||||||
/// key. A signed announce alone does not prove possession of that Noise
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
/// key and must never create a downgrade pin.
|
|
||||||
private func reconcilePrivateMediaCapabilityPin(
|
|
||||||
for peerID: PeerID,
|
|
||||||
authenticatedFingerprint: String? = nil
|
|
||||||
) {
|
) {
|
||||||
let normalizedPeerID = peerID.toShort()
|
let normalizedPeerID = peerID.toShort()
|
||||||
let advertisedFingerprint: String? = collectionsQueue.sync {
|
messageQueue.async { [weak self] in
|
||||||
guard let info = peerRegistry.info(for: normalizedPeerID),
|
guard let self else { return }
|
||||||
info.capabilities.contains(.privateMedia) else { return nil }
|
let immediate = self.privateMediaSendPolicy(to: normalizedPeerID)
|
||||||
return info.noisePublicKey?.sha256Fingerprint()
|
guard immediate == .awaitingCapabilityProof else {
|
||||||
}
|
self.completePrivateMediaPolicyResolution([completion], with: immediate)
|
||||||
guard let advertisedFingerprint else { return }
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let sessionFingerprint = authenticatedFingerprint
|
let fingerprint: String? = self.collectionsQueue.sync {
|
||||||
?? noiseService.getPeerFingerprint(normalizedPeerID)
|
self.peerRegistry.info(for: normalizedPeerID)?
|
||||||
guard let sessionFingerprint,
|
.noisePublicKey?
|
||||||
sessionFingerprint.caseInsensitiveCompare(advertisedFingerprint) == .orderedSame else {
|
.sha256Fingerprint()
|
||||||
if authenticatedFingerprint != nil {
|
}
|
||||||
SecureLogger.warning(
|
guard let fingerprint else {
|
||||||
"Refusing private-media capability pin for \(normalizedPeerID.id.prefix(8))…: authenticated Noise key does not match verified announce",
|
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
|
||||||
category: .security
|
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
|
/// Enables or disables a runtime-advertised capability bit (e.g. the
|
||||||
@@ -1408,6 +1605,25 @@ final class BLEService: NSObject {
|
|||||||
case .encrypted:
|
case .encrypted:
|
||||||
break
|
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:
|
case .legacyRequiresConsent:
|
||||||
guard allowLegacyFallback else {
|
guard allowLegacyFallback else {
|
||||||
SecureLogger.warning(
|
SecureLogger.warning(
|
||||||
@@ -2388,7 +2604,7 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// A valid departure retires transport state too; otherwise
|
// A valid departure retires transport state too; otherwise
|
||||||
// canDeliverSecurely could remain true for a peer we just removed.
|
// canDeliverSecurely could remain true for a peer we just removed.
|
||||||
noiseService.clearSession(for: peerID)
|
clearNoiseSession(for: peerID)
|
||||||
readLinkState { _ in
|
readLinkState { _ in
|
||||||
let departedLinks = noiseAuthenticatedLinkOwners.compactMap { link, owner in
|
let departedLinks = noiseAuthenticatedLinkOwners.compactMap { link, owner in
|
||||||
owner == peerID ? link : nil
|
owner == peerID ? link : nil
|
||||||
@@ -3214,6 +3430,41 @@ extension BLEService {
|
|||||||
sendPendingNoisePayloadsAfterHandshake(for: peerID)
|
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(
|
func _test_privateMediaTransferState(
|
||||||
transferId: String
|
transferId: String
|
||||||
) -> (admissionActive: Bool, pendingNoise: Bool, activeScheduler: Int, pendingScheduler: Int) {
|
) -> (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.
|
/// Builds an authenticated-session packet from an exact typed plaintext.
|
||||||
/// Compatibility tests use this to model Android's deployed 0x20 file
|
/// Compatibility tests use this to model Android's deployed 0x20 file
|
||||||
/// payload and the short-lived 0x09 prerelease payload without exposing a
|
/// payload and the short-lived 0x09 prerelease payload without exposing a
|
||||||
@@ -4320,20 +4582,208 @@ extension BLEService {
|
|||||||
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||||
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))…")
|
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))…")
|
||||||
self?.messageQueue.async { [weak self] in
|
self?.messageQueue.async { [weak self] in
|
||||||
self?.reconcilePrivateMediaCapabilityPin(
|
self?.handleNoisePeerAuthenticated(peerID: peerID, fingerprint: fingerprint)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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.
|
/// Swaps `myPeerID`/`myPeerIDData` to match the current Noise identity.
|
||||||
@@ -5355,22 +5805,24 @@ extension BLEService {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let handshakeData = try noiseService.initiateHandshake(with: peerID)
|
let handshakeData = try noiseService.initiateHandshake(with: peerID)
|
||||||
|
broadcastNoiseHandshake(handshakeData, to: 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)
|
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to initiate handshake: \(error)")
|
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) {
|
private func sendPendingMessagesAfterHandshake(for peerID: PeerID) {
|
||||||
// Atomically take all pending messages to process (prevents concurrent modification)
|
// Atomically take all pending messages to process (prevents concurrent modification)
|
||||||
@@ -5902,14 +6354,17 @@ extension BLEService {
|
|||||||
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
let result = announceHandler.handle(packet, from: peerID)
|
let result = announceHandler.handle(packet, from: peerID)
|
||||||
|
|
||||||
// The verified announce and Noise handshake can arrive in either
|
// A capability bit in the public announce is only a discovery hint.
|
||||||
// order. If authentication already completed, compare its static-key
|
// Start authentication promptly for a directly connected candidate,
|
||||||
// fingerprint with the newly upserted registry key before pinning;
|
// but never pin or pre-queue private bytes until encrypted 0x21 state
|
||||||
// otherwise the session callback performs this reconciliation later.
|
// arrives from the completed Noise session.
|
||||||
if let result,
|
if let result,
|
||||||
result.isVerified,
|
result.isVerified,
|
||||||
result.announcement.capabilities?.contains(.privateMedia) == true {
|
result.isDirectAnnounce,
|
||||||
reconcilePrivateMediaCapabilityPin(for: result.peerID)
|
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
|
// 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) {
|
if noiseService.hasEstablishedSession(with: result.peerID) {
|
||||||
// A session with no surviving authenticated link is stale;
|
// A session with no surviving authenticated link is stale;
|
||||||
// force the current link to prove possession again.
|
// force the current link to prove possession again.
|
||||||
noiseService.clearSession(for: result.peerID)
|
clearNoiseSession(for: result.peerID)
|
||||||
}
|
}
|
||||||
if !noiseService.hasSession(with: result.peerID) {
|
if !noiseService.hasSession(with: result.peerID) {
|
||||||
initiateNoiseHandshake(with: result.peerID)
|
initiateNoiseHandshake(with: result.peerID)
|
||||||
@@ -6185,6 +6640,11 @@ extension BLEService {
|
|||||||
guard let self = self else { return nil }
|
guard let self = self else { return nil }
|
||||||
return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
|
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
|
verifySignature: { [weak self] packet, signingPublicKey in
|
||||||
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||||||
},
|
},
|
||||||
@@ -6545,7 +7005,10 @@ extension BLEService {
|
|||||||
return try self.noiseService.decrypt(payload, from: peerID)
|
return try self.noiseService.decrypt(payload, from: peerID)
|
||||||
},
|
},
|
||||||
clearSession: { [weak self] peerID in
|
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
|
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
|
||||||
if type == .privateFile {
|
if type == .privateFile {
|
||||||
@@ -6578,10 +7041,42 @@ extension BLEService {
|
|||||||
guard !payloads.isEmpty else { return }
|
guard !payloads.isEmpty else { return }
|
||||||
SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session)
|
SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session)
|
||||||
for pending in payloads {
|
for pending in payloads {
|
||||||
let privateMediaTransferId: String? = {
|
let isPrivateMedia = NoisePayloadType.isPrivateFile(rawValue: pending.payload.first)
|
||||||
guard NoisePayloadType.isPrivateFile(rawValue: pending.payload.first) else { return nil }
|
let privateMediaTransferId = isPrivateMedia ? pending.transferId : nil
|
||||||
return pending.transferId
|
|
||||||
}()
|
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,
|
if let transferId = privateMediaTransferId,
|
||||||
!privateMediaTransferAdmissions.isActive(transferId) {
|
!privateMediaTransferAdmissions.isActive(transferId) {
|
||||||
privateMediaTransferAdmissions.finish(transferId)
|
privateMediaTransferAdmissions.finish(transferId)
|
||||||
|
|||||||
@@ -184,6 +184,11 @@ final class NoiseEncryptionService {
|
|||||||
// Callbacks
|
// Callbacks
|
||||||
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||||
|
/// Automatic rekey removed the old session and produced XX message 1.
|
||||||
|
/// 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
|
// Add a handler for peer authentication
|
||||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
||||||
@@ -864,19 +869,26 @@ final class NoiseEncryptionService {
|
|||||||
let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey()
|
let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey()
|
||||||
|
|
||||||
for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey {
|
for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey {
|
||||||
|
|
||||||
// Attempt to rekey the session
|
|
||||||
do {
|
do {
|
||||||
try sessionManager.initiateRekey(for: peerID)
|
try initiateAutomaticRekey(for: peerID)
|
||||||
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
|
|
||||||
|
|
||||||
// Signal that handshake is needed
|
|
||||||
onHandshakeRequired?(peerID)
|
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session)
|
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 {
|
deinit {
|
||||||
stopRekeyTimer()
|
stopRekeyTimer()
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ enum TransportEvent: @unchecked Sendable {
|
|||||||
/// legacy consent.
|
/// legacy consent.
|
||||||
enum PrivateMediaSendPolicy: Equatable {
|
enum PrivateMediaSendPolicy: Equatable {
|
||||||
case encrypted
|
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 legacyRequiresConsent
|
||||||
case blockedDowngrade
|
case blockedDowngrade
|
||||||
}
|
}
|
||||||
@@ -224,6 +229,10 @@ protocol Transport: AnyObject {
|
|||||||
/// empty for peers that predate the capabilities TLV.
|
/// empty for peers that predate the capabilities TLV.
|
||||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
|
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
|
||||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
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.
|
/// Sends an encoded vouch-attestation batch inside the Noise session.
|
||||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
|
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
|
||||||
/// Appends a peer-authenticated observer. Unlike
|
/// Appends a peer-authenticated observer. Unlike
|
||||||
@@ -295,6 +304,15 @@ extension Transport {
|
|||||||
func broadcastGroupMessage(_ envelope: Data) {}
|
func broadcastGroupMessage(_ envelope: Data) {}
|
||||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
||||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
|
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 sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
|
||||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
||||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ enum TransportConfig {
|
|||||||
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
||||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
||||||
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
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 bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
||||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
||||||
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ protocol ChatMediaTransferContext: AnyObject {
|
|||||||
|
|
||||||
// MARK: Mesh file transfer
|
// MARK: Mesh file transfer
|
||||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
||||||
|
func resolvePrivateMediaSendPolicy(
|
||||||
|
to peerID: PeerID,
|
||||||
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
|
)
|
||||||
func requestLegacyPrivateMediaConsent(
|
func requestLegacyPrivateMediaConsent(
|
||||||
for peerID: PeerID,
|
for peerID: PeerID,
|
||||||
transferId: String,
|
transferId: String,
|
||||||
@@ -89,6 +93,13 @@ extension ChatViewModel: ChatMediaTransferContext {
|
|||||||
meshService.privateMediaSendPolicy(to: peerID)
|
meshService.privateMediaSendPolicy(to: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolvePrivateMediaSendPolicy(
|
||||||
|
to peerID: PeerID,
|
||||||
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
|
) {
|
||||||
|
meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
func requestLegacyPrivateMediaConsent(
|
func requestLegacyPrivateMediaConsent(
|
||||||
for peerID: PeerID,
|
for peerID: PeerID,
|
||||||
transferId: String,
|
transferId: String,
|
||||||
@@ -492,7 +503,23 @@ final class ChatMediaTransferCoordinator {
|
|||||||
transferId: String,
|
transferId: String,
|
||||||
messageID: 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:
|
case .encrypted:
|
||||||
context.sendFilePrivate(
|
context.sendFilePrivate(
|
||||||
packet,
|
packet,
|
||||||
@@ -501,6 +528,32 @@ final class ChatMediaTransferCoordinator {
|
|||||||
allowLegacyFallback: false
|
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:
|
case .legacyRequiresConsent:
|
||||||
context.requestLegacyPrivateMediaConsent(
|
context.requestLegacyPrivateMediaConsent(
|
||||||
for: peerID,
|
for: peerID,
|
||||||
|
|||||||
@@ -408,10 +408,11 @@ private extension ChatTransportEventCoordinator {
|
|||||||
case .voiceFrame:
|
case .voiceFrame:
|
||||||
context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp)
|
context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp)
|
||||||
|
|
||||||
case .privateFile:
|
case .privateFile, .authenticatedPeerState:
|
||||||
// BLEService validates and persists decrypted private files before
|
// BLEService validates and persists decrypted private files before
|
||||||
// emitting a normal `.messageReceived` event. No raw file bytes
|
// emitting a normal `.messageReceived` event, and consumes peer
|
||||||
// should cross this UI-facing typed-payload fallback.
|
// state inside the transport. Neither payload crosses this
|
||||||
|
// UI-facing typed-payload fallback.
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ final class NostrInboundPipeline {
|
|||||||
// claiming to be group traffic over Nostr is ignored.
|
// claiming to be group traffic over Nostr is ignored.
|
||||||
// Live voice is mesh-only: latency and relay cost make it
|
// Live voice is mesh-only: latency and relay cost make it
|
||||||
// meaningless over Nostr.
|
// meaningless over Nostr.
|
||||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile:
|
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,7 +366,7 @@ final class NostrInboundPipeline {
|
|||||||
// claiming to be group traffic over Nostr is ignored.
|
// claiming to be group traffic over Nostr is ignored.
|
||||||
// Live voice is mesh-only: latency and relay cost make it
|
// Live voice is mesh-only: latency and relay cost make it
|
||||||
// meaningless over Nostr.
|
// meaningless over Nostr.
|
||||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile:
|
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,7 +449,7 @@ final class NostrInboundPipeline {
|
|||||||
// in v1; group traffic over Nostr is ignored.
|
// in v1; group traffic over Nostr is ignored.
|
||||||
// Live voice is mesh-only: latency and relay cost make it
|
// Live voice is mesh-only: latency and relay cost make it
|
||||||
// meaningless over Nostr.
|
// meaningless over Nostr.
|
||||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile:
|
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|||||||
private(set) var broadcastFileSends: [String] = []
|
private(set) var broadcastFileSends: [String] = []
|
||||||
private(set) var cancelledTransfers: [String] = []
|
private(set) var cancelledTransfers: [String] = []
|
||||||
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
|
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
|
||||||
|
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
|
||||||
private(set) var legacyConsentRequests: [(
|
private(set) var legacyConsentRequests: [(
|
||||||
id: UUID,
|
id: UUID,
|
||||||
peerID: PeerID,
|
peerID: PeerID,
|
||||||
@@ -108,6 +109,13 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|||||||
privateMediaPolicy
|
privateMediaPolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolvePrivateMediaSendPolicy(
|
||||||
|
to peerID: PeerID,
|
||||||
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
|
) {
|
||||||
|
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
func requestLegacyPrivateMediaConsent(
|
func requestLegacyPrivateMediaConsent(
|
||||||
for peerID: PeerID,
|
for peerID: PeerID,
|
||||||
transferId: String,
|
transferId: String,
|
||||||
@@ -576,6 +584,31 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(context.privateFileLegacyAllowances == [true])
|
#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
|
@Test @MainActor
|
||||||
func legacyConsentApprovalAfterCancelCannotSend() async throws {
|
func legacyConsentApprovalAfterCancelCannotSend() async throws {
|
||||||
let context = MockChatMediaTransferContext()
|
let context = MockChatMediaTransferContext()
|
||||||
|
|||||||
@@ -283,26 +283,40 @@ struct PrivateMediaEndToEndTests {
|
|||||||
capabilities: .privateMedia,
|
capabilities: .privateMedia,
|
||||||
noisePublicKey: bobKey
|
noisePublicKey: bobKey
|
||||||
)
|
)
|
||||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
|
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof)
|
||||||
let bobFingerprint = bobKey.sha256Fingerprint()
|
let bobFingerprint = bobKey.sha256Fingerprint()
|
||||||
#expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint))
|
#expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint))
|
||||||
|
|
||||||
try establishSession(alice: alice, bob: bob)
|
try await establishSession(alice: alice, bob: bob)
|
||||||
let capabilityPinned = await TestHelpers.waitUntil(
|
let capabilityPinned = await TestHelpers.waitUntil(
|
||||||
{ identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint) },
|
{ identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint) },
|
||||||
timeout: TestConstants.longTimeout
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
#expect(capabilityPinned)
|
#expect(capabilityPinned)
|
||||||
|
|
||||||
// Model a later verified old/replayed announce for the same stable
|
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
|
||||||
// Noise identity. The current bit disappears, but the authenticated
|
|
||||||
// persistent pin wins.
|
// 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(
|
alice._test_seedConnectedPeer(
|
||||||
bob.myPeerID,
|
bob.myPeerID,
|
||||||
nickname: "Bob",
|
nickname: "Bob",
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
noisePublicKey: bobKey
|
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)
|
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .blockedDowngrade)
|
||||||
|
|
||||||
let tap = PacketTap()
|
let tap = PacketTap()
|
||||||
@@ -372,7 +386,7 @@ struct PrivateMediaEndToEndTests {
|
|||||||
preseedPeer: false
|
preseedPeer: false
|
||||||
)
|
)
|
||||||
let advertised = await TestHelpers.waitUntil(
|
let advertised = await TestHelpers.waitUntil(
|
||||||
{ alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted },
|
{ alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof },
|
||||||
timeout: TestConstants.longTimeout
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
#expect(advertised)
|
#expect(advertised)
|
||||||
@@ -396,6 +410,181 @@ struct PrivateMediaEndToEndTests {
|
|||||||
#expect(remainedLegacyEligible)
|
#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
|
@Test
|
||||||
func authenticatedFingerprintMismatchCannotPoisonCapabilityPin() async throws {
|
func authenticatedFingerprintMismatchCannotPoisonCapabilityPin() async throws {
|
||||||
let root = FileManager.default.temporaryDirectory
|
let root = FileManager.default.temporaryDirectory
|
||||||
@@ -418,7 +607,7 @@ struct PrivateMediaEndToEndTests {
|
|||||||
capabilities: .privateMedia,
|
capabilities: .privateMedia,
|
||||||
noisePublicKey: impostorKey
|
noisePublicKey: impostorKey
|
||||||
)
|
)
|
||||||
try establishSession(alice: alice, bob: bob)
|
try await establishSession(alice: alice, bob: bob)
|
||||||
|
|
||||||
let sessionReconciled = await TestHelpers.waitUntil(
|
let sessionReconciled = await TestHelpers.waitUntil(
|
||||||
{ reconciliations.contains(bob.myPeerID) },
|
{ reconciliations.contains(bob.myPeerID) },
|
||||||
@@ -438,7 +627,7 @@ struct PrivateMediaEndToEndTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func capabilityAnnounceAfterNoiseSessionPinsMatchingFingerprint() async throws {
|
func capabilityAnnounceAfterNoiseSessionStillRequiresEncryptedProof() async throws {
|
||||||
let root = FileManager.default.temporaryDirectory
|
let root = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("private-media-race-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("private-media-race-\(UUID().uuidString)", isDirectory: true)
|
||||||
defer { try? FileManager.default.removeItem(at: root) }
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
@@ -449,7 +638,7 @@ struct PrivateMediaEndToEndTests {
|
|||||||
)
|
)
|
||||||
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
|
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 bobKey = bob.noiseStaticPublicKeyData()
|
||||||
let capableAnnounce = try signedAnnounce(
|
let capableAnnounce = try signedAnnounce(
|
||||||
from: bob,
|
from: bob,
|
||||||
@@ -461,6 +650,17 @@ struct PrivateMediaEndToEndTests {
|
|||||||
preseedPeer: false
|
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(
|
let pinned = await TestHelpers.waitUntil(
|
||||||
{
|
{
|
||||||
identity.hasObservedPrivateMediaCapability(
|
identity.hasObservedPrivateMediaCapability(
|
||||||
@@ -470,6 +670,9 @@ struct PrivateMediaEndToEndTests {
|
|||||||
timeout: TestConstants.longTimeout
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
#expect(pinned)
|
#expect(pinned)
|
||||||
|
bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID)
|
||||||
|
alice._test_onOutboundPacket = nil
|
||||||
|
bob._test_onOutboundPacket = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -492,7 +695,7 @@ struct PrivateMediaEndToEndTests {
|
|||||||
nickname: "Old Carol",
|
nickname: "Old Carol",
|
||||||
noisePublicKey: oldCarol.noiseStaticPublicKeyData()
|
noisePublicKey: oldCarol.noiseStaticPublicKeyData()
|
||||||
)
|
)
|
||||||
try establishSession(alice: alice, bob: bob)
|
try await establishSession(alice: alice, bob: bob)
|
||||||
|
|
||||||
var state: UInt64 = 0x1234_5678_9ABC_DEF0
|
var state: UInt64 = 0x1234_5678_9ABC_DEF0
|
||||||
let body = Data((0..<(130 * 1024)).map { _ in
|
let body = Data((0..<(130 * 1024)).map { _ in
|
||||||
@@ -540,7 +743,7 @@ struct PrivateMediaEndToEndTests {
|
|||||||
defer { try? FileManager.default.removeItem(at: root) }
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
|
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
|
||||||
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", 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 transferID = "queued-encryption-failure-\(UUID().uuidString)"
|
||||||
let rejections = TransferCancellationRecorder()
|
let rejections = TransferCancellationRecorder()
|
||||||
@@ -680,7 +883,7 @@ struct PrivateMediaEndToEndTests {
|
|||||||
let bob = makeService(baseDirectory: bobRoot)
|
let bob = makeService(baseDirectory: bobRoot)
|
||||||
let delegate = MessageCaptureDelegate()
|
let delegate = MessageCaptureDelegate()
|
||||||
bob.delegate = delegate
|
bob.delegate = delegate
|
||||||
try establishSession(alice: alice, bob: bob)
|
try await establishSession(alice: alice, bob: bob)
|
||||||
|
|
||||||
let file = BitchatFilePacket(
|
let file = BitchatFilePacket(
|
||||||
fileName: "\(directoryLabel).pdf",
|
fileName: "\(directoryLabel).pdf",
|
||||||
@@ -801,7 +1004,6 @@ struct PrivateMediaEndToEndTests {
|
|||||||
let bob = makeService(baseDirectory: bobRoot)
|
let bob = makeService(baseDirectory: bobRoot)
|
||||||
let tap = PacketTap()
|
let tap = PacketTap()
|
||||||
let delegate = MessageCaptureDelegate()
|
let delegate = MessageCaptureDelegate()
|
||||||
alice._test_onOutboundPacket = tap.record
|
|
||||||
bob.delegate = delegate
|
bob.delegate = delegate
|
||||||
|
|
||||||
alice._test_seedConnectedPeer(
|
alice._test_seedConnectedPeer(
|
||||||
@@ -816,7 +1018,8 @@ struct PrivateMediaEndToEndTests {
|
|||||||
capabilities: .privateMedia,
|
capabilities: .privateMedia,
|
||||||
noisePublicKey: alice.noiseStaticPublicKeyData()
|
noisePublicKey: alice.noiseStaticPublicKeyData()
|
||||||
)
|
)
|
||||||
try establishSession(alice: alice, bob: bob)
|
try await establishSession(alice: alice, bob: bob)
|
||||||
|
alice._test_onOutboundPacket = tap.record
|
||||||
|
|
||||||
let file = BitchatFilePacket(
|
let file = BitchatFilePacket(
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
@@ -890,7 +1093,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 first = try alice._test_noiseInitiateHandshake(with: bob.myPeerID)
|
||||||
let second = try #require(
|
let second = try #require(
|
||||||
try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: first)
|
try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: first)
|
||||||
@@ -901,6 +1124,35 @@ struct PrivateMediaEndToEndTests {
|
|||||||
_ = try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: third)
|
_ = try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: third)
|
||||||
#expect(alice.canDeliverSecurely(to: bob.myPeerID))
|
#expect(alice.canDeliverSecurely(to: bob.myPeerID))
|
||||||
#expect(bob.canDeliverSecurely(to: alice.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(
|
private func signedAnnounce(
|
||||||
@@ -927,6 +1179,33 @@ struct PrivateMediaEndToEndTests {
|
|||||||
return service.signPacketForBroadcast(unsigned)
|
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] {
|
private func recursivelyStoredFiles(under root: URL) -> [URL] {
|
||||||
guard let enumerator = FileManager.default.enumerator(
|
guard let enumerator = FileManager.default.enumerator(
|
||||||
at: root,
|
at: root,
|
||||||
@@ -1018,6 +1297,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 final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var messages: [BitchatMessage] = []
|
private var messages: [BitchatMessage] = []
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
|||||||
private var blockedNostrPubkeys: Set<String> = []
|
private var blockedNostrPubkeys: Set<String> = []
|
||||||
private var socialIdentities: [String: SocialIdentity] = [:]
|
private var socialIdentities: [String: SocialIdentity] = [:]
|
||||||
private var privateMediaCapableFingerprints: Set<String> = []
|
private var privateMediaCapableFingerprints: Set<String> = []
|
||||||
|
private var authenticatedSigningKeys: [String: Data] = [:]
|
||||||
|
|
||||||
init(_: KeychainManagerProtocol) {}
|
init(_: KeychainManagerProtocol) {}
|
||||||
|
|
||||||
@@ -90,6 +91,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
func clearAllIdentityData() {
|
func clearAllIdentityData() {
|
||||||
privateMediaCapableFingerprints.removeAll()
|
privateMediaCapableFingerprints.removeAll()
|
||||||
|
authenticatedSigningKeys.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeEphemeralSession(peerID: PeerID) {}
|
func removeEphemeralSession(peerID: PeerID) {}
|
||||||
@@ -112,6 +114,14 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
|||||||
privateMediaCapableFingerprints.contains(fingerprint)
|
privateMediaCapableFingerprints.contains(fingerprint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
|
||||||
|
authenticatedSigningKeys[fingerprint] = signingPublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||||
|
authenticatedSigningKeys[fingerprint]
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Vouching (transitive verification)
|
// MARK: Vouching (transitive verification)
|
||||||
|
|
||||||
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
|
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
|
||||||
|
|||||||
@@ -205,6 +205,14 @@ final class MockTransport: Transport {
|
|||||||
privateMediaPolicies[peerID] ?? .encrypted
|
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) {
|
func cancelTransfer(_ transferId: String) {
|
||||||
cancelledTransfers.append(transferId)
|
cancelledTransfers.append(transferId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -650,7 +650,8 @@ struct NoiseCoverageTests {
|
|||||||
try aliceManager.initiateHandshake(with: alicePeerID)
|
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))
|
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
|
||||||
|
|
||||||
#expect(rekeyedSession !== establishedSession)
|
#expect(rekeyedSession !== establishedSession)
|
||||||
|
|||||||
@@ -145,6 +145,39 @@ struct PacketsTests {
|
|||||||
#expect(decoded.capabilities?.rawValue == 0x0180)
|
#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
|
@Test
|
||||||
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
|
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
|
||||||
let unknownTLV = Data([0x7F, 0x01, 0x41])
|
let unknownTLV = Data([0x7F, 0x01, 0x41])
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Testing
|
|||||||
struct BLEAnnounceHandlerTests {
|
struct BLEAnnounceHandlerTests {
|
||||||
private final class Recorder {
|
private final class Recorder {
|
||||||
var existingNoisePublicKey: Data?
|
var existingNoisePublicKey: Data?
|
||||||
|
var authenticatedSigningPublicKey: Data?
|
||||||
var signatureValid = true
|
var signatureValid = true
|
||||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||||
var linkBoundToOtherPeer = false
|
var linkBoundToOtherPeer = false
|
||||||
@@ -37,6 +38,7 @@ struct BLEAnnounceHandlerTests {
|
|||||||
messageTTL: TransportConfig.messageTTLDefault,
|
messageTTL: TransportConfig.messageTTLDefault,
|
||||||
now: { now },
|
now: { now },
|
||||||
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
||||||
|
authenticatedSigningPublicKey: { _ in recorder.authenticatedSigningPublicKey },
|
||||||
verifySignature: { packet, signingPublicKey in
|
verifySignature: { packet, signingPublicKey in
|
||||||
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
||||||
return recorder.signatureValid
|
return recorder.signatureValid
|
||||||
|
|||||||
@@ -169,6 +169,23 @@ struct BLEAnnounceHandlingPolicyTests {
|
|||||||
#expect(decision.isVerified)
|
#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
|
@Test
|
||||||
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
||||||
let directNew = BLEAnnounceResponsePolicy.plan(
|
let directNew = BLEAnnounceResponsePolicy.plan(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ struct BLENoisePacketHandlerTests {
|
|||||||
var lastSeenUpdates: [PeerID] = []
|
var lastSeenUpdates: [PeerID] = []
|
||||||
var decryptCalls: [(payload: Data, peerID: PeerID)] = []
|
var decryptCalls: [(payload: Data, peerID: PeerID)] = []
|
||||||
var clearedSessions: [PeerID] = []
|
var clearedSessions: [PeerID] = []
|
||||||
|
var authenticatedPeerStates: [(peerID: PeerID, payload: Data)] = []
|
||||||
var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
|
var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
|
||||||
/// Ordered side-effect log to assert recovery sequencing.
|
/// Ordered side-effect log to assert recovery sequencing.
|
||||||
var events: [String] = []
|
var events: [String] = []
|
||||||
@@ -67,6 +68,9 @@ struct BLENoisePacketHandlerTests {
|
|||||||
recorder.clearedSessions.append(peerID)
|
recorder.clearedSessions.append(peerID)
|
||||||
recorder.events.append("clearSession")
|
recorder.events.append("clearSession")
|
||||||
},
|
},
|
||||||
|
handleAuthenticatedPeerState: { peerID, payload in
|
||||||
|
recorder.authenticatedPeerStates.append((peerID, payload))
|
||||||
|
},
|
||||||
deliverNoisePayload: { peerID, type, payload, timestamp in
|
deliverNoisePayload: { peerID, type, payload, timestamp in
|
||||||
recorder.deliveries.append((peerID, type, payload, timestamp))
|
recorder.deliveries.append((peerID, type, payload, timestamp))
|
||||||
}
|
}
|
||||||
@@ -244,6 +248,24 @@ struct BLENoisePacketHandlerTests {
|
|||||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
#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
|
@Test
|
||||||
func emptyDecryptedPayloadIsIgnored() {
|
func emptyDecryptedPayloadIsIgnored() {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
|
|||||||
@@ -79,4 +79,16 @@ struct BLENoisePayloadFactoryTests {
|
|||||||
#expect(decoded.data == Data([0xCA, 0xFE]))
|
#expect(decoded.data == Data([0xCA, 0xFE]))
|
||||||
#expect(decoded.encode().first == 0x20)
|
#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))
|
#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")
|
@Test("Large private-file payloads use the bounded Noise extension")
|
||||||
func largePrivateFileNoiseRoundTrip() throws {
|
func largePrivateFileNoiseRoundTrip() throws {
|
||||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
|||||||
@@ -427,6 +427,31 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(cleared)
|
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 {
|
func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {
|
||||||
let keychain = FailingCacheSaveKeychain()
|
let keychain = FailingCacheSaveKeychain()
|
||||||
let manager = SecureIdentityStateManager(keychain)
|
let manager = SecureIdentityStateManager(keychain)
|
||||||
|
|||||||
@@ -268,6 +268,8 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
func markPrivateMediaCapable(fingerprint: String) {}
|
func markPrivateMediaCapable(fingerprint: String) {}
|
||||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false }
|
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false }
|
||||||
|
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {}
|
||||||
|
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? { nil }
|
||||||
|
|
||||||
func removeEphemeralSession(peerID: PeerID) {}
|
func removeEphemeralSession(peerID: PeerID) {}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,23 @@ peer's Noise session before BLE fragmentation.
|
|||||||
- iOS temporarily accepts `0x09`, which appeared in prerelease builds of the
|
- iOS temporarily accepts `0x09`, which appeared in prerelease builds of the
|
||||||
private-media change. Decoders canonicalize it to `privateFile`; they never
|
private-media change. Decoders canonicalize it to `privateFile`; they never
|
||||||
emit it.
|
emit it.
|
||||||
- A peer advertising `PeerCapabilities.privateMedia` receives one encrypted
|
- `NoisePayloadType.authenticatedPeerState` is permanently assigned `0x21`.
|
||||||
`noiseEncrypted` transfer.
|
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
|
- An unpinned peer with a stable Noise key but without that capability is
|
||||||
eligible for one signed, directed
|
eligible for one signed, directed
|
||||||
`fileTransfer`, matching the pre-migration wire form used by older iOS and
|
`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
|
per-send warning that the file is not end-to-end encrypted and mesh relays
|
||||||
can see it. The
|
can see it. The
|
||||||
consent is consumed by that invocation and is never remembered.
|
consent is consumed by that invocation and is never remembered.
|
||||||
- A signed announce never creates a pin by itself. The fingerprint is pinned
|
- A signed announce never creates a pin by itself: an attacker can copy a
|
||||||
in the encrypted identity cache only after a completed Noise session
|
victim's public Noise key, supply its own Ed25519 key and capability bits,
|
||||||
authenticates the same static key as the capability-bearing registry entry.
|
and self-sign an internally consistent announce. Only successfully
|
||||||
This comparison is performed in either event order: session then announce,
|
decrypted `0x21` state pins the authenticated Noise fingerprint and binds
|
||||||
or announce then session. A later announce missing the bit is then treated
|
the Ed25519 key used by later announces/public messages. A later valid
|
||||||
as a downgrade, and raw fallback is blocked even if a caller presents
|
no-bit `0x21` is treated as a downgrade, and raw fallback is blocked even if
|
||||||
legacy consent.
|
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
|
- During migration, both an absent capabilities TLV and an explicit TLV
|
||||||
without `privateMedia` are legacy-eligible when that stable fingerprint is
|
without `privateMedia` are legacy-eligible when that stable fingerprint is
|
||||||
not pinned. This supports clients that added capability advertisement before
|
not pinned. This supports clients that added capability advertisement before
|
||||||
encrypted media. Neither shape bypasses a previously authenticated pin.
|
encrypted media. Neither shape bypasses a previously authenticated pin.
|
||||||
|
|
||||||
The `0x09` receive alias may be removed only after every TestFlight/internal
|
Older clients decrypt and ignore unknown inner type `0x21`; they do not need to
|
||||||
build that emitted it has expired and the project's minimum-supported-client
|
understand it to continue using text or the warned legacy media path. They are
|
||||||
policy excludes those builds. Track that release criterion explicitly; do not
|
never inferred capable merely because the handshake succeeded.
|
||||||
remove the alias on an arbitrary calendar date.
|
|
||||||
|
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
|
## 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
|
Panic wipe clears the persistent capability pins together with the rest of
|
||||||
the encrypted identity cache.
|
the encrypted identity cache.
|
||||||
|
|
||||||
This migration path is BLE-only. Nostr private-media transport is unchanged
|
This migration path is mesh-Noise-only (BLE and compatible direct mesh links).
|
||||||
and remains a follow-up; do not infer the BLE consent fallback or capability
|
Nostr private-media transport is unchanged and remains a follow-up. Nostr
|
||||||
pin semantics for Nostr delivery.
|
inbound paths explicitly ignore `0x21`; do not infer the mesh consent fallback
|
||||||
|
or capability-pin semantics for Nostr delivery.
|
||||||
|
|
||||||
## Size interoperability
|
## Size interoperability
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user