mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +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 let chatViewModel: ChatViewModel
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||||
|
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||||
|
|
||||||
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||||
self.chatViewModel = chatViewModel
|
self.chatViewModel = chatViewModel
|
||||||
self.nickname = chatViewModel.nickname
|
self.nickname = chatViewModel.nickname
|
||||||
|
|||||||
@@ -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) {
|
switch MessageType(rawValue: packetType) {
|
||||||
case .noiseEncrypted, .noiseHandshake:
|
case .noiseEncrypted, .noiseHandshake:
|
||||||
return true
|
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
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ struct BLEReceivePipeline {
|
|||||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||||
isAnnounce: packet.type == MessageType.announce.rawValue,
|
isAnnounce: packet.type == MessageType.announce.rawValue,
|
||||||
isRequestSync: packet.type == MessageType.requestSync.rawValue,
|
isRequestSync: packet.type == MessageType.requestSync.rawValue,
|
||||||
|
// Board posts relay like broadcast messages; urgent ones get the
|
||||||
|
// announce-class TTL headroom so alerts travel the extra hop.
|
||||||
|
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
|
||||||
|
&& BoardWire.urgentFlag(in: packet.payload),
|
||||||
degree: degree,
|
degree: degree,
|
||||||
highDegreeThreshold: highDegreeThreshold
|
highDegreeThreshold: highDegreeThreshold
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ final class BLEService: NSObject {
|
|||||||
// reject. Injectable for tests; main-actor policy because favorites live
|
// reject. Injectable for tests; main-actor policy because favorites live
|
||||||
// on the main actor.
|
// on the main actor.
|
||||||
var courierStore: CourierStore = .shared
|
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
|
var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in
|
||||||
if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite }
|
if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite }
|
||||||
return isVerifiedPeer ? .verified : nil
|
return isVerifiedPeer ? .verified : nil
|
||||||
@@ -351,6 +353,14 @@ final class BLEService: NSObject {
|
|||||||
let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil
|
let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive)
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive)
|
||||||
manager.delegate = self
|
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
|
// 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
|
// tests there is no mesh to sync with, and the periodic sign/broadcast
|
||||||
// churn just keeps the process busy and aggravates flaky exit hangs.
|
// churn just keeps the process busy and aggravates flaky exit hangs.
|
||||||
@@ -3972,6 +3982,10 @@ extension BLEService {
|
|||||||
case .pong:
|
case .pong:
|
||||||
handleMeshPong(packet, from: senderID)
|
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:
|
case .leave:
|
||||||
handleLeave(packet, from: senderID)
|
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
|
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
|
||||||
private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
// REQUEST_SYNC is link-local by design (always sent with ttl 0): a
|
// 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,
|
isHandshake: Bool,
|
||||||
isAnnounce: Bool,
|
isAnnounce: Bool,
|
||||||
isRequestSync: Bool = false,
|
isRequestSync: Bool = false,
|
||||||
|
isUrgentBoardPost: Bool = false,
|
||||||
degree: Int,
|
degree: Int,
|
||||||
highDegreeThreshold: Int) -> RelayDecision {
|
highDegreeThreshold: Int) -> RelayDecision {
|
||||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||||
@@ -64,7 +65,7 @@ struct RelayController {
|
|||||||
// - Dense graphs: keep lower but still allow multi-hop bridging
|
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||||
// - Thin chains (degree <= 2): every hop counts and flood cost is
|
// - Thin chains (degree <= 2): every hop counts and flood cost is
|
||||||
// minimal, so relay at full incoming depth
|
// minimal, so relay at full incoming depth
|
||||||
// - Announces get a bit more headroom
|
// - Announces (and urgent board posts) get a bit more headroom
|
||||||
let ttlLimit: UInt8 = {
|
let ttlLimit: UInt8 = {
|
||||||
if degree >= highDegreeThreshold {
|
if degree >= highDegreeThreshold {
|
||||||
return max(UInt8(2), min(ttlCap, UInt8(5)))
|
return max(UInt8(2), min(ttlCap, UInt8(5)))
|
||||||
@@ -72,7 +73,7 @@ struct RelayController {
|
|||||||
if degree <= 2 {
|
if degree <= 2 {
|
||||||
return ttlCap
|
return ttlCap
|
||||||
}
|
}
|
||||||
let preferred = UInt8(isAnnounce ? 7 : 6)
|
let preferred = UInt8((isAnnounce || isUrgentBoardPost) ? 7 : 6)
|
||||||
return max(UInt8(2), min(ttlCap, preferred))
|
return max(UInt8(2), min(ttlCap, preferred))
|
||||||
}()
|
}()
|
||||||
let newTTL = ttlLimit &- 1
|
let newTTL = ttlLimit &- 1
|
||||||
|
|||||||
@@ -178,6 +178,10 @@ protocol Transport: AnyObject {
|
|||||||
/// Current mesh graph for the topology map; nil when unsupported.
|
/// Current mesh graph for the topology map; nil when unsupported.
|
||||||
func currentMeshTopology() -> MeshTopologySnapshot?
|
func currentMeshTopology() -> MeshTopologySnapshot?
|
||||||
|
|
||||||
|
// Bulletin board (mesh transports only): broadcast a pre-signed board
|
||||||
|
// payload (post or tombstone) so it spreads over relay and gossip sync.
|
||||||
|
func sendBoardPayload(_ payload: Data)
|
||||||
|
|
||||||
// QR verification (optional for transports)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
@@ -234,6 +238,7 @@ extension Transport {
|
|||||||
}
|
}
|
||||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
|
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
|
||||||
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
|
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
|
||||||
|
func sendBoardPayload(_ payload: Data) {}
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||||
func cancelTransfer(_ transferId: String) {}
|
func cancelTransfer(_ transferId: String) {}
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ final class GossipSyncManager {
|
|||||||
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
||||||
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
||||||
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
||||||
|
// Board posts are few but long-lived (days, until each post's own
|
||||||
|
// expiry), so they get a slow round with their own capacity instead
|
||||||
|
// of competing with the 15-minute message window.
|
||||||
|
var boardCapacity: Int = 200
|
||||||
|
var boardSyncIntervalSeconds: TimeInterval = 60.0
|
||||||
var responseRateLimitMaxResponses: Int = 8
|
var responseRateLimitMaxResponses: Int = 8
|
||||||
var responseRateLimitWindowSeconds: TimeInterval = 30.0
|
var responseRateLimitWindowSeconds: TimeInterval = 30.0
|
||||||
// Prekey bundles: one per peer, own sync round, long freshness so
|
// Prekey bundles: one per peer, own sync round, long freshness so
|
||||||
@@ -92,6 +97,12 @@ final class GossipSyncManager {
|
|||||||
private let archive: GossipMessageArchive?
|
private let archive: GossipMessageArchive?
|
||||||
weak var delegate: Delegate?
|
weak var delegate: Delegate?
|
||||||
|
|
||||||
|
/// Source of raw signed board packets (posts + tombstones). The board
|
||||||
|
/// store is the single owner of board retention (expiry, tombstones,
|
||||||
|
/// caps, persistence), so sync rounds query it instead of keeping a
|
||||||
|
/// second copy here. Must be thread-safe; set before `start()`.
|
||||||
|
var boardPacketsProvider: (() -> [BitchatPacket])?
|
||||||
|
|
||||||
// Storage: broadcast packets by type, and latest announce per sender
|
// Storage: broadcast packets by type, and latest announce per sender
|
||||||
private var messages = PacketStore()
|
private var messages = PacketStore()
|
||||||
private var fragments = PacketStore()
|
private var fragments = PacketStore()
|
||||||
@@ -139,6 +150,9 @@ final class GossipSyncManager {
|
|||||||
if config.prekeyBundleCapacity > 0 && config.prekeyBundleSyncIntervalSeconds > 0 {
|
if config.prekeyBundleCapacity > 0 && config.prekeyBundleSyncIntervalSeconds > 0 {
|
||||||
schedules.append(SyncSchedule(types: .prekeyBundle, interval: config.prekeyBundleSyncIntervalSeconds, lastSent: .distantPast))
|
schedules.append(SyncSchedule(types: .prekeyBundle, interval: config.prekeyBundleSyncIntervalSeconds, lastSent: .distantPast))
|
||||||
}
|
}
|
||||||
|
if config.boardCapacity > 0 && config.boardSyncIntervalSeconds > 0 {
|
||||||
|
schedules.append(SyncSchedule(types: .board, interval: config.boardSyncIntervalSeconds, lastSent: .distantPast))
|
||||||
|
}
|
||||||
syncSchedules = schedules
|
syncSchedules = schedules
|
||||||
|
|
||||||
if archive != nil {
|
if archive != nil {
|
||||||
@@ -181,6 +195,9 @@ final class GossipSyncManager {
|
|||||||
if self.config.prekeyBundleCapacity > 0 && self.config.prekeyBundleSyncIntervalSeconds > 0 {
|
if self.config.prekeyBundleCapacity > 0 && self.config.prekeyBundleSyncIntervalSeconds > 0 {
|
||||||
types.formUnion(.prekeyBundle)
|
types.formUnion(.prekeyBundle)
|
||||||
}
|
}
|
||||||
|
if self.config.boardCapacity > 0 && self.config.boardSyncIntervalSeconds > 0 && self.boardPacketsProvider != nil {
|
||||||
|
types.formUnion(.board)
|
||||||
|
}
|
||||||
self.sendRequestSync(to: peerID, types: types)
|
self.sendRequestSync(to: peerID, types: types)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||||
@@ -515,6 +548,9 @@ final class GossipSyncManager {
|
|||||||
if types.contains(.groupMessage) {
|
if types.contains(.groupMessage) {
|
||||||
candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
|
candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
|
||||||
}
|
}
|
||||||
|
if types.contains(.boardPost) {
|
||||||
|
candidates.append(contentsOf: boardPacketsProvider?() ?? [])
|
||||||
|
}
|
||||||
if candidates.isEmpty {
|
if candidates.isEmpty {
|
||||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter)
|
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)
|
cap = max(1, config.fileTransferCapacity)
|
||||||
} else if types == .prekeyBundle {
|
} else if types == .prekeyBundle {
|
||||||
cap = max(1, config.prekeyBundleCapacity)
|
cap = max(1, config.prekeyBundleCapacity)
|
||||||
|
} else if types == .board {
|
||||||
|
cap = max(1, config.boardCapacity)
|
||||||
} else {
|
} else {
|
||||||
cap = max(1, config.seenCapacity)
|
cap = max(1, config.seenCapacity)
|
||||||
}
|
}
|
||||||
@@ -628,6 +666,9 @@ final class GossipSyncManager {
|
|||||||
// fragment traffic can't crowd messages out of the filter.
|
// fragment traffic can't crowd messages out of the filter.
|
||||||
for index in syncSchedules.indices {
|
for index in syncSchedules.indices {
|
||||||
guard syncSchedules[index].interval > 0 else { continue }
|
guard syncSchedules[index].interval > 0 else { continue }
|
||||||
|
// No board source wired up means nothing to offer or store;
|
||||||
|
// skip the round entirely.
|
||||||
|
if syncSchedules[index].types == .board && boardPacketsProvider == nil { continue }
|
||||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||||
syncSchedules[index].lastSent = now
|
syncSchedules[index].lastSent = now
|
||||||
sendPeriodicSync(for: syncSchedules[index].types)
|
sendPeriodicSync(for: syncSchedules[index].types)
|
||||||
|
|||||||
@@ -36,21 +36,21 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
case .fragment: return 5
|
case .fragment: return 5
|
||||||
case .requestSync: return 6
|
case .requestSync: return 6
|
||||||
case .fileTransfer: return 7
|
case .fileTransfer: return 7
|
||||||
// Bits 8/9 are reserved by other in-flight features.
|
|
||||||
// Extended bits are compat-safe by construction: `toData()` encodes
|
// Extended bits are compat-safe by construction: `toData()` encodes
|
||||||
// the bitfield little-endian with trailing zero bytes trimmed (bit 10
|
// the bitfield little-endian with trailing zero bytes trimmed (bit 10
|
||||||
// widens the wire form from 1 to 2 bytes inside the length-prefixed
|
// widens the wire form from 1 to 2 bytes inside the length-prefixed
|
||||||
// REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while
|
// REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while
|
||||||
// `type(forBit:)` maps unknown bits to nil — so old clients simply
|
// `type(forBit:)` maps unknown bits to nil — so old clients simply
|
||||||
// ignore the group bit and answer with the types they know.
|
// ignore the group bit and answer with the types they know.
|
||||||
|
case .boardPost: return 8
|
||||||
case .groupMessage: return 10
|
case .groupMessage: return 10
|
||||||
// Courier envelopes are directed deposits between trusted peers and
|
// Courier envelopes are directed deposits between trusted peers and
|
||||||
// must never spread via gossip sync.
|
// must never spread via gossip sync.
|
||||||
case .courierEnvelope: return nil
|
case .courierEnvelope: return nil
|
||||||
// Bit 8 is reserved for the board feature. The bitfield is already a
|
// The bitfield is a wire-tolerant little-endian UInt64 (1-8 bytes,
|
||||||
// wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits
|
// unknown high bits ignored by `type(forBit:)`), so bits 8+ need no
|
||||||
// ignored by `type(forBit:)`), so bits 8+ need no format change: old
|
// format change: old clients decode the wider flags and simply never
|
||||||
// clients decode the wider flags and simply never match the new bits.
|
// match the new bits.
|
||||||
case .prekeyBundle: return 9
|
case .prekeyBundle: return 9
|
||||||
// Gateway carriers are ephemeral live traffic (uplinks are directed,
|
// Gateway carriers are ephemeral live traffic (uplinks are directed,
|
||||||
// downlinks are rate-budgeted rebroadcasts); replaying them via sync
|
// downlinks are rate-budgeted rebroadcasts); replaying them via sync
|
||||||
@@ -72,7 +72,10 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
case 5: return .fragment
|
case 5: return .fragment
|
||||||
case 6: return .requestSync
|
case 6: return .requestSync
|
||||||
case 7: return .fileTransfer
|
case 7: return .fileTransfer
|
||||||
// Bit 8 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 9: return .prekeyBundle
|
||||||
case 10: return .groupMessage
|
case 10: return .groupMessage
|
||||||
default:
|
default:
|
||||||
@@ -84,6 +87,7 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
static let message = SyncTypeFlags(messageTypes: [.message])
|
static let message = SyncTypeFlags(messageTypes: [.message])
|
||||||
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
||||||
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
||||||
|
static let board = SyncTypeFlags(messageTypes: [.boardPost])
|
||||||
static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle])
|
static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle])
|
||||||
static let groupMessage = SyncTypeFlags(messageTypes: [.groupMessage])
|
static let groupMessage = SyncTypeFlags(messageTypes: [.groupMessage])
|
||||||
|
|
||||||
|
|||||||
@@ -1220,6 +1220,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// Drop private group keys and rosters (keychain + disk)
|
// Drop private group keys and rosters (keychain + disk)
|
||||||
groupStore.wipe()
|
groupStore.wipe()
|
||||||
|
|
||||||
|
// Drop bulletin-board posts and tombstones (memory and disk); board
|
||||||
|
// posts are signed with our identity key and persist for days.
|
||||||
|
BoardStore.shared.wipe()
|
||||||
|
|
||||||
// Identity manager has cleared persisted identity data above
|
// Identity manager has cleared persisted identity data above
|
||||||
|
|
||||||
// Clear autocomplete state
|
// Clear autocomplete state
|
||||||
|
|||||||
@@ -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.
|
/// Courier envelopes this device is carrying for offline third parties.
|
||||||
@State private var carriedMailCount = 0
|
@State private var carriedMailCount = 0
|
||||||
|
|
||||||
|
/// Bulletin board sheet for the current channel context.
|
||||||
|
@State private var showBoard = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text(verbatim: "bitchat/")
|
Text(verbatim: "bitchat/")
|
||||||
@@ -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 {
|
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||||
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
|
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
|
||||||
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||||
@@ -255,6 +272,13 @@ struct ContentHeaderView: View {
|
|||||||
.environmentObject(locationChannelsModel)
|
.environmentObject(locationChannelsModel)
|
||||||
.environmentObject(peerListModel)
|
.environmentObject(peerListModel)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showBoard) {
|
||||||
|
BoardView(
|
||||||
|
geohash: boardGeohash,
|
||||||
|
senderNickname: appChromeModel.nickname,
|
||||||
|
board: appChromeModel.boardManager
|
||||||
|
)
|
||||||
|
}
|
||||||
.sheet(isPresented: $showLocationNotes, onDismiss: {
|
.sheet(isPresented: $showLocationNotes, onDismiss: {
|
||||||
notesGeohash = nil
|
notesGeohash = nil
|
||||||
}) {
|
}) {
|
||||||
@@ -324,6 +348,15 @@ private extension ContentHeaderView {
|
|||||||
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The board scope for the current channel: the geohash channel's board,
|
||||||
|
/// or the mesh-local board ("") in mesh chat.
|
||||||
|
var boardGeohash: String {
|
||||||
|
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||||
|
return channel.geohash
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether anyone is actually reachable on the current channel — the
|
/// Whether anyone is actually reachable on the current channel — the
|
||||||
/// state the count icon's color encodes visually.
|
/// state the count icon's color encodes visually.
|
||||||
var headerPeersReachable: Bool {
|
var headerPeersReachable: Bool {
|
||||||
|
|||||||
@@ -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 fragment = 0x20 // Single fragment type for large messages
|
||||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
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 prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped)
|
||||||
case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body)
|
case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body)
|
||||||
// Mesh diagnostics
|
// Mesh diagnostics
|
||||||
@@ -48,6 +48,7 @@ public enum MessageType: UInt8 {
|
|||||||
case .noiseEncrypted: return "noiseEncrypted"
|
case .noiseEncrypted: return "noiseEncrypted"
|
||||||
case .fragment: return "fragment"
|
case .fragment: return "fragment"
|
||||||
case .fileTransfer: return "fileTransfer"
|
case .fileTransfer: return "fileTransfer"
|
||||||
|
case .boardPost: return "boardPost"
|
||||||
case .prekeyBundle: return "prekeyBundle"
|
case .prekeyBundle: return "prekeyBundle"
|
||||||
case .groupMessage: return "groupMessage"
|
case .groupMessage: return "groupMessage"
|
||||||
case .ping: return "ping"
|
case .ping: return "ping"
|
||||||
|
|||||||
Reference in New Issue
Block a user