mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 19:25:23 +00:00
Closes the last cleartext private-content path over BLE: private DM images/voice were sent as plaintext signed fileTransfer packets, TTL-relayed across the mesh, so every relay saw the full bytes. Now the complete BitchatFilePacket is encrypted as a single Noise AEAD message (inner type 0x20, matching Android) and the opaque ciphertext is fragmented. Adds an authenticated in-session capability proof (0x21 TLV: capabilities + Ed25519 key), TOFU-style downgrade pinning, a per-send consent dialog for the signed-cleartext fallback to legacy peers, and a cancellation/admission registry so cancel/delete cannot race a deferred cleartext send. Android wire constants (0x20 / 0x21 / capability bit 8) confirmed shipping. The 256-fragment preflight cap applies only to the directed fileTransfer migration fallback; encrypted media to capable peers uses the full receiver ceiling. Rebased over #1428/#1349: identity reads go through BLELocalIdentityStateStore; the session-bound authenticated signing-key check and the announce-path TOFU pin are kept as complementary checks. Full local suite green (1744+197 tests).
141 lines
4.4 KiB
Swift
141 lines
4.4 KiB
Swift
import BitFoundation
|
|
import Foundation
|
|
|
|
struct BLEAnnouncePreflightAcceptance {
|
|
let announcement: AnnouncementPacket
|
|
let derivedPeerID: PeerID
|
|
}
|
|
|
|
enum BLEAnnouncePreflightRejection: Equatable {
|
|
case malformed
|
|
case senderMismatch(derivedPeerID: PeerID)
|
|
case selfAnnounce
|
|
case stale(ageSeconds: Double)
|
|
}
|
|
|
|
enum BLEAnnouncePreflightDecision {
|
|
case accept(BLEAnnouncePreflightAcceptance)
|
|
case reject(BLEAnnouncePreflightRejection)
|
|
}
|
|
|
|
enum BLEAnnouncePreflightPolicy {
|
|
static func evaluate(
|
|
packet: BitchatPacket,
|
|
from peerID: PeerID,
|
|
localPeerID: PeerID,
|
|
now: Date
|
|
) -> BLEAnnouncePreflightDecision {
|
|
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
|
|
return .reject(.malformed)
|
|
}
|
|
|
|
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
|
guard derivedPeerID == peerID else {
|
|
return .reject(.senderMismatch(derivedPeerID: derivedPeerID))
|
|
}
|
|
|
|
guard peerID != localPeerID else {
|
|
return .reject(.selfAnnounce)
|
|
}
|
|
|
|
guard !BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) else {
|
|
return .reject(.stale(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
|
timestampMilliseconds: packet.timestamp,
|
|
now: now
|
|
)))
|
|
}
|
|
|
|
return .accept(BLEAnnouncePreflightAcceptance(
|
|
announcement: announcement,
|
|
derivedPeerID: derivedPeerID
|
|
))
|
|
}
|
|
}
|
|
|
|
enum BLEAnnounceTrustRejection: Equatable {
|
|
case missingSignature
|
|
case invalidSignature
|
|
case keyMismatch
|
|
case signingKeyMismatch
|
|
case authenticatedSigningKeyMismatch
|
|
}
|
|
|
|
enum BLEAnnounceTrustDecision: Equatable {
|
|
case verified
|
|
case reject(BLEAnnounceTrustRejection)
|
|
|
|
var isVerified: Bool {
|
|
self == .verified
|
|
}
|
|
}
|
|
|
|
enum BLEAnnounceTrustPolicy {
|
|
static func evaluate(
|
|
hasSignature: Bool,
|
|
signatureValid: Bool,
|
|
existingNoisePublicKey: Data?,
|
|
announcedNoisePublicKey: Data,
|
|
existingSigningPublicKey: Data? = nil,
|
|
authenticatedSigningPublicKey: Data? = nil,
|
|
announcedSigningPublicKey: Data
|
|
) -> BLEAnnounceTrustDecision {
|
|
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
|
return .reject(.keyMismatch)
|
|
}
|
|
|
|
// Strongest binding first: an Ed25519 key bound to this Noise identity
|
|
// inside an authenticated Noise session can never be replaced by a
|
|
// merely self-signed announce.
|
|
if let authenticatedSigningPublicKey,
|
|
announcedSigningPublicKey != authenticatedSigningPublicKey {
|
|
return .reject(.authenticatedSigningKeyMismatch)
|
|
}
|
|
|
|
// TOFU signing-key pinning. The packet signature only proves the
|
|
// announce is self-consistent — it is verified against the Ed25519 key
|
|
// carried *inside the same announce*. Since peerIDs derive from the
|
|
// broadcast (public) noise key, an attacker can replay a victim's
|
|
// peerID+noiseKey with their own signing key and a valid
|
|
// self-signature. Once we have bound a signing key to this peer,
|
|
// refuse to silently replace it.
|
|
if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey {
|
|
return .reject(.signingKeyMismatch)
|
|
}
|
|
|
|
guard hasSignature else {
|
|
return .reject(.missingSignature)
|
|
}
|
|
|
|
guard signatureValid else {
|
|
return .reject(.invalidSignature)
|
|
}
|
|
|
|
return .verified
|
|
}
|
|
}
|
|
|
|
struct BLEAnnounceResponsePlan: Equatable {
|
|
let shouldNotifyPeerConnected: Bool
|
|
let shouldScheduleInitialSync: Bool
|
|
let shouldSendAnnounceBack: Bool
|
|
let shouldScheduleAfterglow: Bool
|
|
}
|
|
|
|
enum BLEAnnounceResponsePolicy {
|
|
static func plan(
|
|
isDirectAnnounce: Bool,
|
|
isNewPeer: Bool,
|
|
isReconnectedPeer: Bool,
|
|
shouldSendAnnounceBack: Bool
|
|
) -> BLEAnnounceResponsePlan {
|
|
let shouldNotifyPeerConnected = isDirectAnnounce && (isNewPeer || isReconnectedPeer)
|
|
|
|
return BLEAnnounceResponsePlan(
|
|
shouldNotifyPeerConnected: shouldNotifyPeerConnected,
|
|
shouldScheduleInitialSync: shouldNotifyPeerConnected,
|
|
shouldSendAnnounceBack: shouldSendAnnounceBack,
|
|
shouldScheduleAfterglow: isNewPeer
|
|
)
|
|
}
|
|
}
|