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:
jack
2026-07-07 14:13:25 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent ede6368296
commit 60be88a4f5
19 changed files with 2119 additions and 4 deletions
+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
@@ -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