mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:25:19 +00:00
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>
This commit is contained in:
@@ -40,6 +40,13 @@ final class BoardStore {
|
||||
/// 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 {
|
||||
@@ -53,6 +60,9 @@ final class BoardStore {
|
||||
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
|
||||
@@ -161,6 +171,14 @@ final class BoardStore {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -191,9 +209,16 @@ final class BoardStore {
|
||||
) -> BoardIngestResult {
|
||||
guard !tombstones.contains(where: { $0.tombstone.postID == tombstone.postID }) else { return .duplicate }
|
||||
|
||||
// Cap so a doctored file cannot pin a tombstone past any legal expiry.
|
||||
let maxRetain = tombstone.deletedAt &+ Limits.orphanTombstoneLifetimeMs
|
||||
// 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
|
||||
@@ -201,22 +226,50 @@ final class BoardStore {
|
||||
// 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))
|
||||
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 }
|
||||
|
||||
@@ -119,6 +119,40 @@ struct BoardStoreTests {
|
||||
#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 {
|
||||
@@ -228,6 +262,80 @@ struct BoardStoreTests {
|
||||
#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 {
|
||||
|
||||
Reference in New Issue
Block a user