Compare commits

..
Author SHA1 Message Date
jack 6d373100d9 Keep share extension helpers target-local 2026-07-10 15:11:12 -04:00
jack 19c06f360d Require review before importing shared content 2026-07-10 20:34:59 +02:00
122 changed files with 1619 additions and 21042 deletions
+4 -4
View File
@@ -17,12 +17,12 @@ bitchat is designed for private, account-free communication. This policy describ
1. **Identity and cryptographic keys**
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
- Secret keys are stored in the system keychain as device-only items. Public keys are shared when required for messaging, verification, groups, or Nostr events.
- Keys remain until they are rotated, removed by the relevant feature, or erased with panic wipe. Because operating-system keychains can outlive an uninstall, bitchat records a non-secret install marker and deletes surviving app keys before use after a later reinstall.
- Secret keys are stored in the system keychain. Public keys are shared when required for messaging, verification, groups, or Nostr events.
- Keys remain until they are rotated, removed by the relevant feature, erased with panic wipe, or removed with the app.
2. **Nickname, preferences, and relationships**
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
- The share extension briefly places content you choose to share in the app-group preferences so the main app can import it.
- The share extension can retain one item you choose to share in the app-group preferences for up to 24 hours. The app shows the destination and a preview for review; it does not send the item automatically. The item is cleared when you add it to the composer, cancel, panic-wipe, or it expires.
3. **Private group state**
- Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support.
@@ -121,7 +121,7 @@ No cryptographic system can protect content after a recipient reads, copies, scr
## Your Controls
- **Panic wipe:** Triple-tap the logo to synchronously cancel in-flight media work and clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
- **Panic wipe:** Triple-tap the logo to clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled.
- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings.
- **No account:** The project operates no account record for you to request or export.
+1 -1
View File
@@ -70,6 +70,7 @@
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Services/SharedContentHandoff.swift,
Services/TransportConfig.swift,
);
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
@@ -337,7 +338,6 @@
es,
ar,
de,
fa,
fr,
he,
id,
+1 -6
View File
@@ -2,11 +2,6 @@ import BitFoundation
import Combine
import Foundation
enum SharedContentKind: String, Sendable, Equatable {
case text
case url
}
enum RuntimeScenePhase: String, Sendable, Equatable {
case active
case inactive
@@ -25,7 +20,7 @@ enum AppEvent: Sendable, Equatable {
case startupCompleted
case scenePhaseChanged(RuntimeScenePhase)
case openedURL(String)
case sharedContentAccepted(SharedContentKind)
case sharedContentReadyForReview(SharedContentKind)
case notificationOpened(peerID: PeerID?)
case deepLinkOpened(String)
case torLifecycleChanged(TorLifecycleEvent)
+8 -9
View File
@@ -20,16 +20,19 @@ final class AppChromeModel: ObservableObject {
@Published var showScreenshotPrivacyWarning = false
private let chatViewModel: ChatViewModel
private let onPanicWipe: () -> Void
private var cancellables = Set<AnyCancellable>()
/// The composer owns capture state above ChatViewModel. ContentView
/// installs this hook so both panic entry points synchronously stop it.
private var prepareForPanic: (@MainActor () -> Void)?
/// Bulletin-board coordinator, created on first use of the board sheet.
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
init(
chatViewModel: ChatViewModel,
privateInboxModel: PrivateInboxModel,
onPanicWipe: @escaping () -> Void = {}
) {
self.chatViewModel = chatViewModel
self.onPanicWipe = onPanicWipe
self.nickname = chatViewModel.nickname
bind(privateInboxModel: privateInboxModel)
@@ -100,12 +103,8 @@ final class AppChromeModel: ObservableObject {
showScreenshotPrivacyWarning = true
}
func setPanicPreparation(_ preparation: (@MainActor () -> Void)?) {
prepareForPanic = preparation
}
func panicClearAllData() {
prepareForPanic?()
onPanicWipe()
chatViewModel.panicClearAllData()
}
+31 -59
View File
@@ -27,6 +27,7 @@ final class AppRuntime: ObservableObject {
let peerListModel: PeerListModel
let appChromeModel: AppChromeModel
let boardAlertsModel: BoardAlertsModel
let sharedContentImportModel: SharedContentImportModel
private let idBridge: NostrIdentityBridge
private var cancellables = Set<AnyCancellable>()
@@ -41,7 +42,8 @@ final class AppRuntime: ObservableObject {
init(
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
idBridge: NostrIdentityBridge = NostrIdentityBridge()
idBridge: NostrIdentityBridge = NostrIdentityBridge(),
sharedContentStore: SharedContentStore? = nil
) {
self.idBridge = idBridge
let conversations = ConversationStore()
@@ -84,9 +86,20 @@ final class AppRuntime: ObservableObject {
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore
)
let resolvedSharedContentStore: SharedContentStore?
if let sharedContentStore {
resolvedSharedContentStore = sharedContentStore
} else if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) {
resolvedSharedContentStore = SharedContentStore(defaults: sharedDefaults)
} else {
resolvedSharedContentStore = nil
}
let sharedContentImportModel = SharedContentImportModel(store: resolvedSharedContentStore)
self.sharedContentImportModel = sharedContentImportModel
self.appChromeModel = AppChromeModel(
chatViewModel: self.chatViewModel,
privateInboxModel: self.privateInboxModel
privateInboxModel: self.privateInboxModel,
onPanicWipe: { sharedContentImportModel.discardAll() }
)
let chatViewModel = self.chatViewModel
self.boardAlertsModel = BoardAlertsModel(
@@ -106,16 +119,12 @@ final class AppRuntime: ObservableObject {
}
)
)
if chatViewModel.networkActivationAllowed {
GeoRelayDirectory.shared.prefetchIfNeeded()
}
GeoRelayDirectory.shared.prefetchIfNeeded()
bindRuntimeObservers()
NotificationDelegate.shared.runtime = self
}
func start() {
guard chatViewModel.networkActivationAllowed else { return }
guard !started else {
checkForSharedContent()
return
@@ -154,14 +163,12 @@ final class AppRuntime: ObservableObject {
}
func handleDidBecomeActiveNotification() {
guard chatViewModel.networkActivationAllowed else { return }
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
}
#if os(macOS)
func handleMacDidBecomeActiveNotification() {
guard chatViewModel.networkActivationAllowed else { return }
record(.scenePhaseChanged(.active))
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
@@ -180,7 +187,6 @@ final class AppRuntime: ObservableObject {
didEnterBackground = true
case .active:
guard chatViewModel.networkActivationAllowed else { return }
record(.scenePhaseChanged(.active))
chatViewModel.meshService.startServices()
TorManager.shared.setAppForeground(true)
@@ -228,7 +234,6 @@ final class AppRuntime: ObservableObject {
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
userInfo: [AnyHashable: Any]
) {
guard chatViewModel.networkActivationAllowed else { return }
if actionIdentifier == NotificationService.waveActionID {
chatViewModel.sendMeshWave()
return
@@ -280,8 +285,6 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorWillRestart)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.willRestart))
self?.chatViewModel.handleTorWillRestart()
}
@@ -290,8 +293,6 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.didBecomeReady))
self?.chatViewModel.handleTorDidBecomeReady()
}
@@ -300,8 +301,6 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorWillStart)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.willStart))
self?.chatViewModel.handleTorWillStart()
}
@@ -310,8 +309,6 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
.receive(on: DispatchQueue.main)
.sink { [weak self] notification in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.preferenceChanged))
self?.chatViewModel.handleTorPreferenceChanged(notification)
}
@@ -328,45 +325,22 @@ private extension AppRuntime {
}
func checkForSharedContent() {
guard chatViewModel.networkActivationAllowed else { return }
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
let clearSharedContent = {
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
let previousID = sharedContentImportModel.offer?.id
guard let payload = sharedContentImportModel.refresh(
destination: currentSharedContentDestination
) else { return }
if previousID != payload.id {
record(.sharedContentReadyForReview(payload.kind))
}
}
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
// A partial or malformed handoff must not linger in the shared
// app-group container indefinitely.
clearSharedContent()
return
}
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
clearSharedContent()
return
}
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
clearSharedContent()
switch contentKind {
case .url:
if let data = sharedContent.data(using: .utf8),
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let url = urlData["url"] {
chatViewModel.sendMessage(url)
} else {
chatViewModel.sendMessage(sharedContent)
}
case .text:
chatViewModel.sendMessage(sharedContent)
}
record(.sharedContentAccepted(contentKind))
var currentSharedContentDestination: SharedContentDestination {
SharedContentDestination.resolve(
selectedPrivatePeerID: privateConversationModel.selectedPeerID,
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
activeChannel: locationChannelsModel.selectedChannel
)
}
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
@@ -375,9 +349,7 @@ private extension AppRuntime {
let becameConnected = isConnected && !lastNostrRelayConnectedState
lastNostrRelayConnectedState = isConnected
guard chatViewModel.networkActivationAllowed,
started,
becameConnected else { return }
guard started, becameConnected else { return }
let isInitialConnection = !didHandleInitialNostrConnection
didHandleInitialNostrConnection = true
-12
View File
@@ -12,7 +12,6 @@ final class ConversationUIModel: ObservableObject {
@Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
/// Who is talking live in the public mesh channel right now (floor
/// courtesy: the composer mic tints "busy" while someone holds the floor).
@Published private(set) var activeLiveVoiceTalker: String?
@@ -154,13 +153,6 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.sendVoiceNote(at: url)
}
func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) {
chatViewModel.resolveLegacyPrivateMediaConsent(
requestID: requestID,
approved: approved
)
}
/// Capture backend for the mic gesture: live PTT when the current DM
/// peer can hear it now, classic voice note otherwise.
func makeVoiceCaptureSession() -> VoiceCaptureSession {
@@ -201,10 +193,6 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main)
.assign(to: &$activeLiveVoiceTalker)
chatViewModel.$legacyPrivateMediaConsentRequest
.receive(on: DispatchQueue.main)
.assign(to: &$legacyPrivateMediaConsentRequest)
conversations.$activeChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
+10 -111
View File
@@ -7,146 +7,45 @@ final class LocationPresenceStore: ObservableObject {
@Published private(set) var geoNicknames: [String: String] = [:]
@Published private(set) var teleportedGeo: Set<String> = []
private let teleportedGeoCapacity: Int
private var teleportedGeoOrder: [String] = []
private let geoNicknameCapacity: Int
private var geoNicknameOrder: [String] = []
init(
teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap,
geoNicknameCapacity: Int = TransportConfig.geoNicknameParticipantsCap
) {
self.teleportedGeoCapacity = max(0, teleportedGeoCapacity)
self.geoNicknameCapacity = max(0, geoNicknameCapacity)
}
func setCurrentGeohash(_ geohash: String?) {
let normalized = geohash?.lowercased()
if currentGeohash != normalized {
// Presence markers are scoped to the active geohash channel.
clearTeleportedGeo()
clearGeoNicknames()
}
currentGeohash = normalized
currentGeohash = geohash?.lowercased()
}
func setNickname(_ nickname: String, for pubkeyHex: String) {
guard geoNicknameCapacity > 0 else {
clearGeoNicknames()
return
}
let key = pubkeyHex.lowercased()
if geoNicknames[key] != nil {
geoNicknames[key] = nickname
return
}
while geoNicknameOrder.count >= geoNicknameCapacity, let oldest = geoNicknameOrder.first {
geoNicknameOrder.removeFirst()
geoNicknames.removeValue(forKey: oldest)
}
geoNicknames[key] = nickname
geoNicknameOrder.append(key)
geoNicknames[pubkeyHex.lowercased()] = nickname
}
func replaceGeoNicknames(_ nicknames: [String: String]) {
guard geoNicknameCapacity > 0 else {
clearGeoNicknames()
return
}
var seen: Set<String> = []
var ordered: [String] = []
var normalized: [String: String] = [:]
for (key, value) in nicknames {
let lower = key.lowercased()
guard seen.insert(lower).inserted else { continue }
ordered.append(lower)
normalized[lower] = value
}
if ordered.count > geoNicknameCapacity {
let kept = Array(ordered.suffix(geoNicknameCapacity))
ordered = kept
normalized = Dictionary(uniqueKeysWithValues: kept.compactMap { key in
normalized[key].map { (key, $0) }
})
}
geoNicknameOrder = ordered
geoNicknames = normalized
geoNicknames = Dictionary(
uniqueKeysWithValues: nicknames.map { key, value in
(key.lowercased(), value)
}
)
}
func clearGeoNicknames() {
geoNicknames.removeAll()
geoNicknameOrder.removeAll()
}
func retainGeoNicknames(keeping pubkeys: Set<String>) {
let allowed = Set(pubkeys.map { $0.lowercased() })
geoNicknameOrder = geoNicknameOrder.filter { allowed.contains($0) }
geoNicknames = geoNicknames.filter { allowed.contains($0.key) }
}
func markTeleported(_ pubkeyHex: String) {
guard teleportedGeoCapacity > 0 else {
clearTeleportedGeo()
return
}
let key = pubkeyHex.lowercased()
guard !teleportedGeo.contains(key) else { return }
while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first {
teleportedGeoOrder.removeFirst()
teleportedGeo.remove(oldest)
}
teleportedGeo.insert(key)
teleportedGeoOrder.append(key)
teleportedGeo.insert(pubkeyHex.lowercased())
}
func clearTeleported(_ pubkeyHex: String) {
let key = pubkeyHex.lowercased()
teleportedGeo.remove(key)
teleportedGeoOrder.removeAll { $0 == key }
teleportedGeo.remove(pubkeyHex.lowercased())
}
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
guard teleportedGeoCapacity > 0 else {
clearTeleportedGeo()
return
}
var seen: Set<String> = []
var ordered: [String] = []
for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) {
seen.insert(key)
ordered.append(key)
}
if ordered.count > teleportedGeoCapacity {
ordered = Array(ordered.suffix(teleportedGeoCapacity))
}
teleportedGeoOrder = ordered
teleportedGeo = Set(ordered)
}
func retainTeleportedGeo(keeping pubkeys: Set<String>) {
let allowed = Set(pubkeys.map { $0.lowercased() })
teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) }
teleportedGeo = teleportedGeo.intersection(allowed)
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
}
func clearTeleportedGeo() {
teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
}
func reset() {
currentGeohash = nil
geoNicknames.removeAll()
geoNicknameOrder.removeAll()
teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
}
}
+119
View File
@@ -0,0 +1,119 @@
import BitFoundation
import Combine
import Foundation
enum SharedContentDestination: Sendable, Equatable {
case mesh
case geohash(String)
case privateConversation(peerID: PeerID, displayName: String)
static func resolve(
selectedPrivatePeerID: PeerID?,
privateDisplayName: String?,
activeChannel: ChannelID
) -> SharedContentDestination {
if let selectedPrivatePeerID {
let fallback = String(selectedPrivatePeerID.id.prefix(12))
return .privateConversation(
peerID: selectedPrivatePeerID,
displayName: privateDisplayName?.trimmedOrNilIfEmpty ?? fallback
)
}
switch activeChannel {
case .mesh:
return .mesh
case .location(let channel):
return .geohash(channel.geohash.lowercased())
}
}
var displayName: String {
switch self {
case .mesh:
return "#mesh"
case .geohash(let geohash):
return "#\(geohash)"
case .privateConversation(_, let displayName):
return displayName
}
}
}
struct SharedContentOffer: Identifiable, Sendable, Equatable {
let payload: SharedContentPayload
let destination: SharedContentDestination
var id: UUID { payload.id }
}
/// Holds a pending extension handoff until the user chooses a destination and
/// explicitly adds it to the composer. This type has no send dependency by
/// design: confirming an import can never transmit a message.
@MainActor
final class SharedContentImportModel: ObservableObject {
@Published private(set) var offer: SharedContentOffer?
private let store: SharedContentStore?
init(store: SharedContentStore?) {
self.store = store
}
@discardableResult
func refresh(
destination: SharedContentDestination,
now: Date = Date()
) -> SharedContentPayload? {
guard let payload = store?.pending(now: now) else {
offer = nil
return nil
}
let nextOffer = SharedContentOffer(payload: payload, destination: destination)
if offer != nextOffer {
offer = nextOffer
}
return payload
}
func updateDestination(_ destination: SharedContentDestination) {
guard let offer, offer.destination != destination else { return }
self.offer = SharedContentOffer(payload: offer.payload, destination: destination)
}
/// Returns composer text only when the currently displayed destination is
/// still current and the reviewed envelope is still the stored envelope.
/// A destination change updates the prompt and requires another tap.
func confirm(
destination: SharedContentDestination,
now: Date = Date()
) -> String? {
guard let offer else { return nil }
guard offer.destination == destination else {
updateDestination(destination)
return nil
}
guard let payload = store?.consume(id: offer.id, now: now) else {
_ = refresh(destination: destination, now: now)
return nil
}
self.offer = nil
return payload.composerText
}
func cancel(destination: SharedContentDestination, now: Date = Date()) {
guard let offer else { return }
store?.discard(id: offer.id)
self.offer = nil
// If a newer share replaced the reviewed envelope, surface it rather
// than losing it with the older cancellation.
_ = refresh(destination: destination, now: now)
}
func discardAll() {
store?.discardAll()
offer = nil
}
}
+1
View File
@@ -41,6 +41,7 @@ struct BitchatApp: App {
.environmentObject(runtime.peerListModel)
.environmentObject(runtime.appChromeModel)
.environmentObject(runtime.boardAlertsModel)
.environmentObject(runtime.sharedContentImportModel)
.onAppear {
appDelegate.runtime = runtime
runtime.start()
@@ -26,8 +26,6 @@ protocol VoiceCaptureSession: AnyObject {
/// nothing valid was captured.
func finish() async -> URL?
func cancel() async
/// Stops capture and suppresses every later send before returning.
func panicCancelSynchronously()
}
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
@@ -57,10 +55,6 @@ final class VoiceNoteCaptureSession: VoiceCaptureSession {
func cancel() async {
await recorder.cancelRecording(owner: owner)
}
func panicCancelSynchronously() {
recorder.panicCancelSynchronously(owner: owner)
}
}
/// Testable surface of the live capture engine. Production uses
@@ -222,13 +216,6 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
}
}
func panicCancelSynchronously() {
// Do not emit a canceled packet: it would itself be pre-panic
// conversation data racing the emergency transport reset.
completed = true
capture.cancel()
}
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
sendPacket(packet.encode())
@@ -246,21 +246,6 @@ actor VoiceRecorder {
currentURL = nil
}
/// Panic is a synchronous security boundary: the caller must know the
/// microphone, audio-session lease, and partial file are gone before it
/// rotates identities or deletes the media tree. VoiceRecorder is an
/// independent actor and this cleanup path never hops to MainActor, so a
/// short semaphore join is safe even when invoked by the UI actor.
nonisolated
func panicCancelSynchronously(owner: RecordingOwner) {
let finished = DispatchSemaphore(value: 0)
Task {
await cancelRecording(owner: owner)
finished.signal()
}
finished.wait()
}
/// The audio session was interrupted (call, Siri) or reconfigured: stop
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
/// `stopRecording()` still returns the partial note.
-12
View File
@@ -189,18 +189,6 @@ struct IdentityCache: Codable {
// Fingerprint -> when we verified it (orders outgoing vouch batches;
// entries verified before this field exists sort as oldest)
var verifiedAt: [String: Date]? = nil
// Stable Noise fingerprints that proved encrypted private-media support
// inside an authenticated Noise session. Optional for decoding caches
// written before this migration. Entries are monotonic until a panic wipe
// so an old/replayed announce cannot silently downgrade a peer.
var privateMediaCapableFingerprints: Set<String>? = nil
// Noise-fingerprint -> Ed25519 announcement key, learned only from the
// authenticated peer-state payload. This prevents a self-signed announce
// containing a copied public Noise key from replacing a previously bound
// public-message signing identity. Optional for old cache compatibility.
var authenticatedSigningKeysByFingerprint: [String: Data]? = nil
}
//
@@ -140,14 +140,6 @@ protocol SecureIdentityStateManagerProtocol {
func markVouchBatchSent(to fingerprint: String, at date: Date)
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
// MARK: Noise-authenticated announcement identity
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String)
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data?
// MARK: Private-media downgrade protection
func markPrivateMediaCapable(fingerprint: String)
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool
}
/// Singleton manager for secure identity state persistence and retrieval.
@@ -165,7 +157,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
private let queueSpecificKey = DispatchSpecificKey<UInt8>()
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
@@ -223,7 +214,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
self.encryptionKey = loadedKey
self.encryptionKeyIsEphemeral = keyIsEphemeral
queue.setSpecific(key: queueSpecificKey, value: 1)
// Only read the persisted cache when we hold the real key; with an
// ephemeral key the decrypt would fail and discard the real cache.
@@ -380,66 +370,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
}
}
// MARK: - Private-media downgrade protection
func markPrivateMediaCapable(fingerprint: String) {
guard !fingerprint.isEmpty else { return }
let insertAndPersist = {
var pinned = self.cache.privateMediaCapableFingerprints ?? []
guard pinned.insert(fingerprint).inserted else { return }
self.cache.privateMediaCapableFingerprints = pinned
self.saveIdentityCache()
}
// Downgrade decisions can run immediately after an authenticated
// announce. Make the pin visible before returning; merely enqueueing a
// barrier leaves a cross-queue window where a replay can look legacy.
// The queue-specific fast path prevents self-deadlock if a future
// identity-state mutation records the capability from inside `queue`.
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
insertAndPersist()
} else {
queue.sync(flags: .barrier, execute: insertAndPersist)
}
}
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
guard !fingerprint.isEmpty else { return false }
return queue.sync {
cache.privateMediaCapableFingerprints?.contains(fingerprint) == true
}
}
// MARK: - Noise-authenticated announcement identity
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
guard signingPublicKey.count == AuthenticatedPeerStatePacket.signingPublicKeyLength,
!fingerprint.isEmpty else { return }
let bindAndPersist = {
var bindings = self.cache.authenticatedSigningKeysByFingerprint ?? [:]
let bindingChanged = bindings[fingerprint] != signingPublicKey
bindings[fingerprint] = signingPublicKey
self.cache.authenticatedSigningKeysByFingerprint = bindings
if var cryptoIdentity = self.cryptographicIdentities[fingerprint] {
cryptoIdentity.signingPublicKey = signingPublicKey
self.cryptographicIdentities[fingerprint] = cryptoIdentity
}
guard bindingChanged else { return }
self.saveIdentityCache()
}
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
bindAndPersist()
} else {
queue.sync(flags: .barrier, execute: bindAndPersist)
}
}
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
guard !fingerprint.isEmpty else { return nil }
return queue.sync {
cache.authenticatedSigningKeysByFingerprint?[fingerprint]
}
}
func updateSocialIdentity(_ identity: SocialIdentity) {
queue.async(flags: .barrier) {
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -30,7 +30,7 @@ struct NoisePayload {
// Safely get the first byte
let firstByte = data[data.startIndex]
guard let type = NoisePayloadType.decoded(rawValue: firstByte) else {
guard let type = NoisePayloadType(rawValue: firstByte) else {
return nil
}
@@ -6,60 +6,14 @@
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
enum NoiseSecurityConstants {
// Maximum message size to prevent memory exhaustion
static let maxMessageSize = 65535 // 64KB as per Noise spec
/// The extracted transport nonce (4 bytes) and Poly1305 tag (16 bytes)
/// added by `NoiseCipherState` around every transport plaintext.
static let transportCiphertextOverhead = 20
/// Private files are an explicit BitChat extension to the ordinary Noise
/// message-size ceiling. They remain bounded by the same framed-file cap
/// used by the binary and fragment decoders. Only the `.privateFile`
/// typed-payload path is allowed to use this larger budget.
private static let privateFileOuterPacketOverhead =
(BinaryProtocol.v1HeaderSize + 2) // v2 adds two length bytes
+ BinaryProtocol.senderIDSize
+ BinaryProtocol.recipientIDSize
static let maxPrivateFilePlaintextSize = FileTransferLimits.maxFramedFileBytes
- privateFileOuterPacketOverhead
- transportCiphertextOverhead
static let maxPrivateFileCiphertextSize =
maxPrivateFilePlaintextSize + transportCiphertextOverhead
// Maximum handshake message size
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
static let xxInitialMessageSize = 32
// Bounds an ordinary initiator whose message 1 or 2 is lost.
static let ordinaryHandshakeTimeout: TimeInterval = 10
// Bounds the receive-only rollback quarantine created by an unauthenticated
// inbound message 1. A lost message 3 must not strand outbound traffic.
static let ordinaryResponderHandshakeTimeout: TimeInterval = 20
// A released client may immediately retry after both crossed initiators
// yielded. Give that unilateral retry a brief head start before the
// patched side spends its one bounded recovery.
static let handshakeCollisionRecoveryDelay: TimeInterval = 0.2
// Rate-limited recovery remains actionable without spinning.
static let handshakeRateLimitRecoveryDelay: TimeInterval = 60
// Covers only reordering between a winning message 3 and the losing
// crossed message 1.
static let recentInitiatorCompletionGracePeriod: TimeInterval = 1
// After unauthenticated responder rollback, reject another attempt long
// enough that paced message 1 traffic cannot keep outbound paused. A
// legitimate peer converges through the one manager-owned local retry.
static let ordinaryReconnectRollbackCooldown: TimeInterval = 60
// Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours
@@ -14,19 +14,6 @@ struct NoiseSecurityValidator {
static func validateMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxMessageSize
}
static func validateCiphertextSize(_ data: Data) -> Bool {
data.count <= NoiseSecurityConstants.maxMessageSize
+ NoiseSecurityConstants.transportCiphertextOverhead
}
static func validatePrivateFileMessageSize(_ data: Data) -> Bool {
data.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize
}
static func validatePrivateFileCiphertextSize(_ data: Data) -> Bool {
data.count <= NoiseSecurityConstants.maxPrivateFileCiphertextSize
}
/// Validate handshake message size
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
+1 -4
View File
@@ -66,10 +66,7 @@ class NoiseSession {
// Only initiator writes the first message
if role == .initiator {
guard let handshake = handshakeState else {
throw NoiseSessionError.invalidState
}
let message = try handshake.writeMessage()
let message = try handshakeState!.writeMessage()
sentHandshakeMessages.append(message)
return message
} else {
-7
View File
@@ -11,11 +11,4 @@ enum NoiseSessionError: Error, Equatable {
case notEstablished
case sessionNotFound
case alreadyEstablished
case peerIdentityMismatch
}
/// The manager owns the exact attempt's one bounded recovery. Packet handling
/// must not launch its historical second, immediate restart for this failure.
struct NoiseManagedHandshakeFailure: Error {
let underlying: Error
}
File diff suppressed because it is too large Load Diff
+4 -11
View File
@@ -24,12 +24,8 @@ final class SecureNoiseSession: NoiseSession {
throw NoiseSecurityError.sessionExhausted
}
// Ordinary Noise messages keep the protocol ceiling. Finalized media
// is the sole typed-payload extension and remains under the framed-file
// cap enforced again at the service and file-decoder layers.
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: plaintext.first)
&& NoiseSecurityValidator.validatePrivateFileMessageSize(plaintext)
guard NoiseSecurityValidator.validateMessageSize(plaintext) || isPrivateFile else {
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
throw NoiseSecurityError.messageTooLarge
}
@@ -46,11 +42,8 @@ final class SecureNoiseSession: NoiseSession {
throw NoiseSecurityError.sessionExpired
}
// The payload type is encrypted, so a large candidate can only be
// bounded here; `NoiseEncryptionService.decrypt` authenticates it and
// then requires the resulting type to be `.privateFile`.
guard NoiseSecurityValidator.validateCiphertextSize(ciphertext)
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(ciphertext) else {
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
throw NoiseSecurityError.messageTooLarge
}
-19
View File
@@ -700,10 +700,6 @@ struct NostrEvent: Codable {
let content = dict["content"] as? String else {
throw NostrError.invalidEvent
}
guard Self.isWithinInboundTagLimits(tags) else {
throw NostrError.invalidEvent
}
self.id = dict["id"] as? String ?? ""
self.pubkey = pubkey
@@ -713,21 +709,6 @@ struct NostrEvent: Codable {
self.content = content
self.sig = dict["sig"] as? String
}
/// Bounds untrusted relay tag arrays so attackers cannot force large
/// allocations or expensive joins on the inbound hot path.
static func isWithinInboundTagLimits(_ tags: [[String]]) -> Bool {
guard tags.count <= TransportConfig.nostrMaxEventTags else { return false }
for tag in tags {
guard tag.count <= TransportConfig.nostrMaxEventTagValues else { return false }
guard tag.allSatisfy({ $0.utf8.count <= TransportConfig.nostrMaxEventTagValueBytes }) else {
return false
}
}
return true
}
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
let (eventId, eventIdHash) = try calculateEventId()
+5 -13
View File
@@ -1480,7 +1480,7 @@ private enum ParsedInbound {
case notice(String)
init?(_ message: URLSessionWebSocketTask.Message) {
guard let data = message.dataWithinInboundLimit,
guard let data = message.data,
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2,
let type = array[0] as? String else {
@@ -1525,19 +1525,11 @@ private enum ParsedInbound {
}
private extension URLSessionWebSocketTask.Message {
/// Prefer rejecting oversized frames before UTF-8/Data materialization
/// where we can (string length), and always before JSON parse.
var dataWithinInboundLimit: Data? {
let maxBytes = TransportConfig.nostrMaxInboundMessageBytes
var data: Data? {
switch self {
case .string(let text):
guard text.utf8.count <= maxBytes else { return nil }
return text.data(using: .utf8)
case .data(let data):
guard data.count <= maxBytes else { return nil }
return data
@unknown default:
return nil
case .string(let text): text.data(using: .utf8)
case .data(let data): data
@unknown default: nil
}
}
}
-87
View File
@@ -154,90 +154,3 @@ struct BitchatFilePacket {
)
}
}
/// Wire-compatible identity for private media exchanged by clients using the
/// current iOS entropy-bearing filenames, without extending the deployed file
/// TLV. Android clients reject unknown file tags, so eligible senders and
/// receivers derive the receipt key from fields already on the wire.
///
/// Locally-created image and voice-note filenames contain a UUID or live-voice
/// burst ID. Including the normalized direction keeps a reused filename
/// distinct across chats while allowing short and full Noise-key peer IDs to
/// converge. Android and older-iOS timestamp-only names remain ineligible and
/// retain their legacy random local IDs (transfer-compatible, no receipts).
enum PrivateMediaMessageIdentity {
private static let domain = Data("bitchat-private-media-message-v1".utf8)
private static let idPrefix = "media-"
private static let digestHexLength = 32
static func isStableID(_ candidate: String) -> Bool {
guard candidate.hasPrefix(idPrefix) else { return false }
let digest = candidate.dropFirst(idPrefix.count)
guard digest.utf8.count == digestHexLength else { return false }
return digest.utf8.allSatisfy { byte in
(UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte)
|| (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte)
}
}
static func stableID(
senderPeerID: PeerID,
recipientPeerID: PeerID,
fileName: String?
) -> String? {
guard let fileName, !fileName.isEmpty else { return nil }
let leafName = (fileName as NSString).lastPathComponent
guard leafName == fileName else { return nil }
let path = leafName as NSString
let stem = path.deletingPathExtension
let fileExtension = path.pathExtension.lowercased()
switch true {
case stem.hasPrefix("img_"):
guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil }
case stem.hasPrefix("voice_"):
guard fileExtension == "m4a" else { return nil }
default:
return nil
}
let entropyToken = stem.split(separator: "_").last.map(String.init)
let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil
let voiceBurstID = stem.hasPrefix("voice_")
? String(stem.dropFirst("voice_".count))
: ""
let hasBurstEntropy = voiceBurstID.count == 16
&& voiceBurstID.allSatisfy(\.isHexDigit)
guard hasUUIDEntropy || hasBurstEntropy else {
return nil
}
let fields = [
Data(senderPeerID.toShort().bare.utf8),
Data(recipientPeerID.toShort().bare.utf8),
Data(leafName.utf8)
]
var input = domain
for field in fields {
guard let length = UInt32(exactly: field.count) else { return nil }
var bigEndianLength = length.bigEndian
withUnsafeBytes(of: &bigEndianLength) {
input.append(contentsOf: $0)
}
input.append(field)
}
return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))"
}
static func stableID(
for packet: BitchatFilePacket,
senderPeerID: PeerID,
recipientPeerID: PeerID
) -> String? {
stableID(
senderPeerID: senderPeerID,
recipientPeerID: recipientPeerID,
fileName: packet.fileName
)
}
}
-25
View File
@@ -79,35 +79,12 @@ enum NoisePayloadType: UInt8 {
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
// Live voice (push-to-talk)
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
// Finalized private media. `0x20` is the value already deployed by the
// Android client. The complete BitchatFilePacket is encrypted inside
// Noise before the outer noiseEncrypted packet is fragmented.
case privateFile = 0x20
// Versioned peer state authenticated by the surrounding Noise session.
// This is intentionally distinct from the public announce: announce
// capabilities are discovery hints, while this payload proves possession
// of the advertised Noise static key before downgrade state is pinned.
case authenticatedPeerState = 0x21
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
// Transitive verification (web of trust)
case vouch = 0x12 // Batch of vouch attestations
/// #1434 briefly used 0x09 before release. Accept it while prerelease
/// builds age out, but never emit it. Decoders canonicalize both values to
/// `.privateFile` so the compatibility alias cannot leak into app logic.
static let prereleasePrivateFileRawValue: UInt8 = 0x09
static func decoded(rawValue: UInt8) -> NoisePayloadType? {
rawValue == prereleasePrivateFileRawValue ? .privateFile : Self(rawValue: rawValue)
}
static func isPrivateFile(rawValue: UInt8?) -> Bool {
guard let rawValue else { return false }
return rawValue == privateFile.rawValue || rawValue == prereleasePrivateFileRawValue
}
var description: String {
switch self {
case .privateMessage: return "privateMessage"
@@ -116,8 +93,6 @@ enum NoisePayloadType: UInt8 {
case .groupInvite: return "groupInvite"
case .groupKeyUpdate: return "groupKeyUpdate"
case .voiceFrame: return "voiceFrame"
case .privateFile: return "privateFile"
case .authenticatedPeerState: return "authenticatedPeerState"
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
case .vouch: return "vouch"
-83
View File
@@ -156,89 +156,6 @@ struct AnnouncementPacket {
}
}
/// State that is authoritative only because it is carried inside an
/// established Noise session. The public announce remains useful for
/// discovery, but its self-signature cannot prove possession of the copied
/// Noise public key it contains.
///
/// Wire format (v1):
/// `[version=0x01][type][length][value]...`
/// - TLV `0x01`: canonical minimal little-endian `PeerCapabilities`
/// - TLV `0x02`: 32-byte Ed25519 signing public key
///
/// Unknown TLVs are skipped for forward compatibility. Unknown versions,
/// duplicates, non-canonical capability fields, and malformed lengths are
/// rejected without changing authenticated state.
struct AuthenticatedPeerStatePacket: Equatable {
static let currentVersion: UInt8 = 1
static let signingPublicKeyLength = 32
let capabilities: PeerCapabilities
let signingPublicKey: Data
private enum TLVType: UInt8 {
case capabilities = 0x01
case signingPublicKey = 0x02
}
func encode() -> Data? {
guard signingPublicKey.count == Self.signingPublicKeyLength else { return nil }
let capabilityBytes = capabilities.encoded()
guard !capabilityBytes.isEmpty, capabilityBytes.count <= 8 else { return nil }
var data = Data([Self.currentVersion])
data.append(TLVType.capabilities.rawValue)
data.append(UInt8(capabilityBytes.count))
data.append(capabilityBytes)
data.append(TLVType.signingPublicKey.rawValue)
data.append(UInt8(signingPublicKey.count))
data.append(signingPublicKey)
return data
}
static func decode(from data: Data) -> AuthenticatedPeerStatePacket? {
guard data.first == Self.currentVersion else { return nil }
var offset = 1
var capabilities: PeerCapabilities?
var signingPublicKey: Data?
while offset < data.count {
guard offset + 2 <= data.count else { return nil }
let typeRaw = data[offset]
let length = Int(data[offset + 1])
offset += 2
guard offset + length <= data.count else { return nil }
let value = Data(data[offset..<(offset + length)])
offset += length
guard let type = TLVType(rawValue: typeRaw) else {
continue
}
switch type {
case .capabilities:
guard capabilities == nil,
!value.isEmpty,
value.count <= 8 else { return nil }
let decoded = PeerCapabilities(encoded: value)
guard decoded.encoded() == value else { return nil }
capabilities = decoded
case .signingPublicKey:
guard signingPublicKey == nil,
value.count == Self.signingPublicKeyLength else { return nil }
signingPublicKey = value
}
}
guard let capabilities, let signingPublicKey else { return nil }
return AuthenticatedPeerStatePacket(
capabilities: capabilities,
signingPublicKey: signingPublicKey
)
}
}
struct PrivateMessagePacket {
let messageID: String
let content: String
@@ -3,11 +3,5 @@ import BitFoundation
extension PeerCapabilities {
/// Capabilities this build advertises in its announce packets.
/// Each feature adds its bit here when it ships.
static let localSupported: PeerCapabilities = [
.vouch,
.prekeys,
.groups,
.privateMedia,
.privateMediaReceipts
]
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
}
+1 -14
View File
@@ -16,9 +16,6 @@ struct BLEAnnounceHandlerEnvironment {
let now: () -> Date
/// Noise public key already recorded for the peer, if any (registry read).
let existingNoisePublicKey: (PeerID) -> Data?
/// Ed25519 key previously bound to this Noise identity by an authenticated
/// peer-state payload, if any (persistent identity-state read).
let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data?
/// Verifies the packet signature against the announced signing key.
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Direct link state for the peer (BLE-queue read).
@@ -133,21 +130,11 @@ final class BLEAnnounceHandler {
hasSignature: hasSignature,
signatureValid: signatureValid,
existingNoisePublicKey: existingNoisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey,
authenticatedSigningPublicKey: env.authenticatedSigningPublicKey(
announcement.noisePublicKey
),
announcedSigningPublicKey: announcement.signingPublicKey
announcedNoisePublicKey: announcement.noisePublicKey
)
if case .reject(.keyMismatch) = trustDecision {
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
}
if case .reject(.authenticatedSigningKeyMismatch) = trustDecision {
SecureLogger.warning(
"⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))",
category: .security
)
}
let verifiedAnnounce = trustDecision.isVerified
var isNewPeer = false
@@ -56,7 +56,6 @@ enum BLEAnnounceTrustRejection: Equatable {
case missingSignature
case invalidSignature
case keyMismatch
case authenticatedSigningKeyMismatch
}
enum BLEAnnounceTrustDecision: Equatable {
@@ -73,19 +72,12 @@ enum BLEAnnounceTrustPolicy {
hasSignature: Bool,
signatureValid: Bool,
existingNoisePublicKey: Data?,
announcedNoisePublicKey: Data,
authenticatedSigningPublicKey: Data? = nil,
announcedSigningPublicKey: Data? = nil
announcedNoisePublicKey: Data
) -> BLEAnnounceTrustDecision {
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
return .reject(.keyMismatch)
}
if let authenticatedSigningPublicKey,
announcedSigningPublicKey != authenticatedSigningPublicKey {
return .reject(.authenticatedSigningKeyMismatch)
}
guard hasSignature else {
return .reject(.missingSignature)
}
+74 -361
View File
@@ -16,8 +16,6 @@ struct BLEFileTransferHandlerEnvironment {
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
/// Verifies a packet's signature against a candidate signing key (registry path).
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Local signing key used to authenticate our own gossip-sync replays.
let localSigningPublicKey: () -> Data
/// Resolves a display name from a verified packet signature for peers missing from the registry.
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
/// Tracks the broadcast file packet for gossip sync.
@@ -32,79 +30,10 @@ struct BLEFileTransferHandlerEnvironment {
_ fallbackExtension: String?,
_ defaultPrefix: String
) -> URL?
/// Resolves the durable receiver decision for a stable private-media ID.
let privateMediaReceiptState: (
_ messageID: String
) -> BLEPrivateMediaReceiptState
/// Atomically records a stable private-media ID after the payload save.
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
/// Rolls back a saved payload when its durable receipt commit fails.
let removeIncomingFile: (_ storedURL: URL) -> Void
/// Checks the authenticated sender before any private-media disk work.
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
/// Updates the registry last-seen timestamp for the peer (async barrier write).
let updatePeerLastSeen: (PeerID) -> Void
/// Acknowledges stable private media only after its synchronous
/// conversation delivery has completed.
let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void
/// Delivers `.messageReceived` as one main-actor hop while
/// `shouldDeliver` remains true before and after the synchronous sink.
/// The completion authorizes the stable-media ACK.
let deliverMessage: (
_ message: BitchatMessage,
_ shouldDeliver: @escaping () -> Bool,
_ completion: @escaping () -> Void
) -> Void
}
/// Process-lifetime reservation cache for stable private-media IDs.
///
/// The first arrival reserves its ID before quota enforcement. Concurrent
/// arrivals remain coalesced in memory, while accepted state is resolved from
/// the durable ID-to-file ledger so it survives relaunch and becomes retryable
/// if quota cleanup removed the file.
private final class PrivateMediaArrivalDeduplicator {
enum Reservation {
case reserved
case pending
case accepted(URL)
case tombstoned
case unavailable
}
private let lock = NSLock()
private var pending: Set<String> = []
func reserve(
_ messageID: String,
receiptState: () -> BLEPrivateMediaReceiptState
) -> Reservation {
lock.lock()
defer { lock.unlock() }
if pending.contains(messageID) {
return .pending
}
switch receiptState() {
case .accepted(let existingURL):
return .accepted(existingURL)
case .tombstoned:
return .tombstoned
case .unavailable:
return .unavailable
case .absent:
break
}
pending.insert(messageID)
return .reserved
}
func finish(_ messageID: String) {
lock.lock()
defer { lock.unlock() }
pending.remove(messageID)
}
/// Delivers `.messageReceived` to the UI as one main-actor hop.
let deliverMessage: (BitchatMessage) -> Void
}
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
@@ -112,204 +41,61 @@ private final class PrivateMediaArrivalDeduplicator {
/// and UI delivery.
final class BLEFileTransferHandler {
private let environment: BLEFileTransferHandlerEnvironment
private let privateMediaArrivals = PrivateMediaArrivalDeduplicator()
init(environment: BLEFileTransferHandlerEnvironment) {
self.environment = environment
}
/// Returns `false` when the raw packet fails sender authentication (or is
/// a live self-echo) and must not be relayed onward. Authentication runs
/// before the routing decision, so a forged directed packet cannot use a
/// node that is not its recipient as an unsigned forwarding hop.
/// Returns `false` when the packet fails sender authentication and must
/// not be relayed onward. Every other outcome returns `true`: files
/// directed to another peer are forwarded untouched, and local-only drops
/// (malformed payload, quota, save failure) don't affect multi-hop
/// delivery to nodes that may handle them fine.
@discardableResult
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
let env = environment
let localPeerID = env.localPeerID()
let peersSnapshot = env.peersSnapshot()
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true }
guard let senderNickname = authenticatedRawSenderNickname(
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
return true
}
let peersSnapshot = env.peersSnapshot()
guard let senderNickname = resolveSenderNickname(
packet: packet,
from: peerID,
isBroadcast: !deliveryPlan.isPrivateMessage,
peers: peersSnapshot,
env: env
) else {
SecureLogger.warning("🚫 Dropping raw file transfer with missing/invalid signature from \(peerID.id.prefix(8))", category: .security)
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return false
}
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: localPeerID) {
return false
}
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: localPeerID) else {
return true
}
if deliveryPlan.shouldTrackForSync {
env.trackPacketSeen(packet)
}
_ = storeIncomingPayload(
packet.payload,
from: peerID,
senderNickname: senderNickname,
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
isPrivate: deliveryPlan.isPrivateMessage,
usesDurableReceipts: false,
env: env
)
// Once authenticated, a local decode/quota/save failure is not proof
// that downstream nodes should be denied the valid signed packet.
return true
}
/// Accepts a file packet only after it has been authenticated and
/// decrypted by the peer's Noise session. The inner packet deliberately
/// has no redundant signature: Noise supplies sender authentication and
/// confidentiality, while this handler retains the same validation,
/// quota, persistence, and UI-delivery behavior as public files.
@discardableResult
func handlePrivatePayload(_ payload: Data, from peerID: PeerID, timestamp: Date) -> Bool {
let env = environment
let peers = env.peersSnapshot()
let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peers,
allowConnectedUnverified: true
) ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
return storeIncomingPayload(
payload,
from: peerID,
senderNickname: senderNickname,
timestamp: timestamp,
isPrivate: true,
// Every authenticated Noise private-file keeps the stable ID/ACK
// contract introduced with capability bit 8. Bit 9 advertises
// sender-side automatic retry support; it must not downgrade
// prior iOS clients to random IDs or single-check delivery.
usesDurableReceipts: true,
env: env
)
}
private func storeIncomingPayload(
_ payload: Data,
from peerID: PeerID,
senderNickname: String,
timestamp: Date,
isPrivate: Bool,
usesDurableReceipts: Bool,
env: BLEFileTransferHandlerEnvironment
) -> Bool {
let localPeerID = env.localPeerID()
let filePacket: BitchatFilePacket
let mime: MimeType
switch BLEIncomingFileValidator.validate(payload: payload) {
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
case .success(let acceptance):
filePacket = acceptance.filePacket
mime = acceptance.mime
case .failure(.malformedPayload):
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
return false
return true
case .failure(.payloadTooLarge(let bytes)):
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
return false
return true
case .failure(.unsupportedMime(let mimeType, let bytes)):
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
return false
return true
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
return false
}
if isPrivate, env.isPrivateMediaSenderBlocked(peerID) {
SecureLogger.debug(
"🚫 Dropping private media from blocked peer \(peerID.id.prefix(8))… before disk write",
category: .security
)
return true
}
let messageID = usesDurableReceipts
? PrivateMediaMessageIdentity.stableID(
for: filePacket,
senderPeerID: peerID,
recipientPeerID: localPeerID
)
: nil
if let messageID {
switch privateMediaArrivals.reserve(
messageID,
receiptState: { env.privateMediaReceiptState(messageID) }
) {
case .reserved:
break
case .pending:
// The first arrival has not reached durable storage yet.
// Coalesce this retry without ACKing so a failed first save
// remains retryable by the sender.
SecureLogger.debug(
"📁 Coalesced in-flight private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))",
category: .session
)
return true
case .accepted(let existingFile):
env.updatePeerLastSeen(peerID)
let message = incomingMessage(
messageID: messageID,
senderNickname: senderNickname,
timestamp: timestamp,
isPrivate: true,
peerID: peerID,
destination: existingFile,
category: storedMediaCategory(
for: existingFile,
fallback: mime.category
),
env: env
)
SecureLogger.debug(
"📁 Restored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))… -> \(existingFile.lastPathComponent)",
category: .session
)
deliverStableMessage(
message,
messageID: messageID,
peerID: peerID,
expectedURL: existingFile,
env: env
)
return true
case .tombstoned:
// Explicit deletion is a durable terminal receiver decision.
env.updatePeerLastSeen(peerID)
env.acknowledgePrivateMedia(messageID, peerID)
SecureLogger.debug(
"📁 Dropped explicitly deleted private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))",
category: .session
)
return true
case .unavailable:
// Never turn an unreadable ledger into an empty ledger. The
// sender can retry after the transient storage failure clears.
SecureLogger.warning(
"📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable",
category: .session
)
return true
}
}
defer {
if let messageID {
privateMediaArrivals.finish(messageID)
}
}
// BCH-01-002: Enforce storage quota before saving
env.enforceStorageQuota(filePacket.content.count)
@@ -320,155 +106,82 @@ final class BLEFileTransferHandler {
mime.defaultExtension,
mime.category.rawValue
) else {
return false
return true
}
if let messageID,
!env.commitPrivateMediaFile(messageID, destination) {
// A payload without its durable ID mapping cannot safely suppress
// a retry after relaunch. Roll it back and withhold UI/ACK.
env.removeIncomingFile(destination)
return false
}
if isPrivate {
if deliveryPlan.isPrivateMessage {
env.updatePeerLastSeen(peerID)
}
let message = incomingMessage(
messageID: messageID,
senderNickname: senderNickname,
timestamp: timestamp,
isPrivate: isPrivate,
peerID: peerID,
destination: destination,
category: mime.category,
env: env
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
let message = BitchatMessage(
sender: senderNickname,
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
timestamp: ts,
isRelay: false,
originalSender: nil,
isPrivate: deliveryPlan.isPrivateMessage,
recipientNickname: nil,
senderPeerID: peerID,
// Received messages need an explicit status: BitchatMessage
// defaults private messages to .sending, which the media views
// render as an in-flight send (empty reveal mask, disabled tap).
deliveryStatus: deliveryPlan.isPrivateMessage
? .delivered(to: env.localNickname(), at: ts)
: nil
)
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
if let messageID {
deliverStableMessage(
message,
messageID: messageID,
peerID: peerID,
expectedURL: destination,
env: env
)
} else {
env.deliverMessage(message, { true }, {})
}
env.deliverMessage(message)
return true
}
private func deliverStableMessage(
_ message: BitchatMessage,
messageID: String,
peerID: PeerID,
expectedURL: URL,
env: BLEFileTransferHandlerEnvironment
) {
env.deliverMessage(
message,
{
guard case .accepted(let resolvedURL) =
env.privateMediaReceiptState(messageID) else {
return false
}
return resolvedURL.standardizedFileURL
== expectedURL.standardizedFileURL
},
{
env.acknowledgePrivateMedia(messageID, peerID)
}
)
}
private func incomingMessage(
messageID: String?,
senderNickname: String,
timestamp: Date,
isPrivate: Bool,
peerID: PeerID,
destination: URL,
category: MimeType.Category,
env: BLEFileTransferHandlerEnvironment
) -> BitchatMessage {
BitchatMessage(
id: messageID,
sender: senderNickname,
content: "\(category.messagePrefix)\(destination.lastPathComponent)",
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: isPrivate,
recipientNickname: nil,
senderPeerID: peerID,
// Received messages need an explicit status: BitchatMessage
// defaults private messages to .sending, which media views render
// as an in-flight send.
deliveryStatus: isPrivate
? .delivered(to: env.localNickname(), at: timestamp)
: nil
)
}
/// The durable URL is authoritative during reconstruction. A sender that
/// reuses a stable filename with a different MIME type must not change how
/// the already-stored payload renders.
private func storedMediaCategory(
for url: URL,
fallback: MimeType.Category
) -> MimeType.Category {
let mediaDirectory = url
.deletingLastPathComponent()
.deletingLastPathComponent()
.lastPathComponent
switch mediaDirectory {
case MimeType.Category.audio.mediaDir:
return .audio
case MimeType.Category.image.mediaDir:
return .image
case MimeType.Category.file.mediaDir:
return .file
default:
return fallback
}
}
/// Every remaining raw file transfer is signed, regardless of whether it
/// is broadcast, addressed to us, or merely passing through. Registry
/// signing keys are preferred; persisted identities cover peers that have
/// rotated or are not currently present in the registry.
private func authenticatedRawSenderNickname(
/// Resolves the authenticated display name for a file transfer's sender.
///
/// Directed (private) transfers are addressed to us specifically and keep
/// the lenient connected-peer path. Broadcast transfers carry an
/// attacker-controllable `senderID` exactly like public messages and public
/// voice frames registry membership alone is NOT proof of identity, so a
/// valid packet signature from the claimed sender is required before we
/// trust it. Without this, a peer that observed a public voice burst could
/// spoof a broadcast `voice_<burstID>.m4a` note under the talker's ID and
/// overwrite the signature-verified live bubble with attacker audio.
private func resolveSenderNickname(
packet: BitchatPacket,
from peerID: PeerID,
isBroadcast: Bool,
peers: [PeerID: BLEPeerInfo],
env: BLEFileTransferHandlerEnvironment
) -> String? {
guard packet.signature != nil else { return nil }
guard isBroadcast else {
return BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peers,
allowConnectedUnverified: true
) ?? env.signedSenderDisplayName(packet, peerID)
}
let localPeerID = env.localPeerID()
let candidateKey = peerID == localPeerID
? env.localSigningPublicKey()
: peers[peerID]?.signingPublicKey
let verifiedWithKnownKey = candidateKey.map {
env.verifyPacketSignature(packet, $0)
} ?? false
let signedDisplayName = verifiedWithKnownKey
? nil
: env.signedSenderDisplayName(packet, peerID)
guard verifiedWithKnownKey || signedDisplayName != nil else { return nil }
// Our own broadcasts replayed back via gossip sync (ttl==0) are
// trivially authentic and cannot be verified against the peer registry
// or identity cache, so exempt self exactly as `BLEPublicMessageHandler`
// does. Verify against the signing key already in the
// (synchronously-updated) registry first, then fall back to the
// persisted-identity signature lookup for peers not yet cached there.
let isSelf = peerID == env.localPeerID()
let registrySigningKey = peers[peerID]?.signingPublicKey
let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil }
return BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: localPeerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peers,
// The packet signature authenticates the announced peer; the old
// connected-but-unsigned leniency is not involved.
allowConnectedUnverified: true
) ?? signedDisplayName ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
allowConnectedUnverified: false
) ?? signedDisplayName
}
}
@@ -201,11 +201,8 @@ struct BLEFragmentAssemblyBuffer {
}
private static func assemblyLimit(for originalType: UInt8) -> Int {
if originalType == MessageType.fileTransfer.rawValue
|| originalType == MessageType.noiseEncrypted.rawValue {
if originalType == MessageType.fileTransfer.rawValue {
// Allow headroom for TLV metadata and binary framing overhead.
// A large noiseEncrypted packet can be an E2E-encrypted private
// file; its authenticated plaintext is validated after decrypt.
return FileTransferLimits.maxFramedFileBytes
}
+5 -277
View File
@@ -2,124 +2,8 @@ import BitLogger
import BitFoundation
import Foundation
struct PanicRecoveryIntent {
let fileMarkerEstablished: Bool
let externalMarkerEstablished: Bool
var hasDurableMarker: Bool {
fileMarkerEstablished || externalMarkerEstablished
}
}
/// Small, dependency-injectable transaction surface used by ChatViewModel.
/// Production persists the same intent in two independent locations before
/// any application state is erased. Tests can inject an ephemeral operation
/// set without touching the developer's Application Support directory.
struct PanicRecoveryOperations {
let isPending: () throws -> Bool
let begin: () -> PanicRecoveryIntent
let wipeMedia: (PanicRecoveryIntent) throws -> Void
let complete: () throws -> Void
static func ephemeral(
wipeMedia: @escaping () throws -> Void = {}
) -> PanicRecoveryOperations {
PanicRecoveryOperations(
isPending: { false },
begin: {
PanicRecoveryIntent(
fileMarkerEstablished: false,
externalMarkerEstablished: false
)
},
wipeMedia: { _ in try wipeMedia() },
complete: {}
)
}
static func live(
fileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
defaults: UserDefaults = .standard
) -> PanicRecoveryOperations {
let defaultsKey = "bitchat.panicResetPending"
return PanicRecoveryOperations(
isPending: {
if defaults.bool(forKey: defaultsKey) {
return true
}
return try fileStore.isPanicRecoveryPending()
},
begin: {
defaults.set(true, forKey: defaultsKey)
let externalMarkerEstablished =
defaults.synchronize()
&& defaults.bool(forKey: defaultsKey)
let fileMarkerEstablished: Bool
do {
try fileStore.markPanicRecoveryPending()
fileMarkerEstablished = true
} catch {
fileMarkerEstablished = false
SecureLogger.error(
"Failed to persist file panic-recovery marker: \(error)",
category: .security
)
}
return PanicRecoveryIntent(
fileMarkerEstablished: fileMarkerEstablished,
externalMarkerEstablished: externalMarkerEstablished
)
},
wipeMedia: { intent in
try fileStore.panicWipe(
hasDurablePendingMarker: intent.hasDurableMarker
)
},
complete: {
// Keep the independent defaults latch until the file marker
// has definitely cleared. Any failure therefore remains
// visible to the next launch.
try fileStore.completePanicRecovery()
defaults.removeObject(forKey: defaultsKey)
guard defaults.synchronize(),
!defaults.bool(forKey: defaultsKey) else {
throw BLEIncomingFileStore.PanicRecoveryError
.externalMarkerCommitFailed
}
}
)
}
}
struct BLEIncomingFileStore {
enum PanicRecoveryError: Error {
case externalMarkerCommitFailed
case markerWriteFailed(Error)
case markerWriteAndMediaWipeFailed(
markerError: Error,
mediaError: Error
)
}
private static let quotaBytes: Int64 = 100 * 1024 * 1024
/// Kept outside `files/` so deleting the media tree cannot erase the
/// fail-closed startup decision before the full panic has committed.
private static let panicRecoveryPendingMarkerFileName =
".panic-recovery-pending"
/// Compatibility with a short-lived development build that used the
/// media-specific name for the same full-transaction latch.
private static let legacyPanicRecoveryPendingMarkerFileName =
".panic-media-wipe-pending"
private static let mediaSubdirectories = [
"voicenotes/incoming",
"voicenotes/outgoing",
"images/incoming",
"images/outgoing",
"files/incoming",
"files/outgoing"
]
/// Name prefix of in-flight live voice captures (progressively written by
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern
@@ -133,107 +17,11 @@ struct BLEIncomingFileStore {
let fileManager: FileManager
private let baseDirectory: URL?
private let dateProvider: () -> Date
private let panicMarkerWriter: (Data, URL) throws -> Void
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
init(
fileManager: FileManager = .default,
baseDirectory: URL? = nil,
dateProvider: @escaping () -> Date = Date.init,
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
try $0.write(to: $1, options: .atomic)
}
) {
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
self.fileManager = fileManager
self.baseDirectory = baseDirectory
self.dateProvider = dateProvider
self.panicMarkerWriter = panicMarkerWriter
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
fileManager: fileManager,
baseDirectory: baseDirectory,
now: dateProvider
)
}
/// Panic-wipe every managed incoming and outgoing media artifact before
/// returning. Recreating the directory tree keeps later capture/receive
/// paths usable without allowing a detached cleanup task to race them.
///
/// Marker persistence and deletion are deliberately separate error
/// domains: even when both durable marker channels fail, deletion is
/// still attempted before this method reports the marker failure.
func panicWipe(
hasDurablePendingMarker: Bool = false
) throws {
// The receipt index caches tombstones as well as accepted payloads.
// Always invalidate it on return, including partial-failure paths, so
// no pre-panic receiver decision survives after identity reset.
defer { privateMediaReceipts.resetForPanic() }
let markerError: Error?
do {
try markPanicRecoveryPending()
markerError = nil
} catch {
markerError = error
SecureLogger.error(
"Could not persist file panic-recovery marker; attempting media deletion anyway: \(error)",
category: .security
)
}
do {
let filesDirectory = try rootDirectory()
.appendingPathComponent("files", isDirectory: true)
if fileManager.fileExists(atPath: filesDirectory.path) {
try fileManager.removeItem(at: filesDirectory)
}
for subdirectory in Self.mediaSubdirectories {
try fileManager.createDirectory(
at: filesDirectory.appendingPathComponent(
subdirectory,
isDirectory: true
),
withIntermediateDirectories: true,
attributes: nil
)
}
} catch {
if let markerError {
throw PanicRecoveryError.markerWriteAndMediaWipeFailed(
markerError: markerError,
mediaError: error
)
}
throw error
}
if let markerError, !hasDurablePendingMarker {
throw PanicRecoveryError.markerWriteFailed(markerError)
}
}
func markPanicRecoveryPending() throws {
let markerURL = try panicRecoveryPendingMarkerURL()
try fileManager.createDirectory(
at: markerURL.deletingLastPathComponent(),
withIntermediateDirectories: true,
attributes: nil
)
try panicMarkerWriter(Data([1]), markerURL)
}
func isPanicRecoveryPending() throws -> Bool {
try panicRecoveryMarkerURLs().contains {
fileManager.fileExists(atPath: $0.path)
}
}
func completePanicRecovery() throws {
for markerURL in try panicRecoveryMarkerURLs()
where fileManager.fileExists(atPath: markerURL.path) {
try fileManager.removeItem(at: markerURL)
}
}
/// Resolves (and creates) an incoming-media directory for callers that
@@ -268,35 +56,6 @@ struct BLEIncomingFileStore {
}
}
func privateMediaReceiptState(
messageID: String
) -> BLEPrivateMediaReceiptState {
privateMediaReceipts.state(for: messageID)
}
func commitPrivateMediaFile(
messageID: String,
storedURL: URL
) -> Bool {
privateMediaReceipts.commitAccepted(
messageID: messageID,
storedURL: storedURL
)
}
/// Best-effort rollback for a payload whose durable receipt commit failed.
func removeIncomingFile(at storedURL: URL) {
guard isURLInsideFilesDirectory(storedURL) else { return }
do {
try fileManager.removeItem(at: storedURL)
} catch {
SecureLogger.warning(
"⚠️ Failed to roll back uncommitted incoming media: \(error)",
category: .session
)
}
}
/// Frees least-recently-modified incoming files until `reservingBytes`
/// fits under the quota. Files named `voice_live_*` (in-flight live
/// captures) are never evicted regardless of who triggers enforcement
@@ -354,46 +113,15 @@ struct BLEIncomingFileStore {
}
private func filesDirectory() throws -> URL {
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
return filesDir
}
private func rootDirectory() throws -> URL {
try baseDirectory ?? fileManager.url(
let root = try baseDirectory ?? fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
}
private func panicRecoveryPendingMarkerURL() throws -> URL {
try rootDirectory().appendingPathComponent(
Self.panicRecoveryPendingMarkerFileName,
isDirectory: false
)
}
private func panicRecoveryMarkerURLs() throws -> [URL] {
let root = try rootDirectory()
return [
root.appendingPathComponent(
Self.panicRecoveryPendingMarkerFileName,
isDirectory: false
),
root.appendingPathComponent(
Self.legacyPanicRecoveryPendingMarkerFileName,
isDirectory: false
)
]
}
private func isURLInsideFilesDirectory(_ url: URL) -> Bool {
guard let filesDirectory = try? filesDirectory().standardizedFileURL else {
return false
}
return url.standardizedFileURL.path.hasPrefix(filesDirectory.path + "/")
let filesDir = root.appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
return filesDir
}
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
+11 -323
View File
@@ -1,18 +1,7 @@
import BitFoundation
import BitLogger
import CryptoKit
import Foundation
struct BLENoiseHandshakeHandlingResult {
let processed: Bool
let didEstablishAuthenticatedSession: Bool
}
struct BLENoiseDecryptionResult {
let plaintext: Data
let sessionGeneration: UUID
}
/// Narrow environment for `BLENoisePacketHandler`.
///
/// All queue hops (collections barrier writes, main-actor UI notification)
@@ -27,15 +16,10 @@ struct BLENoisePacketHandlerEnvironment {
let messageTTL: UInt8
/// Current time source.
let now: () -> Date
/// Processes an inbound handshake message, returning its optional response
/// and whether that exact candidate authenticated (crypto).
let processHandshakeMessage:
(_ peerID: PeerID, _ message: Data) throws
-> NoiseHandshakeProcessingResult
/// Processes an inbound handshake message, returning an optional response payload (crypto).
let processHandshakeMessage: (_ peerID: PeerID, _ message: Data) throws -> Data?
/// Whether any Noise session (established or pending) exists for the peer (crypto).
let hasNoiseSession: (PeerID) -> Bool
/// Whether an inbound ordinary XX responder is waiting for message 3.
let isAwaitingResponderHandshakeCompletion: (PeerID) -> Bool
/// Initiates a fresh Noise handshake with the peer (crypto + send).
let initiateHandshake: (PeerID) -> Void
/// Broadcasts a packet on the mesh (caller is already on the message queue).
@@ -43,16 +27,9 @@ struct BLENoisePacketHandlerEnvironment {
/// Updates the registry last-seen timestamp for the peer (async barrier write).
let updatePeerLastSeen: (PeerID) -> Void
/// Decrypts an encrypted payload from the peer (crypto).
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> BLENoiseDecryptionResult
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data
/// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
let clearSession: (PeerID) -> Void
/// Consumes session-authenticated protocol state inside the transport. It
/// must never escape to UI or Nostr payload dispatch.
let handleAuthenticatedPeerState: (
_ peerID: PeerID,
_ payload: Data,
_ sessionGeneration: UUID
) -> Void
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
let deliverNoisePayload: (
_ peerID: PeerID,
@@ -66,55 +43,19 @@ struct BLENoisePacketHandlerEnvironment {
/// processing (with response), encrypted payload decryption and dispatch,
/// and session recovery on decrypt failure.
final class BLENoisePacketHandler {
private struct DeferredCiphertext {
let packet: BitchatPacket
let receivedAt: Date
}
/// Early post-handshake packets are normally tiny control messages or
/// queued DMs. Keep the recovery surface deliberately small so an
/// unauthenticated half-handshake cannot create an unbounded memory queue.
private static let maxDeferredPacketsPerPeer = 4
private static let maxDeferredPacketsGlobal = 32
/// One legacy sender can immediately follow message 3 with the largest
/// valid private-file ciphertext and has no application-level retry. Keep
/// room for that packet plus a small control-message budget.
private static let maxDeferredBytes =
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 256 * 1024
private static let deferredLifetime =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
private let environment: BLENoisePacketHandlerEnvironment
private let deferredLock = NSLock()
private var deferredCiphertexts: [PeerID: [DeferredCiphertext]] = [:]
private var deferredCiphertextBytes = 0
init(environment: BLENoisePacketHandlerEnvironment) {
self.environment = environment
}
/// Returns true when the handshake message was processed successfully.
/// Callers use this to distinguish an authenticated reconnect completion
/// from a rejected ordinary responder while rollback state is restored.
@discardableResult
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
handleHandshakeWithResult(packet, from: peerID).processed
}
func handleHandshakeWithResult(
_ packet: BitchatPacket,
from peerID: PeerID
) -> BLENoiseHandshakeHandlingResult {
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
// Use NoiseEncryptionService for handshake processing
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
// Handshake is for us
do {
let result = try env.processHandshakeMessage(
peerID,
packet.payload
)
if let response = result.response {
if let response = try env.processHandshakeMessage(peerID, packet.payload) {
// Send response
let responsePacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
@@ -129,76 +70,19 @@ final class BLENoisePacketHandler {
env.broadcastPacket(responsePacket)
}
// The serialized authentication callback installs transport
// state before it drains any bounded early ciphertext.
return BLENoiseHandshakeHandlingResult(
processed: true,
didEstablishAuthenticatedSession:
result.didEstablishAuthenticatedSession
)
} catch let managedFailure as NoiseManagedHandshakeFailure {
SecureLogger.error(
"Failed to process handshake; manager owns recovery: \(managedFailure.underlying)"
)
return BLENoiseHandshakeHandlingResult(
processed: false,
didEstablishAuthenticatedSession: false
)
} catch NoiseSessionError.peerIdentityMismatch {
// The responder was already discarded by the session manager.
// Do not let a spoofed claimed ID trigger a fresh outbound
// handshake or recreate state for the attacker-selected ID.
SecureLogger.warning(
"Rejected Noise handshake whose static key does not match \(peerID.id.prefix(8))",
category: .security
)
return BLENoiseHandshakeHandlingResult(
processed: false,
didEstablishAuthenticatedSession: false
)
// Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time
} catch {
SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
return BLENoiseHandshakeHandlingResult(
processed: false,
didEstablishAuthenticatedSession: false
)
}
}
return BLENoiseHandshakeHandlingResult(
processed: false,
didEstablishAuthenticatedSession: false
)
}
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
handleEncrypted(packet, from: peerID, isDeferredRetry: false)
}
/// Called by the transport's serialized authentication callback after it
/// has installed state for the promoted or restored session generation.
func handleSessionAuthenticated(_ peerID: PeerID) {
drainDeferredCiphertextsIfReady(for: peerID)
}
/// Synchronously discards ciphertext retained for a pre-panic Noise
/// generation. The handler survives the service's identity replacement,
/// so keeping this queue would replay old bytes after post-panic auth.
func resetForPanic() {
deferredLock.lock()
deferredCiphertexts.removeAll(keepingCapacity: false)
deferredCiphertextBytes = 0
deferredLock.unlock()
}
private func handleEncrypted(
_ packet: BitchatPacket,
from peerID: PeerID,
isDeferredRetry: Bool
) {
let env = environment
guard let recipientID = PeerID(hexData: packet.recipientID) else {
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
@@ -214,231 +98,35 @@ final class BLENoisePacketHandler {
env.updatePeerLastSeen(peerID)
do {
let decryption = try env.decrypt(packet.payload, peerID)
let decrypted = decryption.plaintext
let decrypted = try env.decrypt(packet.payload, peerID)
guard decrypted.count > 0 else { return }
// First byte indicates the payload type
let payloadType = decrypted[0]
let payloadData = decrypted.dropFirst()
guard let noisePayloadType = NoisePayloadType.decoded(rawValue: payloadType) else {
guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else {
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
return
}
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))", category: .session)
if noisePayloadType == .authenticatedPeerState {
env.handleAuthenticatedPeerState(
peerID,
Data(payloadData),
decryption.sessionGeneration
)
return
}
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
} catch NoiseEncryptionError.transportGenerationNotReady {
if isDeferredRetry {
SecureLogger.warning(
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because its authenticated transport generation changed again",
category: .session
)
return
}
// The manager promoted or restored keys before BLE's serialized
// callback installed generation-bound transport state. The
// manager rejected this before decrypting, so replay is safe.
deferCiphertext(packet, from: peerID)
} catch NoiseEncryptionError.sessionNotEstablished {
if isDeferredRetry {
SecureLogger.warning(
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because the authenticated session is unavailable",
category: .session
)
return
}
// We received an encrypted message before establishing a session with this peer.
// An initiator may already have sent message 3 followed by this
// ciphertext, with BLE delivering the ciphertext first.
if env.isAwaitingResponderHandshakeCompletion(peerID) {
deferCiphertext(packet, from: peerID)
return
}
// Otherwise trigger a handshake so future messages can decrypt.
// Trigger a handshake so future messages can be decrypted.
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
} catch {
if isDeferredRetry {
// An early packet cannot tear down the authenticated session
// merely because its single bounded retry still fails.
SecureLogger.warning(
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… after retry failed: \(error)",
category: .session
)
return
}
// A responder may retain an older transport as receive-only
// rollback state while ordinary XX waits for message 3. New-key
// ciphertext can fail against those retained receive keys first.
if env.isAwaitingResponderHandshakeCompletion(peerID) {
if isDeferrableEarlyHandshakeFailure(error) {
deferCiphertext(packet, from: peerID)
} else {
SecureLogger.warning(
"Dropping invalid Noise ciphertext from \(peerID.id.prefix(8))… while responder handshake is completing: \(error)",
category: .session
)
}
return
}
if isDropOnlyCiphertextFailure(error) {
// The packet is attacker-controlled and did not prove a
// transport-state failure. Never let malformed, replayed,
// forged, oversized, or rate-limited bytes evict working keys.
SecureLogger.warning(
"Dropping rejected Noise ciphertext from \(peerID.id.prefix(8))… without clearing its session: \(error)",
category: .security
)
return
}
// Decryption failed - clear the corrupted session and re-initiate handshake
// Only local/session lifecycle failures reach this path.
// This handles cases where session state got out of sync (nonce mismatch, etc.)
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
env.clearSession(peerID)
env.initiateHandshake(peerID)
}
}
private func isDeferrableEarlyHandshakeFailure(_ error: Error) -> Bool {
if let noiseError = error as? NoiseError {
switch noiseError {
case .authenticationFailure, .replayDetected:
return true
default:
return false
}
}
if let cryptoError = error as? CryptoKitError,
case .authenticationFailure = cryptoError {
return true
}
return false
}
private func isDropOnlyCiphertextFailure(_ error: Error) -> Bool {
if let securityError = error as? NoiseSecurityError {
switch securityError {
case .messageTooLarge, .rateLimitExceeded, .invalidPeerID:
return true
case .sessionExpired, .sessionExhausted:
return false
}
}
if let noiseError = error as? NoiseError {
switch noiseError {
case .invalidCiphertext, .authenticationFailure, .replayDetected:
return true
case .uninitializedCipher, .handshakeComplete,
.handshakeNotComplete, .missingLocalStaticKey,
.missingKeys, .invalidMessage, .invalidPublicKey,
.nonceExceeded:
return false
}
}
return error is CryptoKitError
}
private func deferCiphertext(_ packet: BitchatPacket, from peerID: PeerID) {
guard NoiseSecurityValidator.validatePrivateFileCiphertextSize(
packet.payload
) else {
SecureLogger.warning(
"Dropping oversized early Noise ciphertext from \(peerID.id.prefix(8))",
category: .security
)
return
}
let now = environment.now()
deferredLock.lock()
defer { deferredLock.unlock() }
purgeExpiredCiphertextsLocked(now: now)
let peerCount = deferredCiphertexts[peerID]?.count ?? 0
let globalCount = deferredCiphertexts.values.reduce(0) {
$0 + $1.count
}
guard peerCount < Self.maxDeferredPacketsPerPeer,
globalCount < Self.maxDeferredPacketsGlobal,
deferredCiphertextBytes + packet.payload.count
<= Self.maxDeferredBytes else {
SecureLogger.warning(
"Dropping early Noise ciphertext from \(peerID.id.prefix(8))… because the handshake buffer is full",
category: .security
)
return
}
deferredCiphertexts[peerID, default: []].append(
DeferredCiphertext(packet: packet, receivedAt: now)
)
deferredCiphertextBytes += packet.payload.count
SecureLogger.debug(
"Deferring early Noise ciphertext from \(peerID.id.prefix(8))… until responder handshake completion",
category: .session
)
}
private func drainDeferredCiphertextsIfReady(for peerID: PeerID) {
let env = environment
guard !env.isAwaitingResponderHandshakeCompletion(peerID),
env.hasNoiseSession(peerID) else {
return
}
let now = env.now()
deferredLock.lock()
purgeExpiredCiphertextsLocked(now: now)
let deferred = deferredCiphertexts.removeValue(forKey: peerID) ?? []
deferredCiphertextBytes -= deferred.reduce(0) {
$0 + $1.packet.payload.count
}
deferredLock.unlock()
guard !deferred.isEmpty else { return }
SecureLogger.debug(
"Retrying \(deferred.count) early Noise ciphertext packet(s) from \(peerID.id.prefix(8))… after handshake completion",
category: .session
)
for item in deferred {
handleEncrypted(item.packet, from: peerID, isDeferredRetry: true)
}
}
private func purgeExpiredCiphertextsLocked(now: Date) {
for peerID in Array(deferredCiphertexts.keys) {
guard let items = deferredCiphertexts[peerID] else { continue }
let retained = items.filter {
now.timeIntervalSince($0.receivedAt) <= Self.deferredLifetime
}
guard retained.count != items.count else { continue }
deferredCiphertextBytes -= items.reduce(0) {
$0 + $1.packet.payload.count
}
deferredCiphertextBytes += retained.reduce(0) {
$0 + $1.packet.payload.count
}
if retained.isEmpty {
deferredCiphertexts.removeValue(forKey: peerID)
} else {
deferredCiphertexts[peerID] = retained
}
}
}
}
@@ -17,16 +17,6 @@ enum BLENoisePayloadFactory {
typedPayload(.delivered, payload: Data(messageID.utf8))
}
static func privateFile(_ filePacket: BitchatFilePacket) -> Data? {
guard let payload = filePacket.encode() else { return nil }
return typedPayload(.privateFile, payload: payload)
}
static func authenticatedPeerState(_ state: AuthenticatedPeerStatePacket) -> Data? {
guard let payload = state.encode() else { return nil }
return typedPayload(.authenticatedPeerState, payload: payload)
}
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
var typed = Data([type.rawValue])
typed.append(payload)
@@ -1,40 +0,0 @@
import Foundation
/// Bounds ordinary Noise revalidation to one attempt per physical-link epoch.
/// A live epoch may retry after the cooldown so a lost handshake cannot leave
/// the link permanently unauthenticated.
struct BLENoiseReconnectPolicy {
static let minimumRetryInterval: TimeInterval = 60
private var lastAttemptAt: [BLEIngressLinkID: Date] = [:]
mutating func shouldRevalidate(
on link: BLEIngressLinkID,
hasEstablishedSession: Bool,
isNoiseAuthenticatedLink: Bool,
hasAuthenticatedPeerLink: Bool,
now: Date
) -> Bool {
guard hasEstablishedSession,
!isNoiseAuthenticatedLink,
!hasAuthenticatedPeerLink else {
return false
}
if let previous = lastAttemptAt[link],
now.timeIntervalSince(previous) < Self.minimumRetryInterval {
return false
}
lastAttemptAt[link] = now
return true
}
/// Link identifiers can be stable across CoreBluetooth reconnects, so a
/// disconnect explicitly starts a new epoch and permits one fresh attempt.
mutating func endLinkEpoch(_ link: BLEIngressLinkID) {
lastAttemptAt.removeValue(forKey: link)
}
mutating func removeAll() {
lastAttemptAt.removeAll()
}
}
@@ -6,16 +6,9 @@ struct BLEPendingPrivateMessage: Equatable {
let messageID: String
}
struct BLEPendingTypedPayload: Equatable {
let payload: Data
/// Present for app-initiated media so handshake queuing preserves the
/// fragment scheduler's progress/cancellation identity.
let transferId: String?
}
struct BLENoiseSessionQueues {
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
private var typedPayloadsByPeerID: [PeerID: [BLEPendingTypedPayload]] = [:]
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:]
var isEmpty: Bool {
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
@@ -41,35 +34,13 @@ struct BLENoiseSessionQueues {
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
}
mutating func appendTypedPayload(_ payload: Data, transferId: String? = nil, for peerID: PeerID) {
typedPayloadsByPeerID[peerID, default: []].append(
BLEPendingTypedPayload(payload: payload, transferId: transferId)
)
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) {
typedPayloadsByPeerID[peerID, default: []].append(payload)
}
mutating func takeTypedPayloads(for peerID: PeerID) -> [BLEPendingTypedPayload] {
mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] {
let payloads = typedPayloadsByPeerID[peerID] ?? []
typedPayloadsByPeerID.removeValue(forKey: peerID)
return payloads
}
func containsTypedPayload(transferId: String) -> Bool {
typedPayloadsByPeerID.values.contains { payloads in
payloads.contains { $0.transferId == transferId }
}
}
@discardableResult
mutating func removeTypedPayload(transferId: String) -> Bool {
for peerID in Array(typedPayloadsByPeerID.keys) {
guard var payloads = typedPayloadsByPeerID[peerID],
let index = payloads.firstIndex(where: { $0.transferId == transferId }) else {
continue
}
payloads.remove(at: index)
typedPayloadsByPeerID[peerID] = payloads.isEmpty ? nil : payloads
return true
}
return false
}
}
@@ -17,9 +17,6 @@ struct BLEOutboundFragmentPlan {
}
enum BLEOutboundFragmentPlanner {
/// Current Android receivers reject fragment sets above 256. Private
/// media v1 treats that deployed ceiling as a cross-platform contract.
static let privateMediaV1MaxFragments = 256
private static let minimumChunkSize = 64
private static let fragmentIDLength = 8
@@ -74,10 +71,6 @@ enum BLEOutboundFragmentPlanner {
)
}
static func isPrivateMediaV1Compatible(_ plan: BLEOutboundFragmentPlan) -> Bool {
plan.totalFragments <= privateMediaV1MaxFragments
}
private static func sizingPolicy(
for packet: BitchatPacket,
requestedMaxChunk: Int?,
@@ -29,9 +29,8 @@ struct BLEOutboundFragmentTransferRequest {
}
var resolvedTransferId: String? {
if let transferId { return transferId }
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
return packet.payload.sha256Hex()
return transferId ?? packet.payload.sha256Hex()
}
/// Content identity independent of the caller-chosen transfer ID: the
+2 -18
View File
@@ -10,9 +10,6 @@ struct BLEPeerInfo: Equatable {
var isVerifiedNickname: Bool
var lastSeen: Date
var capabilities: PeerCapabilities = []
/// Distinguishes an old client that omitted the capabilities TLV from a
/// modern client that explicitly advertised a set without a given bit.
var capabilitiesWereExplicitlyAdvertised: Bool = false
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
var bridgeGeohash: String?
}
@@ -117,10 +114,6 @@ struct BLEPeerRegistry {
peers[peerID.toShort()]?.capabilities ?? []
}
func capabilitiesWereExplicitlyAdvertised(for peerID: PeerID) -> Bool {
peers[peerID.toShort()]?.capabilitiesWereExplicitlyAdvertised == true
}
/// Peers whose last verified announce advertised the given capability.
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
@@ -181,14 +174,6 @@ struct BLEPeerRegistry {
peers[peerID] = peer
}
/// Replaces the announcement signing key only after the surrounding Noise
/// session proved possession of this peer's static key.
mutating func bindAuthenticatedSigningPublicKey(_ key: Data, for peerID: PeerID) {
guard var peer = peers[peerID.toShort()] else { return }
peer.signingPublicKey = key
peers[peer.peerID] = peer
}
mutating func upsertVerifiedAnnounce(
peerID: PeerID,
nickname: String,
@@ -196,7 +181,7 @@ struct BLEPeerRegistry {
signingPublicKey: Data?,
isConnected: Bool,
now: Date,
capabilities: PeerCapabilities? = nil,
capabilities: PeerCapabilities = [],
bridgeGeohash: String? = nil
) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID]
@@ -214,8 +199,7 @@ struct BLEPeerRegistry {
signingPublicKey: signingPublicKey,
isVerifiedNickname: true,
lastSeen: now,
capabilities: capabilities ?? [],
capabilitiesWereExplicitlyAdvertised: capabilities != nil,
capabilities: capabilities,
bridgeGeohash: bridgeGeohash
)
@@ -1,556 +0,0 @@
import BitLogger
import Foundation
enum BLEPrivateMediaReceiptState: Equatable {
/// No durable receiver decision exists for this stable message ID.
case absent
/// The payload is durably mapped to a file that still exists.
case accepted(URL)
/// The user explicitly deleted the payload; retries must not resurrect it.
case tombstoned
/// Durable state could not be read safely. Callers must fail closed and
/// must not save, deliver, or acknowledge the payload.
case unavailable
}
/// Durable, per-message receiver decisions for stable private media.
///
/// Each ID has its own atomic record so one hot lookup never rewrites or
/// decodes the entire ledger. The process-lifetime index is installed only
/// after a complete, successful directory scan. An enumeration, read, decode,
/// or structural-validation failure therefore remains retryable and cannot be
/// mistaken for an empty ledger.
final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
typealias DirectoryReader = (_ directory: URL) throws -> [URL]
typealias DataReader = (_ url: URL) throws -> Data
private static let receiptDirectoryName = ".private-media-receipts"
private struct ReceiptRecord: Codable, Equatable {
enum Kind: String, Codable {
case accepted
case tombstone
}
let kind: Kind
/// Path below the app's `files/` root. Absolute application-container
/// prefixes are not stable across updates, restores, or reinstalls.
let relativePath: String?
let recordedAt: Date
}
private final class Runtime: @unchecked Sendable {
let lock = NSLock()
var records: [String: ReceiptRecord]?
var volatileTombstones: [String: Date] = [:]
}
private let fileManager: FileManager
private let baseDirectory: URL?
private let capacity: Int
private let ttl: TimeInterval
private let now: () -> Date
private let directoryReader: DirectoryReader?
private let dataReader: DataReader?
private let runtime = Runtime()
init(
fileManager: FileManager = .default,
baseDirectory: URL? = nil,
capacity: Int = TransportConfig.privateMediaReceivedLedgerCapacity,
ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds,
now: @escaping () -> Date = Date.init,
directoryReader: DirectoryReader? = nil,
dataReader: DataReader? = nil
) {
self.fileManager = fileManager
self.baseDirectory = baseDirectory
self.capacity = max(1, capacity)
self.ttl = max(0, ttl)
self.now = now
self.directoryReader = directoryReader
self.dataReader = dataReader
}
/// Drops process-lifetime decisions after the enclosing media directory
/// has been panic-wiped. A later lookup must rebuild from the durable
/// ledger instead of retaining an accepted receipt or tombstone whose
/// backing files no longer exist.
func resetForPanic() {
runtime.lock.lock()
runtime.records = nil
runtime.volatileTombstones.removeAll(keepingCapacity: false)
runtime.lock.unlock()
}
func state(for messageID: String) -> BLEPrivateMediaReceiptState {
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
return .absent
}
runtime.lock.lock()
defer { runtime.lock.unlock() }
let date = now()
if let tombstonedAt = runtime.volatileTombstones[messageID] {
if !isExpired(tombstonedAt, at: date) {
return .tombstoned
}
runtime.volatileTombstones.removeValue(forKey: messageID)
}
guard let directory = resolvedReceiptDirectory(),
var records = loadIndexIfNeeded(from: directory, at: date) else {
return .unavailable
}
guard let record = records[messageID] else { return .absent }
if isExpired(record.recordedAt, at: date) {
records.removeValue(forKey: messageID)
runtime.records = records
removeRecord(messageID: messageID, from: directory)
return .absent
}
switch record.kind {
case .tombstone:
removePayloadRecordedByTombstone(record)
return .tombstoned
case .accepted:
guard let relativePath = record.relativePath,
let existingURL = existingPayload(relativePath: relativePath) else {
// Quota cleanup is not explicit deletion. Remove the stale
// receipt so a sender retry can restore the payload and bubble.
records.removeValue(forKey: messageID)
runtime.records = records
removeRecord(messageID: messageID, from: directory)
return .absent
}
return .accepted(existingURL)
}
}
/// Records an accepted ID only after the payload is on disk. Callers must
/// roll the payload back and withhold UI delivery/ACK when this returns
/// false.
func commitAccepted(messageID: String, storedURL: URL) -> Bool {
guard PrivateMediaMessageIdentity.isStableID(messageID),
validExistingPayload(storedURL) != nil,
let relativePath = relativePath(for: storedURL) else {
return false
}
runtime.lock.lock()
defer { runtime.lock.unlock() }
let date = now()
if let tombstonedAt = runtime.volatileTombstones[messageID],
!isExpired(tombstonedAt, at: date) {
return false
}
guard let directory = resolvedReceiptDirectory(),
var records = loadIndexIfNeeded(from: directory, at: date) else {
return false
}
if let existing = records[messageID],
existing.kind == .tombstone,
!isExpired(existing.recordedAt, at: date) {
return false
}
let victim = capacityVictim(
for: .accepted,
replacing: messageID,
in: records
)
if records[messageID]?.kind != .accepted,
records.values.lazy.filter({ $0.kind == .accepted }).count >= capacity,
victim == nil {
return false
}
let record = ReceiptRecord(
kind: .accepted,
relativePath: relativePath,
recordedAt: date
)
guard persist(record, messageID: messageID, to: directory) else {
return false
}
records[messageID] = record
if let victim, victim != messageID {
records.removeValue(forKey: victim)
removeRecord(messageID: victim, from: directory)
}
runtime.records = records
return true
}
/// Foundation for explicit media deletion. This branch does not wire the
/// chat-clear UI; it only makes a tombstone durable and fail closed.
func recordDeleted(messageID: String) -> Bool {
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
return false
}
runtime.lock.lock()
defer { runtime.lock.unlock() }
let date = now()
addVolatileTombstone(messageID, at: date)
guard let directory = resolvedReceiptDirectory(),
var records = loadIndexIfNeeded(from: directory, at: date) else {
runtime.volatileTombstones.removeValue(forKey: messageID)
return false
}
if let existing = records[messageID],
existing.kind == .tombstone,
!isExpired(existing.recordedAt, at: date) {
runtime.volatileTombstones.removeValue(forKey: messageID)
removePayloadRecordedByTombstone(existing)
return true
}
let victim = capacityVictim(
for: .tombstone,
replacing: messageID,
in: records
)
if records[messageID]?.kind != .tombstone,
records.values.lazy.filter({ $0.kind == .tombstone }).count >= capacity,
victim == nil {
runtime.volatileTombstones.removeValue(forKey: messageID)
return false
}
let tombstone = ReceiptRecord(
kind: .tombstone,
// Retain the accepted path so a crash between the atomic record
// write and payload unlink can finish cleanup after relaunch.
relativePath: records[messageID]?.relativePath,
recordedAt: date
)
guard persist(tombstone, messageID: messageID, to: directory) else {
runtime.volatileTombstones.removeValue(forKey: messageID)
return false
}
records[messageID] = tombstone
if let victim, victim != messageID {
records.removeValue(forKey: victim)
removeRecord(messageID: victim, from: directory)
}
runtime.records = records
runtime.volatileTombstones.removeValue(forKey: messageID)
removePayloadRecordedByTombstone(tombstone)
return true
}
private func loadIndexIfNeeded(
from directory: URL,
at date: Date
) -> [String: ReceiptRecord]? {
if let records = runtime.records {
return records
}
do {
try fileManager.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: nil
)
} catch {
SecureLogger.error(
"❌ Failed to create private-media receipt directory: \(error)",
category: .session
)
return nil
}
let urls: [URL]
do {
if let directoryReader {
urls = try directoryReader(directory)
} else {
urls = try fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil,
options: []
)
}
} catch {
SecureLogger.error(
"❌ Failed to enumerate private-media receipts: \(error)",
category: .session
)
return nil
}
var records: [String: ReceiptRecord] = [:]
var expired: [String] = []
var tombstones: [ReceiptRecord] = []
for url in urls {
guard url.pathExtension == "json" else { continue }
let messageID = url.deletingPathExtension().lastPathComponent
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
continue
}
let record: ReceiptRecord
do {
let data = try dataReader?(url) ?? Data(contentsOf: url)
record = try JSONDecoder().decode(ReceiptRecord.self, from: data)
} catch {
// Never delete or skip an unreadable stable-ID record. Treating
// it as absent could resurrect accepted or deleted media.
SecureLogger.error(
"❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)",
category: .session
)
return nil
}
guard isStructurallyValid(record) else {
SecureLogger.error(
"❌ Invalid private-media receipt \(messageID.prefix(12))",
category: .session
)
return nil
}
if isExpired(record.recordedAt, at: date) {
expired.append(messageID)
continue
}
records[messageID] = record
if record.kind == .tombstone {
tombstones.append(record)
}
}
let overflow = overflowVictims(in: records)
for messageID in overflow {
records.removeValue(forKey: messageID)
}
// Install the index only after every stable-ID record was read and
// validated successfully. Cleanup cannot influence a failed scan.
runtime.records = records
for messageID in expired + overflow {
removeRecord(messageID: messageID, from: directory)
}
for tombstone in tombstones {
removePayloadRecordedByTombstone(tombstone)
}
return records
}
private func isStructurallyValid(_ record: ReceiptRecord) -> Bool {
switch record.kind {
case .tombstone:
guard let relativePath = record.relativePath else { return true }
return candidatePayload(relativePath: relativePath) != nil
case .accepted:
guard let relativePath = record.relativePath else { return false }
return candidatePayload(relativePath: relativePath) != nil
}
}
private func isExpired(_ recordedAt: Date, at date: Date) -> Bool {
date.timeIntervalSince(recordedAt) > ttl
}
private func overflowVictims(
in records: [String: ReceiptRecord]
) -> [String] {
var victims: [String] = []
for kind in [ReceiptRecord.Kind.accepted, .tombstone] {
let matching = records.filter { $0.value.kind == kind }
let overflow = matching.count - capacity
guard overflow > 0 else { continue }
victims.append(contentsOf: matching.sorted { lhs, rhs in
if lhs.value.recordedAt == rhs.value.recordedAt {
return lhs.key < rhs.key
}
return lhs.value.recordedAt < rhs.value.recordedAt
}
.prefix(overflow)
.map(\.key))
}
return victims
}
/// Accepted receipts and tombstones have independent capacity. High media
/// volume cannot evict explicit deletion intent, and vice versa.
private func capacityVictim(
for incomingKind: ReceiptRecord.Kind,
replacing messageID: String,
in records: [String: ReceiptRecord]
) -> String? {
guard records[messageID]?.kind != incomingKind else { return nil }
let matching = records.filter {
$0.key != messageID && $0.value.kind == incomingKind
}
guard matching.count >= capacity else { return nil }
return matching.min { lhs, rhs in
if lhs.value.recordedAt == rhs.value.recordedAt {
return lhs.key < rhs.key
}
return lhs.value.recordedAt < rhs.value.recordedAt
}?.key
}
private func persist(
_ record: ReceiptRecord,
messageID: String,
to directory: URL
) -> Bool {
do {
try fileManager.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: nil
)
let data = try JSONEncoder().encode(record)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
let url = recordURL(messageID: messageID, in: directory)
try data.write(to: url, options: options)
return true
} catch {
SecureLogger.error(
"❌ Failed to persist private-media receipt \(messageID.prefix(12))…: \(error)",
category: .session
)
return false
}
}
private func removeRecord(messageID: String, from directory: URL) {
let url = recordURL(messageID: messageID, in: directory)
guard fileManager.fileExists(atPath: url.path) else { return }
do {
try fileManager.removeItem(at: url)
} catch {
SecureLogger.warning(
"⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)",
category: .session
)
}
}
private func recordURL(messageID: String, in directory: URL) -> URL {
directory
.appendingPathComponent(messageID, isDirectory: false)
.appendingPathExtension("json")
}
private func removePayloadRecordedByTombstone(_ record: ReceiptRecord) {
guard record.kind == .tombstone,
let relativePath = record.relativePath,
let payload = candidatePayload(relativePath: relativePath),
fileManager.fileExists(atPath: payload.path) else {
return
}
do {
try fileManager.removeItem(at: payload)
} catch {
SecureLogger.warning(
"⚠️ Failed to remove explicitly deleted private media: \(error)",
category: .session
)
}
}
private func addVolatileTombstone(_ messageID: String, at date: Date) {
runtime.volatileTombstones[messageID] = date
let overflow = runtime.volatileTombstones.count - capacity
guard overflow > 0 else { return }
let oldest = runtime.volatileTombstones.sorted {
if $0.value == $1.value { return $0.key < $1.key }
return $0.value < $1.value
}
for (oldMessageID, _) in oldest.prefix(overflow) {
runtime.volatileTombstones.removeValue(forKey: oldMessageID)
}
}
private func validExistingPayload(_ url: URL) -> URL? {
let standardized = url.standardizedFileURL
guard isInsideFilesDirectory(standardized) else { return nil }
var isDirectory: ObjCBool = false
guard fileManager.fileExists(
atPath: standardized.path,
isDirectory: &isDirectory
), !isDirectory.boolValue else {
return nil
}
return standardized
}
private func relativePath(for url: URL) -> String? {
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
return nil
}
let prefix = filesRoot.path + "/"
let standardized = url.standardizedFileURL
guard standardized.path.hasPrefix(prefix) else { return nil }
let relativePath = String(standardized.path.dropFirst(prefix.count))
return relativePath.isEmpty ? nil : relativePath
}
private func existingPayload(relativePath: String) -> URL? {
guard let candidate = candidatePayload(relativePath: relativePath) else {
return nil
}
return validExistingPayload(candidate)
}
private func candidatePayload(relativePath: String) -> URL? {
guard !relativePath.isEmpty,
let filesRoot = try? filesDirectory().standardizedFileURL else {
return nil
}
let candidate = filesRoot
.appendingPathComponent(relativePath, isDirectory: false)
.standardizedFileURL
guard candidate.path.hasPrefix(filesRoot.path + "/") else { return nil }
return candidate
}
private func isInsideFilesDirectory(_ url: URL) -> Bool {
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
return false
}
return url.standardizedFileURL.path.hasPrefix(filesRoot.path + "/")
}
private func resolvedReceiptDirectory() -> URL? {
return try? filesDirectory().appendingPathComponent(
Self.receiptDirectoryName,
isDirectory: true
)
}
private func filesDirectory() throws -> URL {
let root = try baseDirectory ?? fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let files = root.appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(
at: files,
withIntermediateDirectories: true,
attributes: nil
)
return files
}
}
File diff suppressed because it is too large Load Diff
+8 -56
View File
@@ -45,9 +45,6 @@ final class GeohashPresenceService: ObservableObject {
private var subscriptions = Set<AnyCancellable>()
private var heartbeatTimer: GeohashPresenceTimerProtocol?
private var pendingBroadcastTasks: [UUID: Task<Void, Never>] = [:]
private var heartbeatGeneration: UInt64 = 0
private var started = false
private let availableChannelsProvider: () -> [GeohashChannel]
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
private let torReadyPublisher: AnyPublisher<Void, Never>
@@ -150,25 +147,10 @@ final class GeohashPresenceService: ObservableObject {
/// Start the service (safe to call multiple times)
func start() {
guard !started else { return }
started = true
heartbeatGeneration &+= 1
SecureLogger.info("Presence: service starting...", category: .session)
scheduleNextHeartbeat()
}
/// Stops the timer and every decorrelation task synchronously at the panic
/// boundary. Generation checks also protect against custom sleepers that
/// ignore task cancellation and return later.
func stopForPanic() {
started = false
heartbeatGeneration &+= 1
heartbeatTimer?.invalidate()
heartbeatTimer = nil
pendingBroadcastTasks.values.forEach { $0.cancel() }
pendingBroadcastTasks.removeAll(keepingCapacity: false)
}
private func setupObservers() {
// Monitor location channel changes
locationChanges
@@ -187,26 +169,20 @@ final class GeohashPresenceService: ObservableObject {
}
func handleLocationChange() {
guard started else { return }
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
// to announce presence in the new zone, then reset the loop.
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
heartbeatTimer?.invalidate()
// Small delay to allow location state to settle
let generation = heartbeatGeneration
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
Task { @MainActor [weak self] in
guard let self,
self.started,
self.heartbeatGeneration == generation else { return }
self.performHeartbeat()
self?.performHeartbeat()
}
}
}
func handleConnectivityChange() {
guard started else { return }
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
// If we were waiting for network, do it now
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
@@ -215,29 +191,18 @@ final class GeohashPresenceService: ObservableObject {
}
func scheduleNextHeartbeat() {
guard started else { return }
heartbeatTimer?.invalidate()
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
let generation = heartbeatGeneration
heartbeatTimer = scheduleTimer(interval) { [weak self] in
Task { @MainActor [weak self] in
guard let self,
self.started,
self.heartbeatGeneration == generation else { return }
self.performHeartbeat()
self?.performHeartbeat()
}
}
}
func performHeartbeat() {
guard started else { return }
let generation = heartbeatGeneration
// Always schedule next loop first ensures continuity even if this one fails/skips
defer {
if started, heartbeatGeneration == generation {
scheduleNextHeartbeat()
}
}
defer { scheduleNextHeartbeat() }
// 1. Check preconditions
guard torIsReady() else {
@@ -263,27 +228,14 @@ final class GeohashPresenceService: ObservableObject {
}
// Launch independent task for each channel's delay
let taskID = UUID()
let sleeper = self.sleeper
let delay = TimeInterval.random(
in: burstMinDelay...burstMaxDelay
)
let nanoseconds = UInt64(delay * 1_000_000_000)
let task = Task { @MainActor [weak self] in
Task { @MainActor in
// Random delay for decorrelation
await sleeper(nanoseconds)
guard let self else { return }
guard !Task.isCancelled,
self.started,
self.heartbeatGeneration == generation else {
self.pendingBroadcastTasks.removeValue(forKey: taskID)
return
}
self.pendingBroadcastTasks.removeValue(forKey: taskID)
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
let nanoseconds = UInt64(delay * 1_000_000_000)
await self.sleeper(nanoseconds)
self.broadcastPresence(for: channel.geohash)
}
pendingBroadcastTasks[taskID] = task
}
}
+114 -472
View File
@@ -11,54 +11,6 @@ import BitFoundation
import Foundation
import Security
enum KeychainInstallLifecycleAction: Equatable {
case markerPresent
case bootstrapMarker
case clearStaleKeys
case retryLater
}
/// Process-local fail-closed gate for an unresolved install lifecycle.
///
/// A blocked caller may perform one synchronous reconciliation attempt.
/// Concurrent callers fail closed instead of reading while that cleanup is
/// in flight. Once reconciliation succeeds, access remains open.
final class KeychainInstallAccessGate: @unchecked Sendable {
private let lock = NSLock()
private var blocked = false
private var reconciliationInProgress = false
func block() {
lock.lock()
blocked = true
lock.unlock()
}
func allowsAccess(reconcile: () -> Bool) -> Bool {
lock.lock()
if !blocked {
lock.unlock()
return true
}
guard !reconciliationInProgress else {
lock.unlock()
return false
}
reconciliationInProgress = true
lock.unlock()
let completed = reconcile()
lock.lock()
if completed {
blocked = false
}
reconciliationInProgress = false
lock.unlock()
return completed
}
}
final class KeychainManager: KeychainManagerProtocol {
/// Default keychain for components that construct their own rather than
/// having one injected. Under test this is an in-memory keychain: the
@@ -89,279 +41,53 @@ final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items
private let service = BitchatApp.bundleID
private let appGroup = "group.\(BitchatApp.bundleID)"
private let installAccessGate = KeychainInstallAccessGate()
/// Every generic-password service owned by this app, including names used
/// by older releases. Keep custom services here so one-time security
/// migrations and panic deletion cannot silently miss them.
private static let additionalApplicationOwnedServices = [
"chat.bitchat.nostr",
"chat.bitchat.favorites",
"chat.bitchat.outbox",
"com.bitchat.passwords",
"com.bitchat.deviceidentity",
"com.bitchat.noise.identity",
"chat.bitchat.passwords",
"bitchat.keychain",
"bitchat",
"com.bitchat"
]
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
// device locked (identity-cache saves failed with -25308 throughout
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
// restoration must be able to read the noise keys before the user
// unlocks. ThisDeviceOnly prevents private identities and group keys from
// migrating through device backups onto a second device.
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
init() {
#if os(iOS)
if reconcileInstallLifecycle() {
migrateAccessibilityIfNeeded()
} else {
installAccessGate.block()
}
migrateAccessibilityIfNeeded()
#endif
}
static func installLifecycleAction(
containerKnowsMarker: Bool,
cleanupPending: Bool = false,
markerRead: KeychainReadResult
) -> KeychainInstallLifecycleAction {
// Once a reinstall cleanup has started, its container-local latch
// must win even if the keychain marker was deleted before a later
// keychain operation failed. Otherwise the next launch could mistake
// a partial cleanup for a fresh bootstrap and preserve stale secrets.
if cleanupPending {
return .clearStaleKeys
}
switch markerRead {
case .success:
return containerKnowsMarker ? .markerPresent : .clearStaleKeys
case .itemNotFound:
return .bootstrapMarker
case .accessDenied, .deviceLocked, .authenticationFailed, .otherError:
return .retryLater
}
}
static func applicationOwnedKeychainServices(primaryService: String) -> [String] {
var seen = Set<String>()
return ([primaryService] + additionalApplicationOwnedServices).filter {
seen.insert($0).inserted
}
}
/// Runs every service update even after one failure. Successful updates
/// are idempotent, while returning false keeps the one-time flag unset so
/// a later unlocked launch retries the incomplete migration.
static func migrateAccessibilityForApplicationOwnedServices(
primaryService: String,
updateService: (String) -> OSStatus
) -> Bool {
var completed = true
for serviceName in applicationOwnedKeychainServices(
primaryService: primaryService
) {
let status = updateService(serviceName)
if status != errSecSuccess && status != errSecItemNotFound {
completed = false
}
}
return completed
}
/// Deletes every declared service even after one failure. An empty scope
/// is already clean, while any other status leaves the cleanup
/// incomplete so its durable retry marker remains set.
static func deleteApplicationOwnedKeychainServices(
primaryService: String,
deleteService: (String) -> OSStatus
) -> Bool {
var completed = true
for serviceName in applicationOwnedKeychainServices(
primaryService: primaryService
) {
let status = deleteService(serviceName)
if status != errSecSuccess && status != errSecItemNotFound {
completed = false
}
}
return completed
}
/// The app currently has an application-group entitlement, not a
/// keychain-access-group entitlement. Keep the historical group cleanup
/// probe as best effort without making its expected -34018 response block
/// panic recovery forever.
static func completedApplicationGroupDelete(status: OSStatus) -> Bool {
status == errSecSuccess
|| status == errSecItemNotFound
|| status == -34018
}
#if os(iOS)
private static let installMarkerAccount = "install_lifecycle_marker"
private static let installMarkerDefaultsKey = "keychain.installLifecycleMarker.present"
private static let installCleanupPendingDefaultsKey =
"keychain.installLifecycleCleanup.pending"
/// Keychain items can survive app removal while the app container and its
/// UserDefaults do not. The first version carrying this marker bootstraps
/// without deleting existing users' identities. On a later reinstall, a
/// surviving keychain marker plus a missing defaults marker proves the app
/// container was replaced, so stale secrets are removed before use.
@discardableResult
private func reconcileInstallLifecycle() -> Bool {
let defaults = UserDefaults.standard
let containerKnowsMarker = defaults.bool(forKey: Self.installMarkerDefaultsKey)
let cleanupPending = defaults.bool(
forKey: Self.installCleanupPendingDefaultsKey
)
let markerRead = retrieveDataWithResult(forKey: Self.installMarkerAccount)
switch Self.installLifecycleAction(
containerKnowsMarker: containerKnowsMarker,
cleanupPending: cleanupPending,
markerRead: markerRead
) {
case .markerPresent:
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
return true
case .bootstrapMarker:
if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) {
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
}
// A missing marker is the intentional bootstrap path for both a
// fresh install and the first marker-carrying upgrade. Preserve
// existing users' identities even if marker creation must retry
// on a later construction.
return true
case .clearStaleKeys:
// Establish a container-local retry latch before deleting the
// surviving keychain marker. If the process exits or any keychain
// operation fails, the next launch retries even when that marker
// can no longer be read.
defaults.set(true, forKey: Self.installCleanupPendingDefaultsKey)
guard defaults.synchronize(),
defaults.bool(forKey: Self.installCleanupPendingDefaultsKey)
else {
SecureLogger.error(
"Could not persist reinstall keychain-cleanup intent",
category: .security
)
return false
}
guard deleteAllKeychainData() else {
SecureLogger.error(
"Reinstall keychain cleanup incomplete; retry remains pending",
category: .security
)
return false
}
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
defaults.removeObject(
forKey: Self.installCleanupPendingDefaultsKey
)
guard defaults.synchronize(),
defaults.bool(forKey: Self.installMarkerDefaultsKey),
!defaults.bool(
forKey: Self.installCleanupPendingDefaultsKey
)
else {
// Preserve the fail-closed state in memory and make one more
// best-effort persistence attempt before startup continues.
defaults.set(
true,
forKey: Self.installCleanupPendingDefaultsKey
)
_ = defaults.synchronize()
SecureLogger.error(
"Could not commit reinstall keychain-cleanup state; retry remains pending",
category: .security
)
return false
}
return true
case .retryLater:
// Do not guess that a temporarily unreadable marker is absent.
// An established container may keep using ordinary protected-data
// semantics: reads fail while locked and recover after unlock. A
// container that has not committed the marker must stay blocked
// until the marker becomes readable and this state machine can
// distinguish bootstrap from reinstall.
return containerKnowsMarker
}
}
/// One-time upgrade of items created under WhenUnlocked. New saves get
/// the right class on their own (saves are delete-then-add), but the
/// long-lived identity keys are written once and would otherwise stay
/// unreadable while the device is locked.
private func migrateAccessibilityIfNeeded() {
let flag = "keychain.accessibility.afterFirstUnlockThisDeviceOnly.migrated"
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
guard !UserDefaults.standard.bool(forKey: flag) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service
]
let update: [String: Any] = [
kSecAttrAccessible as String: Self.itemAccessibility
]
let completed = Self.migrateAccessibilityForApplicationOwnedServices(
primaryService: service
) { serviceName in
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName
]
return SecItemUpdate(
query as CFDictionary,
update as CFDictionary
)
}
if completed {
// Missing services on a fresh install are terminal, but the flag is
// set only after every application-owned service was considered.
let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
switch status {
case errSecSuccess, errSecItemNotFound:
// Nothing to migrate on a fresh install; both are terminal.
UserDefaults.standard.set(true, forKey: flag)
SecureLogger.info(
"Keychain accessibility migrated to AfterFirstUnlockThisDeviceOnly",
category: .keychain
)
} else {
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain)
default:
// Likely errSecInteractionNotAllowed (relaunched while locked)
// leave the flag unset so the next launch retries.
SecureLogger.warning(
"Keychain accessibility migration deferred for at least one application-owned service",
category: .keychain
)
SecureLogger.warning("Keychain accessibility migration deferred (status \(status))", category: .keychain)
}
}
#endif
private func installAccessAllowed() -> Bool {
#if os(iOS)
return installAccessGate.allowsAccess { [self] in
guard reconcileInstallLifecycle() else { return false }
migrateAccessibilityIfNeeded()
return true
}
#else
return true
#endif
}
// MARK: - Identity Keys
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
guard installAccessAllowed() else {
SecureLogger.logKeyOperation(.save, keyType: key, success: false)
return false
}
let fullKey = "identity_\(key)"
let result = saveData(keyData, forKey: fullKey)
SecureLogger.logKeyOperation(.save, keyType: key, success: result)
@@ -369,16 +95,11 @@ final class KeychainManager: KeychainManagerProtocol {
}
func getIdentityKey(forKey key: String) -> Data? {
guard installAccessAllowed() else { return nil }
let fullKey = "identity_\(key)"
return retrieveData(forKey: fullKey)
}
func deleteIdentityKey(forKey key: String) -> Bool {
guard installAccessAllowed() else {
SecureLogger.logKeyOperation(.delete, keyType: key, success: false)
return false
}
let result = delete(forKey: "identity_\(key)")
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
return result
@@ -389,14 +110,12 @@ final class KeychainManager: KeychainManagerProtocol {
/// Get identity key with detailed result for proper error handling
/// Distinguishes between missing keys (expected) and critical failures
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
guard installAccessAllowed() else { return .accessDenied }
let fullKey = "identity_\(key)"
return retrieveDataWithResult(forKey: fullKey)
}
/// Save identity key with detailed result and retry logic for transient errors
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
guard installAccessAllowed() else { return .accessDenied }
let fullKey = "identity_\(key)"
return saveDataWithResult(keyData, forKey: fullKey)
}
@@ -666,165 +385,114 @@ final class KeychainManager: KeychainManagerProtocol {
// Delete ALL keychain data for panic mode
func deleteAllKeychainData() -> Bool {
SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
let ownedServices = Set(
Self.applicationOwnedKeychainServices(
primaryService: service
)
)
var enumerationCompleted = true
var totalDeleted = 0
// Search without service restriction to catch all items
let searchQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
var result: AnyObject?
let searchStatus = SecItemCopyMatching(
searchQuery as CFDictionary,
&result
)
switch searchStatus {
case errSecSuccess:
guard let items = result as? [[String: Any]] else {
enumerationCompleted = false
SecureLogger.error(
"Unable to decode application-owned keychain inventory",
category: .security
)
break
}
// Preserve the access-group sweep for custom services that are
// not yet in the declared legacy-service list.
let searchStatus = SecItemCopyMatching(searchQuery as CFDictionary, &result)
if searchStatus == errSecSuccess, let items = result as? [[String: Any]] {
for item in items {
let account =
item[kSecAttrAccount as String] as? String ?? ""
let itemService =
item[kSecAttrService as String] as? String ?? ""
let accessGroup =
item[kSecAttrAccessGroup as String] as? String
guard accessGroup == appGroup
|| ownedServices.contains(itemService)
else {
continue
var shouldDelete = false
let account = item[kSecAttrAccount as String] as? String ?? ""
let service = item[kSecAttrService as String] as? String ?? ""
let accessGroup = item[kSecAttrAccessGroup as String] as? String
// More precise deletion criteria:
// 1. Check for our specific app group
// 2. OR check for our exact service name
// 3. OR check for known legacy service names
if accessGroup == appGroup {
shouldDelete = true
} else if service == self.service {
shouldDelete = true
} else if [
"com.bitchat.passwords",
"com.bitchat.deviceidentity",
"com.bitchat.noise.identity",
"chat.bitchat.passwords",
"bitchat.keychain",
"bitchat",
"com.bitchat"
].contains(service) {
shouldDelete = true
}
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword
]
if !account.isEmpty {
deleteQuery[kSecAttrAccount as String] = account
}
if !itemService.isEmpty {
deleteQuery[kSecAttrService as String] = itemService
}
if let accessGroup,
!accessGroup.isEmpty,
accessGroup != "test" {
deleteQuery[kSecAttrAccessGroup as String] = accessGroup
}
let status = SecItemDelete(deleteQuery as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
enumerationCompleted = false
SecureLogger.error(
NSError(domain: "Keychain", code: Int(status)),
context: "Unable to delete enumerated application-owned keychain item",
category: .keychain
)
if shouldDelete {
// Build delete query with all available attributes for precise deletion
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword
]
if !account.isEmpty {
deleteQuery[kSecAttrAccount as String] = account
}
if !service.isEmpty {
deleteQuery[kSecAttrService as String] = service
}
// Add access group if present
if let accessGroup = item[kSecAttrAccessGroup as String] as? String,
!accessGroup.isEmpty && accessGroup != "test" {
deleteQuery[kSecAttrAccessGroup as String] = accessGroup
}
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
if deleteStatus == errSecSuccess {
totalDeleted += 1
SecureLogger.info("Deleted keychain item: \(account) from \(service)", category: .keychain)
}
}
}
case errSecItemNotFound:
break
default:
enumerationCompleted = false
SecureLogger.error(
NSError(domain: "Keychain", code: Int(searchStatus)),
context: "Unable to enumerate application-owned keychain items",
category: .keychain
)
}
// Bulk deletion by every application-owned service is authoritative
// and idempotent. It also verifies that every known service scope is
// empty even when the inventory pass found no items.
let servicesCompleted =
Self.deleteApplicationOwnedKeychainServices(
primaryService: service
) { serviceName in
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
SecureLogger.error(
NSError(domain: "Keychain", code: Int(status)),
context: "Unable to delete application-owned keychain service \(serviceName)",
category: .keychain
)
}
return status
// Also try to delete by known service names and app group
// This catches any items that might have been missed above
let knownServices = [
self.service, // Current service name
"com.bitchat.passwords",
"com.bitchat.deviceidentity",
"com.bitchat.noise.identity",
"chat.bitchat.passwords",
"chat.bitchat.nostr",
"bitchat.keychain",
"bitchat",
"com.bitchat"
]
for serviceName in knownServices {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess {
totalDeleted += 1
}
// Historical builds attempted this application-group identifier as a
// keychain access group. It is not currently entitled, so -34018
// means the scope is inapplicable rather than partially deleted.
}
// Also delete by app group to ensure complete cleanup
let groupQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccessGroup as String: appGroup
]
let groupStatus = SecItemDelete(groupQuery as CFDictionary)
let groupCompleted = Self.completedApplicationGroupDelete(
status: groupStatus
)
if !groupCompleted {
SecureLogger.error(
NSError(domain: "Keychain", code: Int(groupStatus)),
context: "Unable to delete historical application-group keychain items",
category: .keychain
)
if groupStatus == errSecSuccess {
totalDeleted += 1
}
var markerCompleted = true
#if os(iOS)
// The non-secret marker is intentionally recreated after a panic so a
// later uninstall/reinstall can still be distinguished from an in-place
// upgrade. Do not commit the container-side marker here: reinstall
// reconciliation may still need to retry an incomplete cleanup.
if case .success = saveDataWithResult(
Data([1]),
forKey: Self.installMarkerAccount
) {
markerCompleted = true
} else {
markerCompleted = false
SecureLogger.error(
"Unable to restore install-lifecycle keychain marker",
category: .security
)
}
#endif
let completed =
enumerationCompleted
&& servicesCompleted
&& groupCompleted
&& markerCompleted
if completed {
SecureLogger.warning(
"Panic mode keychain cleanup completed",
category: .keychain
)
} else {
SecureLogger.error(
"Panic mode keychain cleanup incomplete",
category: .security
)
}
return completed
SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
return totalDeleted > 0
}
// MARK: - Security Utilities
@@ -850,7 +518,6 @@ final class KeychainManager: KeychainManagerProtocol {
// MARK: - Debug
func verifyIdentityKeyExists() -> Bool {
guard installAccessAllowed() else { return false }
let key = "identity_noiseStaticKey"
return retrieveData(forKey: key) != nil
}
@@ -859,40 +526,18 @@ final class KeychainManager: KeychainManagerProtocol {
/// Save data with a custom service name
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
guard installAccessAllowed() else { return }
let primaryKeyQuery: [String: Any] = [
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
kSecAttrAccount as String: key
kSecAttrAccount as String: key,
kSecValueData as String: data
]
var addQuery = primaryKeyQuery
addQuery.merge([
kSecValueData as String: data,
kSecAttrAccessible as String: accessible ?? Self.itemAccessibility,
kSecAttrSynchronizable as String: false
]) { _, new in new }
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
// Delete by the item's primary key only. Value/accessibility fields
// are add attributes, not valid selectors for replacing an existing
// item; including them can leave the old item in place and make the
// subsequent add fail as a duplicate.
let deleteStatus = SecItemDelete(primaryKeyQuery as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
SecureLogger.error(
NSError(domain: "Keychain", code: Int(deleteStatus)),
context: "Unable to replace custom-service keychain item",
category: .keychain
)
return
}
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
if addStatus != errSecSuccess {
SecureLogger.error(
NSError(domain: "Keychain", code: Int(addStatus)),
context: "Unable to save custom-service keychain item",
category: .keychain
)
}
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
/// Load data from a custom service
@@ -906,7 +551,6 @@ final class KeychainManager: KeychainManagerProtocol {
/// Load custom-service data without collapsing `itemNotFound` and
/// protected-data/keychain failures into the same nil result.
func loadWithResult(key: String, service customService: String) -> KeychainReadResult {
guard installAccessAllowed() else { return .accessDenied }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
@@ -921,7 +565,6 @@ final class KeychainManager: KeychainManagerProtocol {
/// Delete data from a custom service
func delete(key: String, service customService: String) {
guard installAccessAllowed() else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
@@ -933,7 +576,6 @@ final class KeychainManager: KeychainManagerProtocol {
/// Delete every item stored under a custom service
func deleteAll(service customService: String) {
guard installAccessAllowed() else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
@@ -251,9 +251,9 @@ final class MessageFormattingEngine {
isSelf: Bool,
isMentioned: Bool
) -> AttributedString {
// For very long content, use plain formatting to avoid expensive
// regex/detector work. Cashu presence must not disable this guard.
if content.isOversizedForRichFormatting() {
// For very long content without special tokens, use plain formatting
let containsCashu = containsCashuToken(content)
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
}
@@ -154,19 +154,6 @@ final class NetworkActivationService: ObservableObject {
.store(in: &cancellables)
}
/// Stops all internet-facing work at the synchronous panic boundary.
/// `start()` may be called again only after the full wipe commits.
func stopForPanic() {
cancellables.removeAll()
started = false
reachabilityMonitor.stop()
activationAllowed = false
torAutoStartDesired = false
relayController.disconnect()
torController.setAutoStartAllowed(false)
applyTorState(torDesired: false)
}
func setUserTorEnabled(_ enabled: Bool) {
guard enabled != userTorEnabled else { return }
userTorEnabled = enabled
@@ -180,7 +167,6 @@ final class NetworkActivationService: ObservableObject {
}
private func reevaluate() {
guard started else { return }
let allowed = effectiveAllowed()
let torDesired = allowed && userTorEnabled
let statusChanged = allowed != activationAllowed
@@ -27,8 +27,6 @@ protocol NetworkReachabilityMonitoring: AnyObject {
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
/// Begin monitoring. Idempotent.
func start()
/// Stop monitoring and discard pending debounce work. Idempotent.
func stop()
}
/// Pure debounce/decision logic for reachability, split out so it can be
@@ -100,7 +98,6 @@ final class AlwaysReachableMonitor: NetworkReachabilityMonitoring {
Empty(completeImmediately: false).eraseToAnyPublisher()
}
func start() {}
func stop() {}
}
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
@@ -149,18 +146,6 @@ final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring {
#endif
}
func stop() {
guard started else { return }
started = false
flushWorkItem?.cancel()
flushWorkItem = nil
#if canImport(Network)
monitor?.pathUpdateHandler = nil
monitor?.cancel()
monitor = nil
#endif
}
/// Feed an observation into the debounce and publish committed changes.
/// Exposed internally so higher layers/tests could drive it if needed.
func ingest(reachable: Bool) {
+36 -298
View File
@@ -165,6 +165,7 @@ final class NoiseEncryptionService {
// Peer fingerprints (SHA256 hash of static public key)
private var peerFingerprints: [PeerID: String] = [:]
private var fingerprintToPeerID: [String: PeerID] = [:]
// Thread safety
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
@@ -182,24 +183,12 @@ final class NoiseEncryptionService {
// Callbacks
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = []
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
/// Automatic rekey prepared XX message 1. The transport must claim the
/// exact attempt at its actual BLE handoff; a crossed inbound initiation
/// can invalidate the token before that point.
var onRekeyHandshakeReady:
((_ peerID: PeerID, _ initiation: NoiseHandshakeInitiation) -> Void)?
var onHandshakeRecoveryRequired:
((_ request: NoiseHandshakeRecoveryRequest) -> Void)?
/// An unauthenticated reconnect attempt failed or timed out and the
/// receive-only rollback session became the active transport again.
/// Transport queues must be drained for this exact restored generation.
var onSessionRestoredWithGeneration: ((_ peerID: PeerID, _ generation: UUID) -> Void)?
// Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
serviceQueue.sync(flags: .barrier) {
onPeerAuthenticatedHandlers.append(handler)
serviceQueue.async(flags: .barrier) { [weak self] in
self?.onPeerAuthenticatedHandlers.append(handler)
}
}
@@ -212,30 +201,8 @@ final class NoiseEncryptionService {
}
}
}
/// Generation-aware authentication notifications are used by protocols
/// whose state must be bound to one exact Noise transport session.
var onPeerAuthenticatedWithGeneration: ((PeerID, String, UUID) -> Void)? {
get { nil }
set {
guard let handler = newValue else { return }
serviceQueue.sync(flags: .barrier) {
onPeerAuthenticatedWithGenerationHandlers.append(handler)
}
}
}
init(
keychain: KeychainManagerProtocol,
ordinaryHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryHandshakeTimeout,
ordinaryResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
recentInitiatorCompletionGracePeriod: TimeInterval =
NoiseSecurityConstants.recentInitiatorCompletionGracePeriod,
ordinaryReconnectRollbackCooldown: TimeInterval =
NoiseSecurityConstants.ordinaryReconnectRollbackCooldown
) {
init(keychain: KeychainManagerProtocol) {
self.keychain = keychain
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
@@ -325,31 +292,11 @@ final class NoiseEncryptionService {
self.signingPublicKey = signingKey.publicKey
// Initialize session manager
self.sessionManager = NoiseSessionManager(
localStaticKey: staticIdentityKey,
keychain: keychain,
ordinaryHandshakeTimeout: ordinaryHandshakeTimeout,
ordinaryResponderHandshakeTimeout:
ordinaryResponderHandshakeTimeout,
recentInitiatorCompletionGracePeriod:
recentInitiatorCompletionGracePeriod,
ordinaryReconnectRollbackCooldown:
ordinaryReconnectRollbackCooldown
)
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
// Set up session callbacks
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
self?.handleSessionEstablished(
peerID: peerID,
remoteStaticKey: remoteStaticKey,
sessionGeneration: generation
)
}
sessionManager.onSessionRestored = { [weak self] peerID, generation in
self?.onSessionRestoredWithGeneration?(peerID, generation)
}
sessionManager.onHandshakeRecoveryRequired = { [weak self] request in
self?.onHandshakeRecoveryRequired?(request)
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
}
// Start session maintenance timer
@@ -714,105 +661,9 @@ final class NoiseEncryptionService {
let handshakeData = try sessionManager.initiateHandshake(with: peerID)
return handshakeData
}
/// Atomically admits and prepares one initial ordinary handshake. Returns
/// nil when another discovery callback already created a session.
func initiateHandshakeIfNeeded(
with peerID: PeerID,
retryOnTimeout: Bool = false
) throws -> NoiseHandshakeInitiation? {
guard peerID.isValid else {
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
throw NoiseSecurityError.invalidPeerID
}
guard let initiation = try sessionManager.initiateHandshakeIfAbsent(
with: peerID,
notifyOnTimeout: retryOnTimeout,
authorize: { [rateLimiter] in
guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.warning(
.authenticationFailed(peerID: "Rate limited: \(peerID)")
)
throw NoiseSecurityError.rateLimitExceeded
}
}
) else {
return nil
}
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
return initiation
}
/// Atomically prepares an ordinary reconnect for a peer whose cached
/// transport belongs to an earlier physical link. Failed authorization or
/// handshake setup preserves the established session.
func initiateReconnectHandshake(
with peerID: PeerID,
retryOnTimeout: Bool = false
) throws -> NoiseHandshakeInitiation {
guard peerID.isValid else {
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
throw NoiseSecurityError.invalidPeerID
}
return try sessionManager.initiateReconnectHandshake(
with: peerID,
notifyOnTimeout: retryOnTimeout,
authorize: { [rateLimiter] in
guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.warning(
.authenticationFailed(peerID: "Rate limited: \(peerID)")
)
throw NoiseSecurityError.rateLimitExceeded
}
}
)
}
func prepareHandshakeRecovery(
_ request: NoiseHandshakeRecoveryRequest
) throws -> NoiseHandshakeRecoveryPreparation? {
try sessionManager.prepareHandshakeRecovery(
request,
authorizeAttempt: { [rateLimiter] in
guard rateLimiter.allowHandshake(from: request.peerID) else {
SecureLogger.warning(
.authenticationFailed(
peerID: "Rate limited: \(request.peerID)"
)
)
throw NoiseSecurityError.rateLimitExceeded
}
}
)
}
func cancelHandshakeRecovery(_ request: NoiseHandshakeRecoveryRequest) {
sessionManager.cancelHandshakeRecovery(request)
}
func claimHandshakeInitiation(
_ initiation: NoiseHandshakeInitiation,
for peerID: PeerID
) -> Data? {
sessionManager.claimHandshakeInitiation(initiation, for: peerID)
}
/// Process an incoming handshake message
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
try processHandshakeMessageWithResult(
from: peerID,
message: message
).response
}
/// Process an incoming handshake message and report whether the exact
/// session that consumed it completed authenticated establishment.
func processHandshakeMessageWithResult(
from peerID: PeerID,
message: Data
) throws -> NoiseHandshakeProcessingResult {
// Validate peer ID
guard peerID.isValid else {
@@ -834,14 +685,11 @@ final class NoiseEncryptionService {
// For handshakes, we process the raw data directly without NoiseMessage wrapper
// The Noise protocol handles its own message format
let result = try sessionManager.handleIncomingHandshakeWithResult(
from: peerID,
message: message
)
let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message)
// Return raw response without wrapper
return result
return responsePayload
}
/// Check if we have an established session with a peer
@@ -853,13 +701,6 @@ final class NoiseEncryptionService {
func hasSession(with peerID: PeerID) -> Bool {
return sessionManager.getSession(for: peerID) != nil
}
/// True while an inbound ordinary XX responder is waiting for message 3.
/// A small amount of immediately-following ciphertext may arrive first
/// over BLE and must be retried only after responder promotion.
func isAwaitingResponderHandshakeCompletion(with peerID: PeerID) -> Bool {
sessionManager.isAwaitingResponderHandshakeCompletion(for: peerID)
}
// MARK: - Encryption/Decryption
@@ -884,87 +725,25 @@ final class NoiseEncryptionService {
return try sessionManager.encrypt(data, for: peerID)
}
/// Encrypts a finalized private-media packet. Ordinary Noise application
/// messages retain the 64 KiB ceiling; this purpose-specific path permits
/// the bounded `BitchatFilePacket` envelope and refuses every other typed
/// payload so the larger allocation budget cannot become a generic bypass.
func encryptPrivateFilePayload(
_ data: Data,
for peerID: PeerID,
sessionGeneration: UUID? = nil
) throws -> Data {
guard NoisePayloadType.isPrivateFile(rawValue: data.first),
NoiseSecurityValidator.validatePrivateFileMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge
}
guard rateLimiter.allowMessage(from: peerID) else {
throw NoiseSecurityError.rateLimitExceeded
}
guard hasEstablishedSession(with: peerID) else {
onHandshakeRequired?(peerID)
throw NoiseEncryptionError.handshakeRequired
}
// `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed
// nonce/tag overhead, so the result is bounded without a second copy.
if let sessionGeneration {
return try sessionManager.encrypt(
data,
for: peerID,
expectedSessionGeneration: sessionGeneration
)
}
return try sessionManager.encrypt(data, for: peerID)
}
/// Decrypt data from a specific peer
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
try decryptWithSessionGeneration(data, from: peerID).plaintext
}
func decryptWithSessionGeneration(
_ data: Data,
from peerID: PeerID,
establishedGenerationIsReady: (UUID) -> Bool = { _ in true }
) throws -> (plaintext: Data, sessionGeneration: UUID) {
// Standard transport ciphertext has 20 bytes of nonce/tag overhead.
// A larger ciphertext is admitted only up to the framed-file ceiling;
// after authenticated decryption it must prove it is `.privateFile`.
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
let isAdmittedCiphertext = isStandardCiphertext
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(data)
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge
}
// A quarantined transport is deliberately unavailable for outbound
// state, but remains receive-only until the responder proves identity
// or the bounded rollback restores it.
guard sessionManager.hasReceiveSession(for: peerID) else {
// Check rate limit
guard rateLimiter.allowMessage(from: peerID) else {
throw NoiseSecurityError.rateLimitExceeded
}
// Check if we have an established session
guard hasEstablishedSession(with: peerID) else {
throw NoiseEncryptionError.sessionNotEstablished
}
let result = try sessionManager.decryptWithSessionGeneration(
data,
from: peerID,
establishedGenerationIsReady:
establishedGenerationIsReady,
authorizeDecrypt: { [rateLimiter] in
guard isAdmittedCiphertext else {
throw NoiseSecurityError.messageTooLarge
}
guard rateLimiter.allowMessage(from: peerID) else {
throw NoiseSecurityError.rateLimitExceeded
}
}
)
if !isStandardCiphertext {
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
throw NoiseSecurityError.messageTooLarge
}
}
return result
return try sessionManager.decrypt(data, from: peerID)
}
// MARK: - Peer Management
@@ -976,25 +755,6 @@ final class NoiseEncryptionService {
}
}
func sessionGeneration(for peerID: PeerID) -> UUID? {
sessionManager.sessionGeneration(for: peerID)
}
/// Runs `body` while holding a read lease on the exact session generation.
/// Session insertion, replacement, and removal use the same manager
/// barrier, so they cannot interleave with an authenticated-state commit.
func withCurrentSessionGeneration<Result>(
for peerID: PeerID,
expected: UUID,
_ body: () -> Result
) -> Result? {
sessionManager.withCurrentSessionGeneration(
for: peerID,
expected: expected,
body
)
}
func clearEphemeralStateForPanic() {
sessionManager.removeAllSessions()
serviceQueue.sync(flags: .barrier) {
@@ -1017,36 +777,24 @@ final class NoiseEncryptionService {
// MARK: - Private Helpers
private func handleSessionEstablished(
peerID: PeerID,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey,
sessionGeneration: UUID
) {
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
// Calculate fingerprint
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
// Registering handlers is synchronous, and this barrier snapshots them
// with the fingerprint update. Invoke the snapshot outside the queue:
// parallel Swift Testing workers must not block behind queued callback
// registration or allow a handler to re-enter serviceQueue.
let handlers: (
generationAware: [(PeerID, String, UUID) -> Void],
legacy: [(PeerID, String) -> Void]
) = serviceQueue.sync(flags: .barrier) {
// Store fingerprint mapping
serviceQueue.sync(flags: .barrier) {
peerFingerprints[peerID] = fingerprint
fingerprintToPeerID[fingerprint] = peerID
return (onPeerAuthenticatedWithGenerationHandlers, onPeerAuthenticatedHandlers)
}
// Log security event
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
// Notify all handlers about authentication.
handlers.generationAware.forEach { handler in
handler(peerID, fingerprint, sessionGeneration)
}
handlers.legacy.forEach { handler in
handler(peerID, fingerprint)
// Notify all handlers about authentication
serviceQueue.async { [weak self] in
self?.onPeerAuthenticatedHandlers.forEach { handler in
handler(peerID, fingerprint)
}
}
}
@@ -1067,26 +815,19 @@ final class NoiseEncryptionService {
let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey()
for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey {
// Attempt to rekey the session
do {
try initiateAutomaticRekey(for: peerID)
try sessionManager.initiateRekey(for: peerID)
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
// Signal that handshake is needed
onHandshakeRequired?(peerID)
} catch {
SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session)
}
}
}
private func initiateAutomaticRekey(for peerID: PeerID) throws {
let initiation = try sessionManager.initiateRekey(for: peerID)
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
onRekeyHandshakeReady?(peerID, initiation)
onHandshakeRequired?(peerID)
}
#if DEBUG
func _test_initiateAutomaticRekey(for peerID: PeerID) throws {
try initiateAutomaticRekey(for: peerID)
}
#endif
deinit {
stopRekeyTimer()
@@ -1174,9 +915,6 @@ struct NoiseMessage: Codable {
enum NoiseEncryptionError: Error {
case handshakeRequired
case sessionNotEstablished
/// Manager keys are established or restored, but BLE has not installed
/// generation-bound transport state. No receive nonce was consumed.
case transportGenerationNotReady
/// Envelope references a prekey ID we don't hold (never ours, already
/// deleted after its grace window, or wiped in a panic).
case unknownPrekey
+212
View File
@@ -0,0 +1,212 @@
import Foundation
enum SharedContentKind: String, Codable, Sendable, Equatable {
case text
case url
}
/// The single, bounded payload handed from the share extension to the app.
///
/// The app-group store intentionally contains at most one envelope. A newer
/// share replaces an older one, which prevents unbounded shared-container
/// growth while still surviving suspension and a later app launch.
struct SharedContentPayload: Codable, Sendable, Equatable, Identifiable {
static let currentVersion = 1
static let maxContentBytes = 16_000
static let maxTitleBytes = 512
static let maxEnvelopeBytes = 24_000
static let retentionSeconds: TimeInterval = 24 * 60 * 60
static let allowedFutureSkewSeconds: TimeInterval = 5 * 60
let version: Int
let id: UUID
let kind: SharedContentKind
let content: String
let title: String?
let createdAt: Date
init(
version: Int = Self.currentVersion,
id: UUID = UUID(),
kind: SharedContentKind,
content: String,
title: String? = nil,
createdAt: Date = Date()
) {
self.version = version
self.id = id
self.kind = kind
self.content = content
self.title = title
self.createdAt = createdAt
}
static func text(_ content: String, createdAt: Date = Date()) -> SharedContentPayload {
SharedContentPayload(kind: .text, content: content, createdAt: createdAt)
}
var composerText: String { content }
var preview: String {
let normalized = content
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
guard normalized.count > 240 else { return normalized }
return String(normalized.prefix(240)) + ""
}
func validate(now: Date = Date()) throws {
guard version == Self.currentVersion else {
throw SharedContentHandoffError.unsupportedVersion
}
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw SharedContentHandoffError.emptyContent
}
guard content.utf8.count <= Self.maxContentBytes else {
throw SharedContentHandoffError.contentTooLarge
}
if let title {
guard title.utf8.count <= Self.maxTitleBytes else {
throw SharedContentHandoffError.titleTooLarge
}
guard !Self.containsDisallowedControl(in: title, allowsTextLayout: false) else {
throw SharedContentHandoffError.invalidCharacters
}
}
let age = now.timeIntervalSince(createdAt)
guard age >= -Self.allowedFutureSkewSeconds,
age <= Self.retentionSeconds else {
throw SharedContentHandoffError.expired
}
switch kind {
case .text:
guard !Self.containsDisallowedControl(in: content, allowsTextLayout: true) else {
throw SharedContentHandoffError.invalidCharacters
}
case .url:
guard !Self.containsDisallowedControl(in: content, allowsTextLayout: false),
let components = URLComponents(string: content),
let scheme = components.scheme?.lowercased(),
scheme == "http" || scheme == "https",
components.host?.isEmpty == false else {
throw SharedContentHandoffError.unsupportedURL
}
}
}
private static func containsDisallowedControl(
in value: String,
allowsTextLayout: Bool
) -> Bool {
value.unicodeScalars.contains { scalar in
guard CharacterSet.controlCharacters.contains(scalar) else { return false }
if allowsTextLayout, scalar == "\n" || scalar == "\r" || scalar == "\t" {
return false
}
return true
}
}
}
enum SharedContentHandoffError: Error, Equatable {
case unsupportedVersion
case emptyContent
case contentTooLarge
case titleTooLarge
case invalidCharacters
case expired
case unsupportedURL
case envelopeTooLarge
case encodingFailed
}
/// Durable, single-item app-group storage used by both the extension and app.
final class SharedContentStore {
static let storageKey = "sharedContentEnvelopeV1"
private static let legacyKeys = [
"sharedContent",
"sharedContentType",
"sharedContentDate"
]
private let defaults: UserDefaults
private let encoder: JSONEncoder
private let decoder: JSONDecoder
init(defaults: UserDefaults) {
self.defaults = defaults
self.encoder = JSONEncoder()
self.decoder = JSONDecoder()
}
/// Replaces any older pending share with a validated, bounded envelope.
func stage(_ payload: SharedContentPayload, now: Date = Date()) throws {
try payload.validate(now: now)
guard let encoded = try? encoder.encode(payload) else {
throw SharedContentHandoffError.encodingFailed
}
guard encoded.count <= SharedContentPayload.maxEnvelopeBytes else {
throw SharedContentHandoffError.envelopeTooLarge
}
defaults.set(encoded, forKey: Self.storageKey)
clearLegacyKeys()
}
/// Reads the pending share without consuming it. Invalid and expired data
/// is removed immediately so malformed app-group state cannot linger.
func pending(now: Date = Date()) -> SharedContentPayload? {
clearLegacyKeys()
guard let encoded = defaults.data(forKey: Self.storageKey) else { return nil }
guard encoded.count <= SharedContentPayload.maxEnvelopeBytes,
let payload = try? decoder.decode(SharedContentPayload.self, from: encoded) else {
defaults.removeObject(forKey: Self.storageKey)
return nil
}
do {
try payload.validate(now: now)
return payload
} catch {
defaults.removeObject(forKey: Self.storageKey)
return nil
}
}
/// Consumes only the envelope the user actually reviewed. If a newer share
/// already replaced it, the newer content remains pending.
func consume(id: UUID, now: Date = Date()) -> SharedContentPayload? {
guard let payload = pending(now: now), payload.id == id else { return nil }
defaults.removeObject(forKey: Self.storageKey)
return payload
}
/// Explicit cancellation has the same identity guard as consumption so it
/// can never discard a newer share that arrived while a prompt was open.
func discard(id: UUID) {
guard let encoded = defaults.data(forKey: Self.storageKey),
encoded.count <= SharedContentPayload.maxEnvelopeBytes,
let payload = try? decoder.decode(SharedContentPayload.self, from: encoded),
payload.id == id else {
return
}
defaults.removeObject(forKey: Self.storageKey)
}
func discardAll() {
defaults.removeObject(forKey: Self.storageKey)
clearLegacyKeys()
}
private func clearLegacyKeys() {
for key in Self.legacyKeys {
defaults.removeObject(forKey: key)
}
}
}
@@ -11,7 +11,6 @@ final class TransferProgressManager {
case updated(id: String, sentFragments: Int, totalFragments: Int)
case completed(id: String, totalFragments: Int)
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
case rejected(id: String, reason: String)
}
private let subject = PassthroughSubject<Event, Never>()
@@ -50,17 +49,6 @@ final class TransferProgressManager {
}
}
/// Fails a preflight check while keeping the outgoing placeholder visible
/// with an actionable reason instead of treating policy/size rejection as
/// a user cancellation.
func rejectBeforeStart(id: String, reason: String) {
queue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self.states.removeValue(forKey: id)
self.subject.send(.rejected(id: id, reason: reason))
}
}
func snapshot(id: String) -> (sent: Int, total: Int)? {
var result: (sent: Int, total: Int)?
queue.sync {
-74
View File
@@ -83,31 +83,10 @@ enum TransportEvent: @unchecked Sendable {
case bluetoothStateUpdated(CBManagerState)
}
/// Downgrade-safe decision for a private-media recipient. Callers ask before
/// prompting, and BLEService checks again when it consumes any one-shot
/// legacy consent.
enum PrivateMediaSendPolicy: Equatable {
case encrypted
/// A public announce hinted at encrypted media (or a prior authenticated
/// pin exists), but this exact Noise session has not yet supplied its
/// authenticated peer-state proof. Callers wait boundedly; they must not
/// pre-queue encrypted bytes or silently select the legacy path.
case awaitingCapabilityProof
case legacyRequiresConsent
case blockedDowngrade
}
protocol TransportEventDelegate: AnyObject {
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
}
/// Optional typed-event contract for sinks that can synchronously decide
/// whether an inbound message was accepted.
protocol SynchronousMessageTransportEventDelegate: TransportEventDelegate {
@MainActor
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool
}
protocol Transport: AnyObject {
// Event sink
var delegate: BitchatDelegate? { get set }
@@ -184,20 +163,6 @@ protocol Transport: AnyObject {
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
)
/// Automatic whole-file retry is admitted only while this exact Noise
/// generation authenticates bit 9. It must never queue across a session
/// replacement or enter the signed raw legacy path.
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
)
func cancelTransfer(_ transferId: String)
// Live voice / push-to-talk (mesh transports only): one encoded
@@ -243,16 +208,6 @@ protocol Transport: AnyObject {
/// Capabilities the peer advertised in its last verified announce;
/// empty for peers that predate the capabilities TLV.
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
/// The exact current Noise generation that authenticated both encrypted
/// private media (bit 8) and durable receipts/retry (bit 9).
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID?
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
)
/// Sends an encoded vouch-attestation batch inside the Noise session.
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
/// Appends a peer-authenticated observer. Unlike
@@ -323,21 +278,6 @@ extension Transport {
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
func broadcastGroupMessage(_ envelope: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
nil
}
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
let policy = privateMediaSendPolicy(to: peerID)
Task { @MainActor in
completion(policy == .awaitingCapabilityProof ? .blockedDowngrade : policy)
}
}
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
@@ -354,20 +294,6 @@ extension Transport {
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
guard !allowLegacyFallback else { return }
sendFilePrivate(packet, to: peerID, transferId: transferId)
}
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
) {}
func cancelTransfer(_ transferId: String) {}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
-26
View File
@@ -9,19 +9,6 @@ enum TransportConfig {
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
// Bounded wait for the session-authenticated capability proof used by
// private-media migration. Expiry never auto-sends clear bytes; it only
// resolves to the existing one-shot consent or downgrade-blocked path.
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
/// Accepted private-media receipts and explicit-deletion tombstones each
/// receive this independent capacity.
static let privateMediaReceivedLedgerCapacity: Int = 4_096
/// A bounded retry horizon prevents stable receipt state from growing into
/// permanent application history.
static let privateMediaReceivedLedgerTTLSeconds: TimeInterval =
7 * 24 * 60 * 60
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
@@ -59,7 +46,6 @@ enum TransportConfig {
static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337
static let geoTimelineCap: Int = 1337
static let geoNicknameParticipantsCap: Int = 1337
static let contentLRUCap: Int = 2000
static let geoSamplingEventLRUCap: Int = 2000
@@ -95,11 +81,6 @@ enum TransportConfig {
static let nostrDuplicateEventLogInterval: Int = 50
// Sample interval for per-event debug logs on the inbound hot path.
static let nostrInboundEventLogInterval: Int = 100
// Reject oversized/untrusted relay frames before JSON parse / store.
static let nostrMaxInboundMessageBytes: Int = 256 * 1024
static let nostrMaxEventTags: Int = 64
static let nostrMaxEventTagValues: Int = 16
static let nostrMaxEventTagValueBytes: Int = 1024
// Conversation store diagnostics (field observability)
// Sample interval for the periodic store-audit "OK" heartbeat line
@@ -117,12 +98,6 @@ enum TransportConfig {
static let uiSenderRateBucketRefillPerSec: Double = 1.0
static let uiContentRateBucketCapacity: Double = 3
static let uiContentRateBucketRefillPerSec: Double = 0.5
// Bound attacker-keyed bucket maps (sender IDs / content digests).
static let uiSenderRateBucketMaxEntries: Int = 2000
static let uiContentRateBucketMaxEntries: Int = 2000
static let uiRateBucketIdleTTL: TimeInterval = 10 * 60
// Cap teleported-participant markers so remote events cannot grow the set.
static let geoTeleportedParticipantsCap: Int = 1337
// UI sleeps/delays
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
@@ -339,7 +314,6 @@ enum TransportConfig {
// Share extension
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
// Gossip Sync Configuration
-40
View File
@@ -1,40 +0,0 @@
import Foundation
/// In-app override for the UI language, on top of the system per-app
/// language. Apple resolves localization from the AppleLanguages default at
/// process start, so a new choice takes effect on the next launch callers
/// surface a "restart to apply" note after changing it.
enum AppLanguageSettings {
/// "" means no override: follow the device (or per-app system) language.
static let overrideKey = "app.languageOverride"
private static let appleLanguagesKey = "AppleLanguages"
/// Language codes the app ships translations for, straight from the
/// built bundle so this never drifts from the string catalog.
static var availableLanguages: [String] {
Bundle.main.localizations
.filter { $0 != "Base" }
.sorted { endonym(for: $0).localizedCaseInsensitiveCompare(endonym(for: $1)) == .orderedAscending }
}
/// The language's name in that language ("فارسی", "") so every user
/// can find their own entry regardless of the current UI language.
static func endonym(for code: String) -> String {
let locale = Locale(identifier: code)
let name = locale.localizedString(forIdentifier: code) ?? code
return name.lowercased(with: locale)
}
/// Persists the override (nil clears it). AppleLanguages drives the
/// actual localization lookup on next launch.
static func setOverride(_ code: String?) {
let defaults = UserDefaults.standard
if let code, !code.isEmpty {
defaults.set(code, forKey: overrideKey)
defaults.set([code], forKey: appleLanguagesKey)
} else {
defaults.removeObject(forKey: overrideKey)
defaults.removeObject(forKey: appleLanguagesKey)
}
}
}
@@ -55,9 +55,6 @@ extension ChatViewModel: ChatDeliveryContext {
func markMessageDelivered(_ messageID: String) {
messageRouter.markDelivered(messageID)
mediaTransferCoordinator.confirmPrivateMediaDelivery(
messageID: messageID
)
}
}
@@ -10,7 +10,6 @@ import Foundation
@MainActor
protocol ChatLiveVoiceContext: AnyObject {
var nickname: String { get }
var myPeerID: PeerID { get }
var selectedPrivateChatPeer: PeerID? { get }
/// Whether the public mesh timeline is what's on screen (autoplay gate
/// for public bursts).
@@ -31,12 +30,6 @@ protocol ChatLiveVoiceContext: AnyObject {
func upsertPublicMeshMessage(_ message: BitchatMessage)
@discardableResult
func removePrivateMessage(withID messageID: String) -> BitchatMessage?
/// Records and sends the finalized note's read receipt after a live
/// bubble adopts its wire-derivable message ID.
func hasSentReadReceipt(_ messageID: String) -> Bool
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
/// Removes a message from whichever conversation holds it.
func removeMessage(withID messageID: String, cleanupFile: Bool)
/// Publishes who is currently talking live in the public mesh channel
@@ -233,21 +226,6 @@ final class ChatLiveVoiceCoordinator {
assemblies.values.contains { $0.messageID == message.id }
}
/// Stop every live file handle/player before the panic media directory is
/// removed. This prevents an in-flight assembly from continuing to write
/// through an unlinked file after the wipe returns.
func resetForPanic() {
for assembly in Array(assemblies.values) {
cancelAssembly(assembly)
}
for player in drainingPlayers.values {
player.stop()
}
drainingPlayers.removeAll(keepingCapacity: false)
finishedBursts.removeAll(keepingCapacity: false)
updatePublicTalkerIndicator()
}
/// Called for every inbound private message: when it is the finalized
/// voice note of a burst we assembled (matched by burst ID in the file
/// name), swap it into the existing live bubble and report `true` so the
@@ -279,16 +257,8 @@ final class ChatLiveVoiceCoordinator {
guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false }
let finished = entry.value
// A DM live bubble starts before the finalized file exists and
// therefore has a receiver-local random ID. Adopt the finalized
// message's deterministic ID so delivery/read ACKs address the same
// row as the sender's media placeholder. Public notes retain their
// live-bubble ID because public transfers have no private receipts.
let replacementID = finished.scope == .directMessage
? message.id
: finished.messageID
let replacement = BitchatMessage(
id: replacementID,
id: finished.messageID,
sender: message.sender,
content: message.content,
timestamp: finished.messageTimestamp,
@@ -302,31 +272,7 @@ final class ChatLiveVoiceCoordinator {
)
switch finished.scope {
case .directMessage:
// Capture read state before rekeying. The user may have read the
// live bubble and navigated away before the finalized .m4a lands.
let shouldSendAdoptedReadReceipt =
context.hasSentReadReceipt(finished.messageID)
|| context.selectedPrivateChatPeer == finished.peerID
// Insert first so replacing the only row in a DM never
// transiently deletes its conversation, unread state, or current
// selection. Then remove the receiver-local live-bubble alias.
context.upsertPrivateMessage(replacement, in: finished.peerID)
if replacementID != finished.messageID {
context.removePrivateMessage(withID: finished.messageID)
}
// The live bubble may already have emitted a receiver-local READ
// before the sender created its finalized media row. Re-emit once
// for the adopted stable ID now that the file has arrived.
if shouldSendAdoptedReadReceipt,
context.markReadReceiptSent(replacementID) {
let receipt = ReadReceipt(
originalMessageID: replacementID,
readerID: context.myPeerID,
readerNickname: context.nickname
)
context.sendMeshReadReceipt(receipt, to: finished.peerID)
}
case .publicMesh:
context.upsertPublicMeshMessage(replacement)
}
File diff suppressed because it is too large Load Diff
@@ -71,8 +71,12 @@ final class ChatMessageFormatter {
let content = message.content
let nsContent = content as NSString
let nsLen = nsContent.length
let containsCashuEarly: Bool = {
let regex = Patterns.quickCashuPresence
return regex.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
}()
if content.isOversizedForRichFormatting() {
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
var plainStyle = AttributeContainer()
plainStyle.foregroundColor = baseColor
plainStyle.font = isSelf
@@ -100,14 +100,9 @@ final class ChatPeerListCoordinator: @unchecked Sendable {
func didUpdatePeerList(_ peers: [PeerID]) {
Task { @MainActor [weak self] in
self?.didUpdatePeerListSynchronously(peers)
self?.handlePeerListUpdate(peers)
}
}
@MainActor
func didUpdatePeerListSynchronously(_ peers: [PeerID]) {
handlePeerListUpdate(peers)
}
}
private extension ChatPeerListCoordinator {
@@ -163,18 +163,19 @@ final class ChatTransportEventCoordinator {
}
func didReceiveMessage(_ message: BitchatMessage) {
runOnMain { [self] context in
handleReceivedMessage(message, in: context)
}
}
runOnMain { context in
guard !context.isMessageBlocked(message) else { return }
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
/// Typed transport events already arrive on the main actor. Handle them
/// synchronously so observers see the ConversationStore mutation before
/// the transport completes delivery.
@MainActor
@discardableResult
func didReceiveMessageSynchronously(_ message: BitchatMessage) -> Bool {
handleReceivedMessage(message, in: context)
if message.isPrivate {
context.handlePrivateMessage(message)
} else {
context.handlePublicMessage(message)
}
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
}
func didReceivePublicMessage(
@@ -184,34 +185,26 @@ final class ChatTransportEventCoordinator {
timestamp: Date,
messageID: String?
) {
runOnMain { [self] context in
handlePublicMessage(
from: peerID,
nickname: nickname,
content: content,
runOnMain { context in
let normalized = content.trimmed
let mentions = context.parseMentions(from: normalized)
let message = BitchatMessage(
id: messageID,
sender: nickname,
content: normalized,
timestamp: timestamp,
messageID: messageID,
in: context
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: mentions.isEmpty ? nil : mentions
)
}
}
@MainActor
func didReceivePublicMessageSynchronously(
from peerID: PeerID,
nickname: String,
content: String,
timestamp: Date,
messageID: String?
) {
handlePublicMessage(
from: peerID,
nickname: nickname,
content: content,
timestamp: timestamp,
messageID: messageID,
in: context
)
context.handlePublicMessage(message)
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
}
func didReceiveNoisePayload(
@@ -231,134 +224,59 @@ final class ChatTransportEventCoordinator {
}
}
@MainActor
func didReceiveNoisePayloadSynchronously(
from peerID: PeerID,
type: NoisePayloadType,
payload: Data,
timestamp: Date
) {
handleNoisePayload(
from: peerID,
type: type,
payload: payload,
timestamp: timestamp,
in: context
)
}
func didConnectToPeer(_ peerID: PeerID) {
runOnMain { [weak self] _ in
self?.didConnectToPeerSynchronously(peerID)
}
}
@MainActor
func didConnectToPeerSynchronously(_ peerID: PeerID) {
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
context.isConnected = true
context.registerEphemeralSession(peerID: peerID)
context.notifyUIChanged()
runOnMain { context in
context.isConnected = true
context.registerEphemeralSession(peerID: peerID)
context.notifyUIChanged()
if let peer = context.unifiedPeer(for: peerID) {
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
context.cacheStablePeerID(stablePeerID, for: peerID)
if let peer = context.unifiedPeer(for: peerID) {
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
context.cacheStablePeerID(stablePeerID, for: peerID)
}
context.flushRouterOutbox(for: peerID)
context.retryCourierDeposits(via: peerID)
}
context.flushRouterOutbox(for: peerID)
context.retryCourierDeposits(via: peerID)
}
func didDisconnectFromPeer(_ peerID: PeerID) {
runOnMain { [weak self] _ in
self?.didDisconnectFromPeerSynchronously(peerID)
}
}
@MainActor
func didDisconnectFromPeerSynchronously(_ peerID: PeerID) {
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
context.removeEphemeralSession(peerID: peerID)
runOnMain { context in
context.removeEphemeralSession(peerID: peerID)
var stablePeerID = context.cachedStablePeerID(for: peerID)
if stablePeerID == nil,
let key = context.noiseSessionPublicKeyData(for: peerID) {
let derivedPeerID = PeerID(hexData: key)
context.cacheStablePeerID(derivedPeerID, for: peerID)
stablePeerID = derivedPeerID
var stablePeerID = context.cachedStablePeerID(for: peerID)
if stablePeerID == nil,
let key = context.noiseSessionPublicKeyData(for: peerID) {
let derivedPeerID = PeerID(hexData: key)
context.cacheStablePeerID(derivedPeerID, for: peerID)
stablePeerID = derivedPeerID
}
if let currentPeerID = context.selectedPrivateChatPeer,
currentPeerID == peerID,
let stablePeerID {
self.migrateSelectedConversationIfNeeded(
from: peerID,
to: stablePeerID,
in: context
)
}
let receiptIDs = context.privateMessages(for: peerID)
.filter { $0.senderPeerID == peerID }
.map(\.id)
context.unmarkReadReceiptsSent(receiptIDs)
context.notifyUIChanged()
}
if let currentPeerID = context.selectedPrivateChatPeer,
currentPeerID == peerID,
let stablePeerID {
migrateSelectedConversationIfNeeded(
from: peerID,
to: stablePeerID,
in: context
)
}
let receiptIDs = context.privateMessages(for: peerID)
.filter { $0.senderPeerID == peerID }
.map(\.id)
context.unmarkReadReceiptsSent(receiptIDs)
context.notifyUIChanged()
}
}
private extension ChatTransportEventCoordinator {
@MainActor
func handlePublicMessage(
from peerID: PeerID,
nickname: String,
content: String,
timestamp: Date,
messageID: String?,
in context: any ChatTransportEventContext
) {
let normalized = content.trimmed
let mentions = context.parseMentions(from: normalized)
let message = BitchatMessage(
id: messageID,
sender: nickname,
content: normalized,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: mentions.isEmpty ? nil : mentions
)
context.handlePublicMessage(message)
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
@MainActor
@discardableResult
func handleReceivedMessage(
_ message: BitchatMessage,
in context: any ChatTransportEventContext
) -> Bool {
guard !context.isMessageBlocked(message) else { return false }
guard !message.content.trimmed.isEmpty || message.isPrivate else { return false }
if message.isPrivate {
context.handlePrivateMessage(message)
} else {
context.handlePublicMessage(message)
}
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
return true
}
func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) {
Task { @MainActor [weak context = self.context] in
guard let context else { return }
@@ -489,13 +407,6 @@ private extension ChatTransportEventCoordinator {
case .voiceFrame:
context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp)
case .privateFile, .authenticatedPeerState:
// BLEService validates and persists decrypted private files before
// emitting a normal `.messageReceived` event, and consumes peer
// state inside the transport. Neither payload crosses this
// UI-facing typed-payload fallback.
break
}
}
@@ -58,7 +58,6 @@ protocol ChatVerificationContext: AnyObject {
func noiseStaticPublicKeyData() -> Data
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
func triggerHandshake(with peerID: PeerID)
func privateMediaPeerDidAuthenticate(_ peerID: PeerID)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
@@ -117,10 +116,6 @@ extension ChatViewModel: ChatVerificationContext {
meshService.noiseStaticPublicKeyData()
}
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {
mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort())
}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
}
@@ -134,10 +129,6 @@ extension ChatViewModel: ChatVerificationContext {
}
}
extension ChatVerificationContext {
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {}
}
@MainActor
final class ChatVerificationCoordinator {
struct PendingVerification {
@@ -206,7 +197,6 @@ final class ChatVerificationCoordinator {
guard let self else { return }
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
self.context.privateMediaPeerDidAuthenticate(peerID)
if self.context.isVerifiedFingerprint(fingerprint) {
self.context.setEncryptionStatus(.noiseVerified, for: peerID)
+73 -368
View File
@@ -89,30 +89,10 @@ import UIKit
#endif
import UniformTypeIdentifiers
struct PanicNetworkLifecycle {
let stop: @MainActor () -> Void
let restart: @MainActor () -> Void
static let noop = PanicNetworkLifecycle(stop: {}, restart: {})
static var live: PanicNetworkLifecycle {
PanicNetworkLifecycle(
stop: {
GeohashPresenceService.shared.stopForPanic()
NetworkActivationService.shared.stopForPanic()
},
restart: {
NetworkActivationService.shared.start()
GeohashPresenceService.shared.start()
}
)
}
}
/// Manages the application state and business logic for BitChat.
/// Acts as the primary coordinator between UI components and backend services,
/// implementing the BitchatDelegate protocol to handle network events.
final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessageTransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
// Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled)
typealias Patterns = MessageFormattingEngine.Patterns
@@ -162,8 +142,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
@Published var currentColorScheme: ColorScheme = .light
@Published var currentTheme: AppTheme = .matrix
@Published var isConnected = false
@Published private(set) var panicRecoveryBlocked = false
var networkActivationAllowed: Bool { !panicRecoveryBlocked }
@Published var nickname: String = "" {
didSet {
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
@@ -173,7 +151,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
return
}
// Update mesh service nickname if it's initialized
if !isPanicResetting, !meshService.myPeerID.isEmpty {
if !meshService.myPeerID.isEmpty {
meshService.setNickname(nickname)
}
}
@@ -199,10 +177,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(
context: self,
sweepsOnInit: !TestEnvironment.isRunningTests
)
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(context: self)
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
@@ -317,9 +292,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
var nostrRelayManager: NostrRelayManager?
private let userDefaults = UserDefaults.standard
let keychain: KeychainManagerProtocol
private let panicRecoveryOperations: PanicRecoveryOperations
private let panicNetworkLifecycle: PanicNetworkLifecycle
private var isPanicResetting = false
/// Private group membership: keys in the keychain, metadata on disk.
let groupStore: GroupStore
private let nicknameKey = "bitchat.nickname"
@@ -375,8 +347,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
@Published var showBluetoothAlert = false
@Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
if Thread.isMainThread {
@@ -495,12 +465,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
}
}
/// Whether a read receipt has already been recorded for `messageID`.
@MainActor
func hasSentReadReceipt(_ messageID: String) -> Bool {
sentReadReceipts.contains(messageID)
}
/// Records that a read receipt is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@MainActor
@@ -805,34 +769,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared
) {
let livePanicRecoveryOperations = PanicRecoveryOperations.live()
let startSuspendedForRecovery: Bool
do {
startSuspendedForRecovery =
try livePanicRecoveryOperations.isPending()
} catch {
startSuspendedForRecovery = true
}
// Preserve the preflight decision used to defer CoreBluetooth. A
// transiently successful second read must not skip recovery and leave
// the service permanently suspended without running the wipe.
let panicRecoveryOperations = PanicRecoveryOperations(
isPending: {
if startSuspendedForRecovery {
return true
}
return try livePanicRecoveryOperations.isPending()
},
begin: livePanicRecoveryOperations.begin,
wipeMedia: livePanicRecoveryOperations.wipeMedia,
complete: livePanicRecoveryOperations.complete
)
let meshService = BLEService(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
startSuspendedForPanicRecovery: startSuspendedForRecovery
)
let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
meshService.sfMetrics = .shared
self.init(
keychain: keychain,
@@ -844,9 +781,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
locationManager: locationManager,
outboxStore: MessageOutboxStore(keychain: keychain),
sfMetrics: .shared,
panicRecoveryOperations: panicRecoveryOperations,
panicNetworkLifecycle: .live
sfMetrics: .shared
)
}
@@ -864,10 +799,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
locationManager: LocationChannelManager = .shared,
readReceiptsDefaults: UserDefaults? = nil,
outboxStore: MessageOutboxStore? = nil,
sfMetrics: StoreAndForwardMetrics? = nil,
panicMediaWipe: (() throws -> Void)? = nil,
panicRecoveryOperations: PanicRecoveryOperations? = nil,
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
sfMetrics: StoreAndForwardMetrics? = nil
) {
let conversations = conversations ?? ConversationStore()
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
@@ -882,9 +814,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
)
self.keychain = keychain
self.panicRecoveryOperations = panicRecoveryOperations
?? .ephemeral(wipeMedia: panicMediaWipe ?? {})
self.panicNetworkLifecycle = panicNetworkLifecycle
self.groupStore = GroupStore(keychain: keychain)
self.idBridge = idBridge
self.identityManager = identityManager
@@ -920,31 +849,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
}
.store(in: &cancellables)
let recoveryRequired: Bool
do {
recoveryRequired = try self.panicRecoveryOperations.isPending()
} catch {
// Failure to read the latch cannot fail open. Re-run the complete
// transaction; a persistent storage failure leaves services
// blocked below.
recoveryRequired = true
SecureLogger.error(
"Could not read panic-recovery state; retrying the full wipe before startup: \(error)",
category: .security
)
}
if recoveryRequired {
SecureLogger.warning(
"Pending panic recovery detected; wiping before runtime services start",
category: .security
)
_ = panicClearAllData(restartServices: false)
}
if networkActivationAllowed {
ChatViewModelBootstrapper(viewModel: self).configure()
}
ChatViewModelBootstrapper(viewModel: self).configure()
}
// MARK: - Deinitialization
@@ -1248,37 +1153,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// PANIC: Emergency data clearing for activist safety
@MainActor
@discardableResult
func panicClearAllData(restartServices: Bool = true) -> Bool {
panicRecoveryBlocked = true
isPanicResetting = true
defer { isPanicResetting = false }
// Stop internet and location-presence work before clearing identity or
// state. These services cancel their subscriptions and delayed tasks,
// so old callbacks cannot reconnect during the transaction.
panicNetworkLifecycle.stop()
// Establish both independent durable intents before erasing anything.
// `wipeMedia` will still attempt deletion if neither write succeeds.
let recoveryIntent = panicRecoveryOperations.begin()
// Quiesce the mesh before clearing stores. Identity replacement below
// deliberately stays stopped until media deletion and marker commit.
if let bleService = meshService as? BLEService {
bleService.suspendForPanicReset()
} else {
meshService.emergencyDisconnectAll()
}
// Invalidate detached media preparation and close live capture file
// handles before clearing state or removing the media directory.
mediaTransferCoordinator.resetForPanic()
liveVoiceCoordinator.resetForPanic()
// Deny and release any clear-media confirmations before identities,
// message state, and local files are wiped.
cancelAllLegacyPrivateMediaConsents()
func panicClearAllData() {
// Messages are processed immediately - nothing to flush
// Clear all messages (public timelines and private chats live in the
// single-writer ConversationStore; the derived `messages` view and
@@ -1287,13 +1163,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
pendingGeohashSystemMessages.removeAll()
// Delete all keychain data (including Noise and Nostr keys)
let keychainWipeCompleted = keychain.deleteAllKeychainData()
if !keychainWipeCompleted {
SecureLogger.error(
"Panic keychain cleanup incomplete; recovery remains pending",
category: .security
)
}
_ = keychain.deleteAllKeychainData()
// Clear UserDefaults identity data
userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey")
@@ -1306,14 +1176,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// Reset nickname to anonymous
nickname = "anon\(Int.random(in: 1000...9999))"
userDefaults.set(nickname, forKey: nicknameKey)
saveNickname()
// Clear favorites and peer mappings
// Clear through SecureIdentityStateManager instead of directly
identityManager.clearAllIdentityData()
peerIdentityStore.clearAll()
locationPresenceStore.reset()
publicRateLimiter.reset()
// Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites()
@@ -1378,77 +1247,78 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// Clear Nostr identity associations
idBridge.clearAllAssociations()
// Replace the BLE identity while keeping the radio stopped. It may
// reopen only after the durable panic transaction commits.
// Disconnect from all peers and clear persistent identity
// This will force creation of a new identity (new fingerprint) on next launch
meshService.emergencyDisconnectAll()
if let bleService = meshService as? BLEService {
bleService.resetIdentityForPanic(
currentNickname: nickname,
restartServices: false
)
} else {
meshService.setNickname(nickname)
bleService.resetIdentityForPanic(currentNickname: nickname)
}
// The wipe must finish before this security action returns. A detached
// task could otherwise lose a race with a new capture or app exit and
// leave pre-panic media behind.
let panicCompleted: Bool
do {
try panicRecoveryOperations.wipeMedia(recoveryIntent)
if keychainWipeCompleted {
try panicRecoveryOperations.complete()
panicCompleted = true
SecureLogger.info(
"🗑️ Deleted all media files during panic clear",
category: .session
)
} else {
// Do not clear either durable recovery marker. Startup must
// retry the entire transaction before any transport restarts.
panicCompleted = false
}
} catch {
panicCompleted = false
SecureLogger.error(
"Panic transaction did not commit; services remain stopped: \(error)",
category: .security
)
}
panicRecoveryBlocked = !panicCompleted
// No need to force UserDefaults synchronization
// BCH-01-013: Clear iOS app switcher snapshots. Keep tests away from
// the host user's real cache tree just as the default media wipe does.
#if os(iOS)
// Reinitialize Nostr with new identity
// This will generate new Nostr keys derived from new Noise keys.
// Skipped under tests: connecting the shared relay singleton starts
// real network/reconnect work that never completes and would keep the
// test process alive (the singleton, unlike a discardable instance, is
// never deallocated to cancel it).
if !TestEnvironment.isRunningTests {
Self.clearAppSwitcherSnapshots()
}
#endif
Task { @MainActor in
// Small delay to ensure cleanup completes
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
guard panicCompleted else { return false }
if let bleService = meshService as? BLEService {
// Startup recovery reopens admission but leaves actual service
// start to the bootstrapper immediately after this method.
bleService.completePanicReset(
restartServices: restartServices
)
}
if restartServices {
// All persistent state and media are gone. Bring each service back
// only now, under the new identity.
if !(meshService is BLEService) {
meshService.startServices()
}
if !TestEnvironment.isRunningTests {
// Reinitialize Nostr relay manager with new identity. Reuse the
// shared singleton every other component (NostrTransport, geohash
// subscriptions, AppRuntime observers) is bound to `.shared`, so
// creating a fresh instance here would split relay state and leave
// sends running against a disconnected manager.
nostrRelayManager = NostrRelayManager.shared
setupNostrMessageHandling()
nostrRelayManager?.connect()
}
panicNetworkLifecycle.restart()
}
return true
// Delete ALL media files (incoming and outgoing) in background
Task.detached(priority: .utility) {
// Skipped under tests: the test process shares the user's real
// ~/Library/Application Support/files tree, and this detached
// utility-priority wipe fires at a nondeterministic time
// deleting media that concurrently running tests (e.g. the
// sendImage flow) just wrote there, and the developer's real
// app data with it.
guard !TestEnvironment.isRunningTests else { return }
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
// Delete the entire files directory and recreate it
if FileManager.default.fileExists(atPath: filesDir.path) {
try FileManager.default.removeItem(at: filesDir)
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
}
// Recreate empty directory structure
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
} catch {
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
}
// BCH-01-013: Clear iOS app switcher snapshots
// These are stored in Library/Caches/Snapshots/<bundle_id>/
#if os(iOS)
Self.clearAppSwitcherSnapshots()
#endif
}
// Force immediate UI update for panic mode
// UI updates immediately - no flushing needed
}
/// BCH-01-013: Clear iOS app switcher snapshots during panic mode
@@ -1707,82 +1577,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
@MainActor
func didReceiveTransportEvent(_ event: TransportEvent) {
switch event {
case .messageReceived(let message):
_ = didReceiveTransportMessageSynchronously(message)
case let .publicMessageReceived(
peerID,
nickname,
content,
timestamp,
messageID
):
transportEventCoordinator.didReceivePublicMessageSynchronously(
from: peerID,
nickname: nickname,
content: content,
timestamp: timestamp,
messageID: messageID
)
case let .noisePayloadReceived(peerID, type, payload, timestamp):
transportEventCoordinator.didReceiveNoisePayloadSynchronously(
from: peerID,
type: type,
payload: payload,
timestamp: timestamp
)
case let .groupMessageReceived(payload, timestamp):
groupCoordinator.handleGroupMessagePayload(
payload,
timestamp: timestamp
)
case let .publicVoiceFrameReceived(
peerID,
nickname,
payload,
timestamp
):
liveVoiceCoordinator.handlePublicVoiceFramePayload(
from: peerID,
nickname: nickname,
payload: payload,
timestamp: timestamp
)
case .peerConnected(let peerID):
transportEventCoordinator.didConnectToPeerSynchronously(peerID)
mediaTransferCoordinator.peerDidReconnect(peerID)
case .peerDisconnected(let peerID):
transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID)
case .peerListUpdated(let peers):
peerListCoordinator.didUpdatePeerListSynchronously(peers)
// A peer-list update follows every verified announce, which is
// where a peer's `.vouch` capability actually arrives.
vouchCoordinator.peersUpdated(peers)
case .peerSnapshotsUpdated:
break
case let .messageDeliveryStatusUpdated(messageID, status):
deliveryCoordinator.didUpdateMessageDeliveryStatus(
messageID,
status: status
)
case .bluetoothStateUpdated(let state):
updateBluetoothState(state)
}
}
@MainActor
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool {
transportEventCoordinator.didReceiveMessageSynchronously(message)
receiveTransportEvent(event)
}
func didReceiveMessage(_ message: BitchatMessage) {
@@ -1845,9 +1640,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
func didConnectToPeer(_ peerID: PeerID) {
transportEventCoordinator.didConnectToPeer(peerID)
Task { @MainActor [weak self] in
self?.mediaTransferCoordinator.peerDidReconnect(peerID)
}
}
func didDisconnectFromPeer(_ peerID: PeerID) {
@@ -2012,91 +1804,4 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
publicConversationCoordinator.sendHapticFeedback(for: message)
}
}
@MainActor
extension ChatViewModel {
func enqueueLegacyPrivateMediaConsent(
for peerID: PeerID,
transferId: String,
messageID: String,
completion: @escaping @MainActor (Bool) -> Void
) {
let request = LegacyPrivateMediaConsentRequest(
id: UUID(),
peerID: peerID,
peerName: nicknameForPeer(peerID),
transferId: transferId,
messageID: messageID
)
pendingLegacyPrivateMediaConsents.append(PendingLegacyPrivateMediaConsent(
request: request,
completion: completion
))
if legacyPrivateMediaConsentRequest == nil {
legacyPrivateMediaConsentRequest = request
}
}
func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) {
// SwiftUI may report both the selected button and the presentation
// binding's dismissal. Resolve only the exact request that was shown;
// a duplicate callback for it must not consume the next queued send.
guard legacyPrivateMediaConsentRequest?.id == requestID,
pendingLegacyPrivateMediaConsents.first?.request.id == requestID else {
return
}
let resolved = pendingLegacyPrivateMediaConsents.removeFirst()
// Drive the boolean presentation state through false before showing
// the next queued per-send warning. Otherwise SwiftUI sees truetrue,
// closes the first dialog, and never presents the second.
legacyPrivateMediaConsentRequest = nil
resolved.completion(approved)
presentNextLegacyPrivateMediaConsentDeferred()
}
func invalidateLegacyPrivateMediaConsent(transferId: String, messageID: String) {
let invalidatedIDs = Set(
pendingLegacyPrivateMediaConsents.compactMap { pending -> UUID? in
let request = pending.request
return request.transferId == transferId && request.messageID == messageID
? request.id
: nil
}
)
guard !invalidatedIDs.isEmpty else { return }
pendingLegacyPrivateMediaConsents.removeAll {
invalidatedIDs.contains($0.request.id)
}
if let currentID = legacyPrivateMediaConsentRequest?.id,
invalidatedIDs.contains(currentID) {
legacyPrivateMediaConsentRequest = nil
presentNextLegacyPrivateMediaConsentDeferred()
}
}
func cancelAllLegacyPrivateMediaConsents() {
let pending = pendingLegacyPrivateMediaConsents
pendingLegacyPrivateMediaConsents.removeAll()
legacyPrivateMediaConsentRequest = nil
for item in pending {
item.completion(false)
}
}
private func presentNextLegacyPrivateMediaConsentDeferred() {
guard legacyPrivateMediaConsentRequest == nil,
let nextRequestID = pendingLegacyPrivateMediaConsents.first?.request.id else {
return
}
DispatchQueue.main.async { [weak self] in
guard let self,
self.legacyPrivateMediaConsentRequest == nil,
self.pendingLegacyPrivateMediaConsents.first?.request.id == nextRequestID else {
return
}
self.legacyPrivateMediaConsentRequest = self.pendingLegacyPrivateMediaConsents[0].request
}
}
}
// End of ChatViewModel class
@@ -156,17 +156,6 @@ private extension ChatViewModelBootstrapper {
viewModel?.objectWillChange.send()
}
.store(in: &viewModel.cancellables)
viewModel.participantTracker.$visiblePeople
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] people in
Task { @MainActor [weak viewModel] in
let visible = Set(people.map { $0.id })
viewModel?.locationPresenceStore.retainTeleportedGeo(keeping: visible)
viewModel?.locationPresenceStore.retainGeoNicknames(keeping: visible)
}
}
.store(in: &viewModel.cancellables)
}
func loadPersistedViewState() {
+8 -79
View File
@@ -26,10 +26,6 @@ struct MessageRateLimiter {
}
return false
}
func isIdle(since now: Date, idleTTL: TimeInterval) -> Bool {
now.timeIntervalSince(lastRefill) >= idleTTL
}
}
private var senderBuckets: [String: TokenBucket] = [:]
@@ -39,26 +35,17 @@ struct MessageRateLimiter {
private let senderRefill: Double
private let contentCapacity: Double
private let contentRefill: Double
private let maxSenderBuckets: Int
private let maxContentBuckets: Int
private let bucketIdleTTL: TimeInterval
init(
senderCapacity: Double,
senderRefillPerSec: Double,
contentCapacity: Double,
contentRefillPerSec: Double,
maxSenderBuckets: Int = TransportConfig.uiSenderRateBucketMaxEntries,
maxContentBuckets: Int = TransportConfig.uiContentRateBucketMaxEntries,
bucketIdleTTL: TimeInterval = TransportConfig.uiRateBucketIdleTTL
contentRefillPerSec: Double
) {
self.senderCapacity = senderCapacity
self.senderRefill = senderRefillPerSec
self.contentCapacity = contentCapacity
self.contentRefill = contentRefillPerSec
self.maxSenderBuckets = max(1, maxSenderBuckets)
self.maxContentBuckets = max(1, maxContentBuckets)
self.bucketIdleTTL = bucketIdleTTL
}
/// - Parameter powBits: validated NIP-13 difficulty of the event
@@ -71,83 +58,25 @@ struct MessageRateLimiter {
if powBits >= NostrPoW.rateLimitBypassBits {
senderAllowed = true
} else {
var senderBucket = Self.bucket(
for: senderKey,
in: &senderBuckets,
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
capacity: senderCapacity,
tokens: senderCapacity,
refillPerSec: senderRefill,
maxBuckets: maxSenderBuckets,
idleTTL: bucketIdleTTL,
now: now
lastRefill: now
)
senderAllowed = senderBucket.allow(now: now)
senderBuckets[senderKey] = senderBucket
}
// Rejected senders must not mint attacker-keyed content entries.
guard senderAllowed else { return false }
var contentBucket = Self.bucket(
for: contentKey,
in: &contentBuckets,
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
capacity: contentCapacity,
tokens: contentCapacity,
refillPerSec: contentRefill,
maxBuckets: maxContentBuckets,
idleTTL: bucketIdleTTL,
now: now
lastRefill: now
)
let contentAllowed = contentBucket.allow(now: now)
contentBuckets[contentKey] = contentBucket
return contentAllowed
}
mutating func reset() {
senderBuckets.removeAll()
contentBuckets.removeAll()
}
var bucketCountsForTesting: (sender: Int, content: Int) {
(senderBuckets.count, contentBuckets.count)
}
// Static so we can take `inout` on a stored dictionary without overlapping
// exclusive access through a mutating method on `self`.
private static func bucket(
for key: String,
in buckets: inout [String: TokenBucket],
capacity: Double,
refillPerSec: Double,
maxBuckets: Int,
idleTTL: TimeInterval,
now: Date
) -> TokenBucket {
if let existing = buckets[key] {
return existing
}
evictIfNeeded(from: &buckets, maxBuckets: maxBuckets, idleTTL: idleTTL, now: now)
return TokenBucket(
capacity: capacity,
tokens: capacity,
refillPerSec: refillPerSec,
lastRefill: now
)
}
private static func evictIfNeeded(
from buckets: inout [String: TokenBucket],
maxBuckets: Int,
idleTTL: TimeInterval,
now: Date
) {
guard buckets.count >= maxBuckets else { return }
buckets = buckets.filter { !$0.value.isIdle(since: now, idleTTL: idleTTL) }
guard buckets.count >= maxBuckets else { return }
if let oldestKey = buckets.min(by: { $0.value.lastRefill < $1.value.lastRefill })?.key {
buckets.removeValue(forKey: oldestKey)
}
return senderAllowed && contentAllowed
}
}
@@ -196,10 +196,7 @@ final class NostrInboundPipeline {
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
geoEventLogCount += 1
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug(
"GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tagCount=\(event.tags.count)",
category: .session
)
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
}
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
@@ -310,7 +307,7 @@ final class NostrInboundPipeline {
// claiming to be group traffic over Nostr is ignored.
// Live voice is mesh-only: latency and relay cost make it
// meaningless over Nostr.
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
break
}
}
@@ -366,7 +363,7 @@ final class NostrInboundPipeline {
// claiming to be group traffic over Nostr is ignored.
// Live voice is mesh-only: latency and relay cost make it
// meaningless over Nostr.
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
break
}
}
@@ -449,7 +446,7 @@ final class NostrInboundPipeline {
// in v1; group traffic over Nostr is ignored.
// Live voice is mesh-only: latency and relay cost make it
// meaningless over Nostr.
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
break
}
}
@@ -188,25 +188,8 @@ final class VoiceRecordingViewModel: ObservableObject {
Task {
let finalDuration = Date().timeIntervalSince(startDate)
if let url = await session.finish() {
// Panic and a newer hold both invalidate this completion.
// Never route an old recording using a post-panic target.
guard generation == holdGeneration else {
try? FileManager.default.removeItem(at: url)
return
}
guard isValidRecording(
at: url,
duration: finalDuration
) else {
guard state == .idle else { return }
state = .error(
message: finalDuration < VoiceRecorder.minRecordingDuration
? "Recording is too short."
: "Recording failed to save."
)
return
}
if let url = await session.finish(),
isValidRecording(at: url, duration: finalDuration) {
completion(url)
} else {
guard generation == holdGeneration, state == .idle else { return }
@@ -223,17 +206,6 @@ final class VoiceRecordingViewModel: ObservableObject {
finish(completion: nil)
}
/// Invalidates in-flight permission/start/finalize callbacks and tears
/// down an active microphone before the panic transaction continues.
func panicWipe() {
holdGeneration &+= 1
let session = activeSession
activeSession = nil
state = .idle
isLiveStreaming = false
session?.panicCancelSynchronously()
}
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let fileSize = attributes[.size] as? NSNumber,
-73
View File
@@ -26,10 +26,6 @@ struct AppInfoView: View {
/// introduction), and afterwards the sheet reopens wherever it was left.
@AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info
@State private var showPanicConfirmation = false
@AppStorage(AppLanguageSettings.overrideKey) private var languageOverride = ""
/// The override changed this session; localization resolves at process
/// start, so surface the restart hint.
@State private var showLanguageRestartNote = false
private enum Pane: String {
case settings
@@ -59,11 +55,6 @@ struct AppInfoView: View {
static let connectivityTitle = String(localized: "app_info.settings.connectivity.title", defaultValue: "CONNECTIVITY", comment: "Section header (uppercase) for the connectivity toggles: mesh bridge, internet gateway, tor routing")
static let languageTitle = String(localized: "app_info.settings.language.title", defaultValue: "LANGUAGE", comment: "Section header (uppercase) for the app language picker in settings")
static let languagePickerLabel = String(localized: "app_info.settings.language.picker_label", defaultValue: "app language", comment: "Label of the app language picker row in settings")
static let languageSystem = String(localized: "app_info.settings.language.system", defaultValue: "system default", comment: "Menu option that clears the in-app language override so the app follows the device language")
static let languageRestartNote = String(localized: "app_info.settings.language.restart_note", defaultValue: "restart bitchat to apply the new language", comment: "Caption shown after the user picks a different app language; the change takes effect on next launch")
static let bridgeTitle = String(localized: "app_info.settings.bridge.title", defaultValue: "mesh bridge", comment: "Title of the mesh bridge toggle in settings")
static let bridgeSubtitle = String(localized: "app_info.settings.bridge.subtitle", defaultValue: "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none.", comment: "Subtitle explaining what the mesh bridge toggle does")
static func bridgeCell(_ cell: String) -> String {
@@ -322,52 +313,6 @@ struct AppInfoView: View {
}
}
// Language an in-app override so the UI language can differ
// from the device language (takes effect on next launch).
VStack(alignment: .leading, spacing: 12) {
SectionHeader(verbatim: Strings.Settings.languageTitle)
settingsCard {
Menu {
Button {
selectLanguage(nil)
} label: {
menuItemLabel(Strings.Settings.languageSystem, isSelected: languageOverride.isEmpty)
}
Divider()
ForEach(AppLanguageSettings.availableLanguages, id: \.self) { code in
Button {
selectLanguage(code)
} label: {
menuItemLabel(AppLanguageSettings.endonym(for: code), isSelected: languageOverride == code)
}
}
} label: {
HStack {
Text(Strings.Settings.languagePickerLabel)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(textColor)
Spacer()
Text(languageOverride.isEmpty ? Strings.Settings.languageSystem : AppLanguageSettings.endonym(for: languageOverride))
.bitchatFont(size: 12)
.foregroundColor(palette.accent)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 10))
.foregroundColor(secondaryTextColor)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if showLanguageRestartNote {
Text(Strings.Settings.languageRestartNote)
.bitchatFont(size: 11)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// Voice same card + IRC pill as every other toggle setting.
VStack(alignment: .leading, spacing: 12) {
SectionHeader(Strings.Voice.title)
@@ -513,24 +458,6 @@ struct AppInfoView: View {
.padding()
}
private func selectLanguage(_ code: String?) {
let previous = languageOverride
AppLanguageSettings.setOverride(code)
languageOverride = code ?? ""
if languageOverride != previous {
showLanguageRestartNote = true
}
}
private func menuItemLabel(_ title: String, isSelected: Bool) -> some View {
HStack {
Text(title)
if isSelected {
Image(systemName: "checkmark")
}
}
}
private var bridgeToggleBinding: Binding<Bool> {
Binding(
get: { bridgeService.isEnabled },
@@ -41,7 +41,7 @@ struct TextMessageView: View {
// first text line; a fixed top padding left the lock's solid body
// hanging below the line's visual center.
HStack(alignment: .firstTextBaseline, spacing: 0) {
let isLong = message.content.isLongForDisplay()
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id)
if message.isPrivate {
Image(systemName: "lock.fill")
@@ -103,7 +103,7 @@ struct TextMessageView: View {
}
// Expand/Collapse for very long messages
if message.content.isLongForDisplay() {
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {
let isExpanded = expandedMessageIDs.contains(message.id)
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
Button(labelKey) {
-58
View File
@@ -36,7 +36,6 @@ struct ContentPeopleSheetView: View {
#endif
var body: some View {
let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest
NavigationStack {
Group {
if privateConversationModel.selectedPeerID != nil {
@@ -98,63 +97,6 @@ struct ContentPeopleSheetView: View {
}
.themedSheetBackground()
.foregroundColor(palette.primary)
.confirmationDialog(
String(
localized: "content.private_media.legacy_warning.title",
defaultValue: "Send without end-to-end encryption?",
comment: "Title warning before sending private media to an older client in a clear signed envelope"
),
isPresented: Binding(
get: { legacyConsentRequest != nil },
set: { isPresented in
if !isPresented, let requestID = legacyConsentRequest?.id {
conversationUIModel.resolveLegacyPrivateMediaConsent(
requestID: requestID,
approved: false
)
}
}
),
titleVisibility: .visible
) {
Button(
String(
localized: "content.private_media.legacy_warning.send",
defaultValue: "send visible file",
comment: "Destructive confirmation action for one legacy clear private-media send"
),
role: .destructive
) {
if let requestID = legacyConsentRequest?.id {
conversationUIModel.resolveLegacyPrivateMediaConsent(
requestID: requestID,
approved: true
)
}
}
Button("common.cancel", role: .cancel) {
if let requestID = legacyConsentRequest?.id {
conversationUIModel.resolveLegacyPrivateMediaConsent(
requestID: requestID,
approved: false
)
}
}
} message: {
if let request = legacyConsentRequest {
Text(
String(
format: String(
localized: "content.private_media.legacy_warning.message",
defaultValue: "%@'s client does not advertise encrypted private media. This file will be signed but not end-to-end encrypted, so mesh relays can see it. Send this file anyway?",
comment: "Warning explaining the confidentiality loss for one legacy private-media send; parameter is the peer name"
),
locale: .current,
request.peerName
)
)
}
}
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
+42 -4
View File
@@ -36,6 +36,7 @@ struct ContentView: View {
@EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var sharedContentImportModel: SharedContentImportModel
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
@State private var messageText = ""
@@ -69,6 +70,14 @@ struct ContentView: View {
privateConversationModel.selectedPeerID
}
private var sharedContentDestination: SharedContentDestination {
SharedContentDestination.resolve(
selectedPrivatePeerID: selectedPrivatePeerID,
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
activeChannel: locationChannelsModel.selectedChannel
)
}
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
var body: some View {
@@ -79,15 +88,13 @@ struct ContentView: View {
voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in
conversationUIModel?.makeVoiceCaptureSession() ?? VoiceNoteCaptureSession()
}
appChromeModel.setPanicPreparation { [weak voiceRecordingVM] in
voiceRecordingVM?.panicWipe()
}
#if os(macOS)
DispatchQueue.main.async {
isNicknameFieldFocused = false
isTextFieldFocused = true
}
#endif
sharedContentImportModel.updateDestination(sharedContentDestination)
}
.onChange(of: colorScheme) { newValue in
conversationUIModel.setCurrentColorScheme(newValue)
@@ -104,6 +111,10 @@ struct ContentView: View {
if newValue != nil {
showSidebar = true
}
sharedContentImportModel.updateDestination(sharedContentDestination)
}
.onChange(of: locationChannelsModel.selectedChannel) { _ in
sharedContentImportModel.updateDestination(sharedContentDestination)
}
.sheet(
isPresented: Binding(
@@ -230,9 +241,36 @@ struct ContentView: View {
} message: {
Text(appChromeModel.bluetoothAlertMessage)
}
.alert(
String(localized: "share_import.review.title", comment: "Title for reviewing content received from the share extension"),
isPresented: Binding(
get: { sharedContentImportModel.offer != nil },
set: { _ in }
),
presenting: sharedContentImportModel.offer
) { _ in
Button("common.cancel", role: .cancel) {
sharedContentImportModel.cancel(destination: sharedContentDestination)
}
Button("share_import.review.use_in_composer") {
guard let importedText = sharedContentImportModel.confirm(
destination: sharedContentDestination
) else { return }
// Replacing is deliberate and called out in the prompt. It
// avoids combining a stale draft from another conversation
// with newly shared content.
messageText = importedText
isTextFieldFocused = true
}
} message: { offer in
let format = String(
localized: "share_import.review.message",
comment: "Explains that shared content will replace the named destination's composer and will not be sent automatically"
)
Text(String(format: format, offer.destination.displayName) + "\n\n" + offer.payload.preview)
}
.onDisappear {
autocompleteDebounceTimer?.invalidate()
appChromeModel.setPanicPreparation(nil)
}
}
-20
View File
@@ -21,26 +21,6 @@ extension String {
return current >= threshold
}
/// True when the message should collapse behind Show more in the UI.
/// Length alone decides this embedding a Cashu-looking token must not
/// disable the guard (remote DoS via unbounded layout).
func isLongForDisplay(
lengthThreshold: Int = TransportConfig.uiLongMessageLengthThreshold,
tokenThreshold: Int = TransportConfig.uiVeryLongTokenThreshold
) -> Bool {
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
}
/// True when rich formatting (regex / link detectors) should be skipped.
/// Cashu presence used to exempt oversized content from the plain path;
/// that let untrusted input force expensive formatting work.
func isOversizedForRichFormatting(
lengthThreshold: Int = 4000,
tokenThreshold: Int = 1024
) -> Bool {
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
}
// Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
// form matches too the token embedded after the scheme is the match.
@@ -14,25 +14,11 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
// every default-constructed component under test, which access it from
// arbitrary threads.
private let lock = NSLock()
private let installAccessGate: KeychainInstallAccessGate
private let reconcileInstallAccess: () -> Bool
private var storage: [String: Data] = [:]
private var serviceStorage: [String: [String: Data]] = [:]
init(
installAccessGate: KeychainInstallAccessGate = KeychainInstallAccessGate(),
reconcileInstallAccess: @escaping () -> Bool = { true }
) {
self.installAccessGate = installAccessGate
self.reconcileInstallAccess = reconcileInstallAccess
}
private func installAccessAllowed() -> Bool {
installAccessGate.allowsAccess(reconcile: reconcileInstallAccess)
}
init() {}
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
guard installAccessAllowed() else { return false }
lock.lock()
defer { lock.unlock() }
storage[key] = keyData
@@ -40,14 +26,12 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
}
func getIdentityKey(forKey key: String) -> Data? {
guard installAccessAllowed() else { return nil }
lock.lock()
defer { lock.unlock() }
return storage[key]
}
func deleteIdentityKey(forKey key: String) -> Bool {
guard installAccessAllowed() else { return false }
lock.lock()
defer { lock.unlock() }
storage.removeValue(forKey: key)
@@ -67,7 +51,6 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
func secureClear(_ string: inout String) {}
func verifyIdentityKeyExists() -> Bool {
guard installAccessAllowed() else { return false }
lock.lock()
defer { lock.unlock() }
return storage["identity_noiseStaticKey"] != nil
@@ -75,7 +58,6 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
// BCH-01-009: New methods with proper error classification
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
guard installAccessAllowed() else { return .accessDenied }
lock.lock()
defer { lock.unlock() }
if let data = storage[key] {
@@ -85,7 +67,6 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
}
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
guard installAccessAllowed() else { return .accessDenied }
lock.lock()
defer { lock.unlock() }
storage[key] = keyData
@@ -95,38 +76,24 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
func save(key: String, data: Data, service: String, accessible: CFString?) {
guard installAccessAllowed() else { return }
lock.lock()
defer { lock.unlock() }
serviceStorage[service, default: [:]][key] = data
}
func load(key: String, service: String) -> Data? {
guard case .success(let data) = loadWithResult(key: key, service: service) else {
return nil
}
return data
}
func loadWithResult(key: String, service: String) -> KeychainReadResult {
guard installAccessAllowed() else { return .accessDenied }
lock.lock()
defer { lock.unlock() }
guard let data = serviceStorage[service]?[key] else {
return .itemNotFound
}
return .success(data)
return serviceStorage[service]?[key]
}
func delete(key: String, service: String) {
guard installAccessAllowed() else { return }
lock.lock()
defer { lock.unlock() }
serviceStorage[service]?.removeValue(forKey: key)
}
func deleteAll(service: String) {
guard installAccessAllowed() else { return }
lock.lock()
defer { lock.unlock() }
serviceStorage.removeValue(forKey: service)
@@ -38,12 +38,6 @@
"comment" : "Fallback title when saving a shared link"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "پیوند اشتراک‌گذاری‌شده"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
@@ -203,7 +197,7 @@
}
},
"share.status.failed_to_encode" : {
"extractionState" : "manual",
"extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -239,12 +233,6 @@
"comment" : "Shown when the share payload cannot be encoded"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "کدگذاری پیوند ناموفق بود"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
@@ -440,12 +428,6 @@
"comment" : "Shown when provided content cannot be shared"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "محتوای قابل اشتراک‌گذاری وجود ندارد"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
@@ -641,12 +623,6 @@
"comment" : "Shown when the share extension receives no content"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "چیزی برای اشتراک‌گذاری نیست"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
@@ -806,7 +782,7 @@
}
},
"share.status.shared_link" : {
"extractionState" : "manual",
"extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -842,12 +818,6 @@
"comment" : "Confirmation after successfully sharing a link"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓ پیوند در bitchat به اشتراک گذاشته شد"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
@@ -1007,7 +977,7 @@
}
},
"share.status.shared_text" : {
"extractionState" : "manual",
"extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -1043,12 +1013,6 @@
"comment" : "Confirmation after successfully sharing text"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓ متن در bitchat به اشتراک گذاشته شد"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
@@ -1206,6 +1170,76 @@
}
}
}
},
"share.status.failed_to_save" : {
"comment" : "Shown when content cannot be staged for the main app",
"extractionState" : "manual",
"localizations" : {
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "تعذر الحفظ في bitchat" } },
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-এ সংরক্ষণ করা যায়নি" } },
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Konnte nicht in bitchat gespeichert werden" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Could not save to bitchat" } },
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "No se pudo guardar en bitchat" } },
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Hindi ma-save sa bitchat" } },
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossible denregistrer dans bitchat" } },
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "לא ניתן לשמור ב-bitchat" } },
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat में सेव नहीं किया जा सका" } },
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } },
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossibile salvare in bitchat" } },
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat に保存できませんでした" } },
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat에 저장할 수 없습니다" } },
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } },
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat मा सुरक्षित गर्न सकिएन" } },
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Kon niet opslaan in bitchat" } },
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Nie udało się zapisać w bitchat" } },
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível guardar no bitchat" } },
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível salvar no bitchat" } },
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Не удалось сохранить в bitchat" } },
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Kunde inte spara i bitchat" } },
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-இல் சேமிக்க முடியவில்லை" } },
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "บันทึกไปยัง bitchat ไม่ได้" } },
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchate kaydedilemedi" } },
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Не вдалося зберегти в bitchat" } },
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat میں محفوظ نہیں ہو سکا" } },
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Không thể lưu vào bitchat" } },
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "无法保存到 bitchat" } },
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "無法儲存到 bitchat" } }
}
},
"share.status.saved_for_review" : {
"comment" : "Shown after content is staged for review in the main app",
"extractionState" : "manual",
"localizations" : {
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ حُفظ في bitchat — افتح التطبيق للمراجعة" } },
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-এ সংরক্ষিত — পর্যালোচনার জন্য অ্যাপটি খুলুন" } },
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ In bitchat gespeichert — App zum Prüfen öffnen" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "✓ Saved in bitchat — open the app to review" } },
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado en bitchat — abre la app para revisarlo" } },
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Na-save sa bitchat — buksan ang app para suriin" } },
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Enregistré dans bitchat — ouvrez lapp pour vérifier" } },
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ נשמר ב-bitchat — יש לפתוח את האפליקציה לבדיקה" } },
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat में सेव किया गया — समीक्षा के लिए ऐप खोलें" } },
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan di bitchat — buka aplikasi untuk meninjau" } },
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvato in bitchat — apri lapp per controllare" } },
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat に保存しました — アプリを開いて確認してください" } },
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat에 저장됨 — 앱을 열어 검토하세요" } },
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan dalam bitchat — buka aplikasi untuk menyemak" } },
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat मा सुरक्षित गरियो — समीक्षा गर्न एप खोल्नुहोस्" } },
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Opgeslagen in bitchat — open de app om te bekijken" } },
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Zapisano w bitchat — otwórz aplikację, aby sprawdzić" } },
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado no bitchat — abra a app para rever" } },
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvo no bitchat — abra o app para revisar" } },
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Сохранено в bitchat — откройте приложение для проверки" } },
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Sparat i bitchat — öppna appen för att granska" } },
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-இல் சேமிக்கப்பட்டது — மதிப்பாய்வு செய்ய செயலியைத் திறக்கவும்" } },
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ บันทึกใน bitchat แล้ว — เปิดแอปเพื่อตรวจสอบ" } },
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchate kaydedildi — incelemek için uygulamayı açın" } },
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Збережено в bitchat — відкрийте застосунок для перегляду" } },
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat میں محفوظ ہو گیا — جائزے کے لیے ایپ کھولیں" } },
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Đã lưu trong bitchat — mở ứng dụng để xem lại" } },
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已保存在 bitchat 中 — 打开应用查看" } },
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已儲存在 bitchat 中 — 開啟 App 查看" } }
}
}
},
"version" : "1.0"
+25 -25
View File
@@ -19,9 +19,8 @@ final class ShareViewController: UIViewController {
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
static let noShareableContent = String(localized: "share.status.no_shareable_content", comment: "Shown when provided content cannot be shared")
static let sharedLinkTitleFallback = String(localized: "share.fallback.shared_link_title", comment: "Fallback title when saving a shared link")
static let sharedLinkConfirmation = String(localized: "share.status.shared_link", comment: "Confirmation after successfully sharing a link")
static let sharedTextConfirmation = String(localized: "share.status.shared_text", comment: "Confirmation after successfully sharing text")
static let failedToEncode = String(localized: "share.status.failed_to_encode", comment: "Shown when the share payload cannot be encoded")
static let savedForReview = String(localized: "share.status.saved_for_review", comment: "Shown after content is staged for review in the main app")
static let failedToSave = String(localized: "share.status.failed_to_save", comment: "Shown when content cannot be staged for the main app")
}
private let statusLabel: UILabel = {
@@ -44,9 +43,7 @@ final class ShareViewController: UIViewController {
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
])
DispatchQueue.global().async {
self.processShare()
}
processShare()
}
// MARK: - Processing
@@ -151,30 +148,33 @@ final class ShareViewController: UIViewController {
// MARK: - Save + Finish
private func saveAndFinish(url: URL, title: String?) {
let payload: [String: String] = [
"url": url.absoluteString,
"title": title ?? url.host ?? Strings.sharedLinkTitleFallback
]
if let json = try? JSONSerialization.data(withJSONObject: payload),
let s = String(data: json, encoding: .utf8) {
saveToSharedDefaults(content: s, type: "url")
finishWithMessage(Strings.sharedLinkConfirmation)
} else {
finishWithMessage(Strings.failedToEncode)
}
let payload = SharedContentPayload(
kind: .url,
content: url.absoluteString,
title: title ?? url.host ?? Strings.sharedLinkTitleFallback
)
stageAndFinish(payload)
}
private func saveAndFinish(text: String) {
saveToSharedDefaults(content: text, type: "text")
finishWithMessage(Strings.sharedTextConfirmation)
stageAndFinish(.text(text))
}
private func saveToSharedDefaults(content: String, type: String) {
guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return }
userDefaults.set(content, forKey: "sharedContent")
userDefaults.set(type, forKey: "sharedContentType")
userDefaults.set(Date(), forKey: "sharedContentDate")
// No need to force synchronize; the system persists changes
private func stageAndFinish(_ payload: SharedContentPayload) {
guard let defaults = UserDefaults(suiteName: Self.groupID) else {
finishWithMessage(Strings.failedToSave)
return
}
let store = SharedContentStore(defaults: defaults)
do {
try store.stage(payload)
// Staging is not sending. The main app will require a second,
// destination-labelled confirmation before filling its composer.
finishWithMessage(Strings.savedForReview)
} catch {
finishWithMessage(Strings.failedToSave)
}
}
private func finishWithMessage(_ msg: String) {
-38
View File
@@ -147,44 +147,6 @@ struct AppArchitectureTests {
#expect(store.teleportedGeo.isEmpty)
}
@Test("LocationPresenceStore bounds and prunes teleported geohash participants")
@MainActor
func locationPresenceStoreBoundsTeleportedParticipants() {
let store = LocationPresenceStore(teleportedGeoCapacity: 2)
store.setCurrentGeohash("u4pruy")
store.markTeleported("AAAAAA")
store.markTeleported("BBBBBB")
store.markTeleported("CCCCCC")
#expect(store.teleportedGeo == Set(["bbbbbb", "cccccc"]))
store.retainTeleportedGeo(keeping: Set(["CCCCCC"]))
#expect(store.teleportedGeo == Set(["cccccc"]))
store.setCurrentGeohash("u4pruz")
#expect(store.teleportedGeo.isEmpty)
}
@Test("LocationPresenceStore bounds geohash nicknames and clears on channel switch")
@MainActor
func locationPresenceStoreBoundsGeoNicknames() {
let store = LocationPresenceStore(geoNicknameCapacity: 2)
store.setCurrentGeohash("u4pruy")
store.setNickname("alice", for: "AAAAAA")
store.setNickname("bob", for: "BBBBBB")
store.setNickname("carol", for: "CCCCCC")
#expect(store.geoNicknames == ["bbbbbb": "bob", "cccccc": "carol"])
store.retainGeoNicknames(keeping: Set(["CCCCCC"]))
#expect(store.geoNicknames == ["cccccc": "carol"])
store.setCurrentGeohash("u4pruz")
#expect(store.geoNicknames.isEmpty)
}
@Test("PeerHandle equality and hashing use the canonical identity only")
func peerHandleEqualityUsesCanonicalIdentity() {
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
+4 -560
View File
@@ -99,95 +99,6 @@ struct BLEServiceCoreTests {
#expect(ble.currentPeerSnapshots().isEmpty)
}
@Test
func unsignedAndBadSignatureLeaveDoNotEvictOrRelayClaimedPeer() async throws {
let ble = makeService()
let alice = NoiseEncryptionService(keychain: MockKeychain())
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
let unsigned = makeLeavePacket(sender: alicePeerID, marker: "unsigned")
ble._test_handlePacket(
unsigned,
fromPeerID: alicePeerID,
signingPublicKey: alice.getSigningPublicKeyData()
)
let unsignedRelayed = await TestHelpers.waitUntil(
{ outbound.count(ofType: .leave) > 0 },
timeout: TestConstants.shortTimeout
)
#expect(!unsignedRelayed)
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
let badSignature = try #require(
mallory.signPacket(makeLeavePacket(sender: alicePeerID, marker: "bad-signature"))
)
ble._test_handlePacket(
badSignature,
fromPeerID: alicePeerID,
signingPublicKey: alice.getSigningPublicKeyData()
)
let badSignatureRelayed = await TestHelpers.waitUntil(
{ outbound.count(ofType: .leave) > 0 },
timeout: TestConstants.shortTimeout
)
#expect(!badSignatureRelayed)
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
}
@Test
func validSignedLeaveEvictsSessionAndRelays() async throws {
let ble = makeService()
let alice = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
// Establish a real session so the leave regression also verifies that
// stale secure-delivery state is retired, not just the peer-list row.
let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID)
let message2 = try #require(
try alice.processHandshakeMessage(from: ble.myPeerID, message: message1)
)
let message3 = try #require(
try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2)
)
_ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3)
#expect(ble.canDeliverSecurely(to: alicePeerID))
let centralUUID = "central-valid-leave"
ble._test_bindCentral(centralUUID, to: alicePeerID)
ble._test_markNoiseAuthenticatedCentral(centralUUID, to: alicePeerID)
#expect(ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID))
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
let signedLeave = try #require(
alice.signPacket(makeLeavePacket(sender: alicePeerID, marker: "valid"))
)
ble._test_handlePacket(
signedLeave,
fromPeerID: alicePeerID,
signingPublicKey: alice.getSigningPublicKeyData()
)
let evicted = await TestHelpers.waitUntil(
{
!ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }
&& !ble.canDeliverSecurely(to: alicePeerID)
&& !ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID)
},
timeout: TestConstants.longTimeout
)
#expect(evicted)
let relayed = await TestHelpers.waitUntil(
{ outbound.count(ofType: .leave) == 1 },
timeout: TestConstants.longTimeout
)
#expect(relayed)
}
@Test
func ingressAllowsRelayedSenderOnBoundLink() async throws {
let ble = makeService()
@@ -502,26 +413,14 @@ struct BLEServiceCoreTests {
)
let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
#expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink))
let rebindGate = VerifiedDirectRebindGate()
ble._test_afterVerifiedDirectRebindEnqueued = rebindGate.pause
defer {
rebindGate.release()
ble._test_afterVerifiedDirectRebindEnqueued = nil
}
ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false)
let announcePaused = await TestHelpers.waitUntil(
{ rebindGate.hasPaused },
let rebound = await TestHelpers.waitUntil(
{ ble._test_centralBinding(attackerLink) == victimPeerID },
timeout: TestConstants.longTimeout
)
try #require(announcePaused)
// Rebind and ordinary reconnect preparation are one bleQueue
// critical section. Once the binding is visible, stale sending keys
// must already be unavailable.
#expect(ble._test_centralBinding(attackerLink) == victimPeerID)
#expect(!ble.canDeliverSecurely(to: victimPeerID))
rebindGate.release()
#expect(rebound)
#expect(ble.canDeliverSecurely(to: victimPeerID))
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = { outbound.record($0) }
@@ -541,261 +440,6 @@ struct BLEServiceCoreTests {
#expect(outbound.count(ofType: .courierEnvelope) == 0)
}
@Test
func replacementXXMessageOneWithPayloadCannotAuthenticateIngressLink() async throws {
let ble = makeService()
let victim = NoiseEncryptionService(keychain: MockKeychain())
let victimPeerID = PeerID(publicKey: victim.getStaticPublicKeyData())
// Preserve a working victim session while an unauthenticated
// replacement candidate arrives on a newly bound physical link.
// Establish BLE as responder so the replacement candidate below is
// not coalesced by the initiator-completion grace path.
let message1 = try victim.initiateHandshake(with: ble.myPeerID)
let message2 = try #require(
try ble._test_noiseProcessHandshakeMessage(
from: victimPeerID,
message: message1
)
)
let message3 = try #require(
try victim.processHandshakeMessage(
from: ble.myPeerID,
message: message2
)
)
_ = try ble._test_noiseProcessHandshakeMessage(
from: victimPeerID,
message: message3
)
await ble._test_drainNoiseMessagePipeline()
#expect(ble.canDeliverSecurely(to: victimPeerID))
let centralUUID = "central-replacement-xx-message-one"
ble._test_bindCentral(centralUUID, to: victimPeerID)
#expect(
!ble._test_isNoiseAuthenticatedCentral(
centralUUID,
for: victimPeerID
)
)
// XX message one may legally carry a payload, so its length is not a
// reliable signal that the replacement handshake completed.
let unauthenticatedInitiator = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: MockKeychain()
)
let replacementMessage1 = try unauthenticatedInitiator.writeMessage(
payload: Data([0xA5])
)
#expect(
replacementMessage1.count
> NoiseSecurityConstants.xxInitialMessageSize
)
let packet = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: victimPeerID.id) ?? Data(),
recipientID: Data(hexString: ble.myPeerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: replacementMessage1,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
#expect(
ble._test_recordIngressIfNew(
packet: packet,
linkID: centralUUID
)
)
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
ble._test_handlePacket(
packet,
fromPeerID: victimPeerID,
preseedPeer: false
)
// Waiting for the responder's message two proves the candidate was
// processed before checking its exact authentication result.
let candidateProcessed = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseHandshake) == 1 },
timeout: TestConstants.longTimeout
)
#expect(candidateProcessed)
#expect(
!ble._test_isNoiseAuthenticatedCentral(
centralUUID,
for: victimPeerID
)
)
// Ordinary reconnect hardening quarantines the cached transport while
// this candidate proves the claimed identity. It must be unavailable
// for sending as well as unable to authenticate this ingress link.
#expect(!ble.canDeliverSecurely(to: victimPeerID))
}
@Test
func failedInboundReconnectRestoresAndDrainsWaitingWorkOnce() async throws {
let ble = makeService()
let alice = NoiseEncryptionService(keychain: MockKeychain())
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
// Establish BLE as responder so the following inbound reconnect is
// not intentionally coalesced by the initiator-completion grace path.
let message1 = try alice.initiateHandshake(with: ble.myPeerID)
let message2 = try #require(
try ble._test_noiseProcessHandshakeMessage(
from: alicePeerID,
message: message1
)
)
let message3 = try #require(
try alice.processHandshakeMessage(
from: ble.myPeerID,
message: message2
)
)
_ = try ble._test_noiseProcessHandshakeMessage(
from: alicePeerID,
message: message3
)
await ble._test_drainNoiseMessagePipeline()
#expect(ble.canDeliverSecurely(to: alicePeerID))
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
let forgedMessage1 = try mallory.initiateHandshake(with: ble.myPeerID)
let firstPacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(),
recipientID: Data(hexString: ble.myPeerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
payload: forgedMessage1,
signature: nil,
ttl: 7
)
ble._test_handlePacket(firstPacket, fromPeerID: alicePeerID)
let responseReady = await TestHelpers.waitUntil(
{
outbound.snapshot().contains {
$0.type == MessageType.noiseHandshake.rawValue
&& PeerID(hexData: $0.senderID) == ble.myPeerID
&& $0.payload.count
!= NoiseSecurityConstants.xxInitialMessageSize
}
},
timeout: TestConstants.longTimeout
)
try #require(responseReady)
let forgedMessage2 = try #require(
outbound.snapshot().first {
$0.type == MessageType.noiseHandshake.rawValue
&& PeerID(hexData: $0.senderID) == ble.myPeerID
&& $0.payload.count
!= NoiseSecurityConstants.xxInitialMessageSize
}?.payload
)
#expect(!ble.canDeliverSecurely(to: alicePeerID))
// Typed control traffic must queue behind the ordinary responder,
// rather than attempting encryption and disappearing.
let privateMessageID = "quarantine-pm-\(UUID().uuidString)"
ble.sendPrivateMessage(
"queued private message",
to: alicePeerID,
recipientNickname: "Alice",
messageID: privateMessageID
)
ble.sendGroupInvite(Data("queued-during-quarantine".utf8), to: alicePeerID)
await ble._test_drainNoiseMessagePipeline()
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
let forgedMessage3 = try #require(
try mallory.processHandshakeMessage(
from: ble.myPeerID,
message: forgedMessage2
)
)
let forgedEarlyPayload = try #require(
BLENoisePayloadFactory.privateMessage(
content: "forged early message",
messageID: "forged-early"
)
)
try #require(
mallory.hasEstablishedSession(with: ble.myPeerID),
"forged initiator did not establish after producing message three"
)
let forgedEarlyCiphertext = try mallory.encrypt(
forgedEarlyPayload,
for: ble.myPeerID
)
let earlyPacket = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(),
recipientID: Data(hexString: ble.myPeerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1,
payload: forgedEarlyCiphertext,
signature: nil,
ttl: 7
)
ble._test_handlePacket(earlyPacket, fromPeerID: alicePeerID)
await ble._test_drainNoiseMessagePipeline()
let thirdPacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(),
recipientID: Data(hexString: ble.myPeerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 2,
payload: forgedMessage3,
signature: nil,
ttl: 7
)
ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID)
// Rollback restores the same generation. It retries the bounded early
// ciphertext and drains both outbound queues, but must not repeat a
// new-generation capability proof or forced announce.
let drained = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseEncrypted) >= 2 },
timeout: TestConstants.longTimeout
)
try #require(drained)
await ble._test_drainNoiseMessagePipeline()
let plaintexts = try outbound.snapshot()
.filter { $0.type == MessageType.noiseEncrypted.rawValue }
.map { try alice.decrypt($0.payload, from: ble.myPeerID) }
#expect(plaintexts.count == 2)
#expect(
plaintexts.filter {
$0.first == NoisePayloadType.authenticatedPeerState.rawValue
}.isEmpty
)
#expect(
plaintexts.filter {
$0.first == NoisePayloadType.privateMessage.rawValue
}.count == 1
)
#expect(
plaintexts.filter {
$0.first == NoisePayloadType.groupInvite.rawValue
}.count == 1
)
#expect(outbound.count(ofType: .announce) == 0)
// A duplicate ready callback cannot replay either buffer.
ble._test_reconcileCurrentNoiseSession(for: alicePeerID)
await ble._test_drainNoiseMessagePipeline()
#expect(outbound.count(ofType: .noiseEncrypted) == 2)
#expect(outbound.count(ofType: .announce) == 0)
}
/// A legitimate rotation announce necessarily arrives on a link still
/// bound to the OLD ID, so its registry upsert stores the new peer
/// disconnected. The successful rebind must promote it: a healed
@@ -928,107 +572,6 @@ struct BLEServiceCoreTests {
#expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16)))
}
@Test
func panicSuspension_dropsLateOutboundWorkUntilCommit() async {
let ble = makeService()
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
let packet = makePublicPacket(
content: "late callback",
sender: ble.myPeerID,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
)
ble.suspendForPanicReset()
ble.sendPacket(packet)
#expect(outbound.count(ofType: .message) == 0)
ble.completePanicReset(restartServices: false)
ble.sendPacket(packet)
#expect(outbound.count(ofType: .message) == 1)
}
@Test @MainActor
func panicSuspension_invalidatesQueuedMainActorIngress() async {
let ble = makeService()
let delegate = TransportEventCaptureDelegate()
ble.eventDelegate = delegate
let message = BitchatMessage(
id: "pre-panic-ingress",
sender: "Peer",
content: "must not survive panic",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: PeerID(str: "1122334455667788")
)
// The test already owns MainActor, so this task cannot run until the
// synchronous panic boundary below has invalidated its generation.
ble._test_emitTransportEvent(.messageReceived(message))
ble.suspendForPanicReset()
await Task.yield()
#expect(delegate.messageIDs.isEmpty)
ble.completePanicReset(restartServices: false)
ble._test_emitTransportEvent(.messageReceived(message))
await Task.yield()
#expect(delegate.messageIDs == [message.id])
}
@Test @MainActor
func panicSuspension_rejectsPausedBLEReceiveBeforeMessageQueueHandoff() async {
let ble = makeService()
let gate = ReceivePacketHandoffGate()
ble._test_beforeReceivePacketHandoff = gate.pause
ble._test_onReceivePacketHandoff = gate.recordHandoff
defer {
gate.release()
ble._test_beforeReceivePacketHandoff = nil
ble._test_onReceivePacketHandoff = nil
}
let sender = PeerID(str: "1122334455667788")
let packet = makePublicPacket(
content: "must not cross panic",
sender: sender,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
)
ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender)
#expect(await TestHelpers.waitUntil(
{ gate.hasPaused },
timeout: TestConstants.longTimeout
))
// Panic closes the lifecycle before waiting for the paused bleQueue
// callback. Releasing it afterward lets the callback enqueue its
// messageQueue handoff, where the captured generation must be rejected
// before packet processing starts.
let releaseAfterPanicCloses = Task.detached {
while ble._test_isPanicIngressOpen {
try? await Task.sleep(nanoseconds: 1_000_000)
}
gate.release()
}
let panic = Task { @MainActor in
ble.suspendForPanicReset()
}
await panic.value
await releaseAfterPanicCloses.value
#expect(gate.handoffCount == 0)
// A packet captured under the reopened lifecycle still crosses the
// same handoff, proving the test did not merely disable the hook.
ble.completePanicReset(restartServices: false)
ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender)
#expect(await TestHelpers.waitUntil(
{ gate.handoffCount == 1 },
timeout: TestConstants.longTimeout
))
}
@Test
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
@@ -1121,82 +664,6 @@ private final class OutboundPacketTap {
lock.lock(); defer { lock.unlock() }
return packets.filter { $0.type == type.rawValue }.count
}
func snapshot() -> [BitchatPacket] {
lock.lock(); defer { lock.unlock() }
return packets
}
}
private final class VerifiedDirectRebindGate: @unchecked Sendable {
private let condition = NSCondition()
private var paused = false
private var released = false
var hasPaused: Bool {
condition.lock()
defer { condition.unlock() }
return paused
}
func pause() {
condition.lock()
paused = true
condition.broadcast()
while !released {
condition.wait()
}
condition.unlock()
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
}
private final class ReceivePacketHandoffGate: @unchecked Sendable {
private let condition = NSCondition()
private var paused = false
private var released = false
private var recordedHandoffCount = 0
var hasPaused: Bool {
condition.lock()
defer { condition.unlock() }
return paused
}
var handoffCount: Int {
condition.lock()
defer { condition.unlock() }
return recordedHandoffCount
}
func pause() {
condition.lock()
paused = true
condition.broadcast()
while !released {
condition.wait()
}
condition.unlock()
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
func recordHandoff() {
condition.lock()
recordedHandoffCount += 1
condition.unlock()
}
}
private func makeService() -> BLEService {
@@ -1223,18 +690,6 @@ private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64
)
}
private func makeLeavePacket(sender: PeerID, marker: String) -> BitchatPacket {
BitchatPacket(
type: MessageType.leave.rawValue,
senderID: Data(hexString: sender.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(marker.utf8),
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
}
private final class PublicCaptureDelegate: BitchatDelegate {
private let lock = NSLock()
private(set) var publicMessages: [BitchatMessage] = []
@@ -1268,15 +723,4 @@ private final class PublicCaptureDelegate: BitchatDelegate {
defer { lock.unlock() }
return publicMessages
}
}
@MainActor
private final class TransportEventCaptureDelegate: TransportEventDelegate {
private(set) var messageIDs: [String] = []
func didReceiveTransportEvent(_ event: TransportEvent) {
guard case .messageReceived(let message) = event else { return }
messageIDs.append(message.id)
}
}
@@ -14,7 +14,6 @@ import BitFoundation
@MainActor
private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
var nickname = "me"
var myPeerID = PeerID(str: "0102030405060708")
var selectedPrivateChatPeer: PeerID?
var isViewingPublicMeshTimeline = false
var blockedPeers: Set<PeerID> = []
@@ -24,10 +23,7 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = []
private(set) var upsertedPublicMessages: [BitchatMessage] = []
private(set) var removedMessageIDs: [String] = []
private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
private(set) var talkerUpdates: [String?] = []
private(set) var privateMutationLog: [String] = []
private var readReceiptMessageIDs: Set<String> = []
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
func resolveNickname(for peerID: PeerID) -> String { "alice" }
@@ -35,7 +31,6 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) }
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
upsertedMessages.append((message, peerID))
privateMutationLog.append("upsert:\(message.id)")
}
func upsertPublicMeshMessage(_ message: BitchatMessage) {
upsertedPublicMessages.append(message)
@@ -43,18 +38,8 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
@discardableResult
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
removedMessageIDs.append(messageID)
privateMutationLog.append("remove:\(messageID)")
return nil
}
func hasSentReadReceipt(_ messageID: String) -> Bool {
readReceiptMessageIDs.contains(messageID)
}
func markReadReceiptSent(_ messageID: String) -> Bool {
readReceiptMessageIDs.insert(messageID).inserted
}
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
sentReadReceipts.append((receipt, peerID))
}
func removeMessage(withID messageID: String, cleanupFile: Bool) {
removedMessageIDs.append(messageID)
}
@@ -166,29 +151,17 @@ struct ChatLiveVoiceCoordinatorTests {
@Test func absorbsFinalizedNoteIntoLiveBubble() throws {
let context = MockChatLiveVoiceContext()
context.selectedPrivateChatPeer = peer
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0xB2)
let hex = burstID.hexEncodedString()
let fileName = "voice_\(hex).m4a"
let stableMessageID = try #require(PrivateMediaMessageIdentity.stableID(
senderPeerID: peer,
recipientPeerID: context.myPeerID,
fileName: fileName
))
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
let bubble = try #require(context.handledPrivateMessages.first)
// The user read the live bubble, then left before the finalized file
// arrived. Stable-ID adoption must preserve that read state.
#expect(context.markReadReceiptSent(bubble.id))
context.selectedPrivateChatPeer = nil
let note = BitchatMessage(
id: stableMessageID,
sender: "alice",
content: "[voice] \(fileName)",
content: "[voice] voice_\(hex).m4a",
timestamp: Date(),
isRelay: false,
isPrivate: true,
@@ -197,21 +170,12 @@ struct ChatLiveVoiceCoordinatorTests {
)
#expect(coordinator.absorbFinalizedVoiceNote(note))
// The finalized note adopts the sender-correlatable ID, removes the
// receiver-local live ID, and emits a fresh READ now that the sender
// has created its finalized media row.
// The note replaced the live bubble in place: same message ID, new
// content, partial capture deleted.
let replacement = try #require(context.upsertedMessages.last)
#expect(replacement.message.id == stableMessageID)
#expect(replacement.message.id == bubble.id)
#expect(replacement.message.content == note.content)
#expect(replacement.peerID == peer)
#expect(context.removedMessageIDs.contains(bubble.id))
#expect(Array(context.privateMutationLog.suffix(2)) == [
"upsert:\(stableMessageID)",
"remove:\(bubble.id)"
])
#expect(context.sentReadReceipts.count == 1)
#expect(context.sentReadReceipts.first?.receipt.originalMessageID == stableMessageID)
#expect(context.sentReadReceipts.first?.peerID == peer)
// The promoted partial capture is deleted in favor of the note.
let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
#expect(!FileManager.default.fileExists(atPath: url.path))
@@ -485,8 +449,7 @@ struct ChatLiveVoiceCoordinatorTests {
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
)
#expect(coordinator.absorbFinalizedVoiceNote(dmNote))
#expect(try #require(context.upsertedMessages.last).message.id == dmNote.id)
#expect(context.removedMessageIDs.contains(dmBubble.id))
#expect(try #require(context.upsertedMessages.last).message.id == dmBubble.id)
}
@Test func finalizedNoteBindsToItsAuthenticatedSender() throws {
@@ -516,9 +479,8 @@ struct ChatLiveVoiceCoordinatorTests {
)
#expect(coordinator.absorbFinalizedVoiceNote(note))
let replacement = try #require(context.upsertedMessages.last)
#expect(replacement.message.id == note.id)
#expect(replacement.message.id == victimBubble.id)
#expect(replacement.peerID == peer)
#expect(context.removedMessageIDs.contains(victimBubble.id))
// The attacker's note can only ever claim the attacker's own bubble.
let attackerNote = BitchatMessage(
@@ -527,9 +489,8 @@ struct ChatLiveVoiceCoordinatorTests {
)
#expect(coordinator.absorbFinalizedVoiceNote(attackerNote))
let attackerReplacement = try #require(context.upsertedMessages.last)
#expect(attackerReplacement.message.id == attackerNote.id)
#expect(attackerReplacement.message.id == attackerBubble.id)
#expect(attackerReplacement.peerID == attacker)
#expect(context.removedMessageIDs.contains(attackerBubble.id))
// Both registry entries are consumed nothing left to hijack.
#expect(!coordinator.absorbFinalizedVoiceNote(note))
File diff suppressed because it is too large Load Diff
@@ -104,20 +104,6 @@ private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMess
/// no `ChatViewModel`.
struct ChatPeerListCoordinatorContextTests {
@Test @MainActor
func synchronousPeerListUpdate_appliesBeforeReturning() {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let peerID = PeerID(str: "0011223344556677")
coordinator.didUpdatePeerListSynchronously([peerID])
#expect(context.isConnected)
#expect(context.registeredEphemeralSessions == [peerID])
#expect(context.updateEncryptionStatusForPeersCount == 1)
#expect(context.cleanupOldReadReceiptsCount == 1)
}
@Test @MainActor
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
let context = MockChatPeerListContext()
@@ -220,85 +220,26 @@ struct ChatTransportEventCoordinatorContextTests {
func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
// Blocked messages are dropped before any handling.
context.blockedMessageIDs = ["blocked", "blocked-private"]
context.blockedMessageIDs = ["blocked"]
coordinator.didReceiveMessage(makeMessage(id: "blocked"))
coordinator.didReceiveMessage(makeMessage(
id: "blocked-private",
isPrivate: true,
senderPeerID: peerID
))
// Empty public content is dropped too.
coordinator.didReceiveMessage(makeMessage(id: "empty", content: " "))
await drainMainActorTasks()
#expect(context.handledPublicMessages.isEmpty)
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.mentionCheckedMessageIDs.isEmpty)
#expect(context.meshDeliveryAcks.isEmpty)
// Private goes to the private handler, public to the public handler;
// both get mention checks and haptics. Stable-media ACK authorization
// belongs to BLEFileTransferHandler after its durable commit and this
// synchronous acceptance result, not to the generic UI coordinator.
let stableMediaID = "media-\(String(repeating: "a", count: 32))"
coordinator.didReceiveMessage(makeMessage(
id: stableMediaID,
isPrivate: true,
senderPeerID: peerID
))
coordinator.didReceiveMessage(makeMessage(
id: "legacy-media",
isPrivate: true,
senderPeerID: peerID
))
coordinator.didReceiveMessage(makeMessage(id: "pm-missing-sender", isPrivate: true))
// both get mention checks and haptics.
coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true))
coordinator.didReceiveMessage(makeMessage(id: "pub"))
await drainMainActorTasks()
#expect(context.handledPrivateMessages.map(\.id) == [
stableMediaID,
"legacy-media",
"pm-missing-sender"
])
#expect(context.handledPrivateMessages.map(\.id) == ["pm"])
#expect(context.handledPublicMessages.map(\.id) == ["pub"])
#expect(context.mentionCheckedMessageIDs == [
stableMediaID,
"legacy-media",
"pm-missing-sender",
"pub"
])
#expect(context.hapticMessageIDs == [
stableMediaID,
"legacy-media",
"pm-missing-sender",
"pub"
])
#expect(context.meshDeliveryAcks.isEmpty)
}
@Test @MainActor
func synchronousMessageDeliveryReportsAcceptanceForAckGating() {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let blocked = makeMessage(
id: "blocked-private-media",
isPrivate: true,
senderPeerID: peerID
)
context.blockedMessageIDs = [blocked.id]
#expect(coordinator.didReceiveMessageSynchronously(blocked) == false)
#expect(context.handledPrivateMessages.isEmpty)
let accepted = makeMessage(
id: "accepted-private-media",
isPrivate: true,
senderPeerID: peerID
)
#expect(coordinator.didReceiveMessageSynchronously(accepted) == true)
#expect(context.handledPrivateMessages.map(\.id) == [accepted.id])
#expect(context.mentionCheckedMessageIDs == ["pm", "pub"])
#expect(context.hapticMessageIDs == ["pm", "pub"])
}
@Test @MainActor
@@ -354,32 +295,6 @@ struct ChatTransportEventCoordinatorContextTests {
#expect(context.notifyUIChangedCount == 2)
}
@Test @MainActor
func synchronousConnectAndDisconnect_applyBeforeReturning() {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "2233445566778899")
let incoming = makeMessage(
id: "incoming-receipt",
isPrivate: true,
senderPeerID: peerID
)
context.privateChats[peerID] = [incoming]
coordinator.didConnectToPeerSynchronously(peerID)
#expect(context.isConnected)
#expect(context.registeredEphemeralSessions == [peerID])
#expect(context.flushedOutboxPeerIDs == [peerID])
#expect(context.courierRetryPeerIDs == [peerID])
coordinator.didDisconnectFromPeerSynchronously(peerID)
#expect(context.removedEphemeralSessions == [peerID])
#expect(context.unmarkedReadReceiptBatches == [[incoming.id]])
#expect(context.notifyUIChangedCount == 2)
}
@Test @MainActor
func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async {
let context = MockChatTransportEventContext()
@@ -97,7 +97,6 @@ private final class MockChatVerificationContext: ChatVerificationContext {
var noiseSessionKeysByPeerID: [PeerID: Data] = [:]
private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)?
private(set) var triggeredHandshakes: [PeerID] = []
private(set) var privateMediaAuthenticatedPeers: [PeerID] = []
private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
@@ -114,9 +113,6 @@ private final class MockChatVerificationContext: ChatVerificationContext {
establishedNoiseSessions.contains(peerID)
}
func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) }
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {
privateMediaAuthenticatedPeers.append(peerID)
}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
sentChallenges.append((peerID, noiseKeyHex, nonceA))
@@ -281,7 +277,6 @@ struct ChatVerificationCoordinatorContextTests {
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
#expect(context.privateMediaAuthenticatedPeers == [peerID])
// Handshake required -> handshaking status.
callbacks?.onHandshakeRequired(peerID)
@@ -1048,89 +1048,6 @@ struct ChatViewModelMediaTransferTests {
#expect(viewModel.transferIdToMessageIDs.count == 1)
}
@Test @MainActor
func legacyPrivateMediaConsentRequestsArePerSendAndQueued() async throws {
let (viewModel, _) = makeTestableViewModel()
let firstPeer = PeerID(str: "1111111111111111")
let secondPeer = PeerID(str: "2222222222222222")
var decisions: [Bool] = []
viewModel.enqueueLegacyPrivateMediaConsent(
for: firstPeer,
transferId: "transfer-1",
messageID: "message-1"
) { decisions.append($0) }
viewModel.enqueueLegacyPrivateMediaConsent(
for: secondPeer,
transferId: "transfer-2",
messageID: "message-2"
) { decisions.append($0) }
#expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == firstPeer)
let firstRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true)
let showedSecond = await TestHelpers.waitUntil(
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
timeout: TestConstants.longTimeout
)
#expect(showedSecond)
let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
// A button action and the dialog binding may both resolve the first
// ID. The stale second callback must not consume the queued request.
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: false)
#expect(decisions == [true])
#expect(viewModel.legacyPrivateMediaConsentRequest?.id == secondRequestID)
viewModel.resolveLegacyPrivateMediaConsent(requestID: secondRequestID, approved: false)
#expect(decisions == [true, false])
#expect(viewModel.legacyPrivateMediaConsentRequest == nil)
}
@Test @MainActor
func invalidatingPresentedLegacyConsentAdvancesQueueAndStaleResolutionNoops() async throws {
let (viewModel, _) = makeTestableViewModel()
let firstPeer = PeerID(str: "3333333333333333")
let secondPeer = PeerID(str: "4444444444444444")
var decisions: [String] = []
viewModel.enqueueLegacyPrivateMediaConsent(
for: firstPeer,
transferId: "transfer-cancelled",
messageID: "message-cancelled"
) { decisions.append("first:\($0)") }
viewModel.enqueueLegacyPrivateMediaConsent(
for: secondPeer,
transferId: "transfer-kept",
messageID: "message-kept"
) { decisions.append("second:\($0)") }
let cancelledRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
viewModel.invalidateLegacyPrivateMediaConsent(
transferId: "transfer-cancelled",
messageID: "message-cancelled"
)
let advanced = await TestHelpers.waitUntil(
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
timeout: TestConstants.longTimeout
)
#expect(advanced)
#expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send")
viewModel.resolveLegacyPrivateMediaConsent(
requestID: cancelledRequestID,
approved: true
)
#expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer)
#expect(decisions.isEmpty)
let keptRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
viewModel.resolveLegacyPrivateMediaConsent(requestID: keptRequestID, approved: true)
#expect(decisions == ["second:true"])
#expect(viewModel.legacyPrivateMediaConsentRequest == nil)
}
@Test @MainActor
func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
let (viewModel, transport) = makeTestableViewModel()
+3 -257
View File
@@ -15,13 +15,8 @@ import BitFoundation
/// Creates a ChatViewModel with mock dependencies for testing
@MainActor
private func makeTestableViewModel(
keychain injectedKeychain: MockKeychain? = nil,
panicMediaWipe: (() throws -> Void)? = nil,
panicRecoveryOperations: PanicRecoveryOperations? = nil,
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
) -> (viewModel: ChatViewModel, transport: MockTransport) {
let keychain = injectedKeychain ?? MockKeychain()
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
@@ -31,10 +26,7 @@ private func makeTestableViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport,
panicMediaWipe: panicMediaWipe,
panicRecoveryOperations: panicRecoveryOperations,
panicNetworkLifecycle: panicNetworkLifecycle
transport: transport
)
return (viewModel, transport)
@@ -654,25 +646,6 @@ struct ChatViewModelFormattingTests {
#expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]")
}
@Test @MainActor
func formatMessageAsText_longCashuFallsBackToPlain() async {
let (viewModel, _) = makeTestableViewModel()
let cashu = "cashuA" + String(repeating: "a", count: 40)
let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100)
let message = BitchatMessage(
id: "fmt-long-cashu",
sender: "Alice#a1b2",
content: longContent,
timestamp: Date(timeIntervalSince1970: 1_700_010_123),
isRelay: false,
senderPeerID: PeerID(str: "00000000000000b3")
)
let formatted = viewModel.formatMessageAsText(message, colorScheme: .light)
#expect(String(formatted.characters) == "<@Alice#a1b2> \(longContent) [\(message.formattedTimestamp)]")
}
@Test @MainActor
func formatMessageHeader_formatsSenderHeader() async {
let (viewModel, _) = makeTestableViewModel()
@@ -864,80 +837,6 @@ struct ChatViewModelPublicConversationTests {
struct ChatViewModelPeerTests {
@Test @MainActor
func typedPeerLifecycleEvents_applyBeforeReturning() {
let (viewModel, _) = makeTestableViewModel()
let peerID = PeerID(str: "1122334455667788")
let incoming = BitchatMessage(
id: "typed-peer-incoming",
sender: "Alice",
content: "Hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: peerID
)
viewModel.seedPrivateChat([incoming], for: peerID)
viewModel.sentReadReceipts.insert(incoming.id)
viewModel.didReceiveTransportEvent(.peerConnected(peerID))
#expect(viewModel.isConnected)
viewModel.didReceiveTransportEvent(.peerDisconnected(peerID))
#expect(!viewModel.sentReadReceipts.contains(incoming.id))
}
@Test @MainActor
func typedPeerListDeliveryAndBluetoothEvents_applyBeforeReturning() {
let (viewModel, transport) = makeTestableViewModel()
let stalePeer = PeerID(str: "00000000000000a2")
let deliveryPeer = PeerID(str: "0102030405060708")
let messageID = "typed-delivery-status"
let delivered = DeliveryStatus.delivered(
to: "Alice",
at: Date(timeIntervalSince1970: 1_234)
)
let outgoing = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "On the way",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Alice",
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
viewModel.markPrivateChatUnread(stalePeer)
viewModel.seedPrivateChat([outgoing], for: deliveryPeer)
viewModel.didReceiveTransportEvent(.peerListUpdated([]))
#expect(!viewModel.unreadPrivateMessages.contains(stalePeer))
viewModel.didReceiveTransportEvent(
.messageDeliveryStatusUpdated(
messageID: messageID,
status: delivered
)
)
#expect(
viewModel.privateMessages(for: deliveryPeer).first?.deliveryStatus
== delivered
)
viewModel.didReceiveTransportEvent(.bluetoothStateUpdated(.poweredOff))
#expect(viewModel.bluetoothState == .poweredOff)
#expect(viewModel.showBluetoothAlert)
// Snapshot events belong to TransportPeerEventsDelegate and are
// intentionally ignored at this typed sink.
viewModel.didReceiveTransportEvent(.peerSnapshotsUpdated([]))
#expect(viewModel.bluetoothState == .poweredOff)
}
@Test @MainActor
func didConnectToPeer_notifiesDelegate() async {
let (_, transport) = makeTestableViewModel()
@@ -1198,159 +1097,6 @@ struct ChatViewModelBluetoothTests {
struct ChatViewModelPanicTests {
@Test @MainActor
func panicClearAllData_finishesMediaWipeBeforeReturning() {
var wipeFinished = false
let (viewModel, _) = makeTestableViewModel(panicMediaWipe: {
wipeFinished = true
})
viewModel.panicClearAllData()
#expect(wipeFinished)
}
@Test @MainActor
func panicClearAllData_stopsNetworkBeforeWipeAndRestartsAfterCommit() {
var events: [String] = []
let lifecycle = PanicNetworkLifecycle(
stop: { events.append("stop") },
restart: { events.append("restart") }
)
let (viewModel, _) = makeTestableViewModel(
panicMediaWipe: { events.append("wipe") },
panicNetworkLifecycle: lifecycle
)
let completed = viewModel.panicClearAllData()
#expect(completed)
#expect(events == ["stop", "wipe", "restart"])
#expect(viewModel.networkActivationAllowed)
}
@Test @MainActor
func panicKeychainFailureKeepsRecoveryPendingAndServicesStopped() {
let keychain = MockKeychain()
keychain.simulatedDeleteAllResult = false
var events: [String] = []
let operations = PanicRecoveryOperations(
isPending: { false },
begin: {
events.append("begin")
return PanicRecoveryIntent(
fileMarkerEstablished: true,
externalMarkerEstablished: false
)
},
wipeMedia: { _ in events.append("wipe") },
complete: { events.append("complete") }
)
let lifecycle = PanicNetworkLifecycle(
stop: { events.append("stop") },
restart: { events.append("restart") }
)
let (viewModel, transport) = makeTestableViewModel(
keychain: keychain,
panicRecoveryOperations: operations,
panicNetworkLifecycle: lifecycle
)
let startsBeforePanic = transport.startServicesCallCount
let completed = viewModel.panicClearAllData()
#expect(!completed)
#expect(events == ["stop", "begin", "wipe"])
#expect(keychain.deleteAllCallCount == 1)
#expect(transport.startServicesCallCount == startsBeforePanic)
#expect(!viewModel.networkActivationAllowed)
}
@Test @MainActor
func pendingPanicRecoveryCompletesBeforeTransportBootstrap() {
var events: [String] = []
let operations = PanicRecoveryOperations(
isPending: {
events.append("read")
return true
},
begin: {
events.append("begin")
return PanicRecoveryIntent(
fileMarkerEstablished: true,
externalMarkerEstablished: false
)
},
wipeMedia: { _ in events.append("wipe") },
complete: { events.append("complete") }
)
let (viewModel, transport) = makeTestableViewModel(
panicRecoveryOperations: operations
)
#expect(events == ["read", "begin", "wipe", "complete"])
#expect(transport.emergencyDisconnectCallCount == 1)
#expect(transport.startServicesCallCount == 1)
#expect(viewModel.networkActivationAllowed)
}
@Test @MainActor
func failedStartupRecoveryLeavesTransportAndNetworkBlocked() {
enum WipeFailure: Error { case failed }
var completedMarker = false
let operations = PanicRecoveryOperations(
isPending: { true },
begin: {
PanicRecoveryIntent(
fileMarkerEstablished: true,
externalMarkerEstablished: false
)
},
wipeMedia: { _ in throw WipeFailure.failed },
complete: { completedMarker = true }
)
let (viewModel, transport) = makeTestableViewModel(
panicRecoveryOperations: operations
)
#expect(!completedMarker)
#expect(transport.emergencyDisconnectCallCount == 1)
#expect(transport.startServicesCallCount == 0)
#expect(!viewModel.networkActivationAllowed)
}
@Test @MainActor
func failedStartupKeychainRecoveryLeavesIntentAndTransportBlocked() {
let keychain = MockKeychain()
keychain.simulatedDeleteAllResult = false
var events: [String] = []
let operations = PanicRecoveryOperations(
isPending: { true },
begin: {
events.append("begin")
return PanicRecoveryIntent(
fileMarkerEstablished: true,
externalMarkerEstablished: true
)
},
wipeMedia: { _ in events.append("wipe") },
complete: { events.append("complete") }
)
let (viewModel, transport) = makeTestableViewModel(
keychain: keychain,
panicRecoveryOperations: operations
)
#expect(events == ["begin", "wipe"])
#expect(keychain.deleteAllCallCount == 1)
#expect(transport.emergencyDisconnectCallCount == 1)
#expect(transport.startServicesCallCount == 0)
#expect(!viewModel.networkActivationAllowed)
}
@Test @MainActor
func panicClearAllData_delegatesToTransport() async {
let (viewModel, transport) = makeTestableViewModel()
File diff suppressed because it is too large Load Diff
@@ -12,7 +12,6 @@ import Testing
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat
@Suite("Integration Tests", .serialized)
struct IntegrationTests {
private var helper = TestNetworkHelper()
@@ -273,18 +272,8 @@ struct IntegrationTests {
// Re-establish Noise handshake explicitly via managers
do {
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
let m2 = try #require(
try helper.noiseManagers["Alice"]!.handleIncomingHandshake(
from: helper.nodes["Bob"]!.peerID,
message: m1
)
)
let m3 = try #require(
try helper.noiseManagers["Bob"]!.handleIncomingHandshake(
from: helper.nodes["Alice"]!.peerID,
message: m2
)
)
let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)!
let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)!
_ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
} catch {
Issue.record("Failed to re-establish Noise session after restart: \(error)")
@@ -8,7 +8,6 @@
import Foundation
import CryptoKit
import Testing
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat
@@ -28,14 +27,9 @@ final class TestNetworkHelper {
node.mockNickname = name
nodes[name] = node
// This synchronous helper directly drives all three XX messages and
// has no transport callback loop for delayed collision recovery.
// Create/replace Noise manager for this node
let key = Curve25519.KeyAgreement.PrivateKey()
noiseManagers[name] = NoiseSessionManager(
localStaticKey: key,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
return node
}
@@ -114,18 +108,8 @@ final class TestNetworkHelper {
let peer2ID = nodes[node2]?.peerID else { return }
let msg1 = try manager1.initiateHandshake(with: peer2ID)
let msg2 = try #require(
try manager2.handleIncomingHandshake(
from: peer1ID,
message: msg1
)
)
let msg3 = try #require(
try manager1.handleIncomingHandshake(
from: peer2ID,
message: msg2
)
)
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
}
}
@@ -323,32 +323,6 @@ struct MessageFormattingEngineTests {
// Exactly at threshold DOES trigger (uses >= comparison)
#expect(content.hasVeryLongToken(threshold: 50))
}
@Test func isLongForDisplay_doesNotIgnoreCashuLinks() {
let cashu = "cashuA" + String(repeating: "a", count: 40)
let content = String(repeating: "a", count: TransportConfig.uiLongMessageLengthThreshold + 1) + " " + cashu
#expect(content.extractCashuLinks().count == 1)
#expect(content.isLongForDisplay())
}
@MainActor
@Test func formatMessage_longCashuMessageFallsBackToPlainContentPath() {
let context = MockMessageFormattingContext(nickname: "carol")
let cashu = "cashuA" + String(repeating: "a", count: 40)
let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100)
let message = BitchatMessage(
id: "long-cashu",
sender: "alice",
content: longContent,
timestamp: Date(timeIntervalSince1970: 1_700_000_999),
isRelay: false
)
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
#expect(String(formatted.characters) == "<@alice> \(longContent) [\(message.formattedTimestamp)]")
}
}
@MainActor
@@ -116,87 +116,4 @@ struct MessageRateLimiterTests {
#expect(plain)
#expect(!plainExhausted)
}
@Test("Content buckets do not grow when sender is rate limited")
func contentBucketsDoNotGrowAfterSenderLimit() {
var limiter = MessageRateLimiter(
senderCapacity: 1,
senderRefillPerSec: 0,
contentCapacity: 1,
contentRefillPerSec: 0,
maxSenderBuckets: 10,
maxContentBuckets: 10,
bucketIdleTTL: 60
)
let now = Date()
let first = limiter.allow(senderKey: "sender", contentKey: "content-0", now: now)
var rejected = true
for index in 1...100 {
if limiter.allow(senderKey: "sender", contentKey: "content-\(index)", now: now) {
rejected = false
}
}
#expect(first)
#expect(rejected)
#expect(limiter.bucketCountsForTesting.sender == 1)
#expect(limiter.bucketCountsForTesting.content == 1)
}
@Test("Bucket maps evict entries at configured caps")
func bucketMapsEvictAtConfiguredCaps() {
let maxEntries = 3
var limiter = MessageRateLimiter(
senderCapacity: 1,
senderRefillPerSec: 0,
contentCapacity: 1,
contentRefillPerSec: 0,
maxSenderBuckets: maxEntries,
maxContentBuckets: maxEntries,
bucketIdleTTL: 60
)
let now = Date()
for index in 0..<25 {
_ = limiter.allow(
senderKey: "sender-\(index)",
contentKey: "content-\(index)",
now: now.addingTimeInterval(TimeInterval(index))
)
}
#expect(limiter.bucketCountsForTesting.sender == maxEntries)
#expect(limiter.bucketCountsForTesting.content == maxEntries)
}
@Test("PoW bypass still creates content buckets under the cap")
func powBypassCreatesBoundedContentBuckets() {
let maxEntries = 3
var limiter = MessageRateLimiter(
senderCapacity: 1,
senderRefillPerSec: 0,
contentCapacity: 100,
contentRefillPerSec: 0,
maxSenderBuckets: maxEntries,
maxContentBuckets: maxEntries,
bucketIdleTTL: 60
)
let now = Date()
var allAllowed = true
for index in 0..<10 {
let allowed = limiter.allow(
senderKey: "sender",
contentKey: "content-\(index)",
powBits: NostrPoW.rateLimitBypassBits,
now: now.addingTimeInterval(TimeInterval(index))
)
if !allowed { allAllowed = false }
}
#expect(allAllowed)
#expect(limiter.bucketCountsForTesting.sender == 0)
#expect(limiter.bucketCountsForTesting.content == maxEntries)
}
}
+1 -22
View File
@@ -14,8 +14,6 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
private var blockedFingerprints: Set<String> = []
private var blockedNostrPubkeys: Set<String> = []
private var socialIdentities: [String: SocialIdentity] = [:]
private var privateMediaCapableFingerprints: Set<String> = []
private var authenticatedSigningKeys: [String: Data] = [:]
init(_: KeychainManagerProtocol) {}
@@ -89,10 +87,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
func clearAllIdentityData() {
privateMediaCapableFingerprints.removeAll()
authenticatedSigningKeys.removeAll()
}
func clearAllIdentityData() {}
func removeEphemeralSession(peerID: PeerID) {}
@@ -106,22 +101,6 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
Set()
}
func markPrivateMediaCapable(fingerprint: String) {
privateMediaCapableFingerprints.insert(fingerprint)
}
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
privateMediaCapableFingerprints.contains(fingerprint)
}
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
authenticatedSigningKeys[fingerprint] = signingPublicKey
}
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
authenticatedSigningKeys[fingerprint]
}
// MARK: Vouching (transitive verification)
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
-4
View File
@@ -18,8 +18,6 @@ final class MockKeychain: KeychainManagerProtocol {
var simulatedReadError: KeychainReadResult?
var simulatedSaveError: KeychainSaveResult?
var simulatedGenericReadError: KeychainReadResult?
var simulatedDeleteAllResult = true
private(set) var deleteAllCallCount = 0
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
storage[key] = keyData
@@ -36,8 +34,6 @@ final class MockKeychain: KeychainManagerProtocol {
}
func deleteAllKeychainData() -> Bool {
deleteAllCallCount += 1
guard simulatedDeleteAllResult else { return false }
storage.removeAll()
serviceStorage.removeAll()
return true
-25
View File
@@ -36,7 +36,6 @@ final class MockTransport: Transport {
private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = []
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
private(set) var cancelledTransfers: [String] = []
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
@@ -59,7 +58,6 @@ final class MockTransport: Transport {
var peerNicknames: [PeerID: String] = [:]
var peerFingerprints: [PeerID: String] = [:]
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
private let mockKeychain = MockKeychain()
// MARK: - Transport Protocol Implementation
@@ -188,29 +186,6 @@ final class MockTransport: Transport {
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
sentPrivateFiles.append((packet, peerID, transferId))
sentPrivateFileLegacyAllowances.append(false)
}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
sentPrivateFiles.append((packet, peerID, transferId))
sentPrivateFileLegacyAllowances.append(allowLegacyFallback)
}
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
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) {
+9 -206
View File
@@ -5,22 +5,15 @@ import BitFoundation
@testable import bitchat
@Suite("Noise Coverage Tests", .serialized)
@Suite("Noise Coverage Tests")
struct NoiseCoverageTests {
private let keychain = MockKeychain()
private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey()
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
// Manager test dictionaries are keyed by the remote peer. Keep the
// historical names, but derive each wire ID from the static key that the
// corresponding manager authenticates during the handshake.
private var alicePeerID: PeerID {
PeerID(publicKey: bobStaticKey.publicKey.rawRepresentation)
}
private var bobPeerID: PeerID {
PeerID(publicKey: aliceStaticKey.publicKey.rawRepresentation)
}
private let alicePeerID = PeerID(str: "0011223344556677")
private let bobPeerID = PeerID(str: "8899aabbccddeeff")
private let charliePeerID = PeerID(str: "fedcba9876543210")
@Test("Protocol metadata and handshake patterns expose expected values")
@@ -542,12 +535,8 @@ struct NoiseCoverageTests {
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(
peerID:remoteKey:sessionGeneration:
)
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(
peerID:remoteKey:sessionGeneration:
)
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -633,16 +622,8 @@ struct NoiseCoverageTests {
)
let replacementSession = try #require(manager.getSession(for: alicePeerID))
let localPeerID = PeerID(
publicKey: aliceStaticKey.publicKey.rawRepresentation
)
if localPeerID < alicePeerID {
#expect(replacementResponse == nil)
#expect(replacementSession === restartedSession)
} else {
#expect(replacementResponse != nil)
#expect(replacementSession !== restartedSession)
}
#expect(replacementResponse != nil)
#expect(replacementSession !== restartedSession)
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
@@ -662,128 +643,13 @@ struct NoiseCoverageTests {
try aliceManager.initiateHandshake(with: alicePeerID)
}
let rekeyInitiation = try aliceManager.initiateRekey(for: alicePeerID)
let rekeyHandshake = try #require(
aliceManager.claimHandshakeInitiation(
rekeyInitiation,
for: alicePeerID
)
)
#expect(!rekeyHandshake.isEmpty)
try aliceManager.initiateRekey(for: alicePeerID)
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
#expect(rekeyedSession !== establishedSession)
#expect(rekeyedSession.getState() == .handshaking)
}
@Test("A stale decrypt generation cannot commit across session promotion")
func staleDecryptGenerationCannotCommitAcrossPromotion() throws {
let aliceManager = NoiseSessionManager(
localStaticKey: aliceStaticKey,
keychain: keychain,
recentInitiatorCompletionGracePeriod: 0,
sessionFactory: { peerID, role in
BlockingDecryptNoiseSession(
peerID: peerID,
role: role,
keychain: self.keychain,
localStaticKey: self.aliceStaticKey
)
}
)
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
let oldSession = try #require(
aliceManager.getSession(for: alicePeerID) as? BlockingDecryptNoiseSession
)
let oldGeneration = try #require(aliceManager.sessionGeneration(for: alicePeerID))
// Prepare a fully authenticated responder candidate without promoting
// it yet. Its final XX message is the exact operation that replaces
// the old `sessions[peerID]` entry.
let replacementInitiator = NoiseSession(
peerID: bobPeerID,
role: .initiator,
keychain: keychain,
localStaticKey: bobStaticKey
)
let message1 = try replacementInitiator.startHandshake()
let message2 = try #require(
try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message1)
)
let message3 = try #require(try replacementInitiator.processHandshakeMessage(message2))
let ciphertext = try bobManager.encrypt(Data("old session".utf8), for: bobPeerID)
oldSession.pauseNextDecrypt()
let decryptResult = ConcurrentTestResult<(plaintext: Data, sessionGeneration: UUID)>()
var promotionResultForCleanup: ConcurrentTestResult<Data?>?
defer {
// A failed startup requirement must not strand a late thread in
// the blocking test double after the test has returned.
oldSession.resumeDecrypt()
_ = decryptResult.wait(timeout: 5)
if let promotionResultForCleanup {
_ = promotionResultForCleanup.wait(timeout: 5)
}
}
let decryptThread = Thread {
decryptResult.capture {
try aliceManager.decryptWithSessionGeneration(ciphertext, from: self.alicePeerID)
}
}
decryptThread.name = "NoiseCoverageTests.staleDecrypt.decrypt"
decryptThread.qualityOfService = .userInitiated
decryptThread.start()
try #require(oldSession.waitForDecryptStart(timeout: 5))
let promotionStarted = DispatchSemaphore(value: 0)
let promotionResult = ConcurrentTestResult<Data?>()
promotionResultForCleanup = promotionResult
let promotionThread = Thread {
promotionStarted.signal()
promotionResult.capture {
try aliceManager.handleIncomingHandshake(from: self.alicePeerID, message: message3)
}
}
promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote"
promotionThread.qualityOfService = .userInitiated
promotionThread.start()
try #require(promotionStarted.wait(timeout: .now() + 5) == .success)
#expect(
promotionResult.wait(timeout: 0.05) == nil,
"Promotion must wait for the exact decrypting-session lease"
)
oldSession.resumeDecrypt()
let decrypted = try #require(decryptResult.wait(timeout: 5)).get()
_ = try #require(promotionResult.wait(timeout: 5)).get()
#expect(decrypted.plaintext == Data("old session".utf8))
#expect(decrypted.sessionGeneration == oldGeneration)
#expect(aliceManager.sessionGeneration(for: alicePeerID) != oldGeneration)
#expect(throws: NoiseEncryptionError.sessionNotEstablished) {
try aliceManager.encrypt(
Data("stale send".utf8),
for: alicePeerID,
expectedSessionGeneration: oldGeneration
)
}
var staleCommitRan = false
let staleCommit = aliceManager.withCurrentSessionGeneration(
for: alicePeerID,
expected: decrypted.sessionGeneration
) {
staleCommitRan = true
return true
}
#expect(staleCommit == nil)
#expect(!staleCommitRan)
}
@Test("Secure noise sessions enforce limits and renegotiation thresholds")
func secureNoiseSessionsEnforceLimitsAndThresholds() throws {
let initiator = SecureNoiseSession(
@@ -978,11 +844,7 @@ private final class SessionCallbackRecorder: @unchecked Sendable {
return establishedEntries.map(\.0)
}
func recordEstablished(
peerID: PeerID,
remoteKey: Curve25519.KeyAgreement.PublicKey,
sessionGeneration _: UUID
) {
func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) {
lock.lock()
establishedEntries.append((peerID, remoteKey.rawRepresentation))
lock.unlock()
@@ -1004,62 +866,3 @@ private final class FailingNoiseSession: NoiseSession {
throw Error.synthetic
}
}
private final class BlockingDecryptNoiseSession: NoiseSession, @unchecked Sendable {
private let controlLock = NSLock()
private var shouldPauseNextDecrypt = false
private let decryptStarted = DispatchSemaphore(value: 0)
private let resumeDecryptSemaphore = DispatchSemaphore(value: 0)
func pauseNextDecrypt() {
controlLock.lock()
shouldPauseNextDecrypt = true
controlLock.unlock()
}
func waitForDecryptStart(timeout: TimeInterval) -> Bool {
decryptStarted.wait(timeout: .now() + timeout) == .success
}
func resumeDecrypt() {
resumeDecryptSemaphore.signal()
}
override func decrypt(_ ciphertext: Data) throws -> Data {
controlLock.lock()
let shouldPause = shouldPauseNextDecrypt
shouldPauseNextDecrypt = false
controlLock.unlock()
if shouldPause {
decryptStarted.signal()
resumeDecryptSemaphore.wait()
}
return try super.decrypt(ciphertext)
}
}
private final class ConcurrentTestResult<Value>: @unchecked Sendable {
private let lock = NSLock()
private let completed = DispatchGroup()
private var storedResult: Result<Value, Error>?
init() {
completed.enter()
}
func capture(_ operation: () throws -> Value) {
let result = Result(catching: operation)
lock.lock()
storedResult = result
lock.unlock()
completed.leave()
}
func wait(timeout: TimeInterval) -> Result<Value, Error>? {
guard completed.wait(timeout: .now() + timeout) == .success else { return nil }
lock.lock()
defer { lock.unlock() }
return storedResult
}
}
+18 -57
View File
@@ -357,18 +357,8 @@ struct NoiseProtocolTests {
@Test func peerRestartDetection() throws {
// Establish initial sessions
// This test explicitly drives the three synchronous XX messages and
// does not exercise the transport's delayed collision recovery.
let aliceManager = NoiseSessionManager(
localStaticKey: aliceKey,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
let bobManager = NoiseSessionManager(
localStaticKey: bobKey,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -387,24 +377,15 @@ struct NoiseProtocolTests {
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
// Alice should accept the new handshake (clearing old session)
let newHandshake2 = try #require(
try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: newHandshake1
)
)
let newHandshake2 = try aliceManager.handleIncomingHandshake(
from: alicePeerID, message: newHandshake1)
#expect(newHandshake2 != nil)
// Complete the new handshake
let newHandshake3 = try #require(
try bobManagerRestarted.handleIncomingHandshake(
from: bobPeerID,
message: newHandshake2
)
)
_ = try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: newHandshake3
)
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(
from: bobPeerID, message: newHandshake2!)
#expect(newHandshake3 != nil)
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
// Should be able to exchange messages with new sessions
let testMessage = Data("After restart".utf8)
@@ -562,18 +543,8 @@ struct NoiseProtocolTests {
@Test func nonceDesynchronizationCausesRehandshake() throws {
// Test that nonce desynchronization leads to proper re-handshake
// This test explicitly drives the three synchronous XX messages and
// does not exercise the transport's delayed collision recovery.
let aliceManager = NoiseSessionManager(
localStaticKey: aliceKey,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
let bobManager = NoiseSessionManager(
localStaticKey: bobKey,
keychain: mockKeychain,
recentInitiatorCompletionGracePeriod: 0
)
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -601,25 +572,15 @@ struct NoiseProtocolTests {
let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
// Alice should accept despite having a "valid" (but desynced) session
let rehandshake2 = try #require(
try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: rehandshake1
),
"Alice should accept handshake to fix desync"
)
let rehandshake2 = try aliceManager.handleIncomingHandshake(
from: alicePeerID, message: rehandshake1)
#expect(rehandshake2 != nil, "Alice should accept handshake to fix desync")
// Complete handshake
let rehandshake3 = try #require(
try bobManager.handleIncomingHandshake(
from: bobPeerID,
message: rehandshake2
)
)
_ = try aliceManager.handleIncomingHandshake(
from: alicePeerID,
message: rehandshake3
)
let rehandshake3 = try bobManager.handleIncomingHandshake(
from: bobPeerID, message: rehandshake2!)
#expect(rehandshake3 != nil)
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
// Verify communication works again
let testResynced = Data("Resynced".utf8)
-58
View File
@@ -290,65 +290,7 @@ struct NostrProtocolTests {
#expect(object["limit"] as? Int == 42)
}
@Test func inboundNostrEventRejectsTooManyTags() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = Array(
repeating: ["g", "u4pruyd"],
count: TransportConfig.nostrMaxEventTags + 1
)
#expect(throws: NostrError.invalidEvent) {
_ = try NostrEvent(from: eventDict)
}
}
@Test func inboundNostrEventRejectsTooManyTagValues() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = [Array(
repeating: "value",
count: TransportConfig.nostrMaxEventTagValues + 1
)]
#expect(throws: NostrError.invalidEvent) {
_ = try NostrEvent(from: eventDict)
}
}
@Test func inboundNostrEventRejectsOversizedTagValues() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = [[
"g",
String(repeating: "a", count: TransportConfig.nostrMaxEventTagValueBytes + 1)
]]
#expect(throws: NostrError.invalidEvent) {
_ = try NostrEvent(from: eventDict)
}
}
@Test func inboundNostrEventAcceptsTagsWithinLimits() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = [["g", "u4pruyd"], ["t", "teleport"]]
let event = try NostrEvent(from: eventDict)
#expect(event.tags.count == 2)
}
// MARK: - Helpers
private static func validInboundEventDict() -> [String: Any] {
[
"id": String(repeating: "0", count: 64),
"pubkey": String(repeating: "1", count: 64),
"created_at": 1_234_567,
"kind": NostrProtocol.EventKind.ephemeralEvent.rawValue,
"tags": [["g", "u4pruyd"]],
"content": "hello",
"sig": String(repeating: "2", count: 128)
]
}
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let rem = str.count % 4
@@ -1,103 +1,10 @@
import Foundation
import Security
import Testing
import BitFoundation
@testable import bitchat
@Suite("PreviewKeychainManager Tests")
struct PreviewKeychainManagerTests {
@Test("Install lifecycle distinguishes upgrade, reinstall, bootstrap, and unreadable keychain")
func installLifecycleDecision() {
#expect(KeychainManager.installLifecycleAction(
containerKnowsMarker: true,
markerRead: .success(Data([1]))
) == .markerPresent)
#expect(KeychainManager.installLifecycleAction(
containerKnowsMarker: false,
markerRead: .success(Data([1]))
) == .clearStaleKeys)
#expect(KeychainManager.installLifecycleAction(
containerKnowsMarker: false,
markerRead: .itemNotFound
) == .bootstrapMarker)
#expect(KeychainManager.installLifecycleAction(
containerKnowsMarker: false,
markerRead: .deviceLocked
) == .retryLater)
#expect(KeychainManager.installLifecycleAction(
containerKnowsMarker: false,
cleanupPending: true,
markerRead: .itemNotFound
) == .clearStaleKeys)
}
@Test("Accessibility migration covers custom services and retries after any incomplete update")
func accessibilityMigrationCoversEveryApplicationOwnedService() {
let primaryService = "chat.bitchat.test-primary"
var visitedServices: [String] = []
let completed = KeychainManager
.migrateAccessibilityForApplicationOwnedServices(
primaryService: primaryService
) { service in
visitedServices.append(service)
return service == "chat.bitchat.favorites"
? errSecInteractionNotAllowed
: errSecItemNotFound
}
#expect(!completed)
#expect(visitedServices.first == primaryService)
#expect(Set(visitedServices).isSuperset(of: [
"chat.bitchat.nostr",
"chat.bitchat.favorites",
"chat.bitchat.outbox"
]))
#expect(Set(visitedServices).count == visitedServices.count)
let retryCompleted = KeychainManager
.migrateAccessibilityForApplicationOwnedServices(
primaryService: primaryService
) { _ in errSecSuccess }
#expect(retryCompleted)
}
@Test("Keychain cleanup is complete only when every owned scope is clean")
func keychainCleanupRequiresEveryApplicationOwnedService() {
let primaryService = "chat.bitchat.test-primary"
var visitedServices: [String] = []
let partialCleanup = KeychainManager
.deleteApplicationOwnedKeychainServices(
primaryService: primaryService
) { service in
visitedServices.append(service)
return service == "chat.bitchat.outbox"
? errSecInteractionNotAllowed
: errSecSuccess
}
#expect(!partialCleanup)
#expect(visitedServices.first == primaryService)
#expect(Set(visitedServices).isSuperset(of: [
"chat.bitchat.nostr",
"chat.bitchat.favorites",
"chat.bitchat.outbox"
]))
#expect(Set(visitedServices).count == visitedServices.count)
let emptyCleanup = KeychainManager
.deleteApplicationOwnedKeychainServices(
primaryService: primaryService
) { _ in errSecItemNotFound }
#expect(emptyCleanup)
#expect(KeychainManager.completedApplicationGroupDelete(status: -34018))
#expect(!KeychainManager.completedApplicationGroupDelete(
status: errSecInteractionNotAllowed
))
}
@Test("Preview keychain manager stores identity and service-scoped data in memory")
func previewKeychainManagerRoundTripsData() {
let manager = PreviewKeychainManager()
@@ -144,132 +51,4 @@ struct PreviewKeychainManagerTests {
Issue.record("Expected preview keychain to be empty after deleteAllKeychainData")
}
}
@Test("Failed reinstall cleanup blocks stale data until a successful retry")
func failedReinstallCleanupBlocksEveryNamespaceUntilSuccessfulRetry() {
let gate = KeychainInstallAccessGate()
var cleanupCanComplete = false
var reconciliationAttempts = 0
var manager: PreviewKeychainManager!
manager = PreviewKeychainManager(
installAccessGate: gate
) {
reconciliationAttempts += 1
guard cleanupCanComplete else { return false }
return manager.deleteAllKeychainData()
}
let staleIdentity = Data([1, 2, 3])
let staleFavorite = Data([4, 5, 6])
let staleOutbox = Data([7, 8, 9])
let staleCustom = Data([10, 11, 12])
#expect(manager.saveIdentityKey(
staleIdentity,
forKey: "noiseStaticKey"
))
#expect(manager.saveIdentityKey(
staleIdentity,
forKey: "identity_noiseStaticKey"
))
#expect(manager.verifyIdentityKeyExists())
manager.save(
key: "favorite",
data: staleFavorite,
service: "chat.bitchat.favorites",
accessible: nil
)
manager.save(
key: "outbox",
data: staleOutbox,
service: "chat.bitchat.outbox",
accessible: nil
)
manager.save(
key: "custom",
data: staleCustom,
service: "chat.bitchat.future-custom",
accessible: nil
)
gate.block()
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
#expect(!manager.verifyIdentityKeyExists())
if case .accessDenied = manager.getIdentityKeyWithResult(
forKey: "noiseStaticKey"
) {
} else {
Issue.record("Expected blocked identity read to fail closed")
}
for (key, service) in [
("favorite", "chat.bitchat.favorites"),
("outbox", "chat.bitchat.outbox"),
("custom", "chat.bitchat.future-custom")
] {
#expect(manager.load(key: key, service: service) == nil)
if case .accessDenied = manager.loadWithResult(
key: key,
service: service
) {
} else {
Issue.record(
"Expected blocked \(service) read to fail closed"
)
}
}
#expect(!manager.saveIdentityKey(
Data([13]),
forKey: "replacement"
))
if case .accessDenied = manager.saveIdentityKeyWithResult(
Data([14]),
forKey: "replacement"
) {
} else {
Issue.record("Expected blocked identity save to fail closed")
}
let failedAttempts = reconciliationAttempts
#expect(failedAttempts > 0)
cleanupCanComplete = true
// The first access retries cleanup synchronously. It must not return
// any surviving value from before the reinstall.
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
#expect(reconciliationAttempts == failedAttempts + 1)
#expect(manager.load(
key: "favorite",
service: "chat.bitchat.favorites"
) == nil)
#expect(manager.load(
key: "outbox",
service: "chat.bitchat.outbox"
) == nil)
#expect(manager.load(
key: "custom",
service: "chat.bitchat.future-custom"
) == nil)
let replacementIdentity = Data([21, 22, 23])
let replacementCustom = Data([24, 25, 26])
#expect(manager.saveIdentityKey(
replacementIdentity,
forKey: "noiseStaticKey"
))
#expect(manager.getIdentityKey(
forKey: "noiseStaticKey"
) == replacementIdentity)
manager.save(
key: "custom",
data: replacementCustom,
service: "chat.bitchat.future-custom",
accessible: nil
)
#expect(manager.load(
key: "custom",
service: "chat.bitchat.future-custom"
) == replacementCustom)
}
}
@@ -1,4 +1,3 @@
import BitFoundation
import XCTest
@testable import bitchat
@@ -74,85 +73,4 @@ final class BitchatFilePacketTests: XCTestCase {
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
XCTAssertEqual(decoded.content, content)
}
func testPrivateMediaMessageIdentityConvergesAcrossPeerIDAliases() throws {
let senderKey = Data(repeating: 0x11, count: 32)
let recipientKey = Data(repeating: 0x22, count: 32)
let senderStable = PeerID(hexData: senderKey)
let recipientStable = PeerID(hexData: recipientKey)
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
let senderID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
senderPeerID: senderStable.toShort(),
recipientPeerID: PeerID(str: "mesh:\(recipientStable.toShort().bare)"),
fileName: fileName
))
let receiverID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
senderPeerID: senderStable,
recipientPeerID: recipientStable.toShort(),
fileName: fileName
))
XCTAssertEqual(senderID, receiverID)
XCTAssertTrue(senderID.hasPrefix("media-"))
XCTAssertEqual(senderID.count, 38)
XCTAssertTrue(PrivateMediaMessageIdentity.isStableID(senderID))
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "A", count: 32))"))
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "a", count: 31))"))
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID(UUID().uuidString))
}
func testPrivateMediaMessageIdentitySeparatesDirectionAndFilename() throws {
let alice = PeerID(str: "0011223344556677")
let bob = PeerID(str: "8899aabbccddeeff")
let firstName = "voice_20260725_105708_11111111-1111-1111-1111-111111111111.m4a"
let secondName = "voice_20260725_105709_22222222-2222-2222-2222-222222222222.m4a"
let first = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: firstName
))
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
senderPeerID: bob,
recipientPeerID: alice,
fileName: firstName
))
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: secondName
))
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: nil
))
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: "photo.jpg"
))
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: "img_11111111-1111-1111-1111-111111111111.pdf"
))
XCTAssertNotNil(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: "voice_0011223344556677.m4a"
))
}
func testPrivateMediaMessageIdentityMatchesVersionOneGoldenVector() {
XCTAssertEqual(
PrivateMediaMessageIdentity.stableID(
senderPeerID: PeerID(str: "0011223344556677"),
recipientPeerID: PeerID(str: "8899aabbccddeeff"),
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
),
"media-910bd42c65060ab76bb6406f220c4516"
)
}
}
-33
View File
@@ -145,39 +145,6 @@ struct PacketsTests {
#expect(decoded.capabilities?.rawValue == 0x0180)
}
@Test
func authenticatedPeerStateUsesVersionedCanonicalTLVs() throws {
let signingKey = Data(repeating: 0xA5, count: 32)
let packet = AuthenticatedPeerStatePacket(
capabilities: [.privateMedia, .vouch],
signingPublicKey: signingKey
)
var encoded = try #require(packet.encode())
#expect(encoded.prefix(5) == Data([0x01, 0x01, 0x02, 0x20, 0x01]))
// Unknown TLVs are forward-compatible and do not alter v1 state.
encoded.append(makeTLV(type: 0x7F, value: Data([0xCA, 0xFE])))
#expect(AuthenticatedPeerStatePacket.decode(from: encoded) == packet)
}
@Test
func authenticatedPeerStateRejectsMalformedAmbiguousOrUnknownVersion() {
let key = Data(repeating: 0x44, count: 32)
let capabilities = makeTLV(type: 0x01, value: Data([0x00, 0x01]))
let signing = makeTLV(type: 0x02, value: key)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x02]) + capabilities + signing) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + signing) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + capabilities + signing) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01, 0x01, 0x00]) + signing) == nil)
// 0x0001 is non-minimal little endian; the canonical form is [0x01].
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data([0x01, 0x00])) + signing) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + makeTLV(type: 0x02, value: Data(key.dropLast()))) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + Data(signing.dropLast())) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data(repeating: 0x01, count: 9)) + signing) == nil)
}
@Test
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
let unknownTLV = Data([0x7F, 0x01, 0x41])
@@ -6,7 +6,6 @@ import Testing
struct BLEAnnounceHandlerTests {
private final class Recorder {
var existingNoisePublicKey: Data?
var authenticatedSigningPublicKey: Data?
var signatureValid = true
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
var linkBoundToOtherPeer = false
@@ -38,7 +37,6 @@ struct BLEAnnounceHandlerTests {
messageTTL: TransportConfig.messageTTLDefault,
now: { now },
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
authenticatedSigningPublicKey: { _ in recorder.authenticatedSigningPublicKey },
verifySignature: { packet, signingPublicKey in
recorder.verifySignatureCalls.append((packet, signingPublicKey))
return recorder.signatureValid
@@ -169,23 +169,6 @@ struct BLEAnnounceHandlingPolicyTests {
#expect(decision.isVerified)
}
@Test
func trustPolicyRejectsSigningKeyReplacementAfterNoiseBinding() {
let noiseKey = Data(repeating: 0xCC, count: 32)
let boundSigningKey = Data(repeating: 0x11, count: 32)
let decision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: true,
signatureValid: true,
existingNoisePublicKey: noiseKey,
announcedNoisePublicKey: noiseKey,
authenticatedSigningPublicKey: boundSigningKey,
announcedSigningPublicKey: Data(repeating: 0x22, count: 32)
)
#expect(decision == .reject(.authenticatedSigningKeyMismatch))
}
@Test
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
let directNew = BLEAnnounceResponsePolicy.plan(
@@ -13,27 +13,11 @@ struct BLEFileTransferHandlerTests {
var signatureVerifyCount = 0
var signedNameQueries: [PeerID] = []
var blockedPeers: Set<PeerID> = []
var trackedPackets: [BitchatPacket] = []
var quotaReservations: [Int] = []
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
var receiptStates: [String: BLEPrivateMediaReceiptState] = [:]
var receiptCommits: [(messageID: String, storedURL: URL)] = []
var receiptCommitSucceeds = true
var removedIncomingFiles: [URL] = []
var lastSeenUpdates: [PeerID] = []
var deliveryAcks: [(messageID: String, peerID: PeerID)] = []
var deliveredMessages: [BitchatMessage] = []
var saveOverride: ((
_ data: Data,
_ preferredName: String?,
_ subdirectory: String,
_ fallbackExtension: String?,
_ defaultPrefix: String
) -> URL?)?
var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)?
var receiptCommitOverride: ((String, URL) -> Bool)?
var removeIncomingFileOverride: ((URL) -> Void)?
}
private let localPeerID = PeerID(str: "0102030405060708")
@@ -49,7 +33,6 @@ struct BLEFileTransferHandlerTests {
recorder.signatureVerifyCount += 1
return recorder.signatureVerifies
},
localSigningPublicKey: { [sampleSigningKey] in sampleSigningKey },
signedSenderDisplayName: { _, peerID in
recorder.signedNameQueries.append(peerID)
return recorder.signedName
@@ -62,44 +45,13 @@ struct BLEFileTransferHandlerTests {
},
saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix))
if let saveOverride = recorder.saveOverride {
return saveOverride(data, preferredName, subdirectory, fallbackExtension, defaultPrefix)
}
return recorder.saveResult
},
privateMediaReceiptState: { messageID in
if let receiptStateOverride = recorder.receiptStateOverride {
return receiptStateOverride(messageID)
}
return recorder.receiptStates[messageID] ?? .absent
},
commitPrivateMediaFile: { messageID, storedURL in
recorder.receiptCommits.append((messageID, storedURL))
if let receiptCommitOverride = recorder.receiptCommitOverride {
return receiptCommitOverride(messageID, storedURL)
}
guard recorder.receiptCommitSucceeds else { return false }
recorder.receiptStates[messageID] = .accepted(storedURL)
return true
},
removeIncomingFile: { storedURL in
recorder.removedIncomingFiles.append(storedURL)
recorder.removeIncomingFileOverride?(storedURL)
},
isPrivateMediaSenderBlocked: { peerID in
recorder.blockedPeers.contains(peerID)
},
updatePeerLastSeen: { peerID in
recorder.lastSeenUpdates.append(peerID)
},
acknowledgePrivateMedia: { messageID, peerID in
recorder.deliveryAcks.append((messageID, peerID))
},
deliverMessage: { message, shouldDeliver, completion in
guard shouldDeliver() else { return }
deliverMessage: { message in
recorder.deliveredMessages.append(message)
guard shouldDeliver() else { return }
completion()
}
)
return BLEFileTransferHandler(environment: environment)
@@ -140,11 +92,12 @@ struct BLEFileTransferHandlerTests {
@Test
func selfEchoIsDropped() throws {
let recorder = Recorder()
recorder.signatureVerifies = true
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3)
#expect(!handler.handle(packet, from: localPeerID))
// The relay pipeline already suppresses self-originated packets, so the
// handler reports "relayable" rather than treating the echo as forged.
#expect(handler.handle(packet, from: localPeerID))
expectNoSideEffects(recorder)
}
@@ -167,12 +120,7 @@ struct BLEFileTransferHandlerTests {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(
sender: remotePeerID,
mimeType: "application/pdf",
content: Data("%PDF-1.7".utf8),
hasSignature: false
)
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
// Failed sender authentication must also stop the packet from being
// relayed to downstream nodes.
@@ -181,7 +129,7 @@ struct BLEFileTransferHandlerTests {
// Broadcast files carry an attacker-controllable senderID, so like
// public messages a connected-but-unverified peer must present a valid
// packet signature. No signing key + no signed identity means dropped.
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.signedNameQueries == [remotePeerID])
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}
@@ -205,11 +153,12 @@ struct BLEFileTransferHandlerTests {
}
@Test
func signedSelfBroadcastReplayIsDelivered() throws {
// Our own broadcast file replayed via gossip sync arrives with ttl==0;
// it is verified against our local signing key before delivery.
func selfBroadcastReplayIsDeliveredWithoutSignatureCheck() throws {
// Our own broadcast file replayed via gossip sync arrives with ttl==0
// (so it is not treated as a self-echo) and cannot be verified against
// the peer registry it must still be accepted, matching
// BLEPublicMessageHandler's self exemption.
let recorder = Recorder()
recorder.signatureVerifies = true
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(
sender: localPeerID,
@@ -220,7 +169,7 @@ struct BLEFileTransferHandlerTests {
#expect(handler.handle(packet, from: localPeerID))
#expect(recorder.signatureVerifyCount == 1)
#expect(recorder.signatureVerifyCount == 0)
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.sender == "Me")
@@ -256,8 +205,7 @@ struct BLEFileTransferHandlerTests {
sender: remotePeerID,
mimeType: "audio/mp4",
content: m4a,
fileName: "voice_1122334455667788",
hasSignature: false
fileName: "voice_1122334455667788"
)
// The spoofed note must be dropped locally AND not relayed onward.
@@ -267,7 +215,7 @@ struct BLEFileTransferHandlerTests {
}
@Test
func rawDirectedFileWithoutVerifiableSignatureIsDroppedWithoutWriteOrRelay() throws {
func privateFileFromConnectedUnverifiedPeerIsAccepted() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
let handler = makeHandler(recorder: recorder)
@@ -275,25 +223,23 @@ struct BLEFileTransferHandlerTests {
sender: remotePeerID,
mimeType: "application/pdf",
content: Data("%PDF-1.7".utf8),
recipientID: Data(hexString: localPeerID.id),
hasSignature: false
recipientID: Data(hexString: localPeerID.id)
)
#expect(!handler.handle(packet, from: remotePeerID))
#expect(handler.handle(packet, from: remotePeerID))
// Directed transfers keep the lenient connected-peer path (no broadcast
// exposure); no signature check is required.
#expect(recorder.signatureVerifyCount == 0)
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.isPrivate == true)
}
@Test
func fileDirectedToAnotherPeerIsIgnored() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
recorder.signatureVerifies = true
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(
sender: remotePeerID,
@@ -314,8 +260,7 @@ struct BLEFileTransferHandlerTests {
@Test
func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
recorder.signatureVerifies = true
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(
sender: remotePeerID,
@@ -331,416 +276,12 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.lastSeenUpdates == [remotePeerID])
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.isPrivate == true)
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
// Must be explicit: BitchatMessage defaults private messages to
// .sending, which the media views render as an in-flight send
// (empty reveal mask, disabled reveal tap).
#expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900)))
}
@Test
func bit8EncryptedPrivateFileKeepsStableIDAndAckWithoutBit9Proof() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
let file = BitchatFilePacket(
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
let timestamp = Date(timeIntervalSince1970: 1_234)
#expect(handler.handlePrivatePayload(payload, from: remotePeerID, timestamp: timestamp))
#expect(recorder.signatureVerifyCount == 0)
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.quotaReservations == [content.count])
#expect(recorder.saveCalls.first?.data == content)
#expect(recorder.lastSeenUpdates == [remotePeerID])
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.isPrivate == true)
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
#expect(recorder.deliveredMessages.first?.id == PrivateMediaMessageIdentity.stableID(
senderPeerID: remotePeerID,
recipientPeerID: localPeerID,
fileName: fileName
))
#expect(recorder.receiptCommits.count == 1)
#expect(recorder.deliveryAcks.count == 1)
#expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id)
}
@Test
func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true,
signingPublicKey: sampleSigningKey
)]
recorder.signatureVerifies = true
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
let packet = try makeFileTransferPacket(
sender: remotePeerID,
mimeType: "image/jpeg",
content: content,
recipientID: Data(hexString: localPeerID.id),
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
)
#expect(handler.handle(packet, from: remotePeerID))
#expect(recorder.receiptCommits.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
}
@Test
func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let file = BitchatFilePacket(
fileName: "photo.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
))
#expect(recorder.deliveredMessages.count == 2)
#expect(recorder.deliveredMessages[0].id != recorder.deliveredMessages[1].id)
#expect(recorder.deliveredMessages.allSatisfy { !$0.id.hasPrefix("media-") })
}
@Test
func lostCapabilityProofThenStableRetryReusesDurableIDWithoutSecondDiskWrite() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
let file = BitchatFilePacket(
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
let expectedID = try #require(PrivateMediaMessageIdentity.stableID(
senderPeerID: remotePeerID,
recipientPeerID: localPeerID,
fileName: fileName
))
// First encrypted arrival may precede the sender's authenticated bit-9
// proof. It still uses the bit-8 stable ID/ACK contract.
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
// A later automatic retry after proof must resolve the same durable ID
// rather than create a legacy random-ID bubble.
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
))
#expect(recorder.quotaReservations == [content.count])
#expect(recorder.saveCalls.count == 1)
// The handler re-offers a durable duplicate so a relaunched UI can
// restore its bubble; the synchronous conversation sink deduplicates.
#expect(recorder.deliveredMessages.count == 2)
#expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID])
#expect(recorder.deliveryAcks.count == 2)
#expect(recorder.deliveryAcks.allSatisfy {
$0.messageID == expectedID && $0.peerID == remotePeerID
})
}
@Test
func acceptedPrivateMediaAfterRelaunchRedeliversDurableURLBeforeAck() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
"private-media-handler-relaunch-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEIncomingFileStore(baseDirectory: root)
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
let file = BitchatFilePacket(
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
func configure(_ recorder: Recorder) {
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true
)]
recorder.saveOverride = {
data,
preferredName,
subdirectory,
fallbackExtension,
defaultPrefix in
store.save(
data: data,
preferredName: preferredName,
subdirectory: subdirectory,
fallbackExtension: fallbackExtension,
defaultPrefix: defaultPrefix
)
}
recorder.receiptStateOverride = {
store.privateMediaReceiptState(messageID: $0)
}
recorder.receiptCommitOverride = {
store.commitPrivateMediaFile(messageID: $0, storedURL: $1)
}
recorder.removeIncomingFileOverride = {
store.removeIncomingFile(at: $0)
}
}
let first = Recorder()
configure(first)
#expect(makeHandler(recorder: first).handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
let originalMessage = try #require(first.deliveredMessages.first)
#expect(first.deliveryAcks.count == 1)
// A fresh handler models process relaunch: its in-memory reservation
// cache is empty, so only the durable receipt can suppress disk work.
let relaunched = Recorder()
configure(relaunched)
#expect(makeHandler(recorder: relaunched).handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
))
#expect(relaunched.quotaReservations.isEmpty)
#expect(relaunched.saveCalls.isEmpty)
#expect(relaunched.receiptCommits.isEmpty)
#expect(relaunched.deliveredMessages.count == 1)
#expect(relaunched.deliveredMessages.first?.id == originalMessage.id)
#expect(relaunched.deliveredMessages.first?.content == originalMessage.content)
#expect(relaunched.deliveryAcks.count == 1)
#expect(relaunched.deliveryAcks.first?.messageID == originalMessage.id)
}
@Test
func inFlightStableDuplicateIsNotAcknowledgedAndFailedSaveRemainsRetryable() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let file = BitchatFilePacket(
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
var handler: BLEFileTransferHandler!
var nestedResult: Bool?
var failFirstSave = true
recorder.saveOverride = { _, _, _, _, _ in
if failFirstSave {
failFirstSave = false
nestedResult = handler.handlePrivatePayload(
payload,
from: self.remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
)
return nil
}
return recorder.saveResult
}
handler = makeHandler(recorder: recorder)
// The nested arrival sees the first reservation as pending. It is
// coalesced without an ACK; then the first durable save fails.
#expect(!handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(nestedResult == true)
#expect(recorder.saveCalls.count == 1)
#expect(recorder.deliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
// Failure released the reservation, so the sender's later retry can
// persist and deliver normally.
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_236)
))
#expect(recorder.saveCalls.count == 2)
#expect(recorder.deliveryAcks.count == 1)
#expect(recorder.deliveredMessages.count == 1)
}
@Test
func unavailableDurableReceiptStateWithholdsDiskDeliveryAndAck() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true
)]
let fileName =
"img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
let messageID = try #require(PrivateMediaMessageIdentity.stableID(
senderPeerID: remotePeerID,
recipientPeerID: localPeerID,
fileName: fileName
))
recorder.receiptStates[messageID] = .unavailable
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
let payload = try #require(BitchatFilePacket(
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
).encode())
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.receiptCommits.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
}
@Test
func durableReceiptCommitFailureRollsBackAndWithholdsDeliveryAck() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true
)]
recorder.receiptCommitSucceeds = false
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
let payload = try #require(BitchatFilePacket(
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
).encode())
#expect(!handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(recorder.saveCalls.count == 1)
#expect(recorder.receiptCommits.count == 1)
#expect(recorder.removedIncomingFiles.count == 1)
#expect(recorder.removedIncomingFiles.first == recorder.saveResult)
#expect(recorder.deliveredMessages.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
}
@Test
func blockedPrivateMediaIsDroppedBeforeQuotaDiskAndDedupState() throws {
let recorder = Recorder()
recorder.blockedPeers = [remotePeerID]
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let file = BitchatFilePacket(
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.lastSeenUpdates.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
// Unblocking must allow a retry through; the blocked attempt cannot
// poison the stable-ID dedup reservation.
recorder.blockedPeers = []
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
))
#expect(recorder.saveCalls.count == 1)
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveryAcks.count == 1)
}
@Test
func decryptedPrivateFileOverPayloadCapIsRejectedBeforeQuotaOrDiskWrite() {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder)
let oversizedCount = FileTransferLimits.maxPayloadBytes + 1
var length = UInt32(oversizedCount).bigEndian
var payload = Data([0x04]) // BitchatFilePacket CONTENT TLV
withUnsafeBytes(of: &length) { payload.append(contentsOf: $0) }
payload.append(Data(repeating: 0x41, count: oversizedCount))
#expect(!handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.lastSeenUpdates.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}
@Test
func malformedPayloadIsTrackedForSyncButDropped() {
let recorder = Recorder()
@@ -753,7 +294,7 @@ struct BLEFileTransferHandlerTests {
recipientID: nil,
timestamp: 900_000,
payload: Data([0x01, 0x02, 0x03]),
signature: Data(repeating: 0x5A, count: 64),
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
@@ -829,159 +370,6 @@ struct BLEFileTransferHandlerTests {
#expect(!FileManager.default.fileExists(atPath: evictable.path))
}
@Test
func panicWipeDeletesEveryManagedMediaFileAndRecreatesEmptyDirectories() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent("panic-media-wipe-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: base) }
let store = BLEIncomingFileStore(baseDirectory: base)
let subdirectories = [
"voicenotes/incoming",
"voicenotes/outgoing",
"images/incoming",
"images/outgoing",
"files/incoming",
"files/outgoing"
]
for subdirectory in subdirectories {
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent(subdirectory, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try Data("secret".utf8).write(to: directory.appendingPathComponent("artifact.bin"))
}
let unmanaged = base.appendingPathComponent("files/legacy/secret.bin")
try FileManager.default.createDirectory(at: unmanaged.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data("legacy".utf8).write(to: unmanaged)
try store.panicWipe()
#expect(!FileManager.default.fileExists(atPath: unmanaged.path))
for subdirectory in subdirectories {
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent(subdirectory, isDirectory: true)
var isDirectory: ObjCBool = false
#expect(FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory))
#expect(isDirectory.boolValue)
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty)
}
}
@Test
func panicWipeClearsCachedPrivateMediaReceiptDecisions() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-receipt-cache-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let messageID = "media-00112233445566778899aabbccddeeff"
let seed = BLEPrivateMediaReceiptStore(baseDirectory: base)
#expect(seed.recordDeleted(messageID: messageID))
let store = BLEIncomingFileStore(baseDirectory: base)
#expect(
store.privateMediaReceiptState(messageID: messageID)
== .tombstoned
)
try store.panicWipe()
#expect(
store.privateMediaReceiptState(messageID: messageID)
== .absent
)
}
@Test
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
enum MarkerFailure: Error { case unavailable }
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-marker-failure-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let secret = base
.appendingPathComponent("files/images/outgoing", isDirectory: true)
.appendingPathComponent("secret.jpg")
try FileManager.default.createDirectory(
at: secret.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data("secret".utf8).write(to: secret)
let store = BLEIncomingFileStore(
baseDirectory: base,
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
)
do {
try store.panicWipe(hasDurablePendingMarker: false)
Issue.record("Expected the missing durable marker to fail closed")
} catch {
// The marker error is reported only after the deletion attempt.
}
#expect(!FileManager.default.fileExists(atPath: secret.path))
#expect(
FileManager.default.fileExists(
atPath: secret.deletingLastPathComponent().path
)
)
}
@Test
func externalMarkerAllowsDeletionToCommitWhenFileMarkerFails() throws {
enum MarkerFailure: Error { case unavailable }
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-external-marker-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let secret = base
.appendingPathComponent("files/voicenotes/incoming", isDirectory: true)
.appendingPathComponent("secret.m4a")
try FileManager.default.createDirectory(
at: secret.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data("secret".utf8).write(to: secret)
let store = BLEIncomingFileStore(
baseDirectory: base,
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
)
try store.panicWipe(hasDurablePendingMarker: true)
#expect(!FileManager.default.fileExists(atPath: secret.path))
}
@Test
func panicRecoveryMarkerPersistsUntilExplicitCommit() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-recovery-marker-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let store = BLEIncomingFileStore(baseDirectory: base)
try store.markPanicRecoveryPending()
#expect(try store.isPanicRecoveryPending())
try store.panicWipe(hasDurablePendingMarker: true)
#expect(try store.isPanicRecoveryPending())
try store.completePanicRecovery()
#expect(try !store.isPanicRecoveryPending())
}
private func expectNoSideEffects(_ recorder: Recorder) {
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.trackedPackets.isEmpty)
@@ -1015,8 +403,7 @@ struct BLEFileTransferHandlerTests {
content: Data,
ttl: UInt8 = TransportConfig.messageTTLDefault,
recipientID: Data? = nil,
fileName: String = "sample",
hasSignature: Bool = true
fileName: String = "sample"
) throws -> BitchatPacket {
let filePacket = BitchatFilePacket(
fileName: fileName,
@@ -1031,7 +418,7 @@ struct BLEFileTransferHandlerTests {
recipientID: recipientID,
timestamp: 900_000,
payload: payload,
signature: hasSignature ? Data(repeating: 0x5A, count: 64) : nil,
signature: nil,
ttl: ttl
)
}
@@ -117,35 +117,6 @@ struct BLEFragmentAssemblyBufferTests {
}
}
@Test
func encryptedPrivateFileAssemblyGetsFramedFileHeadroom() throws {
var buffer = BLEFragmentAssemblyBuffer()
let fragmentID = Data(repeating: 0x15, count: 8)
let first = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
fragmentID: fragmentID,
index: 0,
total: 2,
originalType: MessageType.noiseEncrypted.rawValue,
fragmentData: Data(repeating: 0x01, count: FileTransferLimits.maxPayloadBytes)
)))
let second = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
fragmentID: fragmentID,
index: 1,
total: 2,
originalType: MessageType.noiseEncrypted.rawValue,
fragmentData: Data([0x02])
)))
_ = buffer.append(first, maxInFlightAssemblies: 8)
let result = buffer.append(second, maxInFlightAssemblies: 8)
if case let .complete(_, data, _) = result {
#expect(data.count == FileTransferLimits.maxPayloadBytes + 1)
} else {
Issue.record("Expected encrypted private-file assembly to use framed-file limit")
}
}
@Test
func removeExpiredDropsOldAssemblies() throws {
var buffer = BLEFragmentAssemblyBuffer()
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More