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
124 changed files with 474 additions and 16520 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
}
+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() })
+1 -34
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", 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)
+1 -40
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 {
-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
+6 -25
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:
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .group:
return "<" + String(localized: "content.input.group_placeholder") + ">"
case .pay:
return "<" + String(localized: "content.input.token_placeholder") + ">"
case .clear, .help, .who:
return nil
}
@@ -50,35 +42,24 @@ enum CommandInfo: String, Identifiable {
switch self {
case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear")
case .group: String(localized: "content.commands.group")
case .help: String(localized: "content.commands.help")
case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message")
case .pay: String(localized: "content.commands.pay")
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")
case .ping: String(localized: "content.commands.ping")
case .trace: String(localized: "content.commands.trace")
}
}
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)
}
}
-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
)
+19 -749
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 {
@@ -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")
}
}
+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'?
+1 -108
View File
@@ -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: ",")
}
}
@@ -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", comment: "Usage hint for /group create"))
}
guard name.count <= Self.maxGroupNameLength else {
return .error(message: String(localized: "system.group.name_too_long", 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", 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", comment: "Error when group creation fails"))
}
context.startPrivateChat(with: group.peerID)
return .success(message: String(
format: String(localized: "system.group.created", 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", comment: "Usage hint for /group invite"))
}
guard let group = selectedGroup() else {
return .error(message: String(localized: "system.group.not_in_group", 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", 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", 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", 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", 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", 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", 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", 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", 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", comment: "Usage hint for /group remove"))
}
guard let group = selectedGroup() else {
return .error(message: String(localized: "system.group.not_in_group", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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", 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,
+91 -100
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", 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(
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) {
@@ -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()
@@ -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)
}
@@ -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
}
}
-172
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(
@@ -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
}
@@ -278,27 +238,6 @@ struct AppInfoView: View {
}
}
// 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("app_info.network.topology.hint"))
}
}
// Privacy
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Privacy.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
}()
}
+14 -156
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,33 +35,14 @@ 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:
@@ -76,56 +52,27 @@ struct PaymentChipView: View {
}
}
}
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", 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", 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", 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))
-46
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/")
@@ -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))
@@ -155,20 +139,6 @@ struct ContentHeaderView: View {
)
}
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")
@@ -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
}) {
@@ -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 {
+20 -42
View File
@@ -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)
@@ -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", 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.
@@ -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", 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", 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)
}
.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)
@@ -591,9 +571,7 @@ private struct ContentPrivateHeaderInfoButton: View {
)
)
.accessibilityHint(
headerState.isGroupConversation
? ""
: String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
)
.frame(minHeight: headerHeight)
}
+1 -1
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 },
-43
View File
@@ -37,14 +37,6 @@ struct FingerprintView: View {
}
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", comment: "How many people the user verified have vouched for this peer"),
locale: .current,
count
)
}
static func unknownPeer() -> String {
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) {
-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", comment: "Section header above the private groups list")
static let creator = String(localized: "groups.state.creator", comment: "State label for a group the user created")
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
static let openGroupHint = String(localized: "groups.accessibility.open_group_hint", comment: "Accessibility hint on a group row explaining activation opens the group chat")
static let memberCountFormat = String(localized: "groups.member_count %@", 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: ", ")
}
}
-30
View File
@@ -27,8 +27,6 @@ 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"
@@ -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 }
}
-13
View File
@@ -25,8 +25,6 @@ struct MeshPeerList: View {
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 vouched = String(localized: "mesh_peers.state.vouched", comment: "State label for a peer vouched for by someone the user verified")
static let vouchedTooltip = String(localized: "mesh_peers.tooltip.vouched", comment: "Tooltip for the vouched (unfilled seal) badge next to a 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")
@@ -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("topology.title")
.bitchatFont(size: 16, weight: .bold)
.foregroundColor(palette.primary)
Spacer()
refreshButton
Button("app_info.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("topology.title"))
.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("topology.refresh"))
}
@ViewBuilder
private var content: some View {
VStack(spacing: 12) {
if model.nodes.count <= 1 {
Spacer()
Text("topology.empty")
.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("topology.caption")
.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")]
)
})
}
+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 }
}
}
-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))
}
}
+5 -237
View File
@@ -139,7 +139,6 @@ struct GossipSyncManagerTests {
config.messageSyncIntervalSeconds = 1
config.fragmentSyncIntervalSeconds = 1
config.fileTransferSyncIntervalSeconds = 1
config.prekeyBundleSyncIntervalSeconds = 1
config.maintenanceIntervalSeconds = 0
let requestSyncManager = RequestSyncManager()
@@ -196,25 +195,19 @@ struct GossipSyncManagerTests {
manager._performMaintenanceSynchronously(now: Date())
// One request per due schedule so each type group gets the full
// filter capacity: publicMessages, fragment, fileTransfer, and
// prekeyBundle.
// filter capacity: publicMessages, fragment, and fileTransfer.
let sentPackets = delegate.packets
#expect(sentPackets.count == 4)
#expect(sentPackets.count == 3)
let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }
#expect(decoded.count == 4)
#expect(decoded.count == 3)
let allTypes = decoded.compactMap(\.types).reduce(SyncTypeFlags(rawValue: 0)) { $0.union($1) }
#expect(allTypes.contains(.announce))
#expect(allTypes.contains(.message))
#expect(allTypes.contains(.fragment))
#expect(allTypes.contains(.fileTransfer))
#expect(allTypes.contains(.prekeyBundle))
#expect(allTypes.contains(.groupMessage))
// The message schedule also asks for group messages (bit 10);
// responders that don't know the bit just ignore it.
#expect(decoded.contains { $0.types == SyncTypeFlags.publicMessages.union(.groupMessage) })
#expect(decoded.contains { $0.types == .publicMessages })
#expect(decoded.contains { $0.types == .fragment })
#expect(decoded.contains { $0.types == .fileTransfer })
#expect(decoded.contains { $0.types == .prekeyBundle })
}
@Test func truncatedFilterCarriesSinceCursor() throws {
@@ -299,7 +292,6 @@ struct GossipSyncManagerTests {
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
config.prekeyBundleSyncIntervalSeconds = 0
let requestSyncManager = RequestSyncManager()
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
@@ -365,53 +357,12 @@ struct GossipSyncManagerTests {
#expect(sentPackets.allSatisfy { $0.isRSR })
}
@Test func handleRequestSyncSkipsAnnounceAlreadyInFilter() async throws {
var config = GossipSyncManager.Config()
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
// Silence the prekey round so the maintenance barrier below emits nothing.
config.prekeyBundleSyncIntervalSeconds = 0
let requestSyncManager = RequestSyncManager()
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
let delegate = RecordingDelegate()
manager.delegate = delegate
let sender = try #require(Data(hexString: "aabbccddeeff0011"))
let announcePacket = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: sender,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(),
signature: nil,
ttl: 1
)
manager.onPublicPacketSeen(announcePacket)
// A filter that already contains the announce's canonical ID must
// suppress the response this only holds if the responder recomputes
// the ID the same way the filter was built (the dual-path bug would
// diff a stored hex string instead).
let announceID = PacketIdUtil.computeId(announcePacket)
let params = GCSFilter.buildFilter(ids: [announceID], maxBytes: 256, targetFpr: 0.01)
let request = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: .announce)
let peer = PeerID(str: "FFFFFFFFFFFFFFFF")
manager.handleRequestSync(from: peer, request: request)
// Barrier: the async handler is enqueued, so this sync flush runs after it.
manager._performMaintenanceSynchronously(now: Date())
#expect(delegate.packets.isEmpty)
}
@Test func handleRequestSyncIsRateLimitedPerPeer() async throws {
var config = GossipSyncManager.Config()
config.seenCapacity = 5
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
config.prekeyBundleSyncIntervalSeconds = 0
config.responseRateLimitMaxResponses = 1
config.responseRateLimitWindowSeconds = 60
@@ -466,7 +417,6 @@ struct GossipSyncManagerTests {
#expect(types.contains(.message))
#expect(types.contains(.fragment))
#expect(types.contains(.fileTransfer))
#expect(types.contains(.prekeyBundle))
}
@Test func handleRequestSyncHonorsTypeFilter() async throws {
@@ -519,187 +469,6 @@ struct GossipSyncManagerTests {
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
}
// MARK: - Fragment-ID filter (targeted resync)
private func makeFragmentPacket(sender: Data, fragmentID: Data, index: UInt16, timestamp: UInt64) -> BitchatPacket {
// Fragment payload: 8-byte stream ID + index + total + original type.
var payload = fragmentID
payload.append(contentsOf: withUnsafeBytes(of: index.bigEndian) { Data($0) })
payload.append(contentsOf: withUnsafeBytes(of: UInt16(4).bigEndian) { Data($0) })
payload.append(MessageType.fileTransfer.rawValue)
payload.append(Data([0xEE]))
return BitchatPacket(
type: MessageType.fragment.rawValue,
senderID: sender,
recipientID: nil,
timestamp: timestamp,
payload: payload,
signature: nil,
ttl: 1
)
}
@Test func handleRequestSyncHonorsFragmentIdFilter() async throws {
var config = GossipSyncManager.Config()
config.fragmentCapacity = 10
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
// Silence the prekey round so the maintenance barrier isolates fragments.
config.prekeyBundleSyncIntervalSeconds = 0
let requestSyncManager = RequestSyncManager()
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
let delegate = RecordingDelegate()
manager.delegate = delegate
let sender = try #require(Data(hexString: "aabbccddeeff0011"))
let wantedID = try #require(Data(hexString: "0102030405060708"))
let otherID = try #require(Data(hexString: "1112131415161718"))
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
let wanted = makeFragmentPacket(sender: sender, fragmentID: wantedID, index: 1, timestamp: nowMs - 60_000)
let other = makeFragmentPacket(sender: sender, fragmentID: otherID, index: 2, timestamp: nowMs)
manager.onPublicPacketSeen(wanted)
manager.onPublicPacketSeen(other)
// The since-cursor sits after both fragments; without the filter the
// responder would send nothing for `wanted`. The filter both bypasses
// the cursor and restricts the diff to exactly the named stream.
let request = RequestSyncPacket(
p: 7,
m: 1,
data: Data(),
types: .fragment,
sinceTimestamp: nowMs + 1,
fragmentIdFilter: RequestSyncPacket.encodeFragmentIdFilter([wantedID])
)
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
// Barrier: flush the sync queue so a late second packet would be visible.
manager._performMaintenanceSynchronously(now: Date())
let sentPackets = delegate.packets
#expect(sentPackets.count == 1)
let sent = try #require(sentPackets.first)
#expect(sent.type == MessageType.fragment.rawValue)
#expect(sent.payload.prefix(8) == wantedID)
#expect(sent.ttl == 0)
#expect(sent.isRSR)
}
@Test func prekeyBundlesServeSyncAndSurviveStalePeerCleanup() async throws {
var config = GossipSyncManager.Config()
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
config.prekeyBundleSyncIntervalSeconds = 0
config.stalePeerCleanupIntervalSeconds = 0
config.stalePeerTimeoutSeconds = 5
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
let delegate = RecordingDelegate()
manager.delegate = delegate
// Bundles are keyed by their authenticated identity (the noise static
// key), not the packet senderID, so the payload must be a real bundle.
let noiseKey = Data(repeating: 0xAB, count: 32)
let senderPeer = PeerID(publicKey: noiseKey)
let sender = try #require(Data(hexString: senderPeer.id))
let bundle = PrekeyBundle(
noiseStaticPublicKey: noiseKey,
prekeys: [PrekeyBundle.Prekey(id: 0, publicKey: Data(repeating: 0x11, count: 32))],
generatedAt: UInt64(Date().timeIntervalSince1970 * 1000),
signature: Data(count: PrekeyBundle.signatureLength)
)
let bundlePacket = BitchatPacket(
type: MessageType.prekeyBundle.rawValue,
senderID: sender,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: try #require(bundle.encode()),
signature: nil,
ttl: 1
)
manager.onPublicPacketSeen(bundlePacket)
manager._performMaintenanceSynchronously(now: Date())
#expect(manager._hasPrekeyBundle(for: senderPeer))
// Bundles outlive the owner's announce: a leave plus stale cleanup
// must not drop them (they exist to reach offline owners).
manager.removeAnnouncementForPeer(senderPeer)
manager._performMaintenanceSynchronously(now: Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1))
#expect(manager._hasPrekeyBundle(for: senderPeer))
// And a .prekeyBundle sync request is answered with the stored packet.
let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .prekeyBundle)
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
let served = try #require(delegate.packets.first)
#expect(served.type == MessageType.prekeyBundle.rawValue)
#expect(served.isRSR)
}
@Test func prekeyBundleGossipIsKeyedByOwnerNotSenderID() {
// One valid bundle re-broadcast under many fabricated sender IDs must
// collapse to a single entry keyed by the bundle's own identity the
// spray-to-exhaust-the-cap DoS produces one entry, not N.
let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: RequestSyncManager())
let noiseKey = Data(repeating: 0xCD, count: 32)
let ownerPeer = PeerID(publicKey: noiseKey)
let bundle = PrekeyBundle(
noiseStaticPublicKey: noiseKey,
prekeys: [PrekeyBundle.Prekey(id: 0, publicKey: Data(repeating: 0x22, count: 32))],
generatedAt: UInt64(Date().timeIntervalSince1970 * 1000),
signature: Data(count: PrekeyBundle.signatureLength)
)
guard let payload = bundle.encode() else { return }
for i in 0..<5 {
let fakeSender = Data((0..<8).map { j in UInt8(truncatingIfNeeded: i * 31 + j) })
let packet = BitchatPacket(
type: MessageType.prekeyBundle.rawValue,
senderID: fakeSender,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000) + UInt64(i),
payload: payload,
signature: nil,
ttl: 1
)
manager.onPublicPacketSeen(packet)
manager._performMaintenanceSynchronously(now: Date())
// No fabricated sender ID ever creates its own entry.
#expect(!manager._hasPrekeyBundle(for: PeerID(hexData: fakeSender)))
}
// Exactly the owner-keyed entry exists.
#expect(manager._hasPrekeyBundle(for: ownerPeer))
}
@Test func requestMissingFragmentsSendsFilteredRequestToConnectedPeers() async throws {
var config = GossipSyncManager.Config()
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
let requestSyncManager = RequestSyncManager()
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
let delegate = RecordingDelegate()
delegate.connectedPeers = [PeerID(str: "FFFFFFFFFFFFFFFF")]
manager.delegate = delegate
let stalledID = try #require(Data(hexString: "0102030405060708"))
manager.requestMissingFragments(fragmentIDs: [stalledID])
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
let sent = try #require(delegate.packets.first)
#expect(sent.type == MessageType.requestSync.rawValue)
#expect(sent.ttl == 0)
let request = try #require(RequestSyncPacket.decode(from: sent.payload))
#expect(request.types == .fragment)
let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter))
#expect(ids == Set([stalledID]))
}
// MARK: - Archive persistence
@Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws {
@@ -776,7 +545,6 @@ struct GossipSyncManagerTests {
private final class RecordingDelegate: GossipSyncManager.Delegate {
var onSend: (() -> Void)?
var connectedPeers: [PeerID] = []
private(set) var lastPacket: BitchatPacket?
private(set) var packets: [BitchatPacket] = []
private let lock = NSLock()
@@ -798,6 +566,6 @@ private final class RecordingDelegate: GossipSyncManager.Delegate {
}
func getConnectedPeers() -> [PeerID] {
return connectedPeers
return []
}
}
-119
View File
@@ -1,119 +0,0 @@
//
// MessageRateLimiterTests.swift
// bitchatTests
//
// Tests for the public-intake token buckets, including the NIP-13
// proof-of-work relaxation of the per-sender bucket.
//
import Foundation
import Testing
@testable import bitchat
struct MessageRateLimiterTests {
private func makeLimiter(
senderCapacity: Double = 2,
contentCapacity: Double = 100
) -> MessageRateLimiter {
MessageRateLimiter(
senderCapacity: senderCapacity,
senderRefillPerSec: 0.0001,
contentCapacity: contentCapacity,
contentRefillPerSec: 0.0001
)
}
@Test func senderBucketBlocksAfterCapacity() {
var limiter = makeLimiter()
let now = Date()
let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now)
let second = limiter.allow(senderKey: "s", contentKey: "c2", now: now)
let third = limiter.allow(senderKey: "s", contentKey: "c3", now: now)
let otherSender = limiter.allow(senderKey: "other", contentKey: "c4", now: now)
#expect(first)
#expect(second)
#expect(!third)
#expect(otherSender)
}
@Test func validPoWBypassesExhaustedSenderBucket() {
var limiter = makeLimiter()
let now = Date()
// Exhaust the sender bucket with plain (no-PoW) messages.
let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now)
let second = limiter.allow(senderKey: "s", contentKey: "c2", now: now)
let exhausted = limiter.allow(senderKey: "s", contentKey: "c3", now: now)
// A message carrying sufficient validated PoW still passes, and so
// does more-than-sufficient PoW; plain messages stay blocked.
let powExact = limiter.allow(
senderKey: "s",
contentKey: "c4",
powBits: NostrPoW.rateLimitBypassBits,
now: now
)
let powHigh = limiter.allow(senderKey: "s", contentKey: "c5", powBits: 20, now: now)
let plainAgain = limiter.allow(senderKey: "s", contentKey: "c6", now: now)
#expect(first)
#expect(second)
#expect(!exhausted)
#expect(powExact)
#expect(powHigh)
#expect(!plainAgain)
}
@Test func lowPoWDoesNotBypassSenderBucket() {
var limiter = makeLimiter(senderCapacity: 1)
let now = Date()
let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now)
let lowPow = limiter.allow(
senderKey: "s",
contentKey: "c2",
powBits: NostrPoW.rateLimitBypassBits - 1,
now: now
)
let zeroPow = limiter.allow(senderKey: "s", contentKey: "c3", powBits: 0, now: now)
#expect(first)
#expect(!lowPow)
#expect(!zeroPow)
}
@Test func powDoesNotBypassContentFloodBucket() {
var limiter = makeLimiter(senderCapacity: 100, contentCapacity: 1)
let now = Date()
let first = limiter.allow(senderKey: "a", contentKey: "same", now: now)
// Identical content spammed with PoW is still throttled by the
// content bucket: PoW only relaxes the per-sender limit.
let powSameContent = limiter.allow(senderKey: "b", contentKey: "same", powBits: 20, now: now)
let powNewContent = limiter.allow(senderKey: "b", contentKey: "different", powBits: 20, now: now)
#expect(first)
#expect(!powSameContent)
#expect(powNewContent)
}
@Test func powBypassDoesNotDrainSenderBucket() {
var limiter = makeLimiter(senderCapacity: 1)
let now = Date()
// PoW messages don't consume sender tokens, so a subsequent plain
// message still has its full budget.
let powFirst = limiter.allow(senderKey: "s", contentKey: "c1", powBits: 20, now: now)
let powSecond = limiter.allow(senderKey: "s", contentKey: "c2", powBits: 20, now: now)
let plain = limiter.allow(senderKey: "s", contentKey: "c3", now: now)
let plainExhausted = limiter.allow(senderKey: "s", contentKey: "c4", now: now)
#expect(powFirst)
#expect(powSecond)
#expect(plain)
#expect(!plainExhausted)
}
}
+2 -45
View File
@@ -107,55 +107,12 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
func removeEphemeralSession(peerID: PeerID) {}
func setVerified(fingerprint: String, verified: Bool) {}
func isVerified(fingerprint: String) -> Bool {
true
}
func getVerifiedFingerprints() -> Set<String> {
Set()
}
// MARK: Vouching (transitive verification)
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
private var vouchBatchSentAt: [String: Date] = [:]
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
guard voucheeFingerprint != voucherFingerprint else { return false }
var records = vouchesByVouchee[voucheeFingerprint] ?? []
records.removeAll { $0.voucherFingerprint == voucherFingerprint }
records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp))
vouchesByVouchee[voucheeFingerprint] = records
return true
}
func validVouchers(for fingerprint: String) -> [VouchRecord] {
vouchesByVouchee[fingerprint] ?? []
}
func isVouched(fingerprint: String) -> Bool {
!(vouchesByVouchee[fingerprint] ?? []).isEmpty
}
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
socialIdentities[fingerprint]?.trustLevel ?? .unknown
}
func lastVouchBatchSent(to fingerprint: String) -> Date? {
vouchBatchSentAt[fingerprint]
}
func markVouchBatchSent(to fingerprint: String, at date: Date) {
vouchBatchSentAt[fingerprint] = date
}
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
nil
}
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
[]
}
}
-21
View File
@@ -196,27 +196,6 @@ final class MockTransport: Transport {
return courierSendResult
}
// MARK: - Mesh Diagnostics
private(set) var sentMeshPings: [PeerID] = []
var meshPingResult: MeshPingResult?
var meshPaths: [PeerID: [PeerID]] = [:]
var meshTopologySnapshot: MeshTopologySnapshot?
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
sentMeshPings.append(peerID)
let result = meshPingResult
Task { @MainActor in completion(result) }
}
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
meshPaths[peerID]
}
func currentMeshTopology() -> MeshTopologySnapshot? {
meshTopologySnapshot
}
// MARK: - Test Helpers
/// Clears all recorded method calls for fresh assertions
-222
View File
@@ -1,222 +0,0 @@
//
// NostrPoWTests.swift
// bitchatTests
//
// Tests for NIP-13 proof-of-work: leading-zero-bit counting, commitment
// semantics, and nonce-tag mining for geohash (kind 20000) events.
//
import CryptoKit
import Foundation
import Testing
import BitFoundation
@testable import bitchat
struct NostrPoWTests {
// MARK: - Leading zero bits
@Test func leadingZeroBitsVectors() {
#expect(NostrPoW.leadingZeroBits(Data()) == 0)
#expect(NostrPoW.leadingZeroBits(Data([0x80])) == 0)
#expect(NostrPoW.leadingZeroBits(Data([0xFF, 0x00])) == 0)
#expect(NostrPoW.leadingZeroBits(Data([0x40])) == 1)
#expect(NostrPoW.leadingZeroBits(Data([0x01])) == 7)
#expect(NostrPoW.leadingZeroBits(Data([0x00, 0x00, 0xF0])) == 16)
#expect(NostrPoW.leadingZeroBits(Data(repeating: 0x00, count: 32)) == 256)
}
@Test func leadingZeroBitsExactByteBoundaries() {
// Zero byte contributes exactly 8, then the next byte decides.
#expect(NostrPoW.leadingZeroBits(Data([0x00, 0xFF])) == 8)
#expect(NostrPoW.leadingZeroBits(Data([0x00, 0x80])) == 8)
#expect(NostrPoW.leadingZeroBits(Data([0x00, 0x7F])) == 9)
#expect(NostrPoW.leadingZeroBits(Data([0x00, 0x01])) == 15)
#expect(NostrPoW.leadingZeroBits(Data([0x00, 0x00, 0x01])) == 23)
}
@Test func leadingZeroBitsMatchesNIP13ExampleVector() throws {
// Worked example from the NIP-13 spec: this event ID has 36 leading
// zero bits.
let idHex = "000000000e9d97a1ab09fc381030b346cdd7a142ad57e6df0b46dc9bef6c7e2d"
let idData = try #require(Data(hexString: idHex))
#expect(NostrPoW.leadingZeroBits(idData) == 36)
}
// MARK: - Commitment semantics
/// An ID with exactly 16 leading zero bits.
private let id16 = "0000f000" + String(repeating: "ab", count: 28)
@Test func committedTargetCountsNotActualDifficulty() {
// Claimed < actual: only the committed target is credited, so lucky
// extra zeroes earn nothing beyond the commitment.
let tags = [["g", "u4pruy"], ["nonce", "12345", "8"]]
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 8)
}
@Test func unmetCommitmentScoresZero() {
// Actual < claimed: the commitment is not met, so the claim is void.
let tags = [["nonce", "12345", "24"]]
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 0)
}
@Test func exactCommitmentIsCredited() {
let tags = [["nonce", "12345", "16"]]
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 16)
}
@Test func missingOrMalformedNonceTagScoresZero() {
// No nonce tag at all: leading zeroes without a commitment earn no
// credit (old clients simply keep the strict rate limits).
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["g", "u4pruy"]]) == 0)
// Nonce tag without a committed target.
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "12345"]]) == 0)
// Non-numeric or nonsensical targets.
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "high"]]) == 0)
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "0"]]) == 0)
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "-4"]]) == 0)
#expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "400"]]) == 0)
// Malformed event ID.
#expect(NostrPoW.validatedDifficulty(idHex: "not-hex", tags: [["nonce", "1", "8"]]) == 0)
}
// MARK: - Mining
@Test func minedNonceTagMeetsCommittedDifficulty() async throws {
let pubkey = String(repeating: "a", count: 64)
let createdAt = 1_700_000_000
let baseTags = [["g", "u4pruydq"], ["n", "tester"]]
let content = "hello pow"
let nonceTag = try #require(await NostrPoW.mineNonceTag(
pubkey: pubkey,
createdAt: createdAt,
kind: 20000,
tags: baseTags,
content: content,
targetBits: 8
))
#expect(nonceTag.count == 3)
#expect(nonceTag.first == "nonce")
#expect(nonceTag[2] == "8")
// Recompute the canonical NIP-01 event ID with the mined tag appended
// and verify the committed difficulty is genuinely met.
let idData = try Self.eventIDHash(
pubkey: pubkey,
createdAt: createdAt,
kind: 20000,
tags: baseTags + [nonceTag],
content: content
)
#expect(NostrPoW.leadingZeroBits(idData) >= 8)
let idHex = idData.map { String(format: "%02x", $0) }.joined()
#expect(NostrPoW.validatedDifficulty(idHex: idHex, tags: baseTags + [nonceTag]) == 8)
}
@Test func miningSurvivesContentThatNeedsEscaping() async throws {
// The in-place template mutation must stay correct when the content
// gets JSON-escaped including content that contains hex runs that
// look exactly like the internal nonce placeholder.
let pubkey = String(repeating: "b", count: 64)
let createdAt = 1_700_000_123
let content = "she said \"hi\"\n0000000000000000 / ffffffffffffffff 😀\\"
let baseTags = [["g", "9q8yy"]]
let nonceTag = try #require(await NostrPoW.mineNonceTag(
pubkey: pubkey,
createdAt: createdAt,
kind: 20000,
tags: baseTags,
content: content,
targetBits: 4
))
let idData = try Self.eventIDHash(
pubkey: pubkey,
createdAt: createdAt,
kind: 20000,
tags: baseTags + [nonceTag],
content: content
)
#expect(NostrPoW.leadingZeroBits(idData) >= 4)
}
@Test func minedGeohashEventValidatesEndToEnd() async throws {
let identity = try NostrIdentity.generate()
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
content: "hello from a mined event",
geohash: "u4pruydq",
senderIdentity: identity,
nickname: "miner",
teleported: false
)
// The signed event's own ID (recomputed by sign()) carries the work.
#expect(event.isValidSignature())
let idData = try #require(Data(hexString: event.id))
#expect(NostrPoW.leadingZeroBits(idData) >= NostrPoW.targetBits)
#expect(NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) == NostrPoW.targetBits)
// Mining must not disturb the regular geohash tags.
#expect(event.tags.contains(["g", "u4pruydq"]))
#expect(event.tags.contains(["n", "miner"]))
#expect(event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue)
}
@Test func cancelledMiningStillProducesHonestCommitment() async throws {
// Cancelling the surrounding task expedites mining: it steps the
// committed target down and still returns a tag whose commitment the
// hash actually meets the message is never dropped or dishonest.
let pubkey = String(repeating: "c", count: 64)
let createdAt = 1_700_000_456
let baseTags = [["g", "gbsuv"]]
let content = "expedited"
let miningTask = Task {
await NostrPoW.mineNonceTag(
pubkey: pubkey,
createdAt: createdAt,
kind: 20000,
tags: baseTags,
content: content,
targetBits: 240 // unreachable: forces the cap/cancel path
)
}
miningTask.cancel()
let nonceTag = try #require(await miningTask.value)
let committed = try #require(Int(nonceTag[2]))
#expect(committed >= 0)
#expect(committed < 240)
if committed > 0 {
let idData = try Self.eventIDHash(
pubkey: pubkey,
createdAt: createdAt,
kind: 20000,
tags: baseTags + [nonceTag],
content: content
)
#expect(NostrPoW.leadingZeroBits(idData) >= committed)
}
}
// MARK: - Helpers
/// Canonical NIP-01 event ID hash, computed independently of the
/// production code path.
private static func eventIDHash(
pubkey: String,
createdAt: Int,
kind: Int,
tags: [[String]],
content: String
) throws -> Data {
let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
let json = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
return Data(SHA256.hash(data: json))
}
}
@@ -611,7 +611,6 @@ private final class PerfNostrContext: ChatNostrContext {
private(set) var handledPublicMessageCount = 0
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 }
func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { handledPublicMessageCount += 1 }
func checkForMentions(_ message: BitchatMessage) {}
func sendHapticFeedback(for message: BitchatMessage) {}
func parseMentions(from content: String) -> [String] {
-283
View File
@@ -1,283 +0,0 @@
//
// NoisePrekeyTests.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 CryptoKit
import BitFoundation
@testable import bitchat
/// Forward-secret one-way Noise X envelopes sealed to one-time prekeys
/// instead of the recipient's identity static key.
struct NoisePrekeyTests {
@Test func sealAndOpenRoundTrip() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(bob.currentPrekeyBundle())
let prekey = try #require(bundle.prekeys.first)
let payload = Data("meet at the north gate".utf8)
let sealed = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey)
let opened = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
#expect(opened.payload == payload)
// The X pattern authenticates the sender: Bob learns Alice's real static key.
#expect(opened.senderStaticKey == alice.getStaticPublicKeyData())
}
@Test func wrongPrekeyIDCannotOpen() throws {
// The prologue binds the ciphertext to a specific prekey ID; opening
// with a different (existing) prekey must fail.
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(bob.currentPrekeyBundle())
#expect(bundle.prekeys.count >= 2)
let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: bundle.prekeys[0])
#expect(throws: (any Error).self) {
_ = try bob.openPrekeyPayload(sealed, prekeyID: bundle.prekeys[1].id)
}
}
@Test func unknownPrekeyIDThrows() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(bob.currentPrekeyBundle())
let prekey = try #require(bundle.prekeys.first)
let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey)
#expect(throws: NoiseEncryptionError.unknownPrekey) {
_ = try bob.openPrekeyPayload(sealed, prekeyID: 0xDEAD_BEEF)
}
}
@Test func wrongRecipientCannotOpen() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let carol = NoiseEncryptionService(keychain: MockKeychain())
let bobBundle = try #require(bob.currentPrekeyBundle())
// Ensure Carol holds a prekey under the same ID as Bob's.
_ = try #require(carol.currentPrekeyBundle())
let prekey = try #require(bobBundle.prekeys.first)
let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey)
#expect(throws: (any Error).self) {
_ = try carol.openPrekeyPayload(sealed, prekeyID: prekey.id)
}
}
@Test func tamperedCiphertextFailsToOpen() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(bob.currentPrekeyBundle())
let prekey = try #require(bundle.prekeys.first)
var sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey)
sealed[sealed.count - 1] ^= 0x01
#expect(throws: (any Error).self) {
_ = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
}
}
@Test func consumedPrekeyStillOpensRedeliveredCiphertext() throws {
// Spray-and-wait can deliver the same ciphertext via several couriers
// days apart; the consumed private survives a grace window for that.
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(bob.currentPrekeyBundle())
let prekey = try #require(bundle.prekeys.first)
let sealed = try alice.sealPrekeyPayload(Data("hello".utf8), recipientPrekey: prekey)
let first = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
let second = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
#expect(first.payload == second.payload)
}
@Test func prekeyAndStaticSealsAreNotInterchangeable() throws {
// Domain-separated prologues: a static-sealed envelope must not open
// via the prekey path and vice versa, even with matching key material.
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(bob.currentPrekeyBundle())
let prekey = try #require(bundle.prekeys.first)
let staticSealed = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
#expect(throws: (any Error).self) {
_ = try bob.openPrekeyPayload(staticSealed, prekeyID: prekey.id)
}
let prekeySealed = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: prekey)
#expect(throws: (any Error).self) {
_ = try bob.openCourierPayload(prekeySealed)
}
}
@Test func sealRejectsInvalidPrekeyPublicKey() {
let alice = NoiseEncryptionService(keychain: MockKeychain())
#expect(throws: (any Error).self) {
_ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 0, count: 32)))
}
#expect(throws: (any Error).self) {
_ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 1, count: 8)))
}
}
@Test func sealsAreNotLinkableAcrossSends() throws {
// Fresh ephemeral per seal even to the same prekey.
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(bob.currentPrekeyBundle())
let prekey = try #require(bundle.prekeys.first)
let payload = Data("same message".utf8)
let a = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey)
let b = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey)
#expect(a != b)
#expect(a.prefix(32) != b.prefix(32))
}
}
/// Local one-time prekey lifecycle: batch generation, consumption, the 48h
/// redelivery grace window, replenishment, and the panic wipe.
struct LocalPrekeyStoreTests {
private final class Clock {
var now: Date
init(_ now: Date = Date()) { self.now = now }
}
private func makeStore(clock: Clock, keychain: MockKeychain = MockKeychain()) -> LocalPrekeyStore {
LocalPrekeyStore(keychain: keychain, now: { clock.now })
}
private func bundle(noiseKey: Data, prekeys: [PrekeyBundle.Prekey], generatedAt: UInt64) -> PrekeyBundle {
PrekeyBundle(
noiseStaticPublicKey: noiseKey,
prekeys: prekeys,
generatedAt: generatedAt,
signature: Data(count: PrekeyBundle.signatureLength)
)
}
@Test func mintsFullBatchOnFirstUse() {
let store = makeStore(clock: Clock())
let (prekeys, generatedAt) = store.currentBundlePrekeys()
#expect(prekeys.count == LocalPrekeyStore.Policy.batchSize)
#expect(generatedAt > 0)
#expect(Set(prekeys.map(\.id)).count == prekeys.count)
}
@Test func consumptionBelowThresholdTriggersReplenishAndBumpsGeneration() {
let clock = Clock()
let store = makeStore(clock: clock)
let (initial, firstGeneratedAt) = store.currentBundlePrekeys()
// Consuming down to the threshold does not regenerate...
let keepUnconsumed = LocalPrekeyStore.Policy.replenishThreshold
for prekey in initial.dropLast(keepUnconsumed) {
store.markConsumed(prekey.id)
}
#expect(!store.replenishIfNeeded())
#expect(store.unconsumedCount == keepUnconsumed)
// ...one more consumption does, topping back up to a full batch with
// a newer generation stamp.
clock.now = clock.now.addingTimeInterval(60)
store.markConsumed(initial[initial.count - keepUnconsumed].id)
#expect(store.replenishIfNeeded())
let (replenished, secondGeneratedAt) = store.currentBundlePrekeys()
#expect(replenished.count == LocalPrekeyStore.Policy.batchSize)
#expect(secondGeneratedAt > firstGeneratedAt)
// Surviving unconsumed prekeys stay in the fresh bundle.
let survivorIDs = Set(initial.suffix(keepUnconsumed - 1).map(\.id))
#expect(survivorIDs.isSubset(of: Set(replenished.map(\.id))))
}
@Test func consumingAPrekeyRepublishesANewerBundlePeersAccept() {
// Codex P1: consuming a prekey (even above the replenish threshold)
// must republish a strictly newer bundle so a peer that cached the old
// one replaces it and stops assigning the consumed ID before its 48h
// grace lapses.
let clock = Clock()
let store = makeStore(clock: clock)
let noiseKey = Data(repeating: 0xC0, count: 32)
// Owner publishes; a peer caches it and would assign the first prekey.
let (initial, firstGeneratedAt) = store.currentBundlePrekeys()
let peerCache = PrekeyBundleStore(persistsToDisk: false)
#expect(peerCache.ingest(bundle(noiseKey: noiseKey, prekeys: initial, generatedAt: firstGeneratedAt)))
let consumedID = initial[0].id
#expect(peerCache.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == consumedID)
// The owner opens mail sealed to that prekey: it's retired and the
// republished bundle is strictly newer and no longer offers the ID.
#expect(store.markConsumed(consumedID))
let (afterConsume, secondGeneratedAt) = store.currentBundlePrekeys()
#expect(secondGeneratedAt > firstGeneratedAt)
#expect(!afterConsume.contains { $0.id == consumedID })
// The peer accepts the replacement (a same-generatedAt copy would be
// rejected) and stops assigning the consumed ID for new mail.
#expect(peerCache.ingest(bundle(noiseKey: noiseKey, prekeys: afterConsume, generatedAt: secondGeneratedAt)))
#expect(peerCache.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id != consumedID)
// 48h grace: the owner can still open a redelivery of the in-flight
// ciphertext sealed to the consumed ID until the window lapses.
clock.now = clock.now.addingTimeInterval(LocalPrekeyStore.Policy.consumedGraceSeconds - 60)
#expect(store.privateKey(for: consumedID) != nil)
clock.now = clock.now.addingTimeInterval(120)
#expect(store.privateKey(for: consumedID) == nil)
}
@Test func consumedPrivateSurvivesGraceWindowThenDies() {
let clock = Clock()
let store = makeStore(clock: clock)
let (prekeys, _) = store.currentBundlePrekeys()
let id = prekeys[0].id
store.markConsumed(id)
// Within the grace window: still retrievable for redeliveries.
clock.now = clock.now.addingTimeInterval(LocalPrekeyStore.Policy.consumedGraceSeconds - 60)
#expect(store.privateKey(for: id) != nil)
// Past the grace window: gone (even before replenish prunes it).
clock.now = clock.now.addingTimeInterval(120)
#expect(store.privateKey(for: id) == nil)
store.replenishIfNeeded()
#expect(store.privateKey(for: id) == nil)
}
@Test func persistsAcrossInstances() {
let keychain = MockKeychain()
let clock = Clock()
let first = LocalPrekeyStore(keychain: keychain, now: { clock.now })
let (prekeys, generatedAt) = first.currentBundlePrekeys()
first.markConsumed(prekeys[0].id)
let second = LocalPrekeyStore(keychain: keychain, now: { clock.now })
let (reloaded, reloadedGeneratedAt) = second.currentBundlePrekeys()
// Consuming a prekey shrinks the published bundle, so its generation
// stamp advances strictly (even without the clock moving) peers must
// see a newer bundle to replace the one that still offered the
// consumed ID.
#expect(reloadedGeneratedAt > generatedAt)
#expect(Set(reloaded.map(\.id)) == Set(prekeys.dropFirst().map(\.id)))
// The consumed key is still openable within grace after a relaunch.
#expect(second.privateKey(for: prekeys[0].id) != nil)
}
@Test func wipeRemovesEverything() {
let keychain = MockKeychain()
let store = LocalPrekeyStore(keychain: keychain)
let (prekeys, _) = store.currentBundlePrekeys()
store.wipe()
#expect(store.privateKey(for: prekeys[0].id) == nil)
#expect(keychain.getIdentityKey(forKey: "prekeysV1") == nil)
}
}
@@ -1,197 +0,0 @@
//
// PrekeyBundleStoreTests.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 CryptoKit
import BitFoundation
@testable import bitchat
/// Sender-side cache of peers' verified prekey bundles: latest-wins ingest,
/// per-message prekey assignment (never reused across messages), expiry, and
/// the peer cap.
struct PrekeyBundleStoreTests {
private func makeBundle(
noiseKey: Data = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation,
ids: [UInt32] = [0, 1, 2],
generatedAt: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000)
) -> PrekeyBundle {
PrekeyBundle(
noiseStaticPublicKey: noiseKey,
prekeys: ids.map { PrekeyBundle.Prekey(id: $0, publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation) },
generatedAt: generatedAt,
signature: Data(count: PrekeyBundle.signatureLength)
)
}
@Test func ingestKeepsLatestByGeneratedAt() {
let store = PrekeyBundleStore(persistsToDisk: false)
let noiseKey = Data(repeating: 0xB0, count: 32)
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
let old = makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000)
let new = makeBundle(noiseKey: noiseKey, ids: [2, 3], generatedAt: nowMs)
#expect(store.ingest(new))
// Older (and equal) bundles never displace a newer one.
#expect(!store.ingest(old))
#expect(!store.ingest(new))
let assigned = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
#expect(assigned?.id == 2)
}
@Test func assignmentsConsumeDistinctPrekeysPerMessage() {
let store = PrekeyBundleStore(persistsToDisk: false)
let noiseKey = Data(repeating: 0xB1, count: 32)
#expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [10, 11])))
let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
let second = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)
#expect(first?.id == 10)
#expect(second?.id == 11)
// Exhausted: fall back to static sealing.
#expect(store.assignPrekey(messageID: "m3", recipientNoiseKey: noiseKey) == nil)
#expect(!store.hasUsableBundle(for: noiseKey))
}
@Test func redepositOfSameMessageReusesItsPrekey() {
let store = PrekeyBundleStore(persistsToDisk: false)
let noiseKey = Data(repeating: 0xB2, count: 32)
#expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [5, 6, 7])))
let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
let retry = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
#expect(first?.id == retry?.id)
#expect(first?.publicKey == retry?.publicKey)
// Only one prekey was burned.
let next = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)
#expect(next?.id == 6)
}
@Test func topUpBundleKeepsConsumptionStateForSurvivingIDs() {
let store = PrekeyBundleStore(persistsToDisk: false)
let noiseKey = Data(repeating: 0xB3, count: 32)
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
#expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000)))
#expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 0)
// The owner topped up: ID 1 survives (still unconsumed on their side),
// ID 0 rotated out, new IDs appear.
#expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 8, 9], generatedAt: nowMs)))
// m1's assignment referenced a rotated-out ID; a re-deposit picks a
// fresh one rather than sealing to a dead key.
#expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1)
#expect(store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 8)
}
@Test func expiredBundleIsNeverUsed() {
var current = Date()
let store = PrekeyBundleStore(persistsToDisk: false, now: { current })
let noiseKey = Data(repeating: 0xB4, count: 32)
#expect(store.ingest(makeBundle(noiseKey: noiseKey, generatedAt: UInt64(current.timeIntervalSince1970 * 1000))))
#expect(store.hasUsableBundle(for: noiseKey))
current = current.addingTimeInterval(PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds + 60)
#expect(!store.hasUsableBundle(for: noiseKey))
#expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) == nil)
}
@Test func peerCapEvictsLeastRecentlyUpdated() {
var current = Date()
let store = PrekeyBundleStore(persistsToDisk: false, maxPeers: 2, now: { current })
let keys = (0..<3).map { Data(repeating: UInt8(0xC0 + $0), count: 32) }
for key in keys {
#expect(store.ingest(makeBundle(noiseKey: key, generatedAt: UInt64(current.timeIntervalSince1970 * 1000))))
current = current.addingTimeInterval(1)
}
// Oldest entry evicted; the two most recent survive.
#expect(!store.hasUsableBundle(for: keys[0]))
#expect(store.hasUsableBundle(for: keys[1]))
#expect(store.hasUsableBundle(for: keys[2]))
}
@Test func persistsAcrossInstancesAndWipes() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("prekey-bundle-store-tests-\(UUID().uuidString)", isDirectory: true)
let fileURL = dir.appendingPathComponent("bundles.json")
defer { try? FileManager.default.removeItem(at: dir) }
let noiseKey = Data(repeating: 0xB5, count: 32)
let first = PrekeyBundleStore(fileURL: fileURL)
#expect(first.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 2])))
#expect(first.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1)
// Consumption state survives a relaunch, so a restart can't reuse a prekey.
let second = PrekeyBundleStore(fileURL: fileURL)
#expect(second.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 2)
second.wipe()
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
let third = PrekeyBundleStore(fileURL: fileURL)
#expect(!third.hasUsableBundle(for: noiseKey))
}
}
/// Envelope v2 wire compatibility: the prekey ID rides an optional TLV that
/// v1 decoders skip as unknown.
struct CourierEnvelopeV2Tests {
@Test func prekeyIDRoundTrips() throws {
let envelope = CourierEnvelope(
recipientTag: Data(repeating: 0x11, count: CourierEnvelope.tagLength),
expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000,
ciphertext: Data("ciphertext".utf8),
copies: 4,
prekeyID: 0xAABB_CCDD
)
let encoded = try #require(envelope.encode())
let decoded = try #require(CourierEnvelope.decode(encoded))
#expect(decoded == envelope)
#expect(decoded.prekeyID == 0xAABB_CCDD)
}
@Test func v1EnvelopeDecodesWithNilPrekeyID() throws {
let envelope = CourierEnvelope(
recipientTag: Data(repeating: 0x22, count: CourierEnvelope.tagLength),
expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000,
ciphertext: Data("legacy".utf8)
)
let encoded = try #require(envelope.encode())
let decoded = try #require(CourierEnvelope.decode(encoded))
#expect(decoded.prekeyID == nil)
}
@Test func v1EncodingIsByteIdenticalWithoutPrekeyID() throws {
// Static-sealed envelopes must stay on the pre-prekey wire format.
let tag = Data(repeating: 0x33, count: CourierEnvelope.tagLength)
let expiry: UInt64 = 1_800_000_000_000
let ciphertext = Data("same".utf8)
let v1 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext).encode())
let v1Explicit = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: nil).encode())
#expect(v1 == v1Explicit)
// And a v2 envelope is the v1 bytes plus one trailing TLV a v1
// decoder skips as unknown.
let v2 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: 7).encode())
#expect(v2.prefix(v1.count) == v1)
#expect(v2.count == v1.count + 3 + 4)
}
@Test func withCopiesPreservesPrekeyID() {
let envelope = CourierEnvelope(
recipientTag: Data(repeating: 0x44, count: CourierEnvelope.tagLength),
expiry: 1,
ciphertext: Data([0x01]),
copies: 4,
prekeyID: 9
)
#expect(envelope.withCopies(2).prekeyID == 9)
}
}
@@ -1,133 +0,0 @@
//
// PrekeyBundleTests.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 CryptoKit
import BitFoundation
@testable import bitchat
/// Wire format and signature binding for gossiped one-time prekey bundles.
struct PrekeyBundleTests {
private func makePrekeys(_ count: Int) -> [PrekeyBundle.Prekey] {
(0..<count).map { index in
PrekeyBundle.Prekey(
id: UInt32(index),
publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
)
}
}
// MARK: - Wire format
@Test func encodeDecodeRoundTrip() throws {
let bundle = PrekeyBundle(
noiseStaticPublicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation,
prekeys: makePrekeys(8),
generatedAt: 1_234_567_890_123,
signature: Data(repeating: 0xAB, count: PrekeyBundle.signatureLength)
)
let encoded = try #require(bundle.encode())
let decoded = try #require(PrekeyBundle.decode(encoded))
#expect(decoded == bundle)
}
@Test func decodeSkipsUnknownTLVs() throws {
let bundle = PrekeyBundle(
noiseStaticPublicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation,
prekeys: makePrekeys(2),
generatedAt: 42,
signature: Data(repeating: 0x01, count: PrekeyBundle.signatureLength)
)
var encoded = try #require(bundle.encode())
// Unknown TLV 0x7F appended by a future client.
encoded.append(contentsOf: [0x7F, 0x00, 0x03, 0x01, 0x02, 0x03])
let decoded = try #require(PrekeyBundle.decode(encoded))
#expect(decoded == bundle)
}
@Test func encodeRejectsInvalidShapes() {
let key = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
let signature = Data(repeating: 0, count: PrekeyBundle.signatureLength)
// No prekeys.
#expect(PrekeyBundle(noiseStaticPublicKey: key, prekeys: [], generatedAt: 1, signature: signature).encode() == nil)
// Too many prekeys.
#expect(PrekeyBundle(noiseStaticPublicKey: key, prekeys: makePrekeys(PrekeyBundle.maxPrekeys + 1), generatedAt: 1, signature: signature).encode() == nil)
// Wrong owner key length.
#expect(PrekeyBundle(noiseStaticPublicKey: Data(repeating: 1, count: 8), prekeys: makePrekeys(1), generatedAt: 1, signature: signature).encode() == nil)
// Wrong signature length.
#expect(PrekeyBundle(noiseStaticPublicKey: key, prekeys: makePrekeys(1), generatedAt: 1, signature: Data(repeating: 0, count: 32)).encode() == nil)
}
@Test func decodeRejectsDuplicatePrekeyIDs() throws {
let key = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
let prekey = makePrekeys(1)[0]
let bundle = PrekeyBundle(
noiseStaticPublicKey: key,
prekeys: [prekey, PrekeyBundle.Prekey(id: prekey.id, publicKey: key)],
generatedAt: 1,
signature: Data(repeating: 0, count: PrekeyBundle.signatureLength)
)
let encoded = try #require(bundle.encode())
#expect(PrekeyBundle.decode(encoded) == nil)
}
// MARK: - Signature binding
@Test func signVerifyRoundTrip() throws {
let owner = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(owner.currentPrekeyBundle())
#expect(bundle.noiseStaticPublicKey == owner.getStaticPublicKeyData())
#expect(bundle.prekeys.count == PrekeyBundle.maxPrekeys)
#expect(owner.verifyPrekeyBundleSignature(bundle, signingPublicKey: owner.getSigningPublicKeyData()))
}
@Test func forgedSignatureFailsVerification() throws {
let owner = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(owner.currentPrekeyBundle())
var forgedSignature = bundle.signature
forgedSignature[0] ^= 0x01
let forged = PrekeyBundle(
noiseStaticPublicKey: bundle.noiseStaticPublicKey,
prekeys: bundle.prekeys,
generatedAt: bundle.generatedAt,
signature: forgedSignature
)
#expect(!owner.verifyPrekeyBundleSignature(forged, signingPublicKey: owner.getSigningPublicKeyData()))
}
@Test func tamperedContentsFailVerification() throws {
let owner = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(owner.currentPrekeyBundle())
// Mallory swaps in her own prekey but keeps the owner's signature.
var prekeys = bundle.prekeys
prekeys[0] = PrekeyBundle.Prekey(
id: prekeys[0].id,
publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
)
let tampered = PrekeyBundle(
noiseStaticPublicKey: bundle.noiseStaticPublicKey,
prekeys: prekeys,
generatedAt: bundle.generatedAt,
signature: bundle.signature
)
#expect(!owner.verifyPrekeyBundleSignature(tampered, signingPublicKey: owner.getSigningPublicKeyData()))
}
@Test func wrongSigningKeyFailsVerification() throws {
let owner = NoiseEncryptionService(keychain: MockKeychain())
let other = NoiseEncryptionService(keychain: MockKeychain())
let bundle = try #require(owner.currentPrekeyBundle())
#expect(!owner.verifyPrekeyBundleSignature(bundle, signingPublicKey: other.getSigningPublicKeyData()))
}
}
@@ -1,211 +0,0 @@
//
// BoardPacketsTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import Foundation
import Testing
@testable import bitchat
struct BoardPacketsTests {
private let authorKey = Curve25519.Signing.PrivateKey()
private func makeSignedPost(
geohash: String = "9q8yy",
content: String = "water point at the north gate",
nickname: String = "ranger",
createdAt: UInt64 = 1_700_000_000_000,
lifetimeMs: UInt64 = 24 * 60 * 60 * 1000,
flags: UInt8 = 0,
signWith key: Curve25519.Signing.PrivateKey? = nil,
claimKey: Data? = nil
) throws -> BoardPostPacket {
let signer = key ?? authorKey
let publicKey = claimKey ?? signer.publicKey.rawRepresentation
let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) })
let expiresAt = createdAt + lifetimeMs
let signingBytes = BoardPostPacket.signingBytes(
postID: postID,
geohash: geohash,
content: content,
authorSigningKey: publicKey,
authorNickname: nickname,
createdAt: createdAt,
expiresAt: expiresAt,
flags: flags
)
let signature = try signer.signature(for: signingBytes)
return BoardPostPacket(
postID: postID,
geohash: geohash,
content: content,
authorSigningKey: publicKey,
authorNickname: nickname,
createdAt: createdAt,
expiresAt: expiresAt,
flags: flags,
signature: signature
)
}
private func makeSignedTombstone(
postID: Data,
deletedAt: UInt64 = 1_700_000_100_000,
signWith key: Curve25519.Signing.PrivateKey? = nil,
claimKey: Data? = nil
) throws -> BoardTombstonePacket {
let signer = key ?? authorKey
let publicKey = claimKey ?? signer.publicKey.rawRepresentation
let signature = try signer.signature(for: BoardTombstonePacket.signingBytes(postID: postID, deletedAt: deletedAt))
return BoardTombstonePacket(
postID: postID,
authorSigningKey: publicKey,
deletedAt: deletedAt,
signature: signature
)
}
// MARK: - Round trips
@Test func postRoundTrip() throws {
let post = try makeSignedPost(flags: BoardPostPacket.urgentFlag)
let encoded = BoardWire.post(post).encode()
let decoded = try #require(BoardWire.decode(from: encoded))
#expect(decoded == .post(post))
#expect(decoded.verifySignature())
guard case .post(let roundTripped) = decoded else {
Issue.record("expected a post")
return
}
#expect(roundTripped.isUrgent)
#expect(roundTripped.geohash == "9q8yy")
}
@Test func meshLocalPostRoundTrip() throws {
let post = try makeSignedPost(geohash: "")
let decoded = try #require(BoardWire.decode(from: BoardWire.post(post).encode()))
#expect(decoded == .post(post))
#expect(decoded.verifySignature())
}
@Test func tombstoneRoundTrip() throws {
let post = try makeSignedPost()
let tombstone = try makeSignedTombstone(postID: post.postID)
let encoded = BoardWire.tombstone(tombstone).encode()
let decoded = try #require(BoardWire.decode(from: encoded))
#expect(decoded == .tombstone(tombstone))
#expect(decoded.verifySignature())
}
// MARK: - Signature verification
@Test func forgedPostSignatureFailsVerification() throws {
// Signed by an attacker's key but claiming the victim's key as author.
let attacker = Curve25519.Signing.PrivateKey()
let victim = Curve25519.Signing.PrivateKey()
let forged = try makeSignedPost(signWith: attacker, claimKey: victim.publicKey.rawRepresentation)
let decoded = try #require(BoardWire.decode(from: BoardWire.post(forged).encode()))
#expect(!decoded.verifySignature())
}
@Test func tamperedContentFailsVerification() throws {
let post = try makeSignedPost(content: "meet at noon")
let tampered = BoardPostPacket(
postID: post.postID,
geohash: post.geohash,
content: "meet at midnight",
authorSigningKey: post.authorSigningKey,
authorNickname: post.authorNickname,
createdAt: post.createdAt,
expiresAt: post.expiresAt,
flags: post.flags,
signature: post.signature
)
let decoded = try #require(BoardWire.decode(from: BoardWire.post(tampered).encode()))
#expect(!decoded.verifySignature())
}
@Test func forgedTombstoneSignatureFailsVerification() throws {
let post = try makeSignedPost()
let attacker = Curve25519.Signing.PrivateKey()
let forged = try makeSignedTombstone(
postID: post.postID,
signWith: attacker,
claimKey: post.authorSigningKey
)
let decoded = try #require(BoardWire.decode(from: BoardWire.tombstone(forged).encode()))
#expect(!decoded.verifySignature())
}
// MARK: - Decode validation
@Test func rejectsExpiryBeyondSevenDays() throws {
let tooLong = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs + 1)
#expect(BoardWire.decode(from: BoardWire.post(tooLong).encode()) == nil)
let exactlySevenDays = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs)
#expect(BoardWire.decode(from: BoardWire.post(exactlySevenDays).encode()) != nil)
}
@Test func rejectsExpiryBeforeCreation() throws {
let post = try makeSignedPost()
let inverted = BoardPostPacket(
postID: post.postID,
geohash: post.geohash,
content: post.content,
authorSigningKey: post.authorSigningKey,
authorNickname: post.authorNickname,
createdAt: post.expiresAt,
expiresAt: post.createdAt,
flags: post.flags,
signature: post.signature
)
#expect(BoardWire.decode(from: BoardWire.post(inverted).encode()) == nil)
}
@Test func rejectsOversizedContent() throws {
let oversized = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes + 1))
#expect(BoardWire.decode(from: BoardWire.post(oversized).encode()) == nil)
let maxed = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes))
#expect(BoardWire.decode(from: BoardWire.post(maxed).encode()) != nil)
}
@Test func rejectsInvalidGeohashCharacters() throws {
let invalid = try makeSignedPost(geohash: "9q8yA") // "A" is outside base32
#expect(BoardWire.decode(from: BoardWire.post(invalid).encode()) == nil)
}
@Test func toleratesUnknownTLVs() throws {
let post = try makeSignedPost()
var encoded = BoardWire.post(post).encode()
// Append an unknown TLV; decoders must skip it.
encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
let decoded = try #require(BoardWire.decode(from: encoded))
#expect(decoded == .post(post))
#expect(decoded.verifySignature())
}
@Test func rejectsTruncatedPayload() throws {
let post = try makeSignedPost()
let encoded = BoardWire.post(post).encode()
#expect(BoardWire.decode(from: encoded.prefix(encoded.count - 1)) == nil)
}
// MARK: - Urgent flag peek
@Test func urgentFlagPeekMatchesFullDecode() throws {
let urgent = try makeSignedPost(flags: BoardPostPacket.urgentFlag)
let calm = try makeSignedPost()
let tombstone = try makeSignedTombstone(postID: calm.postID)
#expect(BoardWire.urgentFlag(in: BoardWire.post(urgent).encode()))
#expect(!BoardWire.urgentFlag(in: BoardWire.post(calm).encode()))
#expect(!BoardWire.urgentFlag(in: BoardWire.tombstone(tombstone).encode()))
#expect(!BoardWire.urgentFlag(in: Data()))
}
}
@@ -1,96 +0,0 @@
//
// NostrCarrierPacketTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
@testable import bitchat
@Suite("Nostr carrier packet TLV")
struct NostrCarrierPacketTests {
private func makeEvent(geohash: String = "u4pruy", content: String = "hello mesh") throws -> NostrEvent {
let identity = try NostrIdentity.generate()
return try NostrProtocol.createEphemeralGeohashEvent(
content: content,
geohash: geohash,
senderIdentity: identity,
nickname: "tester"
)
}
@Test("round-trips both directions with the signed event intact")
func roundTrip() throws {
let event = try makeEvent()
for direction in [NostrCarrierPacket.Direction.toGateway, .fromGateway] {
let packet = try #require(NostrCarrierPacket(direction: direction, geohash: "u4pruy", event: event))
let encoded = try #require(packet.encode())
let decoded = try #require(NostrCarrierPacket.decode(encoded))
#expect(decoded == packet)
#expect(decoded.direction == direction)
#expect(decoded.geohash == "u4pruy")
// The carried event survives byte-exact: same ID, and the
// signature still verifies after the mesh hop.
let carried = try #require(decoded.event())
#expect(carried.id == event.id)
#expect(carried.sig == event.sig)
#expect(carried.isValidSignature())
}
}
@Test("rejects an oversized event at construction and at decode")
func oversizedRejected() throws {
let oversized = Data(repeating: 0x7B, count: NostrCarrierPacket.maxEventJSONBytes + 1)
#expect(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", eventJSON: oversized) == nil)
// Hand-build the TLV bytes to bypass the initializer's cap.
var data = Data([0x01, 0x00, 0x01, NostrCarrierPacket.Direction.toGateway.rawValue])
let geohash = Data("u4pruy".utf8)
data.append(contentsOf: [0x02, 0x00, UInt8(geohash.count)])
data.append(geohash)
data.append(contentsOf: [0x03, UInt8((oversized.count >> 8) & 0xFF), UInt8(oversized.count & 0xFF)])
data.append(oversized)
#expect(NostrCarrierPacket.decode(data) == nil)
}
@Test("rejects an over-length or empty geohash")
func geohashBoundsEnforced() throws {
let event = try makeEvent()
#expect(NostrCarrierPacket(direction: .toGateway, geohash: "", event: event) == nil)
#expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 13), event: event) == nil)
#expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 12), event: event) != nil)
}
@Test("skips unknown TLVs for forward compatibility")
func unknownTLVSkipped() throws {
let event = try makeEvent()
let packet = try #require(NostrCarrierPacket(direction: .fromGateway, geohash: "u4pruy", event: event))
var encoded = try #require(packet.encode())
// Append an unknown TLV (type 0x7F, 2-byte value).
encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
let decoded = try #require(NostrCarrierPacket.decode(encoded))
#expect(decoded == packet)
}
@Test("rejects truncated and missing-field payloads")
func malformedRejected() throws {
let event = try makeEvent()
let packet = try #require(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", event: event))
let encoded = try #require(packet.encode())
// Truncation anywhere inside the last TLV fails cleanly.
#expect(NostrCarrierPacket.decode(encoded.dropLast(1)) == nil)
#expect(NostrCarrierPacket.decode(encoded.prefix(4)) == nil)
#expect(NostrCarrierPacket.decode(Data()) == nil)
// Direction TLV alone (missing geohash and event) fails.
#expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x01])) == nil)
// Unknown direction value fails.
#expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x77])) == nil)
}
}
@@ -1,176 +0,0 @@
import CryptoKit
import Foundation
import Testing
@testable import bitchat
struct VouchAttestationTests {
private let voucherKey = Curve25519.Signing.PrivateKey()
private func makeAttestation(
fingerprint: Data = Data(repeating: 0xAA, count: 32),
signingKey: Data = Data(repeating: 0xBB, count: 32),
timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
signedBy key: Curve25519.Signing.PrivateKey? = nil
) throws -> VouchAttestation {
let signer = key ?? voucherKey
return try #require(
VouchAttestation.build(
voucheeFingerprint: fingerprint,
voucheeSigningKey: signingKey,
timestampMs: timestampMs,
sign: { try? signer.signature(for: $0) }
)
)
}
@Test
func roundTripsAndVerifiesSignature() throws {
let attestation = try makeAttestation()
let encoded = try #require(attestation.encode())
let decoded = try #require(VouchAttestation.decode(from: encoded))
#expect(decoded == attestation)
#expect(decoded.voucheeFingerprintHex == String(repeating: "aa", count: 32))
#expect(decoded.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation))
}
@Test
func decodeSkipsUnknownTLVsAndRejectsMalformedInput() throws {
let attestation = try makeAttestation()
var encoded = try #require(attestation.encode())
// Unknown TLV appended: skipped for forward compatibility.
encoded.append(contentsOf: [0x7F, 0x02, 0x01, 0x02])
#expect(VouchAttestation.decode(from: encoded) == attestation)
// Truncation and missing fields are rejected.
#expect(VouchAttestation.decode(from: encoded.dropLast()) == nil)
#expect(VouchAttestation.decode(from: Data([0x01, 0x20])) == nil)
#expect(VouchAttestation.decode(from: Data()) == nil)
// Wrong field sizes are rejected.
var wrongSize = Data([0x01, 0x10])
wrongSize.append(Data(repeating: 0xAA, count: 16))
#expect(VouchAttestation.decode(from: wrongSize) == nil)
}
@Test
func buildRejectsWrongKeyAndFingerprintSizes() {
let sign: (Data) -> Data? = { try? self.voucherKey.signature(for: $0) }
#expect(VouchAttestation.build(
voucheeFingerprint: Data(repeating: 0xAA, count: 16),
voucheeSigningKey: Data(repeating: 0xBB, count: 32),
sign: sign
) == nil)
#expect(VouchAttestation.build(
voucheeFingerprint: Data(repeating: 0xAA, count: 32),
voucheeSigningKey: Data(repeating: 0xBB, count: 16),
sign: sign
) == nil)
}
@Test
func forgedSignatureFailsVerification() throws {
let attestation = try makeAttestation()
let otherKey = Curve25519.Signing.PrivateKey()
// Verifying against a key that didn't sign fails.
#expect(!attestation.verifySignature(voucherSigningKey: otherKey.publicKey.rawRepresentation))
// An attestation signed by an imposter fails against the real key.
let forged = try makeAttestation(signedBy: otherKey)
#expect(!forged.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation))
#expect(!attestation.verifySignature(voucherSigningKey: Data(repeating: 0x01, count: 3)))
}
@Test
func tamperedFieldsFailVerification() throws {
let attestation = try makeAttestation()
let publicKey = voucherKey.publicKey.rawRepresentation
var tamperedFingerprint = attestation.voucheeFingerprint
tamperedFingerprint[0] ^= 0xFF
let tampered = VouchAttestation(
voucheeFingerprint: tamperedFingerprint,
voucheeSigningKey: attestation.voucheeSigningKey,
timestampMs: attestation.timestampMs,
signature: attestation.signature
)
#expect(!tampered.verifySignature(voucherSigningKey: publicKey))
let backdated = VouchAttestation(
voucheeFingerprint: attestation.voucheeFingerprint,
voucheeSigningKey: attestation.voucheeSigningKey,
timestampMs: attestation.timestampMs - 1,
signature: attestation.signature
)
#expect(!backdated.verifySignature(voucherSigningKey: publicKey))
}
@Test
func expiryWindowIsEnforced() throws {
let now = Date()
let fresh = try makeAttestation(timestampMs: UInt64(now.timeIntervalSince1970 * 1000))
#expect(!fresh.isExpired(now: now))
let thirtyOneDaysAgo = now.addingTimeInterval(-31 * 24 * 60 * 60)
let expired = try makeAttestation(timestampMs: UInt64(thirtyOneDaysAgo.timeIntervalSince1970 * 1000))
#expect(expired.isExpired(now: now))
let farFuture = now.addingTimeInterval(2 * 60 * 60)
let fromTheFuture = try makeAttestation(timestampMs: UInt64(farFuture.timeIntervalSince1970 * 1000))
#expect(fromTheFuture.isExpired(now: now))
// A verified-but-expired attestation still has a valid signature; the
// two checks are independent gates.
#expect(expired.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation))
}
@Test
func batchRoundTripsAndEnforcesCap() throws {
let attestations = try (0..<3).map { index in
try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32))
}
let payload = try #require(VouchAttestation.encodeList(attestations))
#expect(VouchAttestation.decodeList(from: payload) == attestations)
#expect(VouchAttestation.encodeList([]) == nil)
let tooMany = try (0..<17).map { index in
try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32))
}
#expect(VouchAttestation.encodeList(tooMany) == nil)
}
@Test
func decodeListIgnoresEntriesBeyondCapAndMalformedEntries() throws {
let attestations = try (0..<17).map { index in
try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32))
}
// Hand-build an oversized batch that lies about its count.
var payload = Data([UInt8(attestations.count)])
for attestation in attestations {
let encoded = try #require(attestation.encode())
payload.append(UInt8(encoded.count >> 8))
payload.append(UInt8(encoded.count & 0xFF))
payload.append(encoded)
}
let decoded = VouchAttestation.decodeList(from: payload)
#expect(decoded.count == VouchAttestation.maxBatchCount)
#expect(decoded == Array(attestations.prefix(VouchAttestation.maxBatchCount)))
// A malformed middle entry is dropped without killing the batch.
let good = try makeAttestation()
let goodEncoded = try #require(good.encode())
var mixed = Data([2])
mixed.append(contentsOf: [0x00, 0x03, 0xDE, 0xAD, 0xBE])
mixed.append(UInt8(goodEncoded.count >> 8))
mixed.append(UInt8(goodEncoded.count & 0xFF))
mixed.append(goodEncoded)
#expect(VouchAttestation.decodeList(from: mixed) == [good])
#expect(VouchAttestation.decodeList(from: Data()) == [])
#expect(VouchAttestation.decodeList(from: Data([5])) == [])
}
}
@@ -135,140 +135,6 @@ struct BLEFragmentAssemblyBufferTests {
}
}
@Test
func stalledBroadcastAssemblyReportsFragmentIDOnceUntilRetryLapses() throws {
var buffer = BLEFragmentAssemblyBuffer()
let fragmentID = Data((1...8).map { UInt8($0) })
let packet = makePacket(payload: makePayload(count: 256))
let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID)
let first = try #require(BLEFragmentHeader(packet: fragments[0]))
let t0 = Date(timeIntervalSince1970: 100)
_ = buffer.append(first, maxInFlightAssemblies: 8, now: t0)
// Not yet stalled.
let early = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(4))
#expect(early.isEmpty)
// Stalled: reported once, big-endian stream ID.
let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6))
#expect(stalled == [fragmentID])
// Within the retry window: not re-reported.
let repeated = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(8))
#expect(repeated.isEmpty)
// After the retry window it is requested again.
let retried = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(17))
#expect(retried == [fragmentID])
}
@Test
func newFragmentResetsStallClockAndCompletionStopsRequests() throws {
var buffer = BLEFragmentAssemblyBuffer()
let fragmentID = Data((10...17).map { UInt8($0) })
let packet = makePacket(payload: makePayload(count: 384))
let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID)
let headers = try fragments.map { try #require(BLEFragmentHeader(packet: $0)) }
#expect(headers.count >= 3)
let t0 = Date(timeIntervalSince1970: 100)
_ = buffer.append(headers[0], maxInFlightAssemblies: 8, now: t0)
// A fragment arriving at t0+4 resets the stall clock.
_ = buffer.append(headers[1], maxInFlightAssemblies: 8, now: t0.addingTimeInterval(4))
let afterProgress = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6))
#expect(afterProgress.isEmpty)
// Completion removes the assembly entirely.
var result: BLEFragmentAssemblyBuffer.AppendResult?
for header in headers.dropFirst(2) {
result = buffer.append(header, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(5))
}
guard case .complete = result else {
Issue.record("Expected assembly to complete")
return
}
let afterCompletion = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(60))
#expect(afterCompletion.isEmpty)
}
@Test
func duplicateFragmentsDoNotResetStallClock() throws {
var buffer = BLEFragmentAssemblyBuffer()
let fragmentID = Data((20...27).map { UInt8($0) })
let packet = makePacket(payload: makePayload(count: 256))
let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID)
let first = try #require(BLEFragmentHeader(packet: fragments[0]))
let t0 = Date(timeIntervalSince1970: 100)
_ = buffer.append(first, maxInFlightAssemblies: 8, now: t0)
// Relay duplicates of the same index arrive every few seconds; they
// bring no new data, so they must not keep the stream "fresh".
_ = buffer.append(first, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(3))
_ = buffer.append(first, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(5))
let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6))
#expect(stalled == [fragmentID])
}
@Test
func overflowStalledStreamsRotateAcrossPasses() throws {
var buffer = BLEFragmentAssemblyBuffer()
let cap = RequestSyncPacket.maxFragmentIdFilterCount
let streamCount = cap + 10
let t0 = Date(timeIntervalSince1970: 100)
// Incomplete broadcast assemblies with staggered last-fragment times
// (stream 0 is the oldest stall).
var ids: [Data] = []
for i in 0..<streamCount {
let fragmentID = Data([0xAB, 0, 0, 0, 0, 0, UInt8(i >> 8), UInt8(i & 0xFF)])
ids.append(fragmentID)
let header = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
fragmentID: fragmentID,
index: 0,
total: 2,
originalType: MessageType.message.rawValue,
fragmentData: Data([0x01])
)))
_ = buffer.append(header, maxInFlightAssemblies: streamCount, now: t0.addingTimeInterval(Double(i)))
}
// All streams are stalled; only the cap's worth (oldest first) is
// requested and rate-limited, the overflow stays eligible.
let firstPassAt = t0.addingTimeInterval(Double(streamCount) + 5)
let firstPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt)
#expect(firstPass == Array(ids.prefix(cap)))
// Next pass picks up exactly the overflow streams.
let secondPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt.addingTimeInterval(1))
#expect(secondPass == Array(ids.suffix(streamCount - cap)))
// Nothing left until a retry window lapses.
let thirdPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt.addingTimeInterval(2))
#expect(thirdPass.isEmpty)
}
@Test
func directedAssembliesAreNeverReportedAsStalled() throws {
var buffer = BLEFragmentAssemblyBuffer()
let fragment = makeFragmentPacket(
fragmentID: Data(repeating: 0x0A, count: 8),
index: 0,
total: 2,
originalType: MessageType.message.rawValue,
fragmentData: Data([0x01]),
recipientID: Data(hexString: "0102030405060708")
)
let header = try #require(BLEFragmentHeader(packet: fragment))
let t0 = Date(timeIntervalSince1970: 100)
_ = buffer.append(header, maxInFlightAssemblies: 8, now: t0)
let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(60))
#expect(stalled.isEmpty)
}
private func makePacket(payload: Data, timestamp: UInt64 = 0x0102030405) -> BitchatPacket {
BitchatPacket(
type: MessageType.message.rawValue,
@@ -1,98 +0,0 @@
//
// BLESourceRouteFailureCacheTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
struct BLESourceRouteFailureCacheTests {
private let recipient = PeerID(str: "0102030405060708")
private let config = BLESourceRouteFailureCache.Config(
confirmationWindowSeconds: 10,
suppressionSeconds: 60
)
private func attempts(_ cache: inout BLESourceRouteFailureCache, at date: Date) -> Bool {
cache.shouldAttemptRoute(to: recipient, now: date)
}
@Test func allowsRoutingByDefault() {
var cache = BLESourceRouteFailureCache(config: config)
#expect(attempts(&cache, at: Date()))
}
@Test func unconfirmedRoutedSendSuppressesRouting() {
var cache = BLESourceRouteFailureCache(config: config)
let t0 = Date()
cache.noteRoutedSend(to: recipient, now: t0)
// Inside the confirmation window: keep routing.
#expect(attempts(&cache, at: t0.addingTimeInterval(5)))
// Past the window with no inbound traffic: route failed, flood.
#expect(!attempts(&cache, at: t0.addingTimeInterval(11)))
// Still suppressed for the suppression TTL.
#expect(!attempts(&cache, at: t0.addingTimeInterval(40)))
// Suppression lapses: routing may be attempted again.
#expect(attempts(&cache, at: t0.addingTimeInterval(11 + 61)))
}
@Test func inboundActivityConfirmsPendingSend() {
var cache = BLESourceRouteFailureCache(config: config)
let t0 = Date()
cache.noteRoutedSend(to: recipient, now: t0)
cache.noteInboundActivity(from: recipient)
// Confirmed: no suppression even long after the window.
#expect(attempts(&cache, at: t0.addingTimeInterval(30)))
}
@Test func inboundActivityDoesNotLiftActiveSuppression() {
var cache = BLESourceRouteFailureCache(config: config)
let t0 = Date()
cache.noteRoutedSend(to: recipient, now: t0)
// Trip the failure suppression starts at t0+15.
#expect(!attempts(&cache, at: t0.addingTimeInterval(15)))
// Inbound traffic may have arrived via flood; suppression holds.
cache.noteInboundActivity(from: recipient)
#expect(!attempts(&cache, at: t0.addingTimeInterval(20)))
#expect(attempts(&cache, at: t0.addingTimeInterval(15 + 61)))
}
@Test func backToBackSendsShareOneDeadline() {
var cache = BLESourceRouteFailureCache(config: config)
let t0 = Date()
cache.noteRoutedSend(to: recipient, now: t0)
cache.noteRoutedSend(to: recipient, now: t0.addingTimeInterval(8))
// Deadline runs from the first unconfirmed send.
#expect(!attempts(&cache, at: t0.addingTimeInterval(11)))
}
@Test func pruneDropsExpiredEntries() {
var cache = BLESourceRouteFailureCache(config: config)
let t0 = Date()
cache.noteRoutedSend(to: recipient, now: t0)
// Past confirmation + suppression: the entry can no longer matter.
cache.prune(now: t0.addingTimeInterval(75))
#expect(attempts(&cache, at: t0.addingTimeInterval(76)))
}
@Test func pruneKeepsEntriesThatStillMatter() {
var cache = BLESourceRouteFailureCache(config: config)
let t0 = Date()
cache.noteRoutedSend(to: recipient, now: t0)
cache.prune(now: t0.addingTimeInterval(30))
// The unconverted pending entry survives pruning and still converts
// into a suppression on the next routing decision.
#expect(!attempts(&cache, at: t0.addingTimeInterval(31)))
}
}
@@ -1,98 +0,0 @@
//
// BLESourceRouteOriginationPolicyTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
struct BLESourceRouteOriginationPolicyTests {
private let localPeerIDData = Data(hexString: "0102030405060708")!
private let recipient = PeerID(str: "1112131415161718")
private let hop = Data(hexString: "2122232425262728")!
private func makePacket(
senderID: Data? = nil,
recipientID: Data? = Data(hexString: "1112131415161718"),
ttl: UInt8 = 7
) -> BitchatPacket {
BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: senderID ?? localPeerIDData,
recipientID: recipientID,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data([0x01]),
signature: nil,
ttl: ttl
)
}
private func decide(
packet: BitchatPacket,
isRecipientConnected: Bool = false,
shouldAttemptRoute: Bool = true,
computedRoute: [Data]? = nil
) -> BLESourceRouteOriginationPolicy.Decision {
BLESourceRouteOriginationPolicy.decide(
for: packet,
to: recipient,
localPeerIDData: localPeerIDData,
isRecipientConnected: { _ in isRecipientConnected },
shouldAttemptRoute: { _ in shouldAttemptRoute },
computeRoute: { _ in computedRoute ?? [self.hop] }
)
}
@Test func routesWhenAllGatesPass() {
#expect(decide(packet: makePacket()) == .route([hop]))
}
@Test func relayedPacketNeverGetsRoute() {
let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011"))
#expect(decide(packet: relayed) == .flood(.relayedNotOriginator))
}
@Test func broadcastRecipientNeverGetsRoute() {
let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8))
#expect(decide(packet: broadcast) == .flood(.broadcast))
let noRecipient = makePacket(recipientID: nil)
#expect(decide(packet: noRecipient) == .flood(.broadcast))
}
@Test func linkLocalTTLNeverGetsRoute() {
// TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops.
#expect(decide(packet: makePacket(ttl: 0)) == .flood(.noTTLHeadroom))
#expect(decide(packet: makePacket(ttl: 1)) == .flood(.noTTLHeadroom))
}
@Test func directlyConnectedRecipientNeverGetsRoute() {
#expect(decide(packet: makePacket(), isRecipientConnected: true) == .flood(.recipientDirect))
}
@Test func suppressedRecipientFallsBackToFlood() {
#expect(decide(packet: makePacket(), shouldAttemptRoute: false) == .flood(.routeSuppressed))
}
@Test func missingOrEmptyRouteFallsBackToFlood() {
var sawComputeRoute = false
let result = BLESourceRouteOriginationPolicy.decide(
for: makePacket(),
to: recipient,
localPeerIDData: localPeerIDData,
isRecipientConnected: { _ in false },
shouldAttemptRoute: { _ in true },
computeRoute: { _ in
sawComputeRoute = true
return nil
}
)
#expect(result == .flood(.noPath))
#expect(sawComputeRoute)
#expect(decide(packet: makePacket(), computedRoute: []) == .flood(.noPath))
}
}
-385
View File
@@ -1,385 +0,0 @@
//
// BoardStoreTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import CryptoKit
import Foundation
import Testing
@testable import bitchat
struct BoardStoreTests {
private final class MutableClock: @unchecked Sendable {
var now: Date
init(now: Date) { self.now = now }
}
private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
private func makeStore(clock: MutableClock, fileURL: URL? = nil) -> BoardStore {
BoardStore(persistsToDisk: fileURL != nil, fileURL: fileURL, now: { clock.now })
}
private func tempFileURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("board-store-\(UUID().uuidString).json")
}
private func makePost(
author: Curve25519.Signing.PrivateKey,
geohash: String = "9q8yy",
content: String = "note",
createdAt: UInt64,
lifetimeMs: UInt64 = 24 * 60 * 60 * 1000
) throws -> (wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket) {
let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) })
let key = author.publicKey.rawRepresentation
let expiresAt = createdAt + lifetimeMs
let signingBytes = BoardPostPacket.signingBytes(
postID: postID,
geohash: geohash,
content: content,
authorSigningKey: key,
authorNickname: "tester",
createdAt: createdAt,
expiresAt: expiresAt,
flags: 0
)
let post = BoardPostPacket(
postID: postID,
geohash: geohash,
content: content,
authorSigningKey: key,
authorNickname: "tester",
createdAt: createdAt,
expiresAt: expiresAt,
flags: 0,
signature: try author.signature(for: signingBytes)
)
let wire = BoardWire.post(post)
return (wire, makePacket(payload: wire.encode(), timestamp: createdAt), post)
}
private func makeTombstone(
for post: BoardPostPacket,
author: Curve25519.Signing.PrivateKey,
deletedAt: UInt64,
claimKey: Data? = nil
) throws -> (wire: BoardWire, packet: BitchatPacket) {
let tombstone = BoardTombstonePacket(
postID: post.postID,
authorSigningKey: claimKey ?? author.publicKey.rawRepresentation,
deletedAt: deletedAt,
signature: try author.signature(for: BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt))
)
let wire = BoardWire.tombstone(tombstone)
return (wire, makePacket(payload: wire.encode(), timestamp: deletedAt))
}
private func makePacket(payload: Data, timestamp: UInt64) -> BitchatPacket {
BitchatPacket(
type: MessageType.boardPost.rawValue,
senderID: Data((0..<8).map { _ in UInt8.random(in: 0...255) }),
recipientID: nil,
timestamp: timestamp,
payload: payload,
signature: nil,
ttl: 7
)
}
// MARK: - Ingest basics
@Test func ingestStoresAndDeduplicates() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
let entry = try makePost(author: author, createdAt: baseMs)
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
#expect(store.ingest(entry.wire, packet: entry.packet) == .duplicate)
#expect(store.posts(forGeohash: "9q8yy").count == 1)
#expect(store.posts(forGeohash: "").isEmpty)
#expect(store.syncCandidates().count == 1)
}
@Test func rejectsAlreadyExpiredPost() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
let entry = try makePost(author: author, createdAt: baseMs - 2 * 60 * 60 * 1000, lifetimeMs: 60 * 60 * 1000)
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
}
// MARK: - Receive-time timestamp policy
@Test func rejectsPostCreatedBeyondClockSkew() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs + 60 * 1000)
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
}
@Test func acceptsPostCreatedWithinClockSkew() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs - 60 * 1000)
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
#expect(store.posts(forGeohash: "9q8yy").count == 1)
}
@Test func rejectsPostExpiringTooFarInTheFuture() throws {
// Builds the wire directly, bypassing the decoder's span check, to
// exercise the ingest-level expiresAt bound on its own.
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 30 * 24 * 60 * 60 * 1000)
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
}
// MARK: - Caps and eviction
@Test func perAuthorCapEvictsOldest() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
var oldestID: Data?
for index in 0..<(BoardStore.Limits.maxPostsPerAuthor + 1) {
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
if index == 0 { oldestID = entry.post.postID }
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
}
let posts = store.posts(forGeohash: "9q8yy")
#expect(posts.count == BoardStore.Limits.maxPostsPerAuthor)
#expect(!posts.contains { $0.postID == oldestID })
}
@Test func globalCapEvictsOldest() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
var oldestID: Data?
var author = Curve25519.Signing.PrivateKey()
for index in 0..<(BoardStore.Limits.maxPosts + 1) {
if index % BoardStore.Limits.maxPostsPerAuthor == 0 {
author = Curve25519.Signing.PrivateKey()
}
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
if index == 0 { oldestID = entry.post.postID }
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
}
let posts = store.posts(forGeohash: "9q8yy")
#expect(posts.count == BoardStore.Limits.maxPosts)
#expect(!posts.contains { $0.postID == oldestID })
}
// MARK: - Expiry sweep
@Test func expiredPostsAreSwept() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
let shortLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 60 * 60 * 1000)
let longLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 48 * 60 * 60 * 1000)
store.ingest(shortLived.wire, packet: shortLived.packet)
store.ingest(longLived.wire, packet: longLived.packet)
#expect(store.posts(forGeohash: "9q8yy").count == 2)
clock.now = baseDate.addingTimeInterval(2 * 60 * 60) // 2h later
let remaining = store.posts(forGeohash: "9q8yy")
#expect(remaining.count == 1)
#expect(remaining.first?.postID == longLived.post.postID)
#expect(store.syncCandidates().count == 1)
}
// MARK: - Tombstones
@Test func tombstoneDeletesPostAndPropagatesUntilOriginalExpiry() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 24 * 60 * 60 * 1000)
store.ingest(entry.wire, packet: entry.packet)
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
// Post is gone, tombstone still syncs so the delete propagates.
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
#expect(store.syncCandidates().count == 1)
// Replayed copy of the deleted post is refused.
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
// After the post's original expiry the tombstone is dropped too.
clock.now = baseDate.addingTimeInterval(25 * 60 * 60)
#expect(store.syncCandidates().isEmpty)
}
@Test func tombstoneFromWrongKeyIsRejected() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
let attacker = Curve25519.Signing.PrivateKey()
let entry = try makePost(author: author, createdAt: baseMs)
store.ingest(entry.wire, packet: entry.packet)
// Attacker signs with their own key (self-consistent wire, so it
// passes signature verification) but targets the victim's post.
let forged = try makeTombstone(for: entry.post, author: attacker, deletedAt: baseMs + 1000)
#expect(store.ingest(forged.wire, packet: forged.packet) == .rejected)
#expect(store.posts(forGeohash: "9q8yy").count == 1)
}
@Test func tombstoneArrivingBeforePostSuppressesIt() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
let entry = try makePost(author: author, createdAt: baseMs)
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
}
@Test func orphanTombstoneRetentionIsBoundedByReceiveTime() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
let entry = try makePost(author: author, createdAt: baseMs) // never ingested
// Attacker-chosen far-future deletedAt must not extend retention.
let farFuture = baseMs + 365 * 24 * 60 * 60 * 1000
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: farFuture)
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
#expect(store.syncCandidates().count == 1)
// No post can outlive 7 days from receipt, so neither may an orphan
// tombstone (plus skew allowance).
clock.now = baseDate.addingTimeInterval(8 * 24 * 60 * 60)
#expect(store.syncCandidates().isEmpty)
}
@Test func orphanTombstonePerAuthorCapEvictsOldest() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
var unseenPosts: [(wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket)] = []
for index in 0..<(BoardStore.Limits.maxOrphanTombstonesPerAuthor + 1) {
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index))
unseenPosts.append(entry)
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
}
#expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstonesPerAuthor)
// The oldest orphan was evicted, so its post is no longer suppressed
#expect(store.ingest(unseenPosts[0].wire, packet: unseenPosts[0].packet) == .accepted)
// while the surviving orphans still suppress theirs.
#expect(store.ingest(unseenPosts[1].wire, packet: unseenPosts[1].packet) == .rejected)
}
@Test func orphanTombstoneGlobalCapEvictsOldest() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
var author = Curve25519.Signing.PrivateKey()
for index in 0..<(BoardStore.Limits.maxOrphanTombstones + 1) {
if index % BoardStore.Limits.maxOrphanTombstonesPerAuthor == 0 {
author = Curve25519.Signing.PrivateKey()
}
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index))
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
}
#expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstones)
}
@Test func matchedTombstonesAreExemptFromOrphanCaps() throws {
let clock = MutableClock(now: baseDate)
let store = makeStore(clock: clock)
let author = Curve25519.Signing.PrivateKey()
// Post-then-delete more times than the per-author orphan cap; every
// tombstone matched a live post, so none may be evicted.
let cycles = BoardStore.Limits.maxOrphanTombstonesPerAuthor + 2
for index in 0..<cycles {
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + UInt64(index) * 1000 + 1)
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
}
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
#expect(store.syncCandidates().count == cycles)
}
// MARK: - Persistence and wipe
@Test func persistsAcrossRestart() throws {
let fileURL = tempFileURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let clock = MutableClock(now: baseDate)
let author = Curve25519.Signing.PrivateKey()
let first = makeStore(clock: clock, fileURL: fileURL)
let entry = try makePost(author: author, createdAt: baseMs)
let deleted = try makePost(author: author, createdAt: baseMs + 1)
first.ingest(entry.wire, packet: entry.packet)
first.ingest(deleted.wire, packet: deleted.packet)
let tombstone = try makeTombstone(for: deleted.post, author: author, deletedAt: baseMs + 1000)
first.ingest(tombstone.wire, packet: tombstone.packet)
let second = makeStore(clock: clock, fileURL: fileURL)
let restored = second.posts(forGeohash: "9q8yy")
#expect(restored.count == 1)
#expect(restored.first?.postID == entry.post.postID)
// Post + tombstone both restored into sync.
#expect(second.syncCandidates().count == 2)
// Restart after expiry drops everything.
clock.now = baseDate.addingTimeInterval(25 * 60 * 60)
let third = makeStore(clock: clock, fileURL: fileURL)
#expect(third.posts(forGeohash: "9q8yy").isEmpty)
#expect(third.syncCandidates().isEmpty)
}
@Test func wipeClearsMemoryAndDisk() throws {
let fileURL = tempFileURL()
defer { try? FileManager.default.removeItem(at: fileURL) }
let clock = MutableClock(now: baseDate)
let author = Curve25519.Signing.PrivateKey()
let store = makeStore(clock: clock, fileURL: fileURL)
let entry = try makePost(author: author, createdAt: baseMs)
store.ingest(entry.wire, packet: entry.packet)
#expect(FileManager.default.fileExists(atPath: fileURL.path))
store.wipe()
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
#expect(store.syncCandidates().isEmpty)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
}
@@ -1,548 +0,0 @@
//
// GatewayServiceTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("Gateway mode policy")
@MainActor
struct GatewayServiceTests {
private static let geohash = "u4pruy"
/// Closure-injected harness around `GatewayService` recording every
/// side effect, with a controllable clock and relay connectivity.
@MainActor
private final class Fixture {
private final class ClockBox {
var now = Date()
}
var relaysConnected = true
var currentGeohash: String? = GatewayServiceTests.geohash
var gatewayPeers: [PeerID] = []
var sendToGatewaySucceeds = true
private(set) var published: [(event: NostrEvent, geohash: String)] = []
private(set) var broadcasts: [Data] = []
private(set) var injected: [NostrEvent] = []
private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = []
private(set) var enabledChanges: [Bool] = []
private(set) var scheduledDrains: [(delay: TimeInterval, work: @MainActor () -> Void)] = []
private let clock = ClockBox()
let defaults: UserDefaults
let service: GatewayService
init(enabled: Bool = true, suite: String = "GatewayServiceTests-\(UUID().uuidString)") {
defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
let clock = clock
service = GatewayService(defaults: defaults) { clock.now }
service.publishToRelays = { [weak self] event, geohash in
self?.published.append((event, geohash))
}
service.broadcastToMesh = { [weak self] payload in
self?.broadcasts.append(payload)
}
service.sendToGatewayPeer = { [weak self] payload, peer in
guard let self, self.sendToGatewaySucceeds else { return false }
self.uplinkSends.append((payload, peer))
return true
}
service.availableGatewayPeers = { [weak self] in self?.gatewayPeers ?? [] }
service.relaysConnected = { [weak self] in self?.relaysConnected ?? false }
service.currentGeohash = { [weak self] in self?.currentGeohash }
service.injectInbound = { [weak self] event in self?.injected.append(event) }
service.onEnabledChanged = { [weak self] enabled in self?.enabledChanges.append(enabled) }
// Capture drain timers instead of arming a real Task so the drain
// is deterministic under the fake clock.
service.scheduleDrainTimer = { [weak self] delay, work in
self?.scheduledDrains.append((delay, work))
}
if enabled {
service.setEnabled(true)
}
}
func advance(_ seconds: TimeInterval) {
clock.now = clock.now.addingTimeInterval(seconds)
}
/// Fires every currently-scheduled drain timer (simulating the window
/// freeing), as the real Task would after its delay.
func fireScheduledDrains() {
let due = scheduledDrains
scheduledDrains.removeAll()
for item in due { item.work() }
}
}
// MARK: Event helpers
private func makeEvent(
geohash: String = GatewayServiceTests.geohash,
content: String = "hello \(UUID().uuidString.prefix(8))"
) throws -> NostrEvent {
let identity = try NostrIdentity.generate()
return try NostrProtocol.createEphemeralGeohashEvent(
content: content,
geohash: geohash,
senderIdentity: identity,
nickname: "tester"
)
}
/// A copy of `event` with tampered content but the original ID and
/// signature what a forging gateway or mesh peer would produce.
private func forge(_ event: NostrEvent) throws -> NostrEvent {
let dict: [String: Any] = [
"id": event.id,
"pubkey": event.pubkey,
"created_at": event.created_at,
"kind": event.kind,
"tags": event.tags,
"content": event.content + " (tampered)",
"sig": event.sig ?? ""
]
return try NostrEvent(from: dict)
}
private func carrierPayload(
_ event: NostrEvent,
direction: NostrCarrierPacket.Direction = .toGateway,
geohash: String = GatewayServiceTests.geohash
) throws -> Data {
let packet = try #require(NostrCarrierPacket(direction: direction, geohash: geohash, event: event))
return try #require(packet.encode())
}
private func deposit(
_ event: NostrEvent,
into fixture: Fixture,
from depositor: PeerID = PeerID(str: "1122334455667788"),
geohash: String = GatewayServiceTests.geohash
) throws {
let payload = try carrierPayload(event, direction: .toGateway, geohash: geohash)
fixture.service.handleMeshCarrier(payload, from: depositor, directedToUs: true)
}
// MARK: - Uplink verification gates
@Test("publishes a verified deposit to the geo relays")
func verifiedDepositPublished() throws {
let fixture = Fixture()
let event = try makeEvent()
try deposit(event, into: fixture)
#expect(fixture.published.count == 1)
#expect(fixture.published.first?.event.id == event.id)
#expect(fixture.published.first?.geohash == Self.geohash)
// Viewing the same geohash: the carried message shows on our own timeline.
#expect(fixture.injected.map(\.id) == [event.id])
}
@Test("rejects a forged signature")
func forgedSignatureRejected() throws {
let fixture = Fixture()
let forged = try forge(try makeEvent())
try deposit(forged, into: fixture)
#expect(fixture.published.isEmpty)
#expect(fixture.injected.isEmpty)
}
@Test("rejects wrong kind, geohash mismatch, and stale events")
func structuralGates() throws {
let fixture = Fixture()
// Wrong kind (kind-1 text note instead of kind-20000 ephemeral).
let identity = try NostrIdentity.generate()
let note = try NostrProtocol.createGeohashTextNote(
content: "note",
geohash: Self.geohash,
senderIdentity: identity
)
try deposit(note, into: fixture)
#expect(fixture.published.isEmpty)
// Carrier geohash disagreeing with the event's #g tag.
let mismatched = try makeEvent(geohash: "9q8yyk")
try deposit(mismatched, into: fixture, geohash: Self.geohash)
#expect(fixture.published.isEmpty)
// Stale event (beyond accepted clock skew).
let stale = try makeEvent()
fixture.advance(GatewayService.Limits.maxEventAgeSeconds + 60)
try deposit(stale, into: fixture)
#expect(fixture.published.isEmpty)
}
@Test("does nothing while the toggle is off")
func disabledGatewayIgnoresDeposits() throws {
let fixture = Fixture(enabled: false)
try deposit(try makeEvent(), into: fixture)
#expect(fixture.published.isEmpty)
#expect(fixture.service.queuedUplinks.isEmpty)
fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash)
#expect(fixture.broadcasts.isEmpty)
}
// MARK: - Uplink quotas and rate limit
@Test("rate-limits deposits per depositor per minute")
func uplinkRateLimit() throws {
let fixture = Fixture()
let depositor = PeerID(str: "aabbccddeeff0011")
for _ in 0..<GatewayService.Limits.uplinkEventsPerMinutePerDepositor {
try deposit(try makeEvent(), into: fixture, from: depositor)
}
#expect(fixture.published.count == GatewayService.Limits.uplinkEventsPerMinutePerDepositor)
// One over budget inside the window: dropped.
try deposit(try makeEvent(), into: fixture, from: depositor)
#expect(fixture.published.count == GatewayService.Limits.uplinkEventsPerMinutePerDepositor)
// Another depositor is unaffected.
try deposit(try makeEvent(), into: fixture, from: PeerID(str: "0011223344556677"))
#expect(fixture.published.count == GatewayService.Limits.uplinkEventsPerMinutePerDepositor + 1)
// The window slides.
fixture.advance(61)
try deposit(try makeEvent(), into: fixture, from: depositor)
#expect(fixture.published.count == GatewayService.Limits.uplinkEventsPerMinutePerDepositor + 2)
}
@Test("bounds the offline uplink queue per depositor and in total, evicting oldest")
func uplinkQueueQuotas() throws {
let fixture = Fixture()
fixture.relaysConnected = false
let depositorA = PeerID(str: "1111111111111111")
var firstFromA: NostrEvent?
for i in 0..<(GatewayService.Limits.maxQueuedUplinksPerDepositor + 1) {
let event = try makeEvent()
if i == 0 { firstFromA = event }
try deposit(event, into: fixture, from: depositorA)
}
// The sixth deposit from the same depositor is rejected.
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
// Fill the global queue with other depositors.
var filler = GatewayService.Limits.maxQueuedUplinksPerDepositor
var suffix = 2
while filler < GatewayService.Limits.maxQueuedUplinks {
let depositor = PeerID(str: String(repeating: "\(suffix)", count: 16))
for _ in 0..<GatewayService.Limits.maxQueuedUplinksPerDepositor {
try deposit(try makeEvent(), into: fixture, from: depositor)
}
filler += GatewayService.Limits.maxQueuedUplinksPerDepositor
suffix += 1
}
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinks)
// One more from a fresh depositor evicts the oldest queued deposit.
let newest = try makeEvent()
try deposit(newest, into: fixture, from: PeerID(str: "9999999999999999"))
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinks)
#expect(fixture.service.queuedUplinks.contains { $0.event.id == newest.id })
#expect(!fixture.service.queuedUplinks.contains { $0.event.id == firstFromA?.id })
// Relay connectivity returning flushes the queue.
fixture.relaysConnected = true
fixture.service.flushQueuedUplinks()
#expect(fixture.published.count == GatewayService.Limits.maxQueuedUplinks)
#expect(fixture.service.queuedUplinks.isEmpty)
}
@Test("absorbs repeat deposits of the same event")
func repeatDepositAbsorbed() throws {
let fixture = Fixture()
let event = try makeEvent()
try deposit(event, into: fixture)
try deposit(event, into: fixture, from: PeerID(str: "8877665544332211"))
#expect(fixture.published.count == 1)
}
@Test("a quota-dropped deposit is not rendered on the local timeline")
func quotaDroppedDepositNotInjected() throws {
let fixture = Fixture()
fixture.relaysConnected = false
let depositor = PeerID(str: "1111111111111111")
// Fill this depositor's offline queue to its per-depositor cap; each
// accepted deposit is rendered locally (we view that geohash).
for _ in 0..<GatewayService.Limits.maxQueuedUplinksPerDepositor {
try deposit(try makeEvent(), into: fixture, from: depositor)
}
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
#expect(fixture.injected.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
// One past the cap: dropped by the queue quota, so it is neither
// queued nor shown no phantom local message.
try deposit(try makeEvent(), into: fixture, from: depositor)
#expect(fixture.service.queuedUplinks.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
#expect(fixture.injected.count == GatewayService.Limits.maxQueuedUplinksPerDepositor)
}
// MARK: - Loop prevention
@Test("never re-publishes or re-carries an event learned from a fromGateway broadcast")
func meshCarriedNeverRepublished() throws {
let fixture = Fixture()
let event = try makeEvent()
let broadcast = try carrierPayload(event, direction: .fromGateway)
// Another gateway rebroadcast this event onto the mesh; we saw it.
fixture.service.handleMeshCarrier(broadcast, from: PeerID(str: "8877665544332211"), directedToUs: false)
#expect(fixture.injected.map(\.id) == [event.id])
// Rule: a deposit of the same event must never be published
try deposit(event, into: fixture)
#expect(fixture.published.isEmpty)
// a relay echo of it must never be rebroadcast (we'd loop it back)
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
#expect(fixture.broadcasts.isEmpty)
// and it must never be re-uplinked from this device.
fixture.relaysConnected = false
fixture.gatewayPeers = [PeerID(str: "8877665544332211")]
#expect(!fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
#expect(fixture.uplinkSends.isEmpty)
}
@Test("rebroadcasts a relay event at most once, absorbing echoes")
func relayEventRebroadcastOnce() throws {
let fixture = Fixture()
let event = try makeEvent()
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
#expect(fixture.broadcasts.count == 1)
// The rebroadcast payload decodes as a fromGateway carrier for the
// same signed event.
let payload = try #require(fixture.broadcasts.first)
let decoded = try #require(NostrCarrierPacket.decode(payload))
#expect(decoded.direction == .fromGateway)
#expect(decoded.event()?.id == event.id)
}
@Test("a forged broadcast cannot poison the loop-prevention set")
func forgedBroadcastDoesNotPoison() throws {
let fixture = Fixture()
let event = try makeEvent()
let forgedPayload = try carrierPayload(try forge(event), direction: .fromGateway)
fixture.service.handleMeshCarrier(forgedPayload, from: PeerID(str: "8877665544332211"), directedToUs: false)
#expect(fixture.injected.isEmpty)
// The genuine event still uplinks: the forged copy (sharing its ID)
// was rejected before the mesh-carried marking.
try deposit(event, into: fixture)
#expect(fixture.published.map(\.event.id) == [event.id])
}
// MARK: - Downlink bandwidth guard
@Test("caps mesh rebroadcasts per minute, queueing with drop-oldest")
func downlinkBudget() throws {
let fixture = Fixture()
let overBudget = GatewayService.Limits.downlinkEventsPerMinute + 5
var events: [NostrEvent] = []
for _ in 0..<overBudget {
let event = try makeEvent()
events.append(event)
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
}
#expect(fixture.broadcasts.count == GatewayService.Limits.downlinkEventsPerMinute)
// Once the window slides, the next relay event drains the backlog too.
fixture.advance(61)
fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash)
#expect(fixture.broadcasts.count == overBudget + 1)
}
@Test("does not spend downlink budget on stale backfill or geohash mismatch")
func downlinkDropsStaleAndMismatched() throws {
let fixture = Fixture()
// A fresh, matching event rebroadcasts.
let fresh = try makeEvent()
// An event whose #g tag disagrees with the carrier geohash.
let mismatched = try makeEvent(geohash: "9q8yyk")
// An event that will age past the receiver window before we offer it.
let willBeStale = try makeEvent()
fixture.service.rebroadcastRelayEvent(fresh, geohash: Self.geohash)
#expect(fixture.broadcasts.count == 1)
fixture.service.rebroadcastRelayEvent(mismatched, geohash: Self.geohash)
#expect(fixture.broadcasts.count == 1) // geohash mismatch: dropped pre-budget
fixture.advance(GatewayService.Limits.maxEventAgeSeconds + 60)
fixture.service.rebroadcastRelayEvent(willBeStale, geohash: Self.geohash)
#expect(fixture.broadcasts.count == 1) // stale backfill: dropped pre-budget
}
@Test("drains a downlink backlog on the scheduled timer when the channel goes quiet")
func downlinkDrainsOnTimer() throws {
let fixture = Fixture()
let overBudget = GatewayService.Limits.downlinkEventsPerMinute + 5
for _ in 0..<overBudget {
fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash)
}
// Budget spent; the tail is queued and a drain timer was armed.
#expect(fixture.broadcasts.count == GatewayService.Limits.downlinkEventsPerMinute)
#expect(!fixture.scheduledDrains.isEmpty)
// The channel goes quiet no new inbound event. The window frees and
// the timer fires on its own, draining the remaining backlog.
fixture.advance(61)
fixture.fireScheduledDrains()
#expect(fixture.broadcasts.count == overBudget)
}
// MARK: - Receiver downlink handling
@Test("injects a carried event once, even when broadcast twice")
func downlinkInjectedOnce() throws {
let fixture = Fixture(enabled: false) // receivers need no toggle
let event = try makeEvent()
let payload = try carrierPayload(event, direction: .fromGateway)
fixture.service.handleMeshCarrier(payload, from: PeerID(str: "8877665544332211"), directedToUs: false)
fixture.service.handleMeshCarrier(payload, from: PeerID(str: "7766554433221100"), directedToUs: false)
#expect(fixture.injected.map(\.id) == [event.id])
}
@Test("only injects events for the geohash channel being viewed")
func downlinkChannelScoped() throws {
let fixture = Fixture(enabled: false)
fixture.currentGeohash = "9q8yyk"
let event = try makeEvent()
let payload = try carrierPayload(event, direction: .fromGateway)
fixture.service.handleMeshCarrier(payload, from: PeerID(str: "8877665544332211"), directedToUs: false)
#expect(fixture.injected.isEmpty)
}
@Test("a directed fromGateway or broadcast toGateway carrier is malformed and dropped")
func directionMisuseDropped() throws {
let fixture = Fixture()
let event = try makeEvent()
let downlink = try carrierPayload(event, direction: .fromGateway)
fixture.service.handleMeshCarrier(downlink, from: PeerID(str: "8877665544332211"), directedToUs: true)
#expect(fixture.injected.isEmpty)
let uplink = try carrierPayload(event, direction: .toGateway)
fixture.service.handleMeshCarrier(uplink, from: PeerID(str: "8877665544332211"), directedToUs: false)
#expect(fixture.published.isEmpty)
}
// MARK: - Mesh-only sender uplink
@Test("uplinks via a mesh gateway only when relays are down and a gateway exists")
func uplinkViaMeshConditions() throws {
let fixture = Fixture(enabled: false)
let gatewayPeer = PeerID(str: "8877665544332211")
let event = try makeEvent()
// Relays working: no mesh uplink.
fixture.relaysConnected = true
fixture.gatewayPeers = [gatewayPeer]
#expect(!fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
// Relays down, no gateway around: no mesh uplink.
fixture.relaysConnected = false
fixture.gatewayPeers = []
#expect(!fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
// Relays down and a gateway peer advertised: uplink goes out.
fixture.gatewayPeers = [gatewayPeer]
#expect(fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
#expect(fixture.uplinkSends.count == 1)
#expect(fixture.uplinkSends.first?.peer == gatewayPeer)
let sentPayload = try #require(fixture.uplinkSends.first?.payload)
let sent = try #require(NostrCarrierPacket.decode(sentPayload))
#expect(sent.direction == .toGateway)
#expect(sent.geohash == Self.geohash)
#expect(sent.event()?.id == event.id)
// A failed transport hand-off reports false.
fixture.sendToGatewaySucceeds = false
#expect(!fixture.service.uplinkViaMesh(event: try makeEvent(), geohash: Self.geohash))
}
@Test("uplinkViaMesh backstop refuses an event this gateway already published")
func uplinkViaMeshBackstopsPublished() throws {
let fixture = Fixture()
let event = try makeEvent()
// This gateway publishes the event to relays (loop rule 2 records it
// in publishedEventIDs).
try deposit(event, into: fixture)
#expect(fixture.published.map(\.event.id) == [event.id])
// Relays then drop and a gateway peer appears. The same event must not
// be re-uplinked from this device the publishedEventIDs backstop in
// uplinkViaMesh catches it even though it was never a mesh broadcast.
fixture.relaysConnected = false
fixture.gatewayPeers = [PeerID(str: "8877665544332211")]
#expect(!fixture.service.uplinkViaMesh(event: event, geohash: Self.geohash))
#expect(fixture.uplinkSends.isEmpty)
}
// MARK: - Toggle
@Test("persists the toggle and reports changes")
func togglePersistsAndNotifies() throws {
let suite = "GatewayServiceTests-\(UUID().uuidString)"
let fixture = Fixture(enabled: false, suite: suite)
#expect(!fixture.service.isEnabled)
fixture.service.setEnabled(true)
#expect(fixture.enabledChanges == [true])
// Setting the same value again is a no-op.
fixture.service.setEnabled(true)
#expect(fixture.enabledChanges == [true])
// A fresh service over the same defaults restores the toggle.
let revived = GatewayService(defaults: UserDefaults(suiteName: suite)!)
#expect(revived.isEnabled)
fixture.service.setEnabled(false)
#expect(fixture.enabledChanges == [true, false])
fixture.defaults.removePersistentDomain(forName: suite)
}
@Test("disabling the toggle drops queued work")
func disableClearsQueues() throws {
let fixture = Fixture()
fixture.relaysConnected = false
try deposit(try makeEvent(), into: fixture)
#expect(fixture.service.queuedUplinks.count == 1)
fixture.service.setEnabled(false)
#expect(fixture.service.queuedUplinks.isEmpty)
// Nothing to flush after re-enabling.
fixture.service.setEnabled(true)
fixture.relaysConnected = true
fixture.service.flushQueuedUplinks()
#expect(fixture.published.isEmpty)
}
}
@@ -1,374 +0,0 @@
//
// GroupProtocolTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import Foundation
import Testing
import BitFoundation
@testable import bitchat
struct GroupProtocolTests {
// MARK: - Fixtures
/// Deterministic member identity: an Ed25519 keypair plus the 64-hex
/// fingerprint the roster pins.
private struct TestIdentity {
let signingKey: Curve25519.Signing.PrivateKey
let fingerprint: String
init(seed: UInt8) {
signingKey = Curve25519.Signing.PrivateKey()
fingerprint = Data(repeating: seed, count: 32).hexEncodedString()
}
var member: GroupMember {
GroupMember(
fingerprint: fingerprint,
signingKey: signingKey.publicKey.rawRepresentation,
nickname: "peer-\(fingerprint.prefix(4))"
)
}
func sign(_ data: Data) -> Data? {
try? signingKey.signature(for: data)
}
}
private let creator = TestIdentity(seed: 0xC1)
private let member = TestIdentity(seed: 0xA2)
private let outsider = TestIdentity(seed: 0xE3)
private let groupID = Data((0..<16).map { UInt8($0) })
private let key = Data(repeating: 0x42, count: 32)
private func makeGroup(extraMembers: [GroupMember] = [], epoch: UInt32 = 1) -> BitchatGroup {
BitchatGroup(
groupID: groupID,
name: "trail crew",
epoch: epoch,
members: [creator.member, member.member] + extraMembers,
creatorFingerprint: creator.fingerprint
)
}
// MARK: - State payload (invite / key update)
@Test func statePayloadRoundTripAndSignatureVerify() throws {
let group = makeGroup()
let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign))
let encoded = try #require(payload.encode())
let decoded = try #require(GroupStatePayload.decode(encoded))
#expect(decoded == payload)
#expect(decoded.groupID == groupID)
#expect(decoded.name == "trail crew")
#expect(decoded.key == key)
#expect(decoded.epoch == 1)
#expect(decoded.members == group.members)
#expect(decoded.creatorFingerprint == creator.fingerprint)
#expect(decoded.verifyCreatorSignature())
#expect(decoded.asGroup == group)
}
@Test func forgedCreatorSignatureIsRejected() throws {
let group = makeGroup()
// Signed by a member who is in the roster but is NOT the creator.
let forged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: member.sign))
#expect(!forged.verifyCreatorSignature())
// An outsider signing is equally rejected.
let outsiderForged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: outsider.sign))
#expect(!outsiderForged.verifyCreatorSignature())
}
@Test func tamperedStateFailsSignature() throws {
let group = makeGroup()
let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign))
// Bumping the epoch invalidates the signature.
let epochTampered = GroupStatePayload(
groupID: payload.groupID,
name: payload.name,
key: payload.key,
epoch: payload.epoch + 1,
members: payload.members,
creatorFingerprint: payload.creatorFingerprint,
signature: payload.signature
)
#expect(!epochTampered.verifyCreatorSignature())
// So does swapping the key.
let keyTampered = GroupStatePayload(
groupID: payload.groupID,
name: payload.name,
key: Data(repeating: 0x99, count: 32),
epoch: payload.epoch,
members: payload.members,
creatorFingerprint: payload.creatorFingerprint,
signature: payload.signature
)
#expect(!keyTampered.verifyCreatorSignature())
// And so does adding a member to the roster.
let rosterTampered = GroupStatePayload(
groupID: payload.groupID,
name: payload.name,
key: payload.key,
epoch: payload.epoch,
members: payload.members + [outsider.member],
creatorFingerprint: payload.creatorFingerprint,
signature: payload.signature
)
#expect(!rosterTampered.verifyCreatorSignature())
}
@Test func creatorMissingFromRosterIsRejected() throws {
// State claiming a creator whose fingerprint is not in the roster has
// no key to verify against and must fail closed.
let group = BitchatGroup(
groupID: groupID,
name: "orphan",
epoch: 1,
members: [member.member],
creatorFingerprint: creator.fingerprint
)
guard let rosterBlob = GroupRosterCoding.encode(group.members) else {
Issue.record("roster should encode")
return
}
let content = GroupStatePayload.signingContent(groupID: groupID, epoch: 1, key: key, rosterBlob: rosterBlob, name: group.name)
let payload = GroupStatePayload(
groupID: groupID,
name: group.name,
key: key,
epoch: 1,
members: group.members,
creatorFingerprint: creator.fingerprint,
signature: creator.sign(content) ?? Data()
)
#expect(!payload.verifyCreatorSignature())
}
@Test func rosterCapIsEnforcedOnTheWire() {
// 17 members cannot be encoded (hard cap is 16)
let seventeen = (0..<17).map { TestIdentity(seed: UInt8($0 + 1)).member }
#expect(GroupRosterCoding.encode(seventeen) == nil)
// and a hand-built blob claiming 17 members fails to decode.
let sixteen = (0..<16).map { TestIdentity(seed: UInt8($0 + 1)).member }
guard var blob = GroupRosterCoding.encode(sixteen) else {
Issue.record("16-member roster should encode")
return
}
#expect(GroupRosterCoding.decode(blob)?.count == 16)
blob[blob.startIndex] = 17
#expect(GroupRosterCoding.decode(blob) == nil)
}
// MARK: - Message seal / open
@Test func messageRoundTrip() throws {
let group = makeGroup()
let timestampMs: UInt64 = 1_750_000_000_000
let sealed = try GroupCrypto.sealMessage(
content: "summit at noon",
messageID: "msg-1",
senderNickname: "alice",
senderSigningKey: member.member.signingKey,
timestampMs: timestampMs,
groupID: groupID,
epoch: group.epoch,
key: key,
sign: member.sign
)
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
#expect(envelope.groupID == groupID)
#expect(envelope.epoch == group.epoch)
let plaintext = try GroupCrypto.openMessage(envelope, key: key)
#expect(plaintext.messageID == "msg-1")
#expect(plaintext.content == "summit at noon")
#expect(plaintext.senderNickname == "alice")
#expect(plaintext.timestampMs == timestampMs)
#expect(plaintext.senderSigningKey == member.member.signingKey)
// The roster resolves the sender; an outsider's key would not.
#expect(group.member(withSigningKey: plaintext.senderSigningKey) != nil)
#expect(group.member(withSigningKey: outsider.member.signingKey) == nil)
}
@Test func wrongKeyFailsToDecrypt() throws {
let sealed = try GroupCrypto.sealMessage(
content: "hi",
messageID: "msg-2",
senderNickname: "alice",
senderSigningKey: member.member.signingKey,
timestampMs: 1,
groupID: groupID,
epoch: 1,
key: key,
sign: member.sign
)
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
#expect(throws: GroupCryptoError.decryptionFailed) {
_ = try GroupCrypto.openMessage(envelope, key: Data(repeating: 0x7F, count: 32))
}
}
@Test func epochIsBoundIntoTheCiphertext() throws {
// Re-labeling an epoch-1 envelope as epoch 2 must break the AEAD:
// a rotated-out member cannot replay old ciphertext into a new epoch.
let sealed = try GroupCrypto.sealMessage(
content: "hi",
messageID: "msg-3",
senderNickname: "alice",
senderSigningKey: member.member.signingKey,
timestampMs: 1,
groupID: groupID,
epoch: 1,
key: key,
sign: member.sign
)
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
let relabeled = GroupMessageEnvelope(
groupID: envelope.groupID,
epoch: 2,
nonce: envelope.nonce,
ciphertext: envelope.ciphertext
)
#expect(throws: GroupCryptoError.decryptionFailed) {
_ = try GroupCrypto.openMessage(relabeled, key: key)
}
}
@Test func badSenderSignatureIsRejected() throws {
// A key-holder who signs with a key other than the one they claim
// (or garbage) is dropped even though decryption succeeds.
let sealed = try GroupCrypto.sealMessage(
content: "spoof",
messageID: "msg-4",
senderNickname: "mallory",
senderSigningKey: member.member.signingKey, // claims member's key
timestampMs: 1,
groupID: groupID,
epoch: 1,
key: key,
sign: outsider.sign // but signs with the outsider's
)
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
#expect(throws: GroupCryptoError.badSenderSignature) {
_ = try GroupCrypto.openMessage(envelope, key: key)
}
}
@Test func tamperedCiphertextFailsToOpen() throws {
let sealed = try GroupCrypto.sealMessage(
content: "hi",
messageID: "msg-5",
senderNickname: "alice",
senderSigningKey: member.member.signingKey,
timestampMs: 1,
groupID: groupID,
epoch: 1,
key: key,
sign: member.sign
)
let envelope = try #require(GroupMessageEnvelope.decode(sealed))
var flipped = envelope.ciphertext
flipped[flipped.startIndex] ^= 0x01
let tampered = GroupMessageEnvelope(
groupID: envelope.groupID,
epoch: envelope.epoch,
nonce: envelope.nonce,
ciphertext: flipped
)
#expect(throws: GroupCryptoError.decryptionFailed) {
_ = try GroupCrypto.openMessage(tampered, key: key)
}
}
@Test func malformedEnvelopesAreRejected() {
#expect(GroupMessageEnvelope.decode(Data()) == nil)
#expect(GroupMessageEnvelope.decode(Data([0x01, 0x00])) == nil)
#expect(GroupStatePayload.decode(Data([0xFF, 0x00, 0x01])) == nil)
}
// MARK: - Oversize / UTF-8 safety (Codex findings)
@Test func oversizeMessageContentFailsToSealInsteadOfTruncating() {
// A content whose UTF-8 exceeds the 16-bit TLV length must fail to
// seal (surfacing send_failed) rather than silently truncate into a
// ciphertext recipients would drop.
let oversize = String(repeating: "a", count: 70_000)
#expect(throws: (any Error).self) {
_ = try GroupCrypto.sealMessage(
content: oversize,
messageID: "big",
senderNickname: "alice",
senderSigningKey: member.member.signingKey,
timestampMs: 1,
groupID: groupID,
epoch: 1,
key: key,
sign: member.sign
)
}
}
@Test func multiByteNicknameTruncatesOnScalarBoundary() throws {
// 40 euro signs = 120 UTF-8 bytes; a raw 64-byte prefix would split
// the 21st scalar and make the roster undecodable. Truncation must
// land on a Character boundary so the blob round-trips.
let euros = String(repeating: "", count: 40)
let wide = GroupMember(
fingerprint: creator.fingerprint,
signingKey: creator.member.signingKey,
nickname: euros
)
let blob = try #require(GroupRosterCoding.encode([wide]))
let decoded = try #require(GroupRosterCoding.decode(blob))
#expect(decoded.count == 1)
#expect(Data(decoded[0].nickname.utf8).count <= 64)
#expect(decoded[0].nickname.allSatisfy { $0 == "" })
#expect(!decoded[0].nickname.isEmpty)
}
// MARK: - Signable-bytes forward-proofing
@Test func creatorSignatureCoversName() throws {
let group = makeGroup()
let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign))
#expect(payload.verifyCreatorSignature())
// Swapping only the display name must invalidate the creator signature.
let renamed = GroupStatePayload(
groupID: payload.groupID,
name: "totally different name",
key: payload.key,
epoch: payload.epoch,
members: payload.members,
creatorFingerprint: payload.creatorFingerprint,
signature: payload.signature
)
#expect(!renamed.verifyCreatorSignature())
}
@Test func messageSignatureCoversEpoch() {
// The signed bytes differ by epoch, so a signature captured at one
// epoch cannot verify when re-sealed under a later epoch key.
let atEpoch1 = GroupCrypto.messageSigningContent(
groupID: groupID, epoch: 1, messageID: "m", timestampMs: 1, content: "x"
)
let atEpoch2 = GroupCrypto.messageSigningContent(
groupID: groupID, epoch: 2, messageID: "m", timestampMs: 1, content: "x"
)
#expect(atEpoch1 != atEpoch2)
}
}

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