mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 05:05:19 +00:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cbfd131da | ||
|
|
b62f98cc62 | ||
|
|
02aa1d67db | ||
|
|
e0e90af9fd | ||
|
|
94e7f91b7b | ||
|
|
00c7f0460a | ||
|
|
175da268dd | ||
|
|
f6f7fa704a | ||
|
|
4f5d25808f | ||
|
|
a05c583fb5 | ||
|
|
25efa62587 | ||
|
|
0d93ce0874 | ||
|
|
20cdd6653a | ||
|
|
862484c0d4 | ||
|
|
e1e481a1a7 | ||
|
|
aed3e6a273 | ||
|
|
b481a70458 | ||
|
|
ccccb2dd8c | ||
|
|
385e9e2aab | ||
|
|
48035872e1 | ||
|
|
d4594b9504 | ||
|
|
9ae440be6a | ||
|
|
fd2dffdda7 | ||
|
|
953d24f7f2 | ||
|
|
c2a5668569 | ||
|
|
6dd8dd055a | ||
|
|
85112d809b | ||
|
|
fb80403cd4 | ||
|
|
7ad19ab4c3 | ||
|
|
b1ff5e6b82 | ||
|
|
ee148a018b | ||
|
|
26b247c329 | ||
|
|
81168adad1 | ||
|
|
18dcc747d7 | ||
|
|
6056d20a13 | ||
|
|
112c0ec1ed | ||
|
|
f5dde45c18 |
@@ -18,6 +18,9 @@ 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
|
||||
@@ -59,6 +62,28 @@ 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
|
||||
}
|
||||
|
||||
@@ -349,12 +349,12 @@ private extension AppRuntime {
|
||||
TorManager.shared.isAutoStartAllowed() {
|
||||
chatViewModel.torStatusAnnounced = true
|
||||
chatViewModel.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||
String(localized: "system.tor.starting", defaultValue: "starting tor...", comment: "System message when Tor is starting")
|
||||
)
|
||||
} else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced {
|
||||
chatViewModel.torStatusAnnounced = true
|
||||
chatViewModel.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
|
||||
String(localized: "system.tor.dev_bypass", defaultValue: "development build: Tor bypass enabled.", comment: "System message when Tor bypass is enabled in development")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,9 @@ final class ConversationUIModel: ObservableObject {
|
||||
|
||||
private func refreshComputedState() {
|
||||
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
||||
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -12,20 +12,26 @@ 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
|
||||
network: NetworkActivationService? = nil,
|
||||
gateway: GatewayService? = 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
|
||||
@@ -96,6 +102,10 @@ final class LocationChannelsModel: ObservableObject {
|
||||
network.setUserTorEnabled(enabled)
|
||||
}
|
||||
|
||||
func setGatewayEnabled(_ enabled: Bool) {
|
||||
gateway.setEnabled(enabled)
|
||||
}
|
||||
|
||||
func refreshMeshChannelsIfNeeded() {
|
||||
guard case .mesh = selectedChannel,
|
||||
permissionState == .authorized,
|
||||
@@ -160,6 +170,10 @@ 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 {
|
||||
|
||||
@@ -14,6 +14,9 @@ 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 }
|
||||
}
|
||||
@@ -26,11 +29,22 @@ 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
|
||||
@@ -129,6 +143,13 @@ 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
|
||||
@@ -183,13 +204,12 @@ final class PeerListModel: ObservableObject {
|
||||
let myPeerID = chatViewModel.meshService.myPeerID
|
||||
let meshRows = allPeers.map { peer in
|
||||
let isMe = peer.peerID == myPeerID
|
||||
let verifiedBadge: Bool
|
||||
if !isMe && !peer.isConnected,
|
||||
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
|
||||
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
|
||||
} else {
|
||||
verifiedBadge = false
|
||||
}
|
||||
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)
|
||||
|
||||
return MeshPeerRow(
|
||||
peerID: peer.peerID,
|
||||
@@ -202,7 +222,8 @@ final class PeerListModel: ObservableObject {
|
||||
isReachable: peer.isReachable,
|
||||
isMutualFavorite: peer.isMutualFavorite,
|
||||
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
|
||||
showsVerifiedBadgeWhenOffline: verifiedBadge
|
||||
showsVerifiedBadgeWhenOffline: verifiedBadge,
|
||||
showsVouchedBadge: vouchedBadge
|
||||
)
|
||||
}
|
||||
|
||||
@@ -217,22 +238,40 @@ 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() })
|
||||
|
||||
@@ -108,7 +108,13 @@ struct PrivateConversationHeaderState: Equatable {
|
||||
let encryptionStatus: EncryptionStatus?
|
||||
|
||||
var supportsFavoriteToggle: Bool {
|
||||
!conversationPeerID.isGeoDM
|
||||
!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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +212,13 @@ 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
|
||||
@@ -229,6 +242,26 @@ final class PrivateConversationModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
||||
// Group chats: the "peer" is the whole crew. Name + member count in
|
||||
// the header; availability reads as mesh since group traffic floods
|
||||
// the local mesh, and the per-peer encryption badge does not apply.
|
||||
if conversationPeerID.isGroup {
|
||||
let displayName: String
|
||||
if let group = chatViewModel.groupStore.group(for: conversationPeerID) {
|
||||
displayName = "#\(group.name) (\(group.members.count))"
|
||||
} else {
|
||||
displayName = String(localized: "common.unknown", defaultValue: "unknown", comment: "Fallback label for unknown peer")
|
||||
}
|
||||
return PrivateConversationHeaderState(
|
||||
conversationPeerID: conversationPeerID,
|
||||
headerPeerID: conversationPeerID,
|
||||
displayName: displayName,
|
||||
availability: .meshReachable,
|
||||
isFavorite: false,
|
||||
encryptionStatus: nil
|
||||
)
|
||||
}
|
||||
|
||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||
@@ -295,7 +328,7 @@ final class PrivateConversationModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||
return String(localized: "common.unknown", defaultValue: "unknown", comment: "Fallback label for unknown peer")
|
||||
}
|
||||
|
||||
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
|
||||
|
||||
@@ -9,6 +9,14 @@ 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
|
||||
@@ -82,6 +90,24 @@ 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,
|
||||
@@ -89,7 +115,9 @@ final class VerificationModel: ObservableObject {
|
||||
encryptionStatus: encryptionStatus,
|
||||
theirFingerprint: theirFingerprint,
|
||||
myFingerprint: chatViewModel.getMyFingerprint(),
|
||||
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||
isVerified: isVerified,
|
||||
voucherCount: vouchers.count,
|
||||
voucherNames: voucherNames
|
||||
)
|
||||
}
|
||||
|
||||
@@ -122,6 +150,17 @@ 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 {
|
||||
@@ -147,6 +186,6 @@ final class VerificationModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
return String(localized: "common.unknown", comment: "Label for an unknown peer")
|
||||
return String(localized: "common.unknown", defaultValue: "unknown", comment: "Label for an unknown peer")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
#if DEBUG
|
||||
import BitLogger
|
||||
#endif
|
||||
|
||||
@main
|
||||
struct BitchatApp: App {
|
||||
@@ -43,6 +46,11 @@ 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)
|
||||
|
||||
@@ -126,13 +126,37 @@ 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.
|
||||
@@ -154,7 +178,22 @@ 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,6 +133,17 @@ 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.
|
||||
@@ -550,16 +561,20 @@ 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()
|
||||
}
|
||||
}
|
||||
@@ -576,6 +591,159 @@ 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 }
|
||||
}
|
||||
|
||||
@@ -5142,7 +5142,7 @@
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "symbols"
|
||||
"value" : "SYMBOLS"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5171,6 +5171,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"app_info.network.title" : {
|
||||
"comment" : "Section header for network diagnostics in the app info sheet",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "NETWORK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"app_info.network.topology.description" : {
|
||||
"comment" : "Row description for the mesh topology map",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "map of peers and links learned from mesh announces"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"app_info.network.topology.hint" : {
|
||||
"comment" : "Accessibility hint for the mesh topology row",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "opens the mesh topology map"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"app_info.network.topology.title" : {
|
||||
"comment" : "Row title opening the mesh topology map",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "mesh topology"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"app_info.privacy.ephemeral.description" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -9348,6 +9396,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.accessibility.gateway_active" : {
|
||||
"comment" : "Accessibility label for the internet gateway indicator",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Internet gateway active, sharing your connection with the mesh"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.accessibility.group_chat" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Group chat"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.accessibility.jump_to_latest" : {
|
||||
"comment" : "Accessibility label for the jump to latest messages button",
|
||||
"extractionState" : "manual",
|
||||
@@ -15055,6 +15126,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.commands.group" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "create or manage private groups"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.commands.help" : {
|
||||
"comment" : "Description of the /help command in the suggestions panel",
|
||||
"extractionState" : "manual",
|
||||
@@ -15425,6 +15507,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.commands.pay" : {
|
||||
"comment" : "Autocomplete description for the /pay command that sends a Cashu ecash token",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "send a cashu ecash token in this chat"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.commands.ping" : {
|
||||
"comment" : "Description of the /ping command in the suggestions panel",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "measure round-trip time to a mesh peer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.commands.slap" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -15604,6 +15710,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.commands.trace" : {
|
||||
"comment" : "Description of the /trace command in the suggestions panel",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "estimate the mesh path to a peer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.commands.unblock" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -17872,6 +17990,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.header.gateway_active" : {
|
||||
"comment" : "Tooltip for the internet gateway indicator",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Sharing your internet connection with nearby mesh peers"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.header.people" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -18230,6 +18360,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.input.group_placeholder" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "create|invite|leave|list"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.input.placeholder.location" : {
|
||||
"comment" : "Composer placeholder for a public geohash channel, naming it",
|
||||
"extractionState" : "manual",
|
||||
@@ -18469,6 +18610,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.input.token_placeholder" : {
|
||||
"comment" : "Placeholder shown after /pay in the command suggestion panel",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "token"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.jump.new_count" : {
|
||||
"comment" : "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill",
|
||||
"extractionState" : "manual",
|
||||
@@ -19734,6 +19887,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.payment.copy_token" : {
|
||||
"comment" : "Context menu action copying a Cashu token to the pasteboard",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "copy token"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.payment.lightning" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -19913,6 +20078,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.payment.redeem_wallet" : {
|
||||
"comment" : "Context menu action opening a Cashu token in an ecash wallet app",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "redeem in wallet"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.payment.redeem_web" : {
|
||||
"comment" : "Context menu action opening a Cashu token in the web redemption page",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "redeem on web"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.private.caption_group" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "encrypted group · members only"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"encryption.accessibility.establishing" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -22419,6 +22619,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fingerprint.badge.vouched" : {
|
||||
"comment" : "Badge shown when a peer is vouched for by people the user verified but not directly verified",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ VOUCHED"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fingerprint.handshake_pending" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -22956,6 +23168,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fingerprint.message.vouched_by" : {
|
||||
"comment" : "How many people the user verified have vouched for this peer",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "vouched for by %#@people@ you verified"
|
||||
},
|
||||
"substitutions" : {
|
||||
"people" : {
|
||||
"argNum" : 1,
|
||||
"formatSpecifier" : "lld",
|
||||
"variations" : {
|
||||
"plural" : {
|
||||
"one" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%d person"
|
||||
}
|
||||
},
|
||||
"other" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%d people"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fingerprint.their_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -24424,6 +24670,50 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups.accessibility.open_group_hint" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Opens the group chat"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups.member_count %@" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "(%@)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups.section.header" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "groups"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups.state.creator" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Creator"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"location_channels.accessibility.add_bookmark" : {
|
||||
"comment" : "Accessibility action name for bookmarking a channel",
|
||||
"extractionState" : "manual",
|
||||
@@ -25713,6 +26003,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"location_channels.gateway.subtitle" : {
|
||||
"comment" : "Explanation under the internet gateway toggle",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "share your internet with nearby mesh peers so their geohash messages reach the network"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"location_channels.gateway.title" : {
|
||||
"comment" : "Title for the internet gateway toggle in the location channels sheet",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "internet gateway"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"location_channels.loading_nearby" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -31924,6 +32238,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mesh_peers.state.vouched" : {
|
||||
"comment" : "State label for a peer vouched for by someone the user verified",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "vouched"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mesh_peers.tooltip.new_messages" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -32103,6 +32429,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mesh_peers.tooltip.vouched" : {
|
||||
"comment" : "Tooltip for the vouched (unfilled seal) badge next to a peer",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "vouched for by someone you verified"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording %@" : {
|
||||
"comment" : "Voice note recording duration indicator",
|
||||
"localizations" : {
|
||||
@@ -33535,6 +33873,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.gateway.sent_via_mesh" : {
|
||||
"comment" : "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "sent via mesh gateway"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.geohash.blocked" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -33893,6 +34243,303 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.already_member" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%@ is already a member"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.cannot_remove_creator" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "the creator cannot be removed"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.create_failed" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "could not create the group"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.created" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "created group '%@' — use /group invite @name to add people"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.creator_only" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "only the group creator can do that"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.full" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "group is full (max %@ members)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.identity_unavailable" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "your identity keys are not ready yet"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.invite_failed" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "could not build the group invite"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.invited" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "invited %1$@ to '%2$@'"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.joined" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "you were added to group '%1$@' by %2$@ — it now appears in your people list"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.left" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "left group '%@'"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.list_header" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "your groups:"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.member_not_found" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "'%@' is not a member of this group"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.name_too_long" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "group names are limited to 40 characters"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.none" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "you are not in any groups — /group create <name> to start one"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.not_in_group" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "open a group chat first"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.peer_identity_unknown" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "cannot verify %@'s identity yet — wait for their announce"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.peer_not_connected" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%@ must be connected over mesh to be invited"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.peer_not_found" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "'%@' not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.removed_from" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "you were removed from group '%@'"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.removed_member" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "removed %@ and rotated the group key"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.rotate_failed" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "could not rotate the group key"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.send_failed" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "could not encrypt the message"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.unknown" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "you are no longer in this group"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.usage_create" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "usage: /group create <name>"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.usage_invite" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "usage: /group invite @name"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.group.usage_remove" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "usage: /group remove @name"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"system.location.not_in_channel" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -35194,6 +35841,66 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"topology.caption" : {
|
||||
"comment" : "Caption under the mesh topology map",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"topology.empty" : {
|
||||
"comment" : "Empty state of the mesh topology map",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "no mesh links yet — the map fills in as peer announces arrive"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"topology.refresh" : {
|
||||
"comment" : "Accessibility label of the topology refresh button",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "refresh topology"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"topology.summary" : {
|
||||
"comment" : "Topology map summary: number of peers and links",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%1$ld peers · %2$ld links"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"topology.title" : {
|
||||
"comment" : "Title of the mesh topology map sheet",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "mesh topology"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"verification.my_qr.accessibility_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
@@ -16,14 +16,18 @@ 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 }
|
||||
|
||||
@@ -31,8 +35,12 @@ enum CommandInfo: String, Identifiable {
|
||||
|
||||
var placeholder: String? {
|
||||
switch self {
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
|
||||
return "<" + String(localized: "content.input.nickname_placeholder", defaultValue: "nickname") + ">"
|
||||
case .group:
|
||||
return "<" + String(localized: "content.input.group_placeholder", defaultValue: "create|invite|leave|list") + ">"
|
||||
case .pay:
|
||||
return "<" + String(localized: "content.input.token_placeholder", defaultValue: "token") + ">"
|
||||
case .clear, .help, .who:
|
||||
return nil
|
||||
}
|
||||
@@ -40,26 +48,37 @@ enum CommandInfo: String, Identifiable {
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .block: String(localized: "content.commands.block")
|
||||
case .clear: String(localized: "content.commands.clear")
|
||||
case .help: String(localized: "content.commands.help")
|
||||
case .hug: String(localized: "content.commands.hug")
|
||||
case .message: String(localized: "content.commands.message")
|
||||
case .slap: String(localized: "content.commands.slap")
|
||||
case .unblock: String(localized: "content.commands.unblock")
|
||||
case .who: String(localized: "content.commands.who")
|
||||
case .favorite: String(localized: "content.commands.favorite")
|
||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||
case .block: String(localized: "content.commands.block", defaultValue: "block or list blocked peers")
|
||||
case .clear: String(localized: "content.commands.clear", defaultValue: "clear chat messages")
|
||||
case .group: String(localized: "content.commands.group", defaultValue: "create or manage private groups")
|
||||
case .help: String(localized: "content.commands.help", defaultValue: "show available commands")
|
||||
case .hug: String(localized: "content.commands.hug", defaultValue: "send someone a warm hug")
|
||||
case .message: String(localized: "content.commands.message", defaultValue: "send private message")
|
||||
case .pay: String(localized: "content.commands.pay", defaultValue: "send a cashu ecash token in this chat")
|
||||
case .slap: String(localized: "content.commands.slap", defaultValue: "slap someone with a trout")
|
||||
case .unblock: String(localized: "content.commands.unblock", defaultValue: "unblock a peer")
|
||||
case .who: String(localized: "content.commands.who", defaultValue: "see who's online")
|
||||
case .favorite: String(localized: "content.commands.favorite", defaultValue: "add to favorites")
|
||||
case .unfavorite: String(localized: "content.commands.unfavorite", defaultValue: "remove from favorites")
|
||||
case .ping: String(localized: "content.commands.ping", defaultValue: "measure round-trip time to a mesh peer")
|
||||
case .trace: String(localized: "content.commands.trace", defaultValue: "estimate the mesh path to a peer")
|
||||
}
|
||||
}
|
||||
|
||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||
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 baseCommands
|
||||
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)
|
||||
}
|
||||
return baseCommands + [.favorite, .unfavorite]
|
||||
// The processor rejects favorites, groups and mesh diagnostics in
|
||||
// geohash contexts, so only suggest them where they actually work: mesh.
|
||||
if isGeoPublic || isGeoDM {
|
||||
return commands
|
||||
}
|
||||
return commands + [.favorite, .unfavorite, .group, .ping, .trace]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
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
|
||||
@@ -12,6 +23,29 @@ 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
|
||||
@@ -88,7 +122,9 @@ struct RequestSyncPacket {
|
||||
sinceTimestamp = ts
|
||||
}
|
||||
case 0x06:
|
||||
if let fid = String(data: v, encoding: .utf8) {
|
||||
// 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) {
|
||||
fragmentIdFilter = fid
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,63 @@ 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])
|
||||
@@ -177,15 +234,7 @@ struct NostrProtocol {
|
||||
if teleported {
|
||||
tags.append(["t", "teleport"])
|
||||
}
|
||||
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)
|
||||
return tags
|
||||
}
|
||||
|
||||
/// Create a geohash presence heartbeat (kind 20001)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
/// - Efficient binary message encoding
|
||||
/// - Message fragmentation for large payloads
|
||||
/// - TTL-based routing for mesh networks
|
||||
/// - Privacy features like padding and timing obfuscation
|
||||
/// - Privacy features: message padding and randomized relay jitter
|
||||
/// - Integration points for end-to-end encryption
|
||||
///
|
||||
/// ## Protocol Design
|
||||
@@ -38,18 +38,20 @@
|
||||
/// 7. **Decoding**: Binary data parsed back to message objects
|
||||
///
|
||||
/// ## Security Considerations
|
||||
/// - Message padding obscures actual content length
|
||||
/// - Timing obfuscation prevents traffic analysis
|
||||
/// - 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
|
||||
/// - Integration with Noise Protocol for E2E encryption
|
||||
/// - No persistent identifiers in protocol headers
|
||||
///
|
||||
/// ## Message Types
|
||||
/// - **Announce/Leave**: Peer presence notifications
|
||||
/// - **Message**: User chat messages (broadcast or directed)
|
||||
/// - **Message**: Public chat messages
|
||||
/// - **Fragment**: Multi-part message handling
|
||||
/// - **Delivery/Read**: Message acknowledgments
|
||||
/// - **Noise**: Encrypted channel establishment
|
||||
/// - **Version**: Protocol version negotiation
|
||||
/// - **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
|
||||
///
|
||||
/// ## Future Extensions
|
||||
/// The protocol is designed to be extensible:
|
||||
@@ -75,9 +77,14 @@ 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 {
|
||||
@@ -86,8 +93,11 @@ 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +129,9 @@ 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?)
|
||||
@@ -138,6 +151,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -24,17 +24,17 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .building:
|
||||
return String(localized: "location_levels.building", comment: "Name for building-level location channel")
|
||||
return String(localized: "location_levels.building", defaultValue: "building", comment: "Name for building-level location channel")
|
||||
case .block:
|
||||
return String(localized: "location_levels.block", comment: "Name for block-level location channel")
|
||||
return String(localized: "location_levels.block", defaultValue: "block", comment: "Name for block-level location channel")
|
||||
case .neighborhood:
|
||||
return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
|
||||
return String(localized: "location_levels.neighborhood", defaultValue: "neighborhood", comment: "Name for neighborhood-level location channel")
|
||||
case .city:
|
||||
return String(localized: "location_levels.city", comment: "Name for city-level location channel")
|
||||
return String(localized: "location_levels.city", defaultValue: "city", comment: "Name for city-level location channel")
|
||||
case .province:
|
||||
return String(localized: "location_levels.province", comment: "Name for province-level location channel")
|
||||
return String(localized: "location_levels.province", defaultValue: "province", comment: "Name for province-level location channel")
|
||||
case .region:
|
||||
return String(localized: "location_levels.region", comment: "Name for region-level location channel")
|
||||
return String(localized: "location_levels.region", defaultValue: "region", comment: "Name for region-level location channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//
|
||||
// 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,5 +3,9 @@ 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 = TransportConfig.wifiBulkEnabled ? [.wifiBulk] : []
|
||||
static let localSupported: PeerCapabilities = {
|
||||
var caps: PeerCapabilities = [.prekeys, .vouch, .groups]
|
||||
if TransportConfig.wifiBulkEnabled { caps.insert(.wifiBulk) }
|
||||
return caps
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//
|
||||
// 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: ×tampBE) { 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: ×tampBE) { 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,6 +64,9 @@ 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]] = [:]
|
||||
@@ -105,7 +108,15 @@ 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 {
|
||||
@@ -138,10 +149,59 @@ struct BLEFragmentAssemblyBuffer {
|
||||
}
|
||||
|
||||
fragmentsByKey[header.key] = [:]
|
||||
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now)
|
||||
metadataByKey[header.key] = Metadata(
|
||||
type: header.originalType,
|
||||
total: header.total,
|
||||
timestamp: now,
|
||||
isBroadcast: header.isBroadcastFragment,
|
||||
lastFragmentAt: 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:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .prekeyBundle, .groupMessage, .nostrCarrier, .ping, .pong:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,11 @@ 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) }
|
||||
|
||||
@@ -51,13 +51,25 @@ 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.recipientID != nil,
|
||||
|| packet.type == MessageType.courierEnvelope.rawValue
|
||||
|| packet.type == MessageType.nostrCarrier.rawValue
|
||||
|| packet.type == MessageType.ping.rawValue
|
||||
|| packet.type == MessageType.pong.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
|
||||
)
|
||||
|
||||
@@ -53,6 +53,8 @@ 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
|
||||
@@ -60,6 +62,27 @@ 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.
|
||||
@@ -67,7 +90,29 @@ 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()
|
||||
@@ -298,13 +343,24 @@ final class BLEService: NSObject {
|
||||
fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds,
|
||||
messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds,
|
||||
responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses,
|
||||
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds
|
||||
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds,
|
||||
prekeyBundleCapacity: TransportConfig.syncPrekeyBundleCapacity,
|
||||
prekeyBundleSyncIntervalSeconds: TransportConfig.syncPrekeyBundleIntervalSeconds,
|
||||
prekeyBundleMaxAgeSeconds: TransportConfig.syncPrekeyBundleMaxAgeSeconds
|
||||
)
|
||||
|
||||
// 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.
|
||||
@@ -341,6 +397,8 @@ final class BLEService: NSObject {
|
||||
ingressLinks.removeAll()
|
||||
recentTrafficTracker.removeAll()
|
||||
scheduledRelays.cancelAll()
|
||||
// Let the post-panic identity publish its fresh bundle promptly.
|
||||
lastPrekeyBundleSentAt = nil
|
||||
return transfers
|
||||
}
|
||||
|
||||
@@ -587,6 +645,7 @@ 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()
|
||||
@@ -636,6 +695,32 @@ 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)
|
||||
@@ -1393,16 +1478,16 @@ final class BLEService: NSObject {
|
||||
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
|
||||
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
|
||||
|
||||
let connectedPeerIDs: [Data] = collectionsQueue.sync {
|
||||
peerRegistry.connectedRoutingData
|
||||
let (connectedPeerIDs, advertisedCapabilities): ([Data], PeerCapabilities) = collectionsQueue.sync {
|
||||
(peerRegistry.connectedRoutingData, PeerCapabilities.localSupported.union(runtimeCapabilities))
|
||||
}
|
||||
|
||||
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: myNickname,
|
||||
noisePublicKey: noisePub,
|
||||
signingPublicKey: signingPub,
|
||||
directNeighbors: connectedPeerIDs,
|
||||
capabilities: PeerCapabilities.localSupported
|
||||
capabilities: advertisedCapabilities
|
||||
)
|
||||
|
||||
guard let payload = announcement.encode() else {
|
||||
@@ -1437,10 +1522,56 @@ 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)
|
||||
@@ -1450,6 +1581,18 @@ 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
|
||||
@@ -1829,6 +1972,10 @@ 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(
|
||||
@@ -2544,12 +2691,42 @@ extension BLEService {
|
||||
}
|
||||
|
||||
private func computeRoute(to peerID: PeerID) -> [Data]? {
|
||||
meshTopology.computeRoute(from: myPeerIDData, to: routingData(for: peerID))
|
||||
// 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
|
||||
)
|
||||
}
|
||||
|
||||
private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket {
|
||||
guard let route = computeRoute(to: recipient), route.count >= 1 else {
|
||||
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)
|
||||
}
|
||||
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(
|
||||
@@ -2568,6 +2745,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -2575,6 +2755,155 @@ 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(
|
||||
@@ -2671,10 +3000,13 @@ extension BLEService {
|
||||
|
||||
// MARK: Courier Store-and-Forward
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
|
||||
let connected = couriers.filter { isPeerConnected($0) }
|
||||
guard !connected.isEmpty,
|
||||
@@ -2685,7 +3017,17 @@ extension BLEService {
|
||||
let payload: Data
|
||||
do {
|
||||
let now = Date()
|
||||
let sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
|
||||
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 envelope = CourierEnvelope(
|
||||
recipientTag: CourierEnvelope.recipientTag(
|
||||
noiseStaticKey: recipientNoiseKey,
|
||||
@@ -2693,7 +3035,8 @@ extension BLEService {
|
||||
),
|
||||
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
|
||||
ciphertext: sealed,
|
||||
copies: TransportConfig.courierInitialCopies
|
||||
copies: TransportConfig.courierInitialCopies,
|
||||
prekeyID: prekeyID
|
||||
)
|
||||
guard let encoded = envelope.encode() else { return false }
|
||||
payload = encoded
|
||||
@@ -2712,6 +3055,22 @@ 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,
|
||||
@@ -2747,7 +3106,26 @@ extension BLEService {
|
||||
|
||||
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
|
||||
do {
|
||||
let (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
|
||||
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)
|
||||
}
|
||||
guard let typeRaw = typedPayload.first,
|
||||
let payloadType = NoisePayloadType(rawValue: typeRaw),
|
||||
payloadType == .privateMessage else {
|
||||
@@ -2878,6 +3256,232 @@ 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 {
|
||||
@@ -3115,7 +3719,7 @@ extension BLEService {
|
||||
|
||||
// Notify delegate of failure
|
||||
notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer"))))
|
||||
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", defaultValue: "encryption failed", comment: "Failure reason shown when a message could not be encrypted for the peer"))))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3339,13 +3943,23 @@ 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
|
||||
// Track recent traffic timestamps for adaptive behavior; the same
|
||||
// barrier hop confirms route health for the packet's originator.
|
||||
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:
|
||||
@@ -3372,6 +3986,28 @@ 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)
|
||||
|
||||
@@ -3435,6 +4071,12 @@ 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.
|
||||
@@ -3544,6 +4186,63 @@ 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
|
||||
@@ -3628,6 +4327,26 @@ 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)
|
||||
}
|
||||
@@ -3864,10 +4583,21 @@ extension BLEService {
|
||||
// Clean old processed messages efficiently
|
||||
messageDeduplicator.cleanup()
|
||||
|
||||
// Clean old fragments (> configured seconds old)
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
// 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
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,17 @@ 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
|
||||
@@ -45,15 +56,33 @@ 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
|
||||
@@ -100,12 +129,23 @@ 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:
|
||||
@@ -125,6 +165,12 @@ 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
|
||||
"""
|
||||
|
||||
@@ -331,6 +377,138 @@ 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 {
|
||||
|
||||
@@ -42,9 +42,11 @@ 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)
|
||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
|
||||
}
|
||||
|
||||
init(
|
||||
@@ -56,7 +58,8 @@ final class CourierStore {
|
||||
tier: CourierDepositTier,
|
||||
copies: UInt8,
|
||||
sprayedTo: Set<Data> = [],
|
||||
lastRemoteHandoverAt: Date? = nil
|
||||
lastRemoteHandoverAt: Date? = nil,
|
||||
prekeyID: UInt32? = nil
|
||||
) {
|
||||
self.recipientTag = recipientTag
|
||||
self.expiry = expiry
|
||||
@@ -67,6 +70,7 @@ 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
|
||||
@@ -82,6 +86,7 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +191,8 @@ final class CourierStore {
|
||||
depositorNoiseKey: depositorNoiseKey,
|
||||
storedAt: date,
|
||||
tier: tier,
|
||||
copies: envelope.copies
|
||||
copies: envelope.copies,
|
||||
prekeyID: envelope.prekeyID
|
||||
))
|
||||
persistLocked()
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
//
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -89,11 +89,11 @@ final class LocationNotesManager: ObservableObject {
|
||||
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
|
||||
|
||||
private enum Strings {
|
||||
static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
|
||||
static let noRelays = String(localized: "location_notes.error.no_relays", defaultValue: "no geo relays available near this location. try again soon.", comment: "Shown when no geo relays are available near the selected location")
|
||||
|
||||
static func failedToSend(_ detail: String) -> String {
|
||||
String(
|
||||
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
|
||||
format: String(localized: "location_notes.error.failed_to_send", defaultValue: "failed to send note. %@", comment: "Shown when a location note fails to send"),
|
||||
locale: .current,
|
||||
detail
|
||||
)
|
||||
|
||||
@@ -10,6 +10,10 @@ 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
|
||||
@@ -19,48 +23,75 @@ 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]) {
|
||||
func updateNeighbors(for sourceData: Data?, neighbors: [Data], at now: Date = Date()) {
|
||||
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] = Date()
|
||||
self.lastSeen[source] = now
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let deadline = Date().addingTimeInterval(-age)
|
||||
func prune(olderThan age: TimeInterval, now: Date = Date()) {
|
||||
let deadline = now.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 }
|
||||
}
|
||||
}
|
||||
|
||||
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? {
|
||||
/// 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]? {
|
||||
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]
|
||||
@@ -86,6 +117,10 @@ 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'?
|
||||
|
||||
@@ -116,30 +116,30 @@ enum EncryptionStatus: Equatable {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return String(localized: "encryption.status.failed", comment: "Status text when encryption failed")
|
||||
return String(localized: "encryption.status.failed", defaultValue: "encryption failed", comment: "Status text when encryption failed")
|
||||
case .noHandshake:
|
||||
return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened")
|
||||
return String(localized: "encryption.status.not_encrypted", defaultValue: "not encrypted", comment: "Status text when no encryption handshake happened")
|
||||
case .noiseHandshaking:
|
||||
return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established")
|
||||
return String(localized: "encryption.status.establishing", defaultValue: "establishing encryption...", comment: "Status text when encryption is being established")
|
||||
case .noiseSecured:
|
||||
return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified")
|
||||
return String(localized: "encryption.status.secured", defaultValue: "encrypted", comment: "Status text when encryption is secured but not verified")
|
||||
case .noiseVerified:
|
||||
return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified")
|
||||
return String(localized: "encryption.status.verified", defaultValue: "encrypted & verified", comment: "Status text when encryption is verified")
|
||||
}
|
||||
}
|
||||
|
||||
var accessibilityDescription: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return String(localized: "encryption.accessibility.failed", comment: "Accessibility text when encryption failed")
|
||||
return String(localized: "encryption.accessibility.failed", defaultValue: "encryption failed", comment: "Accessibility text when encryption failed")
|
||||
case .noHandshake:
|
||||
return String(localized: "encryption.accessibility.not_encrypted", comment: "Accessibility text when encryption is not established")
|
||||
return String(localized: "encryption.accessibility.not_encrypted", defaultValue: "not encrypted", comment: "Accessibility text when encryption is not established")
|
||||
case .noiseHandshaking:
|
||||
return String(localized: "encryption.accessibility.establishing", comment: "Accessibility text when encryption is being established")
|
||||
return String(localized: "encryption.accessibility.establishing", defaultValue: "establishing encryption", comment: "Accessibility text when encryption is being established")
|
||||
case .noiseSecured:
|
||||
return String(localized: "encryption.accessibility.secured", comment: "Accessibility text when encryption is secured")
|
||||
return String(localized: "encryption.accessibility.secured", defaultValue: "encrypted", comment: "Accessibility text when encryption is secured")
|
||||
case .noiseVerified:
|
||||
return String(localized: "encryption.accessibility.verified", comment: "Accessibility text when encryption is verified")
|
||||
return String(localized: "encryption.accessibility.verified", defaultValue: "encrypted and verified", comment: "Accessibility text when encryption is verified")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,10 @@ 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?
|
||||
@@ -200,6 +204,7 @@ 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
|
||||
@@ -412,13 +417,112 @@ 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()
|
||||
@@ -812,4 +916,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ struct RelayController {
|
||||
isHandshake: Bool,
|
||||
isAnnounce: Bool,
|
||||
isRequestSync: Bool = false,
|
||||
isUrgentBoardPost: Bool = false,
|
||||
degree: Int,
|
||||
highDegreeThreshold: Int) -> RelayDecision {
|
||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||
@@ -64,7 +65,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 get a bit more headroom
|
||||
// - Announces (and urgent board posts) get a bit more headroom
|
||||
let ttlLimit: UInt8 = {
|
||||
if degree >= highDegreeThreshold {
|
||||
return max(UInt8(2), min(ttlCap, UInt8(5)))
|
||||
@@ -72,7 +73,7 @@ struct RelayController {
|
||||
if degree <= 2 {
|
||||
return ttlCap
|
||||
}
|
||||
let preferred = UInt8(isAnnounce ? 7 : 6)
|
||||
let preferred = UInt8((isAnnounce || isUrgentBoardPost) ? 7 : 6)
|
||||
return max(UInt8(2), min(ttlCap, preferred))
|
||||
}()
|
||||
let newTTL = ttlLimit &- 1
|
||||
|
||||
@@ -31,10 +31,47 @@ 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])
|
||||
@@ -124,10 +161,43 @@ 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)
|
||||
@@ -153,7 +223,22 @@ 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) {}
|
||||
@@ -186,6 +271,8 @@ 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):
|
||||
|
||||
@@ -16,6 +16,11 @@ 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
|
||||
@@ -208,6 +213,25 @@ 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.
|
||||
@@ -304,4 +328,16 @@ 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("WifiBulk: listener creation failed: \(error)", category: .session)
|
||||
SecureLogger.error("[WIFI] listener creation failed: \(error)", category: .transport)
|
||||
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("WifiBulk: disconnecting client with invalid auth frame", category: .security)
|
||||
SecureLogger.warning("[WIFI] AWDL connect rejected (invalid auth frame)", category: .security)
|
||||
self.dropCandidate(connection)
|
||||
return false
|
||||
}
|
||||
@@ -238,6 +238,7 @@ 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)
|
||||
}
|
||||
|
||||
@@ -372,8 +373,10 @@ 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,6 +172,7 @@ 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
|
||||
}
|
||||
@@ -229,7 +230,7 @@ final class WifiBulkTransferService {
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.debug("WifiBulk: offered \(payload.count) bytes to \(peerID.id.prefix(8))… over \(serviceName.prefix(8))…", category: .session)
|
||||
SecureLogger.info("[WIFI] offer sent \(payload.count) bytes to \(peerID.id.prefix(8))… over \(serviceName.prefix(8))…", category: .transport)
|
||||
environment.progressStart(transferId, session.totalChunks)
|
||||
|
||||
let offerTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||
@@ -271,6 +272,7 @@ 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)
|
||||
}
|
||||
|
||||
@@ -284,13 +286,13 @@ final class WifiBulkTransferService {
|
||||
|
||||
switch outcome {
|
||||
case .completed:
|
||||
SecureLogger.debug("WifiBulk: transfer \(transfer.transferId.prefix(8))… completed (\(reason))", category: .session)
|
||||
SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… complete (\(reason))", category: .transport)
|
||||
case .fallback:
|
||||
SecureLogger.info("WifiBulk: transfer \(transfer.transferId.prefix(8))… falling back to BLE (\(reason))", category: .session)
|
||||
SecureLogger.info("[WIFI] fallback→BLE \(transfer.transferId.prefix(8))… (\(reason))", category: .transport)
|
||||
environment.progressReset(transfer.transferId)
|
||||
transfer.fallback()
|
||||
case .cancelled:
|
||||
SecureLogger.debug("WifiBulk: transfer \(transfer.transferId.prefix(8))… cancelled", category: .session)
|
||||
SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… cancelled", category: .transport)
|
||||
environment.progressCancel(transfer.transferId)
|
||||
}
|
||||
}
|
||||
@@ -338,13 +340,13 @@ final class WifiBulkTransferService {
|
||||
|
||||
let transfer = IncomingTransfer(offer: offer, peerID: peerID, key: key)
|
||||
incoming[offer.transferID] = transfer
|
||||
SecureLogger.debug("WifiBulk: accepted offer of \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .session)
|
||||
SecureLogger.info("[WIFI] offer accept \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .transport)
|
||||
|
||||
startBrowsing(for: transfer)
|
||||
|
||||
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||
guard let self, let transfer else { return }
|
||||
SecureLogger.info("WifiBulk: incoming transfer window expired", category: .session)
|
||||
SecureLogger.info("[WIFI] incoming window expired", category: .transport)
|
||||
self.tearDownIncoming(transfer)
|
||||
}
|
||||
transfer.windowTimeout = windowTimeout
|
||||
@@ -352,7 +354,7 @@ final class WifiBulkTransferService {
|
||||
}
|
||||
|
||||
private func decline(offer: WifiBulkOffer, peerID: PeerID) {
|
||||
SecureLogger.debug("WifiBulk: declining offer of \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .session)
|
||||
SecureLogger.info("[WIFI] offer decline \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .transport)
|
||||
guard let responseData = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return }
|
||||
_ = environment.sendNoisePayload(
|
||||
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
|
||||
@@ -380,7 +382,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("WifiBulk: browser failed: \(error)", category: .session)
|
||||
SecureLogger.warning("[WIFI] browser failed: \(error)", category: .transport)
|
||||
self.tearDownIncoming(transfer)
|
||||
}
|
||||
}
|
||||
@@ -416,13 +418,13 @@ final class WifiBulkTransferService {
|
||||
}
|
||||
session.onCompleted = { [weak self, weak transfer] payload in
|
||||
guard let self, let transfer else { return }
|
||||
SecureLogger.debug("WifiBulk: received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))…", category: .session)
|
||||
SecureLogger.info("[WIFI] transfer complete, received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))…", category: .transport)
|
||||
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("WifiBulk: incoming transfer failed (\(reason)); sender falls back to BLE", category: .session)
|
||||
SecureLogger.info("[WIFI] incoming failed (\(reason)); sender falls back to BLE", category: .transport)
|
||||
self.tearDownIncoming(transfer)
|
||||
}
|
||||
transfer.session = session
|
||||
|
||||
@@ -73,11 +73,22 @@ 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
|
||||
@@ -86,11 +97,22 @@ 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 latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
|
||||
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 archiveDirty = false
|
||||
|
||||
// Timer
|
||||
@@ -111,7 +133,13 @@ final class GossipSyncManager {
|
||||
)
|
||||
var schedules: [SyncSchedule] = []
|
||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
||||
// 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))
|
||||
}
|
||||
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
|
||||
@@ -119,6 +147,12 @@ 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 {
|
||||
@@ -149,12 +183,21 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -169,9 +212,17 @@ 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 = packet.type == MessageType.message.rawValue
|
||||
? config.publicMessageMaxAgeSeconds
|
||||
: config.maxMessageAgeSeconds
|
||||
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 nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let ageThresholdMs = UInt64(maxAgeSeconds * 1000)
|
||||
|
||||
@@ -206,9 +257,8 @@ final class GossipSyncManager {
|
||||
removeState(for: sender)
|
||||
return
|
||||
}
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
let sender = PeerID(hexData: packet.senderID)
|
||||
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
||||
latestAnnouncementByPeer[sender] = packet
|
||||
case .message:
|
||||
guard isBroadcastRecipient else { return }
|
||||
guard isPacketFresh(packet) else { return }
|
||||
@@ -225,6 +275,36 @@ 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
|
||||
}
|
||||
@@ -233,7 +313,6 @@ 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)
|
||||
}
|
||||
@@ -258,11 +337,29 @@ final class GossipSyncManager {
|
||||
delegate?.sendPacket(signed)
|
||||
}
|
||||
|
||||
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
||||
/// 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) {
|
||||
// Register the request for RSR validation
|
||||
requestSyncManager.registerRequest(to: peerID)
|
||||
|
||||
let payload = buildGcsPayload(for: types)
|
||||
|
||||
let payload = buildGcsPayload(for: types, fragmentIdFilter: fragmentIdFilter)
|
||||
var recipient = Data()
|
||||
var temp = peerID.id
|
||||
while temp.count >= 2 && recipient.count < 8 {
|
||||
@@ -293,7 +390,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("Rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync)
|
||||
SecureLogger.warning("[SYNC] rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync)
|
||||
return
|
||||
}
|
||||
let requestedTypes = (request.types ?? .publicMessages)
|
||||
@@ -301,6 +398,9 @@ 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 {
|
||||
@@ -312,15 +412,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 (_, pair) in latestAnnouncementByPeer {
|
||||
let (idHex, pkt) = pair
|
||||
for (_, pkt) in latestAnnouncementByPeer {
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = Data(hexString: idHex) ?? Data()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,20 +435,32 @@ 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 since, pkt.timestamp < since { continue }
|
||||
if let fragmentIdFilter {
|
||||
guard fragmentIdFilter.contains(Data(pkt.payload.prefix(8))) else { continue }
|
||||
} else 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,17 +475,73 @@ 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) -> Data {
|
||||
private func buildGcsPayload(for types: SyncTypeFlags, fragmentIdFilter: String? = nil) -> Data {
|
||||
var candidates: [BitchatPacket] = []
|
||||
if types.contains(.announce) {
|
||||
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
|
||||
candidates.append(pair.packet)
|
||||
for (_, pkt) in latestAnnouncementByPeer where isPacketFresh(pkt) {
|
||||
candidates.append(pkt)
|
||||
}
|
||||
}
|
||||
if types.contains(.message) {
|
||||
@@ -385,9 +553,20 @@ 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)
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter)
|
||||
return req.encode()
|
||||
}
|
||||
|
||||
@@ -401,12 +580,16 @@ 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)
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter)
|
||||
return req.encode()
|
||||
}
|
||||
let included = Array(candidates.prefix(takeN))
|
||||
@@ -424,15 +607,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)
|
||||
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
||||
return req.encode()
|
||||
}
|
||||
|
||||
// Periodic cleanup of expired messages and announcements
|
||||
private func cleanupExpiredMessages() {
|
||||
// Remove expired announcements
|
||||
latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in
|
||||
isPacketFresh(pair.packet)
|
||||
latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pkt in
|
||||
isPacketFresh(pkt)
|
||||
}
|
||||
|
||||
let messageCountBefore = messages.packets.count
|
||||
@@ -442,6 +625,10 @@ 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)
|
||||
@@ -490,13 +677,23 @@ 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) {
|
||||
@@ -512,8 +709,8 @@ final class GossipSyncManager {
|
||||
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
||||
guard nowMs >= timeoutMs else { return }
|
||||
let cutoff = nowMs - timeoutMs
|
||||
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in
|
||||
pair.packet.timestamp < cutoff ? peerID : nil
|
||||
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pkt in
|
||||
pkt.timestamp < cutoff ? peerID : nil
|
||||
}
|
||||
guard !stalePeerIDs.isEmpty else { return }
|
||||
for peerKey in stalePeerIDs {
|
||||
@@ -529,6 +726,8 @@ 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 }
|
||||
@@ -537,6 +736,7 @@ final class GossipSyncManager {
|
||||
}
|
||||
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
groupMessages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,6 +754,12 @@ 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
|
||||
|
||||
@@ -7,9 +7,25 @@ struct SyncTypeFlags: OptionSet {
|
||||
let rawValue: UInt64
|
||||
|
||||
init(rawValue: UInt64) {
|
||||
self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Union of every bit that maps to a message type. Derived from the
|
||||
/// bit↔type 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
|
||||
@@ -20,9 +36,29 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +72,12 @@ 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
|
||||
}
|
||||
@@ -45,6 +87,9 @@ 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])
|
||||
|
||||
@@ -105,4 +150,19 @@ struct SyncTypeFlags: OptionSet {
|
||||
}
|
||||
return SyncTypeFlags(rawValue: raw)
|
||||
}
|
||||
|
||||
/// Compact human label for the `[SYNC]` observability logs, e.g. "msg,frag,pre".
|
||||
/// (Referenced only from DEBUG log lines, but kept unconditional so the
|
||||
/// log-call arguments compile in release too, where the log itself no-ops.)
|
||||
var debugShortLabel: String {
|
||||
var parts: [String] = []
|
||||
if contains(.announce) { parts.append("ann") }
|
||||
if contains(.message) { parts.append("msg") }
|
||||
if contains(.fragment) { parts.append("frag") }
|
||||
if contains(.fileTransfer) { parts.append("file") }
|
||||
if contains(.prekeyBundle) { parts.append("pre") }
|
||||
if contains(.groupMessage) { parts.append("grp") }
|
||||
if contains(.boardPost) { parts.append("board") }
|
||||
return parts.isEmpty ? "none" : parts.joined(separator: ",")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,17 +12,17 @@ enum ChatBluetoothAlertPolicy {
|
||||
case .poweredOff:
|
||||
ChatBluetoothAlertUpdate(
|
||||
isPresented: true,
|
||||
message: String(localized: "content.alert.bluetooth_required.off", comment: "Message shown when Bluetooth is turned off")
|
||||
message: String(localized: "content.alert.bluetooth_required.off", defaultValue: "bluetooth is turned off. please turn on bluetooth in settings to use bitchat.", comment: "Message shown when Bluetooth is turned off")
|
||||
)
|
||||
case .unauthorized:
|
||||
ChatBluetoothAlertUpdate(
|
||||
isPresented: true,
|
||||
message: String(localized: "content.alert.bluetooth_required.permission", comment: "Message shown when Bluetooth permission is missing")
|
||||
message: String(localized: "content.alert.bluetooth_required.permission", defaultValue: "bitchat needs bluetooth permission to connect with nearby devices. please enable bluetooth access in settings.", comment: "Message shown when Bluetooth permission is missing")
|
||||
)
|
||||
case .unsupported:
|
||||
ChatBluetoothAlertUpdate(
|
||||
isPresented: true,
|
||||
message: String(localized: "content.alert.bluetooth_required.unsupported", comment: "Message shown when the device lacks Bluetooth support")
|
||||
message: String(localized: "content.alert.bluetooth_required.unsupported", defaultValue: "this device does not support bluetooth. bitchat requires bluetooth to function.", comment: "Message shown when the device lacks Bluetooth support")
|
||||
)
|
||||
case .poweredOn:
|
||||
ChatBluetoothAlertUpdate(isPresented: false, message: "")
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// The narrow surface `ChatGroupCoordinator` needs from its owner.
|
||||
///
|
||||
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
||||
/// minimal context it actually uses instead of holding an `unowned` back-ref
|
||||
/// to the whole `ChatViewModel`. Group chats are keyed like direct chats
|
||||
/// (virtual "group_" peer IDs), so the conversation intents below reuse the
|
||||
/// private-chat store operations.
|
||||
@MainActor
|
||||
protocol ChatGroupContext: AnyObject {
|
||||
// MARK: Identity & state
|
||||
var nickname: String { get }
|
||||
var myPeerID: PeerID { get }
|
||||
var selectedPrivateChatPeer: PeerID? { get }
|
||||
var groupStore: GroupStore { get }
|
||||
|
||||
/// Fingerprint of our own Noise static identity key.
|
||||
func myNoiseFingerprint() -> String
|
||||
/// Our Ed25519 signing public key.
|
||||
func mySigningPublicKey() -> Data
|
||||
/// Signs `data` with our Noise signing key.
|
||||
func signWithNoiseKey(_ data: Data) -> Data?
|
||||
|
||||
// MARK: Peers
|
||||
func getPeerIDForNickname(_ nickname: String) -> PeerID?
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||
func peerNickname(for peerID: PeerID) -> String?
|
||||
/// The peer's Noise fingerprint from the live session/registry.
|
||||
func meshFingerprint(for peerID: PeerID) -> String?
|
||||
/// The peer's persisted crypto identity (fingerprint + signing key), if
|
||||
/// the identity store has a signature-verified announce for them.
|
||||
func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)?
|
||||
/// The connected short peer ID whose fingerprint matches, if any.
|
||||
func connectedPeerID(forFingerprint fingerprint: String) -> PeerID?
|
||||
/// Whether the user has blocked the identity with this Noise fingerprint.
|
||||
func isFingerprintBlocked(_ fingerprint: String) -> Bool
|
||||
|
||||
// MARK: Transport
|
||||
func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID)
|
||||
func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID)
|
||||
func broadcastGroupMessagePayload(_ payload: Data)
|
||||
|
||||
// MARK: Conversation intents (group chats are direct-keyed)
|
||||
@discardableResult
|
||||
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
|
||||
func markPrivateChatUnread(_ peerID: PeerID)
|
||||
func removePrivateChat(_ peerID: PeerID)
|
||||
func startPrivateChat(with peerID: PeerID)
|
||||
func endPrivateChat()
|
||||
func addSystemMessage(_ content: String)
|
||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
||||
func notifyUIChanged()
|
||||
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID)
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatGroupContext {
|
||||
// `nickname`, `myPeerID`, `selectedPrivateChatPeer`, `groupStore`,
|
||||
// `getPeerIDForNickname(_:)`, `isPeerConnected(_:)`, `peerNickname(for:)`,
|
||||
// `appendPrivateMessage(_:to:)`, `markPrivateChatUnread(_:)`,
|
||||
// `removePrivateChat(_:)`, `startPrivateChat(with:)`,
|
||||
// `addSystemMessage(_:)`, `addLocalPrivateSystemMessage(_:to:)`,
|
||||
// `notifyUIChanged()`, and `notifyPrivateMessage(from:message:peerID:)`
|
||||
// are shared requirements with the other contexts or satisfied by
|
||||
// existing `ChatViewModel` members. The members below flatten nested
|
||||
// service accesses into intent-named calls.
|
||||
|
||||
func myNoiseFingerprint() -> String {
|
||||
meshService.noiseIdentityFingerprint()
|
||||
}
|
||||
|
||||
func mySigningPublicKey() -> Data {
|
||||
meshService.noiseSigningPublicKeyData()
|
||||
}
|
||||
|
||||
func signWithNoiseKey(_ data: Data) -> Data? {
|
||||
meshService.noiseSignData(data)
|
||||
}
|
||||
|
||||
func meshFingerprint(for peerID: PeerID) -> String? {
|
||||
meshService.getFingerprint(for: peerID)
|
||||
}
|
||||
|
||||
/// The persisted, signature-verified identity behind a short mesh peer
|
||||
/// ID. Cross-checked against the live session fingerprint so a roster
|
||||
/// entry can never be pinned to a signing key from a different identity.
|
||||
func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? {
|
||||
guard let fingerprint = meshService.getFingerprint(for: peerID) else { return nil }
|
||||
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
||||
guard let identity = candidates.first(where: { $0.fingerprint == fingerprint }),
|
||||
let signingKey = identity.signingPublicKey else { return nil }
|
||||
return (fingerprint, signingKey)
|
||||
}
|
||||
|
||||
/// Short mesh peer IDs are the fingerprint's first 16 hex chars, so the
|
||||
/// connected peer for a roster fingerprint is a direct derivation.
|
||||
func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? {
|
||||
let shortID = PeerID(str: String(fingerprint.prefix(16)))
|
||||
return meshService.isPeerConnected(shortID) ? shortID : nil
|
||||
}
|
||||
|
||||
func isFingerprintBlocked(_ fingerprint: String) -> Bool {
|
||||
identityManager.isBlocked(fingerprint: fingerprint)
|
||||
}
|
||||
|
||||
func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) {
|
||||
meshService.sendGroupInvite(payload, to: peerID)
|
||||
}
|
||||
|
||||
func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) {
|
||||
meshService.sendGroupKeyUpdate(payload, to: peerID)
|
||||
}
|
||||
|
||||
func broadcastGroupMessagePayload(_ payload: Data) {
|
||||
meshService.broadcastGroupMessage(payload)
|
||||
}
|
||||
|
||||
// MARK: CommandContextProvider group commands (parsed by CommandProcessor)
|
||||
|
||||
func groupCreate(named name: String) -> CommandResult {
|
||||
groupCoordinator.createGroup(named: name)
|
||||
}
|
||||
|
||||
func groupInvite(nickname: String) -> CommandResult {
|
||||
groupCoordinator.inviteMember(nickname: nickname)
|
||||
}
|
||||
|
||||
func groupRemove(nickname: String) -> CommandResult {
|
||||
groupCoordinator.removeMember(nickname: nickname)
|
||||
}
|
||||
|
||||
func groupLeave() -> CommandResult {
|
||||
groupCoordinator.leaveGroup()
|
||||
}
|
||||
|
||||
func groupList() -> CommandResult {
|
||||
groupCoordinator.listGroups()
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the private-groups feature: creating groups, creator-managed invites
|
||||
/// and key rotation over Noise, and sealing/opening group message broadcasts.
|
||||
/// Delivery is fire-and-flood like public chat — no per-member acks in v1 —
|
||||
/// with gossip-sync backfill as the only offline catch-up.
|
||||
@MainActor
|
||||
final class ChatGroupCoordinator {
|
||||
private unowned let context: any ChatGroupContext
|
||||
|
||||
private static let maxGroupNameLength = 40
|
||||
|
||||
init(context: any ChatGroupContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
// MARK: - Commands
|
||||
|
||||
func createGroup(named rawName: String) -> CommandResult {
|
||||
let name = rawName.trimmed
|
||||
guard !name.isEmpty else {
|
||||
return .error(message: String(localized: "system.group.usage_create", defaultValue: "usage: /group create <name>", comment: "Usage hint for /group create"))
|
||||
}
|
||||
guard name.count <= Self.maxGroupNameLength else {
|
||||
return .error(message: String(localized: "system.group.name_too_long", defaultValue: "group names are limited to 40 characters", comment: "Error when a group name exceeds the length cap"))
|
||||
}
|
||||
|
||||
let myFingerprint = context.myNoiseFingerprint()
|
||||
let mySigningKey = context.mySigningPublicKey()
|
||||
guard !myFingerprint.isEmpty, mySigningKey.count == 32 else {
|
||||
return .error(message: String(localized: "system.group.identity_unavailable", defaultValue: "your identity keys are not ready yet", comment: "Error when the local identity is not ready for group operations"))
|
||||
}
|
||||
|
||||
let creator = GroupMember(fingerprint: myFingerprint, signingKey: mySigningKey, nickname: context.nickname)
|
||||
guard let group = context.groupStore.createGroup(named: name, creator: creator) else {
|
||||
return .error(message: String(localized: "system.group.create_failed", defaultValue: "could not create the group", comment: "Error when group creation fails"))
|
||||
}
|
||||
|
||||
context.startPrivateChat(with: group.peerID)
|
||||
return .success(message: String(
|
||||
format: String(localized: "system.group.created", defaultValue: "created group '%@' — use /group invite @name to add people", comment: "System message after creating a group; placeholder is the group name"),
|
||||
locale: .current,
|
||||
name
|
||||
))
|
||||
}
|
||||
|
||||
func inviteMember(nickname rawNickname: String) -> CommandResult {
|
||||
let nickname = normalizedNickname(rawNickname)
|
||||
guard !nickname.isEmpty else {
|
||||
return .error(message: String(localized: "system.group.usage_invite", defaultValue: "usage: /group invite @name", comment: "Usage hint for /group invite"))
|
||||
}
|
||||
guard let group = selectedGroup() else {
|
||||
return .error(message: String(localized: "system.group.not_in_group", defaultValue: "open a group chat first", comment: "Error when a group command requires an open group chat"))
|
||||
}
|
||||
guard group.creatorFingerprint == context.myNoiseFingerprint() else {
|
||||
return .error(message: String(localized: "system.group.creator_only", defaultValue: "only the group creator can do that", comment: "Error when a non-creator attempts a creator-only group action"))
|
||||
}
|
||||
guard let peerID = context.getPeerIDForNickname(nickname) else {
|
||||
return .error(message: String(
|
||||
format: String(localized: "system.group.peer_not_found", defaultValue: "'%@' not found", comment: "Error when the invitee nickname is unknown; placeholder is the nickname"),
|
||||
locale: .current,
|
||||
nickname
|
||||
))
|
||||
}
|
||||
guard context.isPeerConnected(peerID) else {
|
||||
return .error(message: String(
|
||||
format: String(localized: "system.group.peer_not_connected", defaultValue: "%@ must be connected over mesh to be invited", comment: "Error when the invitee is not connected over mesh; placeholder is the nickname"),
|
||||
locale: .current,
|
||||
nickname
|
||||
))
|
||||
}
|
||||
guard let identity = context.cryptoIdentity(for: peerID) else {
|
||||
return .error(message: String(
|
||||
format: String(localized: "system.group.peer_identity_unknown", defaultValue: "cannot verify %@'s identity yet — wait for their announce", comment: "Error when the invitee's verified identity is unavailable; placeholder is the nickname"),
|
||||
locale: .current,
|
||||
nickname
|
||||
))
|
||||
}
|
||||
guard !group.isMember(fingerprint: identity.fingerprint) else {
|
||||
return .error(message: String(
|
||||
format: String(localized: "system.group.already_member", defaultValue: "%@ is already a member", comment: "Error when the invitee is already a member; placeholder is the nickname"),
|
||||
locale: .current,
|
||||
nickname
|
||||
))
|
||||
}
|
||||
guard group.members.count < BitchatGroup.maxMembers else {
|
||||
return .error(message: String(
|
||||
format: String(localized: "system.group.full", defaultValue: "group is full (max %@ members)", comment: "Error when the group is at the member cap; placeholder is the cap"),
|
||||
locale: .current,
|
||||
"\(BitchatGroup.maxMembers)"
|
||||
))
|
||||
}
|
||||
|
||||
let newMember = GroupMember(
|
||||
fingerprint: identity.fingerprint,
|
||||
signingKey: identity.signingKey,
|
||||
nickname: context.peerNickname(for: peerID) ?? nickname
|
||||
)
|
||||
// Rotate the key (epoch + 1) on every roster change, not just removals.
|
||||
// A monotonically increasing epoch per roster gives the receiver a
|
||||
// strict ordering: two out-of-order invite states can no longer share
|
||||
// an epoch and last-writer-wins a just-added member back out.
|
||||
let members = group.members + [newMember]
|
||||
guard let (updated, key) = context.groupStore.rotateKey(groupID: group.groupID, members: members),
|
||||
let payload = signedStatePayload(for: updated, key: key) else {
|
||||
return .error(message: String(localized: "system.group.invite_failed", defaultValue: "could not build the group invite", comment: "Error when building or signing a group invite fails"))
|
||||
}
|
||||
|
||||
context.sendGroupInvitePayload(payload, to: peerID)
|
||||
distributeState(payload, group: updated, excluding: [identity.fingerprint], type: .keyUpdate)
|
||||
|
||||
return .success(message: String(
|
||||
format: String(localized: "system.group.invited", defaultValue: "invited %1$@ to '%2$@'", comment: "System message after inviting someone; placeholders are the nickname and the group name"),
|
||||
locale: .current,
|
||||
nickname,
|
||||
updated.name
|
||||
))
|
||||
}
|
||||
|
||||
/// Creator-side removal: rotates the group key (epoch + 1) and sends the
|
||||
/// new state to every remaining member so the removed member's key stops
|
||||
/// decrypting future traffic.
|
||||
func removeMember(nickname rawNickname: String) -> CommandResult {
|
||||
let nickname = normalizedNickname(rawNickname)
|
||||
guard !nickname.isEmpty else {
|
||||
return .error(message: String(localized: "system.group.usage_remove", defaultValue: "usage: /group remove @name", comment: "Usage hint for /group remove"))
|
||||
}
|
||||
guard let group = selectedGroup() else {
|
||||
return .error(message: String(localized: "system.group.not_in_group", defaultValue: "open a group chat first", comment: "Error when a group command requires an open group chat"))
|
||||
}
|
||||
guard group.creatorFingerprint == context.myNoiseFingerprint() else {
|
||||
return .error(message: String(localized: "system.group.creator_only", defaultValue: "only the group creator can do that", comment: "Error when a non-creator attempts a creator-only group action"))
|
||||
}
|
||||
guard let member = group.members.first(where: { $0.nickname.caseInsensitiveCompare(nickname) == .orderedSame }) else {
|
||||
return .error(message: String(
|
||||
format: String(localized: "system.group.member_not_found", defaultValue: "'%@' is not a member of this group", comment: "Error when the member to remove is not in the roster; placeholder is the nickname"),
|
||||
locale: .current,
|
||||
nickname
|
||||
))
|
||||
}
|
||||
guard member.fingerprint != group.creatorFingerprint else {
|
||||
return .error(message: String(localized: "system.group.cannot_remove_creator", defaultValue: "the creator cannot be removed", comment: "Error when the creator tries to remove themselves"))
|
||||
}
|
||||
|
||||
let remaining = group.members.filter { $0.fingerprint != member.fingerprint }
|
||||
guard let (rotated, newKey) = context.groupStore.rotateKey(groupID: group.groupID, members: remaining),
|
||||
let payload = signedStatePayload(for: rotated, key: newKey) else {
|
||||
return .error(message: String(localized: "system.group.rotate_failed", defaultValue: "could not rotate the group key", comment: "Error when rotating the group key fails"))
|
||||
}
|
||||
|
||||
distributeState(payload, group: rotated, excluding: [], type: .keyUpdate)
|
||||
notifyRemovedMember(member, rotated: rotated)
|
||||
|
||||
return .success(message: String(
|
||||
format: String(localized: "system.group.removed_member", defaultValue: "removed %@ and rotated the group key", comment: "System message after removing a member and rotating the key; placeholder is the nickname"),
|
||||
locale: .current,
|
||||
member.nickname
|
||||
))
|
||||
}
|
||||
|
||||
func leaveGroup() -> CommandResult {
|
||||
guard let group = selectedGroup() else {
|
||||
return .error(message: String(localized: "system.group.not_in_group", defaultValue: "open a group chat first", comment: "Error when a group command requires an open group chat"))
|
||||
}
|
||||
// Close the chat window first so the confirmation message doesn't
|
||||
// resurrect the conversation we're about to remove.
|
||||
context.endPrivateChat()
|
||||
context.removePrivateChat(group.peerID)
|
||||
context.groupStore.removeGroup(withID: group.groupID)
|
||||
context.notifyUIChanged()
|
||||
return .success(message: String(
|
||||
format: String(localized: "system.group.left", defaultValue: "left group '%@'", comment: "System message after leaving a group; placeholder is the group name"),
|
||||
locale: .current,
|
||||
group.name
|
||||
))
|
||||
}
|
||||
|
||||
func listGroups() -> CommandResult {
|
||||
let groups = context.groupStore.groups
|
||||
guard !groups.isEmpty else {
|
||||
return .success(message: String(localized: "system.group.none", defaultValue: "you are not in any groups — /group create <name> to start one", comment: "System message when the user is in no groups"))
|
||||
}
|
||||
let myFingerprint = context.myNoiseFingerprint()
|
||||
let lines = groups.map { group -> String in
|
||||
let role = group.creatorFingerprint == myFingerprint ? " (creator)" : ""
|
||||
return "#\(group.name)\(role) — \(group.members.count)/\(BitchatGroup.maxMembers)"
|
||||
}
|
||||
return .success(message: String(localized: "system.group.list_header", defaultValue: "your groups:", comment: "Header line for the /group list output") + "\n" + lines.joined(separator: "\n"))
|
||||
}
|
||||
|
||||
// MARK: - Sending
|
||||
|
||||
/// Fire-and-flood send: local echo goes straight to `.sent` because group
|
||||
/// messages have no per-member acknowledgments in v1.
|
||||
func sendGroupMessage(_ content: String, to groupPeerID: PeerID) {
|
||||
guard !content.isEmpty, content.count <= InputValidator.Limits.maxMessageLength else { return }
|
||||
guard let group = context.groupStore.group(for: groupPeerID),
|
||||
let key = context.groupStore.key(forGroupID: group.groupID) else {
|
||||
context.addSystemMessage(String(localized: "system.group.unknown", defaultValue: "you are no longer in this group", comment: "System message when sending into an unknown group"))
|
||||
return
|
||||
}
|
||||
|
||||
let messageID = UUID().uuidString
|
||||
let timestamp = Date()
|
||||
let payload: Data
|
||||
do {
|
||||
payload = try GroupCrypto.sealMessage(
|
||||
content: content,
|
||||
messageID: messageID,
|
||||
senderNickname: context.nickname,
|
||||
senderSigningKey: context.mySigningPublicKey(),
|
||||
timestampMs: UInt64(timestamp.timeIntervalSince1970 * 1000),
|
||||
groupID: group.groupID,
|
||||
epoch: group.epoch,
|
||||
key: key,
|
||||
sign: { [weak context] data in context?.signWithNoiseKey(data) }
|
||||
)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to seal group message: \(error)", category: .encryption)
|
||||
context.addLocalPrivateSystemMessage(
|
||||
String(localized: "system.group.send_failed", defaultValue: "could not encrypt the message", comment: "System message when sealing a group message fails"),
|
||||
to: groupPeerID
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: context.nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: group.name,
|
||||
senderPeerID: context.myPeerID,
|
||||
mentions: nil,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
context.appendPrivateMessage(message, to: groupPeerID)
|
||||
context.broadcastGroupMessagePayload(payload)
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
// MARK: - Receiving
|
||||
|
||||
/// Decrypt-verify path for an incoming 0x25 broadcast. Drops silently for
|
||||
/// unknown groups (non-members relay but never read), wrong epochs, bad
|
||||
/// sender signatures, and senders missing from the pinned roster.
|
||||
func handleGroupMessagePayload(_ payload: Data, timestamp: Date) {
|
||||
guard let envelope = GroupMessageEnvelope.decode(payload) else { return }
|
||||
guard let group = context.groupStore.group(withID: envelope.groupID) else { return }
|
||||
guard envelope.epoch == group.epoch else {
|
||||
SecureLogger.debug("Dropping group message with epoch \(envelope.epoch) (current \(group.epoch))", category: .encryption)
|
||||
return
|
||||
}
|
||||
guard let key = context.groupStore.key(forGroupID: group.groupID) else { return }
|
||||
|
||||
let plaintext: GroupMessagePlaintext
|
||||
do {
|
||||
plaintext = try GroupCrypto.openMessage(envelope, key: key)
|
||||
} catch {
|
||||
SecureLogger.debug("Failed to open group message: \(error)", category: .encryption)
|
||||
return
|
||||
}
|
||||
|
||||
// Sender must be pinned in the creator-signed roster; key possession
|
||||
// alone is not authorship.
|
||||
guard let member = group.member(withSigningKey: plaintext.senderSigningKey) else {
|
||||
SecureLogger.warning("Dropping group message from non-roster sender", category: .security)
|
||||
return
|
||||
}
|
||||
// Our own broadcast echoed back via relay or sync replay.
|
||||
guard plaintext.senderSigningKey != context.mySigningPublicKey() else { return }
|
||||
// Honor /block inside groups too: drop display + notification for a
|
||||
// blocked member, consistent with every other inbound path.
|
||||
guard !context.isFingerprintBlocked(member.fingerprint) else {
|
||||
SecureLogger.debug("Dropping group message from blocked member", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
let groupPeerID = group.peerID
|
||||
// Trust the authenticated inner timestamp (clamped so a future-dated
|
||||
// message cannot pin itself to the bottom of the timeline).
|
||||
let messageDate = min(Date(timeIntervalSince1970: TimeInterval(plaintext.timestampMs) / 1000), Date())
|
||||
let senderName = member.nickname.isEmpty ? plaintext.senderNickname : member.nickname
|
||||
let senderPeerID = PeerID(str: String(member.fingerprint.prefix(16)))
|
||||
let message = BitchatMessage(
|
||||
id: plaintext.messageID,
|
||||
sender: senderName,
|
||||
content: plaintext.content,
|
||||
timestamp: messageDate,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: group.name,
|
||||
senderPeerID: senderPeerID,
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
guard context.appendPrivateMessage(message, to: groupPeerID) else { return }
|
||||
|
||||
let isViewing = context.selectedPrivateChatPeer == groupPeerID
|
||||
if !isViewing {
|
||||
context.markPrivateChatUnread(groupPeerID)
|
||||
let isRecent = Date().timeIntervalSince(messageDate) < 30
|
||||
if isRecent {
|
||||
context.notifyPrivateMessage(
|
||||
from: "\(senderName) @ \(group.name)",
|
||||
message: plaintext.content,
|
||||
peerID: groupPeerID
|
||||
)
|
||||
}
|
||||
}
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
/// Accepts creator-signed group state arriving as an invite. The Noise
|
||||
/// session peer must BE the creator, the signature must verify against
|
||||
/// the creator key pinned in the roster, and we must be in the roster.
|
||||
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
|
||||
applyGroupState(from: peerID, payload: payload, isInvite: true)
|
||||
}
|
||||
|
||||
/// Accepts creator-signed state updates (rotation/roster). A state whose
|
||||
/// roster no longer includes us means we were removed: drop the group.
|
||||
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
|
||||
applyGroupState(from: peerID, payload: payload, isInvite: false)
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatGroupCoordinator {
|
||||
enum StateSendType {
|
||||
case invite
|
||||
case keyUpdate
|
||||
}
|
||||
|
||||
func normalizedNickname(_ raw: String) -> String {
|
||||
let trimmed = raw.trimmed
|
||||
return trimmed.hasPrefix("@") ? String(trimmed.dropFirst()) : trimmed
|
||||
}
|
||||
|
||||
func selectedGroup() -> BitchatGroup? {
|
||||
guard let selected = context.selectedPrivateChatPeer, selected.isGroup else { return nil }
|
||||
return context.groupStore.group(for: selected)
|
||||
}
|
||||
|
||||
func signedStatePayload(for group: BitchatGroup, key: Data) -> Data? {
|
||||
GroupStatePayload.makeSigned(group: group, key: key) { [weak context] data in
|
||||
context?.signWithNoiseKey(data)
|
||||
}?.encode()
|
||||
}
|
||||
|
||||
/// Sends the state payload to every connected roster member except us and
|
||||
/// the excluded fingerprints. Offline members catch up the next time the
|
||||
/// creator sends them state (v1 limitation, documented in the PR).
|
||||
func distributeState(_ payload: Data, group: BitchatGroup, excluding excludedFingerprints: Set<String>, type: StateSendType) {
|
||||
let myFingerprint = context.myNoiseFingerprint()
|
||||
for member in group.members {
|
||||
guard member.fingerprint != myFingerprint,
|
||||
!excludedFingerprints.contains(member.fingerprint),
|
||||
let peerID = context.connectedPeerID(forFingerprint: member.fingerprint) else { continue }
|
||||
switch type {
|
||||
case .invite:
|
||||
context.sendGroupInvitePayload(payload, to: peerID)
|
||||
case .keyUpdate:
|
||||
context.sendGroupKeyUpdatePayload(payload, to: peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells a just-removed member they're out so their client can deactivate
|
||||
/// the group instead of silently going dark (dropping every message under
|
||||
/// the epoch it no longer has the key for). The notice is a creator-signed
|
||||
/// state whose roster excludes the removee — their `applyGroupState`
|
||||
/// removal branch fires on the missing-self roster and surfaces the
|
||||
/// "removed from group" system message.
|
||||
///
|
||||
/// It carries a throwaway all-zero key, never the rotated key, so the
|
||||
/// removee cannot decrypt post-removal traffic. State is sent 1:1 over
|
||||
/// authenticated Noise, so no remaining member ever receives this blob
|
||||
/// (and even if one did, its own missing-self check would not match).
|
||||
/// If the removee is offline the notice can't be delivered — same v1
|
||||
/// limitation as any other missed key update, documented in the PR.
|
||||
func notifyRemovedMember(_ removed: GroupMember, rotated: BitchatGroup) {
|
||||
guard let peerID = context.connectedPeerID(forFingerprint: removed.fingerprint) else { return }
|
||||
let throwawayKey = Data(count: BitchatGroup.keyLength)
|
||||
guard let payload = signedStatePayload(for: rotated, key: throwawayKey) else { return }
|
||||
context.sendGroupKeyUpdatePayload(payload, to: peerID)
|
||||
}
|
||||
|
||||
func applyGroupState(from peerID: PeerID, payload: Data, isInvite: Bool) {
|
||||
guard let state = GroupStatePayload.decode(payload) else {
|
||||
SecureLogger.warning("Malformed group state payload from \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
}
|
||||
// The Noise session already authenticated `peerID`; require that the
|
||||
// authenticated peer IS the creator whose key signed the state, so a
|
||||
// member can't re-invite or rotate on the creator's behalf.
|
||||
guard let senderFingerprint = context.meshFingerprint(for: peerID),
|
||||
senderFingerprint == state.creatorFingerprint else {
|
||||
SecureLogger.warning("Dropping group state from non-creator \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
}
|
||||
guard state.verifyCreatorSignature() else {
|
||||
SecureLogger.warning("Dropping group state with invalid creator signature", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
let myFingerprint = context.myNoiseFingerprint()
|
||||
let existing = context.groupStore.group(withID: state.groupID)
|
||||
|
||||
// A creator-signed roster that no longer includes us is a removal.
|
||||
guard state.members.contains(where: { $0.fingerprint == myFingerprint }) else {
|
||||
if let existing {
|
||||
if context.selectedPrivateChatPeer == existing.peerID {
|
||||
context.endPrivateChat()
|
||||
}
|
||||
context.removePrivateChat(existing.peerID)
|
||||
context.groupStore.removeGroup(withID: existing.groupID)
|
||||
context.addSystemMessage(String(
|
||||
format: String(localized: "system.group.removed_from", defaultValue: "you were removed from group '%@'", comment: "System message when removed from a group; placeholder is the group name"),
|
||||
locale: .current,
|
||||
existing.name
|
||||
))
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Never regress the epoch: state travels over live Noise sessions,
|
||||
// so an older epoch here is a stale (or misbehaving) creator device.
|
||||
if let existing, state.epoch < existing.epoch {
|
||||
SecureLogger.warning("Dropping stale group state (epoch \(state.epoch) < \(existing.epoch))", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
let isNewMembership = existing == nil
|
||||
guard context.groupStore.upsert(state.asGroup, key: state.key) else {
|
||||
SecureLogger.error("Failed to store group state for \(state.name)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
if isNewMembership {
|
||||
let inviter = state.members.first { $0.fingerprint == state.creatorFingerprint }?.nickname
|
||||
?? context.peerNickname(for: peerID)
|
||||
?? "?"
|
||||
let notice = String(
|
||||
format: String(localized: "system.group.joined", defaultValue: "you were added to group '%1$@' by %2$@ — it now appears in your people list", comment: "System message when added to a group; placeholders are the group name and the inviter"),
|
||||
locale: .current,
|
||||
state.name,
|
||||
inviter
|
||||
)
|
||||
context.addSystemMessage(notice)
|
||||
context.markPrivateChatUnread(state.asGroup.peerID)
|
||||
context.notifyPrivateMessage(from: inviter, message: notice, peerID: state.asGroup.peerID)
|
||||
} else if isInvite == false, let existing, state.epoch > existing.epoch {
|
||||
SecureLogger.info("Group '\(state.name)' rotated to epoch \(state.epoch)", category: .session)
|
||||
}
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,13 @@ 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,
|
||||
@@ -324,7 +331,7 @@ private extension ChatLifecycleCoordinator {
|
||||
|
||||
do {
|
||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||
content: message,
|
||||
geohash: channel.geohash,
|
||||
senderIdentity: identity,
|
||||
@@ -343,7 +350,7 @@ private extension ChatLifecycleCoordinator {
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
|
||||
context.addSystemMessage(
|
||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||
String(localized: "system.location.send_failed", defaultValue: "failed to send to location channel", comment: "System message when a location channel send fails")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,13 +117,13 @@ final class ChatMediaTransferCoordinator {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", defaultValue: "voice note too large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", defaultValue: "voice note failed to send", comment: "Failure reason shown when a voice note could not be sent"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +68,22 @@ 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 }
|
||||
|
||||
@@ -92,120 +104,117 @@ final class ChatOutgoingCoordinator {
|
||||
}
|
||||
|
||||
let mentions = context.parseMentions(from: content)
|
||||
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
|
||||
guard let preparedMessage else { return }
|
||||
|
||||
appendLocalEcho(preparedMessage.message)
|
||||
routePublicMessage(
|
||||
originalContent: content,
|
||||
mentions: mentions,
|
||||
geoContext: preparedMessage.geoContext,
|
||||
messageID: preparedMessage.message.id,
|
||||
timestamp: preparedMessage.message.timestamp
|
||||
)
|
||||
switch context.activeChannel {
|
||||
case .mesh:
|
||||
sendMeshPublicMessage(originalContent: content, trimmed: trimmed, mentions: mentions)
|
||||
case .location(let channel):
|
||||
sendGeohashPublicMessage(trimmed, mentions: mentions, channel: channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatOutgoingCoordinator {
|
||||
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()
|
||||
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
|
||||
)
|
||||
|
||||
switch context.activeChannel {
|
||||
case .mesh:
|
||||
break
|
||||
appendLocalEcho(message, to: .mesh)
|
||||
context.recordPublicActivity(forChannelKey: "mesh")
|
||||
context.sendMeshMessage(
|
||||
originalContent,
|
||||
mentions: mentions,
|
||||
messageID: message.id,
|
||||
timestamp: message.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
case .location(let channel):
|
||||
/// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see
|
||||
/// `NostrPoW`), so the whole echo-and-send runs in a task once the signed
|
||||
/// event — whose ID is also the local message ID — exists. Typical mining
|
||||
/// at the default target is well under 100 ms and hard-capped at
|
||||
/// `NostrPoW.miningTimeCap`, so sending is never meaningfully delayed.
|
||||
func sendGeohashPublicMessage(_ trimmed: String, mentions: [String], channel: GeohashChannel) {
|
||||
let identity: NostrIdentity
|
||||
do {
|
||||
identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
|
||||
context.addSystemMessage(
|
||||
String(localized: "system.location.send_failed", defaultValue: "failed to send to location channel", comment: "System message when a location channel send fails")
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let displaySender = context.nickname + "#" + String(identity.publicKeyHex.suffix(4))
|
||||
let senderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||||
let teleported = context.isTeleported
|
||||
let nickname = context.nickname
|
||||
|
||||
// Serialize geohash sends: each send awaits the previous send's task
|
||||
// before it appends + relays, so user-visible order always matches
|
||||
// send order even when an earlier message mines longer than a later
|
||||
// one. Cancelling the previous task only *expedites* its mining (the
|
||||
// NIP-13 target is polled, not aborted), so it still finishes and
|
||||
// sends — and it finishes fast, so awaiting it never stacks mining
|
||||
// delays or blocks a send beyond `NostrPoW.miningTimeCap`.
|
||||
let previousSend = geohashMiningTask
|
||||
previousSend?.cancel()
|
||||
geohashMiningTask = Task { @MainActor [weak context = self.context] in
|
||||
await previousSend?.value
|
||||
|
||||
let event: NostrEvent
|
||||
do {
|
||||
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(
|
||||
event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||
content: trimmed,
|
||||
geohash: channel.geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: context.nickname,
|
||||
teleported: teleported
|
||||
)
|
||||
|
||||
messageID = event.id
|
||||
messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
geoContext = (
|
||||
channel: channel,
|
||||
event: event,
|
||||
identity: identity,
|
||||
nickname: nickname,
|
||||
teleported: teleported
|
||||
)
|
||||
} 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")
|
||||
context?.addSystemMessage(
|
||||
String(localized: "system.location.send_failed", defaultValue: "failed to send to location channel", comment: "System message when a location channel send fails")
|
||||
)
|
||||
return nil
|
||||
return
|
||||
}
|
||||
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) {
|
||||
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
|
||||
func appendLocalEcho(_ message: BitchatMessage, to conversationID: ConversationID) {
|
||||
context.appendPublicMessage(message, to: conversationID)
|
||||
|
||||
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,6 +323,15 @@ final class ChatPeerIdentityCoordinator {
|
||||
func startPrivateChat(with peerID: PeerID) {
|
||||
guard peerID != context.myPeerID else { return }
|
||||
|
||||
// Group chats are virtual conversations: no peer identity, favorites,
|
||||
// handshake, or message consolidation applies — just select the chat.
|
||||
if peerID.isGroup {
|
||||
context.selectedPrivateChatFingerprint = nil
|
||||
context.beginPrivateChatSession(with: peerID)
|
||||
context.markPrivateChatRead(peerID)
|
||||
return
|
||||
}
|
||||
|
||||
let peerNickname = context.peerNickname(for: peerID) ?? "unknown"
|
||||
|
||||
if context.unifiedIsBlocked(peerID) {
|
||||
|
||||
@@ -238,7 +238,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
let nickname = context.peerNickname(for: peerID) ?? "user"
|
||||
context.addSystemMessage(
|
||||
String(
|
||||
format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"),
|
||||
format: String(localized: "system.dm.blocked_recipient", defaultValue: "cannot send message to %@: person is blocked.", comment: "System message when attempting to message a blocked user"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
@@ -298,7 +298,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
} else {
|
||||
context.setPrivateDeliveryStatus(
|
||||
.failed(
|
||||
reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable")
|
||||
reason: String(localized: "content.delivery.reason.unreachable", defaultValue: "peer not reachable", comment: "Failure reason when a peer is unreachable")
|
||||
),
|
||||
forMessageID: messageID,
|
||||
peerID: peerID
|
||||
@@ -306,7 +306,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
let name = recipientNickname ?? "user"
|
||||
context.addSystemMessage(
|
||||
String(
|
||||
format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"),
|
||||
format: String(localized: "system.dm.unreachable", defaultValue: "cannot send message to %@ - peer is not reachable via mesh or nostr.", comment: "System message when a recipient is unreachable"),
|
||||
locale: .current,
|
||||
name
|
||||
)
|
||||
@@ -317,7 +317,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
func sendGeohashDM(_ content: String, to peerID: PeerID) {
|
||||
guard case .location(let channel) = context.activeChannel else {
|
||||
context.addSystemMessage(
|
||||
String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel")
|
||||
String(localized: "system.location.not_in_channel", defaultValue: "cannot send: not in a location channel", comment: "System message when attempting to send without being in a location channel")
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -341,7 +341,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
guard let recipientHex = context.nostrKeyMapping[peerID] else {
|
||||
context.setPrivateDeliveryStatus(
|
||||
.failed(
|
||||
reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown")
|
||||
reason: String(localized: "content.delivery.reason.unknown_recipient", defaultValue: "unknown recipient", comment: "Failure reason when the recipient is unknown")
|
||||
),
|
||||
forMessageID: messageID,
|
||||
peerID: peerID
|
||||
@@ -352,13 +352,13 @@ final class ChatPrivateConversationCoordinator {
|
||||
if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
|
||||
context.setPrivateDeliveryStatus(
|
||||
.failed(
|
||||
reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked")
|
||||
reason: String(localized: "content.delivery.reason.blocked", defaultValue: "user is blocked", comment: "Failure reason when the user is blocked")
|
||||
),
|
||||
forMessageID: messageID,
|
||||
peerID: peerID
|
||||
)
|
||||
context.addSystemMessage(
|
||||
String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked")
|
||||
String(localized: "system.dm.blocked_generic", defaultValue: "cannot send message: person is blocked.", comment: "System message when sending fails because user is blocked")
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -368,7 +368,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
if recipientHex.lowercased() == identity.publicKeyHex.lowercased() {
|
||||
context.setPrivateDeliveryStatus(
|
||||
.failed(
|
||||
reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself")
|
||||
reason: String(localized: "content.delivery.reason.self", defaultValue: "cannot message yourself", comment: "Failure reason when attempting to message yourself")
|
||||
),
|
||||
forMessageID: messageID,
|
||||
peerID: peerID
|
||||
@@ -390,7 +390,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
} catch {
|
||||
context.setPrivateDeliveryStatus(
|
||||
.failed(
|
||||
reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error")
|
||||
reason: String(localized: "content.delivery.reason.send_error", defaultValue: "send error", comment: "Failure reason for a generic send error")
|
||||
),
|
||||
forMessageID: messageID,
|
||||
peerID: peerID
|
||||
|
||||
@@ -82,7 +82,9 @@ protocol ChatPublicConversationContext: AnyObject {
|
||||
// MARK: Inbound public message processing
|
||||
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage
|
||||
func isMessageBlocked(_ message: BitchatMessage) -> Bool
|
||||
func allowPublicMessage(senderKey: String, contentKey: String) -> 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
|
||||
/// 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)
|
||||
@@ -137,8 +139,8 @@ extension ChatViewModel: ChatPublicConversationContext {
|
||||
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
|
||||
}
|
||||
|
||||
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool {
|
||||
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey)
|
||||
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool {
|
||||
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey, powBits: powBits)
|
||||
}
|
||||
|
||||
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
|
||||
@@ -290,7 +292,17 @@ 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,
|
||||
@@ -367,7 +379,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
guard let context else { return }
|
||||
do {
|
||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||
content: content,
|
||||
geohash: channel.geohash,
|
||||
senderIdentity: identity,
|
||||
@@ -395,7 +407,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
func handlePublicMessage(_ message: BitchatMessage) {
|
||||
/// - 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) {
|
||||
let finalMessage = context.processActionMessage(message)
|
||||
if context.isMessageBlocked(finalMessage) { return }
|
||||
|
||||
@@ -405,7 +421,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) {
|
||||
if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey, powBits: powBits) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,11 @@ 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 {
|
||||
@@ -129,6 +134,18 @@ 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 {
|
||||
@@ -372,6 +389,15 @@ 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.
|
||||
|
||||
@@ -102,7 +102,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
@MainActor
|
||||
var canSendMediaInCurrentContext: Bool {
|
||||
if let peer = selectedPrivateChatPeer {
|
||||
return !(peer.isGeoDM || peer.isGeoChat)
|
||||
// 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)
|
||||
}
|
||||
switch activeChannel {
|
||||
case .mesh: return true
|
||||
@@ -177,6 +179,8 @@ 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
|
||||
@@ -305,12 +309,17 @@ 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()
|
||||
@@ -809,6 +818,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
)
|
||||
|
||||
self.keychain = keychain
|
||||
self.groupStore = GroupStore(keychain: keychain)
|
||||
self.idBridge = idBridge
|
||||
self.identityManager = identityManager
|
||||
self.conversations = conversations
|
||||
@@ -1205,6 +1215,18 @@ 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
|
||||
@@ -1273,6 +1295,11 @@ 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)
|
||||
@@ -1485,6 +1512,14 @@ 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
|
||||
@@ -1524,6 +1559,33 @@ 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
|
||||
@@ -1555,6 +1617,12 @@ 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 {
|
||||
@@ -1677,12 +1745,27 @@ 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,6 +72,7 @@ final class ChatViewModelBootstrapper {
|
||||
configureNoiseCallbacks()
|
||||
bindTransferProgress()
|
||||
configureGeoChannels()
|
||||
configureGateway()
|
||||
bindTeleportState()
|
||||
requestNotifications()
|
||||
registerObservers()
|
||||
@@ -106,7 +107,7 @@ private extension ChatViewModelBootstrapper {
|
||||
category: .session
|
||||
)
|
||||
viewModel.conversations.setDeliveryStatus(
|
||||
.failed(reason: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")),
|
||||
.failed(reason: String(localized: "content.delivery.reason.not_delivered", defaultValue: "not delivered", comment: "Failure reason shown when the router gave up delivering a message")),
|
||||
forMessageID: messageID
|
||||
)
|
||||
}
|
||||
@@ -244,6 +245,72 @@ 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)
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
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,6 +13,12 @@ extension ChatViewModel {
|
||||
|
||||
@MainActor
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
|
||||
// Group chats reuse the private-chat surface but broadcast a sealed
|
||||
// envelope instead of routing to a single peer.
|
||||
if peerID.isGroup {
|
||||
groupCoordinator.sendGroupMessage(content, to: peerID)
|
||||
return
|
||||
}
|
||||
privateConversationCoordinator.sendPrivateMessage(content, to: peerID)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ extension ChatViewModel {
|
||||
self.torStatusAnnounced = true
|
||||
// Post only in geohash channels (queue if not active)
|
||||
self.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||
String(localized: "system.tor.starting", defaultValue: "starting tor...", comment: "System message when Tor is starting")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ extension ChatViewModel {
|
||||
self.torRestartPending = true
|
||||
// Post only in geohash channels (queue if not active)
|
||||
self.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.restarting", comment: "System message when Tor is restarting")
|
||||
String(localized: "system.tor.restarting", defaultValue: "tor restarting to recover connectivity...", comment: "System message when Tor is restarting")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -41,13 +41,13 @@ extension ChatViewModel {
|
||||
if self.torRestartPending {
|
||||
// Post only in geohash channels (queue if not active)
|
||||
self.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.restarted", comment: "System message when Tor has restarted")
|
||||
String(localized: "system.tor.restarted", defaultValue: "tor restarted. network routing restored.", comment: "System message when Tor has restarted")
|
||||
)
|
||||
self.torRestartPending = false
|
||||
} else if TorManager.shared.torEnforced && !self.torInitialReadyAnnounced {
|
||||
// Initial start completed
|
||||
self.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.started", comment: "System message when Tor has started")
|
||||
String(localized: "system.tor.started", defaultValue: "tor started. routing all chats via tor for IP privacy.", comment: "System message when Tor has started")
|
||||
)
|
||||
self.torInitialReadyAnnounced = true
|
||||
}
|
||||
|
||||
@@ -115,6 +115,9 @@ 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
|
||||
@@ -145,6 +148,9 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +241,9 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +289,23 @@ 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(
|
||||
|
||||
@@ -48,15 +48,25 @@ struct MessageRateLimiter {
|
||||
self.contentRefill = contentRefillPerSec
|
||||
}
|
||||
|
||||
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
|
||||
/// - 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
|
||||
}
|
||||
|
||||
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
||||
capacity: contentCapacity,
|
||||
|
||||
@@ -32,7 +32,10 @@ protocol NostrInboundPipelineContext: AnyObject {
|
||||
func recordGeoParticipant(pubkeyHex: String)
|
||||
|
||||
// MARK: Inbound public messages
|
||||
func handlePublicMessage(_ message: BitchatMessage)
|
||||
/// `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 checkForMentions(_ message: BitchatMessage)
|
||||
func sendHapticFeedback(for message: BitchatMessage)
|
||||
func parseMentions(from content: String) -> [String]
|
||||
@@ -152,6 +155,7 @@ 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,
|
||||
@@ -165,7 +169,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)
|
||||
context.handlePublicMessage(message, powBits: powBits)
|
||||
if !isBlocked {
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
@@ -187,10 +191,12 @@ 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))… tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
|
||||
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
|
||||
}
|
||||
|
||||
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||
@@ -255,7 +261,7 @@ final class NostrInboundPipeline {
|
||||
|
||||
Task { @MainActor [weak context] in
|
||||
guard let context else { return }
|
||||
context.handlePublicMessage(message)
|
||||
context.handlePublicMessage(message, powBits: powBits)
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
}
|
||||
@@ -297,9 +303,11 @@ final class NostrInboundPipeline {
|
||||
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .verifyChallenge, .verifyResponse,
|
||||
// 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,
|
||||
.bulkTransferOffer, .bulkTransferResponse:
|
||||
// Wi-Fi bulk negotiation is mesh-proximity only; it never rides Nostr.
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -351,9 +359,11 @@ final class NostrInboundPipeline {
|
||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .verifyChallenge, .verifyResponse,
|
||||
// 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,
|
||||
.bulkTransferOffer, .bulkTransferResponse:
|
||||
// Wi-Fi bulk negotiation is mesh-proximity only; it never rides Nostr.
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -432,10 +442,12 @@ final class NostrInboundPipeline {
|
||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse,
|
||||
// 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,
|
||||
.bulkTransferOffer, .bulkTransferResponse:
|
||||
// Wi-Fi bulk negotiation is mesh-proximity only;
|
||||
// it never rides Nostr.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
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
|
||||
}
|
||||
@@ -75,6 +96,15 @@ 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(
|
||||
@@ -114,7 +144,7 @@ struct AppInfoView: View {
|
||||
// Custom header for macOS
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("app_info.done") {
|
||||
Button(String(localized: "app_info.done", defaultValue: "DONE")) {
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -129,6 +159,11 @@ struct AppInfoView: View {
|
||||
.themedSheetBackground()
|
||||
}
|
||||
.frame(width: 600, height: 700)
|
||||
.sheet(isPresented: $showTopology) {
|
||||
if let topologyProvider {
|
||||
MeshTopologyView(provider: topologyProvider)
|
||||
}
|
||||
}
|
||||
#else
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
@@ -143,6 +178,11 @@ struct AppInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showTopology) {
|
||||
if let topologyProvider {
|
||||
MeshTopologyView(provider: topologyProvider)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -199,6 +239,27 @@ struct AppInfoView: View {
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
|
||||
// Network diagnostics
|
||||
if topologyProvider != nil {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Network.title)
|
||||
|
||||
Button {
|
||||
showTopology = true
|
||||
} label: {
|
||||
HStack(spacing: 0) {
|
||||
FeatureRow(info: Strings.Network.topology)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(Text(String(localized: "app_info.network.topology.hint", defaultValue: "opens the mesh topology map")))
|
||||
}
|
||||
}
|
||||
|
||||
// Features
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Features.title)
|
||||
@@ -248,11 +309,122 @@ 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
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//
|
||||
// BoardView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The bulletin board for one context: a geohash channel, or the mesh-local
|
||||
/// board when `geohash` is empty. Urgent posts pin to the top; own posts can
|
||||
/// be swipe-deleted, which broadcasts a signed tombstone.
|
||||
struct BoardView: View {
|
||||
/// Empty string = mesh-local board.
|
||||
let geohash: String
|
||||
let senderNickname: String
|
||||
@ObservedObject var board: BoardManager
|
||||
|
||||
@ThemedPalette private var palette
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var draft: String = ""
|
||||
@State private var urgent = false
|
||||
@State private var expiryDays = 7
|
||||
|
||||
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
||||
private var posts: [BoardPostPacket] { board.posts(forGeohash: geohash) }
|
||||
|
||||
private enum Strings {
|
||||
static let boardName = String(localized: "board.title", defaultValue: "board", comment: "Title prefix of the bulletin board sheet")
|
||||
static let description = String(localized: "board.description", defaultValue: "persistent notices carried by the mesh. posts are signed, spread device-to-device, and expire on their own.", comment: "Explainer under the board sheet title")
|
||||
static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts")
|
||||
static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts")
|
||||
static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts")
|
||||
static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer")
|
||||
static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field")
|
||||
static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button")
|
||||
static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts")
|
||||
static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker")
|
||||
static let closeHint = String(localized: "board.accessibility.close", defaultValue: "Close board", comment: "Accessibility label for the board close button")
|
||||
|
||||
static func expiryDaysOption(_ days: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"),
|
||||
locale: .current,
|
||||
days
|
||||
)
|
||||
}
|
||||
|
||||
static func postAccessibilityLabel(author: String, content: String, urgent: Bool) -> String {
|
||||
let base = String(
|
||||
format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"),
|
||||
locale: .current,
|
||||
author, content
|
||||
)
|
||||
return urgent ? "\(urgentBadge), \(base)" : base
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerSection
|
||||
postList
|
||||
composer
|
||||
}
|
||||
.themedSurface()
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
|
||||
#endif
|
||||
.themedSheetBackground()
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
Text(verbatim: geohash.isEmpty ? "\(Strings.boardName) @ #mesh" : "\(Strings.boardName) @ #\(geohash)")
|
||||
.bitchatFont(size: 18)
|
||||
Spacer()
|
||||
SheetCloseButton { dismiss() }
|
||||
.accessibilityLabel(Strings.closeHint)
|
||||
}
|
||||
Text(Strings.description)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 12)
|
||||
.themedSurface()
|
||||
}
|
||||
|
||||
private var postList: some View {
|
||||
Group {
|
||||
if posts.isEmpty {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(Strings.emptyTitle)
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
Text(Strings.emptySubtitle)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
ForEach(posts, id: \.postID) { post in
|
||||
postRow(post)
|
||||
.listRowBackground(palette.background)
|
||||
.listRowSeparatorTint(palette.divider)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.themedSurface()
|
||||
}
|
||||
|
||||
private func postRow(_ post: BoardPostPacket) -> some View {
|
||||
let isOwn = board.isOwnPost(post)
|
||||
let author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon"
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
if post.isUrgent {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.bitchatSystem(size: 11))
|
||||
.foregroundColor(palette.alertRed)
|
||||
Text(Strings.urgentBadge)
|
||||
.bitchatFont(size: 11, weight: .semibold)
|
||||
.foregroundColor(palette.alertRed)
|
||||
}
|
||||
Text(verbatim: "@\(author)")
|
||||
.bitchatFont(size: 12, weight: .semibold)
|
||||
Text(timestampText(forMs: post.createdAt))
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
Spacer()
|
||||
if isOwn {
|
||||
Button {
|
||||
board.deletePost(post)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Strings.deleteAction)
|
||||
}
|
||||
}
|
||||
Text(post.content)
|
||||
.bitchatFont(size: 14)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Strings.postAccessibilityLabel(author: author, content: post.content, urgent: post.isUrgent))
|
||||
.accessibilityActions {
|
||||
if isOwn {
|
||||
Button(Strings.deleteAction) { board.deletePost(post) }
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
if isOwn {
|
||||
Button(role: .destructive) {
|
||||
board.deletePost(post)
|
||||
} label: {
|
||||
Label(Strings.deleteAction, systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
TextField(Strings.placeholder, text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.plain)
|
||||
.bitchatFont(size: 14)
|
||||
.lineLimit(maxDraftLines, reservesSpace: true)
|
||||
.padding(.vertical, 6)
|
||||
Button(action: send) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.bitchatSystem(size: 20))
|
||||
.foregroundColor(sendEnabled ? palette.accent : .secondary)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!sendEnabled)
|
||||
.accessibilityLabel(Strings.send)
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
Toggle(isOn: $urgent) {
|
||||
Text(Strings.urgentToggle)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(urgent ? palette.alertRed : palette.secondary)
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.fixedSize()
|
||||
.accessibilityLabel(Strings.urgentToggle)
|
||||
Spacer()
|
||||
Text(Strings.expiryLabel)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
Picker(Strings.expiryLabel, selection: $expiryDays) {
|
||||
ForEach([1, 3, 7], id: \.self) { days in
|
||||
Text(Strings.expiryDaysOption(days)).tag(days)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.fixedSize()
|
||||
.accessibilityLabel(Strings.expiryLabel)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.themedSurface()
|
||||
.overlay(Divider(), alignment: .top)
|
||||
}
|
||||
|
||||
private var sendEnabled: Bool {
|
||||
let trimmed = draft.trimmed
|
||||
return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes
|
||||
}
|
||||
|
||||
private func send() {
|
||||
guard let content = draft.trimmedOrNilIfEmpty else { return }
|
||||
let sent = board.createPost(
|
||||
content: content,
|
||||
geohash: geohash,
|
||||
urgent: urgent,
|
||||
expiryDays: expiryDays,
|
||||
nickname: senderNickname
|
||||
)
|
||||
if sent {
|
||||
draft = ""
|
||||
urgent = false
|
||||
}
|
||||
}
|
||||
|
||||
private func timestampText(forMs ms: UInt64) -> String {
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
|
||||
let now = Date()
|
||||
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
|
||||
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
|
||||
return rel.isEmpty ? "" : "\(rel) ago"
|
||||
}
|
||||
return Self.absDateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private static let relativeFormatter: DateComponentsFormatter = {
|
||||
let f = DateComponentsFormatter()
|
||||
f.allowedUnits = [.day, .hour, .minute]
|
||||
f.maximumUnitCount = 1
|
||||
f.unitsStyle = .abbreviated
|
||||
f.collapsesLargestUnit = true
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let absDateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.setLocalizedDateFormatFromTemplate("MMM d")
|
||||
return f
|
||||
}()
|
||||
}
|
||||
@@ -16,32 +16,32 @@ extension DeliveryStatus {
|
||||
var bitchatDescription: String {
|
||||
switch self {
|
||||
case .sending:
|
||||
return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent")
|
||||
return String(localized: "content.delivery.sending", defaultValue: "sending...", comment: "Delivery status description while a private message is being sent")
|
||||
case .sent:
|
||||
return String(localized: "content.delivery.sent", comment: "Delivery status description for a sent but not yet confirmed private message")
|
||||
return String(localized: "content.delivery.sent", defaultValue: "sent — no delivery confirmation yet", comment: "Delivery status description for a sent but not yet confirmed private message")
|
||||
case .carried:
|
||||
return String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Delivery status description for messages handed to a courier for physical delivery")
|
||||
case .delivered(let nickname, _):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
|
||||
format: String(localized: "content.delivery.delivered_to", defaultValue: "delivered to %@", comment: "Tooltip for delivered private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
case .read(let nickname, _):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
|
||||
format: String(localized: "content.delivery.read_by", defaultValue: "read by %@", comment: "Tooltip for read private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
case .failed(let reason):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
|
||||
format: String(localized: "content.delivery.failed", defaultValue: "failed: %@", comment: "Tooltip for failed message delivery"),
|
||||
locale: .current,
|
||||
reason
|
||||
)
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
|
||||
format: String(localized: "content.delivery.delivered_members", defaultValue: "delivered to %1$d of %2$d members", comment: "Tooltip for partially delivered messages"),
|
||||
locale: .current,
|
||||
reached,
|
||||
total
|
||||
|
||||
@@ -7,12 +7,17 @@
|
||||
//
|
||||
|
||||
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)
|
||||
@@ -35,44 +40,92 @@ struct PaymentChipView: View {
|
||||
return URL(string: link)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// The bare `cashuA…`/`cashuB…` bearer string, when this is a Cashu chip.
|
||||
var cashuToken: String? {
|
||||
if case .cashu(let link) = self {
|
||||
return CashuTokenDecoder.bareToken(from: link)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Web fallback for redemption when no wallet handles `cashu:` URLs.
|
||||
/// The token only reaches the site the user's browser loads; the app
|
||||
/// itself never contacts a mint.
|
||||
var cashuWebRedeemURL: URL? {
|
||||
guard let token = cashuToken,
|
||||
let enc = token.addingPercentEncoding(withAllowedCharacters: Self.cashuAllowedCharacters) else {
|
||||
return nil
|
||||
}
|
||||
return URL(string: "https://redeem.cashu.me/?token=\(enc)")
|
||||
}
|
||||
|
||||
var emoji: String {
|
||||
switch self {
|
||||
case .cashu: "🥜"
|
||||
case .lightning: "⚡"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .cashu:
|
||||
String(localized: "content.payment.cashu", comment: "Label for Cashu payment chip")
|
||||
String(localized: "content.payment.cashu", defaultValue: "pay via cashu", comment: "Label for Cashu payment chip")
|
||||
case .lightning:
|
||||
String(localized: "content.payment.lightning", comment: "Label for Lightning payment chip")
|
||||
String(localized: "content.payment.lightning", defaultValue: "pay via lightning", comment: "Label for Lightning payment chip")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let paymentType: PaymentType
|
||||
|
||||
/// Decoded once at construction; tokens are capped in size so this is
|
||||
/// cheap, and rows re-render often enough that lazy decode in `body`
|
||||
/// would just repeat the work.
|
||||
private let cashuInfo: CashuTokenDecoder.TokenInfo?
|
||||
|
||||
init(paymentType: PaymentType) {
|
||||
self.paymentType = paymentType
|
||||
if case .cashu(let link) = paymentType {
|
||||
self.cashuInfo = CashuTokenDecoder.decode(link)
|
||||
} else {
|
||||
self.cashuInfo = nil
|
||||
}
|
||||
}
|
||||
|
||||
private var fgColor: Color { palette.primary }
|
||||
private var bgColor: Color {
|
||||
palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12)
|
||||
}
|
||||
private var border: Color { fgColor.opacity(0.25) }
|
||||
|
||||
|
||||
/// "500 sat · mint.example.com", degrading to the generic label when the
|
||||
/// token didn't decode (V4 payloads we can't walk, malformed input…).
|
||||
private var primaryLabel: String {
|
||||
guard let info = cashuInfo else { return paymentType.label }
|
||||
var parts: [String] = []
|
||||
if let amount = info.displayAmount { parts.append(amount) }
|
||||
if let host = info.mintHost { parts.append(host) }
|
||||
return parts.isEmpty ? paymentType.label : parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private var memoLabel: String? { cashuInfo?.memo }
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
#if os(iOS)
|
||||
if let url = paymentType.url { openURL(url) }
|
||||
#else
|
||||
if let url = paymentType.url { NSWorkspace.shared.open(url) }
|
||||
#endif
|
||||
primaryAction()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(paymentType.emoji)
|
||||
Text(paymentType.label)
|
||||
.bitchatFont(size: 12, weight: .semibold)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
@@ -87,13 +140,102 @@ struct PaymentChipView: View {
|
||||
.foregroundColor(fgColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
if let token = paymentType.cashuToken {
|
||||
Button {
|
||||
copyToPasteboard(token)
|
||||
} label: {
|
||||
Label(String(localized: "content.payment.copy_token", defaultValue: "copy token", comment: "Context menu action copying a Cashu token to the pasteboard"), systemImage: "doc.on.doc")
|
||||
}
|
||||
Button {
|
||||
redeemCashu()
|
||||
} label: {
|
||||
Label(String(localized: "content.payment.redeem_wallet", defaultValue: "redeem in wallet", comment: "Context menu action opening a Cashu token in an ecash wallet app"), systemImage: "wallet.pass")
|
||||
}
|
||||
if let webURL = paymentType.cashuWebRedeemURL {
|
||||
Button {
|
||||
openExternalURL(webURL)
|
||||
} label: {
|
||||
Label(String(localized: "content.payment.redeem_web", defaultValue: "redeem on web", comment: "Context menu action opening a Cashu token in the web redemption page"), systemImage: "safari")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(Text(verbatim: accessibilityText))
|
||||
}
|
||||
|
||||
private var accessibilityText: String {
|
||||
var text = "\(paymentType.label): \(primaryLabel)"
|
||||
if let memoLabel { text += ", \(memoLabel)" }
|
||||
return text
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func primaryAction() {
|
||||
switch paymentType {
|
||||
case .cashu:
|
||||
redeemCashu()
|
||||
case .lightning:
|
||||
#if os(iOS)
|
||||
if let url = paymentType.url { openURL(url) }
|
||||
#else
|
||||
if let url = paymentType.url { NSWorkspace.shared.open(url) }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Redemption is delegated: try a wallet registered for `cashu:` URLs
|
||||
/// first, then fall back to the web redemption page. Uses the platform
|
||||
/// opener directly (not the `openURL` environment) because the message
|
||||
/// list overrides that action for cashu/lightning schemes without a
|
||||
/// fallback path.
|
||||
private func redeemCashu() {
|
||||
let walletURL = paymentType.url
|
||||
let webURL = paymentType.cashuWebRedeemURL
|
||||
#if os(iOS)
|
||||
if let walletURL {
|
||||
UIApplication.shared.open(walletURL, options: [:]) { accepted in
|
||||
if !accepted, let webURL {
|
||||
UIApplication.shared.open(webURL)
|
||||
}
|
||||
}
|
||||
} else if let webURL {
|
||||
UIApplication.shared.open(webURL)
|
||||
}
|
||||
#else
|
||||
if let walletURL, NSWorkspace.shared.urlForApplication(toOpen: walletURL) != nil {
|
||||
NSWorkspace.shared.open(walletURL)
|
||||
} else if let webURL {
|
||||
NSWorkspace.shared.open(webURL)
|
||||
} else if let walletURL {
|
||||
NSWorkspace.shared.open(walletURL)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func openExternalURL(_ url: URL) {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.open(url)
|
||||
#else
|
||||
NSWorkspace.shared.open(url)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func copyToPasteboard(_ string: String) {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = string
|
||||
#else
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(string, forType: .string)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let cashuLink = "https://example.com/cashu"
|
||||
let lightningLink = "https://example.com/lightning"
|
||||
|
||||
|
||||
List {
|
||||
HStack {
|
||||
PaymentChipView(paymentType: .cashu(cashuLink))
|
||||
|
||||
@@ -24,6 +24,6 @@ struct SheetCloseButton: View {
|
||||
.contentShape(Rectangle().inset(by: -6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
|
||||
.accessibilityLabel(String(localized: "common.close", defaultValue: "close", comment: "Accessibility label for close buttons"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ struct TextMessageView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
||||
String(localized: "content.accessibility.delivery_detail_hint", defaultValue: "tap to show delivery details", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,17 +118,17 @@ private extension ContentComposerView {
|
||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||
let target = isGeoDM ? header.displayName : "@\(header.displayName)"
|
||||
return String(
|
||||
format: String(localized: "content.input.placeholder.private", comment: "Composer placeholder inside a private chat, naming the conversation partner"),
|
||||
format: String(localized: "content.input.placeholder.private", defaultValue: "message %@ — private", comment: "Composer placeholder inside a private chat, naming the conversation partner"),
|
||||
locale: .current,
|
||||
target
|
||||
)
|
||||
}
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .mesh:
|
||||
return String(localized: "content.input.placeholder.mesh", comment: "Composer placeholder for the public mesh channel")
|
||||
return String(localized: "content.input.placeholder.mesh", defaultValue: "message #mesh — public, nearby", comment: "Composer placeholder for the public mesh channel")
|
||||
case .location(let channel):
|
||||
return String(
|
||||
format: String(localized: "content.input.placeholder.location", comment: "Composer placeholder for a public geohash channel, naming it"),
|
||||
format: String(localized: "content.input.placeholder.location", defaultValue: "message #%@ — public", comment: "Composer placeholder for a public geohash channel, naming it"),
|
||||
locale: .current,
|
||||
channel.geohash
|
||||
)
|
||||
@@ -184,15 +184,15 @@ private extension ContentComposerView {
|
||||
showImagePicker = true
|
||||
}
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.attach_photo", comment: "Accessibility label for the photo attachment button")
|
||||
String(localized: "content.accessibility.attach_photo", defaultValue: "attach photo", comment: "Accessibility label for the photo attachment button")
|
||||
)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.attach_photo_hint", comment: "Accessibility hint explaining the attachment button opens the photo library")
|
||||
String(localized: "content.accessibility.attach_photo_hint", defaultValue: "opens the photo library; use the take photo action for the camera", comment: "Accessibility hint explaining the attachment button opens the photo library")
|
||||
)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
// The long-press → camera path is unreachable for VoiceOver users;
|
||||
// mirror it as a named action.
|
||||
.accessibilityAction(named: Text("content.accessibility.take_photo", comment: "Accessibility action name for taking a photo with the camera")) {
|
||||
.accessibilityAction(named: Text(String(localized: "content.accessibility.take_photo", defaultValue: "take photo with camera", comment: "Accessibility action name for taking a photo with the camera"))) {
|
||||
imagePickerSourceType = .camera
|
||||
showImagePicker = true
|
||||
}
|
||||
@@ -205,7 +205,7 @@ private extension ContentComposerView {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button")
|
||||
String(localized: "content.accessibility.choose_photo", defaultValue: "choose photo", comment: "Accessibility label for the macOS photo picker button")
|
||||
)
|
||||
#endif
|
||||
}
|
||||
@@ -249,15 +249,15 @@ private extension ContentComposerView {
|
||||
)
|
||||
)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.record_voice_note", comment: "Accessibility label for the voice note button")
|
||||
String(localized: "content.accessibility.record_voice_note", defaultValue: "record voice note", comment: "Accessibility label for the voice note button")
|
||||
)
|
||||
.accessibilityValue(
|
||||
voiceRecordingVM.state.isActive
|
||||
? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording")
|
||||
? String(localized: "content.accessibility.recording", defaultValue: "recording", comment: "Accessibility value announced while a voice note is recording")
|
||||
: ""
|
||||
)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording")
|
||||
String(localized: "content.accessibility.record_voice_hint", defaultValue: "double-tap to start recording, double-tap again to send", comment: "Accessibility hint explaining double-tap toggles voice recording")
|
||||
)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
// Press-and-hold drag gestures can't be activated by VoiceOver;
|
||||
@@ -282,12 +282,12 @@ private extension ContentComposerView {
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.send_message", comment: "Accessibility label for the send message button")
|
||||
String(localized: "content.accessibility.send_message", defaultValue: "send message", comment: "Accessibility label for the send message button")
|
||||
)
|
||||
.accessibilityHint(
|
||||
enabled
|
||||
? String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the message")
|
||||
: String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message")
|
||||
? String(localized: "content.accessibility.send_hint_ready", defaultValue: "double tap to send", comment: "Hint prompting the user to send the message")
|
||||
: String(localized: "content.accessibility.send_hint_empty", defaultValue: "enter a message to send", comment: "Hint prompting the user to enter a message")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ 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/")
|
||||
@@ -41,7 +44,7 @@ struct ContentHeaderView: View {
|
||||
// stays undiscoverable on purpose — it's destructive.)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.app_info_hint", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info")
|
||||
String(localized: "content.accessibility.app_info_hint", defaultValue: "shows app info", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info")
|
||||
)
|
||||
.accessibilityAction {
|
||||
appChromeModel.presentAppInfo()
|
||||
@@ -53,7 +56,7 @@ struct ContentHeaderView: View {
|
||||
.foregroundColor(palette.secondary)
|
||||
|
||||
TextField(
|
||||
"content.input.nickname_placeholder",
|
||||
String(localized: "content.input.nickname_placeholder", defaultValue: "nickname"),
|
||||
text: Binding(
|
||||
get: { appChromeModel.nickname },
|
||||
set: { appChromeModel.setNickname($0) }
|
||||
@@ -91,6 +94,19 @@ 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))
|
||||
@@ -117,7 +133,7 @@ struct ContentHeaderView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.open_unread_private_chat", comment: "Accessibility label for the unread private chat button")
|
||||
String(localized: "content.accessibility.open_unread_private_chat", defaultValue: "open unread private chat", comment: "Accessibility label for the unread private chat button")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -135,10 +151,24 @@ struct ContentHeaderView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button")
|
||||
String(localized: "content.accessibility.location_notes", defaultValue: "location notes for this place", comment: "Accessibility label for location notes button")
|
||||
)
|
||||
}
|
||||
|
||||
Button(action: { showBoard = true }) {
|
||||
Image(systemName: "pin")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(palette.secondary.opacity(0.9))
|
||||
.headerTapTarget()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.board", defaultValue: "Bulletin board", comment: "Accessibility label for the bulletin board button")
|
||||
)
|
||||
.help(
|
||||
String(localized: "content.header.board", defaultValue: "Bulletin board: persistent notices for this channel", comment: "Tooltip for the bulletin board button")
|
||||
)
|
||||
|
||||
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
|
||||
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||
@@ -148,7 +178,7 @@ struct ContentHeaderView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.toggle_bookmark", comment: "Accessibility label for toggling a geohash bookmark"),
|
||||
format: String(localized: "content.accessibility.toggle_bookmark", defaultValue: "toggle bookmark for #%@", comment: "Accessibility label for toggling a geohash bookmark"),
|
||||
locale: .current,
|
||||
channel.geohash
|
||||
)
|
||||
@@ -181,7 +211,7 @@ struct ContentHeaderView: View {
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button")
|
||||
String(localized: "content.accessibility.location_channels", defaultValue: "location channels", comment: "Accessibility label for the location channels button")
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -208,7 +238,7 @@ struct ContentHeaderView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"),
|
||||
format: String(localized: "content.accessibility.people_count", defaultValue: "%#@people@", comment: "Accessibility label announcing number of people in header"),
|
||||
locale: .current,
|
||||
headerOtherPeersCount
|
||||
)
|
||||
@@ -217,8 +247,8 @@ struct ContentHeaderView: View {
|
||||
// color; say it.
|
||||
.accessibilityValue(
|
||||
headerPeersReachable
|
||||
? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable")
|
||||
: String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable")
|
||||
? String(localized: "content.accessibility.peers_connected", defaultValue: "connected", comment: "Accessibility value when peers are reachable")
|
||||
: String(localized: "content.accessibility.peers_none", defaultValue: "no one reachable", comment: "Accessibility value when no peers are reachable")
|
||||
)
|
||||
}
|
||||
.layoutPriority(3)
|
||||
@@ -242,6 +272,13 @@ 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
|
||||
}) {
|
||||
@@ -288,10 +325,10 @@ struct ContentHeaderView: View {
|
||||
.onChange(of: locationChannelsModel.permissionState) { _ in
|
||||
locationChannelsModel.refreshMeshChannelsIfNeeded()
|
||||
}
|
||||
.alert("content.alert.screenshot.title", isPresented: $appChromeModel.showScreenshotPrivacyWarning) {
|
||||
Button("common.ok", role: .cancel) {}
|
||||
.alert(String(localized: "content.alert.screenshot.title", defaultValue: "heads up"), isPresented: $appChromeModel.showScreenshotPrivacyWarning) {
|
||||
Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("content.alert.screenshot.message")
|
||||
Text(String(localized: "content.alert.screenshot.message", defaultValue: "screenshots of location channels will reveal your location. think before sharing publicly."))
|
||||
}
|
||||
.themedChromePanel(edge: .top)
|
||||
}
|
||||
@@ -311,6 +348,15 @@ 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 {
|
||||
@@ -346,7 +392,7 @@ private struct ContentLocationNotesUnavailableView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text("content.notes.title")
|
||||
Text(String(localized: "content.notes.title", defaultValue: "notes"))
|
||||
.bitchatFont(size: 16, weight: .bold)
|
||||
Spacer()
|
||||
SheetCloseButton { showLocationNotes = false }
|
||||
@@ -355,10 +401,10 @@ private struct ContentLocationNotesUnavailableView: View {
|
||||
.frame(minHeight: headerHeight)
|
||||
.padding(.horizontal, 12)
|
||||
.themedChromePanel(edge: .top)
|
||||
Text("content.notes.location_unavailable")
|
||||
Text(String(localized: "content.notes.location_unavailable", defaultValue: "location unavailable"))
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(palette.secondary)
|
||||
Button("content.location.enable") {
|
||||
Button(String(localized: "content.location.enable", defaultValue: "enable location")) {
|
||||
locationChannelsModel.enableAndRefresh()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
@@ -163,10 +163,10 @@ private struct ContentPeopleListView: View {
|
||||
// .help maps to the accessibility *hint* on iOS, so the
|
||||
// button still needs a spoken name.
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.verification", comment: "Accessibility label for the verification QR button")
|
||||
String(localized: "content.accessibility.verification", defaultValue: "verify encryption", comment: "Accessibility label for the verification QR button")
|
||||
)
|
||||
.help(
|
||||
String(localized: "content.help.verification", comment: "Help text for verification button")
|
||||
String(localized: "content.help.verification", defaultValue: "verification: show my QR or scan a friend", comment: "Help text for verification button")
|
||||
)
|
||||
}
|
||||
SheetCloseButton {
|
||||
@@ -221,6 +221,13 @@ 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)
|
||||
@@ -255,7 +262,7 @@ private struct ContentPeopleListView: View {
|
||||
|
||||
private extension ContentPeopleListView {
|
||||
var peopleSheetTitle: String {
|
||||
String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased()
|
||||
String(localized: "content.header.people", defaultValue: "PEOPLE", comment: "Title for the people list sheet").lowercased()
|
||||
}
|
||||
|
||||
var peopleSheetSubtitle: String? {
|
||||
@@ -322,7 +329,7 @@ private struct ContentPrivateChatSheetView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat")
|
||||
String(localized: "content.accessibility.back_to_main_chat", defaultValue: "back to main chat", comment: "Accessibility label for returning to main chat")
|
||||
)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
@@ -347,8 +354,8 @@ private struct ContentPrivateChatSheetView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
headerState.isFavorite
|
||||
? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
|
||||
: String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
|
||||
? String(localized: "content.accessibility.remove_favorite", defaultValue: "remove from favorites", comment: "Accessibility label to remove a favorite")
|
||||
: String(localized: "content.accessibility.add_favorite", defaultValue: "add to favorites", comment: "Accessibility label to add a favorite")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -455,6 +462,10 @@ private struct ContentPrivateChatSheetView: View {
|
||||
}
|
||||
|
||||
private var privacyCaptionText: String {
|
||||
// Group chats are ChaCha20-Poly1305 sealed to the roster's shared key.
|
||||
if privateConversationModel.selectedPeerID?.isGroup == true {
|
||||
return String(localized: "content.private.caption_group", defaultValue: "encrypted group · members only", comment: "Caption above the group chat composer noting messages are encrypted to group members")
|
||||
}
|
||||
// Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted,
|
||||
// even though they carry no Noise session status. Mesh DMs earn the
|
||||
// "encrypted" claim only once the Noise handshake has secured.
|
||||
@@ -466,9 +477,9 @@ private struct ContentPrivateChatSheetView: View {
|
||||
}
|
||||
}()
|
||||
if isGeoDM || noiseSecured {
|
||||
return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
|
||||
return String(localized: "content.private.caption_encrypted", defaultValue: "private · end-to-end encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
|
||||
}
|
||||
return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established")
|
||||
return String(localized: "content.private.caption", defaultValue: "private conversation", comment: "Caption above the private chat composer before encryption is established")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,30 +514,39 @@ 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) {
|
||||
switch headerState.availability {
|
||||
case .bluetoothConnected:
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
if headerState.isGroupConversation {
|
||||
Image(systemName: "person.3.fill")
|
||||
.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.group_chat", defaultValue: "Group chat", comment: "Accessibility label for the group chat indicator"))
|
||||
} else {
|
||||
switch headerState.availability {
|
||||
case .bluetoothConnected:
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(palette.primary)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", defaultValue: "connected via mesh", comment: "Accessibility label for mesh-connected peer indicator"))
|
||||
case .meshReachable:
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(palette.primary)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", defaultValue: "reachable via mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
|
||||
case .nostrAvailable:
|
||||
Image(systemName: "globe")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(.purple)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", defaultValue: "available via Nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||
case .offline:
|
||||
// Absence of a glyph was the only offline signal; say it.
|
||||
Text(String(localized: "mesh_peers.state.offline", defaultValue: "offline"))
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(headerState.displayName)
|
||||
@@ -554,7 +574,7 @@ private struct ContentPrivateHeaderInfoButton: View {
|
||||
)
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"),
|
||||
format: String(localized: "content.accessibility.encryption_status", defaultValue: "encryption status: %@", comment: "Accessibility label announcing encryption status"),
|
||||
locale: .current,
|
||||
encryptionStatus.accessibilityDescription
|
||||
)
|
||||
@@ -565,13 +585,15 @@ private struct ContentPrivateHeaderInfoButton: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"),
|
||||
format: String(localized: "content.accessibility.private_chat_header", defaultValue: "private chat with %@", comment: "Accessibility label describing the private chat header"),
|
||||
locale: .current,
|
||||
headerState.displayName
|
||||
)
|
||||
)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
|
||||
headerState.isGroupConversation
|
||||
? ""
|
||||
: String(localized: "content.accessibility.view_fingerprint_hint", defaultValue: "tap to view encryption fingerprint", comment: "Accessibility hint for viewing encryption fingerprint")
|
||||
)
|
||||
.frame(minHeight: headerHeight)
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ struct ContentView: View {
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $appChromeModel.isAppInfoPresented) {
|
||||
AppInfoView()
|
||||
AppInfoView(topologyProvider: { appChromeModel.meshTopologyDisplayModel() })
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil },
|
||||
@@ -204,20 +204,20 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: {
|
||||
Button("common.ok", role: .cancel) {}
|
||||
Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {}
|
||||
if voiceRecordingVM.state == .permissionDenied {
|
||||
Button("location_channels.action.open_settings") {
|
||||
Button(String(localized: "location_channels.action.open_settings", defaultValue: "open settings")) {
|
||||
SystemSettings.microphone.open()
|
||||
}
|
||||
}
|
||||
}, message: {
|
||||
Text(voiceRecordingVM.state.alertMessage)
|
||||
})
|
||||
.alert("content.alert.bluetooth_required.title", isPresented: $appChromeModel.showBluetoothAlert) {
|
||||
Button("content.alert.bluetooth_required.settings") {
|
||||
.alert(String(localized: "content.alert.bluetooth_required.title", defaultValue: "bluetooth required"), isPresented: $appChromeModel.showBluetoothAlert) {
|
||||
Button(String(localized: "content.alert.bluetooth_required.settings", defaultValue: "settings")) {
|
||||
SystemSettings.bluetooth.open()
|
||||
}
|
||||
Button("common.ok", role: .cancel) {}
|
||||
Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(appChromeModel.bluetoothAlertMessage)
|
||||
}
|
||||
|
||||
@@ -30,15 +30,23 @@ struct FingerprintView: View {
|
||||
static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified"
|
||||
static func verifyHint(_ nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"),
|
||||
format: String(localized: "fingerprint.message.verify_hint", defaultValue: "compare these fingerprints with %@ using a secure channel.", comment: "Instruction to compare fingerprints with a named peer"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified"
|
||||
static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification"
|
||||
static let vouchedBadge: LocalizedStringKey = "fingerprint.badge.vouched"
|
||||
static func vouchedBy(_ count: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "fingerprint.message.vouched_by", defaultValue: "vouched for by %#@people@ you verified", comment: "How many people the user verified have vouched for this peer"),
|
||||
locale: .current,
|
||||
count
|
||||
)
|
||||
}
|
||||
static func unknownPeer() -> String {
|
||||
String(localized: "common.unknown", comment: "Label for an unknown peer")
|
||||
String(localized: "common.unknown", defaultValue: "unknown", comment: "Label for an unknown peer")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +154,41 @@ 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) {
|
||||
|
||||
@@ -10,16 +10,16 @@ struct GeohashPeopleList: View {
|
||||
private enum Strings {
|
||||
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||
static let youSuffix: LocalizedStringKey = "geohash_people.you_suffix"
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels")
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", defaultValue: "blocked in geohash", comment: "Tooltip shown next to users blocked in geohash channels")
|
||||
static let unblock: LocalizedStringKey = "geohash_people.action.unblock"
|
||||
static let block: LocalizedStringKey = "geohash_people.action.block"
|
||||
static let unblockText = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
|
||||
static let blockText = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
|
||||
static let teleported = String(localized: "geohash_people.state.teleported", comment: "State label for someone who joined the location channel from elsewhere")
|
||||
static let nearby = String(localized: "geohash_people.state.nearby", comment: "State label for someone physically in the location channel's area")
|
||||
static let blockedState = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
|
||||
static let youState = String(localized: "geohash_people.state.you", comment: "State label marking your own row in the people list")
|
||||
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
|
||||
static let unblockText = String(localized: "geohash_people.action.unblock", defaultValue: "unblock", comment: "Context menu action to unblock a person")
|
||||
static let blockText = String(localized: "geohash_people.action.block", defaultValue: "block", comment: "Context menu action to block a person")
|
||||
static let teleported = String(localized: "geohash_people.state.teleported", defaultValue: "teleported from elsewhere", comment: "State label for someone who joined the location channel from elsewhere")
|
||||
static let nearby = String(localized: "geohash_people.state.nearby", defaultValue: "in this area", comment: "State label for someone physically in the location channel's area")
|
||||
static let blockedState = String(localized: "mesh_peers.state.blocked", defaultValue: "blocked", comment: "State label for a blocked peer")
|
||||
static let youState = String(localized: "geohash_people.state.you", defaultValue: "you", comment: "State label marking your own row in the people list")
|
||||
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", defaultValue: "opens a private chat", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import BitFoundation
|
||||
import SwiftUI
|
||||
|
||||
/// Compact "groups" section for the people sheet: one row per private group
|
||||
/// this device belongs to, tappable to open the group chat window.
|
||||
struct GroupChatList: View {
|
||||
@ThemedPalette private var palette
|
||||
|
||||
let groups: [GroupChatRow]
|
||||
let onTapGroup: (PeerID) -> Void
|
||||
|
||||
private enum Strings {
|
||||
static let header = String(localized: "groups.section.header", defaultValue: "groups", comment: "Section header above the private groups list")
|
||||
static let creator = String(localized: "groups.state.creator", defaultValue: "Creator", comment: "State label for a group the user created")
|
||||
static let unread = String(localized: "mesh_peers.state.unread", defaultValue: "new messages", comment: "State label for a peer with unread private messages")
|
||||
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", defaultValue: "new messages", comment: "Tooltip for the unread messages indicator")
|
||||
static let openGroupHint = String(localized: "groups.accessibility.open_group_hint", defaultValue: "Opens the group chat", comment: "Accessibility hint on a group row explaining activation opens the group chat")
|
||||
static let memberCountFormat = String(localized: "groups.member_count %@", defaultValue: "(%@)", comment: "Member count shown next to a group name; placeholder is the count")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if !groups.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(Strings.header)
|
||||
.bitchatFont(size: 11, weight: .medium)
|
||||
.foregroundColor(palette.secondary)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 2)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
|
||||
ForEach(groups) { group in
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "person.3.fill")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(palette.primary)
|
||||
|
||||
Text("#\(group.name)")
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(palette.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
Text(String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)"))
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
|
||||
if group.isCreator {
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.bitchatSystem(size: 9))
|
||||
.foregroundColor(.yellow)
|
||||
.help(Strings.creator)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if group.hasUnread {
|
||||
Image(systemName: "envelope.fill")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(.orange)
|
||||
.help(Strings.newMessagesTooltip)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onTapGroup(group.peerID) }
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityDescription(for: group))
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityHint(Strings.openGroupHint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func accessibilityDescription(for group: GroupChatRow) -> String {
|
||||
var parts: [String] = [
|
||||
group.name,
|
||||
String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)")
|
||||
]
|
||||
if group.isCreator { parts.append(Strings.creator) }
|
||||
if group.hasUnread { parts.append(Strings.unread) }
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
@@ -27,16 +27,18 @@ struct LocationChannelsSheet: View {
|
||||
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
|
||||
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
|
||||
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
|
||||
static let gatewayTitle: LocalizedStringKey = "location_channels.gateway.title"
|
||||
static let gatewaySubtitle: LocalizedStringKey = "location_channels.gateway.subtitle"
|
||||
static let toggleOn: LocalizedStringKey = "common.toggle.on"
|
||||
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
||||
|
||||
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
|
||||
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it")
|
||||
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel")
|
||||
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark")
|
||||
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", defaultValue: "invalid geohash", comment: "Error shown when a custom geohash is invalid")
|
||||
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", defaultValue: "switches to this channel", comment: "Accessibility hint on a channel row explaining activation switches to it")
|
||||
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", defaultValue: "bookmark channel", comment: "Accessibility action name for bookmarking a channel")
|
||||
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", defaultValue: "remove bookmark", comment: "Accessibility action name for removing a channel bookmark")
|
||||
|
||||
static func meshTitle(_ count: Int) -> String {
|
||||
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
|
||||
let label = String(localized: "location_channels.mesh_label", defaultValue: "mesh", comment: "Label for the mesh channel row")
|
||||
return rowTitle(label: label, count: count)
|
||||
}
|
||||
|
||||
@@ -71,7 +73,7 @@ struct LocationChannelsSheet: View {
|
||||
|
||||
static func subtitlePrefix(geohash: String, coverage: String) -> String {
|
||||
String(
|
||||
format: String(localized: "location_channels.subtitle_prefix", comment: "Subtitle prefix showing geohash and coverage"),
|
||||
format: String(localized: "location_channels.subtitle_prefix", defaultValue: "#%1$@ • %2$@", comment: "Subtitle prefix showing geohash and coverage"),
|
||||
locale: .current,
|
||||
geohash, coverage
|
||||
)
|
||||
@@ -80,7 +82,7 @@ struct LocationChannelsSheet: View {
|
||||
static func subtitle(prefix: String, name: String?) -> String {
|
||||
guard let name, !name.isEmpty else { return prefix }
|
||||
return String(
|
||||
format: String(localized: "location_channels.subtitle_with_name", comment: "Subtitle combining prefix and resolved location name"),
|
||||
format: String(localized: "location_channels.subtitle_with_name", defaultValue: "%1$@ • %2$@", comment: "Subtitle combining prefix and resolved location name"),
|
||||
locale: .current,
|
||||
prefix, name
|
||||
)
|
||||
@@ -88,7 +90,7 @@ struct LocationChannelsSheet: View {
|
||||
|
||||
private static func rowTitle(label: String, count: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "location_channels.row_title", comment: "List row title with participant count"),
|
||||
format: String(localized: "location_channels.row_title", defaultValue: "%1$@ [%2$#@people_count@]", comment: "List row title with participant count"),
|
||||
locale: .current,
|
||||
label, count
|
||||
)
|
||||
@@ -244,6 +246,8 @@ struct LocationChannelsSheet: View {
|
||||
sectionDivider
|
||||
torToggleSection
|
||||
.padding(.top, 12)
|
||||
gatewayToggleSection
|
||||
.padding(.top, 8)
|
||||
Button(action: SystemSettings.location.open) {
|
||||
Text(Strings.removeAccess)
|
||||
.bitchatFont(size: 12)
|
||||
@@ -508,6 +512,32 @@ 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 }
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ struct LocationNotesView: View {
|
||||
|
||||
private func headerTitle(for count: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
|
||||
format: String(localized: "location_notes.header", defaultValue: "#%1$@ • %2$#@note_count@", comment: "Header displaying the geohash and localized note count"),
|
||||
locale: .current,
|
||||
"\(geohash) ± 1", count
|
||||
)
|
||||
|
||||
@@ -25,20 +25,20 @@ struct BlockRevealImageView: View {
|
||||
@State private var loadFailed = false
|
||||
|
||||
private enum Strings {
|
||||
static let tapToReveal = String(localized: "media.image.tap_to_reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it")
|
||||
static let open = String(localized: "media.image.action.open", comment: "Context menu action that opens an image full screen")
|
||||
static let reveal = String(localized: "media.image.action.reveal", comment: "Context menu action that reveals a blurred image")
|
||||
static let hide = String(localized: "media.image.action.hide", comment: "Context menu action that re-blurs a revealed image")
|
||||
static let delete = String(localized: "media.image.action.delete", comment: "Context menu action that deletes a received image")
|
||||
static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", comment: "Title of the confirmation dialog before deleting a received image")
|
||||
static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", comment: "Body of the confirmation dialog before deleting a received image")
|
||||
static let hiddenImage = String(localized: "media.image.accessibility.hidden", comment: "Accessibility label for a blurred incoming image")
|
||||
static let revealedImage = String(localized: "media.image.accessibility.revealed", comment: "Accessibility label for a revealed image")
|
||||
static let revealHint = String(localized: "media.image.accessibility.hint.reveal", comment: "Accessibility hint for a blurred image; activating it reveals the image")
|
||||
static let openHint = String(localized: "media.image.accessibility.hint.open", comment: "Accessibility hint for a revealed image; activating it opens the image full screen")
|
||||
static let sendingImage = String(localized: "media.image.accessibility.sending", comment: "Accessibility label for an image that is still sending")
|
||||
static let unavailableImage = String(localized: "media.image.accessibility.unavailable", comment: "Accessibility label for an image whose file could not be loaded")
|
||||
static let cancelSend = String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
|
||||
static let tapToReveal = String(localized: "media.image.tap_to_reveal", defaultValue: "tap to reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it")
|
||||
static let open = String(localized: "media.image.action.open", defaultValue: "open image", comment: "Context menu action that opens an image full screen")
|
||||
static let reveal = String(localized: "media.image.action.reveal", defaultValue: "reveal image", comment: "Context menu action that reveals a blurred image")
|
||||
static let hide = String(localized: "media.image.action.hide", defaultValue: "hide image", comment: "Context menu action that re-blurs a revealed image")
|
||||
static let delete = String(localized: "media.image.action.delete", defaultValue: "delete image", comment: "Context menu action that deletes a received image")
|
||||
static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", defaultValue: "delete this image?", comment: "Title of the confirmation dialog before deleting a received image")
|
||||
static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", defaultValue: "this cannot be undone — the sender may not be in range to send it again.", comment: "Body of the confirmation dialog before deleting a received image")
|
||||
static let hiddenImage = String(localized: "media.image.accessibility.hidden", defaultValue: "hidden image", comment: "Accessibility label for a blurred incoming image")
|
||||
static let revealedImage = String(localized: "media.image.accessibility.revealed", defaultValue: "image", comment: "Accessibility label for a revealed image")
|
||||
static let revealHint = String(localized: "media.image.accessibility.hint.reveal", defaultValue: "reveals the image", comment: "Accessibility hint for a blurred image; activating it reveals the image")
|
||||
static let openHint = String(localized: "media.image.accessibility.hint.open", defaultValue: "opens the image full screen", comment: "Accessibility hint for a revealed image; activating it opens the image full screen")
|
||||
static let sendingImage = String(localized: "media.image.accessibility.sending", defaultValue: "sending image", comment: "Accessibility label for an image that is still sending")
|
||||
static let unavailableImage = String(localized: "media.image.accessibility.unavailable", defaultValue: "image unavailable", comment: "Accessibility label for an image whose file could not be loaded")
|
||||
static let cancelSend = String(localized: "media.accessibility.cancel_send", defaultValue: "cancel sending", comment: "Accessibility label for the cancel button on an in-flight media send")
|
||||
}
|
||||
|
||||
init(
|
||||
@@ -154,7 +154,7 @@ struct BlockRevealImageView: View {
|
||||
Button(Strings.delete, role: .destructive) {
|
||||
onDelete?()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
Button(String(localized: "common.cancel", defaultValue: "cancel"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(verbatim: Strings.deleteConfirmMessage)
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ struct MediaMessageView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
||||
String(localized: "content.accessibility.delivery_detail_hint", defaultValue: "tap to show delivery details", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ struct VoiceNoteView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
playback.isPlaying
|
||||
? String(localized: "media.voice.accessibility.pause", comment: "Accessibility label for pausing voice note playback")
|
||||
: String(localized: "media.voice.accessibility.play", comment: "Accessibility label for playing a voice note")
|
||||
? String(localized: "media.voice.accessibility.pause", defaultValue: "pause voice note", comment: "Accessibility label for pausing voice note playback")
|
||||
: String(localized: "media.voice.accessibility.play", defaultValue: "play voice note", comment: "Accessibility label for playing a voice note")
|
||||
)
|
||||
.accessibilityValue(playbackLabel)
|
||||
|
||||
@@ -83,7 +83,7 @@ struct VoiceNoteView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
|
||||
String(localized: "media.accessibility.cancel_send", defaultValue: "cancel sending", comment: "Accessibility label for the cancel button on an in-flight media send")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,22 +16,24 @@ struct MeshPeerList: View {
|
||||
|
||||
private enum Strings {
|
||||
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator")
|
||||
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
|
||||
static let connected = String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
|
||||
static let reachable = String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
|
||||
static let nostr = String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")
|
||||
static let offline = String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable")
|
||||
static let favorite = String(localized: "mesh_peers.state.favorite", comment: "State label for a favorited peer")
|
||||
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
|
||||
static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
|
||||
static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
|
||||
static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
|
||||
static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen")
|
||||
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
|
||||
static let directMessage = String(localized: "content.actions.direct_message", comment: "Action that opens a private chat with the person")
|
||||
static let block = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
|
||||
static let unblock = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", defaultValue: "blocked in geohash", comment: "Tooltip shown next to a blocked peer indicator")
|
||||
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", defaultValue: "new messages", comment: "Tooltip for the unread messages indicator")
|
||||
static let connected = String(localized: "content.accessibility.connected_mesh", defaultValue: "connected via mesh", comment: "Accessibility label for mesh-connected peer indicator")
|
||||
static let reachable = String(localized: "content.accessibility.reachable_mesh", defaultValue: "reachable via mesh", comment: "Accessibility label for mesh-reachable peer indicator")
|
||||
static let nostr = String(localized: "content.accessibility.available_nostr", defaultValue: "available via Nostr", comment: "Accessibility label for Nostr-available peer indicator")
|
||||
static let offline = String(localized: "mesh_peers.state.offline", defaultValue: "offline", comment: "State label for a peer that is not currently reachable")
|
||||
static let favorite = String(localized: "mesh_peers.state.favorite", defaultValue: "favorite", comment: "State label for a favorited peer")
|
||||
static let unread = String(localized: "mesh_peers.state.unread", defaultValue: "new messages", comment: "State label for a peer with unread private messages")
|
||||
static let blocked = String(localized: "mesh_peers.state.blocked", defaultValue: "blocked", comment: "State label for a blocked peer")
|
||||
static let vouched = String(localized: "mesh_peers.state.vouched", defaultValue: "vouched", comment: "State label for a peer vouched for by someone the user verified")
|
||||
static let vouchedTooltip = String(localized: "mesh_peers.tooltip.vouched", defaultValue: "vouched for by someone you verified", comment: "Tooltip for the vouched (unfilled seal) badge next to a peer")
|
||||
static let addFavorite = String(localized: "content.accessibility.add_favorite", defaultValue: "add to favorites", comment: "Accessibility label to add a favorite")
|
||||
static let removeFavorite = String(localized: "content.accessibility.remove_favorite", defaultValue: "remove from favorites", comment: "Accessibility label to remove a favorite")
|
||||
static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", defaultValue: "show fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen")
|
||||
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", defaultValue: "opens a private chat", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
|
||||
static let directMessage = String(localized: "content.actions.direct_message", defaultValue: "direct message", comment: "Action that opens a private chat with the person")
|
||||
static let block = String(localized: "geohash_people.action.block", defaultValue: "block", comment: "Context menu action to block a person")
|
||||
static let unblock = String(localized: "geohash_people.action.unblock", defaultValue: "unblock", comment: "Context menu action to unblock a person")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -134,6 +136,16 @@ 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()
|
||||
@@ -241,6 +253,7 @@ 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) }
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//
|
||||
// MeshTopologyView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Display model for the mesh topology map: nodes are known mesh peers,
|
||||
/// edges are gossiped `directNeighbors` claims. Built on the main actor from
|
||||
/// a `MeshTopologySnapshot` plus the current nickname table.
|
||||
struct MeshTopologyDisplayModel {
|
||||
struct Node: Identifiable, Equatable {
|
||||
let id: String
|
||||
let label: String
|
||||
let isSelf: Bool
|
||||
}
|
||||
|
||||
let nodes: [Node]
|
||||
/// Pairs of `Node.id`; every id is present in `nodes`.
|
||||
let edges: [(String, String)]
|
||||
|
||||
static let empty = MeshTopologyDisplayModel(nodes: [], edges: [])
|
||||
}
|
||||
|
||||
/// Minimal diagnostics sheet: the mesh graph on a circular layout (self in
|
||||
/// the center), drawn with Canvas so it stays cheap at any peer count.
|
||||
struct MeshTopologyView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.appTheme) private var appTheme
|
||||
@ThemedPalette private var palette
|
||||
|
||||
/// Fetches a fresh model; called on appear and on manual refresh.
|
||||
let provider: @MainActor () -> MeshTopologyDisplayModel
|
||||
@State private var model: MeshTopologyDisplayModel = .empty
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(String(localized: "topology.title", defaultValue: "mesh topology"))
|
||||
.bitchatFont(size: 16, weight: .bold)
|
||||
.foregroundColor(palette.primary)
|
||||
Spacer()
|
||||
refreshButton
|
||||
Button(String(localized: "app_info.done", defaultValue: "DONE")) {
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(palette.primary)
|
||||
}
|
||||
.padding()
|
||||
.themedSurface(opacity: 0.95)
|
||||
|
||||
content
|
||||
}
|
||||
.frame(width: 500, height: 520)
|
||||
.themedSheetBackground()
|
||||
#else
|
||||
NavigationView {
|
||||
content
|
||||
.themedSheetBackground()
|
||||
.navigationTitle(Text(String(localized: "topology.title", defaultValue: "mesh topology")))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
refreshButton
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
SheetCloseButton { dismiss() }
|
||||
.foregroundColor(palette.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var refreshButton: some View {
|
||||
Button {
|
||||
model = provider()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(palette.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(String(localized: "topology.refresh", defaultValue: "refresh topology")))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
VStack(spacing: 12) {
|
||||
if model.nodes.count <= 1 {
|
||||
Spacer()
|
||||
Text(String(localized: "topology.empty", defaultValue: "no mesh links yet — the map fills in as peer announces arrive"))
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(palette.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
Spacer()
|
||||
} else {
|
||||
graphCanvas
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text(summaryText)
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
.foregroundColor(palette.primary)
|
||||
Text(String(localized: "topology.caption", defaultValue: "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted"))
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.onAppear { model = provider() }
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(Text(summaryText))
|
||||
}
|
||||
|
||||
private var summaryText: String {
|
||||
String(
|
||||
format: String(
|
||||
localized: "topology.summary",
|
||||
comment: "Topology map summary: number of peers and links"
|
||||
),
|
||||
locale: .current,
|
||||
model.nodes.count,
|
||||
model.edges.count
|
||||
)
|
||||
}
|
||||
|
||||
private var graphCanvas: some View {
|
||||
Canvas { context, size in
|
||||
let positions = Self.layout(nodes: model.nodes, in: size)
|
||||
let fontDesign = appTheme.bodyFontDesign
|
||||
|
||||
// Edges first so nodes draw on top.
|
||||
for (fromID, toID) in model.edges {
|
||||
guard let from = positions[fromID], let to = positions[toID] else { continue }
|
||||
var path = Path()
|
||||
path.move(to: from)
|
||||
path.addLine(to: to)
|
||||
context.stroke(path, with: .color(palette.secondary.opacity(0.45)), lineWidth: 1)
|
||||
}
|
||||
|
||||
for node in model.nodes {
|
||||
guard let center = positions[node.id] else { continue }
|
||||
let radius: CGFloat = node.isSelf ? 7 : 5
|
||||
let dot = Path(ellipseIn: CGRect(
|
||||
x: center.x - radius,
|
||||
y: center.y - radius,
|
||||
width: radius * 2,
|
||||
height: radius * 2
|
||||
))
|
||||
context.fill(dot, with: .color(node.isSelf ? palette.accent : palette.primary))
|
||||
if node.isSelf {
|
||||
let ring = Path(ellipseIn: CGRect(
|
||||
x: center.x - radius - 3,
|
||||
y: center.y - radius - 3,
|
||||
width: (radius + 3) * 2,
|
||||
height: (radius + 3) * 2
|
||||
))
|
||||
context.stroke(ring, with: .color(palette.accent.opacity(0.6)), lineWidth: 1)
|
||||
}
|
||||
context.draw(
|
||||
Text(node.label)
|
||||
.font(.system(size: 10, design: fontDesign))
|
||||
.foregroundColor(node.isSelf ? palette.accent : palette.secondary),
|
||||
at: CGPoint(x: center.x, y: center.y + radius + 4),
|
||||
anchor: .top
|
||||
)
|
||||
}
|
||||
}
|
||||
.accessibilityHidden(true) // The combined summary label narrates the graph.
|
||||
}
|
||||
|
||||
/// Circular layout: self in the center, everyone else evenly spaced on a
|
||||
/// ring. Deterministic (nodes arrive sorted), so refreshes don't shuffle.
|
||||
static func layout(nodes: [MeshTopologyDisplayModel.Node], in size: CGSize) -> [String: CGPoint] {
|
||||
let center = CGPoint(x: size.width / 2, y: size.height / 2)
|
||||
// Leave room for the label row under each ring node.
|
||||
let radius = max(20, min(size.width, size.height) / 2 - 36)
|
||||
var positions: [String: CGPoint] = [:]
|
||||
|
||||
let ringNodes = nodes.filter { !$0.isSelf }
|
||||
for node in nodes where node.isSelf {
|
||||
positions[node.id] = center
|
||||
}
|
||||
for (index, node) in ringNodes.enumerated() {
|
||||
let angle = (2 * CGFloat.pi * CGFloat(index)) / CGFloat(max(1, ringNodes.count)) - CGFloat.pi / 2
|
||||
positions[node.id] = CGPoint(
|
||||
x: center.x + radius * cos(angle),
|
||||
y: center.y + radius * sin(angle)
|
||||
)
|
||||
}
|
||||
return positions
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Topology") {
|
||||
MeshTopologyView(provider: {
|
||||
MeshTopologyDisplayModel(
|
||||
nodes: [
|
||||
.init(id: "self", label: "me", isSelf: true),
|
||||
.init(id: "a", label: "alice", isSelf: false),
|
||||
.init(id: "b", label: "bob", isSelf: false),
|
||||
.init(id: "c", label: "carol", isSelf: false)
|
||||
],
|
||||
edges: [("self", "a"), ("a", "b"), ("self", "c")]
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -109,11 +109,11 @@ struct MessageListView: View {
|
||||
// mentioning the only other participant is noise, and "DM"
|
||||
// would just reopen the conversation that is already open.
|
||||
if privatePeer == nil {
|
||||
Button("content.actions.mention") {
|
||||
Button(String(localized: "content.actions.mention", defaultValue: "mention")) {
|
||||
insertMention(message.sender)
|
||||
}
|
||||
if let peerID = message.senderPeerID {
|
||||
Button("content.actions.direct_message") {
|
||||
Button(String(localized: "content.actions.direct_message", defaultValue: "direct message")) {
|
||||
privateConversationModel.openConversation(for: peerID)
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
showSidebar = true
|
||||
@@ -121,14 +121,14 @@ struct MessageListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("content.actions.hug") {
|
||||
Button(String(localized: "content.actions.hug", defaultValue: "hug")) {
|
||||
conversationUIModel.sendHug(to: message.sender)
|
||||
}
|
||||
Button("content.actions.slap") {
|
||||
Button(String(localized: "content.actions.slap", defaultValue: "slap")) {
|
||||
conversationUIModel.sendSlap(to: message.sender)
|
||||
}
|
||||
}
|
||||
Button("content.message.copy") {
|
||||
Button(String(localized: "content.message.copy", defaultValue: "copy message")) {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = message.content
|
||||
#else
|
||||
@@ -138,12 +138,12 @@ struct MessageListView: View {
|
||||
#endif
|
||||
}
|
||||
if isResendableFailedMessage(message) {
|
||||
Button("content.actions.resend") {
|
||||
Button(String(localized: "content.actions.resend", defaultValue: "resend")) {
|
||||
conversationUIModel.resendFailedPrivateMessage(message)
|
||||
}
|
||||
}
|
||||
if showsUserActions {
|
||||
Button("content.actions.block", role: .destructive) {
|
||||
Button(String(localized: "content.actions.block", defaultValue: "block"), role: .destructive) {
|
||||
conversationUIModel.block(peerID: message.senderPeerID, displayName: message.sender)
|
||||
}
|
||||
}
|
||||
@@ -165,14 +165,14 @@ struct MessageListView: View {
|
||||
showClearConfirmation = true
|
||||
}
|
||||
.confirmationDialog(
|
||||
"content.clear.confirm_title",
|
||||
String(localized: "content.clear.confirm_title", defaultValue: "clear this chat?"),
|
||||
isPresented: $showClearConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("content.clear.confirm_action", role: .destructive) {
|
||||
Button(String(localized: "content.clear.confirm_action", defaultValue: "clear chat"), role: .destructive) {
|
||||
conversationUIModel.clearCurrentConversation()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
Button(String(localized: "common.cancel", defaultValue: "cancel"), role: .cancel) {}
|
||||
}
|
||||
.onAppear {
|
||||
scrollToBottom(on: proxy)
|
||||
@@ -190,17 +190,17 @@ struct MessageListView: View {
|
||||
onSelectedChannelChange(newChannel, proxy: proxy)
|
||||
}
|
||||
.confirmationDialog(
|
||||
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
|
||||
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", defaultValue: "actions", comment: "Fallback title for the message action sheet"),
|
||||
isPresented: $showMessageActions,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("content.actions.mention") {
|
||||
Button(String(localized: "content.actions.mention", defaultValue: "mention")) {
|
||||
if let sender = selectedMessageSender {
|
||||
insertMention(sender)
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.direct_message") {
|
||||
Button(String(localized: "content.actions.direct_message", defaultValue: "direct message")) {
|
||||
if let peerID = selectedMessageSenderID {
|
||||
privateConversationModel.openConversation(for: peerID)
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
@@ -209,23 +209,23 @@ struct MessageListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.hug") {
|
||||
Button(String(localized: "content.actions.hug", defaultValue: "hug")) {
|
||||
if let sender = selectedMessageSender {
|
||||
conversationUIModel.sendHug(to: sender)
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.slap") {
|
||||
Button(String(localized: "content.actions.slap", defaultValue: "slap")) {
|
||||
if let sender = selectedMessageSender {
|
||||
conversationUIModel.sendSlap(to: sender)
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.block", role: .destructive) {
|
||||
Button(String(localized: "content.actions.block", defaultValue: "block"), role: .destructive) {
|
||||
conversationUIModel.block(peerID: selectedMessageSenderID, displayName: selectedMessageSender)
|
||||
}
|
||||
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
Button(String(localized: "common.cancel", defaultValue: "cancel"), role: .cancel) {}
|
||||
}
|
||||
.onAppear {
|
||||
// Also check when view appears
|
||||
@@ -277,18 +277,18 @@ private extension MessageListView {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .mesh:
|
||||
emptyStateLine(String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is"))
|
||||
emptyStateLine(String(localized: "content.empty.mesh_waiting", comment: "Second line of the empty mesh timeline saying no peers are in range yet"))
|
||||
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
|
||||
emptyStateLine(String(localized: "content.empty.mesh_intro", defaultValue: "you're on #mesh — reaches people within bluetooth range", comment: "First line of the empty mesh timeline explaining what the mesh channel is"))
|
||||
emptyStateLine(String(localized: "content.empty.mesh_waiting", defaultValue: "nobody in range yet... messages appear here", comment: "Second line of the empty mesh timeline saying no peers are in range yet"))
|
||||
emptyStateLine(String(localized: "content.empty.switch_hint", defaultValue: "tap the channel name above to switch · tap bitchat/ for help", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
|
||||
case .location(let channel):
|
||||
emptyStateLine(
|
||||
String(
|
||||
format: String(localized: "content.empty.location_intro", comment: "First line of an empty geohash timeline naming the channel"),
|
||||
format: String(localized: "content.empty.location_intro", defaultValue: "you're in #%@ — a public location channel over the internet", comment: "First line of an empty geohash timeline naming the channel"),
|
||||
locale: .current,
|
||||
channel.geohash
|
||||
)
|
||||
)
|
||||
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
|
||||
emptyStateLine(String(localized: "content.empty.switch_hint", defaultValue: "tap the channel name above to switch · tap bitchat/ for help", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
@@ -368,7 +368,7 @@ private extension MessageListView {
|
||||
if unseenCount > 0 {
|
||||
Text(
|
||||
String(
|
||||
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
|
||||
format: String(localized: "content.jump.new_count", defaultValue: "%lld new", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
|
||||
locale: .current,
|
||||
unseenCount
|
||||
)
|
||||
@@ -389,10 +389,10 @@ private extension MessageListView {
|
||||
}
|
||||
|
||||
var jumpToLatestAccessibilityLabel: String {
|
||||
let base = String(localized: "content.accessibility.jump_to_latest", comment: "Accessibility label for the jump to latest messages button")
|
||||
let base = String(localized: "content.accessibility.jump_to_latest", defaultValue: "jump to latest messages", comment: "Accessibility label for the jump to latest messages button")
|
||||
guard unseenCount > 0 else { return base }
|
||||
let count = String(
|
||||
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
|
||||
format: String(localized: "content.jump.new_count", defaultValue: "%lld new", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
|
||||
locale: .current,
|
||||
unseenCount
|
||||
)
|
||||
|
||||
@@ -21,17 +21,20 @@ extension String {
|
||||
return current >= threshold
|
||||
}
|
||||
|
||||
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
||||
// 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.
|
||||
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) {
|
||||
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)")
|
||||
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)
|
||||
if found.count >= max { break }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ struct MyQRView: View {
|
||||
|
||||
private enum Strings {
|
||||
static let title: LocalizedStringKey = "verification.my_qr.title"
|
||||
static let accessibilityLabel = String(localized: "verification.my_qr.accessibility_label", comment: "Accessibility label describing the verification QR code")
|
||||
static let accessibilityLabel = String(localized: "verification.my_qr.accessibility_label", defaultValue: "verification QR code", comment: "Accessibility label describing the verification QR code")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -124,13 +124,13 @@ struct QRScanView: View {
|
||||
static let validate: LocalizedStringKey = "verification.scan.validate"
|
||||
static func requested(_ nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"),
|
||||
format: String(localized: "verification.scan.status.requested", defaultValue: "verification requested for %@", comment: "Status text when verification is requested for a nickname"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
static let notFound = String(localized: "verification.scan.status.no_peer", comment: "Status when no matching peer is found for a verification request")
|
||||
static let invalid = String(localized: "verification.scan.status.invalid", comment: "Status when a scanned QR payload is invalid")
|
||||
static let notFound = String(localized: "verification.scan.status.no_peer", defaultValue: "could not find matching peer", comment: "Status when no matching peer is found for a verification request")
|
||||
static let invalid = String(localized: "verification.scan.status.invalid", defaultValue: "invalid or expired QR payload", comment: "Status when a scanned QR payload is invalid")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -296,7 +296,7 @@ struct VerificationSheetView: View {
|
||||
VStack(spacing: 0) {
|
||||
// Top header (always at top)
|
||||
HStack {
|
||||
Text("verification.sheet.title")
|
||||
Text(String(localized: "verification.sheet.title", defaultValue: "VERIFY"))
|
||||
.bitchatFont(size: 14, weight: .bold)
|
||||
.foregroundColor(accentColor)
|
||||
Spacer()
|
||||
@@ -316,7 +316,7 @@ struct VerificationSheetView: View {
|
||||
Group {
|
||||
if showingScanner {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("verification.scan.prompt_friend")
|
||||
Text(String(localized: "verification.scan.prompt_friend", defaultValue: "scan a friend's QR"))
|
||||
.bitchatFont(size: 16, weight: .bold)
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -591,6 +591,45 @@ 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 {
|
||||
|
||||
@@ -261,6 +261,69 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ 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,6 +192,9 @@ 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
|
||||
@@ -215,4 +218,35 @@ 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)] = []
|
||||
private(set) var rateLimitChecks: [(senderKey: String, contentKey: String, powBits: Int)] = []
|
||||
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) -> Bool {
|
||||
rateLimitChecks.append((senderKey, contentKey))
|
||||
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool {
|
||||
rateLimitChecks.append((senderKey, contentKey, powBits))
|
||||
return rateLimitAllowed
|
||||
}
|
||||
|
||||
|
||||
@@ -156,6 +156,24 @@ 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
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -370,6 +370,173 @@ 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,
|
||||
@@ -435,6 +602,8 @@ 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)] = []
|
||||
|
||||
@@ -477,6 +646,11 @@ 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))
|
||||
}
|
||||
@@ -485,6 +659,18 @@ 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)
|
||||
}
|
||||
@@ -492,4 +678,32 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
//
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,7 @@ struct GossipSyncManagerTests {
|
||||
config.messageSyncIntervalSeconds = 1
|
||||
config.fragmentSyncIntervalSeconds = 1
|
||||
config.fileTransferSyncIntervalSeconds = 1
|
||||
config.prekeyBundleSyncIntervalSeconds = 1
|
||||
config.maintenanceIntervalSeconds = 0
|
||||
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
@@ -195,19 +196,25 @@ struct GossipSyncManagerTests {
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
|
||||
// One request per due schedule so each type group gets the full
|
||||
// filter capacity: publicMessages, fragment, and fileTransfer.
|
||||
// filter capacity: publicMessages, fragment, fileTransfer, and
|
||||
// prekeyBundle.
|
||||
let sentPackets = delegate.packets
|
||||
#expect(sentPackets.count == 3)
|
||||
#expect(sentPackets.count == 4)
|
||||
let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }
|
||||
#expect(decoded.count == 3)
|
||||
#expect(decoded.count == 4)
|
||||
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(decoded.contains { $0.types == .publicMessages })
|
||||
#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 == .fragment })
|
||||
#expect(decoded.contains { $0.types == .fileTransfer })
|
||||
#expect(decoded.contains { $0.types == .prekeyBundle })
|
||||
}
|
||||
|
||||
@Test func truncatedFilterCarriesSinceCursor() throws {
|
||||
@@ -292,6 +299,7 @@ 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)
|
||||
@@ -357,12 +365,53 @@ 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
|
||||
|
||||
@@ -417,6 +466,7 @@ struct GossipSyncManagerTests {
|
||||
#expect(types.contains(.message))
|
||||
#expect(types.contains(.fragment))
|
||||
#expect(types.contains(.fileTransfer))
|
||||
#expect(types.contains(.prekeyBundle))
|
||||
}
|
||||
|
||||
@Test func handleRequestSyncHonorsTypeFilter() async throws {
|
||||
@@ -469,6 +519,187 @@ 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 {
|
||||
@@ -545,6 +776,7 @@ 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()
|
||||
@@ -566,6 +798,6 @@ private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||
}
|
||||
|
||||
func getConnectedPeers() -> [PeerID] {
|
||||
return []
|
||||
return connectedPeers
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user