mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:45:18 +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>
349 lines
12 KiB
Swift
349 lines
12 KiB
Swift
//
|
|
// BoardPackets.swift
|
|
// bitchat
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import CryptoKit
|
|
import Foundation
|
|
|
|
// MARK: - Board wire format (MessageType.boardPost payloads)
|
|
//
|
|
// TLV layout (type u8, length u16 big-endian, value), matching REQUEST_SYNC:
|
|
// - 0x01: kind (u8) — 0x01 post, 0x02 tombstone
|
|
// - 0x02: postID (16B random)
|
|
// - 0x03: geohash (UTF-8, empty = mesh-local board, max 12 chars)
|
|
// - 0x04: content (UTF-8, 1...512 bytes) [post]
|
|
// - 0x05: authorSigningKey (32B Ed25519 public key)
|
|
// - 0x06: authorNickname (UTF-8, max 64 bytes)
|
|
// - 0x07: createdAt (u64 big-endian, ms) [post]
|
|
// - 0x08: expiresAt (u64 big-endian, ms, max 7 days after createdAt) [post]
|
|
// - 0x09: flags (u8, bit0 = urgent) [post]
|
|
// - 0x0A: signature (64B Ed25519)
|
|
// - 0x0B: deletedAt (u64 big-endian, ms) [tombstone]
|
|
// Unknown TLVs are skipped for forward compatibility.
|
|
|
|
enum BoardWireConstants {
|
|
static let postIDLength = 16
|
|
static let signingKeyLength = 32
|
|
static let signatureLength = 64
|
|
static let contentMaxBytes = 512
|
|
static let nicknameMaxBytes = 64
|
|
static let geohashMaxLength = 12
|
|
/// Posts may live at most 7 days past their creation timestamp.
|
|
static let maxLifetimeMs: UInt64 = 7 * 24 * 60 * 60 * 1000
|
|
static let postSigningContext = "bitchat-board-v1"
|
|
static let tombstoneSigningContext = "bitchat-board-del-v1"
|
|
static let geohashAlphabet = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
|
}
|
|
|
|
private enum BoardTLVType: UInt8 {
|
|
case kind = 0x01
|
|
case postID = 0x02
|
|
case geohash = 0x03
|
|
case content = 0x04
|
|
case authorSigningKey = 0x05
|
|
case authorNickname = 0x06
|
|
case createdAt = 0x07
|
|
case expiresAt = 0x08
|
|
case flags = 0x09
|
|
case signature = 0x0A
|
|
case deletedAt = 0x0B
|
|
}
|
|
|
|
private enum BoardWireKind: UInt8 {
|
|
case post = 0x01
|
|
case tombstone = 0x02
|
|
}
|
|
|
|
/// A signed, persistent bulletin-board notice.
|
|
struct BoardPostPacket: Equatable {
|
|
let postID: Data
|
|
/// Empty string scopes the post to the mesh-local board.
|
|
let geohash: String
|
|
let content: String
|
|
let authorSigningKey: Data
|
|
let authorNickname: String
|
|
let createdAt: UInt64
|
|
let expiresAt: UInt64
|
|
let flags: UInt8
|
|
let signature: Data
|
|
|
|
static let urgentFlag: UInt8 = 0x01
|
|
|
|
var isUrgent: Bool { flags & Self.urgentFlag != 0 }
|
|
|
|
/// Canonical bytes covered by the Ed25519 signature. Variable-length
|
|
/// fields are length-prefixed so no two field combinations can collide.
|
|
static func signingBytes(
|
|
postID: Data,
|
|
geohash: String,
|
|
content: String,
|
|
authorSigningKey: Data,
|
|
authorNickname: String,
|
|
createdAt: UInt64,
|
|
expiresAt: UInt64,
|
|
flags: UInt8
|
|
) -> Data {
|
|
var out = Data()
|
|
BoardWireEncoding.appendContext(BoardWireConstants.postSigningContext, to: &out)
|
|
out.append(postID)
|
|
BoardWireEncoding.appendLengthPrefixed(Data(geohash.utf8), to: &out)
|
|
BoardWireEncoding.appendLengthPrefixed(Data(content.utf8), to: &out)
|
|
out.append(authorSigningKey)
|
|
BoardWireEncoding.appendLengthPrefixed(Data(authorNickname.utf8), to: &out)
|
|
BoardWireEncoding.appendUInt64(createdAt, to: &out)
|
|
BoardWireEncoding.appendUInt64(expiresAt, to: &out)
|
|
out.append(flags)
|
|
return out
|
|
}
|
|
|
|
var signingBytes: Data {
|
|
Self.signingBytes(
|
|
postID: postID,
|
|
geohash: geohash,
|
|
content: content,
|
|
authorSigningKey: authorSigningKey,
|
|
authorNickname: authorNickname,
|
|
createdAt: createdAt,
|
|
expiresAt: expiresAt,
|
|
flags: flags
|
|
)
|
|
}
|
|
|
|
func verifySignature() -> Bool {
|
|
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
|
|
}
|
|
}
|
|
|
|
/// A signed deletion marker. Only the author's key can produce one; receivers
|
|
/// keep it until the post's original expiry so the delete outruns the post.
|
|
struct BoardTombstonePacket: Equatable {
|
|
let postID: Data
|
|
let authorSigningKey: Data
|
|
let deletedAt: UInt64
|
|
let signature: Data
|
|
|
|
static func signingBytes(postID: Data, deletedAt: UInt64) -> Data {
|
|
var out = Data()
|
|
BoardWireEncoding.appendContext(BoardWireConstants.tombstoneSigningContext, to: &out)
|
|
out.append(postID)
|
|
BoardWireEncoding.appendUInt64(deletedAt, to: &out)
|
|
return out
|
|
}
|
|
|
|
var signingBytes: Data {
|
|
Self.signingBytes(postID: postID, deletedAt: deletedAt)
|
|
}
|
|
|
|
func verifySignature() -> Bool {
|
|
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
|
|
}
|
|
}
|
|
|
|
/// Decoded board payload: either a live post or a tombstone.
|
|
enum BoardWire: Equatable {
|
|
case post(BoardPostPacket)
|
|
case tombstone(BoardTombstonePacket)
|
|
|
|
func encode() -> Data {
|
|
var out = Data()
|
|
func putTLV(_ t: BoardTLVType, _ v: Data) {
|
|
out.append(t.rawValue)
|
|
let len = UInt16(v.count)
|
|
out.append(UInt8((len >> 8) & 0xFF))
|
|
out.append(UInt8(len & 0xFF))
|
|
out.append(v)
|
|
}
|
|
switch self {
|
|
case .post(let post):
|
|
putTLV(.kind, Data([BoardWireKind.post.rawValue]))
|
|
putTLV(.postID, post.postID)
|
|
putTLV(.geohash, Data(post.geohash.utf8))
|
|
putTLV(.content, Data(post.content.utf8))
|
|
putTLV(.authorSigningKey, post.authorSigningKey)
|
|
putTLV(.authorNickname, Data(post.authorNickname.utf8))
|
|
putTLV(.createdAt, BoardWireEncoding.uint64Data(post.createdAt))
|
|
putTLV(.expiresAt, BoardWireEncoding.uint64Data(post.expiresAt))
|
|
putTLV(.flags, Data([post.flags]))
|
|
putTLV(.signature, post.signature)
|
|
case .tombstone(let tombstone):
|
|
putTLV(.kind, Data([BoardWireKind.tombstone.rawValue]))
|
|
putTLV(.postID, tombstone.postID)
|
|
putTLV(.authorSigningKey, tombstone.authorSigningKey)
|
|
putTLV(.deletedAt, BoardWireEncoding.uint64Data(tombstone.deletedAt))
|
|
putTLV(.signature, tombstone.signature)
|
|
}
|
|
return out
|
|
}
|
|
|
|
/// Structural decode; the caller must still verify the signature before
|
|
/// ingesting (`verifySignature()`).
|
|
static func decode(from data: Data) -> BoardWire? {
|
|
var off = data.startIndex
|
|
var kind: BoardWireKind?
|
|
var postID: Data?
|
|
var geohash: String?
|
|
var content: String?
|
|
var contentBytes = 0
|
|
var authorSigningKey: Data?
|
|
var authorNickname: String?
|
|
var nicknameBytes = 0
|
|
var createdAt: UInt64?
|
|
var expiresAt: UInt64?
|
|
var flags: UInt8?
|
|
var signature: Data?
|
|
var deletedAt: UInt64?
|
|
|
|
while off + 3 <= data.endIndex {
|
|
let t = data[off]; off += 1
|
|
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
|
|
guard off + len <= data.endIndex else { return nil }
|
|
let v = data.subdata(in: off..<(off + len)); off += len
|
|
switch BoardTLVType(rawValue: t) {
|
|
case .kind:
|
|
guard v.count == 1 else { return nil }
|
|
kind = BoardWireKind(rawValue: v[v.startIndex])
|
|
case .postID:
|
|
guard v.count == BoardWireConstants.postIDLength else { return nil }
|
|
postID = v
|
|
case .geohash:
|
|
guard v.count <= BoardWireConstants.geohashMaxLength else { return nil }
|
|
geohash = String(data: v, encoding: .utf8)
|
|
case .content:
|
|
guard v.count <= BoardWireConstants.contentMaxBytes else { return nil }
|
|
contentBytes = v.count
|
|
content = String(data: v, encoding: .utf8)
|
|
case .authorSigningKey:
|
|
guard v.count == BoardWireConstants.signingKeyLength else { return nil }
|
|
authorSigningKey = v
|
|
case .authorNickname:
|
|
guard v.count <= BoardWireConstants.nicknameMaxBytes else { return nil }
|
|
nicknameBytes = v.count
|
|
authorNickname = String(data: v, encoding: .utf8)
|
|
case .createdAt:
|
|
createdAt = BoardWireEncoding.uint64(from: v)
|
|
case .expiresAt:
|
|
expiresAt = BoardWireEncoding.uint64(from: v)
|
|
case .flags:
|
|
guard v.count == 1 else { return nil }
|
|
flags = v[v.startIndex]
|
|
case .signature:
|
|
guard v.count == BoardWireConstants.signatureLength else { return nil }
|
|
signature = v
|
|
case .deletedAt:
|
|
deletedAt = BoardWireEncoding.uint64(from: v)
|
|
case nil:
|
|
continue // forward compatible; ignore unknown TLVs
|
|
}
|
|
}
|
|
|
|
guard let postID, let authorSigningKey, let signature else { return nil }
|
|
|
|
switch kind {
|
|
case .post:
|
|
guard let geohash, let content, let authorNickname,
|
|
let createdAt, let expiresAt, let flags,
|
|
contentBytes >= 1,
|
|
nicknameBytes <= BoardWireConstants.nicknameMaxBytes,
|
|
isValidGeohashField(geohash),
|
|
expiresAt > createdAt,
|
|
expiresAt - createdAt <= BoardWireConstants.maxLifetimeMs else {
|
|
return nil
|
|
}
|
|
return .post(BoardPostPacket(
|
|
postID: postID,
|
|
geohash: geohash,
|
|
content: content,
|
|
authorSigningKey: authorSigningKey,
|
|
authorNickname: authorNickname,
|
|
createdAt: createdAt,
|
|
expiresAt: expiresAt,
|
|
flags: flags,
|
|
signature: signature
|
|
))
|
|
case .tombstone:
|
|
guard let deletedAt else { return nil }
|
|
return .tombstone(BoardTombstonePacket(
|
|
postID: postID,
|
|
authorSigningKey: authorSigningKey,
|
|
deletedAt: deletedAt,
|
|
signature: signature
|
|
))
|
|
case nil:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func verifySignature() -> Bool {
|
|
switch self {
|
|
case .post(let post): return post.verifySignature()
|
|
case .tombstone(let tombstone): return tombstone.verifySignature()
|
|
}
|
|
}
|
|
|
|
/// Cheap TLV peek for relay policy: is this payload an urgent post?
|
|
/// Avoids a full decode on the hot relay path.
|
|
static func urgentFlag(in data: Data) -> Bool {
|
|
var off = data.startIndex
|
|
while off + 3 <= data.endIndex {
|
|
let t = data[off]; off += 1
|
|
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
|
|
guard off + len <= data.endIndex else { return false }
|
|
if t == BoardTLVType.flags.rawValue, len == 1 {
|
|
return data[off] & BoardPostPacket.urgentFlag != 0
|
|
}
|
|
off += len
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Empty geohash = mesh-local board; otherwise 1-12 chars of the geohash
|
|
/// base32 alphabet.
|
|
private static func isValidGeohashField(_ geohash: String) -> Bool {
|
|
geohash.isEmpty || geohash.allSatisfy { BoardWireConstants.geohashAlphabet.contains($0) }
|
|
}
|
|
}
|
|
|
|
enum BoardWireEncoding {
|
|
static func appendContext(_ context: String, to out: inout Data) {
|
|
let bytes = Data(context.utf8)
|
|
out.append(UInt8(min(bytes.count, 255)))
|
|
out.append(bytes.prefix(255))
|
|
}
|
|
|
|
static func appendLengthPrefixed(_ value: Data, to out: inout Data) {
|
|
let len = UInt16(min(value.count, Int(UInt16.max)))
|
|
out.append(UInt8((len >> 8) & 0xFF))
|
|
out.append(UInt8(len & 0xFF))
|
|
out.append(value.prefix(Int(UInt16.max)))
|
|
}
|
|
|
|
static func appendUInt64(_ value: UInt64, to out: inout Data) {
|
|
var be = value.bigEndian
|
|
withUnsafeBytes(of: &be) { out.append(contentsOf: $0) }
|
|
}
|
|
|
|
static func uint64Data(_ value: UInt64) -> Data {
|
|
var out = Data()
|
|
appendUInt64(value, to: &out)
|
|
return out
|
|
}
|
|
|
|
static func uint64(from data: Data) -> UInt64? {
|
|
guard data.count == 8 else { return nil }
|
|
var value: UInt64 = 0
|
|
for byte in data { value = (value << 8) | UInt64(byte) }
|
|
return value
|
|
}
|
|
|
|
static func verify(signature: Data, over message: Data, publicKey: Data) -> Bool {
|
|
guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else {
|
|
return false
|
|
}
|
|
return key.isValidSignature(signature, for: message)
|
|
}
|
|
}
|