mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 10:45:20 +00:00
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:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user