diff --git a/docs/courier-test-vectors.json b/docs/courier-test-vectors.json new file mode 100644 index 00000000..8981bcbf --- /dev/null +++ b/docs/courier-test-vectors.json @@ -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" + } +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/CourierVectorTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/CourierVectorTests.swift new file mode 100644 index 00000000..ae4b2918 --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/CourierVectorTests.swift @@ -0,0 +1,162 @@ +// +// CourierVectorTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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) + } +}