Compare commits

..
Author SHA1 Message Date
jackandClaude Fable 5 9b8256bf72 Wi-Fi bulk: raise image-prep budget so real photos clear the 64 KiB AWDL threshold
Image prep downscaled every photo to 448 px and force-compressed to a
45 KB byte budget, so a typical camera photo landed ~40 KB — below
TransportConfig.wifiBulkMinPayloadBytes (64 KiB). WifiBulkPolicy.shouldOffer
requires payloadBytes > 64 KiB, so the Wi-Fi bulk (AWDL) data plane never
triggered in production even when it worked on-device: real photos always
fell back to BLE fragmentation.

Raise the prep budget so genuinely detailed photos land well above 64 KiB
while staying under the 512 KiB FileTransferLimits.maxImageBytes hard cap:
  - defaultMaxDimension 448 -> 1024 px
  - compressionQuality 0.82 -> 0.85
  - targetImageBytes 45 KB -> 200 KB (a ceiling, not a target to hit)

Measured on a representative photo-like image: ~40 KB before -> ~190 KB
after, crossing the 64 KiB offer threshold while remaining ~2.6x under the
hard cap.

Because the raised dimension makes near-incompressible inputs (e.g. full-
frame noise) able to exceed maxImageBytes at the quality floor, prep now
downscales-and-retries until the payload fits the hard cap, so a send can
never fail with imageTooLarge on pathological input (it couldn't at 448 px).
Also de-dupes the previously copy-pasted per-platform encodeJPEG into one
shared helper.

Residual limitation: prep is not recipient-capability-aware. Images are
prepared at this fidelity regardless of whether the recipient supports
Wi-Fi bulk, so BLE-only peers and public broadcasts now carry ~190 KB
images too (still within the existing 512 KiB image cap). Making prep
choose fidelity per-recipient would need capability plumbing into the prep
pipeline and doesn't help public broadcasts, so it's deferred.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:34:20 +02:00
142 changed files with 669 additions and 35877 deletions
-25
View File
@@ -18,9 +18,6 @@ final class AppChromeModel: ObservableObject {
private let chatViewModel: ChatViewModel
private var cancellables = Set<AnyCancellable>()
/// Bulletin-board coordinator, created on first use of the board sheet.
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
self.chatViewModel = chatViewModel
self.nickname = chatViewModel.nickname
@@ -62,28 +59,6 @@ final class AppChromeModel: ObservableObject {
isAppInfoPresented = true
}
/// Builds the mesh topology map model from the transport's gossiped
/// graph plus the live nickname table. Unknown nodes (heard about via a
/// neighbor claim but never announced to us) fall back to a short ID.
func meshTopologyDisplayModel() -> MeshTopologyDisplayModel {
let mesh = chatViewModel.meshService
guard let snapshot = mesh.currentMeshTopology() else { return .empty }
let nicknames = mesh.getPeerNicknames()
let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in
let isSelf = peerID == snapshot.localPeerID
let label: String
if isSelf {
label = chatViewModel.nickname
} else {
label = nicknames[peerID] ?? "\(peerID.id.prefix(8))"
}
return MeshTopologyDisplayModel.Node(id: peerID.id, label: label, isSelf: isSelf)
}
let edges = snapshot.edges.map { ($0.a.id, $0.b.id) }
return MeshTopologyDisplayModel(nodes: nodes, edges: edges)
}
func triggerScreenshotPrivacyWarning() {
showScreenshotPrivacyWarning = true
}
+2 -2
View File
@@ -349,12 +349,12 @@ private extension AppRuntime {
TorManager.shared.isAutoStartAllowed() {
chatViewModel.torStatusAnnounced = true
chatViewModel.addGeohashOnlySystemMessage(
String(localized: "system.tor.starting", defaultValue: "starting tor...", comment: "System message when Tor is starting")
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
)
} else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced {
chatViewModel.torStatusAnnounced = true
chatViewModel.addGeohashOnlySystemMessage(
String(localized: "system.tor.dev_bypass", defaultValue: "development build: Tor bypass enabled.", comment: "System message when Tor bypass is enabled in development")
String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
)
}
}
+1 -3
View File
@@ -193,9 +193,7 @@ final class ConversationUIModel: ObservableObject {
private func refreshComputedState() {
if let selectedPeerID = privateConversationModel.selectedPeerID {
// Media transfer is not wired for groups in v1; keep it off so the
// composer can't strand a media placeholder that never sends.
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup)
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
return
}
+1 -15
View File
@@ -12,26 +12,20 @@ final class LocationChannelsModel: ObservableObject {
@Published private(set) var bookmarkNames: [String: String]
@Published private(set) var locationNames: [GeohashChannelLevel: String]
@Published private(set) var userTorEnabled: Bool
@Published private(set) var gatewayEnabled: Bool
private let manager: LocationChannelManager
private let network: NetworkActivationService
private let gateway: GatewayService
private var cancellables = Set<AnyCancellable>()
init(
manager: LocationChannelManager? = nil,
network: NetworkActivationService? = nil,
gateway: GatewayService? = nil
network: NetworkActivationService? = nil
) {
let manager = manager ?? .shared
let network = network ?? .shared
let gateway = gateway ?? .shared
self.manager = manager
self.network = network
self.gateway = gateway
self.gatewayEnabled = gateway.isEnabled
self.permissionState = manager.permissionState
self.availableChannels = manager.availableChannels
self.selectedChannel = manager.selectedChannel
@@ -102,10 +96,6 @@ final class LocationChannelsModel: ObservableObject {
network.setUserTorEnabled(enabled)
}
func setGatewayEnabled(_ enabled: Bool) {
gateway.setEnabled(enabled)
}
func refreshMeshChannelsIfNeeded() {
guard case .mesh = selectedChannel,
permissionState == .authorized,
@@ -170,10 +160,6 @@ final class LocationChannelsModel: ObservableObject {
network.$userTorEnabled
.receive(on: DispatchQueue.main)
.assign(to: &$userTorEnabled)
gateway.$isEnabled
.receive(on: DispatchQueue.main)
.assign(to: &$gatewayEnabled)
}
private func level(forLength length: Int) -> GeohashChannelLevel {
+8 -47
View File
@@ -14,9 +14,6 @@ struct MeshPeerRow: Identifiable, Equatable {
let isMutualFavorite: Bool
let encryptionStatus: EncryptionStatus
let showsVerifiedBadgeWhenOffline: Bool
/// Vouched-for by someone I verified, without an explicit verification of
/// mine rendered as the unfilled seal (verified gets the filled one).
let showsVouchedBadge: Bool
var id: String { peerID.id }
}
@@ -29,22 +26,11 @@ struct GeohashPersonRow: Identifiable, Equatable {
let isBlocked: Bool
}
struct GroupChatRow: Identifiable, Equatable {
let peerID: PeerID
let name: String
let memberCount: Int
let isCreator: Bool
let hasUnread: Bool
var id: String { peerID.id }
}
@MainActor
final class PeerListModel: ObservableObject {
@Published private(set) var allPeers: [BitchatPeer] = []
@Published private(set) var meshRows: [MeshPeerRow] = []
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
@Published private(set) var groupRows: [GroupChatRow] = []
@Published private(set) var reachableMeshPeerCount = 0
@Published private(set) var connectedMeshPeerCount = 0
@Published private(set) var visibleGeohashPeerCount = 0
@@ -143,13 +129,6 @@ final class PeerListModel: ObservableObject {
}
.store(in: &cancellables)
chatViewModel.groupStore.$groups
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
peerIdentityStore.$encryptionStatuses
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
@@ -204,12 +183,13 @@ final class PeerListModel: ObservableObject {
let myPeerID = chatViewModel.meshService.myPeerID
let meshRows = allPeers.map { peer in
let isMe = peer.peerID == myPeerID
let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID)
let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false
let verifiedBadge = !peer.isConnected && isVerifiedFingerprint
// Vouched is subordinate to verified: never show both seals.
let vouchedBadge = !isVerifiedFingerprint
&& (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false)
let verifiedBadge: Bool
if !isMe && !peer.isConnected,
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
} else {
verifiedBadge = false
}
return MeshPeerRow(
peerID: peer.peerID,
@@ -222,8 +202,7 @@ final class PeerListModel: ObservableObject {
isReachable: peer.isReachable,
isMutualFavorite: peer.isMutualFavorite,
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
showsVerifiedBadgeWhenOffline: verifiedBadge,
showsVouchedBadge: vouchedBadge
showsVerifiedBadgeWhenOffline: verifiedBadge
)
}
@@ -238,40 +217,22 @@ final class PeerListModel: ObservableObject {
}
let geohashPeople = buildGeohashPeople()
let groupRows = buildGroupRows()
self.meshRows = meshRows
reachableMeshPeerCount = meshCounts.reachable
connectedMeshPeerCount = meshCounts.connected
self.geohashPeople = geohashPeople
visibleGeohashPeerCount = geohashPeople.count
self.groupRows = groupRows
renderID = (
meshRows.map {
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
} +
geohashPeople.map {
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
} +
groupRows.map {
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
}
).joined(separator: "|")
}
private func buildGroupRows() -> [GroupChatRow] {
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
return chatViewModel.groupStore.groups.map { group in
GroupChatRow(
peerID: group.peerID,
name: group.name,
memberCount: group.members.count,
isCreator: group.creatorFingerprint == myFingerprint,
hasUnread: chatViewModel.hasUnreadMessages(for: group.peerID)
)
}
}
private func buildGeohashPeople() -> [GeohashPersonRow] {
let myHex = currentGeohashIdentityHex()
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
+2 -35
View File
@@ -108,13 +108,7 @@ struct PrivateConversationHeaderState: Equatable {
let encryptionStatus: EncryptionStatus?
var supportsFavoriteToggle: Bool {
!conversationPeerID.isGeoDM && !conversationPeerID.isGroup
}
/// Group chats have no single peer identity behind the header: no
/// fingerprint screen, no per-peer encryption badge.
var isGroupConversation: Bool {
conversationPeerID.isGroup
!conversationPeerID.isGeoDM
}
}
@@ -212,13 +206,6 @@ final class PrivateConversationModel: ObservableObject {
}
.store(in: &cancellables)
chatViewModel.groupStore.$groups
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
@@ -242,26 +229,6 @@ final class PrivateConversationModel: ObservableObject {
}
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
// Group chats: the "peer" is the whole crew. Name + member count in
// the header; availability reads as mesh since group traffic floods
// the local mesh, and the per-peer encryption badge does not apply.
if conversationPeerID.isGroup {
let displayName: String
if let group = chatViewModel.groupStore.group(for: conversationPeerID) {
displayName = "#\(group.name) (\(group.members.count))"
} else {
displayName = String(localized: "common.unknown", defaultValue: "unknown", comment: "Fallback label for unknown peer")
}
return PrivateConversationHeaderState(
conversationPeerID: conversationPeerID,
headerPeerID: conversationPeerID,
displayName: displayName,
availability: .meshReachable,
isFavorite: false,
encryptionStatus: nil
)
}
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
@@ -328,7 +295,7 @@ final class PrivateConversationModel: ObservableObject {
}
}
return String(localized: "common.unknown", defaultValue: "unknown", comment: "Fallback label for unknown peer")
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
}
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
+2 -41
View File
@@ -9,14 +9,6 @@ struct FingerprintPresentationState: Equatable {
let theirFingerprint: String?
let myFingerprint: String
let isVerified: Bool
/// Number of currently-valid vouches from peers the user verified
/// (0 when the peer is explicitly verified the stronger badge wins).
let voucherCount: Int
/// Display names of the (verified) vouchers, where known.
let voucherNames: [String]
/// Vouched for by 1 peer the user verified (and not explicitly verified).
var isVouched: Bool { voucherCount > 0 }
var canToggleVerification: Bool {
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
@@ -90,24 +82,6 @@ final class VerificationModel: ObservableObject {
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
// Vouch state is recomputed on read: only vouchers still in the
// verified set count, so removing a verification silently retires the
// vouches that peer gave.
let vouchers: [VouchRecord]
if !isVerified, let theirFingerprint {
vouchers = chatViewModel.identityManager.validVouchers(for: theirFingerprint)
} else {
vouchers = []
}
let voucherNames = vouchers.compactMap { record -> String? in
guard let social = chatViewModel.identityManager.getSocialIdentity(for: record.voucherFingerprint) else {
return nil
}
if let petname = social.localPetname, !petname.isEmpty { return petname }
return social.claimedNickname.isEmpty ? nil : social.claimedNickname
}
return FingerprintPresentationState(
statusPeerID: statusPeerID,
@@ -115,9 +89,7 @@ final class VerificationModel: ObservableObject {
encryptionStatus: encryptionStatus,
theirFingerprint: theirFingerprint,
myFingerprint: chatViewModel.getMyFingerprint(),
isVerified: isVerified,
voucherCount: vouchers.count,
voucherNames: voucherNames
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
)
}
@@ -150,17 +122,6 @@ final class VerificationModel: ObservableObject {
self?.objectWillChange.send()
}
.store(in: &cancellables)
// Vouch state changes (ChatVouchCoordinator.notifyPeerTrustChanged)
// are signalled via this notification rather than a published
// property, so an open fingerprint sheet refreshes its vouched badge
// live when a vouch batch is accepted.
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
@@ -186,6 +147,6 @@ final class VerificationModel: ObservableObject {
}
}
return String(localized: "common.unknown", defaultValue: "unknown", comment: "Label for an unknown peer")
return String(localized: "common.unknown", comment: "Label for an unknown peer")
}
}
-8
View File
@@ -8,9 +8,6 @@
import SwiftUI
import UserNotifications
#if DEBUG
import BitLogger
#endif
@main
struct BitchatApp: App {
@@ -46,11 +43,6 @@ struct BitchatApp: App {
.onAppear {
appDelegate.runtime = runtime
runtime.start()
#if DEBUG
// Arm the opt-in UDP log sink from persisted config (no-op
// until a collector host is set in the App Info sheet).
LogNetworkSink.shared.reloadConfiguration()
#endif
}
.onOpenURL { url in
runtime.handleOpenURL(url)
+106 -82
View File
@@ -1,3 +1,4 @@
import BitFoundation
import Foundation
import ImageIO
import UniformTypeIdentifiers
@@ -13,11 +14,28 @@ enum ImageUtilsError: Error {
}
enum ImageUtils {
private static let compressionQuality: CGFloat = 0.82
private static let targetImageBytes: Int = 45_000
private static let compressionQuality: CGFloat = 0.85
// Upper bound for the compressed JPEG. This is only a ceiling: the encoder
// keeps whatever a photo naturally weighs at `defaultMaxDimension` and
// `compressionQuality`, and only steps quality down when a payload would
// exceed this budget. It stays well under `FileTransferLimits.maxImageBytes`
// (512 KiB) so the BLE path never overruns its cap.
//
// Wi-Fi bulk relevance: the old 45 KB / 448 px budget crushed every photo
// to ~40 KB below `TransportConfig.wifiBulkMinPayloadBytes` (64 KiB) so
// `WifiBulkPolicy.shouldOffer` never fired and the AWDL data plane was dead
// in production. A genuinely detailed photo at `defaultMaxDimension` now
// weighs well over 64 KiB, so it becomes Wi-Fi-bulk eligible to a capable
// direct peer while still riding BLE fragmentation for everyone else.
private static let targetImageBytes: Int = 200_000
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
// Longest-side ceiling for shared photos. 448 px was thumbnail-tier and
// (together with the tiny byte budget) forced every photo below the Wi-Fi
// bulk threshold. 1024 px keeps a shared photo legible and lets detailed
// images clear 64 KiB, without approaching the 512 KiB hard cap.
static let defaultMaxDimension: CGFloat = 1024
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
static func processImage(at url: URL, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
try validateImageSource(at: url)
let data = try Data(contentsOf: url)
@@ -47,34 +65,33 @@ enum ImageUtils {
}
#if os(iOS)
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
static func processImage(_ image: UIImage, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
return try autoreleasepool {
// Scale the image first
let scaled = scaledImage(image, maxDimension: maxDimension)
// Get CGImage from UIImage - this is the key to stripping metadata
guard let cgImage = scaled.cgImage else {
throw ImageUtilsError.encodingFailed
}
// Use CGImageDestination to encode without metadata (same as macOS)
var quality = compressionQuality
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
throw ImageUtilsError.encodingFailed
}
// Compress to target size
while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1
autoreleasepool {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
var dimension = maxDimension
var jpegData: Data?
// Downscale-and-compress until the payload fits the hard image cap.
// A normal photo converges on the first pass; this loop only kicks
// in for near-incompressible inputs (e.g. full-frame noise) that
// would otherwise overrun `maxImageBytes` at the raised dimension.
while true {
let scaled = scaledImage(image, maxDimension: dimension)
// Get CGImage from UIImage - this is the key to stripping metadata
guard let cgImage = scaled.cgImage else {
throw ImageUtilsError.encodingFailed
}
guard let data = compressToBudget(cgImage) else {
throw ImageUtilsError.encodingFailed
}
jpegData = data
if data.count <= FileTransferLimits.maxImageBytes || dimension <= minRetryDimension {
break
}
dimension = (dimension * dimensionRetryFactor).rounded(.down)
}
guard let finalData = jpegData else { throw ImageUtilsError.encodingFailed }
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
try jpegData.write(to: outputURL, options: .atomic)
try finalData.write(to: outputURL, options: .atomic)
return outputURL
}
}
@@ -93,66 +110,49 @@ enum ImageUtils {
UIGraphicsEndImageContext()
return rendered ?? image
}
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
guard let data = CFDataCreateMutable(nil, 0) else {
return nil
}
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
return nil
}
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
// By only specifying compression quality and no metadata keys,
// we ensure a clean JPEG with no privacy-leaking information
let options: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: quality
]
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
return nil
}
return data as Data
}
#else
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
static func processImage(_ image: NSImage, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
return try autoreleasepool {
let scaled = scaledImage(image, maxDimension: maxDimension)
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
throw ImageUtilsError.encodingFailed
}
let width = inputCG.width
let height = inputCG.height
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
throw ImageUtilsError.encodingFailed
}
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
guard let cgImage = context.makeImage() else {
throw ImageUtilsError.encodingFailed
}
var quality = compressionQuality
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
throw ImageUtilsError.encodingFailed
}
while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1
autoreleasepool {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
var dimension = maxDimension
var jpegData: Data?
// See the iOS path: normal photos converge immediately; the loop
// only shrinks further for near-incompressible inputs so the
// output never overruns `maxImageBytes`.
while true {
let scaled = scaledImage(image, maxDimension: dimension)
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
throw ImageUtilsError.encodingFailed
}
let width = inputCG.width
let height = inputCG.height
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
throw ImageUtilsError.encodingFailed
}
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
guard let cgImage = context.makeImage() else {
throw ImageUtilsError.encodingFailed
}
guard let data = compressToBudget(cgImage) else {
throw ImageUtilsError.encodingFailed
}
jpegData = data
if data.count <= FileTransferLimits.maxImageBytes || dimension <= minRetryDimension {
break
}
dimension = (dimension * dimensionRetryFactor).rounded(.down)
}
guard let finalData = jpegData else { throw ImageUtilsError.encodingFailed }
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
try jpegData.write(to: outputURL, options: .atomic)
try finalData.write(to: outputURL, options: .atomic)
return outputURL
}
}
@@ -172,6 +172,31 @@ enum ImageUtils {
scaledImage.unlockFocus()
return scaledImage
}
#endif
// When even the quality floor can't get an image under the byte budget,
// shrink the longest side by this factor and re-encode. Bounded below so
// the retry loop always terminates.
private static let dimensionRetryFactor: CGFloat = 0.75
private static let minRetryDimension: CGFloat = 256
/// Encodes `cgImage` to JPEG, stepping quality down toward
/// `targetImageBytes`. Shared by both platforms.
private static func compressToBudget(_ cgImage: CGImage) -> Data? {
var quality = compressionQuality
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
return nil
}
while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1
autoreleasepool {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
}
}
return jpegData
}
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
@@ -193,7 +218,6 @@ enum ImageUtils {
}
return data as Data
}
#endif
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
let formatter = DateFormatter()
+1 -40
View File
@@ -126,37 +126,13 @@ struct SocialIdentity: Codable {
var notes: String?
}
/// Trust ladder: unknown casual vouched trusted verified.
///
/// Persistence compatibility: `TrustLevel` is stored by its *String* raw
/// value ("unknown", "casual", ), not by ordinal position, so inserting
/// `vouched` mid-ladder cannot corrupt previously persisted values every
/// pre-existing case keeps the exact raw value it was written with. The
/// `vouched` tier is additionally never persisted into `SocialIdentity`
/// (it's recomputed on read from stored vouches), so downgraded builds never
/// encounter the unfamiliar raw value.
enum TrustLevel: String, Codable {
case unknown
case casual
/// Transitively trusted: vouched for by at least one peer *I* verified.
/// Derived at read time never written to persistent storage.
case vouched
case trusted
case verified
}
// MARK: - Vouching (transitive verification)
/// One accepted vouch: a peer I verified (the voucher) attested that they
/// verified the vouchee. Validity is recomputed on read a record only
/// counts while its voucher remains in `verifiedFingerprints` and its
/// timestamp is within `VouchAttestation.maxAge` so unverifying a voucher
/// silently invalidates the vouches they gave without a cascade delete.
struct VouchRecord: Codable, Equatable {
let voucherFingerprint: String
let timestamp: Date
}
// MARK: - Identity Cache
/// Persistent storage for identity mappings and relationships.
@@ -178,22 +154,7 @@ struct IdentityCache: Codable {
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
var blockedNostrPubkeys: Set<String> = []
// Vouching (transitive verification). All three fields are Optional so
// caches persisted before this feature decode cleanly the synthesized
// decoder uses decodeIfPresent for optionals, and a missing key must not
// trip the "unreadable cache" recovery path that discards everything.
// Vouchee fingerprint -> accepted vouches (capped per vouchee)
var vouchesByVouchee: [String: [VouchRecord]]? = nil
// Peer fingerprint -> when we last sent them a vouch batch (rate limit)
var vouchBatchSentAt: [String: Date]? = nil
// Fingerprint -> when we verified it (orders outgoing vouch batches;
// entries verified before this field exists sort as oldest)
var verifiedAt: [String: Date]? = nil
// Schema version for future migrations
var version: Int = 1
}
@@ -133,17 +133,6 @@ protocol SecureIdentityStateManagerProtocol {
func setVerified(fingerprint: String, verified: Bool)
func isVerified(fingerprint: String) -> Bool
func getVerifiedFingerprints() -> Set<String>
// MARK: Vouching (transitive verification)
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
func validVouchers(for fingerprint: String) -> [VouchRecord]
func isVouched(fingerprint: String) -> Bool
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel
func lastVouchBatchSent(to fingerprint: String) -> Date?
func markVouchBatchSent(to fingerprint: String, at date: Date)
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
}
/// Singleton manager for secure identity state persistence and retrieval.
@@ -561,20 +550,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
queue.async(flags: .barrier) {
if verified {
self.cache.verifiedFingerprints.insert(fingerprint)
var verifiedAt = self.cache.verifiedAt ?? [:]
verifiedAt[fingerprint] = Date()
self.cache.verifiedAt = verifiedAt
} else {
self.cache.verifiedFingerprints.remove(fingerprint)
self.cache.verifiedAt?.removeValue(forKey: fingerprint)
}
// Update trust level if social identity exists
if var identity = self.cache.socialIdentities[fingerprint] {
identity.trustLevel = verified ? .verified : .casual
self.cache.socialIdentities[fingerprint] = identity
}
self.saveIdentityCache()
}
}
@@ -591,159 +576,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
}
// MARK: - Vouching (transitive verification)
/// Maximum vouchers retained per vouchee (most recent kept).
static let maxVouchersPerVouchee = 8
/// Records an accepted vouch, enforcing every accept-policy gate that can
/// be evaluated against stored state (signature verification is the
/// caller's job it needs the sender's announce-bound signing key):
/// - the voucher must be a fingerprint *I* verified
/// - self-vouches are ignored
/// - vouches for peers I already verified are ignored (nothing to add)
/// - attestations outside the validity window are ignored
/// - at most `maxVouchersPerVouchee` vouchers are kept per vouchee
///
/// Returns true when the vouch was stored (or refreshed).
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
recordVouch(
voucheeFingerprint: voucheeFingerprint,
voucherFingerprint: voucherFingerprint,
timestamp: timestamp,
now: Date()
)
}
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date, now: Date) -> Bool {
queue.sync(flags: .barrier) {
guard voucheeFingerprint != voucherFingerprint,
self.cache.verifiedFingerprints.contains(voucherFingerprint),
!self.cache.verifiedFingerprints.contains(voucheeFingerprint) else {
return false
}
let age = now.timeIntervalSince(timestamp)
guard age <= VouchAttestation.maxAge, age >= -VouchAttestation.maxClockSkew else {
return false
}
var records = self.cache.vouchesByVouchee?[voucheeFingerprint] ?? []
if let index = records.firstIndex(where: { $0.voucherFingerprint == voucherFingerprint }) {
let newest = max(records[index].timestamp, timestamp)
records[index] = VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: newest)
} else {
records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp))
}
// Keep the most recent vouchers up to the cap.
records.sort { $0.timestamp > $1.timestamp }
let capped = Array(records.prefix(Self.maxVouchersPerVouchee))
guard capped.contains(where: { $0.voucherFingerprint == voucherFingerprint }) else {
return false // Full of fresher vouches; nothing changed.
}
var vouches = self.cache.vouchesByVouchee ?? [:]
vouches[voucheeFingerprint] = capped
self.cache.vouchesByVouchee = vouches
self.saveIdentityCache()
return true
}
}
/// The vouches that currently count for `fingerprint`. Validity is
/// recomputed here rather than maintained by cascade deletes: a record
/// only counts while its voucher is still verified-by-me and its
/// timestamp is within the expiry window.
func validVouchers(for fingerprint: String) -> [VouchRecord] {
validVouchers(for: fingerprint, now: Date())
}
func validVouchers(for fingerprint: String, now: Date) -> [VouchRecord] {
queue.sync {
self.validVouchersLocked(for: fingerprint, now: now)
}
}
/// Requires `queue`.
private func validVouchersLocked(for fingerprint: String, now: Date) -> [VouchRecord] {
guard let records = cache.vouchesByVouchee?[fingerprint] else { return [] }
return records.filter { record in
record.voucherFingerprint != fingerprint
&& cache.verifiedFingerprints.contains(record.voucherFingerprint)
&& now.timeIntervalSince(record.timestamp) <= VouchAttestation.maxAge
}
}
/// True when the peer has at least one valid vouch and no explicit
/// verification of ours.
func isVouched(fingerprint: String) -> Bool {
isVouched(fingerprint: fingerprint, now: Date())
}
func isVouched(fingerprint: String, now: Date) -> Bool {
queue.sync {
guard !self.cache.verifiedFingerprints.contains(fingerprint) else { return false }
return !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
}
}
/// The trust level to display: explicit verification wins, then the
/// persisted level, with `vouched` layered in (derived, never persisted)
/// between `casual` and `trusted`.
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
effectiveTrustLevel(for: fingerprint, now: Date())
}
func effectiveTrustLevel(for fingerprint: String, now: Date) -> TrustLevel {
queue.sync {
if self.cache.verifiedFingerprints.contains(fingerprint) { return .verified }
let stored = self.cache.socialIdentities[fingerprint]?.trustLevel ?? .unknown
let vouched = !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
switch stored {
case .verified, .trusted:
return stored
case .vouched, .casual, .unknown:
if vouched { return .vouched }
// `.vouched` should never be persisted; degrade defensively.
return stored == .vouched ? .casual : stored
}
}
}
func lastVouchBatchSent(to fingerprint: String) -> Date? {
queue.sync { cache.vouchBatchSentAt?[fingerprint] }
}
func markVouchBatchSent(to fingerprint: String, at date: Date) {
queue.async(flags: .barrier) {
var sentAt = self.cache.vouchBatchSentAt ?? [:]
sentAt[fingerprint] = date
self.cache.vouchBatchSentAt = sentAt
self.saveIdentityCache()
}
}
/// The peer's announce-bound Ed25519 signing key, if seen this session.
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey }
}
/// Verified fingerprints ordered most recently verified first (entries
/// without a recorded verification time sort last), excluding the given
/// fingerprint. Feeds the outgoing vouch batch.
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
queue.sync {
let verifiedAt = cache.verifiedAt ?? [:]
let ordered = cache.verifiedFingerprints
.filter { $0 != fingerprint }
.sorted {
(verifiedAt[$0] ?? .distantPast, $0) > (verifiedAt[$1] ?? .distantPast, $1)
}
return Array(ordered.prefix(limit))
}
}
var debugNicknameIndex: [String: Set<String>] {
queue.sync { cache.nicknameIndex }
}
File diff suppressed because it is too large Load Diff
+17 -36
View File
@@ -16,18 +16,14 @@ enum CommandInfo: String, Identifiable {
// suggesting a spelling the processor rejects teaches users dead ends.
case block
case clear
case group
case help
case hug
case message = "msg"
case slap
case pay
case unblock
case who
case favorite = "fav"
case unfavorite = "unfav"
case ping
case trace
var id: String { rawValue }
@@ -35,12 +31,8 @@ enum CommandInfo: String, Identifiable {
var placeholder: String? {
switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
return "<" + String(localized: "content.input.nickname_placeholder", defaultValue: "nickname") + ">"
case .group:
return "<" + String(localized: "content.input.group_placeholder", defaultValue: "create|invite|leave|list") + ">"
case .pay:
return "<" + String(localized: "content.input.token_placeholder", defaultValue: "token") + ">"
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .clear, .help, .who:
return nil
}
@@ -48,37 +40,26 @@ enum CommandInfo: String, Identifiable {
var description: String {
switch self {
case .block: String(localized: "content.commands.block", defaultValue: "block or list blocked peers")
case .clear: String(localized: "content.commands.clear", defaultValue: "clear chat messages")
case .group: String(localized: "content.commands.group", defaultValue: "create or manage private groups")
case .help: String(localized: "content.commands.help", defaultValue: "show available commands")
case .hug: String(localized: "content.commands.hug", defaultValue: "send someone a warm hug")
case .message: String(localized: "content.commands.message", defaultValue: "send private message")
case .pay: String(localized: "content.commands.pay", defaultValue: "send a cashu ecash token in this chat")
case .slap: String(localized: "content.commands.slap", defaultValue: "slap someone with a trout")
case .unblock: String(localized: "content.commands.unblock", defaultValue: "unblock a peer")
case .who: String(localized: "content.commands.who", defaultValue: "see who's online")
case .favorite: String(localized: "content.commands.favorite", defaultValue: "add to favorites")
case .unfavorite: String(localized: "content.commands.unfavorite", defaultValue: "remove from favorites")
case .ping: String(localized: "content.commands.ping", defaultValue: "measure round-trip time to a mesh peer")
case .trace: String(localized: "content.commands.trace", defaultValue: "estimate the mesh path to a peer")
case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear")
case .help: String(localized: "content.commands.help")
case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message")
case .slap: String(localized: "content.commands.slap")
case .unblock: String(localized: "content.commands.unblock")
case .who: String(localized: "content.commands.who")
case .favorite: String(localized: "content.commands.favorite")
case .unfavorite: String(localized: "content.commands.unfavorite")
}
}
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
var commands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
// Cashu tokens are bearer instruments: in a public geohash any nearby
// stranger can redeem one, so don't *suggest* /pay there (the
// processor still allows it behind an explicit "public" confirm).
// Payments make sense in every DM and in mesh public.
if !isGeoPublic {
commands.append(.pay)
}
// The processor rejects favorites, groups and mesh diagnostics in
// geohash contexts, so only suggest them where they actually work: mesh.
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
// The processor rejects favorites in geohash contexts, so only
// suggest them where they actually work: mesh.
if isGeoPublic || isGeoDM {
return commands
return baseCommands
}
return commands + [.favorite, .unfavorite, .group, .ping, .trace]
return baseCommands + [.favorite, .unfavorite]
}
}
+1 -37
View File
@@ -1,21 +1,10 @@
import BitFoundation
import Foundation
// REQUEST_SYNC payload TLV (type, length16, value)
// - 0x01: P (uint8) Golomb-Rice parameter
// - 0x02: M (uint32, big-endian) hash range (N * 2^P)
// - 0x03: data (opaque) GR bitstream bytes (MSB-first)
// - 0x04: types (SyncTypeFlags) packet types the filter covers
// - 0x05: sinceTimestamp (uint64, big-endian) filter coverage cursor
// - 0x06: fragmentIdFilter (UTF-8) comma-separated 16-hex-char (8-byte)
// fragment stream IDs; restricts the fragment diff to exactly those
// streams (targeted resync for stalled reassemblies)
struct RequestSyncPacket {
/// Maximum fragment IDs one 0x06 filter may carry. Each ID encodes as
/// 16 hex chars plus a comma separator, so the largest encoded value is
/// 60 * 17 - 1 = 1019 bytes, which fits the 1024-byte decoder cap.
static let maxFragmentIdFilterCount = 60
let p: Int
let m: UInt32
let data: Data
@@ -23,29 +12,6 @@ struct RequestSyncPacket {
let sinceTimestamp: UInt64?
let fragmentIdFilter: String?
/// Encodes 8-byte fragment stream IDs as the 0x06 filter string,
/// dropping malformed IDs and capping at `maxFragmentIdFilterCount`.
static func encodeFragmentIdFilter(_ fragmentIDs: [Data]) -> String? {
let tokens = fragmentIDs
.filter { $0.count == 8 }
.prefix(maxFragmentIdFilterCount)
.map { $0.hexEncodedString() }
guard !tokens.isEmpty else { return nil }
return tokens.joined(separator: ",")
}
/// Decodes a 0x06 filter string back into 8-byte fragment stream IDs,
/// ignoring malformed tokens and capping at `maxFragmentIdFilterCount`.
static func decodeFragmentIdFilter(_ filter: String?) -> Set<Data>? {
guard let filter else { return nil }
var ids: Set<Data> = []
for token in filter.split(separator: ",").prefix(maxFragmentIdFilterCount) {
guard token.count == 16, let id = Data(hexString: String(token)) else { continue }
ids.insert(id)
}
return ids.isEmpty ? nil : ids
}
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
self.p = p
self.m = m
@@ -122,9 +88,7 @@ struct RequestSyncPacket {
sinceTimestamp = ts
}
case 0x06:
// Same acceptance cap as the GCS payload; an oversized filter
// is ignored rather than failing the whole request.
if v.count <= maxAcceptBytes, let fid = String(data: v, encoding: .utf8) {
if let fid = String(data: v, encoding: .utf8) {
fragmentIdFilter = fid
}
default:
-215
View File
@@ -1,215 +0,0 @@
import BitFoundation
import CryptoKit
import Foundation
/// NIP-13 proof-of-work for Nostr events.
///
/// Outgoing kind-20000 geohash messages mine a `["nonce", "<value>", "<target>"]`
/// tag so the event ID carries at least `target` leading zero bits. Inbound
/// events are scored (never hard-rejected the network has clients that do
/// not mine): validated PoW at or above `rateLimitBypassBits` relaxes the
/// per-sender public rate limit, everything else keeps the strict limits.
enum NostrPoW {
// MARK: - Tuning
/// Difficulty (leading zero bits of the event ID) mined onto outgoing
/// geohash messages. 8 bits is ~256 hash attempts typically well under
/// 100 ms on any supported device.
static let targetBits = 8
/// Inbound events whose validated NIP-13 difficulty is at least this many
/// bits skip the per-sender rate-limit bucket (the content-flood bucket
/// still applies). See `MessageRateLimiter.allow`.
static let rateLimitBypassBits = 8
/// Hard cap on mining wall-clock time. When it hits, the committed target
/// steps down until a difficulty reachable in a small extra budget is
/// found and the message is sent anyway mining never blocks sending.
static let miningTimeCap: TimeInterval = 2.0
/// Budget for each stepped-down attempt after the main cap (or a task
/// cancellation) hits.
private static let fallbackTimeCap: TimeInterval = 0.15
/// The hot loop checks the deadline and task cancellation every this many
/// hash attempts.
private static let checkInterval: UInt64 = 1024
/// The nonce value is a fixed-width hex counter so the serialized event
/// template can be mutated in place without reallocation.
private static let nonceLength = 16
// MARK: - Scoring
/// Number of leading zero bits in a byte sequence (NIP-13 difficulty of
/// an event-ID hash).
static func leadingZeroBits<Bytes: Sequence<UInt8>>(_ bytes: Bytes) -> Int {
var total = 0
for byte in bytes {
if byte == 0 {
total += 8
} else {
total += byte.leadingZeroBitCount
break
}
}
return total
}
/// Validated NIP-13 difficulty of an inbound event.
///
/// The committed target in the nonce tag is what counts: the actual
/// leading zero bits of the ID must meet it (otherwise the claim is void
/// and the event scores 0), and work beyond the commitment earns no extra
/// credit this stops spammers who mine a low target from getting lucky
/// high scores. Events without a well-formed commitment score 0.
static func validatedDifficulty(idHex: String, tags: [[String]]) -> Int {
guard let nonceTag = tags.last(where: { $0.first == "nonce" }),
nonceTag.count >= 3,
let committed = Int(nonceTag[2]),
committed > 0, committed <= 256,
let idData = Data(hexString: idHex)
else {
return 0
}
return leadingZeroBits(idData) >= committed ? committed : 0
}
// MARK: - Mining
/// Mine a `["nonce", value, target]` tag for the given unsigned-event
/// fields. Nonisolated async: runs off the calling actor.
///
/// Bounded by `miningTimeCap`: when the cap hits or the surrounding
/// task is cancelled the committed target steps down (halving to 0,
/// which any hash satisfies) so the event still ships promptly with an
/// honest commitment at the difficulty actually reached. Returns nil only
/// if canonical serialization fails; the caller then sends unmined.
static func mineNonceTag(
pubkey: String,
createdAt: Int,
kind: Int,
tags: [[String]],
content: String,
targetBits: Int = NostrPoW.targetBits
) async -> [String]? {
var target = min(max(targetBits, 0), 256)
var budget = miningTimeCap
while true {
if let tag = mineAttempt(
pubkey: pubkey,
createdAt: createdAt,
kind: kind,
baseTags: tags,
content: content,
targetBits: target,
budget: budget
) {
return tag
}
// Target 0 succeeds on the first hash, so reaching it with nil
// means serialization itself failed give up on mining.
if target == 0 { return nil }
target /= 2
budget = fallbackTimeCap
}
}
/// One bounded mining pass at a fixed committed target. Allocation-light:
/// the canonical serialization is built once and only the fixed-width
/// nonce bytes are rewritten per attempt (the event ID is recomputed for
/// every attempt, per NIP-13). Returns nil on timeout/cancellation or if
/// the template could not be built.
private static func mineAttempt(
pubkey: String,
createdAt: Int,
kind: Int,
baseTags: [[String]],
content: String,
targetBits: Int,
budget: TimeInterval
) -> [String]? {
let targetString = String(targetBits)
guard let template = serializedTemplate(
pubkey: pubkey,
createdAt: createdAt,
kind: kind,
baseTags: baseTags,
content: content,
targetString: targetString
) else {
return nil
}
var buffer = template.buffer
let nonceRange = template.nonceRange
let deadline = DispatchTime.now().uptimeNanoseconds &+ UInt64(budget * 1_000_000_000)
let hexDigits = [UInt8]("0123456789abcdef".utf8)
var nonce = UInt64.random(in: .min ... .max)
var attempts: UInt64 = 0
while true {
// Write the nonce as 16 lowercase hex chars, in place.
var value = nonce
var index = nonceRange.upperBound
while index > nonceRange.lowerBound {
index -= 1
buffer[index] = hexDigits[Int(value & 0xF)]
value >>= 4
}
if leadingZeroBits(SHA256.hash(data: buffer)) >= targetBits {
// Identical to the bytes just written into the buffer.
return ["nonce", String(format: "%016llx", nonce), targetString]
}
nonce &+= 1
attempts &+= 1
if attempts % checkInterval == 0,
Task.isCancelled || DispatchTime.now().uptimeNanoseconds >= deadline {
return nil
}
}
}
/// Canonical NIP-01 serialization of the event with a placeholder nonce,
/// plus the byte range of the nonce value inside it.
///
/// The range is located by serializing twice with two same-length
/// placeholders and diffing the buffers the only differing bytes are
/// the nonce value, so this stays correct however `JSONSerialization`
/// escapes the surrounding fields (and even if the content contains the
/// placeholder text itself).
private static func serializedTemplate(
pubkey: String,
createdAt: Int,
kind: Int,
baseTags: [[String]],
content: String,
targetString: String
) -> (buffer: Data, nonceRange: Range<Int>)? {
func serialize(noncePlaceholder: String) -> Data? {
var tags = baseTags
tags.append(["nonce", noncePlaceholder, targetString])
let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
return try? JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
}
guard let zeros = serialize(noncePlaceholder: String(repeating: "0", count: nonceLength)),
let effs = serialize(noncePlaceholder: String(repeating: "f", count: nonceLength)),
zeros.count == effs.count
else {
return nil
}
var firstDiff = -1
var lastDiff = -1
for index in 0..<zeros.count where zeros[index] != effs[index] {
if firstDiff < 0 { firstDiff = index }
lastDiff = index
}
guard firstDiff >= 0, lastDiff - firstDiff + 1 == nonceLength else { return nil }
return (zeros, firstDiff..<(firstDiff + nonceLength))
}
}
+9 -58
View File
@@ -170,63 +170,6 @@ struct NostrProtocol {
nickname: String? = nil,
teleported: Bool = false
) throws -> NostrEvent {
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported),
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a kind-20000 geohash message carrying a NIP-13 proof-of-work
/// nonce tag (see `NostrPoW`). Mining runs off the calling actor and is
/// bounded by `NostrPoW.miningTimeCap`; when the cap hits (or the
/// surrounding task is cancelled) the event ships at the highest
/// committed difficulty still met, and if mining is impossible it ships
/// unmined sending is never blocked.
static func createMinedEphemeralGeohashEvent(
content: String,
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = nil,
teleported: Bool = false,
powTargetBits: Int = NostrPoW.targetBits
) async throws -> NostrEvent {
var tags = ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported)
// Fix created_at up front: the mined nonce commits to the full
// serialized event, so the signed event must reuse the exact value.
let createdAt = Int(Date().timeIntervalSince1970)
if let nonceTag = await NostrPoW.mineNonceTag(
pubkey: senderIdentity.publicKeyHex,
createdAt: createdAt,
kind: EventKind.ephemeralEvent.rawValue,
tags: tags,
content: content,
targetBits: powTargetBits
) {
tags.append(nonceTag)
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)),
kind: .ephemeralEvent,
tags: tags,
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Tags for a kind-20000 geohash message (shared by the plain and mined
/// variants).
private static func ephemeralGeohashTags(
geohash: String,
nickname: String?,
teleported: Bool
) -> [[String]] {
var tags = [["g", geohash]]
if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname])
@@ -234,7 +177,15 @@ struct NostrProtocol {
if teleported {
tags.append(["t", "teleport"])
}
return tags
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: tags,
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a geohash presence heartbeat (kind 20001)
+7 -24
View File
@@ -18,7 +18,7 @@
/// - Efficient binary message encoding
/// - Message fragmentation for large payloads
/// - TTL-based routing for mesh networks
/// - Privacy features: message padding and randomized relay jitter
/// - Privacy features like padding and timing obfuscation
/// - Integration points for end-to-end encryption
///
/// ## Protocol Design
@@ -38,20 +38,18 @@
/// 7. **Decoding**: Binary data parsed back to message objects
///
/// ## Security Considerations
/// - Message padding (to 256/512/1024/2048-byte blocks) obscures actual content length
/// - Randomized relay jitter reduces the traffic-analysis signal; there is no
/// cover traffic or per-message timing obfuscation
/// - Message padding obscures actual content length
/// - Timing obfuscation prevents traffic analysis
/// - Integration with Noise Protocol for E2E encryption
/// - No persistent identifiers in protocol headers
///
/// ## Message Types
/// - **Announce/Leave**: Peer presence notifications
/// - **Message**: Public chat messages
/// - **Message**: User chat messages (broadcast or directed)
/// - **Fragment**: Multi-part message handling
/// - **NoiseHandshake/NoiseEncrypted**: Encrypted channel establishment and
/// all private payloads (messages, delivery acks, read receipts)
/// - **CourierEnvelope**: Sealed store-and-forward mail
/// - **RequestSync/FileTransfer**: Gossip history sync and media transfer
/// - **Delivery/Read**: Message acknowledgments
/// - **Noise**: Encrypted channel establishment
/// - **Version**: Protocol version negotiation
///
/// ## Future Extensions
/// The protocol is designed to be extensible:
@@ -77,14 +75,9 @@ enum NoisePayloadType: UInt8 {
// Wi-Fi bulk transport negotiation (AWDL data plane for large media)
case bulkTransferOffer = 0x04 // Offer to move a large file over peer-to-peer Wi-Fi
case bulkTransferResponse = 0x05 // Accept/decline reply to a bulk transfer offer
// Private groups
case groupInvite = 0x06 // Creator-signed group state (invite)
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
// Transitive verification (web of trust)
case vouch = 0x12 // Batch of vouch attestations
var description: String {
switch self {
@@ -93,11 +86,8 @@ enum NoisePayloadType: UInt8 {
case .delivered: return "delivered"
case .bulkTransferOffer: return "bulkTransferOffer"
case .bulkTransferResponse: return "bulkTransferResponse"
case .groupInvite: return "groupInvite"
case .groupKeyUpdate: return "groupKeyUpdate"
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
case .vouch: return "vouch"
}
}
}
@@ -129,9 +119,6 @@ protocol BitchatDelegate: AnyObject {
// Low-level events for better separation of concerns
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
func didReceiveGroupMessage(payload: Data, timestamp: Date)
// Bluetooth state updates for user notifications
func didUpdateBluetoothState(_ state: CBManagerState)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
@@ -151,10 +138,6 @@ extension BitchatDelegate {
// Default empty implementation
}
func didReceiveGroupMessage(payload: Data, timestamp: Date) {
// Default empty implementation
}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
// Default empty implementation
}
-348
View File
@@ -1,348 +0,0 @@
//
// BoardPackets.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import Foundation
// MARK: - Board wire format (MessageType.boardPost payloads)
//
// TLV layout (type u8, length u16 big-endian, value), matching REQUEST_SYNC:
// - 0x01: kind (u8) 0x01 post, 0x02 tombstone
// - 0x02: postID (16B random)
// - 0x03: geohash (UTF-8, empty = mesh-local board, max 12 chars)
// - 0x04: content (UTF-8, 1...512 bytes) [post]
// - 0x05: authorSigningKey (32B Ed25519 public key)
// - 0x06: authorNickname (UTF-8, max 64 bytes)
// - 0x07: createdAt (u64 big-endian, ms) [post]
// - 0x08: expiresAt (u64 big-endian, ms, max 7 days after createdAt) [post]
// - 0x09: flags (u8, bit0 = urgent) [post]
// - 0x0A: signature (64B Ed25519)
// - 0x0B: deletedAt (u64 big-endian, ms) [tombstone]
// Unknown TLVs are skipped for forward compatibility.
enum BoardWireConstants {
static let postIDLength = 16
static let signingKeyLength = 32
static let signatureLength = 64
static let contentMaxBytes = 512
static let nicknameMaxBytes = 64
static let geohashMaxLength = 12
/// Posts may live at most 7 days past their creation timestamp.
static let maxLifetimeMs: UInt64 = 7 * 24 * 60 * 60 * 1000
static let postSigningContext = "bitchat-board-v1"
static let tombstoneSigningContext = "bitchat-board-del-v1"
static let geohashAlphabet = Set("0123456789bcdefghjkmnpqrstuvwxyz")
}
private enum BoardTLVType: UInt8 {
case kind = 0x01
case postID = 0x02
case geohash = 0x03
case content = 0x04
case authorSigningKey = 0x05
case authorNickname = 0x06
case createdAt = 0x07
case expiresAt = 0x08
case flags = 0x09
case signature = 0x0A
case deletedAt = 0x0B
}
private enum BoardWireKind: UInt8 {
case post = 0x01
case tombstone = 0x02
}
/// A signed, persistent bulletin-board notice.
struct BoardPostPacket: Equatable {
let postID: Data
/// Empty string scopes the post to the mesh-local board.
let geohash: String
let content: String
let authorSigningKey: Data
let authorNickname: String
let createdAt: UInt64
let expiresAt: UInt64
let flags: UInt8
let signature: Data
static let urgentFlag: UInt8 = 0x01
var isUrgent: Bool { flags & Self.urgentFlag != 0 }
/// Canonical bytes covered by the Ed25519 signature. Variable-length
/// fields are length-prefixed so no two field combinations can collide.
static func signingBytes(
postID: Data,
geohash: String,
content: String,
authorSigningKey: Data,
authorNickname: String,
createdAt: UInt64,
expiresAt: UInt64,
flags: UInt8
) -> Data {
var out = Data()
BoardWireEncoding.appendContext(BoardWireConstants.postSigningContext, to: &out)
out.append(postID)
BoardWireEncoding.appendLengthPrefixed(Data(geohash.utf8), to: &out)
BoardWireEncoding.appendLengthPrefixed(Data(content.utf8), to: &out)
out.append(authorSigningKey)
BoardWireEncoding.appendLengthPrefixed(Data(authorNickname.utf8), to: &out)
BoardWireEncoding.appendUInt64(createdAt, to: &out)
BoardWireEncoding.appendUInt64(expiresAt, to: &out)
out.append(flags)
return out
}
var signingBytes: Data {
Self.signingBytes(
postID: postID,
geohash: geohash,
content: content,
authorSigningKey: authorSigningKey,
authorNickname: authorNickname,
createdAt: createdAt,
expiresAt: expiresAt,
flags: flags
)
}
func verifySignature() -> Bool {
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
}
}
/// A signed deletion marker. Only the author's key can produce one; receivers
/// keep it until the post's original expiry so the delete outruns the post.
struct BoardTombstonePacket: Equatable {
let postID: Data
let authorSigningKey: Data
let deletedAt: UInt64
let signature: Data
static func signingBytes(postID: Data, deletedAt: UInt64) -> Data {
var out = Data()
BoardWireEncoding.appendContext(BoardWireConstants.tombstoneSigningContext, to: &out)
out.append(postID)
BoardWireEncoding.appendUInt64(deletedAt, to: &out)
return out
}
var signingBytes: Data {
Self.signingBytes(postID: postID, deletedAt: deletedAt)
}
func verifySignature() -> Bool {
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
}
}
/// Decoded board payload: either a live post or a tombstone.
enum BoardWire: Equatable {
case post(BoardPostPacket)
case tombstone(BoardTombstonePacket)
func encode() -> Data {
var out = Data()
func putTLV(_ t: BoardTLVType, _ v: Data) {
out.append(t.rawValue)
let len = UInt16(v.count)
out.append(UInt8((len >> 8) & 0xFF))
out.append(UInt8(len & 0xFF))
out.append(v)
}
switch self {
case .post(let post):
putTLV(.kind, Data([BoardWireKind.post.rawValue]))
putTLV(.postID, post.postID)
putTLV(.geohash, Data(post.geohash.utf8))
putTLV(.content, Data(post.content.utf8))
putTLV(.authorSigningKey, post.authorSigningKey)
putTLV(.authorNickname, Data(post.authorNickname.utf8))
putTLV(.createdAt, BoardWireEncoding.uint64Data(post.createdAt))
putTLV(.expiresAt, BoardWireEncoding.uint64Data(post.expiresAt))
putTLV(.flags, Data([post.flags]))
putTLV(.signature, post.signature)
case .tombstone(let tombstone):
putTLV(.kind, Data([BoardWireKind.tombstone.rawValue]))
putTLV(.postID, tombstone.postID)
putTLV(.authorSigningKey, tombstone.authorSigningKey)
putTLV(.deletedAt, BoardWireEncoding.uint64Data(tombstone.deletedAt))
putTLV(.signature, tombstone.signature)
}
return out
}
/// Structural decode; the caller must still verify the signature before
/// ingesting (`verifySignature()`).
static func decode(from data: Data) -> BoardWire? {
var off = data.startIndex
var kind: BoardWireKind?
var postID: Data?
var geohash: String?
var content: String?
var contentBytes = 0
var authorSigningKey: Data?
var authorNickname: String?
var nicknameBytes = 0
var createdAt: UInt64?
var expiresAt: UInt64?
var flags: UInt8?
var signature: Data?
var deletedAt: UInt64?
while off + 3 <= data.endIndex {
let t = data[off]; off += 1
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
guard off + len <= data.endIndex else { return nil }
let v = data.subdata(in: off..<(off + len)); off += len
switch BoardTLVType(rawValue: t) {
case .kind:
guard v.count == 1 else { return nil }
kind = BoardWireKind(rawValue: v[v.startIndex])
case .postID:
guard v.count == BoardWireConstants.postIDLength else { return nil }
postID = v
case .geohash:
guard v.count <= BoardWireConstants.geohashMaxLength else { return nil }
geohash = String(data: v, encoding: .utf8)
case .content:
guard v.count <= BoardWireConstants.contentMaxBytes else { return nil }
contentBytes = v.count
content = String(data: v, encoding: .utf8)
case .authorSigningKey:
guard v.count == BoardWireConstants.signingKeyLength else { return nil }
authorSigningKey = v
case .authorNickname:
guard v.count <= BoardWireConstants.nicknameMaxBytes else { return nil }
nicknameBytes = v.count
authorNickname = String(data: v, encoding: .utf8)
case .createdAt:
createdAt = BoardWireEncoding.uint64(from: v)
case .expiresAt:
expiresAt = BoardWireEncoding.uint64(from: v)
case .flags:
guard v.count == 1 else { return nil }
flags = v[v.startIndex]
case .signature:
guard v.count == BoardWireConstants.signatureLength else { return nil }
signature = v
case .deletedAt:
deletedAt = BoardWireEncoding.uint64(from: v)
case nil:
continue // forward compatible; ignore unknown TLVs
}
}
guard let postID, let authorSigningKey, let signature else { return nil }
switch kind {
case .post:
guard let geohash, let content, let authorNickname,
let createdAt, let expiresAt, let flags,
contentBytes >= 1,
nicknameBytes <= BoardWireConstants.nicknameMaxBytes,
isValidGeohashField(geohash),
expiresAt > createdAt,
expiresAt - createdAt <= BoardWireConstants.maxLifetimeMs else {
return nil
}
return .post(BoardPostPacket(
postID: postID,
geohash: geohash,
content: content,
authorSigningKey: authorSigningKey,
authorNickname: authorNickname,
createdAt: createdAt,
expiresAt: expiresAt,
flags: flags,
signature: signature
))
case .tombstone:
guard let deletedAt else { return nil }
return .tombstone(BoardTombstonePacket(
postID: postID,
authorSigningKey: authorSigningKey,
deletedAt: deletedAt,
signature: signature
))
case nil:
return nil
}
}
func verifySignature() -> Bool {
switch self {
case .post(let post): return post.verifySignature()
case .tombstone(let tombstone): return tombstone.verifySignature()
}
}
/// Cheap TLV peek for relay policy: is this payload an urgent post?
/// Avoids a full decode on the hot relay path.
static func urgentFlag(in data: Data) -> Bool {
var off = data.startIndex
while off + 3 <= data.endIndex {
let t = data[off]; off += 1
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
guard off + len <= data.endIndex else { return false }
if t == BoardTLVType.flags.rawValue, len == 1 {
return data[off] & BoardPostPacket.urgentFlag != 0
}
off += len
}
return false
}
/// Empty geohash = mesh-local board; otherwise 1-12 chars of the geohash
/// base32 alphabet.
private static func isValidGeohashField(_ geohash: String) -> Bool {
geohash.isEmpty || geohash.allSatisfy { BoardWireConstants.geohashAlphabet.contains($0) }
}
}
enum BoardWireEncoding {
static func appendContext(_ context: String, to out: inout Data) {
let bytes = Data(context.utf8)
out.append(UInt8(min(bytes.count, 255)))
out.append(bytes.prefix(255))
}
static func appendLengthPrefixed(_ value: Data, to out: inout Data) {
let len = UInt16(min(value.count, Int(UInt16.max)))
out.append(UInt8((len >> 8) & 0xFF))
out.append(UInt8(len & 0xFF))
out.append(value.prefix(Int(UInt16.max)))
}
static func appendUInt64(_ value: UInt64, to out: inout Data) {
var be = value.bigEndian
withUnsafeBytes(of: &be) { out.append(contentsOf: $0) }
}
static func uint64Data(_ value: UInt64) -> Data {
var out = Data()
appendUInt64(value, to: &out)
return out
}
static func uint64(from data: Data) -> UInt64? {
guard data.count == 8 else { return nil }
var value: UInt64 = 0
for byte in data { value = (value << 8) | UInt64(byte) }
return value
}
static func verify(signature: Data, over message: Data, publicKey: Data) -> Bool {
guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else {
return false
}
return key.isValidSignature(signature, for: message)
}
}
+6 -6
View File
@@ -24,17 +24,17 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
var displayName: String {
switch self {
case .building:
return String(localized: "location_levels.building", defaultValue: "building", comment: "Name for building-level location channel")
return String(localized: "location_levels.building", comment: "Name for building-level location channel")
case .block:
return String(localized: "location_levels.block", defaultValue: "block", comment: "Name for block-level location channel")
return String(localized: "location_levels.block", comment: "Name for block-level location channel")
case .neighborhood:
return String(localized: "location_levels.neighborhood", defaultValue: "neighborhood", comment: "Name for neighborhood-level location channel")
return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
case .city:
return String(localized: "location_levels.city", defaultValue: "city", comment: "Name for city-level location channel")
return String(localized: "location_levels.city", comment: "Name for city-level location channel")
case .province:
return String(localized: "location_levels.province", defaultValue: "province", comment: "Name for province-level location channel")
return String(localized: "location_levels.province", comment: "Name for province-level location channel")
case .region:
return String(localized: "location_levels.region", defaultValue: "region", comment: "Name for region-level location channel")
return String(localized: "location_levels.region", comment: "Name for region-level location channel")
}
}
}
-136
View File
@@ -1,136 +0,0 @@
//
// NostrCarrierPacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
/// Wire payload for `MessageType.nostrCarrier` (0x28): a complete, signed
/// Nostr event ferried over the mesh between a mesh-only peer and an
/// internet gateway peer.
///
/// - `toGateway` rides a DIRECTED packet (recipientID = the gateway peer):
/// a mesh-only sender asks the gateway to publish its locally signed
/// geohash event to Nostr relays.
/// - `fromGateway` rides a BROADCAST packet (default TTL): the gateway
/// rebroadcasts inbound relay events so mesh-only peers see the channel.
///
/// The carried event is public geohash chat already plaintext on Nostr
/// so the carrier adds no encryption. It IS signed by the originator's
/// per-geohash identity, so neither the gateway nor any mesh relay can forge
/// or alter it undetected: gateways and receivers verify the Schnorr
/// signature before acting on it.
///
/// TLV encoding with 2-byte big-endian lengths (the event JSON exceeds the
/// 1-byte TLV range used by smaller packets). Unknown TLV types are skipped
/// for forward compatibility.
struct NostrCarrierPacket: Equatable {
enum Direction: UInt8 {
case toGateway = 0x01
case fromGateway = 0x02
}
let direction: Direction
let geohash: String
/// Complete signed Nostr event JSON (id, pubkey, created_at, kind, tags,
/// content, sig).
let eventJSON: Data
/// BLE airtime cap for a carried event.
static let maxEventJSONBytes = 16 * 1024
static let maxGeohashLength = 12
private enum TLVType: UInt8 {
case direction = 0x01
case geohash = 0x02
case eventJSON = 0x03
}
init?(direction: Direction, geohash: String, eventJSON: Data) {
let geohashBytes = Data(geohash.utf8)
guard !geohashBytes.isEmpty,
geohashBytes.count <= Self.maxGeohashLength,
!eventJSON.isEmpty,
eventJSON.count <= Self.maxEventJSONBytes else {
return nil
}
self.direction = direction
self.geohash = geohash
self.eventJSON = eventJSON
}
init?(direction: Direction, geohash: String, event: NostrEvent) {
guard let json = try? event.jsonString(), !json.isEmpty else { return nil }
self.init(direction: direction, geohash: geohash, eventJSON: Data(json.utf8))
}
/// Decodes the carried event. Callers MUST still verify
/// `event.isValidSignature()` before publishing or displaying it.
func event() -> NostrEvent? {
guard let dict = try? JSONSerialization.jsonObject(with: eventJSON) as? [String: Any] else {
return nil
}
return try? NostrEvent(from: dict)
}
func encode() -> Data? {
var data = Data()
data.reserveCapacity(eventJSON.count + geohash.utf8.count + 12)
func appendTLV(_ type: TLVType, _ value: Data) {
data.append(type.rawValue)
data.append(UInt8((value.count >> 8) & 0xFF))
data.append(UInt8(value.count & 0xFF))
data.append(value)
}
appendTLV(.direction, Data([direction.rawValue]))
appendTLV(.geohash, Data(geohash.utf8))
appendTLV(.eventJSON, eventJSON)
return data
}
static func decode(_ data: Data) -> NostrCarrierPacket? {
// Defensive slice re-base (Data slices keep parent indices).
let data = Data(data)
var offset = 0
var direction: Direction?
var geohash: String?
var eventJSON: Data?
while offset + 3 <= data.count {
let typeRaw = data[offset]
let length = (Int(data[offset + 1]) << 8) | Int(data[offset + 2])
offset += 3
guard offset + length <= data.count else { return nil }
let value = data.subdata(in: offset..<offset + length)
offset += length
switch TLVType(rawValue: typeRaw) {
case .direction:
guard value.count == 1, let parsed = Direction(rawValue: value[0]) else { return nil }
direction = parsed
case .geohash:
guard let parsed = String(data: value, encoding: .utf8) else { return nil }
geohash = parsed
case .eventJSON:
eventJSON = value
case nil:
// Unknown TLV; skip (tolerant decoder for forward compatibility).
continue
}
}
guard offset == data.count,
let direction,
let geohash,
let eventJSON else {
return nil
}
return NostrCarrierPacket(direction: direction, geohash: geohash, eventJSON: eventJSON)
}
}
@@ -3,9 +3,5 @@ import BitFoundation
extension PeerCapabilities {
/// Capabilities this build advertises in its announce packets.
/// Each feature adds its bit here when it ships.
static let localSupported: PeerCapabilities = {
var caps: PeerCapabilities = [.prekeys, .vouch, .groups]
if TransportConfig.wifiBulkEnabled { caps.insert(.wifiBulk) }
return caps
}()
static let localSupported: PeerCapabilities = TransportConfig.wifiBulkEnabled ? [.wifiBulk] : []
}
-225
View File
@@ -1,225 +0,0 @@
//
// VouchAttestation.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import Foundation
/// A signed statement that the *sender of the enclosing Noise payload* has
/// verified the identity described here ("transitive verification").
///
/// The voucher's identity is deliberately implicit: attestations only travel
/// inside an authenticated Noise session (`NoisePayloadType.vouch`), so the
/// receiver verifies the Ed25519 signature against the session peer's
/// announce-bound signing key and stores the vouch keyed by that peer's
/// fingerprint. Nothing in the attestation names the voucher, so a captured
/// attestation cannot be replayed by a third party whose signing key doesn't
/// match.
///
/// Wire format single attestation (TLV, 1-byte type + 1-byte length):
/// - `0x01` voucheeFingerprint: 32 bytes, SHA-256 of the vouchee's Noise static key
/// - `0x02` voucheeSigningKey: 32 bytes, Ed25519; anchors the vouch to a concrete identity
/// - `0x03` timestamp: 8 bytes big-endian, milliseconds since 1970
/// - `0x04` signature: 64 bytes, Ed25519 by the VOUCHER's signing key over
/// `"bitchat-vouch-v1" | voucheeFingerprint | voucheeSigningKey | timestamp`
///
/// Unknown TLV types are skipped for forward compatibility.
///
/// Batch format (the `vouch` Noise payload body):
/// `[count: UInt8]` then per attestation `[length: UInt16 BE][attestation TLV]`.
struct VouchAttestation: Equatable {
static let signingContext = "bitchat-vouch-v1"
/// Receiver-side expiry for attestations.
static let maxAge: TimeInterval = 30 * 24 * 60 * 60
/// Tolerated clock skew for attestations timestamped in the future.
static let maxClockSkew: TimeInterval = 60 * 60
/// Upper bound of attestations carried/accepted in one batch payload.
static let maxBatchCount = 16
static let fingerprintSize = 32
static let signingKeySize = 32
static let signatureSize = 64
let voucheeFingerprint: Data // 32 bytes
let voucheeSigningKey: Data // 32 bytes
let timestampMs: UInt64
let signature: Data // 64 bytes
private enum TLVType: UInt8 {
case voucheeFingerprint = 0x01
case voucheeSigningKey = 0x02
case timestamp = 0x03
case signature = 0x04
}
var voucheeFingerprintHex: String { voucheeFingerprint.hexEncodedString() }
var timestamp: Date { Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000) }
/// The exact bytes the voucher signs.
static func signableBytes(
voucheeFingerprint: Data,
voucheeSigningKey: Data,
timestampMs: UInt64
) -> Data {
var message = Data(signingContext.utf8)
message.append(voucheeFingerprint)
message.append(voucheeSigningKey)
var timestampBE = timestampMs.bigEndian
withUnsafeBytes(of: &timestampBE) { message.append(contentsOf: $0) }
return message
}
var signableBytes: Data {
Self.signableBytes(
voucheeFingerprint: voucheeFingerprint,
voucheeSigningKey: voucheeSigningKey,
timestampMs: timestampMs
)
}
/// Builds and signs an attestation. `sign` is the voucher's Ed25519
/// signing primitive (e.g. `Transport.noiseSignData`).
static func build(
voucheeFingerprint: Data,
voucheeSigningKey: Data,
timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
sign: (Data) -> Data?
) -> VouchAttestation? {
guard voucheeFingerprint.count == fingerprintSize,
voucheeSigningKey.count == signingKeySize else { return nil }
let message = signableBytes(
voucheeFingerprint: voucheeFingerprint,
voucheeSigningKey: voucheeSigningKey,
timestampMs: timestampMs
)
guard let signature = sign(message), signature.count == signatureSize else { return nil }
return VouchAttestation(
voucheeFingerprint: voucheeFingerprint,
voucheeSigningKey: voucheeSigningKey,
timestampMs: timestampMs,
signature: signature
)
}
/// Verifies the Ed25519 signature against the voucher's announce-bound
/// signing key.
func verifySignature(voucherSigningKey: Data) -> Bool {
guard let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: voucherSigningKey) else {
return false
}
return publicKey.isValidSignature(signature, for: signableBytes)
}
/// Whether the attestation is outside its validity window (older than
/// `maxAge`, or timestamped implausibly far in the future).
func isExpired(now: Date = Date()) -> Bool {
let age = now.timeIntervalSince(timestamp)
return age > Self.maxAge || age < -Self.maxClockSkew
}
// MARK: - Encoding
func encode() -> Data? {
guard voucheeFingerprint.count == Self.fingerprintSize,
voucheeSigningKey.count == Self.signingKeySize,
signature.count == Self.signatureSize else { return nil }
var data = Data()
func appendTLV(_ type: TLVType, _ value: Data) {
data.append(type.rawValue)
data.append(UInt8(value.count))
data.append(value)
}
appendTLV(.voucheeFingerprint, voucheeFingerprint)
appendTLV(.voucheeSigningKey, voucheeSigningKey)
var timestampBE = timestampMs.bigEndian
appendTLV(.timestamp, withUnsafeBytes(of: &timestampBE) { Data($0) })
appendTLV(.signature, signature)
return data
}
static func decode(from data: Data) -> VouchAttestation? {
var fingerprint: Data?
var signingKey: Data?
var timestampMs: UInt64?
var signature: Data?
var offset = data.startIndex
while offset < data.endIndex {
guard data.index(offset, offsetBy: 2, limitedBy: data.endIndex) != nil,
offset + 1 < data.endIndex else { return nil }
let type = data[offset]
let length = Int(data[offset + 1])
let valueStart = offset + 2
guard let valueEnd = data.index(valueStart, offsetBy: length, limitedBy: data.endIndex) else {
return nil
}
let value = Data(data[valueStart..<valueEnd])
switch TLVType(rawValue: type) {
case .voucheeFingerprint:
guard value.count == fingerprintSize else { return nil }
fingerprint = value
case .voucheeSigningKey:
guard value.count == signingKeySize else { return nil }
signingKey = value
case .timestamp:
guard value.count == 8 else { return nil }
timestampMs = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
case .signature:
guard value.count == signatureSize else { return nil }
signature = value
case nil:
break // Unknown TLV: skip for forward compatibility.
}
offset = valueEnd
}
guard let fingerprint, let signingKey, let timestampMs, let signature else { return nil }
return VouchAttestation(
voucheeFingerprint: fingerprint,
voucheeSigningKey: signingKey,
timestampMs: timestampMs,
signature: signature
)
}
// MARK: - Batch encoding
/// Encodes up to `maxBatchCount` attestations into one payload body.
static func encodeList(_ attestations: [VouchAttestation]) -> Data? {
guard !attestations.isEmpty, attestations.count <= maxBatchCount else { return nil }
var data = Data()
data.append(UInt8(attestations.count))
for attestation in attestations {
guard let encoded = attestation.encode(), encoded.count <= Int(UInt16.max) else { return nil }
var lengthBE = UInt16(encoded.count).bigEndian
withUnsafeBytes(of: &lengthBE) { data.append(contentsOf: $0) }
data.append(encoded)
}
return data
}
/// Decodes a batch payload, dropping malformed entries and ignoring
/// anything beyond `maxBatchCount` (sender-declared count is not trusted).
static func decodeList(from data: Data) -> [VouchAttestation] {
guard data.count > 1 else { return [] }
let declaredCount = Int(data[data.startIndex])
let limit = min(declaredCount, maxBatchCount)
var attestations: [VouchAttestation] = []
var offset = data.startIndex + 1
while attestations.count < limit, offset < data.endIndex {
guard let lengthEnd = data.index(offset, offsetBy: 2, limitedBy: data.endIndex) else { break }
let length = Int(data[offset]) << 8 | Int(data[offset + 1])
guard let entryEnd = data.index(lengthEnd, offsetBy: length, limitedBy: data.endIndex) else { break }
if let attestation = decode(from: Data(data[lengthEnd..<entryEnd])) {
attestations.append(attestation)
}
offset = entryEnd
}
return attestations
}
}
@@ -64,9 +64,6 @@ struct BLEFragmentAssemblyBuffer {
let type: UInt8
let total: Int
let timestamp: Date
let isBroadcast: Bool
var lastFragmentAt: Date
var lastResyncRequestAt: Date?
}
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
@@ -108,15 +105,7 @@ struct BLEFragmentAssemblyBuffer {
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
}
// Only actual progress resets the stall clock: fragment packets
// bypass the packet deduplicator, so relayed duplicates of an
// already-held index must not keep suppressing the targeted
// REQUEST_SYNC for a stalled stream.
let isNewIndex = fragmentsByKey[header.key]?[header.index] == nil
fragmentsByKey[header.key]?[header.index] = header.fragmentData
if isNewIndex {
metadataByKey[header.key]?.lastFragmentAt = now
}
guard let fragments = fragmentsByKey[header.key],
fragments.count == header.total else {
@@ -149,59 +138,10 @@ struct BLEFragmentAssemblyBuffer {
}
fragmentsByKey[header.key] = [:]
metadataByKey[header.key] = Metadata(
type: header.originalType,
total: header.total,
timestamp: now,
isBroadcast: header.isBroadcastFragment,
lastFragmentAt: now
)
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now)
return true
}
/// Fragment stream IDs (8-byte, big-endian) of incomplete broadcast
/// reassemblies that have not seen a new fragment for `stalledAfter`
/// seconds candidates for a targeted REQUEST_SYNC. Each returned
/// stream is marked so it is not re-requested within `retryAfter`.
/// At most `RequestSyncPacket.maxFragmentIdFilterCount` streams are
/// returned per pass the wire filter cannot carry more selected
/// oldest-stall first; overflow streams stay unmarked and eligible for
/// the next pass. Directed reassemblies are excluded: peers only archive
/// broadcast fragments for gossip sync, so a targeted request cannot
/// recover them.
mutating func stalledBroadcastFragmentIDs(
stalledAfter: TimeInterval,
retryAfter: TimeInterval,
now: Date = Date()
) -> [Data] {
var candidates: [(key: BLEFragmentKey, lastFragmentAt: Date)] = []
for (key, metadata) in metadataByKey {
guard metadata.isBroadcast,
let fragments = fragmentsByKey[key],
fragments.count < metadata.total,
now.timeIntervalSince(metadata.lastFragmentAt) >= stalledAfter else { continue }
if let lastRequest = metadata.lastResyncRequestAt,
now.timeIntervalSince(lastRequest) < retryAfter { continue }
candidates.append((key: key, lastFragmentAt: metadata.lastFragmentAt))
}
// Mark only the streams that will actually go on the wire, so the
// overflow is not silently suppressed for `retryAfter`.
let selected = candidates
.sorted {
if $0.lastFragmentAt != $1.lastFragmentAt {
return $0.lastFragmentAt < $1.lastFragmentAt
}
return ($0.key.sender, $0.key.id) < ($1.key.sender, $1.key.id)
}
.prefix(RequestSyncPacket.maxFragmentIdFilterCount)
return selected.map { candidate in
metadataByKey[candidate.key]?.lastResyncRequestAt = now
return withUnsafeBytes(of: candidate.key.id.bigEndian) { Data($0) }
}
}
private static func assemblyLimit(for originalType: UInt8) -> Int {
if originalType == MessageType.fileTransfer.rawValue {
// Allow headroom for TLV metadata and binary framing overhead.
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake:
return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .prekeyBundle, .groupMessage, .nostrCarrier, .ping, .pong:
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
return false
}
}
@@ -112,11 +112,6 @@ struct BLEPeerRegistry {
peers[peerID.toShort()]?.capabilities ?? []
}
/// Peers whose last verified announce advertised the given capability.
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
}
func displayNicknames(selfNickname: String) -> [PeerID: String] {
let connected = peers.filter { $0.value.isConnected }
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
+1 -13
View File
@@ -51,25 +51,13 @@ struct BLEReceivePipeline {
// Courier envelopes are directed opaque ciphertext like DMs; a
// remote handover toward a relayed announce rides this same
// deterministic relay treatment instead of the broadcast clamp.
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
// the same multi-hop treatment to reach a non-adjacent gateway.
// Ping/pong diagnostics also ride it: probes need the same
// deterministic multi-hop relay as DMs (always relay, jitter,
// no TTL cap) so RTT and hop counts reflect the real path.
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|| packet.type == MessageType.courierEnvelope.rawValue
|| packet.type == MessageType.nostrCarrier.rawValue
|| packet.type == MessageType.ping.rawValue
|| packet.type == MessageType.pong.rawValue) && packet.recipientID != nil,
|| packet.type == MessageType.courierEnvelope.rawValue) && packet.recipientID != nil,
isFragment: packet.type == MessageType.fragment.rawValue,
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
isAnnounce: packet.type == MessageType.announce.rawValue,
isRequestSync: packet.type == MessageType.requestSync.rawValue,
// Board posts relay like broadcast messages; urgent ones get the
// announce-class TTL headroom so alerts travel the extra hop.
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
&& BoardWire.urgentFlag(in: packet.payload),
degree: degree,
highDegreeThreshold: highDegreeThreshold
)
+20 -750
View File
@@ -53,8 +53,6 @@ final class BLEService: NSObject {
// reject. Injectable for tests; main-actor policy because favorites live
// on the main actor.
var courierStore: CourierStore = .shared
// Bulletin-board posts this device carries; injectable for tests.
var boardStore: BoardStore = .shared
var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in
if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite }
return isVerifiedPeer ? .verified : nil
@@ -62,27 +60,6 @@ final class BLEService: NSObject {
// Local-only store-and-forward counters; nil in unit tests.
var sfMetrics: StoreAndForwardMetrics?
// Verified one-time prekey bundles gossiped by other peers, used to seal
// courier mail forward-secretly. Injectable for tests.
var prekeyBundleStore: PrekeyBundleStore = .shared
// Throttle for re-broadcasting our own (unchanged) bundle; guarded by
// collectionsQueue barriers.
private var lastPrekeyBundleSentAt: Date?
// Prekey bundles that arrived before their owner's verified announce bound
// a signing key. The receive queue is concurrent, so a bundle can race
// ahead of the announce it depends on; we retain the latest such bundle per
// owner (bounded) and re-attempt attribution when the announce lands.
// Guarded by collectionsQueue barriers.
private var pendingPrekeyBundles: [PeerID: BitchatPacket] = [:]
private static let pendingPrekeyBundleCap = 64
// Gateway mode: sink for received nostrCarrier packets (set by app
// wiring, called on the main actor after transport-level checks) and the
// runtime-toggled capability bits ORed into `PeerCapabilities.localSupported`
// for every announce. `directedToUs` distinguishes an uplink deposit
// addressed to this device from a downlink broadcast.
var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)?
private var runtimeCapabilities: PeerCapabilities = [] // collectionsQueue
#if DEBUG
// Test-only tap on the outbound pipeline so multi-node tests can ferry
// packets between in-process service instances.
@@ -90,29 +67,7 @@ final class BLEService: NSObject {
#endif
private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker()
// Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the
// inbound ping budget keyed by the ingress link (the directly connected
// peer that delivered the packet), since the unsigned claimed sender is
// spoofable so a directed unencrypted probe cannot be turned into an
// amplification primitive. Both are owned by collectionsQueue barriers
// like the other mutable collections.
private struct PendingMeshPing {
let peerID: PeerID
let sentAt: Date
let completion: @MainActor (MeshPingResult?) -> Void
let timeout: DispatchWorkItem
}
private var pendingMeshPings: [Data: PendingMeshPing] = [:]
private var meshPingResponseLimiter = SyncResponseRateLimiter(
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
window: TransportConfig.meshPingInboundWindowSeconds
)
// Route health for originated source routes; guarded by collectionsQueue.
private var sourceRouteFailures = BLESourceRouteFailureCache()
// 5. Fragment Reassembly (necessary for messages > MTU)
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
@@ -343,24 +298,13 @@ final class BLEService: NSObject {
fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds,
messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds,
responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses,
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds,
prekeyBundleCapacity: TransportConfig.syncPrekeyBundleCapacity,
prekeyBundleSyncIntervalSeconds: TransportConfig.syncPrekeyBundleIntervalSeconds,
prekeyBundleMaxAgeSeconds: TransportConfig.syncPrekeyBundleMaxAgeSeconds
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds
)
// Only real Bluetooth sessions archive to disk; unit tests stay hermetic.
let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive)
manager.delegate = self
// Board posts sync from the board store (their retention owner) so
// deleted/expired posts drop out of rounds immediately. Real sessions
// only, matching the archive: unit tests stay hermetic.
if meshBackgroundEnabled {
manager.boardPacketsProvider = { [weak self] in
self?.boardStore.syncCandidates() ?? []
}
}
// Only start the periodic sync timers when real Bluetooth exists. In unit
// tests there is no mesh to sync with, and the periodic sign/broadcast
// churn just keeps the process busy and aggravates flaky exit hangs.
@@ -397,8 +341,6 @@ final class BLEService: NSObject {
ingressLinks.removeAll()
recentTrafficTracker.removeAll()
scheduledRelays.cancelAll()
// Let the post-panic identity publish its fresh bundle promptly.
lastPrekeyBundleSentAt = nil
return transfers
}
@@ -645,7 +587,6 @@ final class BLEService: NSObject {
let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) }
peerRegistry.removeAll()
fragmentAssemblyBuffer.removeAll()
sourceRouteFailures = BLESourceRouteFailureCache()
// Also clear pending message queues to avoid stale state across sessions
pendingNoiseSessionQueues.removeAll()
pendingDirectedRelays.removeAll()
@@ -695,32 +636,6 @@ final class BLEService: NSObject {
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
}
/// Enables or disables a runtime-advertised capability bit (e.g. the
/// internet-gateway toggle) and re-announces so peers learn promptly.
/// Build-time bits stay in `PeerCapabilities.localSupported`.
func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool) {
let changed: Bool = collectionsQueue.sync(flags: .barrier) {
let before = runtimeCapabilities
if enabled {
runtimeCapabilities.insert(capability)
} else {
runtimeCapabilities.remove(capability)
}
return runtimeCapabilities != before
}
guard changed else { return }
sendAnnounce(forceSend: true)
}
/// Reachable peers currently advertising the `.gateway` capability.
func reachableGatewayPeers() -> [PeerID] {
let now = Date()
return collectionsQueue.sync {
peerRegistry.peers(advertising: .gateway)
.filter { peerRegistry.isReachable($0, now: now) }
}
}
func getPeerNicknames() -> [PeerID: String] {
return collectionsQueue.sync {
peerRegistry.displayNicknames(selfNickname: myNickname)
@@ -1478,16 +1393,16 @@ final class BLEService: NSObject {
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
let (connectedPeerIDs, advertisedCapabilities): ([Data], PeerCapabilities) = collectionsQueue.sync {
(peerRegistry.connectedRoutingData, PeerCapabilities.localSupported.union(runtimeCapabilities))
let connectedPeerIDs: [Data] = collectionsQueue.sync {
peerRegistry.connectedRoutingData
}
let announcement = AnnouncementPacket(
nickname: myNickname,
noisePublicKey: noisePub,
signingPublicKey: signingPub,
directNeighbors: connectedPeerIDs,
capabilities: advertisedCapabilities
capabilities: PeerCapabilities.localSupported
)
guard let payload = announcement.encode() else {
@@ -1522,56 +1437,10 @@ final class BLEService: NSObject {
}
// Ensure our own announce is included in sync state
gossipSyncManager?.onPublicPacketSeen(signedPacket)
// Keep our prekey bundle riding alongside presence (throttled; the
// send is a no-op when the bundle was refreshed recently).
sendPrekeyBundle()
}
// MARK: QR Verification over Noise
// MARK: Private Groups
/// Sends creator-signed group state (invite) 1:1 over the Noise session,
/// queueing behind a handshake when none is established yet.
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {
sendNoisePayload(NoisePayload(type: .groupInvite, data: statePayload).encode(), to: peerID)
}
/// Sends creator-signed group state (key rotation / roster update) 1:1
/// over the Noise session.
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {
sendNoisePayload(NoisePayload(type: .groupKeyUpdate, data: statePayload).encode(), to: peerID)
}
/// Broadcasts a sealed group message (MessageType 0x25) like a public
/// message: fire-and-flood with gossip-sync backfill. The outer packet is
/// intentionally unsigned receivers authenticate the sender's Ed25519
/// signature inside the ciphertext, which still verifies for backfilled
/// copies long after the sender's announce has expired.
func broadcastGroupMessage(_ envelope: Data) {
guard !envelope.isEmpty else { return }
messageQueue.async { [weak self] in
guard let self else { return }
let packet = BitchatPacket(
type: MessageType.groupMessage.rawValue,
senderID: Data(hexString: self.myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: envelope,
signature: nil,
ttl: self.messageTTL
)
// Pre-mark our own broadcast as processed to avoid handling a
// relayed self copy.
let dedupID = BLESelfBroadcastTracker.dedupID(for: packet)
self.messageDeduplicator.markProcessed(dedupID)
self.broadcastPacket(packet)
// Track our own broadcast for gossip sync
self.gossipSyncManager?.onPublicPacketSeen(packet)
}
}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA)
sendNoisePayload(payload, to: peerID)
@@ -1581,18 +1450,6 @@ final class BLEService: NSObject {
guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return }
sendNoisePayload(payload, to: peerID)
}
// MARK: Vouching over Noise
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
sendNoisePayload(NoisePayload(type: .vouch, data: payload).encode(), to: peerID)
}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
// Appends to the encryption service's handler array, so this never
// displaces the callbacks installed by installNoiseSessionCallbacks.
noiseService.addOnPeerAuthenticatedHandler(handler)
}
}
// MARK: - GossipSyncManager Delegate
@@ -1972,10 +1829,6 @@ extension BLEService {
handleReceivedPacket(packet, from: fromPeerID)
}
func _test_hasGossipPrekeyBundle(for peerID: PeerID) -> Bool {
gossipSyncManager?._hasPrekeyBundle(for: peerID) ?? false
}
func _test_acceptsIngress(packet: BitchatPacket, boundPeerID: PeerID?) -> Bool {
let claimedSenderID = PeerID(hexData: packet.senderID)
guard case .success = BLEIngressLinkRegistry.packetContext(
@@ -2691,42 +2544,12 @@ extension BLEService {
}
private func computeRoute(to peerID: PeerID) -> [Data]? {
// Version-gated: every hop and the recipient must have been observed
// speaking v2, since a v1-only node drops v2 frames on decode.
meshTopology.computeRoute(
from: myPeerIDData,
to: routingData(for: peerID),
maxHops: TransportConfig.bleSourceRouteMaxIntermediateHops,
requiringVersion: 2
)
meshTopology.computeRoute(from: myPeerIDData, to: routingData(for: peerID))
}
private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket {
let now = Date()
let decision = BLESourceRouteOriginationPolicy.decide(
for: packet,
to: recipient,
localPeerIDData: myPeerIDData,
isRecipientConnected: { self.isPeerConnected($0) },
shouldAttemptRoute: { peer in
self.collectionsQueue.sync(flags: .barrier) {
self.sourceRouteFailures.shouldAttemptRoute(to: peer, now: now)
}
},
computeRoute: { self.computeRoute(to: $0) }
)
let route: [Data]
switch decision {
case .flood(let reason):
// Only log the interesting directed cases; a plain broadcast keeps
// flood by design and would spam the origination path.
if reason != .broadcast {
SecureLogger.info("[ROUTE] flood to \(recipient.id.prefix(8))… (\(reason.rawValue))", category: .mesh)
}
guard let route = computeRoute(to: recipient), route.count >= 1 else {
return packet
case .route(let hops):
SecureLogger.info("[ROUTE] source-route to \(recipient.id.prefix(8))… via \(hops.count) hop(s)", category: .mesh)
route = hops
}
// Create new packet with route applied and version upgraded to 2
let routedPacket = BitchatPacket(
@@ -2745,9 +2568,6 @@ extension BLEService {
SecureLogger.error("❌ Failed to re-sign packet with route", category: .security)
return packet // Return original packet if signing fails
}
collectionsQueue.sync(flags: .barrier) {
sourceRouteFailures.noteRoutedSend(to: recipient, now: now)
}
return signedPacket
}
@@ -2755,155 +2575,6 @@ extension BLEService {
PeerID(routingData: data)
}
// MARK: - Mesh Diagnostics (/ping, /trace, topology map)
/// Sends a directed unencrypted ping probe (8-byte nonce + origin TTL).
/// The completion fires exactly once on the main actor: with RTT/hops
/// when the matching pong returns, or nil after the timeout window.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
messageQueue.async { [weak self] in
guard let self,
let recipientData = peerID.toShort().routingData,
let payload = MeshPingPayload(
nonce: Data((0..<MeshPingPayload.nonceLength).map { _ in UInt8.random(in: .min ... .max) }),
originTTL: self.messageTTL
) else {
Task { @MainActor in completion(nil) }
return
}
let nonce = payload.nonce
let packet = BitchatPacket(
type: MessageType.ping.rawValue,
senderID: self.myPeerIDData,
recipientID: recipientData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload.encode(),
signature: nil,
ttl: self.messageTTL
)
let timeout = DispatchWorkItem { [weak self] in
guard let self else { return }
let expired = self.collectionsQueue.sync(flags: .barrier) {
self.pendingMeshPings.removeValue(forKey: nonce)
}
guard let expired else { return }
Task { @MainActor in expired.completion(nil) }
}
self.collectionsQueue.sync(flags: .barrier) {
self.pendingMeshPings[nonce] = PendingMeshPing(
peerID: PeerID(hexData: recipientData),
sentAt: Date(),
completion: completion,
timeout: timeout
)
}
self.messageQueue.asyncAfter(
deadline: .now() + TransportConfig.meshPingTimeoutSeconds,
execute: timeout
)
self.broadcastPacket(packet)
}
}
/// Answers a ping addressed to us with a pong echoing its nonce; pings
/// addressed elsewhere are left to the generic directed-relay path.
///
/// `linkPeerID` is the directly connected peer that delivered the packet
/// (the ingress link), NOT the packet's claimed sender: pings are
/// unsigned, so `packet.senderID` is attacker-controlled, and keying the
/// response budget on it would let one connected peer rotate forged
/// sender IDs to emit unbounded pongs. The budget is per physical link;
/// the pong still goes to the claimed sender (that's the protocol).
private func handleMeshPing(_ packet: BitchatPacket, fromLink linkPeerID: PeerID) {
guard packet.recipientID == myPeerIDData else { return }
guard let ping = MeshPingPayload.decode(packet.payload) else {
SecureLogger.debug("[PING] malformed ping via \(linkPeerID.id.prefix(8))", category: .mesh)
return
}
let allowed = collectionsQueue.sync(flags: .barrier) {
meshPingResponseLimiter.shouldRespond(to: linkPeerID, now: Date())
}
guard allowed else {
if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") {
SecureLogger.warning("[PING] rate-limiting pings via link \(linkPeerID.id.prefix(8))", category: .mesh)
}
return
}
guard let pong = MeshPingPayload(nonce: ping.nonce, originTTL: messageTTL) else { return }
let reply = BitchatPacket(
type: MessageType.pong.rawValue,
senderID: myPeerIDData,
recipientID: packet.senderID,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: pong.encode(),
signature: nil,
ttl: messageTTL
)
broadcastPacket(reply)
}
/// Resolves a pong against its outstanding probe. The unguessable echoed
/// nonce plus the sender check bind the reply to the probed peer; hops
/// come from the pong's TTL decrements on the return path.
private func handleMeshPong(_ packet: BitchatPacket, from peerID: PeerID) {
guard packet.recipientID == myPeerIDData else { return }
guard let pong = MeshPingPayload.decode(packet.payload) else { return }
let pending = collectionsQueue.sync(flags: .barrier) { () -> PendingMeshPing? in
guard pendingMeshPings[pong.nonce]?.peerID == peerID else { return nil }
return pendingMeshPings.removeValue(forKey: pong.nonce)
}
guard let pending else { return }
pending.timeout.cancel()
let rttMs = Int((Date().timeIntervalSince(pending.sentAt) * 1000).rounded())
let result = MeshPingResult(
rttMs: max(0, rttMs),
hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
)
SecureLogger.info("[PING] pong from \(peerID.id.prefix(8))… rtt=\(result.rttMs)ms hops=\(result.hops)", category: .mesh)
Task { @MainActor in pending.completion(result) }
}
/// Estimated intermediate hops toward `peerID`, BFS over gossiped
/// bidirectionally-confirmed neighbor claims ([] = direct, nil = none).
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
refreshLocalTopology()
if let route = computeRoute(to: peerID) {
let path = route.compactMap { PeerID(routingData: $0) }
SecureLogger.info("[TRACE] path to \(peerID.id.prefix(8))… via \(path.count) hop(s)", category: .mesh)
return path
}
// Confirmed claims can lag a brand-new link (the peer's next announce
// hasn't arrived yet); a live direct connection is still a known path.
let direct = isPeerConnected(peerID)
SecureLogger.info("[TRACE] path to \(peerID.id.prefix(8))\(direct ? "direct (0 hops)" : "no known path")", category: .mesh)
return direct ? [] : nil
}
/// Mesh graph for the topology map. Edges are advisory: announces cap
/// neighbor lists at 10, so an edge claimed by either endpoint counts.
func currentMeshTopology() -> MeshTopologySnapshot? {
refreshLocalTopology()
let claims = meshTopology.adjacencySnapshot()
var nodes = Set<PeerID>()
var edges = Set<MeshTopologyEdge>()
for (source, neighbors) in claims {
guard let sourcePeer = PeerID(routingData: source) else { continue }
nodes.insert(sourcePeer)
for neighborData in neighbors {
guard let neighborPeer = PeerID(routingData: neighborData),
neighborPeer != sourcePeer else { continue }
nodes.insert(neighborPeer)
edges.insert(MeshTopologyEdge(sourcePeer, neighborPeer))
}
}
nodes.insert(myPeerID)
return MeshTopologySnapshot(
localPeerID: myPeerID,
nodes: nodes.sorted(),
edges: edges.sorted { ($0.a, $0.b) < ($1.a, $1.b) }
)
}
private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool {
let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData)
let plan = BLERouteForwardingPolicy.plan(
@@ -3000,13 +2671,10 @@ extension BLEService {
// MARK: Courier Store-and-Forward
/// Seal `content` for the recipient and hand the envelope to the given
/// couriers for physical delivery. When a verified one-time prekey bundle
/// is cached for the recipient, sealing targets one of its prekeys
/// (forward secret, envelope v2); otherwise it falls back to their static
/// key (one-way Noise X, v1) exactly as before. Returns false when no
/// courier is connected, the payload cannot be built, or sealing fails;
/// link writes are queued asynchronously after the envelope is ready.
/// Seal `content` to the recipient's static key (one-way Noise X) and hand
/// the envelope to the given couriers for physical delivery. Returns false
/// when no courier is connected, the payload cannot be built, or sealing
/// fails; link writes are queued asynchronously after the envelope is ready.
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
let connected = couriers.filter { isPeerConnected($0) }
guard !connected.isEmpty,
@@ -3017,17 +2685,7 @@ extension BLEService {
let payload: Data
do {
let now = Date()
let sealed: Data
let prekeyID: UInt32?
if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
prekeyID = prekey.id
SecureLogger.info("[PREKEY] seal prekey (id=\(prekey.id)) id=\(messageID.prefix(8))", category: .session)
} else {
sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
prekeyID = nil
SecureLogger.info("[PREKEY] seal static (no bundle) id=\(messageID.prefix(8))", category: .session)
}
let sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
let envelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: recipientNoiseKey,
@@ -3035,8 +2693,7 @@ extension BLEService {
),
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
ciphertext: sealed,
copies: TransportConfig.courierInitialCopies,
prekeyID: prekeyID
copies: TransportConfig.courierInitialCopies
)
guard let encoded = envelope.encode() else { return false }
payload = encoded
@@ -3055,22 +2712,6 @@ extension BLEService {
return true
}
/// The prekey to seal a courier message with, or nil to fall back to
/// static sealing. The real signal is a verified, unexpired bundle with a
/// spare prekey; the advertised `.prekeys` capability only acts as a veto
/// for peers we currently see on the mesh (a cached bundle can outlive a
/// peer's downgrade to a build that no longer holds the privates).
/// Re-deposits of the same message reuse its assigned prekey, so one
/// message consumes exactly one prekey ID regardless of courier count.
private func assignRecipientPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? {
let shortID = PeerID(publicKey: recipientNoiseKey)
let knownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil }
if knownOnMesh, !peerCapabilities(shortID).contains(.prekeys) {
return nil
}
return prekeyBundleStore.assignPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey)
}
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
let packet = BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
@@ -3106,26 +2747,7 @@ extension BLEService {
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
do {
let typedPayload: Data
let senderStaticKey: Data
if let prekeyID = envelope.prekeyID {
// Envelope v2: sealed to one of our one-time prekeys. Opening
// consumes the prekey (48h redelivery grace), which shrinks our
// published bundle under a strictly newer generatedAt. Re-gossip
// so peers replace their cached copy and stop assigning the
// consumed ID before the grace lapses; force the broadcast when
// the batch also topped back up (low-water), otherwise let the
// rebroadcast throttle coalesce bursts.
let opened = try noiseService.openPrekeyPayload(envelope.ciphertext, prekeyID: prekeyID)
(typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey)
if opened.consumedPrekey {
let replenished = noiseService.replenishPrekeysIfNeeded()
SecureLogger.info("[PREKEY] consume id=\(prekeyID), re-gossip bundle (replenished=\(replenished))", category: .session)
sendPrekeyBundle(force: replenished)
}
} else {
(typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
}
let (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
guard let typeRaw = typedPayload.first,
let payloadType = NoisePayloadType(rawValue: typeRaw),
payloadType == .privateMessage else {
@@ -3256,232 +2878,6 @@ extension BLEService {
}
}
// MARK: One-Time Prekey Bundles
/// Broadcasts our signed prekey bundle and tracks it for gossip sync.
/// Unforced sends (piggybacked on announces) are throttled gossip does
/// the spreading, the broadcast just keeps our own gossip entry fresh.
/// Forced sends (bundle changed after consumption) go immediately.
private func sendPrekeyBundle(force: Bool = false) {
let now = Date()
let shouldSend: Bool = collectionsQueue.sync(flags: .barrier) {
if !force,
let last = lastPrekeyBundleSentAt,
now.timeIntervalSince(last) < TransportConfig.prekeyBundleRebroadcastSeconds {
return false
}
lastPrekeyBundleSentAt = now
return true
}
guard shouldSend else { return }
guard let bundle = noiseService.currentPrekeyBundle(),
let payload = bundle.encode() else {
SecureLogger.error("❌ Failed to build prekey bundle", category: .security)
return
}
let packet = BitchatPacket(
type: MessageType.prekeyBundle.rawValue,
senderID: myPeerIDData,
recipientID: nil,
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
guard let signedPacket = noiseService.signPacket(packet) else {
SecureLogger.error("❌ Failed to sign prekey bundle packet", category: .security)
return
}
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(signedPacket)
} else {
messageQueue.async { [weak self] in
self?.broadcastPacket(signedPacket)
}
}
gossipSyncManager?.onPublicPacketSeen(signedPacket)
}
/// Ingests a gossiped prekey bundle. Attribution is layered: the outer
/// packet must originate from the bundle owner (fabricated sender IDs, used
/// to multiply cache/gossip entries, are rejected), and BOTH the inner
/// bundle signature and the outer packet signature must verify against the
/// owner's announce-bound signing key. Verifying the outer packet whose
/// signed bytes cover senderID and timestamp stops a valid bundle from
/// being replayed under a fresh timestamp or spoofed sender to pass
/// freshness or poison attribution. Only after that does the packet enter
/// our own gossip store, so we never help spread a bundle we couldn't
/// attribute.
private func handlePrekeyBundle(_ packet: BitchatPacket, from peerID: PeerID) {
guard let bundle = PrekeyBundle.decode(packet.payload) else {
SecureLogger.debug("🔑 Ignoring malformed prekey bundle from \(peerID.id.prefix(8))", category: .security)
return
}
// Our own bundle is tracked at send time; a copy echoing back adds nothing.
guard bundle.noiseStaticPublicKey != noiseService.getStaticPublicKeyData() else { return }
let owner = PeerID(publicKey: bundle.noiseStaticPublicKey)
// The owner's genuine bundle (direct or relayed) always carries the
// owner's senderID + outer signature; gossip resends preserve both. A
// packet whose senderID isn't the owner can't be authenticated here.
guard PeerID(hexData: packet.senderID) == owner else {
SecureLogger.debug("🔑 Ignoring prekey bundle whose sender ≠ owner \(owner.id.prefix(8))", category: .security)
return
}
// Look up the announce-bound signing key and stash-if-unbound in ONE
// barrier: the receive queue is concurrent, so this bundle can race
// ahead of the announce that binds the key. Reading the live registry
// and stashing atomically closes the check-then-act gap against
// handleAnnounce's drain (see drainPendingPrekeyBundles).
let signingKey: Data? = collectionsQueue.sync(flags: .barrier) {
if let info = peerRegistry.info(for: owner),
info.noisePublicKey == bundle.noiseStaticPublicKey,
let key = info.signingPublicKey {
return key
}
// Offline-verified identities are stable across this race.
for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(owner)
where candidate.publicKey == bundle.noiseStaticPublicKey {
if let key = candidate.signingPublicKey { return key }
}
// No binding yet: retain the latest bundle per owner, bounded, and
// retry once the verified announce lands.
if pendingPrekeyBundles[owner] != nil
|| pendingPrekeyBundles.count < Self.pendingPrekeyBundleCap {
pendingPrekeyBundles[owner] = packet
}
return nil
}
guard let signingKey else {
SecureLogger.debug("[PREKEY] bundle defer (no bound signing key) owner \(owner.id.prefix(8))", category: .session)
return
}
ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
}
/// Verify a bundle's inner + outer signatures against the owner's bound
/// signing key and, on success, cache it and let it enter our gossip store.
private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) {
guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey),
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
SecureLogger.info("[PREKEY] bundle reject (bad signature) owner \(owner.id.prefix(8))", category: .session)
return
}
if prekeyBundleStore.ingest(bundle) {
SecureLogger.info("[PREKEY] bundle accept owner \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .session)
} else {
SecureLogger.debug("[PREKEY] bundle reject (not newer/invalid) owner \(owner.id.prefix(8))", category: .session)
}
gossipSyncManager?.onPublicPacketSeen(packet)
}
/// Re-attempt any prekey bundle that arrived before this owner's announce
/// bound a signing key. Called from handleAnnounce after a verified
/// announce, in a barrier ordered after the registry write, so a bundle
/// stashed before the write is always observed here.
private func drainPendingPrekeyBundles(for owner: PeerID) {
let pending: BitchatPacket? = collectionsQueue.sync(flags: .barrier) {
pendingPrekeyBundles.removeValue(forKey: owner)
}
guard let packet = pending,
let bundle = PrekeyBundle.decode(packet.payload),
let signingKey = announceBoundSigningKey(forNoiseKey: bundle.noiseStaticPublicKey) else { return }
ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
}
/// Ed25519 signing key bound to a Noise static key by a verified
/// announce: from the live registry when the owner is on the mesh, else
/// from identities persisted for offline verification.
private func announceBoundSigningKey(forNoiseKey noiseKey: Data) -> Data? {
let shortID = PeerID(publicKey: noiseKey)
if let info = collectionsQueue.sync(execute: { peerRegistry.info(for: shortID) }),
info.noisePublicKey == noiseKey,
let signingKey = info.signingPublicKey {
return signingKey
}
for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(shortID)
where candidate.publicKey == noiseKey {
if let signingKey = candidate.signingPublicKey {
return signingKey
}
}
return nil
}
// MARK: Gateway carrier (nostrCarrier)
/// Sign and send an encoded `toGateway` carrier payload directed at a
/// gateway peer. The packet is signed so the gateway can key its uplink
/// quotas to an authenticated depositor; the carried Nostr event has its
/// own Schnorr signature for content authenticity. Returns false when
/// the gateway is not reachable or signing fails.
func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool {
guard isPeerReachable(gatewayPeer) else { return false }
let packet = BitchatPacket(
type: MessageType.nostrCarrier.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: gatewayPeer.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
guard let signed = noiseService.signPacket(packet) else { return false }
messageQueue.async { [weak self] in
// broadcastPacket applies a known route when one exists and
// otherwise floods the directed packet like a DM, so a gateway
// that is reachable but multi-hop still gets the deposit.
self?.broadcastPacket(signed)
}
return true
}
/// Broadcast an encoded `fromGateway` carrier payload on the mesh with
/// the default TTL. Unsigned at the packet layer receivers verify the
/// carried event's own Schnorr signature.
func broadcastNostrCarrier(_ payload: Data) {
let packet = BitchatPacket(
type: MessageType.nostrCarrier.rawValue,
senderID: myPeerIDData,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
messageQueue.async { [weak self] in
self?.broadcastPacket(packet)
}
}
/// Transport-level handling for a received nostrCarrier packet; policy
/// (verification of the carried event, quotas, loop prevention) lives in
/// `GatewayService` behind `onNostrCarrierPacket`.
private func handleNostrCarrier(_ packet: BitchatPacket, from peerID: PeerID) {
let senderID = PeerID(hexData: packet.senderID)
let directedToUs: Bool
if let recipientID = packet.recipientID {
// Carriers addressed elsewhere ride the generic relay path untouched.
guard recipientID == myPeerIDData else { return }
// Uplink deposit: quotas are keyed by the depositor, so the
// packet signature must verify against the sender's announced
// signing key. Unlike courier deposits the depositor may be
// multi-hop away, so ingress-link identity is not required.
let signingKey = collectionsQueue.sync { peerRegistry.info(for: senderID)?.signingPublicKey }
guard let signingKey,
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
SecureLogger.debug("🌐 nostrCarrier uplink from \(senderID.id.prefix(8))… rejected (missing/invalid packet signature)", category: .security)
return
}
directedToUs = true
} else {
directedToUs = false
}
let payload = packet.payload
notifyUI { [weak self] in
self?.onNostrCarrierPacket?(payload, senderID, directedToUs)
}
}
// MARK: Link capability snapshots (thread-safe via bleQueue)
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
@@ -3719,7 +3115,7 @@ extension BLEService {
// Notify delegate of failure
notifyUI { [weak self] in
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", defaultValue: "encryption failed", comment: "Failure reason shown when a message could not be encrypted for the peer"))))
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer"))))
}
}
}
@@ -3943,23 +3339,13 @@ extension BLEService {
// Update peer info without verbose logging - update the peer we received from, not the original sender
updatePeerLastSeen(peerID)
// Track recent traffic timestamps for adaptive behavior; the same
// barrier hop confirms route health for the packet's originator.
// Track recent traffic timestamps for adaptive behavior
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self.recentTrafficTracker.recordPacket(at: Date())
self.sourceRouteFailures.noteInboundActivity(from: senderID)
}
// Per-peer protocol version: originated source routes only use hops
// observed speaking v2 (a v1-only node cannot decode v2 frames).
if packet.version >= 2 {
meshTopology.recordObservedVersion(packet.version, for: packet.senderID)
if peerID != senderID {
meshTopology.recordObservedVersion(packet.version, for: routingData(for: peerID))
}
}
// Process by type
switch context.messageType {
case .announce:
@@ -3986,28 +3372,6 @@ extension BLEService {
case .courierEnvelope:
handleCourierEnvelope(packet, from: peerID)
case .prekeyBundle:
handlePrekeyBundle(packet, from: senderID)
case .groupMessage:
handleGroupMessage(packet, from: senderID)
case .nostrCarrier:
handleNostrCarrier(packet, from: peerID)
case .ping:
// Rate limiting must key on the ingress link (`peerID`), not the
// packet-claimed sender: pings are unsigned, so `senderID` is
// attacker-controlled and rotating it would reset the budget.
handleMeshPing(packet, fromLink: peerID)
case .pong:
handleMeshPong(packet, from: senderID)
case .boardPost:
// Invalid or deleted posts must not spread; skip the relay step.
guard handleBoardPost(packet, from: senderID) else { return }
case .leave:
handleLeave(packet, from: senderID)
@@ -4071,12 +3435,6 @@ extension BLEService {
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
let result = announceHandler.handle(packet, from: peerID)
// A verified announce is the moment a signing key becomes bound to this
// owner's noise key: retry any prekey bundle that raced ahead of it.
if let result, result.isVerified {
drainPendingPrekeyBundles(for: result.peerID)
}
// Courier work: an announce is the moment we learn a peer's Noise
// static key, so check whether we're carrying mail addressed to them
// (or spray-able mail they could carry). Verified announces only.
@@ -4186,63 +3544,6 @@ extension BLEService {
)
}
// MARK: - Board (geohash bulletin board)
/// Validates and stores an incoming board post or tombstone. Returns
/// whether the packet is worth relaying onward.
private func handleBoardPost(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
guard let wire = BoardWire.decode(from: packet.payload) else {
SecureLogger.warning("⚠️ Malformed board packet from \(peerID.id.prefix(8))", category: .session)
return false
}
// Posts are self-authenticating: the payload embeds the author's
// Ed25519 key and signature, so verification does not depend on the
// author still being around to announce.
guard wire.verifySignature() else {
if logRateLimiter.shouldLog(key: "board-sig:\(peerID.id)") {
SecureLogger.warning("🚫 Dropping board packet with invalid signature from \(peerID.id.prefix(8))", category: .security)
}
return false
}
switch boardStore.ingest(wire, packet: packet) {
case .accepted, .duplicate:
return true
case .rejected:
return false
}
}
/// Broadcasts a pre-signed board payload (post or tombstone) built by the
/// board manager, and ingests it locally so it shows up on our own board
/// and joins gossip sync immediately.
func sendBoardPayload(_ payload: Data) {
guard let wire = BoardWire.decode(from: payload), wire.verifySignature() else {
SecureLogger.error("❌ Refusing to send invalid board payload", category: .session)
return
}
messageQueue.async { [weak self] in
guard let self = self else { return }
let basePacket = BitchatPacket(
type: MessageType.boardPost.rawValue,
senderID: Data(hexString: self.myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: self.messageTTL
)
guard let signedPacket = self.noiseService.signPacket(basePacket) else {
SecureLogger.error("❌ Failed to sign board packet", category: .security)
return
}
// Pre-mark our own broadcast as processed to avoid handling a relayed self copy
let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
self.messageDeduplicator.markProcessed(dedupID)
self.boardStore.ingest(wire, packet: signedPacket)
self.broadcastPacket(signedPacket)
}
}
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) {
// REQUEST_SYNC is link-local by design (always sent with ttl 0): a
@@ -4327,26 +3628,6 @@ extension BLEService {
)
}
/// Group broadcasts are opaque ciphertext to this layer: track them for
/// gossip backfill and hand the payload to the UI layer, where the group
/// coordinator decrypts and authenticates against the roster. Non-members
/// still relay (generic broadcast relay path) but never decode.
private func handleGroupMessage(_ packet: BitchatPacket, from peerID: PeerID) {
let isBroadcastRecipient: Bool = {
guard let recipient = packet.recipientID else { return true }
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
}()
guard isBroadcastRecipient, !packet.payload.isEmpty else { return }
gossipSyncManager?.onPublicPacketSeen(packet)
let payload = packet.payload
let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
notifyUI { [weak self] in
self?.deliverTransportEvent(.groupMessageReceived(payload: payload, timestamp: timestamp))
}
}
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
noisePacketHandler.handleHandshake(packet, from: peerID)
}
@@ -4583,21 +3864,10 @@ extension BLEService {
// Clean old processed messages efficiently
messageDeduplicator.cleanup()
// Clean old fragments (> configured seconds old), then ask peers for
// the specific fragment streams whose reassembly has stalled instead
// of waiting for the next periodic GCS fragment round.
let stalledFragmentIDs = collectionsQueue.sync(flags: .barrier) { () -> [Data] in
// Clean old fragments (> configured seconds old)
collectionsQueue.sync(flags: .barrier) {
let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds)
fragmentAssemblyBuffer.removeExpired(before: cutoff)
sourceRouteFailures.prune(now: now)
return fragmentAssemblyBuffer.stalledBroadcastFragmentIDs(
stalledAfter: TransportConfig.bleFragmentResyncStallSeconds,
retryAfter: TransportConfig.bleFragmentResyncRetrySeconds,
now: now
)
}
if !stalledFragmentIDs.isEmpty {
gossipSyncManager?.requestMissingFragments(fragmentIDs: stalledFragmentIDs)
}
// Clean old connection timeout backoff entries (> window)
@@ -1,95 +0,0 @@
import BitFoundation
import Foundation
/// Tracks whether source-routed sends to a recipient appear to be working.
///
/// A routed unicast rides exactly one path, so a broken hop silently loses the
/// packet where a flood would have healed around it. Rather than building a
/// retransmission machine (MessageRouter already retries at a higher layer),
/// this cache degrades: a routed send that sees no inbound traffic from the
/// recipient within the confirmation window marks the route as failed, and
/// subsequent sends fall back to flooding until the suppression TTL lapses.
struct BLESourceRouteFailureCache {
struct Config {
/// How long a routed send may go unconfirmed before it counts as a
/// route failure.
var confirmationWindowSeconds: TimeInterval = TransportConfig.bleSourceRouteConfirmationWindowSeconds
/// How long to flood instead of routing after a failure.
var suppressionSeconds: TimeInterval = TransportConfig.bleSourceRouteSuppressionSeconds
}
private struct State {
var pendingSince: Date?
var suppressedUntil: Date?
}
private let config: Config
private var states: [PeerID: State] = [:]
init(config: Config = Config()) {
self.config = config
}
/// Whether the next directed send to `recipient` may carry a source
/// route. Flips the recipient into suppression when the last routed send
/// went unconfirmed past the confirmation window.
mutating func shouldAttemptRoute(to recipient: PeerID, now: Date = Date()) -> Bool {
guard var state = states[recipient] else { return true }
if let until = state.suppressedUntil {
guard now >= until else { return false }
state.suppressedUntil = nil
}
if let pending = state.pendingSince,
now.timeIntervalSince(pending) > config.confirmationWindowSeconds {
// The routed send was never confirmed: treat the route as broken
// and flood until the suppression window lapses.
state.pendingSince = nil
state.suppressedUntil = now.addingTimeInterval(config.suppressionSeconds)
states[recipient] = state
return false
}
states[recipient] = state
return true
}
/// Records that a source-routed packet was sent to `recipient`. Keeps the
/// earliest unconfirmed send so back-to-back packets share one deadline.
mutating func noteRoutedSend(to recipient: PeerID, now: Date = Date()) {
var state = states[recipient] ?? State()
if state.pendingSince == nil {
state.pendingSince = now
}
states[recipient] = state
}
/// Any inbound packet authored by `peer` confirms the pending routed send
/// (delivery acks and replies arrive this way). Deliberately does not
/// lift an active suppression: that traffic may have arrived via flood.
mutating func noteInboundActivity(from peer: PeerID) {
guard var state = states[peer] else { return }
state.pendingSince = nil
if state.suppressedUntil == nil {
states.removeValue(forKey: peer)
} else {
states[peer] = state
}
}
/// Drops entries that can no longer influence a routing decision. An
/// expired-but-unconverted pending entry is kept for as long as the
/// suppression it would trigger could still be active.
mutating func prune(now: Date = Date()) {
let pendingRetention = config.confirmationWindowSeconds + config.suppressionSeconds
states = states.filter { _, state in
if let until = state.suppressedUntil, now < until { return true }
if let pending = state.pendingSince,
now.timeIntervalSince(pending) <= pendingRetention {
return true
}
return false
}
}
}
@@ -1,59 +0,0 @@
import BitFoundation
import Foundation
/// Decides whether an outbound directed packet should carry a v2 source
/// route. Pure gating logic so BLEService's hot send path stays a thin wire.
enum BLESourceRouteOriginationPolicy {
/// Why a packet kept flood/direct-write behavior instead of routing.
/// The `rawValue` doubles as the greppable `[ROUTE]` log reason.
enum FloodReason: String {
case relayedNotOriginator = "not originator"
case broadcast = "broadcast recipient"
case noTTLHeadroom = "link-local ttl"
case recipientDirect = "recipient direct"
case routeSuppressed = "route suppressed→flood"
case noPath = "no v2 path"
}
/// The routing decision for an originated packet.
enum Decision: Equatable {
/// Keep flood/direct-write behavior unchanged; carries the reason.
case flood(FloodReason)
/// Originate a v2 source route over these intermediate hops.
case route([Data])
}
/// Returns whether to originate a v2 source route (with its hops) or keep
/// flood/direct-write, with the reason for the latter.
///
/// Routes are only originated when every gate passes:
/// - we authored the packet (relays must not rewrite and re-sign someone
/// else's packet; route-following for in-flight routed packets lives in
/// `BLERouteForwardingPolicy`),
/// - the packet is directed at a single peer (not broadcast),
/// - the packet has TTL headroom to traverse hops (link-local TTL-0
/// packets like REQUEST_SYNC never route),
/// - the recipient is not directly connected (a direct write already
/// delivers in one hop),
/// - routing to the recipient is not suppressed by a recent unconfirmed
/// routed send, and
/// - the topology yields a complete path.
static func decide(
for packet: BitchatPacket,
to recipient: PeerID,
localPeerIDData: Data,
isRecipientConnected: (PeerID) -> Bool,
shouldAttemptRoute: (PeerID) -> Bool,
computeRoute: (PeerID) -> [Data]?
) -> Decision {
guard packet.senderID == localPeerIDData else { return .flood(.relayedNotOriginator) }
guard let recipientData = packet.recipientID,
recipientData.count == 8,
!recipientData.allSatisfy({ $0 == 0xFF }) else { return .flood(.broadcast) }
guard packet.ttl > 1 else { return .flood(.noTTLHeadroom) }
guard !isRecipientConnected(recipient) else { return .flood(.recipientDirect) }
guard shouldAttemptRoute(recipient) else { return .flood(.routeSuppressed) }
guard let route = computeRoute(recipient), !route.isEmpty else { return .flood(.noPath) }
return .route(route)
}
}
-165
View File
@@ -1,165 +0,0 @@
//
// BoardManager.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Combine
import Foundation
/// UI-facing coordinator for the bulletin board: builds and signs posts and
/// tombstones with the device's Noise signing key, hands them to the mesh
/// transport, and mirrors the store's live posts for SwiftUI.
@MainActor
final class BoardManager: ObservableObject {
/// Live posts across all boards, newest state from the store.
@Published private(set) var posts: [BoardPostPacket] = []
private let transport: Transport
private let store: BoardStore
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String) -> Void
private var cancellable: AnyCancellable?
init(
transport: Transport,
store: BoardStore = .shared,
publishToNostr: ((String, String, String) -> Void)? = nil
) {
self.transport = transport
self.store = store
self.publishToNostr = publishToNostr ?? Self.livePublishToNostr
cancellable = store.$postsSnapshot
.receive(on: DispatchQueue.main)
.sink { [weak self] snapshot in
self?.posts = snapshot
}
}
/// Posts for one board context, urgent first, then newest first.
func posts(forGeohash geohash: String) -> [BoardPostPacket] {
posts
.filter { $0.geohash == geohash }
.sorted {
if $0.isUrgent != $1.isUrgent { return $0.isUrgent }
return $0.createdAt > $1.createdAt
}
}
func isOwnPost(_ post: BoardPostPacket) -> Bool {
let key = transport.noiseSigningPublicKeyData()
return !key.isEmpty && key == post.authorSigningKey
}
/// Creates, signs, and broadcasts a board post. Returns false when the
/// content is empty/oversized or signing fails.
@discardableResult
func createPost(
content: String,
geohash: String,
urgent: Bool,
expiryDays: Int,
nickname: String
) -> Bool {
guard let trimmed = content.trimmedOrNilIfEmpty,
trimmed.utf8.count <= BoardWireConstants.contentMaxBytes else {
return false
}
let signingKey = transport.noiseSigningPublicKeyData()
guard signingKey.count == BoardWireConstants.signingKeyLength else { return false }
var cleanNickname = nickname
while cleanNickname.utf8.count > BoardWireConstants.nicknameMaxBytes {
cleanNickname.removeLast()
}
let createdAt = UInt64(Date().timeIntervalSince1970 * 1000)
let lifetimeMs = min(
UInt64(max(1, expiryDays)) * 24 * 60 * 60 * 1000,
BoardWireConstants.maxLifetimeMs
)
let expiresAt = createdAt + lifetimeMs
let flags: UInt8 = urgent ? BoardPostPacket.urgentFlag : 0
var postID = Data(count: BoardWireConstants.postIDLength)
let status = postID.withUnsafeMutableBytes { buffer -> Int32 in
guard let base = buffer.baseAddress else { return -1 }
return SecRandomCopyBytes(kSecRandomDefault, buffer.count, base)
}
guard status == errSecSuccess else { return false }
let signingBytes = BoardPostPacket.signingBytes(
postID: postID,
geohash: geohash,
content: trimmed,
authorSigningKey: signingKey,
authorNickname: cleanNickname,
createdAt: createdAt,
expiresAt: expiresAt,
flags: flags
)
guard let signature = transport.noiseSignData(signingBytes) else {
SecureLogger.error("Board: failed to sign post", category: .session)
return false
}
let post = BoardPostPacket(
postID: postID,
geohash: geohash,
content: trimmed,
authorSigningKey: signingKey,
authorNickname: cleanNickname,
createdAt: createdAt,
expiresAt: expiresAt,
flags: flags,
signature: signature
)
transport.sendBoardPayload(BoardWire.post(post).encode())
// One-way Nostr bridge (v1): geohash posts also go out as kind-1
// location notes so online users see them. No inbound merge yet.
if !geohash.isEmpty {
publishToNostr(trimmed, geohash, cleanNickname)
}
return true
}
/// Signs and broadcasts a tombstone for one of our own posts.
@discardableResult
func deletePost(_ post: BoardPostPacket) -> Bool {
guard isOwnPost(post) else { return false }
let deletedAt = UInt64(Date().timeIntervalSince1970 * 1000)
let signingBytes = BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt)
guard let signature = transport.noiseSignData(signingBytes) else {
SecureLogger.error("Board: failed to sign tombstone", category: .session)
return false
}
let tombstone = BoardTombstonePacket(
postID: post.postID,
authorSigningKey: post.authorSigningKey,
deletedAt: deletedAt,
signature: signature
)
transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
return true
}
private static func livePublishToNostr(content: String, geohash: String, nickname: String) {
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session)
return
}
do {
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
let event = try NostrProtocol.createGeohashTextNote(
content: content,
geohash: geohash,
senderIdentity: identity,
nickname: nickname
)
NostrRelayManager.shared.sendEvent(event, to: relays)
} catch {
SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session)
}
}
}
-358
View File
@@ -1,358 +0,0 @@
//
// BoardStore.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 Combine
import Foundation
/// Outcome of feeding a board packet into the store, so the transport can
/// decide whether the packet is still worth relaying.
enum BoardIngestResult {
/// New post or tombstone accepted (or a quota rejected it locally while
/// it remains valid for other devices).
case accepted
/// Already known; nothing changed.
case duplicate
/// Invalid, expired, or deleted; do not relay.
case rejected
}
/// Persistent storage for bulletin-board posts and their tombstones.
///
/// Posts are signed public notices designed to outlive chat: they stay on
/// disk until their author-chosen expiry (max 7 days) and re-enter gossip
/// sync after a restart. Tombstones are retained until the deleted post's
/// original expiry so the delete keeps outrunning stale copies of the post.
///
/// The on-disk format is the raw signed packets themselves (like
/// `GossipMessageArchive`); state is rebuilt by re-verifying and re-ingesting
/// them on launch. Wiped on panic.
final class BoardStore {
enum Limits {
static let maxPosts = 200
static let maxPostsPerAuthor = 5
/// Retention for a tombstone whose post we never saw: we cannot know
/// the original expiry, so cap at the max post lifetime.
static let orphanTombstoneLifetimeMs = BoardWireConstants.maxLifetimeMs
/// Orphan tombstones name posts nobody here has seen, so their volume
/// is entirely sender-controlled; cap them like posts.
static let maxOrphanTombstones = 100
static let maxOrphanTombstonesPerAuthor = 5
/// Allowance for clock skew between peers when judging received
/// timestamps against local time.
static let clockSkewMs: UInt64 = 60 * 60 * 1000
}
private struct StoredPost {
let post: BoardPostPacket
let packet: BitchatPacket
let rawPacket: Data
}
private struct StoredTombstone {
let tombstone: BoardTombstonePacket
let packet: BitchatPacket
let rawPacket: Data
let retainUntil: UInt64
/// True when no matching post was known at ingest time; only these
/// count against the orphan caps.
let isOrphan: Bool
}
/// On-disk entry: the raw signed packet, plus the retention deadline for
/// tombstones (derived from the deleted post's original expiry, which is
/// no longer recoverable once the post is gone).
private struct PersistedEntry: Codable {
let packet: Data
let retainUntil: UInt64?
}
static let shared = BoardStore()
/// Live posts, published on the main thread for the board UI.
@Published private(set) var postsSnapshot: [BoardPostPacket] = []
private var posts: [StoredPost] = []
private var tombstones: [StoredTombstone] = []
private let queue = DispatchQueue(label: "chat.bitchat.board.store")
private let fileURL: URL?
private let now: () -> Date
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
self.now = now
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
loadFromDisk()
}
// MARK: - Ingest
/// Ingest a board packet whose payload decodes to `wire`. The caller must
/// have verified the wire signature already (`BoardWire.verifySignature`).
@discardableResult
func ingest(_ wire: BoardWire, packet: BitchatPacket) -> BoardIngestResult {
guard let rawPacket = packet.toBinaryData(padding: false) else { return .rejected }
let nowMs = currentMs()
return queue.sync {
let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
if result == .accepted {
persistLocked()
}
return result
}
}
// MARK: - Reads
/// Live posts scoped to one board (geohash, or "" for the mesh board).
func posts(forGeohash geohash: String) -> [BoardPostPacket] {
let nowMs = currentMs()
return queue.sync {
pruneExpiredLocked(nowMs: nowMs)
return posts.map(\.post).filter { $0.geohash == geohash }
}
}
/// Raw signed packets (posts and live tombstones) for gossip sync rounds.
func syncCandidates() -> [BitchatPacket] {
let nowMs = currentMs()
return queue.sync {
pruneExpiredLocked(nowMs: nowMs)
return posts.map(\.packet) + tombstones.map(\.packet)
}
}
// MARK: - Maintenance
func pruneExpired() {
let nowMs = currentMs()
queue.sync {
pruneExpiredLocked(nowMs: nowMs)
persistLocked()
}
}
/// Panic wipe: drop all board data from memory and disk.
func wipe() {
queue.sync {
posts.removeAll()
tombstones.removeAll()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
publishSnapshotLocked()
}
}
// MARK: - Internals (call only on `queue`)
private func ingestLocked(
_ wire: BoardWire,
packet: BitchatPacket,
rawPacket: Data,
nowMs: UInt64,
retainUntilOverride: UInt64? = nil
) -> BoardIngestResult {
pruneExpiredLocked(nowMs: nowMs)
switch wire {
case .post(let post):
return ingestPostLocked(post, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
case .tombstone(let tombstone):
return ingestTombstoneLocked(tombstone, packet: packet, rawPacket: rawPacket, nowMs: nowMs, retainUntilOverride: retainUntilOverride)
}
}
private func ingestPostLocked(_ post: BoardPostPacket, packet: BitchatPacket, rawPacket: Data, nowMs: UInt64) -> BoardIngestResult {
guard post.expiresAt > nowMs else { return .rejected }
// Receive-time sanity (this is the single chokepoint for radio, sync,
// and disk restores): the decoder only enforces the createdAt to
// expiresAt span, so a forged future createdAt would sort ahead of
// honest posts and hold a store slot without ever pruning.
guard post.createdAt <= nowMs &+ Limits.clockSkewMs,
post.expiresAt <= nowMs &+ BoardWireConstants.maxLifetimeMs &+ Limits.clockSkewMs else {
return .rejected
}
if tombstones.contains(where: { $0.tombstone.postID == post.postID && $0.tombstone.authorSigningKey == post.authorSigningKey }) {
return .rejected
}
guard !posts.contains(where: { $0.post.postID == post.postID }) else { return .duplicate }
posts.append(StoredPost(post: post, packet: packet, rawPacket: rawPacket))
// Per-author cap, then global cap; oldest posts are evicted first.
let authorPosts = posts.filter { $0.post.authorSigningKey == post.authorSigningKey }
if authorPosts.count > Limits.maxPostsPerAuthor {
evictOldestLocked(from: authorPosts, keep: Limits.maxPostsPerAuthor)
}
if posts.count > Limits.maxPosts {
evictOldestLocked(from: posts, keep: Limits.maxPosts)
}
publishSnapshotLocked()
// Even when the new post itself was the eviction victim it stays
// valid mesh-wide; peers with room should still receive it.
return .accepted
}
private func ingestTombstoneLocked(
_ tombstone: BoardTombstonePacket,
packet: BitchatPacket,
rawPacket: Data,
nowMs: UInt64,
retainUntilOverride: UInt64? = nil
) -> BoardIngestResult {
guard !tombstones.contains(where: { $0.tombstone.postID == tombstone.postID }) else { return .duplicate }
// Cap retention by both the claimed deletion time (so a doctored file
// cannot pin a tombstone past any legal expiry) and the receive time:
// deletedAt is sender-chosen, so a far-future value must not retain
// the tombstone longer than any post still able to arrive could live.
let maxRetain = min(
tombstone.deletedAt &+ Limits.orphanTombstoneLifetimeMs,
nowMs &+ Limits.orphanTombstoneLifetimeMs &+ Limits.clockSkewMs
)
let retainUntil: UInt64
let isOrphan: Bool
if let index = posts.firstIndex(where: { $0.post.postID == tombstone.postID }) {
let target = posts[index].post
// Only the author's key can delete: the tombstone signature was
// already verified against its embedded key, so it suffices to
// require that key to be the post's author key.
guard target.authorSigningKey == tombstone.authorSigningKey else { return .rejected }
retainUntil = target.expiresAt
isOrphan = false
posts.remove(at: index)
publishSnapshotLocked()
} else if let retainUntilOverride {
// Restored from disk: the post is long gone, so trust the
// retention deadline recorded when the delete was first applied.
// Orphans were already capped when first ingested off the air.
retainUntil = min(retainUntilOverride, maxRetain)
isOrphan = false
} else {
// Post unknown (tombstone raced ahead); keep it around so the
// post is suppressed if it arrives later.
retainUntil = maxRetain
isOrphan = true
}
guard retainUntil > nowMs else { return .rejected }
tombstones.append(StoredTombstone(tombstone: tombstone, packet: packet, rawPacket: rawPacket, retainUntil: retainUntil, isOrphan: isOrphan))
if isOrphan {
enforceOrphanTombstoneCapsLocked(author: tombstone.authorSigningKey)
}
// Like posts, a locally evicted tombstone stays valid mesh-wide.
return .accepted
}
/// Orphan tombstones reference posts we never saw, so a peer can mint
/// unlimited valid ones for random IDs; bound them per author and
/// globally, evicting the oldest received first (array order).
private func enforceOrphanTombstoneCapsLocked(author: Data) {
let authorOrphans = tombstones.filter { $0.isOrphan && $0.tombstone.authorSigningKey == author }
if authorOrphans.count > Limits.maxOrphanTombstonesPerAuthor {
removeTombstonesLocked(authorOrphans.prefix(authorOrphans.count - Limits.maxOrphanTombstonesPerAuthor))
}
let orphans = tombstones.filter(\.isOrphan)
if orphans.count > Limits.maxOrphanTombstones {
removeTombstonesLocked(orphans.prefix(orphans.count - Limits.maxOrphanTombstones))
}
}
private func removeTombstonesLocked(_ victims: ArraySlice<StoredTombstone>) {
guard !victims.isEmpty else { return }
let victimIDs = Set(victims.map { $0.tombstone.postID })
tombstones.removeAll { victimIDs.contains($0.tombstone.postID) }
}
private func evictOldestLocked(from candidates: [StoredPost], keep: Int) {
let victims = candidates
.sorted { $0.post.createdAt < $1.post.createdAt }
.prefix(max(0, candidates.count - keep))
guard !victims.isEmpty else { return }
let victimIDs = Set(victims.map { $0.post.postID })
posts.removeAll { victimIDs.contains($0.post.postID) }
}
private func pruneExpiredLocked(nowMs: UInt64) {
let postsBefore = posts.count
posts.removeAll { $0.post.expiresAt <= nowMs }
tombstones.removeAll { $0.retainUntil <= nowMs }
if posts.count != postsBefore {
publishSnapshotLocked()
}
}
private func publishSnapshotLocked() {
let snapshot = posts.map(\.post)
DispatchQueue.main.async { [weak self] in
self?.postsSnapshot = snapshot
}
}
private func currentMs() -> UInt64 {
UInt64(max(0, now().timeIntervalSince1970) * 1000)
}
// MARK: - Persistence
private func persistLocked() {
guard let fileURL else { return }
let payloads = posts.map { PersistedEntry(packet: $0.rawPacket, retainUntil: nil) }
+ tombstones.map { PersistedEntry(packet: $0.rawPacket, retainUntil: $0.retainUntil) }
do {
if payloads.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
return
}
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(payloads)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try data.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist board store: \(error)", category: .session)
}
}
private func loadFromDisk() {
guard let fileURL,
let data = try? Data(contentsOf: fileURL),
let payloads = try? JSONDecoder().decode([PersistedEntry].self, from: data) else {
return
}
let nowMs = currentMs()
queue.sync {
for entry in payloads {
guard let packet = BitchatPacket.from(entry.packet),
packet.type == MessageType.boardPost.rawValue,
let wire = BoardWire.decode(from: packet.payload),
wire.verifySignature() else { continue }
_ = ingestLocked(wire, packet: packet, rawPacket: entry.packet, nowMs: nowMs, retainUntilOverride: entry.retainUntil)
}
publishSnapshotLocked()
}
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("board", isDirectory: true)
.appendingPathComponent("posts.json")
}
}
-339
View File
@@ -1,339 +0,0 @@
//
// CashuTokenDecoder.swift
// bitchat
//
// Decodes Cashu ecash tokens (V3 `cashuA` = base64url JSON, V4 `cashuB` =
// base64url CBOR) just far enough to summarize them for the UI: total
// amount, unit, mint host, and memo. The app never contacts a mint tokens
// are bearer strings and redemption is delegated to an external wallet.
//
// This parses attacker-controlled message content, so every path is
// bounds-checked, size-capped, and returns nil instead of trapping.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
enum CashuTokenDecoder {
struct TokenInfo: Equatable {
/// Token serialization version: "A" (JSON) or "B" (CBOR).
let version: String
/// Sum of all proof amounts; nil when no valid amounts were found.
let amount: Int?
/// Currency unit as declared by the token (commonly "sat"), if any.
let unit: String?
/// Host of the (first) mint URL, for display.
let mintHost: String?
/// Optional sender memo, sanitized for display.
let memo: String?
/// "500 sat" style summary, defaulting the unit to sats per NUT-00.
var displayAmount: String? {
amount.map { "\($0) \(unit ?? "sat")" }
}
}
/// Upper bound on accepted token length in characters. Real tokens are a
/// few KB; anything much bigger is abuse we shouldn't spend CPU on.
static let maxTokenLength = 60_000
/// Per-proof and total amount sanity caps (order of total sats in existence).
private static let maxAmount: Int64 = 2_100_000_000_000_000
// MARK: - Public API
/// Extracts the bare `cashuA`/`cashuB` token from raw text that may be
/// a `cashu:`/`cashu://` URI and/or percent-encoded. Returns nil when the
/// input doesn't look like a Cashu token at all.
static func bareToken(from raw: String) -> String? {
var token = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let lower = token.lowercased()
if lower.hasPrefix("cashu://") {
token = String(token.dropFirst(8))
} else if lower.hasPrefix("cashu:") {
token = String(token.dropFirst(6))
}
if token.contains("%"), let decoded = token.removingPercentEncoding {
token = decoded
}
guard token.count >= 12, token.count <= maxTokenLength else { return nil }
guard token.hasPrefix("cashuA") || token.hasPrefix("cashuB") else { return nil }
// Base64 / base64url payload charset ('.' appears in some legacy multi-part tokens)
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_+/=."))
guard token.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil }
return token
}
/// Decodes a token (raw or `cashu:` URI form) into a display summary.
///
/// In the default (permissive) mode this is for *rendering*: V3 tokens
/// must parse as JSON, but a V4 token whose CBOR we cannot walk still
/// returns a generic `TokenInfo` (version "B", no amount) because the
/// payload may use encodings this minimal reader doesn't support an
/// unknown chip is fine for display.
///
/// In `strict` mode (used by the `/pay` SEND path) there is no permissive
/// fallback: the token must cleanly decode to a known version *and* carry
/// a positive amount, otherwise this returns nil. This stops base64 junk
/// and truncated V4 tokens from being relayed as if they were valid money.
static func decode(_ raw: String, strict: Bool = false) -> TokenInfo? {
guard let token = bareToken(from: raw) else { return nil }
let version = String(token[token.index(token.startIndex, offsetBy: 5)])
guard let payload = base64URLDecode(String(token.dropFirst(6))), !payload.isEmpty else {
return nil
}
let info: TokenInfo?
switch version {
case "A":
info = decodeV3(payload)
case "B":
if let walked = decodeV4(payload) {
info = walked
} else if strict {
// Couldn't cleanly walk the CBOR refuse to send it.
return nil
} else {
info = TokenInfo(version: "B", amount: nil, unit: nil, mintHost: nil, memo: nil)
}
default:
return nil
}
guard let info else { return nil }
if strict {
// A sendable token must resolve to a positive, sane amount.
guard let amount = info.amount, amount > 0 else { return nil }
}
return info
}
// MARK: - Base64url
private static func base64URLDecode(_ input: String) -> Data? {
var s = input
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
// Normalize padding (wallets emit both padded and unpadded forms)
s = s.replacingOccurrences(of: "=", with: "")
let remainder = s.count % 4
if remainder == 1 { return nil }
if remainder > 0 { s += String(repeating: "=", count: 4 - remainder) }
return Data(base64Encoded: s)
}
// MARK: - V3 (JSON)
private static func decodeV3(_ payload: Data) -> TokenInfo? {
guard let obj = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any],
let entries = obj["token"] as? [[String: Any]],
!entries.isEmpty else {
return nil
}
var total: Int64 = 0
var sawAmount = false
var mintHost: String?
for entry in entries {
if mintHost == nil, let mint = entry["mint"] as? String {
mintHost = sanitizedHost(from: mint)
}
for proof in (entry["proofs"] as? [[String: Any]]) ?? [] {
guard let number = proof["amount"] as? NSNumber else { continue }
let value = number.int64Value
guard value > 0, value <= maxAmount else { continue }
total += value
guard total <= maxAmount else { return nil }
sawAmount = true
}
}
return TokenInfo(
version: "A",
amount: sawAmount ? Int(total) : nil,
unit: sanitizedUnit(obj["unit"] as? String),
mintHost: mintHost,
memo: sanitizedMemo(obj["memo"] as? String)
)
}
// MARK: - V4 (CBOR)
/// Minimal walk of the NUT-00 TokenV4 CBOR map:
/// { "m": mint, "u": unit, "d": memo, "t": [ { "i": bytes, "p": [ { "a": amount, } ] } ] }
private static func decodeV4(_ payload: Data) -> TokenInfo? {
var reader = CBORReader(data: payload)
guard case .map(let pairs)? = reader.parseValue(depth: 0) else { return nil }
var mintHost: String?
var unit: String?
var memo: String?
var total: Int64 = 0
var sawAmount = false
for (key, value) in pairs {
guard case .text(let name) = key else { continue }
switch (name, value) {
case ("m", .text(let mint)):
mintHost = sanitizedHost(from: mint)
case ("u", .text(let u)):
unit = sanitizedUnit(u)
case ("d", .text(let d)):
memo = sanitizedMemo(d)
case ("t", .array(let groups)):
for case .map(let group) in groups {
for case (.text("p"), .array(let proofs)) in group {
for case .map(let proof) in proofs {
for case (.text("a"), .unsigned(let amount)) in proof {
guard amount > 0, amount <= UInt64(maxAmount) else { continue }
total += Int64(amount)
guard total <= maxAmount else { return nil }
sawAmount = true
}
}
}
}
default:
break
}
}
return TokenInfo(
version: "B",
amount: sawAmount ? Int(total) : nil,
unit: unit,
mintHost: mintHost,
memo: memo
)
}
// MARK: - Display Sanitization (values are attacker-controlled)
private static func sanitizedHost(from mint: String) -> String? {
guard mint.count <= 512, let host = URL(string: mint)?.host, !host.isEmpty else { return nil }
return String(host.lowercased().prefix(48))
}
private static func sanitizedUnit(_ unit: String?) -> String? {
guard let unit, !unit.isEmpty, unit.count <= 12,
unit.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) }) else {
return nil
}
return unit
}
private static func sanitizedMemo(_ memo: String?) -> String? {
guard let memo, memo.count <= 512 else { return nil }
let stripped = CharacterSet.controlCharacters.union(.newlines)
var cleaned = ""
cleaned.unicodeScalars.append(contentsOf: memo.unicodeScalars.filter { !stripped.contains($0) })
cleaned = cleaned.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
return String(cleaned.prefix(80))
}
}
// MARK: - Minimal CBOR Reader
/// Just enough definite-length CBOR to traverse a TokenV4 map. Bounded in
/// depth, item count, and byte length; indefinite-length items and anything
/// else exotic make the parse fail (the caller degrades to a generic chip).
private struct CBORReader {
indirect enum Value {
case unsigned(UInt64)
case text(String)
case array([Value])
case map([(Value, Value)])
/// Parsed-and-skipped content we don't need (byte strings, negatives, floats)
case opaque
}
private let bytes: [UInt8]
private var index = 0
/// Total item budget so hostile nesting can't run away.
private var itemBudget = 50_000
private static let maxDepth = 16
private static let maxContainerCount: UInt64 = 10_000
init(data: Data) {
bytes = [UInt8](data)
}
mutating func parseValue(depth: Int) -> Value? {
guard depth < Self.maxDepth, itemBudget > 0 else { return nil }
itemBudget -= 1
guard let (major, argument) = readHead() else { return nil }
switch major {
case 0: // unsigned int
return .unsigned(argument)
case 1: // negative int (argument already consumed)
return .opaque
case 2: // byte string
return readBytes(count: argument) != nil ? .opaque : nil
case 3: // text string
guard let raw = readBytes(count: argument) else { return nil }
return String(bytes: raw, encoding: .utf8).map(Value.text) ?? .opaque
case 4: // array
guard argument <= Self.maxContainerCount else { return nil }
var items: [Value] = []
items.reserveCapacity(Int(min(argument, 64)))
for _ in 0..<argument {
guard let item = parseValue(depth: depth + 1) else { return nil }
items.append(item)
}
return .array(items)
case 5: // map
guard argument <= Self.maxContainerCount else { return nil }
var pairs: [(Value, Value)] = []
pairs.reserveCapacity(Int(min(argument, 64)))
for _ in 0..<argument {
guard let key = parseValue(depth: depth + 1),
let value = parseValue(depth: depth + 1) else { return nil }
pairs.append((key, value))
}
return .map(pairs)
case 6: // tag: skip the tag number, parse the tagged value
return parseValue(depth: depth + 1)
case 7: // simple values / floats (payload consumed by readHead)
return .opaque
default:
return nil
}
}
/// Reads a CBOR head byte plus its argument. Rejects indefinite lengths.
private mutating func readHead() -> (major: UInt8, argument: UInt64)? {
guard index < bytes.count else { return nil }
let head = bytes[index]
index += 1
let major = head >> 5
let info = head & 0x1F
switch info {
case 0...23:
return (major, UInt64(info))
case 24:
return readUInt(width: 1).map { (major, $0) }
case 25:
return readUInt(width: 2).map { (major, $0) }
case 26:
return readUInt(width: 4).map { (major, $0) }
case 27:
return readUInt(width: 8).map { (major, $0) }
default: // 28-30 reserved, 31 indefinite
return nil
}
}
private mutating func readUInt(width: Int) -> UInt64? {
guard bytes.count - index >= width else { return nil }
var value: UInt64 = 0
for _ in 0..<width {
value = (value << 8) | UInt64(bytes[index])
index += 1
}
return value
}
private mutating func readBytes(count: UInt64) -> [UInt8]? {
guard count <= UInt64(bytes.count - index) else { return nil }
let length = Int(count)
let slice = Array(bytes[index..<(index + length)])
index += length
return slice
}
}
-178
View File
@@ -22,17 +22,6 @@ struct CommandGeoParticipant {
let displayName: String
}
/// The conversation a command was typed into, captured when the command is
/// issued so deferred output (e.g. an async /ping result, which can arrive
/// many seconds later) lands there even if the user switches chats first.
enum CommandOutputDestination: Equatable {
/// The #mesh public timeline. Commands that defer output (/ping) are
/// mesh-only, so a non-DM origin is always the mesh timeline.
case meshTimeline
/// The private chat that was open when the command was typed.
case privateChat(PeerID)
}
/// Protocol defining what CommandProcessor needs from its context.
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
@MainActor
@@ -56,33 +45,15 @@ protocol CommandContextProvider: AnyObject {
/// Empties the peer's chat (single-writer store intent for `/clear`).
func clearPrivateChat(_ peerID: PeerID)
func sendPublicRaw(_ content: String)
/// Sends a normal public message (with local echo) to the active channel.
func sendPublicMessage(_ content: String)
// MARK: - System Messages
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
func addPublicSystemMessage(_ content: String)
/// The conversation the user is typing into right now. Commands that
/// finish asynchronously capture this BEFORE starting async work, so a
/// chat switch cannot misroute their deferred output.
func currentCommandDestination() -> CommandOutputDestination
/// Routes deferred command output (e.g. an async /ping result) into the
/// conversation captured when the command was issued.
func addCommandOutput(_ content: String, to destination: CommandOutputDestination)
// MARK: - Favorites
/// Toggles the favorite via the unified peer flow, which persists by the
/// real noise key and notifies the peer over mesh or Nostr.
func toggleFavorite(peerID: PeerID)
// MARK: - Groups
// Group logic lives in `ChatGroupCoordinator`; these forward the parsed
// /group subcommands.
func groupCreate(named name: String) -> CommandResult
func groupInvite(nickname: String) -> CommandResult
func groupRemove(nickname: String) -> CommandResult
func groupLeave() -> CommandResult
func groupList() -> CommandResult
}
/// Processes chat commands in a focused, efficient way
@@ -129,23 +100,12 @@ final class CommandProcessor {
return handleBlock(args)
case "/unblock":
return handleUnblock(args)
case "/group":
if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") }
return handleGroup(args)
case "/fav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: true)
case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false)
case "/ping":
if inGeoPublic || inGeoDM { return .error(message: "ping only works for mesh peers in #mesh") }
return handlePing(args)
case "/trace":
if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") }
return handleTrace(args)
case "/pay":
return handlePay(args)
case "/help":
return .success(message: Self.helpText)
default:
@@ -165,12 +125,6 @@ final class CommandProcessor {
/slap @name slap with a large trout
/block @name · /unblock @name
/fav @name · /unfav @name favorites (mesh only)
/group create <name> start an encrypted group
/group invite @name · /group remove @name manage members (creator)
/group leave · /group list leave or list your groups
/ping @name measure round-trip time (mesh only)
/trace @name estimated mesh path (mesh only)
/pay <token> send a cashu ecash token in this chat
/help this list
"""
@@ -377,138 +331,6 @@ final class CommandProcessor {
return .error(message: "cannot unblock \(nickname): not found")
}
private static let groupUsage = "usage: /group create <name> · invite @name · remove @name · leave · list"
private func handleGroup(_ args: String) -> CommandResult {
let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
guard let subcommand = parts.first else {
return .error(message: Self.groupUsage)
}
let rest = parts.count > 1 ? String(parts[1]) : ""
guard let provider = contextProvider else { return .handled }
switch subcommand {
case "create":
return provider.groupCreate(named: rest)
case "invite":
return provider.groupInvite(nickname: rest)
case "remove":
return provider.groupRemove(nickname: rest)
case "leave":
return provider.groupLeave()
case "list":
return provider.groupList()
default:
return .error(message: Self.groupUsage)
}
}
// MARK: - Mesh Diagnostics
private enum MeshPeerResolution {
case resolved(peerID: PeerID, nickname: String)
case failed(CommandResult)
}
/// Resolves a mesh peer for /ping and /trace. Geohash identities are
/// rejected diagnostics measure the BLE mesh, not Nostr.
private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution {
let targetName = args.trimmed
guard !targetName.isEmpty else {
return .failed(.error(message: "usage: /\(command) <nickname>"))
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
!peerID.isGeoDM, !peerID.isGeoChat else {
return .failed(.error(message: "cannot \(command) \(nickname): not found on mesh"))
}
return .resolved(peerID: peerID, nickname: nickname)
}
private func handlePing(_ args: String) -> CommandResult {
let target: (peerID: PeerID, nickname: String)
switch resolveMeshPeer(args, command: "ping") {
case .resolved(let peerID, let nickname): target = (peerID, nickname)
case .failed(let result): return result
}
let nickname = target.nickname
let currentProvider = contextProvider
// Capture the origin conversation now: the pong can arrive up to
// meshPingTimeoutSeconds later, and reading the selected chat at
// callback time would misroute the result after a chat switch.
let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline
meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
let provider = currentProvider
guard let result else {
provider?.addCommandOutput("no reply from \(nickname)", to: destination)
return
}
let hopText: String = result.hops.map { hops in
hops == 1 ? " · direct (1 hop)" : " · \(hops) hops"
} ?? ""
provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)", to: destination)
}
return .success(message: "pinging \(nickname)")
}
private func handleTrace(_ args: String) -> CommandResult {
let target: (peerID: PeerID, nickname: String)
switch resolveMeshPeer(args, command: "trace") {
case .resolved(let peerID, let nickname): target = (peerID, nickname)
case .failed(let result): return result
}
guard let mesh = meshService,
let intermediates = mesh.computeMeshPath(to: target.peerID) else {
return .success(message: "no known path to \(target.nickname)")
}
// Graph-derived from gossiped neighbor claims, not route-recorded
// present it as an estimate.
let hopNames = intermediates.map { hop in
mesh.peerNickname(peerID: hop) ?? "\(hop.id.prefix(8))"
}
let chain = (["you"] + hopNames + [target.nickname]).joined(separator: "")
let hops = intermediates.count + 1
return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))")
}
/// `/pay <cashu-token>` validates the token decodes, then sends it as
/// the message body in the current chat. Cashu tokens are bearer
/// instruments (whoever redeems first gets the funds), so posting one to
/// a public channel requires an explicit `/pay <token> public` confirm.
/// The app never contacts a mint; it only relays the string.
private func handlePay(_ args: String) -> CommandResult {
var parts = args.trimmed.split(separator: " ").map(String.init)
guard !parts.isEmpty else {
return .success(message: "usage: /pay <token> — paste a cashu token: /pay cashuA…")
}
let confirmedPublic = parts.count > 1 && parts.last?.lowercased() == "public"
if confirmedPublic { parts.removeLast() }
guard parts.count == 1, let token = CashuTokenDecoder.bareToken(from: parts[0]) else {
return .error(message: "that doesn't look like a cashu token — expected cashuA… or cashuB…")
}
guard let info = CashuTokenDecoder.decode(token, strict: true) else {
return .error(message: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it")
}
let summary = info.displayAmount ?? "a cashu token"
if let peerID = contextProvider?.selectedPrivateChatPeer {
contextProvider?.sendPrivateMessage(token, to: peerID)
return .success(message: "sent \(summary) — cashu is a bearer token; whoever redeems it first gets the funds")
}
guard confirmedPublic else {
return .error(message: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay <token> public")
}
contextProvider?.sendPublicMessage(token)
return .success(message: "sent \(summary) to the public channel — anyone here can redeem it")
}
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
let targetName = args.trimmed
guard !targetName.isEmpty else {
+3 -9
View File
@@ -42,11 +42,9 @@ final class CourierStore {
var sprayedTo: Set<Data>
/// Last speculative multi-hop handover toward a relayed announce.
var lastRemoteHandoverAt: Date?
/// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1.
let prekeyID: UInt32?
var envelope: CourierEnvelope {
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
}
init(
@@ -58,8 +56,7 @@ final class CourierStore {
tier: CourierDepositTier,
copies: UInt8,
sprayedTo: Set<Data> = [],
lastRemoteHandoverAt: Date? = nil,
prekeyID: UInt32? = nil
lastRemoteHandoverAt: Date? = nil
) {
self.recipientTag = recipientTag
self.expiry = expiry
@@ -70,7 +67,6 @@ final class CourierStore {
self.copies = copies
self.sprayedTo = sprayedTo
self.lastRemoteHandoverAt = lastRemoteHandoverAt
self.prekeyID = prekeyID
}
// Files written before tiers/spray lack the newer fields; treat that
@@ -86,7 +82,6 @@ final class CourierStore {
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID)
}
}
@@ -191,8 +186,7 @@ final class CourierStore {
depositorNoiseKey: depositorNoiseKey,
storedAt: date,
tier: tier,
copies: envelope.copies,
prekeyID: envelope.prekeyID
copies: envelope.copies
))
persistLocked()
return true
@@ -1,496 +0,0 @@
//
// GatewayService.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 Combine
import Foundation
/// Policy engine for gateway mode: an opt-in "share my internet with the
/// mesh" bridge. While the toggle is on, this device advertises the
/// `.gateway` capability bit, publishes signed geohash events deposited by
/// mesh-only peers to Nostr relays (uplink), and rebroadcasts inbound relay
/// events onto the mesh (downlink) so mesh-only peers can take part in the
/// local geohash channel. Mesh-only peers need no toggle: their uplink
/// engages automatically when relays are unreachable and a gateway peer
/// exists.
///
/// Threat model:
/// - Keys never leave the originating device. Mesh-only senders sign events
/// locally with their per-geohash ephemeral identity; the gateway carries
/// only the finished, signed event.
/// - The gateway cannot forge or alter events: every carried event is
/// Schnorr-verified here before it is published or rebroadcast, and again
/// independently by relays and receivers.
/// - Carried contents are public geohash chat, already plaintext on Nostr,
/// so the mesh carrier adds no confidentiality loss.
///
/// Loop-prevention rules:
/// 1. An event learned from a `fromGateway` mesh broadcast is never
/// re-published to relays, never re-uplinked, and never rebroadcast
/// (`meshBroadcastEventIDs`), so a second gateway on the same mesh cannot
/// echo mesh-carried traffic back out. Mesh-level propagation of the
/// original broadcast packet is the TTL relay's job, not ours.
/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and
/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so
/// repeat deposits and relay echoes are absorbed.
/// 3. Uplink is only attempted for locally composed events at the send site
/// (`GeohashSubscriptionManager.sendGeohash`); events received over the
/// carrier never re-enter the uplink path. This is a call-site convention;
/// the `meshBroadcastEventIDs`/`publishedEventIDs` backstops in
/// `uplinkViaMesh` enforce it defensively and are unit-tested.
/// Rules 1 and 2 are enforced here and unit-tested.
/// Rebroadcast storms at the mesh layer are additionally bounded by the BLE
/// `MessageDeduplicator` and packet TTL, and receivers dedup carried events
/// against their own relay subscriptions via the Nostr event-ID cache in
/// `NostrInboundPipeline`.
///
/// All dependencies are closure-injected (repo convention) so the policy
/// layer is unit-testable without relays or radios.
@MainActor
final class GatewayService: ObservableObject {
enum Limits {
/// Uplink deposits held while relays are unreachable (CourierStore-style
/// bounded mailbag: bounded total, bounded per depositor).
static let maxQueuedUplinks = 20
static let maxQueuedUplinksPerDepositor = 5
/// Uplink deposits accepted per depositor per minute.
static let uplinkEventsPerMinutePerDepositor = 10
/// Downlink mesh rebroadcasts per minute BLE airtime is precious.
/// Beyond the budget events queue (bounded, drop-oldest) and drain on
/// a scheduled timer once the window frees (also re-driven by the next
/// inbound relay event); a quiet channel does not strand its backlog.
static let downlinkEventsPerMinute = 30
static let maxPendingDownlinks = 30
/// Accepted clock skew for a carried ephemeral event; anything older
/// is stale replay the relays would drop anyway.
static let maxEventAgeSeconds: TimeInterval = 15 * 60
/// Bounded loop-prevention ID caches (oldest evicted).
static let maxTrackedEventIDs = 512
}
struct QueuedUplink {
let depositor: PeerID
let geohash: String
let event: NostrEvent
let queuedAt: Date
}
static let shared = GatewayService()
/// The user toggle. While true this device advertises `.gateway` and
/// bridges mesh <-> Nostr for geohash channels.
@Published private(set) var isEnabled: Bool
// MARK: Wiring (set once by the bootstrapper; fakes in tests)
/// Publishes a verified event to the geo relays for a geohash.
var publishToRelays: (@MainActor (NostrEvent, String) -> Void)?
/// Broadcasts an encoded `fromGateway` carrier payload on the mesh.
var broadcastToMesh: (@MainActor (Data) -> Void)?
/// Sends an encoded `toGateway` carrier payload directed to a gateway
/// peer. Returns false when the transport could not accept it.
var sendToGatewayPeer: (@MainActor (Data, PeerID) -> Bool)?
/// Reachable mesh peers currently advertising the `.gateway` capability.
var availableGatewayPeers: (@MainActor () -> [PeerID])?
/// Whether any Nostr relay connection is currently working.
var relaysConnected: (@MainActor () -> Bool)?
/// The geohash channel the local user is viewing, if any.
var currentGeohash: (@MainActor () -> String?)?
/// Injects a verified carried event into the same inbound pipeline as
/// relay-received events (blocking, rate limits, dedup, rendering).
var injectInbound: (@MainActor (NostrEvent) -> Void)?
/// Fired on toggle changes (advertise/withdraw the capability bit and
/// force a re-announce).
var onEnabledChanged: (@MainActor (Bool) -> Void)?
/// Schedules a downlink-drain closure to run after a delay. Injected so
/// the drain timer is deterministic in tests; nil arms a real `Task`.
var scheduleDrainTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
// MARK: State
/// Loop rule 1: event IDs seen in `fromGateway` mesh broadcasts.
private var meshBroadcastEventIDs: BoundedIDSet
/// Loop rule 2 (uplink): event IDs this gateway already published.
private var publishedEventIDs: BoundedIDSet
/// Loop rule 2 (downlink): event IDs this gateway already rebroadcast.
private var rebroadcastEventIDs: BoundedIDSet
private(set) var queuedUplinks: [QueuedUplink] = []
private var uplinkDepositTimes: [PeerID: [Date]] = [:]
private var downlinkSendTimes: [Date] = []
private var pendingDownlinks: [(event: NostrEvent, geohash: String)] = []
/// True while a drain timer is armed, so a burst schedules at most one.
private var downlinkDrainScheduled = false
private let defaults: UserDefaults
private let now: () -> Date
private static let enabledKey = "gateway.userEnabled"
init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) {
self.defaults = defaults
self.now = now
self.isEnabled = defaults.bool(forKey: Self.enabledKey)
self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
}
// MARK: - Toggle
func setEnabled(_ enabled: Bool) {
guard enabled != isEnabled else { return }
isEnabled = enabled
defaults.set(enabled, forKey: Self.enabledKey)
if !enabled {
queuedUplinks.removeAll()
pendingDownlinks.removeAll()
uplinkDepositTimes.removeAll()
}
SecureLogger.info("[GW] mode \(enabled ? "enabled" : "disabled")", category: .gateway)
onEnabledChanged?(enabled)
}
// MARK: - Mesh carrier ingress (both roles)
/// Entry point for received `nostrCarrier` packets. `directedToUs` is
/// true for packets addressed to this device (uplink deposits); false
/// for broadcasts (downlink rebroadcasts from a gateway).
func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) {
guard let carrier = NostrCarrierPacket.decode(payload) else {
SecureLogger.debug("[GW] carrier drop (undecodable) from \(peerID.id.prefix(8))", category: .gateway)
return
}
switch carrier.direction {
case .toGateway:
// Uplink deposits are directed; a broadcast toGateway is malformed.
guard directedToUs else { return }
handleUplinkDeposit(carrier, from: peerID)
case .fromGateway:
// Downlink rides broadcast only; a directed fromGateway is malformed.
guard !directedToUs else { return }
handleDownlinkBroadcast(carrier)
}
}
// MARK: - Uplink (gateway role: mesh peer -> internet)
private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) {
guard isEnabled else { return }
// Cheap structural checks first (parse, size, geohash, kind, #g tag,
// age) no crypto so junk and stale replays are dropped before we
// ever pay for a MainActor Schnorr verify.
guard let event = structurallyValidEvent(from: carrier) else {
SecureLogger.info("[GW] uplink reject (validation) from \(depositor.id.prefix(8))", category: .gateway)
return
}
// Dedup by the carried event ID BEFORE verification. Loop rule 1: a
// fromGateway-learned event is mesh-carried and must never be
// re-published. Loop rule 2: repeat deposits of an already handled
// event are absorbed. A replay of one valid deposit is short-circuited
// here without a per-packet signature verify.
guard !meshBroadcastEventIDs.contains(event.id),
!publishedEventIDs.contains(event.id),
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
SecureLogger.debug("[GW] uplink skip (loop/dup) \(event.id.prefix(8))", category: .gateway)
return
}
// Consume the per-depositor rate token BEFORE the expensive verify so
// a flood of distinct forged/junk deposits is bounded by cheap work,
// not by main-actor Schnorr verifications.
guard allowUplinkDeposit(from: depositor) else {
SecureLogger.info("[GW] uplink reject (rate-limit) from \(depositor.id.prefix(8))", category: .gateway)
return
}
// Only now pay for cryptographic verification; receivers verify again.
guard event.isValidSignature() else {
SecureLogger.info("[GW] uplink reject (sig-fail) from \(depositor.id.prefix(8))", category: .gateway)
return
}
let accepted: Bool
if relaysConnected?() ?? false {
publish(event, geohash: carrier.geohash)
accepted = true
} else {
accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now()))
if accepted {
SecureLogger.info("[GW] uplink accept→queue \(event.id.prefix(8))… (relays down)", category: .gateway)
}
}
// Only render on our own timeline what we actually accepted for
// publish or queue: a quota-dropped deposit is never published and,
// being directed, no other peer will ever see it, so showing it would
// diverge our timeline permanently from what reached the channel.
if accepted, currentGeohash?() == carrier.geohash {
injectInbound?(event)
}
}
/// Publish everything queued while relays were unreachable. Called when
/// relay connectivity comes back.
func flushQueuedUplinks() {
guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return }
let queued = queuedUplinks
queuedUplinks.removeAll()
for item in queued where !publishedEventIDs.contains(item.event.id) {
publish(item.event, geohash: item.geohash)
}
}
private func publish(_ event: NostrEvent, geohash: String) {
publishedEventIDs.insert(event.id)
publishToRelays?(event, geohash)
SecureLogger.info("[GW] uplink accept→publish \(event.id.prefix(8))… to relays for #\(geohash)", category: .gateway)
}
/// Returns true when the item was actually stored for later publish.
@discardableResult
private func enqueueUplink(_ item: QueuedUplink) -> Bool {
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else {
SecureLogger.info("[GW] uplink reject (quota) for \(item.depositor.id.prefix(8))", category: .gateway)
return false
}
if queuedUplinks.count >= Limits.maxQueuedUplinks {
queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1)
}
queuedUplinks.append(item)
return true
}
private func allowUplinkDeposit(from depositor: PeerID) -> Bool {
let cutoff = now().addingTimeInterval(-60)
var times = uplinkDepositTimes[depositor, default: []]
times.removeAll { $0 < cutoff }
guard times.count < Limits.uplinkEventsPerMinutePerDepositor else {
uplinkDepositTimes[depositor] = times
return false
}
times.append(now())
uplinkDepositTimes[depositor] = times
// Bound the tracker itself against a churn of spoofed depositors.
if uplinkDepositTimes.count > Limits.maxTrackedEventIDs {
uplinkDepositTimes = uplinkDepositTimes.filter { !$0.value.isEmpty && $0.value.contains { $0 >= cutoff } }
}
return true
}
// MARK: - Downlink (gateway role: internet -> mesh)
/// Called for every event the gateway's own geohash-channel subscription
/// delivers. Wraps it in a `fromGateway` carrier and broadcasts it on
/// the mesh, within the airtime budget.
func rebroadcastRelayEvent(_ event: NostrEvent, geohash: String) {
guard isEnabled, broadcastToMesh != nil else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
// Freshness + geohash gate BEFORE spending any budget. A channel
// (re)subscribe backfills up to an hour of history (limit 200), but
// every receiver's `validatedEvent` drops anything older than the
// same window so rebroadcasting backfill would burn the whole
// per-minute budget on events no mesh peer accepts. Also require the
// event's own `#g` tag to match the carrier geohash.
guard isFresh(event),
event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else {
SecureLogger.debug("[GW] downlink drop (stale/mismatch) \(event.id.prefix(8))", category: .gateway)
return
}
// Loop rule 1: never rebroadcast mesh-carried events back onto the
// mesh. Loop rule 2: rebroadcast each relay event at most once but
// mark only AFTER it is actually sent (in `drainPendingDownlinks`), so
// an event dropped by the queue overflow stays retryable on relay
// redelivery. Guard against a redelivery re-queueing an event that is
// still waiting to be sent.
guard !meshBroadcastEventIDs.contains(event.id),
!rebroadcastEventIDs.contains(event.id),
!pendingDownlinks.contains(where: { $0.event.id == event.id }) else {
SecureLogger.debug("[GW] downlink skip (loop/dup) \(event.id.prefix(8))", category: .gateway)
return
}
// Verify before spending BLE airtime; receivers verify again.
guard event.isValidSignature() else {
SecureLogger.debug("[GW] downlink drop (sig-fail) \(event.id.prefix(8))", category: .gateway)
return
}
pendingDownlinks.append((event, geohash))
if pendingDownlinks.count > Limits.maxPendingDownlinks {
// Bandwidth guard: drop-oldest fresher chat is worth more. The
// dropped event is not yet in `rebroadcastEventIDs`, so a later
// relay redelivery can still carry it.
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
SecureLogger.info("[GW] downlink drop-oldest (budget), \(pendingDownlinks.count) pending", category: .gateway)
}
drainPendingDownlinks()
}
private func drainPendingDownlinks() {
let cutoff = now().addingTimeInterval(-60)
downlinkSendTimes.removeAll { $0 < cutoff }
while !pendingDownlinks.isEmpty,
downlinkSendTimes.count < Limits.downlinkEventsPerMinute {
let (event, geohash) = pendingDownlinks.removeFirst()
// A queued event may have aged past the window while it waited;
// don't burn airtime on what receivers would now drop.
guard isFresh(event) else { continue }
guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event),
let payload = carrier.encode() else { continue }
broadcastToMesh?(payload)
SecureLogger.info("[GW] downlink rebroadcast \(event.id.prefix(8))… to mesh for #\(geohash)", category: .gateway)
// Mark-after-send: only now is the relay event definitively
// rebroadcast (loop rule 2).
rebroadcastEventIDs.insert(event.id)
downlinkSendTimes.append(now())
}
// Budget exhausted with events still queued: arm a timer to drain when
// the window frees, instead of stranding them until the next inbound
// relay event (which may never come on a channel that went quiet).
scheduleDownlinkDrainIfNeeded()
}
/// Arms a single timer to drain the backlog once the per-minute window
/// frees. No-op when nothing is pending or a drain is already scheduled.
private func scheduleDownlinkDrainIfNeeded() {
guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return }
// The window frees when the oldest recorded send ages out of 60s.
let oldest = downlinkSendTimes.min() ?? now()
let delay = max(0.05, 60 - now().timeIntervalSince(oldest))
downlinkDrainScheduled = true
SecureLogger.info("[GW] drain-timer armed (\(String(format: "%.1f", delay))s, \(pendingDownlinks.count) pending)", category: .gateway)
let fire: @MainActor () -> Void = { [weak self] in
guard let self else { return }
self.downlinkDrainScheduled = false
SecureLogger.info("[GW] drain-timer fired", category: .gateway)
self.drainPendingDownlinks()
}
if let scheduleDrainTimer {
scheduleDrainTimer(delay, fire)
} else {
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
fire()
}
}
}
// MARK: - Downlink (receiver role: carried event arrives over mesh)
private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) {
guard let event = validatedEvent(from: carrier) else { return }
// Mark only AFTER signature verification, so a forged copy carrying a
// real event's ID cannot poison the never-republish set, and use the
// marking as dedup: the same broadcast relayed along several mesh
// paths injects once (the pipeline's Nostr event-ID cache additionally
// dedups against our own relay subscription).
guard meshBroadcastEventIDs.insert(event.id) else { return }
// Only inject events for the channel we're viewing; the inbound
// pipeline files public messages under the current geohash.
guard currentGeohash?() == carrier.geohash else { return }
injectInbound?(event)
}
// MARK: - Uplink (sender role: mesh-only peer with no relays)
/// Hands a locally signed event to a mesh gateway peer when we have no
/// working relay connection. Returns true when the event was sent.
///
/// v1 is deliberately fire-and-forget: no gateway ack. The event also
/// stays in `NostrRelayManager`'s own pending queue, so if our internet
/// comes back the relays dedup the duplicate publish by event ID.
///
/// Loop rule 3: call sites only pass freshly composed events (see
/// `GeohashSubscriptionManager.sendGeohash`); received carrier events
/// never reach this path, and the mesh-carried guard below backstops it.
func uplinkViaMesh(event: NostrEvent, geohash: String) -> Bool {
if relaysConnected?() ?? true { return false }
guard !meshBroadcastEventIDs.contains(event.id),
!publishedEventIDs.contains(event.id) else {
return false
}
// A single gateway is enough relays fan out from there, and BLE
// airtime is precious.
guard let gateway = availableGatewayPeers?().first else { return false }
guard let carrier = NostrCarrierPacket(direction: .toGateway, geohash: geohash, event: event),
let payload = carrier.encode() else {
return false
}
guard sendToGatewayPeer?(payload, gateway) ?? false else { return false }
SecureLogger.info("[GW] uplink send \(event.id.prefix(8))… for #\(geohash) via gateway \(gateway.id.prefix(8))", category: .gateway)
return true
}
// MARK: - Validation
/// Structural and cryptographic checks every carried event must pass
/// before a gateway publishes it or a receiver displays it. Ordered
/// cheap-first; Schnorr verification runs last.
private func validatedEvent(from carrier: NostrCarrierPacket) -> NostrEvent? {
guard let event = structurallyValidEvent(from: carrier),
event.isValidSignature() else {
return nil
}
return event
}
/// The cheap half of `validatedEvent`: parse + size + geohash + kind +
/// `#g` tag + freshness, with NO signature verification. Callers that can
/// dedup or rate-limit on the carried ID run this first so the expensive
/// Schnorr verify is reached only for events that survive the cheap gates.
private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? {
guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes,
Self.isValidGeohash(carrier.geohash),
let event = carrier.event(),
event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue,
event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == carrier.geohash }),
isFresh(event) else {
return nil
}
return event
}
/// True when `event.created_at` is within the accepted clock skew the
/// SAME freshness window receivers enforce, so a gateway never spends
/// airtime on events every receiver would drop as stale.
private func isFresh(_ event: NostrEvent) -> Bool {
abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds
}
static func isValidGeohash(_ geohash: String) -> Bool {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return (1...NostrCarrierPacket.maxGeohashLength).contains(geohash.count)
&& geohash.allSatisfy { allowed.contains($0) }
}
}
/// Insertion-ordered string set with a fixed capacity; the oldest entry is
/// evicted when full.
private struct BoundedIDSet {
private var members: Set<String> = []
private var order: [String] = []
let capacity: Int
init(capacity: Int) {
self.capacity = capacity
}
func contains(_ id: String) -> Bool {
members.contains(id)
}
/// Returns false when the ID was already present.
@discardableResult
mutating func insert(_ id: String) -> Bool {
guard members.insert(id).inserted else { return false }
order.append(id)
if order.count > capacity {
members.remove(order.removeFirst())
}
return true
}
}
-569
View File
@@ -1,569 +0,0 @@
//
// GroupProtocol.swift
// bitchat
//
// Wire formats and crypto for private groups: creator-signed group state
// (invites and key updates over Noise) and ChaCha20-Poly1305 group messages
// broadcast as MessageType.groupMessage (0x25).
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import CryptoKit
import Foundation
// MARK: - Models
/// A member of a private group as pinned in the creator-signed roster.
struct GroupMember: Codable, Equatable {
/// SHA-256 fingerprint (64 hex chars) of the member's Noise static key.
let fingerprint: String
/// The member's Ed25519 signing public key (32 bytes, from their announce).
let signingKey: Data
/// Nickname at invite time; display fallback when the peer is offline.
var nickname: String
}
/// Creator-managed encrypted group. Metadata only the symmetric key lives
/// in the keychain (see `GroupStore`).
struct BitchatGroup: Codable, Equatable {
static let maxMembers = 16
static let groupIDLength = 16
static let keyLength = 32
/// 16 random bytes; travels in cleartext on group message packets so
/// relays can dedup/filter without membership.
let groupID: Data
var name: String
/// Bumps on every key rotation; messages are bound to the epoch they
/// were sealed under.
var epoch: UInt32
var members: [GroupMember]
/// Fingerprint of the creator the only identity allowed to sign group
/// state (invites, key updates) in v1.
let creatorFingerprint: String
/// Virtual conversation ID this group's chat is keyed under.
var peerID: PeerID { PeerID(groupID: groupID) }
var creator: GroupMember? {
members.first { $0.fingerprint == creatorFingerprint }
}
func isMember(fingerprint: String) -> Bool {
members.contains { $0.fingerprint == fingerprint }
}
func member(withSigningKey signingKey: Data) -> GroupMember? {
members.first { $0.signingKey == signingKey }
}
}
// MARK: - TLV helpers
enum GroupTLVError: Error, Equatable {
/// A TLV value exceeded the 16-bit length field. Encoding fails instead
/// of silently truncating (which would ship a value the receiver drops).
case valueTooLong
}
private enum GroupTLV {
/// Appends a (type, 16-bit length, value) triple. Throws rather than
/// truncating when `value` does not fit the 16-bit length field, so an
/// oversize field surfaces a send failure instead of a silently truncated
/// blob the recipient rejects during decrypt/verify.
static func put(_ type: UInt8, _ value: Data, into out: inout Data) throws {
guard value.count <= Int(UInt16.max) else { throw GroupTLVError.valueTooLong }
out.append(type)
let length = UInt16(value.count)
out.append(UInt8((length >> 8) & 0xFF))
out.append(UInt8(length & 0xFF))
out.append(value)
}
/// Iterates (type, value) pairs; returns nil on malformed framing.
static func parse(_ data: Data) -> [(type: UInt8, value: Data)]? {
var fields: [(UInt8, Data)] = []
var offset = data.startIndex
while offset < data.endIndex {
guard data.distance(from: offset, to: data.endIndex) >= 3 else { return nil }
let type = data[offset]
let high = Int(data[data.index(offset, offsetBy: 1)])
let low = Int(data[data.index(offset, offsetBy: 2)])
let length = (high << 8) | low
let valueStart = data.index(offset, offsetBy: 3)
guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil }
let valueEnd = data.index(valueStart, offsetBy: length)
fields.append((type, Data(data[valueStart..<valueEnd])))
offset = valueEnd
}
return fields
}
static func epochData(_ epoch: UInt32) -> Data {
var bigEndian = epoch.bigEndian
return withUnsafeBytes(of: &bigEndian) { Data($0) }
}
static func epoch(from data: Data) -> UInt32? {
guard data.count == 4 else { return nil }
return data.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
}
static func timestampData(_ timestampMs: UInt64) -> Data {
var bigEndian = timestampMs.bigEndian
return withUnsafeBytes(of: &bigEndian) { Data($0) }
}
static func timestamp(from data: Data) -> UInt64? {
guard data.count == 8 else { return nil }
return data.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
}
}
// MARK: - Roster wire form
enum GroupRosterCoding {
private static let fingerprintLength = 32
private static let signingKeyLength = 32
private static let maxNicknameBytes = 64
/// Deterministic roster blob: count byte, then per member the raw 32-byte
/// fingerprint, 32-byte signing key, and length-prefixed UTF-8 nickname.
/// The creator signature covers the SHA-256 of these exact bytes.
static func encode(_ members: [GroupMember]) -> Data? {
guard members.count <= BitchatGroup.maxMembers else { return nil }
var out = Data([UInt8(members.count)])
for member in members {
guard let fingerprintData = Data(hexString: member.fingerprint),
fingerprintData.count == fingerprintLength,
member.signingKey.count == signingKeyLength else { return nil }
out.append(fingerprintData)
out.append(member.signingKey)
// Truncate on a Character boundary so the byte prefix is always
// valid UTF-8; a raw byte-prefix could split a multi-byte scalar
// and make the whole signed roster undecodable on the recipient.
let nickname = truncatedNicknameBytes(member.nickname)
out.append(UInt8(nickname.count))
out.append(nickname)
}
return out
}
static func decode(_ data: Data) -> [GroupMember]? {
guard let count = data.first, count <= UInt8(BitchatGroup.maxMembers) else { return nil }
var members: [GroupMember] = []
var offset = data.index(after: data.startIndex)
for _ in 0..<count {
let fixed = fingerprintLength + signingKeyLength + 1
guard data.distance(from: offset, to: data.endIndex) >= fixed else { return nil }
let fingerprintEnd = data.index(offset, offsetBy: fingerprintLength)
let fingerprint = Data(data[offset..<fingerprintEnd]).hexEncodedString()
let signingKeyEnd = data.index(fingerprintEnd, offsetBy: signingKeyLength)
let signingKey = Data(data[fingerprintEnd..<signingKeyEnd])
let nickLength = Int(data[signingKeyEnd])
let nickStart = data.index(after: signingKeyEnd)
guard data.distance(from: nickStart, to: data.endIndex) >= nickLength else { return nil }
let nickEnd = data.index(nickStart, offsetBy: nickLength)
guard let nickname = String(data: Data(data[nickStart..<nickEnd]), encoding: .utf8) else { return nil }
members.append(GroupMember(fingerprint: fingerprint, signingKey: signingKey, nickname: nickname))
offset = nickEnd
}
guard offset == data.endIndex else { return nil }
return members
}
/// UTF-8 bytes of `nickname` trimmed to at most `maxNicknameBytes`,
/// dropping whole Characters so the result is never split mid-scalar.
private static func truncatedNicknameBytes(_ nickname: String) -> Data {
var candidate = nickname
while Data(candidate.utf8).count > maxNicknameBytes {
candidate.removeLast()
}
return Data(candidate.utf8)
}
}
// MARK: - Group state payload (groupInvite / groupKeyUpdate over Noise)
/// Creator-signed group state. The same wire form serves invites (0x06) and
/// key updates (0x07); receivers verify the creator signature computed over
/// "bitchat-group-v1" | groupID | epoch | SHA256(key) | SHA256(roster)
/// against the creator's signing key pinned in the roster, and require the
/// Noise session peer to BE the creator before accepting any state.
struct GroupStatePayload: Equatable {
let groupID: Data
let name: String
/// Symmetric ChaCha20-Poly1305 group key (32 bytes) for `epoch`.
let key: Data
let epoch: UInt32
let members: [GroupMember]
let creatorFingerprint: String
/// Ed25519 signature by the creator.
let signature: Data
private enum FieldType: UInt8 {
case groupID = 0x01
case name = 0x02
case key = 0x03
case epoch = 0x04
case roster = 0x05
case creatorFingerprint = 0x06
case signature = 0x07
}
static let signingDomain = Data("bitchat-group-v1".utf8)
/// The bytes the creator signs. Binding the key, roster, and name by hash
/// keeps the signed content fixed-size. The name is covered so a relay
/// that caches/replays a signed state (e.g. store-and-forward) cannot swap
/// the display name while keeping a valid creator signature.
static func signingContent(groupID: Data, epoch: UInt32, key: Data, rosterBlob: Data, name: String) -> Data {
var content = signingDomain
content.append(groupID)
content.append(GroupTLV.epochData(epoch))
content.append(key.sha256Hash())
content.append(rosterBlob.sha256Hash())
content.append(Data(name.utf8).sha256Hash())
return content
}
/// Builds a signed state payload. Returns nil when the roster cannot be
/// encoded (over cap, malformed member) or signing fails.
static func makeSigned(
group: BitchatGroup,
key: Data,
sign: (Data) -> Data?
) -> GroupStatePayload? {
guard let rosterBlob = GroupRosterCoding.encode(group.members) else { return nil }
let content = signingContent(groupID: group.groupID, epoch: group.epoch, key: key, rosterBlob: rosterBlob, name: group.name)
guard let signature = sign(content) else { return nil }
return GroupStatePayload(
groupID: group.groupID,
name: group.name,
key: key,
epoch: group.epoch,
members: group.members,
creatorFingerprint: group.creatorFingerprint,
signature: signature
)
}
func encode() -> Data? {
guard let rosterBlob = GroupRosterCoding.encode(members),
let fingerprintData = Data(hexString: creatorFingerprint),
fingerprintData.count == 32 else { return nil }
var out = Data()
do {
try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out)
try GroupTLV.put(FieldType.name.rawValue, Data(name.utf8), into: &out)
try GroupTLV.put(FieldType.key.rawValue, key, into: &out)
try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out)
try GroupTLV.put(FieldType.roster.rawValue, rosterBlob, into: &out)
try GroupTLV.put(FieldType.creatorFingerprint.rawValue, fingerprintData, into: &out)
try GroupTLV.put(FieldType.signature.rawValue, signature, into: &out)
} catch {
return nil
}
return out
}
static func decode(_ data: Data) -> GroupStatePayload? {
guard let fields = GroupTLV.parse(data) else { return nil }
var groupID: Data?
var name: String?
var key: Data?
var epoch: UInt32?
var rosterBlob: Data?
var members: [GroupMember]?
var creatorFingerprint: String?
var signature: Data?
for (type, value) in fields {
switch FieldType(rawValue: type) {
case .groupID where value.count == BitchatGroup.groupIDLength:
groupID = value
case .name:
name = String(data: value, encoding: .utf8)
case .key where value.count == BitchatGroup.keyLength:
key = value
case .epoch:
epoch = GroupTLV.epoch(from: value)
case .roster:
rosterBlob = value
members = GroupRosterCoding.decode(value)
case .creatorFingerprint where value.count == 32:
creatorFingerprint = value.hexEncodedString()
case .signature where value.count == 64:
signature = value
default:
break // forward compatible; ignore unknown TLVs
}
}
guard let groupID, let name, let key, let epoch,
rosterBlob != nil, let members, !members.isEmpty,
let creatorFingerprint, let signature else { return nil }
return GroupStatePayload(
groupID: groupID,
name: name,
key: key,
epoch: epoch,
members: members,
creatorFingerprint: creatorFingerprint,
signature: signature
)
}
/// Verifies the creator signature against the creator's signing key
/// pinned in the roster, and that the creator is actually in the roster.
func verifyCreatorSignature() -> Bool {
guard members.count <= BitchatGroup.maxMembers,
let creator = members.first(where: { $0.fingerprint == creatorFingerprint }),
let rosterBlob = GroupRosterCoding.encode(members) else { return false }
let content = GroupStatePayload.signingContent(groupID: groupID, epoch: epoch, key: key, rosterBlob: rosterBlob, name: name)
return GroupCrypto.verify(signature: signature, for: content, publicKey: creator.signingKey)
}
var asGroup: BitchatGroup {
BitchatGroup(
groupID: groupID,
name: name,
epoch: epoch,
members: members,
creatorFingerprint: creatorFingerprint
)
}
}
// MARK: - Group message envelope (MessageType 0x25 payload)
/// Cleartext framing of a group message broadcast. Only the group ID, epoch,
/// and nonce are visible to relays; everything about the message sender,
/// content, timestamps is inside the ChaCha20-Poly1305 ciphertext.
struct GroupMessageEnvelope: Equatable {
let groupID: Data
let epoch: UInt32
let nonce: Data
/// ChaChaPoly ciphertext || 16-byte tag.
let ciphertext: Data
private enum FieldType: UInt8 {
case groupID = 0x01
case epoch = 0x02
case nonce = 0x03
case ciphertext = 0x04
}
func encode() throws -> Data {
var out = Data()
try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out)
try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out)
try GroupTLV.put(FieldType.nonce.rawValue, nonce, into: &out)
try GroupTLV.put(FieldType.ciphertext.rawValue, ciphertext, into: &out)
return out
}
static func decode(_ data: Data) -> GroupMessageEnvelope? {
guard let fields = GroupTLV.parse(data) else { return nil }
var groupID: Data?
var epoch: UInt32?
var nonce: Data?
var ciphertext: Data?
for (type, value) in fields {
switch FieldType(rawValue: type) {
case .groupID where value.count == BitchatGroup.groupIDLength:
groupID = value
case .epoch:
epoch = GroupTLV.epoch(from: value)
case .nonce where value.count == 12:
nonce = value
case .ciphertext where !value.isEmpty:
ciphertext = value
default:
break
}
}
guard let groupID, let epoch, let nonce, let ciphertext else { return nil }
return GroupMessageEnvelope(groupID: groupID, epoch: epoch, nonce: nonce, ciphertext: ciphertext)
}
}
/// Decrypted, signature-verified inner content of a group message.
struct GroupMessagePlaintext: Equatable {
let messageID: String
let senderSigningKey: Data
let senderNickname: String
let timestampMs: UInt64
let content: String
}
// MARK: - Crypto
enum GroupCryptoError: Error, Equatable {
case malformedPayload
case signingFailed
case sealFailed
case wrongEpoch
case decryptionFailed
case badSenderSignature
}
enum GroupCrypto {
static let messageSigningDomain = Data("bitchat-group-msg-v1".utf8)
private enum InnerField: UInt8 {
case messageID = 0x01
case senderSigningKey = 0x02
case senderNickname = 0x03
case timestamp = 0x04
case content = 0x05
case signature = 0x06
}
/// Bytes the sender signs: domain | groupID | epoch | messageID | timestamp | content.
/// Covering the epoch stops a current member from re-sealing another
/// member's decrypted inner bytes under a later epoch key (the signature
/// would no longer verify at the new epoch).
static func messageSigningContent(groupID: Data, epoch: UInt32, messageID: String, timestampMs: UInt64, content: String) -> Data {
var data = messageSigningDomain
data.append(groupID)
data.append(GroupTLV.epochData(epoch))
data.append(Data(messageID.utf8))
data.append(GroupTLV.timestampData(timestampMs))
data.append(Data(content.utf8))
return data
}
static func verify(signature: Data, for data: Data, publicKey: Data) -> Bool {
guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else { return false }
return key.isValidSignature(signature, for: data)
}
/// Seals a group message: builds the signed inner TLV and encrypts it with
/// the epoch key. The cleartext group ID and epoch are bound into the AEAD
/// as additional data so ciphertext cannot be replayed across groups or
/// epochs. Returns the encoded 0x25 packet payload.
static func sealMessage(
content: String,
messageID: String,
senderNickname: String,
senderSigningKey: Data,
timestampMs: UInt64,
groupID: Data,
epoch: UInt32,
key: Data,
sign: (Data) -> Data?
) throws -> Data {
let signingContent = messageSigningContent(
groupID: groupID,
epoch: epoch,
messageID: messageID,
timestampMs: timestampMs,
content: content
)
guard let signature = sign(signingContent), signature.count == 64 else {
throw GroupCryptoError.signingFailed
}
var inner = Data()
try GroupTLV.put(InnerField.messageID.rawValue, Data(messageID.utf8), into: &inner)
try GroupTLV.put(InnerField.senderSigningKey.rawValue, senderSigningKey, into: &inner)
try GroupTLV.put(InnerField.senderNickname.rawValue, Data(senderNickname.utf8), into: &inner)
try GroupTLV.put(InnerField.timestamp.rawValue, GroupTLV.timestampData(timestampMs), into: &inner)
try GroupTLV.put(InnerField.content.rawValue, Data(content.utf8), into: &inner)
try GroupTLV.put(InnerField.signature.rawValue, signature, into: &inner)
do {
let symmetricKey = SymmetricKey(data: key)
var aad = groupID
aad.append(GroupTLV.epochData(epoch))
let sealed = try ChaChaPoly.seal(inner, using: symmetricKey, authenticating: aad)
var ciphertext = sealed.ciphertext
ciphertext.append(sealed.tag)
let envelope = GroupMessageEnvelope(
groupID: groupID,
epoch: epoch,
nonce: Data(sealed.nonce),
ciphertext: ciphertext
)
return try envelope.encode()
} catch {
throw GroupCryptoError.sealFailed
}
}
/// Opens a group message envelope with the epoch key: decrypts, parses the
/// inner TLV, and verifies the sender's Ed25519 signature. Roster
/// membership of the sender is the CALLER's check this function only
/// proves the payload was authored by `senderSigningKey`.
static func openMessage(_ envelope: GroupMessageEnvelope, key: Data) throws -> GroupMessagePlaintext {
let inner: Data
do {
let symmetricKey = SymmetricKey(data: key)
var aad = envelope.groupID
aad.append(GroupTLV.epochData(envelope.epoch))
let nonce = try ChaChaPoly.Nonce(data: envelope.nonce)
guard envelope.ciphertext.count > 16 else { throw GroupCryptoError.decryptionFailed }
let tag = envelope.ciphertext.suffix(16)
let body = envelope.ciphertext.prefix(envelope.ciphertext.count - 16)
let sealedBox = try ChaChaPoly.SealedBox(nonce: nonce, ciphertext: body, tag: tag)
inner = try ChaChaPoly.open(sealedBox, using: symmetricKey, authenticating: aad)
} catch {
throw GroupCryptoError.decryptionFailed
}
guard let fields = GroupTLV.parse(inner) else { throw GroupCryptoError.malformedPayload }
var messageID: String?
var senderSigningKey: Data?
var senderNickname: String?
var timestampMs: UInt64?
var content: String?
var signature: Data?
for (type, value) in fields {
switch InnerField(rawValue: type) {
case .messageID:
messageID = String(data: value, encoding: .utf8)
case .senderSigningKey where value.count == 32:
senderSigningKey = value
case .senderNickname:
senderNickname = String(data: value, encoding: .utf8)
case .timestamp:
timestampMs = GroupTLV.timestamp(from: value)
case .content:
content = String(data: value, encoding: .utf8)
case .signature where value.count == 64:
signature = value
default:
break
}
}
guard let messageID, !messageID.isEmpty,
let senderSigningKey,
let senderNickname,
let timestampMs,
let content,
let signature else { throw GroupCryptoError.malformedPayload }
let signingContent = messageSigningContent(
groupID: envelope.groupID,
epoch: envelope.epoch,
messageID: messageID,
timestampMs: timestampMs,
content: content
)
guard verify(signature: signature, for: signingContent, publicKey: senderSigningKey) else {
throw GroupCryptoError.badSenderSignature
}
return GroupMessagePlaintext(
messageID: messageID,
senderSigningKey: senderSigningKey,
senderNickname: senderNickname,
timestampMs: timestampMs,
content: content
)
}
}
-194
View File
@@ -1,194 +0,0 @@
//
// GroupStore.swift
// bitchat
//
// Persistence for private groups: symmetric keys in the keychain, metadata
// (roster, name, epoch) as protected JSON in Application Support. Both are
// dropped by the panic wipe.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import Combine
import Foundation
import Security
@MainActor
final class GroupStore: ObservableObject {
/// All groups this device is a member of, in creation/join order.
@Published private(set) var groups: [BitchatGroup] = []
private let keychain: KeychainManagerProtocol
private let fileURL: URL?
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(keychain: KeychainManagerProtocol, persistsToDisk: Bool = true, fileURL: URL? = nil) {
self.keychain = keychain
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
loadFromDisk()
}
// MARK: - Reads
func group(withID groupID: Data) -> BitchatGroup? {
groups.first { $0.groupID == groupID }
}
func group(for peerID: PeerID) -> BitchatGroup? {
guard let groupID = peerID.groupIDData else { return nil }
return group(withID: groupID)
}
/// Current-epoch symmetric key for the group, from the keychain.
func key(forGroupID groupID: Data) -> Data? {
keychain.getIdentityKey(forKey: Self.keychainKey(for: groupID))
}
// MARK: - Mutations
/// Creates a new group with a random 16-byte ID and 32-byte key at
/// epoch 1, with the creator as sole member. Returns nil when key
/// generation or persistence fails.
func createGroup(named name: String, creator: GroupMember) -> BitchatGroup? {
guard let groupID = Self.randomBytes(BitchatGroup.groupIDLength),
let key = Self.randomBytes(BitchatGroup.keyLength) else { return nil }
let group = BitchatGroup(
groupID: groupID,
name: name,
epoch: 1,
members: [creator],
creatorFingerprint: creator.fingerprint
)
guard upsert(group, key: key) else { return nil }
return group
}
/// Inserts or replaces a group and its current key. Rejects rosters over
/// the hard cap or groups whose creator is missing from the roster.
@discardableResult
func upsert(_ group: BitchatGroup, key: Data) -> Bool {
guard group.groupID.count == BitchatGroup.groupIDLength,
key.count == BitchatGroup.keyLength,
!group.members.isEmpty,
group.members.count <= BitchatGroup.maxMembers,
group.creator != nil else { return false }
guard keychain.saveIdentityKey(key, forKey: Self.keychainKey(for: group.groupID)) else {
SecureLogger.error("Failed to store group key in keychain", category: .security)
return false
}
if let index = groups.firstIndex(where: { $0.groupID == group.groupID }) {
groups[index] = group
} else {
groups.append(group)
}
persist()
return true
}
/// Updates the roster of an existing group without changing key or epoch
/// (creator-side invite). Enforces the member cap.
@discardableResult
func updateRoster(groupID: Data, members: [GroupMember]) -> BitchatGroup? {
guard let index = groups.firstIndex(where: { $0.groupID == groupID }),
!members.isEmpty,
members.count <= BitchatGroup.maxMembers,
members.contains(where: { $0.fingerprint == groups[index].creatorFingerprint }) else { return nil }
groups[index].members = members
persist()
return groups[index]
}
/// Rotates the group key (creator-side removal/rotation): new random key,
/// epoch + 1, and the given roster. Returns the updated group and new key.
func rotateKey(groupID: Data, members: [GroupMember]) -> (group: BitchatGroup, key: Data)? {
guard let existing = group(withID: groupID),
let newKey = Self.randomBytes(BitchatGroup.keyLength) else { return nil }
var rotated = existing
rotated.epoch = existing.epoch &+ 1
rotated.members = members
guard upsert(rotated, key: newKey) else { return nil }
return (rotated, newKey)
}
func removeGroup(withID groupID: Data) {
groups.removeAll { $0.groupID == groupID }
_ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: groupID))
persist()
}
/// Panic wipe: drop all group keys and metadata from memory and disk.
/// (The panic flow also nukes the whole keychain; deleting per-group keys
/// here keeps the store safe to wipe on its own.)
func wipe() {
for group in groups {
_ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: group.groupID))
}
groups.removeAll()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
}
// MARK: - Internals
private static func keychainKey(for groupID: Data) -> String {
"groupKey-\(groupID.hexEncodedString())"
}
private static func randomBytes(_ count: Int) -> Data? {
var bytes = Data(count: count)
let status = bytes.withUnsafeMutableBytes { buffer -> OSStatus in
guard let baseAddress = buffer.baseAddress else { return errSecParam }
return SecRandomCopyBytes(kSecRandomDefault, count, baseAddress)
}
return status == errSecSuccess ? bytes : nil
}
private func persist() {
guard let fileURL else { return }
do {
if groups.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
return
}
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(groups)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try data.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist group store: \(error)", category: .session)
}
}
private func loadFromDisk() {
guard let fileURL,
let data = try? Data(contentsOf: fileURL),
let stored = try? JSONDecoder().decode([BitchatGroup].self, from: data) else {
return
}
// Only groups whose key survived in the keychain are usable.
groups = stored.filter { key(forGroupID: $0.groupID) != nil }
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("groups", isDirectory: true)
.appendingPathComponent("groups.json")
}
}
+2 -2
View File
@@ -89,11 +89,11 @@ final class LocationNotesManager: ObservableObject {
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
private enum Strings {
static let noRelays = String(localized: "location_notes.error.no_relays", defaultValue: "no geo relays available near this location. try again soon.", comment: "Shown when no geo relays are available near the selected location")
static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
static func failedToSend(_ detail: String) -> String {
String(
format: String(localized: "location_notes.error.failed_to_send", defaultValue: "failed to send note. %@", comment: "Shown when a location note fails to send"),
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
locale: .current,
detail
)
+8 -43
View File
@@ -10,10 +10,6 @@ final class MeshTopologyTracker {
private var claims: [RoutingID: Set<RoutingID>] = [:]
// Last time we received an update from a node
private var lastSeen: [RoutingID: Date] = [:]
// Highest protocol version observed from each node's decoded packets.
// Nodes absent from this map are assumed v1-only and are never used as
// hops (or targets) for version-gated routes.
private var observedVersions: [RoutingID: (version: UInt8, seenAt: Date)] = [:]
// Maximum age for topology claims to be considered fresh for routing
// Routes computed using stale topology can fail when the network has changed
@@ -23,75 +19,48 @@ final class MeshTopologyTracker {
queue.sync(flags: .barrier) {
self.claims.removeAll()
self.lastSeen.removeAll()
self.observedVersions.removeAll()
}
}
/// Update the topology with a node's self-reported neighbor list
func updateNeighbors(for sourceData: Data?, neighbors: [Data], at now: Date = Date()) {
func updateNeighbors(for sourceData: Data?, neighbors: [Data]) {
guard let source = sanitize(sourceData) else { return }
// Sanitize neighbors and exclude self-loops
let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source])
queue.sync(flags: .barrier) {
self.claims[source] = validNeighbors
self.lastSeen[source] = now
self.lastSeen[source] = Date()
}
}
/// Record the protocol version observed on a decoded packet from a node.
/// Only versions above the v1 baseline are stored; the highest wins.
func recordObservedVersion(_ version: UInt8, for peerData: Data?, at now: Date = Date()) {
guard version > 1, let peer = sanitize(peerData) else { return }
queue.sync(flags: .barrier) {
let current = self.observedVersions[peer]?.version ?? 1
self.observedVersions[peer] = (version: max(version, current), seenAt: now)
}
}
/// Raw directed neighbor claims, for diagnostics (topology map, /trace).
/// Callers treat the claims as advisory: announces cap `directNeighbors`
/// at 10, so an edge may be claimed by only one of its endpoints.
func adjacencySnapshot() -> [Data: Set<Data>] {
queue.sync { claims }
}
func removePeer(_ data: Data?) {
guard let peer = sanitize(data) else { return }
queue.sync(flags: .barrier) {
self.claims.removeValue(forKey: peer)
self.lastSeen.removeValue(forKey: peer)
self.observedVersions.removeValue(forKey: peer)
}
}
/// Prune nodes that haven't updated their topology in `age` seconds
func prune(olderThan age: TimeInterval, now: Date = Date()) {
let deadline = now.addingTimeInterval(-age)
func prune(olderThan age: TimeInterval) {
let deadline = Date().addingTimeInterval(-age)
queue.sync(flags: .barrier) {
let stale = self.lastSeen.filter { $0.value < deadline }
for (peer, _) in stale {
self.claims.removeValue(forKey: peer)
self.lastSeen.removeValue(forKey: peer)
}
self.observedVersions = self.observedVersions.filter { $0.value.seenAt >= deadline }
}
}
/// BFS over confirmed, fresh edges. When `requiringVersion` is set, every
/// node on the path except the source (i.e. all intermediate hops and the
/// target) must have been observed speaking at least that protocol
/// version a v1-only hop cannot decode a v2 routed packet.
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10, requiringVersion: UInt8? = nil, now: Date = Date()) -> [Data]? {
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? {
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
if source == target { return [] } // Direct connection, no intermediate hops
return queue.sync {
let now = Date()
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
func meetsRequiredVersion(_ peer: RoutingID) -> Bool {
guard let requiringVersion else { return true }
return (observedVersions[peer]?.version ?? 1) >= requiringVersion
}
// BFS
var visited: Set<RoutingID> = [source]
@@ -117,10 +86,6 @@ final class MeshTopologyTracker {
for neighbor in neighbors {
if visited.contains(neighbor) { continue }
// Version gate: skip nodes not known to speak the
// required protocol version.
guard meetsRequiredVersion(neighbor) else { continue }
// CONFIRMED EDGE CHECK:
// 'last' claims 'neighbor' (checked above)
// Does 'neighbor' claim 'last'?
+11 -118
View File
@@ -116,30 +116,30 @@ enum EncryptionStatus: Equatable {
var description: String {
switch self {
case .none:
return String(localized: "encryption.status.failed", defaultValue: "encryption failed", comment: "Status text when encryption failed")
return String(localized: "encryption.status.failed", comment: "Status text when encryption failed")
case .noHandshake:
return String(localized: "encryption.status.not_encrypted", defaultValue: "not encrypted", comment: "Status text when no encryption handshake happened")
return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened")
case .noiseHandshaking:
return String(localized: "encryption.status.establishing", defaultValue: "establishing encryption...", comment: "Status text when encryption is being established")
return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established")
case .noiseSecured:
return String(localized: "encryption.status.secured", defaultValue: "encrypted", comment: "Status text when encryption is secured but not verified")
return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified")
case .noiseVerified:
return String(localized: "encryption.status.verified", defaultValue: "encrypted & verified", comment: "Status text when encryption is verified")
return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified")
}
}
var accessibilityDescription: String {
switch self {
case .none:
return String(localized: "encryption.accessibility.failed", defaultValue: "encryption failed", comment: "Accessibility text when encryption failed")
return String(localized: "encryption.accessibility.failed", comment: "Accessibility text when encryption failed")
case .noHandshake:
return String(localized: "encryption.accessibility.not_encrypted", defaultValue: "not encrypted", comment: "Accessibility text when encryption is not established")
return String(localized: "encryption.accessibility.not_encrypted", comment: "Accessibility text when encryption is not established")
case .noiseHandshaking:
return String(localized: "encryption.accessibility.establishing", defaultValue: "establishing encryption", comment: "Accessibility text when encryption is being established")
return String(localized: "encryption.accessibility.establishing", comment: "Accessibility text when encryption is being established")
case .noiseSecured:
return String(localized: "encryption.accessibility.secured", defaultValue: "encrypted", comment: "Accessibility text when encryption is secured")
return String(localized: "encryption.accessibility.secured", comment: "Accessibility text when encryption is secured")
case .noiseVerified:
return String(localized: "encryption.accessibility.verified", defaultValue: "encrypted and verified", comment: "Accessibility text when encryption is verified")
return String(localized: "encryption.accessibility.verified", comment: "Accessibility text when encryption is verified")
}
}
}
@@ -172,10 +172,6 @@ final class NoiseEncryptionService {
// Security components
private let rateLimiter = NoiseRateLimiter()
private let keychain: KeychainManagerProtocol
// One-time prekeys for forward-secret courier sealing (lazy generation
// inside the store; the batch is minted on first bundle build).
private let localPrekeys: LocalPrekeyStore
// Session maintenance
private var rekeyTimer: Timer?
@@ -204,7 +200,6 @@ final class NoiseEncryptionService {
init(keychain: KeychainManagerProtocol) {
self.keychain = keychain
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
// BCH-01-009: Load or create static identity key with proper error handling
let loadedKey: Curve25519.KeyAgreement.PrivateKey
@@ -417,112 +412,13 @@ final class NoiseEncryptionService {
}
return (payload: payload, senderStaticKey: senderKey.rawRepresentation)
}
// MARK: - One-Time Prekey Envelopes (forward-secret Noise X)
/// Domain separation for prekey-sealed envelopes: distinct from both the
/// interactive XX transcripts and static-sealed courier envelopes, and
/// bound to the specific prekey ID so a ciphertext cannot be replayed
/// against a different prekey.
private static let prekeyProloguePrefix = Data("bitchat-prekey-v1".utf8)
private static func prekeyPrologue(for prekeyID: UInt32) -> Data {
var prologue = prekeyProloguePrefix
var big = prekeyID.bigEndian
withUnsafeBytes(of: &big) { prologue.append(contentsOf: $0) }
return prologue
}
/// Encrypt a payload to one of the recipient's gossiped one-time prekeys
/// (Noise X where the responder static is the prekey, not the identity
/// key). Unlike `sealCourierPayload`, this is forward secret: once the
/// recipient consumes the prekey and its grace window lapses, the private
/// key is deleted and captured ciphertext becomes undecryptable even if
/// the recipient's identity key is later compromised. The initiator's
/// static still rides inside (encrypted), so the recipient authenticates
/// the sender exactly as with static-sealed envelopes.
func sealPrekeyPayload(_ payload: Data, recipientPrekey: PrekeyBundle.Prekey) throws -> Data {
let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientPrekey.publicKey)
let handshake = NoiseHandshakeState(
role: .initiator,
pattern: .X,
keychain: keychain,
localStaticKey: staticIdentityKey,
remoteStaticKey: remoteKey,
prologue: Self.prekeyPrologue(for: recipientPrekey.id)
)
return try handshake.writeMessage(payload: payload)
}
/// Decrypt an envelope sealed to one of our one-time prekeys. On success
/// the prekey is marked consumed (its private key survives a 48h grace
/// window for spray-and-wait redeliveries, then is deleted for good).
/// Returns the payload, the sender's authenticated static key (same
/// contract as `openCourierPayload`), and whether this open actually
/// retired the prekey false for a redelivery of already-consumed mail
/// so the caller can re-gossip the shrunken bundle only when it changed.
func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) {
guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else {
SecureLogger.info("[PREKEY] open failed (unknown prekey id=\(prekeyID))", category: .session)
throw NoiseEncryptionError.unknownPrekey
}
let handshake = NoiseHandshakeState(
role: .responder,
pattern: .X,
keychain: keychain,
localStaticKey: prekeyPrivate,
prologue: Self.prekeyPrologue(for: prekeyID)
)
let payload = try handshake.readMessage(envelopeCiphertext)
guard let senderKey = handshake.getRemoteStaticPublicKey() else {
throw NoiseError.missingKeys
}
let consumedPrekey = localPrekeys.markConsumed(prekeyID)
return (payload: payload, senderStaticKey: senderKey.rawRepresentation, consumedPrekey: consumedPrekey)
}
/// Current signed prekey bundle for gossip, minting the initial batch on
/// first use. Nil only when signing fails.
func currentPrekeyBundle() -> PrekeyBundle? {
let (prekeys, generatedAt) = localPrekeys.currentBundlePrekeys()
guard !prekeys.isEmpty else { return nil }
let unsigned = PrekeyBundle(
noiseStaticPublicKey: getStaticPublicKeyData(),
prekeys: prekeys,
generatedAt: generatedAt,
signature: Data(count: PrekeyBundle.signatureLength)
)
guard let signature = signData(unsigned.signableBytes()) else { return nil }
return PrekeyBundle(
noiseStaticPublicKey: unsigned.noiseStaticPublicKey,
prekeys: prekeys,
generatedAt: generatedAt,
signature: signature
)
}
/// Verify a peer's bundle signature against their announce-bound Ed25519
/// signing key.
func verifyPrekeyBundleSignature(_ bundle: PrekeyBundle, signingPublicKey: Data) -> Bool {
verifySignature(bundle.signature, for: bundle.signableBytes(), publicKey: signingPublicKey)
}
/// Prune dead prekeys and top the batch back up when consumption runs it
/// low. Returns true when the published bundle changed and should be
/// re-gossiped.
@discardableResult
func replenishPrekeysIfNeeded() -> Bool {
localPrekeys.replenishIfNeeded()
}
/// Clear persistent identity (for panic mode)
func clearPersistentIdentity() {
// Clear from keychain
let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
// One-time prekey privates go with the identity they were bound to.
localPrekeys.wipe()
SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
// Stop rekey timer
stopRekeyTimer()
@@ -916,7 +812,4 @@ struct NoiseMessage: Codable {
enum NoiseEncryptionError: Error {
case handshakeRequired
case sessionNotEstablished
/// Envelope references a prekey ID we don't hold (never ours, already
/// deleted after its grace window, or wiped in a panic).
case unknownPrekey
}
@@ -1,228 +0,0 @@
//
// LocalPrekeyStore.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 CryptoKit
import Foundation
/// Owns this device's one-time Curve25519 prekey private keys.
///
/// Privates persist in the Keychain (single blob, same protection class as
/// the identity keys). A batch of `batchSize` unconsumed prekeys backs the
/// gossiped bundle; when consumption drops the unconsumed count below
/// `replenishThreshold`, the batch tops back up and the bundle's
/// `generatedAt` bumps so peers replace their cached copy.
///
/// Redelivery grace: spray-and-wait means the same prekey-sealed ciphertext
/// (or a re-seal of the same message to the same prekey ID) can arrive via
/// several couriers days apart. A consumed prekey's private key is therefore
/// retained for `consumedGraceSeconds` after first use and only then deleted.
/// Tradeoff: during the grace window a compromise of the device still exposes
/// mail sealed to that prekey the forward-secrecy clock starts at deletion,
/// not at first open. Refusing new ciphertexts while accepting redeliveries
/// is not possible (the recipient cannot distinguish them), so the window is
/// kept short and fixed.
final class LocalPrekeyStore {
struct Record: Codable {
let id: UInt32
let privateKey: Data
let createdAt: Date
var consumedAt: Date?
}
private struct Persisted: Codable {
var records: [Record]
var nextID: UInt32
var generatedAt: UInt64
}
enum Policy {
static let batchSize = PrekeyBundle.maxPrekeys
static let replenishThreshold = 3
/// How long a consumed prekey private survives for duplicate courier
/// deliveries of mail sealed to it.
static let consumedGraceSeconds: TimeInterval = 48 * 60 * 60
/// Unconsumed prekeys older than this are rotated out: no honest
/// sender seals to a bundle that stale (see
/// `PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds`).
static let unconsumedRetentionSeconds: TimeInterval = 30 * 24 * 60 * 60
}
private static let keychainKey = "prekeysV1"
private let keychain: KeychainManagerProtocol
private let now: () -> Date
private let queue = DispatchQueue(label: "chat.bitchat.prekeys.local")
// Guarded by `queue`.
private var records: [Record] = []
private var nextID: UInt32 = 0
private var generatedAt: UInt64 = 0
private var loaded = false
init(keychain: KeychainManagerProtocol, now: @escaping () -> Date = Date.init) {
self.keychain = keychain
self.now = now
}
// MARK: - Bundle contents (public prekeys)
/// Unconsumed public prekeys for the gossiped bundle, generating the
/// initial batch on first use. Sorted by ID for canonical signing bytes.
func currentBundlePrekeys() -> (prekeys: [PrekeyBundle.Prekey], generatedAt: UInt64) {
queue.sync {
loadLocked()
_ = replenishLocked()
let prekeys = records
.filter { $0.consumedAt == nil }
.sorted { $0.id < $1.id }
.compactMap { record -> PrekeyBundle.Prekey? in
guard let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey) else { return nil }
return PrekeyBundle.Prekey(id: record.id, publicKey: key.publicKey.rawRepresentation)
}
return (prekeys, generatedAt)
}
}
// MARK: - Opening (private prekeys)
/// Private key for a prekey ID: unconsumed, or consumed within the
/// redelivery grace window.
func privateKey(for id: UInt32) -> Curve25519.KeyAgreement.PrivateKey? {
queue.sync {
loadLocked()
let date = now()
guard let record = records.first(where: { $0.id == id }) else { return nil }
if let consumedAt = record.consumedAt,
date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds {
return nil
}
return try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey)
}
}
/// Marks a prekey consumed (starts its grace clock). Idempotent: a
/// redelivery within the grace window does not restart the clock.
///
/// Returns true when this call actually retired a prekey, i.e. the
/// published bundle shrank. Consuming a prekey drops it from
/// `currentBundlePrekeys()`, so `generatedAt` must advance strictly too:
/// otherwise peers that cached the old bundle reject the same-`generatedAt`
/// replacement in `PrekeyBundleStore.ingest`, keep assigning the consumed
/// ID, and their mail starts failing `unknownPrekey` once the 48h grace
/// lapses. The caller re-gossips on a true result.
@discardableResult
func markConsumed(_ id: UInt32) -> Bool {
queue.sync {
loadLocked()
guard let index = records.firstIndex(where: { $0.id == id }),
records[index].consumedAt == nil else { return false }
records[index].consumedAt = now()
advanceGeneratedAtLocked()
persistLocked()
return true
}
}
/// Prunes dead prekeys and tops the unconsumed batch back up when it runs
/// low. Returns true when the published bundle changed (caller should
/// re-gossip).
@discardableResult
func replenishIfNeeded() -> Bool {
queue.sync {
loadLocked()
return replenishLocked()
}
}
var unconsumedCount: Int {
queue.sync {
loadLocked()
return records.filter { $0.consumedAt == nil }.count
}
}
/// Panic wipe: drop all prekey privates from memory and the Keychain.
func wipe() {
queue.sync {
records.removeAll()
nextID = 0
generatedAt = 0
loaded = true
_ = keychain.deleteIdentityKey(forKey: Self.keychainKey)
}
}
// MARK: - Internals (call only on `queue`)
private func replenishLocked() -> Bool {
let date = now()
// Consumed prekeys past the grace window are gone for good; stale
// unconsumed ones rotate out (their bundle is too old to seal to).
let recordsBefore = records.count
let unconsumedBefore = records.filter { $0.consumedAt == nil }.count
records.removeAll { record in
if let consumedAt = record.consumedAt {
return date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds
}
return date.timeIntervalSince(record.createdAt) > Policy.unconsumedRetentionSeconds
}
// Only a change to the *unconsumed* set alters the published bundle;
// grace-expired consumed keys were never in it.
let unconsumed = records.filter { $0.consumedAt == nil }.count
var bundleChanged = unconsumed != unconsumedBefore
if unconsumed < Policy.replenishThreshold {
for _ in unconsumed..<Policy.batchSize {
let key = Curve25519.KeyAgreement.PrivateKey()
records.append(Record(id: nextID, privateKey: key.rawRepresentation, createdAt: date, consumedAt: nil))
nextID &+= 1
}
advanceGeneratedAtLocked()
bundleChanged = true
SecureLogger.debug("🔑 Replenished one-time prekeys (unconsumed was \(unconsumed))", category: .security)
}
if bundleChanged || records.count != recordsBefore { persistLocked() }
return bundleChanged
}
/// Advance `generatedAt` strictly monotonically. Uses wall-clock millis but
/// never repeats or regresses, so two changes within the same millisecond
/// still produce distinct, increasing stamps that peers' monotonic ingest
/// accepts.
private func advanceGeneratedAtLocked() {
let nowMillis = UInt64(max(0, now().timeIntervalSince1970 * 1000))
generatedAt = max(nowMillis, generatedAt &+ 1)
}
private func loadLocked() {
guard !loaded else { return }
loaded = true
guard let data = keychain.getIdentityKey(forKey: Self.keychainKey),
let persisted = try? JSONDecoder().decode(Persisted.self, from: data) else {
return
}
records = persisted.records
nextID = persisted.nextID
generatedAt = persisted.generatedAt
}
private func persistLocked() {
let persisted = Persisted(records: records, nextID: nextID, generatedAt: generatedAt)
guard let data = try? JSONEncoder().encode(persisted) else {
SecureLogger.error("Failed to encode prekey store", category: .keychain)
return
}
if !keychain.saveIdentityKey(data, forKey: Self.keychainKey) {
SecureLogger.error("Failed to persist prekey store", category: .keychain)
}
}
}
@@ -1,210 +0,0 @@
//
// PrekeyBundleStore.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 Foundation
/// Signature-verified one-time prekey bundles received from other peers.
///
/// One bundle per Noise static key: a newer `generatedAt` replaces the cached
/// copy, keeping the IDs we already sealed with marked used so a prekey is
/// never reused across messages. Assignments are remembered per message ID so
/// deposit retries of the same message re-use its prekey (and its budget)
/// instead of burning a fresh one per courier.
///
/// Only public key material lives here; it persists to disk so a sender can
/// prekey-seal for recipients met long ago. Included in the panic wipe.
final class PrekeyBundleStore {
struct StoredBundle: Codable {
let noiseKey: Data
var generatedAt: UInt64
var prekeyIDs: [UInt32]
var prekeyPublicKeys: [Data]
/// IDs this device already sealed with (never reused).
var usedIDs: Set<UInt32>
/// messageID prekey ID, so re-deposits of one message share one prekey.
var assignments: [String: UInt32]
var updatedAt: Date
}
enum Limits {
static let maxPeers = 200
/// Don't seal to bundles older than this: the owner may have rotated
/// the unconsumed keys out (see `LocalPrekeyStore.Policy`).
static let maxBundleAgeForSealingSeconds: TimeInterval = 7 * 24 * 60 * 60
}
static let shared = PrekeyBundleStore()
private var bundles: [Data: StoredBundle] = [:]
private let queue = DispatchQueue(label: "chat.bitchat.prekeys.bundles")
private let fileURL: URL?
private let maxPeers: Int
private let now: () -> Date
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(
persistsToDisk: Bool = true,
fileURL: URL? = nil,
maxPeers: Int = Limits.maxPeers,
now: @escaping () -> Date = Date.init
) {
self.now = now
self.maxPeers = maxPeers
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
loadFromDisk()
}
// MARK: - Ingest
/// Stores a bundle whose signature the caller has already verified
/// against the owner's announce-bound signing key. Returns false when an
/// equal-or-newer bundle is already cached (nothing changed).
@discardableResult
func ingest(_ bundle: PrekeyBundle) -> Bool {
guard bundle.noiseStaticPublicKey.count == PrekeyBundle.keyLength,
!bundle.prekeys.isEmpty else { return false }
return queue.sync {
if let existing = bundles[bundle.noiseStaticPublicKey],
existing.generatedAt >= bundle.generatedAt {
return false
}
let previous = bundles[bundle.noiseStaticPublicKey]
let newIDs = Set(bundle.prekeys.map(\.id))
// Keep consumption state for IDs the fresh bundle still offers
// (a top-up keeps the owner's unconsumed keys); drop the rest.
let carriedUsed = (previous?.usedIDs ?? []).intersection(newIDs)
let carriedAssignments = (previous?.assignments ?? [:]).filter { newIDs.contains($0.value) }
bundles[bundle.noiseStaticPublicKey] = StoredBundle(
noiseKey: bundle.noiseStaticPublicKey,
generatedAt: bundle.generatedAt,
prekeyIDs: bundle.prekeys.map(\.id),
prekeyPublicKeys: bundle.prekeys.map(\.publicKey),
usedIDs: carriedUsed,
assignments: carriedAssignments,
updatedAt: now()
)
enforceCapLocked()
persistLocked()
return true
}
}
// MARK: - Sealing support
/// Whether an unexpired bundle with sealable prekeys is cached for a peer.
func hasUsableBundle(for noiseKey: Data) -> Bool {
queue.sync {
guard let bundle = bundles[noiseKey], isFreshLocked(bundle) else { return false }
return bundle.usedIDs.count < bundle.prekeyIDs.count
}
}
/// The prekey to seal a message with: the message's existing assignment if
/// any (re-deposits reuse it), else the lowest unused ID, which is then
/// marked used. Nil when no fresh bundle is cached or all its prekeys are
/// spent callers fall back to static sealing.
func assignPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? {
queue.sync {
guard var bundle = bundles[recipientNoiseKey], isFreshLocked(bundle) else { return nil }
if let assigned = bundle.assignments[messageID],
let index = bundle.prekeyIDs.firstIndex(of: assigned) {
return PrekeyBundle.Prekey(id: assigned, publicKey: bundle.prekeyPublicKeys[index])
}
guard let index = bundle.prekeyIDs.indices
.filter({ !bundle.usedIDs.contains(bundle.prekeyIDs[$0]) })
.min(by: { bundle.prekeyIDs[$0] < bundle.prekeyIDs[$1] }) else {
return nil
}
let id = bundle.prekeyIDs[index]
bundle.usedIDs.insert(id)
bundle.assignments[messageID] = id
bundle.updatedAt = now()
bundles[recipientNoiseKey] = bundle
persistLocked()
return PrekeyBundle.Prekey(id: id, publicKey: bundle.prekeyPublicKeys[index])
}
}
// MARK: - Maintenance
/// Panic wipe: drop all cached bundles from memory and disk.
func wipe() {
queue.sync {
bundles.removeAll()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
}
}
// MARK: - Internals (call only on `queue`)
private func isFreshLocked(_ bundle: StoredBundle) -> Bool {
let ageSeconds = now().timeIntervalSince1970 - Double(bundle.generatedAt) / 1000
return ageSeconds <= Limits.maxBundleAgeForSealingSeconds
}
private func enforceCapLocked() {
while bundles.count > maxPeers {
guard let victim = bundles.min(by: { $0.value.updatedAt < $1.value.updatedAt }) else { return }
bundles.removeValue(forKey: victim.key)
}
}
private func persistLocked() {
guard let fileURL else { return }
do {
if bundles.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
return
}
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(Array(bundles.values))
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try data.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist prekey bundle store: \(error)", category: .security)
}
}
private func loadFromDisk() {
guard let fileURL else { return }
queue.sync {
guard let data = try? Data(contentsOf: fileURL),
let stored = try? JSONDecoder().decode([StoredBundle].self, from: data) else {
return
}
for bundle in stored where bundle.prekeyIDs.count == bundle.prekeyPublicKeys.count {
bundles[bundle.noiseKey] = bundle
}
}
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("prekeys", isDirectory: true)
.appendingPathComponent("bundles.json")
}
}
+2 -3
View File
@@ -19,7 +19,6 @@ struct RelayController {
isHandshake: Bool,
isAnnounce: Bool,
isRequestSync: Bool = false,
isUrgentBoardPost: Bool = false,
degree: Int,
highDegreeThreshold: Int) -> RelayDecision {
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
@@ -65,7 +64,7 @@ struct RelayController {
// - Dense graphs: keep lower but still allow multi-hop bridging
// - Thin chains (degree <= 2): every hop counts and flood cost is
// minimal, so relay at full incoming depth
// - Announces (and urgent board posts) get a bit more headroom
// - Announces get a bit more headroom
let ttlLimit: UInt8 = {
if degree >= highDegreeThreshold {
return max(UInt8(2), min(ttlCap, UInt8(5)))
@@ -73,7 +72,7 @@ struct RelayController {
if degree <= 2 {
return ttlCap
}
let preferred = UInt8((isAnnounce || isUrgentBoardPost) ? 7 : 6)
let preferred = UInt8(isAnnounce ? 7 : 6)
return max(UInt8(2), min(ttlCap, preferred))
}()
let newTTL = ttlLimit &- 1
-87
View File
@@ -31,47 +31,10 @@ struct TransportPeerSnapshot: Equatable, Hashable {
}
}
/// Outcome of a `/ping` probe over the mesh.
struct MeshPingResult: Equatable {
/// Round-trip time in milliseconds.
let rttMs: Int
/// Total hops to the peer (1 = directly connected), derived from the
/// pong's TTL decrements; nil when the reply carried inconsistent TTLs.
let hops: Int?
}
/// Undirected mesh link between two peers, normalized so `(a, b)` and
/// `(b, a)` collapse to one edge.
struct MeshTopologyEdge: Hashable {
let a: PeerID
let b: PeerID
init(_ first: PeerID, _ second: PeerID) {
if first < second {
a = first
b = second
} else {
a = second
b = first
}
}
}
/// Point-in-time view of the mesh graph learned from gossiped announces
/// (each announce carries up to 10 `directNeighbors`).
struct MeshTopologySnapshot: Equatable {
let localPeerID: PeerID
let nodes: [PeerID]
let edges: [MeshTopologyEdge]
}
enum TransportEvent: @unchecked Sendable {
case messageReceived(BitchatMessage)
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
/// Encrypted group broadcast (MessageType 0x25). Opaque here the group
/// coordinator decrypts and authenticates against the roster.
case groupMessageReceived(payload: Data, timestamp: Date)
case peerConnected(PeerID)
case peerDisconnected(PeerID)
case peerListUpdated([PeerID])
@@ -161,43 +124,10 @@ protocol Transport: AnyObject {
// transport cannot courier (no connected courier, or unsupported).
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
// Private groups (mesh transports only): creator-signed state travels
// 1:1 over Noise sessions; group messages flood like public broadcasts.
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID)
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
func broadcastGroupMessage(_ envelope: Data)
// Mesh diagnostics (optional for transports). Defaults are inert so
// queue-backed transports (e.g. NostrTransport) stay untouched.
/// Sends a directed ping probe; the completion fires exactly once on the
/// main actor with the measured result, or nil on timeout/unsupported.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void)
/// Estimated intermediate hops toward `peerID` from gossiped topology
/// ([] = direct link, nil = no known path).
func computeMeshPath(to peerID: PeerID) -> [PeerID]?
/// Current mesh graph for the topology map; nil when unsupported.
func currentMeshTopology() -> MeshTopologySnapshot?
// Bulletin board (mesh transports only): broadcast a pre-signed board
// payload (post or tombstone) so it spreads over relay and gossip sync.
func sendBoardPayload(_ payload: Data)
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
// Vouching / transitive verification (optional for transports)
/// Capabilities the peer advertised in its last verified announce;
/// empty for peers that predate the capabilities TLV.
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
/// Sends an encoded vouch-attestation batch inside the Noise session.
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
/// Appends a peer-authenticated observer. Unlike
/// `installNoiseSessionCallbacks` this never touches the (single-slot)
/// handshake-required callback, so secondary features can observe
/// session establishment without disturbing the primary registration.
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void)
// Pending file management (BCH-01-002: files held in memory until user accepts)
func acceptPendingFile(id: String) -> URL?
func declinePendingFile(id: String)
@@ -223,22 +153,7 @@ extension Transport {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {}
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
func broadcastGroupMessage(_ envelope: Data) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
// Mesh diagnostics are mesh-transport-only; other transports report
// "no reply"/"no path" rather than pretending to measure anything.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
Task { @MainActor in completion(nil) }
}
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
func sendBoardPayload(_ payload: Data) {}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {}
@@ -271,8 +186,6 @@ extension BitchatDelegate {
)
case let .noisePayloadReceived(peerID, type, payload, timestamp):
didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp)
case let .groupMessageReceived(payload, timestamp):
didReceiveGroupMessage(payload: payload, timestamp: timestamp)
case .peerConnected(let peerID):
didConnectToPeer(peerID)
case .peerDisconnected(let peerID):
-36
View File
@@ -16,11 +16,6 @@ enum TransportConfig {
static let bleFragmentRelayTtlCap: UInt8 = 7
static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs
// Mesh diagnostics (/ping)
static let meshPingTimeoutSeconds: TimeInterval = 10 // Give up on a probe after this window
static let meshPingInboundMaxPerLink: Int = 5 // Inbound ping budget per ingress link (claimed sender is spoofable)...
static let meshPingInboundWindowSeconds: TimeInterval = 10 // ...per sliding window (anti-amplification)
// UI / Storage Caps
static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337
@@ -213,25 +208,6 @@ enum TransportConfig {
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
// Source routing (v2 directed packets)
// Longest path we will originate, in intermediate hops between us and the
// recipient. Keep small: every hop must be a fresh, confirmed, v2-capable
// node, and long stale paths fail more often than floods.
static let bleSourceRouteMaxIntermediateHops: Int = 4
// A routed send with no inbound traffic from the recipient within this
// window counts as a route failure.
static let bleSourceRouteConfirmationWindowSeconds: TimeInterval = 10.0
// After a route failure, directed sends to that recipient flood instead
// of routing until this lapses.
static let bleSourceRouteSuppressionSeconds: TimeInterval = 60.0
// Targeted fragment resync (REQUEST_SYNC fragmentIdFilter)
// A broadcast reassembly with no new fragment for this long is stalled
// and triggers a targeted REQUEST_SYNC naming its fragment stream.
static let bleFragmentResyncStallSeconds: TimeInterval = 5.0
// Minimum spacing between targeted resync requests for the same stream.
static let bleFragmentResyncRetrySeconds: TimeInterval = 10.0
// Store-and-forward for directed packets at relays. Spooled packets retry
// on each maintenance flush until the window lapses; a longer window lets
// brief link gaps (walking between rooms, reconnect churn) heal themselves.
@@ -328,16 +304,4 @@ enum TransportConfig {
// Cooldown between speculative multi-hop handovers of the same envelope
// toward a recipient heard only via relayed announces.
static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60
// One-time prekey bundles (forward-secret courier sealing)
// Own gossip-sync round for bundles: modest cadence, bounded peer count,
// and a long freshness window so bundles persist mesh-wide while their
// owners are away.
static let syncPrekeyBundleCapacity: Int = 200
static let syncPrekeyBundleIntervalSeconds: TimeInterval = 60.0
static let syncPrekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60
// Unforced re-broadcasts of our own (unchanged) bundle, piggybacked on
// announces, keep it alive in peers' gossip stores; changed bundles are
// sent immediately.
static let prekeyBundleRebroadcastSeconds: TimeInterval = 60 * 60
}
@@ -137,7 +137,7 @@ final class WifiBulkSenderSession {
do {
listener = try NWListener(using: parameters)
} catch {
SecureLogger.error("[WIFI] listener creation failed: \(error)", category: .transport)
SecureLogger.error("WifiBulk: listener creation failed: \(error)", category: .session)
return false
}
listener.service = service
@@ -214,7 +214,7 @@ final class WifiBulkSenderSession {
guard let self, let connection, !self.finished, self.authenticated == nil else { return false }
guard WifiBulkCrypto.validateClientAuthFrameBody(body, transferID: self.transferID, key: key) else {
// Bonjour-level gatecrasher: no channel key, no service.
SecureLogger.warning("[WIFI] AWDL connect rejected (invalid auth frame)", category: .security)
SecureLogger.warning("WifiBulk: disconnecting client with invalid auth frame", category: .security)
self.dropCandidate(connection)
return false
}
@@ -238,7 +238,6 @@ final class WifiBulkSenderSession {
candidate.cancel()
}
candidates.removeAll()
SecureLogger.info("[WIFI] AWDL connected (sender), streaming \(totalChunks) chunk(s)", category: .transport)
streamChunk(at: 0, over: connection, key: key, receiptBuffer: residualBuffer)
}
@@ -373,10 +372,8 @@ final class WifiBulkReceiverSession {
guard let self else { return }
switch state {
case .ready:
SecureLogger.info("[WIFI] AWDL connect established (receiver)", category: .transport)
self.sendAuthFrameAndReceive()
case .failed(let error):
SecureLogger.info("[WIFI] AWDL connect failed: \(error)", category: .transport)
self.fail("connect failed: \(error)")
case .waiting(let error):
// .waiting can resolve on its own, but a per-transfer channel
@@ -172,7 +172,6 @@ final class WifiBulkTransferService {
serviceName: serviceName
)
guard let offerData = offer.encode() else {
SecureLogger.info("[WIFI] fallback→BLE to \(peerID.id.prefix(8))… (offer encode failed)", category: .transport)
fallbackToBLE()
return
}
@@ -230,7 +229,7 @@ final class WifiBulkTransferService {
return
}
SecureLogger.info("[WIFI] offer sent \(payload.count) bytes to \(peerID.id.prefix(8))… over \(serviceName.prefix(8))", category: .transport)
SecureLogger.debug("WifiBulk: offered \(payload.count) bytes to \(peerID.id.prefix(8))… over \(serviceName.prefix(8))", category: .session)
environment.progressStart(transferId, session.totalChunks)
let offerTimeout = DispatchWorkItem { [weak self, weak transfer] in
@@ -272,7 +271,6 @@ final class WifiBulkTransferService {
transfer.accepted = true
transfer.offerTimeout?.cancel()
transfer.offerTimeout = nil
SecureLogger.info("[WIFI] offer accepted by \(peerID.id.prefix(8))…, activating channel", category: .transport)
transfer.session?.activate(key: key)
}
@@ -286,13 +284,13 @@ final class WifiBulkTransferService {
switch outcome {
case .completed:
SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… complete (\(reason))", category: .transport)
SecureLogger.debug("WifiBulk: transfer \(transfer.transferId.prefix(8))… completed (\(reason))", category: .session)
case .fallback:
SecureLogger.info("[WIFI] fallback→BLE \(transfer.transferId.prefix(8))… (\(reason))", category: .transport)
SecureLogger.info("WifiBulk: transfer \(transfer.transferId.prefix(8)) falling back to BLE (\(reason))", category: .session)
environment.progressReset(transfer.transferId)
transfer.fallback()
case .cancelled:
SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… cancelled", category: .transport)
SecureLogger.debug("WifiBulk: transfer \(transfer.transferId.prefix(8))… cancelled", category: .session)
environment.progressCancel(transfer.transferId)
}
}
@@ -340,13 +338,13 @@ final class WifiBulkTransferService {
let transfer = IncomingTransfer(offer: offer, peerID: peerID, key: key)
incoming[offer.transferID] = transfer
SecureLogger.info("[WIFI] offer accept \(offer.fileSize) bytes from \(peerID.id.prefix(8))", category: .transport)
SecureLogger.debug("WifiBulk: accepted offer of \(offer.fileSize) bytes from \(peerID.id.prefix(8))", category: .session)
startBrowsing(for: transfer)
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
guard let self, let transfer else { return }
SecureLogger.info("[WIFI] incoming window expired", category: .transport)
SecureLogger.info("WifiBulk: incoming transfer window expired", category: .session)
self.tearDownIncoming(transfer)
}
transfer.windowTimeout = windowTimeout
@@ -354,7 +352,7 @@ final class WifiBulkTransferService {
}
private func decline(offer: WifiBulkOffer, peerID: PeerID) {
SecureLogger.info("[WIFI] offer decline \(offer.fileSize) bytes from \(peerID.id.prefix(8))", category: .transport)
SecureLogger.debug("WifiBulk: declining offer of \(offer.fileSize) bytes from \(peerID.id.prefix(8))", category: .session)
guard let responseData = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return }
_ = environment.sendNoisePayload(
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
@@ -382,7 +380,7 @@ final class WifiBulkTransferService {
browser.stateUpdateHandler = { [weak self, weak transfer] state in
guard let self, let transfer else { return }
if case .failed(let error) = state {
SecureLogger.warning("[WIFI] browser failed: \(error)", category: .transport)
SecureLogger.warning("WifiBulk: browser failed: \(error)", category: .session)
self.tearDownIncoming(transfer)
}
}
@@ -418,13 +416,13 @@ final class WifiBulkTransferService {
}
session.onCompleted = { [weak self, weak transfer] payload in
guard let self, let transfer else { return }
SecureLogger.info("[WIFI] transfer complete, received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))", category: .transport)
SecureLogger.debug("WifiBulk: received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))", category: .session)
self.environment.deliverReceivedFile(payload, transfer.peerID, self.config.maxIncomingPayloadBytes)
self.tearDownIncoming(transfer)
}
session.onFailed = { [weak self, weak transfer] reason in
guard let self, let transfer else { return }
SecureLogger.info("[WIFI] incoming failed (\(reason)); sender falls back to BLE", category: .transport)
SecureLogger.info("WifiBulk: incoming transfer failed (\(reason)); sender falls back to BLE", category: .session)
self.tearDownIncoming(transfer)
}
transfer.session = session
+26 -232
View File
@@ -73,22 +73,11 @@ final class GossipSyncManager {
var stalePeerTimeoutSeconds: TimeInterval = 60.0
var fragmentCapacity: Int = 600
var fileTransferCapacity: Int = 200
var groupMessageCapacity: Int = 200
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
var messageSyncIntervalSeconds: TimeInterval = 15.0
// Board posts are few but long-lived (days, until each post's own
// expiry), so they get a slow round with their own capacity instead
// of competing with the 15-minute message window.
var boardCapacity: Int = 200
var boardSyncIntervalSeconds: TimeInterval = 60.0
var responseRateLimitMaxResponses: Int = 8
var responseRateLimitWindowSeconds: TimeInterval = 30.0
// Prekey bundles: one per peer, own sync round, long freshness so
// bundles persist mesh-wide while their owners are offline.
var prekeyBundleCapacity: Int = 200
var prekeyBundleSyncIntervalSeconds: TimeInterval = 60.0
var prekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60
}
private let myPeerID: PeerID
@@ -97,22 +86,11 @@ final class GossipSyncManager {
private let archive: GossipMessageArchive?
weak var delegate: Delegate?
/// Source of raw signed board packets (posts + tombstones). The board
/// store is the single owner of board retention (expiry, tombstones,
/// caps, persistence), so sync rounds query it instead of keeping a
/// second copy here. Must be thread-safe; set before `start()`.
var boardPacketsProvider: (() -> [BitchatPacket])?
// Storage: broadcast packets by type, and latest announce per sender
private var messages = PacketStore()
private var fragments = PacketStore()
private var fileTransfers = PacketStore()
private var groupMessages = PacketStore()
private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:]
// Latest verified prekey bundle per owner. Unlike announces, bundles are
// NOT dropped on leave/stale peer: their whole purpose is reaching a
// sender while the owner is away.
private var latestPrekeyBundleByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
private var archiveDirty = false
// Timer
@@ -133,13 +111,7 @@ final class GossipSyncManager {
)
var schedules: [SyncSchedule] = []
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
// Group messages ride the public-message cadence; old clients
// ignore the extended bit and answer with announces/messages only.
var messageTypes: SyncTypeFlags = .publicMessages
if config.groupMessageCapacity > 0 {
messageTypes.formUnion(.groupMessage)
}
schedules.append(SyncSchedule(types: messageTypes, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
}
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
@@ -147,12 +119,6 @@ final class GossipSyncManager {
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
}
if config.prekeyBundleCapacity > 0 && config.prekeyBundleSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .prekeyBundle, interval: config.prekeyBundleSyncIntervalSeconds, lastSent: .distantPast))
}
if config.boardCapacity > 0 && config.boardSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .board, interval: config.boardSyncIntervalSeconds, lastSent: .distantPast))
}
syncSchedules = schedules
if archive != nil {
@@ -183,21 +149,12 @@ final class GossipSyncManager {
guard let self = self else { return }
var types: SyncTypeFlags = .publicMessages
if self.config.groupMessageCapacity > 0 {
types.formUnion(.groupMessage)
}
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
types.formUnion(.fragment)
}
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
types.formUnion(.fileTransfer)
}
if self.config.prekeyBundleCapacity > 0 && self.config.prekeyBundleSyncIntervalSeconds > 0 {
types.formUnion(.prekeyBundle)
}
if self.config.boardCapacity > 0 && self.config.boardSyncIntervalSeconds > 0 && self.boardPacketsProvider != nil {
types.formUnion(.board)
}
self.sendRequestSync(to: peerID, types: types)
}
}
@@ -212,17 +169,9 @@ final class GossipSyncManager {
// messages get the long town-crier window; fragments, file transfers and
// announces keep the short one.
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
let maxAgeSeconds: TimeInterval
switch packet.type {
// Group messages share the whole-message window: members off the mesh
// for a while should backfill their crew's history like public chat.
case MessageType.message.rawValue, MessageType.groupMessage.rawValue:
maxAgeSeconds = config.publicMessageMaxAgeSeconds
case MessageType.prekeyBundle.rawValue:
maxAgeSeconds = config.prekeyBundleMaxAgeSeconds
default:
maxAgeSeconds = config.maxMessageAgeSeconds
}
let maxAgeSeconds = packet.type == MessageType.message.rawValue
? config.publicMessageMaxAgeSeconds
: config.maxMessageAgeSeconds
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
let ageThresholdMs = UInt64(maxAgeSeconds * 1000)
@@ -257,8 +206,9 @@ final class GossipSyncManager {
removeState(for: sender)
return
}
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
let sender = PeerID(hexData: packet.senderID)
latestAnnouncementByPeer[sender] = packet
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
case .message:
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
@@ -275,36 +225,6 @@ final class GossipSyncManager {
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
case .prekeyBundle:
// Callers only feed verified bundles here (own bundles at send
// time, peers' after signature verification), so gossip never
// spreads a bundle this node couldn't attribute.
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
// Key by the bundle's authenticated identity (its noise static key),
// NOT the unauthenticated packet senderID. Otherwise one valid
// bundle re-broadcast under many fabricated sender IDs would create
// one cache entry each and exhaust the per-owner cap, starving
// legitimate bundles. One owner at most one entry.
guard let bundle = PrekeyBundle.decode(packet.payload) else { return }
let owner = PeerID(publicKey: bundle.noiseStaticPublicKey)
if let existing = latestPrekeyBundleByPeer[owner],
existing.packet.timestamp >= packet.timestamp {
return
}
// Bounded owner count; replacing a known owner's bundle is always
// allowed so the cap can't block refreshes.
guard latestPrekeyBundleByPeer[owner] != nil
|| latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet)
case .groupMessage:
// Opaque ciphertext to non-members; carried and served like any
// other broadcast so members get backfill from any relay.
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
groupMessages.insert(idHex: idHex, packet: packet, capacity: max(1, config.groupMessageCapacity))
default:
break
}
@@ -313,6 +233,7 @@ final class GossipSyncManager {
private func sendPeriodicSync(for types: SyncTypeFlags) {
// Unicast sync to connected peers to allow RSR attribution
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync)
for peerID in connectedPeers {
sendRequestSync(to: peerID, types: types)
}
@@ -337,29 +258,11 @@ final class GossipSyncManager {
delegate?.sendPacket(signed)
}
/// Targeted fragment recovery: ask connected peers for the specific
/// fragment streams whose reassembly has stalled, instead of waiting on
/// the next periodic GCS fragment round to cover them.
func requestMissingFragments(fragmentIDs: [Data]) {
queue.async { [weak self] in
self?._requestMissingFragments(fragmentIDs)
}
}
private func _requestMissingFragments(_ fragmentIDs: [Data]) {
guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return }
guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return }
SecureLogger.info("[ROUTE] targeted-resync \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .mesh)
for peerID in connectedPeers {
sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter)
}
}
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags, fragmentIdFilter: String? = nil) {
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
// Register the request for RSR validation
requestSyncManager.registerRequest(to: peerID)
let payload = buildGcsPayload(for: types, fragmentIdFilter: fragmentIdFilter)
let payload = buildGcsPayload(for: types)
var recipient = Data()
var temp = peerID.id
while temp.count >= 2 && recipient.count < 8 {
@@ -390,7 +293,7 @@ final class GossipSyncManager {
// A response can replay the whole store, so bound how often one peer
// can trigger a diff pass regardless of how fast it asks.
guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else {
SecureLogger.warning("[SYNC] rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))", category: .sync)
SecureLogger.warning("Rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))", category: .sync)
return
}
let requestedTypes = (request.types ?? .publicMessages)
@@ -398,9 +301,6 @@ final class GossipSyncManager {
// older packets are outside the filter but not missing, and without
// the cursor they would be re-sent every round.
let since = request.sinceTimestamp
// One concise per-request summary (RSR is already rate-limited per
// peer), never per-packet spam.
var servedCount = 0
// Decode GCS into sorted set and prepare membership checker
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool {
@@ -412,15 +312,15 @@ final class GossipSyncManager {
// keys needed to verify everything else, and there is at most one per
// peer, so the resend cost is negligible.
if requestedTypes.contains(.announce) {
for (_, pkt) in latestAnnouncementByPeer {
for (_, pair) in latestAnnouncementByPeer {
let (idHex, pkt) = pair
guard isPacketFresh(pkt) else { continue }
let idBytes = PacketIdUtil.computeId(pkt)
let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
}
}
}
@@ -435,32 +335,20 @@ final class GossipSyncManager {
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
}
}
}
if requestedTypes.contains(.fragment) {
// A fragment-ID filter narrows the diff to exactly the named
// fragment streams (targeted resync for stalled reassemblies)
// and bypasses the since-cursor for them; the GCS filter still
// excludes the pieces the requester already holds. Fragment
// payloads start with the 8-byte stream ID.
let fragmentIdFilter = RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter)
let frags = fragments.allPackets(isFresh: isPacketFresh)
for pkt in frags {
if let fragmentIdFilter {
guard fragmentIdFilter.contains(Data(pkt.payload.prefix(8))) else { continue }
} else if let since, pkt.timestamp < since {
continue
}
if let since, pkt.timestamp < since { continue }
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
}
}
}
@@ -475,73 +363,17 @@ final class GossipSyncManager {
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
}
}
}
// Like announces, prekey bundles are exempt from the since-cursor:
// there is at most one per owner (newer replaces older), so the
// resend cost is bounded and a joining peer must be able to learn
// bundles generated long before it arrived.
if requestedTypes.contains(.prekeyBundle) {
for (_, pair) in latestPrekeyBundleByPeer {
let (idHex, pkt) = pair
guard isPacketFresh(pkt) else { continue }
let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
}
}
}
if requestedTypes.contains(.groupMessage) {
let groupPkts = groupMessages.allPackets(isFresh: isPacketFresh)
for pkt in groupPkts {
if let since, pkt.timestamp < since { continue }
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
}
}
}
if requestedTypes.contains(.boardPost) {
// The board store already filters to live posts and tombstones;
// no freshness window applies (posts sync until their own expiry).
let boardPackets = boardPacketsProvider?() ?? []
for pkt in boardPackets {
if let since, pkt.timestamp < since { continue }
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
servedCount += 1
}
}
}
if servedCount > 0 {
SecureLogger.info("[SYNC] served \(servedCount) packet(s) [\(requestedTypes.debugShortLabel)] to \(peerID.id.prefix(8))", category: .sync)
}
}
// Build REQUEST_SYNC payload using current candidates and GCS params
private func buildGcsPayload(for types: SyncTypeFlags, fragmentIdFilter: String? = nil) -> Data {
private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
var candidates: [BitchatPacket] = []
if types.contains(.announce) {
for (_, pkt) in latestAnnouncementByPeer where isPacketFresh(pkt) {
candidates.append(pkt)
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
candidates.append(pair.packet)
}
}
if types.contains(.message) {
@@ -553,20 +385,9 @@ final class GossipSyncManager {
if types.contains(.fileTransfer) {
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
}
if types.contains(.prekeyBundle) {
for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) {
candidates.append(pair.packet)
}
}
if types.contains(.groupMessage) {
candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
}
if types.contains(.boardPost) {
candidates.append(contentsOf: boardPacketsProvider?() ?? [])
}
if candidates.isEmpty {
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter)
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
return req.encode()
}
@@ -580,16 +401,12 @@ final class GossipSyncManager {
cap = max(1, config.fragmentCapacity)
} else if types == .fileTransfer {
cap = max(1, config.fileTransferCapacity)
} else if types == .prekeyBundle {
cap = max(1, config.prekeyBundleCapacity)
} else if types == .board {
cap = max(1, config.boardCapacity)
} else {
cap = max(1, config.seenCapacity)
}
let takeN = min(candidates.count, min(nMax, cap))
if takeN <= 0 {
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter)
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
return req.encode()
}
let included = Array(candidates.prefix(takeN))
@@ -607,15 +424,15 @@ final class GossipSyncManager {
let sinceTimestamp: UInt64? = (covered < candidates.count && covered > 0)
? included[covered - 1].timestamp
: nil
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp)
return req.encode()
}
// Periodic cleanup of expired messages and announcements
private func cleanupExpiredMessages() {
// Remove expired announcements
latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pkt in
isPacketFresh(pkt)
latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in
isPacketFresh(pair.packet)
}
let messageCountBefore = messages.packets.count
@@ -625,10 +442,6 @@ final class GossipSyncManager {
}
fragments.removeExpired(isFresh: isPacketFresh)
fileTransfers.removeExpired(isFresh: isPacketFresh)
latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
isPacketFresh(pair.packet)
}
groupMessages.removeExpired(isFresh: isPacketFresh)
}
// MARK: - Archive (public message persistence)
@@ -677,23 +490,13 @@ final class GossipSyncManager {
// One request per due schedule rather than a union filter: each type
// group gets the full GCS capacity and its own since-cursor, so heavy
// fragment traffic can't crowd messages out of the filter.
var dueLabels: [String] = []
for index in syncSchedules.indices {
guard syncSchedules[index].interval > 0 else { continue }
// No board source wired up means nothing to offer or store;
// skip the round entirely.
if syncSchedules[index].types == .board && boardPacketsProvider == nil { continue }
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
syncSchedules[index].lastSent = now
sendPeriodicSync(for: syncSchedules[index].types)
dueLabels.append(syncSchedules[index].types.debugShortLabel)
}
}
// One concise summary per maintenance cycle, not per packet.
if !dueLabels.isEmpty {
let peerCount = delegate?.getConnectedPeers().count ?? 0
SecureLogger.info("[SYNC] cycle: request [\(dueLabels.joined(separator: " "))] to \(peerCount) peer(s)", category: .sync)
}
}
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
@@ -709,8 +512,8 @@ final class GossipSyncManager {
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
guard nowMs >= timeoutMs else { return }
let cutoff = nowMs - timeoutMs
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pkt in
pkt.timestamp < cutoff ? peerID : nil
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in
pair.packet.timestamp < cutoff ? peerID : nil
}
guard !stalePeerIDs.isEmpty else { return }
for peerKey in stalePeerIDs {
@@ -726,8 +529,6 @@ final class GossipSyncManager {
}
private func removeState(for peerID: PeerID) {
// Deliberately keeps the peer's prekey bundle: bundles exist to reach
// owners who left the mesh, and they age out on their own schedule.
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
let messageCountBefore = messages.packets.count
messages.remove { PeerID(hexData: $0.senderID) == peerID }
@@ -736,7 +537,6 @@ final class GossipSyncManager {
}
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
groupMessages.remove { PeerID(hexData: $0.senderID) == peerID }
}
}
@@ -754,12 +554,6 @@ extension GossipSyncManager {
}
}
func _hasPrekeyBundle(for peerID: PeerID) -> Bool {
queue.sync {
latestPrekeyBundleByPeer[peerID] != nil
}
}
func _messageCount(for peerID: PeerID) -> Int {
queue.sync {
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
+1 -61
View File
@@ -7,25 +7,9 @@ struct SyncTypeFlags: OptionSet {
let rawValue: UInt64
init(rawValue: UInt64) {
// Drop any bit that doesn't map to a known message type. Wire data can
// carry up to 8 bytes of flags; without this mask, bits with no type
// (a truncated/garbled field, or a type a newer peer added) would live
// in the set as phantom membership that no `contains` check matches and
// `toData` re-serializes a meaningless "accepted but does nothing"
// state. Masking here keeps every instance normalized at the source.
self.rawValue = rawValue & SyncTypeFlags.knownTypeMask
self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes
}
/// Union of every bit that maps to a message type. Derived from the
/// bittype table so it tracks automatically when a type is added.
private static let knownTypeMask: UInt64 = {
var mask: UInt64 = 0
for bit in 0..<64 where SyncTypeFlags.type(forBit: bit) != nil {
mask |= (1 << UInt64(bit))
}
return mask
}()
private static func bitIndex(for type: MessageType) -> Int? {
switch type {
case .announce: return 0
@@ -36,29 +20,9 @@ struct SyncTypeFlags: OptionSet {
case .fragment: return 5
case .requestSync: return 6
case .fileTransfer: return 7
// Extended bits are compat-safe by construction: `toData()` encodes
// the bitfield little-endian with trailing zero bytes trimmed (bit 10
// widens the wire form from 1 to 2 bytes inside the length-prefixed
// REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while
// `type(forBit:)` maps unknown bits to nil so old clients simply
// ignore the group bit and answer with the types they know.
case .boardPost: return 8
case .groupMessage: return 10
// Courier envelopes are directed deposits between trusted peers and
// must never spread via gossip sync.
case .courierEnvelope: return nil
// 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 clients decode the wider flags and simply never
// match the new bits.
case .prekeyBundle: return 9
// Gateway carriers are ephemeral live traffic (uplinks are directed,
// downlinks are rate-budgeted rebroadcasts); replaying them via sync
// would waste airtime and extend their lifetime.
case .nostrCarrier: return nil
// Ping/pong are ephemeral directed probes; replaying them via gossip
// sync would only produce stale, unanswerable echoes.
case .ping, .pong: return nil
}
}
@@ -72,12 +36,6 @@ struct SyncTypeFlags: OptionSet {
case 5: return .fragment
case 6: return .requestSync
case 7: return .fileTransfer
// Bit 8 spills the encoded bitfield into a second byte. Decoders since
// type-aware sync (#853) accept 1-8 bytes and map unknown bits to no
// known type, so old clients ignore board rounds instead of choking.
case 8: return .boardPost
case 9: return .prekeyBundle
case 10: return .groupMessage
default:
return nil
}
@@ -87,9 +45,6 @@ struct SyncTypeFlags: OptionSet {
static let message = SyncTypeFlags(messageTypes: [.message])
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
static let board = SyncTypeFlags(messageTypes: [.boardPost])
static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle])
static let groupMessage = SyncTypeFlags(messageTypes: [.groupMessage])
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
@@ -150,19 +105,4 @@ struct SyncTypeFlags: OptionSet {
}
return SyncTypeFlags(rawValue: raw)
}
/// Compact human label for the `[SYNC]` observability logs, e.g. "msg,frag,pre".
/// (Referenced only from DEBUG log lines, but kept unconditional so the
/// log-call arguments compile in release too, where the log itself no-ops.)
var debugShortLabel: String {
var parts: [String] = []
if contains(.announce) { parts.append("ann") }
if contains(.message) { parts.append("msg") }
if contains(.fragment) { parts.append("frag") }
if contains(.fileTransfer) { parts.append("file") }
if contains(.prekeyBundle) { parts.append("pre") }
if contains(.groupMessage) { parts.append("grp") }
if contains(.boardPost) { parts.append("board") }
return parts.isEmpty ? "none" : parts.joined(separator: ",")
}
}
@@ -12,17 +12,17 @@ enum ChatBluetoothAlertPolicy {
case .poweredOff:
ChatBluetoothAlertUpdate(
isPresented: true,
message: String(localized: "content.alert.bluetooth_required.off", defaultValue: "bluetooth is turned off. please turn on bluetooth in settings to use bitchat.", comment: "Message shown when Bluetooth is turned off")
message: String(localized: "content.alert.bluetooth_required.off", comment: "Message shown when Bluetooth is turned off")
)
case .unauthorized:
ChatBluetoothAlertUpdate(
isPresented: true,
message: String(localized: "content.alert.bluetooth_required.permission", defaultValue: "bitchat needs bluetooth permission to connect with nearby devices. please enable bluetooth access in settings.", comment: "Message shown when Bluetooth permission is missing")
message: String(localized: "content.alert.bluetooth_required.permission", comment: "Message shown when Bluetooth permission is missing")
)
case .unsupported:
ChatBluetoothAlertUpdate(
isPresented: true,
message: String(localized: "content.alert.bluetooth_required.unsupported", defaultValue: "this device does not support bluetooth. bitchat requires bluetooth to function.", comment: "Message shown when the device lacks Bluetooth support")
message: String(localized: "content.alert.bluetooth_required.unsupported", comment: "Message shown when the device lacks Bluetooth support")
)
case .poweredOn:
ChatBluetoothAlertUpdate(isPresented: false, message: "")
@@ -1,602 +0,0 @@
import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatGroupCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. Group chats are keyed like direct chats
/// (virtual "group_" peer IDs), so the conversation intents below reuse the
/// private-chat store operations.
@MainActor
protocol ChatGroupContext: AnyObject {
// MARK: Identity & state
var nickname: String { get }
var myPeerID: PeerID { get }
var selectedPrivateChatPeer: PeerID? { get }
var groupStore: GroupStore { get }
/// Fingerprint of our own Noise static identity key.
func myNoiseFingerprint() -> String
/// Our Ed25519 signing public key.
func mySigningPublicKey() -> Data
/// Signs `data` with our Noise signing key.
func signWithNoiseKey(_ data: Data) -> Data?
// MARK: Peers
func getPeerIDForNickname(_ nickname: String) -> PeerID?
func isPeerConnected(_ peerID: PeerID) -> Bool
func peerNickname(for peerID: PeerID) -> String?
/// The peer's Noise fingerprint from the live session/registry.
func meshFingerprint(for peerID: PeerID) -> String?
/// The peer's persisted crypto identity (fingerprint + signing key), if
/// the identity store has a signature-verified announce for them.
func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)?
/// The connected short peer ID whose fingerprint matches, if any.
func connectedPeerID(forFingerprint fingerprint: String) -> PeerID?
/// Whether the user has blocked the identity with this Noise fingerprint.
func isFingerprintBlocked(_ fingerprint: String) -> Bool
// MARK: Transport
func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID)
func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID)
func broadcastGroupMessagePayload(_ payload: Data)
// MARK: Conversation intents (group chats are direct-keyed)
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
func markPrivateChatUnread(_ peerID: PeerID)
func removePrivateChat(_ peerID: PeerID)
func startPrivateChat(with peerID: PeerID)
func endPrivateChat()
func addSystemMessage(_ content: String)
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
func notifyUIChanged()
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID)
}
extension ChatViewModel: ChatGroupContext {
// `nickname`, `myPeerID`, `selectedPrivateChatPeer`, `groupStore`,
// `getPeerIDForNickname(_:)`, `isPeerConnected(_:)`, `peerNickname(for:)`,
// `appendPrivateMessage(_:to:)`, `markPrivateChatUnread(_:)`,
// `removePrivateChat(_:)`, `startPrivateChat(with:)`,
// `addSystemMessage(_:)`, `addLocalPrivateSystemMessage(_:to:)`,
// `notifyUIChanged()`, and `notifyPrivateMessage(from:message:peerID:)`
// are shared requirements with the other contexts or satisfied by
// existing `ChatViewModel` members. The members below flatten nested
// service accesses into intent-named calls.
func myNoiseFingerprint() -> String {
meshService.noiseIdentityFingerprint()
}
func mySigningPublicKey() -> Data {
meshService.noiseSigningPublicKeyData()
}
func signWithNoiseKey(_ data: Data) -> Data? {
meshService.noiseSignData(data)
}
func meshFingerprint(for peerID: PeerID) -> String? {
meshService.getFingerprint(for: peerID)
}
/// The persisted, signature-verified identity behind a short mesh peer
/// ID. Cross-checked against the live session fingerprint so a roster
/// entry can never be pinned to a signing key from a different identity.
func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? {
guard let fingerprint = meshService.getFingerprint(for: peerID) else { return nil }
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
guard let identity = candidates.first(where: { $0.fingerprint == fingerprint }),
let signingKey = identity.signingPublicKey else { return nil }
return (fingerprint, signingKey)
}
/// Short mesh peer IDs are the fingerprint's first 16 hex chars, so the
/// connected peer for a roster fingerprint is a direct derivation.
func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? {
let shortID = PeerID(str: String(fingerprint.prefix(16)))
return meshService.isPeerConnected(shortID) ? shortID : nil
}
func isFingerprintBlocked(_ fingerprint: String) -> Bool {
identityManager.isBlocked(fingerprint: fingerprint)
}
func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) {
meshService.sendGroupInvite(payload, to: peerID)
}
func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) {
meshService.sendGroupKeyUpdate(payload, to: peerID)
}
func broadcastGroupMessagePayload(_ payload: Data) {
meshService.broadcastGroupMessage(payload)
}
// MARK: CommandContextProvider group commands (parsed by CommandProcessor)
func groupCreate(named name: String) -> CommandResult {
groupCoordinator.createGroup(named: name)
}
func groupInvite(nickname: String) -> CommandResult {
groupCoordinator.inviteMember(nickname: nickname)
}
func groupRemove(nickname: String) -> CommandResult {
groupCoordinator.removeMember(nickname: nickname)
}
func groupLeave() -> CommandResult {
groupCoordinator.leaveGroup()
}
func groupList() -> CommandResult {
groupCoordinator.listGroups()
}
}
/// Owns the private-groups feature: creating groups, creator-managed invites
/// and key rotation over Noise, and sealing/opening group message broadcasts.
/// Delivery is fire-and-flood like public chat no per-member acks in v1
/// with gossip-sync backfill as the only offline catch-up.
@MainActor
final class ChatGroupCoordinator {
private unowned let context: any ChatGroupContext
private static let maxGroupNameLength = 40
init(context: any ChatGroupContext) {
self.context = context
}
// MARK: - Commands
func createGroup(named rawName: String) -> CommandResult {
let name = rawName.trimmed
guard !name.isEmpty else {
return .error(message: String(localized: "system.group.usage_create", defaultValue: "usage: /group create <name>", comment: "Usage hint for /group create"))
}
guard name.count <= Self.maxGroupNameLength else {
return .error(message: String(localized: "system.group.name_too_long", defaultValue: "group names are limited to 40 characters", comment: "Error when a group name exceeds the length cap"))
}
let myFingerprint = context.myNoiseFingerprint()
let mySigningKey = context.mySigningPublicKey()
guard !myFingerprint.isEmpty, mySigningKey.count == 32 else {
return .error(message: String(localized: "system.group.identity_unavailable", defaultValue: "your identity keys are not ready yet", comment: "Error when the local identity is not ready for group operations"))
}
let creator = GroupMember(fingerprint: myFingerprint, signingKey: mySigningKey, nickname: context.nickname)
guard let group = context.groupStore.createGroup(named: name, creator: creator) else {
return .error(message: String(localized: "system.group.create_failed", defaultValue: "could not create the group", comment: "Error when group creation fails"))
}
context.startPrivateChat(with: group.peerID)
return .success(message: String(
format: String(localized: "system.group.created", defaultValue: "created group '%@' — use /group invite @name to add people", comment: "System message after creating a group; placeholder is the group name"),
locale: .current,
name
))
}
func inviteMember(nickname rawNickname: String) -> CommandResult {
let nickname = normalizedNickname(rawNickname)
guard !nickname.isEmpty else {
return .error(message: String(localized: "system.group.usage_invite", defaultValue: "usage: /group invite @name", comment: "Usage hint for /group invite"))
}
guard let group = selectedGroup() else {
return .error(message: String(localized: "system.group.not_in_group", defaultValue: "open a group chat first", comment: "Error when a group command requires an open group chat"))
}
guard group.creatorFingerprint == context.myNoiseFingerprint() else {
return .error(message: String(localized: "system.group.creator_only", defaultValue: "only the group creator can do that", comment: "Error when a non-creator attempts a creator-only group action"))
}
guard let peerID = context.getPeerIDForNickname(nickname) else {
return .error(message: String(
format: String(localized: "system.group.peer_not_found", defaultValue: "'%@' not found", comment: "Error when the invitee nickname is unknown; placeholder is the nickname"),
locale: .current,
nickname
))
}
guard context.isPeerConnected(peerID) else {
return .error(message: String(
format: String(localized: "system.group.peer_not_connected", defaultValue: "%@ must be connected over mesh to be invited", comment: "Error when the invitee is not connected over mesh; placeholder is the nickname"),
locale: .current,
nickname
))
}
guard let identity = context.cryptoIdentity(for: peerID) else {
return .error(message: String(
format: String(localized: "system.group.peer_identity_unknown", defaultValue: "cannot verify %@'s identity yet — wait for their announce", comment: "Error when the invitee's verified identity is unavailable; placeholder is the nickname"),
locale: .current,
nickname
))
}
guard !group.isMember(fingerprint: identity.fingerprint) else {
return .error(message: String(
format: String(localized: "system.group.already_member", defaultValue: "%@ is already a member", comment: "Error when the invitee is already a member; placeholder is the nickname"),
locale: .current,
nickname
))
}
guard group.members.count < BitchatGroup.maxMembers else {
return .error(message: String(
format: String(localized: "system.group.full", defaultValue: "group is full (max %@ members)", comment: "Error when the group is at the member cap; placeholder is the cap"),
locale: .current,
"\(BitchatGroup.maxMembers)"
))
}
let newMember = GroupMember(
fingerprint: identity.fingerprint,
signingKey: identity.signingKey,
nickname: context.peerNickname(for: peerID) ?? nickname
)
// Rotate the key (epoch + 1) on every roster change, not just removals.
// A monotonically increasing epoch per roster gives the receiver a
// strict ordering: two out-of-order invite states can no longer share
// an epoch and last-writer-wins a just-added member back out.
let members = group.members + [newMember]
guard let (updated, key) = context.groupStore.rotateKey(groupID: group.groupID, members: members),
let payload = signedStatePayload(for: updated, key: key) else {
return .error(message: String(localized: "system.group.invite_failed", defaultValue: "could not build the group invite", comment: "Error when building or signing a group invite fails"))
}
context.sendGroupInvitePayload(payload, to: peerID)
distributeState(payload, group: updated, excluding: [identity.fingerprint], type: .keyUpdate)
return .success(message: String(
format: String(localized: "system.group.invited", defaultValue: "invited %1$@ to '%2$@'", comment: "System message after inviting someone; placeholders are the nickname and the group name"),
locale: .current,
nickname,
updated.name
))
}
/// Creator-side removal: rotates the group key (epoch + 1) and sends the
/// new state to every remaining member so the removed member's key stops
/// decrypting future traffic.
func removeMember(nickname rawNickname: String) -> CommandResult {
let nickname = normalizedNickname(rawNickname)
guard !nickname.isEmpty else {
return .error(message: String(localized: "system.group.usage_remove", defaultValue: "usage: /group remove @name", comment: "Usage hint for /group remove"))
}
guard let group = selectedGroup() else {
return .error(message: String(localized: "system.group.not_in_group", defaultValue: "open a group chat first", comment: "Error when a group command requires an open group chat"))
}
guard group.creatorFingerprint == context.myNoiseFingerprint() else {
return .error(message: String(localized: "system.group.creator_only", defaultValue: "only the group creator can do that", comment: "Error when a non-creator attempts a creator-only group action"))
}
guard let member = group.members.first(where: { $0.nickname.caseInsensitiveCompare(nickname) == .orderedSame }) else {
return .error(message: String(
format: String(localized: "system.group.member_not_found", defaultValue: "'%@' is not a member of this group", comment: "Error when the member to remove is not in the roster; placeholder is the nickname"),
locale: .current,
nickname
))
}
guard member.fingerprint != group.creatorFingerprint else {
return .error(message: String(localized: "system.group.cannot_remove_creator", defaultValue: "the creator cannot be removed", comment: "Error when the creator tries to remove themselves"))
}
let remaining = group.members.filter { $0.fingerprint != member.fingerprint }
guard let (rotated, newKey) = context.groupStore.rotateKey(groupID: group.groupID, members: remaining),
let payload = signedStatePayload(for: rotated, key: newKey) else {
return .error(message: String(localized: "system.group.rotate_failed", defaultValue: "could not rotate the group key", comment: "Error when rotating the group key fails"))
}
distributeState(payload, group: rotated, excluding: [], type: .keyUpdate)
notifyRemovedMember(member, rotated: rotated)
return .success(message: String(
format: String(localized: "system.group.removed_member", defaultValue: "removed %@ and rotated the group key", comment: "System message after removing a member and rotating the key; placeholder is the nickname"),
locale: .current,
member.nickname
))
}
func leaveGroup() -> CommandResult {
guard let group = selectedGroup() else {
return .error(message: String(localized: "system.group.not_in_group", defaultValue: "open a group chat first", comment: "Error when a group command requires an open group chat"))
}
// Close the chat window first so the confirmation message doesn't
// resurrect the conversation we're about to remove.
context.endPrivateChat()
context.removePrivateChat(group.peerID)
context.groupStore.removeGroup(withID: group.groupID)
context.notifyUIChanged()
return .success(message: String(
format: String(localized: "system.group.left", defaultValue: "left group '%@'", comment: "System message after leaving a group; placeholder is the group name"),
locale: .current,
group.name
))
}
func listGroups() -> CommandResult {
let groups = context.groupStore.groups
guard !groups.isEmpty else {
return .success(message: String(localized: "system.group.none", defaultValue: "you are not in any groups — /group create <name> to start one", comment: "System message when the user is in no groups"))
}
let myFingerprint = context.myNoiseFingerprint()
let lines = groups.map { group -> String in
let role = group.creatorFingerprint == myFingerprint ? " (creator)" : ""
return "#\(group.name)\(role)\(group.members.count)/\(BitchatGroup.maxMembers)"
}
return .success(message: String(localized: "system.group.list_header", defaultValue: "your groups:", comment: "Header line for the /group list output") + "\n" + lines.joined(separator: "\n"))
}
// MARK: - Sending
/// Fire-and-flood send: local echo goes straight to `.sent` because group
/// messages have no per-member acknowledgments in v1.
func sendGroupMessage(_ content: String, to groupPeerID: PeerID) {
guard !content.isEmpty, content.count <= InputValidator.Limits.maxMessageLength else { return }
guard let group = context.groupStore.group(for: groupPeerID),
let key = context.groupStore.key(forGroupID: group.groupID) else {
context.addSystemMessage(String(localized: "system.group.unknown", defaultValue: "you are no longer in this group", comment: "System message when sending into an unknown group"))
return
}
let messageID = UUID().uuidString
let timestamp = Date()
let payload: Data
do {
payload = try GroupCrypto.sealMessage(
content: content,
messageID: messageID,
senderNickname: context.nickname,
senderSigningKey: context.mySigningPublicKey(),
timestampMs: UInt64(timestamp.timeIntervalSince1970 * 1000),
groupID: group.groupID,
epoch: group.epoch,
key: key,
sign: { [weak context] data in context?.signWithNoiseKey(data) }
)
} catch {
SecureLogger.error("Failed to seal group message: \(error)", category: .encryption)
context.addLocalPrivateSystemMessage(
String(localized: "system.group.send_failed", defaultValue: "could not encrypt the message", comment: "System message when sealing a group message fails"),
to: groupPeerID
)
return
}
let message = BitchatMessage(
id: messageID,
sender: context.nickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: group.name,
senderPeerID: context.myPeerID,
mentions: nil,
deliveryStatus: .sent
)
context.appendPrivateMessage(message, to: groupPeerID)
context.broadcastGroupMessagePayload(payload)
context.notifyUIChanged()
}
// MARK: - Receiving
/// Decrypt-verify path for an incoming 0x25 broadcast. Drops silently for
/// unknown groups (non-members relay but never read), wrong epochs, bad
/// sender signatures, and senders missing from the pinned roster.
func handleGroupMessagePayload(_ payload: Data, timestamp: Date) {
guard let envelope = GroupMessageEnvelope.decode(payload) else { return }
guard let group = context.groupStore.group(withID: envelope.groupID) else { return }
guard envelope.epoch == group.epoch else {
SecureLogger.debug("Dropping group message with epoch \(envelope.epoch) (current \(group.epoch))", category: .encryption)
return
}
guard let key = context.groupStore.key(forGroupID: group.groupID) else { return }
let plaintext: GroupMessagePlaintext
do {
plaintext = try GroupCrypto.openMessage(envelope, key: key)
} catch {
SecureLogger.debug("Failed to open group message: \(error)", category: .encryption)
return
}
// Sender must be pinned in the creator-signed roster; key possession
// alone is not authorship.
guard let member = group.member(withSigningKey: plaintext.senderSigningKey) else {
SecureLogger.warning("Dropping group message from non-roster sender", category: .security)
return
}
// Our own broadcast echoed back via relay or sync replay.
guard plaintext.senderSigningKey != context.mySigningPublicKey() else { return }
// Honor /block inside groups too: drop display + notification for a
// blocked member, consistent with every other inbound path.
guard !context.isFingerprintBlocked(member.fingerprint) else {
SecureLogger.debug("Dropping group message from blocked member", category: .security)
return
}
let groupPeerID = group.peerID
// Trust the authenticated inner timestamp (clamped so a future-dated
// message cannot pin itself to the bottom of the timeline).
let messageDate = min(Date(timeIntervalSince1970: TimeInterval(plaintext.timestampMs) / 1000), Date())
let senderName = member.nickname.isEmpty ? plaintext.senderNickname : member.nickname
let senderPeerID = PeerID(str: String(member.fingerprint.prefix(16)))
let message = BitchatMessage(
id: plaintext.messageID,
sender: senderName,
content: plaintext.content,
timestamp: messageDate,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: group.name,
senderPeerID: senderPeerID,
mentions: nil
)
guard context.appendPrivateMessage(message, to: groupPeerID) else { return }
let isViewing = context.selectedPrivateChatPeer == groupPeerID
if !isViewing {
context.markPrivateChatUnread(groupPeerID)
let isRecent = Date().timeIntervalSince(messageDate) < 30
if isRecent {
context.notifyPrivateMessage(
from: "\(senderName) @ \(group.name)",
message: plaintext.content,
peerID: groupPeerID
)
}
}
context.notifyUIChanged()
}
/// Accepts creator-signed group state arriving as an invite. The Noise
/// session peer must BE the creator, the signature must verify against
/// the creator key pinned in the roster, and we must be in the roster.
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
applyGroupState(from: peerID, payload: payload, isInvite: true)
}
/// Accepts creator-signed state updates (rotation/roster). A state whose
/// roster no longer includes us means we were removed: drop the group.
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
applyGroupState(from: peerID, payload: payload, isInvite: false)
}
}
private extension ChatGroupCoordinator {
enum StateSendType {
case invite
case keyUpdate
}
func normalizedNickname(_ raw: String) -> String {
let trimmed = raw.trimmed
return trimmed.hasPrefix("@") ? String(trimmed.dropFirst()) : trimmed
}
func selectedGroup() -> BitchatGroup? {
guard let selected = context.selectedPrivateChatPeer, selected.isGroup else { return nil }
return context.groupStore.group(for: selected)
}
func signedStatePayload(for group: BitchatGroup, key: Data) -> Data? {
GroupStatePayload.makeSigned(group: group, key: key) { [weak context] data in
context?.signWithNoiseKey(data)
}?.encode()
}
/// Sends the state payload to every connected roster member except us and
/// the excluded fingerprints. Offline members catch up the next time the
/// creator sends them state (v1 limitation, documented in the PR).
func distributeState(_ payload: Data, group: BitchatGroup, excluding excludedFingerprints: Set<String>, type: StateSendType) {
let myFingerprint = context.myNoiseFingerprint()
for member in group.members {
guard member.fingerprint != myFingerprint,
!excludedFingerprints.contains(member.fingerprint),
let peerID = context.connectedPeerID(forFingerprint: member.fingerprint) else { continue }
switch type {
case .invite:
context.sendGroupInvitePayload(payload, to: peerID)
case .keyUpdate:
context.sendGroupKeyUpdatePayload(payload, to: peerID)
}
}
}
/// Tells a just-removed member they're out so their client can deactivate
/// the group instead of silently going dark (dropping every message under
/// the epoch it no longer has the key for). The notice is a creator-signed
/// state whose roster excludes the removee their `applyGroupState`
/// removal branch fires on the missing-self roster and surfaces the
/// "removed from group" system message.
///
/// It carries a throwaway all-zero key, never the rotated key, so the
/// removee cannot decrypt post-removal traffic. State is sent 1:1 over
/// authenticated Noise, so no remaining member ever receives this blob
/// (and even if one did, its own missing-self check would not match).
/// If the removee is offline the notice can't be delivered same v1
/// limitation as any other missed key update, documented in the PR.
func notifyRemovedMember(_ removed: GroupMember, rotated: BitchatGroup) {
guard let peerID = context.connectedPeerID(forFingerprint: removed.fingerprint) else { return }
let throwawayKey = Data(count: BitchatGroup.keyLength)
guard let payload = signedStatePayload(for: rotated, key: throwawayKey) else { return }
context.sendGroupKeyUpdatePayload(payload, to: peerID)
}
func applyGroupState(from peerID: PeerID, payload: Data, isInvite: Bool) {
guard let state = GroupStatePayload.decode(payload) else {
SecureLogger.warning("Malformed group state payload from \(peerID.id.prefix(8))", category: .security)
return
}
// The Noise session already authenticated `peerID`; require that the
// authenticated peer IS the creator whose key signed the state, so a
// member can't re-invite or rotate on the creator's behalf.
guard let senderFingerprint = context.meshFingerprint(for: peerID),
senderFingerprint == state.creatorFingerprint else {
SecureLogger.warning("Dropping group state from non-creator \(peerID.id.prefix(8))", category: .security)
return
}
guard state.verifyCreatorSignature() else {
SecureLogger.warning("Dropping group state with invalid creator signature", category: .security)
return
}
let myFingerprint = context.myNoiseFingerprint()
let existing = context.groupStore.group(withID: state.groupID)
// A creator-signed roster that no longer includes us is a removal.
guard state.members.contains(where: { $0.fingerprint == myFingerprint }) else {
if let existing {
if context.selectedPrivateChatPeer == existing.peerID {
context.endPrivateChat()
}
context.removePrivateChat(existing.peerID)
context.groupStore.removeGroup(withID: existing.groupID)
context.addSystemMessage(String(
format: String(localized: "system.group.removed_from", defaultValue: "you were removed from group '%@'", comment: "System message when removed from a group; placeholder is the group name"),
locale: .current,
existing.name
))
context.notifyUIChanged()
}
return
}
// Never regress the epoch: state travels over live Noise sessions,
// so an older epoch here is a stale (or misbehaving) creator device.
if let existing, state.epoch < existing.epoch {
SecureLogger.warning("Dropping stale group state (epoch \(state.epoch) < \(existing.epoch))", category: .security)
return
}
let isNewMembership = existing == nil
guard context.groupStore.upsert(state.asGroup, key: state.key) else {
SecureLogger.error("Failed to store group state for \(state.name)", category: .session)
return
}
if isNewMembership {
let inviter = state.members.first { $0.fingerprint == state.creatorFingerprint }?.nickname
?? context.peerNickname(for: peerID)
?? "?"
let notice = String(
format: String(localized: "system.group.joined", defaultValue: "you were added to group '%1$@' by %2$@ — it now appears in your people list", comment: "System message when added to a group; placeholders are the group name and the inviter"),
locale: .current,
state.name,
inviter
)
context.addSystemMessage(notice)
context.markPrivateChatUnread(state.asGroup.peerID)
context.notifyPrivateMessage(from: inviter, message: notice, peerID: state.asGroup.peerID)
} else if isInvite == false, let existing, state.epoch > existing.epoch {
SecureLogger.info("Group '\(state.name)' rotated to epoch \(state.epoch)", category: .session)
}
context.notifyUIChanged()
}
}
@@ -185,13 +185,6 @@ final class ChatLifecycleCoordinator {
func markPrivateMessagesAsRead(from peerID: PeerID) {
context.markChatAsRead(from: peerID)
// Group chats are keyed under a virtual group_ peerID; no member IS the
// conversation peer, so the receipt loops below (which gate on
// senderPeerID == peerID) must never emit a read/delivered receipt for
// one. This guard makes that explicit so a future refactor of the
// receipt matching can't silently start leaking receipts into groups.
guard !peerID.isGroup else { return }
if peerID.isGeoDM,
let recipientHex = context.nostrKeyMapping[peerID],
case .location(let channel) = context.activeChannel,
@@ -331,7 +324,7 @@ private extension ChatLifecycleCoordinator {
do {
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: message,
geohash: channel.geohash,
senderIdentity: identity,
@@ -350,7 +343,7 @@ private extension ChatLifecycleCoordinator {
} catch {
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
context.addSystemMessage(
String(localized: "system.location.send_failed", defaultValue: "failed to send to location channel", comment: "System message when a location channel send fails")
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
}
}
@@ -117,13 +117,13 @@ final class ChatMediaTransferCoordinator {
try? FileManager.default.removeItem(at: url)
await MainActor.run { [weak self] in
guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", defaultValue: "voice note too large", comment: "Failure reason shown when a voice note exceeds the size limit"))
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
}
} catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", defaultValue: "voice note failed to send", comment: "Failure reason shown when a voice note could not be sent"))
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
}
}
}
+92 -101
View File
@@ -68,22 +68,10 @@ extension ChatViewModel: ChatOutgoingContext {
final class ChatOutgoingCoordinator {
private unowned let context: any ChatOutgoingContext
/// In-flight NIP-13 mining for the most recent geohash send. A newer send
/// (or leaving the channel) cancels it, which only expedites the mining
/// the message still goes out at the difficulty already reached.
/// (Read access is internal so tests can await the send's completion.)
private(set) var geohashMiningTask: Task<Void, Never>?
init(context: any ChatOutgoingContext) {
self.context = context
}
/// Finish any in-flight geohash PoW mining early (the pending message
/// still sends, at whatever committed difficulty it reached).
func expeditePendingGeohashMining() {
geohashMiningTask?.cancel()
}
func sendMessage(_ content: String) {
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
@@ -104,117 +92,120 @@ final class ChatOutgoingCoordinator {
}
let mentions = context.parseMentions(from: content)
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
guard let preparedMessage else { return }
switch context.activeChannel {
case .mesh:
sendMeshPublicMessage(originalContent: content, trimmed: trimmed, mentions: mentions)
case .location(let channel):
sendGeohashPublicMessage(trimmed, mentions: mentions, channel: channel)
}
appendLocalEcho(preparedMessage.message)
routePublicMessage(
originalContent: content,
mentions: mentions,
geoContext: preparedMessage.geoContext,
messageID: preparedMessage.message.id,
timestamp: preparedMessage.message.timestamp
)
}
}
private extension ChatOutgoingCoordinator {
func sendMeshPublicMessage(originalContent: String, trimmed: String, mentions: [String]) {
let message = BitchatMessage(
sender: context.nickname,
content: trimmed,
timestamp: Date(),
isRelay: false,
senderPeerID: context.myPeerID,
mentions: mentions.isEmpty ? nil : mentions
)
func preparePublicMessage(
content: String,
trimmed: String,
mentions: [String]
) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? {
var geoContext: ChatViewModel.GeoOutgoingContext?
var displaySender = context.nickname
var localSenderPeerID = context.myPeerID
var messageID: String?
var messageTimestamp = Date()
appendLocalEcho(message, to: .mesh)
context.recordPublicActivity(forChannelKey: "mesh")
context.sendMeshMessage(
originalContent,
mentions: mentions,
messageID: message.id,
timestamp: message.timestamp
)
}
switch context.activeChannel {
case .mesh:
break
/// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see
/// `NostrPoW`), so the whole echo-and-send runs in a task once the signed
/// event whose ID is also the local message ID exists. Typical mining
/// at the default target is well under 100 ms and hard-capped at
/// `NostrPoW.miningTimeCap`, so sending is never meaningfully delayed.
func sendGeohashPublicMessage(_ trimmed: String, mentions: [String], channel: GeohashChannel) {
let identity: NostrIdentity
do {
identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
} catch {
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
context.addSystemMessage(
String(localized: "system.location.send_failed", defaultValue: "failed to send to location channel", comment: "System message when a location channel send fails")
)
return
}
let displaySender = context.nickname + "#" + String(identity.publicKeyHex.suffix(4))
let senderPeerID = PeerID(nostr: identity.publicKeyHex)
let teleported = context.isTeleported
let nickname = context.nickname
// Serialize geohash sends: each send awaits the previous send's task
// before it appends + relays, so user-visible order always matches
// send order even when an earlier message mines longer than a later
// one. Cancelling the previous task only *expedites* its mining (the
// NIP-13 target is polled, not aborted), so it still finishes and
// sends and it finishes fast, so awaiting it never stacks mining
// delays or blocks a send beyond `NostrPoW.miningTimeCap`.
let previousSend = geohashMiningTask
previousSend?.cancel()
geohashMiningTask = Task { @MainActor [weak context = self.context] in
await previousSend?.value
let event: NostrEvent
case .location(let channel):
do {
event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
let suffix = String(identity.publicKeyHex.suffix(4))
displaySender = context.nickname + "#" + suffix
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
let teleported = context.isTeleported
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: trimmed,
geohash: channel.geohash,
senderIdentity: identity,
nickname: nickname,
nickname: context.nickname,
teleported: teleported
)
messageID = event.id
messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
geoContext = (
channel: channel,
event: event,
identity: identity,
teleported: teleported
)
} catch {
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
context?.addSystemMessage(
String(localized: "system.location.send_failed", defaultValue: "failed to send to location channel", comment: "System message when a location channel send fails")
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return
return nil
}
guard let context else { return }
let message = BitchatMessage(
id: event.id,
sender: displaySender,
content: trimmed,
timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
isRelay: false,
senderPeerID: senderPeerID,
mentions: mentions.isEmpty ? nil : mentions
)
context.appendPublicMessage(message, to: ConversationID(channelID: .location(channel)))
let contentKey = context.normalizedContentKey(message.content)
context.recordContentKey(contentKey, timestamp: message.timestamp)
context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)")
context.sendGeohash(context: (
channel: channel,
event: event,
identity: identity,
teleported: teleported
))
}
let message = BitchatMessage(
id: messageID,
sender: displaySender,
content: trimmed,
timestamp: messageTimestamp,
isRelay: false,
senderPeerID: localSenderPeerID,
mentions: mentions.isEmpty ? nil : mentions
)
return (message, geoContext)
}
func appendLocalEcho(_ message: BitchatMessage, to conversationID: ConversationID) {
context.appendPublicMessage(message, to: conversationID)
func appendLocalEcho(_ message: BitchatMessage) {
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
let contentKey = context.normalizedContentKey(message.content)
context.recordContentKey(contentKey, timestamp: message.timestamp)
}
func routePublicMessage(
originalContent: String,
mentions: [String],
geoContext: ChatViewModel.GeoOutgoingContext?,
messageID: String,
timestamp: Date
) {
switch context.activeChannel {
case .mesh:
context.recordPublicActivity(forChannelKey: "mesh")
context.sendMeshMessage(
originalContent,
mentions: mentions,
messageID: messageID,
timestamp: timestamp
)
case .location(let channel):
context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)")
guard let geoContext, geoContext.channel.geohash == channel.geohash else {
SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session)
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return
}
Task { @MainActor [weak context = self.context] in
context?.sendGeohash(context: geoContext)
}
}
}
}
@@ -323,15 +323,6 @@ final class ChatPeerIdentityCoordinator {
func startPrivateChat(with peerID: PeerID) {
guard peerID != context.myPeerID else { return }
// Group chats are virtual conversations: no peer identity, favorites,
// handshake, or message consolidation applies just select the chat.
if peerID.isGroup {
context.selectedPrivateChatFingerprint = nil
context.beginPrivateChatSession(with: peerID)
context.markPrivateChatRead(peerID)
return
}
let peerNickname = context.peerNickname(for: peerID) ?? "unknown"
if context.unifiedIsBlocked(peerID) {
@@ -238,7 +238,7 @@ final class ChatPrivateConversationCoordinator {
let nickname = context.peerNickname(for: peerID) ?? "user"
context.addSystemMessage(
String(
format: String(localized: "system.dm.blocked_recipient", defaultValue: "cannot send message to %@: person is blocked.", comment: "System message when attempting to message a blocked user"),
format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"),
locale: .current,
nickname
)
@@ -298,7 +298,7 @@ final class ChatPrivateConversationCoordinator {
} else {
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.unreachable", defaultValue: "peer not reachable", comment: "Failure reason when a peer is unreachable")
reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable")
),
forMessageID: messageID,
peerID: peerID
@@ -306,7 +306,7 @@ final class ChatPrivateConversationCoordinator {
let name = recipientNickname ?? "user"
context.addSystemMessage(
String(
format: String(localized: "system.dm.unreachable", defaultValue: "cannot send message to %@ - peer is not reachable via mesh or nostr.", comment: "System message when a recipient is unreachable"),
format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"),
locale: .current,
name
)
@@ -317,7 +317,7 @@ final class ChatPrivateConversationCoordinator {
func sendGeohashDM(_ content: String, to peerID: PeerID) {
guard case .location(let channel) = context.activeChannel else {
context.addSystemMessage(
String(localized: "system.location.not_in_channel", defaultValue: "cannot send: not in a location channel", comment: "System message when attempting to send without being in a location channel")
String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel")
)
return
}
@@ -341,7 +341,7 @@ final class ChatPrivateConversationCoordinator {
guard let recipientHex = context.nostrKeyMapping[peerID] else {
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.unknown_recipient", defaultValue: "unknown recipient", comment: "Failure reason when the recipient is unknown")
reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown")
),
forMessageID: messageID,
peerID: peerID
@@ -352,13 +352,13 @@ final class ChatPrivateConversationCoordinator {
if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.blocked", defaultValue: "user is blocked", comment: "Failure reason when the user is blocked")
reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked")
),
forMessageID: messageID,
peerID: peerID
)
context.addSystemMessage(
String(localized: "system.dm.blocked_generic", defaultValue: "cannot send message: person is blocked.", comment: "System message when sending fails because user is blocked")
String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked")
)
return
}
@@ -368,7 +368,7 @@ final class ChatPrivateConversationCoordinator {
if recipientHex.lowercased() == identity.publicKeyHex.lowercased() {
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.self", defaultValue: "cannot message yourself", comment: "Failure reason when attempting to message yourself")
reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself")
),
forMessageID: messageID,
peerID: peerID
@@ -390,7 +390,7 @@ final class ChatPrivateConversationCoordinator {
} catch {
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.send_error", defaultValue: "send error", comment: "Failure reason for a generic send error")
reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error")
),
forMessageID: messageID,
peerID: peerID
@@ -82,9 +82,7 @@ protocol ChatPublicConversationContext: AnyObject {
// MARK: Inbound public message processing
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage
func isMessageBlocked(_ message: BitchatMessage) -> Bool
/// `powBits` is the validated NIP-13 difficulty of the source Nostr event
/// (0 for mesh messages); sufficient PoW relaxes the per-sender bucket.
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool
/// Buffers a visible-channel message for the batched (~80 ms) pipeline
/// flush, which commits it to `conversationID` in the store.
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID)
@@ -139,8 +137,8 @@ extension ChatViewModel: ChatPublicConversationContext {
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
}
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool {
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey, powBits: powBits)
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool {
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey)
}
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
@@ -292,17 +290,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
func clearCurrentPublicTimeline() {
context.clearPublicConversation(ConversationID(channelID: context.activeChannel))
// The SPM test process shares the real Application Support tree, so this
// detached deletion can land mid-test under parallel scheduling and flake
// a file-dependent test. Tests never need the on-disk media cleared.
guard !TestEnvironment.isRunningTests else { return }
Task.detached(priority: .utility) {
// Skipped under tests: the test process shares the user's real
// ~/Library/Application Support/files tree, and this detached
// wipe fires at a nondeterministic time racing tests that
// write media there (see the same guard in panicClearAllData).
guard !TestEnvironment.isRunningTests else { return }
do {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
@@ -379,7 +367,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
guard let context else { return }
do {
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: content,
geohash: channel.geohash,
senderIdentity: identity,
@@ -407,11 +395,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
)
}
/// - Parameter powBits: validated NIP-13 difficulty of the source Nostr
/// event (0 for mesh messages). Sufficient PoW relaxes the per-sender
/// rate limit; low/no-PoW events keep the strict limits so old clients
/// still get through at normal rates.
func handlePublicMessage(_ message: BitchatMessage, powBits: Int = 0) {
func handlePublicMessage(_ message: BitchatMessage) {
let finalMessage = context.processActionMessage(message)
if context.isMessageBlocked(finalMessage) { return }
@@ -421,7 +405,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
if shouldRateLimit {
let senderKey = normalizedSenderKey(for: finalMessage)
let contentKey = context.normalizedContentKey(finalMessage.content)
if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey, powBits: powBits) {
if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey) {
return
}
}
@@ -71,11 +71,6 @@ protocol ChatTransportEventContext: AnyObject {
// MARK: Verification payloads
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data)
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data)
func handleVouchPayload(from peerID: PeerID, payload: Data)
// MARK: Group payloads (creator-signed state over Noise)
func handleGroupInvitePayload(from peerID: PeerID, payload: Data)
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data)
}
extension ChatViewModel: ChatTransportEventContext {
@@ -134,18 +129,6 @@ extension ChatViewModel: ChatTransportEventContext {
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload)
}
func handleVouchPayload(from peerID: PeerID, payload: Data) {
vouchCoordinator.handleVouchPayload(from: peerID, payload: payload)
}
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
groupCoordinator.handleGroupInvitePayload(from: peerID, payload: payload)
}
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
groupCoordinator.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
}
}
final class ChatTransportEventCoordinator {
@@ -389,15 +372,6 @@ private extension ChatTransportEventCoordinator {
case .verifyResponse:
context.handleVerifyResponsePayload(from: peerID, payload: payload)
case .vouch:
context.handleVouchPayload(from: peerID, payload: payload)
case .groupInvite:
context.handleGroupInvitePayload(from: peerID, payload: payload)
case .groupKeyUpdate:
context.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
case .bulkTransferOffer, .bulkTransferResponse:
// Wi-Fi bulk negotiation is consumed inside the mesh transport
// (BLEService); it never reaches the UI layer.
+1 -84
View File
@@ -102,9 +102,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
@MainActor
var canSendMediaInCurrentContext: Bool {
if let peer = selectedPrivateChatPeer {
// Media transfer is not wired for groups in v1 (sendFilePrivate
// rejects the virtual group_ recipient), so keep the affordance off.
return !(peer.isGeoDM || peer.isGeoChat || peer.isGroup)
return !(peer.isGeoDM || peer.isGeoChat)
}
switch activeChannel {
case .mesh: return true
@@ -179,8 +177,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
// Computed properties for compatibility
@MainActor
@@ -309,17 +305,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
var nostrRelayManager: NostrRelayManager?
private let userDefaults = UserDefaults.standard
let keychain: KeychainManagerProtocol
/// Private group membership: keys in the keychain, metadata on disk.
let groupStore: GroupStore
private let nicknameKey = "bitchat.nickname"
// Location channel state (macOS supports manual geohash selection)
var activeChannel: ChannelID {
get { conversations.activeChannel }
set {
guard conversations.activeChannel != newValue else { return }
// Leaving a channel expedites any in-flight NIP-13 mining: the
// pending message still sends, at the difficulty already reached.
outgoingCoordinator.expeditePendingGeohashMining()
conversations.setActiveChannel(newValue)
visibleMessagesCache = nil
objectWillChange.send()
@@ -818,7 +809,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
)
self.keychain = keychain
self.groupStore = GroupStore(keychain: keychain)
self.idBridge = idBridge
self.identityManager = identityManager
self.conversations = conversations
@@ -1215,18 +1205,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
GossipMessageArchive.wipeDefault()
StoreAndForwardMetrics.shared.reset()
// Drop cached peers' prekey bundles (who we could write to is
// metadata too). Our own prekey privates are keychain-backed and go
// with deleteAllKeychainData above plus the identity reset below.
PrekeyBundleStore.shared.wipe()
// Drop private group keys and rosters (keychain + disk)
groupStore.wipe()
// Drop bulletin-board posts and tombstones (memory and disk); board
// posts are signed with our identity key and persist for days.
BoardStore.shared.wipe()
// Identity manager has cleared persisted identity data above
// Clear autocomplete state
@@ -1295,11 +1273,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Delete ALL media files (incoming and outgoing) in background
Task.detached(priority: .utility) {
// The SPM test process shares the real Application Support tree, so
// this detached tree-delete can land mid-test under parallel
// scheduling and flake a file-dependent test; tests never need the
// on-disk media wiped.
guard !TestEnvironment.isRunningTests else { return }
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
@@ -1512,14 +1485,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func setupNoiseCallbacks() {
verificationCoordinator.setupNoiseCallbacks()
vouchCoordinator.setupNoiseCallbacks()
}
/// Whether the fingerprint currently counts as vouched (1 valid vouch
/// from a voucher I verified, and no explicit verification of mine).
@MainActor
func isVouchedFingerprint(_ fingerprint: String) -> Bool {
identityManager.isVouched(fingerprint: fingerprint)
}
// MARK: - BitchatDelegate Methods
@@ -1559,33 +1524,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
}
}
/// Origin conversation for deferred command output, captured when the
/// command is issued (before any async work starts).
@MainActor
func currentCommandDestination() -> CommandOutputDestination {
if let peerID = selectedPrivateChatPeer {
return .privateChat(peerID)
}
// Deferring commands (/ping) are rejected in geohash channels, so a
// non-DM origin is always the #mesh timeline.
return .meshTimeline
}
/// Routes deferred command output (async /ping results) into the
/// conversation captured at issue time, immune to chat switches in the
/// meantime. A DM result lands in the origin chat's history even if that
/// chat is no longer selected (or was cleared it then reappears as the
/// first message when the chat is reopened).
@MainActor
func addCommandOutput(_ content: String, to destination: CommandOutputDestination) {
switch destination {
case .privateChat(let peerID):
addLocalPrivateSystemMessage(content, to: peerID)
case .meshTimeline:
publicConversationCoordinator.addMeshOnlySystemMessage(content)
}
}
// MARK: - Message Reception
@MainActor
@@ -1617,12 +1555,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
)
}
func didReceiveGroupMessage(payload: Data, timestamp: Date) {
Task { @MainActor [weak self] in
self?.groupCoordinator.handleGroupMessagePayload(payload, timestamp: timestamp)
}
}
// MARK: - QR Verification API
@MainActor
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
@@ -1745,27 +1677,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
publicConversationCoordinator.sendPublicRaw(content)
}
// Send a normal public message (with local echo) to the active channel.
// CommandContextProvider hook for commands that post real messages
// (`/pay`); only called when no private chat is selected.
@MainActor
func sendPublicMessage(_ content: String) {
sendMessage(content)
}
/// Handle incoming public message
@MainActor
func handlePublicMessage(_ message: BitchatMessage) {
publicConversationCoordinator.handlePublicMessage(message)
}
/// Handle an incoming public Nostr message with its validated NIP-13
/// difficulty; sufficient PoW relaxes the per-sender rate limit.
@MainActor
func handlePublicMessage(_ message: BitchatMessage, powBits: Int) {
publicConversationCoordinator.handlePublicMessage(message, powBits: powBits)
}
/// Check for mentions and send notifications
func checkForMentions(_ message: BitchatMessage) {
publicConversationCoordinator.checkForMentions(message)
@@ -72,7 +72,6 @@ final class ChatViewModelBootstrapper {
configureNoiseCallbacks()
bindTransferProgress()
configureGeoChannels()
configureGateway()
bindTeleportState()
requestNotifications()
registerObservers()
@@ -107,7 +106,7 @@ private extension ChatViewModelBootstrapper {
category: .session
)
viewModel.conversations.setDeliveryStatus(
.failed(reason: String(localized: "content.delivery.reason.not_delivered", defaultValue: "not delivered", comment: "Failure reason shown when the router gave up delivering a message")),
.failed(reason: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")),
forMessageID: messageID
)
}
@@ -245,72 +244,6 @@ private extension ChatViewModelBootstrapper {
)
}
/// Wires the gateway-mode policy layer (`GatewayService`) to the mesh
/// transport, the relay manager, and the inbound Nostr pipeline. All
/// dependencies are closures so the service stays unit-testable with
/// fakes.
func configureGateway() {
// Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests)
// has no carrier packets to bridge.
guard let bleService = viewModel.meshService as? BLEService else { return }
let gateway = GatewayService.shared
gateway.publishToRelays = { event, geohash in
let relays = GeoRelayDirectory.shared.closestRelays(
toGeohash: geohash,
count: TransportConfig.nostrGeoRelayCount
)
// Symmetric with the local send path (GeohashSubscriptionManager
// .sendGeohash): with no known geo relay, refuse rather than
// publish to default relays no geo subscriber reads that would
// be silent dead traffic, not delivery.
guard !relays.isEmpty else {
SecureLogger.warning("🌐 Gateway: no geo relays for #\(geohash); not publishing carried event", category: .session)
return
}
NostrRelayManager.shared.sendEvent(event, to: relays)
}
gateway.broadcastToMesh = { [weak bleService] payload in
bleService?.broadcastNostrCarrier(payload)
}
gateway.sendToGatewayPeer = { [weak bleService] payload, peer in
bleService?.sendNostrCarrier(payload, to: peer) ?? false
}
gateway.availableGatewayPeers = { [weak bleService] in
bleService?.reachableGatewayPeers() ?? []
}
gateway.relaysConnected = { NostrRelayManager.shared.isConnected }
gateway.currentGeohash = { [weak viewModel] in viewModel?.currentGeohash }
// Carried events enter the same pipeline as relay-received events so
// blocking, rate limits, dedup, and rendering behave identically.
gateway.injectInbound = { [weak viewModel] event in
viewModel?.handleNostrEvent(event)
}
// The capability bit is advertised ONLY while the toggle is on; a
// change forces a re-announce so peers learn promptly.
gateway.onEnabledChanged = { [weak bleService] enabled in
bleService?.setLocalCapability(.gateway, enabled: enabled)
}
bleService.onNostrCarrierPacket = { payload, from, directedToUs in
GatewayService.shared.handleMeshCarrier(payload, from: from, directedToUs: directedToUs)
}
// Uplinks deposited while relays were unreachable flush on reconnect.
NostrRelayManager.shared.$isConnected
.receive(on: DispatchQueue.main)
.sink { connected in
if connected {
GatewayService.shared.flushQueuedUplinks()
}
}
.store(in: &viewModel.cancellables)
// Apply the persisted toggle at launch.
if gateway.isEnabled {
bleService.setLocalCapability(.gateway, enabled: true)
}
}
func bindTeleportState() {
viewModel.locationManager.$teleported
.receive(on: DispatchQueue.main)
@@ -1,210 +0,0 @@
import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatVouchCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatVouchCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatVouchContext: AnyObject {
// MARK: Identity & trust state
func getFingerprint(for peerID: PeerID) -> String?
func isVerifiedFingerprint(_ fingerprint: String) -> Bool
/// The peer's announce-bound Ed25519 signing key, if known this session.
func signingKey(forFingerprint fingerprint: String) -> Data?
/// Verified fingerprints ordered most recently verified first.
func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
/// Stores an accepted vouch (identity manager enforces the storage gates).
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
func lastVouchBatchSent(to fingerprint: String) -> Date?
func markVouchBatchSent(to fingerprint: String, at date: Date)
// MARK: Transport
func peerCapabilities(for peerID: PeerID) -> PeerCapabilities
/// Appends a session-established observer (additive; never displaces the
/// verification coordinator's callbacks).
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void)
/// Signs `data` with our Noise (Ed25519) signing key.
func noiseSignData(_ data: Data) -> Data?
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
// MARK: UI refresh
/// Signals that derived trust state changed so peer list / fingerprint
/// views recompute badges.
func notifyPeerTrustChanged()
}
extension ChatViewModel: ChatVouchContext {
// `getFingerprint(for:)` and `isVerifiedFingerprint(_:)` are shared
// requirements with the verification context and satisfied by existing
// `ChatViewModel` members. The members below flatten nested service
// accesses into intent-named calls.
func signingKey(forFingerprint fingerprint: String) -> Data? {
identityManager.signingPublicKey(forFingerprint: fingerprint)
}
func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
identityManager.mostRecentlyVerifiedFingerprints(limit: limit, excluding: fingerprint)
}
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
identityManager.recordVouch(
voucheeFingerprint: voucheeFingerprint,
voucherFingerprint: voucherFingerprint,
timestamp: timestamp
)
}
func lastVouchBatchSent(to fingerprint: String) -> Date? {
identityManager.lastVouchBatchSent(to: fingerprint)
}
func markVouchBatchSent(to fingerprint: String, at date: Date) {
identityManager.markVouchBatchSent(to: fingerprint, at: date)
}
func peerCapabilities(for peerID: PeerID) -> PeerCapabilities {
meshService.peerCapabilities(peerID)
}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
meshService.addPeerAuthenticatedObserver(handler)
}
func noiseSignData(_ data: Data) -> Data? {
meshService.noiseSignData(data)
}
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
meshService.sendVouchAttestations(payload, to: peerID)
}
func notifyPeerTrustChanged() {
// PeerListModel refreshes on this notification; the view-model change
// covers FingerprintView / VerificationModel consumers.
NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil)
notifyUIChanged()
}
}
/// Transitive verification ("vouching"): when a Noise session comes up with a
/// peer I verified, I attest over that authenticated, encrypted session
/// to the other identities I have verified. Receivers accept such vouches
/// only from peers *they* verified, giving a serverless
/// verified-by-people-you-verified tier (`TrustLevel.vouched`).
@MainActor
final class ChatVouchCoordinator {
/// Minimum spacing between vouch batches to the same peer (persisted).
static let batchInterval: TimeInterval = 24 * 60 * 60
private unowned let context: any ChatVouchContext
init(context: any ChatVouchContext) {
self.context = context
}
/// Registers the session-established hook. Additive alongside the
/// verification coordinator's callbacks; call once at bootstrap.
func setupNoiseCallbacks() {
context.addPeerAuthenticatedObserver { [weak self] peerID, fingerprint in
DispatchQueue.main.async { [weak self] in
self?.peerAuthenticated(peerID, fingerprint: fingerprint)
}
}
}
/// Exchange policy: on session establishment with a peer I verified that
/// advertises the `.vouch` capability, send attestations for up to
/// `VouchAttestation.maxBatchCount` *other* verified fingerprints (most
/// recently verified first), at most once per peer per `batchInterval`.
func peerAuthenticated(_ peerID: PeerID, fingerprint: String, now: Date = Date()) {
guard context.isVerifiedFingerprint(fingerprint) else { return }
guard context.peerCapabilities(for: peerID).contains(.vouch) else { return }
if let lastSent = context.lastVouchBatchSent(to: fingerprint),
now.timeIntervalSince(lastSent) < Self.batchInterval {
return
}
let candidates = context.recentlyVerifiedFingerprints(
limit: VouchAttestation.maxBatchCount,
excluding: fingerprint
)
var attestations: [VouchAttestation] = []
for candidate in candidates {
// Only fingerprints whose announce-bound signing key we know can
// be anchored to a concrete identity; skip the rest.
guard let fingerprintData = Data(hexString: candidate),
fingerprintData.count == VouchAttestation.fingerprintSize,
let signingKey = context.signingKey(forFingerprint: candidate),
signingKey.count == VouchAttestation.signingKeySize,
let attestation = VouchAttestation.build(
voucheeFingerprint: fingerprintData,
voucheeSigningKey: signingKey,
timestampMs: UInt64(now.timeIntervalSince1970 * 1000),
sign: context.noiseSignData
) else {
continue
}
attestations.append(attestation)
}
guard !attestations.isEmpty,
let payload = VouchAttestation.encodeList(attestations) else { return }
context.sendVouchAttestations(payload, to: peerID)
context.markVouchBatchSent(to: fingerprint, at: now)
SecureLogger.debug(
"🪪 Sent \(attestations.count) vouch attestation(s) to \(peerID.id.prefix(8))",
category: .security
)
}
/// Accept policy: process inbound vouches only from a sender I verified,
/// only with a valid Ed25519 signature under the sender's announce-bound
/// signing key, and only within the validity window. Self-vouches and
/// vouches for already-verified peers are dropped by the identity
/// manager's storage gates.
func handleVouchPayload(from peerID: PeerID, payload: Data, now: Date = Date()) {
guard let senderFingerprint = context.getFingerprint(for: peerID),
context.isVerifiedFingerprint(senderFingerprint) else {
SecureLogger.debug(
"🪪 Ignoring vouch payload from unverified peer \(peerID.id.prefix(8))",
category: .security
)
return
}
guard let senderSigningKey = context.signingKey(forFingerprint: senderFingerprint) else {
SecureLogger.debug(
"🪪 No signing key for vouching peer \(peerID.id.prefix(8))…; dropping batch",
category: .security
)
return
}
var acceptedCount = 0
for attestation in VouchAttestation.decodeList(from: payload) {
guard attestation.verifySignature(voucherSigningKey: senderSigningKey),
!attestation.isExpired(now: now) else { continue }
let stored = context.recordVouch(
voucheeFingerprint: attestation.voucheeFingerprintHex,
voucherFingerprint: senderFingerprint,
timestamp: attestation.timestamp
)
if stored { acceptedCount += 1 }
}
if acceptedCount > 0 {
SecureLogger.info(
"🪪 Accepted \(acceptedCount) vouch(es) from \(senderFingerprint.prefix(8))",
category: .security
)
context.notifyPeerTrustChanged()
}
}
}
@@ -13,12 +13,6 @@ extension ChatViewModel {
@MainActor
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
// Group chats reuse the private-chat surface but broadcast a sealed
// envelope instead of routing to a single peer.
if peerID.isGroup {
groupCoordinator.sendGroupMessage(content, to: peerID)
return
}
privateConversationCoordinator.sendPrivateMessage(content, to: peerID)
}
@@ -19,7 +19,7 @@ extension ChatViewModel {
self.torStatusAnnounced = true
// Post only in geohash channels (queue if not active)
self.addGeohashOnlySystemMessage(
String(localized: "system.tor.starting", defaultValue: "starting tor...", comment: "System message when Tor is starting")
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
)
}
}
@@ -30,7 +30,7 @@ extension ChatViewModel {
self.torRestartPending = true
// Post only in geohash channels (queue if not active)
self.addGeohashOnlySystemMessage(
String(localized: "system.tor.restarting", defaultValue: "tor restarting to recover connectivity...", comment: "System message when Tor is restarting")
String(localized: "system.tor.restarting", comment: "System message when Tor is restarting")
)
}
}
@@ -41,13 +41,13 @@ extension ChatViewModel {
if self.torRestartPending {
// Post only in geohash channels (queue if not active)
self.addGeohashOnlySystemMessage(
String(localized: "system.tor.restarted", defaultValue: "tor restarted. network routing restored.", comment: "System message when Tor has restarted")
String(localized: "system.tor.restarted", comment: "System message when Tor has restarted")
)
self.torRestartPending = false
} else if TorManager.shared.torEnforced && !self.torInitialReadyAnnounced {
// Initial start completed
self.addGeohashOnlySystemMessage(
String(localized: "system.tor.started", defaultValue: "tor started. routing all chats via tor for IP privacy.", comment: "System message when Tor has started")
String(localized: "system.tor.started", comment: "System message when Tor has started")
)
self.torInitialReadyAnnounced = true
}
@@ -115,9 +115,6 @@ final class GeohashSubscriptionManager {
private weak var context: (any GeohashSubscriptionContext)?
private let inbound: NostrInboundPipeline
private let presence: GeoPresenceTracker
/// Geohashes already told "sent via mesh gateway" this session, so the
/// notice appears once per channel instead of once per message.
private var gatewayNoticeGeohashes = Set<String>()
init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) {
self.context = context
@@ -148,9 +145,6 @@ final class GeohashSubscriptionManager {
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in
self?.inbound.subscribeNostrEvent(event)
// Gateway downlink: rebroadcast relay events for the viewed
// channel onto the mesh (no-op unless gateway mode is on).
GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash)
}
}
@@ -241,9 +235,6 @@ final class GeohashSubscriptionManager {
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in
self?.inbound.handleNostrEvent(event)
// Gateway downlink: rebroadcast relay events for the viewed
// channel onto the mesh (no-op unless gateway mode is on).
GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash)
}
}
@@ -289,23 +280,6 @@ final class GeohashSubscriptionManager {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
// Mesh gateway uplink: with no working relay connection, hand the
// locally signed event to a mesh peer advertising the gateway
// capability (keys never leave this device only the finished,
// signed event travels). Uplink is only ever attempted here, for a
// freshly composed event, never for received carrier events (loop
// rule 3 in GatewayService).
if GatewayService.shared.uplinkViaMesh(event: event, geohash: channel.geohash),
gatewayNoticeGeohashes.insert(channel.geohash).inserted {
context.addPublicSystemMessage(
String(
localized: "system.gateway.sent_via_mesh",
defaultValue: "sent via mesh gateway",
comment: "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable"
)
)
}
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex))
SecureLogger.debug(
+9 -19
View File
@@ -48,25 +48,15 @@ struct MessageRateLimiter {
self.contentRefill = contentRefillPerSec
}
/// - Parameter powBits: validated NIP-13 difficulty of the event
/// (`NostrPoW.validatedDifficulty`; 0 for mesh or no-PoW events).
/// At or above `NostrPoW.rateLimitBypassBits` the per-sender bucket is
/// skipped entirely each such message paid for itself with work but
/// the per-content flood bucket still applies.
mutating func allow(senderKey: String, contentKey: String, powBits: Int = 0, now: Date = Date()) -> Bool {
let senderAllowed: Bool
if powBits >= NostrPoW.rateLimitBypassBits {
senderAllowed = true
} else {
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
capacity: senderCapacity,
tokens: senderCapacity,
refillPerSec: senderRefill,
lastRefill: now
)
senderAllowed = senderBucket.allow(now: now)
senderBuckets[senderKey] = senderBucket
}
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
capacity: senderCapacity,
tokens: senderCapacity,
refillPerSec: senderRefill,
lastRefill: now
)
let senderAllowed = senderBucket.allow(now: now)
senderBuckets[senderKey] = senderBucket
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
capacity: contentCapacity,
+11 -23
View File
@@ -32,10 +32,7 @@ protocol NostrInboundPipelineContext: AnyObject {
func recordGeoParticipant(pubkeyHex: String)
// MARK: Inbound public messages
/// `powBits` is the validated NIP-13 difficulty of the source event
/// (`NostrPoW.validatedDifficulty`); it relaxes the per-sender rate limit
/// downstream.
func handlePublicMessage(_ message: BitchatMessage, powBits: Int)
func handlePublicMessage(_ message: BitchatMessage)
func checkForMentions(_ message: BitchatMessage)
func sendHapticFeedback(for message: BitchatMessage)
func parseMentions(from content: String) -> [String]
@@ -155,7 +152,6 @@ final class NostrInboundPipeline {
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let timestamp = min(rawTs, Date())
let mentions = context.parseMentions(from: content)
let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags)
let message = BitchatMessage(
id: event.id,
sender: senderName,
@@ -169,7 +165,7 @@ final class NostrInboundPipeline {
Task { @MainActor [weak context] in
guard let context else { return }
let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
context.handlePublicMessage(message, powBits: powBits)
context.handlePublicMessage(message)
if !isBlocked {
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
@@ -191,12 +187,10 @@ final class NostrInboundPipeline {
guard event.isValidSignature() else { return }
context.recordProcessedNostrEvent(event.id)
let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags)
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
geoEventLogCount += 1
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8)) pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
}
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
@@ -261,7 +255,7 @@ final class NostrInboundPipeline {
Task { @MainActor [weak context] in
guard let context else { return }
context.handlePublicMessage(message, powBits: powBits)
context.handlePublicMessage(message)
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
@@ -303,11 +297,9 @@ final class NostrInboundPipeline {
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
// Group state travels only over mesh Noise sessions in v1; anything
// claiming to be group traffic over Nostr is ignored. Wi-Fi bulk
// negotiation is mesh-proximity only; it never rides Nostr either.
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate,
case .verifyChallenge, .verifyResponse,
.bulkTransferOffer, .bulkTransferResponse:
// Wi-Fi bulk negotiation is mesh-proximity only; it never rides Nostr.
break
}
}
@@ -359,11 +351,9 @@ final class NostrInboundPipeline {
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
// Group state travels only over mesh Noise sessions in v1; anything
// claiming to be group traffic over Nostr is ignored. Wi-Fi bulk
// negotiation is mesh-proximity only; it never rides Nostr either.
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate,
case .verifyChallenge, .verifyResponse,
.bulkTransferOffer, .bulkTransferResponse:
// Wi-Fi bulk negotiation is mesh-proximity only; it never rides Nostr.
break
}
}
@@ -442,12 +432,10 @@ final class NostrInboundPipeline {
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
// Group state travels only over mesh Noise sessions
// in v1; group traffic over Nostr is ignored. Wi-Fi
// bulk negotiation is mesh-proximity only; it never
// rides Nostr either.
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate,
case .verifyChallenge, .verifyResponse,
.bulkTransferOffer, .bulkTransferResponse:
// Wi-Fi bulk negotiation is mesh-proximity only;
// it never rides Nostr.
break
}
}
+1 -173
View File
@@ -1,31 +1,10 @@
import SwiftUI
#if DEBUG
import BitLogger
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
#endif
struct AppInfoView: View {
@Environment(\.dismiss) var dismiss
@ThemedPalette private var palette
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
/// Supplies the mesh topology map data. Nil (previews, missing wiring)
/// hides the topology row entirely.
var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)?
@State private var showTopology = false
#if DEBUG
// Opt-in LAN log streaming + in-app export. DEBUG-only; empty host = off.
@AppStorage(LogNetworkSink.hostDefaultsKey) private var logSinkHost = ""
@AppStorage(LogNetworkSink.portDefaultsKey) private var logSinkPort = LogNetworkSink.defaultPort
@State private var exportedLogURL: URL?
@State private var isPresentingLogShare = false
#endif
private var selectedTheme: AppTheme {
AppTheme(rawValue: appThemeRawValue) ?? .matrix
}
@@ -96,15 +75,6 @@ struct AppInfoView: View {
]
}
enum Network {
static let title: LocalizedStringKey = "app_info.network.title"
static let topology = AppInfoFeatureInfo(
icon: "point.3.connected.trianglepath.dotted",
title: "app_info.network.topology.title",
description: "app_info.network.topology.description"
)
}
enum Privacy {
static let title: LocalizedStringKey = "app_info.privacy.title"
static let noTracking = AppInfoFeatureInfo(
@@ -144,7 +114,7 @@ struct AppInfoView: View {
// Custom header for macOS
HStack {
Spacer()
Button(String(localized: "app_info.done", defaultValue: "DONE")) {
Button("app_info.done") {
dismiss()
}
.buttonStyle(.plain)
@@ -159,11 +129,6 @@ struct AppInfoView: View {
.themedSheetBackground()
}
.frame(width: 600, height: 700)
.sheet(isPresented: $showTopology) {
if let topologyProvider {
MeshTopologyView(provider: topologyProvider)
}
}
#else
NavigationView {
ScrollView {
@@ -178,11 +143,6 @@ struct AppInfoView: View {
}
}
}
.sheet(isPresented: $showTopology) {
if let topologyProvider {
MeshTopologyView(provider: topologyProvider)
}
}
#endif
}
@@ -239,27 +199,6 @@ struct AppInfoView: View {
.foregroundColor(textColor)
}
// Network diagnostics
if topologyProvider != nil {
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Network.title)
Button {
showTopology = true
} label: {
HStack(spacing: 0) {
FeatureRow(info: Strings.Network.topology)
Image(systemName: "chevron.right")
.font(.bitchatSystem(size: 12))
.foregroundColor(secondaryTextColor)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(Text(String(localized: "app_info.network.topology.hint", defaultValue: "opens the mesh topology map")))
}
}
// Features
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Features.title)
@@ -309,122 +248,11 @@ struct AppInfoView: View {
FeatureRow(info: Strings.Privacy.panic)
}
#if DEBUG
debugSection
#endif
}
.padding()
}
#if DEBUG
// Tester-only observability panel: live LAN log streaming + a logs export.
// Never compiled into release (privacy-first: release ships no such UI).
@ViewBuilder
private var debugSection: some View {
VStack(alignment: .leading, spacing: 12) {
SectionHeader("debug & logs")
Text("Stream sanitized logs over the LAN to a collector (nc -lu \(logSinkPort)). Empty host = off.")
.bitchatFont(size: 12)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
TextField("collector host (e.g. 192.168.1.5)", text: $logSinkHost)
.textFieldStyle(.roundedBorder)
.bitchatFont(size: 13)
#if os(iOS)
.keyboardType(.numbersAndPunctuation)
.autocapitalization(.none)
.disableAutocorrection(true)
#endif
.accessibilityLabel(Text("log collector host"))
TextField("port", value: $logSinkPort, format: .number.grouping(.never))
.textFieldStyle(.roundedBorder)
.bitchatFont(size: 13)
.frame(width: 70)
#if os(iOS)
.keyboardType(.numberPad)
#endif
.accessibilityLabel(Text("log collector port"))
}
.onChange(of: logSinkHost) { _ in LogNetworkSink.shared.reloadConfiguration() }
.onChange(of: logSinkPort) { _ in LogNetworkSink.shared.reloadConfiguration() }
Button(action: exportLogs) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "square.and.arrow.up")
.font(.bitchatSystem(size: 20))
.foregroundColor(textColor)
.frame(width: 30)
VStack(alignment: .leading, spacing: 4) {
Text("Export Logs")
.bitchatFont(size: 14, weight: .semibold)
.foregroundColor(textColor)
Text("Share the recent in-memory log buffer as a .txt file.")
.bitchatFont(size: 12)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Export logs"))
.accessibilityHint(Text("Shares the recent log buffer as a text file"))
}
#if os(iOS)
.sheet(isPresented: $isPresentingLogShare) {
if let exportedLogURL {
LogShareSheet(activityItems: [exportedLogURL])
}
}
#endif
}
/// Write the buffered log to a temp `.txt` and present the platform share.
private func exportLogs() {
let text = LogExportBuffer.shared.snapshot()
let stamp = ISO8601DateFormatter().string(from: Date())
.replacingOccurrences(of: ":", with: "-")
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("bitchat-logs-\(stamp).txt")
do {
try text.data(using: .utf8)?.write(to: url)
} catch {
return
}
#if os(iOS)
exportedLogURL = url
isPresentingLogShare = true
#elseif os(macOS)
let panel = NSSavePanel()
panel.nameFieldStringValue = url.lastPathComponent
panel.allowedContentTypes = [.plainText]
if panel.runModal() == .OK, let dest = panel.url {
try? text.data(using: .utf8)?.write(to: dest)
}
#endif
}
#endif
}
#if DEBUG && os(iOS)
/// Minimal UIActivityViewController bridge for the Export Logs share sheet.
private struct LogShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
}
#endif
struct AppInfoFeatureInfo {
let icon: String
let title: LocalizedStringKey
-270
View File
@@ -1,270 +0,0 @@
//
// BoardView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// The bulletin board for one context: a geohash channel, or the mesh-local
/// board when `geohash` is empty. Urgent posts pin to the top; own posts can
/// be swipe-deleted, which broadcasts a signed tombstone.
struct BoardView: View {
/// Empty string = mesh-local board.
let geohash: String
let senderNickname: String
@ObservedObject var board: BoardManager
@ThemedPalette private var palette
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.dismiss) private var dismiss
@State private var draft: String = ""
@State private var urgent = false
@State private var expiryDays = 7
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
private var posts: [BoardPostPacket] { board.posts(forGeohash: geohash) }
private enum Strings {
static let boardName = String(localized: "board.title", defaultValue: "board", comment: "Title prefix of the bulletin board sheet")
static let description = String(localized: "board.description", defaultValue: "persistent notices carried by the mesh. posts are signed, spread device-to-device, and expire on their own.", comment: "Explainer under the board sheet title")
static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts")
static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts")
static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts")
static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer")
static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field")
static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button")
static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts")
static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker")
static let closeHint = String(localized: "board.accessibility.close", defaultValue: "Close board", comment: "Accessibility label for the board close button")
static func expiryDaysOption(_ days: Int) -> String {
String(
format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"),
locale: .current,
days
)
}
static func postAccessibilityLabel(author: String, content: String, urgent: Bool) -> String {
let base = String(
format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"),
locale: .current,
author, content
)
return urgent ? "\(urgentBadge), \(base)" : base
}
}
var body: some View {
VStack(spacing: 0) {
headerSection
postList
composer
}
.themedSurface()
#if os(macOS)
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
#endif
.themedSheetBackground()
}
private var headerSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(verbatim: geohash.isEmpty ? "\(Strings.boardName) @ #mesh" : "\(Strings.boardName) @ #\(geohash)")
.bitchatFont(size: 18)
Spacer()
SheetCloseButton { dismiss() }
.accessibilityLabel(Strings.closeHint)
}
Text(Strings.description)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.themedSurface()
}
private var postList: some View {
Group {
if posts.isEmpty {
ScrollView {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.emptyTitle)
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.emptySubtitle)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
} else {
List {
ForEach(posts, id: \.postID) { post in
postRow(post)
.listRowBackground(palette.background)
.listRowSeparatorTint(palette.divider)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.themedSurface()
}
private func postRow(_ post: BoardPostPacket) -> some View {
let isOwn = board.isOwnPost(post)
let author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon"
return VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
if post.isUrgent {
Image(systemName: "exclamationmark.triangle.fill")
.font(.bitchatSystem(size: 11))
.foregroundColor(palette.alertRed)
Text(Strings.urgentBadge)
.bitchatFont(size: 11, weight: .semibold)
.foregroundColor(palette.alertRed)
}
Text(verbatim: "@\(author)")
.bitchatFont(size: 12, weight: .semibold)
Text(timestampText(forMs: post.createdAt))
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
Spacer()
if isOwn {
Button {
board.deletePost(post)
} label: {
Image(systemName: "trash")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.deleteAction)
}
}
Text(post.content)
.bitchatFont(size: 14)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Strings.postAccessibilityLabel(author: author, content: post.content, urgent: post.isUrgent))
.accessibilityActions {
if isOwn {
Button(Strings.deleteAction) { board.deletePost(post) }
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if isOwn {
Button(role: .destructive) {
board.deletePost(post)
} label: {
Label(Strings.deleteAction, systemImage: "trash")
}
}
}
}
private var composer: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 10) {
TextField(Strings.placeholder, text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.bitchatFont(size: 14)
.lineLimit(maxDraftLines, reservesSpace: true)
.padding(.vertical, 6)
Button(action: send) {
Image(systemName: "arrow.up.circle.fill")
.font(.bitchatSystem(size: 20))
.foregroundColor(sendEnabled ? palette.accent : .secondary)
}
.padding(.top, 2)
.buttonStyle(.plain)
.disabled(!sendEnabled)
.accessibilityLabel(Strings.send)
}
HStack(spacing: 12) {
Toggle(isOn: $urgent) {
Text(Strings.urgentToggle)
.bitchatFont(size: 12)
.foregroundColor(urgent ? palette.alertRed : palette.secondary)
}
.toggleStyle(.switch)
.fixedSize()
.accessibilityLabel(Strings.urgentToggle)
Spacer()
Text(Strings.expiryLabel)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Picker(Strings.expiryLabel, selection: $expiryDays) {
ForEach([1, 3, 7], id: \.self) { days in
Text(Strings.expiryDaysOption(days)).tag(days)
}
}
.pickerStyle(.segmented)
.fixedSize()
.accessibilityLabel(Strings.expiryLabel)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.themedSurface()
.overlay(Divider(), alignment: .top)
}
private var sendEnabled: Bool {
let trimmed = draft.trimmed
return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes
}
private func send() {
guard let content = draft.trimmedOrNilIfEmpty else { return }
let sent = board.createPost(
content: content,
geohash: geohash,
urgent: urgent,
expiryDays: expiryDays,
nickname: senderNickname
)
if sent {
draft = ""
urgent = false
}
}
private func timestampText(forMs ms: UInt64) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
let now = Date()
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
return rel.isEmpty ? "" : "\(rel) ago"
}
return Self.absDateFormatter.string(from: date)
}
private static let relativeFormatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.allowedUnits = [.day, .hour, .minute]
f.maximumUnitCount = 1
f.unitsStyle = .abbreviated
f.collapsesLargestUnit = true
return f
}()
private static let absDateFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMM d")
return f
}()
}
@@ -16,32 +16,32 @@ extension DeliveryStatus {
var bitchatDescription: String {
switch self {
case .sending:
return String(localized: "content.delivery.sending", defaultValue: "sending...", comment: "Delivery status description while a private message is being sent")
return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent")
case .sent:
return String(localized: "content.delivery.sent", defaultValue: "sent — no delivery confirmation yet", comment: "Delivery status description for a sent but not yet confirmed private message")
return String(localized: "content.delivery.sent", comment: "Delivery status description for a sent but not yet confirmed private message")
case .carried:
return String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Delivery status description for messages handed to a courier for physical delivery")
case .delivered(let nickname, _):
return String(
format: String(localized: "content.delivery.delivered_to", defaultValue: "delivered to %@", comment: "Tooltip for delivered private messages"),
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
locale: .current,
nickname
)
case .read(let nickname, _):
return String(
format: String(localized: "content.delivery.read_by", defaultValue: "read by %@", comment: "Tooltip for read private messages"),
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
locale: .current,
nickname
)
case .failed(let reason):
return String(
format: String(localized: "content.delivery.failed", defaultValue: "failed: %@", comment: "Tooltip for failed message delivery"),
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
locale: .current,
reason
)
case .partiallyDelivered(let reached, let total):
return String(
format: String(localized: "content.delivery.delivered_members", defaultValue: "delivered to %1$d of %2$d members", comment: "Tooltip for partially delivered messages"),
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
locale: .current,
reached,
total
+16 -158
View File
@@ -7,17 +7,12 @@
//
import SwiftUI
#if os(iOS)
import UIKit
#else
import AppKit
#endif
struct PaymentChipView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.openURL) private var openURL
@ThemedPalette private var palette
enum PaymentType {
case cashu(String)
case lightning(String)
@@ -40,92 +35,44 @@ struct PaymentChipView: View {
return URL(string: link)
}
}
/// The bare `cashuA`/`cashuB` bearer string, when this is a Cashu chip.
var cashuToken: String? {
if case .cashu(let link) = self {
return CashuTokenDecoder.bareToken(from: link)
}
return nil
}
/// Web fallback for redemption when no wallet handles `cashu:` URLs.
/// The token only reaches the site the user's browser loads; the app
/// itself never contacts a mint.
var cashuWebRedeemURL: URL? {
guard let token = cashuToken,
let enc = token.addingPercentEncoding(withAllowedCharacters: Self.cashuAllowedCharacters) else {
return nil
}
return URL(string: "https://redeem.cashu.me/?token=\(enc)")
}
var emoji: String {
switch self {
case .cashu: "🥜"
case .lightning: ""
}
}
var label: String {
switch self {
case .cashu:
String(localized: "content.payment.cashu", defaultValue: "pay via cashu", comment: "Label for Cashu payment chip")
String(localized: "content.payment.cashu", comment: "Label for Cashu payment chip")
case .lightning:
String(localized: "content.payment.lightning", defaultValue: "pay via lightning", comment: "Label for Lightning payment chip")
String(localized: "content.payment.lightning", comment: "Label for Lightning payment chip")
}
}
}
let paymentType: PaymentType
/// Decoded once at construction; tokens are capped in size so this is
/// cheap, and rows re-render often enough that lazy decode in `body`
/// would just repeat the work.
private let cashuInfo: CashuTokenDecoder.TokenInfo?
init(paymentType: PaymentType) {
self.paymentType = paymentType
if case .cashu(let link) = paymentType {
self.cashuInfo = CashuTokenDecoder.decode(link)
} else {
self.cashuInfo = nil
}
}
private var fgColor: Color { palette.primary }
private var bgColor: Color {
palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12)
}
private var border: Color { fgColor.opacity(0.25) }
/// "500 sat · mint.example.com", degrading to the generic label when the
/// token didn't decode (V4 payloads we can't walk, malformed input).
private var primaryLabel: String {
guard let info = cashuInfo else { return paymentType.label }
var parts: [String] = []
if let amount = info.displayAmount { parts.append(amount) }
if let host = info.mintHost { parts.append(host) }
return parts.isEmpty ? paymentType.label : parts.joined(separator: " · ")
}
private var memoLabel: String? { cashuInfo?.memo }
var body: some View {
Button {
primaryAction()
#if os(iOS)
if let url = paymentType.url { openURL(url) }
#else
if let url = paymentType.url { NSWorkspace.shared.open(url) }
#endif
} label: {
HStack(spacing: 6) {
Text(paymentType.emoji)
VStack(alignment: .leading, spacing: 1) {
Text(primaryLabel)
.bitchatFont(size: 12, weight: .semibold)
if let memoLabel {
Text(memoLabel)
.bitchatFont(size: 10)
.opacity(0.7)
.lineLimit(1)
}
}
Text(paymentType.label)
.bitchatFont(size: 12, weight: .semibold)
}
.padding(.vertical, 6)
.padding(.horizontal, 12)
@@ -140,102 +87,13 @@ struct PaymentChipView: View {
.foregroundColor(fgColor)
}
.buttonStyle(.plain)
.contextMenu {
if let token = paymentType.cashuToken {
Button {
copyToPasteboard(token)
} label: {
Label(String(localized: "content.payment.copy_token", defaultValue: "copy token", comment: "Context menu action copying a Cashu token to the pasteboard"), systemImage: "doc.on.doc")
}
Button {
redeemCashu()
} label: {
Label(String(localized: "content.payment.redeem_wallet", defaultValue: "redeem in wallet", comment: "Context menu action opening a Cashu token in an ecash wallet app"), systemImage: "wallet.pass")
}
if let webURL = paymentType.cashuWebRedeemURL {
Button {
openExternalURL(webURL)
} label: {
Label(String(localized: "content.payment.redeem_web", defaultValue: "redeem on web", comment: "Context menu action opening a Cashu token in the web redemption page"), systemImage: "safari")
}
}
}
}
.accessibilityLabel(Text(verbatim: accessibilityText))
}
private var accessibilityText: String {
var text = "\(paymentType.label): \(primaryLabel)"
if let memoLabel { text += ", \(memoLabel)" }
return text
}
// MARK: - Actions
private func primaryAction() {
switch paymentType {
case .cashu:
redeemCashu()
case .lightning:
#if os(iOS)
if let url = paymentType.url { openURL(url) }
#else
if let url = paymentType.url { NSWorkspace.shared.open(url) }
#endif
}
}
/// Redemption is delegated: try a wallet registered for `cashu:` URLs
/// first, then fall back to the web redemption page. Uses the platform
/// opener directly (not the `openURL` environment) because the message
/// list overrides that action for cashu/lightning schemes without a
/// fallback path.
private func redeemCashu() {
let walletURL = paymentType.url
let webURL = paymentType.cashuWebRedeemURL
#if os(iOS)
if let walletURL {
UIApplication.shared.open(walletURL, options: [:]) { accepted in
if !accepted, let webURL {
UIApplication.shared.open(webURL)
}
}
} else if let webURL {
UIApplication.shared.open(webURL)
}
#else
if let walletURL, NSWorkspace.shared.urlForApplication(toOpen: walletURL) != nil {
NSWorkspace.shared.open(walletURL)
} else if let webURL {
NSWorkspace.shared.open(webURL)
} else if let walletURL {
NSWorkspace.shared.open(walletURL)
}
#endif
}
private func openExternalURL(_ url: URL) {
#if os(iOS)
UIApplication.shared.open(url)
#else
NSWorkspace.shared.open(url)
#endif
}
private func copyToPasteboard(_ string: String) {
#if os(iOS)
UIPasteboard.general.string = string
#else
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(string, forType: .string)
#endif
}
}
#Preview {
let cashuLink = "https://example.com/cashu"
let lightningLink = "https://example.com/lightning"
List {
HStack {
PaymentChipView(paymentType: .cashu(cashuLink))
@@ -24,6 +24,6 @@ struct SheetCloseButton: View {
.contentShape(Rectangle().inset(by: -6))
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "common.close", defaultValue: "close", comment: "Accessibility label for close buttons"))
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
}
}
@@ -69,7 +69,7 @@ struct TextMessageView: View {
}
.buttonStyle(.plain)
.accessibilityHint(
String(localized: "content.accessibility.delivery_detail_hint", defaultValue: "tap to show delivery details", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
)
}
}
+13 -13
View File
@@ -118,17 +118,17 @@ private extension ContentComposerView {
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
let target = isGeoDM ? header.displayName : "@\(header.displayName)"
return String(
format: String(localized: "content.input.placeholder.private", defaultValue: "message %@ — private", comment: "Composer placeholder inside a private chat, naming the conversation partner"),
format: String(localized: "content.input.placeholder.private", comment: "Composer placeholder inside a private chat, naming the conversation partner"),
locale: .current,
target
)
}
switch locationChannelsModel.selectedChannel {
case .mesh:
return String(localized: "content.input.placeholder.mesh", defaultValue: "message #mesh — public, nearby", comment: "Composer placeholder for the public mesh channel")
return String(localized: "content.input.placeholder.mesh", comment: "Composer placeholder for the public mesh channel")
case .location(let channel):
return String(
format: String(localized: "content.input.placeholder.location", defaultValue: "message #%@ — public", comment: "Composer placeholder for a public geohash channel, naming it"),
format: String(localized: "content.input.placeholder.location", comment: "Composer placeholder for a public geohash channel, naming it"),
locale: .current,
channel.geohash
)
@@ -184,15 +184,15 @@ private extension ContentComposerView {
showImagePicker = true
}
.accessibilityLabel(
String(localized: "content.accessibility.attach_photo", defaultValue: "attach photo", comment: "Accessibility label for the photo attachment button")
String(localized: "content.accessibility.attach_photo", comment: "Accessibility label for the photo attachment button")
)
.accessibilityHint(
String(localized: "content.accessibility.attach_photo_hint", defaultValue: "opens the photo library; use the take photo action for the camera", comment: "Accessibility hint explaining the attachment button opens the photo library")
String(localized: "content.accessibility.attach_photo_hint", comment: "Accessibility hint explaining the attachment button opens the photo library")
)
.accessibilityAddTraits(.isButton)
// The long-press camera path is unreachable for VoiceOver users;
// mirror it as a named action.
.accessibilityAction(named: Text(String(localized: "content.accessibility.take_photo", defaultValue: "take photo with camera", comment: "Accessibility action name for taking a photo with the camera"))) {
.accessibilityAction(named: Text("content.accessibility.take_photo", comment: "Accessibility action name for taking a photo with the camera")) {
imagePickerSourceType = .camera
showImagePicker = true
}
@@ -205,7 +205,7 @@ private extension ContentComposerView {
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.choose_photo", defaultValue: "choose photo", comment: "Accessibility label for the macOS photo picker button")
String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button")
)
#endif
}
@@ -249,15 +249,15 @@ private extension ContentComposerView {
)
)
.accessibilityLabel(
String(localized: "content.accessibility.record_voice_note", defaultValue: "record voice note", comment: "Accessibility label for the voice note button")
String(localized: "content.accessibility.record_voice_note", comment: "Accessibility label for the voice note button")
)
.accessibilityValue(
voiceRecordingVM.state.isActive
? String(localized: "content.accessibility.recording", defaultValue: "recording", comment: "Accessibility value announced while a voice note is recording")
? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording")
: ""
)
.accessibilityHint(
String(localized: "content.accessibility.record_voice_hint", defaultValue: "double-tap to start recording, double-tap again to send", comment: "Accessibility hint explaining double-tap toggles voice recording")
String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording")
)
.accessibilityAddTraits(.isButton)
// Press-and-hold drag gestures can't be activated by VoiceOver;
@@ -282,12 +282,12 @@ private extension ContentComposerView {
.buttonStyle(.plain)
.disabled(!enabled)
.accessibilityLabel(
String(localized: "content.accessibility.send_message", defaultValue: "send message", comment: "Accessibility label for the send message button")
String(localized: "content.accessibility.send_message", comment: "Accessibility label for the send message button")
)
.accessibilityHint(
enabled
? String(localized: "content.accessibility.send_hint_ready", defaultValue: "double tap to send", comment: "Hint prompting the user to send the message")
: String(localized: "content.accessibility.send_hint_empty", defaultValue: "enter a message to send", comment: "Hint prompting the user to enter a message")
? String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the message")
: String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message")
)
}
}
+15 -61
View File
@@ -25,9 +25,6 @@ struct ContentHeaderView: View {
/// Courier envelopes this device is carrying for offline third parties.
@State private var carriedMailCount = 0
/// Bulletin board sheet for the current channel context.
@State private var showBoard = false
var body: some View {
HStack(spacing: 0) {
Text(verbatim: "bitchat/")
@@ -44,7 +41,7 @@ struct ContentHeaderView: View {
// stays undiscoverable on purpose it's destructive.)
.accessibilityAddTraits(.isButton)
.accessibilityHint(
String(localized: "content.accessibility.app_info_hint", defaultValue: "shows app info", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info")
String(localized: "content.accessibility.app_info_hint", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info")
)
.accessibilityAction {
appChromeModel.presentAppInfo()
@@ -56,7 +53,7 @@ struct ContentHeaderView: View {
.foregroundColor(palette.secondary)
TextField(
String(localized: "content.input.nickname_placeholder", defaultValue: "nickname"),
"content.input.nickname_placeholder",
text: Binding(
get: { appChromeModel.nickname },
set: { appChromeModel.setNickname($0) }
@@ -94,19 +91,6 @@ struct ContentHeaderView: View {
}()
HStack(spacing: 2) {
if locationChannelsModel.gatewayEnabled {
Image(systemName: "globe")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary.opacity(0.8))
.headerTapTarget()
.accessibilityLabel(
String(localized: "content.accessibility.gateway_active", defaultValue: "Internet gateway active, sharing your connection with the mesh", comment: "Accessibility label for the internet gateway indicator")
)
.help(
String(localized: "content.header.gateway_active", defaultValue: "Sharing your internet connection with nearby mesh peers", comment: "Tooltip for the internet gateway indicator")
)
}
if carriedMailCount > 0 {
Image(systemName: "figure.walk")
.font(.bitchatSystem(size: 12))
@@ -133,7 +117,7 @@ struct ContentHeaderView: View {
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.open_unread_private_chat", defaultValue: "open unread private chat", comment: "Accessibility label for the unread private chat button")
String(localized: "content.accessibility.open_unread_private_chat", comment: "Accessibility label for the unread private chat button")
)
}
@@ -151,24 +135,10 @@ struct ContentHeaderView: View {
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.location_notes", defaultValue: "location notes for this place", comment: "Accessibility label for location notes button")
String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button")
)
}
Button(action: { showBoard = true }) {
Image(systemName: "pin")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary.opacity(0.9))
.headerTapTarget()
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.board", defaultValue: "Bulletin board", comment: "Accessibility label for the bulletin board button")
)
.help(
String(localized: "content.header.board", defaultValue: "Bulletin board: persistent notices for this channel", comment: "Tooltip for the bulletin board button")
)
if case .location(let channel) = locationChannelsModel.selectedChannel {
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
@@ -178,7 +148,7 @@ struct ContentHeaderView: View {
.buttonStyle(.plain)
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.toggle_bookmark", defaultValue: "toggle bookmark for #%@", comment: "Accessibility label for toggling a geohash bookmark"),
format: String(localized: "content.accessibility.toggle_bookmark", comment: "Accessibility label for toggling a geohash bookmark"),
locale: .current,
channel.geohash
)
@@ -211,7 +181,7 @@ struct ContentHeaderView: View {
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.accessibilityLabel(
String(localized: "content.accessibility.location_channels", defaultValue: "location channels", comment: "Accessibility label for the location channels button")
String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button")
)
}
.buttonStyle(.plain)
@@ -238,7 +208,7 @@ struct ContentHeaderView: View {
.buttonStyle(.plain)
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.people_count", defaultValue: "%#@people@", comment: "Accessibility label announcing number of people in header"),
format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"),
locale: .current,
headerOtherPeersCount
)
@@ -247,8 +217,8 @@ struct ContentHeaderView: View {
// color; say it.
.accessibilityValue(
headerPeersReachable
? String(localized: "content.accessibility.peers_connected", defaultValue: "connected", comment: "Accessibility value when peers are reachable")
: String(localized: "content.accessibility.peers_none", defaultValue: "no one reachable", comment: "Accessibility value when no peers are reachable")
? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable")
: String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable")
)
}
.layoutPriority(3)
@@ -272,13 +242,6 @@ struct ContentHeaderView: View {
.environmentObject(locationChannelsModel)
.environmentObject(peerListModel)
}
.sheet(isPresented: $showBoard) {
BoardView(
geohash: boardGeohash,
senderNickname: appChromeModel.nickname,
board: appChromeModel.boardManager
)
}
.sheet(isPresented: $showLocationNotes, onDismiss: {
notesGeohash = nil
}) {
@@ -325,10 +288,10 @@ struct ContentHeaderView: View {
.onChange(of: locationChannelsModel.permissionState) { _ in
locationChannelsModel.refreshMeshChannelsIfNeeded()
}
.alert(String(localized: "content.alert.screenshot.title", defaultValue: "heads up"), isPresented: $appChromeModel.showScreenshotPrivacyWarning) {
Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {}
.alert("content.alert.screenshot.title", isPresented: $appChromeModel.showScreenshotPrivacyWarning) {
Button("common.ok", role: .cancel) {}
} message: {
Text(String(localized: "content.alert.screenshot.message", defaultValue: "screenshots of location channels will reveal your location. think before sharing publicly."))
Text("content.alert.screenshot.message")
}
.themedChromePanel(edge: .top)
}
@@ -348,15 +311,6 @@ private extension ContentHeaderView {
dynamicTypeSize.isAccessibilitySize ? 2 : 1
}
/// The board scope for the current channel: the geohash channel's board,
/// or the mesh-local board ("") in mesh chat.
var boardGeohash: String {
if case .location(let channel) = locationChannelsModel.selectedChannel {
return channel.geohash
}
return ""
}
/// Whether anyone is actually reachable on the current channel the
/// state the count icon's color encodes visually.
var headerPeersReachable: Bool {
@@ -392,7 +346,7 @@ private struct ContentLocationNotesUnavailableView: View {
var body: some View {
VStack(spacing: 12) {
HStack {
Text(String(localized: "content.notes.title", defaultValue: "notes"))
Text("content.notes.title")
.bitchatFont(size: 16, weight: .bold)
Spacer()
SheetCloseButton { showLocationNotes = false }
@@ -401,10 +355,10 @@ private struct ContentLocationNotesUnavailableView: View {
.frame(minHeight: headerHeight)
.padding(.horizontal, 12)
.themedChromePanel(edge: .top)
Text(String(localized: "content.notes.location_unavailable", defaultValue: "location unavailable"))
Text("content.notes.location_unavailable")
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
Button(String(localized: "content.location.enable", defaultValue: "enable location")) {
Button("content.location.enable") {
locationChannelsModel.enableAndRefresh()
}
.buttonStyle(.bordered)
+30 -52
View File
@@ -163,10 +163,10 @@ private struct ContentPeopleListView: View {
// .help maps to the accessibility *hint* on iOS, so the
// button still needs a spoken name.
.accessibilityLabel(
String(localized: "content.accessibility.verification", defaultValue: "verify encryption", comment: "Accessibility label for the verification QR button")
String(localized: "content.accessibility.verification", comment: "Accessibility label for the verification QR button")
)
.help(
String(localized: "content.help.verification", defaultValue: "verification: show my QR or scan a friend", comment: "Help text for verification button")
String(localized: "content.help.verification", comment: "Help text for verification button")
)
}
SheetCloseButton {
@@ -221,13 +221,6 @@ private struct ContentPeopleListView: View {
}
)
} else {
GroupChatList(
groups: peerListModel.groupRows,
onTapGroup: { peerID in
peerListModel.startConversation(with: peerID)
showSidebar = true
}
)
MeshPeerList(
onTapPeer: { peerID in
peerListModel.startConversation(with: peerID)
@@ -262,7 +255,7 @@ private struct ContentPeopleListView: View {
private extension ContentPeopleListView {
var peopleSheetTitle: String {
String(localized: "content.header.people", defaultValue: "PEOPLE", comment: "Title for the people list sheet").lowercased()
String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased()
}
var peopleSheetSubtitle: String? {
@@ -329,7 +322,7 @@ private struct ContentPrivateChatSheetView: View {
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.back_to_main_chat", defaultValue: "back to main chat", comment: "Accessibility label for returning to main chat")
String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat")
)
Spacer(minLength: 0)
@@ -354,8 +347,8 @@ private struct ContentPrivateChatSheetView: View {
.buttonStyle(.plain)
.accessibilityLabel(
headerState.isFavorite
? String(localized: "content.accessibility.remove_favorite", defaultValue: "remove from favorites", comment: "Accessibility label to remove a favorite")
: String(localized: "content.accessibility.add_favorite", defaultValue: "add to favorites", comment: "Accessibility label to add a favorite")
? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
: String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
)
}
}
@@ -462,10 +455,6 @@ private struct ContentPrivateChatSheetView: View {
}
private var privacyCaptionText: String {
// Group chats are ChaCha20-Poly1305 sealed to the roster's shared key.
if privateConversationModel.selectedPeerID?.isGroup == true {
return String(localized: "content.private.caption_group", defaultValue: "encrypted group · members only", comment: "Caption above the group chat composer noting messages are encrypted to group members")
}
// Geohash DMs are NIP-17 gift-wrapped always end-to-end encrypted,
// even though they carry no Noise session status. Mesh DMs earn the
// "encrypted" claim only once the Noise handshake has secured.
@@ -477,9 +466,9 @@ private struct ContentPrivateChatSheetView: View {
}
}()
if isGeoDM || noiseSecured {
return String(localized: "content.private.caption_encrypted", defaultValue: "private · end-to-end encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
}
return String(localized: "content.private.caption", defaultValue: "private conversation", comment: "Caption above the private chat composer before encryption is established")
return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established")
}
}
@@ -514,39 +503,30 @@ private struct ContentPrivateHeaderInfoButton: View {
var body: some View {
Button(action: {
// A group has no single fingerprint to show.
guard !headerState.isGroupConversation else { return }
appChromeModel.showFingerprint(for: headerState.headerPeerID)
}) {
HStack(spacing: 6) {
if headerState.isGroupConversation {
Image(systemName: "person.3.fill")
switch headerState.availability {
case .bluetoothConnected:
Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14))
.foregroundColor(palette.primary)
.accessibilityLabel(String(localized: "content.accessibility.group_chat", defaultValue: "Group chat", comment: "Accessibility label for the group chat indicator"))
} else {
switch headerState.availability {
case .bluetoothConnected:
Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14))
.foregroundColor(palette.primary)
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", defaultValue: "connected via mesh", comment: "Accessibility label for mesh-connected peer indicator"))
case .meshReachable:
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14))
.foregroundColor(palette.primary)
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", defaultValue: "reachable via mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
case .nostrAvailable:
Image(systemName: "globe")
.font(.bitchatSystem(size: 14))
.foregroundColor(.purple)
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", defaultValue: "available via Nostr", comment: "Accessibility label for Nostr-available peer indicator"))
case .offline:
// Absence of a glyph was the only offline signal; say it.
Text(String(localized: "mesh_peers.state.offline", defaultValue: "offline"))
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
}
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
case .meshReachable:
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14))
.foregroundColor(palette.primary)
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
case .nostrAvailable:
Image(systemName: "globe")
.font(.bitchatSystem(size: 14))
.foregroundColor(.purple)
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
case .offline:
// Absence of a glyph was the only offline signal; say it.
Text("mesh_peers.state.offline")
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
}
Text(headerState.displayName)
@@ -574,7 +554,7 @@ private struct ContentPrivateHeaderInfoButton: View {
)
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.encryption_status", defaultValue: "encryption status: %@", comment: "Accessibility label announcing encryption status"),
format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"),
locale: .current,
encryptionStatus.accessibilityDescription
)
@@ -585,15 +565,13 @@ private struct ContentPrivateHeaderInfoButton: View {
.buttonStyle(.plain)
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.private_chat_header", defaultValue: "private chat with %@", comment: "Accessibility label describing the private chat header"),
format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"),
locale: .current,
headerState.displayName
)
)
.accessibilityHint(
headerState.isGroupConversation
? ""
: String(localized: "content.accessibility.view_fingerprint_hint", defaultValue: "tap to view encryption fingerprint", comment: "Accessibility hint for viewing encryption fingerprint")
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
)
.frame(minHeight: headerHeight)
}
+6 -6
View File
@@ -149,7 +149,7 @@ struct ContentView: View {
#endif
}
.sheet(isPresented: $appChromeModel.isAppInfoPresented) {
AppInfoView(topologyProvider: { appChromeModel.meshTopologyDisplayModel() })
AppInfoView()
}
.sheet(isPresented: Binding(
get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil },
@@ -204,20 +204,20 @@ struct ContentView: View {
}
}
.alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: {
Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {}
Button("common.ok", role: .cancel) {}
if voiceRecordingVM.state == .permissionDenied {
Button(String(localized: "location_channels.action.open_settings", defaultValue: "open settings")) {
Button("location_channels.action.open_settings") {
SystemSettings.microphone.open()
}
}
}, message: {
Text(voiceRecordingVM.state.alertMessage)
})
.alert(String(localized: "content.alert.bluetooth_required.title", defaultValue: "bluetooth required"), isPresented: $appChromeModel.showBluetoothAlert) {
Button(String(localized: "content.alert.bluetooth_required.settings", defaultValue: "settings")) {
.alert("content.alert.bluetooth_required.title", isPresented: $appChromeModel.showBluetoothAlert) {
Button("content.alert.bluetooth_required.settings") {
SystemSettings.bluetooth.open()
}
Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {}
Button("common.ok", role: .cancel) {}
} message: {
Text(appChromeModel.bluetoothAlertMessage)
}
+2 -45
View File
@@ -30,23 +30,15 @@ struct FingerprintView: View {
static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified"
static func verifyHint(_ nickname: String) -> String {
String(
format: String(localized: "fingerprint.message.verify_hint", defaultValue: "compare these fingerprints with %@ using a secure channel.", comment: "Instruction to compare fingerprints with a named peer"),
format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"),
locale: .current,
nickname
)
}
static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified"
static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification"
static let vouchedBadge: LocalizedStringKey = "fingerprint.badge.vouched"
static func vouchedBy(_ count: Int) -> String {
String(
format: String(localized: "fingerprint.message.vouched_by", defaultValue: "vouched for by %#@people@ you verified", comment: "How many people the user verified have vouched for this peer"),
locale: .current,
count
)
}
static func unknownPeer() -> String {
String(localized: "common.unknown", defaultValue: "unknown", comment: "Label for an unknown peer")
String(localized: "common.unknown", comment: "Label for an unknown peer")
}
}
@@ -154,41 +146,6 @@ struct FingerprintView: View {
}
}
// Vouched (transitively verified) status: shown whenever the
// peer isn't explicitly verified but people I verified vouch
// for them, independent of the current session state.
if fingerprintState.isVouched && !fingerprintState.isVerified {
VStack(spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "checkmark.seal")
.font(.bitchatSystem(size: 14))
.foregroundColor(.teal)
Text(Strings.vouchedBadge)
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(.teal)
}
.frame(maxWidth: .infinity)
Text(Strings.vouchedBy(fingerprintState.voucherCount))
.bitchatFont(size: 12)
.foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
if !fingerprintState.voucherNames.isEmpty {
Text(fingerprintState.voucherNames.joined(separator: ", "))
.bitchatFont(size: 12)
.foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity)
}
}
.padding(.top, 8)
.accessibilityElement(children: .combine)
}
// Verification status
if fingerprintState.canToggleVerification {
VStack(spacing: 12) {
+8 -8
View File
@@ -10,16 +10,16 @@ struct GeohashPeopleList: View {
private enum Strings {
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
static let youSuffix: LocalizedStringKey = "geohash_people.you_suffix"
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", defaultValue: "blocked in geohash", comment: "Tooltip shown next to users blocked in geohash channels")
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels")
static let unblock: LocalizedStringKey = "geohash_people.action.unblock"
static let block: LocalizedStringKey = "geohash_people.action.block"
static let unblockText = String(localized: "geohash_people.action.unblock", defaultValue: "unblock", comment: "Context menu action to unblock a person")
static let blockText = String(localized: "geohash_people.action.block", defaultValue: "block", comment: "Context menu action to block a person")
static let teleported = String(localized: "geohash_people.state.teleported", defaultValue: "teleported from elsewhere", comment: "State label for someone who joined the location channel from elsewhere")
static let nearby = String(localized: "geohash_people.state.nearby", defaultValue: "in this area", comment: "State label for someone physically in the location channel's area")
static let blockedState = String(localized: "mesh_peers.state.blocked", defaultValue: "blocked", comment: "State label for a blocked peer")
static let youState = String(localized: "geohash_people.state.you", defaultValue: "you", comment: "State label marking your own row in the people list")
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", defaultValue: "opens a private chat", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
static let unblockText = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
static let blockText = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
static let teleported = String(localized: "geohash_people.state.teleported", comment: "State label for someone who joined the location channel from elsewhere")
static let nearby = String(localized: "geohash_people.state.nearby", comment: "State label for someone physically in the location channel's area")
static let blockedState = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
static let youState = String(localized: "geohash_people.state.you", comment: "State label marking your own row in the people list")
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
}
var body: some View {
-86
View File
@@ -1,86 +0,0 @@
import BitFoundation
import SwiftUI
/// Compact "groups" section for the people sheet: one row per private group
/// this device belongs to, tappable to open the group chat window.
struct GroupChatList: View {
@ThemedPalette private var palette
let groups: [GroupChatRow]
let onTapGroup: (PeerID) -> Void
private enum Strings {
static let header = String(localized: "groups.section.header", defaultValue: "groups", comment: "Section header above the private groups list")
static let creator = String(localized: "groups.state.creator", defaultValue: "Creator", comment: "State label for a group the user created")
static let unread = String(localized: "mesh_peers.state.unread", defaultValue: "new messages", comment: "State label for a peer with unread private messages")
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", defaultValue: "new messages", comment: "Tooltip for the unread messages indicator")
static let openGroupHint = String(localized: "groups.accessibility.open_group_hint", defaultValue: "Opens the group chat", comment: "Accessibility hint on a group row explaining activation opens the group chat")
static let memberCountFormat = String(localized: "groups.member_count %@", defaultValue: "(%@)", comment: "Member count shown next to a group name; placeholder is the count")
}
var body: some View {
if !groups.isEmpty {
VStack(alignment: .leading, spacing: 0) {
Text(Strings.header)
.bitchatFont(size: 11, weight: .medium)
.foregroundColor(palette.secondary)
.padding(.horizontal)
.padding(.top, 10)
.padding(.bottom, 2)
.accessibilityAddTraits(.isHeader)
ForEach(groups) { group in
HStack(spacing: 4) {
Image(systemName: "person.3.fill")
.font(.bitchatSystem(size: 10))
.foregroundColor(palette.primary)
Text("#\(group.name)")
.bitchatFont(size: 14)
.foregroundColor(palette.primary)
.lineLimit(1)
.truncationMode(.tail)
Text(String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)"))
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
if group.isCreator {
Image(systemName: "crown.fill")
.font(.bitchatSystem(size: 9))
.foregroundColor(.yellow)
.help(Strings.creator)
}
Spacer()
if group.hasUnread {
Image(systemName: "envelope.fill")
.font(.bitchatSystem(size: 10))
.foregroundColor(.orange)
.help(Strings.newMessagesTooltip)
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture { onTapGroup(group.peerID) }
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription(for: group))
.accessibilityAddTraits(.isButton)
.accessibilityHint(Strings.openGroupHint)
}
}
}
}
private func accessibilityDescription(for group: GroupChatRow) -> String {
var parts: [String] = [
group.name,
String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)")
]
if group.isCreator { parts.append(Strings.creator) }
if group.hasUnread { parts.append(Strings.unread) }
return parts.joined(separator: ", ")
}
}
+8 -38
View File
@@ -27,18 +27,16 @@ struct LocationChannelsSheet: View {
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
static let gatewayTitle: LocalizedStringKey = "location_channels.gateway.title"
static let gatewaySubtitle: LocalizedStringKey = "location_channels.gateway.subtitle"
static let toggleOn: LocalizedStringKey = "common.toggle.on"
static let toggleOff: LocalizedStringKey = "common.toggle.off"
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", defaultValue: "invalid geohash", comment: "Error shown when a custom geohash is invalid")
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", defaultValue: "switches to this channel", comment: "Accessibility hint on a channel row explaining activation switches to it")
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", defaultValue: "bookmark channel", comment: "Accessibility action name for bookmarking a channel")
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", defaultValue: "remove bookmark", comment: "Accessibility action name for removing a channel bookmark")
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it")
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel")
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark")
static func meshTitle(_ count: Int) -> String {
let label = String(localized: "location_channels.mesh_label", defaultValue: "mesh", comment: "Label for the mesh channel row")
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
return rowTitle(label: label, count: count)
}
@@ -73,7 +71,7 @@ struct LocationChannelsSheet: View {
static func subtitlePrefix(geohash: String, coverage: String) -> String {
String(
format: String(localized: "location_channels.subtitle_prefix", defaultValue: "#%1$@ • %2$@", comment: "Subtitle prefix showing geohash and coverage"),
format: String(localized: "location_channels.subtitle_prefix", comment: "Subtitle prefix showing geohash and coverage"),
locale: .current,
geohash, coverage
)
@@ -82,7 +80,7 @@ struct LocationChannelsSheet: View {
static func subtitle(prefix: String, name: String?) -> String {
guard let name, !name.isEmpty else { return prefix }
return String(
format: String(localized: "location_channels.subtitle_with_name", defaultValue: "%1$@ • %2$@", comment: "Subtitle combining prefix and resolved location name"),
format: String(localized: "location_channels.subtitle_with_name", comment: "Subtitle combining prefix and resolved location name"),
locale: .current,
prefix, name
)
@@ -90,7 +88,7 @@ struct LocationChannelsSheet: View {
private static func rowTitle(label: String, count: Int) -> String {
String(
format: String(localized: "location_channels.row_title", defaultValue: "%1$@ [%2$#@people_count@]", comment: "List row title with participant count"),
format: String(localized: "location_channels.row_title", comment: "List row title with participant count"),
locale: .current,
label, count
)
@@ -246,8 +244,6 @@ struct LocationChannelsSheet: View {
sectionDivider
torToggleSection
.padding(.top, 12)
gatewayToggleSection
.padding(.top, 8)
Button(action: SystemSettings.location.open) {
Text(Strings.removeAccess)
.bitchatFont(size: 12)
@@ -512,32 +508,6 @@ extension LocationChannelsSheet {
.cornerRadius(8)
}
private var gatewayToggleBinding: Binding<Bool> {
Binding(
get: { locationChannelsModel.gatewayEnabled },
set: { locationChannelsModel.setGatewayEnabled($0) }
)
}
private var gatewayToggleSection: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: gatewayToggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(Strings.gatewayTitle)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(palette.primary)
Text(Strings.gatewaySubtitle)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
}
.padding(12)
.background(palette.secondary.opacity(0.12))
.cornerRadius(8)
}
private var standardGreen: Color { palette.primary }
private var standardBlue: Color { palette.accentBlue }
}
+1 -1
View File
@@ -135,7 +135,7 @@ struct LocationNotesView: View {
private func headerTitle(for count: Int) -> String {
String(
format: String(localized: "location_notes.header", defaultValue: "#%1$@ • %2$#@note_count@", comment: "Header displaying the geohash and localized note count"),
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
locale: .current,
"\(geohash) ± 1", count
)
+15 -15
View File
@@ -25,20 +25,20 @@ struct BlockRevealImageView: View {
@State private var loadFailed = false
private enum Strings {
static let tapToReveal = String(localized: "media.image.tap_to_reveal", defaultValue: "tap to reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it")
static let open = String(localized: "media.image.action.open", defaultValue: "open image", comment: "Context menu action that opens an image full screen")
static let reveal = String(localized: "media.image.action.reveal", defaultValue: "reveal image", comment: "Context menu action that reveals a blurred image")
static let hide = String(localized: "media.image.action.hide", defaultValue: "hide image", comment: "Context menu action that re-blurs a revealed image")
static let delete = String(localized: "media.image.action.delete", defaultValue: "delete image", comment: "Context menu action that deletes a received image")
static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", defaultValue: "delete this image?", comment: "Title of the confirmation dialog before deleting a received image")
static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", defaultValue: "this cannot be undone — the sender may not be in range to send it again.", comment: "Body of the confirmation dialog before deleting a received image")
static let hiddenImage = String(localized: "media.image.accessibility.hidden", defaultValue: "hidden image", comment: "Accessibility label for a blurred incoming image")
static let revealedImage = String(localized: "media.image.accessibility.revealed", defaultValue: "image", comment: "Accessibility label for a revealed image")
static let revealHint = String(localized: "media.image.accessibility.hint.reveal", defaultValue: "reveals the image", comment: "Accessibility hint for a blurred image; activating it reveals the image")
static let openHint = String(localized: "media.image.accessibility.hint.open", defaultValue: "opens the image full screen", comment: "Accessibility hint for a revealed image; activating it opens the image full screen")
static let sendingImage = String(localized: "media.image.accessibility.sending", defaultValue: "sending image", comment: "Accessibility label for an image that is still sending")
static let unavailableImage = String(localized: "media.image.accessibility.unavailable", defaultValue: "image unavailable", comment: "Accessibility label for an image whose file could not be loaded")
static let cancelSend = String(localized: "media.accessibility.cancel_send", defaultValue: "cancel sending", comment: "Accessibility label for the cancel button on an in-flight media send")
static let tapToReveal = String(localized: "media.image.tap_to_reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it")
static let open = String(localized: "media.image.action.open", comment: "Context menu action that opens an image full screen")
static let reveal = String(localized: "media.image.action.reveal", comment: "Context menu action that reveals a blurred image")
static let hide = String(localized: "media.image.action.hide", comment: "Context menu action that re-blurs a revealed image")
static let delete = String(localized: "media.image.action.delete", comment: "Context menu action that deletes a received image")
static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", comment: "Title of the confirmation dialog before deleting a received image")
static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", comment: "Body of the confirmation dialog before deleting a received image")
static let hiddenImage = String(localized: "media.image.accessibility.hidden", comment: "Accessibility label for a blurred incoming image")
static let revealedImage = String(localized: "media.image.accessibility.revealed", comment: "Accessibility label for a revealed image")
static let revealHint = String(localized: "media.image.accessibility.hint.reveal", comment: "Accessibility hint for a blurred image; activating it reveals the image")
static let openHint = String(localized: "media.image.accessibility.hint.open", comment: "Accessibility hint for a revealed image; activating it opens the image full screen")
static let sendingImage = String(localized: "media.image.accessibility.sending", comment: "Accessibility label for an image that is still sending")
static let unavailableImage = String(localized: "media.image.accessibility.unavailable", comment: "Accessibility label for an image whose file could not be loaded")
static let cancelSend = String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
}
init(
@@ -154,7 +154,7 @@ struct BlockRevealImageView: View {
Button(Strings.delete, role: .destructive) {
onDelete?()
}
Button(String(localized: "common.cancel", defaultValue: "cancel"), role: .cancel) {}
Button("common.cancel", role: .cancel) {}
} message: {
Text(verbatim: Strings.deleteConfirmMessage)
}
+1 -1
View File
@@ -67,7 +67,7 @@ struct MediaMessageView: View {
}
.buttonStyle(.plain)
.accessibilityHint(
String(localized: "content.accessibility.delivery_detail_hint", defaultValue: "tap to show delivery details", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
)
}
}
+3 -3
View File
@@ -54,8 +54,8 @@ struct VoiceNoteView: View {
.buttonStyle(.plain)
.accessibilityLabel(
playback.isPlaying
? String(localized: "media.voice.accessibility.pause", defaultValue: "pause voice note", comment: "Accessibility label for pausing voice note playback")
: String(localized: "media.voice.accessibility.play", defaultValue: "play voice note", comment: "Accessibility label for playing a voice note")
? String(localized: "media.voice.accessibility.pause", comment: "Accessibility label for pausing voice note playback")
: String(localized: "media.voice.accessibility.play", comment: "Accessibility label for playing a voice note")
)
.accessibilityValue(playbackLabel)
@@ -83,7 +83,7 @@ struct VoiceNoteView: View {
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "media.accessibility.cancel_send", defaultValue: "cancel sending", comment: "Accessibility label for the cancel button on an in-flight media send")
String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
)
}
}
+16 -29
View File
@@ -16,24 +16,22 @@ struct MeshPeerList: View {
private enum Strings {
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", defaultValue: "blocked in geohash", comment: "Tooltip shown next to a blocked peer indicator")
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", defaultValue: "new messages", comment: "Tooltip for the unread messages indicator")
static let connected = String(localized: "content.accessibility.connected_mesh", defaultValue: "connected via mesh", comment: "Accessibility label for mesh-connected peer indicator")
static let reachable = String(localized: "content.accessibility.reachable_mesh", defaultValue: "reachable via mesh", comment: "Accessibility label for mesh-reachable peer indicator")
static let nostr = String(localized: "content.accessibility.available_nostr", defaultValue: "available via Nostr", comment: "Accessibility label for Nostr-available peer indicator")
static let offline = String(localized: "mesh_peers.state.offline", defaultValue: "offline", comment: "State label for a peer that is not currently reachable")
static let favorite = String(localized: "mesh_peers.state.favorite", defaultValue: "favorite", comment: "State label for a favorited peer")
static let unread = String(localized: "mesh_peers.state.unread", defaultValue: "new messages", comment: "State label for a peer with unread private messages")
static let blocked = String(localized: "mesh_peers.state.blocked", defaultValue: "blocked", comment: "State label for a blocked peer")
static let vouched = String(localized: "mesh_peers.state.vouched", defaultValue: "vouched", comment: "State label for a peer vouched for by someone the user verified")
static let vouchedTooltip = String(localized: "mesh_peers.tooltip.vouched", defaultValue: "vouched for by someone you verified", comment: "Tooltip for the vouched (unfilled seal) badge next to a peer")
static let addFavorite = String(localized: "content.accessibility.add_favorite", defaultValue: "add to favorites", comment: "Accessibility label to add a favorite")
static let removeFavorite = String(localized: "content.accessibility.remove_favorite", defaultValue: "remove from favorites", comment: "Accessibility label to remove a favorite")
static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", defaultValue: "show fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen")
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", defaultValue: "opens a private chat", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
static let directMessage = String(localized: "content.actions.direct_message", defaultValue: "direct message", comment: "Action that opens a private chat with the person")
static let block = String(localized: "geohash_people.action.block", defaultValue: "block", comment: "Context menu action to block a person")
static let unblock = String(localized: "geohash_people.action.unblock", defaultValue: "unblock", comment: "Context menu action to unblock a person")
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator")
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
static let connected = String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
static let reachable = String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
static let nostr = String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")
static let offline = String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable")
static let favorite = String(localized: "mesh_peers.state.favorite", comment: "State label for a favorited peer")
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen")
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
static let directMessage = String(localized: "content.actions.direct_message", comment: "Action that opens a private chat with the person")
static let block = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
static let unblock = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
}
var body: some View {
@@ -136,16 +134,6 @@ struct MeshPeerList: View {
.foregroundColor(baseColor)
}
}
// Vouched (transitively verified): unfilled seal,
// deliberately distinct from verified's filled one.
// Never shown alongside a verified badge.
if peer.showsVouchedBadge {
Image(systemName: "checkmark.seal")
.font(.bitchatSystem(size: 10))
.foregroundColor(baseColor)
.help(Strings.vouchedTooltip)
}
}
Spacer()
@@ -253,7 +241,6 @@ struct MeshPeerList: View {
parts.append(Strings.offline)
}
}
if peer.showsVouchedBadge { parts.append(Strings.vouched) }
if peer.isFavorite { parts.append(Strings.favorite) }
if peer.hasUnread { parts.append(Strings.unread) }
if peer.isBlocked { parts.append(Strings.blocked) }
-217
View File
@@ -1,217 +0,0 @@
//
// MeshTopologyView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// Display model for the mesh topology map: nodes are known mesh peers,
/// edges are gossiped `directNeighbors` claims. Built on the main actor from
/// a `MeshTopologySnapshot` plus the current nickname table.
struct MeshTopologyDisplayModel {
struct Node: Identifiable, Equatable {
let id: String
let label: String
let isSelf: Bool
}
let nodes: [Node]
/// Pairs of `Node.id`; every id is present in `nodes`.
let edges: [(String, String)]
static let empty = MeshTopologyDisplayModel(nodes: [], edges: [])
}
/// Minimal diagnostics sheet: the mesh graph on a circular layout (self in
/// the center), drawn with Canvas so it stays cheap at any peer count.
struct MeshTopologyView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.appTheme) private var appTheme
@ThemedPalette private var palette
/// Fetches a fresh model; called on appear and on manual refresh.
let provider: @MainActor () -> MeshTopologyDisplayModel
@State private var model: MeshTopologyDisplayModel = .empty
var body: some View {
#if os(macOS)
VStack(spacing: 0) {
HStack {
Text(String(localized: "topology.title", defaultValue: "mesh topology"))
.bitchatFont(size: 16, weight: .bold)
.foregroundColor(palette.primary)
Spacer()
refreshButton
Button(String(localized: "app_info.done", defaultValue: "DONE")) {
dismiss()
}
.buttonStyle(.plain)
.foregroundColor(palette.primary)
}
.padding()
.themedSurface(opacity: 0.95)
content
}
.frame(width: 500, height: 520)
.themedSheetBackground()
#else
NavigationView {
content
.themedSheetBackground()
.navigationTitle(Text(String(localized: "topology.title", defaultValue: "mesh topology")))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
refreshButton
}
ToolbarItem(placement: .navigationBarTrailing) {
SheetCloseButton { dismiss() }
.foregroundColor(palette.primary)
}
}
}
#endif
}
private var refreshButton: some View {
Button {
model = provider()
} label: {
Image(systemName: "arrow.clockwise")
.font(.bitchatSystem(size: 14))
.foregroundColor(palette.primary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(String(localized: "topology.refresh", defaultValue: "refresh topology")))
}
@ViewBuilder
private var content: some View {
VStack(spacing: 12) {
if model.nodes.count <= 1 {
Spacer()
Text(String(localized: "topology.empty", defaultValue: "no mesh links yet — the map fills in as peer announces arrive"))
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Spacer()
} else {
graphCanvas
.padding(.horizontal, 8)
}
VStack(spacing: 4) {
Text(summaryText)
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(palette.primary)
Text(String(localized: "topology.caption", defaultValue: "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted"))
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal)
.padding(.bottom, 16)
}
.onAppear { model = provider() }
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(summaryText))
}
private var summaryText: String {
String(
format: String(
localized: "topology.summary",
comment: "Topology map summary: number of peers and links"
),
locale: .current,
model.nodes.count,
model.edges.count
)
}
private var graphCanvas: some View {
Canvas { context, size in
let positions = Self.layout(nodes: model.nodes, in: size)
let fontDesign = appTheme.bodyFontDesign
// Edges first so nodes draw on top.
for (fromID, toID) in model.edges {
guard let from = positions[fromID], let to = positions[toID] else { continue }
var path = Path()
path.move(to: from)
path.addLine(to: to)
context.stroke(path, with: .color(palette.secondary.opacity(0.45)), lineWidth: 1)
}
for node in model.nodes {
guard let center = positions[node.id] else { continue }
let radius: CGFloat = node.isSelf ? 7 : 5
let dot = Path(ellipseIn: CGRect(
x: center.x - radius,
y: center.y - radius,
width: radius * 2,
height: radius * 2
))
context.fill(dot, with: .color(node.isSelf ? palette.accent : palette.primary))
if node.isSelf {
let ring = Path(ellipseIn: CGRect(
x: center.x - radius - 3,
y: center.y - radius - 3,
width: (radius + 3) * 2,
height: (radius + 3) * 2
))
context.stroke(ring, with: .color(palette.accent.opacity(0.6)), lineWidth: 1)
}
context.draw(
Text(node.label)
.font(.system(size: 10, design: fontDesign))
.foregroundColor(node.isSelf ? palette.accent : palette.secondary),
at: CGPoint(x: center.x, y: center.y + radius + 4),
anchor: .top
)
}
}
.accessibilityHidden(true) // The combined summary label narrates the graph.
}
/// Circular layout: self in the center, everyone else evenly spaced on a
/// ring. Deterministic (nodes arrive sorted), so refreshes don't shuffle.
static func layout(nodes: [MeshTopologyDisplayModel.Node], in size: CGSize) -> [String: CGPoint] {
let center = CGPoint(x: size.width / 2, y: size.height / 2)
// Leave room for the label row under each ring node.
let radius = max(20, min(size.width, size.height) / 2 - 36)
var positions: [String: CGPoint] = [:]
let ringNodes = nodes.filter { !$0.isSelf }
for node in nodes where node.isSelf {
positions[node.id] = center
}
for (index, node) in ringNodes.enumerated() {
let angle = (2 * CGFloat.pi * CGFloat(index)) / CGFloat(max(1, ringNodes.count)) - CGFloat.pi / 2
positions[node.id] = CGPoint(
x: center.x + radius * cos(angle),
y: center.y + radius * sin(angle)
)
}
return positions
}
}
#Preview("Topology") {
MeshTopologyView(provider: {
MeshTopologyDisplayModel(
nodes: [
.init(id: "self", label: "me", isSelf: true),
.init(id: "a", label: "alice", isSelf: false),
.init(id: "b", label: "bob", isSelf: false),
.init(id: "c", label: "carol", isSelf: false)
],
edges: [("self", "a"), ("a", "b"), ("self", "c")]
)
})
}
+25 -25
View File
@@ -109,11 +109,11 @@ struct MessageListView: View {
// mentioning the only other participant is noise, and "DM"
// would just reopen the conversation that is already open.
if privatePeer == nil {
Button(String(localized: "content.actions.mention", defaultValue: "mention")) {
Button("content.actions.mention") {
insertMention(message.sender)
}
if let peerID = message.senderPeerID {
Button(String(localized: "content.actions.direct_message", defaultValue: "direct message")) {
Button("content.actions.direct_message") {
privateConversationModel.openConversation(for: peerID)
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true
@@ -121,14 +121,14 @@ struct MessageListView: View {
}
}
}
Button(String(localized: "content.actions.hug", defaultValue: "hug")) {
Button("content.actions.hug") {
conversationUIModel.sendHug(to: message.sender)
}
Button(String(localized: "content.actions.slap", defaultValue: "slap")) {
Button("content.actions.slap") {
conversationUIModel.sendSlap(to: message.sender)
}
}
Button(String(localized: "content.message.copy", defaultValue: "copy message")) {
Button("content.message.copy") {
#if os(iOS)
UIPasteboard.general.string = message.content
#else
@@ -138,12 +138,12 @@ struct MessageListView: View {
#endif
}
if isResendableFailedMessage(message) {
Button(String(localized: "content.actions.resend", defaultValue: "resend")) {
Button("content.actions.resend") {
conversationUIModel.resendFailedPrivateMessage(message)
}
}
if showsUserActions {
Button(String(localized: "content.actions.block", defaultValue: "block"), role: .destructive) {
Button("content.actions.block", role: .destructive) {
conversationUIModel.block(peerID: message.senderPeerID, displayName: message.sender)
}
}
@@ -165,14 +165,14 @@ struct MessageListView: View {
showClearConfirmation = true
}
.confirmationDialog(
String(localized: "content.clear.confirm_title", defaultValue: "clear this chat?"),
"content.clear.confirm_title",
isPresented: $showClearConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "content.clear.confirm_action", defaultValue: "clear chat"), role: .destructive) {
Button("content.clear.confirm_action", role: .destructive) {
conversationUIModel.clearCurrentConversation()
}
Button(String(localized: "common.cancel", defaultValue: "cancel"), role: .cancel) {}
Button("common.cancel", role: .cancel) {}
}
.onAppear {
scrollToBottom(on: proxy)
@@ -190,17 +190,17 @@ struct MessageListView: View {
onSelectedChannelChange(newChannel, proxy: proxy)
}
.confirmationDialog(
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", defaultValue: "actions", comment: "Fallback title for the message action sheet"),
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
isPresented: $showMessageActions,
titleVisibility: .visible
) {
Button(String(localized: "content.actions.mention", defaultValue: "mention")) {
Button("content.actions.mention") {
if let sender = selectedMessageSender {
insertMention(sender)
}
}
Button(String(localized: "content.actions.direct_message", defaultValue: "direct message")) {
Button("content.actions.direct_message") {
if let peerID = selectedMessageSenderID {
privateConversationModel.openConversation(for: peerID)
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
@@ -209,23 +209,23 @@ struct MessageListView: View {
}
}
Button(String(localized: "content.actions.hug", defaultValue: "hug")) {
Button("content.actions.hug") {
if let sender = selectedMessageSender {
conversationUIModel.sendHug(to: sender)
}
}
Button(String(localized: "content.actions.slap", defaultValue: "slap")) {
Button("content.actions.slap") {
if let sender = selectedMessageSender {
conversationUIModel.sendSlap(to: sender)
}
}
Button(String(localized: "content.actions.block", defaultValue: "block"), role: .destructive) {
Button("content.actions.block", role: .destructive) {
conversationUIModel.block(peerID: selectedMessageSenderID, displayName: selectedMessageSender)
}
Button(String(localized: "common.cancel", defaultValue: "cancel"), role: .cancel) {}
Button("common.cancel", role: .cancel) {}
}
.onAppear {
// Also check when view appears
@@ -277,18 +277,18 @@ private extension MessageListView {
VStack(alignment: .leading, spacing: 6) {
switch locationChannelsModel.selectedChannel {
case .mesh:
emptyStateLine(String(localized: "content.empty.mesh_intro", defaultValue: "you're on #mesh — reaches people within bluetooth range", comment: "First line of the empty mesh timeline explaining what the mesh channel is"))
emptyStateLine(String(localized: "content.empty.mesh_waiting", defaultValue: "nobody in range yet... messages appear here", comment: "Second line of the empty mesh timeline saying no peers are in range yet"))
emptyStateLine(String(localized: "content.empty.switch_hint", defaultValue: "tap the channel name above to switch · tap bitchat/ for help", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
emptyStateLine(String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is"))
emptyStateLine(String(localized: "content.empty.mesh_waiting", comment: "Second line of the empty mesh timeline saying no peers are in range yet"))
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
case .location(let channel):
emptyStateLine(
String(
format: String(localized: "content.empty.location_intro", defaultValue: "you're in #%@ — a public location channel over the internet", comment: "First line of an empty geohash timeline naming the channel"),
format: String(localized: "content.empty.location_intro", comment: "First line of an empty geohash timeline naming the channel"),
locale: .current,
channel.geohash
)
)
emptyStateLine(String(localized: "content.empty.switch_hint", defaultValue: "tap the channel name above to switch · tap bitchat/ for help", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
}
}
.padding(.horizontal, 12)
@@ -368,7 +368,7 @@ private extension MessageListView {
if unseenCount > 0 {
Text(
String(
format: String(localized: "content.jump.new_count", defaultValue: "%lld new", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
locale: .current,
unseenCount
)
@@ -389,10 +389,10 @@ private extension MessageListView {
}
var jumpToLatestAccessibilityLabel: String {
let base = String(localized: "content.accessibility.jump_to_latest", defaultValue: "jump to latest messages", comment: "Accessibility label for the jump to latest messages button")
let base = String(localized: "content.accessibility.jump_to_latest", comment: "Accessibility label for the jump to latest messages button")
guard unseenCount > 0 else { return base }
let count = String(
format: String(localized: "content.jump.new_count", defaultValue: "%lld new", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
locale: .current,
unseenCount
)
+6 -9
View File
@@ -21,20 +21,17 @@ extension String {
return current >= threshold
}
// Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
// form matches too the token embedded after the scheme is the match.
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
func extractCashuLinks(max: Int = 3) -> [String] {
let regex = MessageFormattingEngine.Patterns.cashu
let ns = self as NSString
let range = NSRange(location: 0, length: ns.length)
var found: [String] = []
for m in regex.matches(in: self, range: range) where m.numberOfRanges > 0 {
let token = ns.substring(with: m.range(at: 0))
// Dedup: repeated tokens are one bearer instrument (and duplicate
// ForEach IDs) one chip is enough.
if !found.contains(token) {
found.append(token)
for m in regex.matches(in: self, range: range) {
if m.numberOfRanges > 0 {
let token = ns.substring(with: m.range(at: 0))
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
found.append("cashu:\(enc)")
if found.count >= max { break }
}
}
+6 -6
View File
@@ -18,7 +18,7 @@ struct MyQRView: View {
private enum Strings {
static let title: LocalizedStringKey = "verification.my_qr.title"
static let accessibilityLabel = String(localized: "verification.my_qr.accessibility_label", defaultValue: "verification QR code", comment: "Accessibility label describing the verification QR code")
static let accessibilityLabel = String(localized: "verification.my_qr.accessibility_label", comment: "Accessibility label describing the verification QR code")
}
var body: some View {
@@ -124,13 +124,13 @@ struct QRScanView: View {
static let validate: LocalizedStringKey = "verification.scan.validate"
static func requested(_ nickname: String) -> String {
String(
format: String(localized: "verification.scan.status.requested", defaultValue: "verification requested for %@", comment: "Status text when verification is requested for a nickname"),
format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"),
locale: .current,
nickname
)
}
static let notFound = String(localized: "verification.scan.status.no_peer", defaultValue: "could not find matching peer", comment: "Status when no matching peer is found for a verification request")
static let invalid = String(localized: "verification.scan.status.invalid", defaultValue: "invalid or expired QR payload", comment: "Status when a scanned QR payload is invalid")
static let notFound = String(localized: "verification.scan.status.no_peer", comment: "Status when no matching peer is found for a verification request")
static let invalid = String(localized: "verification.scan.status.invalid", comment: "Status when a scanned QR payload is invalid")
}
var body: some View {
@@ -296,7 +296,7 @@ struct VerificationSheetView: View {
VStack(spacing: 0) {
// Top header (always at top)
HStack {
Text(String(localized: "verification.sheet.title", defaultValue: "VERIFY"))
Text("verification.sheet.title")
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(accentColor)
Spacer()
@@ -316,7 +316,7 @@ struct VerificationSheetView: View {
Group {
if showingScanner {
VStack(alignment: .leading, spacing: 12) {
Text(String(localized: "verification.scan.prompt_friend", defaultValue: "scan a friend's QR"))
Text("verification.scan.prompt_friend")
.bitchatFont(size: 16, weight: .bold)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
-39
View File
@@ -591,45 +591,6 @@ struct AppArchitectureTests {
#expect(!verificationModel.isVerified(peerID: peerID))
}
@Test("VerificationModel refreshes when peer trust changes (vouch accepted)")
@MainActor
func verificationModelRefreshesOnPeerTrustChange() async {
let viewModel = makeArchitectureViewModel()
var privateConversationModel: PrivateConversationModel? = PrivateConversationModel(
chatViewModel: viewModel,
conversations: viewModel.conversations,
locationChannelsModel: LocationChannelsModel(manager: makeArchitectureLocationManager())
)
let verificationModel = VerificationModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel!
)
// PrivateConversationModel happens to observe the same notification
// and re-assign its published selection, which would ripple into
// VerificationModel; release it so this test pins VerificationModel's
// own subscription rather than that incidental chain.
privateConversationModel = nil
// The bound @Published sources replay their current values on
// subscription; let those initial main-queue emissions settle so the
// sink below observes only the trust-change signal.
try? await Task.sleep(nanoseconds: 100_000_000)
// ChatVouchCoordinator.notifyPeerTrustChanged() signals accepted
// vouches via "peerStatusUpdated"; an open fingerprint sheet must
// re-render its vouched badge from that signal alone.
var refreshed = false
let cancellable = verificationModel.objectWillChange.sink { _ in
refreshed = true
}
defer { cancellable.cancel() }
NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil)
await waitUntil { refreshed }
#expect(refreshed)
}
@Test("PeerListModel publishes mesh and geohash directory state")
@MainActor
func peerListModelPublishesDirectoryState() async {
-63
View File
@@ -261,69 +261,6 @@ struct BLEServiceCoreTests {
#expect(WifiBulkPolicy.shouldOffer(viaShort, enabled: true))
#expect(WifiBulkPolicy.shouldOffer(viaFull, enabled: true))
}
/// Pings are unsigned, so their claimed sender is attacker-controlled.
/// The pong budget must be keyed on the ingress link (the directly
/// connected peer that delivered the packet): rotating forged sender IDs
/// over one link exhausts one budget instead of resetting it, so a single
/// malicious link cannot turn /ping into an amplification primitive.
@Test
func meshPingResponseBudget_isPerIngressLinkNotClaimedSender() async throws {
let ble = makeService()
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
let link = PeerID(str: "1122334455667788")
let budget = TransportConfig.meshPingInboundMaxPerLink
let myRecipientData = try #require(Data(hexString: ble.myPeerID.id))
for i in 0..<(budget * 2) {
// A fresh forged sender for every ping, all arriving on one link.
let forgedSender = PeerID(str: String(format: "%016x", 0xA0_0000 + i))
var nonce = Data(repeating: 0, count: MeshPingPayload.nonceLength)
nonce[0] = UInt8(i)
let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7))
let packet = BitchatPacket(
type: MessageType.ping.rawValue,
senderID: Data(hexString: forgedSender.id) ?? Data(),
recipientID: myRecipientData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload.encode(),
signature: nil,
ttl: 7
)
ble._test_handlePacket(packet, fromPeerID: link, preseedPeer: false)
}
let reachedBudget = await TestHelpers.waitUntil(
{ outbound.count(ofType: .pong) >= budget },
timeout: TestConstants.defaultTimeout
)
#expect(reachedBudget)
// Give any over-budget pong a chance to surface, then confirm the
// rotated sender IDs never bought a sixth response.
let exceededBudget = await TestHelpers.waitUntil(
{ outbound.count(ofType: .pong) > budget },
timeout: TestConstants.shortTimeout
)
#expect(!exceededBudget)
#expect(outbound.count(ofType: .pong) == budget)
}
}
/// Thread-safe capture of packets leaving the service under test.
private final class OutboundPacketTap {
private let lock = NSLock()
private var packets: [BitchatPacket] = []
func record(_ packet: BitchatPacket) {
lock.lock(); packets.append(packet); lock.unlock()
}
func count(ofType type: MessageType) -> Int {
lock.lock(); defer { lock.unlock() }
return packets.filter { $0.type == type.rawValue }.count
}
}
private func makeService() -> BLEService {
-350
View File
@@ -1,350 +0,0 @@
//
// CashuTokenDecoderTests.swift
// bitchatTests
//
// Tests for the Cashu token summary decoder: V3 JSON decode, minimal V4
// CBOR traversal, URI normalization, detection ranges, and adversarial
// (truncated / garbage / huge) input. The decoder renders attacker-controlled
// message content, so "never crash" matters as much as "decode correctly".
// This is free and unencumbered software released into the public domain.
//
import Foundation
import Testing
@testable import bitchat
struct CashuTokenDecoderTests {
// MARK: - Token Builders
private func base64URL(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private func makeV3Token(
entries: [(mint: String, amounts: [Int])],
unit: String? = "sat",
memo: String? = nil
) -> String {
var json: [String: Any] = [
"token": entries.map { entry in
[
"mint": entry.mint,
"proofs": entry.amounts.map {
["amount": $0, "id": "009a1f293253e41e", "secret": "s", "C": "02c"] as [String: Any]
}
] as [String: Any]
}
]
if let unit { json["unit"] = unit }
if let memo { json["memo"] = memo }
let data = try! JSONSerialization.data(withJSONObject: json)
return "cashuA" + base64URL(data)
}
/// Tiny deterministic CBOR encoder (definite lengths only) for building
/// V4 test tokens without depending on the decoder under test.
private enum CBOREncode {
static func head(_ major: UInt8, _ value: UInt64) -> [UInt8] {
switch value {
case 0...23:
return [(major << 5) | UInt8(value)]
case 24...0xFF:
return [(major << 5) | 24, UInt8(value)]
case 0x100...0xFFFF:
return [(major << 5) | 25, UInt8(value >> 8), UInt8(value & 0xFF)]
default:
return [(major << 5) | 26,
UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF),
UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)]
}
}
static func uint(_ v: UInt64) -> [UInt8] { head(0, v) }
static func bytes(_ b: [UInt8]) -> [UInt8] { head(2, UInt64(b.count)) + b }
static func text(_ s: String) -> [UInt8] {
let utf8 = Array(s.utf8)
return head(3, UInt64(utf8.count)) + utf8
}
static func array(_ items: [[UInt8]]) -> [UInt8] {
head(4, UInt64(items.count)) + items.flatMap { $0 }
}
static func map(_ pairs: [(String, [UInt8])]) -> [UInt8] {
head(5, UInt64(pairs.count)) + pairs.flatMap { text($0.0) + $0.1 }
}
}
private func makeV4Token(
mint: String = "https://mint.example.com",
unit: String = "sat",
memo: String? = nil,
amounts: [UInt64] = [1, 4]
) -> String {
var pairs: [(String, [UInt8])] = [
("m", CBOREncode.text(mint)),
("u", CBOREncode.text(unit))
]
if let memo { pairs.append(("d", CBOREncode.text(memo))) }
let proofs = amounts.map { amount in
CBOREncode.map([
("a", CBOREncode.uint(amount)),
("s", CBOREncode.text("secret")),
("c", CBOREncode.bytes([0x02, 0xAB, 0xCD]))
])
}
pairs.append(("t", CBOREncode.array([
CBOREncode.map([
("i", CBOREncode.bytes([0x00, 0xAD, 0x26, 0x8C])),
("p", CBOREncode.array(proofs))
])
])))
return "cashuB" + base64URL(Data(CBOREncode.map(pairs)))
}
// MARK: - V3 Decode
@Test func v3DecodeValidToken() {
let token = makeV3Token(
entries: [("https://mint.example.com", [2, 8])],
unit: "sat",
memo: "thanks!"
)
let info = CashuTokenDecoder.decode(token)
#expect(info != nil)
#expect(info?.version == "A")
#expect(info?.amount == 10)
#expect(info?.unit == "sat")
#expect(info?.mintHost == "mint.example.com")
#expect(info?.memo == "thanks!")
#expect(info?.displayAmount == "10 sat")
}
@Test func v3AmountSumsAcrossEntriesAndProofs() {
let token = makeV3Token(entries: [
("https://a.mint.example", [1, 2, 4]),
("https://b.mint.example", [8, 16])
])
let info = CashuTokenDecoder.decode(token)
#expect(info?.amount == 31)
// First mint wins for the display host
#expect(info?.mintHost == "a.mint.example")
}
@Test func v3MissingUnitDefaultsToSatForDisplay() {
let token = makeV3Token(entries: [("https://mint.example.com", [5])], unit: nil)
let info = CashuTokenDecoder.decode(token)
#expect(info?.unit == nil)
#expect(info?.displayAmount == "5 sat")
}
@Test func v3RejectsNonsenseAmounts() {
// Negative and absurd amounts must not poison the sum
let json: [String: Any] = [
"token": [[
"mint": "https://mint.example.com",
"proofs": [
["amount": -5, "id": "x", "secret": "s", "C": "c"],
["amount": 3, "id": "x", "secret": "s", "C": "c"]
]
] as [String: Any]]
]
let token = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: json))
#expect(CashuTokenDecoder.decode(token)?.amount == 3)
}
@Test func v3MemoIsSanitizedForDisplay() {
let token = makeV3Token(
entries: [("https://mint.example.com", [1])],
memo: "line1\nline2\u{0007}" + String(repeating: "x", count: 300)
)
let memo = CashuTokenDecoder.decode(token)?.memo
#expect(memo != nil)
#expect(memo?.contains("\n") == false)
#expect(memo?.contains("\u{0007}") == false)
#expect((memo?.count ?? 0) <= 80)
}
// MARK: - V4 (CBOR) Decode
@Test func v4DecodeValidToken() {
let token = makeV4Token(memo: "Thank you", amounts: [1, 4, 16])
let info = CashuTokenDecoder.decode(token)
#expect(info?.version == "B")
#expect(info?.amount == 21)
#expect(info?.unit == "sat")
#expect(info?.mintHost == "mint.example.com")
#expect(info?.memo == "Thank you")
}
@Test func v4UnparseableCBORDegradesToGenericToken() {
// Valid base64 payload, but not CBOR we can walk: still a token,
// rendered as a generic chip with no amount.
let token = "cashuB" + base64URL(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02]))
let info = CashuTokenDecoder.decode(token)
#expect(info?.version == "B")
#expect(info?.amount == nil)
#expect(info?.mintHost == nil)
}
// MARK: - Strict Mode (used by the /pay SEND path)
@Test func strictAcceptsValidV3WithPositiveAmount() {
let token = makeV3Token(entries: [("https://mint.example.com", [2, 8])])
let info = CashuTokenDecoder.decode(token, strict: true)
#expect(info?.version == "A")
#expect(info?.amount == 10)
}
@Test func strictAcceptsValidDefiniteLengthV4() {
let token = makeV4Token(amounts: [1, 4, 16])
let info = CashuTokenDecoder.decode(token, strict: true)
#expect(info?.version == "B")
#expect(info?.amount == 21)
}
@Test func strictRejectsUnwalkableV4() {
// Valid base64, but not CBOR we can walk: permissive mode returns a
// generic chip, strict mode refuses it.
let token = "cashuB" + base64URL(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02]))
#expect(CashuTokenDecoder.decode(token)?.version == "B")
#expect(CashuTokenDecoder.decode(token, strict: true) == nil)
}
@Test func strictRejectsTruncatedV4() {
let token = makeV4Token(amounts: [1, 4, 16])
// Lop off the tail of the base64 payload CBOR can no longer be walked.
let truncated = String(token.prefix(token.count - 12))
#expect(CashuTokenDecoder.decode(truncated, strict: true) == nil)
}
@Test func strictRejectsAmountlessToken() {
// A well-formed V3 token that carries no positive proof amount.
let json: [String: Any] = [
"token": [[
"mint": "https://mint.example.com",
"proofs": [["amount": 0, "id": "x", "secret": "s", "C": "c"] as [String: Any]]
] as [String: Any]]
]
let token = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: json))
#expect(CashuTokenDecoder.decode(token)?.amount == nil)
#expect(CashuTokenDecoder.decode(token, strict: true) == nil)
}
// MARK: - URI Form and Normalization
@Test func uriFormsDecode() {
let token = makeV3Token(entries: [("https://mint.example.com", [7])])
for wrapped in ["cashu:\(token)", "cashu://\(token)", "CASHU:\(token)"] {
#expect(CashuTokenDecoder.bareToken(from: wrapped) == token, "failed for \(wrapped)")
#expect(CashuTokenDecoder.decode(wrapped)?.amount == 7)
}
}
@Test func percentEncodedURIDecodes() {
let token = makeV3Token(entries: [("https://mint.example.com", [7])])
let encoded = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
#expect(CashuTokenDecoder.decode("cashu:\(encoded)")?.amount == 7)
}
@Test func bareTokenRejectsNonTokens() {
#expect(CashuTokenDecoder.bareToken(from: "hello world") == nil)
#expect(CashuTokenDecoder.bareToken(from: "cashuC" + String(repeating: "a", count: 50)) == nil)
#expect(CashuTokenDecoder.bareToken(from: "cashuA{not-base64!}") == nil)
#expect(CashuTokenDecoder.bareToken(from: "cashuA") == nil) // too short
}
// MARK: - Adversarial Input (never crash, fail closed)
@Test func truncatedTokensNeverCrash() {
let v3 = makeV3Token(entries: [("https://mint.example.com", [1, 2, 4, 8])], memo: "memo")
let v4 = makeV4Token(memo: "memo", amounts: [1, 2, 4, 8])
for token in [v3, v4] {
for length in stride(from: 0, to: token.count, by: 3) {
_ = CashuTokenDecoder.decode(String(token.prefix(length)))
}
}
// Truncating the payload must not produce a phantom V3 summary
#expect(CashuTokenDecoder.decode(String(v3.prefix(v3.count - 10))) == nil)
}
@Test func garbagePayloadsNeverCrash() {
var rng = SystemRandomNumberGenerator()
for _ in 0..<200 {
let length = Int.random(in: 0..<600, using: &rng)
let junk = Data((0..<length).map { _ in UInt8.random(in: 0...255, using: &rng) })
_ = CashuTokenDecoder.decode("cashuA" + base64URL(junk))
_ = CashuTokenDecoder.decode("cashuB" + base64URL(junk))
}
}
@Test func hugeInputIsRejectedQuickly() {
let huge = "cashuA" + String(repeating: "Q", count: 500_000)
#expect(CashuTokenDecoder.bareToken(from: huge) == nil)
#expect(CashuTokenDecoder.decode(huge) == nil)
}
@Test func absurdAmountsFailClosed() {
// Each proof exceeds the per-proof sanity cap: skipped, no amount.
let perProofJSON: [String: Any] = [
"token": [[
"mint": "https://mint.example.com",
"proofs": [["amount": Int64.max / 2, "id": "x", "secret": "s", "C": "c"] as [String: Any]]
] as [String: Any]]
]
let perProofToken = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: perProofJSON))
#expect(CashuTokenDecoder.decode(perProofToken)?.amount == nil)
// Individually plausible proofs whose *sum* overflows the cap: the
// token is nonsense, reject it entirely (never trap on the add).
let sumJSON: [String: Any] = [
"token": [[
"mint": "https://mint.example.com",
"proofs": (0..<3).map { _ in
["amount": 1_500_000_000_000_000, "id": "x", "secret": "s", "C": "c"] as [String: Any]
}
] as [String: Any]]
]
let sumToken = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: sumJSON))
#expect(CashuTokenDecoder.decode(sumToken) == nil)
}
@Test func deeplyNestedCBORIsBounded() {
// 64 nested single-element arrays around an int: deeper than the
// reader's depth cap, must fail cleanly.
var payload = CBOREncode.uint(1)
for _ in 0..<64 { payload = CBOREncode.array([payload]) }
let token = "cashuB" + base64URL(Data(payload))
let info = CashuTokenDecoder.decode(token)
#expect(info?.amount == nil)
}
// MARK: - Detection Ranges (message scanning)
@Test func detectionFindsWholeMessageToken() {
let token = makeV3Token(entries: [("https://mint.example.com", [1])])
#expect(token.extractCashuLinks() == [token])
}
@Test func detectionFindsEmbeddedAndURITokens() {
let token = makeV3Token(entries: [("https://mint.example.com", [1])])
let message = "here you go: cashu:\(token) enjoy!"
// The regex matches the token embedded after the scheme
#expect(message.extractCashuLinks() == [token])
let embedded = "prefix \(token) suffix"
#expect(embedded.extractCashuLinks() == [token])
}
@Test func detectionDeduplicatesRepeatedTokens() {
let token = makeV3Token(entries: [("https://mint.example.com", [1])])
let message = "\(token) and again \(token)"
#expect(message.extractCashuLinks() == [token])
}
@Test func detectionIgnoresNonTokens() {
#expect("just talking about cashu here".extractCashuLinks().isEmpty)
#expect("cashuAshort".extractCashuLinks().isEmpty)
}
}
@@ -1,6 +1,9 @@
import BitFoundation
import CoreGraphics
import Foundation
import ImageIO
import Testing
import UniformTypeIdentifiers
#if os(iOS)
import UIKit
#else
@@ -58,6 +61,25 @@ struct ChatMediaPreparationTests {
#expect(prepared.packet.fileSize == UInt64(prepared.packet.content.count))
#expect(prepared.packet.encode() != nil)
}
/// A genuinely detailed photo must prepare to more than
/// `TransportConfig.wifiBulkMinPayloadBytes` (64 KiB); otherwise the Wi-Fi
/// bulk (AWDL) data plane is never offered in production because
/// `WifiBulkPolicy.shouldOffer` requires `payloadBytes > 64 KiB`.
/// Regression guard for the ~40 KB over-compression gap (PR #1385).
@Test
func prepareImagePacket_detailedImageExceedsWifiBulkThreshold() throws {
let sourceURL = try makeDetailedImageURL(dimension: 1200)
defer { try? FileManager.default.removeItem(at: sourceURL) }
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
defer { try? FileManager.default.removeItem(at: prepared.outputURL) }
// Comfortably above the 64 KiB Wi-Fi bulk offer threshold...
#expect(prepared.packet.content.count > TransportConfig.wifiBulkMinPayloadBytes)
// ...and still within the hard image cap for the BLE path.
#expect(prepared.packet.content.count <= FileTransferLimits.maxImageBytes)
}
}
private func makeTemporaryImageURL() throws -> URL {
@@ -87,6 +109,50 @@ private func makeTemporaryImageURL() throws -> URL {
return url
}
/// Builds a random-noise PNG. Noise is incompressible, so the JPEG the prep
/// pipeline produces stays large a faithful stand-in for a detailed photo,
/// unlike a flat solid-color image which would compress to a few KB regardless
/// of dimension.
private func makeDetailedImageURL(dimension: Int) throws -> URL {
let width = dimension
let height = dimension
let bytesPerPixel = 4
let bytesPerRow = width * bytesPerPixel
var pixels = [UInt8](repeating: 0, count: bytesPerRow * height)
for index in pixels.indices {
pixels[index] = UInt8.random(in: 0...255)
}
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: &pixels,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
), let cgImage = context.makeImage() else {
throw ChatMediaPreparationTestError.imageEncodingFailed
}
let url = FileManager.default.temporaryDirectory.appendingPathComponent("detailed-\(UUID().uuidString).png")
guard let destination = CGImageDestinationCreateWithURL(
url as CFURL,
UTType.png.identifier as CFString,
1,
nil
) else {
throw ChatMediaPreparationTestError.imageEncodingFailed
}
CGImageDestinationAddImage(destination, cgImage, nil)
guard CGImageDestinationFinalize(destination) else {
throw ChatMediaPreparationTestError.imageEncodingFailed
}
return url
}
private enum ChatMediaPreparationTestError: Error {
case imageEncodingFailed
}
@@ -71,7 +71,6 @@ private final class MockChatNostrContext: ChatNostrContext {
private(set) var hapticMessageIDs: [String] = []
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessages.append(message) }
func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { handledPublicMessages.append(message) }
func checkForMentions(_ message: BitchatMessage) { mentionCheckedMessageIDs.append(message.id) }
func sendHapticFeedback(for message: BitchatMessage) { hapticMessageIDs.append(message.id) }
func parseMentions(from content: String) -> [String] { [] }
@@ -192,9 +192,6 @@ struct ChatOutgoingCoordinatorContextTests {
context.isTeleported = true
coordinator.sendMessage("hello geo")
// Geohash sends mine a NIP-13 nonce tag off-main before echoing and
// sending; await the send task, then drain the main queue.
await coordinator.geohashMiningTask?.value
await drainMainActorTasks()
// Local echo carries the geohash sender suffix (#last-4-of-pubkey) and
@@ -218,35 +215,4 @@ struct ChatOutgoingCoordinatorContextTests {
#expect(context.appendedPublicMessages.count == 1)
#expect(context.sentGeohashContexts.count == 1)
}
@Test @MainActor
func sendMessage_onLocationChannel_serializesRapidSendsInSendOrder() async {
let context = MockChatOutgoingContext()
let coordinator = ChatOutgoingCoordinator(context: context)
let channel = GeohashChannel(level: .city, geohash: "u4pruydq")
context.activeChannel = .location(channel)
// Two back-to-back sends. The first carries much larger content, so
// its NIP-13 mining hashes a bigger event per attempt and runs longer
// than the second's. Without serialization the second (faster) task
// could finish first and reorder both the local timeline and the
// relayed events. The coordinator chains the mining tasks each send
// awaits the previous send's task before it echoes and relays so the
// visible order must always match the send order.
let first = "first " + String(repeating: "x", count: 4000)
let second = "second"
coordinator.sendMessage(first)
coordinator.sendMessage(second)
// The stored task is the second send, which awaits the first.
await coordinator.geohashMiningTask?.value
await drainMainActorTasks()
// Local echoes land in send order
#expect(context.appendedPublicMessages.map(\.message.content) == [first, second])
// and so do the relayed events (IDs match the echoes 1:1, in order).
#expect(context.sentGeohashContexts.count == 2)
#expect(context.sentGeohashContexts.map(\.event.id)
== context.appendedPublicMessages.map(\.message.id))
}
}
@@ -186,7 +186,7 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
// Inbound public message processing
var blockedMessageIDs: Set<String> = []
var rateLimitAllowed = true
private(set) var rateLimitChecks: [(senderKey: String, contentKey: String, powBits: Int)] = []
private(set) var rateLimitChecks: [(senderKey: String, contentKey: String)] = []
private(set) var enqueuedMessages: [(messageID: String, conversationID: ConversationID)] = []
var enqueuedMessageIDs: [String] { enqueuedMessages.map(\.messageID) }
var stablePeerIDs: [PeerID: PeerID] = [:]
@@ -199,8 +199,8 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
blockedMessageIDs.contains(message.id)
}
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool {
rateLimitChecks.append((senderKey, contentKey, powBits))
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool {
rateLimitChecks.append((senderKey, contentKey))
return rateLimitAllowed
}
@@ -156,24 +156,6 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
verifyResponsePayloads.append((peerID, payload))
}
private(set) var vouchPayloads: [(peerID: PeerID, payload: Data)] = []
func handleVouchPayload(from peerID: PeerID, payload: Data) {
vouchPayloads.append((peerID, payload))
}
// Group payloads
private(set) var groupInvitePayloads: [(peerID: PeerID, payload: Data)] = []
private(set) var groupKeyUpdatePayloads: [(peerID: PeerID, payload: Data)] = []
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
groupInvitePayloads.append((peerID, payload))
}
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
groupKeyUpdatePayloads.append((peerID, payload))
}
}
// MARK: - Helpers
@@ -1,285 +0,0 @@
//
// ChatVouchCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatVouchCoordinator` against a mock `ChatVouchContext`
// proving the exchange policy (verified + capable peers only, batch cap,
// 24h rate limit) and the accept policy (verified senders only, real
// Ed25519 signature verification, expiry) without a `ChatViewModel`.
// Storage-level gates (self-vouch, already-verified vouchee, per-vouchee
// cap) are covered by `SecureIdentityStateManagerVouchTests`.
//
import CryptoKit
import Foundation
import BitFoundation
import Testing
@testable import bitchat
// MARK: - Mock Context
@MainActor
private final class MockChatVouchContext: ChatVouchContext {
// Identity & trust state
var fingerprintsByPeerID: [PeerID: String] = [:]
var verifiedFingerprints: Set<String> = []
var signingKeysByFingerprint: [String: Data] = [:]
var recentVerified: [String] = []
private(set) var recentVerifiedRequests: [(limit: Int, excluding: String)] = []
private(set) var recordedVouches: [(vouchee: String, voucher: String, timestamp: Date)] = []
var recordVouchResult = true
var lastBatchSentAt: [String: Date] = [:]
private(set) var markedBatchSent: [(fingerprint: String, date: Date)] = []
func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
func isVerifiedFingerprint(_ fingerprint: String) -> Bool { verifiedFingerprints.contains(fingerprint) }
func signingKey(forFingerprint fingerprint: String) -> Data? { signingKeysByFingerprint[fingerprint] }
func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
recentVerifiedRequests.append((limit, fingerprint))
return Array(recentVerified.filter { $0 != fingerprint }.prefix(limit))
}
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
recordedVouches.append((voucheeFingerprint, voucherFingerprint, timestamp))
return recordVouchResult
}
func lastVouchBatchSent(to fingerprint: String) -> Date? { lastBatchSentAt[fingerprint] }
func markVouchBatchSent(to fingerprint: String, at date: Date) {
markedBatchSent.append((fingerprint, date))
lastBatchSentAt[fingerprint] = date
}
// Transport
var capabilitiesByPeerID: [PeerID: PeerCapabilities] = [:]
var mySigningKey = Curve25519.Signing.PrivateKey()
private(set) var installedObservers: [(PeerID, String) -> Void] = []
private(set) var sentVouchPayloads: [(payload: Data, peerID: PeerID)] = []
func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { capabilitiesByPeerID[peerID] ?? [] }
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
installedObservers.append(handler)
}
func noiseSignData(_ data: Data) -> Data? { try? mySigningKey.signature(for: data) }
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
sentVouchPayloads.append((payload, peerID))
}
// UI refresh
private(set) var trustChangedCount = 0
func notifyPeerTrustChanged() { trustChangedCount += 1 }
}
// MARK: - Tests
struct ChatVouchCoordinatorContextTests {
private let peerID = PeerID(str: "1122334455667788")
private let peerFingerprint = String(repeating: "0f", count: 32)
@MainActor
private func makeVerifiedCapablePeer() -> (MockChatVouchContext, ChatVouchCoordinator) {
let context = MockChatVouchContext()
let coordinator = ChatVouchCoordinator(context: context)
context.fingerprintsByPeerID[peerID] = peerFingerprint
context.verifiedFingerprints.insert(peerFingerprint)
context.capabilitiesByPeerID[peerID] = [.vouch]
return (context, coordinator)
}
// MARK: Exchange policy
@Test @MainActor
func peerAuthenticated_sendsBatchForVerifiedCapablePeer() throws {
let (context, coordinator) = makeVerifiedCapablePeer()
let vouchees = [String(repeating: "01", count: 32), String(repeating: "02", count: 32)]
context.recentVerified = vouchees
for vouchee in vouchees {
context.signingKeysByFingerprint[vouchee] = Data(repeating: 0x33, count: 32)
}
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
// Candidates are requested most-recent-first, excluding the target.
#expect(context.recentVerifiedRequests.count == 1)
#expect(context.recentVerifiedRequests.first?.limit == VouchAttestation.maxBatchCount)
#expect(context.recentVerifiedRequests.first?.excluding == peerFingerprint)
let sent = try #require(context.sentVouchPayloads.first)
#expect(sent.peerID == peerID)
let attestations = VouchAttestation.decodeList(from: sent.payload)
#expect(attestations.map(\.voucheeFingerprintHex) == vouchees)
// Every attestation carries a valid signature under our signing key.
let myPublicKey = context.mySigningKey.publicKey.rawRepresentation
#expect(attestations.allSatisfy { $0.verifySignature(voucherSigningKey: myPublicKey) })
// The rate limit is stamped only after an actual send.
#expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint])
}
@Test @MainActor
func peerAuthenticated_requiresVerificationAndCapability() {
let (context, coordinator) = makeVerifiedCapablePeer()
context.recentVerified = [String(repeating: "01", count: 32)]
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
// Not verified by me: nothing.
context.verifiedFingerprints.remove(peerFingerprint)
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
#expect(context.sentVouchPayloads.isEmpty)
// Verified but no .vouch capability: nothing.
context.verifiedFingerprints.insert(peerFingerprint)
context.capabilitiesByPeerID[peerID] = []
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
#expect(context.sentVouchPayloads.isEmpty)
#expect(context.markedBatchSent.isEmpty)
}
@Test @MainActor
func peerAuthenticated_rateLimitsPerPeerPer24Hours() {
let (context, coordinator) = makeVerifiedCapablePeer()
context.recentVerified = [String(repeating: "01", count: 32)]
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
let now = Date()
context.lastBatchSentAt[peerFingerprint] = now.addingTimeInterval(-60 * 60)
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint, now: now)
#expect(context.sentVouchPayloads.isEmpty)
// Once the interval has elapsed the batch goes out again.
context.lastBatchSentAt[peerFingerprint] = now.addingTimeInterval(-ChatVouchCoordinator.batchInterval - 1)
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint, now: now)
#expect(context.sentVouchPayloads.count == 1)
}
@Test @MainActor
func peerAuthenticated_skipsCandidatesWithoutSigningKeysAndEmptyBatches() {
let (context, coordinator) = makeVerifiedCapablePeer()
let withKey = String(repeating: "01", count: 32)
let withoutKey = String(repeating: "02", count: 32)
context.recentVerified = [withoutKey, withKey]
context.signingKeysByFingerprint[withKey] = Data(repeating: 0x33, count: 32)
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
let attestations = VouchAttestation.decodeList(from: context.sentVouchPayloads[0].payload)
#expect(attestations.map(\.voucheeFingerprintHex) == [withKey])
// No signable candidates at all: nothing is sent or rate-stamped.
let freshPeer = PeerID(str: "aabbccddeeff0011")
let freshFingerprint = String(repeating: "0e", count: 32)
context.fingerprintsByPeerID[freshPeer] = freshFingerprint
context.verifiedFingerprints.insert(freshFingerprint)
context.capabilitiesByPeerID[freshPeer] = [.vouch]
context.recentVerified = [withoutKey]
coordinator.peerAuthenticated(freshPeer, fingerprint: freshFingerprint)
#expect(context.sentVouchPayloads.count == 1)
#expect(!context.markedBatchSent.contains { $0.fingerprint == freshFingerprint })
}
// MARK: Accept policy
@MainActor
private func makeInboundBatch(
signedBy key: Curve25519.Signing.PrivateKey,
vouchee: String = String(repeating: "07", count: 32),
timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000)
) throws -> Data {
let voucheeData = try #require(Data(hexString: vouchee))
let attestation = try #require(VouchAttestation.build(
voucheeFingerprint: voucheeData,
voucheeSigningKey: Data(repeating: 0x44, count: 32),
timestampMs: timestampMs,
sign: { try? key.signature(for: $0) }
))
return try #require(VouchAttestation.encodeList([attestation]))
}
@Test @MainActor
func handleVouchPayload_acceptsValidVouchFromVerifiedSender() throws {
let (context, coordinator) = makeVerifiedCapablePeer()
let senderKey = Curve25519.Signing.PrivateKey()
context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation
let vouchee = String(repeating: "07", count: 32)
let payload = try makeInboundBatch(signedBy: senderKey, vouchee: vouchee)
coordinator.handleVouchPayload(from: peerID, payload: payload)
#expect(context.recordedVouches.count == 1)
#expect(context.recordedVouches.first?.vouchee == vouchee)
#expect(context.recordedVouches.first?.voucher == peerFingerprint)
#expect(context.trustChangedCount == 1)
}
@Test @MainActor
func handleVouchPayload_rejectsUnverifiedOrUnknownSender() throws {
let (context, coordinator) = makeVerifiedCapablePeer()
let senderKey = Curve25519.Signing.PrivateKey()
context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation
let payload = try makeInboundBatch(signedBy: senderKey)
// Sender's fingerprint is not in my verified set.
context.verifiedFingerprints.remove(peerFingerprint)
coordinator.handleVouchPayload(from: peerID, payload: payload)
#expect(context.recordedVouches.isEmpty)
// Unknown peer entirely.
coordinator.handleVouchPayload(from: PeerID(str: "ffeeddccbbaa9988"), payload: payload)
#expect(context.recordedVouches.isEmpty)
#expect(context.trustChangedCount == 0)
}
@Test @MainActor
func handleVouchPayload_rejectsForgedSignaturesAndExpiredAttestations() throws {
let (context, coordinator) = makeVerifiedCapablePeer()
let senderKey = Curve25519.Signing.PrivateKey()
context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation
// Signed by an imposter key: signature check against the sender's
// announce-bound key fails.
let imposter = Curve25519.Signing.PrivateKey()
let forged = try makeInboundBatch(signedBy: imposter)
coordinator.handleVouchPayload(from: peerID, payload: forged)
#expect(context.recordedVouches.isEmpty)
// Correctly signed but expired.
let staleMs = UInt64(Date().addingTimeInterval(-31 * 24 * 60 * 60).timeIntervalSince1970 * 1000)
let expired = try makeInboundBatch(signedBy: senderKey, timestampMs: staleMs)
coordinator.handleVouchPayload(from: peerID, payload: expired)
#expect(context.recordedVouches.isEmpty)
#expect(context.trustChangedCount == 0)
// No signing key known for the sender: batch dropped.
context.signingKeysByFingerprint.removeValue(forKey: peerFingerprint)
let valid = try makeInboundBatch(signedBy: senderKey)
coordinator.handleVouchPayload(from: peerID, payload: valid)
#expect(context.recordedVouches.isEmpty)
}
@Test @MainActor
func handleVouchPayload_skipsUIRefreshWhenNothingStored() throws {
let (context, coordinator) = makeVerifiedCapablePeer()
let senderKey = Curve25519.Signing.PrivateKey()
context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation
context.recordVouchResult = false // e.g. self-vouch dropped by the store
let payload = try makeInboundBatch(signedBy: senderKey)
coordinator.handleVouchPayload(from: peerID, payload: payload)
#expect(context.recordedVouches.count == 1)
#expect(context.trustChangedCount == 0)
}
@Test @MainActor
func setupNoiseCallbacks_installsAdditiveObserver() {
let (context, coordinator) = makeVerifiedCapablePeer()
coordinator.setupNoiseCallbacks()
#expect(context.installedObservers.count == 1)
}
}
-214
View File
@@ -370,173 +370,6 @@ struct CommandProcessorTests {
}
}
// MARK: - /pay
@MainActor
@Test func payWithoutArgumentsPrintsUsage() {
let processor = makePayProcessor(context: MockCommandContextProvider())
switch processor.process("/pay") {
case .success(let message):
#expect(message?.contains("usage: /pay") == true)
default:
Issue.record("Expected success (usage) result")
}
}
@MainActor
@Test func payRejectsInvalidToken() {
let context = MockCommandContextProvider()
let processor = makePayProcessor(context: context)
for bad in ["/pay nonsense", "/pay cashuAshort", "/pay cashuA!!!!!!!!!!!!!!!!"] {
switch processor.process(bad) {
case .error:
break
default:
Issue.record("Expected error for \(bad)")
}
}
#expect(context.sentPrivateMessages.isEmpty)
#expect(context.sentPublicMessages.isEmpty)
}
@MainActor
@Test func paySendsBareTokenInPrivateChat() {
let context = MockCommandContextProvider()
let peerID = PeerID(str: "abcd1234abcd1234")
context.selectedPrivateChatPeer = peerID
let processor = makePayProcessor(context: context)
// cashu: URI form must be normalized to the bare token before sending
switch processor.process("/pay cashu:\(Self.validV3Token)") {
case .success(let message):
#expect(message?.contains("21 sat") == true)
default:
Issue.record("Expected success result")
}
#expect(context.sentPrivateMessages.count == 1)
#expect(context.sentPrivateMessages.first?.content == Self.validV3Token)
#expect(context.sentPrivateMessages.first?.peerID == peerID)
#expect(context.sentPublicMessages.isEmpty)
}
@MainActor
@Test func payInPublicChannelRequiresExplicitConfirm() {
let context = MockCommandContextProvider()
let processor = makePayProcessor(context: context)
switch processor.process("/pay \(Self.validV3Token)") {
case .error(let message):
#expect(message.contains("public") == true)
default:
Issue.record("Expected error without confirm")
}
#expect(context.sentPublicMessages.isEmpty)
switch processor.process("/pay \(Self.validV3Token) public") {
case .success:
break
default:
Issue.record("Expected success with confirm")
}
#expect(context.sentPublicMessages == [Self.validV3Token])
#expect(context.sentPrivateMessages.isEmpty)
}
@MainActor
@Test func payRejectsTruncatedOrJunkV4Token() {
let context = MockCommandContextProvider()
context.selectedPrivateChatPeer = PeerID(str: "abcd1234abcd1234")
let processor = makePayProcessor(context: context)
// Truncated V4 (definite-length CBOR can no longer be walked) and
// pure base64 junk under the cashuB prefix must both be refused.
let truncatedV4 = String(Self.validV4Token.prefix(Self.validV4Token.count - 12))
let junkV4 = "cashuB" + String(repeating: "Q", count: 40)
for bad in ["/pay \(truncatedV4)", "/pay \(junkV4)"] {
switch processor.process(bad) {
case .error(let message):
#expect(message.contains("invalid cashu token") == true)
default:
Issue.record("Expected error for \(bad)")
}
}
#expect(context.sentPrivateMessages.isEmpty)
#expect(context.sentPublicMessages.isEmpty)
}
@MainActor
@Test func paySendsValidDefiniteLengthV4Token() {
let context = MockCommandContextProvider()
let peerID = PeerID(str: "abcd1234abcd1234")
context.selectedPrivateChatPeer = peerID
let processor = makePayProcessor(context: context)
switch processor.process("/pay \(Self.validV4Token)") {
case .success(let message):
#expect(message?.contains("21 sat") == true)
default:
Issue.record("Expected success result for valid V4 token")
}
#expect(context.sentPrivateMessages.count == 1)
#expect(context.sentPrivateMessages.first?.content == Self.validV4Token)
}
/// 21-sat single-mint V3 token (proofs of 1+4+16).
private static let validV3Token: String = {
let json: [String: Any] = [
"token": [[
"mint": "https://mint.example.com",
"proofs": [1, 4, 16].map { ["amount": $0, "id": "009a1f293253e41e", "secret": "s", "C": "02c"] }
]],
"unit": "sat"
]
let data = try! JSONSerialization.data(withJSONObject: json)
let b64 = data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
return "cashuA" + b64
}()
/// 21-sat single-mint definite-length V4 (CBOR) token (proofs of 1+4+16).
private static let validV4Token: String = {
func head(_ major: UInt8, _ value: UInt64) -> [UInt8] {
switch value {
case 0...23: return [(major << 5) | UInt8(value)]
case 24...0xFF: return [(major << 5) | 24, UInt8(value)]
default: return [(major << 5) | 25, UInt8(value >> 8), UInt8(value & 0xFF)]
}
}
func text(_ s: String) -> [UInt8] { head(3, UInt64(s.utf8.count)) + Array(s.utf8) }
func bytes(_ b: [UInt8]) -> [UInt8] { head(2, UInt64(b.count)) + b }
func uint(_ v: UInt64) -> [UInt8] { head(0, v) }
func array(_ items: [[UInt8]]) -> [UInt8] { head(4, UInt64(items.count)) + items.flatMap { $0 } }
func map(_ pairs: [(String, [UInt8])]) -> [UInt8] { head(5, UInt64(pairs.count)) + pairs.flatMap { text($0.0) + $0.1 } }
let proofs = [UInt64(1), 4, 16].map { amount in
map([("a", uint(amount)), ("s", text("secret")), ("c", bytes([0x02, 0xAB, 0xCD]))])
}
let cbor = map([
("m", text("https://mint.example.com")),
("u", text("sat")),
("t", array([map([("i", bytes([0x00, 0xAD, 0x26, 0x8C])), ("p", array(proofs))])]))
])
let b64 = Data(cbor).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
return "cashuB" + b64
}()
@MainActor
private func makePayProcessor(context: MockCommandContextProvider) -> CommandProcessor {
CommandProcessor(
contextProvider: context,
meshService: MockTransport(),
identityManager: MockIdentityManager(MockKeychain())
)
}
@MainActor
private func withSelectedChannel<T>(
_ channel: ChannelID,
@@ -602,8 +435,6 @@ private final class MockCommandContextProvider: CommandContextProvider {
private(set) var sentPublicRawMessages: [String] = []
private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = []
private(set) var publicSystemMessages: [String] = []
private(set) var commandOutputs: [String] = []
private(set) var commandOutputDestinations: [CommandOutputDestination] = []
private(set) var toggledFavorites: [PeerID] = []
private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
@@ -646,11 +477,6 @@ private final class MockCommandContextProvider: CommandContextProvider {
sentPublicRawMessages.append(content)
}
private(set) var sentPublicMessages: [String] = []
func sendPublicMessage(_ content: String) {
sentPublicMessages.append(content)
}
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {
localPrivateSystemMessages.append((content, peerID))
}
@@ -659,18 +485,6 @@ private final class MockCommandContextProvider: CommandContextProvider {
publicSystemMessages.append(content)
}
func currentCommandDestination() -> CommandOutputDestination {
if let peerID = selectedPrivateChatPeer {
return .privateChat(peerID)
}
return .meshTimeline
}
func addCommandOutput(_ content: String, to destination: CommandOutputDestination) {
commandOutputs.append(content)
commandOutputDestinations.append(destination)
}
func toggleFavorite(peerID: PeerID) {
toggledFavorites.append(peerID)
}
@@ -678,32 +492,4 @@ private final class MockCommandContextProvider: CommandContextProvider {
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
favoriteNotifications.append((peerID, isFavorite))
}
// Groups: record the parsed subcommand + argument the processor forwarded.
private(set) var groupCommands: [(subcommand: String, argument: String)] = []
func groupCreate(named name: String) -> CommandResult {
groupCommands.append(("create", name))
return .handled
}
func groupInvite(nickname: String) -> CommandResult {
groupCommands.append(("invite", nickname))
return .handled
}
func groupRemove(nickname: String) -> CommandResult {
groupCommands.append(("remove", nickname))
return .handled
}
func groupLeave() -> CommandResult {
groupCommands.append(("leave", ""))
return .handled
}
func groupList() -> CommandResult {
groupCommands.append(("list", ""))
return .handled
}
}
@@ -1,399 +0,0 @@
//
// PrekeyEndToEndTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import CoreBluetooth
import BitFoundation
@testable import bitchat
/// Forward-secret courier flow through real BLEService instances: Bob gossips
/// a signed prekey bundle, Alice verifies and caches it, seals to a one-time
/// prekey instead of Bob's static key, Carol carries the opaque envelope, and
/// Bob opens it with the matching prekey private.
struct PrekeyEndToEndTests {
// MARK: - Helpers
private final class PacketTap {
private let lock = NSLock()
private var packets: [BitchatPacket] = []
func record(_ packet: BitchatPacket) {
lock.lock(); packets.append(packet); lock.unlock()
}
func first(ofType type: MessageType) -> BitchatPacket? {
lock.lock(); defer { lock.unlock() }
return packets.first { $0.type == type.rawValue }
}
}
private final class NoiseCaptureDelegate: BitchatDelegate {
private let lock = NSLock()
private var payloads: [(peerID: PeerID, type: NoisePayloadType, payload: Data)] = []
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
lock.lock(); payloads.append((peerID, type, payload)); lock.unlock()
}
func snapshot() -> [(peerID: PeerID, type: NoisePayloadType, payload: Data)] {
lock.lock(); defer { lock.unlock() }
return payloads
}
// Unused BitchatDelegate requirements.
func didReceiveMessage(_ message: BitchatMessage) {}
func didConnectToPeer(_ peerID: PeerID) {}
func didDisconnectFromPeer(_ peerID: PeerID) {}
func didUpdatePeerList(_ peers: [PeerID]) {}
func didUpdateBluetoothState(_ state: CBManagerState) {}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
}
private func makeService() -> BLEService {
let keychain = MockKeychain()
let service = BLEService(
keychain: keychain,
idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()),
identityManager: MockIdentityManager(keychain),
initializeBluetoothManagers: false
)
service.courierStore = CourierStore(persistsToDisk: false)
service.prekeyBundleStore = PrekeyBundleStore(persistsToDisk: false)
return service
}
private func preseedConnectedPeer(_ peer: BLEService, in service: BLEService) {
let packet = BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: peer.myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data("ping".utf8),
signature: nil,
ttl: 1
)
service._test_handlePacket(packet, fromPeerID: peer.myPeerID)
}
/// Broadcast announce + prekey bundle from `peer` and return both packets.
private func captureAnnounceAndBundle(from peer: BLEService, tap: PacketTap) async throws -> (announce: BitchatPacket, bundle: BitchatPacket) {
peer.sendBroadcastAnnounce()
let published = await TestHelpers.waitUntil(
{ tap.first(ofType: .announce) != nil && tap.first(ofType: .prekeyBundle) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(published)
return (
announce: try #require(tap.first(ofType: .announce)),
bundle: try #require(tap.first(ofType: .prekeyBundle))
)
}
// MARK: - Tests
@Test func prekeySealedMailTravelsViaCourierAndOpens() async throws {
let alice = makeService()
let carol = makeService()
let bob = makeService()
carol.courierDepositPolicy = { _, _ in .favorite }
let bobDelegate = NoiseCaptureDelegate()
bob.delegate = bobDelegate
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
let carolOut = PacketTap()
carol._test_onOutboundPacket = carolOut.record
let bobOut = PacketTap()
bob._test_onOutboundPacket = bobOut.record
preseedConnectedPeer(carol, in: alice)
// 1. While Bob is still around, Alice hears his verified announce
// (binding his signing key) and his gossiped prekey bundle.
let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false)
alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.defaultTimeout
)
#expect(cached)
// 2. Bob goes dark; Alice seals for him and deposits with Carol.
// The envelope must be v2: sealed to a one-time prekey.
#expect(alice.sendCourierMessage(
"burn after reading",
messageID: "prekey-msg-1",
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
via: [carol.myPeerID]
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
let sealedEnvelope = try #require(CourierEnvelope.decode(depositPacket.payload))
#expect(sealedEnvelope.prekeyID != nil)
// 3. Carol carries it (opaque, prekey or not).
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(carried)
// 4. Bob resurfaces near Carol handover, and the v2 discriminator
// survives the store round-trip.
bob.sendBroadcastAnnounce()
let reannounced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(reannounced)
let handoverTrigger = try #require(bobOut.first(ofType: .announce))
carol._test_handlePacket(handoverTrigger, fromPeerID: bob.myPeerID, preseedPeer: false)
let handedOver = await TestHelpers.waitUntil(
{ carolOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(handedOver)
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
let handedEnvelope = try #require(CourierEnvelope.decode(handoverPacket.payload))
#expect(handedEnvelope.prekeyID == sealedEnvelope.prekeyID)
// 5. Bob opens it with the matching one-time prekey private and sees
// Alice as the authenticated sender.
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
let received = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(received)
let delivered = try #require(bobDelegate.snapshot().first)
#expect(delivered.type == .privateMessage)
#expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData()))
let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
#expect(message.messageID == "prekey-msg-1")
#expect(message.content == "burn after reading")
// 6. Redelivery tolerance: the same envelope arriving via another
// packet (spray-and-wait) still opens inside the grace window.
let redelivery = BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
senderID: Data(hexString: carol.myPeerID.id) ?? Data(),
recipientID: handoverPacket.recipientID,
timestamp: handoverPacket.timestamp + 1,
payload: handoverPacket.payload,
signature: nil,
ttl: 1
)
bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID)
let redelivered = await TestHelpers.waitUntil(
{ bobDelegate.snapshot().count == 2 },
timeout: TestConstants.defaultTimeout
)
#expect(redelivered)
}
@Test func withoutBundleSealingFallsBackToStatic() async throws {
let alice = makeService()
let bob = makeService()
let bobDelegate = NoiseCaptureDelegate()
bob.delegate = bobDelegate
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
// Bob is a connected "courier" who happens to be the recipient: the
// envelope reaches him directly and the recipient tag matches.
preseedConnectedPeer(bob, in: alice)
// Alice never saw a bundle for Bob v1 static-sealed envelope.
#expect(alice.sendCourierMessage(
"plain static seal",
messageID: "static-msg-1",
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
via: [bob.myPeerID]
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
let envelope = try #require(CourierEnvelope.decode(depositPacket.payload))
#expect(envelope.prekeyID == nil)
// Bob opens the v1 envelope exactly as before the prekey change.
// (No preseed: Alice is absent from Bob's mesh, so the sender should
// resolve to her full noise-key ID like the courier case.)
bob._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, preseedPeer: false)
let received = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(received)
let delivered = try #require(bobDelegate.snapshot().first)
#expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData()))
let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
#expect(message.content == "plain static seal")
}
@Test func unverifiableBundleIsIgnored() async throws {
let alice = makeService()
let bob = makeService()
let bobOut = PacketTap()
bob._test_onOutboundPacket = bobOut.record
// Alice receives Bob's bundle but never saw a verified announce, so
// no signing key is bound to his noise key: the bundle must not be
// cached or enter Alice's gossip store.
let (_, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.shortTimeout
)
#expect(!cached)
}
@Test func forgedBundleSignatureIsRejected() async throws {
let alice = makeService()
let bob = makeService()
let bobOut = PacketTap()
bob._test_onOutboundPacket = bobOut.record
let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false)
// Mallory tampers with the gossiped bundle in flight.
let bundle = try #require(PrekeyBundle.decode(bundlePacket.payload))
var forgedSignature = bundle.signature
forgedSignature[0] ^= 0x01
let forged = PrekeyBundle(
noiseStaticPublicKey: bundle.noiseStaticPublicKey,
prekeys: bundle.prekeys,
generatedAt: bundle.generatedAt,
signature: forgedSignature
)
let forgedPacket = BitchatPacket(
type: MessageType.prekeyBundle.rawValue,
senderID: bundlePacket.senderID,
recipientID: nil,
timestamp: bundlePacket.timestamp,
payload: try #require(forged.encode()),
signature: bundlePacket.signature,
ttl: bundlePacket.ttl
)
alice._test_handlePacket(forgedPacket, fromPeerID: bob.myPeerID, preseedPeer: false)
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.shortTimeout
)
#expect(!cached)
}
@Test func verifiedBundleEntersGossipStore() async throws {
let alice = makeService()
let bob = makeService()
let bobOut = PacketTap()
bob._test_onOutboundPacket = bobOut.record
let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false)
alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.defaultTimeout
)
#expect(cached)
// The verified bundle now participates in Alice's sync rounds.
#expect(alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
}
@Test func spoofedSenderPrekeyBundleIsRejected() async throws {
let alice = makeService()
let bob = makeService()
let bobOut = PacketTap()
bob._test_onOutboundPacket = bobOut.record
let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false)
// A relay re-broadcasts Bob's genuine bundle under a fabricated sender
// ID (the DoS that would multiply cache/gossip entries and exhaust the
// per-owner cap). Attribution is by the bundle's own key and the outer
// signature is bound to Bob's sender ID, so the spoof is dropped no
// cache entry, and no gossip entry under either the fake or real ID.
let fakeSender = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
let spoofed = BitchatPacket(
type: MessageType.prekeyBundle.rawValue,
senderID: fakeSender,
recipientID: nil,
timestamp: bundlePacket.timestamp + 5_000,
payload: bundlePacket.payload,
signature: bundlePacket.signature,
ttl: bundlePacket.ttl
)
alice._test_handlePacket(spoofed, fromPeerID: PeerID(hexData: fakeSender), preseedPeer: false)
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.shortTimeout
)
#expect(!cached)
#expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
#expect(!alice._test_hasGossipPrekeyBundle(for: PeerID(hexData: fakeSender)))
}
@Test func replayedPrekeyBundleWithFreshTimestampIsRejected() async throws {
let alice = makeService()
let bob = makeService()
let bobOut = PacketTap()
bob._test_onOutboundPacket = bobOut.record
let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false)
// Rewriting the outer timestamp (to defeat the freshness window)
// invalidates the packet signature, which covers senderID + timestamp.
let replay = BitchatPacket(
type: MessageType.prekeyBundle.rawValue,
senderID: bundlePacket.senderID,
recipientID: nil,
timestamp: bundlePacket.timestamp + 5_000,
payload: bundlePacket.payload,
signature: bundlePacket.signature,
ttl: bundlePacket.ttl
)
alice._test_handlePacket(replay, fromPeerID: bob.myPeerID, preseedPeer: false)
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.shortTimeout
)
#expect(!cached)
#expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
}
}

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