mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
Prekey bundles: forward-secret async first contact for courier mail (#1381)
* Add capability bits to announce TLV Announces now carry an optional capabilities TLV (0x05): a little-endian bitfield with named bits for upcoming features (prekeys, wifiBulk, gateway, groups, board, vouch, meshDiagnostics). Old clients skip the unknown TLV; peers without it decode as nil so features can distinguish "legacy peer" from "advertises nothing". PeerCapabilities lives in BitFoundation with a minimal-length encoding that preserves unknown bits for forward compatibility. Peer capabilities are stored in the BLE peer registry on verified announce and exposed via BLEService.peerCapabilities(_:). The local advertisement set is empty until each feature ships its bit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Prekey bundles: forward-secret async first contact for courier mail Courier envelopes were sealed with one-way Noise X to the recipient's long-lived static key, so a later compromise of that key exposed every envelope captured in transit. This adds one-time prekey bundles: - PrekeyBundle (MessageType 0x24): 8 one-time Curve25519 public prekeys bound to the owner's Noise static key by an Ed25519 signature over "bitchat-prekey-bundle-v1" canonical bytes; gossiped mesh-wide on its own 60s sync round (SyncTypeFlags bit 9, 200-peer cap, 24h freshness) and verified against the announce-bound signing key before caching. - Sealed envelope v2: Noise X where the responder static is the one-time prekey, prologue "bitchat-prekey-v1" || prekeyID. Sender identity rides encrypted inside and is authenticated exactly like v1 (blocked-sender check included). CourierEnvelope gains an optional prekeyID TLV that v1 decoders skip as unknown. - Local prekeys live in the Keychain; consumed privates survive a 48h grace window for spray-and-wait redeliveries, then are deleted (the forward-secrecy clock starts at deletion). The batch tops back up and re-gossips when unconsumed count drops below 3, and everything is wiped in panic mode. - Routing: courier sealing picks a cached verified bundle when one exists (one prekey per message, reused across deposit retries), with the advertised .prekeys capability as a veto for on-mesh peers, and falls back to static sealing otherwise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Prekeys: authenticate bundle packets, fix consume-republish, deflake CI Fixes the prekey-bundle PR review + CI failure: - CI root cause: the receive queue (mesh.message) is concurrent, so a gossiped prekey bundle can be processed before the announce that binds its owner's signing key. The old handler dropped such bundles outright, so under CI parallel load the bundle was permanently lost and the cache/gossip tests flaked (verifiedBundleEntersGossipStore, prekeySealedMailTravelsViaCourierAndOpens). Bundles that arrive before their binding are now retained per-owner (bounded) and re-attempted when the verified announce lands, atomically to avoid a check-then-act race. - Authenticate the OUTER prekey-bundle packet (Codex P2 / review MEDIUM): require senderID == PeerID(bundle.noiseStaticPublicKey) and verify the packet's Ed25519 signature (covers senderID + timestamp) against the owner's bound signing key, in addition to the inner bundle signature. Stops replay under a fresh timestamp / fake senderID. - Key the gossip prekey-bundle store/dedup by the bundle's authenticated identity (noiseStaticPublicKey), not the unauthenticated packet senderID, so one valid bundle sprayed under many fabricated sender IDs can't multiply entries and exhaust the 200-owner cap. - Bump published-bundle generatedAt strictly on consume (Codex P1): consuming a prekey shrinks the published bundle, so it now republishes with a strictly newer generatedAt and re-gossips, so peers replace the cached copy and stop assigning the consumed ID before its 48h grace. - Guard the panic/clear detached Application Support tree-deletes behind TestEnvironment.isRunningTests: the SPM test process shares that tree, so the wipe could land mid-test and flake file-dependent tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Update sync tests for prekeyBundle as bit 9 / default sync round Prekeys makes bit 9 (prekeyBundle) a known SyncTypeFlags bit and enables a prekey sync round by default. That broke tests authored by other PRs that assumed bit 9 was phantom or that only their own sync round fires: - SyncTypeFlags(Board)Tests: move the "unknown bits" probes to bits 10+ (0xFE -> 0xFC / 0xFD), since bit 9 is now assigned. - GossipSync(Board)Tests + GossipSyncManagerTests: disable the prekey sync round in configs that run maintenance (as they already do for message/ fragment/fileTransfer), so they isolate the behavior under test. Full app suite (1301 tests) green locally via SPM. 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
f9032cf2b9
commit
87910541ef
@@ -28,6 +28,13 @@ public struct CourierEnvelope: Equatable {
|
||||
/// the holder may still hand to other couriers (binary split on each
|
||||
/// spray). 1 means carry-only — deliver to the recipient, never re-spray.
|
||||
public let copies: UInt8
|
||||
/// Seal-format discriminator: nil means v1 (ciphertext is one-way Noise X
|
||||
/// to the recipient's *static* key); a value means v2 (Noise X to the
|
||||
/// recipient's one-time prekey with this ID, forward secret). Encoded as
|
||||
/// an optional TLV so v1 decoders skip it as unknown: an old client still
|
||||
/// carries and hands over v2 envelopes opaquely, and when one is addressed
|
||||
/// to it the static-key open simply fails and is dropped quietly.
|
||||
public let prekeyID: UInt32?
|
||||
|
||||
public static let tagLength = 16
|
||||
/// Couriered messages are text-sized; media transfers are out of scope.
|
||||
@@ -43,18 +50,20 @@ public struct CourierEnvelope: Equatable {
|
||||
case expiry = 0x02
|
||||
case ciphertext = 0x03
|
||||
case copies = 0x04
|
||||
case prekeyID = 0x05
|
||||
}
|
||||
|
||||
public init(recipientTag: Data, expiry: UInt64, ciphertext: Data, copies: UInt8 = 1) {
|
||||
public init(recipientTag: Data, expiry: UInt64, ciphertext: Data, copies: UInt8 = 1, prekeyID: UInt32? = nil) {
|
||||
self.recipientTag = recipientTag
|
||||
self.expiry = expiry
|
||||
self.ciphertext = ciphertext
|
||||
self.copies = min(max(copies, 1), Self.maxCopies)
|
||||
self.prekeyID = prekeyID
|
||||
}
|
||||
|
||||
/// The same envelope with a different remaining copy budget.
|
||||
public func withCopies(_ copies: UInt8) -> CourierEnvelope {
|
||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
|
||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
|
||||
}
|
||||
|
||||
public var isExpired: Bool {
|
||||
@@ -97,6 +106,14 @@ public struct CourierEnvelope: Equatable {
|
||||
encoded.append(copies)
|
||||
}
|
||||
|
||||
// Omitted for v1 static-sealed envelopes so they stay byte-identical
|
||||
// to the pre-prekey wire format.
|
||||
if let prekeyID {
|
||||
encoded.append(TLVType.prekeyID.rawValue)
|
||||
appendBE(UInt16(4), into: &encoded)
|
||||
appendBE(prekeyID, into: &encoded)
|
||||
}
|
||||
|
||||
return encoded
|
||||
}
|
||||
|
||||
@@ -108,6 +125,7 @@ public struct CourierEnvelope: Equatable {
|
||||
var expiry: UInt64?
|
||||
var ciphertext: Data?
|
||||
var copies: UInt8 = 1
|
||||
var prekeyID: UInt32?
|
||||
|
||||
while cursor < end {
|
||||
let typeRaw = data[cursor]
|
||||
@@ -133,6 +151,9 @@ public struct CourierEnvelope: Equatable {
|
||||
case .copies:
|
||||
guard length == 1 else { return nil }
|
||||
copies = value.first ?? 1
|
||||
case .prekeyID:
|
||||
guard length == 4 else { return nil }
|
||||
prekeyID = value.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
case nil:
|
||||
// Unknown TLV: skip for forward compatibility.
|
||||
continue
|
||||
@@ -140,7 +161,7 @@ public struct CourierEnvelope: Equatable {
|
||||
}
|
||||
|
||||
guard let recipientTag, let expiry, let ciphertext else { return nil }
|
||||
return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
|
||||
return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
|
||||
}
|
||||
|
||||
// MARK: - Recipient Tags
|
||||
|
||||
@@ -16,15 +16,16 @@ public enum MessageType: UInt8 {
|
||||
case leave = 0x03 // "I'm leaving"
|
||||
case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer
|
||||
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
||||
|
||||
|
||||
// Noise encryption
|
||||
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
|
||||
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
|
||||
|
||||
|
||||
// Fragmentation (simplified)
|
||||
case fragment = 0x20 // Single fragment type for large messages
|
||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||
case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
|
||||
case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped)
|
||||
|
||||
// Mesh diagnostics
|
||||
case ping = 0x26 // Directed echo request (nonce + origin TTL)
|
||||
@@ -46,6 +47,7 @@ public enum MessageType: UInt8 {
|
||||
case .fragment: return "fragment"
|
||||
case .fileTransfer: return "fileTransfer"
|
||||
case .boardPost: return "boardPost"
|
||||
case .prekeyBundle: return "prekeyBundle"
|
||||
case .ping: return "ping"
|
||||
case .pong: return "pong"
|
||||
case .nostrCarrier: return "nostrCarrier"
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//
|
||||
// PrekeyBundle.swift
|
||||
// BitFoundation
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// TLV payload for gossiped one-time prekey bundles (MessageType 0x24).
|
||||
///
|
||||
/// A bundle publishes a batch of one-time Curve25519 public prekeys bound to
|
||||
/// the owner's Noise static key by an Ed25519 signature over domain-prefixed
|
||||
/// canonical bytes. Anyone holding the owner's announce-verified signing key
|
||||
/// can verify a bundle offline, which is what lets bundles spread and persist
|
||||
/// mesh-wide via gossip sync while the owner is away. Senders seal courier
|
||||
/// mail to one of these prekeys (one-way Noise X) instead of the owner's
|
||||
/// long-lived static key, restoring forward secrecy for async first contact.
|
||||
public struct PrekeyBundle: Equatable {
|
||||
public struct Prekey: Equatable {
|
||||
public let id: UInt32
|
||||
/// Curve25519.KeyAgreement public key (32 bytes).
|
||||
public let publicKey: Data
|
||||
|
||||
public init(id: UInt32, publicKey: Data) {
|
||||
self.id = id
|
||||
self.publicKey = publicKey
|
||||
}
|
||||
}
|
||||
|
||||
/// Noise static public key identifying whose prekeys these are (32 bytes).
|
||||
public let noiseStaticPublicKey: Data
|
||||
/// One-time prekeys, at most `maxPrekeys` per bundle.
|
||||
public let prekeys: [Prekey]
|
||||
/// Milliseconds since epoch when this bundle was generated; newer bundles
|
||||
/// replace older ones for the same noise key.
|
||||
public let generatedAt: UInt64
|
||||
/// Ed25519 signature over `signableBytes()` by the owner's announce-bound
|
||||
/// signing key.
|
||||
public let signature: Data
|
||||
|
||||
public static let keyLength = 32
|
||||
public static let signatureLength = 64
|
||||
public static let maxPrekeys = 8
|
||||
private static let prekeyEntryLength = 4 + keyLength
|
||||
|
||||
/// Domain separation for the bundle signature so it can never be confused
|
||||
/// with announce or packet signatures.
|
||||
private static let signingContext = Data("bitchat-prekey-bundle-v1".utf8)
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case noiseStaticPublicKey = 0x01
|
||||
case prekeys = 0x02
|
||||
case generatedAt = 0x03
|
||||
case signature = 0x04
|
||||
}
|
||||
|
||||
public init(noiseStaticPublicKey: Data, prekeys: [Prekey], generatedAt: UInt64, signature: Data) {
|
||||
self.noiseStaticPublicKey = noiseStaticPublicKey
|
||||
self.prekeys = prekeys
|
||||
self.generatedAt = generatedAt
|
||||
self.signature = signature
|
||||
}
|
||||
|
||||
/// Canonical bytes covered by the Ed25519 signature: domain context,
|
||||
/// owner key, prekey count, each (id, key) pair, and the generation time.
|
||||
/// Encoders and verifiers must derive these identically.
|
||||
public func signableBytes() -> Data {
|
||||
var out = Data()
|
||||
out.reserveCapacity(1 + Self.signingContext.count + Self.keyLength + 1
|
||||
+ prekeys.count * Self.prekeyEntryLength + 8)
|
||||
out.append(UInt8(min(Self.signingContext.count, 255)))
|
||||
out.append(Self.signingContext.prefix(255))
|
||||
out.append(paddedKey(noiseStaticPublicKey))
|
||||
out.append(UInt8(min(prekeys.count, 255)))
|
||||
for prekey in prekeys.prefix(255) {
|
||||
appendBE(prekey.id, into: &out)
|
||||
out.append(paddedKey(prekey.publicKey))
|
||||
}
|
||||
appendBE(generatedAt, into: &out)
|
||||
return out
|
||||
}
|
||||
|
||||
public func encode() -> Data? {
|
||||
guard noiseStaticPublicKey.count == Self.keyLength,
|
||||
signature.count == Self.signatureLength,
|
||||
!prekeys.isEmpty, prekeys.count <= Self.maxPrekeys,
|
||||
prekeys.allSatisfy({ $0.publicKey.count == Self.keyLength }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var entries = Data()
|
||||
entries.reserveCapacity(prekeys.count * Self.prekeyEntryLength)
|
||||
for prekey in prekeys {
|
||||
appendBE(prekey.id, into: &entries)
|
||||
entries.append(prekey.publicKey)
|
||||
}
|
||||
|
||||
var encoded = Data()
|
||||
encoded.reserveCapacity(4 * 3 + Self.keyLength + entries.count + 8 + Self.signatureLength)
|
||||
|
||||
encoded.append(TLVType.noiseStaticPublicKey.rawValue)
|
||||
appendBE(UInt16(noiseStaticPublicKey.count), into: &encoded)
|
||||
encoded.append(noiseStaticPublicKey)
|
||||
|
||||
encoded.append(TLVType.prekeys.rawValue)
|
||||
appendBE(UInt16(entries.count), into: &encoded)
|
||||
encoded.append(entries)
|
||||
|
||||
encoded.append(TLVType.generatedAt.rawValue)
|
||||
appendBE(UInt16(8), into: &encoded)
|
||||
appendBE(generatedAt, into: &encoded)
|
||||
|
||||
encoded.append(TLVType.signature.rawValue)
|
||||
appendBE(UInt16(signature.count), into: &encoded)
|
||||
encoded.append(signature)
|
||||
|
||||
return encoded
|
||||
}
|
||||
|
||||
public static func decode(_ data: Data) -> PrekeyBundle? {
|
||||
var cursor = data.startIndex
|
||||
let end = data.endIndex
|
||||
|
||||
var noiseStaticPublicKey: Data?
|
||||
var prekeys: [Prekey]?
|
||||
var generatedAt: UInt64?
|
||||
var signature: Data?
|
||||
|
||||
while cursor < end {
|
||||
let typeRaw = data[cursor]
|
||||
cursor = data.index(after: cursor)
|
||||
|
||||
guard data.distance(from: cursor, to: end) >= 2 else { return nil }
|
||||
let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)])
|
||||
cursor = data.index(cursor, offsetBy: 2)
|
||||
guard data.distance(from: cursor, to: end) >= length else { return nil }
|
||||
let value = data[cursor..<data.index(cursor, offsetBy: length)]
|
||||
cursor = data.index(cursor, offsetBy: length)
|
||||
|
||||
switch TLVType(rawValue: typeRaw) {
|
||||
case .noiseStaticPublicKey:
|
||||
guard length == keyLength else { return nil }
|
||||
noiseStaticPublicKey = Data(value)
|
||||
case .prekeys:
|
||||
guard length > 0, length % prekeyEntryLength == 0,
|
||||
length / prekeyEntryLength <= maxPrekeys else { return nil }
|
||||
var parsed: [Prekey] = []
|
||||
var entryStart = value.startIndex
|
||||
while entryStart < value.endIndex {
|
||||
let idEnd = value.index(entryStart, offsetBy: 4)
|
||||
let id = value[entryStart..<idEnd].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
let keyEnd = value.index(idEnd, offsetBy: keyLength)
|
||||
parsed.append(Prekey(id: id, publicKey: Data(value[idEnd..<keyEnd])))
|
||||
entryStart = keyEnd
|
||||
}
|
||||
prekeys = parsed
|
||||
case .generatedAt:
|
||||
guard length == 8 else { return nil }
|
||||
generatedAt = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||
case .signature:
|
||||
guard length == signatureLength else { return nil }
|
||||
signature = Data(value)
|
||||
case nil:
|
||||
// Unknown TLV: skip for forward compatibility.
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
guard let noiseStaticPublicKey, let prekeys, let generatedAt, let signature,
|
||||
!prekeys.isEmpty else { return nil }
|
||||
// Duplicate prekey IDs would let one consumed ID shadow another.
|
||||
guard Set(prekeys.map(\.id)).count == prekeys.count else { return nil }
|
||||
return PrekeyBundle(
|
||||
noiseStaticPublicKey: noiseStaticPublicKey,
|
||||
prekeys: prekeys,
|
||||
generatedAt: generatedAt,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func paddedKey(_ key: Data) -> Data {
|
||||
let fixed = key.prefix(Self.keyLength)
|
||||
guard fixed.count < Self.keyLength else { return Data(fixed) }
|
||||
return Data(fixed) + Data(repeating: 0, count: Self.keyLength - fixed.count)
|
||||
}
|
||||
}
|
||||
|
||||
private func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||
var big = value.bigEndian
|
||||
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
|
||||
}
|
||||
Reference in New Issue
Block a user