Files
bitchat/bitchatTests/Protocols/BoardPacketsTests.swift
T
jackandClaude Fable 5 81168adad1 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>
2026-07-06 20:11:17 +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()))
}
}