mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
* 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>
386 lines
16 KiB
Swift
386 lines
16 KiB
Swift
//
|
|
// 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))
|
|
}
|
|
}
|