Files
bitchat/bitchatTests/Protocols/BoardPacketsTests.swift
T
60be88a4f5 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>
2026-07-07 14:13:25 +02:00

212 lines
8.1 KiB
Swift

//
// 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()))
}
}