Files
jackandClaude Fable 5 5aaa209020 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>
2026-06-12 11:13:16 +02:00

123 lines
5.2 KiB
Swift

//
// 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))
}
}