mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-22 07:16:03 +00:00
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:
parent
ca18843bb0
commit
0a5647b3e8
141
docs/courier-test-vectors.json
Normal file
141
docs/courier-test-vectors.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user