mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:45:22 +00:00
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>
This commit is contained in:
@@ -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,14 +16,17 @@ 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
|
||||
|
||||
// 0x23, 0x25-0x28 reserved for in-flight protocol work.
|
||||
case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
@@ -36,6 +39,7 @@ public enum MessageType: UInt8 {
|
||||
case .noiseEncrypted: return "noiseEncrypted"
|
||||
case .fragment: return "fragment"
|
||||
case .fileTransfer: return "fileTransfer"
|
||||
case .prekeyBundle: return "prekeyBundle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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