mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +00:00
Geohash bulletin board: persistent signed notices over mesh sync (#1379)
* Add geohash bulletin board: persistent signed notices over mesh sync
New MessageType 0x23 carries TLV-encoded board posts and tombstones,
self-signed with the author's Ed25519 key ("bitchat-board-v1" /
"bitchat-board-del-v1" domains) so notices verify without the author
present. BoardStore persists raw signed packets under Application
Support/board/ (200 posts, 5 per author, oldest evicted; expiry sweep;
tombstones retained until the deleted post's original expiry) and is
wiped on panic.
Board packets join gossip sync as bit 8 of the existing variable-length
types bitfield (a second byte old decoders already accept and ignore),
with a 60s round and its own capacity, served straight from the board
store so retention has one owner. Posts relay like broadcasts; urgent
posts get the announce-class TTL cap.
UI: a pin button in the header opens the board for the current channel
(geohash board, or mesh-local board), with urgent-pinned newest-first
listing, compose with urgent toggle and 1/3/7-day expiry, and
swipe-delete on own posts. Geohash posts also publish one-way as
Nostr kind-1 location notes when relays are reachable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Board: bound orphan tombstones and reject future-dated posts at ingest
Two hardening fixes from Codex review of the geohash bulletin board:
- Orphan tombstones (P1): retention was derived solely from the
sender-chosen deletedAt, so self-signed tombstones for unseen post IDs
with far-future deletedAt persisted and re-entered sync unboundedly.
Retention is now also clamped to receive time (now + 7d + 1h skew --
no post can outlive that), and orphans are capped at 100 globally and
5 per author key with oldest-received evicted first. Matched
tombstones and disk restores keep their existing behavior.
- Future-dated posts (P2): ingest only checked expiresAt > now, letting
posts dated years ahead sort above honest posts and squat the 200
global slots without ever pruning. The single ingest chokepoint
(radio, sync, and disk restore all funnel through it) now rejects
createdAt > now + 1h skew and expiresAt > now + 7d + 1h skew; the
decoder's span rule is unchanged.
Adds tests for the skew boundary, far-future expiry, receive-time
tombstone clamping, orphan caps/eviction, and matched-tombstone
exemption.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
ede6368296
commit
60be88a4f5
@@ -0,0 +1,211 @@
|
||||
//
|
||||
// BoardPacketsTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BoardPacketsTests {
|
||||
|
||||
private let authorKey = Curve25519.Signing.PrivateKey()
|
||||
|
||||
private func makeSignedPost(
|
||||
geohash: String = "9q8yy",
|
||||
content: String = "water point at the north gate",
|
||||
nickname: String = "ranger",
|
||||
createdAt: UInt64 = 1_700_000_000_000,
|
||||
lifetimeMs: UInt64 = 24 * 60 * 60 * 1000,
|
||||
flags: UInt8 = 0,
|
||||
signWith key: Curve25519.Signing.PrivateKey? = nil,
|
||||
claimKey: Data? = nil
|
||||
) throws -> BoardPostPacket {
|
||||
let signer = key ?? authorKey
|
||||
let publicKey = claimKey ?? signer.publicKey.rawRepresentation
|
||||
let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) })
|
||||
let expiresAt = createdAt + lifetimeMs
|
||||
let signingBytes = BoardPostPacket.signingBytes(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: publicKey,
|
||||
authorNickname: nickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags
|
||||
)
|
||||
let signature = try signer.signature(for: signingBytes)
|
||||
return BoardPostPacket(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: publicKey,
|
||||
authorNickname: nickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
private func makeSignedTombstone(
|
||||
postID: Data,
|
||||
deletedAt: UInt64 = 1_700_000_100_000,
|
||||
signWith key: Curve25519.Signing.PrivateKey? = nil,
|
||||
claimKey: Data? = nil
|
||||
) throws -> BoardTombstonePacket {
|
||||
let signer = key ?? authorKey
|
||||
let publicKey = claimKey ?? signer.publicKey.rawRepresentation
|
||||
let signature = try signer.signature(for: BoardTombstonePacket.signingBytes(postID: postID, deletedAt: deletedAt))
|
||||
return BoardTombstonePacket(
|
||||
postID: postID,
|
||||
authorSigningKey: publicKey,
|
||||
deletedAt: deletedAt,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Round trips
|
||||
|
||||
@Test func postRoundTrip() throws {
|
||||
let post = try makeSignedPost(flags: BoardPostPacket.urgentFlag)
|
||||
let encoded = BoardWire.post(post).encode()
|
||||
let decoded = try #require(BoardWire.decode(from: encoded))
|
||||
#expect(decoded == .post(post))
|
||||
#expect(decoded.verifySignature())
|
||||
guard case .post(let roundTripped) = decoded else {
|
||||
Issue.record("expected a post")
|
||||
return
|
||||
}
|
||||
#expect(roundTripped.isUrgent)
|
||||
#expect(roundTripped.geohash == "9q8yy")
|
||||
}
|
||||
|
||||
@Test func meshLocalPostRoundTrip() throws {
|
||||
let post = try makeSignedPost(geohash: "")
|
||||
let decoded = try #require(BoardWire.decode(from: BoardWire.post(post).encode()))
|
||||
#expect(decoded == .post(post))
|
||||
#expect(decoded.verifySignature())
|
||||
}
|
||||
|
||||
@Test func tombstoneRoundTrip() throws {
|
||||
let post = try makeSignedPost()
|
||||
let tombstone = try makeSignedTombstone(postID: post.postID)
|
||||
let encoded = BoardWire.tombstone(tombstone).encode()
|
||||
let decoded = try #require(BoardWire.decode(from: encoded))
|
||||
#expect(decoded == .tombstone(tombstone))
|
||||
#expect(decoded.verifySignature())
|
||||
}
|
||||
|
||||
// MARK: - Signature verification
|
||||
|
||||
@Test func forgedPostSignatureFailsVerification() throws {
|
||||
// Signed by an attacker's key but claiming the victim's key as author.
|
||||
let attacker = Curve25519.Signing.PrivateKey()
|
||||
let victim = Curve25519.Signing.PrivateKey()
|
||||
let forged = try makeSignedPost(signWith: attacker, claimKey: victim.publicKey.rawRepresentation)
|
||||
let decoded = try #require(BoardWire.decode(from: BoardWire.post(forged).encode()))
|
||||
#expect(!decoded.verifySignature())
|
||||
}
|
||||
|
||||
@Test func tamperedContentFailsVerification() throws {
|
||||
let post = try makeSignedPost(content: "meet at noon")
|
||||
let tampered = BoardPostPacket(
|
||||
postID: post.postID,
|
||||
geohash: post.geohash,
|
||||
content: "meet at midnight",
|
||||
authorSigningKey: post.authorSigningKey,
|
||||
authorNickname: post.authorNickname,
|
||||
createdAt: post.createdAt,
|
||||
expiresAt: post.expiresAt,
|
||||
flags: post.flags,
|
||||
signature: post.signature
|
||||
)
|
||||
let decoded = try #require(BoardWire.decode(from: BoardWire.post(tampered).encode()))
|
||||
#expect(!decoded.verifySignature())
|
||||
}
|
||||
|
||||
@Test func forgedTombstoneSignatureFailsVerification() throws {
|
||||
let post = try makeSignedPost()
|
||||
let attacker = Curve25519.Signing.PrivateKey()
|
||||
let forged = try makeSignedTombstone(
|
||||
postID: post.postID,
|
||||
signWith: attacker,
|
||||
claimKey: post.authorSigningKey
|
||||
)
|
||||
let decoded = try #require(BoardWire.decode(from: BoardWire.tombstone(forged).encode()))
|
||||
#expect(!decoded.verifySignature())
|
||||
}
|
||||
|
||||
// MARK: - Decode validation
|
||||
|
||||
@Test func rejectsExpiryBeyondSevenDays() throws {
|
||||
let tooLong = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs + 1)
|
||||
#expect(BoardWire.decode(from: BoardWire.post(tooLong).encode()) == nil)
|
||||
|
||||
let exactlySevenDays = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs)
|
||||
#expect(BoardWire.decode(from: BoardWire.post(exactlySevenDays).encode()) != nil)
|
||||
}
|
||||
|
||||
@Test func rejectsExpiryBeforeCreation() throws {
|
||||
let post = try makeSignedPost()
|
||||
let inverted = BoardPostPacket(
|
||||
postID: post.postID,
|
||||
geohash: post.geohash,
|
||||
content: post.content,
|
||||
authorSigningKey: post.authorSigningKey,
|
||||
authorNickname: post.authorNickname,
|
||||
createdAt: post.expiresAt,
|
||||
expiresAt: post.createdAt,
|
||||
flags: post.flags,
|
||||
signature: post.signature
|
||||
)
|
||||
#expect(BoardWire.decode(from: BoardWire.post(inverted).encode()) == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsOversizedContent() throws {
|
||||
let oversized = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes + 1))
|
||||
#expect(BoardWire.decode(from: BoardWire.post(oversized).encode()) == nil)
|
||||
|
||||
let maxed = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes))
|
||||
#expect(BoardWire.decode(from: BoardWire.post(maxed).encode()) != nil)
|
||||
}
|
||||
|
||||
@Test func rejectsInvalidGeohashCharacters() throws {
|
||||
let invalid = try makeSignedPost(geohash: "9q8yA") // "A" is outside base32
|
||||
#expect(BoardWire.decode(from: BoardWire.post(invalid).encode()) == nil)
|
||||
}
|
||||
|
||||
@Test func toleratesUnknownTLVs() throws {
|
||||
let post = try makeSignedPost()
|
||||
var encoded = BoardWire.post(post).encode()
|
||||
// Append an unknown TLV; decoders must skip it.
|
||||
encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
|
||||
let decoded = try #require(BoardWire.decode(from: encoded))
|
||||
#expect(decoded == .post(post))
|
||||
#expect(decoded.verifySignature())
|
||||
}
|
||||
|
||||
@Test func rejectsTruncatedPayload() throws {
|
||||
let post = try makeSignedPost()
|
||||
let encoded = BoardWire.post(post).encode()
|
||||
#expect(BoardWire.decode(from: encoded.prefix(encoded.count - 1)) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Urgent flag peek
|
||||
|
||||
@Test func urgentFlagPeekMatchesFullDecode() throws {
|
||||
let urgent = try makeSignedPost(flags: BoardPostPacket.urgentFlag)
|
||||
let calm = try makeSignedPost()
|
||||
let tombstone = try makeSignedTombstone(postID: calm.postID)
|
||||
#expect(BoardWire.urgentFlag(in: BoardWire.post(urgent).encode()))
|
||||
#expect(!BoardWire.urgentFlag(in: BoardWire.post(calm).encode()))
|
||||
#expect(!BoardWire.urgentFlag(in: BoardWire.tombstone(tombstone).encode()))
|
||||
#expect(!BoardWire.urgentFlag(in: Data()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//
|
||||
// BoardStoreTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BoardStoreTests {
|
||||
|
||||
private final class MutableClock: @unchecked Sendable {
|
||||
var now: Date
|
||||
init(now: Date) { self.now = now }
|
||||
}
|
||||
|
||||
private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
|
||||
|
||||
private func makeStore(clock: MutableClock, fileURL: URL? = nil) -> BoardStore {
|
||||
BoardStore(persistsToDisk: fileURL != nil, fileURL: fileURL, now: { clock.now })
|
||||
}
|
||||
|
||||
private func tempFileURL() -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("board-store-\(UUID().uuidString).json")
|
||||
}
|
||||
|
||||
private func makePost(
|
||||
author: Curve25519.Signing.PrivateKey,
|
||||
geohash: String = "9q8yy",
|
||||
content: String = "note",
|
||||
createdAt: UInt64,
|
||||
lifetimeMs: UInt64 = 24 * 60 * 60 * 1000
|
||||
) throws -> (wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket) {
|
||||
let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) })
|
||||
let key = author.publicKey.rawRepresentation
|
||||
let expiresAt = createdAt + lifetimeMs
|
||||
let signingBytes = BoardPostPacket.signingBytes(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: key,
|
||||
authorNickname: "tester",
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: 0
|
||||
)
|
||||
let post = BoardPostPacket(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: key,
|
||||
authorNickname: "tester",
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: 0,
|
||||
signature: try author.signature(for: signingBytes)
|
||||
)
|
||||
let wire = BoardWire.post(post)
|
||||
return (wire, makePacket(payload: wire.encode(), timestamp: createdAt), post)
|
||||
}
|
||||
|
||||
private func makeTombstone(
|
||||
for post: BoardPostPacket,
|
||||
author: Curve25519.Signing.PrivateKey,
|
||||
deletedAt: UInt64,
|
||||
claimKey: Data? = nil
|
||||
) throws -> (wire: BoardWire, packet: BitchatPacket) {
|
||||
let tombstone = BoardTombstonePacket(
|
||||
postID: post.postID,
|
||||
authorSigningKey: claimKey ?? author.publicKey.rawRepresentation,
|
||||
deletedAt: deletedAt,
|
||||
signature: try author.signature(for: BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt))
|
||||
)
|
||||
let wire = BoardWire.tombstone(tombstone)
|
||||
return (wire, makePacket(payload: wire.encode(), timestamp: deletedAt))
|
||||
}
|
||||
|
||||
private func makePacket(payload: Data, timestamp: UInt64) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.boardPost.rawValue,
|
||||
senderID: Data((0..<8).map { _ in UInt8.random(in: 0...255) }),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Ingest basics
|
||||
|
||||
@Test func ingestStoresAndDeduplicates() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .duplicate)
|
||||
#expect(store.posts(forGeohash: "9q8yy").count == 1)
|
||||
#expect(store.posts(forGeohash: "").isEmpty)
|
||||
#expect(store.syncCandidates().count == 1)
|
||||
}
|
||||
|
||||
@Test func rejectsAlreadyExpiredPost() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs - 2 * 60 * 60 * 1000, lifetimeMs: 60 * 60 * 1000)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Receive-time timestamp policy
|
||||
|
||||
@Test func rejectsPostCreatedBeyondClockSkew() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs + 60 * 1000)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
}
|
||||
|
||||
@Test func acceptsPostCreatedWithinClockSkew() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs - 60 * 1000)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
#expect(store.posts(forGeohash: "9q8yy").count == 1)
|
||||
}
|
||||
|
||||
@Test func rejectsPostExpiringTooFarInTheFuture() throws {
|
||||
// Builds the wire directly, bypassing the decoder's span check, to
|
||||
// exercise the ingest-level expiresAt bound on its own.
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Caps and eviction
|
||||
|
||||
@Test func perAuthorCapEvictsOldest() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
var oldestID: Data?
|
||||
for index in 0..<(BoardStore.Limits.maxPostsPerAuthor + 1) {
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
|
||||
if index == 0 { oldestID = entry.post.postID }
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
}
|
||||
|
||||
let posts = store.posts(forGeohash: "9q8yy")
|
||||
#expect(posts.count == BoardStore.Limits.maxPostsPerAuthor)
|
||||
#expect(!posts.contains { $0.postID == oldestID })
|
||||
}
|
||||
|
||||
@Test func globalCapEvictsOldest() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
|
||||
var oldestID: Data?
|
||||
var author = Curve25519.Signing.PrivateKey()
|
||||
for index in 0..<(BoardStore.Limits.maxPosts + 1) {
|
||||
if index % BoardStore.Limits.maxPostsPerAuthor == 0 {
|
||||
author = Curve25519.Signing.PrivateKey()
|
||||
}
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
|
||||
if index == 0 { oldestID = entry.post.postID }
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
}
|
||||
|
||||
let posts = store.posts(forGeohash: "9q8yy")
|
||||
#expect(posts.count == BoardStore.Limits.maxPosts)
|
||||
#expect(!posts.contains { $0.postID == oldestID })
|
||||
}
|
||||
|
||||
// MARK: - Expiry sweep
|
||||
|
||||
@Test func expiredPostsAreSwept() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let shortLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 60 * 60 * 1000)
|
||||
let longLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 48 * 60 * 60 * 1000)
|
||||
store.ingest(shortLived.wire, packet: shortLived.packet)
|
||||
store.ingest(longLived.wire, packet: longLived.packet)
|
||||
#expect(store.posts(forGeohash: "9q8yy").count == 2)
|
||||
|
||||
clock.now = baseDate.addingTimeInterval(2 * 60 * 60) // 2h later
|
||||
let remaining = store.posts(forGeohash: "9q8yy")
|
||||
#expect(remaining.count == 1)
|
||||
#expect(remaining.first?.postID == longLived.post.postID)
|
||||
#expect(store.syncCandidates().count == 1)
|
||||
}
|
||||
|
||||
// MARK: - Tombstones
|
||||
|
||||
@Test func tombstoneDeletesPostAndPropagatesUntilOriginalExpiry() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 24 * 60 * 60 * 1000)
|
||||
store.ingest(entry.wire, packet: entry.packet)
|
||||
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
|
||||
// Post is gone, tombstone still syncs so the delete propagates.
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
#expect(store.syncCandidates().count == 1)
|
||||
|
||||
// Replayed copy of the deleted post is refused.
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
|
||||
// After the post's original expiry the tombstone is dropped too.
|
||||
clock.now = baseDate.addingTimeInterval(25 * 60 * 60)
|
||||
#expect(store.syncCandidates().isEmpty)
|
||||
}
|
||||
|
||||
@Test func tombstoneFromWrongKeyIsRejected() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let attacker = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
store.ingest(entry.wire, packet: entry.packet)
|
||||
|
||||
// Attacker signs with their own key (self-consistent wire, so it
|
||||
// passes signature verification) but targets the victim's post.
|
||||
let forged = try makeTombstone(for: entry.post, author: attacker, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(forged.wire, packet: forged.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").count == 1)
|
||||
}
|
||||
|
||||
@Test func tombstoneArrivingBeforePostSuppressesIt() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
}
|
||||
|
||||
@Test func orphanTombstoneRetentionIsBoundedByReceiveTime() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
let entry = try makePost(author: author, createdAt: baseMs) // never ingested
|
||||
|
||||
// Attacker-chosen far-future deletedAt must not extend retention.
|
||||
let farFuture = baseMs + 365 * 24 * 60 * 60 * 1000
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: farFuture)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
#expect(store.syncCandidates().count == 1)
|
||||
|
||||
// No post can outlive 7 days from receipt, so neither may an orphan
|
||||
// tombstone (plus skew allowance).
|
||||
clock.now = baseDate.addingTimeInterval(8 * 24 * 60 * 60)
|
||||
#expect(store.syncCandidates().isEmpty)
|
||||
}
|
||||
|
||||
@Test func orphanTombstonePerAuthorCapEvictsOldest() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
var unseenPosts: [(wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket)] = []
|
||||
for index in 0..<(BoardStore.Limits.maxOrphanTombstonesPerAuthor + 1) {
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index))
|
||||
unseenPosts.append(entry)
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
}
|
||||
|
||||
#expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstonesPerAuthor)
|
||||
// The oldest orphan was evicted, so its post is no longer suppressed…
|
||||
#expect(store.ingest(unseenPosts[0].wire, packet: unseenPosts[0].packet) == .accepted)
|
||||
// …while the surviving orphans still suppress theirs.
|
||||
#expect(store.ingest(unseenPosts[1].wire, packet: unseenPosts[1].packet) == .rejected)
|
||||
}
|
||||
|
||||
@Test func orphanTombstoneGlobalCapEvictsOldest() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
|
||||
var author = Curve25519.Signing.PrivateKey()
|
||||
for index in 0..<(BoardStore.Limits.maxOrphanTombstones + 1) {
|
||||
if index % BoardStore.Limits.maxOrphanTombstonesPerAuthor == 0 {
|
||||
author = Curve25519.Signing.PrivateKey()
|
||||
}
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index))
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
}
|
||||
|
||||
#expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstones)
|
||||
}
|
||||
|
||||
@Test func matchedTombstonesAreExemptFromOrphanCaps() throws {
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let store = makeStore(clock: clock)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
// Post-then-delete more times than the per-author orphan cap; every
|
||||
// tombstone matched a live post, so none may be evicted.
|
||||
let cycles = BoardStore.Limits.maxOrphanTombstonesPerAuthor + 2
|
||||
for index in 0..<cycles {
|
||||
let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
|
||||
#expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
|
||||
let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + UInt64(index) * 1000 + 1)
|
||||
#expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
|
||||
}
|
||||
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
#expect(store.syncCandidates().count == cycles)
|
||||
}
|
||||
|
||||
// MARK: - Persistence and wipe
|
||||
|
||||
@Test func persistsAcrossRestart() throws {
|
||||
let fileURL = tempFileURL()
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
let first = makeStore(clock: clock, fileURL: fileURL)
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
let deleted = try makePost(author: author, createdAt: baseMs + 1)
|
||||
first.ingest(entry.wire, packet: entry.packet)
|
||||
first.ingest(deleted.wire, packet: deleted.packet)
|
||||
let tombstone = try makeTombstone(for: deleted.post, author: author, deletedAt: baseMs + 1000)
|
||||
first.ingest(tombstone.wire, packet: tombstone.packet)
|
||||
|
||||
let second = makeStore(clock: clock, fileURL: fileURL)
|
||||
let restored = second.posts(forGeohash: "9q8yy")
|
||||
#expect(restored.count == 1)
|
||||
#expect(restored.first?.postID == entry.post.postID)
|
||||
// Post + tombstone both restored into sync.
|
||||
#expect(second.syncCandidates().count == 2)
|
||||
|
||||
// Restart after expiry drops everything.
|
||||
clock.now = baseDate.addingTimeInterval(25 * 60 * 60)
|
||||
let third = makeStore(clock: clock, fileURL: fileURL)
|
||||
#expect(third.posts(forGeohash: "9q8yy").isEmpty)
|
||||
#expect(third.syncCandidates().isEmpty)
|
||||
}
|
||||
|
||||
@Test func wipeClearsMemoryAndDisk() throws {
|
||||
let fileURL = tempFileURL()
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let clock = MutableClock(now: baseDate)
|
||||
let author = Curve25519.Signing.PrivateKey()
|
||||
|
||||
let store = makeStore(clock: clock, fileURL: fileURL)
|
||||
let entry = try makePost(author: author, createdAt: baseMs)
|
||||
store.ingest(entry.wire, packet: entry.packet)
|
||||
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||
|
||||
store.wipe()
|
||||
#expect(store.posts(forGeohash: "9q8yy").isEmpty)
|
||||
#expect(store.syncCandidates().isEmpty)
|
||||
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//
|
||||
// GossipSyncBoardTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// Board posts ride gossip sync through a provider that queries the board
|
||||
/// store, so retention (expiry, tombstones, caps) has a single owner.
|
||||
struct GossipSyncBoardTests {
|
||||
|
||||
private let myPeerID = PeerID(str: "0102030405060708")
|
||||
|
||||
private func makeBoardPacket(timestamp: UInt64) throws -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.boardPost.rawValue,
|
||||
senderID: try #require(Data(hexString: "aabbccddeeff0011")),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: Data([0x42]),
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
}
|
||||
|
||||
private func quietConfig() -> GossipSyncManager.Config {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
return config
|
||||
}
|
||||
|
||||
@Test func boardRequestIsServedFromProvider() async throws {
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager())
|
||||
let delegate = RecordingBoardDelegate()
|
||||
manager.delegate = delegate
|
||||
let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000))
|
||||
manager.boardPacketsProvider = { return [boardPacket] }
|
||||
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
let sent = try #require(delegate.packets.first)
|
||||
#expect(sent.type == MessageType.boardPost.rawValue)
|
||||
#expect(sent.isRSR)
|
||||
}
|
||||
|
||||
@Test func nonBoardRequestDoesNotServeBoardPackets() async throws {
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager())
|
||||
let delegate = RecordingBoardDelegate()
|
||||
manager.delegate = delegate
|
||||
let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000))
|
||||
manager.boardPacketsProvider = { return [boardPacket] }
|
||||
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .publicMessages)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
|
||||
// Follow with a board request; only its response should arrive, which
|
||||
// also proves the first request produced nothing.
|
||||
let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
#expect(delegate.packets.count == 1)
|
||||
#expect(delegate.packets.first?.type == MessageType.boardPost.rawValue)
|
||||
}
|
||||
|
||||
@Test func maintenanceEmitsBoardRoundOnlyWithProvider() throws {
|
||||
var config = quietConfig()
|
||||
config.boardSyncIntervalSeconds = 1
|
||||
|
||||
// Without a provider the board schedule stays silent.
|
||||
let unwired = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
|
||||
let unwiredDelegate = RecordingBoardDelegate()
|
||||
unwired.delegate = unwiredDelegate
|
||||
unwired._performMaintenanceSynchronously(now: Date())
|
||||
#expect(unwiredDelegate.packets.isEmpty)
|
||||
|
||||
// With a provider, maintenance sends a board-typed request.
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
|
||||
let delegate = RecordingBoardDelegate()
|
||||
manager.delegate = delegate
|
||||
manager.boardPacketsProvider = { return [] }
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
|
||||
#expect(delegate.packets.count == 1)
|
||||
let payload = try #require(delegate.packets.first?.payload)
|
||||
let request = try #require(RequestSyncPacket.decode(from: payload))
|
||||
#expect(request.types == .board)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RecordingBoardDelegate: GossipSyncManager.Delegate {
|
||||
private let lock = NSLock()
|
||||
private var _packets: [BitchatPacket] = []
|
||||
|
||||
var packets: [BitchatPacket] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _packets
|
||||
}
|
||||
|
||||
func sendPacket(_ packet: BitchatPacket) {
|
||||
lock.lock()
|
||||
_packets.append(packet)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
|
||||
lock.lock()
|
||||
_packets.append(packet)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||
packet
|
||||
}
|
||||
|
||||
func getConnectedPeers() -> [PeerID] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// SyncTypeFlagsBoardTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// The board sync flag is the first bit outside the original single byte of
|
||||
/// type flags. These tests pin down the wire compatibility contract: the
|
||||
/// types TLV has been a variable-length (1-8 byte) little-endian bitfield
|
||||
/// since type-aware sync, so widening to two bytes must decode everywhere
|
||||
/// and unknown bits must be ignored, not rejected.
|
||||
struct SyncTypeFlagsBoardTests {
|
||||
|
||||
@Test func boardFlagEncodesIntoSecondByte() throws {
|
||||
let data = try #require(SyncTypeFlags.board.toData())
|
||||
// Little-endian: low byte first, board bit (bit 8) in byte 2.
|
||||
#expect(data == Data([0x00, 0x01]))
|
||||
}
|
||||
|
||||
@Test func boardFlagRoundTrips() throws {
|
||||
let flags = SyncTypeFlags(messageTypes: [.message, .boardPost])
|
||||
let data = try #require(flags.toData())
|
||||
let decoded = try #require(SyncTypeFlags.decode(data))
|
||||
#expect(decoded.contains(.message))
|
||||
#expect(decoded.contains(.boardPost))
|
||||
#expect(!decoded.contains(.fragment))
|
||||
#expect(Set(decoded.toMessageTypes()) == Set([.message, .boardPost]))
|
||||
}
|
||||
|
||||
/// An old decoder is modeled by bits it has no mapping for: the shared
|
||||
/// decode path accepts the bytes and simply maps unknown bits to no
|
||||
/// message type, so a board-only request reads as "nothing I can serve".
|
||||
@Test func unknownBitsDecodeToNoTypes() throws {
|
||||
// Bits 9-15 are unassigned; a future (or unknown) two-byte bitfield
|
||||
// must decode without error and yield no known types.
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xFE])))
|
||||
#expect(decoded.toMessageTypes().isEmpty)
|
||||
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost] {
|
||||
#expect(!decoded.contains(type))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws {
|
||||
// Known low-byte flags survive alongside unknown high bits.
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xFE])))
|
||||
#expect(decoded.contains(.announce))
|
||||
#expect(decoded.contains(.message))
|
||||
#expect(Set(decoded.toMessageTypes()) == Set([.announce, .message]))
|
||||
}
|
||||
|
||||
@Test func requestSyncPacketRoundTripsBoardFlag() throws {
|
||||
let request = RequestSyncPacket(
|
||||
p: 4,
|
||||
m: 128,
|
||||
data: Data([0xAB, 0xCD]),
|
||||
types: SyncTypeFlags(messageTypes: [.boardPost])
|
||||
)
|
||||
let decoded = try #require(RequestSyncPacket.decode(from: request.encode()))
|
||||
let types = try #require(decoded.types)
|
||||
#expect(types.contains(.boardPost))
|
||||
#expect(!types.contains(.message))
|
||||
}
|
||||
|
||||
@Test func singleByteLegacyEncodingStillDecodes() throws {
|
||||
// Requests from old clients keep the one-byte bitfield.
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x03])))
|
||||
#expect(decoded.contains(.announce))
|
||||
#expect(decoded.contains(.message))
|
||||
#expect(!decoded.contains(.boardPost))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user