mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 15:45:20 +00:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f02319f6f | ||
|
|
970802c923 | ||
|
|
935dcdbd51 | ||
|
|
bf5c9a3850 | ||
|
|
9cbfd131da | ||
|
|
20255c9916 | ||
|
|
b62f98cc62 | ||
|
|
02aa1d67db | ||
|
|
e0e90af9fd | ||
|
|
94e7f91b7b | ||
|
|
00c7f0460a | ||
|
|
175da268dd | ||
|
|
f6f7fa704a | ||
|
|
4f5d25808f | ||
|
|
a05c583fb5 | ||
|
|
25efa62587 | ||
|
|
0d93ce0874 | ||
|
|
20cdd6653a | ||
|
|
862484c0d4 | ||
|
|
e1e481a1a7 | ||
|
|
aed3e6a273 | ||
|
|
b481a70458 | ||
|
|
2b7fd2002b | ||
|
|
ccccb2dd8c | ||
|
|
385e9e2aab | ||
|
|
48035872e1 | ||
|
|
d4594b9504 | ||
|
|
9ae440be6a | ||
|
|
fd2dffdda7 | ||
|
|
953d24f7f2 | ||
|
|
6dd8dd055a | ||
|
|
85112d809b | ||
|
|
e4b3cff5fa | ||
|
|
fb80403cd4 | ||
|
|
7ad19ab4c3 | ||
|
|
b1ff5e6b82 | ||
|
|
ee148a018b | ||
|
|
26b247c329 | ||
|
|
81168adad1 | ||
|
|
18dcc747d7 | ||
|
|
6056d20a13 | ||
|
|
112c0ec1ed | ||
|
|
688b954fb8 | ||
|
|
f5dde45c18 |
@@ -18,6 +18,9 @@ final class AppChromeModel: ObservableObject {
|
|||||||
private let chatViewModel: ChatViewModel
|
private let chatViewModel: ChatViewModel
|
||||||
private var cancellables = Set<AnyCancellable>()
|
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) {
|
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||||
self.chatViewModel = chatViewModel
|
self.chatViewModel = chatViewModel
|
||||||
self.nickname = chatViewModel.nickname
|
self.nickname = chatViewModel.nickname
|
||||||
@@ -59,6 +62,28 @@ final class AppChromeModel: ObservableObject {
|
|||||||
isAppInfoPresented = true
|
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() {
|
func triggerScreenshotPrivacyWarning() {
|
||||||
showScreenshotPrivacyWarning = true
|
showScreenshotPrivacyWarning = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -349,12 +349,12 @@ private extension AppRuntime {
|
|||||||
TorManager.shared.isAutoStartAllowed() {
|
TorManager.shared.isAutoStartAllowed() {
|
||||||
chatViewModel.torStatusAnnounced = true
|
chatViewModel.torStatusAnnounced = true
|
||||||
chatViewModel.addGeohashOnlySystemMessage(
|
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 {
|
} else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced {
|
||||||
chatViewModel.torStatusAnnounced = true
|
chatViewModel.torStatusAnnounced = true
|
||||||
chatViewModel.addGeohashOnlySystemMessage(
|
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() {
|
private func refreshComputedState() {
|
||||||
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,20 +12,26 @@ final class LocationChannelsModel: ObservableObject {
|
|||||||
@Published private(set) var bookmarkNames: [String: String]
|
@Published private(set) var bookmarkNames: [String: String]
|
||||||
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
||||||
@Published private(set) var userTorEnabled: Bool
|
@Published private(set) var userTorEnabled: Bool
|
||||||
|
@Published private(set) var gatewayEnabled: Bool
|
||||||
|
|
||||||
private let manager: LocationChannelManager
|
private let manager: LocationChannelManager
|
||||||
private let network: NetworkActivationService
|
private let network: NetworkActivationService
|
||||||
|
private let gateway: GatewayService
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
manager: LocationChannelManager? = nil,
|
manager: LocationChannelManager? = nil,
|
||||||
network: NetworkActivationService? = nil
|
network: NetworkActivationService? = nil,
|
||||||
|
gateway: GatewayService? = nil
|
||||||
) {
|
) {
|
||||||
let manager = manager ?? .shared
|
let manager = manager ?? .shared
|
||||||
let network = network ?? .shared
|
let network = network ?? .shared
|
||||||
|
let gateway = gateway ?? .shared
|
||||||
|
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
self.network = network
|
self.network = network
|
||||||
|
self.gateway = gateway
|
||||||
|
self.gatewayEnabled = gateway.isEnabled
|
||||||
self.permissionState = manager.permissionState
|
self.permissionState = manager.permissionState
|
||||||
self.availableChannels = manager.availableChannels
|
self.availableChannels = manager.availableChannels
|
||||||
self.selectedChannel = manager.selectedChannel
|
self.selectedChannel = manager.selectedChannel
|
||||||
@@ -96,6 +102,10 @@ final class LocationChannelsModel: ObservableObject {
|
|||||||
network.setUserTorEnabled(enabled)
|
network.setUserTorEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setGatewayEnabled(_ enabled: Bool) {
|
||||||
|
gateway.setEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
func refreshMeshChannelsIfNeeded() {
|
func refreshMeshChannelsIfNeeded() {
|
||||||
guard case .mesh = selectedChannel,
|
guard case .mesh = selectedChannel,
|
||||||
permissionState == .authorized,
|
permissionState == .authorized,
|
||||||
@@ -160,6 +170,10 @@ final class LocationChannelsModel: ObservableObject {
|
|||||||
network.$userTorEnabled
|
network.$userTorEnabled
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: &$userTorEnabled)
|
.assign(to: &$userTorEnabled)
|
||||||
|
|
||||||
|
gateway.$isEnabled
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$gatewayEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func level(forLength length: Int) -> GeohashChannelLevel {
|
private func level(forLength length: Int) -> GeohashChannelLevel {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ struct MeshPeerRow: Identifiable, Equatable {
|
|||||||
let isMutualFavorite: Bool
|
let isMutualFavorite: Bool
|
||||||
let encryptionStatus: EncryptionStatus
|
let encryptionStatus: EncryptionStatus
|
||||||
let showsVerifiedBadgeWhenOffline: Bool
|
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 }
|
var id: String { peerID.id }
|
||||||
}
|
}
|
||||||
@@ -26,11 +29,22 @@ struct GeohashPersonRow: Identifiable, Equatable {
|
|||||||
let isBlocked: Bool
|
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
|
@MainActor
|
||||||
final class PeerListModel: ObservableObject {
|
final class PeerListModel: ObservableObject {
|
||||||
@Published private(set) var allPeers: [BitchatPeer] = []
|
@Published private(set) var allPeers: [BitchatPeer] = []
|
||||||
@Published private(set) var meshRows: [MeshPeerRow] = []
|
@Published private(set) var meshRows: [MeshPeerRow] = []
|
||||||
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
||||||
|
@Published private(set) var groupRows: [GroupChatRow] = []
|
||||||
@Published private(set) var reachableMeshPeerCount = 0
|
@Published private(set) var reachableMeshPeerCount = 0
|
||||||
@Published private(set) var connectedMeshPeerCount = 0
|
@Published private(set) var connectedMeshPeerCount = 0
|
||||||
@Published private(set) var visibleGeohashPeerCount = 0
|
@Published private(set) var visibleGeohashPeerCount = 0
|
||||||
@@ -129,6 +143,13 @@ final class PeerListModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.groupStore.$groups
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
peerIdentityStore.$encryptionStatuses
|
peerIdentityStore.$encryptionStatuses
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
@@ -183,13 +204,12 @@ final class PeerListModel: ObservableObject {
|
|||||||
let myPeerID = chatViewModel.meshService.myPeerID
|
let myPeerID = chatViewModel.meshService.myPeerID
|
||||||
let meshRows = allPeers.map { peer in
|
let meshRows = allPeers.map { peer in
|
||||||
let isMe = peer.peerID == myPeerID
|
let isMe = peer.peerID == myPeerID
|
||||||
let verifiedBadge: Bool
|
let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID)
|
||||||
if !isMe && !peer.isConnected,
|
let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||||
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
|
let verifiedBadge = !peer.isConnected && isVerifiedFingerprint
|
||||||
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
|
// Vouched is subordinate to verified: never show both seals.
|
||||||
} else {
|
let vouchedBadge = !isVerifiedFingerprint
|
||||||
verifiedBadge = false
|
&& (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false)
|
||||||
}
|
|
||||||
|
|
||||||
return MeshPeerRow(
|
return MeshPeerRow(
|
||||||
peerID: peer.peerID,
|
peerID: peer.peerID,
|
||||||
@@ -202,7 +222,8 @@ final class PeerListModel: ObservableObject {
|
|||||||
isReachable: peer.isReachable,
|
isReachable: peer.isReachable,
|
||||||
isMutualFavorite: peer.isMutualFavorite,
|
isMutualFavorite: peer.isMutualFavorite,
|
||||||
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
|
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
|
||||||
showsVerifiedBadgeWhenOffline: verifiedBadge
|
showsVerifiedBadgeWhenOffline: verifiedBadge,
|
||||||
|
showsVouchedBadge: vouchedBadge
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,22 +238,40 @@ final class PeerListModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let geohashPeople = buildGeohashPeople()
|
let geohashPeople = buildGeohashPeople()
|
||||||
|
let groupRows = buildGroupRows()
|
||||||
|
|
||||||
self.meshRows = meshRows
|
self.meshRows = meshRows
|
||||||
reachableMeshPeerCount = meshCounts.reachable
|
reachableMeshPeerCount = meshCounts.reachable
|
||||||
connectedMeshPeerCount = meshCounts.connected
|
connectedMeshPeerCount = meshCounts.connected
|
||||||
self.geohashPeople = geohashPeople
|
self.geohashPeople = geohashPeople
|
||||||
visibleGeohashPeerCount = geohashPeople.count
|
visibleGeohashPeerCount = geohashPeople.count
|
||||||
|
self.groupRows = groupRows
|
||||||
renderID = (
|
renderID = (
|
||||||
meshRows.map {
|
meshRows.map {
|
||||||
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||||
} +
|
} +
|
||||||
geohashPeople.map {
|
geohashPeople.map {
|
||||||
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
||||||
|
} +
|
||||||
|
groupRows.map {
|
||||||
|
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
|
||||||
}
|
}
|
||||||
).joined(separator: "|")
|
).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] {
|
private func buildGeohashPeople() -> [GeohashPersonRow] {
|
||||||
let myHex = currentGeohashIdentityHex()
|
let myHex = currentGeohashIdentityHex()
|
||||||
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
||||||
|
|||||||
@@ -108,7 +108,13 @@ struct PrivateConversationHeaderState: Equatable {
|
|||||||
let encryptionStatus: EncryptionStatus?
|
let encryptionStatus: EncryptionStatus?
|
||||||
|
|
||||||
var supportsFavoriteToggle: Bool {
|
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)
|
.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"))
|
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
@@ -229,6 +242,26 @@ final class PrivateConversationModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
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 headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
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 {
|
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ struct FingerprintPresentationState: Equatable {
|
|||||||
let theirFingerprint: String?
|
let theirFingerprint: String?
|
||||||
let myFingerprint: String
|
let myFingerprint: String
|
||||||
let isVerified: Bool
|
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 {
|
var canToggleVerification: Bool {
|
||||||
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
|
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
|
||||||
@@ -82,6 +90,24 @@ final class VerificationModel: ObservableObject {
|
|||||||
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
|
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
|
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
|
||||||
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: 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(
|
return FingerprintPresentationState(
|
||||||
statusPeerID: statusPeerID,
|
statusPeerID: statusPeerID,
|
||||||
@@ -89,7 +115,9 @@ final class VerificationModel: ObservableObject {
|
|||||||
encryptionStatus: encryptionStatus,
|
encryptionStatus: encryptionStatus,
|
||||||
theirFingerprint: theirFingerprint,
|
theirFingerprint: theirFingerprint,
|
||||||
myFingerprint: chatViewModel.getMyFingerprint(),
|
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()
|
self?.objectWillChange.send()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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 {
|
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 SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
#if DEBUG
|
||||||
|
import BitLogger
|
||||||
|
#endif
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct BitchatApp: App {
|
struct BitchatApp: App {
|
||||||
@@ -43,6 +46,11 @@ struct BitchatApp: App {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
appDelegate.runtime = runtime
|
appDelegate.runtime = runtime
|
||||||
runtime.start()
|
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
|
.onOpenURL { url in
|
||||||
runtime.handleOpenURL(url)
|
runtime.handleOpenURL(url)
|
||||||
|
|||||||
@@ -126,13 +126,37 @@ struct SocialIdentity: Codable {
|
|||||||
var notes: String?
|
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 {
|
enum TrustLevel: String, Codable {
|
||||||
case unknown
|
case unknown
|
||||||
case casual
|
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 trusted
|
||||||
case verified
|
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
|
// MARK: - Identity Cache
|
||||||
|
|
||||||
/// Persistent storage for identity mappings and relationships.
|
/// Persistent storage for identity mappings and relationships.
|
||||||
@@ -155,6 +179,21 @@ struct IdentityCache: Codable {
|
|||||||
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||||
var blockedNostrPubkeys: Set<String> = []
|
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
|
// Schema version for future migrations
|
||||||
var version: Int = 1
|
var version: Int = 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,17 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func setVerified(fingerprint: String, verified: Bool)
|
func setVerified(fingerprint: String, verified: Bool)
|
||||||
func isVerified(fingerprint: String) -> Bool
|
func isVerified(fingerprint: String) -> Bool
|
||||||
func getVerifiedFingerprints() -> Set<String>
|
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.
|
/// Singleton manager for secure identity state persistence and retrieval.
|
||||||
@@ -550,8 +561,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
if verified {
|
if verified {
|
||||||
self.cache.verifiedFingerprints.insert(fingerprint)
|
self.cache.verifiedFingerprints.insert(fingerprint)
|
||||||
|
var verifiedAt = self.cache.verifiedAt ?? [:]
|
||||||
|
verifiedAt[fingerprint] = Date()
|
||||||
|
self.cache.verifiedAt = verifiedAt
|
||||||
} else {
|
} else {
|
||||||
self.cache.verifiedFingerprints.remove(fingerprint)
|
self.cache.verifiedFingerprints.remove(fingerprint)
|
||||||
|
self.cache.verifiedAt?.removeValue(forKey: fingerprint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update trust level if social identity exists
|
// Update trust level if social identity exists
|
||||||
@@ -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>] {
|
var debugNicknameIndex: [String: Set<String>] {
|
||||||
queue.sync { cache.nicknameIndex }
|
queue.sync { cache.nicknameIndex }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,12 +33,18 @@
|
|||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_bitchat-bulk._tcp</string>
|
||||||
|
</array>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>bitchat uses peer-to-peer Wi-Fi to transfer large photos and voice notes directly between nearby devices.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
|||||||
+20720
-1
File diff suppressed because it is too large
Load Diff
@@ -16,14 +16,18 @@ enum CommandInfo: String, Identifiable {
|
|||||||
// suggesting a spelling the processor rejects teaches users dead ends.
|
// suggesting a spelling the processor rejects teaches users dead ends.
|
||||||
case block
|
case block
|
||||||
case clear
|
case clear
|
||||||
|
case group
|
||||||
case help
|
case help
|
||||||
case hug
|
case hug
|
||||||
case message = "msg"
|
case message = "msg"
|
||||||
case slap
|
case slap
|
||||||
|
case pay
|
||||||
case unblock
|
case unblock
|
||||||
case who
|
case who
|
||||||
case favorite = "fav"
|
case favorite = "fav"
|
||||||
case unfavorite = "unfav"
|
case unfavorite = "unfav"
|
||||||
|
case ping
|
||||||
|
case trace
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
@@ -31,8 +35,12 @@ enum CommandInfo: String, Identifiable {
|
|||||||
|
|
||||||
var placeholder: String? {
|
var placeholder: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
|
||||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
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:
|
case .clear, .help, .who:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -40,26 +48,37 @@ enum CommandInfo: String, Identifiable {
|
|||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .block: String(localized: "content.commands.block")
|
case .block: String(localized: "content.commands.block", defaultValue: "block or list blocked peers")
|
||||||
case .clear: String(localized: "content.commands.clear")
|
case .clear: String(localized: "content.commands.clear", defaultValue: "clear chat messages")
|
||||||
case .help: String(localized: "content.commands.help")
|
case .group: String(localized: "content.commands.group", defaultValue: "create or manage private groups")
|
||||||
case .hug: String(localized: "content.commands.hug")
|
case .help: String(localized: "content.commands.help", defaultValue: "show available commands")
|
||||||
case .message: String(localized: "content.commands.message")
|
case .hug: String(localized: "content.commands.hug", defaultValue: "send someone a warm hug")
|
||||||
case .slap: String(localized: "content.commands.slap")
|
case .message: String(localized: "content.commands.message", defaultValue: "send private message")
|
||||||
case .unblock: String(localized: "content.commands.unblock")
|
case .pay: String(localized: "content.commands.pay", defaultValue: "send a cashu ecash token in this chat")
|
||||||
case .who: String(localized: "content.commands.who")
|
case .slap: String(localized: "content.commands.slap", defaultValue: "slap someone with a trout")
|
||||||
case .favorite: String(localized: "content.commands.favorite")
|
case .unblock: String(localized: "content.commands.unblock", defaultValue: "unblock a peer")
|
||||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
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] {
|
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
|
var commands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
|
||||||
// The processor rejects favorites in geohash contexts, so only
|
// Cashu tokens are bearer instruments: in a public geohash any nearby
|
||||||
// suggest them where they actually work: mesh.
|
// stranger can redeem one, so don't *suggest* /pay there (the
|
||||||
if isGeoPublic || isGeoDM {
|
// processor still allows it behind an explicit "public" confirm).
|
||||||
return baseCommands
|
// 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,19 +1,21 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// REQUEST_SYNC payload TLV (type, length16, value)
|
// REQUEST_SYNC payload TLV (type, length16, value)
|
||||||
// - 0x01: P (uint8) — Golomb-Rice parameter
|
// - 0x01: P (uint8) — Golomb-Rice parameter
|
||||||
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
||||||
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
||||||
// - 0x04: types (bitfield) — SyncTypeFlags of covered message types
|
// - 0x04: types (SyncTypeFlags) — packet types the filter covers
|
||||||
// - 0x05: sinceTimestamp (uint64, big-endian) — oldest ts the filter covers
|
// - 0x05: sinceTimestamp (uint64, big-endian) — filter coverage cursor
|
||||||
// - 0x06: fragmentIdFilter (utf8) — reserved
|
// - 0x06: fragmentIdFilter (UTF-8) — comma-separated 16-hex-char (8-byte)
|
||||||
//
|
// fragment stream IDs; restricts the fragment diff to exactly those
|
||||||
// TODO(v2): fragmentIdFilter (0x06) is parsed and re-serialized but never
|
// streams (targeted resync for stalled reassemblies)
|
||||||
// populated or honored — it's the reserved surface for incremental fragment
|
|
||||||
// sync (request the missing fragments of one file by ID instead of diffing the
|
|
||||||
// whole fragment set). Either wire it into buildGcsPayload/_handleRequestSync
|
|
||||||
// or drop the field; don't leave it as silent dead protocol surface.
|
|
||||||
struct RequestSyncPacket {
|
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 p: Int
|
||||||
let m: UInt32
|
let m: UInt32
|
||||||
let data: Data
|
let data: Data
|
||||||
@@ -21,6 +23,29 @@ struct RequestSyncPacket {
|
|||||||
let sinceTimestamp: UInt64?
|
let sinceTimestamp: UInt64?
|
||||||
let fragmentIdFilter: String?
|
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) {
|
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
||||||
self.p = p
|
self.p = p
|
||||||
self.m = m
|
self.m = m
|
||||||
@@ -97,7 +122,9 @@ struct RequestSyncPacket {
|
|||||||
sinceTimestamp = ts
|
sinceTimestamp = ts
|
||||||
}
|
}
|
||||||
case 0x06:
|
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
|
fragmentIdFilter = fid
|
||||||
}
|
}
|
||||||
default:
|
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,
|
nickname: String? = nil,
|
||||||
teleported: Bool = false
|
teleported: Bool = false
|
||||||
) throws -> NostrEvent {
|
) 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]]
|
var tags = [["g", geohash]]
|
||||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
@@ -177,15 +234,7 @@ struct NostrProtocol {
|
|||||||
if teleported {
|
if teleported {
|
||||||
tags.append(["t", "teleport"])
|
tags.append(["t", "teleport"])
|
||||||
}
|
}
|
||||||
let event = NostrEvent(
|
return tags
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: tags,
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
||||||
return try event.sign(with: schnorrKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a geohash presence heartbeat (kind 20001)
|
/// Create a geohash presence heartbeat (kind 20001)
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ struct BitchatFilePacket {
|
|||||||
|
|
||||||
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
||||||
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
||||||
func encode() -> Data? {
|
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||||
|
/// `FileTransferLimits.maxWifiBulkPayloadBytes`.
|
||||||
|
func encode(limit: Int = FileTransferLimits.maxPayloadBytes) -> Data? {
|
||||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||||
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
guard resolvedSize <= UInt64(limit) else { return nil }
|
||||||
guard content.count <= Int(UInt32.max) else { return nil }
|
guard content.count <= Int(UInt32.max) else { return nil }
|
||||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||||
|
|
||||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||||
var big = value.bigEndian
|
var big = value.bigEndian
|
||||||
@@ -66,7 +68,9 @@ struct BitchatFilePacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||||
static func decode(_ data: Data) -> BitchatFilePacket? {
|
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||||
|
/// the (smaller of the) accepted-offer size and the Wi-Fi bulk ceiling.
|
||||||
|
static func decode(_ data: Data, limit: Int = FileTransferLimits.maxPayloadBytes) -> BitchatFilePacket? {
|
||||||
var cursor = data.startIndex
|
var cursor = data.startIndex
|
||||||
let end = data.endIndex
|
let end = data.endIndex
|
||||||
|
|
||||||
@@ -126,7 +130,7 @@ struct BitchatFilePacket {
|
|||||||
for byte in value {
|
for byte in value {
|
||||||
size = (size << 8) | UInt64(byte)
|
size = (size << 8) | UInt64(byte)
|
||||||
}
|
}
|
||||||
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
if size > UInt64(limit) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fileSize = size
|
fileSize = size
|
||||||
@@ -135,7 +139,7 @@ struct BitchatFilePacket {
|
|||||||
mimeType = String(data: Data(value), encoding: .utf8)
|
mimeType = String(data: Data(value), encoding: .utf8)
|
||||||
case .content:
|
case .content:
|
||||||
let proposedSize = content.count + value.count
|
let proposedSize = content.count + value.count
|
||||||
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
if proposedSize > limit {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
content.append(contentsOf: value)
|
content.append(contentsOf: value)
|
||||||
@@ -145,7 +149,7 @@ struct BitchatFilePacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard !content.isEmpty else { return nil }
|
guard !content.isEmpty else { return nil }
|
||||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||||
return BitchatFilePacket(
|
return BitchatFilePacket(
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
fileSize: fileSize ?? UInt64(content.count),
|
fileSize: fileSize ?? UInt64(content.count),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
/// - Efficient binary message encoding
|
/// - Efficient binary message encoding
|
||||||
/// - Message fragmentation for large payloads
|
/// - Message fragmentation for large payloads
|
||||||
/// - TTL-based routing for mesh networks
|
/// - 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
|
/// - Integration points for end-to-end encryption
|
||||||
///
|
///
|
||||||
/// ## Protocol Design
|
/// ## Protocol Design
|
||||||
@@ -38,18 +38,20 @@
|
|||||||
/// 7. **Decoding**: Binary data parsed back to message objects
|
/// 7. **Decoding**: Binary data parsed back to message objects
|
||||||
///
|
///
|
||||||
/// ## Security Considerations
|
/// ## Security Considerations
|
||||||
/// - Message padding obscures actual content length
|
/// - Message padding (to 256/512/1024/2048-byte blocks) obscures actual content length
|
||||||
/// - Timing obfuscation prevents traffic analysis
|
/// - 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
|
/// - Integration with Noise Protocol for E2E encryption
|
||||||
/// - No persistent identifiers in protocol headers
|
/// - No persistent identifiers in protocol headers
|
||||||
///
|
///
|
||||||
/// ## Message Types
|
/// ## Message Types
|
||||||
/// - **Announce/Leave**: Peer presence notifications
|
/// - **Announce/Leave**: Peer presence notifications
|
||||||
/// - **Message**: User chat messages (broadcast or directed)
|
/// - **Message**: Public chat messages
|
||||||
/// - **Fragment**: Multi-part message handling
|
/// - **Fragment**: Multi-part message handling
|
||||||
/// - **Delivery/Read**: Message acknowledgments
|
/// - **NoiseHandshake/NoiseEncrypted**: Encrypted channel establishment and
|
||||||
/// - **Noise**: Encrypted channel establishment
|
/// all private payloads (messages, delivery acks, read receipts)
|
||||||
/// - **Version**: Protocol version negotiation
|
/// - **CourierEnvelope**: Sealed store-and-forward mail
|
||||||
|
/// - **RequestSync/FileTransfer**: Gossip history sync and media transfer
|
||||||
///
|
///
|
||||||
/// ## Future Extensions
|
/// ## Future Extensions
|
||||||
/// The protocol is designed to be extensible:
|
/// The protocol is designed to be extensible:
|
||||||
@@ -72,17 +74,30 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case privateMessage = 0x01 // Private chat message
|
case privateMessage = 0x01 // Private chat message
|
||||||
case readReceipt = 0x02 // Message was read
|
case readReceipt = 0x02 // Message was read
|
||||||
case delivered = 0x03 // Message was delivered
|
case delivered = 0x03 // Message was delivered
|
||||||
|
// 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)
|
// Verification (QR-based OOB binding)
|
||||||
case verifyChallenge = 0x10 // Verification challenge
|
case verifyChallenge = 0x10 // Verification challenge
|
||||||
case verifyResponse = 0x11 // Verification response
|
case verifyResponse = 0x11 // Verification response
|
||||||
|
// Transitive verification (web of trust)
|
||||||
|
case vouch = 0x12 // Batch of vouch attestations
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .privateMessage: return "privateMessage"
|
case .privateMessage: return "privateMessage"
|
||||||
case .readReceipt: return "readReceipt"
|
case .readReceipt: return "readReceipt"
|
||||||
case .delivered: return "delivered"
|
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 .verifyChallenge: return "verifyChallenge"
|
||||||
case .verifyResponse: return "verifyResponse"
|
case .verifyResponse: return "verifyResponse"
|
||||||
|
case .vouch: return "vouch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,6 +129,9 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
// Low-level events for better separation of concerns
|
// Low-level events for better separation of concerns
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
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
|
// Bluetooth state updates for user notifications
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||||
@@ -133,6 +151,10 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// 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?) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
// Default empty implementation
|
// 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 {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .building:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - Protocol TLV Packets
|
// MARK: - Protocol TLV Packets
|
||||||
@@ -7,12 +8,28 @@ struct AnnouncementPacket {
|
|||||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||||
let signingPublicKey: Data // Ed25519 public key for signing
|
let signingPublicKey: Data // Ed25519 public key for signing
|
||||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
let directNeighbors: [Data]? // 8-byte peer IDs
|
||||||
|
let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients)
|
||||||
|
|
||||||
|
init(
|
||||||
|
nickname: String,
|
||||||
|
noisePublicKey: Data,
|
||||||
|
signingPublicKey: Data,
|
||||||
|
directNeighbors: [Data]?,
|
||||||
|
capabilities: PeerCapabilities? = nil
|
||||||
|
) {
|
||||||
|
self.nickname = nickname
|
||||||
|
self.noisePublicKey = noisePublicKey
|
||||||
|
self.signingPublicKey = signingPublicKey
|
||||||
|
self.directNeighbors = directNeighbors
|
||||||
|
self.capabilities = capabilities
|
||||||
|
}
|
||||||
|
|
||||||
private enum TLVType: UInt8 {
|
private enum TLVType: UInt8 {
|
||||||
case nickname = 0x01
|
case nickname = 0x01
|
||||||
case noisePublicKey = 0x02
|
case noisePublicKey = 0x02
|
||||||
case signingPublicKey = 0x03
|
case signingPublicKey = 0x03
|
||||||
case directNeighbors = 0x04
|
case directNeighbors = 0x04
|
||||||
|
case capabilities = 0x05
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode() -> Data? {
|
func encode() -> Data? {
|
||||||
@@ -48,6 +65,15 @@ struct AnnouncementPacket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TLV for capabilities (optional)
|
||||||
|
if let capabilities = capabilities {
|
||||||
|
let capabilityBytes = capabilities.encoded()
|
||||||
|
guard capabilityBytes.count <= 255 else { return nil }
|
||||||
|
data.append(TLVType.capabilities.rawValue)
|
||||||
|
data.append(UInt8(capabilityBytes.count))
|
||||||
|
data.append(capabilityBytes)
|
||||||
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +83,7 @@ struct AnnouncementPacket {
|
|||||||
var noisePublicKey: Data?
|
var noisePublicKey: Data?
|
||||||
var signingPublicKey: Data?
|
var signingPublicKey: Data?
|
||||||
var directNeighbors: [Data]?
|
var directNeighbors: [Data]?
|
||||||
|
var capabilities: PeerCapabilities?
|
||||||
|
|
||||||
while offset + 2 <= data.count {
|
while offset + 2 <= data.count {
|
||||||
let typeRaw = data[offset]
|
let typeRaw = data[offset]
|
||||||
@@ -87,6 +114,8 @@ struct AnnouncementPacket {
|
|||||||
}
|
}
|
||||||
directNeighbors = neighbors
|
directNeighbors = neighbors
|
||||||
}
|
}
|
||||||
|
case .capabilities:
|
||||||
|
capabilities = PeerCapabilities(encoded: Data(value))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||||
@@ -99,7 +128,8 @@ struct AnnouncementPacket {
|
|||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
directNeighbors: directNeighbors
|
directNeighbors: directNeighbors,
|
||||||
|
capabilities: capabilities
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import BitFoundation
|
||||||
|
|
||||||
|
extension PeerCapabilities {
|
||||||
|
/// Capabilities this build advertises in its announce packets.
|
||||||
|
/// Each feature adds its bit here when it ships.
|
||||||
|
static let localSupported: PeerCapabilities = {
|
||||||
|
var caps: PeerCapabilities = [.prekeys, .vouch, .groups]
|
||||||
|
if TransportConfig.wifiBulkEnabled { caps.insert(.wifiBulk) }
|
||||||
|
return caps
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,9 @@ final class BLEFileTransferHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
/// `payloadLimit` defaults to the Bluetooth cap; Wi-Fi bulk deliveries
|
||||||
|
/// pass the ceiling that was enforced against the accepted offer.
|
||||||
|
func handle(_ packet: BitchatPacket, from peerID: PeerID, payloadLimit: Int = FileTransferLimits.maxPayloadBytes) {
|
||||||
let env = environment
|
let env = environment
|
||||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ final class BLEFileTransferHandler {
|
|||||||
|
|
||||||
let filePacket: BitchatFilePacket
|
let filePacket: BitchatFilePacket
|
||||||
let mime: MimeType
|
let mime: MimeType
|
||||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
switch BLEIncomingFileValidator.validate(payload: packet.payload, limit: payloadLimit) {
|
||||||
case .success(let acceptance):
|
case .success(let acceptance):
|
||||||
filePacket = acceptance.filePacket
|
filePacket = acceptance.filePacket
|
||||||
mime = acceptance.mime
|
mime = acceptance.mime
|
||||||
|
|||||||
@@ -42,12 +42,17 @@ enum BLEIncomingFileRejection: Error, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum BLEIncomingFileValidator {
|
enum BLEIncomingFileValidator {
|
||||||
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk deliveries
|
||||||
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
/// pass the ceiling enforced against the accepted offer.
|
||||||
|
static func validate(
|
||||||
|
payload: Data,
|
||||||
|
limit: Int = FileTransferLimits.maxPayloadBytes
|
||||||
|
) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||||
|
guard let filePacket = BitchatFilePacket.decode(payload, limit: limit) else {
|
||||||
return .failure(.malformedPayload)
|
return .failure(.malformedPayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
guard FileTransferLimits.isValidPayload(filePacket.content.count, limit: limit) else {
|
||||||
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ struct BLEFragmentAssemblyBuffer {
|
|||||||
let type: UInt8
|
let type: UInt8
|
||||||
let total: Int
|
let total: Int
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
|
let isBroadcast: Bool
|
||||||
|
var lastFragmentAt: Date
|
||||||
|
var lastResyncRequestAt: Date?
|
||||||
}
|
}
|
||||||
|
|
||||||
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
|
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
|
||||||
@@ -105,7 +108,15 @@ struct BLEFragmentAssemblyBuffer {
|
|||||||
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
|
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
|
fragmentsByKey[header.key]?[header.index] = header.fragmentData
|
||||||
|
if isNewIndex {
|
||||||
|
metadataByKey[header.key]?.lastFragmentAt = now
|
||||||
|
}
|
||||||
|
|
||||||
guard let fragments = fragmentsByKey[header.key],
|
guard let fragments = fragmentsByKey[header.key],
|
||||||
fragments.count == header.total else {
|
fragments.count == header.total else {
|
||||||
@@ -138,10 +149,59 @@ struct BLEFragmentAssemblyBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fragmentsByKey[header.key] = [:]
|
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
|
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 {
|
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
||||||
if originalType == MessageType.fileTransfer.rawValue {
|
if originalType == MessageType.fileTransfer.rawValue {
|
||||||
// Allow headroom for TLV metadata and binary framing overhead.
|
// Allow headroom for TLV metadata and binary framing overhead.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
|||||||
switch MessageType(rawValue: packetType) {
|
switch MessageType(rawValue: packetType) {
|
||||||
case .noiseEncrypted, .noiseHandshake:
|
case .noiseEncrypted, .noiseHandshake:
|
||||||
return true
|
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
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct BLEPeerInfo: Equatable {
|
|||||||
var signingPublicKey: Data?
|
var signingPublicKey: Data?
|
||||||
var isVerifiedNickname: Bool
|
var isVerifiedNickname: Bool
|
||||||
var lastSeen: Date
|
var lastSeen: Date
|
||||||
|
var capabilities: PeerCapabilities = []
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BLEPeerAnnounceUpdate: Equatable {
|
struct BLEPeerAnnounceUpdate: Equatable {
|
||||||
@@ -107,6 +108,15 @@ struct BLEPeerRegistry {
|
|||||||
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
|
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func capabilities(for peerID: PeerID) -> PeerCapabilities {
|
||||||
|
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] {
|
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
||||||
let connected = peers.filter { $0.value.isConnected }
|
let connected = peers.filter { $0.value.isConnected }
|
||||||
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
||||||
@@ -157,7 +167,8 @@ struct BLEPeerRegistry {
|
|||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
signingPublicKey: Data?,
|
signingPublicKey: Data?,
|
||||||
isConnected: Bool,
|
isConnected: Bool,
|
||||||
now: Date
|
now: Date,
|
||||||
|
capabilities: PeerCapabilities = []
|
||||||
) -> BLEPeerAnnounceUpdate {
|
) -> BLEPeerAnnounceUpdate {
|
||||||
let existing = peers[peerID]
|
let existing = peers[peerID]
|
||||||
let update = BLEPeerAnnounceUpdate(
|
let update = BLEPeerAnnounceUpdate(
|
||||||
@@ -173,7 +184,8 @@ struct BLEPeerRegistry {
|
|||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
lastSeen: now
|
lastSeen: now,
|
||||||
|
capabilities: capabilities
|
||||||
)
|
)
|
||||||
|
|
||||||
return update
|
return update
|
||||||
|
|||||||
@@ -51,13 +51,25 @@ struct BLEReceivePipeline {
|
|||||||
// Courier envelopes are directed opaque ciphertext like DMs; a
|
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||||
// remote handover toward a relayed announce rides this same
|
// remote handover toward a relayed announce rides this same
|
||||||
// deterministic relay treatment instead of the broadcast clamp.
|
// 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
|
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,
|
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||||
isAnnounce: packet.type == MessageType.announce.rawValue,
|
isAnnounce: packet.type == MessageType.announce.rawValue,
|
||||||
isRequestSync: packet.type == MessageType.requestSync.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,
|
degree: degree,
|
||||||
highDegreeThreshold: highDegreeThreshold
|
highDegreeThreshold: highDegreeThreshold
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
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.
|
/// Protocol defining what CommandProcessor needs from its context.
|
||||||
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
|
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -45,15 +56,33 @@ protocol CommandContextProvider: AnyObject {
|
|||||||
/// Empties the peer's chat (single-writer store intent for `/clear`).
|
/// Empties the peer's chat (single-writer store intent for `/clear`).
|
||||||
func clearPrivateChat(_ peerID: PeerID)
|
func clearPrivateChat(_ peerID: PeerID)
|
||||||
func sendPublicRaw(_ content: String)
|
func sendPublicRaw(_ content: String)
|
||||||
|
/// Sends a normal public message (with local echo) to the active channel.
|
||||||
|
func sendPublicMessage(_ content: String)
|
||||||
|
|
||||||
// MARK: - System Messages
|
// MARK: - System Messages
|
||||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
||||||
func addPublicSystemMessage(_ content: String)
|
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
|
// MARK: - Favorites
|
||||||
/// Toggles the favorite via the unified peer flow, which persists by the
|
/// Toggles the favorite via the unified peer flow, which persists by the
|
||||||
/// real noise key and notifies the peer over mesh or Nostr.
|
/// real noise key and notifies the peer over mesh or Nostr.
|
||||||
func toggleFavorite(peerID: PeerID)
|
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
|
/// Processes chat commands in a focused, efficient way
|
||||||
@@ -100,12 +129,23 @@ final class CommandProcessor {
|
|||||||
return handleBlock(args)
|
return handleBlock(args)
|
||||||
case "/unblock":
|
case "/unblock":
|
||||||
return handleUnblock(args)
|
return handleUnblock(args)
|
||||||
|
case "/group":
|
||||||
|
if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") }
|
||||||
|
return handleGroup(args)
|
||||||
case "/fav":
|
case "/fav":
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||||
return handleFavorite(args, add: true)
|
return handleFavorite(args, add: true)
|
||||||
case "/unfav":
|
case "/unfav":
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||||
return handleFavorite(args, add: false)
|
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":
|
case "/help":
|
||||||
return .success(message: Self.helpText)
|
return .success(message: Self.helpText)
|
||||||
default:
|
default:
|
||||||
@@ -125,6 +165,12 @@ final class CommandProcessor {
|
|||||||
/slap @name — slap with a large trout
|
/slap @name — slap with a large trout
|
||||||
/block @name · /unblock @name
|
/block @name · /unblock @name
|
||||||
/fav @name · /unfav @name — favorites (mesh only)
|
/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
|
/help — this list
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -331,6 +377,138 @@ final class CommandProcessor {
|
|||||||
return .error(message: "cannot unblock \(nickname): not found")
|
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 {
|
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||||
let targetName = args.trimmed
|
let targetName = args.trimmed
|
||||||
guard !targetName.isEmpty else {
|
guard !targetName.isEmpty else {
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ final class CourierStore {
|
|||||||
var sprayedTo: Set<Data>
|
var sprayedTo: Set<Data>
|
||||||
/// Last speculative multi-hop handover toward a relayed announce.
|
/// Last speculative multi-hop handover toward a relayed announce.
|
||||||
var lastRemoteHandoverAt: Date?
|
var lastRemoteHandoverAt: Date?
|
||||||
|
/// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1.
|
||||||
|
let prekeyID: UInt32?
|
||||||
|
|
||||||
var envelope: CourierEnvelope {
|
var envelope: CourierEnvelope {
|
||||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
|
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -56,7 +58,8 @@ final class CourierStore {
|
|||||||
tier: CourierDepositTier,
|
tier: CourierDepositTier,
|
||||||
copies: UInt8,
|
copies: UInt8,
|
||||||
sprayedTo: Set<Data> = [],
|
sprayedTo: Set<Data> = [],
|
||||||
lastRemoteHandoverAt: Date? = nil
|
lastRemoteHandoverAt: Date? = nil,
|
||||||
|
prekeyID: UInt32? = nil
|
||||||
) {
|
) {
|
||||||
self.recipientTag = recipientTag
|
self.recipientTag = recipientTag
|
||||||
self.expiry = expiry
|
self.expiry = expiry
|
||||||
@@ -67,6 +70,7 @@ final class CourierStore {
|
|||||||
self.copies = copies
|
self.copies = copies
|
||||||
self.sprayedTo = sprayedTo
|
self.sprayedTo = sprayedTo
|
||||||
self.lastRemoteHandoverAt = lastRemoteHandoverAt
|
self.lastRemoteHandoverAt = lastRemoteHandoverAt
|
||||||
|
self.prekeyID = prekeyID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files written before tiers/spray lack the newer fields; treat that
|
// 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
|
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
|
||||||
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
|
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
|
||||||
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
|
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,
|
depositorNoiseKey: depositorNoiseKey,
|
||||||
storedAt: date,
|
storedAt: date,
|
||||||
tier: tier,
|
tier: tier,
|
||||||
copies: envelope.copies
|
copies: envelope.copies,
|
||||||
|
prekeyID: envelope.prekeyID
|
||||||
))
|
))
|
||||||
persistLocked()
|
persistLocked()
|
||||||
return true
|
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 let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
|
||||||
|
|
||||||
private enum Strings {
|
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 {
|
static func failedToSend(_ detail: String) -> 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,
|
locale: .current,
|
||||||
detail
|
detail
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ final class MeshTopologyTracker {
|
|||||||
private var claims: [RoutingID: Set<RoutingID>] = [:]
|
private var claims: [RoutingID: Set<RoutingID>] = [:]
|
||||||
// Last time we received an update from a node
|
// Last time we received an update from a node
|
||||||
private var lastSeen: [RoutingID: Date] = [:]
|
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
|
// Maximum age for topology claims to be considered fresh for routing
|
||||||
// Routes computed using stale topology can fail when the network has changed
|
// Routes computed using stale topology can fail when the network has changed
|
||||||
@@ -19,48 +23,75 @@ final class MeshTopologyTracker {
|
|||||||
queue.sync(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
self.claims.removeAll()
|
self.claims.removeAll()
|
||||||
self.lastSeen.removeAll()
|
self.lastSeen.removeAll()
|
||||||
|
self.observedVersions.removeAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the topology with a node's self-reported neighbor list
|
/// 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 }
|
guard let source = sanitize(sourceData) else { return }
|
||||||
// Sanitize neighbors and exclude self-loops
|
// Sanitize neighbors and exclude self-loops
|
||||||
let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source])
|
let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source])
|
||||||
|
|
||||||
queue.sync(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
self.claims[source] = validNeighbors
|
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?) {
|
func removePeer(_ data: Data?) {
|
||||||
guard let peer = sanitize(data) else { return }
|
guard let peer = sanitize(data) else { return }
|
||||||
queue.sync(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
self.claims.removeValue(forKey: peer)
|
self.claims.removeValue(forKey: peer)
|
||||||
self.lastSeen.removeValue(forKey: peer)
|
self.lastSeen.removeValue(forKey: peer)
|
||||||
|
self.observedVersions.removeValue(forKey: peer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prune nodes that haven't updated their topology in `age` seconds
|
/// Prune nodes that haven't updated their topology in `age` seconds
|
||||||
func prune(olderThan age: TimeInterval) {
|
func prune(olderThan age: TimeInterval, now: Date = Date()) {
|
||||||
let deadline = Date().addingTimeInterval(-age)
|
let deadline = now.addingTimeInterval(-age)
|
||||||
queue.sync(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
let stale = self.lastSeen.filter { $0.value < deadline }
|
let stale = self.lastSeen.filter { $0.value < deadline }
|
||||||
for (peer, _) in stale {
|
for (peer, _) in stale {
|
||||||
self.claims.removeValue(forKey: peer)
|
self.claims.removeValue(forKey: peer)
|
||||||
self.lastSeen.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 }
|
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
|
||||||
if source == target { return [] } // Direct connection, no intermediate hops
|
if source == target { return [] } // Direct connection, no intermediate hops
|
||||||
|
|
||||||
return queue.sync {
|
return queue.sync {
|
||||||
let now = Date()
|
|
||||||
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
|
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
|
||||||
|
func meetsRequiredVersion(_ peer: RoutingID) -> Bool {
|
||||||
|
guard let requiringVersion else { return true }
|
||||||
|
return (observedVersions[peer]?.version ?? 1) >= requiringVersion
|
||||||
|
}
|
||||||
|
|
||||||
// BFS
|
// BFS
|
||||||
var visited: Set<RoutingID> = [source]
|
var visited: Set<RoutingID> = [source]
|
||||||
@@ -86,6 +117,10 @@ final class MeshTopologyTracker {
|
|||||||
for neighbor in neighbors {
|
for neighbor in neighbors {
|
||||||
if visited.contains(neighbor) { continue }
|
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:
|
// CONFIRMED EDGE CHECK:
|
||||||
// 'last' claims 'neighbor' (checked above)
|
// 'last' claims 'neighbor' (checked above)
|
||||||
// Does 'neighbor' claim 'last'?
|
// Does 'neighbor' claim 'last'?
|
||||||
|
|||||||
@@ -116,30 +116,30 @@ enum EncryptionStatus: Equatable {
|
|||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .none:
|
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:
|
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:
|
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:
|
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:
|
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 {
|
var accessibilityDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .none:
|
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:
|
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:
|
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:
|
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:
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,6 +173,10 @@ final class NoiseEncryptionService {
|
|||||||
private let rateLimiter = NoiseRateLimiter()
|
private let rateLimiter = NoiseRateLimiter()
|
||||||
private let keychain: KeychainManagerProtocol
|
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
|
// Session maintenance
|
||||||
private var rekeyTimer: Timer?
|
private var rekeyTimer: Timer?
|
||||||
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
||||||
@@ -200,6 +204,7 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
init(keychain: KeychainManagerProtocol) {
|
init(keychain: KeychainManagerProtocol) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
|
||||||
|
|
||||||
// BCH-01-009: Load or create static identity key with proper error handling
|
// BCH-01-009: Load or create static identity key with proper error handling
|
||||||
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
@@ -413,12 +418,111 @@ final class NoiseEncryptionService {
|
|||||||
return (payload: payload, senderStaticKey: senderKey.rawRepresentation)
|
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)
|
/// Clear persistent identity (for panic mode)
|
||||||
func clearPersistentIdentity() {
|
func clearPersistentIdentity() {
|
||||||
// Clear from keychain
|
// Clear from keychain
|
||||||
let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
|
let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
|
||||||
let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
|
let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
|
||||||
SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
|
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)
|
SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
|
||||||
// Stop rekey timer
|
// Stop rekey timer
|
||||||
stopRekeyTimer()
|
stopRekeyTimer()
|
||||||
@@ -812,4 +916,7 @@ struct NoiseMessage: Codable {
|
|||||||
enum NoiseEncryptionError: Error {
|
enum NoiseEncryptionError: Error {
|
||||||
case handshakeRequired
|
case handshakeRequired
|
||||||
case sessionNotEstablished
|
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,
|
isHandshake: Bool,
|
||||||
isAnnounce: Bool,
|
isAnnounce: Bool,
|
||||||
isRequestSync: Bool = false,
|
isRequestSync: Bool = false,
|
||||||
|
isUrgentBoardPost: Bool = false,
|
||||||
degree: Int,
|
degree: Int,
|
||||||
highDegreeThreshold: Int) -> RelayDecision {
|
highDegreeThreshold: Int) -> RelayDecision {
|
||||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||||
@@ -64,7 +65,7 @@ struct RelayController {
|
|||||||
// - Dense graphs: keep lower but still allow multi-hop bridging
|
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||||
// - Thin chains (degree <= 2): every hop counts and flood cost is
|
// - Thin chains (degree <= 2): every hop counts and flood cost is
|
||||||
// minimal, so relay at full incoming depth
|
// 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 = {
|
let ttlLimit: UInt8 = {
|
||||||
if degree >= highDegreeThreshold {
|
if degree >= highDegreeThreshold {
|
||||||
return max(UInt8(2), min(ttlCap, UInt8(5)))
|
return max(UInt8(2), min(ttlCap, UInt8(5)))
|
||||||
@@ -72,7 +73,7 @@ struct RelayController {
|
|||||||
if degree <= 2 {
|
if degree <= 2 {
|
||||||
return ttlCap
|
return ttlCap
|
||||||
}
|
}
|
||||||
let preferred = UInt8(isAnnounce ? 7 : 6)
|
let preferred = UInt8((isAnnounce || isUrgentBoardPost) ? 7 : 6)
|
||||||
return max(UInt8(2), min(ttlCap, preferred))
|
return max(UInt8(2), min(ttlCap, preferred))
|
||||||
}()
|
}()
|
||||||
let newTTL = ttlLimit &- 1
|
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 {
|
enum TransportEvent: @unchecked Sendable {
|
||||||
case messageReceived(BitchatMessage)
|
case messageReceived(BitchatMessage)
|
||||||
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||||
case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
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 peerConnected(PeerID)
|
||||||
case peerDisconnected(PeerID)
|
case peerDisconnected(PeerID)
|
||||||
case peerListUpdated([PeerID])
|
case peerListUpdated([PeerID])
|
||||||
@@ -124,10 +161,43 @@ protocol Transport: AnyObject {
|
|||||||
// transport cannot courier (no connected courier, or unsupported).
|
// transport cannot courier (no connected courier, or unsupported).
|
||||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
|
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)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
func sendVerifyResponse(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)
|
// Pending file management (BCH-01-002: files held in memory until user accepts)
|
||||||
func acceptPendingFile(id: String) -> URL?
|
func acceptPendingFile(id: String) -> URL?
|
||||||
func declinePendingFile(id: String)
|
func declinePendingFile(id: String)
|
||||||
@@ -153,7 +223,22 @@ extension Transport {
|
|||||||
|
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
func sendVerifyResponse(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 }
|
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 sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||||
func cancelTransfer(_ transferId: String) {}
|
func cancelTransfer(_ transferId: String) {}
|
||||||
@@ -186,6 +271,8 @@ extension BitchatDelegate {
|
|||||||
)
|
)
|
||||||
case let .noisePayloadReceived(peerID, type, payload, timestamp):
|
case let .noisePayloadReceived(peerID, type, payload, timestamp):
|
||||||
didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp)
|
didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp)
|
||||||
|
case let .groupMessageReceived(payload, timestamp):
|
||||||
|
didReceiveGroupMessage(payload: payload, timestamp: timestamp)
|
||||||
case .peerConnected(let peerID):
|
case .peerConnected(let peerID):
|
||||||
didConnectToPeer(peerID)
|
didConnectToPeer(peerID)
|
||||||
case .peerDisconnected(let peerID):
|
case .peerDisconnected(let peerID):
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ enum TransportConfig {
|
|||||||
static let bleFragmentRelayTtlCap: UInt8 = 7
|
static let bleFragmentRelayTtlCap: UInt8 = 7
|
||||||
static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs
|
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
|
// UI / Storage Caps
|
||||||
static let privateChatCap: Int = 1337
|
static let privateChatCap: Int = 1337
|
||||||
static let meshTimelineCap: 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 bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
|
||||||
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
|
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
|
// Store-and-forward for directed packets at relays. Spooled packets retry
|
||||||
// on each maintenance flush until the window lapses; a longer window lets
|
// on each maintenance flush until the window lapses; a longer window lets
|
||||||
// brief link gaps (walking between rooms, reconnect churn) heal themselves.
|
// brief link gaps (walking between rooms, reconnect churn) heal themselves.
|
||||||
@@ -281,6 +305,21 @@ enum TransportConfig {
|
|||||||
static let syncResponseRateLimitMaxResponses: Int = 8
|
static let syncResponseRateLimitMaxResponses: Int = 8
|
||||||
static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0
|
static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0
|
||||||
|
|
||||||
|
// Wi-Fi bulk transport (peer-to-peer AWDL data plane for large media).
|
||||||
|
// BLE stays the control plane: offers/responses ride the Noise session,
|
||||||
|
// only the sealed chunk stream moves to TCP over AWDL.
|
||||||
|
static let wifiBulkEnabled: Bool = true
|
||||||
|
// Below this size BLE fragmentation is fast enough that negotiation
|
||||||
|
// overhead isn't worth it.
|
||||||
|
static let wifiBulkMinPayloadBytes: Int = 64 * 1024
|
||||||
|
static let wifiBulkChunkBytes: Int = 64 * 1024
|
||||||
|
// Offer unanswered for this long → fall back to BLE fragmentation.
|
||||||
|
static let wifiBulkOfferTimeoutSeconds: TimeInterval = 10.0
|
||||||
|
// Hard ceiling on how long the Bonjour listener/connection may live.
|
||||||
|
static let wifiBulkTransferWindowSeconds: TimeInterval = 60.0
|
||||||
|
static let wifiBulkServiceType: String = "_bitchat-bulk._tcp"
|
||||||
|
static let wifiBulkMaxConcurrentIncoming: Int = 4
|
||||||
|
|
||||||
// Courier store-and-forward
|
// Courier store-and-forward
|
||||||
// Initial spray-and-wait budget per deposited envelope: each courier may
|
// Initial spray-and-wait budget per deposited envelope: each courier may
|
||||||
// hand half its remaining copies to another courier on encounter, so a
|
// hand half its remaining copies to another courier on encounter, so a
|
||||||
@@ -289,4 +328,16 @@ enum TransportConfig {
|
|||||||
// Cooldown between speculative multi-hop handovers of the same envelope
|
// Cooldown between speculative multi-hop handovers of the same envelope
|
||||||
// toward a recipient heard only via relayed announces.
|
// toward a recipient heard only via relayed announces.
|
||||||
static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkChannel.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/// Shared frame-stream reading over an `NWConnection`. All callbacks fire on
|
||||||
|
/// the connection's dispatch queue.
|
||||||
|
enum WifiBulkStream {
|
||||||
|
/// Largest sealed frame body on the wire: one plaintext chunk plus AEAD overhead.
|
||||||
|
static func maxFrameBodyBytes(chunkBytes: Int) -> Int {
|
||||||
|
chunkBytes + WifiBulkCrypto.frameOverhead
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads frames until `onFrame` returns false (stop) or the stream
|
||||||
|
/// errors/closes. `onFrame` returning true keeps the loop alive.
|
||||||
|
static func readFrames(
|
||||||
|
on connection: NWConnection,
|
||||||
|
buffer: WifiBulkFrameBuffer,
|
||||||
|
maxFrameBodyBytes: Int,
|
||||||
|
onFrame: @escaping (Data) -> Bool,
|
||||||
|
onError: @escaping (String) -> Void
|
||||||
|
) {
|
||||||
|
// Drain any frames already buffered before touching the socket.
|
||||||
|
do {
|
||||||
|
while let body = try buffer.nextFrameBody() {
|
||||||
|
guard onFrame(body) else { return }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onError("frame decode failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.receive(
|
||||||
|
minimumIncompleteLength: 1,
|
||||||
|
maximumLength: maxFrameBodyBytes + WifiBulkCrypto.framePrefixLength
|
||||||
|
) { data, _, isComplete, error in
|
||||||
|
if let data, !data.isEmpty {
|
||||||
|
buffer.append(data)
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
onError("receive failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isComplete {
|
||||||
|
// Peer closed: hand over whatever complete frames remain, then
|
||||||
|
// report the close (sessions that already got what they need
|
||||||
|
// will have stopped the loop from inside onFrame).
|
||||||
|
do {
|
||||||
|
while let body = try buffer.nextFrameBody() {
|
||||||
|
guard onFrame(body) else { return }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onError("frame decode failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onError("connection closed by peer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
readFrames(
|
||||||
|
on: connection,
|
||||||
|
buffer: buffer,
|
||||||
|
maxFrameBodyBytes: maxFrameBodyBytes,
|
||||||
|
onFrame: onFrame,
|
||||||
|
onError: onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sender side of the bulk channel: publishes the per-transfer Bonjour
|
||||||
|
/// listener, requires the first inbound frame to prove knowledge of the
|
||||||
|
/// Noise-exchanged channel key, then streams sealed chunks and waits for the
|
||||||
|
/// receiver's verified receipt.
|
||||||
|
///
|
||||||
|
/// The listener starts at offer time (Bonjour registration takes a moment)
|
||||||
|
/// but data can only flow after `activate(key:)` supplies the channel key
|
||||||
|
/// derived from the accepted response.
|
||||||
|
final class WifiBulkSenderSession {
|
||||||
|
private let queue: DispatchQueue
|
||||||
|
private let payload: Data
|
||||||
|
private let transferID: Data
|
||||||
|
private let payloadHash: Data
|
||||||
|
private let chunkBytes: Int
|
||||||
|
private let parameters: NWParameters
|
||||||
|
private let service: NWListener.Service?
|
||||||
|
private let maxCandidateConnections = 4
|
||||||
|
|
||||||
|
private var key: SymmetricKey?
|
||||||
|
private var listener: NWListener?
|
||||||
|
/// Connections that have not yet produced a valid auth frame.
|
||||||
|
private var candidates: [NWConnection] = []
|
||||||
|
private var authenticated: NWConnection?
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
let totalChunks: Int
|
||||||
|
|
||||||
|
/// Test hook: fires once the listener is ready, with its bound port.
|
||||||
|
var onListenerReady: ((UInt16) -> Void)?
|
||||||
|
var onChunkSent: ((_ sent: Int, _ total: Int) -> Void)?
|
||||||
|
var onCompleted: (() -> Void)?
|
||||||
|
var onFailed: ((String) -> Void)?
|
||||||
|
|
||||||
|
init(
|
||||||
|
payload: Data,
|
||||||
|
transferID: Data,
|
||||||
|
chunkBytes: Int,
|
||||||
|
parameters: NWParameters,
|
||||||
|
service: NWListener.Service?,
|
||||||
|
queue: DispatchQueue
|
||||||
|
) {
|
||||||
|
self.payload = payload
|
||||||
|
self.transferID = transferID
|
||||||
|
self.payloadHash = Data(SHA256.hash(data: payload))
|
||||||
|
self.chunkBytes = chunkBytes
|
||||||
|
self.parameters = parameters
|
||||||
|
self.service = service
|
||||||
|
self.queue = queue
|
||||||
|
self.totalChunks = (payload.count + chunkBytes - 1) / chunkBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
cancelNetworkResources()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the listener. Returns false when the listener cannot be created
|
||||||
|
/// (caller falls back to BLE immediately).
|
||||||
|
func start() -> Bool {
|
||||||
|
let listener: NWListener
|
||||||
|
do {
|
||||||
|
listener = try NWListener(using: parameters)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("[WIFI] listener creation failed: \(error)", category: .transport)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
listener.service = service
|
||||||
|
listener.stateUpdateHandler = { [weak self] state in
|
||||||
|
guard let self else { return }
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
if let port = listener.port?.rawValue {
|
||||||
|
self.onListenerReady?(port)
|
||||||
|
}
|
||||||
|
case .failed(let error):
|
||||||
|
self.fail("listener failed: \(error)")
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listener.newConnectionHandler = { [weak self] connection in
|
||||||
|
self?.acceptCandidate(connection)
|
||||||
|
}
|
||||||
|
self.listener = listener
|
||||||
|
listener.start(queue: queue)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supplies the channel key once the receiver accepted the offer; begins
|
||||||
|
/// authenticating any connections that raced ahead of the response.
|
||||||
|
func activate(key: SymmetricKey) {
|
||||||
|
guard !finished, self.key == nil else { return }
|
||||||
|
self.key = key
|
||||||
|
for candidate in candidates {
|
||||||
|
beginAuthentication(on: candidate, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
finished = true
|
||||||
|
cancelNetworkResources()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connection handling
|
||||||
|
|
||||||
|
private func acceptCandidate(_ connection: NWConnection) {
|
||||||
|
guard !finished, authenticated == nil, candidates.count < maxCandidateConnections else {
|
||||||
|
connection.cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidates.append(connection)
|
||||||
|
connection.stateUpdateHandler = { [weak self, weak connection] state in
|
||||||
|
guard let self, let connection else { return }
|
||||||
|
if case .failed = state {
|
||||||
|
self.dropCandidate(connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.start(queue: queue)
|
||||||
|
if let key {
|
||||||
|
beginAuthentication(on: connection, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dropCandidate(_ connection: NWConnection) {
|
||||||
|
if let index = candidates.firstIndex(where: { $0 === connection }) {
|
||||||
|
candidates.remove(at: index)
|
||||||
|
connection.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginAuthentication(on connection: NWConnection, key: SymmetricKey) {
|
||||||
|
let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes))
|
||||||
|
WifiBulkStream.readFrames(
|
||||||
|
on: connection,
|
||||||
|
buffer: buffer,
|
||||||
|
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||||
|
onFrame: { [weak self, weak connection] body in
|
||||||
|
guard let self, let connection, !self.finished, self.authenticated == nil else { return false }
|
||||||
|
guard WifiBulkCrypto.validateClientAuthFrameBody(body, transferID: self.transferID, key: key) else {
|
||||||
|
// Bonjour-level gatecrasher: no channel key, no service.
|
||||||
|
SecureLogger.warning("[WIFI] AWDL connect rejected (invalid auth frame)", category: .security)
|
||||||
|
self.dropCandidate(connection)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
self.promoteAuthenticated(connection, key: key, residualBuffer: buffer)
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onError: { [weak self, weak connection] _ in
|
||||||
|
guard let self, let connection, self.authenticated !== connection else { return }
|
||||||
|
self.dropCandidate(connection)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func promoteAuthenticated(_ connection: NWConnection, key: SymmetricKey, residualBuffer: WifiBulkFrameBuffer) {
|
||||||
|
authenticated = connection
|
||||||
|
// One authenticated peer is all a transfer needs: stop advertising and
|
||||||
|
// shed the other candidates.
|
||||||
|
listener?.cancel()
|
||||||
|
listener = nil
|
||||||
|
for candidate in candidates where candidate !== connection {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Streaming
|
||||||
|
|
||||||
|
private func streamChunk(at index: Int, over connection: NWConnection, key: SymmetricKey, receiptBuffer: WifiBulkFrameBuffer) {
|
||||||
|
guard !finished else { return }
|
||||||
|
guard index < totalChunks else {
|
||||||
|
awaitReceipt(on: connection, key: key, buffer: receiptBuffer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = payload.index(payload.startIndex, offsetBy: index * chunkBytes)
|
||||||
|
let end = payload.index(start, offsetBy: min(chunkBytes, payload.distance(from: start, to: payload.endIndex)))
|
||||||
|
let chunk = Data(payload[start..<end])
|
||||||
|
|
||||||
|
let body: Data
|
||||||
|
do {
|
||||||
|
body = try WifiBulkCrypto.sealFrameBody(chunk, direction: .senderToReceiver, counter: UInt64(index), key: key)
|
||||||
|
} catch {
|
||||||
|
fail("chunk seal failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.send(content: WifiBulkCrypto.frameData(body: body), completion: .contentProcessed { [weak self] error in
|
||||||
|
guard let self, !self.finished else { return }
|
||||||
|
if let error {
|
||||||
|
self.fail("send failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.onChunkSent?(index + 1, self.totalChunks)
|
||||||
|
self.streamChunk(at: index + 1, over: connection, key: key, receiptBuffer: receiptBuffer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func awaitReceipt(on connection: NWConnection, key: SymmetricKey, buffer: WifiBulkFrameBuffer) {
|
||||||
|
WifiBulkStream.readFrames(
|
||||||
|
on: connection,
|
||||||
|
buffer: buffer,
|
||||||
|
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||||
|
onFrame: { [weak self] body in
|
||||||
|
guard let self, !self.finished else { return false }
|
||||||
|
guard WifiBulkCrypto.validateReceiptFrameBody(body, payloadHash: self.payloadHash, key: key) else {
|
||||||
|
self.fail("invalid receipt frame")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
self.finished = true
|
||||||
|
self.cancelNetworkResources()
|
||||||
|
self.onCompleted?()
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onError: { [weak self] reason in
|
||||||
|
self?.fail("receipt wait failed: \(reason)")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Teardown
|
||||||
|
|
||||||
|
private func fail(_ reason: String) {
|
||||||
|
guard !finished else { return }
|
||||||
|
finished = true
|
||||||
|
cancelNetworkResources()
|
||||||
|
onFailed?(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelNetworkResources() {
|
||||||
|
listener?.cancel()
|
||||||
|
listener = nil
|
||||||
|
authenticated?.cancel()
|
||||||
|
authenticated = nil
|
||||||
|
for candidate in candidates {
|
||||||
|
candidate.cancel()
|
||||||
|
}
|
||||||
|
candidates.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receiver side of the bulk channel: connects to the sender's per-transfer
|
||||||
|
/// endpoint, proves knowledge of the channel key with the first frame, then
|
||||||
|
/// reassembles sealed chunks, verifies the offer hash, and returns a receipt.
|
||||||
|
final class WifiBulkReceiverSession {
|
||||||
|
private let queue: DispatchQueue
|
||||||
|
private let connection: NWConnection
|
||||||
|
private let key: SymmetricKey
|
||||||
|
private let transferID: Data
|
||||||
|
private let payloadHash: Data
|
||||||
|
private let chunkBytes: Int
|
||||||
|
private let assembler: WifiBulkPayloadAssembler
|
||||||
|
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
var onCompleted: ((Data) -> Void)?
|
||||||
|
var onFailed: ((String) -> Void)?
|
||||||
|
|
||||||
|
/// Fails (returns nil) when the offer exceeds `sizeCap` — the receiver
|
||||||
|
/// enforces the cap it advertised, not the sender's word.
|
||||||
|
init?(
|
||||||
|
endpoint: NWEndpoint,
|
||||||
|
parameters: NWParameters,
|
||||||
|
key: SymmetricKey,
|
||||||
|
transferID: Data,
|
||||||
|
expectedSize: UInt64,
|
||||||
|
expectedHash: Data,
|
||||||
|
sizeCap: Int,
|
||||||
|
chunkBytes: Int,
|
||||||
|
queue: DispatchQueue
|
||||||
|
) {
|
||||||
|
guard let assembler = WifiBulkPayloadAssembler(
|
||||||
|
key: key,
|
||||||
|
expectedSize: expectedSize,
|
||||||
|
expectedHash: expectedHash,
|
||||||
|
sizeCap: sizeCap
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.assembler = assembler
|
||||||
|
self.connection = NWConnection(to: endpoint, using: parameters)
|
||||||
|
self.key = key
|
||||||
|
self.transferID = transferID
|
||||||
|
self.payloadHash = expectedHash
|
||||||
|
self.chunkBytes = chunkBytes
|
||||||
|
self.queue = queue
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
connection.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
connection.stateUpdateHandler = { [weak self] state in
|
||||||
|
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
|
||||||
|
// has a peer actively listening; treat unreachable as fatal so
|
||||||
|
// the sender's fallback isn't left to the window timeout alone.
|
||||||
|
self.fail("connection waiting: \(error)")
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.start(queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
finished = true
|
||||||
|
connection.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendAuthFrameAndReceive() {
|
||||||
|
guard !finished else { return }
|
||||||
|
let authBody: Data
|
||||||
|
do {
|
||||||
|
authBody = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key)
|
||||||
|
} catch {
|
||||||
|
fail("auth frame seal failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connection.send(content: WifiBulkCrypto.frameData(body: authBody), completion: .contentProcessed { [weak self] error in
|
||||||
|
guard let self, !self.finished else { return }
|
||||||
|
if let error {
|
||||||
|
self.fail("auth frame send failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.receiveChunks()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receiveChunks() {
|
||||||
|
let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes))
|
||||||
|
WifiBulkStream.readFrames(
|
||||||
|
on: connection,
|
||||||
|
buffer: buffer,
|
||||||
|
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||||
|
onFrame: { [weak self] body in
|
||||||
|
guard let self, !self.finished else { return false }
|
||||||
|
do {
|
||||||
|
guard let payload = try self.assembler.consume(frameBody: body) else {
|
||||||
|
return true // keep reading
|
||||||
|
}
|
||||||
|
self.sendReceiptAndComplete(payload)
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
self.fail("chunk rejected: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: { [weak self] reason in
|
||||||
|
self?.fail(reason)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendReceiptAndComplete(_ payload: Data) {
|
||||||
|
let receiptBody: Data
|
||||||
|
do {
|
||||||
|
receiptBody = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: payloadHash, key: key)
|
||||||
|
} catch {
|
||||||
|
fail("receipt seal failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connection.send(content: WifiBulkCrypto.frameData(body: receiptBody), completion: .contentProcessed { [weak self] _ in
|
||||||
|
// Receipt is best-effort from the receiver's perspective: the
|
||||||
|
// payload is already verified. Close the channel either way.
|
||||||
|
guard let self, !self.finished else { return }
|
||||||
|
self.finished = true
|
||||||
|
self.connection.cancel()
|
||||||
|
self.onCompleted?(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fail(_ reason: String) {
|
||||||
|
guard !finished else { return }
|
||||||
|
finished = true
|
||||||
|
connection.cancel()
|
||||||
|
onFailed?(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkCrypto.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Channel security for the Wi-Fi bulk data plane.
|
||||||
|
///
|
||||||
|
/// The TCP stream is encrypted and authenticated independently of TLS: both
|
||||||
|
/// endpoints exchanged random 32-byte tokens inside the established Noise
|
||||||
|
/// session, so only they can derive the ChaChaPoly channel key via
|
||||||
|
/// HKDF-SHA256 (domain "bitchat-bulk-v1", transferID as salt). A Bonjour-level
|
||||||
|
/// gatecrasher that connects to the listener cannot produce a single valid
|
||||||
|
/// frame and is disconnected.
|
||||||
|
///
|
||||||
|
/// Stream format: length-prefixed frames, each a ChaChaPoly sealed box in
|
||||||
|
/// combined form (12-byte nonce ‖ ciphertext ‖ 16-byte tag). Nonces are
|
||||||
|
/// structured, never random: [direction byte][3 zero bytes][8-byte BE counter],
|
||||||
|
/// and the reader requires the exact expected nonce for each frame, so frames
|
||||||
|
/// cannot be replayed, reordered, or reflected across directions.
|
||||||
|
enum WifiBulkCryptoError: Error, Equatable {
|
||||||
|
case invalidParameters
|
||||||
|
case frameTooLarge
|
||||||
|
case truncatedFrame
|
||||||
|
case nonceMismatch
|
||||||
|
case authenticationFailed
|
||||||
|
case emptyChunk
|
||||||
|
case payloadOverflow
|
||||||
|
case hashMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WifiBulkFrameDirection: UInt8 {
|
||||||
|
/// Data chunks: counters 0, 1, 2, …
|
||||||
|
case senderToReceiver = 0x00
|
||||||
|
/// Counter 0 = client auth frame, counter 1 = final receipt.
|
||||||
|
case receiverToSender = 0x01
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WifiBulkCrypto {
|
||||||
|
static let keyDomain = "bitchat-bulk-v1"
|
||||||
|
static let nonceLength = 12
|
||||||
|
static let tagLength = 16
|
||||||
|
/// AEAD overhead per frame body (nonce + tag).
|
||||||
|
static let frameOverhead = nonceLength + tagLength
|
||||||
|
/// 4-byte big-endian length prefix per frame.
|
||||||
|
static let framePrefixLength = 4
|
||||||
|
|
||||||
|
// MARK: Key derivation
|
||||||
|
|
||||||
|
/// Derives the ChaChaPoly channel key from the two Noise-exchanged tokens.
|
||||||
|
/// Deterministic: same tokens + transferID always yield the same key.
|
||||||
|
static func deriveKey(senderToken: Data, receiverToken: Data, transferID: Data) -> SymmetricKey? {
|
||||||
|
guard senderToken.count == WifiBulkWire.tokenLength,
|
||||||
|
receiverToken.count == WifiBulkWire.tokenLength,
|
||||||
|
transferID.count == WifiBulkWire.transferIDLength else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var inputKeyMaterial = Data()
|
||||||
|
inputKeyMaterial.append(senderToken)
|
||||||
|
inputKeyMaterial.append(receiverToken)
|
||||||
|
return HKDF<SHA256>.deriveKey(
|
||||||
|
inputKeyMaterial: SymmetricKey(data: inputKeyMaterial),
|
||||||
|
salt: transferID,
|
||||||
|
info: Data(keyDomain.utf8),
|
||||||
|
outputByteCount: 32
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Frame sealing
|
||||||
|
|
||||||
|
static func nonceData(direction: WifiBulkFrameDirection, counter: UInt64) -> Data {
|
||||||
|
var nonce = Data(count: nonceLength)
|
||||||
|
nonce[0] = direction.rawValue
|
||||||
|
var counterBE = counter.bigEndian
|
||||||
|
withUnsafeBytes(of: &counterBE) { nonce.replaceSubrange(4..<nonceLength, with: $0) }
|
||||||
|
return nonce
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seals one frame body (nonce ‖ ciphertext ‖ tag), without length prefix.
|
||||||
|
static func sealFrameBody(
|
||||||
|
_ plaintext: Data,
|
||||||
|
direction: WifiBulkFrameDirection,
|
||||||
|
counter: UInt64,
|
||||||
|
key: SymmetricKey
|
||||||
|
) throws -> Data {
|
||||||
|
let nonce = try ChaChaPoly.Nonce(data: nonceData(direction: direction, counter: counter))
|
||||||
|
return try ChaChaPoly.seal(plaintext, using: key, nonce: nonce).combined
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens one frame body, enforcing the exact expected nonce.
|
||||||
|
static func openFrameBody(
|
||||||
|
_ body: Data,
|
||||||
|
direction: WifiBulkFrameDirection,
|
||||||
|
counter: UInt64,
|
||||||
|
key: SymmetricKey
|
||||||
|
) throws -> Data {
|
||||||
|
guard body.count >= frameOverhead else { throw WifiBulkCryptoError.truncatedFrame }
|
||||||
|
guard body.prefix(nonceLength) == nonceData(direction: direction, counter: counter) else {
|
||||||
|
throw WifiBulkCryptoError.nonceMismatch
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let box = try ChaChaPoly.SealedBox(combined: body)
|
||||||
|
return try ChaChaPoly.open(box, using: key)
|
||||||
|
} catch {
|
||||||
|
throw WifiBulkCryptoError.authenticationFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefixes a frame body with its 4-byte big-endian length for the wire.
|
||||||
|
static func frameData(body: Data) -> Data {
|
||||||
|
var framed = Data(capacity: framePrefixLength + body.count)
|
||||||
|
var lengthBE = UInt32(body.count).bigEndian
|
||||||
|
withUnsafeBytes(of: &lengthBE) { framed.append(contentsOf: $0) }
|
||||||
|
framed.append(body)
|
||||||
|
return framed
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Control frames
|
||||||
|
|
||||||
|
/// First frame on the wire, receiver → sender: proves the connecting
|
||||||
|
/// client holds the Noise-exchanged secret before any data flows.
|
||||||
|
static func makeClientAuthFrameBody(transferID: Data, key: SymmetricKey) throws -> Data {
|
||||||
|
try sealFrameBody(transferID, direction: .receiverToSender, counter: 0, key: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func validateClientAuthFrameBody(_ body: Data, transferID: Data, key: SymmetricKey) -> Bool {
|
||||||
|
(try? openFrameBody(body, direction: .receiverToSender, counter: 0, key: key)) == transferID
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Final frame, receiver → sender: acknowledges the fully verified payload.
|
||||||
|
static func makeReceiptFrameBody(payloadHash: Data, key: SymmetricKey) throws -> Data {
|
||||||
|
try sealFrameBody(payloadHash, direction: .receiverToSender, counter: 1, key: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func validateReceiptFrameBody(_ body: Data, payloadHash: Data, key: SymmetricKey) -> Bool {
|
||||||
|
(try? openFrameBody(body, direction: .receiverToSender, counter: 1, key: key)) == payloadHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Incremental length-prefix parser for the frame stream. Bounded: bodies
|
||||||
|
/// larger than `maxBodyBytes` throw instead of buffering unboundedly.
|
||||||
|
final class WifiBulkFrameBuffer {
|
||||||
|
private var buffer = Data()
|
||||||
|
private let maxBodyBytes: Int
|
||||||
|
|
||||||
|
init(maxBodyBytes: Int) {
|
||||||
|
self.maxBodyBytes = maxBodyBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func append(_ data: Data) {
|
||||||
|
buffer.append(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the next complete frame body, or nil when more bytes are needed.
|
||||||
|
func nextFrameBody() throws -> Data? {
|
||||||
|
guard buffer.count >= WifiBulkCrypto.framePrefixLength else { return nil }
|
||||||
|
let length = buffer.prefix(WifiBulkCrypto.framePrefixLength).reduce(Int(0)) { ($0 << 8) | Int($1) }
|
||||||
|
guard length <= maxBodyBytes else { throw WifiBulkCryptoError.frameTooLarge }
|
||||||
|
guard buffer.count >= WifiBulkCrypto.framePrefixLength + length else { return nil }
|
||||||
|
let body = Data(buffer.dropFirst(WifiBulkCrypto.framePrefixLength).prefix(length))
|
||||||
|
buffer.removeFirst(WifiBulkCrypto.framePrefixLength + length)
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receiver-side reassembly: opens sequential data frames, enforces the size
|
||||||
|
/// negotiated in the accepted offer, and verifies the final SHA-256.
|
||||||
|
final class WifiBulkPayloadAssembler {
|
||||||
|
private let key: SymmetricKey
|
||||||
|
private let expectedSize: Int
|
||||||
|
private let expectedHash: Data
|
||||||
|
private var received = Data()
|
||||||
|
private var counter: UInt64 = 0
|
||||||
|
|
||||||
|
/// Fails when the offer exceeds the receiver-enforced cap.
|
||||||
|
init?(key: SymmetricKey, expectedSize: UInt64, expectedHash: Data, sizeCap: Int) {
|
||||||
|
guard expectedSize > 0,
|
||||||
|
expectedSize <= UInt64(sizeCap),
|
||||||
|
expectedHash.count == WifiBulkWire.hashLength else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.key = key
|
||||||
|
self.expectedSize = Int(expectedSize)
|
||||||
|
self.expectedHash = expectedHash
|
||||||
|
}
|
||||||
|
|
||||||
|
var isComplete: Bool { received.count == expectedSize }
|
||||||
|
|
||||||
|
/// Consumes one sealed data frame body. Returns the verified payload when
|
||||||
|
/// the final byte arrives; throws on tampering, overflow, or hash mismatch.
|
||||||
|
func consume(frameBody: Data) throws -> Data? {
|
||||||
|
let chunk = try WifiBulkCrypto.openFrameBody(
|
||||||
|
frameBody,
|
||||||
|
direction: .senderToReceiver,
|
||||||
|
counter: counter,
|
||||||
|
key: key
|
||||||
|
)
|
||||||
|
guard !chunk.isEmpty else { throw WifiBulkCryptoError.emptyChunk }
|
||||||
|
counter += 1
|
||||||
|
guard received.count + chunk.count <= expectedSize else {
|
||||||
|
throw WifiBulkCryptoError.payloadOverflow
|
||||||
|
}
|
||||||
|
received.append(chunk)
|
||||||
|
guard isComplete else { return nil }
|
||||||
|
guard Data(SHA256.hash(data: received)) == expectedHash else {
|
||||||
|
throw WifiBulkCryptoError.hashMismatch
|
||||||
|
}
|
||||||
|
return received
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkMessages.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// TLV payloads for negotiating a Wi-Fi bulk transfer inside an established
|
||||||
|
/// Noise session (`NoisePayloadType.bulkTransferOffer` / `.bulkTransferResponse`).
|
||||||
|
///
|
||||||
|
/// Both messages ride the encrypted Noise channel, so every field — including
|
||||||
|
/// the session tokens and the random Bonjour instance name — is only visible
|
||||||
|
/// to the two endpoints. TLV format matches `BitchatFilePacket`: 1-byte type,
|
||||||
|
/// 2-byte big-endian length, value. Unknown TLVs are skipped for forward
|
||||||
|
/// compatibility.
|
||||||
|
enum WifiBulkWire {
|
||||||
|
static let transferIDLength = 16
|
||||||
|
static let tokenLength = 32
|
||||||
|
static let hashLength = 32
|
||||||
|
/// Bonjour instance names are capped at 63 UTF-8 bytes.
|
||||||
|
static let maxServiceNameBytes = 63
|
||||||
|
|
||||||
|
static func appendTLV(_ type: UInt8, value: Data, into data: inout Data) {
|
||||||
|
data.append(type)
|
||||||
|
var length = UInt16(value.count).bigEndian
|
||||||
|
withUnsafeBytes(of: &length) { data.append(contentsOf: $0) }
|
||||||
|
data.append(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterates well-formed TLVs, handing each (type, value) to `visit`.
|
||||||
|
/// Returns false when the buffer is structurally malformed.
|
||||||
|
static func parseTLVs(_ data: Data, visit: (UInt8, Data) -> Void) -> Bool {
|
||||||
|
var cursor = data.startIndex
|
||||||
|
let end = data.endIndex
|
||||||
|
while cursor < end {
|
||||||
|
let type = data[cursor]
|
||||||
|
cursor = data.index(after: cursor)
|
||||||
|
guard data.distance(from: cursor, to: end) >= 2 else { return false }
|
||||||
|
let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)])
|
||||||
|
cursor = data.index(cursor, offsetBy: 2)
|
||||||
|
guard data.distance(from: cursor, to: end) >= length else { return false }
|
||||||
|
let valueEnd = data.index(cursor, offsetBy: length)
|
||||||
|
visit(type, Data(data[cursor..<valueEnd]))
|
||||||
|
cursor = valueEnd
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sender → receiver: proposal to move an already-encoded file payload over
|
||||||
|
/// a peer-to-peer Wi-Fi (AWDL) TCP channel instead of BLE fragmentation.
|
||||||
|
struct WifiBulkOffer: Equatable {
|
||||||
|
/// Random per-transfer identifier; also the HKDF salt.
|
||||||
|
let transferID: Data
|
||||||
|
/// Exact byte count of the payload that will cross the channel.
|
||||||
|
let fileSize: UInt64
|
||||||
|
/// SHA-256 over the payload bytes as they cross the channel, verified by
|
||||||
|
/// the receiver after reassembly.
|
||||||
|
let payloadHash: Data
|
||||||
|
/// Sender's random half of the channel secret.
|
||||||
|
let token: Data
|
||||||
|
/// Random Bonjour instance name the sender publishes for this transfer.
|
||||||
|
/// Never derived from nickname or peer ID.
|
||||||
|
let serviceName: String
|
||||||
|
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case transferID = 0x01
|
||||||
|
case fileSize = 0x02
|
||||||
|
case payloadHash = 0x03
|
||||||
|
case token = 0x04
|
||||||
|
case serviceName = 0x05
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
guard transferID.count == WifiBulkWire.transferIDLength,
|
||||||
|
payloadHash.count == WifiBulkWire.hashLength,
|
||||||
|
token.count == WifiBulkWire.tokenLength else { return nil }
|
||||||
|
let nameData = Data(serviceName.utf8)
|
||||||
|
guard !nameData.isEmpty, nameData.count <= WifiBulkWire.maxServiceNameBytes else { return nil }
|
||||||
|
|
||||||
|
var encoded = Data()
|
||||||
|
WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded)
|
||||||
|
var sizeBE = fileSize.bigEndian
|
||||||
|
WifiBulkWire.appendTLV(TLVType.fileSize.rawValue, value: withUnsafeBytes(of: &sizeBE) { Data($0) }, into: &encoded)
|
||||||
|
WifiBulkWire.appendTLV(TLVType.payloadHash.rawValue, value: payloadHash, into: &encoded)
|
||||||
|
WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded)
|
||||||
|
WifiBulkWire.appendTLV(TLVType.serviceName.rawValue, value: nameData, into: &encoded)
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> WifiBulkOffer? {
|
||||||
|
var transferID: Data?
|
||||||
|
var fileSize: UInt64?
|
||||||
|
var payloadHash: Data?
|
||||||
|
var token: Data?
|
||||||
|
var serviceName: String?
|
||||||
|
|
||||||
|
let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in
|
||||||
|
switch TLVType(rawValue: type) {
|
||||||
|
case .transferID where value.count == WifiBulkWire.transferIDLength:
|
||||||
|
transferID = value
|
||||||
|
case .fileSize where value.count == 8:
|
||||||
|
fileSize = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||||
|
case .payloadHash where value.count == WifiBulkWire.hashLength:
|
||||||
|
payloadHash = value
|
||||||
|
case .token where value.count == WifiBulkWire.tokenLength:
|
||||||
|
token = value
|
||||||
|
case .serviceName where !value.isEmpty && value.count <= WifiBulkWire.maxServiceNameBytes:
|
||||||
|
serviceName = String(data: value, encoding: .utf8)
|
||||||
|
default:
|
||||||
|
break // Unknown or malformed field: ignore; required checks below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard wellFormed,
|
||||||
|
let transferID, let fileSize, let payloadHash, let token, let serviceName else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return WifiBulkOffer(
|
||||||
|
transferID: transferID,
|
||||||
|
fileSize: fileSize,
|
||||||
|
payloadHash: payloadHash,
|
||||||
|
token: token,
|
||||||
|
serviceName: serviceName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receiver → sender: accept (with the receiver's token half) or decline.
|
||||||
|
struct WifiBulkResponse: Equatable {
|
||||||
|
let transferID: Data
|
||||||
|
let accepted: Bool
|
||||||
|
/// Receiver's random half of the channel secret; present iff accepted.
|
||||||
|
let token: Data?
|
||||||
|
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case transferID = 0x01
|
||||||
|
case accepted = 0x02
|
||||||
|
case token = 0x03
|
||||||
|
}
|
||||||
|
|
||||||
|
static func accept(transferID: Data, token: Data) -> WifiBulkResponse {
|
||||||
|
WifiBulkResponse(transferID: transferID, accepted: true, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decline(transferID: Data) -> WifiBulkResponse {
|
||||||
|
WifiBulkResponse(transferID: transferID, accepted: false, token: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
guard transferID.count == WifiBulkWire.transferIDLength else { return nil }
|
||||||
|
if accepted {
|
||||||
|
guard token?.count == WifiBulkWire.tokenLength else { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded = Data()
|
||||||
|
WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded)
|
||||||
|
WifiBulkWire.appendTLV(TLVType.accepted.rawValue, value: Data([accepted ? 1 : 0]), into: &encoded)
|
||||||
|
if accepted, let token {
|
||||||
|
WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded)
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> WifiBulkResponse? {
|
||||||
|
var transferID: Data?
|
||||||
|
var accepted: Bool?
|
||||||
|
var token: Data?
|
||||||
|
|
||||||
|
let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in
|
||||||
|
switch TLVType(rawValue: type) {
|
||||||
|
case .transferID where value.count == WifiBulkWire.transferIDLength:
|
||||||
|
transferID = value
|
||||||
|
case .accepted where value.count == 1:
|
||||||
|
accepted = value.first == 1
|
||||||
|
case .token where value.count == WifiBulkWire.tokenLength:
|
||||||
|
token = value
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard wellFormed, let transferID, let accepted else { return nil }
|
||||||
|
if accepted {
|
||||||
|
guard let token else { return nil }
|
||||||
|
return WifiBulkResponse(transferID: transferID, accepted: true, token: token)
|
||||||
|
}
|
||||||
|
return WifiBulkResponse(transferID: transferID, accepted: false, token: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkPolicy.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Pure eligibility decisions for the Wi-Fi bulk data plane. Anything that
|
||||||
|
/// fails these gates rides BLE fragmentation exactly as before — the BLE
|
||||||
|
/// fallback is the common case and must stay bulletproof.
|
||||||
|
enum WifiBulkPolicy {
|
||||||
|
struct SendCandidate {
|
||||||
|
let payloadBytes: Int
|
||||||
|
let peerCapabilities: PeerCapabilities
|
||||||
|
/// Direct BLE link (1 hop). Multi-hop recipients stay on BLE: AWDL
|
||||||
|
/// only reaches direct neighbors, and relays can't proxy the channel.
|
||||||
|
let isDirectlyConnected: Bool
|
||||||
|
/// The offer rides the Noise session, so one must already exist.
|
||||||
|
let hasEstablishedNoiseSession: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldOffer(
|
||||||
|
_ candidate: SendCandidate,
|
||||||
|
enabled: Bool = TransportConfig.wifiBulkEnabled,
|
||||||
|
minPayloadBytes: Int = TransportConfig.wifiBulkMinPayloadBytes,
|
||||||
|
maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
) -> Bool {
|
||||||
|
enabled
|
||||||
|
&& candidate.payloadBytes > minPayloadBytes
|
||||||
|
&& candidate.payloadBytes <= maxPayloadBytes
|
||||||
|
&& candidate.peerCapabilities.contains(.wifiBulk)
|
||||||
|
&& candidate.isDirectlyConnected
|
||||||
|
&& candidate.hasEstablishedNoiseSession
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receiver-side gate. Field lengths were validated at decode; this
|
||||||
|
/// enforces the size cap (from the local ceiling, not the sender's word)
|
||||||
|
/// and local enablement.
|
||||||
|
static func shouldAccept(
|
||||||
|
offer: WifiBulkOffer,
|
||||||
|
senderIsDirectlyConnected: Bool,
|
||||||
|
activeIncomingTransfers: Int,
|
||||||
|
enabled: Bool = TransportConfig.wifiBulkEnabled,
|
||||||
|
maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes,
|
||||||
|
maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming
|
||||||
|
) -> Bool {
|
||||||
|
enabled
|
||||||
|
&& senderIsDirectlyConnected
|
||||||
|
&& activeIncomingTransfers < maxConcurrentIncoming
|
||||||
|
&& offer.fileSize > 0
|
||||||
|
&& offer.fileSize <= UInt64(maxPayloadBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkTransferService.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
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/// Narrow environment for `WifiBulkTransferService`. All BLE-service queue
|
||||||
|
/// hops live inside the closures supplied by `BLEService`, keeping this
|
||||||
|
/// service independently testable.
|
||||||
|
struct WifiBulkTransferServiceEnvironment {
|
||||||
|
/// Sends a typed payload inside the established Noise session with the
|
||||||
|
/// peer. Returns false when no established session exists (the caller
|
||||||
|
/// falls back to BLE).
|
||||||
|
let sendNoisePayload: (_ typedPayload: Data, _ peerID: PeerID) -> Bool
|
||||||
|
/// Whether the peer is on a direct BLE link right now.
|
||||||
|
let isPeerConnected: (PeerID) -> Bool
|
||||||
|
/// Delivers a fully received, hash-verified payload (encoded
|
||||||
|
/// `BitchatFilePacket` TLV) into the normal incoming-file pipeline.
|
||||||
|
let deliverReceivedFile: (_ payload: Data, _ peerID: PeerID, _ payloadLimit: Int) -> Void
|
||||||
|
/// Progress bus hooks mirroring the BLE fragmentation path so the UI is
|
||||||
|
/// unchanged (chunks report as "fragments").
|
||||||
|
let progressStart: (_ transferId: String, _ totalChunks: Int) -> Void
|
||||||
|
let progressChunkSent: (_ transferId: String) -> Void
|
||||||
|
/// Silently forgets progress state ahead of a BLE fallback re-start.
|
||||||
|
let progressReset: (_ transferId: String) -> Void
|
||||||
|
/// Emits the cancelled event for user-cancelled transfers.
|
||||||
|
let progressCancel: (_ transferId: String) -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Knobs with test overrides; production values come from `TransportConfig`.
|
||||||
|
struct WifiBulkTransferServiceConfig {
|
||||||
|
var serviceType: String = TransportConfig.wifiBulkServiceType
|
||||||
|
var chunkBytes: Int = TransportConfig.wifiBulkChunkBytes
|
||||||
|
var offerTimeout: TimeInterval = TransportConfig.wifiBulkOfferTimeoutSeconds
|
||||||
|
var transferWindow: TimeInterval = TransportConfig.wifiBulkTransferWindowSeconds
|
||||||
|
var maxIncomingPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
var maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming
|
||||||
|
/// Tests disable peer-to-peer so loopback interfaces stay usable.
|
||||||
|
var usePeerToPeer: Bool = true
|
||||||
|
/// Tests disable Bonjour publication (unit-test hosts may lack mDNS access).
|
||||||
|
var publishBonjourService: Bool = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Orchestrates the Wi-Fi bulk data plane: BLE/Noise carries the offer and
|
||||||
|
/// response (control plane), then the payload crosses a per-transfer TCP
|
||||||
|
/// channel over AWDL, sealed with a key both sides derived from the
|
||||||
|
/// Noise-exchanged tokens. Any failure at any stage falls back to BLE
|
||||||
|
/// fragmentation exactly once; the receiver side fails silently and lets the
|
||||||
|
/// sender's timeout drive that fallback.
|
||||||
|
final class WifiBulkTransferService {
|
||||||
|
private let queue = DispatchQueue(label: "com.bitchat.wifi-bulk", qos: .userInitiated)
|
||||||
|
private let environment: WifiBulkTransferServiceEnvironment
|
||||||
|
private let config: WifiBulkTransferServiceConfig
|
||||||
|
|
||||||
|
private final class OutgoingTransfer {
|
||||||
|
let transferID: Data
|
||||||
|
let transferId: String
|
||||||
|
let peerID: PeerID
|
||||||
|
let token: Data
|
||||||
|
let fallback: () -> Void
|
||||||
|
var session: WifiBulkSenderSession?
|
||||||
|
var offerTimeout: DispatchWorkItem?
|
||||||
|
var windowTimeout: DispatchWorkItem?
|
||||||
|
var accepted = false
|
||||||
|
var finished = false
|
||||||
|
|
||||||
|
init(transferID: Data, transferId: String, peerID: PeerID, token: Data, fallback: @escaping () -> Void) {
|
||||||
|
self.transferID = transferID
|
||||||
|
self.transferId = transferId
|
||||||
|
self.peerID = peerID
|
||||||
|
self.token = token
|
||||||
|
self.fallback = fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class IncomingTransfer {
|
||||||
|
let offer: WifiBulkOffer
|
||||||
|
let peerID: PeerID
|
||||||
|
let key: SymmetricKey
|
||||||
|
var browser: NWBrowser?
|
||||||
|
var session: WifiBulkReceiverSession?
|
||||||
|
var windowTimeout: DispatchWorkItem?
|
||||||
|
|
||||||
|
init(offer: WifiBulkOffer, peerID: PeerID, key: SymmetricKey) {
|
||||||
|
self.offer = offer
|
||||||
|
self.peerID = peerID
|
||||||
|
self.key = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var outgoing: [Data: OutgoingTransfer] = [:]
|
||||||
|
private var incoming: [Data: IncomingTransfer] = [:]
|
||||||
|
|
||||||
|
init(
|
||||||
|
environment: WifiBulkTransferServiceEnvironment,
|
||||||
|
config: WifiBulkTransferServiceConfig = WifiBulkTransferServiceConfig()
|
||||||
|
) {
|
||||||
|
self.environment = environment
|
||||||
|
self.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sender
|
||||||
|
|
||||||
|
/// Offers `payload` over the Wi-Fi bulk channel. `fallbackToBLE` runs at
|
||||||
|
/// most once, on decline, timeout, or any mid-transfer error.
|
||||||
|
func sendFile(payload: Data, to peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?.beginOutgoing(payload: payload, peerID: peerID, transferId: transferId, fallbackToBLE: fallbackToBLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a decrypted `bulkTransferResponse` Noise payload.
|
||||||
|
func handleResponsePayload(_ payload: Data, from peerID: PeerID) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?.processResponse(payload, from: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-initiated cancel from the UI (mirrors BLE `cancelTransfer`).
|
||||||
|
func cancelTransfer(transferId: String) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
let transfer = self.outgoing.values.first(where: { $0.transferId == transferId }) else { return }
|
||||||
|
self.finishOutgoing(transfer, outcome: .cancelled, reason: "cancelled by user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tears down every transfer (service shutdown / emergency disconnect).
|
||||||
|
/// In-flight outgoing transfers do NOT fall back — the transport is going away.
|
||||||
|
func stop() {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
for transfer in self.outgoing.values {
|
||||||
|
transfer.finished = true
|
||||||
|
transfer.offerTimeout?.cancel()
|
||||||
|
transfer.windowTimeout?.cancel()
|
||||||
|
transfer.session?.cancel()
|
||||||
|
}
|
||||||
|
self.outgoing.removeAll()
|
||||||
|
for transfer in self.incoming.values {
|
||||||
|
self.tearDownIncomingResources(transfer)
|
||||||
|
}
|
||||||
|
self.incoming.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum OutgoingOutcome {
|
||||||
|
case completed
|
||||||
|
case fallback
|
||||||
|
case cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginOutgoing(payload: Data, peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) {
|
||||||
|
let transferID = Self.randomData(WifiBulkWire.transferIDLength)
|
||||||
|
let token = Self.randomData(WifiBulkWire.tokenLength)
|
||||||
|
// Random per-transfer instance name — never the nickname or peer ID.
|
||||||
|
let serviceName = Self.randomData(16).hexEncodedString()
|
||||||
|
|
||||||
|
let offer = WifiBulkOffer(
|
||||||
|
transferID: transferID,
|
||||||
|
fileSize: UInt64(payload.count),
|
||||||
|
payloadHash: Data(SHA256.hash(data: payload)),
|
||||||
|
token: token,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
let transfer = OutgoingTransfer(
|
||||||
|
transferID: transferID,
|
||||||
|
transferId: transferId,
|
||||||
|
peerID: peerID,
|
||||||
|
token: token,
|
||||||
|
fallback: fallbackToBLE
|
||||||
|
)
|
||||||
|
|
||||||
|
let session = WifiBulkSenderSession(
|
||||||
|
payload: payload,
|
||||||
|
transferID: transferID,
|
||||||
|
chunkBytes: config.chunkBytes,
|
||||||
|
parameters: makeParameters(),
|
||||||
|
service: config.publishBonjourService
|
||||||
|
? NWListener.Service(name: serviceName, type: config.serviceType)
|
||||||
|
: nil,
|
||||||
|
queue: queue
|
||||||
|
)
|
||||||
|
if let onListenerReady = _test_onListenerReady {
|
||||||
|
session.onListenerReady = { port in onListenerReady(transferID, port) }
|
||||||
|
}
|
||||||
|
session.onChunkSent = { [weak self, weak transfer] sent, total in
|
||||||
|
guard let self, let transfer, !transfer.finished else { return }
|
||||||
|
// Hold the final tick until the receipt confirms delivery, so the
|
||||||
|
// progress bus only emits .completed for verified transfers.
|
||||||
|
if sent < total {
|
||||||
|
self.environment.progressChunkSent(transfer.transferId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.onCompleted = { [weak self, weak transfer] in
|
||||||
|
guard let self, let transfer, !transfer.finished else { return }
|
||||||
|
self.environment.progressChunkSent(transfer.transferId)
|
||||||
|
self.finishOutgoing(transfer, outcome: .completed, reason: "receipt verified")
|
||||||
|
}
|
||||||
|
session.onFailed = { [weak self, weak transfer] reason in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
self.finishOutgoing(transfer, outcome: .fallback, reason: reason)
|
||||||
|
}
|
||||||
|
transfer.session = session
|
||||||
|
outgoing[transferID] = transfer
|
||||||
|
|
||||||
|
guard session.start() else {
|
||||||
|
finishOutgoing(transfer, outcome: .fallback, reason: "listener unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard environment.sendNoisePayload(
|
||||||
|
BLENoisePayloadFactory.typedPayload(.bulkTransferOffer, payload: offerData),
|
||||||
|
peerID
|
||||||
|
) else {
|
||||||
|
finishOutgoing(transfer, outcome: .fallback, reason: "no established noise session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
guard let self, let transfer, !transfer.accepted else { return }
|
||||||
|
self.finishOutgoing(transfer, outcome: .fallback, reason: "offer timed out")
|
||||||
|
}
|
||||||
|
transfer.offerTimeout = offerTimeout
|
||||||
|
queue.asyncAfter(deadline: .now() + config.offerTimeout, execute: offerTimeout)
|
||||||
|
|
||||||
|
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
self.finishOutgoing(transfer, outcome: .fallback, reason: "transfer window expired")
|
||||||
|
}
|
||||||
|
transfer.windowTimeout = windowTimeout
|
||||||
|
queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processResponse(_ payload: Data, from peerID: PeerID) {
|
||||||
|
guard let response = WifiBulkResponse.decode(payload),
|
||||||
|
let transfer = outgoing[response.transferID],
|
||||||
|
transfer.peerID.toShort() == peerID.toShort(),
|
||||||
|
!transfer.accepted, !transfer.finished else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard response.accepted, let receiverToken = response.token else {
|
||||||
|
finishOutgoing(transfer, outcome: .fallback, reason: "offer declined")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let key = WifiBulkCrypto.deriveKey(
|
||||||
|
senderToken: transfer.token,
|
||||||
|
receiverToken: receiverToken,
|
||||||
|
transferID: transfer.transferID
|
||||||
|
) else {
|
||||||
|
finishOutgoing(transfer, outcome: .fallback, reason: "key derivation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishOutgoing(_ transfer: OutgoingTransfer, outcome: OutgoingOutcome, reason: String) {
|
||||||
|
guard !transfer.finished else { return }
|
||||||
|
transfer.finished = true
|
||||||
|
transfer.offerTimeout?.cancel()
|
||||||
|
transfer.windowTimeout?.cancel()
|
||||||
|
transfer.session?.cancel()
|
||||||
|
outgoing.removeValue(forKey: transfer.transferID)
|
||||||
|
|
||||||
|
switch outcome {
|
||||||
|
case .completed:
|
||||||
|
SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… complete (\(reason))", category: .transport)
|
||||||
|
case .fallback:
|
||||||
|
SecureLogger.info("[WIFI] fallback→BLE \(transfer.transferId.prefix(8))… (\(reason))", category: .transport)
|
||||||
|
environment.progressReset(transfer.transferId)
|
||||||
|
transfer.fallback()
|
||||||
|
case .cancelled:
|
||||||
|
SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… cancelled", category: .transport)
|
||||||
|
environment.progressCancel(transfer.transferId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Receiver
|
||||||
|
|
||||||
|
/// Handles a decrypted `bulkTransferOffer` Noise payload.
|
||||||
|
func handleOfferPayload(_ payload: Data, from peerID: PeerID) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?.processOffer(payload, from: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processOffer(_ payload: Data, from peerID: PeerID) {
|
||||||
|
guard let offer = WifiBulkOffer.decode(payload) else { return }
|
||||||
|
guard incoming[offer.transferID] == nil else { return }
|
||||||
|
|
||||||
|
guard WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer,
|
||||||
|
senderIsDirectlyConnected: environment.isPeerConnected(peerID),
|
||||||
|
activeIncomingTransfers: incoming.count,
|
||||||
|
maxPayloadBytes: config.maxIncomingPayloadBytes,
|
||||||
|
maxConcurrentIncoming: config.maxConcurrentIncoming
|
||||||
|
) else {
|
||||||
|
decline(offer: offer, peerID: peerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = Self.randomData(WifiBulkWire.tokenLength)
|
||||||
|
guard let key = WifiBulkCrypto.deriveKey(
|
||||||
|
senderToken: offer.token,
|
||||||
|
receiverToken: token,
|
||||||
|
transferID: offer.transferID
|
||||||
|
),
|
||||||
|
let responseData = WifiBulkResponse.accept(transferID: offer.transferID, token: token).encode() else {
|
||||||
|
decline(offer: offer, peerID: peerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard environment.sendNoisePayload(
|
||||||
|
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
|
||||||
|
peerID
|
||||||
|
) else {
|
||||||
|
return // No session to answer on; the sender's timeout handles fallback.
|
||||||
|
}
|
||||||
|
|
||||||
|
let transfer = IncomingTransfer(offer: offer, peerID: peerID, key: key)
|
||||||
|
incoming[offer.transferID] = transfer
|
||||||
|
SecureLogger.info("[WIFI] offer accept \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .transport)
|
||||||
|
|
||||||
|
startBrowsing(for: transfer)
|
||||||
|
|
||||||
|
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
SecureLogger.info("[WIFI] incoming window expired", category: .transport)
|
||||||
|
self.tearDownIncoming(transfer)
|
||||||
|
}
|
||||||
|
transfer.windowTimeout = windowTimeout
|
||||||
|
queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decline(offer: WifiBulkOffer, peerID: PeerID) {
|
||||||
|
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),
|
||||||
|
peerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startBrowsing(for transfer: IncomingTransfer) {
|
||||||
|
let browser = NWBrowser(
|
||||||
|
for: .bonjour(type: config.serviceType, domain: nil),
|
||||||
|
using: makeParameters()
|
||||||
|
)
|
||||||
|
transfer.browser = browser
|
||||||
|
browser.browseResultsChangedHandler = { [weak self, weak transfer] results, _ in
|
||||||
|
guard let self, let transfer, transfer.session == nil else { return }
|
||||||
|
let match = results.first { result in
|
||||||
|
if case .service(let name, _, _, _) = result.endpoint {
|
||||||
|
return name == transfer.offer.serviceName
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard let match else { return }
|
||||||
|
self.connect(transfer, to: match.endpoint)
|
||||||
|
}
|
||||||
|
browser.stateUpdateHandler = { [weak self, weak transfer] state in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
if case .failed(let error) = state {
|
||||||
|
SecureLogger.warning("[WIFI] browser failed: \(error)", category: .transport)
|
||||||
|
self.tearDownIncoming(transfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
browser.start(queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test hook: connects an accepted incoming transfer straight to an
|
||||||
|
/// endpoint, standing in for Bonjour discovery on hosts without mDNS.
|
||||||
|
func _test_connectIncoming(transferID: Data, to endpoint: NWEndpoint) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self, let transfer = self.incoming[transferID], transfer.session == nil else { return }
|
||||||
|
self.connect(transfer, to: endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connect(_ transfer: IncomingTransfer, to endpoint: NWEndpoint) {
|
||||||
|
transfer.browser?.cancel()
|
||||||
|
transfer.browser = nil
|
||||||
|
|
||||||
|
guard let session = WifiBulkReceiverSession(
|
||||||
|
endpoint: endpoint,
|
||||||
|
parameters: makeParameters(),
|
||||||
|
key: transfer.key,
|
||||||
|
transferID: transfer.offer.transferID,
|
||||||
|
expectedSize: transfer.offer.fileSize,
|
||||||
|
expectedHash: transfer.offer.payloadHash,
|
||||||
|
sizeCap: config.maxIncomingPayloadBytes,
|
||||||
|
chunkBytes: config.chunkBytes,
|
||||||
|
queue: queue
|
||||||
|
) else {
|
||||||
|
tearDownIncoming(transfer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.onCompleted = { [weak self, weak transfer] payload in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
SecureLogger.info("[WIFI] transfer complete, received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))…", category: .transport)
|
||||||
|
self.environment.deliverReceivedFile(payload, transfer.peerID, self.config.maxIncomingPayloadBytes)
|
||||||
|
self.tearDownIncoming(transfer)
|
||||||
|
}
|
||||||
|
session.onFailed = { [weak self, weak transfer] reason in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
SecureLogger.info("[WIFI] incoming failed (\(reason)); sender falls back to BLE", category: .transport)
|
||||||
|
self.tearDownIncoming(transfer)
|
||||||
|
}
|
||||||
|
transfer.session = session
|
||||||
|
session.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tearDownIncoming(_ transfer: IncomingTransfer) {
|
||||||
|
tearDownIncomingResources(transfer)
|
||||||
|
incoming.removeValue(forKey: transfer.offer.transferID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tearDownIncomingResources(_ transfer: IncomingTransfer) {
|
||||||
|
transfer.windowTimeout?.cancel()
|
||||||
|
transfer.windowTimeout = nil
|
||||||
|
transfer.browser?.cancel()
|
||||||
|
transfer.browser = nil
|
||||||
|
transfer.session?.cancel()
|
||||||
|
transfer.session = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func makeParameters() -> NWParameters {
|
||||||
|
let parameters = NWParameters.tcp
|
||||||
|
if config.usePeerToPeer {
|
||||||
|
parameters.includePeerToPeer = true
|
||||||
|
// Keep the channel off infrastructure-independent radios we never
|
||||||
|
// want (cellular/wired); AWDL rides on the peer-to-peer flag.
|
||||||
|
parameters.prohibitedInterfaceTypes = [.cellular, .wiredEthernet, .loopback]
|
||||||
|
}
|
||||||
|
return parameters
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cryptographically secure random bytes (Swift's default RNG is CSPRNG-backed).
|
||||||
|
private static func randomData(_ count: Int) -> Data {
|
||||||
|
Data((0..<count).map { _ in UInt8.random(in: .min ... .max) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test observability
|
||||||
|
|
||||||
|
/// Test hook: reports each outgoing listener's bound port, standing in
|
||||||
|
/// for Bonjour resolution on hosts without mDNS. Set before `sendFile`.
|
||||||
|
var _test_onListenerReady: ((_ transferID: Data, _ port: UInt16) -> Void)?
|
||||||
|
|
||||||
|
var _test_activeOutgoingCount: Int {
|
||||||
|
queue.sync { outgoing.count }
|
||||||
|
}
|
||||||
|
|
||||||
|
var _test_activeIncomingCount: Int {
|
||||||
|
queue.sync { incoming.count }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,11 +73,22 @@ final class GossipSyncManager {
|
|||||||
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||||
var fragmentCapacity: Int = 600
|
var fragmentCapacity: Int = 600
|
||||||
var fileTransferCapacity: Int = 200
|
var fileTransferCapacity: Int = 200
|
||||||
|
var groupMessageCapacity: Int = 200
|
||||||
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
||||||
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
||||||
var messageSyncIntervalSeconds: TimeInterval = 15.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 responseRateLimitMaxResponses: Int = 8
|
||||||
var responseRateLimitWindowSeconds: TimeInterval = 30.0
|
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
|
private let myPeerID: PeerID
|
||||||
@@ -86,11 +97,22 @@ final class GossipSyncManager {
|
|||||||
private let archive: GossipMessageArchive?
|
private let archive: GossipMessageArchive?
|
||||||
weak var delegate: Delegate?
|
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
|
// Storage: broadcast packets by type, and latest announce per sender
|
||||||
private var messages = PacketStore()
|
private var messages = PacketStore()
|
||||||
private var fragments = PacketStore()
|
private var fragments = PacketStore()
|
||||||
private var fileTransfers = PacketStore()
|
private var fileTransfers = PacketStore()
|
||||||
|
private var groupMessages = PacketStore()
|
||||||
private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:]
|
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
|
private var archiveDirty = false
|
||||||
|
|
||||||
// Timer
|
// Timer
|
||||||
@@ -111,7 +133,13 @@ final class GossipSyncManager {
|
|||||||
)
|
)
|
||||||
var schedules: [SyncSchedule] = []
|
var schedules: [SyncSchedule] = []
|
||||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
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 {
|
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
|
||||||
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
|
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
|
||||||
@@ -119,6 +147,12 @@ final class GossipSyncManager {
|
|||||||
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
|
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
|
||||||
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
|
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
|
syncSchedules = schedules
|
||||||
|
|
||||||
if archive != nil {
|
if archive != nil {
|
||||||
@@ -149,12 +183,21 @@ final class GossipSyncManager {
|
|||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
var types: SyncTypeFlags = .publicMessages
|
var types: SyncTypeFlags = .publicMessages
|
||||||
|
if self.config.groupMessageCapacity > 0 {
|
||||||
|
types.formUnion(.groupMessage)
|
||||||
|
}
|
||||||
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
||||||
types.formUnion(.fragment)
|
types.formUnion(.fragment)
|
||||||
}
|
}
|
||||||
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
||||||
types.formUnion(.fileTransfer)
|
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)
|
self.sendRequestSync(to: peerID, types: types)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,9 +212,17 @@ final class GossipSyncManager {
|
|||||||
// messages get the long town-crier window; fragments, file transfers and
|
// messages get the long town-crier window; fragments, file transfers and
|
||||||
// announces keep the short one.
|
// announces keep the short one.
|
||||||
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||||
let maxAgeSeconds = packet.type == MessageType.message.rawValue
|
let maxAgeSeconds: TimeInterval
|
||||||
? config.publicMessageMaxAgeSeconds
|
switch packet.type {
|
||||||
: config.maxMessageAgeSeconds
|
// 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 nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
let ageThresholdMs = UInt64(maxAgeSeconds * 1000)
|
let ageThresholdMs = UInt64(maxAgeSeconds * 1000)
|
||||||
|
|
||||||
@@ -224,6 +275,36 @@ final class GossipSyncManager {
|
|||||||
guard isPacketFresh(packet) else { return }
|
guard isPacketFresh(packet) else { return }
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
|
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:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -232,7 +313,6 @@ final class GossipSyncManager {
|
|||||||
private func sendPeriodicSync(for types: SyncTypeFlags) {
|
private func sendPeriodicSync(for types: SyncTypeFlags) {
|
||||||
// Unicast sync to connected peers to allow RSR attribution
|
// Unicast sync to connected peers to allow RSR attribution
|
||||||
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
|
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
|
||||||
SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync)
|
|
||||||
for peerID in connectedPeers {
|
for peerID in connectedPeers {
|
||||||
sendRequestSync(to: peerID, types: types)
|
sendRequestSync(to: peerID, types: types)
|
||||||
}
|
}
|
||||||
@@ -257,11 +337,29 @@ final class GossipSyncManager {
|
|||||||
delegate?.sendPacket(signed)
|
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
|
// Register the request for RSR validation
|
||||||
requestSyncManager.registerRequest(to: peerID)
|
requestSyncManager.registerRequest(to: peerID)
|
||||||
|
|
||||||
let payload = buildGcsPayload(for: types)
|
let payload = buildGcsPayload(for: types, fragmentIdFilter: fragmentIdFilter)
|
||||||
var recipient = Data()
|
var recipient = Data()
|
||||||
var temp = peerID.id
|
var temp = peerID.id
|
||||||
while temp.count >= 2 && recipient.count < 8 {
|
while temp.count >= 2 && recipient.count < 8 {
|
||||||
@@ -292,7 +390,7 @@ final class GossipSyncManager {
|
|||||||
// A response can replay the whole store, so bound how often one peer
|
// A response can replay the whole store, so bound how often one peer
|
||||||
// can trigger a diff pass regardless of how fast it asks.
|
// can trigger a diff pass regardless of how fast it asks.
|
||||||
guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else {
|
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
|
return
|
||||||
}
|
}
|
||||||
let requestedTypes = (request.types ?? .publicMessages)
|
let requestedTypes = (request.types ?? .publicMessages)
|
||||||
@@ -300,6 +398,9 @@ final class GossipSyncManager {
|
|||||||
// older packets are outside the filter but not missing, and without
|
// older packets are outside the filter but not missing, and without
|
||||||
// the cursor they would be re-sent every round.
|
// the cursor they would be re-sent every round.
|
||||||
let since = request.sinceTimestamp
|
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
|
// Decode GCS into sorted set and prepare membership checker
|
||||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||||
func mightContain(_ id: Data) -> Bool {
|
func mightContain(_ id: Data) -> Bool {
|
||||||
@@ -319,6 +420,7 @@ final class GossipSyncManager {
|
|||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
toSend.isRSR = true // Mark as solicited response
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
servedCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,20 +435,32 @@ final class GossipSyncManager {
|
|||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
toSend.isRSR = true // Mark as solicited response
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
servedCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if requestedTypes.contains(.fragment) {
|
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)
|
let frags = fragments.allPackets(isFresh: isPacketFresh)
|
||||||
for pkt in frags {
|
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)
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
toSend.isRSR = true // Mark as solicited response
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
servedCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,13 +475,69 @@ final class GossipSyncManager {
|
|||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
toSend.isRSR = true // Mark as solicited response
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
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
|
// 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] = []
|
var candidates: [BitchatPacket] = []
|
||||||
if types.contains(.announce) {
|
if types.contains(.announce) {
|
||||||
for (_, pkt) in latestAnnouncementByPeer where isPacketFresh(pkt) {
|
for (_, pkt) in latestAnnouncementByPeer where isPacketFresh(pkt) {
|
||||||
@@ -383,9 +553,20 @@ final class GossipSyncManager {
|
|||||||
if types.contains(.fileTransfer) {
|
if types.contains(.fileTransfer) {
|
||||||
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
|
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 {
|
if candidates.isEmpty {
|
||||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
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()
|
return req.encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,12 +580,16 @@ final class GossipSyncManager {
|
|||||||
cap = max(1, config.fragmentCapacity)
|
cap = max(1, config.fragmentCapacity)
|
||||||
} else if types == .fileTransfer {
|
} else if types == .fileTransfer {
|
||||||
cap = max(1, config.fileTransferCapacity)
|
cap = max(1, config.fileTransferCapacity)
|
||||||
|
} else if types == .prekeyBundle {
|
||||||
|
cap = max(1, config.prekeyBundleCapacity)
|
||||||
|
} else if types == .board {
|
||||||
|
cap = max(1, config.boardCapacity)
|
||||||
} else {
|
} else {
|
||||||
cap = max(1, config.seenCapacity)
|
cap = max(1, config.seenCapacity)
|
||||||
}
|
}
|
||||||
let takeN = min(candidates.count, min(nMax, cap))
|
let takeN = min(candidates.count, min(nMax, cap))
|
||||||
if takeN <= 0 {
|
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()
|
return req.encode()
|
||||||
}
|
}
|
||||||
let included = Array(candidates.prefix(takeN))
|
let included = Array(candidates.prefix(takeN))
|
||||||
@@ -422,7 +607,7 @@ final class GossipSyncManager {
|
|||||||
let sinceTimestamp: UInt64? = (covered < candidates.count && covered > 0)
|
let sinceTimestamp: UInt64? = (covered < candidates.count && covered > 0)
|
||||||
? included[covered - 1].timestamp
|
? included[covered - 1].timestamp
|
||||||
: nil
|
: 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()
|
return req.encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +625,10 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
fragments.removeExpired(isFresh: isPacketFresh)
|
fragments.removeExpired(isFresh: isPacketFresh)
|
||||||
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||||
|
latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
|
||||||
|
isPacketFresh(pair.packet)
|
||||||
|
}
|
||||||
|
groupMessages.removeExpired(isFresh: isPacketFresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Archive (public message persistence)
|
// MARK: - Archive (public message persistence)
|
||||||
@@ -488,13 +677,23 @@ final class GossipSyncManager {
|
|||||||
// One request per due schedule rather than a union filter: each type
|
// 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
|
// group gets the full GCS capacity and its own since-cursor, so heavy
|
||||||
// fragment traffic can't crowd messages out of the filter.
|
// fragment traffic can't crowd messages out of the filter.
|
||||||
|
var dueLabels: [String] = []
|
||||||
for index in syncSchedules.indices {
|
for index in syncSchedules.indices {
|
||||||
guard syncSchedules[index].interval > 0 else { continue }
|
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 {
|
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||||
syncSchedules[index].lastSent = now
|
syncSchedules[index].lastSent = now
|
||||||
sendPeriodicSync(for: syncSchedules[index].types)
|
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) {
|
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||||
@@ -527,6 +726,8 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func removeState(for peerID: PeerID) {
|
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)
|
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
|
||||||
let messageCountBefore = messages.packets.count
|
let messageCountBefore = messages.packets.count
|
||||||
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
@@ -535,6 +736,7 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
|
groupMessages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,6 +754,12 @@ extension GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _hasPrekeyBundle(for peerID: PeerID) -> Bool {
|
||||||
|
queue.sync {
|
||||||
|
latestPrekeyBundleByPeer[peerID] != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func _messageCount(for peerID: PeerID) -> Int {
|
func _messageCount(for peerID: PeerID) -> Int {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
|
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
|
||||||
|
|||||||
@@ -36,9 +36,29 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
case .fragment: return 5
|
case .fragment: return 5
|
||||||
case .requestSync: return 6
|
case .requestSync: return 6
|
||||||
case .fileTransfer: return 7
|
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
|
// Courier envelopes are directed deposits between trusted peers and
|
||||||
// must never spread via gossip sync.
|
// must never spread via gossip sync.
|
||||||
case .courierEnvelope: return nil
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +72,12 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
case 5: return .fragment
|
case 5: return .fragment
|
||||||
case 6: return .requestSync
|
case 6: return .requestSync
|
||||||
case 7: return .fileTransfer
|
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:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -61,6 +87,9 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
static let message = SyncTypeFlags(messageTypes: [.message])
|
static let message = SyncTypeFlags(messageTypes: [.message])
|
||||||
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
||||||
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
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])
|
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
|
||||||
|
|
||||||
@@ -121,4 +150,19 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
}
|
}
|
||||||
return SyncTypeFlags(rawValue: raw)
|
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:
|
case .poweredOff:
|
||||||
ChatBluetoothAlertUpdate(
|
ChatBluetoothAlertUpdate(
|
||||||
isPresented: true,
|
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:
|
case .unauthorized:
|
||||||
ChatBluetoothAlertUpdate(
|
ChatBluetoothAlertUpdate(
|
||||||
isPresented: true,
|
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:
|
case .unsupported:
|
||||||
ChatBluetoothAlertUpdate(
|
ChatBluetoothAlertUpdate(
|
||||||
isPresented: true,
|
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:
|
case .poweredOn:
|
||||||
ChatBluetoothAlertUpdate(isPresented: false, message: "")
|
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) {
|
func markPrivateMessagesAsRead(from peerID: PeerID) {
|
||||||
context.markChatAsRead(from: 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,
|
if peerID.isGeoDM,
|
||||||
let recipientHex = context.nostrKeyMapping[peerID],
|
let recipientHex = context.nostrKeyMapping[peerID],
|
||||||
case .location(let channel) = context.activeChannel,
|
case .location(let channel) = context.activeChannel,
|
||||||
@@ -324,7 +331,7 @@ private extension ChatLifecycleCoordinator {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||||
content: message,
|
content: message,
|
||||||
geohash: channel.geohash,
|
geohash: channel.geohash,
|
||||||
senderIdentity: identity,
|
senderIdentity: identity,
|
||||||
@@ -343,7 +350,7 @@ private extension ChatLifecycleCoordinator {
|
|||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
|
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
|
||||||
context.addSystemMessage(
|
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)
|
try? FileManager.default.removeItem(at: url)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
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 {
|
} catch {
|
||||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
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 {
|
final class ChatOutgoingCoordinator {
|
||||||
private unowned let context: any ChatOutgoingContext
|
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) {
|
init(context: any ChatOutgoingContext) {
|
||||||
self.context = context
|
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) {
|
func sendMessage(_ content: String) {
|
||||||
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||||
|
|
||||||
@@ -92,120 +104,117 @@ final class ChatOutgoingCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mentions = context.parseMentions(from: content)
|
let mentions = context.parseMentions(from: content)
|
||||||
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
|
|
||||||
guard let preparedMessage else { return }
|
|
||||||
|
|
||||||
appendLocalEcho(preparedMessage.message)
|
switch context.activeChannel {
|
||||||
routePublicMessage(
|
case .mesh:
|
||||||
originalContent: content,
|
sendMeshPublicMessage(originalContent: content, trimmed: trimmed, mentions: mentions)
|
||||||
mentions: mentions,
|
case .location(let channel):
|
||||||
geoContext: preparedMessage.geoContext,
|
sendGeohashPublicMessage(trimmed, mentions: mentions, channel: channel)
|
||||||
messageID: preparedMessage.message.id,
|
}
|
||||||
timestamp: preparedMessage.message.timestamp
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ChatOutgoingCoordinator {
|
private extension ChatOutgoingCoordinator {
|
||||||
func preparePublicMessage(
|
func sendMeshPublicMessage(originalContent: String, trimmed: String, mentions: [String]) {
|
||||||
content: String,
|
let message = BitchatMessage(
|
||||||
trimmed: String,
|
sender: context.nickname,
|
||||||
mentions: [String]
|
content: trimmed,
|
||||||
) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? {
|
timestamp: Date(),
|
||||||
var geoContext: ChatViewModel.GeoOutgoingContext?
|
isRelay: false,
|
||||||
var displaySender = context.nickname
|
senderPeerID: context.myPeerID,
|
||||||
var localSenderPeerID = context.myPeerID
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
var messageID: String?
|
)
|
||||||
var messageTimestamp = Date()
|
|
||||||
|
|
||||||
switch context.activeChannel {
|
appendLocalEcho(message, to: .mesh)
|
||||||
case .mesh:
|
context.recordPublicActivity(forChannelKey: "mesh")
|
||||||
break
|
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 {
|
do {
|
||||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||||
let suffix = String(identity.publicKeyHex.suffix(4))
|
|
||||||
displaySender = context.nickname + "#" + suffix
|
|
||||||
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
|
|
||||||
|
|
||||||
let teleported = context.isTeleported
|
|
||||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
geohash: channel.geohash,
|
geohash: channel.geohash,
|
||||||
senderIdentity: identity,
|
senderIdentity: identity,
|
||||||
nickname: context.nickname,
|
nickname: nickname,
|
||||||
teleported: teleported
|
|
||||||
)
|
|
||||||
|
|
||||||
messageID = event.id
|
|
||||||
messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
||||||
geoContext = (
|
|
||||||
channel: channel,
|
|
||||||
event: event,
|
|
||||||
identity: identity,
|
|
||||||
teleported: teleported
|
teleported: teleported
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
|
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
|
||||||
context.addSystemMessage(
|
context?.addSystemMessage(
|
||||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
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) {
|
func appendLocalEcho(_ message: BitchatMessage, to conversationID: ConversationID) {
|
||||||
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
|
context.appendPublicMessage(message, to: conversationID)
|
||||||
|
|
||||||
let contentKey = context.normalizedContentKey(message.content)
|
let contentKey = context.normalizedContentKey(message.content)
|
||||||
context.recordContentKey(contentKey, timestamp: message.timestamp)
|
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) {
|
func startPrivateChat(with peerID: PeerID) {
|
||||||
guard peerID != context.myPeerID else { return }
|
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"
|
let peerNickname = context.peerNickname(for: peerID) ?? "unknown"
|
||||||
|
|
||||||
if context.unifiedIsBlocked(peerID) {
|
if context.unifiedIsBlocked(peerID) {
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
let nickname = context.peerNickname(for: peerID) ?? "user"
|
let nickname = context.peerNickname(for: peerID) ?? "user"
|
||||||
context.addSystemMessage(
|
context.addSystemMessage(
|
||||||
String(
|
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,
|
locale: .current,
|
||||||
nickname
|
nickname
|
||||||
)
|
)
|
||||||
@@ -298,7 +298,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
} else {
|
} else {
|
||||||
context.setPrivateDeliveryStatus(
|
context.setPrivateDeliveryStatus(
|
||||||
.failed(
|
.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,
|
forMessageID: messageID,
|
||||||
peerID: peerID
|
peerID: peerID
|
||||||
@@ -306,7 +306,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
let name = recipientNickname ?? "user"
|
let name = recipientNickname ?? "user"
|
||||||
context.addSystemMessage(
|
context.addSystemMessage(
|
||||||
String(
|
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,
|
locale: .current,
|
||||||
name
|
name
|
||||||
)
|
)
|
||||||
@@ -317,7 +317,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
func sendGeohashDM(_ content: String, to peerID: PeerID) {
|
func sendGeohashDM(_ content: String, to peerID: PeerID) {
|
||||||
guard case .location(let channel) = context.activeChannel else {
|
guard case .location(let channel) = context.activeChannel else {
|
||||||
context.addSystemMessage(
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -341,7 +341,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
guard let recipientHex = context.nostrKeyMapping[peerID] else {
|
guard let recipientHex = context.nostrKeyMapping[peerID] else {
|
||||||
context.setPrivateDeliveryStatus(
|
context.setPrivateDeliveryStatus(
|
||||||
.failed(
|
.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,
|
forMessageID: messageID,
|
||||||
peerID: peerID
|
peerID: peerID
|
||||||
@@ -352,13 +352,13 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
|
if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
|
||||||
context.setPrivateDeliveryStatus(
|
context.setPrivateDeliveryStatus(
|
||||||
.failed(
|
.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,
|
forMessageID: messageID,
|
||||||
peerID: peerID
|
peerID: peerID
|
||||||
)
|
)
|
||||||
context.addSystemMessage(
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -368,7 +368,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
if recipientHex.lowercased() == identity.publicKeyHex.lowercased() {
|
if recipientHex.lowercased() == identity.publicKeyHex.lowercased() {
|
||||||
context.setPrivateDeliveryStatus(
|
context.setPrivateDeliveryStatus(
|
||||||
.failed(
|
.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,
|
forMessageID: messageID,
|
||||||
peerID: peerID
|
peerID: peerID
|
||||||
@@ -390,7 +390,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
} catch {
|
} catch {
|
||||||
context.setPrivateDeliveryStatus(
|
context.setPrivateDeliveryStatus(
|
||||||
.failed(
|
.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,
|
forMessageID: messageID,
|
||||||
peerID: peerID
|
peerID: peerID
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ protocol ChatPublicConversationContext: AnyObject {
|
|||||||
// MARK: Inbound public message processing
|
// MARK: Inbound public message processing
|
||||||
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage
|
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage
|
||||||
func isMessageBlocked(_ message: BitchatMessage) -> Bool
|
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
|
/// Buffers a visible-channel message for the batched (~80 ms) pipeline
|
||||||
/// flush, which commits it to `conversationID` in the store.
|
/// flush, which commits it to `conversationID` in the store.
|
||||||
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID)
|
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID)
|
||||||
@@ -137,8 +139,8 @@ extension ChatViewModel: ChatPublicConversationContext {
|
|||||||
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
|
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool {
|
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool {
|
||||||
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey)
|
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey, powBits: powBits)
|
||||||
}
|
}
|
||||||
|
|
||||||
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
|
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
|
||||||
@@ -290,7 +292,17 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
func clearCurrentPublicTimeline() {
|
func clearCurrentPublicTimeline() {
|
||||||
context.clearPublicConversation(ConversationID(channelID: context.activeChannel))
|
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) {
|
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 {
|
do {
|
||||||
let base = try FileManager.default.url(
|
let base = try FileManager.default.url(
|
||||||
for: .applicationSupportDirectory,
|
for: .applicationSupportDirectory,
|
||||||
@@ -367,7 +379,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
do {
|
do {
|
||||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||||
content: content,
|
content: content,
|
||||||
geohash: channel.geohash,
|
geohash: channel.geohash,
|
||||||
senderIdentity: identity,
|
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)
|
let finalMessage = context.processActionMessage(message)
|
||||||
if context.isMessageBlocked(finalMessage) { return }
|
if context.isMessageBlocked(finalMessage) { return }
|
||||||
|
|
||||||
@@ -405,7 +421,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
if shouldRateLimit {
|
if shouldRateLimit {
|
||||||
let senderKey = normalizedSenderKey(for: finalMessage)
|
let senderKey = normalizedSenderKey(for: finalMessage)
|
||||||
let contentKey = context.normalizedContentKey(finalMessage.content)
|
let contentKey = context.normalizedContentKey(finalMessage.content)
|
||||||
if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey) {
|
if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey, powBits: powBits) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ protocol ChatTransportEventContext: AnyObject {
|
|||||||
// MARK: Verification payloads
|
// MARK: Verification payloads
|
||||||
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data)
|
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data)
|
||||||
func handleVerifyResponsePayload(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 {
|
extension ChatViewModel: ChatTransportEventContext {
|
||||||
@@ -129,6 +134,18 @@ extension ChatViewModel: ChatTransportEventContext {
|
|||||||
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
|
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
|
||||||
verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload)
|
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 {
|
final class ChatTransportEventCoordinator {
|
||||||
@@ -371,6 +388,20 @@ private extension ChatTransportEventCoordinator {
|
|||||||
|
|
||||||
case .verifyResponse:
|
case .verifyResponse:
|
||||||
context.handleVerifyResponsePayload(from: peerID, payload: payload)
|
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.
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
@MainActor
|
@MainActor
|
||||||
var canSendMediaInCurrentContext: Bool {
|
var canSendMediaInCurrentContext: Bool {
|
||||||
if let peer = selectedPrivateChatPeer {
|
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 {
|
switch activeChannel {
|
||||||
case .mesh: return true
|
case .mesh: return true
|
||||||
@@ -177,6 +179,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
||||||
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
||||||
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
||||||
|
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
||||||
|
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
||||||
|
|
||||||
// Computed properties for compatibility
|
// Computed properties for compatibility
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -305,12 +309,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
var nostrRelayManager: NostrRelayManager?
|
var nostrRelayManager: NostrRelayManager?
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
let keychain: KeychainManagerProtocol
|
let keychain: KeychainManagerProtocol
|
||||||
|
/// Private group membership: keys in the keychain, metadata on disk.
|
||||||
|
let groupStore: GroupStore
|
||||||
private let nicknameKey = "bitchat.nickname"
|
private let nicknameKey = "bitchat.nickname"
|
||||||
// Location channel state (macOS supports manual geohash selection)
|
// Location channel state (macOS supports manual geohash selection)
|
||||||
var activeChannel: ChannelID {
|
var activeChannel: ChannelID {
|
||||||
get { conversations.activeChannel }
|
get { conversations.activeChannel }
|
||||||
set {
|
set {
|
||||||
guard conversations.activeChannel != newValue else { return }
|
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)
|
conversations.setActiveChannel(newValue)
|
||||||
visibleMessagesCache = nil
|
visibleMessagesCache = nil
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
@@ -809,6 +818,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
self.groupStore = GroupStore(keychain: keychain)
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
self.conversations = conversations
|
self.conversations = conversations
|
||||||
@@ -1205,6 +1215,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
GossipMessageArchive.wipeDefault()
|
GossipMessageArchive.wipeDefault()
|
||||||
StoreAndForwardMetrics.shared.reset()
|
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
|
// Identity manager has cleared persisted identity data above
|
||||||
|
|
||||||
// Clear autocomplete state
|
// Clear autocomplete state
|
||||||
@@ -1273,6 +1295,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
// Delete ALL media files (incoming and outgoing) in background
|
// Delete ALL media files (incoming and outgoing) in background
|
||||||
Task.detached(priority: .utility) {
|
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 {
|
do {
|
||||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||||
@@ -1485,6 +1512,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
func setupNoiseCallbacks() {
|
func setupNoiseCallbacks() {
|
||||||
verificationCoordinator.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
|
// 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
|
// MARK: - Message Reception
|
||||||
|
|
||||||
@MainActor
|
@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
|
// MARK: - QR Verification API
|
||||||
@MainActor
|
@MainActor
|
||||||
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
||||||
@@ -1677,12 +1745,27 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
publicConversationCoordinator.sendPublicRaw(content)
|
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
|
/// Handle incoming public message
|
||||||
@MainActor
|
@MainActor
|
||||||
func handlePublicMessage(_ message: BitchatMessage) {
|
func handlePublicMessage(_ message: BitchatMessage) {
|
||||||
publicConversationCoordinator.handlePublicMessage(message)
|
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
|
/// Check for mentions and send notifications
|
||||||
func checkForMentions(_ message: BitchatMessage) {
|
func checkForMentions(_ message: BitchatMessage) {
|
||||||
publicConversationCoordinator.checkForMentions(message)
|
publicConversationCoordinator.checkForMentions(message)
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ final class ChatViewModelBootstrapper {
|
|||||||
configureNoiseCallbacks()
|
configureNoiseCallbacks()
|
||||||
bindTransferProgress()
|
bindTransferProgress()
|
||||||
configureGeoChannels()
|
configureGeoChannels()
|
||||||
|
configureGateway()
|
||||||
bindTeleportState()
|
bindTeleportState()
|
||||||
requestNotifications()
|
requestNotifications()
|
||||||
registerObservers()
|
registerObservers()
|
||||||
@@ -106,7 +107,7 @@ private extension ChatViewModelBootstrapper {
|
|||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
viewModel.conversations.setDeliveryStatus(
|
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
|
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() {
|
func bindTeleportState() {
|
||||||
viewModel.locationManager.$teleported
|
viewModel.locationManager.$teleported
|
||||||
.receive(on: DispatchQueue.main)
|
.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
|
@MainActor
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
|
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)
|
privateConversationCoordinator.sendPrivateMessage(content, to: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ extension ChatViewModel {
|
|||||||
self.torStatusAnnounced = true
|
self.torStatusAnnounced = true
|
||||||
// Post only in geohash channels (queue if not active)
|
// Post only in geohash channels (queue if not active)
|
||||||
self.addGeohashOnlySystemMessage(
|
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
|
self.torRestartPending = true
|
||||||
// Post only in geohash channels (queue if not active)
|
// Post only in geohash channels (queue if not active)
|
||||||
self.addGeohashOnlySystemMessage(
|
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 {
|
if self.torRestartPending {
|
||||||
// Post only in geohash channels (queue if not active)
|
// Post only in geohash channels (queue if not active)
|
||||||
self.addGeohashOnlySystemMessage(
|
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
|
self.torRestartPending = false
|
||||||
} else if TorManager.shared.torEnforced && !self.torInitialReadyAnnounced {
|
} else if TorManager.shared.torEnforced && !self.torInitialReadyAnnounced {
|
||||||
// Initial start completed
|
// Initial start completed
|
||||||
self.addGeohashOnlySystemMessage(
|
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
|
self.torInitialReadyAnnounced = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ final class GeohashSubscriptionManager {
|
|||||||
private weak var context: (any GeohashSubscriptionContext)?
|
private weak var context: (any GeohashSubscriptionContext)?
|
||||||
private let inbound: NostrInboundPipeline
|
private let inbound: NostrInboundPipeline
|
||||||
private let presence: GeoPresenceTracker
|
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) {
|
init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) {
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -145,6 +148,9 @@ final class GeohashSubscriptionManager {
|
|||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.inbound.subscribeNostrEvent(event)
|
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
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.inbound.handleNostrEvent(event)
|
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)
|
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.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||||
context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex))
|
context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex))
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
|
|||||||
@@ -48,15 +48,25 @@ struct MessageRateLimiter {
|
|||||||
self.contentRefill = contentRefillPerSec
|
self.contentRefill = contentRefillPerSec
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
|
/// - Parameter powBits: validated NIP-13 difficulty of the event
|
||||||
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
/// (`NostrPoW.validatedDifficulty`; 0 for mesh or no-PoW events).
|
||||||
capacity: senderCapacity,
|
/// At or above `NostrPoW.rateLimitBypassBits` the per-sender bucket is
|
||||||
tokens: senderCapacity,
|
/// skipped entirely — each such message paid for itself with work — but
|
||||||
refillPerSec: senderRefill,
|
/// the per-content flood bucket still applies.
|
||||||
lastRefill: now
|
mutating func allow(senderKey: String, contentKey: String, powBits: Int = 0, now: Date = Date()) -> Bool {
|
||||||
)
|
let senderAllowed: Bool
|
||||||
let senderAllowed = senderBucket.allow(now: now)
|
if powBits >= NostrPoW.rateLimitBypassBits {
|
||||||
senderBuckets[senderKey] = senderBucket
|
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(
|
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
||||||
capacity: contentCapacity,
|
capacity: contentCapacity,
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ protocol NostrInboundPipelineContext: AnyObject {
|
|||||||
func recordGeoParticipant(pubkeyHex: String)
|
func recordGeoParticipant(pubkeyHex: String)
|
||||||
|
|
||||||
// MARK: Inbound public messages
|
// 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 checkForMentions(_ message: BitchatMessage)
|
||||||
func sendHapticFeedback(for message: BitchatMessage)
|
func sendHapticFeedback(for message: BitchatMessage)
|
||||||
func parseMentions(from content: String) -> [String]
|
func parseMentions(from content: String) -> [String]
|
||||||
@@ -152,6 +155,7 @@ final class NostrInboundPipeline {
|
|||||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
let timestamp = min(rawTs, Date())
|
let timestamp = min(rawTs, Date())
|
||||||
let mentions = context.parseMentions(from: content)
|
let mentions = context.parseMentions(from: content)
|
||||||
|
let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags)
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
id: event.id,
|
id: event.id,
|
||||||
sender: senderName,
|
sender: senderName,
|
||||||
@@ -165,7 +169,7 @@ final class NostrInboundPipeline {
|
|||||||
Task { @MainActor [weak context] in
|
Task { @MainActor [weak context] in
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
||||||
context.handlePublicMessage(message)
|
context.handlePublicMessage(message, powBits: powBits)
|
||||||
if !isBlocked {
|
if !isBlocked {
|
||||||
context.checkForMentions(message)
|
context.checkForMentions(message)
|
||||||
context.sendHapticFeedback(for: message)
|
context.sendHapticFeedback(for: message)
|
||||||
@@ -187,10 +191,12 @@ final class NostrInboundPipeline {
|
|||||||
guard event.isValidSignature() else { return }
|
guard event.isValidSignature() else { return }
|
||||||
context.recordProcessedNostrEvent(event.id)
|
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.
|
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
||||||
geoEventLogCount += 1
|
geoEventLogCount += 1
|
||||||
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
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) {
|
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||||
@@ -255,7 +261,7 @@ final class NostrInboundPipeline {
|
|||||||
|
|
||||||
Task { @MainActor [weak context] in
|
Task { @MainActor [weak context] in
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
context.handlePublicMessage(message)
|
context.handlePublicMessage(message, powBits: powBits)
|
||||||
context.checkForMentions(message)
|
context.checkForMentions(message)
|
||||||
context.sendHapticFeedback(for: message)
|
context.sendHapticFeedback(for: message)
|
||||||
}
|
}
|
||||||
@@ -297,7 +303,11 @@ final class NostrInboundPipeline {
|
|||||||
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
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:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +359,11 @@ final class NostrInboundPipeline {
|
|||||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
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:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,7 +442,12 @@ final class NostrInboundPipeline {
|
|||||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
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:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if DEBUG
|
||||||
|
import BitLogger
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
struct AppInfoView: View {
|
struct AppInfoView: View {
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@ThemedPalette private var palette
|
@ThemedPalette private var palette
|
||||||
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
|
@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 {
|
private var selectedTheme: AppTheme {
|
||||||
AppTheme(rawValue: appThemeRawValue) ?? .matrix
|
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 {
|
enum Privacy {
|
||||||
static let title: LocalizedStringKey = "app_info.privacy.title"
|
static let title: LocalizedStringKey = "app_info.privacy.title"
|
||||||
static let noTracking = AppInfoFeatureInfo(
|
static let noTracking = AppInfoFeatureInfo(
|
||||||
@@ -114,7 +144,7 @@ struct AppInfoView: View {
|
|||||||
// Custom header for macOS
|
// Custom header for macOS
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("app_info.done") {
|
Button(String(localized: "app_info.done", defaultValue: "DONE")) {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -129,6 +159,11 @@ struct AppInfoView: View {
|
|||||||
.themedSheetBackground()
|
.themedSheetBackground()
|
||||||
}
|
}
|
||||||
.frame(width: 600, height: 700)
|
.frame(width: 600, height: 700)
|
||||||
|
.sheet(isPresented: $showTopology) {
|
||||||
|
if let topologyProvider {
|
||||||
|
MeshTopologyView(provider: topologyProvider)
|
||||||
|
}
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
NavigationView {
|
NavigationView {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -143,6 +178,11 @@ struct AppInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showTopology) {
|
||||||
|
if let topologyProvider {
|
||||||
|
MeshTopologyView(provider: topologyProvider)
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +239,27 @@ struct AppInfoView: View {
|
|||||||
.foregroundColor(textColor)
|
.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
|
// Features
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
SectionHeader(Strings.Features.title)
|
SectionHeader(Strings.Features.title)
|
||||||
@@ -248,11 +309,122 @@ struct AppInfoView: View {
|
|||||||
|
|
||||||
FeatureRow(info: Strings.Privacy.panic)
|
FeatureRow(info: Strings.Privacy.panic)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
debugSection
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.padding()
|
.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 {
|
struct AppInfoFeatureInfo {
|
||||||
let icon: String
|
let icon: String
|
||||||
let title: LocalizedStringKey
|
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 {
|
var bitchatDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .sending:
|
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:
|
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:
|
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")
|
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, _):
|
case .delivered(let nickname, _):
|
||||||
return String(
|
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,
|
locale: .current,
|
||||||
nickname
|
nickname
|
||||||
)
|
)
|
||||||
case .read(let nickname, _):
|
case .read(let nickname, _):
|
||||||
return String(
|
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,
|
locale: .current,
|
||||||
nickname
|
nickname
|
||||||
)
|
)
|
||||||
case .failed(let reason):
|
case .failed(let reason):
|
||||||
return String(
|
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,
|
locale: .current,
|
||||||
reason
|
reason
|
||||||
)
|
)
|
||||||
case .partiallyDelivered(let reached, let total):
|
case .partiallyDelivered(let reached, let total):
|
||||||
return String(
|
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,
|
locale: .current,
|
||||||
reached,
|
reached,
|
||||||
total
|
total
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct PaymentChipView: View {
|
struct PaymentChipView: View {
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@@ -36,6 +41,25 @@ struct PaymentChipView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
var emoji: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .cashu: "🥜"
|
case .cashu: "🥜"
|
||||||
@@ -46,14 +70,27 @@ struct PaymentChipView: View {
|
|||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .cashu:
|
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:
|
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
|
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 fgColor: Color { palette.primary }
|
||||||
private var bgColor: Color {
|
private var bgColor: Color {
|
||||||
@@ -61,18 +98,34 @@ struct PaymentChipView: View {
|
|||||||
}
|
}
|
||||||
private var border: Color { fgColor.opacity(0.25) }
|
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 {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
#if os(iOS)
|
primaryAction()
|
||||||
if let url = paymentType.url { openURL(url) }
|
|
||||||
#else
|
|
||||||
if let url = paymentType.url { NSWorkspace.shared.open(url) }
|
|
||||||
#endif
|
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(paymentType.emoji)
|
Text(paymentType.emoji)
|
||||||
Text(paymentType.label)
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
.bitchatFont(size: 12, weight: .semibold)
|
Text(primaryLabel)
|
||||||
|
.bitchatFont(size: 12, weight: .semibold)
|
||||||
|
if let memoLabel {
|
||||||
|
Text(memoLabel)
|
||||||
|
.bitchatFont(size: 10)
|
||||||
|
.opacity(0.7)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -87,6 +140,95 @@ struct PaymentChipView: View {
|
|||||||
.foregroundColor(fgColor)
|
.foregroundColor(fgColor)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,6 @@ struct SheetCloseButton: View {
|
|||||||
.contentShape(Rectangle().inset(by: -6))
|
.contentShape(Rectangle().inset(by: -6))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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)
|
.buttonStyle(.plain)
|
||||||
.accessibilityHint(
|
.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 isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||||
let target = isGeoDM ? header.displayName : "@\(header.displayName)"
|
let target = isGeoDM ? header.displayName : "@\(header.displayName)"
|
||||||
return String(
|
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,
|
locale: .current,
|
||||||
target
|
target
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
switch locationChannelsModel.selectedChannel {
|
switch locationChannelsModel.selectedChannel {
|
||||||
case .mesh:
|
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):
|
case .location(let channel):
|
||||||
return String(
|
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,
|
locale: .current,
|
||||||
channel.geohash
|
channel.geohash
|
||||||
)
|
)
|
||||||
@@ -184,15 +184,15 @@ private extension ContentComposerView {
|
|||||||
showImagePicker = true
|
showImagePicker = true
|
||||||
}
|
}
|
||||||
.accessibilityLabel(
|
.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(
|
.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)
|
.accessibilityAddTraits(.isButton)
|
||||||
// The long-press → camera path is unreachable for VoiceOver users;
|
// The long-press → camera path is unreachable for VoiceOver users;
|
||||||
// mirror it as a named action.
|
// 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
|
imagePickerSourceType = .camera
|
||||||
showImagePicker = true
|
showImagePicker = true
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ private extension ContentComposerView {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(
|
.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
|
#endif
|
||||||
}
|
}
|
||||||
@@ -249,15 +249,15 @@ private extension ContentComposerView {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.accessibilityLabel(
|
.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(
|
.accessibilityValue(
|
||||||
voiceRecordingVM.state.isActive
|
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(
|
.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)
|
.accessibilityAddTraits(.isButton)
|
||||||
// Press-and-hold drag gestures can't be activated by VoiceOver;
|
// Press-and-hold drag gestures can't be activated by VoiceOver;
|
||||||
@@ -282,12 +282,12 @@ private extension ContentComposerView {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(!enabled)
|
.disabled(!enabled)
|
||||||
.accessibilityLabel(
|
.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(
|
.accessibilityHint(
|
||||||
enabled
|
enabled
|
||||||
? String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the 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", comment: "Hint prompting the user to enter a 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.
|
/// Courier envelopes this device is carrying for offline third parties.
|
||||||
@State private var carriedMailCount = 0
|
@State private var carriedMailCount = 0
|
||||||
|
|
||||||
|
/// Bulletin board sheet for the current channel context.
|
||||||
|
@State private var showBoard = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text(verbatim: "bitchat/")
|
Text(verbatim: "bitchat/")
|
||||||
@@ -41,7 +44,7 @@ struct ContentHeaderView: View {
|
|||||||
// stays undiscoverable on purpose — it's destructive.)
|
// stays undiscoverable on purpose — it's destructive.)
|
||||||
.accessibilityAddTraits(.isButton)
|
.accessibilityAddTraits(.isButton)
|
||||||
.accessibilityHint(
|
.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 {
|
.accessibilityAction {
|
||||||
appChromeModel.presentAppInfo()
|
appChromeModel.presentAppInfo()
|
||||||
@@ -53,7 +56,7 @@ struct ContentHeaderView: View {
|
|||||||
.foregroundColor(palette.secondary)
|
.foregroundColor(palette.secondary)
|
||||||
|
|
||||||
TextField(
|
TextField(
|
||||||
"content.input.nickname_placeholder",
|
String(localized: "content.input.nickname_placeholder", defaultValue: "nickname"),
|
||||||
text: Binding(
|
text: Binding(
|
||||||
get: { appChromeModel.nickname },
|
get: { appChromeModel.nickname },
|
||||||
set: { appChromeModel.setNickname($0) }
|
set: { appChromeModel.setNickname($0) }
|
||||||
@@ -91,6 +94,19 @@ struct ContentHeaderView: View {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
HStack(spacing: 2) {
|
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 {
|
if carriedMailCount > 0 {
|
||||||
Image(systemName: "figure.walk")
|
Image(systemName: "figure.walk")
|
||||||
.font(.bitchatSystem(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
@@ -117,7 +133,7 @@ struct ContentHeaderView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(
|
.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)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(
|
.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 {
|
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||||
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
|
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
|
||||||
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||||
@@ -148,7 +178,7 @@ struct ContentHeaderView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(
|
.accessibilityLabel(
|
||||||
String(
|
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,
|
locale: .current,
|
||||||
channel.geohash
|
channel.geohash
|
||||||
)
|
)
|
||||||
@@ -181,7 +211,7 @@ struct ContentHeaderView: View {
|
|||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.accessibilityLabel(
|
.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)
|
.buttonStyle(.plain)
|
||||||
@@ -208,7 +238,7 @@ struct ContentHeaderView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(
|
.accessibilityLabel(
|
||||||
String(
|
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,
|
locale: .current,
|
||||||
headerOtherPeersCount
|
headerOtherPeersCount
|
||||||
)
|
)
|
||||||
@@ -217,8 +247,8 @@ struct ContentHeaderView: View {
|
|||||||
// color; say it.
|
// color; say it.
|
||||||
.accessibilityValue(
|
.accessibilityValue(
|
||||||
headerPeersReachable
|
headerPeersReachable
|
||||||
? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable")
|
? String(localized: "content.accessibility.peers_connected", defaultValue: "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_none", defaultValue: "no one reachable", comment: "Accessibility value when no peers are reachable")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.layoutPriority(3)
|
.layoutPriority(3)
|
||||||
@@ -242,6 +272,13 @@ struct ContentHeaderView: View {
|
|||||||
.environmentObject(locationChannelsModel)
|
.environmentObject(locationChannelsModel)
|
||||||
.environmentObject(peerListModel)
|
.environmentObject(peerListModel)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showBoard) {
|
||||||
|
BoardView(
|
||||||
|
geohash: boardGeohash,
|
||||||
|
senderNickname: appChromeModel.nickname,
|
||||||
|
board: appChromeModel.boardManager
|
||||||
|
)
|
||||||
|
}
|
||||||
.sheet(isPresented: $showLocationNotes, onDismiss: {
|
.sheet(isPresented: $showLocationNotes, onDismiss: {
|
||||||
notesGeohash = nil
|
notesGeohash = nil
|
||||||
}) {
|
}) {
|
||||||
@@ -288,10 +325,10 @@ struct ContentHeaderView: View {
|
|||||||
.onChange(of: locationChannelsModel.permissionState) { _ in
|
.onChange(of: locationChannelsModel.permissionState) { _ in
|
||||||
locationChannelsModel.refreshMeshChannelsIfNeeded()
|
locationChannelsModel.refreshMeshChannelsIfNeeded()
|
||||||
}
|
}
|
||||||
.alert("content.alert.screenshot.title", isPresented: $appChromeModel.showScreenshotPrivacyWarning) {
|
.alert(String(localized: "content.alert.screenshot.title", defaultValue: "heads up"), isPresented: $appChromeModel.showScreenshotPrivacyWarning) {
|
||||||
Button("common.ok", role: .cancel) {}
|
Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {}
|
||||||
} message: {
|
} 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)
|
.themedChromePanel(edge: .top)
|
||||||
}
|
}
|
||||||
@@ -311,6 +348,15 @@ private extension ContentHeaderView {
|
|||||||
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
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
|
/// Whether anyone is actually reachable on the current channel — the
|
||||||
/// state the count icon's color encodes visually.
|
/// state the count icon's color encodes visually.
|
||||||
var headerPeersReachable: Bool {
|
var headerPeersReachable: Bool {
|
||||||
@@ -346,7 +392,7 @@ private struct ContentLocationNotesUnavailableView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("content.notes.title")
|
Text(String(localized: "content.notes.title", defaultValue: "notes"))
|
||||||
.bitchatFont(size: 16, weight: .bold)
|
.bitchatFont(size: 16, weight: .bold)
|
||||||
Spacer()
|
Spacer()
|
||||||
SheetCloseButton { showLocationNotes = false }
|
SheetCloseButton { showLocationNotes = false }
|
||||||
@@ -355,10 +401,10 @@ private struct ContentLocationNotesUnavailableView: View {
|
|||||||
.frame(minHeight: headerHeight)
|
.frame(minHeight: headerHeight)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.themedChromePanel(edge: .top)
|
.themedChromePanel(edge: .top)
|
||||||
Text("content.notes.location_unavailable")
|
Text(String(localized: "content.notes.location_unavailable", defaultValue: "location unavailable"))
|
||||||
.bitchatFont(size: 14)
|
.bitchatFont(size: 14)
|
||||||
.foregroundColor(palette.secondary)
|
.foregroundColor(palette.secondary)
|
||||||
Button("content.location.enable") {
|
Button(String(localized: "content.location.enable", defaultValue: "enable location")) {
|
||||||
locationChannelsModel.enableAndRefresh()
|
locationChannelsModel.enableAndRefresh()
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
|
|||||||
@@ -163,10 +163,10 @@ private struct ContentPeopleListView: View {
|
|||||||
// .help maps to the accessibility *hint* on iOS, so the
|
// .help maps to the accessibility *hint* on iOS, so the
|
||||||
// button still needs a spoken name.
|
// button still needs a spoken name.
|
||||||
.accessibilityLabel(
|
.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(
|
.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 {
|
SheetCloseButton {
|
||||||
@@ -221,6 +221,13 @@ private struct ContentPeopleListView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
GroupChatList(
|
||||||
|
groups: peerListModel.groupRows,
|
||||||
|
onTapGroup: { peerID in
|
||||||
|
peerListModel.startConversation(with: peerID)
|
||||||
|
showSidebar = true
|
||||||
|
}
|
||||||
|
)
|
||||||
MeshPeerList(
|
MeshPeerList(
|
||||||
onTapPeer: { peerID in
|
onTapPeer: { peerID in
|
||||||
peerListModel.startConversation(with: peerID)
|
peerListModel.startConversation(with: peerID)
|
||||||
@@ -255,7 +262,7 @@ private struct ContentPeopleListView: View {
|
|||||||
|
|
||||||
private extension ContentPeopleListView {
|
private extension ContentPeopleListView {
|
||||||
var peopleSheetTitle: String {
|
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? {
|
var peopleSheetSubtitle: String? {
|
||||||
@@ -322,7 +329,7 @@ private struct ContentPrivateChatSheetView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(
|
.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)
|
Spacer(minLength: 0)
|
||||||
@@ -347,8 +354,8 @@ private struct ContentPrivateChatSheetView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(
|
.accessibilityLabel(
|
||||||
headerState.isFavorite
|
headerState.isFavorite
|
||||||
? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove 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", comment: "Accessibility label to add 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 {
|
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,
|
// Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted,
|
||||||
// even though they carry no Noise session status. Mesh DMs earn the
|
// even though they carry no Noise session status. Mesh DMs earn the
|
||||||
// "encrypted" claim only once the Noise handshake has secured.
|
// "encrypted" claim only once the Noise handshake has secured.
|
||||||
@@ -466,9 +477,9 @@ private struct ContentPrivateChatSheetView: View {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if isGeoDM || noiseSecured {
|
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 {
|
var body: some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
|
// A group has no single fingerprint to show.
|
||||||
|
guard !headerState.isGroupConversation else { return }
|
||||||
appChromeModel.showFingerprint(for: headerState.headerPeerID)
|
appChromeModel.showFingerprint(for: headerState.headerPeerID)
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
switch headerState.availability {
|
if headerState.isGroupConversation {
|
||||||
case .bluetoothConnected:
|
Image(systemName: "person.3.fill")
|
||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
|
||||||
.font(.bitchatSystem(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(palette.primary)
|
.foregroundColor(palette.primary)
|
||||||
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
|
.accessibilityLabel(String(localized: "content.accessibility.group_chat", defaultValue: "Group chat", comment: "Accessibility label for the group chat indicator"))
|
||||||
case .meshReachable:
|
} else {
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
switch headerState.availability {
|
||||||
.font(.bitchatSystem(size: 14))
|
case .bluetoothConnected:
|
||||||
.foregroundColor(palette.primary)
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
|
.font(.bitchatSystem(size: 14))
|
||||||
case .nostrAvailable:
|
.foregroundColor(palette.primary)
|
||||||
Image(systemName: "globe")
|
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", defaultValue: "connected via mesh", comment: "Accessibility label for mesh-connected peer indicator"))
|
||||||
.font(.bitchatSystem(size: 14))
|
case .meshReachable:
|
||||||
.foregroundColor(.purple)
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
.font(.bitchatSystem(size: 14))
|
||||||
case .offline:
|
.foregroundColor(palette.primary)
|
||||||
// Absence of a glyph was the only offline signal; say it.
|
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", defaultValue: "reachable via mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
|
||||||
Text("mesh_peers.state.offline")
|
case .nostrAvailable:
|
||||||
.bitchatFont(size: 11)
|
Image(systemName: "globe")
|
||||||
.foregroundColor(palette.secondary)
|
.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)
|
Text(headerState.displayName)
|
||||||
@@ -554,7 +574,7 @@ private struct ContentPrivateHeaderInfoButton: View {
|
|||||||
)
|
)
|
||||||
.accessibilityLabel(
|
.accessibilityLabel(
|
||||||
String(
|
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,
|
locale: .current,
|
||||||
encryptionStatus.accessibilityDescription
|
encryptionStatus.accessibilityDescription
|
||||||
)
|
)
|
||||||
@@ -565,13 +585,15 @@ private struct ContentPrivateHeaderInfoButton: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(
|
.accessibilityLabel(
|
||||||
String(
|
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,
|
locale: .current,
|
||||||
headerState.displayName
|
headerState.displayName
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.accessibilityHint(
|
.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)
|
.frame(minHeight: headerHeight)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ struct ContentView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $appChromeModel.isAppInfoPresented) {
|
.sheet(isPresented: $appChromeModel.isAppInfoPresented) {
|
||||||
AppInfoView()
|
AppInfoView(topologyProvider: { appChromeModel.meshTopologyDisplayModel() })
|
||||||
}
|
}
|
||||||
.sheet(isPresented: Binding(
|
.sheet(isPresented: Binding(
|
||||||
get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil },
|
get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil },
|
||||||
@@ -204,20 +204,20 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: {
|
.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 {
|
if voiceRecordingVM.state == .permissionDenied {
|
||||||
Button("location_channels.action.open_settings") {
|
Button(String(localized: "location_channels.action.open_settings", defaultValue: "open settings")) {
|
||||||
SystemSettings.microphone.open()
|
SystemSettings.microphone.open()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, message: {
|
}, message: {
|
||||||
Text(voiceRecordingVM.state.alertMessage)
|
Text(voiceRecordingVM.state.alertMessage)
|
||||||
})
|
})
|
||||||
.alert("content.alert.bluetooth_required.title", isPresented: $appChromeModel.showBluetoothAlert) {
|
.alert(String(localized: "content.alert.bluetooth_required.title", defaultValue: "bluetooth required"), isPresented: $appChromeModel.showBluetoothAlert) {
|
||||||
Button("content.alert.bluetooth_required.settings") {
|
Button(String(localized: "content.alert.bluetooth_required.settings", defaultValue: "settings")) {
|
||||||
SystemSettings.bluetooth.open()
|
SystemSettings.bluetooth.open()
|
||||||
}
|
}
|
||||||
Button("common.ok", role: .cancel) {}
|
Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text(appChromeModel.bluetoothAlertMessage)
|
Text(appChromeModel.bluetoothAlertMessage)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,15 +30,23 @@ struct FingerprintView: View {
|
|||||||
static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified"
|
static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified"
|
||||||
static func verifyHint(_ nickname: String) -> String {
|
static func verifyHint(_ nickname: String) -> 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,
|
locale: .current,
|
||||||
nickname
|
nickname
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified"
|
static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified"
|
||||||
static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification"
|
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 {
|
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
|
// Verification status
|
||||||
if fingerprintState.canToggleVerification {
|
if fingerprintState.canToggleVerification {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ struct GeohashPeopleList: View {
|
|||||||
private enum Strings {
|
private enum Strings {
|
||||||
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||||
static let youSuffix: LocalizedStringKey = "geohash_people.you_suffix"
|
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 unblock: LocalizedStringKey = "geohash_people.action.unblock"
|
||||||
static let block: LocalizedStringKey = "geohash_people.action.block"
|
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 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", comment: "Context menu action to block 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", comment: "State label for someone who joined the location channel from elsewhere")
|
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", comment: "State label for someone physically in the location channel's area")
|
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", comment: "State label for a blocked peer")
|
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", comment: "State label marking your own row in the people list")
|
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", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
|
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 {
|
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 removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
|
||||||
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
|
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
|
||||||
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
|
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 toggleOn: LocalizedStringKey = "common.toggle.on"
|
||||||
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
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 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", comment: "Accessibility hint on a channel row explaining activation switches to it")
|
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", comment: "Accessibility action name for bookmarking a channel")
|
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", comment: "Accessibility action name for removing a channel bookmark")
|
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 {
|
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)
|
return rowTitle(label: label, count: count)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +73,7 @@ struct LocationChannelsSheet: View {
|
|||||||
|
|
||||||
static func subtitlePrefix(geohash: String, coverage: String) -> String {
|
static func subtitlePrefix(geohash: String, coverage: String) -> 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,
|
locale: .current,
|
||||||
geohash, coverage
|
geohash, coverage
|
||||||
)
|
)
|
||||||
@@ -80,7 +82,7 @@ struct LocationChannelsSheet: View {
|
|||||||
static func subtitle(prefix: String, name: String?) -> String {
|
static func subtitle(prefix: String, name: String?) -> String {
|
||||||
guard let name, !name.isEmpty else { return prefix }
|
guard let name, !name.isEmpty else { return prefix }
|
||||||
return String(
|
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,
|
locale: .current,
|
||||||
prefix, name
|
prefix, name
|
||||||
)
|
)
|
||||||
@@ -88,7 +90,7 @@ struct LocationChannelsSheet: View {
|
|||||||
|
|
||||||
private static func rowTitle(label: String, count: Int) -> String {
|
private static func rowTitle(label: String, count: Int) -> String {
|
||||||
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,
|
locale: .current,
|
||||||
label, count
|
label, count
|
||||||
)
|
)
|
||||||
@@ -244,6 +246,8 @@ struct LocationChannelsSheet: View {
|
|||||||
sectionDivider
|
sectionDivider
|
||||||
torToggleSection
|
torToggleSection
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
gatewayToggleSection
|
||||||
|
.padding(.top, 8)
|
||||||
Button(action: SystemSettings.location.open) {
|
Button(action: SystemSettings.location.open) {
|
||||||
Text(Strings.removeAccess)
|
Text(Strings.removeAccess)
|
||||||
.bitchatFont(size: 12)
|
.bitchatFont(size: 12)
|
||||||
@@ -508,6 +512,32 @@ extension LocationChannelsSheet {
|
|||||||
.cornerRadius(8)
|
.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 standardGreen: Color { palette.primary }
|
||||||
private var standardBlue: Color { palette.accentBlue }
|
private var standardBlue: Color { palette.accentBlue }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ struct LocationNotesView: View {
|
|||||||
|
|
||||||
private func headerTitle(for count: Int) -> String {
|
private func headerTitle(for count: Int) -> String {
|
||||||
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,
|
locale: .current,
|
||||||
"\(geohash) ± 1", count
|
"\(geohash) ± 1", count
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,20 +25,20 @@ struct BlockRevealImageView: View {
|
|||||||
@State private var loadFailed = false
|
@State private var loadFailed = false
|
||||||
|
|
||||||
private enum Strings {
|
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 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", comment: "Context menu action that opens an image full screen")
|
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", comment: "Context menu action that reveals a blurred image")
|
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", comment: "Context menu action that re-blurs a revealed 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", comment: "Context menu action that deletes a received 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", comment: "Title of the confirmation dialog before deleting 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", comment: "Body 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", comment: "Accessibility label for a blurred incoming 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", comment: "Accessibility label for a revealed 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", comment: "Accessibility hint for a blurred image; activating it reveals the 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", comment: "Accessibility hint for a revealed image; activating it opens the image full screen")
|
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", comment: "Accessibility label for an image that is still sending")
|
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", comment: "Accessibility label for an image whose file could not be loaded")
|
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", comment: "Accessibility label for the cancel button on an in-flight media send")
|
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(
|
init(
|
||||||
@@ -154,7 +154,7 @@ struct BlockRevealImageView: View {
|
|||||||
Button(Strings.delete, role: .destructive) {
|
Button(Strings.delete, role: .destructive) {
|
||||||
onDelete?()
|
onDelete?()
|
||||||
}
|
}
|
||||||
Button("common.cancel", role: .cancel) {}
|
Button(String(localized: "common.cancel", defaultValue: "cancel"), role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text(verbatim: Strings.deleteConfirmMessage)
|
Text(verbatim: Strings.deleteConfirmMessage)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ struct MediaMessageView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityHint(
|
.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)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(
|
.accessibilityLabel(
|
||||||
playback.isPlaying
|
playback.isPlaying
|
||||||
? String(localized: "media.voice.accessibility.pause", comment: "Accessibility label for pausing voice note playback")
|
? String(localized: "media.voice.accessibility.pause", defaultValue: "pause voice note", 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.play", defaultValue: "play voice note", comment: "Accessibility label for playing a voice note")
|
||||||
)
|
)
|
||||||
.accessibilityValue(playbackLabel)
|
.accessibilityValue(playbackLabel)
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ struct VoiceNoteView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(
|
.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 {
|
private enum Strings {
|
||||||
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
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 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", comment: "Tooltip for the unread messages 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", comment: "Accessibility label for mesh-connected peer 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", comment: "Accessibility label for mesh-reachable 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", comment: "Accessibility label for Nostr-available 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", comment: "State label for a peer that is not currently reachable")
|
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", comment: "State label for a favorited peer")
|
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", comment: "State label for a peer with unread private messages")
|
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", comment: "State label for a blocked peer")
|
static let blocked = String(localized: "mesh_peers.state.blocked", defaultValue: "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 vouched = String(localized: "mesh_peers.state.vouched", defaultValue: "vouched", comment: "State label for a peer vouched for by someone the user verified")
|
||||||
static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
|
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 showFingerprint = String(localized: "mesh_peers.action.fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen")
|
static let addFavorite = String(localized: "content.accessibility.add_favorite", defaultValue: "add to favorites", comment: "Accessibility label to add a favorite")
|
||||||
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 removeFavorite = String(localized: "content.accessibility.remove_favorite", defaultValue: "remove from favorites", comment: "Accessibility label to remove a favorite")
|
||||||
static let directMessage = String(localized: "content.actions.direct_message", comment: "Action that opens a private chat with the person")
|
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 block = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
|
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 unblock = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
|
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 {
|
var body: some View {
|
||||||
@@ -134,6 +136,16 @@ struct MeshPeerList: View {
|
|||||||
.foregroundColor(baseColor)
|
.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()
|
Spacer()
|
||||||
@@ -241,6 +253,7 @@ struct MeshPeerList: View {
|
|||||||
parts.append(Strings.offline)
|
parts.append(Strings.offline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if peer.showsVouchedBadge { parts.append(Strings.vouched) }
|
||||||
if peer.isFavorite { parts.append(Strings.favorite) }
|
if peer.isFavorite { parts.append(Strings.favorite) }
|
||||||
if peer.hasUnread { parts.append(Strings.unread) }
|
if peer.hasUnread { parts.append(Strings.unread) }
|
||||||
if peer.isBlocked { parts.append(Strings.blocked) }
|
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"
|
// mentioning the only other participant is noise, and "DM"
|
||||||
// would just reopen the conversation that is already open.
|
// would just reopen the conversation that is already open.
|
||||||
if privatePeer == nil {
|
if privatePeer == nil {
|
||||||
Button("content.actions.mention") {
|
Button(String(localized: "content.actions.mention", defaultValue: "mention")) {
|
||||||
insertMention(message.sender)
|
insertMention(message.sender)
|
||||||
}
|
}
|
||||||
if let peerID = message.senderPeerID {
|
if let peerID = message.senderPeerID {
|
||||||
Button("content.actions.direct_message") {
|
Button(String(localized: "content.actions.direct_message", defaultValue: "direct message")) {
|
||||||
privateConversationModel.openConversation(for: peerID)
|
privateConversationModel.openConversation(for: peerID)
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
showSidebar = true
|
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)
|
conversationUIModel.sendHug(to: message.sender)
|
||||||
}
|
}
|
||||||
Button("content.actions.slap") {
|
Button(String(localized: "content.actions.slap", defaultValue: "slap")) {
|
||||||
conversationUIModel.sendSlap(to: message.sender)
|
conversationUIModel.sendSlap(to: message.sender)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button("content.message.copy") {
|
Button(String(localized: "content.message.copy", defaultValue: "copy message")) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
UIPasteboard.general.string = message.content
|
UIPasteboard.general.string = message.content
|
||||||
#else
|
#else
|
||||||
@@ -138,12 +138,12 @@ struct MessageListView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
if isResendableFailedMessage(message) {
|
if isResendableFailedMessage(message) {
|
||||||
Button("content.actions.resend") {
|
Button(String(localized: "content.actions.resend", defaultValue: "resend")) {
|
||||||
conversationUIModel.resendFailedPrivateMessage(message)
|
conversationUIModel.resendFailedPrivateMessage(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if showsUserActions {
|
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)
|
conversationUIModel.block(peerID: message.senderPeerID, displayName: message.sender)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,14 +165,14 @@ struct MessageListView: View {
|
|||||||
showClearConfirmation = true
|
showClearConfirmation = true
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"content.clear.confirm_title",
|
String(localized: "content.clear.confirm_title", defaultValue: "clear this chat?"),
|
||||||
isPresented: $showClearConfirmation,
|
isPresented: $showClearConfirmation,
|
||||||
titleVisibility: .visible
|
titleVisibility: .visible
|
||||||
) {
|
) {
|
||||||
Button("content.clear.confirm_action", role: .destructive) {
|
Button(String(localized: "content.clear.confirm_action", defaultValue: "clear chat"), role: .destructive) {
|
||||||
conversationUIModel.clearCurrentConversation()
|
conversationUIModel.clearCurrentConversation()
|
||||||
}
|
}
|
||||||
Button("common.cancel", role: .cancel) {}
|
Button(String(localized: "common.cancel", defaultValue: "cancel"), role: .cancel) {}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
scrollToBottom(on: proxy)
|
scrollToBottom(on: proxy)
|
||||||
@@ -190,17 +190,17 @@ struct MessageListView: View {
|
|||||||
onSelectedChannelChange(newChannel, proxy: proxy)
|
onSelectedChannelChange(newChannel, proxy: proxy)
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.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,
|
isPresented: $showMessageActions,
|
||||||
titleVisibility: .visible
|
titleVisibility: .visible
|
||||||
) {
|
) {
|
||||||
Button("content.actions.mention") {
|
Button(String(localized: "content.actions.mention", defaultValue: "mention")) {
|
||||||
if let sender = selectedMessageSender {
|
if let sender = selectedMessageSender {
|
||||||
insertMention(sender)
|
insertMention(sender)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("content.actions.direct_message") {
|
Button(String(localized: "content.actions.direct_message", defaultValue: "direct message")) {
|
||||||
if let peerID = selectedMessageSenderID {
|
if let peerID = selectedMessageSenderID {
|
||||||
privateConversationModel.openConversation(for: peerID)
|
privateConversationModel.openConversation(for: peerID)
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
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 {
|
if let sender = selectedMessageSender {
|
||||||
conversationUIModel.sendHug(to: sender)
|
conversationUIModel.sendHug(to: sender)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("content.actions.slap") {
|
Button(String(localized: "content.actions.slap", defaultValue: "slap")) {
|
||||||
if let sender = selectedMessageSender {
|
if let sender = selectedMessageSender {
|
||||||
conversationUIModel.sendSlap(to: sender)
|
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)
|
conversationUIModel.block(peerID: selectedMessageSenderID, displayName: selectedMessageSender)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("common.cancel", role: .cancel) {}
|
Button(String(localized: "common.cancel", defaultValue: "cancel"), role: .cancel) {}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Also check when view appears
|
// Also check when view appears
|
||||||
@@ -277,18 +277,18 @@ private extension MessageListView {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
switch locationChannelsModel.selectedChannel {
|
switch locationChannelsModel.selectedChannel {
|
||||||
case .mesh:
|
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_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", comment: "Second line of the empty mesh timeline saying no peers are in range yet"))
|
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", 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"))
|
||||||
case .location(let channel):
|
case .location(let channel):
|
||||||
emptyStateLine(
|
emptyStateLine(
|
||||||
String(
|
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,
|
locale: .current,
|
||||||
channel.geohash
|
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)
|
.padding(.horizontal, 12)
|
||||||
@@ -368,7 +368,7 @@ private extension MessageListView {
|
|||||||
if unseenCount > 0 {
|
if unseenCount > 0 {
|
||||||
Text(
|
Text(
|
||||||
String(
|
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,
|
locale: .current,
|
||||||
unseenCount
|
unseenCount
|
||||||
)
|
)
|
||||||
@@ -389,10 +389,10 @@ private extension MessageListView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var jumpToLatestAccessibilityLabel: String {
|
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 }
|
guard unseenCount > 0 else { return base }
|
||||||
let count = String(
|
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,
|
locale: .current,
|
||||||
unseenCount
|
unseenCount
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,17 +21,20 @@ extension String {
|
|||||||
return current >= threshold
|
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] {
|
func extractCashuLinks(max: Int = 3) -> [String] {
|
||||||
let regex = MessageFormattingEngine.Patterns.cashu
|
let regex = MessageFormattingEngine.Patterns.cashu
|
||||||
let ns = self as NSString
|
let ns = self as NSString
|
||||||
let range = NSRange(location: 0, length: ns.length)
|
let range = NSRange(location: 0, length: ns.length)
|
||||||
var found: [String] = []
|
var found: [String] = []
|
||||||
for m in regex.matches(in: self, range: range) {
|
for m in regex.matches(in: self, range: range) where m.numberOfRanges > 0 {
|
||||||
if m.numberOfRanges > 0 {
|
let token = ns.substring(with: m.range(at: 0))
|
||||||
let token = ns.substring(with: m.range(at: 0))
|
// Dedup: repeated tokens are one bearer instrument (and duplicate
|
||||||
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
|
// ForEach IDs) — one chip is enough.
|
||||||
found.append("cashu:\(enc)")
|
if !found.contains(token) {
|
||||||
|
found.append(token)
|
||||||
if found.count >= max { break }
|
if found.count >= max { break }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ struct MyQRView: View {
|
|||||||
|
|
||||||
private enum Strings {
|
private enum Strings {
|
||||||
static let title: LocalizedStringKey = "verification.my_qr.title"
|
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 {
|
var body: some View {
|
||||||
@@ -124,13 +124,13 @@ struct QRScanView: View {
|
|||||||
static let validate: LocalizedStringKey = "verification.scan.validate"
|
static let validate: LocalizedStringKey = "verification.scan.validate"
|
||||||
static func requested(_ nickname: String) -> String {
|
static func requested(_ nickname: String) -> 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,
|
locale: .current,
|
||||||
nickname
|
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 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", comment: "Status when a scanned QR payload is invalid")
|
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 {
|
var body: some View {
|
||||||
@@ -296,7 +296,7 @@ struct VerificationSheetView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Top header (always at top)
|
// Top header (always at top)
|
||||||
HStack {
|
HStack {
|
||||||
Text("verification.sheet.title")
|
Text(String(localized: "verification.sheet.title", defaultValue: "VERIFY"))
|
||||||
.bitchatFont(size: 14, weight: .bold)
|
.bitchatFont(size: 14, weight: .bold)
|
||||||
.foregroundColor(accentColor)
|
.foregroundColor(accentColor)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -316,7 +316,7 @@ struct VerificationSheetView: View {
|
|||||||
Group {
|
Group {
|
||||||
if showingScanner {
|
if showingScanner {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
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)
|
.bitchatFont(size: 16, weight: .bold)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|||||||
@@ -591,6 +591,45 @@ struct AppArchitectureTests {
|
|||||||
#expect(!verificationModel.isVerified(peerID: peerID))
|
#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")
|
@Test("PeerListModel publishes mesh and geohash directory state")
|
||||||
@MainActor
|
@MainActor
|
||||||
func peerListModelPublishesDirectoryState() async {
|
func peerListModelPublishesDirectoryState() async {
|
||||||
|
|||||||
@@ -216,6 +216,114 @@ struct BLEServiceCoreTests {
|
|||||||
cachedServiceUUIDs: [BLEService.serviceUUID, otherService]
|
cachedServiceUUIDs: [BLEService.serviceUUID, otherService]
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression: a stable/verified private chat addresses the peer by its
|
||||||
|
// full 64-hex Noise key, but the Noise session (and capabilities/
|
||||||
|
// connection) are keyed by the short routing ID. The Wi-Fi bulk send path
|
||||||
|
// must normalize the ID first, or `hasEstablishedSession` misses and
|
||||||
|
// Wi-Fi is never offered for a large payload to a direct `.wifiBulk` peer.
|
||||||
|
@Test
|
||||||
|
func wifiBulkEligibility_resolvesWhenAddressedBy64HexNoiseKey() throws {
|
||||||
|
let ble = makeService()
|
||||||
|
|
||||||
|
let noiseKey = Data((0..<32).map { UInt8(($0 &* 7) &+ 3) })
|
||||||
|
let fullKey = PeerID(str: noiseKey.hexEncodedString()) // 64-hex Noise key
|
||||||
|
#expect(fullKey.noiseKey != nil)
|
||||||
|
let shortID = fullKey.toShort() // 16-hex routing ID
|
||||||
|
#expect(shortID != fullKey)
|
||||||
|
|
||||||
|
// Establish a real Noise session in the service's noise engine, keyed
|
||||||
|
// by the short routing ID (exactly as a completed handshake would).
|
||||||
|
let peer = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let noise = ble._test_noiseService
|
||||||
|
let m1 = try noise.initiateHandshake(with: shortID)
|
||||||
|
let m2 = try #require(try peer.processHandshakeMessage(from: shortID, message: m1))
|
||||||
|
let m3 = try #require(try noise.processHandshakeMessage(from: shortID, message: m2))
|
||||||
|
_ = try peer.processHandshakeMessage(from: shortID, message: m3)
|
||||||
|
|
||||||
|
#expect(noise.hasEstablishedSession(with: shortID))
|
||||||
|
// The bug in raw form: querying by the 64-hex key misses the session.
|
||||||
|
#expect(!noise.hasEstablishedSession(with: fullKey))
|
||||||
|
|
||||||
|
// Register the peer as a directly-connected `.wifiBulk` neighbor.
|
||||||
|
ble._test_registerConnectedPeer(fullKey, capabilities: [.wifiBulk])
|
||||||
|
|
||||||
|
let bigPayload = FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
|
||||||
|
// The send path normalizes first, so eligibility + offer resolve for
|
||||||
|
// both the short ID and the full 64-hex Noise key.
|
||||||
|
let viaShort = ble._test_wifiBulkSendCandidate(payloadBytes: bigPayload, to: shortID)
|
||||||
|
let viaFull = ble._test_wifiBulkSendCandidate(payloadBytes: bigPayload, to: fullKey)
|
||||||
|
#expect(viaShort.hasEstablishedNoiseSession)
|
||||||
|
#expect(viaFull.hasEstablishedNoiseSession)
|
||||||
|
#expect(viaFull.isDirectlyConnected)
|
||||||
|
#expect(viaFull.peerCapabilities.contains(.wifiBulk))
|
||||||
|
#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 {
|
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] = []
|
private(set) var hapticMessageIDs: [String] = []
|
||||||
|
|
||||||
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessages.append(message) }
|
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 checkForMentions(_ message: BitchatMessage) { mentionCheckedMessageIDs.append(message.id) }
|
||||||
func sendHapticFeedback(for message: BitchatMessage) { hapticMessageIDs.append(message.id) }
|
func sendHapticFeedback(for message: BitchatMessage) { hapticMessageIDs.append(message.id) }
|
||||||
func parseMentions(from content: String) -> [String] { [] }
|
func parseMentions(from content: String) -> [String] { [] }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user