mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
Merge remote-tracking branch 'origin/feat/geo-board' into feat/integration-all
# Conflicts: # bitchat/Services/BLE/BLEOutboundPacketPolicy.swift # bitchat/Services/BLE/BLEService.swift # bitchat/Services/Transport.swift # bitchat/Sync/GossipSyncManager.swift # bitchat/Sync/SyncTypeFlags.swift # bitchat/ViewModels/ChatViewModel.swift # localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift
This commit is contained in:
@@ -18,6 +18,9 @@ final class AppChromeModel: ObservableObject {
|
||||
private let chatViewModel: ChatViewModel
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||
|
||||
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.nickname = chatViewModel.nickname
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .prekeyBundle, .groupMessage, .nostrCarrier, .ping, .pong:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .prekeyBundle, .groupMessage, .nostrCarrier, .ping, .pong:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ struct BLEReceivePipeline {
|
||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||
isAnnounce: packet.type == MessageType.announce.rawValue,
|
||||
isRequestSync: packet.type == MessageType.requestSync.rawValue,
|
||||
// Board posts relay like broadcast messages; urgent ones get the
|
||||
// announce-class TTL headroom so alerts travel the extra hop.
|
||||
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
|
||||
&& BoardWire.urgentFlag(in: packet.payload),
|
||||
degree: degree,
|
||||
highDegreeThreshold: highDegreeThreshold
|
||||
)
|
||||
|
||||
@@ -53,6 +53,8 @@ final class BLEService: NSObject {
|
||||
// reject. Injectable for tests; main-actor policy because favorites live
|
||||
// on the main actor.
|
||||
var courierStore: CourierStore = .shared
|
||||
// Bulletin-board posts this device carries; injectable for tests.
|
||||
var boardStore: BoardStore = .shared
|
||||
var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in
|
||||
if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite }
|
||||
return isVerifiedPeer ? .verified : nil
|
||||
@@ -351,6 +353,14 @@ final class BLEService: NSObject {
|
||||
let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive)
|
||||
manager.delegate = self
|
||||
// Board posts sync from the board store (their retention owner) so
|
||||
// deleted/expired posts drop out of rounds immediately. Real sessions
|
||||
// only, matching the archive: unit tests stay hermetic.
|
||||
if meshBackgroundEnabled {
|
||||
manager.boardPacketsProvider = { [weak self] in
|
||||
self?.boardStore.syncCandidates() ?? []
|
||||
}
|
||||
}
|
||||
// Only start the periodic sync timers when real Bluetooth exists. In unit
|
||||
// tests there is no mesh to sync with, and the periodic sign/broadcast
|
||||
// churn just keeps the process busy and aggravates flaky exit hangs.
|
||||
@@ -3972,6 +3982,10 @@ extension BLEService {
|
||||
case .pong:
|
||||
handleMeshPong(packet, from: senderID)
|
||||
|
||||
case .boardPost:
|
||||
// Invalid or deleted posts must not spread; skip the relay step.
|
||||
guard handleBoardPost(packet, from: senderID) else { return }
|
||||
|
||||
case .leave:
|
||||
handleLeave(packet, from: senderID)
|
||||
|
||||
@@ -4150,6 +4164,63 @@ extension BLEService {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Board (geohash bulletin board)
|
||||
|
||||
/// Validates and stores an incoming board post or tombstone. Returns
|
||||
/// whether the packet is worth relaying onward.
|
||||
private func handleBoardPost(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||
guard let wire = BoardWire.decode(from: packet.payload) else {
|
||||
SecureLogger.warning("⚠️ Malformed board packet from \(peerID.id.prefix(8))…", category: .session)
|
||||
return false
|
||||
}
|
||||
// Posts are self-authenticating: the payload embeds the author's
|
||||
// Ed25519 key and signature, so verification does not depend on the
|
||||
// author still being around to announce.
|
||||
guard wire.verifySignature() else {
|
||||
if logRateLimiter.shouldLog(key: "board-sig:\(peerID.id)") {
|
||||
SecureLogger.warning("🚫 Dropping board packet with invalid signature from \(peerID.id.prefix(8))…", category: .security)
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch boardStore.ingest(wire, packet: packet) {
|
||||
case .accepted, .duplicate:
|
||||
return true
|
||||
case .rejected:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Broadcasts a pre-signed board payload (post or tombstone) built by the
|
||||
/// board manager, and ingests it locally so it shows up on our own board
|
||||
/// and joins gossip sync immediately.
|
||||
func sendBoardPayload(_ payload: Data) {
|
||||
guard let wire = BoardWire.decode(from: payload), wire.verifySignature() else {
|
||||
SecureLogger.error("❌ Refusing to send invalid board payload", category: .session)
|
||||
return
|
||||
}
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let basePacket = BitchatPacket(
|
||||
type: MessageType.boardPost.rawValue,
|
||||
senderID: Data(hexString: self.myPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: self.messageTTL
|
||||
)
|
||||
guard let signedPacket = self.noiseService.signPacket(basePacket) else {
|
||||
SecureLogger.error("❌ Failed to sign board packet", category: .security)
|
||||
return
|
||||
}
|
||||
// Pre-mark our own broadcast as processed to avoid handling a relayed self copy
|
||||
let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
|
||||
self.messageDeduplicator.markProcessed(dedupID)
|
||||
self.boardStore.ingest(wire, packet: signedPacket)
|
||||
self.broadcastPacket(signedPacket)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
|
||||
private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
// REQUEST_SYNC is link-local by design (always sent with ttl 0): a
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ struct RelayController {
|
||||
isHandshake: Bool,
|
||||
isAnnounce: Bool,
|
||||
isRequestSync: Bool = false,
|
||||
isUrgentBoardPost: Bool = false,
|
||||
degree: Int,
|
||||
highDegreeThreshold: Int) -> RelayDecision {
|
||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||
@@ -64,7 +65,7 @@ struct RelayController {
|
||||
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||
// - Thin chains (degree <= 2): every hop counts and flood cost is
|
||||
// minimal, so relay at full incoming depth
|
||||
// - Announces get a bit more headroom
|
||||
// - Announces (and urgent board posts) get a bit more headroom
|
||||
let ttlLimit: UInt8 = {
|
||||
if degree >= highDegreeThreshold {
|
||||
return max(UInt8(2), min(ttlCap, UInt8(5)))
|
||||
@@ -72,7 +73,7 @@ struct RelayController {
|
||||
if degree <= 2 {
|
||||
return ttlCap
|
||||
}
|
||||
let preferred = UInt8(isAnnounce ? 7 : 6)
|
||||
let preferred = UInt8((isAnnounce || isUrgentBoardPost) ? 7 : 6)
|
||||
return max(UInt8(2), min(ttlCap, preferred))
|
||||
}()
|
||||
let newTTL = ttlLimit &- 1
|
||||
|
||||
@@ -178,6 +178,10 @@ protocol Transport: AnyObject {
|
||||
/// Current mesh graph for the topology map; nil when unsupported.
|
||||
func currentMeshTopology() -> MeshTopologySnapshot?
|
||||
|
||||
// Bulletin board (mesh transports only): broadcast a pre-signed board
|
||||
// payload (post or tombstone) so it spreads over relay and gossip sync.
|
||||
func sendBoardPayload(_ payload: Data)
|
||||
|
||||
// QR verification (optional for transports)
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
@@ -234,6 +238,7 @@ extension Transport {
|
||||
}
|
||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
|
||||
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
|
||||
func sendBoardPayload(_ payload: Data) {}
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||
func cancelTransfer(_ transferId: String) {}
|
||||
|
||||
@@ -77,6 +77,11 @@ final class GossipSyncManager {
|
||||
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
||||
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
||||
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
||||
// Board posts are few but long-lived (days, until each post's own
|
||||
// expiry), so they get a slow round with their own capacity instead
|
||||
// of competing with the 15-minute message window.
|
||||
var boardCapacity: Int = 200
|
||||
var boardSyncIntervalSeconds: TimeInterval = 60.0
|
||||
var responseRateLimitMaxResponses: Int = 8
|
||||
var responseRateLimitWindowSeconds: TimeInterval = 30.0
|
||||
// Prekey bundles: one per peer, own sync round, long freshness so
|
||||
@@ -92,6 +97,12 @@ final class GossipSyncManager {
|
||||
private let archive: GossipMessageArchive?
|
||||
weak var delegate: Delegate?
|
||||
|
||||
/// Source of raw signed board packets (posts + tombstones). The board
|
||||
/// store is the single owner of board retention (expiry, tombstones,
|
||||
/// caps, persistence), so sync rounds query it instead of keeping a
|
||||
/// second copy here. Must be thread-safe; set before `start()`.
|
||||
var boardPacketsProvider: (() -> [BitchatPacket])?
|
||||
|
||||
// Storage: broadcast packets by type, and latest announce per sender
|
||||
private var messages = PacketStore()
|
||||
private var fragments = PacketStore()
|
||||
@@ -139,6 +150,9 @@ final class GossipSyncManager {
|
||||
if config.prekeyBundleCapacity > 0 && config.prekeyBundleSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .prekeyBundle, interval: config.prekeyBundleSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
if config.boardCapacity > 0 && config.boardSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .board, interval: config.boardSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
syncSchedules = schedules
|
||||
|
||||
if archive != nil {
|
||||
@@ -181,6 +195,9 @@ final class GossipSyncManager {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -488,6 +505,22 @@ final class GossipSyncManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||
@@ -515,6 +548,9 @@ final class GossipSyncManager {
|
||||
if types.contains(.groupMessage) {
|
||||
candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
|
||||
}
|
||||
if types.contains(.boardPost) {
|
||||
candidates.append(contentsOf: boardPacketsProvider?() ?? [])
|
||||
}
|
||||
if candidates.isEmpty {
|
||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter)
|
||||
@@ -533,6 +569,8 @@ final class GossipSyncManager {
|
||||
cap = max(1, config.fileTransferCapacity)
|
||||
} else if types == .prekeyBundle {
|
||||
cap = max(1, config.prekeyBundleCapacity)
|
||||
} else if types == .board {
|
||||
cap = max(1, config.boardCapacity)
|
||||
} else {
|
||||
cap = max(1, config.seenCapacity)
|
||||
}
|
||||
@@ -628,6 +666,9 @@ final class GossipSyncManager {
|
||||
// fragment traffic can't crowd messages out of the filter.
|
||||
for index in syncSchedules.indices {
|
||||
guard syncSchedules[index].interval > 0 else { continue }
|
||||
// No board source wired up means nothing to offer or store;
|
||||
// skip the round entirely.
|
||||
if syncSchedules[index].types == .board && boardPacketsProvider == nil { continue }
|
||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||
syncSchedules[index].lastSent = now
|
||||
sendPeriodicSync(for: syncSchedules[index].types)
|
||||
|
||||
@@ -36,21 +36,21 @@ struct SyncTypeFlags: OptionSet {
|
||||
case .fragment: return 5
|
||||
case .requestSync: return 6
|
||||
case .fileTransfer: return 7
|
||||
// Bits 8/9 are reserved by other in-flight features.
|
||||
// Extended bits are compat-safe by construction: `toData()` encodes
|
||||
// the bitfield little-endian with trailing zero bytes trimmed (bit 10
|
||||
// widens the wire form from 1 to 2 bytes inside the length-prefixed
|
||||
// REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while
|
||||
// `type(forBit:)` maps unknown bits to nil — so old clients simply
|
||||
// ignore the group bit and answer with the types they know.
|
||||
case .boardPost: return 8
|
||||
case .groupMessage: return 10
|
||||
// Courier envelopes are directed deposits between trusted peers and
|
||||
// must never spread via gossip sync.
|
||||
case .courierEnvelope: return nil
|
||||
// Bit 8 is reserved for the board feature. The bitfield is already 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.
|
||||
// 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
|
||||
@@ -72,7 +72,10 @@ struct SyncTypeFlags: OptionSet {
|
||||
case 5: return .fragment
|
||||
case 6: return .requestSync
|
||||
case 7: return .fileTransfer
|
||||
// Bit 8 reserved (board).
|
||||
// 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:
|
||||
@@ -84,6 +87,7 @@ struct SyncTypeFlags: OptionSet {
|
||||
static let message = SyncTypeFlags(messageTypes: [.message])
|
||||
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
||||
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
||||
static let board = SyncTypeFlags(messageTypes: [.boardPost])
|
||||
static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle])
|
||||
static let groupMessage = SyncTypeFlags(messageTypes: [.groupMessage])
|
||||
|
||||
|
||||
@@ -1220,6 +1220,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// Drop private group keys and rosters (keychain + disk)
|
||||
groupStore.wipe()
|
||||
|
||||
// Drop bulletin-board posts and tombstones (memory and disk); board
|
||||
// posts are signed with our identity key and persist for days.
|
||||
BoardStore.shared.wipe()
|
||||
|
||||
// Identity manager has cleared persisted identity data above
|
||||
|
||||
// Clear autocomplete state
|
||||
|
||||
@@ -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
|
||||
}()
|
||||
}
|
||||
@@ -25,6 +25,9 @@ struct ContentHeaderView: View {
|
||||
/// Courier envelopes this device is carrying for offline third parties.
|
||||
@State private var carriedMailCount = 0
|
||||
|
||||
/// Bulletin board sheet for the current channel context.
|
||||
@State private var showBoard = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text(verbatim: "bitchat/")
|
||||
@@ -152,6 +155,20 @@ struct ContentHeaderView: View {
|
||||
)
|
||||
}
|
||||
|
||||
Button(action: { showBoard = true }) {
|
||||
Image(systemName: "pin")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(palette.secondary.opacity(0.9))
|
||||
.headerTapTarget()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.board", defaultValue: "Bulletin board", comment: "Accessibility label for the bulletin board button")
|
||||
)
|
||||
.help(
|
||||
String(localized: "content.header.board", defaultValue: "Bulletin board: persistent notices for this channel", comment: "Tooltip for the bulletin board button")
|
||||
)
|
||||
|
||||
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
|
||||
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||
@@ -255,6 +272,13 @@ struct ContentHeaderView: View {
|
||||
.environmentObject(locationChannelsModel)
|
||||
.environmentObject(peerListModel)
|
||||
}
|
||||
.sheet(isPresented: $showBoard) {
|
||||
BoardView(
|
||||
geohash: boardGeohash,
|
||||
senderNickname: appChromeModel.nickname,
|
||||
board: appChromeModel.boardManager
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showLocationNotes, onDismiss: {
|
||||
notesGeohash = nil
|
||||
}) {
|
||||
@@ -324,6 +348,15 @@ private extension ContentHeaderView {
|
||||
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
||||
}
|
||||
|
||||
/// The board scope for the current channel: the geohash channel's board,
|
||||
/// or the mesh-local board ("") in mesh chat.
|
||||
var boardGeohash: String {
|
||||
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||
return channel.geohash
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// Whether anyone is actually reachable on the current channel — the
|
||||
/// state the count icon's color encodes visually.
|
||||
var headerPeersReachable: Bool {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//
|
||||
// BoardPacketsTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BoardPacketsTests {
|
||||
|
||||
private let authorKey = Curve25519.Signing.PrivateKey()
|
||||
|
||||
private func makeSignedPost(
|
||||
geohash: String = "9q8yy",
|
||||
content: String = "water point at the north gate",
|
||||
nickname: String = "ranger",
|
||||
createdAt: UInt64 = 1_700_000_000_000,
|
||||
lifetimeMs: UInt64 = 24 * 60 * 60 * 1000,
|
||||
flags: UInt8 = 0,
|
||||
signWith key: Curve25519.Signing.PrivateKey? = nil,
|
||||
claimKey: Data? = nil
|
||||
) throws -> BoardPostPacket {
|
||||
let signer = key ?? authorKey
|
||||
let publicKey = claimKey ?? signer.publicKey.rawRepresentation
|
||||
let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) })
|
||||
let expiresAt = createdAt + lifetimeMs
|
||||
let signingBytes = BoardPostPacket.signingBytes(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: publicKey,
|
||||
authorNickname: nickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags
|
||||
)
|
||||
let signature = try signer.signature(for: signingBytes)
|
||||
return BoardPostPacket(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: publicKey,
|
||||
authorNickname: nickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
private func makeSignedTombstone(
|
||||
postID: Data,
|
||||
deletedAt: UInt64 = 1_700_000_100_000,
|
||||
signWith key: Curve25519.Signing.PrivateKey? = nil,
|
||||
claimKey: Data? = nil
|
||||
) throws -> BoardTombstonePacket {
|
||||
let signer = key ?? authorKey
|
||||
let publicKey = claimKey ?? signer.publicKey.rawRepresentation
|
||||
let signature = try signer.signature(for: BoardTombstonePacket.signingBytes(postID: postID, deletedAt: deletedAt))
|
||||
return BoardTombstonePacket(
|
||||
postID: postID,
|
||||
authorSigningKey: publicKey,
|
||||
deletedAt: deletedAt,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Round trips
|
||||
|
||||
@Test func postRoundTrip() throws {
|
||||
let post = try makeSignedPost(flags: BoardPostPacket.urgentFlag)
|
||||
let encoded = BoardWire.post(post).encode()
|
||||
let decoded = try #require(BoardWire.decode(from: encoded))
|
||||
#expect(decoded == .post(post))
|
||||
#expect(decoded.verifySignature())
|
||||
guard case .post(let roundTripped) = decoded else {
|
||||
Issue.record("expected a post")
|
||||
return
|
||||
}
|
||||
#expect(roundTripped.isUrgent)
|
||||
#expect(roundTripped.geohash == "9q8yy")
|
||||
}
|
||||
|
||||
@Test func meshLocalPostRoundTrip() throws {
|
||||
let post = try makeSignedPost(geohash: "")
|
||||
let decoded = try #require(BoardWire.decode(from: BoardWire.post(post).encode()))
|
||||
#expect(decoded == .post(post))
|
||||
#expect(decoded.verifySignature())
|
||||
}
|
||||
|
||||
@Test func tombstoneRoundTrip() throws {
|
||||
let post = try makeSignedPost()
|
||||
let tombstone = try makeSignedTombstone(postID: post.postID)
|
||||
let encoded = BoardWire.tombstone(tombstone).encode()
|
||||
let decoded = try #require(BoardWire.decode(from: encoded))
|
||||
#expect(decoded == .tombstone(tombstone))
|
||||
#expect(decoded.verifySignature())
|
||||
}
|
||||
|
||||
// MARK: - Signature verification
|
||||
|
||||
@Test func forgedPostSignatureFailsVerification() throws {
|
||||
// Signed by an attacker's key but claiming the victim's key as author.
|
||||
let attacker = Curve25519.Signing.PrivateKey()
|
||||
let victim = Curve25519.Signing.PrivateKey()
|
||||
let forged = try makeSignedPost(signWith: attacker, claimKey: victim.publicKey.rawRepresentation)
|
||||
let decoded = try #require(BoardWire.decode(from: BoardWire.post(forged).encode()))
|
||||
#expect(!decoded.verifySignature())
|
||||
}
|
||||
|
||||
@Test func tamperedContentFailsVerification() throws {
|
||||
let post = try makeSignedPost(content: "meet at noon")
|
||||
let tampered = BoardPostPacket(
|
||||
postID: post.postID,
|
||||
geohash: post.geohash,
|
||||
content: "meet at midnight",
|
||||
authorSigningKey: post.authorSigningKey,
|
||||
authorNickname: post.authorNickname,
|
||||
createdAt: post.createdAt,
|
||||
expiresAt: post.expiresAt,
|
||||
flags: post.flags,
|
||||
signature: post.signature
|
||||
)
|
||||
let decoded = try #require(BoardWire.decode(from: BoardWire.post(tampered).encode()))
|
||||
#expect(!decoded.verifySignature())
|
||||
}
|
||||
|
||||
@Test func forgedTombstoneSignatureFailsVerification() throws {
|
||||
let post = try makeSignedPost()
|
||||
let attacker = Curve25519.Signing.PrivateKey()
|
||||
let forged = try makeSignedTombstone(
|
||||
postID: post.postID,
|
||||
signWith: attacker,
|
||||
claimKey: post.authorSigningKey
|
||||
)
|
||||
let decoded = try #require(BoardWire.decode(from: BoardWire.tombstone(forged).encode()))
|
||||
#expect(!decoded.verifySignature())
|
||||
}
|
||||
|
||||
// MARK: - Decode validation
|
||||
|
||||
@Test func rejectsExpiryBeyondSevenDays() throws {
|
||||
let tooLong = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs + 1)
|
||||
#expect(BoardWire.decode(from: BoardWire.post(tooLong).encode()) == nil)
|
||||
|
||||
let exactlySevenDays = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs)
|
||||
#expect(BoardWire.decode(from: BoardWire.post(exactlySevenDays).encode()) != nil)
|
||||
}
|
||||
|
||||
@Test func rejectsExpiryBeforeCreation() throws {
|
||||
let post = try makeSignedPost()
|
||||
let inverted = BoardPostPacket(
|
||||
postID: post.postID,
|
||||
geohash: post.geohash,
|
||||
content: post.content,
|
||||
authorSigningKey: post.authorSigningKey,
|
||||
authorNickname: post.authorNickname,
|
||||
createdAt: post.expiresAt,
|
||||
expiresAt: post.createdAt,
|
||||
flags: post.flags,
|
||||
signature: post.signature
|
||||
)
|
||||
#expect(BoardWire.decode(from: BoardWire.post(inverted).encode()) == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsOversizedContent() throws {
|
||||
let oversized = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes + 1))
|
||||
#expect(BoardWire.decode(from: BoardWire.post(oversized).encode()) == nil)
|
||||
|
||||
let maxed = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes))
|
||||
#expect(BoardWire.decode(from: BoardWire.post(maxed).encode()) != nil)
|
||||
}
|
||||
|
||||
@Test func rejectsInvalidGeohashCharacters() throws {
|
||||
let invalid = try makeSignedPost(geohash: "9q8yA") // "A" is outside base32
|
||||
#expect(BoardWire.decode(from: BoardWire.post(invalid).encode()) == nil)
|
||||
}
|
||||
|
||||
@Test func toleratesUnknownTLVs() throws {
|
||||
let post = try makeSignedPost()
|
||||
var encoded = BoardWire.post(post).encode()
|
||||
// Append an unknown TLV; decoders must skip it.
|
||||
encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
|
||||
let decoded = try #require(BoardWire.decode(from: encoded))
|
||||
#expect(decoded == .post(post))
|
||||
#expect(decoded.verifySignature())
|
||||
}
|
||||
|
||||
@Test func rejectsTruncatedPayload() throws {
|
||||
let post = try makeSignedPost()
|
||||
let encoded = BoardWire.post(post).encode()
|
||||
#expect(BoardWire.decode(from: encoded.prefix(encoded.count - 1)) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Urgent flag peek
|
||||
|
||||
@Test func urgentFlagPeekMatchesFullDecode() throws {
|
||||
let urgent = try makeSignedPost(flags: BoardPostPacket.urgentFlag)
|
||||
let calm = try makeSignedPost()
|
||||
let tombstone = try makeSignedTombstone(postID: calm.postID)
|
||||
#expect(BoardWire.urgentFlag(in: BoardWire.post(urgent).encode()))
|
||||
#expect(!BoardWire.urgentFlag(in: BoardWire.post(calm).encode()))
|
||||
#expect(!BoardWire.urgentFlag(in: BoardWire.tombstone(tombstone).encode()))
|
||||
#expect(!BoardWire.urgentFlag(in: Data()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//
|
||||
// BoardStoreTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BoardStoreTests {
|
||||
|
||||
private final class MutableClock: @unchecked Sendable {
|
||||
var now: Date
|
||||
init(now: Date) { self.now = now }
|
||||
}
|
||||
|
||||
private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
|
||||
|
||||
private func makeStore(clock: MutableClock, fileURL: URL? = nil) -> BoardStore {
|
||||
BoardStore(persistsToDisk: fileURL != nil, fileURL: fileURL, now: { clock.now })
|
||||
}
|
||||
|
||||
private func tempFileURL() -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("board-store-\(UUID().uuidString).json")
|
||||
}
|
||||
|
||||
private func makePost(
|
||||
author: Curve25519.Signing.PrivateKey,
|
||||
geohash: String = "9q8yy",
|
||||
content: String = "note",
|
||||
createdAt: UInt64,
|
||||
lifetimeMs: UInt64 = 24 * 60 * 60 * 1000
|
||||
) throws -> (wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket) {
|
||||
let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) })
|
||||
let key = author.publicKey.rawRepresentation
|
||||
let expiresAt = createdAt + lifetimeMs
|
||||
let signingBytes = BoardPostPacket.signingBytes(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: key,
|
||||
authorNickname: "tester",
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: 0
|
||||
)
|
||||
let post = BoardPostPacket(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: key,
|
||||
authorNickname: "tester",
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: 0,
|
||||
signature: try author.signature(for: signingBytes)
|
||||
)
|
||||
let wire = BoardWire.post(post)
|
||||
return (wire, makePacket(payload: wire.encode(), timestamp: createdAt), post)
|
||||
}
|
||||
|
||||
private func makeTombstone(
|
||||
for post: BoardPostPacket,
|
||||
author: Curve25519.Signing.PrivateKey,
|
||||
deletedAt: UInt64,
|
||||
claimKey: Data? = nil
|
||||
) throws -> (wire: BoardWire, packet: BitchatPacket) {
|
||||
let tombstone = BoardTombstonePacket(
|
||||
postID: post.postID,
|
||||
authorSigningKey: claimKey ?? author.publicKey.rawRepresentation,
|
||||
deletedAt: deletedAt,
|
||||
signature: try author.signature(for: BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt))
|
||||
)
|
||||
let wire = BoardWire.tombstone(tombstone)
|
||||
return (wire, makePacket(payload: wire.encode(), timestamp: deletedAt))
|
||||
}
|
||||
|
||||
private func makePacket(payload: Data, timestamp: UInt64) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.boardPost.rawValue,
|
||||
senderID: Data((0..<8).map { _ in UInt8.random(in: 0...255) }),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Ingest basics
|
||||
|
||||
@Test func ingestStoresAndDeduplicates() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .duplicate)
|
||||
#expect(store.posts(forGeohash: "9q8yy").count == 1)
|
||||
#expect(store.posts(forGeohash: "").isEmpty)
|
||||
#expect(store.syncCandidates().count == 1)
|
||||
}
|
||||
|
||||
@Test func rejectsAlreadyExpiredPost() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs - 2 * 60 * 60 * 1000, lifetimeMs: 60 * 60 * 1000)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Receive-time timestamp policy
|
||||
|
||||
@Test func rejectsPostCreatedBeyondClockSkew() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs + 60 * 1000)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
}
|
||||
|
||||
@Test func acceptsPostCreatedWithinClockSkew() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs - 60 * 1000)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
#expect(store.posts(forGeohash: "9q8yy").count == 1)
|
||||
}
|
||||
|
||||
@Test func rejectsPostExpiringTooFarInTheFuture() throws {
|
||||
// Builds the wire directly, bypassing the decoder's span check, to
|
||||
// exercise the ingest-level expiresAt bound on its own.
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Caps and eviction
|
||||
|
||||
@Test func perAuthorCapEvictsOldest() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
var oldestID: Data?
|
||||
for index in 0..<(BoardStore.Limits.maxPostsPerAuthor + 1) {
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
|
||||
if index == 0 { oldestID = entry.post.postID }
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
}
|
||||
|
||||
let posts = store.posts(forGeohash: "9q8yy")
|
||||
#expect(posts.count == BoardStore.Limits.maxPostsPerAuthor)
|
||||
#expect(!posts.contains { $0.postID == oldestID })
|
||||
}
|
||||
|
||||
@Test func globalCapEvictsOldest() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
|
||||
var oldestID: Data?
|
||||
var author = Curve25519.Signing.PrivateKey()
|
||||
for index in 0..<(BoardStore.Limits.maxPosts + 1) {
|
||||
if index % BoardStore.Limits.maxPostsPerAuthor == 0 {
|
||||
author = Curve25519.Signing.PrivateKey()
|
||||
}
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
|
||||
if index == 0 { oldestID = entry.post.postID }
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
}
|
||||
|
||||
let posts = store.posts(forGeohash: "9q8yy")
|
||||
#expect(posts.count == BoardStore.Limits.maxPosts)
|
||||
#expect(!posts.contains { $0.postID == oldestID })
|
||||
}
|
||||
|
||||
// MARK: - Expiry sweep
|
||||
|
||||
@Test func expiredPostsAreSwept() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let shortLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 60 * 60 * 1000)
|
||||
let longLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 48 * 60 * 60 * 1000)
|
||||
store.ingest(shortLived.wire, packet: shortLived.packet)
|
||||
store.ingest(longLived.wire, packet: longLived.packet)
|
||||
#expect(store.posts(forGeohash: "9q8yy").count == 2)
|
||||
|
||||
clock.now = baseDate.addingTimeInterval(2 * 60 * 60) // 2h later
|
||||
let remaining = store.posts(forGeohash: "9q8yy")
|
||||
#expect(remaining.count == 1)
|
||||
#expect(remaining.first?.postID == longLived.post.postID)
|
||||
#expect(store.syncCandidates().count == 1)
|
||||
}
|
||||
|
||||
// MARK: - Tombstones
|
||||
|
||||
@Test func tombstoneDeletesPostAndPropagatesUntilOriginalExpiry() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 24 * 60 * 60 * 1000)
|
||||
store.ingest(entry.wire, packet: entry.packet)
|
||||
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
|
||||
// Post is gone, tombstone still syncs so the delete propagates.
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
#expect(store.syncCandidates().count == 1)
|
||||
|
||||
// Replayed copy of the deleted post is refused.
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
|
||||
// After the post's original expiry the tombstone is dropped too.
|
||||
clock.now = baseDate.addingTimeInterval(25 * 60 * 60)
|
||||
#expect(store.syncCandidates().isEmpty)
|
||||
}
|
||||
|
||||
@Test func tombstoneFromWrongKeyIsRejected() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let attacker = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
store.ingest(entry.wire, packet: entry.packet)
|
||||
|
||||
// Attacker signs with their own key (self-consistent wire, so it
|
||||
// passes signature verification) but targets the victim's post.
|
||||
let forged = try makeTombstone(for: entry.post, author: attacker, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(forged.wire, packet: forged.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").count == 1)
|
||||
}
|
||||
|
||||
@Test func tombstoneArrivingBeforePostSuppressesIt() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
}
|
||||
|
||||
@Test func orphanTombstoneRetentionIsBoundedByReceiveTime() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs) // never ingested
|
||||
|
||||
// Attacker-chosen far-future deletedAt must not extend retention.
|
||||
let farFuture = baseMs + 365 * 24 * 60 * 60 * 1000
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: farFuture)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
#expect(store.syncCandidates().count == 1)
|
||||
|
||||
// No post can outlive 7 days from receipt, so neither may an orphan
|
||||
// tombstone (plus skew allowance).
|
||||
clock.now = baseDate.addingTimeInterval(8 * 24 * 60 * 60)
|
||||
#expect(store.syncCandidates().isEmpty)
|
||||
}
|
||||
|
||||
@Test func orphanTombstonePerAuthorCapEvictsOldest() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
var unseenPosts: [(wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket)] = []
|
||||
for index in 0..<(BoardStore.Limits.maxOrphanTombstonesPerAuthor + 1) {
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index))
|
||||
unseenPosts.append(entry)
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
}
|
||||
|
||||
#expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstonesPerAuthor)
|
||||
// The oldest orphan was evicted, so its post is no longer suppressed…
|
||||
#expect(store.ingest(unseenPosts[0].wire, packet: unseenPosts[0].packet) == .accepted)
|
||||
// …while the surviving orphans still suppress theirs.
|
||||
#expect(store.ingest(unseenPosts[1].wire, packet: unseenPosts[1].packet) == .rejected)
|
||||
}
|
||||
|
||||
@Test func orphanTombstoneGlobalCapEvictsOldest() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
|
||||
var author = Curve25519.Signing.PrivateKey()
|
||||
for index in 0..<(BoardStore.Limits.maxOrphanTombstones + 1) {
|
||||
if index % BoardStore.Limits.maxOrphanTombstonesPerAuthor == 0 {
|
||||
author = Curve25519.Signing.PrivateKey()
|
||||
}
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index))
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
}
|
||||
|
||||
#expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstones)
|
||||
}
|
||||
|
||||
@Test func matchedTombstonesAreExemptFromOrphanCaps() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
// Post-then-delete more times than the per-author orphan cap; every
|
||||
// tombstone matched a live post, so none may be evicted.
|
||||
let cycles = BoardStore.Limits.maxOrphanTombstonesPerAuthor + 2
|
||||
for index in 0..<cycles {
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + UInt64(index) * 1000 + 1)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
}
|
||||
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
#expect(store.syncCandidates().count == cycles)
|
||||
}
|
||||
|
||||
// MARK: - Persistence and wipe
|
||||
|
||||
@Test func persistsAcrossRestart() throws {
|
||||
let fileURL = tempFileURL()
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
let first = makeStore(clock: clock, fileURL: fileURL)
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
let deleted = try makePost(author: author, createdAt: baseMs + 1)
|
||||
first.ingest(entry.wire, packet: entry.packet)
|
||||
first.ingest(deleted.wire, packet: deleted.packet)
|
||||
let tombstone = try makeTombstone(for: deleted.post, author: author, deletedAt: baseMs + 1000)
|
||||
first.ingest(tombstone.wire, packet: tombstone.packet)
|
||||
|
||||
let second = makeStore(clock: clock, fileURL: fileURL)
|
||||
let restored = second.posts(forGeohash: "9q8yy")
|
||||
#expect(restored.count == 1)
|
||||
#expect(restored.first?.postID == entry.post.postID)
|
||||
// Post + tombstone both restored into sync.
|
||||
#expect(second.syncCandidates().count == 2)
|
||||
|
||||
// Restart after expiry drops everything.
|
||||
clock.now = baseDate.addingTimeInterval(25 * 60 * 60)
|
||||
let third = makeStore(clock: clock, fileURL: fileURL)
|
||||
#expect(third.posts(forGeohash: "9q8yy").isEmpty)
|
||||
#expect(third.syncCandidates().isEmpty)
|
||||
}
|
||||
|
||||
@Test func wipeClearsMemoryAndDisk() throws {
|
||||
let fileURL = tempFileURL()
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
let store = makeStore(clock: clock, fileURL: fileURL)
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
store.ingest(entry.wire, packet: entry.packet)
|
||||
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||
|
||||
store.wipe()
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
#expect(store.syncCandidates().isEmpty)
|
||||
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//
|
||||
// GossipSyncBoardTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// Board posts ride gossip sync through a provider that queries the board
|
||||
/// store, so retention (expiry, tombstones, caps) has a single owner.
|
||||
struct GossipSyncBoardTests {
|
||||
|
||||
private let myPeerID = PeerID(str: "0102030405060708")
|
||||
|
||||
private func makeBoardPacket(timestamp: UInt64) throws -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.boardPost.rawValue,
|
||||
senderID: try #require(Data(hexString: "aabbccddeeff0011")),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: Data([0x42]),
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
}
|
||||
|
||||
private func quietConfig() -> GossipSyncManager.Config {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
return config
|
||||
}
|
||||
|
||||
@Test func boardRequestIsServedFromProvider() async throws {
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager())
|
||||
let delegate = RecordingBoardDelegate()
|
||||
manager.delegate = delegate
|
||||
let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000))
|
||||
manager.boardPacketsProvider = { return [boardPacket] }
|
||||
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
let sent = try #require(delegate.packets.first)
|
||||
#expect(sent.type == MessageType.boardPost.rawValue)
|
||||
#expect(sent.isRSR)
|
||||
}
|
||||
|
||||
@Test func nonBoardRequestDoesNotServeBoardPackets() async throws {
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager())
|
||||
let delegate = RecordingBoardDelegate()
|
||||
manager.delegate = delegate
|
||||
let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000))
|
||||
manager.boardPacketsProvider = { return [boardPacket] }
|
||||
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .publicMessages)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
|
||||
// Follow with a board request; only its response should arrive, which
|
||||
// also proves the first request produced nothing.
|
||||
let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
#expect(delegate.packets.count == 1)
|
||||
#expect(delegate.packets.first?.type == MessageType.boardPost.rawValue)
|
||||
}
|
||||
|
||||
@Test func maintenanceEmitsBoardRoundOnlyWithProvider() throws {
|
||||
var config = quietConfig()
|
||||
config.boardSyncIntervalSeconds = 1
|
||||
|
||||
// Without a provider the board schedule stays silent.
|
||||
let unwired = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
|
||||
let unwiredDelegate = RecordingBoardDelegate()
|
||||
unwired.delegate = unwiredDelegate
|
||||
unwired._performMaintenanceSynchronously(now: Date())
|
||||
#expect(unwiredDelegate.packets.isEmpty)
|
||||
|
||||
// With a provider, maintenance sends a board-typed request.
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
|
||||
let delegate = RecordingBoardDelegate()
|
||||
manager.delegate = delegate
|
||||
manager.boardPacketsProvider = { return [] }
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
|
||||
#expect(delegate.packets.count == 1)
|
||||
let payload = try #require(delegate.packets.first?.payload)
|
||||
let request = try #require(RequestSyncPacket.decode(from: payload))
|
||||
#expect(request.types == .board)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RecordingBoardDelegate: GossipSyncManager.Delegate {
|
||||
private let lock = NSLock()
|
||||
private var _packets: [BitchatPacket] = []
|
||||
|
||||
var packets: [BitchatPacket] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _packets
|
||||
}
|
||||
|
||||
func sendPacket(_ packet: BitchatPacket) {
|
||||
lock.lock()
|
||||
_packets.append(packet)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
|
||||
lock.lock()
|
||||
_packets.append(packet)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||
packet
|
||||
}
|
||||
|
||||
func getConnectedPeers() -> [PeerID] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// SyncTypeFlagsBoardTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// The board sync flag is the first bit outside the original single byte of
|
||||
/// type flags. These tests pin down the wire compatibility contract: the
|
||||
/// types TLV has been a variable-length (1-8 byte) little-endian bitfield
|
||||
/// since type-aware sync, so widening to two bytes must decode everywhere
|
||||
/// and unknown bits must be ignored, not rejected.
|
||||
struct SyncTypeFlagsBoardTests {
|
||||
|
||||
@Test func boardFlagEncodesIntoSecondByte() throws {
|
||||
let data = try #require(SyncTypeFlags.board.toData())
|
||||
// Little-endian: low byte first, board bit (bit 8) in byte 2.
|
||||
#expect(data == Data([0x00, 0x01]))
|
||||
}
|
||||
|
||||
@Test func boardFlagRoundTrips() throws {
|
||||
let flags = SyncTypeFlags(messageTypes: [.message, .boardPost])
|
||||
let data = try #require(flags.toData())
|
||||
let decoded = try #require(SyncTypeFlags.decode(data))
|
||||
#expect(decoded.contains(.message))
|
||||
#expect(decoded.contains(.boardPost))
|
||||
#expect(!decoded.contains(.fragment))
|
||||
#expect(Set(decoded.toMessageTypes()) == Set([.message, .boardPost]))
|
||||
}
|
||||
|
||||
/// An old decoder is modeled by bits it has no mapping for: the shared
|
||||
/// decode path accepts the bytes and simply maps unknown bits to no
|
||||
/// message type, so a board-only request reads as "nothing I can serve".
|
||||
@Test func unknownBitsDecodeToNoTypes() throws {
|
||||
// Bits 9-15 are unassigned; a future (or unknown) two-byte bitfield
|
||||
// must decode without error and yield no known types.
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xFE])))
|
||||
#expect(decoded.toMessageTypes().isEmpty)
|
||||
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost] {
|
||||
#expect(!decoded.contains(type))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws {
|
||||
// Known low-byte flags survive alongside unknown high bits.
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xFE])))
|
||||
#expect(decoded.contains(.announce))
|
||||
#expect(decoded.contains(.message))
|
||||
#expect(Set(decoded.toMessageTypes()) == Set([.announce, .message]))
|
||||
}
|
||||
|
||||
@Test func requestSyncPacketRoundTripsBoardFlag() throws {
|
||||
let request = RequestSyncPacket(
|
||||
p: 4,
|
||||
m: 128,
|
||||
data: Data([0xAB, 0xCD]),
|
||||
types: SyncTypeFlags(messageTypes: [.boardPost])
|
||||
)
|
||||
let decoded = try #require(RequestSyncPacket.decode(from: request.encode()))
|
||||
let types = try #require(decoded.types)
|
||||
#expect(types.contains(.boardPost))
|
||||
#expect(!types.contains(.message))
|
||||
}
|
||||
|
||||
@Test func singleByteLegacyEncodingStillDecodes() throws {
|
||||
// Requests from old clients keep the one-byte bitfield.
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x03])))
|
||||
#expect(decoded.contains(.announce))
|
||||
#expect(decoded.contains(.message))
|
||||
#expect(!decoded.contains(.boardPost))
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ public enum MessageType: UInt8 {
|
||||
case fragment = 0x20 // Single fragment type for large messages
|
||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||
|
||||
// 0x23 reserved by other in-flight features.
|
||||
case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
|
||||
case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped)
|
||||
case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body)
|
||||
// Mesh diagnostics
|
||||
@@ -48,6 +48,7 @@ public enum MessageType: UInt8 {
|
||||
case .noiseEncrypted: return "noiseEncrypted"
|
||||
case .fragment: return "fragment"
|
||||
case .fileTransfer: return "fileTransfer"
|
||||
case .boardPost: return "boardPost"
|
||||
case .prekeyBundle: return "prekeyBundle"
|
||||
case .groupMessage: return "groupMessage"
|
||||
case .ping: return "ping"
|
||||
|
||||
Reference in New Issue
Block a user