Add golden test vectors for the courier wire format

The courier layer is only implementable from the Swift source today, and three
of the ways a second client gets it wrong fail silently — it builds, connects,
and delivers nothing, with no error at either end:

- `expiry` is milliseconds. Seconds makes every envelope read as long expired,
  so it is dropped at deposit with nothing logged.
- The signature does not cover the wire bytes. It covers a re-encoding with
  ttl zeroed, isRSR cleared and the signature omitted, which is then PKCS#7
  padded — a spray receipt is 46 bytes unsigned and unpadded, but 256 bytes
  signed. Signing the 46 produces a signature that never verifies.
- The signing key is Ed25519. CryptoKit spells it Curve25519.Signing, so
  reaching for a "Curve25519" primitive elsewhere yields X25519 key agreement.

Prose cannot defend against that class of bug; a fixture can. These vectors
pin the envelope TLV, the copies clamp, recipient-tag derivation, the
ciphertext hash, and the padded signing pre-image, and they are asserted from
the implementation so they cannot drift without a test failing.

Signature bytes are deliberately not pinned: CryptoKit's Ed25519 signing is
randomized rather than the deterministic RFC 8032 construction, so two
signatures over identical input differ and both verify. A second
implementation will not reproduce these bytes and does not need to — the
pre-image is what must match.

docs/courier-test-vectors.json carries the same values for implementers who
are not running Swift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ecgang 2026-07-25 14:47:50 -07:00
parent ca18843bb0
commit 0a5647b3e8
2 changed files with 303 additions and 0 deletions

View File

@ -0,0 +1,141 @@
{
"_comment": [
"Golden vectors for the bitchat courier wire format. Generated from the iOS",
"implementation and asserted by CourierVectorTests in the BitFoundation test",
"target, so these cannot drift from the code without a test failing.",
"All hex is lowercase. All multi-byte integers are big-endian.",
"",
"These exist because three courier encoding mistakes fail SILENTLY — the",
"client builds, connects, and delivers nothing, with no error at either end:",
" 1. treating `expiry` as seconds when it is milliseconds",
" 2. signing the wire bytes instead of the padded, ttl-zeroed pre-image",
" 3. reaching for X25519 key agreement because CryptoKit spells Ed25519",
" as `Curve25519.Signing`"
],
"inputs": {
"_comment": "Synthetic. Not derived from any real key.",
"recipientTag": "000102030405060708090a0b0c0d0e0f",
"noiseStaticKey": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
"ciphertextUTF8": "courier-vector-ciphertext-0001",
"ciphertext": "636f75726965722d766563746f722d636970686572746578742d30303031",
"expiryMillis": 1800000000000,
"epochDay": 20833,
"senderID": "1122334455667788",
"recipientID": "99aabbccddeeff00",
"timestampMillis": 1750000000000,
"signingSeed": "4242424242424242424242424242424242424242424242424242424242424242"
},
"envelopeTLV": {
"_comment": [
"TLV records are type(1) length(2, big-endian) value(length).",
"0x01 recipientTag(16) 0x02 expiry(8) 0x03 ciphertext 0x04 copies(1) 0x05 prekeyID(4).",
"Lengths are checked exactly, not as minimums. Unknown types are skipped",
"using the length field.",
"NOTE the 0x02 value below is 000001a3185c5000 = 1800000000000 MILLISECONDS.",
"Emitting seconds yields a value ~1000x too small, so every envelope reads",
"as long expired and is dropped at deposit with nothing logged."
],
"copies": 4,
"prekeyID": 287454020,
"encoded": "010010000102030405060708090a0b0c0d0e0f020008000001a3185c500003001e636f75726965722d766563746f722d636970686572746578742d303030310400010405000411223344",
"encodedLength": 74
},
"copiesClamping": {
"_comment": [
"`copies` is clamped into 1...maxCopies by the initializer, never rejected.",
"An implementation that rejects out-of-range values will drop envelopes",
"this one accepts."
],
"maxCopies": 8,
"cases": [
{ "requested": 0, "stored": 1 },
{ "requested": 4, "stored": 4 },
{ "requested": 200, "stored": 8 }
]
},
"recipientTagDerivation": {
"_comment": [
"HMAC-SHA256(key = recipient noise static key,",
" message = \"bitchat-courier-tag-v1\" || BE32(epochDay))",
"truncated to the first 16 bytes. The label is ASCII with no terminator.",
"epochDay = floor(unixSeconds / 86400).",
"A matcher computes tags for the previous, current and next epoch day and",
"accepts any of the three, so mail crossing UTC midnight still resolves."
],
"label": "bitchat-courier-tag-v1",
"epochDay": 20833,
"expected": "ad8514c90ca1fa6bf44e38c8a6252482"
},
"ciphertextHash": {
"_comment": "SHA-256 of the ciphertext, truncated to 16 bytes. This is the envelope identity a spray receipt carries.",
"expected": "bb85dcc4d8b17377c61817992df95826"
},
"packetSigning": {
"_comment": [
"THE trap. The signature does NOT cover the bytes as they appear on the",
"wire. It covers a re-encoding in which ttl is set to 0, the isRSR flag is",
"cleared, the signature is omitted (so the hasSignature flag bit 0x02 is",
"CLEAR in the signed bytes although SET on the wire) — and the result is",
"then PKCS#7-padded.",
"",
"Padding target is the smallest of [256, 512, 1024, 2048] that fits",
"size + 16, applied only when the shortfall is 1...255, every pad byte",
"equal to the shortfall. Here: 46 + 16 = 62 <= 256, so target 256,",
"shortfall 210 = 0xd2.",
"",
"Signing the 46 unpadded bytes produces a signature that never verifies,",
"and nothing in any log points at the cause."
],
"packet": {
"version": 1,
"type": "0x2a",
"ttlOnWire": 7,
"payload": "bb85dcc4d8b17377c61817992df95826"
},
"unsignedUnpaddedLength": 46,
"unsignedUnpadded": "012a07000001977420dc00010010112233445566778899aabbccddeeff00bb85dcc4d8b17377c61817992df95826",
"signingPreimageLength": 256,
"signingPreimagePadByte": "0xd2",
"signingPreimagePadCount": 210,
"signingPreimage": "012a00000001977420dc00010010112233445566778899aabbccddeeff00bb85dcc4d8b17377c61817992df95826d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2",
"signedWireLength": 110,
"flags": {
"_comment": [
"Flag bits: hasRecipient 0x01, hasSignature 0x02, isCompressed 0x04,",
"hasRoute 0x08, isRSR 0x10. Byte lives at offset 11.",
"The unsignedUnpadded and signingPreimage vectors above both carry 0x01",
"because the signature is absent from each. Only the finished 110-byte",
"wire packet sets 0x02 as well — which is exactly why re-signing a",
"received packet requires clearing that bit before re-encoding."
],
"unsignedUnpadded": "0x01",
"signingPreimage": "0x01",
"signedWirePacket": "0x03"
}
},
"signature": {
"_comment": [
"Ed25519. CryptoKit spells the type `Curve25519.Signing.PrivateKey`, which",
"IS Ed25519 — reaching for a primitive named 'Curve25519' in another",
"language gets you X25519 key agreement, the wrong algorithm.",
"",
"Signature BYTES are deliberately not pinned here. CryptoKit's Ed25519",
"signing is RANDOMIZED rather than the deterministic RFC 8032",
"construction, so two signatures over identical input differ and both",
"verify. A second implementation using a deterministic Ed25519 library",
"will not reproduce iOS's bytes and does not need to. Pin the pre-image;",
"verification is the contract."
],
"publicKey": "2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12",
"signatureLength": 64,
"deterministic": false,
"verify": "Ed25519 verify(publicKey, signature, packetSigning.signingPreimage) must succeed"
}
}

