mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
Merge remote-tracking branch 'origin/main' into pr1542
This commit is contained in:
commit
b98df88f1a
@ -1 +1 @@
|
||||
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat18BLERadioControllerC14candidateCountSivp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
|
||||
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC18logBluetoothStatusyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat18BLERadioControllerC14candidateCountSivp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
|
||||
4
Justfile
4
Justfile
@ -26,7 +26,7 @@ check-clean-safety:
|
||||
check: check-clean-safety
|
||||
@echo "Checking prerequisites..."
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install full Xcode." && exit 1)
|
||||
@developer_dir="$$(xcode-select -p 2>/dev/null)"; case "$$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac
|
||||
@developer_dir="$(xcode-select -p 2>/dev/null)"; case "$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac
|
||||
@xcodebuild -version
|
||||
@echo "✅ Development environment ready (a signing identity is not required for just build)"
|
||||
|
||||
@ -35,7 +35,7 @@ build: check
|
||||
@xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build
|
||||
|
||||
run: build
|
||||
@app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$$app" || (echo "❌ Built app not found at $$app" && exit 1); open "$$app"
|
||||
@app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$app" || (echo "❌ Built app not found at $app" && exit 1); open "$app"
|
||||
|
||||
# Backward-compatible alias for the old quick-run recipe.
|
||||
dev-run: run
|
||||
|
||||
@ -51,7 +51,7 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
|
||||
|
||||
- **Global Reach**: Connect with users worldwide via internet relays
|
||||
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
||||
- **290+ Relay Network**: Distributed across the globe for reliability
|
||||
- **440+ Relay Network**: Distributed across the globe for reliability
|
||||
- **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays
|
||||
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
||||
|
||||
|
||||
13
bitchat.xcodeproj/project.pbxproj
generated
13
bitchat.xcodeproj/project.pbxproj
generated
@ -94,7 +94,6 @@
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
bitchatShareExtension.entitlements,
|
||||
);
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
};
|
||||
@ -379,6 +378,11 @@
|
||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
|
||||
);
|
||||
};
|
||||
7E9B64F63F93443FB7BA12DF /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
files = (
|
||||
);
|
||||
};
|
||||
C5E027A42ECCDFD700BD6012 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
files = (
|
||||
@ -395,13 +399,6 @@
|
||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
|
||||
);
|
||||
};
|
||||
7E9B64F63F93443FB7BA12DF /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
|
||||
@ -152,18 +152,23 @@ final class AppRuntime: ObservableObject {
|
||||
NetworkActivationService.shared.start()
|
||||
GeohashPresenceService.shared.start()
|
||||
checkForSharedContent()
|
||||
expireAgedMedia()
|
||||
performMediaMaintenance()
|
||||
|
||||
record(.launched)
|
||||
record(.startupCompleted)
|
||||
}
|
||||
|
||||
/// Drops media that has outlived the retention window. Off the main thread
|
||||
/// and best-effort: the sweep walks the media tree, and nothing at launch
|
||||
/// depends on its result.
|
||||
private func expireAgedMedia() {
|
||||
Task(priority: .utility) {
|
||||
BLEIncomingFileStore().expireAgedMedia()
|
||||
/// Drops media that has outlived the retention window, then applies the
|
||||
/// explicit protection class to files that older builds wrote without
|
||||
/// one. Expiry runs first so the migration never touches files the
|
||||
/// sweep is about to delete. Detached because `AppRuntime` is
|
||||
/// main-actor and both passes go file by file through the media tree;
|
||||
/// best-effort, nothing at launch depends on their results.
|
||||
private func performMediaMaintenance() {
|
||||
Task.detached(priority: .utility) {
|
||||
let store = BLEIncomingFileStore()
|
||||
store.expireAgedMedia()
|
||||
store.migrateFileProtectionIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -230,8 +230,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
|
||||
// MARK: Internals
|
||||
|
||||
static func shouldSkipStatusUpdate(current: DeliveryStatus?, new: DeliveryStatus) -> Bool {
|
||||
guard let current else { return false }
|
||||
static func shouldSkipStatusUpdate(current: DeliveryStatus, new: DeliveryStatus) -> Bool {
|
||||
if current == new { return true }
|
||||
|
||||
// Never downgrade to a weaker delivery state. Ordering of certainty:
|
||||
@ -254,6 +253,10 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
return true
|
||||
case (.sent, .sending):
|
||||
return true
|
||||
case (_, .notSentYet):
|
||||
// .notSentYet is the pre-transport initial state; once a message
|
||||
// has any real status, resetting to it is always a downgrade.
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@ -36,6 +36,7 @@ final class LocationPresenceStore: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
let nickname = nickname.normalizedNickname
|
||||
let key = pubkeyHex.lowercased()
|
||||
if geoNicknames[key] != nil {
|
||||
geoNicknames[key] = nickname
|
||||
@ -64,7 +65,7 @@ final class LocationPresenceStore: ObservableObject {
|
||||
let lower = key.lowercased()
|
||||
guard seen.insert(lower).inserted else { continue }
|
||||
ordered.append(lower)
|
||||
normalized[lower] = value
|
||||
normalized[lower] = value.normalizedNickname
|
||||
}
|
||||
if ordered.count > geoNicknameCapacity {
|
||||
let kept = Array(ordered.suffix(geoNicknameCapacity))
|
||||
|
||||
@ -206,7 +206,7 @@ enum ImageUtils {
|
||||
} else {
|
||||
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
|
||||
return directory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
|
||||
@ -244,7 +244,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
let directory = base
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
|
||||
return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
|
||||
}
|
||||
}
|
||||
|
||||
@ -300,7 +300,7 @@ actor VoiceRecorder {
|
||||
|
||||
let baseDirectory = try outputDirectory
|
||||
?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
|
||||
return baseDirectory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
|
||||
@ -663,7 +663,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||
_ = self.ephemeralSessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ extension BitchatMessage {
|
||||
do {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
|
||||
self.filesDir = filesDir
|
||||
} catch {
|
||||
filesDir = nil
|
||||
|
||||
@ -1028,6 +1028,27 @@ final class NoiseSessionManager {
|
||||
.cancel()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Fires a pending suppressed-initiation recovery immediately instead of
|
||||
/// waiting out the completion-grace timer, so tests can inject a grace
|
||||
/// period too large to lose against a starved runner and still exercise
|
||||
/// the recovery path deterministically.
|
||||
func _test_fireSuppressedInitiationRecovery(for peerID: PeerID) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
guard let pending = suppressedInitiationRecoveryTimeouts
|
||||
.removeValue(forKey: peerID) else {
|
||||
return
|
||||
}
|
||||
pending.cancel()
|
||||
guard let current = sessions[peerID],
|
||||
current.isEstablished() else {
|
||||
return
|
||||
}
|
||||
requestHandshakeRecovery(for: peerID)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func requestHandshakeRecovery(
|
||||
for peerID: PeerID,
|
||||
after delay: TimeInterval = 0
|
||||
|
||||
@ -153,14 +153,18 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// Built-in relays carry private-message envelopes, so avoid relays known to
|
||||
// reject the kinds they use.
|
||||
private static let builtInRelays = [
|
||||
nonisolated private static let builtInRelays = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.primal.net",
|
||||
"wss://offchain.pub"
|
||||
// For local testing, you can add: "ws://localhost:8080"
|
||||
]
|
||||
private static let builtInRelaySet = Set(builtInRelays.compactMap { NostrRelayURL.normalized($0) })
|
||||
/// Exposed so the relay settings UI can reject re-adding a built-in.
|
||||
/// `nonisolated` because it is an immutable constant with no actor state.
|
||||
nonisolated static let builtInRelayURLs = Set(
|
||||
builtInRelays.compactMap { NostrRelayURL.normalized($0) }
|
||||
)
|
||||
|
||||
/// The relays private messages target: the built-in set plus any added by
|
||||
/// hand. Four hardcoded hostnames are four names for a censor to block, so
|
||||
@ -182,10 +186,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
defaultRelaySet = Set(defaultRelays)
|
||||
}
|
||||
|
||||
/// Exposed so the relay settings UI can reject re-adding a built-in.
|
||||
/// `nonisolated` because it is an immutable constant with no actor state.
|
||||
nonisolated static var builtInRelayURLs: Set<String> { builtInRelaySet }
|
||||
|
||||
@Published private(set) var relays: [Relay] = []
|
||||
@Published private(set) var isConnected = false
|
||||
/// Whether a relay that carries private messages is connected. DMs
|
||||
|
||||
@ -55,10 +55,10 @@ final class AutocompleteService {
|
||||
|
||||
let fullRange = match.range(at: 0)
|
||||
let captureRange = match.range(at: 1)
|
||||
let prefix = nsText.substring(with: captureRange).lowercased()
|
||||
|
||||
let prefix = nsText.substring(with: captureRange).normalizedNickname.lowercased()
|
||||
|
||||
let suggestions = peers
|
||||
.filter { $0.lowercased().hasPrefix(prefix) }
|
||||
.filter { $0.normalizedNickname.lowercased().hasPrefix(prefix) }
|
||||
.sorted()
|
||||
.prefix(5)
|
||||
.map { "@\($0)" }
|
||||
|
||||
@ -37,4 +37,13 @@ final class BLEAnnounceThrottle: @unchecked Sendable {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets the last-sent timestamp. A panic rotation calls this so the
|
||||
/// new identity's first announce cannot be swallowed by the old
|
||||
/// identity's throttle debt — otherwise a panic within the forced
|
||||
/// minimum interval of the last announce leaves the rotated identity
|
||||
/// invisible until the next maintenance cycle.
|
||||
func reset() {
|
||||
lock.withLock { lastSent = .distantPast }
|
||||
}
|
||||
}
|
||||
|
||||
@ -140,6 +140,20 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
/// orphans a previous session left behind.
|
||||
static let liveCapturePrefix = "voice_live_"
|
||||
|
||||
/// Media payloads follow the same at-rest posture as the app's other
|
||||
/// persistence layers (courier, outbox, receipt index): protected until
|
||||
/// first unlock, so the launch-time retention sweep can still run after
|
||||
/// a reboot. Applied to the media directories so recordings that save
|
||||
/// as they go (live captures, `AVAudioRecorder`) inherit it, and stated
|
||||
/// explicitly at the payload write site like every other store.
|
||||
static var mediaProtectionAttributes: [FileAttributeKey: Any]? {
|
||||
#if os(iOS)
|
||||
return [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication]
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Exposed so callers that write progressively into the store's
|
||||
/// directories (live voice captures) share the same file manager.
|
||||
let fileManager: FileManager
|
||||
@ -223,7 +237,7 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
isDirectory: true
|
||||
),
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
attributes: Self.mediaProtectionAttributes
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
@ -268,7 +282,7 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
/// write progressively instead of via `save` (live voice captures).
|
||||
func incomingDirectory(subdirectory: String) throws -> URL {
|
||||
let directory = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes)
|
||||
return directory
|
||||
}
|
||||
|
||||
@ -284,7 +298,7 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
|
||||
do {
|
||||
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
||||
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes)
|
||||
let sanitized = sanitizedFileName(
|
||||
preferredName,
|
||||
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
||||
@ -306,7 +320,11 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
),
|
||||
forceRandomizedName: reservedPaths == nil
|
||||
)
|
||||
try data.write(to: destination, options: .atomic)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
|
||||
#endif
|
||||
try data.write(to: destination, options: options)
|
||||
payloadCoordination.pendingDeliveryPaths.insert(
|
||||
destination.standardizedFileURL.path
|
||||
)
|
||||
@ -650,9 +668,90 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
return removed
|
||||
}
|
||||
|
||||
/// Stamps the media directories and any resident payloads with the
|
||||
/// explicit protection class, covering files written by builds that
|
||||
/// relied on the container default. Runs every launch: re-stamping an
|
||||
/// equal class is a metadata no-op, and anything carrying a stronger
|
||||
/// class is left alone, so repetition is cheap and can never downgrade.
|
||||
/// In-flight live captures are skipped for symmetry with the retention
|
||||
/// sweep; they receive the class at creation and need no repair.
|
||||
/// Best-effort like the sweep it runs alongside; a file that cannot be
|
||||
/// stamped is logged, not fatal, and the migration moves on to the next
|
||||
/// item. Returns the number of items stamped so the launch path and
|
||||
/// tests can observe coverage.
|
||||
@discardableResult
|
||||
func migrateFileProtectionIfNeeded() -> Int {
|
||||
#if os(iOS)
|
||||
guard let attributes = Self.mediaProtectionAttributes else { return 0 }
|
||||
var stamped = 0
|
||||
guard let base = try? filesDirectory() else { return 0 }
|
||||
for subdirectory in Self.mediaSubdirectories {
|
||||
let dir = base.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
guard fileManager.fileExists(atPath: dir.path) else { continue }
|
||||
let files = (try? fileManager.contentsOfDirectory(
|
||||
at: dir,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey, .fileProtectionKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)) ?? []
|
||||
stamped += stampProtectionIfWeaker(dir, requireRegularFile: false, attributes: attributes)
|
||||
for fileURL in files {
|
||||
guard !fileURL.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
|
||||
stamped += stampProtectionIfWeaker(fileURL, requireRegularFile: true, attributes: attributes)
|
||||
}
|
||||
}
|
||||
return stamped
|
||||
#else
|
||||
return 0
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Applies the class to one item, but only when the item currently sits
|
||||
/// at the container default or weaker. The list names the classes that
|
||||
/// are safe to replace; anything else, including classes added in later
|
||||
/// iOS versions, is left alone. Only regular files are stamped when
|
||||
/// `requireRegularFile` is set (and only real directories otherwise),
|
||||
/// matching the caution the legacy-file removal path applies; symlinks
|
||||
/// and other non-regular files are left untouched.
|
||||
private func stampProtectionIfWeaker(
|
||||
_ itemURL: URL,
|
||||
requireRegularFile: Bool,
|
||||
attributes: [FileAttributeKey: Any]
|
||||
) -> Int {
|
||||
let values = try? itemURL.resourceValues(
|
||||
forKeys: [.isRegularFileKey, .isDirectoryKey, .fileProtectionKey]
|
||||
)
|
||||
if requireRegularFile {
|
||||
guard values?.isRegularFile == true else { return 0 }
|
||||
} else {
|
||||
guard values?.isDirectory == true else { return 0 }
|
||||
}
|
||||
if let current = values?.fileProtection,
|
||||
current != .none,
|
||||
current != .completeUntilFirstUserAuthentication {
|
||||
return 0
|
||||
}
|
||||
do {
|
||||
try fileManager.setAttributes(attributes, ofItemAtPath: itemURL.path)
|
||||
return 1
|
||||
} catch let error as CocoaError where error.code == .fileNoSuchFile {
|
||||
// Quota eviction or a deletion commit on another store instance
|
||||
// can delete an item out from under this migration; that is not
|
||||
// a failure.
|
||||
return 0
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to migrate media file protection: \(error)",
|
||||
category: .security
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func filesDirectory() throws -> URL {
|
||||
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
|
||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes)
|
||||
return filesDir
|
||||
}
|
||||
|
||||
|
||||
115
bitchat/Services/BLE/BLELinkAuthState.swift
Normal file
115
bitchat/Services/BLE/BLELinkAuthState.swift
Normal file
@ -0,0 +1,115 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Per-link Noise authentication and rebind-containment state.
|
||||
///
|
||||
/// A peer ID can retain an established Noise session after its physical
|
||||
/// link disappears, and link bindings heal on announces whose directness
|
||||
/// is forgeable (TTL is unsigned). This state pins the stronger facts the
|
||||
/// containment rules need: which exact ingress link a Noise handshake
|
||||
/// completed on, each link's revalidation epoch, and the cooldowns that
|
||||
/// stop a replayed announce from flip-flopping bindings or survivor
|
||||
/// selection.
|
||||
///
|
||||
/// Engine-owned (option-B boundary, docs/BLE-ARCHITECTURE-V3.md),
|
||||
/// alongside the link bindings it qualifies: BLEService debug-traps any
|
||||
/// access off the engine queue.
|
||||
struct BLELinkAuthState {
|
||||
private var authenticatedOwners: [BLEIngressLinkID: PeerID] = [:]
|
||||
private var reconnectPolicy = BLENoiseReconnectPolicy()
|
||||
// Entries older than the cooldown are pruned on each check.
|
||||
private var lastRebindAt: [String: Date] = [:]
|
||||
private var lastRedundantRetirementAt: [PeerID: Date] = [:]
|
||||
|
||||
// MARK: - Authentication ownership
|
||||
|
||||
/// Whether `peerID`'s Noise session was established on this exact link.
|
||||
func isAuthenticated(_ link: BLEIngressLinkID, for peerID: PeerID) -> Bool {
|
||||
authenticatedOwners[link] == peerID
|
||||
}
|
||||
|
||||
func links(ownedBy peerID: PeerID) -> [BLEIngressLinkID] {
|
||||
authenticatedOwners.compactMap { link, owner in
|
||||
owner == peerID ? link : nil
|
||||
}
|
||||
}
|
||||
|
||||
mutating func markAuthenticated(_ link: BLEIngressLinkID, owner peerID: PeerID) {
|
||||
authenticatedOwners[link] = peerID
|
||||
}
|
||||
|
||||
/// Retires a link's proof and closes its revalidation epoch — the pair
|
||||
/// every teardown path (disconnect, unsubscribe, timeout, rebind,
|
||||
/// redundant retirement) must apply together.
|
||||
mutating func retireLink(_ link: BLEIngressLinkID) {
|
||||
authenticatedOwners.removeValue(forKey: link)
|
||||
reconnectPolicy.endLinkEpoch(link)
|
||||
}
|
||||
|
||||
/// Retires every link the departing peer's proofs still own; returns
|
||||
/// the retired links.
|
||||
mutating func retireLinks(ownedBy peerID: PeerID) -> [BLEIngressLinkID] {
|
||||
let departed = links(ownedBy: peerID)
|
||||
for link in departed {
|
||||
retireLink(link)
|
||||
}
|
||||
return departed
|
||||
}
|
||||
|
||||
/// Drops every link proof and revalidation epoch. The containment
|
||||
/// cooldowns deliberately SURVIVE this: panic and emergency resets can
|
||||
/// restart services well inside `bleLinkRebindCooldownSeconds`, and a
|
||||
/// stable CoreBluetooth UUID must not get a fresh rebind/retirement
|
||||
/// allowance just because the session state around it was wiped. The
|
||||
/// maps stay time-pruned on each permit check.
|
||||
mutating func removeAll() {
|
||||
authenticatedOwners.removeAll()
|
||||
reconnectPolicy.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Session revalidation
|
||||
|
||||
/// Whether a fresh direct link warrants revalidating a cached
|
||||
/// peer-level session with a new XX exchange.
|
||||
mutating func shouldRevalidate(
|
||||
on link: BLEIngressLinkID,
|
||||
for peerID: PeerID,
|
||||
hasEstablishedSession: Bool,
|
||||
hasAuthenticatedPeerLink: Bool,
|
||||
now: Date
|
||||
) -> Bool {
|
||||
reconnectPolicy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: hasEstablishedSession,
|
||||
isNoiseAuthenticatedLink: isAuthenticated(link, for: peerID),
|
||||
hasAuthenticatedPeerLink: hasAuthenticatedPeerLink,
|
||||
now: now
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Rebind containment cooldowns
|
||||
|
||||
/// At most one rotation rebind per link per cooldown window, so two
|
||||
/// identities can't fight over a link in a replay flip-flop. Prunes,
|
||||
/// checks, and records in one transition; true = permitted (recorded).
|
||||
mutating func permitRebind(linkUUID: String, now: Date, cooldown: TimeInterval) -> Bool {
|
||||
lastRebindAt = lastRebindAt.filter {
|
||||
now.timeIntervalSince($0.value) < cooldown
|
||||
}
|
||||
guard lastRebindAt[linkUUID] == nil else { return false }
|
||||
lastRebindAt[linkUUID] = now
|
||||
return true
|
||||
}
|
||||
|
||||
/// At most one redundant-link retirement per peer per cooldown window,
|
||||
/// bounding how often a replayed announce could flip which duplicate
|
||||
/// link survives. True = permitted (recorded).
|
||||
mutating func permitRedundantRetirement(peerID: PeerID, now: Date, cooldown: TimeInterval) -> Bool {
|
||||
lastRedundantRetirementAt = lastRedundantRetirementAt.filter {
|
||||
now.timeIntervalSince($0.value) < cooldown
|
||||
}
|
||||
guard lastRedundantRetirementAt[peerID] == nil else { return false }
|
||||
lastRedundantRetirementAt[peerID] = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
152
bitchat/Services/BLE/BLELinkBindings.swift
Normal file
152
bitchat/Services/BLE/BLELinkBindings.swift
Normal file
@ -0,0 +1,152 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Identity↔link bindings: which peer each physical link currently
|
||||
/// belongs to, in both roles, plus each peer's preferred peripheral link
|
||||
/// for directed sends and fanout collapse.
|
||||
///
|
||||
/// Engine-owned (option-B boundary, docs/BLE-ARCHITECTURE-V3.md),
|
||||
/// alongside `BLELinkAuthState`: *who owns a link* lives on the engine,
|
||||
/// *what links exist* stays on bleQueue in the physical store. BLEService
|
||||
/// debug-traps any access off the engine queue.
|
||||
///
|
||||
/// Lifecycle contract: bindings are only created for live physical links
|
||||
/// (callers check liveness through `readLinkState`) and are retired
|
||||
/// through `peripheralRemoved`/`centralRemoved`/`clear*` on an engine hop
|
||||
/// queued by the physical teardown. A binding can therefore briefly
|
||||
/// outlive its departed link; queries that need liveness join against the
|
||||
/// physical store, and everything converges once the queued retirement
|
||||
/// runs.
|
||||
struct BLELinkBindings {
|
||||
private var peripheralPeers: [String: PeerID] = [:]
|
||||
private var centralPeers: [String: PeerID] = [:]
|
||||
/// The peer's most recently bound peripheral link, kept so duplicate-
|
||||
/// link fanout collapse stays deterministic (see BLEFanoutSelector).
|
||||
private var preferredPeripheral: [PeerID: String] = [:]
|
||||
|
||||
// MARK: - Queries
|
||||
|
||||
func peer(forPeripheralID peripheralID: String) -> PeerID? {
|
||||
peripheralPeers[peripheralID]
|
||||
}
|
||||
|
||||
func peer(forCentralUUID centralUUID: String) -> PeerID? {
|
||||
centralPeers[centralUUID]
|
||||
}
|
||||
|
||||
func boundPeer(for link: BLEIngressLinkID) -> PeerID? {
|
||||
switch link {
|
||||
case .peripheral(let peripheralUUID):
|
||||
return peripheralPeers[peripheralUUID]
|
||||
case .central(let centralUUID):
|
||||
return centralPeers[centralUUID]
|
||||
}
|
||||
}
|
||||
|
||||
/// Every link bound to the peer, both roles. After a state restoration
|
||||
/// the same device can hold several live peripheral links bound to one
|
||||
/// peer (it reappears under a fresh UUID while the restored connection
|
||||
/// lives on), so this scans all bindings rather than the 1:1 preferred
|
||||
/// map.
|
||||
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
|
||||
guard let peerID else { return [] }
|
||||
var links: Set<BLEIngressLinkID> = []
|
||||
for (peripheralUUID, boundPeer) in peripheralPeers where boundPeer == peerID {
|
||||
links.insert(.peripheral(peripheralUUID))
|
||||
}
|
||||
for (centralUUID, boundPeer) in centralPeers where boundPeer == peerID {
|
||||
links.insert(.central(centralUUID))
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
func hasCentral(boundTo peerID: PeerID) -> Bool {
|
||||
centralPeers.values.contains(peerID)
|
||||
}
|
||||
|
||||
func preferredPeripheralUUID(for peerID: PeerID) -> String? {
|
||||
preferredPeripheral[peerID]
|
||||
}
|
||||
|
||||
/// The full preferred-peripheral map, for fanout collapse.
|
||||
var preferredPeripheralBindings: [PeerID: String] {
|
||||
preferredPeripheral
|
||||
}
|
||||
|
||||
/// The full central binding map, for the subscribed-central snapshot.
|
||||
var centralPeersByUUID: [String: PeerID] {
|
||||
centralPeers
|
||||
}
|
||||
|
||||
// MARK: - Binding transitions
|
||||
|
||||
mutating func bindCentral(_ centralUUID: String, to peerID: PeerID) {
|
||||
centralPeers[centralUUID] = peerID
|
||||
}
|
||||
|
||||
mutating func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
||||
let previousPeerID = peripheralPeers[peripheralUUID]
|
||||
peripheralPeers[peripheralUUID] = peerID
|
||||
// Rebinding (peer-ID rotation): drop the retired ID's reverse
|
||||
// mapping so the old peer no longer claims this link.
|
||||
if let previousPeerID, previousPeerID != peerID,
|
||||
preferredPeripheral[previousPeerID] == peripheralUUID {
|
||||
preferredPeripheral.removeValue(forKey: previousPeerID)
|
||||
}
|
||||
preferredPeripheral[peerID] = peripheralUUID
|
||||
}
|
||||
|
||||
/// Retires a peripheral link's binding. When the removed link was the
|
||||
/// peer's preferred one, the reverse map is repaired onto a surviving
|
||||
/// duplicate chosen by the caller from the peer's remaining bound links
|
||||
/// (the caller knows physical liveness; prefer a writable survivor —
|
||||
/// repairing onto a link mid-service-rediscovery would strand directed
|
||||
/// sends until its characteristic comes back).
|
||||
mutating func peripheralRemoved(
|
||||
_ peripheralUUID: String,
|
||||
chooseSurvivor: (_ remainingBoundUUIDs: [String]) -> String?
|
||||
) -> PeerID? {
|
||||
guard let peerID = peripheralPeers.removeValue(forKey: peripheralUUID) else {
|
||||
return nil
|
||||
}
|
||||
// Only clear (or repair) the reverse map when it points at the
|
||||
// removed link: with duplicate links to one peer, removing a stale
|
||||
// duplicate must not strand the peer's surviving bound link.
|
||||
if preferredPeripheral[peerID] == peripheralUUID {
|
||||
let remaining = peripheralPeers.compactMap { uuid, boundPeer in
|
||||
boundPeer == peerID ? uuid : nil
|
||||
}
|
||||
if let survivorUUID = chooseSurvivor(remaining) {
|
||||
preferredPeripheral[peerID] = survivorUUID
|
||||
} else {
|
||||
preferredPeripheral.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
return peerID
|
||||
}
|
||||
|
||||
mutating func centralRemoved(_ centralUUID: String) -> PeerID? {
|
||||
centralPeers.removeValue(forKey: centralUUID)
|
||||
}
|
||||
|
||||
/// Drops every peripheral binding; returns the peers that held one.
|
||||
mutating func clearPeripherals() -> [PeerID] {
|
||||
let peerIDs = Array(peripheralPeers.values)
|
||||
peripheralPeers.removeAll()
|
||||
preferredPeripheral.removeAll()
|
||||
return peerIDs
|
||||
}
|
||||
|
||||
/// Drops every central binding; returns the peers that held one.
|
||||
mutating func clearCentrals() -> [PeerID] {
|
||||
let peerIDs = Array(centralPeers.values)
|
||||
centralPeers.removeAll()
|
||||
return peerIDs
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
peripheralPeers.removeAll()
|
||||
centralPeers.removeAll()
|
||||
preferredPeripheral.removeAll()
|
||||
}
|
||||
}
|
||||
40
bitchat/Services/BLE/BLELinkEvent.swift
Normal file
40
bitchat/Services/BLE/BLELinkEvent.swift
Normal file
@ -0,0 +1,40 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// The upward half of the link-layer port: everything the bleQueue link
|
||||
/// layer tells the engine, as one enumerable surface with one engine
|
||||
/// entry point (`BLEService.handleLinkEvent`). CoreBluetooth delegates
|
||||
/// shrink to physical bookkeeping plus event emission, and the simulated
|
||||
/// mesh drives the engine through exactly the same seam.
|
||||
///
|
||||
/// Naming follows the physical stores: a *peripheral link* is a
|
||||
/// connection we own as central (keyed by the remote peripheral's UUID);
|
||||
/// a *central link* is a remote central subscribed to our peripheral role
|
||||
/// (keyed by its UUID).
|
||||
enum BLELinkEvent {
|
||||
/// A decoded frame arrived on a link. Attribution — binding lookup,
|
||||
/// spoof rejection, raw-announce binding, ingress recording — is
|
||||
/// engine work. Emission captures the panic lifecycle at the handoff.
|
||||
case frameDecoded(BitchatPacket, link: BLEIngressLinkID, linkDescription: String)
|
||||
|
||||
/// One peripheral link ended (disconnect, connect failure, or radio
|
||||
/// policy teardown). The engine retires the link's identity half —
|
||||
/// proof, epoch, binding with survivor repair — and, when
|
||||
/// `runPeerBookkeeping` is set (real disconnects), marks the peer
|
||||
/// disconnected once its last live link is gone and republishes the
|
||||
/// peer list.
|
||||
case peripheralLinkEnded(peripheralID: String, runPeerBookkeeping: Bool)
|
||||
|
||||
/// A remote central unsubscribed. The engine retires the central
|
||||
/// link's identity half and runs last-link peer bookkeeping.
|
||||
case centralLinkEnded(centralUUID: String)
|
||||
|
||||
/// The central role reset and every peripheral link is gone
|
||||
/// (power-off retires proofs and notifies peers; an authorization
|
||||
/// loss only drops the bindings).
|
||||
case allPeripheralLinksEnded(peripheralIDs: [String], retireProofsAndNotify: Bool)
|
||||
|
||||
/// The peripheral role reset and every central link is gone (same
|
||||
/// power-off / authorization-loss split).
|
||||
case allCentralLinksEnded(centralUUIDs: [String], retireProofsAndNotify: Bool)
|
||||
}
|
||||
@ -5,10 +5,15 @@ import Foundation
|
||||
struct BLEPeripheralLinkState {
|
||||
let peripheral: CBPeripheral
|
||||
var characteristic: CBCharacteristic?
|
||||
var peerID: PeerID?
|
||||
var isConnecting: Bool
|
||||
var isConnected: Bool
|
||||
var lastConnectionAttempt: Date?
|
||||
/// When didConnect last fired for this link. Nil for links restored
|
||||
/// already-connected (their connect predates this process), which is
|
||||
/// exactly the signal redundant-link consolidation needs: a restored
|
||||
/// link lives on an old BLE address the peer no longer advertises,
|
||||
/// so it must never be kept over a freshly connected duplicate.
|
||||
var lastConnectedAt: Date? = nil
|
||||
var assembler: NotificationStreamAssembler
|
||||
}
|
||||
|
||||
@ -26,17 +31,20 @@ struct BLESubscribedCentralSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns all BLE link state (peripheral connections we hold as central, and
|
||||
/// central subscriptions we serve as peripheral). The store has no internal
|
||||
/// locking: every access must happen on the single owning queue (the BLE
|
||||
/// queue). Other queues must go through BLEService's `readLinkState`, which
|
||||
/// hops to that queue. Call `assumeOwnership(of:)` to have debug builds trap
|
||||
/// any access from the wrong queue.
|
||||
// BLEDirectLinkState and the identity↔link binding queries live on
|
||||
// BLELinkBindings; this store owns only physical link state.
|
||||
|
||||
/// Owns the PHYSICAL BLE link state (peripheral connections we hold as
|
||||
/// central, and central subscriptions we serve as peripheral) — CB object
|
||||
/// handles, connect lifecycles, characteristics, and stream assemblers.
|
||||
/// Identity↔link bindings live on `BLELinkBindings`. The store has no
|
||||
/// internal locking: every access must happen on the single owning queue
|
||||
/// (the BLE queue). Other queues must go through BLEService's
|
||||
/// `readLinkState`, which hops to that queue. Call `assumeOwnership(of:)`
|
||||
/// to have debug builds trap any access from the wrong queue.
|
||||
final class BLELinkStateStore {
|
||||
private(set) var peripherals: [String: BLEPeripheralLinkState] = [:]
|
||||
private(set) var peerToPeripheralUUID: [PeerID: String] = [:]
|
||||
private(set) var subscribedCentrals: [CBCentral] = []
|
||||
private(set) var centralToPeerID: [String: PeerID] = [:]
|
||||
|
||||
#if DEBUG
|
||||
private var ownerQueue: DispatchQueue?
|
||||
@ -64,14 +72,6 @@ final class BLELinkStateStore {
|
||||
return Array(peripherals.values)
|
||||
}
|
||||
|
||||
var subscribedCentralSnapshot: BLESubscribedCentralSnapshot {
|
||||
assertOwned()
|
||||
return BLESubscribedCentralSnapshot(
|
||||
centrals: subscribedCentrals,
|
||||
peerIDsByCentralUUID: centralToPeerID
|
||||
)
|
||||
}
|
||||
|
||||
var subscribedCentralCount: Int {
|
||||
assertOwned()
|
||||
return subscribedCentrals.count
|
||||
@ -109,7 +109,6 @@ final class BLELinkStateStore {
|
||||
BLEPeripheralLinkState(
|
||||
peripheral: peripheral,
|
||||
characteristic: nil,
|
||||
peerID: nil,
|
||||
isConnecting: true,
|
||||
isConnected: false,
|
||||
lastConnectionAttempt: date,
|
||||
@ -119,20 +118,21 @@ final class BLELinkStateStore {
|
||||
)
|
||||
}
|
||||
|
||||
func markConnected(_ peripheral: CBPeripheral) {
|
||||
func markConnected(_ peripheral: CBPeripheral, at now: Date = Date()) {
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
if updatePeripheral(peripheralID, {
|
||||
$0.isConnecting = false
|
||||
$0.isConnected = true
|
||||
$0.lastConnectedAt = now
|
||||
}) == nil {
|
||||
setPeripheralState(
|
||||
BLEPeripheralLinkState(
|
||||
peripheral: peripheral,
|
||||
characteristic: nil,
|
||||
peerID: nil,
|
||||
isConnecting: false,
|
||||
isConnected: true,
|
||||
lastConnectionAttempt: nil,
|
||||
lastConnectedAt: now,
|
||||
assembler: NotificationStreamAssembler()
|
||||
),
|
||||
for: peripheralID
|
||||
@ -146,130 +146,35 @@ final class BLELinkStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
|
||||
assertOwned()
|
||||
return peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
|
||||
}
|
||||
|
||||
func directLinkState(for peerID: PeerID) -> BLEDirectLinkState {
|
||||
assertOwned()
|
||||
let peripheralUUID = peerToPeripheralUUID[peerID]
|
||||
let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false
|
||||
let hasCentral = centralToPeerID.values.contains(peerID)
|
||||
return BLEDirectLinkState(hasPeripheral: hasPeripheral, hasCentral: hasCentral)
|
||||
}
|
||||
|
||||
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
|
||||
assertOwned()
|
||||
guard let peerID else { return [] }
|
||||
|
||||
var links: Set<BLEIngressLinkID> = []
|
||||
// Scan all states rather than the 1:1 reverse map: after a state
|
||||
// restoration the same device can hold several live peripheral links
|
||||
// bound to one peer (it reappears under a fresh UUID while the
|
||||
// restored connection lives on).
|
||||
for (peripheralUUID, state) in peripherals where state.peerID == peerID {
|
||||
links.insert(.peripheral(peripheralUUID))
|
||||
}
|
||||
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
|
||||
links.insert(.central(centralUUID))
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
/// The peer's most recently bound peripheral link, per peer. Used to keep
|
||||
/// duplicate-link fanout collapse deterministic (see BLEFanoutSelector).
|
||||
var preferredPeripheralBindings: [PeerID: String] {
|
||||
assertOwned()
|
||||
return peerToPeripheralUUID
|
||||
}
|
||||
|
||||
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
|
||||
assertOwned()
|
||||
return peripherals[peripheralID]?.peerID
|
||||
}
|
||||
|
||||
func peerID(forCentralUUID centralUUID: String) -> PeerID? {
|
||||
assertOwned()
|
||||
return centralToPeerID[centralUUID]
|
||||
}
|
||||
|
||||
func addSubscribedCentral(_ central: CBCentral) {
|
||||
assertOwned()
|
||||
guard !subscribedCentrals.contains(central) else { return }
|
||||
subscribedCentrals.append(central)
|
||||
}
|
||||
|
||||
func removeSubscribedCentral(_ central: CBCentral) -> PeerID? {
|
||||
func removeSubscribedCentral(_ central: CBCentral) {
|
||||
assertOwned()
|
||||
let centralUUID = central.identifier.uuidString
|
||||
subscribedCentrals.removeAll { $0.identifier == central.identifier }
|
||||
return centralToPeerID.removeValue(forKey: centralUUID)
|
||||
}
|
||||
|
||||
func bindCentral(_ centralUUID: String, to peerID: PeerID) {
|
||||
func removePeripheral(_ peripheralID: String) {
|
||||
assertOwned()
|
||||
centralToPeerID[centralUUID] = peerID
|
||||
peripherals.removeValue(forKey: peripheralID)
|
||||
}
|
||||
|
||||
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
||||
func clearPeripherals() {
|
||||
assertOwned()
|
||||
var previousPeerID: PeerID?
|
||||
let updated = updatePeripheral(peripheralUUID) {
|
||||
previousPeerID = $0.peerID
|
||||
$0.peerID = peerID
|
||||
}
|
||||
guard updated != nil else { return }
|
||||
// Rebinding (peer-ID rotation): drop the retired ID's reverse mapping
|
||||
// so the old peer no longer claims this link.
|
||||
if let previousPeerID, previousPeerID != peerID,
|
||||
peerToPeripheralUUID[previousPeerID] == peripheralUUID {
|
||||
peerToPeripheralUUID.removeValue(forKey: previousPeerID)
|
||||
}
|
||||
peerToPeripheralUUID[peerID] = peripheralUUID
|
||||
}
|
||||
|
||||
func removePeripheral(_ peripheralID: String) -> PeerID? {
|
||||
assertOwned()
|
||||
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
|
||||
// Only clear (or repair) the reverse map when it points at the removed
|
||||
// link: with duplicate links to one peer, removing a stale duplicate
|
||||
// must not strand the peer's surviving bound link.
|
||||
if let peerID, peerToPeripheralUUID[peerID] == peripheralID {
|
||||
// Prefer a writable survivor: repairing onto a link that is
|
||||
// mid-service-rediscovery would strand directed sends until the
|
||||
// characteristic comes back.
|
||||
let survivors = peripherals.filter { $0.value.peerID == peerID && $0.value.isConnected }
|
||||
if let survivorUUID = survivors.first(where: { $0.value.characteristic != nil })?.key ?? survivors.first?.key {
|
||||
peerToPeripheralUUID[peerID] = survivorUUID
|
||||
} else {
|
||||
peerToPeripheralUUID.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
return peerID
|
||||
}
|
||||
|
||||
func clearPeripherals() -> [PeerID] {
|
||||
assertOwned()
|
||||
let peerIDs = peripherals.compactMap { $0.value.peerID }
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
return peerIDs
|
||||
}
|
||||
|
||||
func clearCentrals() -> [PeerID] {
|
||||
func clearCentrals() {
|
||||
assertOwned()
|
||||
let peerIDs = Array(centralToPeerID.values)
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
return peerIDs
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
assertOwned()
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
@ -261,8 +261,6 @@ struct BLEOutboundFragmentTransferScheduler {
|
||||
continue
|
||||
}
|
||||
|
||||
availableSlots -= 1
|
||||
|
||||
guard activeTransfers.count < maxConcurrentTransfers else {
|
||||
pendingTransfers.insert(request, at: 0)
|
||||
results.append(.queued(request: request, transferId: transferId, position: .front))
|
||||
@ -270,11 +268,17 @@ struct BLEOutboundFragmentTransferScheduler {
|
||||
}
|
||||
|
||||
guard activeTransfers[transferId] == nil else {
|
||||
// Blocked on an already-active copy of this content: leave
|
||||
// the slot budget untouched so a later, unrelated pending
|
||||
// transfer can still start in this same pass instead of
|
||||
// being starved until some other transfer happens to
|
||||
// complete.
|
||||
blockedFront.append(request)
|
||||
results.append(.queued(request: request, transferId: transferId, position: .front))
|
||||
continue
|
||||
}
|
||||
|
||||
availableSlots -= 1
|
||||
activeTransfers[transferId] = ActiveTransferState(
|
||||
totalFragments: 0,
|
||||
sentFragments: 0,
|
||||
|
||||
@ -15,7 +15,15 @@ enum BLEOutboundPacketPolicy {
|
||||
// voiceFrame is deliberately unpadded: padding to the 512 block would
|
||||
// push every ~490-byte signed voice packet over the MTU into the
|
||||
// fragment path.
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame:
|
||||
//
|
||||
// announceV2 is unpadded too, but for a different reason and it is worth
|
||||
// revisiting: it is ~75 bytes, so the smallest bucket would triple the
|
||||
// airtime of the most frequently sent packet in the protocol. Its length
|
||||
// is already near-constant by construction (the tag block is fixed
|
||||
// width); the residual variation is the capability width and whether a
|
||||
// bridge geohash is present. Making those fixed-width would be cheaper
|
||||
// than padding. See docs/PEER-ID-ROTATION.md.
|
||||
case .none, .announce, .announceV2, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -27,6 +35,13 @@ enum BLEOutboundPacketPolicy {
|
||||
return .fragment(totalFragments: fragmentTotalCount(from: packet.payload))
|
||||
case .fileTransfer:
|
||||
return .fileTransfer
|
||||
case .announceV2:
|
||||
// Stated rather than inherited from `default`. Presence is small,
|
||||
// time-bounded to its epoch, and useless once stale, so it belongs
|
||||
// with the other control traffic at high priority — but that should
|
||||
// be a decision on the record, not a fall-through, since this type
|
||||
// is not emitted yet and nobody would notice the choice being made.
|
||||
return .high
|
||||
default:
|
||||
return .high
|
||||
}
|
||||
|
||||
@ -223,7 +223,7 @@ struct BLEPeerRegistry {
|
||||
|
||||
peers[peerID] = BLEPeerInfo(
|
||||
peerID: existing?.peerID ?? peerID,
|
||||
nickname: nickname,
|
||||
nickname: nickname.normalizedNickname,
|
||||
isConnected: isConnected,
|
||||
noisePublicKey: noisePublicKey,
|
||||
// Never drop an already-pinned signing key.
|
||||
|
||||
@ -337,7 +337,6 @@ final class BLERadioController {
|
||||
BLEPeripheralLinkState(
|
||||
peripheral: target.peripheral,
|
||||
characteristic: nil,
|
||||
peerID: nil,
|
||||
isConnecting: true,
|
||||
isConnected: false,
|
||||
lastConnectionAttempt: nil,
|
||||
|
||||
@ -21,24 +21,53 @@ enum BLERedundantLinkPolicy {
|
||||
/// A link mid-service-rediscovery (didModifyServices cleared it)
|
||||
/// must never be kept over a writable duplicate.
|
||||
let hasCharacteristic: Bool
|
||||
/// When didConnect last fired for this link in this process. Nil
|
||||
/// for restored links, whose connect predates the relaunch.
|
||||
let lastConnectedAt: Date?
|
||||
|
||||
init(uuid: String, peerID: PeerID?, isConnected: Bool, hasCharacteristic: Bool) {
|
||||
init(
|
||||
uuid: String,
|
||||
peerID: PeerID?,
|
||||
isConnected: Bool,
|
||||
hasCharacteristic: Bool,
|
||||
lastConnectedAt: Date? = nil
|
||||
) {
|
||||
self.uuid = uuid
|
||||
self.peerID = peerID
|
||||
self.isConnected = isConnected
|
||||
self.hasCharacteristic = hasCharacteristic
|
||||
self.lastConnectedAt = lastConnectedAt
|
||||
}
|
||||
}
|
||||
|
||||
/// The link to keep when a peer has several connected bound peripheral
|
||||
/// links, or nil when there is nothing to consolidate. Prefers the
|
||||
/// ingress link of the verified direct announce that triggered the check
|
||||
/// (the strongest liveness proof available), falling back to the peer's
|
||||
/// most recently bound link — but only among writable links while any
|
||||
/// exist: keeping a characteristic-less link and cancelling the writable
|
||||
/// links, or nil when there is nothing to consolidate.
|
||||
///
|
||||
/// Prefers the most recently CONNECTED candidate. Duplicates arise when
|
||||
/// the peer reappears under a fresh BLE address (privacy address
|
||||
/// rotation) while an older connection — typically state-restored —
|
||||
/// lives on: only the newest connection sits on the address the peer
|
||||
/// still advertises. Cancelling that one instead just gets it
|
||||
/// rediscovered and reconnected, a retire↔reconnect oscillation at the
|
||||
/// retirement cooldown (field-observed July 31); the older-address link
|
||||
/// cannot return once cancelled, so consolidation converges immediately.
|
||||
/// Physical connect recency is also a signal an announce replay cannot
|
||||
/// nominate, unlike the previous ingress-link preference — announce
|
||||
/// anchors (ingress, then most recently bound) now only break ties and
|
||||
/// serve links with no connect timestamp at all. Link "health" signals
|
||||
/// like RSSI are deliberately not inputs: they are transient and the
|
||||
/// stale-address link often reads stronger; connect recency is the only
|
||||
/// signal that tracks address currency.
|
||||
///
|
||||
/// The survivor must be writable while any writable candidate exists:
|
||||
/// keeping a characteristic-less link and cancelling the writable
|
||||
/// duplicate would strand outbound traffic on the central link until
|
||||
/// rediscovery finishes. When neither anchor is a viable candidate,
|
||||
/// consolidation waits for a later announce rather than guessing.
|
||||
/// rediscovery finishes. But when the physically NEWEST connection is
|
||||
/// the one that is not writable yet (service discovery still running),
|
||||
/// consolidation defers entirely — selecting an older writable link
|
||||
/// would cancel the freshly advertised connection and recreate the
|
||||
/// oscillation. When no candidate is identifiable, consolidation waits
|
||||
/// for a later announce rather than guessing.
|
||||
static func keptPeripheralUUID(
|
||||
ingressPeripheralUUID: String?,
|
||||
mostRecentlyBoundUUID: String?,
|
||||
@ -51,6 +80,42 @@ enum BLERedundantLinkPolicy {
|
||||
let writable = bound.filter(\.hasCharacteristic)
|
||||
let candidates = writable.isEmpty ? bound : writable
|
||||
|
||||
// The newest connection is still mid-service-discovery while a
|
||||
// writable (typically restored, stale-address) duplicate exists:
|
||||
// defer to a later announce instead of keeping the older link and
|
||||
// cancelling the one connection on the currently advertised address.
|
||||
if !writable.isEmpty,
|
||||
let newestBoundDate = bound.compactMap(\.lastConnectedAt).max(),
|
||||
!writable.contains(where: { $0.lastConnectedAt == newestBoundDate }) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let newestDate = candidates.compactMap(\.lastConnectedAt).max() {
|
||||
let newest = candidates.filter { $0.lastConnectedAt == newestDate }
|
||||
if newest.count == 1 {
|
||||
return newest[0].uuid
|
||||
}
|
||||
return anchoredChoice(
|
||||
among: newest,
|
||||
ingressPeripheralUUID: ingressPeripheralUUID,
|
||||
mostRecentlyBoundUUID: mostRecentlyBoundUUID
|
||||
) ?? newest.map(\.uuid).min()
|
||||
}
|
||||
|
||||
return anchoredChoice(
|
||||
among: candidates,
|
||||
ingressPeripheralUUID: ingressPeripheralUUID,
|
||||
mostRecentlyBoundUUID: mostRecentlyBoundUUID
|
||||
)
|
||||
}
|
||||
|
||||
/// The pre-timestamp anchors: the verified announce's ingress link,
|
||||
/// then the peer's most recently bound link.
|
||||
private static func anchoredChoice(
|
||||
among candidates: [PeripheralLink],
|
||||
ingressPeripheralUUID: String?,
|
||||
mostRecentlyBoundUUID: String?
|
||||
) -> String? {
|
||||
if let ingressPeripheralUUID, candidates.contains(where: { $0.uuid == ingressPeripheralUUID }) {
|
||||
return ingressPeripheralUUID
|
||||
}
|
||||
|
||||
433
bitchat/Services/BLE/BLEService+LinkLayerCentralRole.swift
Normal file
433
bitchat/Services/BLE/BLEService+LinkLayerCentralRole.swift
Normal file
@ -0,0 +1,433 @@
|
||||
//
|
||||
// BLEService+LinkLayerCentralRole.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
// The bleQueue half of the link layer: CoreBluetooth delegate callbacks do
|
||||
// physical bookkeeping (link-state store, buffers, radio policy) and report
|
||||
// everything else to the engine through the link-event port
|
||||
// (BLELinkEvent / emitLinkEvent). See docs/BLE-ARCHITECTURE-V3.md.
|
||||
|
||||
// MARK: - CBCentralManagerDelegate
|
||||
|
||||
extension BLEService: CBCentralManagerDelegate {
|
||||
#if os(iOS)
|
||||
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
||||
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
|
||||
guard !isPanicSuspended else {
|
||||
central.stopScan()
|
||||
restoredPeripherals.forEach {
|
||||
central.cancelPeripheralConnection($0)
|
||||
}
|
||||
return
|
||||
}
|
||||
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
|
||||
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
|
||||
let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool
|
||||
|
||||
SecureLogger.info(
|
||||
"♻️ Central restore: peripherals=\(restoredPeripherals.count) services=\(restoredServices.count) allowDuplicates=\(String(describing: allowDuplicates))",
|
||||
category: .session
|
||||
)
|
||||
|
||||
for peripheral in restoredPeripherals {
|
||||
let identifier = peripheral.identifier.uuidString
|
||||
peripheral.delegate = self
|
||||
let existing = linkStateStore.state(forPeripheralID: identifier)
|
||||
let assembler = existing?.assembler ?? NotificationStreamAssembler()
|
||||
let characteristic = existing?.characteristic
|
||||
let wasConnecting = existing?.isConnecting ?? false
|
||||
let wasConnected = existing?.isConnected ?? false
|
||||
|
||||
let restoredState = BLEPeripheralLinkState(
|
||||
peripheral: peripheral,
|
||||
characteristic: characteristic,
|
||||
isConnecting: wasConnecting || peripheral.state == .connecting,
|
||||
isConnected: wasConnected || peripheral.state == .connected,
|
||||
lastConnectionAttempt: existing?.lastConnectionAttempt,
|
||||
assembler: assembler
|
||||
)
|
||||
linkStateStore.setPeripheralState(restoredState, for: identifier)
|
||||
|
||||
// Restored peripherals are the freshest wake-on-proximity
|
||||
// candidates we have after a relaunch — without this the cache
|
||||
// starts empty and backgrounding right after a restore arms
|
||||
// nothing. Service rediscovery for restored-connected links waits
|
||||
// for poweredOn: CoreBluetooth drops commands issued during
|
||||
// restoration (API MISUSE warnings).
|
||||
radio.recordRecentPeripheral(peripheral, peripheralID: identifier, at: Date())
|
||||
}
|
||||
|
||||
// Via the sampler (not a direct capture): it refreshes the cached
|
||||
// background budget on main first, so the restore log shows the real
|
||||
// wake window instead of the init sentinel.
|
||||
logBluetoothStatus("central-restore")
|
||||
|
||||
if central.state == .poweredOn {
|
||||
radio.startScanning()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
emitTransportEvent(.bluetoothStateUpdated(central.state))
|
||||
|
||||
switch central.state {
|
||||
case .poweredOn:
|
||||
guard !isPanicSuspended else {
|
||||
central.stopScan()
|
||||
return
|
||||
}
|
||||
// Links restored as connected have no characteristic in the new
|
||||
// process; without rediscovery they sit connected-but-unusable
|
||||
// until the peer disconnects. Runs here (not willRestoreState)
|
||||
// because commands issued before poweredOn are dropped.
|
||||
for state in linkStateStore.peripheralStates where state.isConnected
|
||||
&& state.characteristic == nil
|
||||
&& state.peripheral.state == .connected {
|
||||
SecureLogger.info("♻️ Rediscovering services on restored link: \(state.peripheral.identifier.uuidString.prefix(8))…", category: .session)
|
||||
state.peripheral.discoverServices([BLEService.serviceUUID])
|
||||
}
|
||||
|
||||
// Start scanning - use allow duplicates for faster discovery when active
|
||||
radio.startScanning()
|
||||
|
||||
case .poweredOff:
|
||||
// CoreBluetooth has already transitioned out of poweredOn. Do
|
||||
// not issue stop/cancel commands now; they are rejected as API
|
||||
// misuse. Retire our link state locally instead.
|
||||
SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
|
||||
let peripheralIDs = linkStateStore.peripheralStates.map { $0.peripheral.identifier.uuidString }
|
||||
for peripheralID in peripheralIDs {
|
||||
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||
}
|
||||
linkStateStore.clearPeripherals()
|
||||
emitLinkEvent(.allPeripheralLinksEnded(peripheralIDs: peripheralIDs, retireProofsAndNotify: true))
|
||||
|
||||
case .unauthorized:
|
||||
// User denied Bluetooth permission
|
||||
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
|
||||
linkStateStore.clearPeripherals()
|
||||
emitLinkEvent(.allPeripheralLinksEnded(peripheralIDs: [], retireProofsAndNotify: false))
|
||||
|
||||
case .unsupported:
|
||||
// Device doesn't support BLE
|
||||
SecureLogger.error("❌ Bluetooth LE not supported on this device", category: .session)
|
||||
|
||||
case .resetting:
|
||||
// Bluetooth stack is resetting - will get another state update when done
|
||||
SecureLogger.info("🔄 Bluetooth stack resetting...", category: .session)
|
||||
|
||||
case .unknown:
|
||||
// Initial state before we know the actual state
|
||||
SecureLogger.debug("❓ Bluetooth state unknown (initializing)", category: .session)
|
||||
|
||||
@unknown default:
|
||||
SecureLogger.warning("⚠️ Unknown Bluetooth state: \(central.state.rawValue)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||
radio.handleDiscovery(peripheral, advertisementData: advertisementData, rssi: RSSI)
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
guard !isPanicSuspended else {
|
||||
central.cancelPeripheralConnection(peripheral)
|
||||
return
|
||||
}
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
|
||||
#if os(iOS)
|
||||
// A connect completing while backgrounded is the wake-on-proximity
|
||||
// path doing its job — worth an info line for field verification.
|
||||
if !isAppActive {
|
||||
SecureLogger.info("🌙 Background wake: connected to \(peripheral.name ?? peripheralID) while backgrounded", category: .session)
|
||||
}
|
||||
#endif
|
||||
|
||||
// Update state to connected
|
||||
linkStateStore.markConnected(peripheral)
|
||||
|
||||
// Reset backoff state on success
|
||||
radio.recordConnectionSuccess(peripheralID: peripheralID)
|
||||
|
||||
SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session)
|
||||
|
||||
// Discover services
|
||||
peripheral.discoverServices([BLEService.serviceUUID])
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
|
||||
SecureLogger.debug("📱 Disconnect: \(peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session)
|
||||
|
||||
// If disconnect carried an error (often timeout), apply short backoff to avoid thrash
|
||||
if error != nil {
|
||||
radio.recordDisconnectError(peripheralID: peripheralID, at: Date())
|
||||
}
|
||||
|
||||
// Retain the handle: a dropped link is the best wake-on-proximity
|
||||
// candidate if the app backgrounds before the peer returns.
|
||||
radio.recordRecentPeripheral(peripheral, peripheralID: peripheralID, at: Date())
|
||||
|
||||
#if os(iOS)
|
||||
// Link lost while backgrounded (peer walked away): re-arm a pending
|
||||
// connect during this wake window so the peer's return wakes us again.
|
||||
// Delayed past the disconnect-settle window to avoid reconnect thrash
|
||||
// at range edge.
|
||||
if !isAppActive {
|
||||
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleDisconnectDiscoveryIgnoreSeconds) { [weak self] in
|
||||
guard let self, !self.isAppActive else { return }
|
||||
// Reserve 0: use the slot this disconnect freed even in a
|
||||
// dense mesh, so the lost peer can wake us when it returns.
|
||||
self.radio.armPendingBackgroundConnects(slotReserve: 0)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Physical teardown now; identity retirement and peer-disconnect
|
||||
// bookkeeping ride the link-event port. The scan restart and
|
||||
// connect-slot refill below stay on bleQueue — they respond to
|
||||
// the physical drop regardless of remaining logical links.
|
||||
discardPeripheralLinkPhysical(peripheralID)
|
||||
emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: true))
|
||||
|
||||
// Restart scanning with allow duplicates for faster rediscovery
|
||||
if centralManager?.state == .poweredOn {
|
||||
// Stop and restart scanning to ensure we get fresh discovery events
|
||||
centralManager?.stopScan()
|
||||
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in
|
||||
self?.radio.startScanning()
|
||||
}
|
||||
}
|
||||
// Attempt to fill freed slot from queue
|
||||
bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() }
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
|
||||
// Clean up the references: physical now, identity via the port.
|
||||
discardPeripheralLinkPhysical(peripheralID)
|
||||
emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: false))
|
||||
|
||||
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
|
||||
radio.recordConnectionFailure(peripheralID: peripheralID)
|
||||
// Try next candidate
|
||||
bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBPeripheralDelegate
|
||||
|
||||
extension BLEService: CBPeripheralDelegate {
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
guard !isPanicSuspended else { return }
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
||||
// Retry service discovery after a delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
guard peripheral.state == .connected else { return }
|
||||
peripheral.discoverServices([BLEService.serviceUUID])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let services = peripheral.services else {
|
||||
SecureLogger.warning("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
guard let service = services.first(where: { $0.uuid == BLEService.serviceUUID }) else {
|
||||
// Not a BitChat peer - disconnect
|
||||
centralManager?.cancelPeripheralConnection(peripheral)
|
||||
return
|
||||
}
|
||||
|
||||
// Discovering BLE characteristics
|
||||
peripheral.discoverCharacteristics([BLEService.characteristicUUID], for: service)
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||
guard !isPanicSuspended else { return }
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else {
|
||||
SecureLogger.warning("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Found characteristic
|
||||
|
||||
// Log characteristic properties for debugging
|
||||
var properties: [String] = []
|
||||
if characteristic.properties.contains(.read) { properties.append("read") }
|
||||
if characteristic.properties.contains(.write) { properties.append("write") }
|
||||
if characteristic.properties.contains(.writeWithoutResponse) { properties.append("writeWithoutResponse") }
|
||||
if characteristic.properties.contains(.notify) { properties.append("notify") }
|
||||
if characteristic.properties.contains(.indicate) { properties.append("indicate") }
|
||||
// Characteristic properties: \(properties.joined(separator: ", "))
|
||||
|
||||
// Verify characteristic supports reliable writes
|
||||
if !characteristic.properties.contains(.write) {
|
||||
SecureLogger.warning("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: .session)
|
||||
}
|
||||
|
||||
// Store characteristic in our consolidated structure
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
linkStateStore.updateCharacteristic(characteristic, forPeripheralID: peripheralID)
|
||||
|
||||
// Subscribe for notifications
|
||||
if characteristic.properties.contains(.notify) {
|
||||
peripheral.setNotifyValue(true, for: characteristic)
|
||||
SecureLogger.debug("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: .session)
|
||||
|
||||
// Send announce after subscription is confirmed (force send for new connection)
|
||||
engineScheduler.schedule(after: TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
// Try flushing any spooled directed packets now that we have a link
|
||||
self?.flushDirectedSpool()
|
||||
}
|
||||
} else {
|
||||
SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
guard !isPanicSuspended else { return }
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = characteristic.value, !data.isEmpty else {
|
||||
SecureLogger.warning("⚠️ No data in notification", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
bufferNotificationChunk(data, from: peripheral)
|
||||
}
|
||||
|
||||
private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) {
|
||||
let peripheralUUID = peripheral.identifier.uuidString
|
||||
|
||||
var state = linkStateStore.state(forPeripheralID: peripheralUUID) ?? BLEPeripheralLinkState(
|
||||
peripheral: peripheral,
|
||||
characteristic: nil,
|
||||
isConnecting: false,
|
||||
isConnected: peripheral.state == .connected,
|
||||
lastConnectionAttempt: nil,
|
||||
assembler: NotificationStreamAssembler()
|
||||
)
|
||||
|
||||
var assembler = state.assembler
|
||||
let result = assembler.append(chunk)
|
||||
state.assembler = assembler
|
||||
linkStateStore.setPeripheralState(state, for: peripheralUUID)
|
||||
|
||||
for byte in result.droppedPrefixes {
|
||||
SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session)
|
||||
}
|
||||
|
||||
if result.reset {
|
||||
SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session)
|
||||
}
|
||||
|
||||
// Attribution — spoof rejection, announce binding, ingress
|
||||
// recording — is engine work now (the engine owns the bindings).
|
||||
// Frames hop up in decode order; the engine's serial slot ordering
|
||||
// gives the same same-batch spoof protection the old bleQueue-side
|
||||
// batch-local binding enforced: an announce that binds this link is
|
||||
// attributed before every frame that rode behind it.
|
||||
for frame in result.frames {
|
||||
guard let packet = BinaryProtocol.decode(frame) else {
|
||||
let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||
SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
|
||||
continue
|
||||
}
|
||||
emitLinkEvent(.frameDecoded(
|
||||
packet,
|
||||
link: .peripheral(peripheralUUID),
|
||||
linkDescription: "Peripheral \(peripheralUUID.prefix(8))…"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: .session)
|
||||
// Don't retry - just log the error
|
||||
} else {
|
||||
SecureLogger.debug("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
||||
guard !isPanicSuspended else { return }
|
||||
// Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
|
||||
if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") {
|
||||
SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session)
|
||||
}
|
||||
drainPendingWrites(for: peripheral)
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
|
||||
guard !isPanicSuspended else { return }
|
||||
SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
|
||||
|
||||
let shouldRediscover = BLEService.shouldRediscoverBitChatService(
|
||||
invalidatedServiceUUIDs: invalidatedServices.map(\.uuid),
|
||||
cachedServiceUUIDs: peripheral.services?.map(\.uuid)
|
||||
)
|
||||
|
||||
guard shouldRediscover else { return }
|
||||
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
linkStateStore.updatePeripheral(peripheralID) {
|
||||
$0.characteristic = nil
|
||||
$0.assembler = NotificationStreamAssembler()
|
||||
}
|
||||
|
||||
SecureLogger.debug("🔄 BitChat service changed for \(peripheral.name ?? peripheral.identifier.uuidString), rediscovering", category: .session)
|
||||
peripheral.discoverServices([BLEService.serviceUUID])
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
||||
guard !isPanicSuspended else { return }
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
|
||||
} else {
|
||||
SecureLogger.debug("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: .session)
|
||||
|
||||
// If notifications are now on, send an announce to ensure this peer knows about us
|
||||
if characteristic.isNotifying {
|
||||
// Sending announce after subscription
|
||||
self.sendAnnounce(forceSend: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension BLEService {
|
||||
static func shouldRediscoverBitChatService(
|
||||
invalidatedServiceUUIDs: [CBUUID],
|
||||
cachedServiceUUIDs: [CBUUID]?
|
||||
) -> Bool {
|
||||
invalidatedServiceUUIDs.contains(serviceUUID) || cachedServiceUUIDs?.contains(serviceUUID) != true
|
||||
}
|
||||
}
|
||||
320
bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift
Normal file
320
bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift
Normal file
@ -0,0 +1,320 @@
|
||||
//
|
||||
// BLEService+LinkLayerPeripheralRole.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
// The bleQueue half of the link layer: CoreBluetooth delegate callbacks do
|
||||
// physical bookkeeping (link-state store, buffers, radio policy) and report
|
||||
// everything else to the engine through the link-event port
|
||||
// (BLELinkEvent / emitLinkEvent). See docs/BLE-ARCHITECTURE-V3.md.
|
||||
|
||||
// MARK: - CBPeripheralManagerDelegate
|
||||
|
||||
extension BLEService: CBPeripheralManagerDelegate {
|
||||
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
|
||||
SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session)
|
||||
|
||||
switch peripheral.state {
|
||||
case .poweredOn:
|
||||
guard !isPanicSuspended else {
|
||||
peripheral.stopAdvertising()
|
||||
peripheral.removeAllServices()
|
||||
characteristic = nil
|
||||
return
|
||||
}
|
||||
// Remove all services first to ensure clean state
|
||||
peripheral.removeAllServices()
|
||||
|
||||
// Create characteristic
|
||||
characteristic = CBMutableCharacteristic(
|
||||
type: BLEService.characteristicUUID,
|
||||
properties: [.notify, .write, .writeWithoutResponse, .read],
|
||||
value: nil,
|
||||
permissions: [.readable, .writeable]
|
||||
)
|
||||
|
||||
// Create service
|
||||
let service = CBMutableService(type: BLEService.serviceUUID, primary: true)
|
||||
service.characteristics = [characteristic!]
|
||||
|
||||
// Add service (advertising will start in didAdd delegate)
|
||||
SecureLogger.debug("🔧 Adding BLE service...", category: .session)
|
||||
peripheral.add(service)
|
||||
|
||||
case .poweredOff:
|
||||
// Bluetooth was turned off - clean up peripheral state
|
||||
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
|
||||
// Clear subscribed centrals (they are now invalid)
|
||||
let centralIDs = linkStateStore.subscribedCentrals.map { $0.identifier.uuidString }
|
||||
pendingNotifications.removeAll()
|
||||
pendingWriteBuffers.removeAll()
|
||||
linkStateStore.clearCentrals()
|
||||
subscriptionAnnounceLimiter.removeAll()
|
||||
characteristic = nil
|
||||
emitLinkEvent(.allCentralLinksEnded(centralUUIDs: centralIDs, retireProofsAndNotify: true))
|
||||
|
||||
case .unauthorized:
|
||||
// User denied Bluetooth permission
|
||||
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
|
||||
linkStateStore.clearCentrals()
|
||||
subscriptionAnnounceLimiter.removeAll()
|
||||
characteristic = nil
|
||||
emitLinkEvent(.allCentralLinksEnded(centralUUIDs: [], retireProofsAndNotify: false))
|
||||
|
||||
case .unsupported:
|
||||
// Device doesn't support BLE peripheral role
|
||||
SecureLogger.error("❌ Bluetooth LE peripheral role not supported", category: .session)
|
||||
|
||||
case .resetting:
|
||||
// Bluetooth stack is resetting
|
||||
SecureLogger.info("🔄 Bluetooth peripheral stack resetting...", category: .session)
|
||||
|
||||
case .unknown:
|
||||
SecureLogger.debug("❓ Peripheral Bluetooth state unknown (initializing)", category: .session)
|
||||
|
||||
@unknown default:
|
||||
SecureLogger.warning("⚠️ Unknown peripheral Bluetooth state: \(peripheral.state.rawValue)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
|
||||
guard !isPanicSuspended else {
|
||||
peripheral.stopAdvertising()
|
||||
peripheral.removeAllServices()
|
||||
characteristic = nil
|
||||
return
|
||||
}
|
||||
let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
|
||||
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
|
||||
|
||||
SecureLogger.info(
|
||||
"♻️ Peripheral restore: services=\(restoredServices.count) advertisingDataKeys=\(Array(restoredAdvertisement.keys))",
|
||||
category: .session
|
||||
)
|
||||
|
||||
// Attempt to recover characteristic from restored services
|
||||
if characteristic == nil {
|
||||
if let service = restoredServices.first(where: { $0.uuid == BLEService.serviceUUID }),
|
||||
let restoredCharacteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) as? CBMutableCharacteristic {
|
||||
characteristic = restoredCharacteristic
|
||||
}
|
||||
}
|
||||
|
||||
// Via the sampler for a fresh background budget (see central-restore).
|
||||
logBluetoothStatus("peripheral-restore")
|
||||
|
||||
if peripheral.state == .poweredOn && !peripheral.isAdvertising {
|
||||
peripheral.startAdvertising(BLERadioController.advertisementData())
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
|
||||
guard !isPanicSuspended else {
|
||||
peripheral.stopAdvertising()
|
||||
return
|
||||
}
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.debug("✅ Service added successfully, starting advertising", category: .session)
|
||||
|
||||
// Start advertising after service is confirmed added
|
||||
let adData = BLERadioController.advertisementData()
|
||||
peripheral.startAdvertising(adData)
|
||||
|
||||
SecureLogger.debug("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.id.prefix(8))…)", category: .session)
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
|
||||
guard !isPanicSuspended else { return }
|
||||
let centralUUID = central.identifier.uuidString
|
||||
SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))…", category: .session)
|
||||
linkStateStore.addSubscribedCentral(central)
|
||||
|
||||
// BCH-01-004: Rate-limit subscription-triggered announces to prevent enumeration attacks
|
||||
let now = Date()
|
||||
switch subscriptionAnnounceLimiter.decision(for: centralUUID, now: now) {
|
||||
case .allowed:
|
||||
break
|
||||
case let .rateLimited(backoffSeconds, attemptCount, suppressAnnounce):
|
||||
SecureLogger.warning("🛡️ BCH-01-004: Rate-limited announce for central \(centralUUID.prefix(8))... (backoff: \(Int(backoffSeconds))s, attempts: \(attemptCount))", category: .security)
|
||||
if suppressAnnounce {
|
||||
SecureLogger.warning("🚨 BCH-01-004: Possible enumeration attack from central \(centralUUID.prefix(8))... - suppressing announce", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
// Still flush directed packets for legitimate mesh operation
|
||||
engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
|
||||
self?.flushDirectedSpool()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Send announce to the newly subscribed central after a small delay
|
||||
engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
// Flush any spooled directed packets now that we have a central subscribed
|
||||
self?.flushDirectedSpool()
|
||||
}
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
|
||||
let centralID = central.identifier.uuidString
|
||||
SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))…", category: .session)
|
||||
// bleQueue: physical retirement now.
|
||||
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
|
||||
linkStateStore.removeSubscribedCentral(central)
|
||||
|
||||
// Ensure we're still advertising for other devices to find us
|
||||
if !isPanicSuspended, peripheral.isAdvertising == false {
|
||||
SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
|
||||
peripheral.startAdvertising(BLERadioController.advertisementData())
|
||||
}
|
||||
|
||||
// Identity retirement and peer-disconnect bookkeeping ride the
|
||||
// link-event port.
|
||||
emitLinkEvent(.centralLinkEnded(centralUUID: centralID))
|
||||
}
|
||||
|
||||
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
|
||||
guard !isPanicSuspended else { return }
|
||||
drainPendingNotifications(logPrefix: "✅ Sent")
|
||||
}
|
||||
|
||||
func logBackpressureSampled(_ message: @autoclosure () -> String) {
|
||||
notificationBackpressureLogCount += 1
|
||||
if notificationBackpressureLogCount == 1 ||
|
||||
notificationBackpressureLogCount.isMultiple(of: TransportConfig.bleBackpressureLogInterval) {
|
||||
SecureLogger.debug("\(message()) [backpressure event #\(notificationBackpressureLogCount)]", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func drainPendingNotifications(logPrefix: String) {
|
||||
bleQueue.async { [weak self] in
|
||||
guard let self = self,
|
||||
let characteristic = self.characteristic,
|
||||
!self.pendingNotifications.isEmpty else { return }
|
||||
|
||||
let pending = self.pendingNotifications.takeAll()
|
||||
let sentCount = self.sendPendingNotifications(pending, characteristic: characteristic)
|
||||
|
||||
if sentCount > 0 {
|
||||
self.logBackpressureSampled("\(logPrefix) \(sentCount) pending notifications from retry queue (\(self.pendingNotifications.count) still pending)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendPendingNotifications(_ pending: [BLEPendingNotification<CBCentral>], characteristic: CBMutableCharacteristic) -> Int {
|
||||
var sentCount = 0
|
||||
|
||||
for (index, notification) in pending.enumerated() {
|
||||
let success = peripheralManager?.updateValue(
|
||||
notification.data,
|
||||
for: characteristic,
|
||||
onSubscribedCentrals: notification.targets
|
||||
) ?? false
|
||||
|
||||
guard success else {
|
||||
let remaining = Array(pending.dropFirst(index))
|
||||
pendingNotifications.prepend(remaining)
|
||||
logBackpressureSampled("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items")
|
||||
break
|
||||
}
|
||||
|
||||
sentCount += 1
|
||||
}
|
||||
|
||||
return sentCount
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
|
||||
// Suppress logs for single write requests to reduce noise
|
||||
if requests.count > 1 {
|
||||
SecureLogger.debug("📥 Received \(requests.count) write requests from central", category: .session)
|
||||
}
|
||||
|
||||
// IMPORTANT: Respond immediately to prevent timeouts!
|
||||
// We must respond within a few milliseconds or the central will timeout
|
||||
for request in requests {
|
||||
peripheral.respond(to: request, withResult: .success)
|
||||
}
|
||||
guard !isPanicSuspended else { return }
|
||||
|
||||
// Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets.
|
||||
// Combine per-central request values by offset before decoding.
|
||||
// Process directly on our message queue to match transport context
|
||||
let grouped = Dictionary(grouping: requests, by: { $0.central.identifier.uuidString })
|
||||
for (centralUUID, group) in grouped {
|
||||
// Sort by offset ascending
|
||||
let sorted = group.sorted { $0.offset < $1.offset }
|
||||
let hasMultiple = sorted.count > 1 || (sorted.first?.offset ?? 0) > 0
|
||||
let chunks = sorted.compactMap { request -> BLEInboundWriteChunk? in
|
||||
guard let data = request.value, !data.isEmpty else { return nil }
|
||||
return BLEInboundWriteChunk(offset: request.offset, data: data)
|
||||
}
|
||||
|
||||
let result = pendingWriteBuffers.append(
|
||||
chunks: chunks,
|
||||
for: centralUUID,
|
||||
capBytes: TransportConfig.blePendingWriteBufferCapBytes
|
||||
)
|
||||
|
||||
switch result {
|
||||
case let .decoded(packet, metadata):
|
||||
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
|
||||
processDecodedCentralWrite(packet, centralUUID: centralUUID, central: sorted[0].central)
|
||||
|
||||
case let .waiting(metadata):
|
||||
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
|
||||
logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)
|
||||
|
||||
case let .oversized(metadata):
|
||||
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
|
||||
SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(metadata.accumulatedBytes) bytes) for central \(centralUUID.prefix(8))…", category: .session)
|
||||
logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func logAccumulatedCentralWrite(_ metadata: BLEInboundWriteAppendMetadata, centralUUID: String) {
|
||||
guard let packetType = metadata.packetType,
|
||||
packetType != MessageType.announce.rawValue else { return }
|
||||
|
||||
SecureLogger.debug(
|
||||
"📥 Accumulated write from central \(centralUUID.prefix(8))…: size=\(metadata.accumulatedBytes) (+\(metadata.appendedBytes)) bytes (type=\(packetType)), offsets=\(metadata.offsets)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
|
||||
private func logFailedSingleWriteIfNeeded(hasMultiple: Bool, sortedRequests: [CBATTRequest]) {
|
||||
guard !hasMultiple, let raw = sortedRequests.first?.value else { return }
|
||||
|
||||
let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||
SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session)
|
||||
}
|
||||
|
||||
private func processDecodedCentralWrite(_ packet: BitchatPacket, centralUUID: String, central: CBCentral) {
|
||||
// bleQueue: physical bookkeeping only. A writer is a live central
|
||||
// whether or not it subscribed; track it so directed replies and
|
||||
// the fanout planner can reach it.
|
||||
linkStateStore.addSubscribedCentral(central)
|
||||
// Attribution is engine work (the engine owns the bindings).
|
||||
emitLinkEvent(.frameDecoded(
|
||||
packet,
|
||||
link: .central(centralUUID),
|
||||
linkDescription: "Central \(centralUUID.prefix(8))…"
|
||||
))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -104,12 +104,10 @@ final class LRUDeduplicationCache<Value> {
|
||||
enum ContentNormalizer {
|
||||
|
||||
/// Regex to simplify HTTP URLs by stripping query strings and fragments
|
||||
private static let simplifyHTTPURL: NSRegularExpression = {
|
||||
try! NSRegularExpression(
|
||||
pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?",
|
||||
options: [.caseInsensitive]
|
||||
)
|
||||
}()
|
||||
private static let simplifyHTTPURL = SafeRegex.compile(
|
||||
"https?://[^\\s?#]+(?:[?#][^\\s]*)?",
|
||||
options: [.caseInsensitive]
|
||||
)
|
||||
|
||||
/// Normalizes content for deduplication comparison.
|
||||
/// - Parameters:
|
||||
|
||||
@ -39,37 +39,23 @@ final class MessageFormattingEngine {
|
||||
|
||||
/// Precompiled regex patterns for message content parsing
|
||||
enum Patterns {
|
||||
static let hashtag: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
|
||||
}()
|
||||
static let hashtag = SafeRegex.compile("#([a-zA-Z0-9_]+)")
|
||||
|
||||
static let mention: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
|
||||
}()
|
||||
static let mention = SafeRegex.compile("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)")
|
||||
|
||||
static let cashu: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||
}()
|
||||
static let cashu = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b")
|
||||
|
||||
static let bolt11: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
||||
}()
|
||||
static let bolt11 = SafeRegex.compile("(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b")
|
||||
|
||||
static let lnurl: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
||||
}()
|
||||
static let lnurl = SafeRegex.compile("(?i)\\blnurl1[a-z0-9]{20,}\\b")
|
||||
|
||||
static let lightningScheme: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
||||
}()
|
||||
static let lightningScheme = SafeRegex.compile("(?i)\\blightning:[^\\s]+")
|
||||
|
||||
static let linkDetector: NSDataDetector? = {
|
||||
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
}()
|
||||
|
||||
static let quickCashuPresence: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||
}()
|
||||
static let quickCashuPresence = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b")
|
||||
}
|
||||
|
||||
// MARK: - Match Types
|
||||
@ -124,11 +110,12 @@ final class MessageFormattingEngine {
|
||||
)
|
||||
|
||||
// Format content
|
||||
let myNickname = context.nickname.normalizedNickname
|
||||
let contentResult = formatContent(
|
||||
message.content,
|
||||
baseColor: baseColor,
|
||||
isSelf: isSelf,
|
||||
isMentioned: message.mentions?.contains(context.nickname) ?? false
|
||||
isMentioned: message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false
|
||||
)
|
||||
result.append(contentResult)
|
||||
|
||||
|
||||
@ -1089,6 +1089,10 @@ final class NoiseEncryptionService {
|
||||
func _test_initiateAutomaticRekey(for peerID: PeerID) throws {
|
||||
try initiateAutomaticRekey(for: peerID)
|
||||
}
|
||||
|
||||
func _test_fireSuppressedInitiationRecovery(for peerID: PeerID) {
|
||||
sessionManager._test_fireSuppressedInitiationRecovery(for: peerID)
|
||||
}
|
||||
#endif
|
||||
|
||||
deinit {
|
||||
|
||||
@ -203,14 +203,12 @@ final class PrivateChatManager: ObservableObject {
|
||||
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
|
||||
for message in messages(for: peerID) {
|
||||
if message.sender == nickname {
|
||||
if let status = message.deliveryStatus {
|
||||
switch status {
|
||||
case .read, .delivered:
|
||||
externalReceipts.insert(message.id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
case .failed, .partiallyDelivered, .sending, .sent, .carried:
|
||||
break
|
||||
}
|
||||
switch message.deliveryStatus {
|
||||
case .read, .delivered:
|
||||
externalReceipts.insert(message.id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
case .notSentYet, .failed, .partiallyDelivered, .sending, .sent, .carried:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -236,8 +236,11 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
/// Get peer ID for nickname
|
||||
func getPeerID(for nickname: String) -> PeerID? {
|
||||
// Normalize both sides: the query may come from typed content and
|
||||
// stored names may predate NFC-at-ingest (e.g. persisted favorites).
|
||||
let target = nickname.normalizedNickname
|
||||
for peer in peers {
|
||||
if peer.displayName == nickname || peer.nickname == nickname {
|
||||
if peer.displayName.normalizedNickname == target || peer.nickname.normalizedNickname == target {
|
||||
return peer.peerID
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,6 +57,11 @@ struct SyncTypeFlags: OptionSet {
|
||||
// Live voice is only useful now; replaying stale audio frames via
|
||||
// sync would waste airtime (receivers drop them as stale anyway).
|
||||
case .voiceFrame: return nil
|
||||
// Rotating-ID presence is valid only inside its epoch, and gossiping it
|
||||
// would defeat the point: a synced announce would let a device that was
|
||||
// never in radio range collect tag blocks, turning a local presence
|
||||
// beacon into a network-wide one.
|
||||
case .announceV2: return nil
|
||||
// Prekey bundles gossip like board posts. The bitfield is a
|
||||
// wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits
|
||||
// ignored by `type(forBit:)`), so bits 8+ need no format change: old
|
||||
|
||||
@ -39,9 +39,10 @@ struct InputValidator {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/// Validates nickname
|
||||
/// Validates nickname and returns it in canonical (NFC) form so
|
||||
/// visually identical names always compare equal.
|
||||
static func validateNickname(_ nickname: String) -> String? {
|
||||
return validateUserString(nickname, maxLength: Limits.maxNicknameLength)
|
||||
return validateUserString(nickname, maxLength: Limits.maxNicknameLength)?.normalizedNickname
|
||||
}
|
||||
|
||||
// MARK: - Protocol Field Validation
|
||||
|
||||
36
bitchat/Utils/SafeRegex.swift
Normal file
36
bitchat/Utils/SafeRegex.swift
Normal file
@ -0,0 +1,36 @@
|
||||
//
|
||||
// SafeRegex.swift
|
||||
// bitchat
|
||||
//
|
||||
// Non-trapping construction for the app's compiled-in regex patterns.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
enum SafeRegex {
|
||||
/// Compiles a bundled pattern. On failure it logs and returns a regex
|
||||
/// that can never match, so a bad pattern degrades that one feature
|
||||
/// instead of crashing at startup.
|
||||
static func compile(_ pattern: String, options: NSRegularExpression.Options = []) -> NSRegularExpression {
|
||||
do {
|
||||
return try NSRegularExpression(pattern: pattern, options: options)
|
||||
} catch {
|
||||
SecureLogger.error("Regex pattern failed to compile, matching disabled: \(pattern) (\(error))", category: .session)
|
||||
return neverMatching
|
||||
}
|
||||
}
|
||||
|
||||
/// `(?!)` — an empty negative lookahead — always compiles and can never match.
|
||||
private static let neverMatching: NSRegularExpression = {
|
||||
if let regex = try? NSRegularExpression(pattern: "(?!)", options: []) {
|
||||
return regex
|
||||
}
|
||||
// Unreachable: "(?!)" is a valid ICU pattern. The inherited plain
|
||||
// initializer (empty pattern) is the least-bad non-trapping fallback
|
||||
// if ICU itself were ever broken.
|
||||
return NSRegularExpression()
|
||||
}()
|
||||
}
|
||||
@ -9,6 +9,14 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
/// Canonical form for nickname storage and comparison (Unicode NFC).
|
||||
/// "café" typed with a combining accent and "café" typed precomposed
|
||||
/// must resolve to the same user wherever nicknames are stored or
|
||||
/// matched (mentions, DM resolution, autocomplete, geo presence).
|
||||
var normalizedNickname: String {
|
||||
precomposedStringWithCanonicalMapping
|
||||
}
|
||||
|
||||
/// Split a nickname into base and a '#abcd' suffix if present
|
||||
func splitSuffix() -> (String, String) {
|
||||
let name = self.replacingOccurrences(of: "@", with: "")
|
||||
|
||||
@ -31,6 +31,10 @@ protocol ChatComposerContext: AnyObject {
|
||||
/// The transport's own nickname (excluded from autocomplete candidates).
|
||||
var meshNickname: String { get }
|
||||
func meshPeerNicknames() -> [PeerID: String]
|
||||
/// True when this mesh nickname belongs to a blocked peer.
|
||||
func isMeshNicknameBlocked(_ nickname: String) -> Bool
|
||||
/// True when this geohash pubkey is blocked for location chats.
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
|
||||
|
||||
// MARK: Geohash identity (shared with the other contexts)
|
||||
var geoNicknames: [String: String] { get }
|
||||
@ -40,8 +44,8 @@ protocol ChatComposerContext: AnyObject {
|
||||
extension ChatViewModel: ChatComposerContext {
|
||||
// `autocompleteSuggestions`, `autocompleteRange`, `showAutocomplete`,
|
||||
// `selectedAutocompleteIndex`, `nickname`, `myPeerID`, `activeChannel`,
|
||||
// `geoNicknames`, `meshPeerNicknames()`, and
|
||||
// `deriveNostrIdentity(forGeohash:)` are shared requirements with the
|
||||
// `geoNicknames`, `meshPeerNicknames()`, `isNostrBlocked(pubkeyHexLowercased:)`,
|
||||
// and `deriveNostrIdentity(forGeohash:)` are shared requirements with the
|
||||
// other contexts or satisfied by existing `ChatViewModel` members. The
|
||||
// members below flatten nested service accesses into intent-named calls.
|
||||
|
||||
@ -60,6 +64,13 @@ extension ChatViewModel: ChatComposerContext {
|
||||
var meshNickname: String {
|
||||
meshService.myNickname
|
||||
}
|
||||
|
||||
func isMeshNicknameBlocked(_ nickname: String) -> Bool {
|
||||
for (peerID, nick) in meshService.getPeerNicknames() where nick == nickname {
|
||||
if isPeerBlocked(peerID) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -136,11 +147,14 @@ private extension ChatComposerCoordinator {
|
||||
switch context.activeChannel {
|
||||
case .mesh:
|
||||
let values = context.meshPeerNicknames().values
|
||||
return Array(values.filter { $0 != context.meshNickname })
|
||||
return Array(values.filter { nick in
|
||||
nick != context.meshNickname && !context.isMeshNicknameBlocked(nick)
|
||||
})
|
||||
|
||||
case .location(let channel):
|
||||
var tokens = Set<String>()
|
||||
for (pubkey, nick) in context.geoNicknames {
|
||||
guard !context.isNostrBlocked(pubkeyHexLowercased: pubkey) else { continue }
|
||||
tokens.insert("\(nick)#\(pubkey.suffix(4))")
|
||||
}
|
||||
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||
|
||||
@ -360,9 +360,9 @@ private extension ChatLifecycleCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
func deliveryStatusRank(_ status: DeliveryStatus?) -> Int {
|
||||
guard let status else { return 0 }
|
||||
func deliveryStatusRank(_ status: DeliveryStatus) -> Int {
|
||||
switch status {
|
||||
case .notSentYet: return 0
|
||||
case .failed: return 1
|
||||
case .sending: return 2
|
||||
case .sent: return 3
|
||||
|
||||
@ -353,7 +353,11 @@ final class ChatLiveVoiceCoordinator {
|
||||
// Eviction skips voice_live_* names, so partials still streaming in
|
||||
// are safe no matter which caller triggers enforcement.
|
||||
fileStore.enforceQuota(reservingBytes: TransportConfig.pttMaxBurstBytes)
|
||||
fileManager.createFile(atPath: fileURL.path, contents: nil)
|
||||
fileManager.createFile(
|
||||
atPath: fileURL.path,
|
||||
contents: nil,
|
||||
attributes: BLEIncomingFileStore.mediaProtectionAttributes
|
||||
)
|
||||
guard let handle = try? FileHandle(forWritingTo: fileURL) else {
|
||||
SecureLogger.error("PTT: cannot open capture file for burst \(burstID.hexEncodedString())", category: .session)
|
||||
try? fileManager.removeItem(at: fileURL)
|
||||
|
||||
@ -1899,7 +1899,7 @@ private extension ChatMediaTransferCoordinator {
|
||||
try FileManager.default.createDirectory(
|
||||
at: filesDirectory,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
attributes: BLEIncomingFileStore.mediaProtectionAttributes
|
||||
)
|
||||
return filesDirectory
|
||||
}
|
||||
|
||||
@ -41,7 +41,9 @@ final class ChatMessageFormatter {
|
||||
}()
|
||||
|
||||
let isDark = colorScheme == .dark
|
||||
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant) {
|
||||
let isVerifiedSender = !isSelf && isVerifiedSender(of: message)
|
||||
let cacheVariant = theme.formatCacheVariant + (isVerifiedSender ? "-vf" : "")
|
||||
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: cacheVariant) {
|
||||
return cachedText
|
||||
}
|
||||
|
||||
@ -66,6 +68,9 @@ final class ChatMessageFormatter {
|
||||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||
}
|
||||
if isVerifiedSender {
|
||||
appendVerifiedSeal(to: &result, baseColor: baseColor, design: design)
|
||||
}
|
||||
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||
|
||||
let content = message.content
|
||||
@ -183,7 +188,8 @@ final class ChatMessageFormatter {
|
||||
allMatches.sort { $0.range.location < $1.range.location }
|
||||
|
||||
var lastEnd = content.startIndex
|
||||
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
|
||||
let myNickname = viewModel.nickname.normalizedNickname
|
||||
let isMentioned = message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false
|
||||
|
||||
for (range, type) in allMatches {
|
||||
guard let swiftRange = Range(range, in: content) else { continue }
|
||||
@ -335,7 +341,7 @@ final class ChatMessageFormatter {
|
||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||
}
|
||||
|
||||
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant)
|
||||
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: cacheVariant)
|
||||
return result
|
||||
}
|
||||
|
||||
@ -356,6 +362,7 @@ final class ChatMessageFormatter {
|
||||
|
||||
let isDark = colorScheme == .dark
|
||||
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
|
||||
let isVerifiedSender = !isSelf && isVerifiedSender(of: message)
|
||||
|
||||
if message.sender == "system" {
|
||||
var style = AttributeContainer()
|
||||
@ -381,6 +388,9 @@ final class ChatMessageFormatter {
|
||||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||
}
|
||||
if isVerifiedSender {
|
||||
appendVerifiedSeal(to: &result, baseColor: baseColor, design: design)
|
||||
}
|
||||
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||
return result
|
||||
}
|
||||
@ -427,6 +437,29 @@ final class ChatMessageFormatter {
|
||||
}
|
||||
|
||||
private extension ChatMessageFormatter {
|
||||
/// Whether the message sender has a fingerprint the user has verified.
|
||||
/// Used for the in-chat seal next to `<@name>` so verification is visible
|
||||
/// without opening the fingerprint sheet (#1439).
|
||||
func isVerifiedSender(of message: BitchatMessage) -> Bool {
|
||||
guard let peerID = message.senderPeerID,
|
||||
let fingerprint = viewModel.getFingerprint(for: peerID) else {
|
||||
return false
|
||||
}
|
||||
return viewModel.peerIdentityStore.isVerified(fingerprint)
|
||||
}
|
||||
|
||||
func appendVerifiedSeal(
|
||||
to result: inout AttributedString,
|
||||
baseColor: Color,
|
||||
design: Font.Design
|
||||
) {
|
||||
var sealStyle = AttributeContainer()
|
||||
// Match the peer-list verified seal: filled checkmark in the sender tint.
|
||||
sealStyle.foregroundColor = baseColor
|
||||
sealStyle.font = .bitchatSystem(size: 11, weight: .semibold, design: design)
|
||||
result.append(AttributedString(" ✓").mergingAttributes(sealStyle))
|
||||
}
|
||||
|
||||
func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
||||
if let spid = message.senderPeerID {
|
||||
if spid.isGeoChat || spid.isGeoDM {
|
||||
|
||||
@ -501,6 +501,9 @@ final class ChatPeerIdentityCoordinator {
|
||||
|
||||
@MainActor
|
||||
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
|
||||
// Queries arrive from typed commands and message content, so bring
|
||||
// them to the same canonical (NFC) form nicknames are stored in.
|
||||
let nickname = nickname.normalizedNickname
|
||||
switch context.activeChannel {
|
||||
case .location:
|
||||
if nickname.contains("#"),
|
||||
|
||||
@ -506,14 +506,15 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
}
|
||||
|
||||
func checkForMentions(_ message: BitchatMessage) {
|
||||
var myTokens: Set<String> = [context.nickname]
|
||||
let myNickname = context.nickname.normalizedNickname
|
||||
var myTokens: Set<String> = [myNickname]
|
||||
let meshPeers = context.meshPeerNicknames()
|
||||
let collisions = meshPeers.values.filter { $0.hasPrefix(context.nickname + "#") }
|
||||
let collisions = meshPeers.values.filter { $0.normalizedNickname.hasPrefix(myNickname + "#") }
|
||||
if !collisions.isEmpty {
|
||||
let suffix = "#" + String(context.myPeerID.id.prefix(4))
|
||||
myTokens = [context.nickname + suffix]
|
||||
myTokens = [myNickname + suffix]
|
||||
}
|
||||
let isMentioned = message.mentions?.contains(where: myTokens.contains) ?? false
|
||||
let isMentioned = message.mentions?.contains { myTokens.contains($0.normalizedNickname) } ?? false
|
||||
|
||||
if isMentioned && message.sender != context.nickname {
|
||||
SecureLogger.info("🔔 Mention from \(message.sender)", category: .session)
|
||||
|
||||
@ -176,10 +176,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
||||
var networkActivationAllowed: Bool { !panicRecoveryBlocked }
|
||||
@Published var nickname: String = "" {
|
||||
didSet {
|
||||
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
|
||||
let trimmed = nickname.trimmedOrNilIfEmpty ?? ""
|
||||
if trimmed != nickname {
|
||||
nickname = trimmed
|
||||
// Canonicalize whenever nickname is set: trim whitespace
|
||||
// (whitespace-only becomes "") and apply Unicode NFC so accented
|
||||
// names match regardless of how they were typed.
|
||||
let cleaned = (nickname.trimmedOrNilIfEmpty ?? "").normalizedNickname
|
||||
if cleaned != nickname {
|
||||
nickname = cleaned
|
||||
return
|
||||
}
|
||||
// Update mesh service nickname if it's initialized
|
||||
|
||||
@ -15,6 +15,8 @@ extension DeliveryStatus {
|
||||
/// the glyphs alone are unexplained 10pt icons.
|
||||
var bitchatDescription: String {
|
||||
switch self {
|
||||
case .notSentYet:
|
||||
return String(localized: "content.delivery.not_sent_yet", defaultValue: "Not sent yet", comment: "Delivery status description for a message that has not entered any send pipeline")
|
||||
case .sending:
|
||||
return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent")
|
||||
case .sent:
|
||||
@ -72,6 +74,13 @@ struct DeliveryStatusView: View {
|
||||
@ViewBuilder
|
||||
private var statusGlyph: some View {
|
||||
switch status {
|
||||
case .notSentYet:
|
||||
// Normally hidden by callers; shown as a hollow dotted circle if
|
||||
// it ever surfaces so the state is visible rather than invisible.
|
||||
Image(systemName: "circle.dotted")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .sending:
|
||||
Image(systemName: "circle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
@ -125,6 +134,7 @@ struct DeliveryStatusView: View {
|
||||
|
||||
#Preview {
|
||||
let statuses: [DeliveryStatus] = [
|
||||
.notSentYet,
|
||||
.sending,
|
||||
.sent,
|
||||
.carried,
|
||||
|
||||
@ -23,7 +23,7 @@ struct TextMessageView: View {
|
||||
/// SAME instance would otherwise compare "unchanged" and this row's body
|
||||
/// would be skipped even though the parent list re-rendered. Snapshotting
|
||||
/// the enum makes the change visible to SwiftUI's structural diff.
|
||||
private let deliveryStatus: DeliveryStatus?
|
||||
private let deliveryStatus: DeliveryStatus
|
||||
@State private var expandedMessageIDs: Set<String> = []
|
||||
@State private var showDeliveryDetail = false
|
||||
|
||||
@ -68,11 +68,11 @@ struct TextMessageView: View {
|
||||
// .help() tooltips only exist on macOS, so iOS users get the
|
||||
// explanation as a caption under the row instead.
|
||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||
let status = deliveryStatus {
|
||||
deliveryStatus != .notSentYet {
|
||||
Button {
|
||||
showDeliveryDetail.toggle()
|
||||
} label: {
|
||||
DeliveryStatusView(status: status)
|
||||
DeliveryStatusView(status: deliveryStatus)
|
||||
.padding(.leading, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
@ -86,15 +86,15 @@ struct TextMessageView: View {
|
||||
// Failure reasons stay visible without a tap; other statuses
|
||||
// reveal on demand.
|
||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||
let status = deliveryStatus {
|
||||
if case .failed = status {
|
||||
Text(verbatim: status.bitchatDescription)
|
||||
deliveryStatus != .notSentYet {
|
||||
if case .failed = deliveryStatus {
|
||||
Text(verbatim: deliveryStatus.bitchatDescription)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(Color.red.opacity(0.9))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 2)
|
||||
} else if showDeliveryDetail {
|
||||
Text(verbatim: status.bitchatDescription)
|
||||
Text(verbatim: deliveryStatus.bitchatDescription)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@ -92,6 +92,9 @@ struct ContentView: View {
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
@EnvironmentObject private var sharedContentImportModel: SharedContentImportModel
|
||||
@EnvironmentObject private var peerListModel: PeerListModel
|
||||
@EnvironmentObject private var publicChatModel: PublicChatModel
|
||||
@EnvironmentObject private var privateInboxModel: PrivateInboxModel
|
||||
|
||||
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
|
||||
@State private var messageText = ""
|
||||
@ -297,6 +300,17 @@ struct ContentView: View {
|
||||
showImagePicker: $showImagePicker,
|
||||
imagePickerSourceType: $imagePickerSourceType
|
||||
)
|
||||
// Sheets + NavigationStack can drop inherited EnvironmentObjects on
|
||||
// some iOS versions (#1558). Re-inject every model the sheet tree
|
||||
// reads so ContentPeopleListView / MessageListView never crash.
|
||||
.environmentObject(appChromeModel)
|
||||
.environmentObject(privateConversationModel)
|
||||
.environmentObject(verificationModel)
|
||||
.environmentObject(conversationUIModel)
|
||||
.environmentObject(locationChannelsModel)
|
||||
.environmentObject(peerListModel)
|
||||
.environmentObject(publicChatModel)
|
||||
.environmentObject(privateInboxModel)
|
||||
#else
|
||||
ContentPeopleSheetView(
|
||||
showSidebar: $showSidebar,
|
||||
@ -314,6 +328,14 @@ struct ContentView: View {
|
||||
onSendMessage: sendMessage,
|
||||
showMacImagePicker: $showMacImagePicker
|
||||
)
|
||||
.environmentObject(appChromeModel)
|
||||
.environmentObject(privateConversationModel)
|
||||
.environmentObject(verificationModel)
|
||||
.environmentObject(conversationUIModel)
|
||||
.environmentObject(locationChannelsModel)
|
||||
.environmentObject(peerListModel)
|
||||
.environmentObject(publicChatModel)
|
||||
.environmentObject(privateInboxModel)
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $appChromeModel.isAppInfoPresented) {
|
||||
|
||||
@ -20,7 +20,7 @@ struct MediaMessageView: View {
|
||||
/// is a reference type mutated in place, and SwiftUI compares reference
|
||||
/// fields by identity, so without the snapshot a status-only change
|
||||
/// (send progress, delivered → read) would not re-render this row.
|
||||
private let deliveryStatus: DeliveryStatus?
|
||||
private let deliveryStatus: DeliveryStatus
|
||||
@State private var showDeliveryDetail = false
|
||||
|
||||
@Binding var imagePreviewURL: URL?
|
||||
@ -57,11 +57,11 @@ struct MediaMessageView: View {
|
||||
// .help() tooltips only exist on macOS, so iOS users get the
|
||||
// explanation as a caption under the row instead.
|
||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||
let status = deliveryStatus {
|
||||
deliveryStatus != .notSentYet {
|
||||
Button {
|
||||
showDeliveryDetail.toggle()
|
||||
} label: {
|
||||
DeliveryStatusView(status: status)
|
||||
DeliveryStatusView(status: deliveryStatus)
|
||||
.padding(.leading, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
@ -75,14 +75,14 @@ struct MediaMessageView: View {
|
||||
// Failure reasons stay visible without a tap; other statuses
|
||||
// reveal on demand.
|
||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||
let status = deliveryStatus {
|
||||
if case .failed = status {
|
||||
Text(verbatim: status.bitchatDescription)
|
||||
deliveryStatus != .notSentYet {
|
||||
if case .failed = deliveryStatus {
|
||||
Text(verbatim: deliveryStatus.bitchatDescription)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(Color.red.opacity(0.9))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if showDeliveryDetail {
|
||||
Text(verbatim: status.bitchatDescription)
|
||||
Text(verbatim: deliveryStatus.bitchatDescription)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@ -132,26 +132,24 @@ struct MediaMessageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func mediaSendState(for deliveryStatus: DeliveryStatus?, isFromMe: Bool) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
|
||||
private func mediaSendState(for deliveryStatus: DeliveryStatus, isFromMe: Bool) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
|
||||
// A received message is never in a send state: BitchatMessage defaults
|
||||
// private messages to .sending, so an incoming message's status must
|
||||
// not drive the reveal mask or disable the reveal tap.
|
||||
guard isFromMe else { return (false, nil, false) }
|
||||
var isSending = false
|
||||
var progress: Double?
|
||||
if let status = deliveryStatus {
|
||||
switch status {
|
||||
case .sending:
|
||||
switch deliveryStatus {
|
||||
case .sending:
|
||||
isSending = true
|
||||
progress = 0
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
if total > 0 {
|
||||
isSending = true
|
||||
progress = 0
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
if total > 0 {
|
||||
isSending = true
|
||||
progress = Double(reached) / Double(total)
|
||||
}
|
||||
case .sent, .carried, .read, .delivered, .failed:
|
||||
break
|
||||
progress = Double(reached) / Double(total)
|
||||
}
|
||||
case .notSentYet, .sent, .carried, .read, .delivered, .failed:
|
||||
break
|
||||
}
|
||||
let canCancel = isSending && conversationUIModel.isSentByCurrentUser(message)
|
||||
let clamped = progress.map { max(0, min(1, $0)) }
|
||||
|
||||
@ -430,7 +430,7 @@ private extension MessageListView {
|
||||
guard message.isPrivate,
|
||||
conversationUIModel.isSentByCurrentUser(message),
|
||||
conversationUIModel.mediaAttachment(for: message) == nil,
|
||||
case .some(.failed) = message.deliveryStatus
|
||||
case .failed = message.deliveryStatus
|
||||
else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
@ -554,26 +554,19 @@ 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 },
|
||||
// The rebind, its Noise-proof retirement, and the ordinary
|
||||
// reconnect preparation are one engine slot: no observer can see
|
||||
// the new binding while the victim's stale sending keys are still
|
||||
// available. Once the binding is visible, the keys must already be
|
||||
// gone.
|
||||
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)
|
||||
try #require(rebound)
|
||||
#expect(!ble.canDeliverSecurely(to: victimPeerID))
|
||||
rebindGate.release()
|
||||
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = { outbound.record($0) }
|
||||
@ -1469,35 +1462,6 @@ private final class SessionReconcileCounter: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -52,9 +52,19 @@ private final class MockChatComposerContext: ChatComposerContext {
|
||||
var activeChannel: ChannelID = .mesh
|
||||
var meshNickname = "me"
|
||||
var meshNicknamesByPeerID: [PeerID: String] = [:]
|
||||
var blockedMeshNicknames: Set<String> = []
|
||||
var blockedNostrPubkeys: Set<String> = []
|
||||
|
||||
func meshPeerNicknames() -> [PeerID: String] { meshNicknamesByPeerID }
|
||||
|
||||
func isMeshNicknameBlocked(_ nickname: String) -> Bool {
|
||||
blockedMeshNicknames.contains(nickname)
|
||||
}
|
||||
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
||||
blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased())
|
||||
}
|
||||
|
||||
// Geohash identity
|
||||
var geoNicknames: [String: String] = [:]
|
||||
static let dummyIdentity = NostrIdentity(
|
||||
@ -120,6 +130,34 @@ struct ChatComposerCoordinatorContextTests {
|
||||
#expect(context.queriedPeerCandidates == [["carol#dddd"]])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func updateAutocomplete_excludesBlockedMeshAndGeohashPeers() {
|
||||
let context = MockChatComposerContext()
|
||||
let coordinator = ChatComposerCoordinator(context: context)
|
||||
context.meshNicknamesByPeerID = [
|
||||
PeerID(str: "1111111111111111"): "alice",
|
||||
PeerID(str: "2222222222222222"): "eve",
|
||||
PeerID(str: "3333333333333333"): "me"
|
||||
]
|
||||
context.blockedMeshNicknames = ["eve"]
|
||||
context.queryResult = (["@alice"], NSRange(location: 0, length: 3))
|
||||
|
||||
coordinator.updateAutocomplete(for: "@a", cursorPosition: 2)
|
||||
#expect(context.queriedPeerCandidates == [["alice"]])
|
||||
|
||||
let geoContext = MockChatComposerContext()
|
||||
let geoCoordinator = ChatComposerCoordinator(context: geoContext)
|
||||
geoContext.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruydq"))
|
||||
geoContext.geoNicknames = [
|
||||
"aaaabbbbccccdddd": "carol",
|
||||
"bbbbccccddddeeee": "blocked"
|
||||
]
|
||||
geoContext.blockedNostrPubkeys = ["bbbbccccddddeeee"]
|
||||
|
||||
geoCoordinator.updateAutocomplete(for: "@", cursorPosition: 1)
|
||||
#expect(geoContext.queriedPeerCandidates == [["carol#dddd"]])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func completeNickname_appliesSuggestionResetsStateAndReturnsCursor() {
|
||||
let context = MockChatComposerContext()
|
||||
|
||||
@ -147,6 +147,10 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
#expect(Conversation.shouldSkipStatusUpdate(current: .sent, new: .sending))
|
||||
// ...but a retry after a real failure stays visible.
|
||||
#expect(!Conversation.shouldSkipStatusUpdate(current: .failed(reason: "no route"), new: .sending))
|
||||
// .notSentYet is the pre-transport initial state: leaving it is always
|
||||
// allowed, returning to it never is.
|
||||
#expect(!Conversation.shouldSkipStatusUpdate(current: .notSentYet, new: .sending))
|
||||
#expect(Conversation.shouldSkipStatusUpdate(current: .sent, new: .notSentYet))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@ -729,9 +733,10 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
@Test @MainActor
|
||||
func statusRank_orderingIsCorrect() async {
|
||||
// This tests the implicit ordering used in refreshVisibleMessages
|
||||
// failed < sending < sent < carried < partiallyDelivered < delivered < read
|
||||
// notSentYet < failed < sending < sent < carried < partiallyDelivered < delivered < read
|
||||
|
||||
let statuses: [DeliveryStatus] = [
|
||||
.notSentYet,
|
||||
.failed(reason: "test"),
|
||||
.sending,
|
||||
.sent,
|
||||
@ -745,13 +750,14 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
// This is more of a documentation test to ensure the ranking logic is understood
|
||||
for (index, status) in statuses.enumerated() {
|
||||
switch status {
|
||||
case .failed: #expect(index == 0)
|
||||
case .sending: #expect(index == 1)
|
||||
case .sent: #expect(index == 2)
|
||||
case .carried: #expect(index == 3)
|
||||
case .partiallyDelivered: #expect(index == 4)
|
||||
case .delivered: #expect(index == 5)
|
||||
case .read: #expect(index == 6)
|
||||
case .notSentYet: #expect(index == 0)
|
||||
case .failed: #expect(index == 1)
|
||||
case .sending: #expect(index == 2)
|
||||
case .sent: #expect(index == 3)
|
||||
case .carried: #expect(index == 4)
|
||||
case .partiallyDelivered: #expect(index == 5)
|
||||
case .delivered: #expect(index == 6)
|
||||
case .read: #expect(index == 7)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,7 +50,7 @@ struct PublicChatE2ETests {
|
||||
var bobReceivedMessage = false
|
||||
var charlieReceivedMessage = false
|
||||
|
||||
await confirmation("Both recieve message", expectedCount: 2) { receiveMessage in
|
||||
await confirmation("Both receive message", expectedCount: 2) { receiveMessage in
|
||||
bob.messageDeliveryHandler = { message in
|
||||
if message.content == TestConstants.testMessage1 {
|
||||
if !bobReceivedMessage {
|
||||
|
||||
53
bitchatTests/NicknameNormalizationTests.swift
Normal file
53
bitchatTests/NicknameNormalizationTests.swift
Normal file
@ -0,0 +1,53 @@
|
||||
//
|
||||
// NicknameNormalizationTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Nicknames must compare equal regardless of how the user's keyboard
|
||||
// produced them: "café" as precomposed U+00E9 and as "e" + combining
|
||||
// U+0301 are canonically equivalent but bytewise different, which broke
|
||||
// mention matching, DM resolution, and autocomplete (#214). Storage and
|
||||
// comparison both canonicalize to NFC via String.normalizedNickname.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct NicknameNormalizationTests {
|
||||
/// "café" with a combining acute accent (NFD form)
|
||||
private let decomposed = "cafe\u{0301}"
|
||||
/// "café" with precomposed é (NFC form)
|
||||
private let precomposed = "caf\u{00E9}"
|
||||
|
||||
@Test
|
||||
func canonicallyEquivalentFormsNormalizeIdentically() {
|
||||
// Sanity: the raw forms really are different strings byte-wise …
|
||||
#expect(decomposed.unicodeScalars.count != precomposed.unicodeScalars.count)
|
||||
// … and normalization unifies them.
|
||||
#expect(decomposed.normalizedNickname == precomposed.normalizedNickname)
|
||||
#expect(decomposed.normalizedNickname == precomposed)
|
||||
}
|
||||
|
||||
@Test
|
||||
func asciiNicknamesPassThroughUnchanged() {
|
||||
#expect("alice_42".normalizedNickname == "alice_42")
|
||||
#expect("".normalizedNickname == "")
|
||||
}
|
||||
|
||||
@Test
|
||||
func validateNicknameReturnsCanonicalForm() {
|
||||
#expect(InputValidator.validateNickname(decomposed) == precomposed)
|
||||
#expect(InputValidator.validateNickname(" \(decomposed) ") == precomposed)
|
||||
// Validation behavior is otherwise unchanged.
|
||||
#expect(InputValidator.validateNickname(" ") == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func collisionSuffixSplittingSurvivesNormalization() {
|
||||
let (base, suffix) = (decomposed.normalizedNickname + "#ab12").splitSuffix()
|
||||
#expect(base == precomposed)
|
||||
#expect(suffix == "#ab12")
|
||||
}
|
||||
}
|
||||
@ -75,6 +75,70 @@ final class BitchatFilePacketTests: XCTestCase {
|
||||
XCTAssertEqual(decoded.content, content)
|
||||
}
|
||||
|
||||
/// The TLV tag list is a floor, not a ceiling: a decoder that bails on the
|
||||
/// first tag it does not know makes the format unextendable, because a field
|
||||
/// the sender considered optional costs the receiver the whole file. This
|
||||
/// decoder skips them (`case nil: continue`) and that has to stay true — it
|
||||
/// is load-bearing for any peer, version or third-party client that adds a
|
||||
/// field we have not seen. `PrivateMediaMessageIdentity` exists precisely
|
||||
/// because the Android decoder does *not* do this, so the asymmetry is real
|
||||
/// and worth pinning on the side that gets it right.
|
||||
func testDecodeSkipsUnknownTLVTypesInsteadOfDroppingTheFile() throws {
|
||||
let content = Data((0..<64).map { UInt8($0) })
|
||||
let unknownValue = Data("some-message-id".utf8)
|
||||
var data = Data()
|
||||
|
||||
// fileName
|
||||
data.append(0x01)
|
||||
data.append(contentsOf: [0x00, 0x09])
|
||||
data.append(Data("photo.jpg".utf8))
|
||||
// fileSize
|
||||
data.append(0x02)
|
||||
data.append(contentsOf: [0x00, 0x04])
|
||||
data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)])
|
||||
// mimeType
|
||||
data.append(0x03)
|
||||
data.append(contentsOf: [0x00, 0x0A])
|
||||
data.append(Data("image/jpeg".utf8))
|
||||
// An unknown tag, where an encoder appending content last would put it
|
||||
data.append(0x05)
|
||||
data.append(contentsOf: [0x00, UInt8(unknownValue.count)])
|
||||
data.append(unknownValue)
|
||||
// content
|
||||
data.append(0x04)
|
||||
data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)])
|
||||
data.append(content)
|
||||
|
||||
let decoded = try XCTUnwrap(BitchatFilePacket.decode(data))
|
||||
XCTAssertEqual(decoded.fileName, "photo.jpg")
|
||||
XCTAssertEqual(decoded.mimeType, "image/jpeg")
|
||||
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
|
||||
XCTAssertEqual(decoded.content, content)
|
||||
}
|
||||
|
||||
/// Same contract for an extension that trails the content, which a decoder
|
||||
/// stopping at the first unknown tag would also lose.
|
||||
func testDecodeSkipsAnUnknownTLVTrailingTheContent() throws {
|
||||
let content = Data(repeating: 0x7F, count: 16)
|
||||
var data = Data()
|
||||
|
||||
data.append(0x01)
|
||||
data.append(contentsOf: [0x00, 0x08])
|
||||
data.append(Data("note.m4a".utf8))
|
||||
data.append(0x04)
|
||||
data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)])
|
||||
data.append(content)
|
||||
data.append(0x7F)
|
||||
data.append(contentsOf: [0x00, 0x04])
|
||||
data.append(Data([0x11, 0x11, 0x11, 0x11]))
|
||||
|
||||
let decoded = try XCTUnwrap(BitchatFilePacket.decode(data))
|
||||
XCTAssertEqual(decoded.fileName, "note.m4a")
|
||||
XCTAssertNil(decoded.mimeType)
|
||||
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)
|
||||
|
||||
@ -68,6 +68,21 @@ struct BLEAnnounceThrottleTests {
|
||||
#expect(accepted.value == 1)
|
||||
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
func resetForgetsThrottleDebtSoARotationAnnounceIsNeverSwallowed() {
|
||||
let throttle = BLEAnnounceThrottle(
|
||||
normalMinimumInterval: 1,
|
||||
forcedMinimumInterval: 1
|
||||
)
|
||||
let now = Date()
|
||||
#expect(throttle.shouldSend(force: true, now: now))
|
||||
// A panic inside the forced window would be throttled...
|
||||
#expect(!throttle.shouldSend(force: true, now: now.addingTimeInterval(0.2)))
|
||||
// ...so the rotation resets the debt and announces immediately.
|
||||
throttle.reset()
|
||||
#expect(throttle.shouldSend(force: true, now: now.addingTimeInterval(0.3)))
|
||||
}
|
||||
}
|
||||
|
||||
private final class LockedCounter: @unchecked Sendable {
|
||||
|
||||
@ -222,7 +222,7 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(message?.isPrivate == false)
|
||||
#expect(message?.senderPeerID == remotePeerID)
|
||||
#expect(message?.timestamp == Date(timeIntervalSince1970: 900))
|
||||
#expect(message?.deliveryStatus == nil)
|
||||
#expect(message?.deliveryStatus == .notSentYet)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
75
bitchatTests/Services/BLELinkAuthStateTests.swift
Normal file
75
bitchatTests/Services/BLELinkAuthStateTests.swift
Normal file
@ -0,0 +1,75 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLELinkAuthStateTests {
|
||||
private let peerID = PeerID(str: "1122334455667788")
|
||||
private let link = BLEIngressLinkID.peripheral("periph-a")
|
||||
|
||||
@Test
|
||||
func authenticationBindsToTheExactLinkAndOwner() {
|
||||
var auth = BLELinkAuthState()
|
||||
auth.markAuthenticated(link, owner: peerID)
|
||||
|
||||
#expect(auth.isAuthenticated(link, for: peerID))
|
||||
#expect(!auth.isAuthenticated(link, for: PeerID(str: "8899aabbccddeeff")))
|
||||
#expect(!auth.isAuthenticated(.peripheral("periph-b"), for: peerID))
|
||||
|
||||
auth.retireLink(link)
|
||||
#expect(!auth.isAuthenticated(link, for: peerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
func retireLinksOwnedByPeerReturnsAndRetiresThemAll() {
|
||||
var auth = BLELinkAuthState()
|
||||
auth.markAuthenticated(.peripheral("periph-a"), owner: peerID)
|
||||
auth.markAuthenticated(.central("central-a"), owner: peerID)
|
||||
auth.markAuthenticated(.central("central-b"), owner: PeerID(str: "8899aabbccddeeff"))
|
||||
|
||||
let departed = Set(auth.retireLinks(ownedBy: peerID))
|
||||
|
||||
#expect(departed == [.peripheral("periph-a"), .central("central-a")])
|
||||
#expect(auth.links(ownedBy: peerID).isEmpty)
|
||||
#expect(auth.isAuthenticated(.central("central-b"), for: PeerID(str: "8899aabbccddeeff")))
|
||||
}
|
||||
|
||||
@Test
|
||||
func rebindCooldownPermitsOncePerWindowAndAgesOut() {
|
||||
var auth = BLELinkAuthState()
|
||||
let start = Date(timeIntervalSince1970: 1_000)
|
||||
|
||||
let first = auth.permitRebind(linkUUID: "periph-a", now: start, cooldown: 30)
|
||||
#expect(first)
|
||||
let withinWindow = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(10), cooldown: 30)
|
||||
#expect(!withinWindow)
|
||||
// A different link has its own allowance.
|
||||
let otherLink = auth.permitRebind(linkUUID: "periph-b", now: start.addingTimeInterval(10), cooldown: 30)
|
||||
#expect(otherLink)
|
||||
// The window ages out.
|
||||
let afterWindow = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(31), cooldown: 30)
|
||||
#expect(afterWindow)
|
||||
}
|
||||
|
||||
@Test
|
||||
func containmentCooldownsSurviveASessionReset() {
|
||||
var auth = BLELinkAuthState()
|
||||
let start = Date(timeIntervalSince1970: 2_000)
|
||||
auth.markAuthenticated(link, owner: peerID)
|
||||
let rebindBefore = auth.permitRebind(linkUUID: "periph-a", now: start, cooldown: 30)
|
||||
let retirementBefore = auth.permitRedundantRetirement(peerID: peerID, now: start, cooldown: 30)
|
||||
#expect(rebindBefore)
|
||||
#expect(retirementBefore)
|
||||
|
||||
// Panic/emergency resets wipe proofs and epochs — but a stable
|
||||
// CoreBluetooth UUID must not earn a fresh rebind or retirement
|
||||
// allowance just because the session state around it was wiped.
|
||||
auth.removeAll()
|
||||
|
||||
#expect(!auth.isAuthenticated(link, for: peerID))
|
||||
let rebindAfterReset = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(5), cooldown: 30)
|
||||
let retirementAfterReset = auth.permitRedundantRetirement(peerID: peerID, now: start.addingTimeInterval(5), cooldown: 30)
|
||||
#expect(!rebindAfterReset)
|
||||
#expect(!retirementAfterReset)
|
||||
}
|
||||
}
|
||||
111
bitchatTests/Services/BLELinkBindingsTests.swift
Normal file
111
bitchatTests/Services/BLELinkBindingsTests.swift
Normal file
@ -0,0 +1,111 @@
|
||||
import BitFoundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLELinkBindingsTests {
|
||||
private let peerID = PeerID(str: "1122334455667788")
|
||||
private let otherPeerID = PeerID(str: "8899aabbccddeeff")
|
||||
|
||||
@Test
|
||||
func centralBindingExposesBoundPeerAndLinks() {
|
||||
var bindings = BLELinkBindings()
|
||||
|
||||
bindings.bindCentral("central-a", to: peerID)
|
||||
|
||||
#expect(bindings.peer(forCentralUUID: "central-a") == peerID)
|
||||
#expect(bindings.hasCentral(boundTo: peerID))
|
||||
#expect(bindings.boundPeer(for: .central("central-a")) == peerID)
|
||||
#expect(bindings.links(to: peerID) == [.central("central-a")])
|
||||
}
|
||||
|
||||
@Test
|
||||
func linksReturnsAllBindingsForPeerAcrossRoles() {
|
||||
var bindings = BLELinkBindings()
|
||||
|
||||
bindings.bindCentral("central-a", to: peerID)
|
||||
bindings.bindCentral("central-b", to: peerID)
|
||||
bindings.bindCentral("central-c", to: otherPeerID)
|
||||
bindings.bindPeripheral("periph-a", to: peerID)
|
||||
|
||||
#expect(bindings.links(to: peerID) == [.central("central-a"), .central("central-b"), .peripheral("periph-a")])
|
||||
}
|
||||
|
||||
@Test
|
||||
func clearCentralsReturnsPreviouslyBoundPeerIDsAndClearsLookups() {
|
||||
var bindings = BLELinkBindings()
|
||||
|
||||
bindings.bindCentral("central-a", to: peerID)
|
||||
bindings.bindCentral("central-b", to: otherPeerID)
|
||||
|
||||
let removedPeerIDs = Set(bindings.clearCentrals())
|
||||
|
||||
#expect(removedPeerIDs == Set([peerID, otherPeerID]))
|
||||
#expect(bindings.peer(forCentralUUID: "central-a") == nil)
|
||||
#expect(bindings.links(to: peerID).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rotationRebindDropsTheRetiredIdentitysReverseMapping() {
|
||||
var bindings = BLELinkBindings()
|
||||
bindings.bindPeripheral("periph-a", to: peerID)
|
||||
#expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-a")
|
||||
|
||||
// The link's owner rotates: the old identity must no longer claim
|
||||
// this link as its preferred peripheral.
|
||||
bindings.bindPeripheral("periph-a", to: otherPeerID)
|
||||
|
||||
#expect(bindings.preferredPeripheralUUID(for: peerID) == nil)
|
||||
#expect(bindings.preferredPeripheralUUID(for: otherPeerID) == "periph-a")
|
||||
#expect(bindings.peer(forPeripheralID: "periph-a") == otherPeerID)
|
||||
}
|
||||
|
||||
@Test
|
||||
func removingThePreferredLinkRepairsOntoTheChosenSurvivor() {
|
||||
var bindings = BLELinkBindings()
|
||||
bindings.bindPeripheral("periph-a", to: peerID)
|
||||
bindings.bindPeripheral("periph-b", to: peerID)
|
||||
// periph-b bound last: it is the preferred link.
|
||||
#expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-b")
|
||||
|
||||
let removed = bindings.peripheralRemoved("periph-b") { remaining in
|
||||
#expect(remaining == ["periph-a"])
|
||||
return remaining.first
|
||||
}
|
||||
|
||||
#expect(removed == peerID)
|
||||
#expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-a")
|
||||
#expect(bindings.links(to: peerID) == [.peripheral("periph-a")])
|
||||
}
|
||||
|
||||
@Test
|
||||
func removingADuplicateLinkDoesNotStrandThePreferredOne() {
|
||||
var bindings = BLELinkBindings()
|
||||
bindings.bindPeripheral("periph-a", to: peerID)
|
||||
bindings.bindPeripheral("periph-b", to: peerID)
|
||||
|
||||
// Removing the non-preferred duplicate must leave the reverse map
|
||||
// untouched (no repair callback consulted for a non-preferred link).
|
||||
let removed = bindings.peripheralRemoved("periph-a") { _ in
|
||||
Issue.record("survivor choice must not run for a non-preferred link")
|
||||
return nil
|
||||
}
|
||||
|
||||
#expect(removed == peerID)
|
||||
#expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-b")
|
||||
}
|
||||
|
||||
@Test
|
||||
func removingTheLastLinkClearsThePreferredMapping() {
|
||||
var bindings = BLELinkBindings()
|
||||
bindings.bindPeripheral("periph-a", to: peerID)
|
||||
|
||||
let removed = bindings.peripheralRemoved("periph-a") { remaining in
|
||||
#expect(remaining.isEmpty)
|
||||
return nil
|
||||
}
|
||||
|
||||
#expect(removed == peerID)
|
||||
#expect(bindings.preferredPeripheralUUID(for: peerID) == nil)
|
||||
#expect(bindings.links(to: peerID).isEmpty)
|
||||
}
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
import BitFoundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLELinkStateStoreTests {
|
||||
@Test
|
||||
func centralBindingExposesDirectLinkStateAndLinks() {
|
||||
let store = BLELinkStateStore()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
|
||||
store.bindCentral("central-a", to: peerID)
|
||||
|
||||
#expect(store.peerID(forCentralUUID: "central-a") == peerID)
|
||||
#expect(store.directLinkState(for: peerID) == BLEDirectLinkState(hasPeripheral: false, hasCentral: true))
|
||||
#expect(store.links(to: peerID) == [.central("central-a")])
|
||||
}
|
||||
|
||||
@Test
|
||||
func linksReturnsAllCentralBindingsForPeer() {
|
||||
let store = BLELinkStateStore()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let otherPeerID = PeerID(str: "8899aabbccddeeff")
|
||||
|
||||
store.bindCentral("central-a", to: peerID)
|
||||
store.bindCentral("central-b", to: peerID)
|
||||
store.bindCentral("central-c", to: otherPeerID)
|
||||
|
||||
#expect(store.links(to: peerID) == [.central("central-a"), .central("central-b")])
|
||||
}
|
||||
|
||||
@Test
|
||||
func clearCentralsReturnsPreviouslyBoundPeerIDsAndClearsLookups() {
|
||||
let store = BLELinkStateStore()
|
||||
let firstPeerID = PeerID(str: "1122334455667788")
|
||||
let secondPeerID = PeerID(str: "8899aabbccddeeff")
|
||||
|
||||
store.bindCentral("central-a", to: firstPeerID)
|
||||
store.bindCentral("central-b", to: secondPeerID)
|
||||
|
||||
let removedPeerIDs = Set(store.clearCentrals())
|
||||
|
||||
#expect(removedPeerIDs == Set([firstPeerID, secondPeerID]))
|
||||
#expect(store.peerID(forCentralUUID: "central-a") == nil)
|
||||
#expect(store.links(to: firstPeerID).isEmpty)
|
||||
}
|
||||
}
|
||||
@ -260,6 +260,48 @@ struct BLEOutboundFragmentTransferSchedulerTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func blockedDuplicateAtFrontOfQueueDoesNotStarveALaterUnrelatedPendingTransfer() {
|
||||
// Bug: reservePendingStarts spent the slot budget on a pending
|
||||
// request the moment it was dequeued, before checking whether that
|
||||
// request would actually be admitted. A resend of still-active
|
||||
// content sitting at the front of the queue therefore consumed a
|
||||
// slot even though it was deferred back to the queue rather than
|
||||
// started -- starving an unrelated, genuinely startable transfer
|
||||
// right behind it until some other transfer happened to complete.
|
||||
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||
let t1 = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t1", payload: "file-a")
|
||||
let t2 = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t2", payload: "file-b")
|
||||
let dupT1 = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t1", payload: "file-a")
|
||||
let unrelated = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t3", payload: "file-c")
|
||||
|
||||
_ = scheduler.submit(t1, maxConcurrentTransfers: 2)
|
||||
_ = scheduler.submit(t2, maxConcurrentTransfers: 2)
|
||||
#expect(scheduler.activeCount == 2)
|
||||
|
||||
// Both slots are full, so a resend of "t1" (still active) and an
|
||||
// unrelated transfer both land in the pending queue, in that order.
|
||||
_ = scheduler.submit(dupT1, maxConcurrentTransfers: 2)
|
||||
_ = scheduler.submit(unrelated, maxConcurrentTransfers: 2)
|
||||
#expect(scheduler.pendingCount == 2)
|
||||
|
||||
// "t2" finishes; "t1" stays active, so the queued "t1" resend at the
|
||||
// front of the queue is still blocked when we reserve pending starts.
|
||||
let didActivate = scheduler.activateReservedTransfer(id: "t2", totalFragments: 1, workItems: [])
|
||||
#expect(didActivate)
|
||||
#expect(scheduler.markFragmentSent(transferId: "t2") == .complete(sentFragments: 1, totalFragments: 1))
|
||||
|
||||
let starts = scheduler.reservePendingStarts(maxConcurrentTransfers: 2)
|
||||
|
||||
let startedTransferIds: [String] = starts.compactMap {
|
||||
if case let .start(_, reservedTransferId) = $0 { return reservedTransferId }
|
||||
return nil
|
||||
}
|
||||
#expect(startedTransferIds == ["t3"], "the unrelated pending transfer must start in the same pass despite the blocked front item")
|
||||
#expect(scheduler.activeCount == 2, "t1 (still running) and the newly-started t3")
|
||||
#expect(scheduler.pendingCount == 1, "only the blocked t1 resend remains queued")
|
||||
}
|
||||
|
||||
@Test
|
||||
func removeAllReturnsActiveWorkItemsAndDropsPendingTransfers() {
|
||||
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||
|
||||
@ -7,8 +7,8 @@ struct BLERedundantLinkPolicyTests {
|
||||
private let peer = PeerID(str: "1122334455667788")
|
||||
private let otherPeer = PeerID(str: "8877665544332211")
|
||||
|
||||
private func link(_ uuid: String, _ peerID: PeerID?, connected: Bool = true, writable: Bool = true) -> BLERedundantLinkPolicy.PeripheralLink {
|
||||
BLERedundantLinkPolicy.PeripheralLink(uuid: uuid, peerID: peerID, isConnected: connected, hasCharacteristic: writable)
|
||||
private func link(_ uuid: String, _ peerID: PeerID?, connected: Bool = true, writable: Bool = true, connectedAt: Date? = nil) -> BLERedundantLinkPolicy.PeripheralLink {
|
||||
BLERedundantLinkPolicy.PeripheralLink(uuid: uuid, peerID: peerID, isConnected: connected, hasCharacteristic: writable, lastConnectedAt: connectedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -131,4 +131,144 @@ struct BLERedundantLinkPolicyTests {
|
||||
)
|
||||
#expect(Set(retiring) == Set(["p-stale-1", "p-stale-2"]))
|
||||
}
|
||||
|
||||
// MARK: Connect-recency preference (the July 31 retire↔reconnect fix)
|
||||
|
||||
@Test
|
||||
func newestConnectionWinsOverIngressAndBindingAnchors() {
|
||||
// Field oscillation: the restored old-address link (no connect
|
||||
// timestamp) carried the announce ingress AND the binding, so it
|
||||
// kept winning — and the cancelled fresh-address link kept getting
|
||||
// rediscovered and reconnected. Physical connect recency must beat
|
||||
// both announce anchors.
|
||||
let now = Date()
|
||||
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: "p-restored",
|
||||
mostRecentlyBoundUUID: "p-restored",
|
||||
links: [
|
||||
link("p-restored", peer),
|
||||
link("p-fresh", peer, connectedAt: now)
|
||||
],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(kept == "p-fresh")
|
||||
}
|
||||
|
||||
@Test
|
||||
func amongTimestampedLinksTheNewestWins() {
|
||||
let now = Date()
|
||||
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: "p-older",
|
||||
mostRecentlyBoundUUID: "p-older",
|
||||
links: [
|
||||
link("p-older", peer, connectedAt: now.addingTimeInterval(-30)),
|
||||
link("p-newer", peer, connectedAt: now)
|
||||
],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(kept == "p-newer")
|
||||
}
|
||||
|
||||
@Test
|
||||
func newestLinkMidDiscoveryDefersInsteadOfKeepingOlderWritable() {
|
||||
// The fresh connection hasn't finished service discovery, so it is
|
||||
// not writable yet. Keeping the older writable (restored) link now
|
||||
// would cancel the one connection on the currently advertised
|
||||
// address and recreate the oscillation — defer to a later announce.
|
||||
let now = Date()
|
||||
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: "p-writable",
|
||||
mostRecentlyBoundUUID: "p-writable",
|
||||
links: [
|
||||
link("p-writable", peer, connectedAt: now.addingTimeInterval(-30)),
|
||||
link("p-fresh-bare", peer, writable: false, connectedAt: now)
|
||||
],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(kept == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func restoredWritableAnchorAlsoDefersToFreshUnwritableLink() {
|
||||
// Same discovery window as above, but the writable duplicate is a
|
||||
// restored link with no connect timestamp at all — the exact field
|
||||
// topology. It must not win just because the fresh link is bare.
|
||||
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: "p-restored",
|
||||
mostRecentlyBoundUUID: "p-restored",
|
||||
links: [
|
||||
link("p-restored", peer),
|
||||
link("p-fresh-bare", peer, writable: false, connectedAt: Date())
|
||||
],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(kept == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func coNewestWritableLinkStillWinsOverBareTwin() {
|
||||
// Two links share the newest timestamp and one is writable: no
|
||||
// discovery window to wait out — the writable co-newest survives.
|
||||
let now = Date()
|
||||
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: nil,
|
||||
mostRecentlyBoundUUID: nil,
|
||||
links: [
|
||||
link("p-bare", peer, writable: false, connectedAt: now),
|
||||
link("p-writable", peer, connectedAt: now)
|
||||
],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(kept == "p-writable")
|
||||
}
|
||||
|
||||
@Test
|
||||
func allUnwritableDuplicatesConsolidateByConnectRecency() {
|
||||
// No writable link exists at all: nothing can be stranded, so the
|
||||
// newest connection consolidates immediately.
|
||||
let now = Date()
|
||||
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: "p-older",
|
||||
mostRecentlyBoundUUID: "p-older",
|
||||
links: [
|
||||
link("p-older", peer, writable: false, connectedAt: now.addingTimeInterval(-30)),
|
||||
link("p-newer", peer, writable: false, connectedAt: now)
|
||||
],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(kept == "p-newer")
|
||||
}
|
||||
|
||||
@Test
|
||||
func allRestoredLinksFallBackToAnnounceAnchors() {
|
||||
// No connect timestamps at all (every link restored): the legacy
|
||||
// ingress-then-binding preference still decides.
|
||||
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: "p-ingress",
|
||||
mostRecentlyBoundUUID: "p-bound",
|
||||
links: [link("p-ingress", peer), link("p-bound", peer)],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(kept == "p-ingress")
|
||||
}
|
||||
|
||||
@Test
|
||||
func timestampTiesBreakByAnchorsThenDeterministically() {
|
||||
let now = Date()
|
||||
let anchored = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: "p-b",
|
||||
mostRecentlyBoundUUID: nil,
|
||||
links: [link("p-a", peer, connectedAt: now), link("p-b", peer, connectedAt: now)],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(anchored == "p-b")
|
||||
|
||||
let unanchored = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: nil,
|
||||
mostRecentlyBoundUUID: nil,
|
||||
links: [link("p-b", peer, connectedAt: now), link("p-a", peer, connectedAt: now)],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(unanchored == "p-a")
|
||||
}
|
||||
}
|
||||
|
||||
@ -114,4 +114,83 @@ struct MediaRetentionTests {
|
||||
func defaultRetentionIsSevenDays() {
|
||||
#expect(BLEIncomingFileStore.defaultMediaRetention == 7 * 24 * 60 * 60)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Media was the one persistence layer that never stated a protection
|
||||
/// class at its write site, so payloads inherited the container
|
||||
/// default. Saves must survive the added write option,
|
||||
/// and on device the class must read back. The simulator's filesystem
|
||||
/// does not model data protection (the attribute reads back nil there),
|
||||
/// so the readback assertion is device-only.
|
||||
@Test
|
||||
func savedMediaSurvivesExplicitProtectionClass() throws {
|
||||
let root = makeRoot()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||
|
||||
let payload = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||
let saved = try #require(store.save(
|
||||
data: payload,
|
||||
preferredName: "note.m4a",
|
||||
subdirectory: "voicenotes/incoming",
|
||||
fallbackExtension: "m4a",
|
||||
defaultPrefix: "voice"
|
||||
))
|
||||
|
||||
#expect(try Data(contentsOf: saved) == payload)
|
||||
#if !targetEnvironment(simulator)
|
||||
let protection = try FileManager.default.attributesOfItem(
|
||||
atPath: saved.path
|
||||
)[.protectionKey] as? FileProtectionType
|
||||
#expect(protection == .completeUntilFirstUserAuthentication)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Files written before payloads carried an explicit class are stamped
|
||||
/// by the launch-time migration that follows the retention sweep: the
|
||||
/// directory plus each resident file, without error. In-flight live
|
||||
/// captures are left alone, exactly as the sweep leaves them: the
|
||||
/// coordinator may still be writing to one through an open FileHandle,
|
||||
/// and new captures receive the class at creation. Readback is device-only for the same
|
||||
/// reason as above.
|
||||
@Test
|
||||
func migrationStampsPreexistingMediaAndSkipsLiveCaptures() throws {
|
||||
let root = makeRoot()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||
let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming")
|
||||
|
||||
let legacy = try write(
|
||||
"received.m4a",
|
||||
in: incoming,
|
||||
modified: Date(timeIntervalSinceNow: -60)
|
||||
)
|
||||
_ = try write(
|
||||
"\(BLEIncomingFileStore.liveCapturePrefix)00112233445566ff_dm.aac",
|
||||
in: incoming,
|
||||
modified: Date(timeIntervalSinceNow: -60)
|
||||
)
|
||||
|
||||
// Exactly the directory itself plus the legacy file; strict equality
|
||||
// is what proves the live capture was not stamped.
|
||||
#expect(store.migrateFileProtectionIfNeeded() == 2)
|
||||
#expect(FileManager.default.fileExists(atPath: legacy.path))
|
||||
#if !targetEnvironment(simulator)
|
||||
let protection = try FileManager.default.attributesOfItem(
|
||||
atPath: legacy.path
|
||||
)[.protectionKey] as? FileProtectionType
|
||||
#expect(protection == .completeUntilFirstUserAuthentication)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// A store with no media on disk has nothing to stamp.
|
||||
@Test
|
||||
func migrationWithNoMediaIsANoOp() {
|
||||
let root = makeRoot()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||
|
||||
#expect(store.migrateFileProtectionIfNeeded() == 0)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -962,15 +962,20 @@ struct NoiseEncryptionServiceTests {
|
||||
|
||||
@Test("Immediate legacy restart during completion grace converges once")
|
||||
func immediateLegacyRestartDuringCompletionGrace() async throws {
|
||||
// The grace period must still be open when the restart initiation
|
||||
// arrives below. A small value races the wall clock on a starved
|
||||
// runner, so inject one no test run can outlive; the recovery half
|
||||
// is then fired explicitly instead of waiting out the timer.
|
||||
let unlosableGracePeriod: TimeInterval = 600
|
||||
let firstKeychain = MockKeychain()
|
||||
let secondKeychain = MockKeychain()
|
||||
let first = NoiseEncryptionService(
|
||||
keychain: firstKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0.03
|
||||
recentInitiatorCompletionGracePeriod: unlosableGracePeriod
|
||||
)
|
||||
let second = NoiseEncryptionService(
|
||||
keychain: secondKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0.03
|
||||
recentInitiatorCompletionGracePeriod: unlosableGracePeriod
|
||||
)
|
||||
let firstPeerID = PeerID(publicKey: first.getStaticPublicKeyData())
|
||||
let secondPeerID = PeerID(publicKey: second.getStaticPublicKeyData())
|
||||
@ -1035,6 +1040,7 @@ struct NoiseEncryptionServiceTests {
|
||||
)
|
||||
#expect(lower.hasEstablishedSession(with: higherPeerID))
|
||||
|
||||
lower._test_fireSuppressedInitiationRecovery(for: higherPeerID)
|
||||
let requested = await TestHelpers.waitUntil(
|
||||
{ recovery.messages.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
|
||||
64
bitchatTests/Services/SafeRegexTests.swift
Normal file
64
bitchatTests/Services/SafeRegexTests.swift
Normal file
@ -0,0 +1,64 @@
|
||||
//
|
||||
// SafeRegexTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// SafeRegex must never trap: valid patterns compile normally, invalid ones
|
||||
// degrade to a regex that matches nothing. The production-pattern test keeps
|
||||
// the compile-time guarantee try! used to provide - a typo in any bundled
|
||||
// pattern fails here instead of crashing the app at startup.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct SafeRegexTests {
|
||||
|
||||
private func matchCount(_ regex: NSRegularExpression, _ text: String) -> Int {
|
||||
regex.numberOfMatches(in: text, options: [], range: NSRange(text.startIndex..., in: text))
|
||||
}
|
||||
|
||||
@Test
|
||||
func validPatternCompilesAndMatches() {
|
||||
let regex = SafeRegex.compile("#([a-zA-Z0-9_]+)")
|
||||
#expect(matchCount(regex, "tag #bitchat here") == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func invalidPatternDegradesToNeverMatching() {
|
||||
let regex = SafeRegex.compile("(unclosed")
|
||||
#expect(matchCount(regex, "(unclosed anything") == 0)
|
||||
#expect(matchCount(regex, "") == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func productionPatternsCompileAndMatchTheirTargets() {
|
||||
// A pattern that failed to compile would have degraded to
|
||||
// never-matching, so each positive match proves the literal compiled.
|
||||
#expect(matchCount(MessageFormattingEngine.Patterns.hashtag, "see #mesh") == 1)
|
||||
#expect(matchCount(MessageFormattingEngine.Patterns.mention, "hi @alice#ab12") == 1)
|
||||
|
||||
let cashuToken = "cashuA" + String(repeating: "x", count: 45)
|
||||
#expect(matchCount(MessageFormattingEngine.Patterns.cashu, cashuToken) == 1)
|
||||
#expect(matchCount(MessageFormattingEngine.Patterns.quickCashuPresence, cashuToken) == 1)
|
||||
|
||||
let bolt11 = "lnbc1" + String(repeating: "q", count: 55)
|
||||
#expect(matchCount(MessageFormattingEngine.Patterns.bolt11, bolt11) == 1)
|
||||
|
||||
let lnurl = "lnurl1" + String(repeating: "q", count: 25)
|
||||
#expect(matchCount(MessageFormattingEngine.Patterns.lnurl, lnurl) == 1)
|
||||
|
||||
#expect(matchCount(MessageFormattingEngine.Patterns.lightningScheme, "pay lightning:abc123") == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func contentNormalizerStillSimplifiesURLs() {
|
||||
// Exercises ContentNormalizer's regex through its public entry point:
|
||||
// same URL with different query strings must normalize identically.
|
||||
let a = ContentNormalizer.normalizedKey("check https://example.com/page?q=1")
|
||||
let b = ContentNormalizer.normalizedKey("check https://example.com/page?q=2")
|
||||
#expect(a == b)
|
||||
}
|
||||
}
|
||||
216
bitchatTests/Simulation/SimulatedMesh.swift
Normal file
216
bitchatTests/Simulation/SimulatedMesh.swift
Normal file
@ -0,0 +1,216 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
/// A deterministic multi-node mesh over real `BLEService` engines and no
|
||||
/// CoreBluetooth: nodes are wired edge-to-edge through the outbound packet
|
||||
/// tap and the production ingress-attribution path (`_test_ingestFrame`),
|
||||
/// so announces bind links, signatures verify, Noise handshakes complete,
|
||||
/// and rotation rebinds run exactly the engine code a radio would drive.
|
||||
///
|
||||
/// Determinism model: outbound packets are buffered under a lock (the tap
|
||||
/// fires on each sender's engine); the test thread pumps deliveries and
|
||||
/// fences every engine between rounds. Timer-driven work (relay jitter,
|
||||
/// deferred flushes) is released explicitly through each node's
|
||||
/// `BLEEngineManualScheduler` via `advanceTime`.
|
||||
///
|
||||
/// Fidelity boundary: there are no physical links, so per-link fanout
|
||||
/// planning always reports failure to the sender (directed packets spool)
|
||||
/// — every capture happens at the pre-planning tap. Protocol-level
|
||||
/// behavior (attribution, binding, dedup, TTL, relay decisions, sessions)
|
||||
/// is faithful; link-selection and backpressure behavior is not exercised.
|
||||
final class SimulatedMesh {
|
||||
struct Node {
|
||||
let service: BLEService
|
||||
let scheduler: BLEEngineManualScheduler
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var pendingDeliveries: [(from: Int, packet: BitchatPacket)] = []
|
||||
/// Total (packet, receiving-node) deliveries pumped — the storm bound.
|
||||
private(set) var deliveredFrameCount = 0
|
||||
|
||||
private(set) var nodes: [Node] = []
|
||||
private var neighbors: [Set<Int>] = []
|
||||
private var duplicateLinkEdges: Set<String> = []
|
||||
private var emitted: [[BitchatPacket]] = []
|
||||
|
||||
/// Every packet a node has put on the wire — the attacker's capture
|
||||
/// buffer for replay tests.
|
||||
func emittedPackets(from index: Int) -> [BitchatPacket] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return emitted[index]
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addNode(nickname: String) -> Node {
|
||||
let keychain = MockKeychain()
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let scheduler = BLEEngineManualScheduler()
|
||||
let service = BLEService(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
initializeBluetoothManagers: false,
|
||||
engineScheduler: scheduler
|
||||
)
|
||||
let index = nodes.count
|
||||
let node = Node(service: service, scheduler: scheduler)
|
||||
// An earlier node's engine can fire its tap (which reads `emitted`
|
||||
// under the lock) while this append reallocates the array.
|
||||
lock.lock()
|
||||
nodes.append(node)
|
||||
neighbors.append([])
|
||||
emitted.append([])
|
||||
lock.unlock()
|
||||
// The tap must be live before `setNickname` below: setNickname
|
||||
// force-announces asynchronously on the engine, and if that slot
|
||||
// ran in the gap before a later tap install, the announce was
|
||||
// emitted invisibly while still stamping the wall-clock announce
|
||||
// throttle — swallowing `announceAll`'s forced announce on a
|
||||
// starved runner (the CI flake this ordering fixes).
|
||||
service._test_onOutboundPacket = { [weak self] packet in
|
||||
// Runs on the sender's engine; only buffer here — delivering
|
||||
// inline would nest one engine inside another.
|
||||
guard let self else { return }
|
||||
self.lock.lock()
|
||||
self.pendingDeliveries.append((from: index, packet: packet))
|
||||
self.emitted[index].append(packet)
|
||||
self.lock.unlock()
|
||||
}
|
||||
service.setNickname(nickname)
|
||||
return node
|
||||
}
|
||||
|
||||
func connect(_ a: Int, _ b: Int) {
|
||||
neighbors[a].insert(b)
|
||||
neighbors[b].insert(a)
|
||||
}
|
||||
|
||||
/// Radio silence: stops delivering between two nodes without reporting
|
||||
/// any link event, so existing bindings persist exactly as they do when
|
||||
/// a peer walks out of range before its link times out. Lets a test
|
||||
/// capture a packet the far side never received.
|
||||
func silence(_ a: Int, _ b: Int) {
|
||||
neighbors[a].remove(b)
|
||||
neighbors[b].remove(a)
|
||||
}
|
||||
|
||||
/// Models two live links to the same phone (issue #1538): every frame
|
||||
/// from the neighbour arrives twice, on two link IDs that both bind to
|
||||
/// the sender.
|
||||
///
|
||||
/// Both are central links — the remote's connections to our peripheral
|
||||
/// role. That is deliberate and faithful to the defect: central links
|
||||
/// are the ones we cannot cancel (they belong to the remote), so they
|
||||
/// are exactly the links the peripheral-cancel path cannot reach after
|
||||
/// a rotation. Peripheral-role bindings additionally require physical
|
||||
/// link state keyed by a real CBPeripheral, which no CB-free harness
|
||||
/// can fabricate.
|
||||
func connectDuplicateLinks(_ a: Int, _ b: Int) {
|
||||
connect(a, b)
|
||||
duplicateLinkEdges.insert(Self.edgeKey(a, b))
|
||||
}
|
||||
|
||||
/// The synthetic central link a frame from `sender` arrives on at
|
||||
/// `receiver`. Stable per directed edge, like a CoreBluetooth central
|
||||
/// UUID.
|
||||
func linkUUID(from sender: Int, at receiver: Int) -> String {
|
||||
"SIM-\(sender)-TO-\(receiver)"
|
||||
}
|
||||
|
||||
/// Order-independent edge key.
|
||||
private static func edgeKey(_ a: Int, _ b: Int) -> String {
|
||||
"\(min(a, b))-\(max(a, b))"
|
||||
}
|
||||
|
||||
/// The second link of a duplicate-link edge.
|
||||
func duplicateLinkUUID(from sender: Int, at receiver: Int) -> String {
|
||||
"SIM-DUP-\(sender)-TO-\(receiver)"
|
||||
}
|
||||
|
||||
private func links(from sender: Int, at receiver: Int) -> [BLEIngressLinkID] {
|
||||
var links: [BLEIngressLinkID] = [.central(linkUUID(from: sender, at: receiver))]
|
||||
if duplicateLinkEdges.contains(Self.edgeKey(sender, receiver)) {
|
||||
links.append(.central(duplicateLinkUUID(from: sender, at: receiver)))
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
func forceAnnounce(from index: Int) {
|
||||
nodes[index].service._test_forceAnnounce()
|
||||
pump()
|
||||
}
|
||||
|
||||
/// Pumps buffered deliveries until the mesh is quiescent: no pending
|
||||
/// frames and every engine drained. Timer-deferred work stays pending
|
||||
/// until `advanceTime`.
|
||||
func pump(maxRounds: Int = 64) {
|
||||
for _ in 0..<maxRounds {
|
||||
lock.lock()
|
||||
let batch = pendingDeliveries
|
||||
pendingDeliveries.removeAll()
|
||||
lock.unlock()
|
||||
|
||||
if batch.isEmpty {
|
||||
// Engines may still be running slots that will emit more.
|
||||
nodes.forEach { $0.service._test_fenceEngine() }
|
||||
lock.lock()
|
||||
let stillEmpty = pendingDeliveries.isEmpty
|
||||
lock.unlock()
|
||||
if stillEmpty { return }
|
||||
continue
|
||||
}
|
||||
|
||||
for (from, packet) in batch {
|
||||
for receiver in neighbors[from] {
|
||||
for link in links(from: from, at: receiver) {
|
||||
deliveredFrameCount += 1
|
||||
nodes[receiver].service._test_ingestFrame(packet, link: link)
|
||||
}
|
||||
}
|
||||
}
|
||||
nodes.forEach { $0.service._test_fenceEngine() }
|
||||
}
|
||||
fatalError("SimulatedMesh.pump did not quiesce in \(maxRounds) rounds — relay storm?")
|
||||
}
|
||||
|
||||
/// Advances every node's engine clock (releasing relay jitter, retries,
|
||||
/// deferred flushes) and pumps the resulting traffic.
|
||||
func advanceTime(by interval: TimeInterval) {
|
||||
nodes.forEach { $0.scheduler.advance(by: interval) }
|
||||
pump()
|
||||
}
|
||||
|
||||
/// Full discovery round: every node announces, traffic settles.
|
||||
///
|
||||
/// Resets each node's announce throttle first: the throttle window is
|
||||
/// wall-clock, so any announce that already ran (setNickname's, in
|
||||
/// `addNode`) would otherwise swallow this forced one whenever the two
|
||||
/// land within the forced minimum interval — which is always, on any
|
||||
/// runner. `forceAnnounce(from:)` deliberately does NOT reset — the
|
||||
/// panic-rotation tests pin the production reset behavior through it.
|
||||
func announceAll() {
|
||||
for node in nodes {
|
||||
node.service._test_resetAnnounceThrottle()
|
||||
node.service._test_forceAnnounce()
|
||||
}
|
||||
pump()
|
||||
}
|
||||
|
||||
/// Advances scheduler time one second per round until `condition`
|
||||
/// holds (or the round budget runs out — the caller's assertion then
|
||||
/// reports the real failure). Protocol exchanges normally settle in
|
||||
/// one or two rounds; under a heavily loaded parallel suite, engine
|
||||
/// slots can interleave with wall-clock-windowed crypto decisions and
|
||||
/// need a retry cycle or two more. Deterministic: rounds are scheduler
|
||||
/// time, never sleeps.
|
||||
func settleUntil(maxRounds: Int = 20, _ condition: () -> Bool) {
|
||||
for _ in 0..<maxRounds {
|
||||
if condition() { return }
|
||||
advanceTime(by: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
330
bitchatTests/Simulation/SimulatedMeshTests.swift
Normal file
330
bitchatTests/Simulation/SimulatedMeshTests.swift
Normal file
@ -0,0 +1,330 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// Deterministic multi-node mesh tests over the real engine (no
|
||||
/// CoreBluetooth, no wall-clock waits): announces bind simulated links,
|
||||
/// signatures verify, Noise sessions establish, and rotation rebinds run
|
||||
/// the same engine slots a radio would drive. See SimulatedMesh for the
|
||||
/// fidelity boundary.
|
||||
@Suite(.serialized)
|
||||
struct SimulatedMeshTests {
|
||||
@Test
|
||||
func announceExchangeBindsLinksAndConnectsPeers() {
|
||||
let mesh = SimulatedMesh()
|
||||
let a = mesh.addNode(nickname: "alice")
|
||||
let b = mesh.addNode(nickname: "bob")
|
||||
mesh.connect(0, 1)
|
||||
|
||||
mesh.announceAll()
|
||||
|
||||
// Raw direct announces bind each directed edge to the sender.
|
||||
#expect(b.service._test_centralBinding(mesh.linkUUID(from: 0, at: 1)) == a.service.myPeerID)
|
||||
#expect(a.service._test_centralBinding(mesh.linkUUID(from: 1, at: 0)) == b.service.myPeerID)
|
||||
// Verified announces register connected peers on both sides.
|
||||
#expect(a.service.getConnectedPeers().contains(b.service.myPeerID))
|
||||
#expect(b.service.getConnectedPeers().contains(a.service.myPeerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
func noiseSessionEstablishesEndToEnd() {
|
||||
let mesh = SimulatedMesh()
|
||||
let a = mesh.addNode(nickname: "alice")
|
||||
let b = mesh.addNode(nickname: "bob")
|
||||
mesh.connect(0, 1)
|
||||
|
||||
mesh.announceAll()
|
||||
// Handshake initiation and any deferred retries ride engine
|
||||
// timers; settle until both directions hold (normally 1 round).
|
||||
mesh.settleUntil {
|
||||
a.service.canDeliverSecurely(to: b.service.myPeerID)
|
||||
&& b.service.canDeliverSecurely(to: a.service.myPeerID)
|
||||
}
|
||||
|
||||
#expect(a.service.canDeliverSecurely(to: b.service.myPeerID))
|
||||
#expect(b.service.canDeliverSecurely(to: a.service.myPeerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
func publicMessageRelaysAcrossLineTopologyWithinTTLBudget() async {
|
||||
let mesh = SimulatedMesh()
|
||||
let a = mesh.addNode(nickname: "alice")
|
||||
_ = mesh.addNode(nickname: "bob")
|
||||
let c = mesh.addNode(nickname: "carol")
|
||||
mesh.connect(0, 1)
|
||||
mesh.connect(1, 2)
|
||||
|
||||
mesh.announceAll()
|
||||
mesh.advanceTime(by: 2)
|
||||
let baseline = mesh.deliveredFrameCount
|
||||
|
||||
let capture = TransportEventCapture()
|
||||
c.service.eventDelegate = capture
|
||||
a.service.sendMessage("hello line", mentions: [])
|
||||
mesh.pump()
|
||||
// Relay jitter defers B's forward; release it (twice: the relay's
|
||||
// own broadcast may schedule follow-on work).
|
||||
mesh.advanceTime(by: 2)
|
||||
mesh.advanceTime(by: 2)
|
||||
|
||||
let arrived = await capture.drainedPublicMessageCount(content: "hello line") == 1
|
||||
#expect(arrived)
|
||||
// Storm bound: a single public message across one relay hop must
|
||||
// not multiply into more than a handful of frames.
|
||||
#expect(mesh.deliveredFrameCount - baseline <= 12)
|
||||
}
|
||||
|
||||
@Test
|
||||
func duplicateFloodIsDeliveredOnce() async {
|
||||
let mesh = SimulatedMesh()
|
||||
let a = mesh.addNode(nickname: "alice")
|
||||
let b = mesh.addNode(nickname: "bob")
|
||||
mesh.connect(0, 1)
|
||||
mesh.announceAll()
|
||||
|
||||
let capture = TransportEventCapture()
|
||||
b.service.eventDelegate = capture
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data(hexString: a.service.myPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data("flooded".utf8),
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
let signed = a.service.signPacketForBroadcast(packet)
|
||||
let link = BLEIngressLinkID.central(mesh.linkUUID(from: 0, at: 1))
|
||||
for _ in 0..<8 {
|
||||
b.service._test_ingestFrame(signed, link: link)
|
||||
}
|
||||
mesh.pump()
|
||||
mesh.advanceTime(by: 2)
|
||||
|
||||
let deliveredOnce = await capture.drainedPublicMessageCount(content: "flooded") == 1
|
||||
#expect(deliveredOnce)
|
||||
}
|
||||
|
||||
@Test
|
||||
func linkDropEventRetiresBindingAndReconnectHeals() {
|
||||
let mesh = SimulatedMesh()
|
||||
let a = mesh.addNode(nickname: "alice")
|
||||
let b = mesh.addNode(nickname: "bob")
|
||||
mesh.connect(0, 1)
|
||||
mesh.announceAll()
|
||||
|
||||
let bobLinkOnAlice = mesh.linkUUID(from: 1, at: 0)
|
||||
#expect(a.service._test_centralBinding(bobLinkOnAlice) == b.service.myPeerID)
|
||||
#expect(a.service.getConnectedPeers().contains(b.service.myPeerID))
|
||||
|
||||
// The link layer reports the drop through the same port
|
||||
// CoreBluetooth's didUnsubscribe uses: identity retirement and
|
||||
// last-link peer bookkeeping are engine work.
|
||||
a.service.emitLinkEvent(.centralLinkEnded(centralUUID: bobLinkOnAlice))
|
||||
a.service._test_fenceEngine()
|
||||
|
||||
#expect(a.service._test_centralBinding(bobLinkOnAlice) == nil)
|
||||
#expect(!a.service.getConnectedPeers().contains(b.service.myPeerID))
|
||||
|
||||
// A fresh announce over the (re-established) link binds and
|
||||
// reconnects — the same heal path a real reconnection drives.
|
||||
// (The announce throttle runs on wall clock; model elapsed time.)
|
||||
b.service._test_resetAnnounceThrottle()
|
||||
mesh.forceAnnounce(from: 1)
|
||||
mesh.settleUntil {
|
||||
a.service.getConnectedPeers().contains(b.service.myPeerID)
|
||||
}
|
||||
#expect(a.service._test_centralBinding(bobLinkOnAlice) == b.service.myPeerID)
|
||||
#expect(a.service.getConnectedPeers().contains(b.service.myPeerID))
|
||||
}
|
||||
|
||||
/// Issue #1538: with two live links to the same phone, a panic
|
||||
/// rotation used to heal only the link the verified announce arrived
|
||||
/// on. The second link kept its binding to
|
||||
/// the retired identity, which therefore stayed in the peer list as a
|
||||
/// ghost — and, worse, kept being refreshed by the *new* identity's
|
||||
/// traffic (a bound link attributes non-announce frames to its bound
|
||||
/// peer, so the dead ID looked alive for as long as the link lived).
|
||||
@Test
|
||||
func duplicateLinkPanicRotationLeavesNoGhostAndHealsBothLinks() {
|
||||
let mesh = SimulatedMesh()
|
||||
let a = mesh.addNode(nickname: "alice")
|
||||
let b = mesh.addNode(nickname: "bob")
|
||||
mesh.connectDuplicateLinks(0, 1)
|
||||
mesh.announceAll()
|
||||
|
||||
let centralLink = BLEIngressLinkID.central(mesh.linkUUID(from: 1, at: 0))
|
||||
let duplicateLink = BLEIngressLinkID.central(mesh.duplicateLinkUUID(from: 1, at: 0))
|
||||
let oldBobID = b.service.myPeerID
|
||||
// Both links bind to bob: raw direct announces bind unbound links,
|
||||
// and that happens before duplicate suppression.
|
||||
#expect(a.service._test_linkBinding(centralLink) == oldBobID)
|
||||
#expect(a.service._test_linkBinding(duplicateLink) == oldBobID)
|
||||
|
||||
b.service.suspendForPanicReset()
|
||||
b.service.resetIdentityForPanic(currentNickname: "anon", restartServices: false)
|
||||
b.service.completePanicReset(restartServices: false)
|
||||
mesh.pump()
|
||||
let newBobID = b.service.myPeerID
|
||||
#expect(newBobID != oldBobID)
|
||||
|
||||
// One verified direct announce must retire the old identity
|
||||
// outright — no ghost survives on the link it did not arrive on.
|
||||
mesh.forceAnnounce(from: 1)
|
||||
mesh.settleUntil { !a.service._test_knownPeerIDs().contains(oldBobID) }
|
||||
#expect(!a.service._test_knownPeerIDs().contains(oldBobID))
|
||||
#expect(a.service._test_linkBinding(centralLink) != oldBobID)
|
||||
#expect(a.service._test_linkBinding(duplicateLink) != oldBobID)
|
||||
|
||||
// Both links converge onto the new identity as its announces land
|
||||
// (the released link binds through the ordinary unbound-link path,
|
||||
// so no containment rule has to be relaxed).
|
||||
for _ in 0..<4 {
|
||||
b.service._test_resetAnnounceThrottle()
|
||||
mesh.forceAnnounce(from: 1)
|
||||
mesh.advanceTime(by: 1)
|
||||
}
|
||||
#expect(a.service._test_linkBinding(centralLink) == newBobID)
|
||||
#expect(a.service._test_linkBinding(duplicateLink) == newBobID)
|
||||
#expect(a.service.getConnectedPeers() == [newBobID])
|
||||
}
|
||||
|
||||
/// The #1401 containment rule, pinned against the attack the #1538 fix
|
||||
/// had to avoid re-opening: a captured verified direct announce replayed
|
||||
/// onto a link the attacker controls must NOT bind that link to the
|
||||
/// victim while the victim holds a live link of its own — and must not
|
||||
/// evict the victim either (the rotation release only runs after a
|
||||
/// rebind the containment actually permitted).
|
||||
@Test
|
||||
func replayedVerifiedAnnounceCannotStealALinkOrEvictTheVictim() {
|
||||
let mesh = SimulatedMesh()
|
||||
let alice = mesh.addNode(nickname: "alice")
|
||||
let bob = mesh.addNode(nickname: "bob")
|
||||
let mallory = mesh.addNode(nickname: "mallory")
|
||||
mesh.connect(0, 1)
|
||||
mesh.connect(0, 2)
|
||||
mesh.announceAll()
|
||||
|
||||
let bobLink = BLEIngressLinkID.central(mesh.linkUUID(from: 1, at: 0))
|
||||
let malloryLink = BLEIngressLinkID.central(mesh.linkUUID(from: 2, at: 0))
|
||||
#expect(alice.service._test_linkBinding(bobLink) == bob.service.myPeerID)
|
||||
#expect(alice.service._test_linkBinding(malloryLink) == mallory.service.myPeerID)
|
||||
|
||||
// Mallory captures a signed direct announce alice has NOT seen, so
|
||||
// duplicate suppression cannot mask the containment check: bob
|
||||
// announces while out of alice's range, and mallory replays it on
|
||||
// her own link. Directness is forgeable; the signature is real.
|
||||
mesh.silence(0, 1)
|
||||
bob.service._test_resetAnnounceThrottle()
|
||||
mesh.forceAnnounce(from: 1)
|
||||
let replay = mesh.emittedPackets(from: 1).last {
|
||||
$0.type == MessageType.announce.rawValue && $0.ttl == TransportConfig.messageTTLDefault
|
||||
}
|
||||
guard let replay else {
|
||||
Issue.record("bob emitted no direct announce to capture")
|
||||
return
|
||||
}
|
||||
alice.service._test_ingestFrame(replay, link: malloryLink)
|
||||
mesh.pump()
|
||||
mesh.advanceTime(by: 1)
|
||||
|
||||
// The link is not stolen, and bob keeps both his binding and his
|
||||
// place in the peer list.
|
||||
#expect(alice.service._test_linkBinding(malloryLink) == mallory.service.myPeerID)
|
||||
#expect(alice.service._test_linkBinding(bobLink) == bob.service.myPeerID)
|
||||
#expect(alice.service._test_knownPeerIDs().contains(bob.service.myPeerID))
|
||||
#expect(alice.service.getConnectedPeers().contains(bob.service.myPeerID))
|
||||
|
||||
// Positive control — proves the refusal above was the containment
|
||||
// rule and not duplicate suppression: once bob holds no live link,
|
||||
// the very same replayed announce on the very same link does take
|
||||
// effect. (Long-standing accepted residual: a stolen link carries
|
||||
// only Noise ciphertext, and the rebind retires the link's proof.)
|
||||
alice.service.emitLinkEvent(.centralLinkEnded(centralUUID: mesh.linkUUID(from: 1, at: 0)))
|
||||
alice.service._test_fenceEngine()
|
||||
bob.service._test_resetAnnounceThrottle()
|
||||
mesh.forceAnnounce(from: 1)
|
||||
let secondReplay = mesh.emittedPackets(from: 1).last {
|
||||
$0.type == MessageType.announce.rawValue && $0.ttl == TransportConfig.messageTTLDefault
|
||||
}
|
||||
#expect(secondReplay?.timestamp != replay.timestamp)
|
||||
if let secondReplay {
|
||||
alice.service._test_ingestFrame(secondReplay, link: malloryLink)
|
||||
mesh.pump()
|
||||
mesh.advanceTime(by: 1)
|
||||
}
|
||||
#expect(alice.service._test_linkBinding(malloryLink) == bob.service.myPeerID)
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicRotationRebindsSurvivorExactlyOnceAndStays() {
|
||||
let mesh = SimulatedMesh()
|
||||
let a = mesh.addNode(nickname: "alice")
|
||||
let b = mesh.addNode(nickname: "bob")
|
||||
mesh.connect(0, 1)
|
||||
mesh.announceAll()
|
||||
mesh.advanceTime(by: 2)
|
||||
|
||||
let oldBobID = b.service.myPeerID
|
||||
let bobLinkOnAlice = mesh.linkUUID(from: 1, at: 0)
|
||||
#expect(a.service._test_centralBinding(bobLinkOnAlice) == oldBobID)
|
||||
|
||||
// Bob panics: the production sequence — suspend, rotate the whole
|
||||
// identity, commit — over the same simulated link.
|
||||
b.service.suspendForPanicReset()
|
||||
b.service.resetIdentityForPanic(currentNickname: "anon", restartServices: false)
|
||||
b.service.completePanicReset(restartServices: false)
|
||||
mesh.pump()
|
||||
let newBobID = b.service.myPeerID
|
||||
#expect(newBobID != oldBobID)
|
||||
|
||||
// His first verified direct announce heals the stale binding in
|
||||
// one engine slot on the survivor.
|
||||
mesh.forceAnnounce(from: 1)
|
||||
mesh.advanceTime(by: 2)
|
||||
#expect(a.service._test_centralBinding(bobLinkOnAlice) == newBobID)
|
||||
|
||||
// Containment: further announces (and the rebind cooldown) leave
|
||||
// the healed binding alone — no flip-flop back to the dead ID.
|
||||
// (Reset the wall-clock announce throttles so these actually send.)
|
||||
b.service._test_resetAnnounceThrottle()
|
||||
mesh.forceAnnounce(from: 1)
|
||||
a.service._test_resetAnnounceThrottle()
|
||||
mesh.forceAnnounce(from: 0)
|
||||
mesh.advanceTime(by: 2)
|
||||
#expect(a.service._test_centralBinding(bobLinkOnAlice) == newBobID)
|
||||
#expect(a.service.getConnectedPeers().contains(newBobID))
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures `.publicMessageReceived` transport events. Delivery crosses the main
|
||||
/// actor (`notifyUI`), so counting first drains that hop — a bounded number
|
||||
/// of main-actor round-trips, never a wall-clock wait (the mesh is already
|
||||
/// quiescent when this is called; only the queued MainActor task remains).
|
||||
private final class TransportEventCapture: TransportEventDelegate, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var publicMessages: [String] = []
|
||||
|
||||
func didReceiveTransportEvent(_ event: TransportEvent) {
|
||||
guard case let .publicMessageReceived(_, _, content, _, _) = event else { return }
|
||||
lock.lock()
|
||||
publicMessages.append(content)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
private func count(content: String) -> Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return publicMessages.filter { $0 == content }.count
|
||||
}
|
||||
|
||||
func drainedPublicMessageCount(content: String, drains: Int = 50) async -> Int {
|
||||
for _ in 0..<drains {
|
||||
if count(content: content) > 0 { break }
|
||||
await MainActor.run {}
|
||||
}
|
||||
return count(content: content)
|
||||
}
|
||||
}
|
||||
@ -542,6 +542,9 @@ struct ViewSmokeTests {
|
||||
])
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
// ContentView + people sheet must mount with the full feature-model
|
||||
// set (peerList / publicChat / privateInbox included). Missing any of
|
||||
// those crashes the NavigationStack sheet on some iOS versions (#1558).
|
||||
_ = mount(installSmokeEnvironment(ContentView(), featureModels: featureModels))
|
||||
_ = mount(installSmokeEnvironment(ContentPeopleSheetHarness(), featureModels: featureModels))
|
||||
|
||||
|
||||
@ -10,10 +10,41 @@ import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// One-shot event that bridges synchronous production seams to async tests
|
||||
/// without blocking a shared dispatch worker while waiting for the seam.
|
||||
private final class VoiceRecorderAsyncEvent: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var isSignaled = false
|
||||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func wait() async {
|
||||
await withCheckedContinuation { continuation in
|
||||
let resumeImmediately = lock.withLock { () -> Bool in
|
||||
guard !isSignaled else { return true }
|
||||
waiters.append(continuation)
|
||||
return false
|
||||
}
|
||||
if resumeImmediately {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func signal() {
|
||||
let continuations = lock.withLock { () -> [CheckedContinuation<Void, Never>] in
|
||||
guard !isSignaled else { return [] }
|
||||
isSignaled = true
|
||||
defer { waiters.removeAll() }
|
||||
return waiters
|
||||
}
|
||||
continuations.forEach { $0.resume() }
|
||||
}
|
||||
}
|
||||
|
||||
private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let activationGate = DispatchSemaphore(value: 0)
|
||||
private let activationBeganGate = DispatchSemaphore(value: 0)
|
||||
private let activationBegan = VoiceRecorderAsyncEvent()
|
||||
private let shouldGateFirstActivation: Bool
|
||||
private var gatedFirstActivation = false
|
||||
private var _activationCalls: [Bool] = []
|
||||
@ -34,23 +65,13 @@ private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendab
|
||||
return true
|
||||
}
|
||||
if shouldWait {
|
||||
activationBeganGate.signal()
|
||||
activationBegan.signal()
|
||||
activationGate.wait()
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntilActivationBegan(
|
||||
timeout: DispatchTimeInterval = .seconds(5)
|
||||
) async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
continuation.resume(
|
||||
returning: self.activationBeganGate.wait(
|
||||
timeout: DispatchTime.now() + timeout
|
||||
) == .success
|
||||
)
|
||||
}
|
||||
}
|
||||
func waitUntilActivationBegan() async {
|
||||
await activationBegan.wait()
|
||||
}
|
||||
|
||||
func resumeActivation() {
|
||||
@ -155,7 +176,7 @@ private final class TestVoiceAudioRecorderFactory: VoiceAudioRecorderCreating {
|
||||
/// this remains deterministic when the full test suite saturates the executor.
|
||||
private final class VoiceRecorderPaddingGate: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let enteredGate = DispatchSemaphore(value: 0)
|
||||
private let entered = VoiceRecorderAsyncEvent()
|
||||
private var isOpen = false
|
||||
private var openWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
@ -166,25 +187,15 @@ private final class VoiceRecorderPaddingGate: @unchecked Sendable {
|
||||
openWaiters.append(continuation)
|
||||
return false
|
||||
}
|
||||
enteredGate.signal()
|
||||
entered.signal()
|
||||
if resumeImmediately {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntilEntered(
|
||||
timeout: DispatchTimeInterval = .seconds(5)
|
||||
) async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
continuation.resume(
|
||||
returning: self.enteredGate.wait(
|
||||
timeout: DispatchTime.now() + timeout
|
||||
) == .success
|
||||
)
|
||||
}
|
||||
}
|
||||
func waitUntilEntered() async {
|
||||
await entered.wait()
|
||||
}
|
||||
|
||||
func open() {
|
||||
@ -223,7 +234,7 @@ struct VoiceRecorderTests {
|
||||
let owner = VoiceRecorder.RecordingOwner()
|
||||
|
||||
let startTask = Task { try await voiceRecorder.startRecording(owner: owner) }
|
||||
#expect(await session.waitUntilActivationBegan())
|
||||
await session.waitUntilActivationBegan()
|
||||
|
||||
await voiceRecorder.cancelRecording(owner: owner)
|
||||
session.resumeActivation()
|
||||
@ -321,7 +332,7 @@ struct VoiceRecorderTests {
|
||||
try await finishingHold.start()
|
||||
let firstURL = try #require(factory.urls.first)
|
||||
let finishTask = Task { await finishingHold.finish() }
|
||||
#expect(await paddingGate.waitUntilEntered())
|
||||
await paddingGate.waitUntilEntered()
|
||||
|
||||
await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) {
|
||||
try await rejectedHold.start()
|
||||
|
||||
@ -158,6 +158,39 @@ throughput is nowhere near what one serial queue sustains.
|
||||
(it makes no peer decisions); (b) bindings + link-auth migrate to
|
||||
the engine, converting `readLinkState` callers; (c) the delegates
|
||||
shrink to event emission and move behind the port.
|
||||
|
||||
**(a) and (b) are done.** (a) landed as `BLERadioController`
|
||||
(#1539). (b) landed in two steps: #1540 cohered the loose maps into
|
||||
`BLELinkAuthState` + `BLELinkBindings` (still bleQueue-owned,
|
||||
behavior-identical), and the option-B flip then moved ownership to
|
||||
the engine. Since the flip:
|
||||
|
||||
- `linkAuth`/`linkBindings` are engine-owned behind a DEBUG
|
||||
`dispatchPrecondition` trap; bleQueue code cannot touch them.
|
||||
- The receive path is in its sans-I/O shape: bleQueue decodes
|
||||
frames and hands `(packet, linkID)` up through
|
||||
`ingestDecodedPacket` (which captures the panic lifecycle at the
|
||||
handoff); `attributeAndHandlePacket` resolves the sender binding,
|
||||
admits or rejects the claimed sender, applies raw-announce
|
||||
binding, and records ingress — all on the engine. Per-link frame
|
||||
order is preserved end to end (both queues are serial), which
|
||||
supersedes the old batch-local TOCTOU binding.
|
||||
- The rotation rebind is one engine slot
|
||||
(`rebindLinkAfterVerifiedDirectAnnounce`): containment checks,
|
||||
proof retirement, binding flip, reconnect decision, and
|
||||
rotated-identity retirement, with only CoreBluetooth cancels
|
||||
hopping to bleQueue.
|
||||
- Authenticated-send eligibility (`notifyOrEnqueueIfAccepted`,
|
||||
`writeOrEnqueueIfAccepted`) is checked on the engine — serialized
|
||||
against rebinds by construction — and only the physical admission
|
||||
(updateValue / write / backpressure queues) runs on bleQueue.
|
||||
- Teardown splits: bleQueue delegates do physical work inline
|
||||
(`discardPeripheralLinkPhysical`) and queue the identity half
|
||||
(`retirePeripheralLinkIdentity`, binding survivor repair) to the
|
||||
engine. A binding can briefly outlive its physical link; queries
|
||||
that need liveness join against the physical store via
|
||||
`readLinkState` (the engine→bleQueue sync direction), and the
|
||||
queued retirement converges the two.
|
||||
2. **Sans-I/O engine core + simulator.** Make the engine formally
|
||||
`handle(event) -> [Effect]`, feed it from a `SimulatedLinkLayer`, and
|
||||
move the multi-node E2E suite onto deterministic simulation (no
|
||||
@ -168,6 +201,46 @@ throughput is nowhere near what one serial queue sustains.
|
||||
packet switch) ride this seam as handler-registered modules instead
|
||||
of getting closure-environment extractions now.
|
||||
|
||||
**The simulator half is done — simulator-first.** Because the B2
|
||||
receive path already hands `(packet, linkID)` up through one choke
|
||||
point, `SimulatedMesh` (bitchatTests/Simulation/) wires real
|
||||
CB-free `BLEService` engines edge-to-edge through the outbound tap
|
||||
and `_test_ingestFrame` (the production attribution path), with
|
||||
per-edge synthetic link IDs and manual-scheduler time. Five
|
||||
deterministic multi-node tests run in ~40ms: announce/bind
|
||||
convergence, end-to-end Noise establishment, line-topology relay
|
||||
within a TTL/frame budget, duplicate-flood dedup, and the panic
|
||||
rotation single-slot rebind + containment — the scenario that
|
||||
previously required two phones. Fidelity boundary: no physical
|
||||
links, so fanout planning/backpressure is not exercised; protocol
|
||||
behavior is. On its first day the simulator found a real bug: the
|
||||
forced-announce throttle survived panic, so a rotation within
|
||||
`bleForceAnnounceMinIntervalSeconds` of the last announce left the
|
||||
new identity invisible until the next maintenance cycle
|
||||
(`BLEAnnounceThrottle.reset()` now runs in the panic slot).
|
||||
**The upward port is named and the delegates live behind it.**
|
||||
`BLELinkEvent` (frameDecoded + the four physical lifecycle
|
||||
transitions) is the enumerable bleQueue→engine surface; every
|
||||
crossing goes through `emitLinkEvent` into one engine consumer
|
||||
(`handleLinkEvent`), and the simulated mesh drives lifecycle events
|
||||
through the identical enum a radio does (see
|
||||
`linkDropEventRetiresBindingAndReconnectHeals`). The CoreBluetooth
|
||||
delegate extensions moved to their own files —
|
||||
`BLEService+LinkLayerCentralRole.swift` /
|
||||
`BLEService+LinkLayerPeripheralRole.swift` — as physical
|
||||
bookkeeping plus event emission; the physical-domain members they
|
||||
share are `internal` with the queue contract enforced by the
|
||||
existing traps and grep guards rather than access control.
|
||||
|
||||
**Deliberately not done:** a formal `handle(event) -> [Effect]`
|
||||
effect system, and splitting the engine-domain feature handlers
|
||||
into more files. Both would flip the engine's private state
|
||||
(noiseService, peerRegistry, the identity domain) to internal for
|
||||
purely cosmetic file counts — the domains are already uniform
|
||||
(one queue, one rule set) and mechanically guarded. The effect
|
||||
formalization should ride actual feature-module extractions when a
|
||||
feature earns its own module, not precede them.
|
||||
|
||||
## What this is not
|
||||
|
||||
No wire changes: packet formats, signing (padding is signed), the
|
||||
|
||||
342
docs/PEER-ID-ROTATION.md
Normal file
342
docs/PEER-ID-ROTATION.md
Normal file
@ -0,0 +1,342 @@
|
||||
# Peer ID Rotation Specification
|
||||
|
||||
**Status:** Draft for cross-platform review. The derivations and the wire format **are implemented and tested**; nothing is wired into the shipping mesh.
|
||||
**Audience:** bitchat iOS and bitchat Android maintainers.
|
||||
**Requires agreement before going further.** This changes the wire protocol, so neither platform can ship it alone.
|
||||
|
||||
**Where the code is:**
|
||||
|
||||
| Piece | File |
|
||||
|---|---|
|
||||
| Epochs, ID derivation, recognition tags, tag block, binding message | `localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift` |
|
||||
| `announceV2 = 0x2C` wire format | `localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift` |
|
||||
| Executable test vectors | `localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift` |
|
||||
| Wire-format tests | `localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift` |
|
||||
|
||||
The code is deliberately an **opinionated working base, not a finished feature**. Every number and context string in it is a concrete proposal you can disagree with by changing one function and watching a test vector move. What is *not* implemented is the part that carries risk: nothing emits a v2 announce, and `BLEService` parses the type and explicitly ignores it, because consuming it needs both the replacement identity binding (§4.5) and a decision on how unverified presence appears in the peer list (O4).
|
||||
|
||||
Three policy decisions were forced by the compiler when the new message type was added, and are worth reviewing as part of this:
|
||||
|
||||
- **Not gossip-synced** (`SyncTypeFlags`). Syncing presence would defeat the purpose: a device never in radio range could collect tag blocks, turning a local beacon into a network-wide one.
|
||||
- **Not padded** (`BLEOutboundPacketPolicy`). At ~75 bytes the smallest bucket would triple the airtime of the most frequent packet in the protocol. The format is already near-constant width; making the capability and geohash fields fixed-width would be cheaper than padding. Open for argument.
|
||||
- **Parsed but ignored** on receive (`BLEService`), as above.
|
||||
|
||||
---
|
||||
|
||||
## 1. The problem
|
||||
|
||||
Today a passive listener with a BLE dongle, standing in a crowd, can do the following with no cryptographic attack and no active participation:
|
||||
|
||||
1. **Detect that a phone is running bitchat.** The service UUID is a fixed constant.
|
||||
2. **Assign that phone a permanent identifier.** The 8-byte sender ID in every packet header is `SHA-256(noiseStaticPublicKey)[0..8]`, and the Noise static key is generated once and kept in the keychain. It does not rotate. Same phone, same bytes, next week, next city.
|
||||
3. **Learn the phone's long-term public keys and its self-chosen nickname.** The announce carries the 32-byte Noise static key, the 32-byte Ed25519 signing key, and the nickname, all in cleartext, re-broadcast every 4–30 seconds and on demand to anything that connects and subscribes.
|
||||
4. **Reconstruct who was standing near whom.** The announce also carries up to ten neighbour IDs, so one receiver gets the local adjacency graph without needing several receivers or signal-strength trilateration.
|
||||
|
||||
For the people this app is explicitly built for, (2) and (4) are the dangerous ones. A protest attendee's phone announces a stable pseudonym and its social graph to anyone within radio range.
|
||||
|
||||
iOS BLE address randomization does not help. It randomizes the link-layer address underneath an application layer that publishes a stable identifier above it.
|
||||
|
||||
**The correction that matters most:** rotating the peer ID *alone* accomplishes nothing. As long as the announce carries the static keys in cleartext, a rotated ID is re-linked to the same device on its first announce. Rotation and announce confidentiality have to land together or not at all.
|
||||
|
||||
## 2. Goals and non-goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- **G1.** A passive listener cannot link two observations of the same device across rotation periods.
|
||||
- **G2.** A passive listener cannot learn a device's long-term identity keys or nickname.
|
||||
- **G3.** Peers who already know each other (mutual favourites) still recognise each other automatically, without an interactive handshake, so existing UX does not regress.
|
||||
- **G4.** Strangers can still discover and handshake, so the mesh still forms among people who have never met.
|
||||
- **G5.** Old and new clients interoperate. A mixed mesh keeps working, in both directions, with no flag day.
|
||||
- **G6.** Rotation does not make identity spoofing easier than it is today.
|
||||
|
||||
**Non-goals, explicitly out of scope here**
|
||||
|
||||
- Hiding *that* bitchat is in use. The service UUID is a separate problem; BLE requires something discoverable. Tracked separately.
|
||||
- Traffic-analysis resistance in general: padding coverage, send-time jitter, TTL randomization, and the neighbour-list leak each need their own change. Rotation does not fix them and they do not fix rotation.
|
||||
- Resistance to an active attacker who connects and completes a handshake. Anyone you handshake with learns your identity; that is what a handshake is for.
|
||||
|
||||
## 3. What currently binds an identity, and why rotation breaks it
|
||||
|
||||
This is the part most likely to be underestimated, so it is stated precisely.
|
||||
|
||||
`peerID == SHA-256(noiseStaticPublicKey)[0..8]` is not merely a convention. It is **the mechanism that makes peer IDs unforgeable**, and it is enforced in two places:
|
||||
|
||||
**Announce preflight** — `BLEAnnounceHandlingPolicy.swift:32-35`:
|
||||
|
||||
```swift
|
||||
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
||||
guard derivedPeerID == peerID else { return .reject(.senderMismatch(derivedPeerID: derivedPeerID)) }
|
||||
```
|
||||
|
||||
**Handshake completion** — `NoiseSessionManager.swift:1106-1122`:
|
||||
|
||||
```swift
|
||||
private func authenticatedRemoteKey(_ remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||
matches claimedPeerID: PeerID) -> Bool {
|
||||
let rawKey = remoteKey.rawRepresentation
|
||||
if claimedPeerID.isShort { return PeerID(publicKey: rawKey) == claimedPeerID }
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
Failure throws `NoiseSessionError.peerIdentityMismatch`.
|
||||
|
||||
If the peer ID becomes independent of the key, **both checks fail for every peer** and there is nothing left proving that a sender ID belongs to the sender. Any rotation design must therefore ship a *replacement* binding in the same change. This is why the work is a protocol revision and not a patch.
|
||||
|
||||
Note also what the existing announce signature does and does not prove. The packet signature covers the sender ID (`BitchatPacket.toBinaryDataForSigning()` zeroes only TTL and the RSR flag), but it is verified against the Ed25519 key carried *inside the same announce* — a self-signature. The code says so plainly (`BLEAnnounceHandlingPolicy.swift:94-103`): an attacker can replay a victim's peer ID and Noise key with their own signing key and a valid self-signature, and only trust-on-first-use pinning of the signing key stops it. So today's binding is "derived ID + TOFU", and a replacement must be at least that strong.
|
||||
|
||||
## 4. Design
|
||||
|
||||
### 4.1 Epochs
|
||||
|
||||
Rotation is on a wall-clock schedule so that two devices that have never met agree on the current period without negotiation.
|
||||
|
||||
```
|
||||
epoch = floor(unixTimeSeconds / ROTATION_PERIOD)
|
||||
ROTATION_PERIOD = 3600 (1 hour, proposed — see open question O1)
|
||||
```
|
||||
|
||||
`epoch` is a `UInt32`, big-endian wherever it is hashed. Implementations MUST accept `epoch-1`, `epoch`, and `epoch+1` when matching (the ±1 window absorbs clock skew and boundary crossings), following the precedent already set by courier recipient tags (`CourierEnvelope.candidateTags`).
|
||||
|
||||
### 4.2 The rotating peer ID
|
||||
|
||||
```
|
||||
K_rot = HKDF-SHA256(ikm: noiseStaticPrivateKey,
|
||||
salt: "",
|
||||
info: "bitchat-peer-rotation-v1",
|
||||
length: 32)
|
||||
|
||||
peerID_e = HMAC-SHA256(key: K_rot,
|
||||
message: "bitchat-peer-id-v2" || uint32be(epoch))[0..8]
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
- Derived from the **private** key, so no observer can compute or predict it, and two epochs' IDs are unlinkable.
|
||||
- Deterministic, so the device recomputes the same ID after a restart within the same epoch.
|
||||
- Still 8 bytes, so the packet header layout is unchanged.
|
||||
|
||||
**It must be derived from private key material.** Deriving from the *public* key would let anyone who has ever seen that key compute every past and future ID, which is worse than doing nothing because it would look like protection. This mistake already exists in the codebase: `CourierEnvelope.recipientTag` is `HMAC(key: recipient's **public** static key, epochDay)`, and since that public key is broadcast in cleartext today, any observer in radio range can compute a peer's courier tags for any day. The whitepaper's claim that couriers "cannot link it across days" does not currently hold. Fixing that is out of scope here but should be tracked; do not copy the pattern.
|
||||
|
||||
### 4.3 Recognising peers you already know
|
||||
|
||||
With the static keys off the air, mutual favourites need another way to spot each other. Each announce carries a set of **pairwise recognition tags**. For a device A announcing under `peerID_e` to mutual favourite B:
|
||||
|
||||
```
|
||||
S_AB = X25519(A_noiseStaticPrivate, B_noiseStaticPublic) // == X25519(B_priv, A_pub)
|
||||
K_AB = HKDF-SHA256(ikm: S_AB, salt: "", info: "bitchat-recognition-v1", length: 32)
|
||||
|
||||
tag_A→B = HMAC-SHA256(key: K_AB,
|
||||
message: uint32be(epoch)
|
||||
|| A_noiseStaticPublic (32)
|
||||
|| B_noiseStaticPublic (32)
|
||||
|| peerID_e (8))[0..8]
|
||||
```
|
||||
|
||||
A includes `tag_A→B` in its announce. B computes the same value independently — it holds the same shared secret and both public keys — and matches it against inbound announces. Only A and B can compute it, because it needs one of the two private keys.
|
||||
|
||||
Two properties of that MAC input are load-bearing, and an earlier draft of this document got both wrong. They were caught in review of #1487, which is the argument for shipping the code alongside the prose.
|
||||
|
||||
**Ordered keys make the tag directional.** The earlier form was `HMAC(K_AB, epoch)`, which is symmetric: A and B would broadcast the *identical* 8 bytes. An observer who saw one value appear in two different announces would learn that those two devices are mutual favourites, and could link their two rotating IDs to each other — handing over precisely the social graph this design exists to hide, and providing a cross-epoch correlation handle. Ordering the keys yields distinct A→B and B→A values, and both parties can still compute both directions because both hold both public keys.
|
||||
|
||||
**`peerID_e` binds the tag to the announce carrying it.** Without it a tag depends only on (pair, epoch), so an attacker could lift A's tag out of a recorded announce and replay it in a fresh announce under an ID of their own choosing; B would match and treat that ID as A. Because `epoch-1` is also accepted, the spoof would stay usable into the following period. Binding to the ID reduces this from impersonation-as-any-ID to replaying A's own presence.
|
||||
|
||||
**Residual risk, unfixable while announces are unsigned:** an attacker can rebroadcast A's exact announce within the epoch window, making A appear present when absent. Recognition is therefore a **hint only**. A match may populate presence, but anything consequential — routing a DM, showing a verified badge — MUST wait for a completed handshake whose static key equals the favourite that produced the match. See O4.
|
||||
|
||||
Rules:
|
||||
|
||||
- Tags are **unordered**. Implementations MUST NOT infer anything from position.
|
||||
- The tag list MUST be padded with uniform random 8-byte values to a fixed count `TAG_SLOTS = 8`, so the number of tags does not disclose how many mutual favourites a device has. Random padding is indistinguishable from a real tag to anyone who cannot compute it.
|
||||
- With more than `TAG_SLOTS` mutual favourites, a device MUST rotate which favourites occupy the slots across successive announces so all of them eventually see a tag. (Selection strategy is an implementation detail; convergence is not — see O2.)
|
||||
- A device MUST NOT include a tag for a one-directional favourite, since that would disclose interest to someone who has not reciprocated.
|
||||
|
||||
### 4.4 Strangers
|
||||
|
||||
Nothing identifying is broadcast for strangers. Discovery still works:
|
||||
|
||||
1. A hears an announce from unknown `peerID_e` advertising the rotation capability.
|
||||
2. A initiates Noise **XX** to that ID.
|
||||
3. In XX, the responder's static key is sent in message 2 *after* `ee`, and the initiator's in message 3 — both encrypted. A passive observer learns neither.
|
||||
4. On completion, both sides learn the peer's real static key and fingerprint, exactly as they do today (`handleSessionEstablished`), and the existing `AuthenticatedPeerStatePacket` (Noise payload `0x21`) carries the Ed25519 signing key and capability claims *inside* the session, where they are proven rather than asserted.
|
||||
|
||||
So the model becomes **handshake first, identify second**, for anyone who is not already a mutual favourite.
|
||||
|
||||
### 4.5 The replacement binding
|
||||
|
||||
Inside the completed handshake, each side proves that the rotating ID it was using belongs to its static key:
|
||||
|
||||
```
|
||||
proof = Ed25519-Sign(signingPrivateKey,
|
||||
"bitchat-peerid-binding-v1"
|
||||
|| uint32be(epoch)
|
||||
|| peerID_e (8 bytes)
|
||||
|| noiseStaticPublicKey (32 bytes))
|
||||
```
|
||||
|
||||
Sent as a new TLV in `AuthenticatedPeerStatePacket`, whose existing structure already carries a version byte, a canonicality-checked capability TLV, and the 32-byte signing key. The receiver verifies:
|
||||
|
||||
- **that the `noiseStaticPublicKey` inside the proof is byte-equal to the remote static key the Noise session actually established** — see below, this one is load-bearing, and
|
||||
- the signature against the signing key in the same packet, **and**
|
||||
- that the signing key matches whatever it has already pinned for this fingerprint, using the existing trust ladder (authenticated key, then TOFU pin), and
|
||||
- that `peerID_e` equals the ID the session was actually conducted under, and
|
||||
- that `epoch` is within the ±1 window.
|
||||
|
||||
An earlier draft of this list omitted the first check, which left a hole worth spelling out because it is the kind that survives review. The proof is a self-contained signed blob: nothing in the signature ties it to *the session it arrives on*. So a peer M who has observed A's proof — it travels inside a session, but M can be a peer A legitimately talked to — could replay A's proof verbatim inside M's own session with B. Without the static-key check, B verifies A's signature successfully, sees a well-formed binding, and on **first contact** TOFU-pins A's signing key against M's fingerprint. From then on B attributes M's identity to A's key. Comparing the proof's static key against the key the handshake actually produced closes it: M cannot substitute A's key without also being A.
|
||||
|
||||
This replaces `authenticatedRemoteKey`'s derivation check with an explicit signed statement. With the static-key check present it is strictly stronger than today's self-signed announce, because the signing key is checked against a pin rather than taken from the same message. Without it, it is weaker — a reminder that "signed" and "bound to this conversation" are different properties.
|
||||
|
||||
Note the canonical-bytes helper for this already half-exists: `NoiseEncryptionService.buildAnnounceSignature` / `verifyAnnounceSignature` / `canonicalAnnounceBytes`, with context `"bitchat-announce-v1"`, are present but unreferenced in production (only tests call them). They sign `context‖peerID(8)‖noiseKey(32)‖ed25519Key(32)‖nickname‖timestampMs`. The binding above is deliberately a **different context string** and a different field set, so the two can never be confused; the dead code should be deleted or repurposed explicitly rather than silently reused.
|
||||
|
||||
### 4.6 The announce, before and after
|
||||
|
||||
**Today** (`AnnouncementPacket`, TLVs in `Packets.swift:33-40`), all cleartext:
|
||||
|
||||
| T | Field | Width |
|
||||
|---|---|---|
|
||||
| `0x01` | nickname | var |
|
||||
| `0x02` | Noise static public key | 32 |
|
||||
| `0x03` | Ed25519 signing public key | 32 |
|
||||
| `0x04` | direct neighbours | N × 8, max 10 |
|
||||
| `0x05` | capabilities | 1–8 |
|
||||
| `0x06` | bridge geohash | var |
|
||||
|
||||
`0x01`, `0x02`, `0x03` are **required** by the decoder (`Packets.swift:147`).
|
||||
|
||||
**Proposed v2 announce.** Because the existing decoder hard-requires the three identity TLVs, a v2 announce cannot simply omit them — that is a parse failure, not a graceful degrade. It therefore needs a distinct message type: **`announceV2 = 0x2C`**.
|
||||
|
||||
An earlier draft proposed `0x05` on the grounds that it is unassigned today and sits next to `announce = 0x01`. That was wrong. `0x05` has already been recycled twice — `announce`, then `bulkTransferResponse`, then `fragmentStart` until #446 — so a sufficiently old peer may still map it to a fragment header and misparse presence as a partial message. Values above `voiceFrame = 0x29` have only ever been allocated forward, which is the safe direction; `0x2A`/`0x2B` are spoken for by the courier spray-ack work, leaving `0x2C`. Verified never used anywhere in this repository's history (see O3).
|
||||
|
||||
TLVs, all cleartext but none identifying:
|
||||
|
||||
| T | Field | Width | Notes |
|
||||
|---|---|---|---|
|
||||
| `0x01` | epoch | 4 | `uint32be`; lets a receiver match without guessing |
|
||||
| `0x02` | recognition tags | `TAG_SLOTS` × 8 = 64 | unordered, random-padded |
|
||||
| `0x03` | capabilities | 1–8 | same minimal-LE encoding as today |
|
||||
| `0x04` | bridge geohash | ≤12 | unchanged semantics |
|
||||
|
||||
Deliberately absent: nickname, both public keys, neighbour list.
|
||||
|
||||
Worth noting because it is counter-intuitive: **the v2 announce is smaller than the v1 announce**, despite carrying 64 bytes of tags. A v1 announce with a 10-byte nickname and a full neighbour list is roughly 165 payload bytes plus a 64-byte signature; a v2 announce is roughly 75 bytes and unsigned. Dropping two 32-byte keys, the neighbour list, and the signature more than pays for the tag block, so this reduces airtime rather than adding to it.
|
||||
|
||||
- **Nickname** moves inside the session (`AuthenticatedPeerStatePacket`). A nickname is a self-chosen, often reused human label; broadcasting it in cleartext is a linkage vector on its own.
|
||||
- **Neighbour list** is dropped entirely. It exists to seed source routing, and its documented fallback is flooding. Publishing the adjacency graph of a crowd is not a reasonable price for routing efficiency. (Dropping it is independently backward compatible — the TLV is optional on decode — and can ship ahead of this spec.)
|
||||
|
||||
**The v2 announce is unsigned.** This is a real trade-off and needs review (O4). There is no key to verify a signature against without disclosing one, so a v2 announce asserts nothing except "somebody is here, and here are some tags". Consequences:
|
||||
|
||||
- An attacker can emit v2 announces with arbitrary IDs and random tags — cheap peer-list noise. This is bounded by the existing announce rate limiting, per-central subscription limiting, and connection rate limits, but it is weaker than today.
|
||||
- An attacker **cannot** impersonate a specific known peer, because it cannot compute that peer's recognition tags without one of the two private keys.
|
||||
- An attacker cannot get a Noise session, so it cannot send messages, only occupy a peer-list slot.
|
||||
|
||||
Mitigation for review: treat a v2 announce as *unverified presence* only, and do not surface it in the peer list until either a recognition tag matches or a handshake completes. That preserves today's property that the peer list reflects authenticated peers.
|
||||
|
||||
## 5. Compatibility and rollout
|
||||
|
||||
The repo already has the two mechanisms this needs, both proven in production.
|
||||
|
||||
**Capability bit.** `PeerCapabilities` is a `UInt64` `OptionSet` with minimal little-endian wire encoding, at least one byte, so "no TLV" and "empty set" stay distinguishable. Crucially `BLEPeerRegistry.capabilitiesWereExplicitlyAdvertised(for:)` distinguishes *old client that sent no TLV* from *new client with the bit off*. Add `peerIDRotation` at the next free bit — **bit 14** at the time of writing: bit 10 is burned and MUST NOT be reused, bit 11 is claimed by the Nostr double-ratchet work (#1107), bit 12 by courier spray receipts (#1438), and bit 13 is reserved for stickers (#1544). Re-check the claim table in `PeerCapabilities.swift` before assigning; whichever platform implements first pins the number in a shared test vector.
|
||||
|
||||
**Observed-version gating.** `MeshTopologyTracker.recordObservedVersion(_:for:)` records the highest protocol version seen from each node, and `computeRoute(…, requiringVersion:)` refuses paths through nodes not observed at that version. `docs/SOURCE_ROUTING.md` records this as the shipped pattern for a compatible rollout. The same shape applies here.
|
||||
|
||||
**Phased plan.**
|
||||
|
||||
| Phase | Behaviour |
|
||||
|---|---|
|
||||
| 1 | Both platforms ship the ability to **parse** v2 announces and advertise the capability, while still sending v1. Purely additive; a v2 announce from a test build is understood rather than dropped. |
|
||||
| 2 | Send v1 **and** v2 announces, alternating. New clients prefer v2 and ignore the v1 from a peer they have recognised via v2; old clients see only the v1. Costs airtime, buys a no-flag-day transition. |
|
||||
| 3 | Once telemetry-free judgement says adoption is sufficient, a setting (default on) suppresses v1 announces. A device that suppresses v1 becomes invisible to old clients — that is the intended cost of unlinkability, and it must be stated in the UI, not buried. |
|
||||
|
||||
During phases 2–3 a device runs **both** a stable v1 ID and a rotating v2 ID. They must never appear as two peers; a peer recognised by both paths has to collapse to one entry. The repo has the beginnings of this in `MessageRouter.peerIDAliases` and `ChatPeerIdentityCoordinator.migrateChatState`, but they were built for panic-reset rotation, not steady-state rotation.
|
||||
|
||||
## 6. Impact inventory
|
||||
|
||||
This is what an implementer must handle. Every item below was verified against the iOS source; Android should expect its own equivalents.
|
||||
|
||||
### 6.1 Must be fixed or the feature is broken
|
||||
|
||||
| Area | Why | iOS reference |
|
||||
|---|---|---|
|
||||
| **Handshake identity check** | `authenticatedRemoteKey` re-derives the ID from the static key and fails for every peer once IDs are independent. Replace with §4.5. | `NoiseSessionManager.swift:1106-1122`, enforced `:714-718` |
|
||||
| **Announce preflight** | Same derivation check rejects any announce whose ID is not the key's hash. | `BLEAnnounceHandlingPolicy.swift:32-35` |
|
||||
| **Sealed message outbox** | Queued DM plaintext is keyed by peer ID on disk and survives app kill. A recipient's rotation orphans their queue. Needs re-keying by **fingerprint** (stable) with the peer ID as a lookup hint. This is the single worst offender. | `MessageOutboxStore.swift:66`, `:704-707`, `:746` |
|
||||
| **Private-media durable IDs** | `stableID` hashes sender and recipient short IDs, and the durable receipt ledger keys accept/tombstone records on it. Rotation silently breaks dedup **and user deletion tombstones**, so deleted media could be re-accepted. | `BitchatFilePacket.swift:183-231`, `BLEPrivateMediaReceiptStore.swift` |
|
||||
| **Initiator tie-break** | Crossed-initiation resolution compares `localPeerID < peerID`. Both sides must reach the same verdict; a rotation mid-negotiation flips it asymmetrically. Needs a rotation-stable comparison key (fingerprint). | `NoiseSessionManager.swift:83`, `:569`, `:582`, `:603` |
|
||||
| **Fingerprint-prefix lookups** | Several paths recover a peer from `fingerprint.hasPrefix(peerID)`. These silently return empty, and one of them is what lets a public message from a not-yet-registered peer be accepted at all. | `SecureIdentityStateManager.swift:437-444`; `ChatGroupCoordinator.swift:98-102`, `:432`; `FavoritesPersistenceService.swift:188-195`; `BLEService.swift:2552`, `:2823` |
|
||||
| **`PeerID.routingData`** | Falls back to `toShort()`, i.e. fingerprint-derived routing bytes. | `PeerID.swift:190-202` |
|
||||
|
||||
### 6.2 Degrades gracefully but needs handling
|
||||
|
||||
| Area | Effect | iOS reference |
|
||||
|---|---|---|
|
||||
| **Noise sessions** | A rotation mid-session leaves an established session under the old ID. Rotation should either be deferred while sessions are live or migrate them explicitly. | `NoiseEncryptionService.swift:1010-1019` |
|
||||
| **Fragment reassembly** | The reassembly key mixes the 8-byte sender ID, so a rotation mid-transfer strands every in-flight assembly until the 30 s timeout. Defer rotation while fragments are in flight. | `BLEFragmentAssemblyBuffer.swift:4-47` |
|
||||
| **Dedup LRU** | Keys embed the sender ID, so the same packet crossing a rotation boundary can be reprocessed once. Bounded and probably acceptable. | `BLEReceivePipeline.swift:21` |
|
||||
| **Source routes / topology** | A remote rotation invalidates cached adjacency, and a rotated relay no longer finds itself in an in-flight v2 route, falling back to flooding. Already the documented fallback. | `MeshTopologyTracker.swift`, `BLERouteForwardingPolicy.swift:62` |
|
||||
| **Gossip archive** | Archived raw packets keep the old sender ID forever, and packet IDs are sender-derived, so attribution and purge-by-peer break for pre-rotation history. | `GossipMessageArchive.swift`, `PacketIdUtil.swift:8-17` |
|
||||
| **Read receipts** | The wire receipt carries an 8-byte `readerID`; one sent before and matched after a rotation will not correlate. | `ReadReceipt.swift:47-64` |
|
||||
|
||||
### 6.3 Already safe — no work needed
|
||||
|
||||
Keyed by fingerprint, Noise key, or Ed25519 key rather than peer ID: the identity cache and every map in it (social identities, verified fingerprints, vouches, blocks), favourites (keyed by Noise static key), courier envelopes and recipient tags, prekey bundles, board posts, bridge drop dedup, group rosters, vouch attestations, and all geohash/location state (keyed by Nostr pubkey). Peer registry, link state, and all Noise session maps are in-memory and session-scoped.
|
||||
|
||||
## 7. Test vectors
|
||||
|
||||
These live as assertions in `PeerIDRotationTests.swift`, so they run on every build rather than rotting in a table.
|
||||
|
||||
All three were **cross-checked against an independent implementation written from this document alone** — Python `hmac`/`hashlib`, HKDF as extract-then-expand with an empty salt — and matched byte for byte. That is the property that matters: the spec text is sufficient to reproduce the numbers without reading the Swift.
|
||||
|
||||
With `noiseStaticPrivateKey = 0102…20` (bytes 1 through 32):
|
||||
|
||||
```
|
||||
rotationSecret = HKDF-SHA256(ikm: 0102…20, salt: <empty>,
|
||||
info: "bitchat-peer-rotation-v1", len: 32)
|
||||
= fb82dfec0c0a2a4677beca44e2f72c80e7c5de773dd5fce6ee47af83d3c25f09
|
||||
|
||||
peerID(epoch=100) = HMAC-SHA256(rotationSecret,
|
||||
"bitchat-peer-id-v2" || uint32be(100))[0..8]
|
||||
= f7c08c528506a374
|
||||
```
|
||||
|
||||
With a recognition key derived from a shared secret of 32 × `0x42`, sender key
|
||||
32 × `0x0A`, recipient key 32 × `0x0B`, and announced ID 8 × `0xA1`:
|
||||
|
||||
```
|
||||
recognitionKey = HKDF-SHA256(ikm: 42×32, salt: <empty>,
|
||||
info: "bitchat-recognition-v1", len: 32)
|
||||
|
||||
tag_A→B(epoch=100) = HMAC-SHA256(recognitionKey,
|
||||
uint32be(100) || 0A×32 || 0B×32 || A1×8)[0..8]
|
||||
= 4568f61d61d6cbfb
|
||||
|
||||
tag_B→A(epoch=100) (same key, keys swapped)
|
||||
= 5313c7731f629959
|
||||
```
|
||||
|
||||
Both directions are given because their *difference* is the security property: if
|
||||
an implementation produces the same value for both, it has reintroduced the
|
||||
symmetric-tag flaw.
|
||||
|
||||
Also asserted, and worth reproducing on Android because they are the properties rather than the numbers: both sides of a real X25519 pair derive the identical tag from opposite key halves; consecutive epochs produce unrelated IDs; the ±1 epoch window matches across a boundary but two epochs out does not; the tag block is always 64 bytes regardless of how many tags it carries; a match is found regardless of slot position; and the binding message is fixed-width so a short input cannot shift a later field into an earlier field's position.
|
||||
|
||||
Still to be written jointly: a full `announceV2` packet as a hex blob, and the §4.5 signature over a fixed key. Whichever platform writes a vector, the other MUST reproduce it from this document rather than from the first platform's code.
|
||||
|
||||
## 8. Open questions for review
|
||||
|
||||
- **O1 — Rotation period.** One hour is a guess balancing unlinkability against churn. Shorter means less linkable and more session/route disruption; longer the reverse. Is there a period that is clearly right, or should it be a build constant both platforms pin?
|
||||
- **O2 — More than `TAG_SLOTS` favourites.** What is the required convergence guarantee — "every mutual favourite sees a tag within N announces"? Should the slot rotation be deterministic from the epoch so it is testable?
|
||||
- **O3 — New message type vs. announce version byte.** A distinct `MessageType` is cleanest given the decoder's required TLVs, but it consumes a type value and means two announce paths. Would a version TLV inside the existing type, with the identity TLVs made optional on both platforms first, be preferable?
|
||||
- **O4 — Unsigned v2 announces.** Binding tags to the announced peer ID removes impersonation-as-any-ID, but a recorded announce can still be rebroadcast verbatim within the epoch window, so a peer can be made to look present when absent. Is "presence is a hint; nothing consequential until a handshake whose static key matches the favourite that produced the match" acceptable? The alternatives are an ephemeral per-epoch signing key with a proof-of-continuity, or a freshness nonce echoed by the recipient — both more machinery and more bytes.
|
||||
- **O5 — Rotation while a session is live.** Defer rotation until sessions are idle, or rotate and migrate? Deferring is simpler and safer, but a long-lived session pins the ID for its lifetime, which weakens G1 for exactly the people who talk most.
|
||||
- **O6 — Nickname timing.** Moving the nickname into the session means a stranger's name appears only after a handshake. Is that acceptable UX on both platforms, or does the peer list need a "someone nearby" placeholder state?
|
||||
- **O7 — Padding is a coordinated change, not a local one.** This started as a question about decoder tolerance and turned into something firmer. `BitchatPacket.toBinaryDataForSigning()` encodes with padding enabled, so **the padding bytes are inside the signed material for every signed packet**. Changing the padding algorithm therefore changes the signed byte stream, and signatures stop verifying against any peer that has not made the identical change. Both outstanding padding fixes are affected: extending coverage beyond `noiseEncrypted`/`noiseHandshake`, and closing the gap where a frame needing more than 255 bytes of padding is emitted unpadded (encoded *frames* of 241–256, 497–768 and 1009–1792 bytes ship at exact length today — the arithmetic is over the whole encoded packet that `pad` receives, not the payload alone). Two things to settle: whether Android's decoder also tolerates trailing bytes the way iOS's does (`guard offset <= buf.count`, plus an unpad retry), and whether padding changes ride this protocol revision or get their own capability-gated one.
|
||||
|
||||
- **O9 — A seized device recomputes every past peer ID.** `K_rot` is a long-lived secret, so `peerID_e = HMAC(K_rot, epoch)` is computable for *any* epoch by whoever holds it. Someone who seizes a phone, or extracts the Noise static key from a backup, can therefore take historical radio captures and identify which of them were this device — retroactively defeating the unlinkability for every past epoch. Rotation protects against the passive observer, not against later key compromise. A hash ratchet (`K_{e+1} = HKDF(K_e)`, discarding `K_e`) would give forward secrecy for the ID stream, at the cost of state that must survive restarts, tolerate clock jumps, and resynchronise after a gap — none of which is free, and all of which interacts with the ±1 window. Worth deciding deliberately rather than inheriting.
|
||||
|
||||
## 9. Relationship to other work
|
||||
|
||||
Rotation is the largest item in the radio-layer metadata cluster but not the only one, and the others are cheaper:
|
||||
|
||||
- **Drop the neighbour list** and **randomize origin TTL** — both landed separately, since neither needs agreement: see the radio-metadata PR.
|
||||
- **Extend padding beyond Noise frames, and fix the length-marker gap** — only `noiseEncrypted` and `noiseHandshake` are padded, and `pad` silently declines when the required padding exceeds the single-byte marker, so frames well below their bucket ship unpadded. **Not unilateral**: padding is inside the signed bytes, so this needs both platforms. See O7.
|
||||
|
||||
None of these substitute for rotation, and rotation does not substitute for them: a device with a rotating ID that still publishes its neighbour list, or that still marks its own originated packets by TTL, remains linkable.
|
||||
@ -0,0 +1,158 @@
|
||||
//
|
||||
// AnnounceV2Packet.swift
|
||||
// BitFoundation
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Identity-free presence announcement for rotating peer IDs.
|
||||
///
|
||||
/// The v1 `AnnouncementPacket` broadcasts, in cleartext, every 4–30 seconds: the
|
||||
/// nickname, the 32-byte Noise static public key, the 32-byte Ed25519 signing
|
||||
/// key, and up to ten neighbour IDs. That is a permanent device fingerprint plus
|
||||
/// the local social graph, free to anyone in radio range. This carries none of
|
||||
/// it — only an epoch, a fixed-size block of pairwise recognition tags, and
|
||||
/// capability bits.
|
||||
///
|
||||
/// Deliberately absent, with reasons:
|
||||
/// - **Public keys**: they are the linkage. Peers learn them inside the Noise XX
|
||||
/// handshake, where they are already encrypted on the wire.
|
||||
/// - **Nickname**: a self-chosen, frequently reused human label. It moves into
|
||||
/// the session (`AuthenticatedPeerStatePacket`).
|
||||
/// - **Neighbour list**: it seeds source routing, whose documented fallback is
|
||||
/// flooding. Publishing a crowd's adjacency graph is not a reasonable price
|
||||
/// for routing efficiency.
|
||||
///
|
||||
/// **Unsigned, on purpose and not without cost.** There is no key to verify a
|
||||
/// signature against without disclosing one, so this asserts only "somebody is
|
||||
/// here, and here are some tags". An attacker can therefore emit noise — bounded
|
||||
/// by existing announce and connection rate limits — but cannot impersonate a
|
||||
/// specific peer, because forging a recognition tag needs one of the two private
|
||||
/// keys, and cannot send anything without completing a handshake. The intended
|
||||
/// posture is to treat a v2 announce as *unverified presence* and not surface it
|
||||
/// until a tag matches or a handshake completes. See open question O4 in
|
||||
/// `docs/PEER-ID-ROTATION.md`.
|
||||
///
|
||||
/// Not emitted or consumed by the shipping mesh yet.
|
||||
// periphery:ignore - intentionally unreferenced by production code; nothing
|
||||
// emits or consumes this type yet, and BLEService parses it only to ignore it.
|
||||
// Delete this annotation when the mesh starts using it.
|
||||
public struct AnnounceV2Packet: Equatable, Sendable {
|
||||
/// Rotation epoch this announce was built for. Carried explicitly so a
|
||||
/// receiver matches against a stated epoch instead of guessing.
|
||||
public let epoch: UInt32
|
||||
/// Exactly `PeerIDRotation.tagSlots * PeerIDRotation.idLength` bytes.
|
||||
public let tagBlock: Data
|
||||
public let capabilities: PeerCapabilities?
|
||||
/// Coarse rendezvous cell, when bridging. Same semantics as v1.
|
||||
public let bridgeGeohash: String?
|
||||
|
||||
public init(
|
||||
epoch: UInt32,
|
||||
tagBlock: Data,
|
||||
capabilities: PeerCapabilities? = nil,
|
||||
bridgeGeohash: String? = nil
|
||||
) {
|
||||
self.epoch = epoch
|
||||
self.tagBlock = tagBlock
|
||||
self.capabilities = capabilities
|
||||
self.bridgeGeohash = bridgeGeohash
|
||||
}
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case epoch = 0x01
|
||||
case tagBlock = 0x02
|
||||
case capabilities = 0x03
|
||||
case bridgeGeohash = 0x04
|
||||
}
|
||||
|
||||
/// Expected tag-block width. A fixed size is load-bearing: it hides how many
|
||||
/// mutual favourites a device has.
|
||||
public static var tagBlockLength: Int {
|
||||
PeerIDRotation.tagSlots * PeerIDRotation.idLength
|
||||
}
|
||||
|
||||
public func encode() -> Data? {
|
||||
guard tagBlock.count == Self.tagBlockLength else { return nil }
|
||||
|
||||
var data = Data()
|
||||
|
||||
data.append(TLVType.epoch.rawValue)
|
||||
data.append(UInt8(4))
|
||||
withUnsafeBytes(of: epoch.bigEndian) { data.append(contentsOf: $0) }
|
||||
|
||||
data.append(TLVType.tagBlock.rawValue)
|
||||
data.append(UInt8(tagBlock.count))
|
||||
data.append(tagBlock)
|
||||
|
||||
if let capabilities {
|
||||
let bytes = capabilities.encoded()
|
||||
guard bytes.count <= 255 else { return nil }
|
||||
data.append(TLVType.capabilities.rawValue)
|
||||
data.append(UInt8(bytes.count))
|
||||
data.append(bytes)
|
||||
}
|
||||
|
||||
if let bridgeGeohash, !bridgeGeohash.isEmpty {
|
||||
let bytes = Data(bridgeGeohash.utf8)
|
||||
guard bytes.count <= 12 else { return nil }
|
||||
data.append(TLVType.bridgeGeohash.rawValue)
|
||||
data.append(UInt8(bytes.count))
|
||||
data.append(bytes)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
public static func decode(from data: Data) -> AnnounceV2Packet? {
|
||||
var epoch: UInt32?
|
||||
var tagBlock: Data?
|
||||
var capabilities: PeerCapabilities?
|
||||
var bridgeGeohash: String?
|
||||
|
||||
var offset = data.startIndex
|
||||
while offset < data.endIndex {
|
||||
guard data.distance(from: offset, to: data.endIndex) >= 2 else { return nil }
|
||||
let rawType = data[offset]
|
||||
let length = Int(data[data.index(after: offset)])
|
||||
let valueStart = data.index(offset, offsetBy: 2)
|
||||
guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil }
|
||||
let value = data.subdata(in: valueStart..<data.index(valueStart, offsetBy: length))
|
||||
|
||||
switch TLVType(rawValue: rawType) {
|
||||
case .epoch:
|
||||
guard length == 4 else { return nil }
|
||||
epoch = value.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
case .tagBlock:
|
||||
guard length == tagBlockLength else { return nil }
|
||||
tagBlock = value
|
||||
case .capabilities:
|
||||
let decoded = PeerCapabilities(encoded: value)
|
||||
// Canonicality check, matching AuthenticatedPeerStatePacket: a
|
||||
// non-minimal encoding would let the same capability set travel
|
||||
// as different bytes.
|
||||
guard decoded.encoded() == value else { return nil }
|
||||
capabilities = decoded
|
||||
case .bridgeGeohash:
|
||||
guard length <= 12, let text = String(data: value, encoding: .utf8) else { return nil }
|
||||
bridgeGeohash = text
|
||||
case nil:
|
||||
// Unknown TLV: skip, for forward compatibility.
|
||||
break
|
||||
}
|
||||
|
||||
offset = data.index(valueStart, offsetBy: length)
|
||||
}
|
||||
|
||||
guard let epoch, let tagBlock else { return nil }
|
||||
return AnnounceV2Packet(
|
||||
epoch: epoch,
|
||||
tagBlock: tagBlock,
|
||||
capabilities: capabilities,
|
||||
bridgeGeohash: bridgeGeohash
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -29,7 +29,7 @@ public final class BitchatMessage: Codable {
|
||||
public let recipientNickname: String?
|
||||
public let senderPeerID: PeerID?
|
||||
public let mentions: [String]? // Array of mentioned nicknames
|
||||
public var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||
public var deliveryStatus: DeliveryStatus // Delivery tracking
|
||||
/// True when this message reached us across a mesh bridge (signed by its
|
||||
/// author for an internet rendezvous) rather than over local radio.
|
||||
public let isBridged: Bool
|
||||
@ -64,7 +64,9 @@ public final class BitchatMessage: Codable {
|
||||
recipientNickname = try container.decodeIfPresent(String.self, forKey: .recipientNickname)
|
||||
senderPeerID = try container.decodeIfPresent(PeerID.self, forKey: .senderPeerID)
|
||||
mentions = try container.decodeIfPresent([String].self, forKey: .mentions)
|
||||
deliveryStatus = try container.decodeIfPresent(DeliveryStatus.self, forKey: .deliveryStatus)
|
||||
// Archives written while the field was optional omit it for public
|
||||
// messages; absent means the message never entered a send pipeline.
|
||||
deliveryStatus = try container.decodeIfPresent(DeliveryStatus.self, forKey: .deliveryStatus) ?? .notSentYet
|
||||
// Absent in archives written before bridging existed.
|
||||
isBridged = try container.decodeIfPresent(Bool.self, forKey: .isBridged) ?? false
|
||||
}
|
||||
@ -93,7 +95,7 @@ public final class BitchatMessage: Codable {
|
||||
self.recipientNickname = recipientNickname
|
||||
self.senderPeerID = senderPeerID
|
||||
self.mentions = mentions
|
||||
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
|
||||
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : .notSentYet)
|
||||
self.isBridged = isBridged
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
import struct Foundation.Date
|
||||
|
||||
public enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||
case notSentYet // Created but not yet handed to any transport
|
||||
case sending
|
||||
case sent // Left our device
|
||||
case carried // Sealed envelope handed to a courier; best-effort physical delivery
|
||||
@ -19,6 +20,8 @@ public enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||
|
||||
public var displayText: String {
|
||||
switch self {
|
||||
case .notSentYet:
|
||||
return "Not sent yet"
|
||||
case .sending:
|
||||
return "Sending..."
|
||||
case .sent:
|
||||
|
||||
@ -40,9 +40,27 @@ public enum MessageType: UInt8 {
|
||||
// never gossip-synced). Private bursts ride noiseEncrypted instead.
|
||||
case voiceFrame = 0x29
|
||||
|
||||
/// Identity-free presence for rotating peer IDs. Carries an epoch, a fixed
|
||||
/// block of pairwise recognition tags, and capabilities — no nickname, no
|
||||
/// public keys, no neighbour list. A separate type rather than a version of
|
||||
/// `announce` because that decoder hard-requires the identity TLVs, so
|
||||
/// omitting them is a parse failure rather than a graceful degrade.
|
||||
///
|
||||
/// `0x2C`, not the seemingly-free `0x05`: that value has been recycled
|
||||
/// twice already (`announce`, then `bulkTransferResponse`, then
|
||||
/// `fragmentStart` until #446), and a very old peer that still maps it to a
|
||||
/// fragment header would misparse presence as a partial message. Values
|
||||
/// after `voiceFrame` have only ever been allocated forward. `0x2A`/`0x2B`
|
||||
/// are spoken for by the courier spray-ack work, hence `0x2C`.
|
||||
///
|
||||
/// Not emitted or consumed by the shipping mesh yet; see
|
||||
/// `docs/PEER-ID-ROTATION.md`.
|
||||
case announceV2 = 0x2C
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .announce: return "announce"
|
||||
case .announceV2: return "announceV2"
|
||||
case .message: return "message"
|
||||
case .leave: return "leave"
|
||||
case .courierEnvelope: return "courierEnvelope"
|
||||
|
||||
@ -0,0 +1,315 @@
|
||||
//
|
||||
// PeerIDRotation.swift
|
||||
// BitFoundation
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
private import CryptoKit
|
||||
|
||||
/// Derivations for rotating peer IDs and pairwise recognition tags.
|
||||
///
|
||||
/// See `docs/PEER-ID-ROTATION.md` for the design, the threat model, and the
|
||||
/// open questions. This type is the executable half of that document: it is
|
||||
/// deliberately pure (no I/O, no clock of its own, no dependency on the BLE
|
||||
/// stack) so both platforms can agree on the numbers before anyone wires it
|
||||
/// into a transport.
|
||||
///
|
||||
/// Nothing here is used by the shipping mesh yet.
|
||||
///
|
||||
/// ## Why the derivations look like this
|
||||
///
|
||||
/// The rotating ID comes from **private** key material. Deriving it from the
|
||||
/// public key would let anyone who has ever seen that key compute every past
|
||||
/// and future ID, which is worse than not rotating because it would look like
|
||||
/// protection. The same mistake is live in `CourierEnvelope.recipientTag`,
|
||||
/// which is keyed on the recipient's *public* static key — and since that key
|
||||
/// is broadcast in cleartext in every announce today, any observer in radio
|
||||
/// range can compute a peer's courier tags for any day.
|
||||
///
|
||||
/// Recognition tags come from the X25519 shared secret between two static
|
||||
/// keys, so exactly two parties can compute a given tag and an observer can
|
||||
/// compute none of them.
|
||||
// periphery:ignore - intentionally unreferenced by production code. These are
|
||||
// the reviewable primitives for a protocol change that cannot ship until both
|
||||
// platforms agree on it; wiring them into the transport is the next step, not
|
||||
// this one. Delete this annotation when the mesh starts using them.
|
||||
public enum PeerIDRotation {
|
||||
// MARK: - Parameters
|
||||
|
||||
/// Seconds per rotation epoch. One hour is a starting position, not a
|
||||
/// settled one: shorter is less linkable but churns sessions, routes, and
|
||||
/// in-flight fragment reassembly more often. See open question O1.
|
||||
public static let rotationPeriod: TimeInterval = 3600
|
||||
|
||||
/// Bytes of an ID or tag placed on the wire. Matches the existing 8-byte
|
||||
/// header sender ID, so the packet layout is unchanged.
|
||||
public static let idLength = 8
|
||||
|
||||
/// Fixed number of tag slots in an announce. Padding to a constant hides
|
||||
/// how many mutual favourites a device has, which is itself identifying.
|
||||
public static let tagSlots = 8
|
||||
|
||||
// MARK: - Context strings
|
||||
//
|
||||
// Distinct per use so a value derived for one purpose can never be
|
||||
// substituted for another. `bitchat-announce-v1` is deliberately NOT reused:
|
||||
// it belongs to the production-dead announce-signature helpers in
|
||||
// NoiseEncryptionService, and confusing the two would be a real bug.
|
||||
|
||||
private static let rotationInfo = Data("bitchat-peer-rotation-v1".utf8)
|
||||
private static let peerIDContext = Data("bitchat-peer-id-v2".utf8)
|
||||
private static let recognitionInfo = Data("bitchat-recognition-v1".utf8)
|
||||
private static let bindingContext = Data("bitchat-peerid-binding-v1".utf8)
|
||||
|
||||
// MARK: - Epochs
|
||||
|
||||
/// Epoch number for a point in time. Wall-clock derived so two devices that
|
||||
/// have never met agree on the current epoch without negotiating.
|
||||
public static func epoch(at date: Date) -> UInt32 {
|
||||
let seconds = max(0, date.timeIntervalSince1970)
|
||||
return UInt32(truncatingIfNeeded: Int(seconds / rotationPeriod))
|
||||
}
|
||||
|
||||
/// Epochs to test when matching, oldest first.
|
||||
///
|
||||
/// The ±1 window absorbs clock skew and the moment either side crosses a
|
||||
/// boundary, mirroring `CourierEnvelope.candidateTags`. Without it, two
|
||||
/// devices a few seconds apart across a boundary would fail to recognise
|
||||
/// each other for no reason a person could understand.
|
||||
public static func candidateEpochs(around date: Date) -> [UInt32] {
|
||||
let current = epoch(at: date)
|
||||
return current == 0 ? [0, 1] : [current - 1, current, current + 1]
|
||||
}
|
||||
|
||||
// MARK: - Rotating peer ID
|
||||
|
||||
/// Long-lived rotation secret for this device. Derived from the Noise
|
||||
/// static **private** key, so it never leaves the device and no observer
|
||||
/// can predict any ID it produces.
|
||||
public static func rotationSecret(noiseStaticPrivateKey: Data) -> Data {
|
||||
let derived = HKDF<SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: noiseStaticPrivateKey),
|
||||
info: rotationInfo,
|
||||
outputByteCount: 32
|
||||
)
|
||||
return derived.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
|
||||
/// This device's peer ID for a given epoch.
|
||||
public static func peerID(rotationSecret: Data, epoch: UInt32) -> Data {
|
||||
var message = peerIDContext
|
||||
message.append(bigEndianBytes(epoch))
|
||||
let mac = HMAC<SHA256>.authenticationCode(
|
||||
for: message,
|
||||
using: SymmetricKey(data: rotationSecret)
|
||||
)
|
||||
return Data(mac).prefix(idLength)
|
||||
}
|
||||
|
||||
/// Convenience: the ID this device should be using at `date`.
|
||||
public static func currentPeerID(noiseStaticPrivateKey: Data, at date: Date) -> Data {
|
||||
peerID(
|
||||
rotationSecret: rotationSecret(noiseStaticPrivateKey: noiseStaticPrivateKey),
|
||||
epoch: epoch(at: date)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Pairwise recognition tags
|
||||
|
||||
/// Symmetric recognition key for a pair, from their X25519 shared secret.
|
||||
///
|
||||
/// Both sides compute the identical value from opposite key halves, which
|
||||
/// is the whole point: recognition needs no round trip, and no third party
|
||||
/// can derive it.
|
||||
public static func recognitionKey(sharedSecret: Data) -> Data {
|
||||
let derived = HKDF<SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecret),
|
||||
info: recognitionInfo,
|
||||
outputByteCount: 32
|
||||
)
|
||||
return derived.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
|
||||
/// The tag `sender` puts in its announce for `recipient` this epoch.
|
||||
///
|
||||
/// Three inputs beyond the epoch, each load-bearing:
|
||||
///
|
||||
/// - **Ordered keys make the tag directional.** An earlier draft used
|
||||
/// `HMAC(K_AB, epoch)`, which is symmetric — so A and B broadcast the
|
||||
/// *same* 8 bytes, and an observer who spots one value in two different
|
||||
/// announces learns those two devices are mutual favourites and can link
|
||||
/// their rotating IDs to each other. That hands over exactly the social
|
||||
/// graph this design exists to hide. Ordering the keys gives A→B and B→A
|
||||
/// distinct values; both parties can still compute both directions,
|
||||
/// because both hold both public keys.
|
||||
/// - **`peerID` binds the tag to the announce carrying it.** Without it the
|
||||
/// tag depends only on (pair, epoch), so an attacker could lift a tag out
|
||||
/// of A's announce and replay it under an ID of their choosing; the
|
||||
/// recipient would match and believe that ID is A. Binding means a lifted
|
||||
/// tag is only valid alongside A's own ID, which reduces the attack from
|
||||
/// impersonation-as-any-ID to replaying A's presence.
|
||||
///
|
||||
/// Replaying A's own announce within the epoch window remains possible —
|
||||
/// unsigned announces cannot prevent it. Recognition is therefore a hint
|
||||
/// only, and anything consequential must wait for a completed handshake.
|
||||
/// See open question O4.
|
||||
public static func recognitionTag(
|
||||
recognitionKey: Data,
|
||||
epoch: UInt32,
|
||||
senderStaticPublicKey: Data,
|
||||
recipientStaticPublicKey: Data,
|
||||
peerID: Data
|
||||
) -> Data {
|
||||
var message = bigEndianBytes(epoch)
|
||||
message.append(fixedWidth(senderStaticPublicKey, 32))
|
||||
message.append(fixedWidth(recipientStaticPublicKey, 32))
|
||||
message.append(fixedWidth(peerID, idLength))
|
||||
let mac = HMAC<SHA256>.authenticationCode(
|
||||
for: message,
|
||||
using: SymmetricKey(data: recognitionKey)
|
||||
)
|
||||
return Data(mac).prefix(idLength)
|
||||
}
|
||||
|
||||
// MARK: - Tag block
|
||||
|
||||
/// Packs tags into the fixed-size announce block, padding with uniform
|
||||
/// random bytes.
|
||||
///
|
||||
/// Random padding is indistinguishable from a real tag to anyone who cannot
|
||||
/// compute the real ones, so the block discloses neither how many mutual
|
||||
/// favourites a device has nor which slot belongs to whom. Tags beyond
|
||||
/// `tagSlots` are dropped here; choosing *which* to carry across successive
|
||||
/// announces is the caller's problem (open question O2).
|
||||
public static func tagBlock(
|
||||
tags: [Data],
|
||||
randomBytes: (Int) -> Data = Self.secureRandomBytes
|
||||
) -> Data {
|
||||
var slots = tags.prefix(tagSlots).map { $0.prefix(idLength) }
|
||||
// Order must carry no information, so shuffle rather than appending
|
||||
// real tags at the front.
|
||||
slots.shuffle()
|
||||
var block = Data()
|
||||
for slot in slots {
|
||||
block.append(slot)
|
||||
if slot.count < idLength {
|
||||
block.append(Data(repeating: 0, count: idLength - slot.count))
|
||||
}
|
||||
}
|
||||
let padding = (tagSlots - slots.count) * idLength
|
||||
if padding > 0 {
|
||||
block.append(randomBytes(padding))
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
/// Splits a received block back into candidate tags.
|
||||
///
|
||||
/// Returns nil for a block that is not exactly `tagSlots * idLength`, so a
|
||||
/// malformed announce is rejected rather than partially interpreted.
|
||||
public static func tags(fromBlock block: Data) -> [Data]? {
|
||||
guard block.count == tagSlots * idLength else { return nil }
|
||||
return stride(from: 0, to: block.count, by: idLength).map {
|
||||
block.subdata(in: (block.startIndex + $0)..<(block.startIndex + $0 + idLength))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any slot in `block` holds the tag we expect a specific peer to
|
||||
/// have put there, for an announce carrying `peerID`.
|
||||
///
|
||||
/// `senderStaticPublicKey` is the peer we hope sent this (so we compute the
|
||||
/// direction they would use) and `recipientStaticPublicKey` is our own.
|
||||
/// Passing them the other way round tests the opposite direction and will
|
||||
/// not match, which is the point of making tags directional.
|
||||
///
|
||||
/// Comparison is constant-time per candidate, and every slot is examined
|
||||
/// even after a match, so neither the presence of a match nor its slot
|
||||
/// index is observable through timing.
|
||||
public static func blockMatches(
|
||||
_ block: Data,
|
||||
recognitionKey: Data,
|
||||
senderStaticPublicKey: Data,
|
||||
recipientStaticPublicKey: Data,
|
||||
peerID: Data,
|
||||
at date: Date
|
||||
) -> Bool {
|
||||
guard let slots = tags(fromBlock: block) else { return false }
|
||||
let expected = candidateEpochs(around: date).map {
|
||||
recognitionTag(
|
||||
recognitionKey: recognitionKey,
|
||||
epoch: $0,
|
||||
senderStaticPublicKey: senderStaticPublicKey,
|
||||
recipientStaticPublicKey: recipientStaticPublicKey,
|
||||
peerID: peerID
|
||||
)
|
||||
}
|
||||
var matched = false
|
||||
for slot in slots {
|
||||
for candidate in expected where constantTimeEquals(slot, candidate) {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
// MARK: - Identity binding
|
||||
|
||||
/// Canonical bytes proving a rotating ID belongs to a static key.
|
||||
///
|
||||
/// Signed with the Ed25519 identity key and exchanged **inside** a
|
||||
/// completed Noise session, this replaces the derivation check that today
|
||||
/// makes peer IDs unforgeable (`peerID == SHA-256(staticKey)[0..8]`, checked
|
||||
/// in the announce preflight and again at handshake completion). Once IDs
|
||||
/// are independent of the key, those checks fail for every peer, so a
|
||||
/// replacement has to exist before rotation can ship.
|
||||
///
|
||||
/// Fixed-width fields throughout: no length prefixes are needed and no two
|
||||
/// distinct inputs can produce the same bytes.
|
||||
public static func bindingMessage(
|
||||
epoch: UInt32,
|
||||
peerID: Data,
|
||||
noiseStaticPublicKey: Data
|
||||
) -> Data {
|
||||
var out = bindingContext
|
||||
out.append(bigEndianBytes(epoch))
|
||||
out.append(fixedWidth(peerID, idLength))
|
||||
out.append(fixedWidth(noiseStaticPublicKey, 32))
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Padding must be indistinguishable from a real tag, so it comes from the
|
||||
/// system CSPRNG via key generation rather than a general-purpose RNG.
|
||||
public static func secureRandomBytes(_ count: Int) -> Data {
|
||||
guard count > 0 else { return Data() }
|
||||
let key = SymmetricKey(size: SymmetricKeySize(bitCount: count * 8))
|
||||
return key.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
|
||||
private static func bigEndianBytes(_ value: UInt32) -> Data {
|
||||
withUnsafeBytes(of: value.bigEndian) { Data($0) }
|
||||
}
|
||||
|
||||
private static func fixedWidth(_ data: Data, _ width: Int) -> Data {
|
||||
var out = data.prefix(width)
|
||||
if out.count < width {
|
||||
out.append(Data(repeating: 0, count: width - out.count))
|
||||
}
|
||||
return Data(out)
|
||||
}
|
||||
|
||||
/// Length-independent comparison, so a match cannot be found byte by byte
|
||||
/// through timing.
|
||||
private static func constantTimeEquals(_ lhs: Data, _ rhs: Data) -> Bool {
|
||||
guard lhs.count == rhs.count else { return false }
|
||||
var difference: UInt8 = 0
|
||||
for (left, right) in zip(lhs, rhs) {
|
||||
difference |= left ^ right
|
||||
}
|
||||
return difference == 0
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import BitFoundation
|
||||
|
||||
/// Wire-format tests for the identity-free announce. These are the second half
|
||||
/// of the cross-platform contract: Android must encode and decode byte-identical
|
||||
/// packets, so anything asserted here is a promise, not an implementation detail.
|
||||
struct AnnounceV2PacketTests {
|
||||
private var block: Data {
|
||||
Data(repeating: 0xAB, count: AnnounceV2Packet.tagBlockLength)
|
||||
}
|
||||
|
||||
@Test func typeValueIsStable() {
|
||||
// Changing this breaks every deployed decoder.
|
||||
//
|
||||
// Deliberately NOT 0x05, which merely looks free: it has been recycled
|
||||
// twice already (announce, then bulkTransferResponse, then fragmentStart
|
||||
// until #446), so an old peer could still map it to a fragment header
|
||||
// and misparse presence as a partial message. Values above
|
||||
// voiceFrame = 0x29 have only ever been allocated forward; 0x2A/0x2B
|
||||
// belong to the courier spray-ack work.
|
||||
#expect(MessageType.announceV2.rawValue == 0x2C)
|
||||
#expect(MessageType(rawValue: 0x2C) == .announceV2)
|
||||
#expect(MessageType.announceV2.description == "announceV2")
|
||||
}
|
||||
|
||||
@Test func tagBlockIsSixtyFourBytes() {
|
||||
#expect(AnnounceV2Packet.tagBlockLength == 64)
|
||||
}
|
||||
|
||||
@Test func roundTripsWithEveryField() throws {
|
||||
let packet = AnnounceV2Packet(
|
||||
epoch: 495_555,
|
||||
tagBlock: block,
|
||||
capabilities: [.bridge, .prekeys],
|
||||
bridgeGeohash: "u4pruy"
|
||||
)
|
||||
let encoded = try #require(packet.encode())
|
||||
let decoded = try #require(AnnounceV2Packet.decode(from: encoded))
|
||||
#expect(decoded == packet)
|
||||
}
|
||||
|
||||
@Test func roundTripsWithOnlyRequiredFields() throws {
|
||||
let packet = AnnounceV2Packet(epoch: 0, tagBlock: block)
|
||||
let encoded = try #require(packet.encode())
|
||||
let decoded = try #require(AnnounceV2Packet.decode(from: encoded))
|
||||
#expect(decoded == packet)
|
||||
#expect(decoded.capabilities == nil)
|
||||
#expect(decoded.bridgeGeohash == nil)
|
||||
}
|
||||
|
||||
@Test func epochIsBigEndianOnTheWire() throws {
|
||||
let encoded = try #require(AnnounceV2Packet(epoch: 0x0102_0304, tagBlock: block).encode())
|
||||
// TLV 0x01, length 4, then the epoch most-significant byte first.
|
||||
#expect(Array(encoded.prefix(6)) == [0x01, 0x04, 0x01, 0x02, 0x03, 0x04])
|
||||
}
|
||||
|
||||
/// The whole point of the format: none of the identifying v1 fields appear.
|
||||
@Test func encodingCarriesNoIdentity() throws {
|
||||
let noiseKey = Data(repeating: 0x11, count: 32)
|
||||
let signingKey = Data(repeating: 0x22, count: 32)
|
||||
let nickname = Data("alice".utf8)
|
||||
|
||||
let encoded = try #require(
|
||||
AnnounceV2Packet(
|
||||
epoch: 100,
|
||||
tagBlock: block,
|
||||
capabilities: [.bridge],
|
||||
bridgeGeohash: "u4pruy"
|
||||
).encode()
|
||||
)
|
||||
|
||||
#expect(!encoded.contains(noiseKey))
|
||||
#expect(!encoded.contains(signingKey))
|
||||
#expect(encoded.range(of: nickname) == nil)
|
||||
}
|
||||
|
||||
@Test func encodingIsSmallerThanAV1Announce() throws {
|
||||
let v2 = try #require(
|
||||
AnnounceV2Packet(epoch: 100, tagBlock: block, capabilities: [.bridge]).encode()
|
||||
)
|
||||
// v1 with a 10-byte nickname and a full neighbour list, before its
|
||||
// 64-byte signature: nickname 12 + noise 34 + signing 34 + neighbours 82
|
||||
// + capabilities 3.
|
||||
let v1PayloadEstimate = 12 + 34 + 34 + 82 + 3
|
||||
#expect(v2.count < v1PayloadEstimate)
|
||||
}
|
||||
|
||||
// MARK: - Rejection
|
||||
|
||||
@Test func encodeRejectsAWrongWidthTagBlock() {
|
||||
// A short block would disclose the favourite count, so it must never go
|
||||
// on the wire.
|
||||
#expect(AnnounceV2Packet(epoch: 1, tagBlock: Data(repeating: 0, count: 63)).encode() == nil)
|
||||
#expect(AnnounceV2Packet(epoch: 1, tagBlock: Data(repeating: 0, count: 65)).encode() == nil)
|
||||
#expect(AnnounceV2Packet(epoch: 1, tagBlock: Data()).encode() == nil)
|
||||
}
|
||||
|
||||
@Test func encodeRejectsAnOversizedGeohash() {
|
||||
#expect(AnnounceV2Packet(
|
||||
epoch: 1,
|
||||
tagBlock: block,
|
||||
bridgeGeohash: String(repeating: "u", count: 13)
|
||||
).encode() == nil)
|
||||
}
|
||||
|
||||
@Test func decodeRequiresEpochAndTagBlock() throws {
|
||||
// Capabilities alone is not a valid announce.
|
||||
var onlyCapabilities = Data([0x03, 0x01])
|
||||
onlyCapabilities.append(PeerCapabilities([.bridge]).encoded())
|
||||
#expect(AnnounceV2Packet.decode(from: onlyCapabilities) == nil)
|
||||
|
||||
// Epoch without a tag block is not either.
|
||||
let onlyEpoch = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64])
|
||||
#expect(AnnounceV2Packet.decode(from: onlyEpoch) == nil)
|
||||
}
|
||||
|
||||
@Test func decodeRejectsTruncatedAndMalformedInput() {
|
||||
#expect(AnnounceV2Packet.decode(from: Data()) == nil)
|
||||
// Declares 4 bytes, supplies 2.
|
||||
#expect(AnnounceV2Packet.decode(from: Data([0x01, 0x04, 0x00, 0x00])) == nil)
|
||||
// Dangling type byte with no length.
|
||||
#expect(AnnounceV2Packet.decode(from: Data([0x01])) == nil)
|
||||
// Wrong epoch width.
|
||||
#expect(AnnounceV2Packet.decode(from: Data([0x01, 0x02, 0x00, 0x64])) == nil)
|
||||
}
|
||||
|
||||
@Test func decodeRejectsAWrongWidthTagBlock() {
|
||||
var data = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64])
|
||||
data.append(0x02)
|
||||
data.append(UInt8(63))
|
||||
data.append(Data(repeating: 0xAB, count: 63))
|
||||
#expect(AnnounceV2Packet.decode(from: data) == nil)
|
||||
}
|
||||
|
||||
@Test func decodeRejectsNonCanonicalCapabilities() throws {
|
||||
// Same capability set, non-minimal encoding: it must not be accepted, or
|
||||
// one set could travel as several distinct byte strings.
|
||||
var data = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64])
|
||||
data.append(0x02)
|
||||
data.append(UInt8(AnnounceV2Packet.tagBlockLength))
|
||||
data.append(block)
|
||||
data.append(0x03)
|
||||
data.append(UInt8(3))
|
||||
data.append(Data([0x80, 0x00, 0x00])) // trailing zero bytes are non-minimal
|
||||
#expect(AnnounceV2Packet.decode(from: data) == nil)
|
||||
}
|
||||
|
||||
@Test func unknownTLVsAreSkippedForForwardCompatibility() throws {
|
||||
var data = try #require(AnnounceV2Packet(epoch: 100, tagBlock: block).encode())
|
||||
data.append(0x7F) // a type this build has never heard of
|
||||
data.append(UInt8(3))
|
||||
data.append(Data([0x01, 0x02, 0x03]))
|
||||
|
||||
let decoded = try #require(AnnounceV2Packet.decode(from: data))
|
||||
#expect(decoded.epoch == 100)
|
||||
#expect(decoded.tagBlock == block)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
//
|
||||
// DeliveryStatusNotSentYetTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// DeliveryStatus is a total state machine: every message carries a concrete
|
||||
// status from creation. Public messages start .notSentYet, private messages
|
||||
// keep their historical .sending default, and archives persisted while the
|
||||
// field was optional decode with the absent field mapped to .notSentYet.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import BitFoundation
|
||||
|
||||
struct DeliveryStatusNotSentYetTests {
|
||||
|
||||
private func makeMessage(isPrivate: Bool, deliveryStatus: DeliveryStatus? = nil) -> BitchatMessage {
|
||||
BitchatMessage(
|
||||
sender: "alice",
|
||||
content: "hello",
|
||||
timestamp: Date(timeIntervalSince1970: 1_000),
|
||||
isRelay: false,
|
||||
isPrivate: isPrivate,
|
||||
deliveryStatus: deliveryStatus
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func publicMessagesStartNotSentYetAndPrivateStartSending() {
|
||||
#expect(makeMessage(isPrivate: false).deliveryStatus == .notSentYet)
|
||||
#expect(makeMessage(isPrivate: true).deliveryStatus == .sending)
|
||||
// An explicit status always wins over the defaults.
|
||||
#expect(makeMessage(isPrivate: false, deliveryStatus: .sent).deliveryStatus == .sent)
|
||||
}
|
||||
|
||||
@Test
|
||||
func decodingLegacyArchiveWithoutStatusYieldsNotSentYet() throws {
|
||||
// Pre-existing archives omitted the key for public messages while the
|
||||
// field was optional; absent must map to .notSentYet, not fail.
|
||||
let encoded = try JSONEncoder().encode(makeMessage(isPrivate: false))
|
||||
var json = try #require(
|
||||
JSONSerialization.jsonObject(with: encoded) as? [String: Any]
|
||||
)
|
||||
json.removeValue(forKey: "deliveryStatus")
|
||||
let legacyData = try JSONSerialization.data(withJSONObject: json)
|
||||
|
||||
let decoded = try JSONDecoder().decode(BitchatMessage.self, from: legacyData)
|
||||
#expect(decoded.deliveryStatus == .notSentYet)
|
||||
}
|
||||
|
||||
@Test
|
||||
func decodingRoundTripPreservesConcreteStatus() throws {
|
||||
let message = makeMessage(isPrivate: true, deliveryStatus: .delivered(to: "bob", at: Date(timeIntervalSince1970: 2_000)))
|
||||
let decoded = try JSONDecoder().decode(BitchatMessage.self, from: JSONEncoder().encode(message))
|
||||
#expect(decoded.deliveryStatus == .delivered(to: "bob", at: Date(timeIntervalSince1970: 2_000)))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,408 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import CryptoKit
|
||||
@testable import BitFoundation
|
||||
|
||||
/// Executable test vectors for peer ID rotation.
|
||||
///
|
||||
/// These are the numbers the Android implementation must reproduce. Two rules
|
||||
/// for keeping them useful:
|
||||
///
|
||||
/// 1. **Reproduce them from `docs/PEER-ID-ROTATION.md`, not from this code.**
|
||||
/// Deriving the expected values by reading the other platform's
|
||||
/// implementation proves only that both share a bug.
|
||||
/// 2. **If a derivation changes, the hex here changes too, deliberately.** A
|
||||
/// vector that gets "fixed" to match new behavior has stopped being a vector.
|
||||
///
|
||||
/// The three `VECTOR:` values below were cross-checked against an independent
|
||||
/// HKDF/HMAC implementation written from the specification alone (Python
|
||||
/// `hmac`/`hashlib`, empty salt, extract-then-expand) and matched byte for byte.
|
||||
/// So the spec text is sufficient to reproduce them without reading this code —
|
||||
/// which is the property Android needs.
|
||||
struct PeerIDRotationTests {
|
||||
// A fixed, obviously-fake private key so the vectors are stable.
|
||||
private let staticPrivateA = Data((0..<32).map { UInt8($0 + 1) }) // 01..20
|
||||
private let staticPrivateB = Data((0..<32).map { UInt8(0xA0 &+ $0) }) // a0..bf
|
||||
|
||||
private func hex(_ data: Data) -> String {
|
||||
data.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
// MARK: - Epochs
|
||||
|
||||
@Test func epochIsWallClockDivision() {
|
||||
#expect(PeerIDRotation.rotationPeriod == 3600)
|
||||
#expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 0)) == 0)
|
||||
#expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 3599)) == 0)
|
||||
#expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 3600)) == 1)
|
||||
// 2026-07-26T00:00:00Z
|
||||
#expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 1_784_000_000)) == 495_555)
|
||||
}
|
||||
|
||||
@Test func candidateEpochsCoverTheBoundaryBothWays() {
|
||||
// Two devices seconds apart across a boundary must still recognise each
|
||||
// other, so the window spans the neighbouring epochs.
|
||||
let date = Date(timeIntervalSince1970: 3600 * 100)
|
||||
#expect(PeerIDRotation.candidateEpochs(around: date) == [99, 100, 101])
|
||||
}
|
||||
|
||||
@Test func candidateEpochsDoNotUnderflowAtTheOrigin() {
|
||||
// UInt32 underflow here would produce 4294967295 and break matching.
|
||||
#expect(PeerIDRotation.candidateEpochs(around: Date(timeIntervalSince1970: 0)) == [0, 1])
|
||||
}
|
||||
|
||||
// MARK: - Rotating peer ID
|
||||
|
||||
@Test func rotationSecretIsStableForAKey() {
|
||||
let first = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA)
|
||||
let second = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA)
|
||||
#expect(first == second)
|
||||
#expect(first.count == 32)
|
||||
// VECTOR: HKDF-SHA256(ikm: 01..20, salt: empty, info: "bitchat-peer-rotation-v1", 32)
|
||||
#expect(hex(first) == "fb82dfec0c0a2a4677beca44e2f72c80e7c5de773dd5fce6ee47af83d3c25f09")
|
||||
}
|
||||
|
||||
@Test func peerIDIsEightBytesAndEpochDependent() {
|
||||
let secret = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA)
|
||||
let a = PeerIDRotation.peerID(rotationSecret: secret, epoch: 100)
|
||||
let b = PeerIDRotation.peerID(rotationSecret: secret, epoch: 101)
|
||||
|
||||
#expect(a.count == PeerIDRotation.idLength)
|
||||
#expect(b.count == PeerIDRotation.idLength)
|
||||
// VECTOR: HMAC-SHA256(rotationSecret, "bitchat-peer-id-v2" || uint32be(100))[0..8]
|
||||
#expect(hex(a) == "f7c08c528506a374")
|
||||
// The whole point: consecutive epochs are unrelated to an observer.
|
||||
#expect(a != b)
|
||||
// Deterministic within an epoch, so a restart keeps the same ID.
|
||||
#expect(a == PeerIDRotation.peerID(rotationSecret: secret, epoch: 100))
|
||||
}
|
||||
|
||||
@Test func peerIDDiffersBetweenDevices() {
|
||||
let secretA = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA)
|
||||
let secretB = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateB)
|
||||
#expect(PeerIDRotation.peerID(rotationSecret: secretA, epoch: 100)
|
||||
!= PeerIDRotation.peerID(rotationSecret: secretB, epoch: 100))
|
||||
}
|
||||
|
||||
@Test func currentPeerIDMatchesTheExplicitEpochForm() {
|
||||
let date = Date(timeIntervalSince1970: 3600 * 100 + 17)
|
||||
let viaConvenience = PeerIDRotation.currentPeerID(
|
||||
noiseStaticPrivateKey: staticPrivateA,
|
||||
at: date
|
||||
)
|
||||
let viaParts = PeerIDRotation.peerID(
|
||||
rotationSecret: PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA),
|
||||
epoch: 100
|
||||
)
|
||||
#expect(viaConvenience == viaParts)
|
||||
}
|
||||
|
||||
// MARK: - Recognition tags
|
||||
|
||||
private var pubA: Data { Data(repeating: 0x0A, count: 32) }
|
||||
private var pubB: Data { Data(repeating: 0x0B, count: 32) }
|
||||
private var idA: Data { Data(repeating: 0xA1, count: 8) }
|
||||
|
||||
/// The property that makes handshake-free recognition possible: both sides
|
||||
/// reach the same tag from opposite halves of the key pair.
|
||||
@Test func bothSidesDeriveTheSameRecognitionTag() throws {
|
||||
let privA = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: staticPrivateA)
|
||||
let privB = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: staticPrivateB)
|
||||
|
||||
let sharedFromA = try privA.sharedSecretFromKeyAgreement(with: privB.publicKey)
|
||||
let sharedFromB = try privB.sharedSecretFromKeyAgreement(with: privA.publicKey)
|
||||
let rawA = sharedFromA.withUnsafeBytes { Data($0) }
|
||||
let rawB = sharedFromB.withUnsafeBytes { Data($0) }
|
||||
#expect(rawA == rawB)
|
||||
|
||||
let keyA = PeerIDRotation.recognitionKey(sharedSecret: rawA)
|
||||
let keyB = PeerIDRotation.recognitionKey(sharedSecret: rawB)
|
||||
#expect(keyA == keyB)
|
||||
|
||||
// A emits its A->B tag; B computes the same value to look for it.
|
||||
let emitted = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: keyA, epoch: 100,
|
||||
senderStaticPublicKey: privA.publicKey.rawRepresentation,
|
||||
recipientStaticPublicKey: privB.publicKey.rawRepresentation,
|
||||
peerID: idA
|
||||
)
|
||||
let expected = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: keyB, epoch: 100,
|
||||
senderStaticPublicKey: privA.publicKey.rawRepresentation,
|
||||
recipientStaticPublicKey: privB.publicKey.rawRepresentation,
|
||||
peerID: idA
|
||||
)
|
||||
#expect(emitted == expected)
|
||||
#expect(emitted.count == PeerIDRotation.idLength)
|
||||
}
|
||||
|
||||
/// Regression, Codex #1487 P1: a symmetric tag means A and B broadcast the
|
||||
/// identical 8 bytes, so an observer who sees one value in two announces
|
||||
/// learns those two are mutual favourites and can link their rotating IDs.
|
||||
/// Tags must therefore differ by direction.
|
||||
@Test func recognitionTagsAreDirectional() {
|
||||
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32))
|
||||
let aToB = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: key, epoch: 100,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
||||
)
|
||||
let bToA = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: key, epoch: 100,
|
||||
senderStaticPublicKey: pubB, recipientStaticPublicKey: pubA, peerID: idA
|
||||
)
|
||||
#expect(aToB != bToA)
|
||||
}
|
||||
|
||||
/// Regression, Codex #1487 P1: without the peer ID in the MAC, a tag lifted
|
||||
/// from someone's announce could be replayed under an attacker-chosen ID and
|
||||
/// the recipient would accept that ID as the favourite.
|
||||
@Test func recognitionTagIsBoundToTheAnnouncedPeerID() {
|
||||
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32))
|
||||
let real = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: key, epoch: 100,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
||||
)
|
||||
let underAttackerID = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: key, epoch: 100,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
|
||||
peerID: Data(repeating: 0xFF, count: 8)
|
||||
)
|
||||
#expect(real != underAttackerID)
|
||||
|
||||
// And the lifted tag must not verify against the attacker's ID.
|
||||
let block = PeerIDRotation.tagBlock(tags: [real])
|
||||
#expect(!PeerIDRotation.blockMatches(
|
||||
block, recognitionKey: key,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
|
||||
peerID: Data(repeating: 0xFF, count: 8),
|
||||
at: Date(timeIntervalSince1970: 3600 * 100)
|
||||
))
|
||||
}
|
||||
|
||||
@Test func recognitionTagRotatesWithTheEpoch() {
|
||||
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32))
|
||||
let now = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: key, epoch: 100,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
||||
)
|
||||
let next = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: key, epoch: 101,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
||||
)
|
||||
#expect(now != next)
|
||||
// VECTOR: HMAC-SHA256(HKDF(ikm: 0x42*32, info: "bitchat-recognition-v1"),
|
||||
// uint32be(100) || 0x0A*32 || 0x0B*32 || 0xA1*8)[0..8]
|
||||
#expect(hex(now) == "4568f61d61d6cbfb")
|
||||
}
|
||||
|
||||
@Test func aThirdPartyCannotDeriveAPairsTag() {
|
||||
// An observer holding a *different* shared secret gets a different tag,
|
||||
// which is what stops it from tracking the pair.
|
||||
let pair = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x01, count: 32))
|
||||
let other = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x02, count: 32))
|
||||
#expect(PeerIDRotation.recognitionTag(
|
||||
recognitionKey: pair, epoch: 7,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
||||
) != PeerIDRotation.recognitionTag(
|
||||
recognitionKey: other, epoch: 7,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - Tag block
|
||||
|
||||
@Test func tagBlockIsAlwaysFullWidth() {
|
||||
let expected = PeerIDRotation.tagSlots * PeerIDRotation.idLength
|
||||
for count in 0...PeerIDRotation.tagSlots {
|
||||
let tags = (0..<count).map { Data(repeating: UInt8($0 + 1), count: 8) }
|
||||
#expect(PeerIDRotation.tagBlock(tags: tags).count == expected)
|
||||
}
|
||||
}
|
||||
|
||||
/// A device with one favourite and a device with six must be
|
||||
/// indistinguishable from the block, or the block leaks social-graph size.
|
||||
@Test func tagBlockHidesHowManyFavouritesThereAre() {
|
||||
let one = PeerIDRotation.tagBlock(tags: [Data(repeating: 0xAA, count: 8)])
|
||||
let six = PeerIDRotation.tagBlock(
|
||||
tags: (1...6).map { Data(repeating: UInt8($0), count: 8) }
|
||||
)
|
||||
#expect(one.count == six.count)
|
||||
}
|
||||
|
||||
@Test func tagBlockDropsOverflowRatherThanGrowing() {
|
||||
let tags = (1...(PeerIDRotation.tagSlots + 5)).map { Data(repeating: UInt8($0), count: 8) }
|
||||
#expect(PeerIDRotation.tagBlock(tags: tags).count == PeerIDRotation.tagSlots * 8)
|
||||
}
|
||||
|
||||
@Test func padOnlyBlockUsesFreshRandomnessEachTime() {
|
||||
// Repeated identical padding would make an empty block recognisable.
|
||||
let first = PeerIDRotation.tagBlock(tags: [])
|
||||
let second = PeerIDRotation.tagBlock(tags: [])
|
||||
#expect(first != second)
|
||||
}
|
||||
|
||||
@Test func tagsRoundTripThroughTheBlock() throws {
|
||||
let real = Data(repeating: 0xC3, count: 8)
|
||||
let block = PeerIDRotation.tagBlock(
|
||||
tags: [real],
|
||||
randomBytes: { Data(repeating: 0x00, count: $0) }
|
||||
)
|
||||
let slots = try #require(PeerIDRotation.tags(fromBlock: block))
|
||||
#expect(slots.count == PeerIDRotation.tagSlots)
|
||||
#expect(slots.contains(real))
|
||||
}
|
||||
|
||||
@Test func malformedBlockIsRejectedRatherThanPartiallyRead() {
|
||||
#expect(PeerIDRotation.tags(fromBlock: Data()) == nil)
|
||||
#expect(PeerIDRotation.tags(fromBlock: Data(repeating: 0, count: 7)) == nil)
|
||||
#expect(PeerIDRotation.tags(fromBlock: Data(repeating: 0, count: 65)) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Matching
|
||||
|
||||
private func matchFixture() -> (key: Data, tag: Data, date: Date) {
|
||||
let date = Date(timeIntervalSince1970: 3600 * 100)
|
||||
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x77, count: 32))
|
||||
let tag = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: key,
|
||||
epoch: PeerIDRotation.epoch(at: date),
|
||||
senderStaticPublicKey: pubA,
|
||||
recipientStaticPublicKey: pubB,
|
||||
peerID: idA
|
||||
)
|
||||
return (key, tag, date)
|
||||
}
|
||||
|
||||
@Test func blockMatchesRecogniseAPeerAnywhereInTheBlock() {
|
||||
let (key, tag, date) = matchFixture()
|
||||
// Slot order must not matter, so assert across many shuffles.
|
||||
for _ in 0..<20 {
|
||||
let block = PeerIDRotation.tagBlock(tags: [tag])
|
||||
#expect(PeerIDRotation.blockMatches(
|
||||
block, recognitionKey: key,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
|
||||
peerID: idA, at: date
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Testing the wrong direction must fail, or the directional fix would be
|
||||
/// cosmetic.
|
||||
@Test func blockDoesNotMatchTheOppositeDirection() {
|
||||
let (key, tag, date) = matchFixture()
|
||||
let block = PeerIDRotation.tagBlock(tags: [tag])
|
||||
#expect(!PeerIDRotation.blockMatches(
|
||||
block, recognitionKey: key,
|
||||
senderStaticPublicKey: pubB, recipientStaticPublicKey: pubA,
|
||||
peerID: idA, at: date
|
||||
))
|
||||
}
|
||||
|
||||
@Test func blockMatchesToleratesTheEpochBoundary() {
|
||||
let date = Date(timeIntervalSince1970: 3600 * 100)
|
||||
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x11, count: 32))
|
||||
|
||||
func tag(epoch: UInt32) -> Data {
|
||||
PeerIDRotation.recognitionTag(
|
||||
recognitionKey: key, epoch: epoch,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
||||
)
|
||||
}
|
||||
func matches(_ candidate: Data) -> Bool {
|
||||
PeerIDRotation.blockMatches(
|
||||
PeerIDRotation.tagBlock(tags: [candidate]), recognitionKey: key,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
|
||||
peerID: idA, at: date
|
||||
)
|
||||
}
|
||||
|
||||
// A peer whose clock has already ticked over still matches.
|
||||
#expect(matches(tag(epoch: 101)))
|
||||
// Two epochs out is outside the window and must not.
|
||||
#expect(!matches(tag(epoch: 98)))
|
||||
}
|
||||
|
||||
@Test func randomBlockDoesNotMatch() {
|
||||
let (key, _, date) = matchFixture()
|
||||
#expect(!PeerIDRotation.blockMatches(
|
||||
PeerIDRotation.tagBlock(tags: []), recognitionKey: key,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
|
||||
peerID: idA, at: date
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - Identity binding
|
||||
|
||||
@Test func bindingMessageIsFixedWidthAndContextSeparated() {
|
||||
let message = PeerIDRotation.bindingMessage(
|
||||
epoch: 100,
|
||||
peerID: Data(repeating: 0xAB, count: 8),
|
||||
noiseStaticPublicKey: Data(repeating: 0xCD, count: 32)
|
||||
)
|
||||
let context = Data("bitchat-peerid-binding-v1".utf8)
|
||||
#expect(message.count == context.count + 4 + 8 + 32)
|
||||
#expect(message.starts(with: context))
|
||||
// Must not collide with the production-dead announce-signature helpers,
|
||||
// which use "bitchat-announce-v1".
|
||||
#expect(!message.starts(with: Data("bitchat-announce-v1".utf8)))
|
||||
}
|
||||
|
||||
@Test func bindingMessagePadsShortInputsRatherThanShifting() {
|
||||
// Fixed-width fields mean a short ID cannot shift the key into the ID's
|
||||
// position and produce a message that verifies for the wrong pairing.
|
||||
let short = PeerIDRotation.bindingMessage(
|
||||
epoch: 1,
|
||||
peerID: Data([0x01]),
|
||||
noiseStaticPublicKey: Data([0x02])
|
||||
)
|
||||
let padded = PeerIDRotation.bindingMessage(
|
||||
epoch: 1,
|
||||
peerID: Data([0x01]) + Data(repeating: 0, count: 7),
|
||||
noiseStaticPublicKey: Data([0x02]) + Data(repeating: 0, count: 31)
|
||||
)
|
||||
#expect(short == padded)
|
||||
}
|
||||
|
||||
@Test func bindingMessageChangesWithEveryField() {
|
||||
let base = PeerIDRotation.bindingMessage(
|
||||
epoch: 1,
|
||||
peerID: Data(repeating: 0x01, count: 8),
|
||||
noiseStaticPublicKey: Data(repeating: 0x02, count: 32)
|
||||
)
|
||||
#expect(base != PeerIDRotation.bindingMessage(
|
||||
epoch: 2,
|
||||
peerID: Data(repeating: 0x01, count: 8),
|
||||
noiseStaticPublicKey: Data(repeating: 0x02, count: 32)
|
||||
))
|
||||
#expect(base != PeerIDRotation.bindingMessage(
|
||||
epoch: 1,
|
||||
peerID: Data(repeating: 0x03, count: 8),
|
||||
noiseStaticPublicKey: Data(repeating: 0x02, count: 32)
|
||||
))
|
||||
#expect(base != PeerIDRotation.bindingMessage(
|
||||
epoch: 1,
|
||||
peerID: Data(repeating: 0x01, count: 8),
|
||||
noiseStaticPublicKey: Data(repeating: 0x04, count: 32)
|
||||
))
|
||||
}
|
||||
|
||||
@Test func bindingMessageVerifiesUnderTheIdentityKey() throws {
|
||||
let signing = Curve25519.Signing.PrivateKey()
|
||||
let message = PeerIDRotation.bindingMessage(
|
||||
epoch: 100,
|
||||
peerID: Data(repeating: 0xAB, count: 8),
|
||||
noiseStaticPublicKey: Data(repeating: 0xCD, count: 32)
|
||||
)
|
||||
let signature = try signing.signature(for: message)
|
||||
#expect(signing.publicKey.isValidSignature(signature, for: message))
|
||||
|
||||
// A different epoch must not verify: replaying a binding into a later
|
||||
// epoch is exactly what this prevents.
|
||||
let other = PeerIDRotation.bindingMessage(
|
||||
epoch: 101,
|
||||
peerID: Data(repeating: 0xAB, count: 8),
|
||||
noiseStaticPublicKey: Data(repeating: 0xCD, count: 32)
|
||||
)
|
||||
#expect(!signing.publicKey.isValidSignature(signature, for: other))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user