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
@@ -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] {
[]
}
}