Merge remote-tracking branch 'origin/feat/geo-board' into feat/integration-all

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