View File

@ -0,0 +1,162 @@
//
// CourierVectorTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import CryptoKit
@testable import BitFoundation
/// Golden vectors for the courier wire format, mirrored in
/// `docs/courier-test-vectors.json` so a second implementation can check itself
/// without running this app.
///
/// These cover the three ways a courier client fails *silently* it builds,
/// connects, and delivers nothing, with no error at either end:
///
/// 1. `expiry` is milliseconds. Seconds makes every envelope read as long
/// expired, so it is dropped at deposit with nothing logged.
/// 2. The signature does not cover the wire bytes. It covers a re-encoding with
/// `ttl = 0`, `isRSR` cleared and the signature omitted, which is then
/// PKCS#7-padded. Signing the unpadded bytes produces a signature that never
/// verifies.
/// 3. The signing key is Ed25519. CryptoKit spells it `Curve25519.Signing`;
/// reaching for a "Curve25519" primitive elsewhere yields X25519 key
/// agreement instead.
struct CourierVectorTests {
// MARK: Fixed inputs (synthetic not derived from any real key)
static let recipientTag = Data((0..<16).map { UInt8($0) })
static let noiseStaticKey = Data((0..<32).map { UInt8(0xA0 &+ $0) })
static let ciphertext = Data("courier-vector-ciphertext-0001".utf8)
static let expiryMs: UInt64 = 1_800_000_000_000
static let epochDay: UInt32 = 20_833
static let senderID = Data([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88])
static let recipientID = Data([0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00])
static let timestampMs: UInt64 = 1_750_000_000_000
static let signingSeed = Data(repeating: 0x42, count: 32)
// MARK: Envelope
/// `expiry` is milliseconds since epoch, big-endian, in an 8-byte TLV.
@Test func envelopeTLVEncoding() throws {
let envelope = CourierEnvelope(recipientTag: Self.recipientTag,
expiry: Self.expiryMs,
ciphertext: Self.ciphertext,
copies: 4,
prekeyID: 0x1122_3344)
let encoded = try #require(envelope.encode())
#expect(encoded.hexEncodedString() == """
010010000102030405060708090a0b0c0d0e0f020008000001a3185c500003001e636f7\
5726965722d766563746f722d636970686572746578742d30303031040001040500041122\
3344
""".replacingOccurrences(of: "\n", with: ""))
// 0x02 carries 000001a3185c50 00 == 1_800_000_000_000 ms, not seconds.
let decoded = try #require(CourierEnvelope.decode(encoded))
#expect(decoded.expiry == Self.expiryMs)
#expect(decoded.copies == 4)
#expect(decoded.prekeyID == 0x1122_3344)
}
/// `copies` is clamped into 1...maxCopies, never rejected. An implementation
/// that rejects out-of-range values drops envelopes this one accepts.
@Test func copiesAreClampedNotRejected() {
func copies(_ requested: UInt8) -> UInt8 {
CourierEnvelope(recipientTag: Self.recipientTag,
expiry: Self.expiryMs,
ciphertext: Self.ciphertext,
copies: requested).copies
}
#expect(copies(0) == 1)
#expect(copies(200) == CourierEnvelope.maxCopies)
#expect(CourierEnvelope.maxCopies == 8)
}
/// HMAC-SHA256(noiseStaticKey, "bitchat-courier-tag-v1" || BE32(epochDay)),
/// truncated to 16 bytes.
@Test func recipientTagDerivation() {
let tag = CourierEnvelope.recipientTag(noiseStaticKey: Self.noiseStaticKey,
epochDay: Self.epochDay)
#expect(tag.hexEncodedString() == "ad8514c90ca1fa6bf44e38c8a6252482")
#expect(tag.count == CourierEnvelope.tagLength)
}
/// The 16-byte envelope identity a spray receipt carries.
@Test func ciphertextHashIsSHA256TruncatedTo16() {
let hash = Data(Self.ciphertext.sha256Hash().prefix(CourierEnvelope.tagLength))
#expect(hash.hexEncodedString() == "bb85dcc4d8b17377c61817992df95826")
}
// MARK: Packet canonicalization the trap worth a vector
/// The signed pre-image is **not** the wire bytes. It zeroes `ttl`, clears
/// the `hasSignature` flag, and is PKCS#7-padded to a block boundary.
@Test func signingPreimageIsTTLZeroedAndPadded() throws {
let payload = Data(Self.ciphertext.sha256Hash().prefix(CourierEnvelope.tagLength))
let packet = BitchatPacket(type: 0x2A,
senderID: Self.senderID,
recipientID: Self.recipientID,
timestamp: Self.timestampMs,
payload: payload,
signature: nil,
ttl: 7)
// Unsigned, unpadded: 14 header + 8 sender + 8 recipient + 16 payload.
let unpadded = try #require(BinaryProtocol.encode(packet, padding: false))
#expect(unpadded.count == 46)
#expect(unpadded.hexEncodedString().hasPrefix("012a07")) // ttl = 7 here
let preimage = try #require(packet.toBinaryDataForSigning())
#expect(preimage.count == 256) // padded, not 46
#expect(preimage.hexEncodedString().hasPrefix("012a00")) // ttl zeroed
#expect(preimage[BinaryProtocol.Offsets.flags] == BinaryProtocol.Flags.hasRecipient)
#expect(preimage.dropFirst(46).allSatisfy { $0 == 210 }) // 210 == 0xD2 == pad length
}
/// Ed25519 CryptoKit's `Curve25519.Signing`, not X25519 key agreement.
@Test func signatureOverPreimageVerifies() throws {
let payload = Data(Self.ciphertext.sha256Hash().prefix(CourierEnvelope.tagLength))
let packet = BitchatPacket(type: 0x2A,
senderID: Self.senderID,
recipientID: Self.recipientID,
timestamp: Self.timestampMs,
payload: payload,
signature: nil,
ttl: 7)
let preimage = try #require(packet.toBinaryDataForSigning())
let key = try Curve25519.Signing.PrivateKey(rawRepresentation: Self.signingSeed)
#expect(key.publicKey.rawRepresentation.hexEncodedString()
== "2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12")
// Signature BYTES are deliberately not pinned. CryptoKit's Ed25519
// signing is randomized rather than the deterministic RFC 8032
// construction, so two signatures over identical input differ and both
// verify. A second implementation using a deterministic Ed25519 library
// will therefore not reproduce iOS's bytes and does not need to.
// What must match is the pre-image above; verification is the contract.
let a = try key.signature(for: preimage)
let b = try key.signature(for: preimage)
#expect(Data(a) != Data(b), "CryptoKit Ed25519 signing is randomized")
#expect(key.publicKey.isValidSignature(a, for: preimage))
#expect(key.publicKey.isValidSignature(b, for: preimage))
#expect(Data(a).count == BinaryProtocol.signatureSize)
let signature = a
// On the wire the packet is 110 bytes the 46 above plus a 64-byte
// signature and the flags byte now also carries hasSignature.
var signed = packet
signed.signature = Data(signature)
let wire = try #require(BinaryProtocol.encode(signed, padding: false))
#expect(wire.count == 110)
#expect(wire[BinaryProtocol.Offsets.flags]
== BinaryProtocol.Flags.hasRecipient | BinaryProtocol.Flags.hasSignature)
}
}