Friend-courier store-and-forward: mutual favorites carry sealed messages to offline peers

When a private message has no reachable transport, the router now seals it
to the recipient's Noise static key (new one-way Noise X pattern) and hands
the envelope to up to three connected mutual favorites. Couriers store the
opaque ciphertext under strict quotas (20 total, 5 per depositor, 16 KiB,
24 h) and hand it over when the recipient's announce matches a rotating
HMAC recipient tag; the recipient opens it and the message flows through
the normal private-message pipeline, so dedup and delivery acks just work.

- CourierEnvelope TLV + courierEnvelope (0x04) message type in BitFoundation
- Noise X one-way pattern reusing the existing handshake machinery,
  domain-separated by a courier prologue; sender identity authenticated
  via the ss DH (no forward secrecy - documented tradeoff)
- CourierStore with eviction, file persistence, and panic-wipe integration
- Rotating recipient tags (HMAC over epoch day) so carried envelopes don't
  correlate for observers who don't already know the recipient's key
- New "carried" delivery status with figure.walk glyph; header indicator
  while carrying mail for others
- Three-node end-to-end test ferrying packets through real BLEService
  instances, plus codec/crypto/store/router suites (986 tests green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-12 11:13:16 +02:00
co-authored by Claude Fable 5
parent 266827ceff
commit 5aaa209020
23 changed files with 1451 additions and 16 deletions
@@ -0,0 +1,147 @@
//
// CourierEnvelope.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
private import CryptoKit
/// TLV payload for store-and-forward courier envelopes.
///
/// A courier envelope lets a mutual favorite physically carry an encrypted
/// message to a peer who is currently offline. The envelope is opaque to the
/// courier: the only routing information is a rotating recipient tag derived
/// from the recipient's Noise static public key and the UTC day, so envelopes
/// addressed to the same peer on different days do not correlate for
/// observers who don't already know that peer's public key.
public struct CourierEnvelope: Equatable {
/// Rotating recipient hint: HMAC-SHA256(recipient static key, context || epoch day), truncated.
public let recipientTag: Data
/// Milliseconds since epoch after which the envelope must be discarded.
public let expiry: UInt64
/// Opaque one-way Noise X ciphertext (sender identity rides inside).
public let ciphertext: Data
public static let tagLength = 16
/// Couriered messages are text-sized; media transfers are out of scope.
public static let maxCiphertextBytes = 16 * 1024
/// Matches the outbox retention policy in MessageRouter.
public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60
private enum TLVType: UInt8 {
case recipientTag = 0x01
case expiry = 0x02
case ciphertext = 0x03
}
public init(recipientTag: Data, expiry: UInt64, ciphertext: Data) {
self.recipientTag = recipientTag
self.expiry = expiry
self.ciphertext = ciphertext
}
public var isExpired: Bool {
isExpired(at: Date())
}
public func isExpired(at date: Date) -> Bool {
UInt64(max(0, date.timeIntervalSince1970 * 1000)) >= expiry
}
public func encode() -> Data? {
guard recipientTag.count == Self.tagLength else { return nil }
guard !ciphertext.isEmpty, ciphertext.count <= Self.maxCiphertextBytes else { return nil }
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
var big = value.bigEndian
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
}
var encoded = Data()
encoded.reserveCapacity(3 * 3 + Self.tagLength + 8 + ciphertext.count)
encoded.append(TLVType.recipientTag.rawValue)
appendBE(UInt16(recipientTag.count), into: &encoded)
encoded.append(recipientTag)
encoded.append(TLVType.expiry.rawValue)
appendBE(UInt16(8), into: &encoded)
appendBE(expiry, into: &encoded)
encoded.append(TLVType.ciphertext.rawValue)
appendBE(UInt16(ciphertext.count), into: &encoded)
encoded.append(ciphertext)
return encoded
}
public static func decode(_ data: Data) -> CourierEnvelope? {
var cursor = data.startIndex
let end = data.endIndex
var recipientTag: Data?
var expiry: UInt64?
var ciphertext: 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 .recipientTag:
guard length == tagLength else { return nil }
recipientTag = Data(value)
case .expiry:
guard length == 8 else { return nil }
expiry = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
case .ciphertext:
guard length > 0, length <= maxCiphertextBytes else { return nil }
ciphertext = Data(value)
case nil:
// Unknown TLV: skip for forward compatibility.
continue
}
}
guard let recipientTag, let expiry, let ciphertext else { return nil }
return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
}
// MARK: - Recipient Tags
private static let tagContext = Data("bitchat-courier-tag-v1".utf8)
/// UTC day number used to rotate recipient tags.
public static func epochDay(for date: Date) -> UInt32 {
UInt32(max(0, date.timeIntervalSince1970) / 86_400)
}
/// Rotating recipient hint for a given day. Computable only by parties
/// who already know the recipient's Noise static public key.
public static func recipientTag(noiseStaticKey: Data, epochDay: UInt32) -> Data {
var message = tagContext
withUnsafeBytes(of: epochDay.bigEndian) { message.append(contentsOf: $0) }
let mac = HMAC<SHA256>.authenticationCode(for: message, using: SymmetricKey(data: noiseStaticKey))
return Data(mac).prefix(tagLength)
}
/// Tags to test when checking whether an envelope is addressed to a peer.
/// Covers the adjacent days so envelopes sealed near midnight (or across
/// modest clock skew) still match while being carried.
public static func candidateTags(noiseStaticKey: Data, around date: Date) -> [Data] {
let day = epochDay(for: date)
return [day == 0 ? 0 : day - 1, day, day + 1].map {
recipientTag(noiseStaticKey: noiseStaticKey, epochDay: $0)
}
}
}
@@ -11,17 +11,20 @@ import struct Foundation.Date
public enum DeliveryStatus: Codable, Equatable, Hashable {
case sending
case sent // Left our device
case carried // Sealed envelope handed to a courier; best-effort physical delivery
case delivered(to: String, at: Date) // Confirmed by recipient
case read(by: String, at: Date) // Seen by recipient
case failed(reason: String)
case partiallyDelivered(reached: Int, total: Int) // For rooms
public var displayText: String {
switch self {
case .sending:
return "Sending..."
case .sent:
return "Sent"
case .carried:
return "Carried by a friend"
case .delivered(let nickname, _):
return "Delivered to \(nickname)"
case .read(let nickname, _):
@@ -12,8 +12,9 @@
public enum MessageType: UInt8 {
// Public messages (unencrypted)
case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message
case message = 0x02 // Public chat message
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
@@ -29,6 +30,7 @@ public enum MessageType: UInt8 {
case .announce: return "announce"
case .message: return "message"
case .leave: return "leave"
case .courierEnvelope: return "courierEnvelope"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted"
@@ -0,0 +1,122 @@
//
// CourierEnvelopeTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import BitFoundation
struct CourierEnvelopeTests {
private func makeEnvelope(
tag: Data = Data(repeating: 0xAB, count: CourierEnvelope.tagLength),
expiry: UInt64 = 1_900_000_000_000,
ciphertext: Data = Data(repeating: 0x42, count: 128)
) -> CourierEnvelope {
CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext)
}
// MARK: - Codec
@Test func roundTrip() throws {
let envelope = makeEnvelope()
let encoded = try #require(envelope.encode())
let decoded = try #require(CourierEnvelope.decode(encoded))
#expect(decoded == envelope)
}
@Test func roundTripAtMaxCiphertextSize() throws {
let envelope = makeEnvelope(ciphertext: Data(repeating: 0x01, count: CourierEnvelope.maxCiphertextBytes))
let encoded = try #require(envelope.encode())
let decoded = try #require(CourierEnvelope.decode(encoded))
#expect(decoded == envelope)
}
@Test func encodeRejectsInvalidFields() {
#expect(makeEnvelope(tag: Data(repeating: 0, count: 8)).encode() == nil)
#expect(makeEnvelope(ciphertext: Data()).encode() == nil)
#expect(makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1)).encode() == nil)
}
@Test func decodeRejectsMissingFields() throws {
// Strip the trailing ciphertext TLV: tag(3+16) + expiry(3+8) only.
let encoded = try #require(makeEnvelope().encode())
let truncated = encoded.prefix(3 + CourierEnvelope.tagLength + 3 + 8)
#expect(CourierEnvelope.decode(Data(truncated)) == nil)
}
@Test func decodeRejectsTruncatedValue() throws {
let encoded = try #require(makeEnvelope().encode())
#expect(CourierEnvelope.decode(encoded.dropLast(1)) == nil)
}
@Test func decodeSkipsUnknownTLVs() throws {
var encoded = try #require(makeEnvelope().encode())
// Append an unknown TLV (type 0x7F, 2-byte value); decoder must tolerate it.
encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
let decoded = try #require(CourierEnvelope.decode(encoded))
#expect(decoded == makeEnvelope())
}
@Test func decodeOffsetSlice() throws {
// Decoder must handle slices with non-zero startIndex.
let encoded = try #require(makeEnvelope().encode())
let padded = Data([0xFF, 0xFF]) + encoded
let slice = padded.dropFirst(2)
#expect(CourierEnvelope.decode(Data(slice)) == makeEnvelope())
#expect(CourierEnvelope.decode(slice) == makeEnvelope())
}
// MARK: - Expiry
@Test func expiryComparison() {
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
#expect(!makeEnvelope(expiry: nowMs + 60_000).isExpired)
#expect(makeEnvelope(expiry: nowMs - 60_000).isExpired)
#expect(makeEnvelope(expiry: 0).isExpired)
}
// MARK: - Recipient Tags
@Test func tagIsDeterministicPerKeyAndDay() {
let key = Data(repeating: 0x11, count: 32)
let tag1 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000)
let tag2 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000)
#expect(tag1 == tag2)
#expect(tag1.count == CourierEnvelope.tagLength)
}
@Test func tagRotatesAcrossDaysAndKeys() {
let key = Data(repeating: 0x11, count: 32)
let otherKey = Data(repeating: 0x22, count: 32)
let day: UInt32 = 20_000
#expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)
!= CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1))
#expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)
!= CourierEnvelope.recipientTag(noiseStaticKey: otherKey, epochDay: day))
}
@Test func candidateTagsCoverAdjacentDays() {
let key = Data(repeating: 0x33, count: 32)
let date = Date(timeIntervalSince1970: 1_750_000_000)
let day = CourierEnvelope.epochDay(for: date)
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: key, around: date)
#expect(candidates.count == 3)
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day - 1)))
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)))
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1)))
}
@Test func sealedYesterdayMatchesToday() {
// An envelope sealed late on day D must still match the recipient on day D+1.
let key = Data(repeating: 0x44, count: 32)
let sealedAt = Date(timeIntervalSince1970: 1_750_000_000)
let deliveredAt = sealedAt.addingTimeInterval(20 * 60 * 60)
let tag = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: CourierEnvelope.epochDay(for: sealedAt))
#expect(CourierEnvelope.candidateTags(noiseStaticKey: key, around: deliveredAt).contains(tag))
}
}