mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
Geohash bulletin board: persistent signed notices over mesh sync (#1379)
* Add geohash bulletin board: persistent signed notices over mesh sync
New MessageType 0x23 carries TLV-encoded board posts and tombstones,
self-signed with the author's Ed25519 key ("bitchat-board-v1" /
"bitchat-board-del-v1" domains) so notices verify without the author
present. BoardStore persists raw signed packets under Application
Support/board/ (200 posts, 5 per author, oldest evicted; expiry sweep;
tombstones retained until the deleted post's original expiry) and is
wiped on panic.
Board packets join gossip sync as bit 8 of the existing variable-length
types bitfield (a second byte old decoders already accept and ignore),
with a 60s round and its own capacity, served straight from the board
store so retention has one owner. Posts relay like broadcasts; urgent
posts get the announce-class TTL cap.
UI: a pin button in the header opens the board for the current channel
(geohash board, or mesh-local board), with urgent-pinned newest-first
listing, compose with urgent toggle and 1/3/7-day expiry, and
swipe-delete on own posts. Geohash posts also publish one-way as
Nostr kind-1 location notes when relays are reachable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Board: bound orphan tombstones and reject future-dated posts at ingest
Two hardening fixes from Codex review of the geohash bulletin board:
- Orphan tombstones (P1): retention was derived solely from the
sender-chosen deletedAt, so self-signed tombstones for unseen post IDs
with far-future deletedAt persisted and re-entered sync unboundedly.
Retention is now also clamped to receive time (now + 7d + 1h skew --
no post can outlive that), and orphans are capped at 100 globally and
5 per author key with oldest-received evicted first. Matched
tombstones and disk restores keep their existing behavior.
- Future-dated posts (P2): ingest only checked expiresAt > now, letting
posts dated years ahead sort above honest posts and squat the 200
global slots without ever pruning. The single ingest chokepoint
(radio, sync, and disk restore all funnel through it) now rejects
createdAt > now + 1h skew and expiresAt > now + 7d + 1h skew; the
decoder's span rule is unchanged.
Adds tests for the skew boundary, far-future expiry, receive-time
tombstone clamping, orphan caps/eviction, and matched-tombstone
exemption.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
ede6368296
commit
60be88a4f5
@@ -18,6 +18,9 @@ final class AppChromeModel: ObservableObject {
|
||||
private let chatViewModel: ChatViewModel
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||
|
||||
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.nickname = chatViewModel.nickname
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//
|
||||
// BoardPackets.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - Board wire format (MessageType.boardPost payloads)
|
||||
//
|
||||
// TLV layout (type u8, length u16 big-endian, value), matching REQUEST_SYNC:
|
||||
// - 0x01: kind (u8) — 0x01 post, 0x02 tombstone
|
||||
// - 0x02: postID (16B random)
|
||||
// - 0x03: geohash (UTF-8, empty = mesh-local board, max 12 chars)
|
||||
// - 0x04: content (UTF-8, 1...512 bytes) [post]
|
||||
// - 0x05: authorSigningKey (32B Ed25519 public key)
|
||||
// - 0x06: authorNickname (UTF-8, max 64 bytes)
|
||||
// - 0x07: createdAt (u64 big-endian, ms) [post]
|
||||
// - 0x08: expiresAt (u64 big-endian, ms, max 7 days after createdAt) [post]
|
||||
// - 0x09: flags (u8, bit0 = urgent) [post]
|
||||
// - 0x0A: signature (64B Ed25519)
|
||||
// - 0x0B: deletedAt (u64 big-endian, ms) [tombstone]
|
||||
// Unknown TLVs are skipped for forward compatibility.
|
||||
|
||||
enum BoardWireConstants {
|
||||
static let postIDLength = 16
|
||||
static let signingKeyLength = 32
|
||||
static let signatureLength = 64
|
||||
static let contentMaxBytes = 512
|
||||
static let nicknameMaxBytes = 64
|
||||
static let geohashMaxLength = 12
|
||||
/// Posts may live at most 7 days past their creation timestamp.
|
||||
static let maxLifetimeMs: UInt64 = 7 * 24 * 60 * 60 * 1000
|
||||
static let postSigningContext = "bitchat-board-v1"
|
||||
static let tombstoneSigningContext = "bitchat-board-del-v1"
|
||||
static let geohashAlphabet = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
}
|
||||
|
||||
private enum BoardTLVType: UInt8 {
|
||||
case kind = 0x01
|
||||
case postID = 0x02
|
||||
case geohash = 0x03
|
||||
case content = 0x04
|
||||
case authorSigningKey = 0x05
|
||||
case authorNickname = 0x06
|
||||
case createdAt = 0x07
|
||||
case expiresAt = 0x08
|
||||
case flags = 0x09
|
||||
case signature = 0x0A
|
||||
case deletedAt = 0x0B
|
||||
}
|
||||
|
||||
private enum BoardWireKind: UInt8 {
|
||||
case post = 0x01
|
||||
case tombstone = 0x02
|
||||
}
|
||||
|
||||
/// A signed, persistent bulletin-board notice.
|
||||
struct BoardPostPacket: Equatable {
|
||||
let postID: Data
|
||||
/// Empty string scopes the post to the mesh-local board.
|
||||
let geohash: String
|
||||
let content: String
|
||||
let authorSigningKey: Data
|
||||
let authorNickname: String
|
||||
let createdAt: UInt64
|
||||
let expiresAt: UInt64
|
||||
let flags: UInt8
|
||||
let signature: Data
|
||||
|
||||
static let urgentFlag: UInt8 = 0x01
|
||||
|
||||
var isUrgent: Bool { flags & Self.urgentFlag != 0 }
|
||||
|
||||
/// Canonical bytes covered by the Ed25519 signature. Variable-length
|
||||
/// fields are length-prefixed so no two field combinations can collide.
|
||||
static func signingBytes(
|
||||
postID: Data,
|
||||
geohash: String,
|
||||
content: String,
|
||||
authorSigningKey: Data,
|
||||
authorNickname: String,
|
||||
createdAt: UInt64,
|
||||
expiresAt: UInt64,
|
||||
flags: UInt8
|
||||
) -> Data {
|
||||
var out = Data()
|
||||
BoardWireEncoding.appendContext(BoardWireConstants.postSigningContext, to: &out)
|
||||
out.append(postID)
|
||||
BoardWireEncoding.appendLengthPrefixed(Data(geohash.utf8), to: &out)
|
||||
BoardWireEncoding.appendLengthPrefixed(Data(content.utf8), to: &out)
|
||||
out.append(authorSigningKey)
|
||||
BoardWireEncoding.appendLengthPrefixed(Data(authorNickname.utf8), to: &out)
|
||||
BoardWireEncoding.appendUInt64(createdAt, to: &out)
|
||||
BoardWireEncoding.appendUInt64(expiresAt, to: &out)
|
||||
out.append(flags)
|
||||
return out
|
||||
}
|
||||
|
||||
var signingBytes: Data {
|
||||
Self.signingBytes(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: authorSigningKey,
|
||||
authorNickname: authorNickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags
|
||||
)
|
||||
}
|
||||
|
||||
func verifySignature() -> Bool {
|
||||
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// A signed deletion marker. Only the author's key can produce one; receivers
|
||||
/// keep it until the post's original expiry so the delete outruns the post.
|
||||
struct BoardTombstonePacket: Equatable {
|
||||
let postID: Data
|
||||
let authorSigningKey: Data
|
||||
let deletedAt: UInt64
|
||||
let signature: Data
|
||||
|
||||
static func signingBytes(postID: Data, deletedAt: UInt64) -> Data {
|
||||
var out = Data()
|
||||
BoardWireEncoding.appendContext(BoardWireConstants.tombstoneSigningContext, to: &out)
|
||||
out.append(postID)
|
||||
BoardWireEncoding.appendUInt64(deletedAt, to: &out)
|
||||
return out
|
||||
}
|
||||
|
||||
var signingBytes: Data {
|
||||
Self.signingBytes(postID: postID, deletedAt: deletedAt)
|
||||
}
|
||||
|
||||
func verifySignature() -> Bool {
|
||||
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoded board payload: either a live post or a tombstone.
|
||||
enum BoardWire: Equatable {
|
||||
case post(BoardPostPacket)
|
||||
case tombstone(BoardTombstonePacket)
|
||||
|
||||
func encode() -> Data {
|
||||
var out = Data()
|
||||
func putTLV(_ t: BoardTLVType, _ v: Data) {
|
||||
out.append(t.rawValue)
|
||||
let len = UInt16(v.count)
|
||||
out.append(UInt8((len >> 8) & 0xFF))
|
||||
out.append(UInt8(len & 0xFF))
|
||||
out.append(v)
|
||||
}
|
||||
switch self {
|
||||
case .post(let post):
|
||||
putTLV(.kind, Data([BoardWireKind.post.rawValue]))
|
||||
putTLV(.postID, post.postID)
|
||||
putTLV(.geohash, Data(post.geohash.utf8))
|
||||
putTLV(.content, Data(post.content.utf8))
|
||||
putTLV(.authorSigningKey, post.authorSigningKey)
|
||||
putTLV(.authorNickname, Data(post.authorNickname.utf8))
|
||||
putTLV(.createdAt, BoardWireEncoding.uint64Data(post.createdAt))
|
||||
putTLV(.expiresAt, BoardWireEncoding.uint64Data(post.expiresAt))
|
||||
putTLV(.flags, Data([post.flags]))
|
||||
putTLV(.signature, post.signature)
|
||||
case .tombstone(let tombstone):
|
||||
putTLV(.kind, Data([BoardWireKind.tombstone.rawValue]))
|
||||
putTLV(.postID, tombstone.postID)
|
||||
putTLV(.authorSigningKey, tombstone.authorSigningKey)
|
||||
putTLV(.deletedAt, BoardWireEncoding.uint64Data(tombstone.deletedAt))
|
||||
putTLV(.signature, tombstone.signature)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// Structural decode; the caller must still verify the signature before
|
||||
/// ingesting (`verifySignature()`).
|
||||
static func decode(from data: Data) -> BoardWire? {
|
||||
var off = data.startIndex
|
||||
var kind: BoardWireKind?
|
||||
var postID: Data?
|
||||
var geohash: String?
|
||||
var content: String?
|
||||
var contentBytes = 0
|
||||
var authorSigningKey: Data?
|
||||
var authorNickname: String?
|
||||
var nicknameBytes = 0
|
||||
var createdAt: UInt64?
|
||||
var expiresAt: UInt64?
|
||||
var flags: UInt8?
|
||||
var signature: Data?
|
||||
var deletedAt: UInt64?
|
||||
|
||||
while off + 3 <= data.endIndex {
|
||||
let t = data[off]; off += 1
|
||||
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
|
||||
guard off + len <= data.endIndex else { return nil }
|
||||
let v = data.subdata(in: off..<(off + len)); off += len
|
||||
switch BoardTLVType(rawValue: t) {
|
||||
case .kind:
|
||||
guard v.count == 1 else { return nil }
|
||||
kind = BoardWireKind(rawValue: v[v.startIndex])
|
||||
case .postID:
|
||||
guard v.count == BoardWireConstants.postIDLength else { return nil }
|
||||
postID = v
|
||||
case .geohash:
|
||||
guard v.count <= BoardWireConstants.geohashMaxLength else { return nil }
|
||||
geohash = String(data: v, encoding: .utf8)
|
||||
case .content:
|
||||
guard v.count <= BoardWireConstants.contentMaxBytes else { return nil }
|
||||
contentBytes = v.count
|
||||
content = String(data: v, encoding: .utf8)
|
||||
case .authorSigningKey:
|
||||
guard v.count == BoardWireConstants.signingKeyLength else { return nil }
|
||||
authorSigningKey = v
|
||||
case .authorNickname:
|
||||
guard v.count <= BoardWireConstants.nicknameMaxBytes else { return nil }
|
||||
nicknameBytes = v.count
|
||||
authorNickname = String(data: v, encoding: .utf8)
|
||||
case .createdAt:
|
||||
createdAt = BoardWireEncoding.uint64(from: v)
|
||||
case .expiresAt:
|
||||
expiresAt = BoardWireEncoding.uint64(from: v)
|
||||
case .flags:
|
||||
guard v.count == 1 else { return nil }
|
||||
flags = v[v.startIndex]
|
||||
case .signature:
|
||||
guard v.count == BoardWireConstants.signatureLength else { return nil }
|
||||
signature = v
|
||||
case .deletedAt:
|
||||
deletedAt = BoardWireEncoding.uint64(from: v)
|
||||
case nil:
|
||||
continue // forward compatible; ignore unknown TLVs
|
||||
}
|
||||
}
|
||||
|
||||
guard let postID, let authorSigningKey, let signature else { return nil }
|
||||
|
||||
switch kind {
|
||||
case .post:
|
||||
guard let geohash, let content, let authorNickname,
|
||||
let createdAt, let expiresAt, let flags,
|
||||
contentBytes >= 1,
|
||||
nicknameBytes <= BoardWireConstants.nicknameMaxBytes,
|
||||
isValidGeohashField(geohash),
|
||||
expiresAt > createdAt,
|
||||
expiresAt - createdAt <= BoardWireConstants.maxLifetimeMs else {
|
||||
return nil
|
||||
}
|
||||
return .post(BoardPostPacket(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: authorSigningKey,
|
||||
authorNickname: authorNickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags,
|
||||
signature: signature
|
||||
))
|
||||
case .tombstone:
|
||||
guard let deletedAt else { return nil }
|
||||
return .tombstone(BoardTombstonePacket(
|
||||
postID: postID,
|
||||
authorSigningKey: authorSigningKey,
|
||||
deletedAt: deletedAt,
|
||||
signature: signature
|
||||
))
|
||||
case nil:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func verifySignature() -> Bool {
|
||||
switch self {
|
||||
case .post(let post): return post.verifySignature()
|
||||
case .tombstone(let tombstone): return tombstone.verifySignature()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap TLV peek for relay policy: is this payload an urgent post?
|
||||
/// Avoids a full decode on the hot relay path.
|
||||
static func urgentFlag(in data: Data) -> Bool {
|
||||
var off = data.startIndex
|
||||
while off + 3 <= data.endIndex {
|
||||
let t = data[off]; off += 1
|
||||
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
|
||||
guard off + len <= data.endIndex else { return false }
|
||||
if t == BoardTLVType.flags.rawValue, len == 1 {
|
||||
return data[off] & BoardPostPacket.urgentFlag != 0
|
||||
}
|
||||
off += len
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Empty geohash = mesh-local board; otherwise 1-12 chars of the geohash
|
||||
/// base32 alphabet.
|
||||
private static func isValidGeohashField(_ geohash: String) -> Bool {
|
||||
geohash.isEmpty || geohash.allSatisfy { BoardWireConstants.geohashAlphabet.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
enum BoardWireEncoding {
|
||||
static func appendContext(_ context: String, to out: inout Data) {
|
||||
let bytes = Data(context.utf8)
|
||||
out.append(UInt8(min(bytes.count, 255)))
|
||||
out.append(bytes.prefix(255))
|
||||
}
|
||||
|
||||
static func appendLengthPrefixed(_ value: Data, to out: inout Data) {
|
||||
let len = UInt16(min(value.count, Int(UInt16.max)))
|
||||
out.append(UInt8((len >> 8) & 0xFF))
|
||||
out.append(UInt8(len & 0xFF))
|
||||
out.append(value.prefix(Int(UInt16.max)))
|
||||
}
|
||||
|
||||
static func appendUInt64(_ value: UInt64, to out: inout Data) {
|
||||
var be = value.bigEndian
|
||||
withUnsafeBytes(of: &be) { out.append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
static func uint64Data(_ value: UInt64) -> Data {
|
||||
var out = Data()
|
||||
appendUInt64(value, to: &out)
|
||||
return out
|
||||
}
|
||||
|
||||
static func uint64(from data: Data) -> UInt64? {
|
||||
guard data.count == 8 else { return nil }
|
||||
var value: UInt64 = 0
|
||||
for byte in data { value = (value << 8) | UInt64(byte) }
|
||||
return value
|
||||
}
|
||||
|
||||
static func verify(signature: Data, over message: Data, publicKey: Data) -> Bool {
|
||||
guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else {
|
||||
return false
|
||||
}
|
||||
return key.isValidSignature(signature, for: message)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ struct BLEReceivePipeline {
|
||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||
isAnnounce: packet.type == MessageType.announce.rawValue,
|
||||
isRequestSync: packet.type == MessageType.requestSync.rawValue,
|
||||
// Board posts relay like broadcast messages; urgent ones get the
|
||||
// announce-class TTL headroom so alerts travel the extra hop.
|
||||
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
|
||||
&& BoardWire.urgentFlag(in: packet.payload),
|
||||
degree: degree,
|
||||
highDegreeThreshold: highDegreeThreshold
|
||||
)
|
||||
|
||||
@@ -53,6 +53,8 @@ final class BLEService: NSObject {
|
||||
// reject. Injectable for tests; main-actor policy because favorites live
|
||||
// on the main actor.
|
||||
var courierStore: CourierStore = .shared
|
||||
// Bulletin-board posts this device carries; injectable for tests.
|
||||
var boardStore: BoardStore = .shared
|
||||
var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in
|
||||
if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite }
|
||||
return isVerifiedPeer ? .verified : nil
|
||||
@@ -297,6 +299,14 @@ final class BLEService: NSObject {
|
||||
let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive)
|
||||
manager.delegate = self
|
||||
// Board posts sync from the board store (their retention owner) so
|
||||
// deleted/expired posts drop out of rounds immediately. Real sessions
|
||||
// only, matching the archive: unit tests stay hermetic.
|
||||
if meshBackgroundEnabled {
|
||||
manager.boardPacketsProvider = { [weak self] in
|
||||
self?.boardStore.syncCandidates() ?? []
|
||||
}
|
||||
}
|
||||
// Only start the periodic sync timers when real Bluetooth exists. In unit
|
||||
// tests there is no mesh to sync with, and the periodic sign/broadcast
|
||||
// churn just keeps the process busy and aggravates flaky exit hangs.
|
||||
@@ -3210,6 +3220,10 @@ extension BLEService {
|
||||
case .courierEnvelope:
|
||||
handleCourierEnvelope(packet, from: peerID)
|
||||
|
||||
case .boardPost:
|
||||
// Invalid or deleted posts must not spread; skip the relay step.
|
||||
guard handleBoardPost(packet, from: senderID) else { return }
|
||||
|
||||
case .leave:
|
||||
handleLeave(packet, from: senderID)
|
||||
|
||||
@@ -3382,6 +3396,63 @@ extension BLEService {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Board (geohash bulletin board)
|
||||
|
||||
/// Validates and stores an incoming board post or tombstone. Returns
|
||||
/// whether the packet is worth relaying onward.
|
||||
private func handleBoardPost(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||
guard let wire = BoardWire.decode(from: packet.payload) else {
|
||||
SecureLogger.warning("⚠️ Malformed board packet from \(peerID.id.prefix(8))…", category: .session)
|
||||
return false
|
||||
}
|
||||
// Posts are self-authenticating: the payload embeds the author's
|
||||
// Ed25519 key and signature, so verification does not depend on the
|
||||
// author still being around to announce.
|
||||
guard wire.verifySignature() else {
|
||||
if logRateLimiter.shouldLog(key: "board-sig:\(peerID.id)") {
|
||||
SecureLogger.warning("🚫 Dropping board packet with invalid signature from \(peerID.id.prefix(8))…", category: .security)
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch boardStore.ingest(wire, packet: packet) {
|
||||
case .accepted, .duplicate:
|
||||
return true
|
||||
case .rejected:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Broadcasts a pre-signed board payload (post or tombstone) built by the
|
||||
/// board manager, and ingests it locally so it shows up on our own board
|
||||
/// and joins gossip sync immediately.
|
||||
func sendBoardPayload(_ payload: Data) {
|
||||
guard let wire = BoardWire.decode(from: payload), wire.verifySignature() else {
|
||||
SecureLogger.error("❌ Refusing to send invalid board payload", category: .session)
|
||||
return
|
||||
}
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let basePacket = BitchatPacket(
|
||||
type: MessageType.boardPost.rawValue,
|
||||
senderID: Data(hexString: self.myPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: self.messageTTL
|
||||
)
|
||||
guard let signedPacket = self.noiseService.signPacket(basePacket) else {
|
||||
SecureLogger.error("❌ Failed to sign board packet", category: .security)
|
||||
return
|
||||
}
|
||||
// Pre-mark our own broadcast as processed to avoid handling a relayed self copy
|
||||
let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
|
||||
self.messageDeduplicator.markProcessed(dedupID)
|
||||
self.boardStore.ingest(wire, packet: signedPacket)
|
||||
self.broadcastPacket(signedPacket)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
|
||||
private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
// REQUEST_SYNC is link-local by design (always sent with ttl 0): a
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//
|
||||
// BoardManager.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// UI-facing coordinator for the bulletin board: builds and signs posts and
|
||||
/// tombstones with the device's Noise signing key, hands them to the mesh
|
||||
/// transport, and mirrors the store's live posts for SwiftUI.
|
||||
@MainActor
|
||||
final class BoardManager: ObservableObject {
|
||||
/// Live posts across all boards, newest state from the store.
|
||||
@Published private(set) var posts: [BoardPostPacket] = []
|
||||
|
||||
private let transport: Transport
|
||||
private let store: BoardStore
|
||||
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String) -> Void
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(
|
||||
transport: Transport,
|
||||
store: BoardStore = .shared,
|
||||
publishToNostr: ((String, String, String) -> Void)? = nil
|
||||
) {
|
||||
self.transport = transport
|
||||
self.store = store
|
||||
self.publishToNostr = publishToNostr ?? Self.livePublishToNostr
|
||||
cancellable = store.$postsSnapshot
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] snapshot in
|
||||
self?.posts = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
/// Posts for one board context, urgent first, then newest first.
|
||||
func posts(forGeohash geohash: String) -> [BoardPostPacket] {
|
||||
posts
|
||||
.filter { $0.geohash == geohash }
|
||||
.sorted {
|
||||
if $0.isUrgent != $1.isUrgent { return $0.isUrgent }
|
||||
return $0.createdAt > $1.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
func isOwnPost(_ post: BoardPostPacket) -> Bool {
|
||||
let key = transport.noiseSigningPublicKeyData()
|
||||
return !key.isEmpty && key == post.authorSigningKey
|
||||
}
|
||||
|
||||
/// Creates, signs, and broadcasts a board post. Returns false when the
|
||||
/// content is empty/oversized or signing fails.
|
||||
@discardableResult
|
||||
func createPost(
|
||||
content: String,
|
||||
geohash: String,
|
||||
urgent: Bool,
|
||||
expiryDays: Int,
|
||||
nickname: String
|
||||
) -> Bool {
|
||||
guard let trimmed = content.trimmedOrNilIfEmpty,
|
||||
trimmed.utf8.count <= BoardWireConstants.contentMaxBytes else {
|
||||
return false
|
||||
}
|
||||
let signingKey = transport.noiseSigningPublicKeyData()
|
||||
guard signingKey.count == BoardWireConstants.signingKeyLength else { return false }
|
||||
|
||||
var cleanNickname = nickname
|
||||
while cleanNickname.utf8.count > BoardWireConstants.nicknameMaxBytes {
|
||||
cleanNickname.removeLast()
|
||||
}
|
||||
let createdAt = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let lifetimeMs = min(
|
||||
UInt64(max(1, expiryDays)) * 24 * 60 * 60 * 1000,
|
||||
BoardWireConstants.maxLifetimeMs
|
||||
)
|
||||
let expiresAt = createdAt + lifetimeMs
|
||||
let flags: UInt8 = urgent ? BoardPostPacket.urgentFlag : 0
|
||||
var postID = Data(count: BoardWireConstants.postIDLength)
|
||||
let status = postID.withUnsafeMutableBytes { buffer -> Int32 in
|
||||
guard let base = buffer.baseAddress else { return -1 }
|
||||
return SecRandomCopyBytes(kSecRandomDefault, buffer.count, base)
|
||||
}
|
||||
guard status == errSecSuccess else { return false }
|
||||
|
||||
let signingBytes = BoardPostPacket.signingBytes(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: trimmed,
|
||||
authorSigningKey: signingKey,
|
||||
authorNickname: cleanNickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags
|
||||
)
|
||||
guard let signature = transport.noiseSignData(signingBytes) else {
|
||||
SecureLogger.error("Board: failed to sign post", category: .session)
|
||||
return false
|
||||
}
|
||||
let post = BoardPostPacket(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: trimmed,
|
||||
authorSigningKey: signingKey,
|
||||
authorNickname: cleanNickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags,
|
||||
signature: signature
|
||||
)
|
||||
transport.sendBoardPayload(BoardWire.post(post).encode())
|
||||
|
||||
// One-way Nostr bridge (v1): geohash posts also go out as kind-1
|
||||
// location notes so online users see them. No inbound merge yet.
|
||||
if !geohash.isEmpty {
|
||||
publishToNostr(trimmed, geohash, cleanNickname)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Signs and broadcasts a tombstone for one of our own posts.
|
||||
@discardableResult
|
||||
func deletePost(_ post: BoardPostPacket) -> Bool {
|
||||
guard isOwnPost(post) else { return false }
|
||||
let deletedAt = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let signingBytes = BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt)
|
||||
guard let signature = transport.noiseSignData(signingBytes) else {
|
||||
SecureLogger.error("Board: failed to sign tombstone", category: .session)
|
||||
return false
|
||||
}
|
||||
let tombstone = BoardTombstonePacket(
|
||||
postID: post.postID,
|
||||
authorSigningKey: post.authorSigningKey,
|
||||
deletedAt: deletedAt,
|
||||
signature: signature
|
||||
)
|
||||
transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
|
||||
return true
|
||||
}
|
||||
|
||||
private static func livePublishToNostr(content: String, geohash: String, nickname: String) {
|
||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session)
|
||||
return
|
||||
}
|
||||
do {
|
||||
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
|
||||
let event = try NostrProtocol.createGeohashTextNote(
|
||||
content: content,
|
||||
geohash: geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: nickname
|
||||
)
|
||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||
} catch {
|
||||
SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
//
|
||||
// BoardStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// Outcome of feeding a board packet into the store, so the transport can
|
||||
/// decide whether the packet is still worth relaying.
|
||||
enum BoardIngestResult {
|
||||
/// New post or tombstone accepted (or a quota rejected it locally while
|
||||
/// it remains valid for other devices).
|
||||
case accepted
|
||||
/// Already known; nothing changed.
|
||||
case duplicate
|
||||
/// Invalid, expired, or deleted; do not relay.
|
||||
case rejected
|
||||
}
|
||||
|
||||
/// Persistent storage for bulletin-board posts and their tombstones.
|
||||
///
|
||||
/// Posts are signed public notices designed to outlive chat: they stay on
|
||||
/// disk until their author-chosen expiry (max 7 days) and re-enter gossip
|
||||
/// sync after a restart. Tombstones are retained until the deleted post's
|
||||
/// original expiry so the delete keeps outrunning stale copies of the post.
|
||||
///
|
||||
/// The on-disk format is the raw signed packets themselves (like
|
||||
/// `GossipMessageArchive`); state is rebuilt by re-verifying and re-ingesting
|
||||
/// them on launch. Wiped on panic.
|
||||
final class BoardStore {
|
||||
enum Limits {
|
||||
static let maxPosts = 200
|
||||
static let maxPostsPerAuthor = 5
|
||||
/// Retention for a tombstone whose post we never saw: we cannot know
|
||||
/// the original expiry, so cap at the max post lifetime.
|
||||
static let orphanTombstoneLifetimeMs = BoardWireConstants.maxLifetimeMs
|
||||
/// Orphan tombstones name posts nobody here has seen, so their volume
|
||||
/// is entirely sender-controlled; cap them like posts.
|
||||
static let maxOrphanTombstones = 100
|
||||
static let maxOrphanTombstonesPerAuthor = 5
|
||||
/// Allowance for clock skew between peers when judging received
|
||||
/// timestamps against local time.
|
||||
static let clockSkewMs: UInt64 = 60 * 60 * 1000
|
||||
}
|
||||
|
||||
private struct StoredPost {
|
||||
let post: BoardPostPacket
|
||||
let packet: BitchatPacket
|
||||
let rawPacket: Data
|
||||
}
|
||||
|
||||
private struct StoredTombstone {
|
||||
let tombstone: BoardTombstonePacket
|
||||
let packet: BitchatPacket
|
||||
let rawPacket: Data
|
||||
let retainUntil: UInt64
|
||||
/// True when no matching post was known at ingest time; only these
|
||||
/// count against the orphan caps.
|
||||
let isOrphan: Bool
|
||||
}
|
||||
|
||||
/// On-disk entry: the raw signed packet, plus the retention deadline for
|
||||
/// tombstones (derived from the deleted post's original expiry, which is
|
||||
/// no longer recoverable once the post is gone).
|
||||
private struct PersistedEntry: Codable {
|
||||
let packet: Data
|
||||
let retainUntil: UInt64?
|
||||
}
|
||||
|
||||
static let shared = BoardStore()
|
||||
|
||||
/// Live posts, published on the main thread for the board UI.
|
||||
@Published private(set) var postsSnapshot: [BoardPostPacket] = []
|
||||
|
||||
private var posts: [StoredPost] = []
|
||||
private var tombstones: [StoredTombstone] = []
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.board.store")
|
||||
private let fileURL: URL?
|
||||
private let now: () -> Date
|
||||
|
||||
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
||||
/// when `persistsToDisk` is false.
|
||||
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
|
||||
self.now = now
|
||||
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
// MARK: - Ingest
|
||||
|
||||
/// Ingest a board packet whose payload decodes to `wire`. The caller must
|
||||
/// have verified the wire signature already (`BoardWire.verifySignature`).
|
||||
@discardableResult
|
||||
func ingest(_ wire: BoardWire, packet: BitchatPacket) -> BoardIngestResult {
|
||||
guard let rawPacket = packet.toBinaryData(padding: false) else { return .rejected }
|
||||
let nowMs = currentMs()
|
||||
return queue.sync {
|
||||
let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
|
||||
if result == .accepted {
|
||||
persistLocked()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
/// Live posts scoped to one board (geohash, or "" for the mesh board).
|
||||
func posts(forGeohash geohash: String) -> [BoardPostPacket] {
|
||||
let nowMs = currentMs()
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(nowMs: nowMs)
|
||||
return posts.map(\.post).filter { $0.geohash == geohash }
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw signed packets (posts and live tombstones) for gossip sync rounds.
|
||||
func syncCandidates() -> [BitchatPacket] {
|
||||
let nowMs = currentMs()
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(nowMs: nowMs)
|
||||
return posts.map(\.packet) + tombstones.map(\.packet)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Maintenance
|
||||
|
||||
func pruneExpired() {
|
||||
let nowMs = currentMs()
|
||||
queue.sync {
|
||||
pruneExpiredLocked(nowMs: nowMs)
|
||||
persistLocked()
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic wipe: drop all board data from memory and disk.
|
||||
func wipe() {
|
||||
queue.sync {
|
||||
posts.removeAll()
|
||||
tombstones.removeAll()
|
||||
if let fileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
publishSnapshotLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals (call only on `queue`)
|
||||
|
||||
private func ingestLocked(
|
||||
_ wire: BoardWire,
|
||||
packet: BitchatPacket,
|
||||
rawPacket: Data,
|
||||
nowMs: UInt64,
|
||||
retainUntilOverride: UInt64? = nil
|
||||
) -> BoardIngestResult {
|
||||
pruneExpiredLocked(nowMs: nowMs)
|
||||
switch wire {
|
||||
case .post(let post):
|
||||
return ingestPostLocked(post, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
|
||||
case .tombstone(let tombstone):
|
||||
return ingestTombstoneLocked(tombstone, packet: packet, rawPacket: rawPacket, nowMs: nowMs, retainUntilOverride: retainUntilOverride)
|
||||
}
|
||||
}
|
||||
|
||||
private func ingestPostLocked(_ post: BoardPostPacket, packet: BitchatPacket, rawPacket: Data, nowMs: UInt64) -> BoardIngestResult {
|
||||
guard post.expiresAt > nowMs else { return .rejected }
|
||||
// Receive-time sanity (this is the single chokepoint for radio, sync,
|
||||
// and disk restores): the decoder only enforces the createdAt to
|
||||
// expiresAt span, so a forged future createdAt would sort ahead of
|
||||
// honest posts and hold a store slot without ever pruning.
|
||||
guard post.createdAt <= nowMs &+ Limits.clockSkewMs,
|
||||
post.expiresAt <= nowMs &+ BoardWireConstants.maxLifetimeMs &+ Limits.clockSkewMs else {
|
||||
return .rejected
|
||||
}
|
||||
if tombstones.contains(where: { $0.tombstone.postID == post.postID && $0.tombstone.authorSigningKey == post.authorSigningKey }) {
|
||||
return .rejected
|
||||
}
|
||||
guard !posts.contains(where: { $0.post.postID == post.postID }) else { return .duplicate }
|
||||
|
||||
posts.append(StoredPost(post: post, packet: packet, rawPacket: rawPacket))
|
||||
|
||||
// Per-author cap, then global cap; oldest posts are evicted first.
|
||||
let authorPosts = posts.filter { $0.post.authorSigningKey == post.authorSigningKey }
|
||||
if authorPosts.count > Limits.maxPostsPerAuthor {
|
||||
evictOldestLocked(from: authorPosts, keep: Limits.maxPostsPerAuthor)
|
||||
}
|
||||
if posts.count > Limits.maxPosts {
|
||||
evictOldestLocked(from: posts, keep: Limits.maxPosts)
|
||||
}
|
||||
publishSnapshotLocked()
|
||||
// Even when the new post itself was the eviction victim it stays
|
||||
// valid mesh-wide; peers with room should still receive it.
|
||||
return .accepted
|
||||
}
|
||||
|
||||
private func ingestTombstoneLocked(
|
||||
_ tombstone: BoardTombstonePacket,
|
||||
packet: BitchatPacket,
|
||||
rawPacket: Data,
|
||||
nowMs: UInt64,
|
||||
retainUntilOverride: UInt64? = nil
|
||||
) -> BoardIngestResult {
|
||||
guard !tombstones.contains(where: { $0.tombstone.postID == tombstone.postID }) else { return .duplicate }
|
||||
|
||||
// Cap retention by both the claimed deletion time (so a doctored file
|
||||
// cannot pin a tombstone past any legal expiry) and the receive time:
|
||||
// deletedAt is sender-chosen, so a far-future value must not retain
|
||||
// the tombstone longer than any post still able to arrive could live.
|
||||
let maxRetain = min(
|
||||
tombstone.deletedAt &+ Limits.orphanTombstoneLifetimeMs,
|
||||
nowMs &+ Limits.orphanTombstoneLifetimeMs &+ Limits.clockSkewMs
|
||||
)
|
||||
let retainUntil: UInt64
|
||||
let isOrphan: Bool
|
||||
if let index = posts.firstIndex(where: { $0.post.postID == tombstone.postID }) {
|
||||
let target = posts[index].post
|
||||
// Only the author's key can delete: the tombstone signature was
|
||||
// already verified against its embedded key, so it suffices to
|
||||
// require that key to be the post's author key.
|
||||
guard target.authorSigningKey == tombstone.authorSigningKey else { return .rejected }
|
||||
retainUntil = target.expiresAt
|
||||
isOrphan = false
|
||||
posts.remove(at: index)
|
||||
publishSnapshotLocked()
|
||||
} else if let retainUntilOverride {
|
||||
// Restored from disk: the post is long gone, so trust the
|
||||
// retention deadline recorded when the delete was first applied.
|
||||
// Orphans were already capped when first ingested off the air.
|
||||
retainUntil = min(retainUntilOverride, maxRetain)
|
||||
isOrphan = false
|
||||
} else {
|
||||
// Post unknown (tombstone raced ahead); keep it around so the
|
||||
// post is suppressed if it arrives later.
|
||||
retainUntil = maxRetain
|
||||
isOrphan = true
|
||||
}
|
||||
guard retainUntil > nowMs else { return .rejected }
|
||||
tombstones.append(StoredTombstone(tombstone: tombstone, packet: packet, rawPacket: rawPacket, retainUntil: retainUntil, isOrphan: isOrphan))
|
||||
if isOrphan {
|
||||
enforceOrphanTombstoneCapsLocked(author: tombstone.authorSigningKey)
|
||||
}
|
||||
// Like posts, a locally evicted tombstone stays valid mesh-wide.
|
||||
return .accepted
|
||||
}
|
||||
|
||||
/// Orphan tombstones reference posts we never saw, so a peer can mint
|
||||
/// unlimited valid ones for random IDs; bound them per author and
|
||||
/// globally, evicting the oldest received first (array order).
|
||||
private func enforceOrphanTombstoneCapsLocked(author: Data) {
|
||||
let authorOrphans = tombstones.filter { $0.isOrphan && $0.tombstone.authorSigningKey == author }
|
||||
if authorOrphans.count > Limits.maxOrphanTombstonesPerAuthor {
|
||||
removeTombstonesLocked(authorOrphans.prefix(authorOrphans.count - Limits.maxOrphanTombstonesPerAuthor))
|
||||
}
|
||||
let orphans = tombstones.filter(\.isOrphan)
|
||||
if orphans.count > Limits.maxOrphanTombstones {
|
||||
removeTombstonesLocked(orphans.prefix(orphans.count - Limits.maxOrphanTombstones))
|
||||
}
|
||||
}
|
||||
|
||||
private func removeTombstonesLocked(_ victims: ArraySlice<StoredTombstone>) {
|
||||
guard !victims.isEmpty else { return }
|
||||
let victimIDs = Set(victims.map { $0.tombstone.postID })
|
||||
tombstones.removeAll { victimIDs.contains($0.tombstone.postID) }
|
||||
}
|
||||
|
||||
private func evictOldestLocked(from candidates: [StoredPost], keep: Int) {
|
||||
let victims = candidates
|
||||
.sorted { $0.post.createdAt < $1.post.createdAt }
|
||||
.prefix(max(0, candidates.count - keep))
|
||||
guard !victims.isEmpty else { return }
|
||||
let victimIDs = Set(victims.map { $0.post.postID })
|
||||
posts.removeAll { victimIDs.contains($0.post.postID) }
|
||||
}
|
||||
|
||||
private func pruneExpiredLocked(nowMs: UInt64) {
|
||||
let postsBefore = posts.count
|
||||
posts.removeAll { $0.post.expiresAt <= nowMs }
|
||||
tombstones.removeAll { $0.retainUntil <= nowMs }
|
||||
if posts.count != postsBefore {
|
||||
publishSnapshotLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private func publishSnapshotLocked() {
|
||||
let snapshot = posts.map(\.post)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.postsSnapshot = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
private func currentMs() -> UInt64 {
|
||||
UInt64(max(0, now().timeIntervalSince1970) * 1000)
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func persistLocked() {
|
||||
guard let fileURL else { return }
|
||||
let payloads = posts.map { PersistedEntry(packet: $0.rawPacket, retainUntil: nil) }
|
||||
+ tombstones.map { PersistedEntry(packet: $0.rawPacket, retainUntil: $0.retainUntil) }
|
||||
do {
|
||||
if payloads.isEmpty {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return
|
||||
}
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let data = try JSONEncoder().encode(payloads)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtection)
|
||||
#endif
|
||||
try data.write(to: fileURL, options: options)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to persist board store: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFromDisk() {
|
||||
guard let fileURL,
|
||||
let data = try? Data(contentsOf: fileURL),
|
||||
let payloads = try? JSONDecoder().decode([PersistedEntry].self, from: data) else {
|
||||
return
|
||||
}
|
||||
let nowMs = currentMs()
|
||||
queue.sync {
|
||||
for entry in payloads {
|
||||
guard let packet = BitchatPacket.from(entry.packet),
|
||||
packet.type == MessageType.boardPost.rawValue,
|
||||
let wire = BoardWire.decode(from: packet.payload),
|
||||
wire.verifySignature() else { continue }
|
||||
_ = ingestLocked(wire, packet: packet, rawPacket: entry.packet, nowMs: nowMs, retainUntilOverride: entry.retainUntil)
|
||||
}
|
||||
publishSnapshotLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultFileURL() -> URL? {
|
||||
guard let base = try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
) else { return nil }
|
||||
return base
|
||||
.appendingPathComponent("board", isDirectory: true)
|
||||
.appendingPathComponent("posts.json")
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ struct RelayController {
|
||||
isHandshake: Bool,
|
||||
isAnnounce: Bool,
|
||||
isRequestSync: Bool = false,
|
||||
isUrgentBoardPost: Bool = false,
|
||||
degree: Int,
|
||||
highDegreeThreshold: Int) -> RelayDecision {
|
||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||
@@ -64,7 +65,7 @@ struct RelayController {
|
||||
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||
// - Thin chains (degree <= 2): every hop counts and flood cost is
|
||||
// minimal, so relay at full incoming depth
|
||||
// - Announces get a bit more headroom
|
||||
// - Announces (and urgent board posts) get a bit more headroom
|
||||
let ttlLimit: UInt8 = {
|
||||
if degree >= highDegreeThreshold {
|
||||
return max(UInt8(2), min(ttlCap, UInt8(5)))
|
||||
@@ -72,7 +73,7 @@ struct RelayController {
|
||||
if degree <= 2 {
|
||||
return ttlCap
|
||||
}
|
||||
let preferred = UInt8(isAnnounce ? 7 : 6)
|
||||
let preferred = UInt8((isAnnounce || isUrgentBoardPost) ? 7 : 6)
|
||||
return max(UInt8(2), min(ttlCap, preferred))
|
||||
}()
|
||||
let newTTL = ttlLimit &- 1
|
||||
|
||||
@@ -124,6 +124,10 @@ protocol Transport: AnyObject {
|
||||
// transport cannot courier (no connected courier, or unsupported).
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
|
||||
|
||||
// Bulletin board (mesh transports only): broadcast a pre-signed board
|
||||
// payload (post or tombstone) so it spreads over relay and gossip sync.
|
||||
func sendBoardPayload(_ payload: Data)
|
||||
|
||||
// QR verification (optional for transports)
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
@@ -154,6 +158,7 @@ extension Transport {
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
||||
func sendBoardPayload(_ payload: Data) {}
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||
func cancelTransfer(_ transferId: String) {}
|
||||
|
||||
@@ -76,6 +76,11 @@ final class GossipSyncManager {
|
||||
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
||||
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
||||
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
||||
// Board posts are few but long-lived (days, until each post's own
|
||||
// expiry), so they get a slow round with their own capacity instead
|
||||
// of competing with the 15-minute message window.
|
||||
var boardCapacity: Int = 200
|
||||
var boardSyncIntervalSeconds: TimeInterval = 60.0
|
||||
var responseRateLimitMaxResponses: Int = 8
|
||||
var responseRateLimitWindowSeconds: TimeInterval = 30.0
|
||||
}
|
||||
@@ -86,6 +91,12 @@ final class GossipSyncManager {
|
||||
private let archive: GossipMessageArchive?
|
||||
weak var delegate: Delegate?
|
||||
|
||||
/// Source of raw signed board packets (posts + tombstones). The board
|
||||
/// store is the single owner of board retention (expiry, tombstones,
|
||||
/// caps, persistence), so sync rounds query it instead of keeping a
|
||||
/// second copy here. Must be thread-safe; set before `start()`.
|
||||
var boardPacketsProvider: (() -> [BitchatPacket])?
|
||||
|
||||
// Storage: broadcast packets by type, and latest announce per sender
|
||||
private var messages = PacketStore()
|
||||
private var fragments = PacketStore()
|
||||
@@ -119,6 +130,9 @@ final class GossipSyncManager {
|
||||
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
if config.boardCapacity > 0 && config.boardSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .board, interval: config.boardSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
syncSchedules = schedules
|
||||
|
||||
if archive != nil {
|
||||
@@ -155,6 +169,9 @@ final class GossipSyncManager {
|
||||
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
||||
types.formUnion(.fileTransfer)
|
||||
}
|
||||
if self.config.boardCapacity > 0 && self.config.boardSyncIntervalSeconds > 0 && self.boardPacketsProvider != nil {
|
||||
types.formUnion(.board)
|
||||
}
|
||||
self.sendRequestSync(to: peerID, types: types)
|
||||
}
|
||||
}
|
||||
@@ -364,6 +381,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
|
||||
@@ -383,6 +416,9 @@ final class GossipSyncManager {
|
||||
if types.contains(.fileTransfer) {
|
||||
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
|
||||
}
|
||||
if types.contains(.boardPost) {
|
||||
candidates.append(contentsOf: boardPacketsProvider?() ?? [])
|
||||
}
|
||||
if candidates.isEmpty {
|
||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||
@@ -399,6 +435,8 @@ final class GossipSyncManager {
|
||||
cap = max(1, config.fragmentCapacity)
|
||||
} else if types == .fileTransfer {
|
||||
cap = max(1, config.fileTransferCapacity)
|
||||
} else if types == .board {
|
||||
cap = max(1, config.boardCapacity)
|
||||
} else {
|
||||
cap = max(1, config.seenCapacity)
|
||||
}
|
||||
@@ -490,6 +528,9 @@ final class GossipSyncManager {
|
||||
// fragment traffic can't crowd messages out of the filter.
|
||||
for index in syncSchedules.indices {
|
||||
guard syncSchedules[index].interval > 0 else { continue }
|
||||
// No board source wired up means nothing to offer or store;
|
||||
// skip the round entirely.
|
||||
if syncSchedules[index].types == .board && boardPacketsProvider == nil { continue }
|
||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||
syncSchedules[index].lastSent = now
|
||||
sendPeriodicSync(for: syncSchedules[index].types)
|
||||
|
||||
@@ -36,6 +36,7 @@ struct SyncTypeFlags: OptionSet {
|
||||
case .fragment: return 5
|
||||
case .requestSync: return 6
|
||||
case .fileTransfer: return 7
|
||||
case .boardPost: return 8
|
||||
// Courier envelopes are directed deposits between trusted peers and
|
||||
// must never spread via gossip sync.
|
||||
case .courierEnvelope: return nil
|
||||
@@ -52,6 +53,10 @@ struct SyncTypeFlags: OptionSet {
|
||||
case 5: return .fragment
|
||||
case 6: return .requestSync
|
||||
case 7: return .fileTransfer
|
||||
// Bit 8 spills the encoded bitfield into a second byte. Decoders since
|
||||
// type-aware sync (#853) accept 1-8 bytes and map unknown bits to no
|
||||
// known type, so old clients ignore board rounds instead of choking.
|
||||
case 8: return .boardPost
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -61,6 +66,7 @@ struct SyncTypeFlags: OptionSet {
|
||||
static let message = SyncTypeFlags(messageTypes: [.message])
|
||||
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
||||
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
||||
static let board = SyncTypeFlags(messageTypes: [.boardPost])
|
||||
|
||||
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
|
||||
|
||||
|
||||
@@ -1208,6 +1208,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
GossipMessageArchive.wipeDefault()
|
||||
StoreAndForwardMetrics.shared.reset()
|
||||
|
||||
// Drop bulletin-board posts and tombstones (memory and disk); board
|
||||
// posts are signed with our identity key and persist for days.
|
||||
BoardStore.shared.wipe()
|
||||
|
||||
// Identity manager has cleared persisted identity data above
|
||||
|
||||
// Clear autocomplete state
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//
|
||||
// BoardView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The bulletin board for one context: a geohash channel, or the mesh-local
|
||||
/// board when `geohash` is empty. Urgent posts pin to the top; own posts can
|
||||
/// be swipe-deleted, which broadcasts a signed tombstone.
|
||||
struct BoardView: View {
|
||||
/// Empty string = mesh-local board.
|
||||
let geohash: String
|
||||
let senderNickname: String
|
||||
@ObservedObject var board: BoardManager
|
||||
|
||||
@ThemedPalette private var palette
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var draft: String = ""
|
||||
@State private var urgent = false
|
||||
@State private var expiryDays = 7
|
||||
|
||||
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
||||
private var posts: [BoardPostPacket] { board.posts(forGeohash: geohash) }
|
||||
|
||||
private enum Strings {
|
||||
static let boardName = String(localized: "board.title", defaultValue: "board", comment: "Title prefix of the bulletin board sheet")
|
||||
static let description = String(localized: "board.description", defaultValue: "persistent notices carried by the mesh. posts are signed, spread device-to-device, and expire on their own.", comment: "Explainer under the board sheet title")
|
||||
static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts")
|
||||
static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts")
|
||||
static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts")
|
||||
static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer")
|
||||
static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field")
|
||||
static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button")
|
||||
static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts")
|
||||
static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker")
|
||||
static let closeHint = String(localized: "board.accessibility.close", defaultValue: "Close board", comment: "Accessibility label for the board close button")
|
||||
|
||||
static func expiryDaysOption(_ days: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"),
|
||||
locale: .current,
|
||||
days
|
||||
)
|
||||
}
|
||||
|
||||
static func postAccessibilityLabel(author: String, content: String, urgent: Bool) -> String {
|
||||
let base = String(
|
||||
format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"),
|
||||
locale: .current,
|
||||
author, content
|
||||
)
|
||||
return urgent ? "\(urgentBadge), \(base)" : base
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerSection
|
||||
postList
|
||||
composer
|
||||
}
|
||||
.themedSurface()
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
|
||||
#endif
|
||||
.themedSheetBackground()
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
Text(verbatim: geohash.isEmpty ? "\(Strings.boardName) @ #mesh" : "\(Strings.boardName) @ #\(geohash)")
|
||||
.bitchatFont(size: 18)
|
||||
Spacer()
|
||||
SheetCloseButton { dismiss() }
|
||||
.accessibilityLabel(Strings.closeHint)
|
||||
}
|
||||
Text(Strings.description)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 12)
|
||||
.themedSurface()
|
||||
}
|
||||
|
||||
private var postList: some View {
|
||||
Group {
|
||||
if posts.isEmpty {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(Strings.emptyTitle)
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
Text(Strings.emptySubtitle)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
ForEach(posts, id: \.postID) { post in
|
||||
postRow(post)
|
||||
.listRowBackground(palette.background)
|
||||
.listRowSeparatorTint(palette.divider)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.themedSurface()
|
||||
}
|
||||
|
||||
private func postRow(_ post: BoardPostPacket) -> some View {
|
||||
let isOwn = board.isOwnPost(post)
|
||||
let author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon"
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
if post.isUrgent {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.bitchatSystem(size: 11))
|
||||
.foregroundColor(palette.alertRed)
|
||||
Text(Strings.urgentBadge)
|
||||
.bitchatFont(size: 11, weight: .semibold)
|
||||
.foregroundColor(palette.alertRed)
|
||||
}
|
||||
Text(verbatim: "@\(author)")
|
||||
.bitchatFont(size: 12, weight: .semibold)
|
||||
Text(timestampText(forMs: post.createdAt))
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
Spacer()
|
||||
if isOwn {
|
||||
Button {
|
||||
board.deletePost(post)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Strings.deleteAction)
|
||||
}
|
||||
}
|
||||
Text(post.content)
|
||||
.bitchatFont(size: 14)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Strings.postAccessibilityLabel(author: author, content: post.content, urgent: post.isUrgent))
|
||||
.accessibilityActions {
|
||||
if isOwn {
|
||||
Button(Strings.deleteAction) { board.deletePost(post) }
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
if isOwn {
|
||||
Button(role: .destructive) {
|
||||
board.deletePost(post)
|
||||
} label: {
|
||||
Label(Strings.deleteAction, systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
TextField(Strings.placeholder, text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.plain)
|
||||
.bitchatFont(size: 14)
|
||||
.lineLimit(maxDraftLines, reservesSpace: true)
|
||||
.padding(.vertical, 6)
|
||||
Button(action: send) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.bitchatSystem(size: 20))
|
||||
.foregroundColor(sendEnabled ? palette.accent : .secondary)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!sendEnabled)
|
||||
.accessibilityLabel(Strings.send)
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
Toggle(isOn: $urgent) {
|
||||
Text(Strings.urgentToggle)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(urgent ? palette.alertRed : palette.secondary)
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.fixedSize()
|
||||
.accessibilityLabel(Strings.urgentToggle)
|
||||
Spacer()
|
||||
Text(Strings.expiryLabel)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
Picker(Strings.expiryLabel, selection: $expiryDays) {
|
||||
ForEach([1, 3, 7], id: \.self) { days in
|
||||
Text(Strings.expiryDaysOption(days)).tag(days)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.fixedSize()
|
||||
.accessibilityLabel(Strings.expiryLabel)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.themedSurface()
|
||||
.overlay(Divider(), alignment: .top)
|
||||
}
|
||||
|
||||
private var sendEnabled: Bool {
|
||||
let trimmed = draft.trimmed
|
||||
return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes
|
||||
}
|
||||
|
||||
private func send() {
|
||||
guard let content = draft.trimmedOrNilIfEmpty else { return }
|
||||
let sent = board.createPost(
|
||||
content: content,
|
||||
geohash: geohash,
|
||||
urgent: urgent,
|
||||
expiryDays: expiryDays,
|
||||
nickname: senderNickname
|
||||
)
|
||||
if sent {
|
||||
draft = ""
|
||||
urgent = false
|
||||
}
|
||||
}
|
||||
|
||||
private func timestampText(forMs ms: UInt64) -> String {
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
|
||||
let now = Date()
|
||||
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
|
||||
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
|
||||
return rel.isEmpty ? "" : "\(rel) ago"
|
||||
}
|
||||
return Self.absDateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private static let relativeFormatter: DateComponentsFormatter = {
|
||||
let f = DateComponentsFormatter()
|
||||
f.allowedUnits = [.day, .hour, .minute]
|
||||
f.maximumUnitCount = 1
|
||||
f.unitsStyle = .abbreviated
|
||||
f.collapsesLargestUnit = true
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let absDateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.setLocalizedDateFormatFromTemplate("MMM d")
|
||||
return f
|
||||
}()
|
||||
}
|
||||
@@ -25,6 +25,9 @@ struct ContentHeaderView: View {
|
||||
/// Courier envelopes this device is carrying for offline third parties.
|
||||
@State private var carriedMailCount = 0
|
||||
|
||||
/// Bulletin board sheet for the current channel context.
|
||||
@State private var showBoard = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text(verbatim: "bitchat/")
|
||||
@@ -139,6 +142,20 @@ struct ContentHeaderView: View {
|
||||
)
|
||||
}
|
||||
|
||||
Button(action: { showBoard = true }) {
|
||||
Image(systemName: "pin")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(palette.secondary.opacity(0.9))
|
||||
.headerTapTarget()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.board", defaultValue: "Bulletin board", comment: "Accessibility label for the bulletin board button")
|
||||
)
|
||||
.help(
|
||||
String(localized: "content.header.board", defaultValue: "Bulletin board: persistent notices for this channel", comment: "Tooltip for the bulletin board button")
|
||||
)
|
||||
|
||||
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
|
||||
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||
@@ -242,6 +259,13 @@ struct ContentHeaderView: View {
|
||||
.environmentObject(locationChannelsModel)
|
||||
.environmentObject(peerListModel)
|
||||
}
|
||||
.sheet(isPresented: $showBoard) {
|
||||
BoardView(
|
||||
geohash: boardGeohash,
|
||||
senderNickname: appChromeModel.nickname,
|
||||
board: appChromeModel.boardManager
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showLocationNotes, onDismiss: {
|
||||
notesGeohash = nil
|
||||
}) {
|
||||
@@ -311,6 +335,15 @@ private extension ContentHeaderView {
|
||||
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
||||
}
|
||||
|
||||
/// The board scope for the current channel: the geohash channel's board,
|
||||
/// or the mesh-local board ("") in mesh chat.
|
||||
var boardGeohash: String {
|
||||
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||
return channel.geohash
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// Whether anyone is actually reachable on the current channel — the
|
||||
/// state the count icon's color encodes visually.
|
||||
var headerPeersReachable: Bool {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//
|
||||
// BoardPacketsTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BoardPacketsTests {
|
||||
|
||||
private let authorKey = Curve25519.Signing.PrivateKey()
|
||||
|
||||
private func makeSignedPost(
|
||||
geohash: String = "9q8yy",
|
||||
content: String = "water point at the north gate",
|
||||
nickname: String = "ranger",
|
||||
createdAt: UInt64 = 1_700_000_000_000,
|
||||
lifetimeMs: UInt64 = 24 * 60 * 60 * 1000,
|
||||
flags: UInt8 = 0,
|
||||
signWith key: Curve25519.Signing.PrivateKey? = nil,
|
||||
claimKey: Data? = nil
|
||||
) throws -> BoardPostPacket {
|
||||
let signer = key ?? authorKey
|
||||
let publicKey = claimKey ?? signer.publicKey.rawRepresentation
|
||||
let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) })
|
||||
let expiresAt = createdAt + lifetimeMs
|
||||
let signingBytes = BoardPostPacket.signingBytes(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: publicKey,
|
||||
authorNickname: nickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags
|
||||
)
|
||||
let signature = try signer.signature(for: signingBytes)
|
||||
return BoardPostPacket(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: publicKey,
|
||||
authorNickname: nickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
private func makeSignedTombstone(
|
||||
postID: Data,
|
||||
deletedAt: UInt64 = 1_700_000_100_000,
|
||||
signWith key: Curve25519.Signing.PrivateKey? = nil,
|
||||
claimKey: Data? = nil
|
||||
) throws -> BoardTombstonePacket {
|
||||
let signer = key ?? authorKey
|
||||
let publicKey = claimKey ?? signer.publicKey.rawRepresentation
|
||||
let signature = try signer.signature(for: BoardTombstonePacket.signingBytes(postID: postID, deletedAt: deletedAt))
|
||||
return BoardTombstonePacket(
|
||||
postID: postID,
|
||||
authorSigningKey: publicKey,
|
||||
deletedAt: deletedAt,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Round trips
|
||||
|
||||
@Test func postRoundTrip() throws {
|
||||
let post = try makeSignedPost(flags: BoardPostPacket.urgentFlag)
|
||||
let encoded = BoardWire.post(post).encode()
|
||||
let decoded = try #require(BoardWire.decode(from: encoded))
|
||||
#expect(decoded == .post(post))
|
||||
#expect(decoded.verifySignature())
|
||||
guard case .post(let roundTripped) = decoded else {
|
||||
Issue.record("expected a post")
|
||||
return
|
||||
}
|
||||
#expect(roundTripped.isUrgent)
|
||||
#expect(roundTripped.geohash == "9q8yy")
|
||||
}
|
||||
|
||||
@Test func meshLocalPostRoundTrip() throws {
|
||||
let post = try makeSignedPost(geohash: "")
|
||||
let decoded = try #require(BoardWire.decode(from: BoardWire.post(post).encode()))
|
||||
#expect(decoded == .post(post))
|
||||
#expect(decoded.verifySignature())
|
||||
}
|
||||
|
||||
@Test func tombstoneRoundTrip() throws {
|
||||
let post = try makeSignedPost()
|
||||
let tombstone = try makeSignedTombstone(postID: post.postID)
|
||||
let encoded = BoardWire.tombstone(tombstone).encode()
|
||||
let decoded = try #require(BoardWire.decode(from: encoded))
|
||||
#expect(decoded == .tombstone(tombstone))
|
||||
#expect(decoded.verifySignature())
|
||||
}
|
||||
|
||||
// MARK: - Signature verification
|
||||
|
||||
@Test func forgedPostSignatureFailsVerification() throws {
|
||||
// Signed by an attacker's key but claiming the victim's key as author.
|
||||
let attacker = Curve25519.Signing.PrivateKey()
|
||||
let victim = Curve25519.Signing.PrivateKey()
|
||||
let forged = try makeSignedPost(signWith: attacker, claimKey: victim.publicKey.rawRepresentation)
|
||||
let decoded = try #require(BoardWire.decode(from: BoardWire.post(forged).encode()))
|
||||
#expect(!decoded.verifySignature())
|
||||
}
|
||||
|
||||
@Test func tamperedContentFailsVerification() throws {
|
||||
let post = try makeSignedPost(content: "meet at noon")
|
||||
let tampered = BoardPostPacket(
|
||||
postID: post.postID,
|
||||
geohash: post.geohash,
|
||||
content: "meet at midnight",
|
||||
authorSigningKey: post.authorSigningKey,
|
||||
authorNickname: post.authorNickname,
|
||||
createdAt: post.createdAt,
|
||||
expiresAt: post.expiresAt,
|
||||
flags: post.flags,
|
||||
signature: post.signature
|
||||
)
|
||||
let decoded = try #require(BoardWire.decode(from: BoardWire.post(tampered).encode()))
|
||||
#expect(!decoded.verifySignature())
|
||||
}
|
||||
|
||||
@Test func forgedTombstoneSignatureFailsVerification() throws {
|
||||
let post = try makeSignedPost()
|
||||
let attacker = Curve25519.Signing.PrivateKey()
|
||||
let forged = try makeSignedTombstone(
|
||||
postID: post.postID,
|
||||
signWith: attacker,
|
||||
claimKey: post.authorSigningKey
|
||||
)
|
||||
let decoded = try #require(BoardWire.decode(from: BoardWire.tombstone(forged).encode()))
|
||||
#expect(!decoded.verifySignature())
|
||||
}
|
||||
|
||||
// MARK: - Decode validation
|
||||
|
||||
@Test func rejectsExpiryBeyondSevenDays() throws {
|
||||
let tooLong = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs + 1)
|
||||
#expect(BoardWire.decode(from: BoardWire.post(tooLong).encode()) == nil)
|
||||
|
||||
let exactlySevenDays = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs)
|
||||
#expect(BoardWire.decode(from: BoardWire.post(exactlySevenDays).encode()) != nil)
|
||||
}
|
||||
|
||||
@Test func rejectsExpiryBeforeCreation() throws {
|
||||
let post = try makeSignedPost()
|
||||
let inverted = BoardPostPacket(
|
||||
postID: post.postID,
|
||||
geohash: post.geohash,
|
||||
content: post.content,
|
||||
authorSigningKey: post.authorSigningKey,
|
||||
authorNickname: post.authorNickname,
|
||||
createdAt: post.expiresAt,
|
||||
expiresAt: post.createdAt,
|
||||
flags: post.flags,
|
||||
signature: post.signature
|
||||
)
|
||||
#expect(BoardWire.decode(from: BoardWire.post(inverted).encode()) == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsOversizedContent() throws {
|
||||
let oversized = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes + 1))
|
||||
#expect(BoardWire.decode(from: BoardWire.post(oversized).encode()) == nil)
|
||||
|
||||
let maxed = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes))
|
||||
#expect(BoardWire.decode(from: BoardWire.post(maxed).encode()) != nil)
|
||||
}
|
||||
|
||||
@Test func rejectsInvalidGeohashCharacters() throws {
|
||||
let invalid = try makeSignedPost(geohash: "9q8yA") // "A" is outside base32
|
||||
#expect(BoardWire.decode(from: BoardWire.post(invalid).encode()) == nil)
|
||||
}
|
||||
|
||||
@Test func toleratesUnknownTLVs() throws {
|
||||
let post = try makeSignedPost()
|
||||
var encoded = BoardWire.post(post).encode()
|
||||
// Append an unknown TLV; decoders must skip it.
|
||||
encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
|
||||
let decoded = try #require(BoardWire.decode(from: encoded))
|
||||
#expect(decoded == .post(post))
|
||||
#expect(decoded.verifySignature())
|
||||
}
|
||||
|
||||
@Test func rejectsTruncatedPayload() throws {
|
||||
let post = try makeSignedPost()
|
||||
let encoded = BoardWire.post(post).encode()
|
||||
#expect(BoardWire.decode(from: encoded.prefix(encoded.count - 1)) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Urgent flag peek
|
||||
|
||||
@Test func urgentFlagPeekMatchesFullDecode() throws {
|
||||
let urgent = try makeSignedPost(flags: BoardPostPacket.urgentFlag)
|
||||
let calm = try makeSignedPost()
|
||||
let tombstone = try makeSignedTombstone(postID: calm.postID)
|
||||
#expect(BoardWire.urgentFlag(in: BoardWire.post(urgent).encode()))
|
||||
#expect(!BoardWire.urgentFlag(in: BoardWire.post(calm).encode()))
|
||||
#expect(!BoardWire.urgentFlag(in: BoardWire.tombstone(tombstone).encode()))
|
||||
#expect(!BoardWire.urgentFlag(in: Data()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//
|
||||
// BoardStoreTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BoardStoreTests {
|
||||
|
||||
private final class MutableClock: @unchecked Sendable {
|
||||
var now: Date
|
||||
init(now: Date) { self.now = now }
|
||||
}
|
||||
|
||||
private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
|
||||
|
||||
private func makeStore(clock: MutableClock, fileURL: URL? = nil) -> BoardStore {
|
||||
BoardStore(persistsToDisk: fileURL != nil, fileURL: fileURL, now: { clock.now })
|
||||
}
|
||||
|
||||
private func tempFileURL() -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("board-store-\(UUID().uuidString).json")
|
||||
}
|
||||
|
||||
private func makePost(
|
||||
author: Curve25519.Signing.PrivateKey,
|
||||
geohash: String = "9q8yy",
|
||||
content: String = "note",
|
||||
createdAt: UInt64,
|
||||
lifetimeMs: UInt64 = 24 * 60 * 60 * 1000
|
||||
) throws -> (wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket) {
|
||||
let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) })
|
||||
let key = author.publicKey.rawRepresentation
|
||||
let expiresAt = createdAt + lifetimeMs
|
||||
let signingBytes = BoardPostPacket.signingBytes(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: key,
|
||||
authorNickname: "tester",
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: 0
|
||||
)
|
||||
let post = BoardPostPacket(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: key,
|
||||
authorNickname: "tester",
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: 0,
|
||||
signature: try author.signature(for: signingBytes)
|
||||
)
|
||||
let wire = BoardWire.post(post)
|
||||
return (wire, makePacket(payload: wire.encode(), timestamp: createdAt), post)
|
||||
}
|
||||
|
||||
private func makeTombstone(
|
||||
for post: BoardPostPacket,
|
||||
author: Curve25519.Signing.PrivateKey,
|
||||
deletedAt: UInt64,
|
||||
claimKey: Data? = nil
|
||||
) throws -> (wire: BoardWire, packet: BitchatPacket) {
|
||||
let tombstone = BoardTombstonePacket(
|
||||
postID: post.postID,
|
||||
authorSigningKey: claimKey ?? author.publicKey.rawRepresentation,
|
||||
deletedAt: deletedAt,
|
||||
signature: try author.signature(for: BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt))
|
||||
)
|
||||
let wire = BoardWire.tombstone(tombstone)
|
||||
return (wire, makePacket(payload: wire.encode(), timestamp: deletedAt))
|
||||
}
|
||||
|
||||
private func makePacket(payload: Data, timestamp: UInt64) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.boardPost.rawValue,
|
||||
senderID: Data((0..<8).map { _ in UInt8.random(in: 0...255) }),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Ingest basics
|
||||
|
||||
@Test func ingestStoresAndDeduplicates() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .duplicate)
|
||||
#expect(store.posts(forGeohash: "9q8yy").count == 1)
|
||||
#expect(store.posts(forGeohash: "").isEmpty)
|
||||
#expect(store.syncCandidates().count == 1)
|
||||
}
|
||||
|
||||
@Test func rejectsAlreadyExpiredPost() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs - 2 * 60 * 60 * 1000, lifetimeMs: 60 * 60 * 1000)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Receive-time timestamp policy
|
||||
|
||||
@Test func rejectsPostCreatedBeyondClockSkew() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs + 60 * 1000)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
}
|
||||
|
||||
@Test func acceptsPostCreatedWithinClockSkew() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs - 60 * 1000)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
#expect(store.posts(forGeohash: "9q8yy").count == 1)
|
||||
}
|
||||
|
||||
@Test func rejectsPostExpiringTooFarInTheFuture() throws {
|
||||
// Builds the wire directly, bypassing the decoder's span check, to
|
||||
// exercise the ingest-level expiresAt bound on its own.
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Caps and eviction
|
||||
|
||||
@Test func perAuthorCapEvictsOldest() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
var oldestID: Data?
|
||||
for index in 0..<(BoardStore.Limits.maxPostsPerAuthor + 1) {
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
|
||||
if index == 0 { oldestID = entry.post.postID }
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
}
|
||||
|
||||
let posts = store.posts(forGeohash: "9q8yy")
|
||||
#expect(posts.count == BoardStore.Limits.maxPostsPerAuthor)
|
||||
#expect(!posts.contains { $0.postID == oldestID })
|
||||
}
|
||||
|
||||
@Test func globalCapEvictsOldest() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
|
||||
var oldestID: Data?
|
||||
var author = Curve25519.Signing.PrivateKey()
|
||||
for index in 0..<(BoardStore.Limits.maxPosts + 1) {
|
||||
if index % BoardStore.Limits.maxPostsPerAuthor == 0 {
|
||||
author = Curve25519.Signing.PrivateKey()
|
||||
}
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
|
||||
if index == 0 { oldestID = entry.post.postID }
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
}
|
||||
|
||||
let posts = store.posts(forGeohash: "9q8yy")
|
||||
#expect(posts.count == BoardStore.Limits.maxPosts)
|
||||
#expect(!posts.contains { $0.postID == oldestID })
|
||||
}
|
||||
|
||||
// MARK: - Expiry sweep
|
||||
|
||||
@Test func expiredPostsAreSwept() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let shortLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 60 * 60 * 1000)
|
||||
let longLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 48 * 60 * 60 * 1000)
|
||||
store.ingest(shortLived.wire, packet: shortLived.packet)
|
||||
store.ingest(longLived.wire, packet: longLived.packet)
|
||||
#expect(store.posts(forGeohash: "9q8yy").count == 2)
|
||||
|
||||
clock.now = baseDate.addingTimeInterval(2 * 60 * 60) // 2h later
|
||||
let remaining = store.posts(forGeohash: "9q8yy")
|
||||
#expect(remaining.count == 1)
|
||||
#expect(remaining.first?.postID == longLived.post.postID)
|
||||
#expect(store.syncCandidates().count == 1)
|
||||
}
|
||||
|
||||
// MARK: - Tombstones
|
||||
|
||||
@Test func tombstoneDeletesPostAndPropagatesUntilOriginalExpiry() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 24 * 60 * 60 * 1000)
|
||||
store.ingest(entry.wire, packet: entry.packet)
|
||||
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
|
||||
// Post is gone, tombstone still syncs so the delete propagates.
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
#expect(store.syncCandidates().count == 1)
|
||||
|
||||
// Replayed copy of the deleted post is refused.
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
|
||||
// After the post's original expiry the tombstone is dropped too.
|
||||
clock.now = baseDate.addingTimeInterval(25 * 60 * 60)
|
||||
#expect(store.syncCandidates().isEmpty)
|
||||
}
|
||||
|
||||
@Test func tombstoneFromWrongKeyIsRejected() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let attacker = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
store.ingest(entry.wire, packet: entry.packet)
|
||||
|
||||
// Attacker signs with their own key (self-consistent wire, so it
|
||||
// passes signature verification) but targets the victim's post.
|
||||
let forged = try makeTombstone(for: entry.post, author: attacker, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(forged.wire, packet: forged.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").count == 1)
|
||||
}
|
||||
|
||||
@Test func tombstoneArrivingBeforePostSuppressesIt() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
}
|
||||
|
||||
@Test func orphanTombstoneRetentionIsBoundedByReceiveTime() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs) // never ingested
|
||||
|
||||
// Attacker-chosen far-future deletedAt must not extend retention.
|
||||
let farFuture = baseMs + 365 * 24 * 60 * 60 * 1000
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: farFuture)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
#expect(store.syncCandidates().count == 1)
|
||||
|
||||
// No post can outlive 7 days from receipt, so neither may an orphan
|
||||
// tombstone (plus skew allowance).
|
||||
clock.now = baseDate.addingTimeInterval(8 * 24 * 60 * 60)
|
||||
#expect(store.syncCandidates().isEmpty)
|
||||
}
|
||||
|
||||
@Test func orphanTombstonePerAuthorCapEvictsOldest() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
var unseenPosts: [(wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket)] = []
|
||||
for index in 0..<(BoardStore.Limits.maxOrphanTombstonesPerAuthor + 1) {
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index))
|
||||
unseenPosts.append(entry)
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
}
|
||||
|
||||
#expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstonesPerAuthor)
|
||||
// The oldest orphan was evicted, so its post is no longer suppressed…
|
||||
#expect(store.ingest(unseenPosts[0].wire, packet: unseenPosts[0].packet) == .accepted)
|
||||
// …while the surviving orphans still suppress theirs.
|
||||
#expect(store.ingest(unseenPosts[1].wire, packet: unseenPosts[1].packet) == .rejected)
|
||||
}
|
||||
|
||||
@Test func orphanTombstoneGlobalCapEvictsOldest() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
|
||||
var author = Curve25519.Signing.PrivateKey()
|
||||
for index in 0..<(BoardStore.Limits.maxOrphanTombstones + 1) {
|
||||
if index % BoardStore.Limits.maxOrphanTombstonesPerAuthor == 0 {
|
||||
author = Curve25519.Signing.PrivateKey()
|
||||
}
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index))
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
}
|
||||
|
||||
#expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstones)
|
||||
}
|
||||
|
||||
@Test func matchedTombstonesAreExemptFromOrphanCaps() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
// Post-then-delete more times than the per-author orphan cap; every
|
||||
// tombstone matched a live post, so none may be evicted.
|
||||
let cycles = BoardStore.Limits.maxOrphanTombstonesPerAuthor + 2
|
||||
for index in 0..<cycles {
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + UInt64(index) * 1000 + 1)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
}
|
||||
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
#expect(store.syncCandidates().count == cycles)
|
||||
}
|
||||
|
||||
// MARK: - Persistence and wipe
|
||||
|
||||
@Test func persistsAcrossRestart() throws {
|
||||
let fileURL = tempFileURL()
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
let first = makeStore(clock: clock, fileURL: fileURL)
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
let deleted = try makePost(author: author, createdAt: baseMs + 1)
|
||||
first.ingest(entry.wire, packet: entry.packet)
|
||||
first.ingest(deleted.wire, packet: deleted.packet)
|
||||
let tombstone = try makeTombstone(for: deleted.post, author: author, deletedAt: baseMs + 1000)
|
||||
first.ingest(tombstone.wire, packet: tombstone.packet)
|
||||
|
||||
let second = makeStore(clock: clock, fileURL: fileURL)
|
||||
let restored = second.posts(forGeohash: "9q8yy")
|
||||
#expect(restored.count == 1)
|
||||
#expect(restored.first?.postID == entry.post.postID)
|
||||
// Post + tombstone both restored into sync.
|
||||
#expect(second.syncCandidates().count == 2)
|
||||
|
||||
// Restart after expiry drops everything.
|
||||
clock.now = baseDate.addingTimeInterval(25 * 60 * 60)
|
||||
let third = makeStore(clock: clock, fileURL: fileURL)
|
||||
#expect(third.posts(forGeohash: "9q8yy").isEmpty)
|
||||
#expect(third.syncCandidates().isEmpty)
|
||||
}
|
||||
|
||||
@Test func wipeClearsMemoryAndDisk() throws {
|
||||
let fileURL = tempFileURL()
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
let store = makeStore(clock: clock, fileURL: fileURL)
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
store.ingest(entry.wire, packet: entry.packet)
|
||||
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||
|
||||
store.wipe()
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
#expect(store.syncCandidates().isEmpty)
|
||||
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//
|
||||
// GossipSyncBoardTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// Board posts ride gossip sync through a provider that queries the board
|
||||
/// store, so retention (expiry, tombstones, caps) has a single owner.
|
||||
struct GossipSyncBoardTests {
|
||||
|
||||
private let myPeerID = PeerID(str: "0102030405060708")
|
||||
|
||||
private func makeBoardPacket(timestamp: UInt64) throws -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.boardPost.rawValue,
|
||||
senderID: try #require(Data(hexString: "aabbccddeeff0011")),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: Data([0x42]),
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
}
|
||||
|
||||
private func quietConfig() -> GossipSyncManager.Config {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
return config
|
||||
}
|
||||
|
||||
@Test func boardRequestIsServedFromProvider() async throws {
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager())
|
||||
let delegate = RecordingBoardDelegate()
|
||||
manager.delegate = delegate
|
||||
let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000))
|
||||
manager.boardPacketsProvider = { return [boardPacket] }
|
||||
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
let sent = try #require(delegate.packets.first)
|
||||
#expect(sent.type == MessageType.boardPost.rawValue)
|
||||
#expect(sent.isRSR)
|
||||
}
|
||||
|
||||
@Test func nonBoardRequestDoesNotServeBoardPackets() async throws {
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager())
|
||||
let delegate = RecordingBoardDelegate()
|
||||
manager.delegate = delegate
|
||||
let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000))
|
||||
manager.boardPacketsProvider = { return [boardPacket] }
|
||||
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .publicMessages)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
|
||||
// Follow with a board request; only its response should arrive, which
|
||||
// also proves the first request produced nothing.
|
||||
let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
#expect(delegate.packets.count == 1)
|
||||
#expect(delegate.packets.first?.type == MessageType.boardPost.rawValue)
|
||||
}
|
||||
|
||||
@Test func maintenanceEmitsBoardRoundOnlyWithProvider() throws {
|
||||
var config = quietConfig()
|
||||
config.boardSyncIntervalSeconds = 1
|
||||
|
||||
// Without a provider the board schedule stays silent.
|
||||
let unwired = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
|
||||
let unwiredDelegate = RecordingBoardDelegate()
|
||||
unwired.delegate = unwiredDelegate
|
||||
unwired._performMaintenanceSynchronously(now: Date())
|
||||
#expect(unwiredDelegate.packets.isEmpty)
|
||||
|
||||
// With a provider, maintenance sends a board-typed request.
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
|
||||
let delegate = RecordingBoardDelegate()
|
||||
manager.delegate = delegate
|
||||
manager.boardPacketsProvider = { return [] }
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
|
||||
#expect(delegate.packets.count == 1)
|
||||
let payload = try #require(delegate.packets.first?.payload)
|
||||
let request = try #require(RequestSyncPacket.decode(from: payload))
|
||||
#expect(request.types == .board)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RecordingBoardDelegate: GossipSyncManager.Delegate {
|
||||
private let lock = NSLock()
|
||||
private var _packets: [BitchatPacket] = []
|
||||
|
||||
var packets: [BitchatPacket] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _packets
|
||||
}
|
||||
|
||||
func sendPacket(_ packet: BitchatPacket) {
|
||||
lock.lock()
|
||||
_packets.append(packet)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
|
||||
lock.lock()
|
||||
_packets.append(packet)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||
packet
|
||||
}
|
||||
|
||||
func getConnectedPeers() -> [PeerID] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// SyncTypeFlagsBoardTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// The board sync flag is the first bit outside the original single byte of
|
||||
/// type flags. These tests pin down the wire compatibility contract: the
|
||||
/// types TLV has been a variable-length (1-8 byte) little-endian bitfield
|
||||
/// since type-aware sync, so widening to two bytes must decode everywhere
|
||||
/// and unknown bits must be ignored, not rejected.
|
||||
struct SyncTypeFlagsBoardTests {
|
||||
|
||||
@Test func boardFlagEncodesIntoSecondByte() throws {
|
||||
let data = try #require(SyncTypeFlags.board.toData())
|
||||
// Little-endian: low byte first, board bit (bit 8) in byte 2.
|
||||
#expect(data == Data([0x00, 0x01]))
|
||||
}
|
||||
|
||||
@Test func boardFlagRoundTrips() throws {
|
||||
let flags = SyncTypeFlags(messageTypes: [.message, .boardPost])
|
||||
let data = try #require(flags.toData())
|
||||
let decoded = try #require(SyncTypeFlags.decode(data))
|
||||
#expect(decoded.contains(.message))
|
||||
#expect(decoded.contains(.boardPost))
|
||||
#expect(!decoded.contains(.fragment))
|
||||
#expect(Set(decoded.toMessageTypes()) == Set([.message, .boardPost]))
|
||||
}
|
||||
|
||||
/// An old decoder is modeled by bits it has no mapping for: the shared
|
||||
/// decode path accepts the bytes and simply maps unknown bits to no
|
||||
/// message type, so a board-only request reads as "nothing I can serve".
|
||||
@Test func unknownBitsDecodeToNoTypes() throws {
|
||||
// Bits 9-15 are unassigned; a future (or unknown) two-byte bitfield
|
||||
// must decode without error and yield no known types.
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xFE])))
|
||||
#expect(decoded.toMessageTypes().isEmpty)
|
||||
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost] {
|
||||
#expect(!decoded.contains(type))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws {
|
||||
// Known low-byte flags survive alongside unknown high bits.
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xFE])))
|
||||
#expect(decoded.contains(.announce))
|
||||
#expect(decoded.contains(.message))
|
||||
#expect(Set(decoded.toMessageTypes()) == Set([.announce, .message]))
|
||||
}
|
||||
|
||||
@Test func requestSyncPacketRoundTripsBoardFlag() throws {
|
||||
let request = RequestSyncPacket(
|
||||
p: 4,
|
||||
m: 128,
|
||||
data: Data([0xAB, 0xCD]),
|
||||
types: SyncTypeFlags(messageTypes: [.boardPost])
|
||||
)
|
||||
let decoded = try #require(RequestSyncPacket.decode(from: request.encode()))
|
||||
let types = try #require(decoded.types)
|
||||
#expect(types.contains(.boardPost))
|
||||
#expect(!types.contains(.message))
|
||||
}
|
||||
|
||||
@Test func singleByteLegacyEncodingStillDecodes() throws {
|
||||
// Requests from old clients keep the one-byte bitfield.
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x03])))
|
||||
#expect(decoded.contains(.announce))
|
||||
#expect(decoded.contains(.message))
|
||||
#expect(!decoded.contains(.boardPost))
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public enum MessageType: UInt8 {
|
||||
// Fragmentation (simplified)
|
||||
case fragment = 0x20 // Single fragment type for large messages
|
||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||
case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
@@ -36,6 +37,7 @@ public enum MessageType: UInt8 {
|
||||
case .noiseEncrypted: return "noiseEncrypted"
|
||||
case .fragment: return "fragment"
|
||||
case .fileTransfer: return "fileTransfer"
|
||||
case .boardPost: return "boardPost"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user